Skip to content
1,591 linesCodeBlameRaw
1/**
2 * What an agent can read while it replies: code, issues, pull requests,
3 * chat, the roster, and its colleagues (docs.g1t.sh/guides/agent-access/,
4 * "What an agent can and can't know").
5 *
6 * Every tool goes through the reply's `Audience` before it reads
7 * anything, and the check is here, in code:
8 * - code tools are offered only when the audience may read code at all,
9 * and a repository is used only when it is on the audience's allow-list;
10 * - chat tools ask the chat service, which works out the audience from the
11 * conversation itself;
12 * - what the audience may not see comes back as one neutral line,
13 * `WITHHELD`, the same for a thing that is private and a thing that does
14 * not exist, and never names it.
15 *
16 * Whatever a tool returns is wrapped as untrusted data: text in files,
17 * issues and messages is never an instruction to the agent.
18 *
19 * Pure apart from its ports, so the rules are tested adversarially.
20 */
21import type { Ability, AbilityGroup, AbilitySection, AbilitySource, AgentComputerCommand, DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, McpServer, McpTool, User } from "@g1t/contracts";
22
23import { askedFor, levelWords, mcpToolName } from "../../../packages/contracts/src/abilities.ts";
24import { FOLIO_KINDS, folioIdFrom, isFolioKind } from "../../../packages/contracts/src/folios.ts";
25import type { MakeFileFormat } from "../../../packages/contracts/src/skills.ts";
26import { type Audience, type RepoRef, WITHHELD } from "./audience.ts";
27import { makeFile, previewTable, readSheets, sizeLabel } from "./files.ts";
28import { type ShelfSkill, type StoredVersion, skillBlock, skillText } from "./skills.ts";
29
30/** One tool, as the Messages API takes it. */
31export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> };
32
33export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string };
34
35/** What the tools reach outside this module. */
36export interface ToolPorts {
37 readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>;
38 searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>;
39 listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>;
40 getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>;
41 getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>;
42 recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>;
43 /** Chat's own audience rule applies; null when the search failed. */
44 searchMessages(query: string): Promise<FoundMessage[] | null>;
45 /** Null when the audience may not read it (or it does not exist). */
46 readThread(channelId: string, id: string): Promise<FoundMessage[] | null>;
47 roster(viewer: User | null): Promise<string>;
48 consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>;
49 /**
50 * The workspace's artifacts (Artifacts mode), as the artifacts service lets
51 * this agent use them for the person it acts for and everyone who will
52 * read the answer. Absent where there is no artifacts service.
53 */
54 folios?: FoliosPorts;
55}
56
57/** A space as an agent sees it, with what it may do there for the person it acts for. */
58export type FolioSpaceLine = {
59 id: string;
60 slug: string;
61 name: string;
62 description: string | null;
63 kind: string;
64 projects: string[];
65 can: { read: boolean; suggest: boolean; edit: boolean };
66};
67
68/** Where a new artifact goes: a space, its asker's Private, or Private shared with the conversation's people. */
69export type FolioWhere = { space_id: string } | "private" | { conversation: string[] };
70
71/** A call's answer: the value, or the artifacts service's error code and sentence. */
72export type FolioDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string };
73
74/**
75 * Artifacts, as an agent uses them. Every call names the person it acts
76 * for, and the reads also who reads the answer; the artifacts service checks
77 * both.
78 */
79export interface FoliosPorts {
80 /** Spaces everyone here can read; null when the artifacts service couldn't answer. */
81 spaces(viewer: User, audience: FolioAudience): Promise<FolioSpaceLine[] | null>;
82 /** Passages closest in meaning to `query`; `spaces` (required reading) first. Null when the artifacts service couldn't answer. */
83 recall(viewer: User, audience: FolioAudience, query: string, spaces: string[], kinds?: FolioKind[]): Promise<FolioPassage[] | null>;
84 /** Artifacts matching `query` (words and meaning), as lines with links. */
85 search(viewer: User, audience: FolioAudience, input: { query: string; kind: FolioKind | null; space_id: string | null; project: string | null }): Promise<string | null>;
86 read(viewer: User, audience: FolioAudience, folioId: string): Promise<FolioDone<FolioAgentRead>>;
87 /** Artifacts possibly out of date since code they cite changed. */
88 stale(viewer: User, audience: FolioAudience, repo: string | null): Promise<string | null>;
89 create(
90 viewer: User,
91 input: { kind: FolioKind; title: string; markdown: string | null; template_id: string | null; where: FolioWhere; parent_id: string | null; source: { title: string; href: string } | null },
92 ): Promise<FolioDone<FolioRef>>;
93 edit(viewer: User, folioId: string, edit: FolioAgentEdit): Promise<FolioDone<FolioAgentEditResult>>;
94 /** `view` or `comment` for people already in this conversation. */
95 share(viewer: User, audience: FolioAudience, folioId: string, userIds: string[], role: "view" | "comment"): Promise<FolioDone<null>>;
96 /**
97 * Keeps a file the agent made with a doc the asker can edit; `url` is
98 * where it is served (on the usercontent origin). Absent where files
99 * can't be kept.
100 */
101 attach?(viewer: User, folioId: string, file: { name: string; content_type: string; bytes: Uint8Array }): Promise<FolioDone<{ url: string; name: string; bytes: number }>>;
102 /** Sends the asker a link directly, as a message from the agent in their DM with it; false when it couldn't. */
103 sendLink(asker: User, link: { title: string; path: string }, note: string): Promise<boolean>;
104}
105
106/**
107 * What an agent may do, beyond reading: remember, file an issue for the
108 * person who asked, and start or shape work. Each is checked here before it
109 * runs (the audience, the asker, the hop limit) and again by the service
110 * that does it.
111 */
112export interface ActionPorts {
113 /**
114 * `onlyForAsker`: this turn read an artifact the whole workspace can't
115 * read, so the fact is kept for the person who asked alone.
116 */
117 remember(body: string, scope: "workspace" | "channel" | "person" | null, onlyForAsker?: boolean): Promise<{ ok: boolean; message: string }>;
118 forget(id: string): Promise<{ ok: boolean; message: string }>;
119 /**
120 * Posts a draft issue as a card in the conversation, with File issue and
121 * Discard: whoever presses File files it as themselves, if they can read
122 * the repository. Nothing is filed by the agent.
123 */
124 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
125 /**
126 * Comments on an issue or pull request, or reviews a pull request, as the
127 * agent on behalf of the person who asked. Reviews are advisory: they
128 * never count toward required approvals.
129 */
130 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
131 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
132 /** From chat: spins off a session for real work. */
133 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
134 /** In a session: a short progress note in its thread. */
135 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
136 /** In a session: one of the agent's own subagents takes part of the work. */
137 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
138 /** In a session: a colleague works on part of it, paid from this session's budget. */
139 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
140 /** From chat: a colleague takes the work on for the person who asked, here or in a group message. */
141 handOff?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
142}
143
144/** The most hand-offs one reply makes. */
145export const MAX_HAND_OFFS = 2;
146
147/** The most tool calls one reply makes. */
148export const MAX_TOOL_CALLS = 8;
149/** The most tool calls one step of a session makes. */
150export const MAX_SESSION_TOOL_CALLS = 24;
151/** The most of a file or result an answer is given, in characters. */
152const MAX_RESULT = 20_000;
153
154export type ToolCall = {
155 tool: string;
156 /** Its arguments, with long text cut, as recorded. */
157 args: string;
158 outcome: "allowed" | "withheld" | "refused" | "error";
159 bytes: number;
160};
161
162export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
163
164/**
165 * Text a tool read, marked as data. Anything in it that looks like the
166 * closing mark is defused, so content can't end the block early.
167 */
168export function untrusted(source: string, content: string): string {
169 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
170 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
171 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
172}
173
174/** Arguments as recorded: every string cut to 120 characters. */
175export function redact(args: unknown): string {
176 const cut = (value: unknown): unknown => {
177 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
178 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
179 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
180 return value;
181 };
182 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
183}
184
185const CODE_TOOLS: ToolDef[] = [
186 {
187 name: "list_repositories",
188 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
189 input_schema: { type: "object", properties: {} },
190 },
191 {
192 name: "search_code",
193 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
194 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
195 },
196 {
197 name: "read_file",
198 description: "Read a file from a repository, at its default branch or a ref.",
199 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
200 },
201 {
202 name: "list_issues",
203 description: "A repository's newest issues, open by default.",
204 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
205 },
206 {
207 name: "get_issue",
208 description: "One issue with its comments.",
209 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
210 },
211 {
212 name: "get_pull",
213 description: "One pull request: what it changes, its status and checks.",
214 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
215 },
216 {
217 name: "recent_activity",
218 description: "Recently merged and open pull requests, in one repository or across those you can read.",
219 input_schema: { type: "object", properties: { repo: { type: "string" } } },
220 },
221];
222
223const CHAT_TOOLS: ToolDef[] = [
224 {
225 name: "search_messages",
226 description: "Search chat messages this conversation's people can all read.",
227 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
228 },
229 {
230 name: "read_thread",
231 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
232 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
233 },
234 {
235 name: "workspace_roster",
236 description: "The workspace's people and agents: names, teams, titles and roles.",
237 input_schema: { type: "object", properties: {} },
238 },
239];
240
241const ASK_COLLEAGUE: ToolDef = {
242 name: "ask_colleague",
243 description:
244 "Ask a colleague agent a quick question, privately: their answer comes back to you alone, they do no work in the conversation, and the work stays yours. Use it when their role knows something yours doesn't. To give them the work itself, use hand_off.",
245 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
246};
247
248const HAND_OFF: ToolDef = {
249 name: "hand_off",
250 description:
251 "Hand work to a colleague agent, for the person who asked: they take it on and answer that person themselves, with that person's access. If they are a member of this conversation (a channel or group message), your brief is posted here; otherwise a group message opens (or is reused) with the person who asked, you and them, your brief goes there, and a card here links to it. It is the only way to get a colleague working: an @mention in your message wakes nobody. Give their handle and a complete brief written to them: what is wanted, why, what done looks like, and what they need from this conversation. For a quick question you answer with, use ask_colleague instead.",
252 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
253};
254
255const REMEMBER: ToolDef = {
256 name: "remember",
257 description:
258 "Keep a short fact for later work: a preference, a decision, who owns what, how something works here. One fact per call, in your own words. It is kept where this conversation allows (this person, this conversation, or the workspace from a public channel), with this conversation as its source. Never keep secrets, credentials or customers' personal data.",
259 input_schema: {
260 type: "object",
261 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
262 required: ["fact"],
263 },
264};
265
266const FORGET: ToolDef = {
267 name: "forget",
268 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
269 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
270};
271
272const DRAFT_ISSUE: ToolDef = {
273 name: "draft_issue",
274 description:
275 "Draft an issue (a bug report or a feature request) for a repository with what you found. It appears in the conversation as a card with File issue and Discard buttons: the person files it themselves with one press, so don't ask them to confirm in words. Write it for the team that will fix it: what happens, what should happen, steps or evidence, and where in the code it likely is.",
276 input_schema: {
277 type: "object",
278 properties: {
279 repo: { type: "string" },
280 title: { type: "string" },
281 body: { type: "string" },
282 labels: { type: "array", items: { type: "string" } },
283 },
284 required: ["repo", "title", "body"],
285 },
286};
287
288const COMMENT: ToolDef = {
289 name: "comment",
290 description:
291 "Comment on an issue or pull request, as yourself on behalf of the person you're working for. Use it when the comment belongs on the issue or pull request (findings, a test plan, a question for its author), not for chatting.",
292 input_schema: {
293 type: "object",
294 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
295 required: ["repo", "number", "body"],
296 },
297};
298
299const REVIEW_PULL: ToolDef = {
300 name: "review_pull",
301 description:
302 "Review a pull request on the pull request itself, as yourself on behalf of the person you're working for: approve, request changes, or just comment, with your review in the body. Your review is advisory: people still give the approvals a merge needs. Read the change first.",
303 input_schema: {
304 type: "object",
305 properties: {
306 repo: { type: "string" },
307 number: { type: "integer" },
308 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
309 body: { type: "string" },
310 },
311 required: ["repo", "number", "verdict", "body"],
312 },
313};
314
315const START_SESSION: ToolDef = {
316 name: "start_session",
317 description:
318 "Spin off a session for work that needs more than a quick answer: investigating, reading a lot of code, writing something long, or anything that takes several steps. It runs on its own with its own context, shows a live card here, and reports back in this conversation when done. Give it a short title and a complete brief: the goal, what done looks like, and everything it needs from this conversation.",
319 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
320};
321
322const POST_UPDATE: ToolDef = {
323 name: "post_update",
324 description: "Post a short progress note in your session's thread, for the people following it. Use it for real milestones or a question, not for every step.",
325 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
326};
327
328const USE_SUBAGENT: ToolDef = {
329 name: "use_subagent",
330 description:
331 "Hand a well-defined part of this session to one of your subagents (listed under Subagents). It works in its own session, paid from this one, and its result comes back to you before you go on. Give a complete brief.",
332 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
333};
334
335const BRING_IN: ToolDef = {
336 name: "bring_in",
337 description:
338 "Bring a colleague in on part of this session when their role owns it. They work in their own session, paid from this one, and their result comes back to you before you go on. Give a complete brief.",
339 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
340};
341
342/** What every artifact tool's description starts from, so the model never mixes them up with workflow run artifacts. */
343const ARTIFACTS =
344 "Artifacts are the workspace's own documents, made and shared in its Artifacts section: docs now, and later slides, designs and dashboards. They are not a workflow run's build artifacts.";
345
346const FOLIO_TOOLS: ToolDef[] = [
347 {
348 name: "search_artifacts",
349 description: `${ARTIFACTS} Search the artifacts everyone in this conversation can read (specs, runbooks, policies, onboarding, decisions), by words and meaning, plus projects' docs. Optionally only one kind, one space (its name or id from list_spaces) or one project (\`workspace/name\`). Each result has its link and id. Look here first for how things work and what was decided.`,
350 input_schema: {
351 type: "object",
352 properties: {
353 query: { type: "string" },
354 kind: { type: "string", enum: [...FOLIO_KINDS] },
355 space: { type: "string", description: "A space's name or id." },
356 project: { type: "string", description: "A repository, `workspace/name`." },
357 },
358 required: ["query"],
359 },
360 },
361 {
362 name: "read_artifact",
363 description: `${ARTIFACTS} Read one artifact by its id (fol_…) or its link (…/-/artifacts/<name>-fol_…). A doc comes back as Markdown with the ids of its top-level blocks (for edit_artifact), and says what you may do with it.`,
364 input_schema: { type: "object", properties: { id: { type: "string", description: "Its id or link." } }, required: ["id"] },
365 },
366 {
367 name: "list_spaces",
368 description: `${ARTIFACTS} The spaces whose artifacts everyone in this conversation can read, with what you may do in each (read, suggest, edit).`,
369 input_schema: { type: "object", properties: {} },
370 },
371 {
372 name: "stale_artifacts",
373 description: `${ARTIFACTS} Artifacts possibly out of date because code they cite changed. Optionally only for one repository (\`workspace/name\`). Start here when keeping the docs current: read each, then bring it up to date with edit_artifact and marks_current.`,
374 input_schema: { type: "object", properties: { repo: { type: "string" } } },
375 },
376];
377
378const FOLIO_WRITE_TOOLS: ToolDef[] = [
379 {
380 name: "create_artifact",
381 description: `${ARTIFACTS} Make a new artifact ("write this up"). Only kind "doc" can be made for now. Give a title and its content as Markdown (or a template id). where: a space (its name or id from list_spaces) as { "space": "..." }, "private" for the person who asked alone, or "conversation" for them plus view access for this conversation's people. Left out: shared with this conversation in a direct message or private channel, the General space in a public channel. It belongs to the person who asked, and you can keep editing it. When it comes from a conversation, set source to that thread's link.`,
382 input_schema: {
383 type: "object",
384 properties: {
385 kind: { type: "string", enum: [...FOLIO_KINDS] },
386 title: { type: "string" },
387 content: { type: "string", description: "Markdown." },
388 template: { type: "string", description: "A template id, instead of content." },
389 where: {
390 description: '{ "space": "<name or id>" }, "private" or "conversation".',
391 anyOf: [
392 { type: "string", enum: ["private", "conversation"] },
393 { type: "object", properties: { space: { type: "string" } }, required: ["space"] },
394 ],
395 },
396 parent: { type: "string", description: "A doc to put it under: its id or link." },
397 source: { type: "string", description: "The link of the thread it was written up from." },
398 },
399 required: ["kind", "title"],
400 },
401 },
402 {
403 name: "edit_artifact",
404 description: `${ARTIFACTS} Change a doc: replace a section (by its heading), a range of top-level blocks (ids from read_artifact), the whole doc, or add to the end. Where you may edit, it applies at once and shows in its history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read it first. Write Markdown. Only docs can be changed this way for now.`,
405 input_schema: {
406 type: "object",
407 properties: {
408 id: { type: "string", description: "Its id or link." },
409 target: { type: "string", enum: ["append", "section", "blocks", "document"] },
410 heading: { type: "string", description: "For target section: the heading's text." },
411 from_block: { type: "string" },
412 to_block: { type: "string" },
413 markdown: { type: "string" },
414 note: { type: "string", description: "Why, in a line, for the history or the suggestion." },
415 marks_current: { type: "boolean", description: "This edit brings a doc marked possibly out of date up to date: it clears the mark when it applies or is accepted." },
416 suggest_only: { type: "boolean", description: "Suggest even where you could edit." },
417 },
418 required: ["id", "target", "markdown"],
419 },
420 },
421 {
422 name: "share_artifact",
423 description: `${ARTIFACTS} Let people in this conversation view or comment on an artifact, when the person who asked has full access to it. Only in a direct message or a private channel, and only with people already in it. You can't give edit or full access, or change who else can open it: for that, ask the person to use Share.`,
424 input_schema: {
425 type: "object",
426 properties: {
427 id: { type: "string", description: "Its id or link." },
428 people: { type: "array", items: { type: "string" }, description: "Usernames of people in this conversation." },
429 role: { type: "string", enum: ["view", "comment"] },
430 },
431 required: ["id", "people", "role"],
432 },
433 },
434];
435
436/**
437 * Files people asked for (the Documents, Data and Files and media skills):
438 * written here, kept with a doc the person who asked owns or can edit, and
439 * served from the usercontent origin like an upload.
440 */
441const MAKE_FILE: ToolDef = {
442 name: "make_file",
443 description:
444 'Make a file someone asked for and keep it with a doc in Artifacts: format "pdf" or "docx" (a Word document) from Markdown in content (headings, paragraphs, bold, italic, code, links, lists, quotes, code blocks, tables); "xlsx" (a spreadsheet, one or more sheets) or "csv" (one sheet) from sheets, each { name, rows }, the first row its header and numbers as numbers; or "md". Give a title (the file is named after it). Without artifact, a new doc is made holding the content (or a preview of the rows) with the file attached; with artifact (a doc\'s id or link you can edit), the file is attached to that doc. where works as for create_artifact. It returns the file\'s link: give it to them.',
445 input_schema: {
446 type: "object",
447 properties: {
448 format: { type: "string", enum: ["pdf", "docx", "xlsx", "csv", "md"] },
449 title: { type: "string" },
450 content: { type: "string", description: "Markdown, for pdf, docx and md." },
451 sheets: {
452 type: "array",
453 description: "For xlsx and csv.",
454 items: {
455 type: "object",
456 properties: { name: { type: "string" }, rows: { type: "array", items: { type: "array", items: {} } } },
457 required: ["rows"],
458 },
459 },
460 artifact: { type: "string", description: "A doc to attach it to: its id or link. Left out: a new doc." },
461 where: {
462 description: '{ "space": "<name or id>" }, "private" or "conversation", for a new doc.',
463 anyOf: [{ type: "string" }, { type: "object", properties: { space: { type: "string" } }, required: ["space"] }],
464 },
465 },
466 required: ["format", "title"],
467 },
468};
469
470/**
471 * Reading one of the agent's skills (skills.ts): the prompt lists them by
472 * name and when to use them, and this reads one when a request matches.
473 * Reading a skill never offers another tool.
474 */
475const USE_SKILL: ToolDef = {
476 name: "use_skill",
477 description:
478 "Read one of your skills (listed under Your skills) before you do work it covers, and follow it. With file, read one of the files a skill lists instead, such as a template or a reference.",
479 input_schema: {
480 type: "object",
481 properties: { name: { type: "string", description: "The skill's name, as listed." }, file: { type: "string", description: "A file of the skill, by its path, such as resources/template.md." } },
482 required: ["name"],
483 },
484};
485
486/**
487 * Outside g1t, through the workspace's integrations (abilities.ts,
488 * docs.g1t.sh/guides/agent-abilities/): each call is checked against the
489 * agent's abilities for the system that knows the item, in `gate`, before
490 * anything runs.
491 */
492const LOOKUP_OUTSIDE: ToolDef = {
493 name: "lookup_outside",
494 description:
495 "Look up an item in one of the workspace's connected integrations by its key or address: a Linear issue (ENG-42), a Jira ticket (TECH-1234) or a Sentry issue (its link). You get its title, status and description. Only where your abilities allow reading it.",
496 input_schema: { type: "object", properties: { reference: { type: "string", description: "A key such as ENG-42, or the item's address." } }, required: ["reference"] },
497};
498
499const IMPORT_OUTSIDE: ToolDef = {
500 name: "import_outside",
501 description:
502 "Open an issue in a repository here from an item outside g1t (a Linear issue, a Jira ticket or a Sentry issue), linked back to it, as the person you're working for. If it was imported before, you get the existing issue.",
503 input_schema: { type: "object", properties: { repo: { type: "string" }, reference: { type: "string" } }, required: ["repo", "reference"] },
504};
505
506const ACT_OUTSIDE: ToolDef = {
507 name: "act_outside",
508 description:
509 "Act on an item outside g1t: comment on a Linear issue, a Jira ticket or a Sentry issue, or resolve a Sentry issue, with text that names the person you're working for. Depending on your abilities this runs at once, or posts a card asking them first: then say it's waiting on their OK and carry on.",
510 input_schema: {
511 type: "object",
512 properties: { reference: { type: "string" }, action: { type: "string", enum: ["comment", "resolve"] }, text: { type: "string" } },
513 required: ["reference", "action", "text"],
514 },
515};
516
517const REQUEST_ABILITY: ToolDef = {
518 name: "request_ability",
519 description:
520 "When something you were asked for needs an integration that isn't connected, or an ability you don't have (one of yours is Never, or a tool you lack), say so plainly and call this once: it posts a card the person uses to ask the workspace's owners (or to connect it, if they are one). Name what is needed (an integration such as linear, jira or sentry, or one of your abilities by its id) and why, in their words.",
521 input_schema: {
522 type: "object",
523 properties: { needs: { type: "string", description: "An integration's id (linear, jira, sentry…) or an ability's id (integration:linear:comment)." }, why: { type: "string" } },
524 required: ["needs", "why"],
525 },
526};
527
528const OUTSIDE_TOOLS: ToolDef[] = [LOOKUP_OUTSIDE, IMPORT_OUTSIDE, ACT_OUTSIDE, REQUEST_ABILITY];
529const OUTSIDE_NAMES = new Set(OUTSIDE_TOOLS.map((tool) => tool.name));
530
531/** An item outside g1t, as the integrations service fetched it. Reference material, never instructions. */
532export type OutsideItem = { provider: string; key: string; title: string; url: string; status: string | null; body: string };
533
534export type OutsideDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string };
535
536/**
537 * What carries an agent's abilities out, outside g1t: the integrations
538 * service, MCP servers, and the cards in chat that ask, connect and
539 * request. Every call here comes after `gate` allowed it.
540 */
541export interface AbilityPorts {
542 /** The item `reference` names, through the workspace's connections, for the asker. */
543 lookup(asker: User, reference: string): Promise<OutsideDone<OutsideItem>>;
544 import(asker: User, repo: RepoRef, reference: string): Promise<OutsideDone<{ number: number; item: OutsideItem; created: boolean }>>;
545 act(asker: User, reference: string, action: "comment" | "resolve", text: string): Promise<OutsideDone<OutsideItem>>;
546 /** Whether the asker has a connection of their own for the connector. */
547 askerConnected(connector: string, asker: User): Promise<boolean>;
548 /** Calls a tool on one of the agent's MCP servers: the text it returned. */
549 mcp(server: McpServer, tool: McpTool, args: Record<string, unknown>): Promise<OutsideDone<string>>;
550 /** Posts the Ask-first card for a call; the request's id, or null when it couldn't be posted. */
551 askFirst(input: { ability: Ability; source: AbilitySource; tool: string; args: Record<string, unknown>; summary: string; note: string | null }): Promise<string | null>;
552 /** Posts a Connect card: the asker's own connection is needed and missing. */
553 connect(source: AbilitySource, ability: Ability): Promise<boolean>;
554 /** Posts a Request card: an integration to connect, or an ability to allow, for the owners. */
555 request(input: { connector: string | null; ability: Ability | null; source: AbilitySource | null; why: string }): Promise<boolean>;
556 /** Records a refusal in the audit log, naming the rule. */
557 refused(input: { ability: Ability; source: AbilitySource; rule: string; call: string }): void;
558}
559
560/** What a gate decided: go on, or what the agent is told instead. */
561type Gated = { ok: true } | { ok: false; result: ToolResult };
562
563/**
564 * The agent's own computer, in a session (computer.ts): what carries a
565 * command or a file to it. Every call here comes after `gate` allowed it
566 * under the `computer:shell` or `computer:files` ability.
567 */
568export interface ComputerPorts {
569 /** This session's directory under the home, where commands run unless told otherwise. */
570 cwd: string;
571 exec(cmd: string, cwd: string | null, timeoutSeconds: number | null): Promise<OutsideDone<AgentComputerCommand>>;
572 readFile(path: string): Promise<OutsideDone<{ path: string; text: string; bytes: number }>>;
573 writeFile(path: string, text: string): Promise<OutsideDone<{ path: string; bytes: number }>>;
574}
575
576const RUN_COMMAND: ToolDef = {
577 name: "run_command",
578 description:
579 "Run a shell command on your own computer (bash -lc, with git, Node, Python, Go, Rust, Java, .NET and Ruby installed). It runs in this session's directory unless cwd names another under /home/agent, your home, which persists between sessions: clones, installed tools and notes stay. Output comes back when the command ends, so chain steps with && and avoid anything that waits for input or runs forever; a command is killed at timeout_seconds (120 unless given, 1800 at most). Quote real output; never say you ran something you didn't.",
580 input_schema: {
581 type: "object",
582 properties: {
583 command: { type: "string" },
584 cwd: { type: "string", description: "A directory under /home/agent; made if missing." },
585 timeout_seconds: { type: "integer", description: "1 to 1800." },
586 },
587 required: ["command"],
588 },
589};
590
591const COMPUTER_READ_FILE: ToolDef = {
592 name: "computer_read_file",
593 description: "Read a text file from your computer's home (/home/agent), by path: absolute under the home, or relative to it. Up to 5 MB. For a file in a repository on g1t, read_file reads it without the computer.",
594 input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
595};
596
597const COMPUTER_WRITE_FILE: ToolDef = {
598 name: "computer_write_file",
599 description: "Write a text file on your computer, by path under /home/agent (parents are made). Up to 5 MB. It replaces what was there.",
600 input_schema: { type: "object", properties: { path: { type: "string" }, text: { type: "string" } }, required: ["path", "text"] },
601};
602
603const COMPUTER_TOOLS: ToolDef[] = [RUN_COMMAND, COMPUTER_READ_FILE, COMPUTER_WRITE_FILE];
604const COMPUTER_NAMES = new Set(COMPUTER_TOOLS.map((tool) => tool.name));
605
606/**
607 * Words in what the person said that count as asking for the computer, for
608 * a shell set to "alone when asked for it": the work named running,
609 * building, testing or the like.
610 */
611export const COMPUTER_ASKED_WORDS: readonly string[] = ["run", "shell", "command", "terminal", "script", "computer", "install", "build", "test", "clone", "execute", "compile", "benchmark", "reproduce"];
612
613const FOLIO_NAMES = new Set([...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE].map((tool) => tool.name));
614
615/** Every tool an agent may be offered, by name: what skills may name (@g1t/contracts skill-format.ts `AGENT_TOOL_NAMES`, which a test keeps equal). */
616export const TOOL_NAMES: ReadonlySet<string> = new Set(
617 [
618 ...CODE_TOOLS,
619 ...CHAT_TOOLS,
620 ...FOLIO_TOOLS,
621 ...FOLIO_WRITE_TOOLS,
622 ...OUTSIDE_TOOLS,
623 ...COMPUTER_TOOLS,
624 MAKE_FILE,
625 ASK_COLLEAGUE,
626 HAND_OFF,
627 REMEMBER,
628 FORGET,
629 DRAFT_ISSUE,
630 COMMENT,
631 REVIEW_PULL,
632 START_SESSION,
633 POST_UPDATE,
634 USE_SUBAGENT,
635 BRING_IN,
636 USE_SKILL,
637 ].map((tool) => tool.name),
638);
639
640/** Reads a library skill's version (skill-library.ts), for `use_skill`. */
641export type SkillReader = (skillId: string, version: number) => Promise<StoredVersion | null>;
642
643const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
644
645export type ToolContext = {
646 /** The agent replying. */
647 agentId: string;
648 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
649 notConsult: string[];
650 /** Hops so far: a consult is one more, and none is offered at the limit. */
651 hops: number;
652 maxHops: number;
653 /** Whether this is a session's step (more calls, session tools) or a reply. */
654 session?: boolean;
655 /** Told of every call as it is made, for a session's transcript. */
656 onCall?: (call: ToolCall) => void;
657};
658
659export class ToolBox {
660 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
661 readonly calls: ToolCall[];
662 private readonly audience: Audience;
663 private readonly ports: ToolPorts;
664 private readonly context: ToolContext;
665
666 private readonly actions: ActionPorts | null;
667 /** Updates posted in this step. */
668 private updates = 0;
669 /** Hand-offs made in this reply. */
670 private handOffs = 0;
671 /** Artifacts read or made in this turn that not everyone here can read: never named here. */
672 private readonly notHere = new Set<string>();
673 /**
674 * Whether this turn read an artifact the whole workspace can't: then what
675 * it remembers is kept for the person who asked alone.
676 */
677 private privateRead = false;
678 /** The agent's skills this turn (skills.ts), and how to read a library skill's text. */
679 private shelf: readonly ShelfSkill[] = [];
680 private readSkill: SkillReader | null = null;
681 /** The agent's abilities this turn (abilities.ts), what carries them out, and what the asker said (for "alone when asked for it"). */
682 private sections: AbilitySection[] | null = null;
683 private abilityPorts: AbilityPorts | null = null;
684 private said = "";
685
686 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
687 this.audience = audience;
688 this.ports = ports;
689 this.context = context;
690 this.calls = calls;
691 this.actions = actions;
692 }
693
694 /** The agent's own computer this session (computer.ts); null in a reply, which never has one. */
695 private computer: ComputerPorts | null = null;
696
697 /** Gives the agent its skills: `use_skill` is offered while it has any. */
698 useShelf(shelf: readonly ShelfSkill[], read: SkillReader): void {
699 this.shelf = shelf;
700 this.readSkill = read;
701 }
702
703 /**
704 * Gives a session its agent's computer. The tools are offered only once
705 * `useAbilities` said what the shell and files abilities allow, and only
706 * in a session: a reply is never given a computer.
707 */
708 useComputer(ports: ComputerPorts): void {
709 if (!this.context.session) return;
710 this.computer = ports;
711 }
712
713 /**
714 * Gives the agent its abilities: the sections `resolveAbilities` made
715 * for it, the ports that carry them out, and what the person said (the
716 * latest messages, or a session's goal), which "alone when asked for it"
717 * reads. Outside tools and MCP tools are offered from here on.
718 */
719 useAbilities(sections: AbilitySection[], ports: AbilityPorts, said: string, servers: McpServer[] = []): void {
720 this.sections = sections;
721 this.abilityPorts = ports;
722 this.said = said.slice(0, 20_000);
723 this.servers = servers;
724 }
725
726 /** The abilities of one group's sources, flat, with their source. */
727 private abilities(group: AbilityGroup): { ability: Ability; source: AbilitySource }[] {
728 return (this.sections ?? []).filter((section) => section.group === group).flatMap((section) => section.sources.flatMap((source) => source.abilities.map((ability) => ({ ability, source }))));
729 }
730
731 /** The MCP tools offered: every tool of every server whose ability isn't Never. */
732 private mcpTools(): { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }[] {
733 if (!this.abilityPorts) return [];
734 const out: { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }[] = [];
735 for (const { ability, source } of this.abilities("mcp")) {
736 if (ability.level === "never" || !source.connected) continue;
737 const server = this.servers.find((s) => s.id === source.id);
738 const tool = server?.tools.find((t) => `mcp:${server.id}:${t.name}` === ability.id);
739 if (!server || !tool) continue;
740 out.push({
741 def: {
742 name: mcpToolName(server.name, tool.name),
743 description: `${tool.description || tool.name} (a tool of the ${server.name} MCP server, outside g1t; ${tool.kind === "read" ? "it reads" : "it may change things there"}).`.slice(0, 1024),
744 input_schema: tool.input_schema && typeof tool.input_schema === "object" ? tool.input_schema : { type: "object", properties: {} },
745 },
746 server,
747 tool,
748 ability,
749 source,
750 });
751 }
752 return out;
753 }
754
755 /** The agent's MCP servers themselves (addresses and tools), given with `useAbilities`. */
756 private servers: McpServer[] = [];
757
758 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
759 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
760 return new ToolBox(this.audience, ports, context, this.calls);
761 }
762
763 /** The most calls this box makes. */
764 get maxCalls(): number {
765 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
766 }
767
768 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
769 private canFile(): boolean {
770 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
771 }
772
773 /**
774 * The tools offered: no code tools for an audience that can't read code,
775 * no consults or hand-offs at the hop limit, session tools only in a
776 * session, and a spin-off only from chat.
777 */
778 definitions(): ToolDef[] {
779 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
780 const actions = this.actions;
781 return [
782 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
783 ...CHAT_TOOLS,
784 // Artifacts are for everyone, Code or not: the artifacts service decides what this person and audience can read.
785 ...(this.ports.folios && this.audience.asker ? FOLIO_TOOLS : []),
786 ...(this.ports.folios && this.audience.asker && actions ? FOLIO_WRITE_TOOLS : []),
787 ...(this.ports.folios?.attach && this.audience.asker && actions ? [MAKE_FILE] : []),
788 ...(roomForHop ? [ASK_COLLEAGUE] : []),
789 ...(actions ? [REMEMBER, FORGET] : []),
790 ...(this.canFile() ? [DRAFT_ISSUE] : []),
791 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
792 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
793 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
794 ...(actions?.handOff && !this.context.session && roomForHop ? [HAND_OFF] : []),
795 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
796 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
797 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
798 ...(this.shelf.length ? [USE_SKILL] : []),
799 ...this.outsideTools(),
800 ...this.mcpTools().map((entry) => entry.def),
801 ...this.computerTools(),
802 ];
803 }
804
805 /**
806 * The computer's tools offered: in a session with a computer, each tool
807 * of a shell or files ability that is ready and not Never, for a person
808 * the agent acts for.
809 */
810 private computerTools(): ToolDef[] {
811 if (!this.computer || !this.context.session || !this.sections || !this.audience.asker) return [];
812 const live = this.abilities("computer").filter(({ ability }) => ability.status === "ready" && ability.level !== "never");
813 return COMPUTER_TOOLS.filter((tool) => live.some(({ ability }) => ability.tools.includes(tool.name)));
814 }
815
816 /**
817 * The outside tools offered: each only where some connected integration
818 * lets the agent use it at a level other than Never, for a person it can
819 * act for; `request_ability` whenever it has abilities at all.
820 */
821 private outsideTools(): ToolDef[] {
822 if (!this.sections || !this.abilityPorts || !this.audience.asker || !this.actions) return [];
823 const live = this.abilities("integration").filter(({ ability, source }) => source.connected && ability.level !== "never");
824 const offers = (tool: string) => live.some(({ ability }) => ability.tools.includes(tool));
825 return [
826 ...(offers("lookup_outside") ? [LOOKUP_OUTSIDE] : []),
827 ...(offers("import_outside") && this.canFile() ? [IMPORT_OUTSIDE] : []),
828 ...(offers("act_outside") ? [ACT_OUTSIDE] : []),
829 REQUEST_ABILITY,
830 ];
831 }
832
833 /** Whether another call may be made. */
834 get spent(): boolean {
835 return this.calls.length >= this.maxCalls;
836 }
837
838 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
839 const result = await this.attempt(name, input);
840 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
841 this.calls.push(call);
842 this.context.onCall?.(call);
843 return result;
844 }
845
846 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
847 let result: ToolResult;
848 const what = this.context.session ? "step" : "reply";
849 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
850 else {
851 try {
852 result = await this.dispatch(name, input ?? {});
853 } catch (error) {
854 console.error("agents: a tool failed", name, String(error));
855 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
856 }
857 }
858 return result;
859 }
860
861 private withheld(): ToolResult {
862 return { text: WITHHELD, outcome: "withheld" };
863 }
864
865 /** One of the agent's skills, as `use_skill` reads it; only skills it has, and never a tool it lacks. */
866 private async skill(name: string, file: string | null): Promise<ToolResult> {
867 const entry = this.shelf.find((skill) => skill.name === name.toLowerCase());
868 if (!entry) {
869 const names = this.shelf.map((skill) => skill.name).join(", ");
870 return { text: `You have no skill called ${name || "that"}. Your skills: ${names || "none"}.`, outcome: "refused" };
871 }
872 const offered = new Set(this.definitions().map((tool) => tool.name));
873 if (entry.kind === "foundational") {
874 if (file) return { text: `${entry.name} is one of g1t's skills and has no files; its instructions are all there is.`, outcome: "refused" };
875 return { text: skillBlock(entry.skill, offered), outcome: "allowed" };
876 }
877 const stored = this.readSkill ? await this.readSkill(entry.id, entry.version) : null;
878 if (!stored) return { text: `${entry.name} couldn't be read just now. Do the work as you would without it.`, outcome: "error" };
879 return { text: skillText(entry, stored, offered, file), outcome: "allowed" };
880 }
881
882 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
883 const asker = this.audience.asker;
884 if (OUTSIDE_NAMES.has(name)) return this.outside(name, input);
885 if (COMPUTER_NAMES.has(name)) return this.computerCall(name, input);
886 const mcp = this.mcpTools().find((entry) => entry.def.name === name);
887 if (mcp) return this.mcp(mcp, input);
888 if (FOLIO_NAMES.has(name)) {
889 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
890 return this.folios(name, input, asker, this.ports.folios);
891 }
892 if (CODE_NAMES.has(name)) {
893 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
894 if (!this.audience.codeAllowed() || !asker) return this.withheld();
895 return this.code(name, input, asker);
896 }
897 switch (name) {
898 case "use_skill":
899 return this.skill(String(input.name ?? "").trim(), typeof input.file === "string" && input.file.trim() ? input.file.trim() : null);
900 case "search_messages": {
901 const query = String(input.query ?? "").trim();
902 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
903 const found = await this.ports.searchMessages(query);
904 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
905 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
906 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
907 }
908 case "read_thread": {
909 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
910 if (!thread || !thread.length) return this.withheld();
911 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
912 }
913 case "workspace_roster":
914 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
915 case "ask_colleague": {
916 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
917 const question = String(input.question ?? "").trim();
918 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
919 if (this.context.hops + 1 > this.context.maxHops) return { text: "This request has been passed along too many times; answer with what you have.", outcome: "refused" };
920 if (this.context.notConsult.includes(handle)) {
921 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
922 }
923 const answer = await this.ports.consult(handle, question.slice(0, 2000));
924 if (!answer.ok) return { text: answer.message, outcome: "refused" };
925 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
926 }
927 default:
928 return this.act(name, input);
929 }
930 }
931
932 /**
933 * What the workspace's artifacts say about `query`, for this person and
934 * this audience, before the agent answers: no tool call, nothing counted
935 * against its tools. Empty when there is no artifacts service or nothing
936 * relevant.
937 */
938 async recall(query: string | null, spaces: string[]): Promise<FolioPassage[]> {
939 const folios = this.ports.folios;
940 const asker = this.audience.asker;
941 if (!folios || !asker || !query) return [];
942 try {
943 return (await folios.recall(asker, this.folioAudience(), query, spaces)) ?? [];
944 } catch (error) {
945 console.error("agents: artifacts recall failed", String(error));
946 return [];
947 }
948 }
949
950 /** Who reads what an agent says here, as the artifacts service takes it. */
951 private folioAudience(): FolioAudience {
952 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
953 }
954
955 /** Whether anyone besides the person who asked reads what is said here. */
956 private othersHere(asker: User): boolean {
957 return this.audience.shared || this.audience.members.some((m) => m.id !== asker.id);
958 }
959
960 private async folios(name: string, input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
961 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
962 const audience = this.folioAudience();
963 switch (name) {
964 case "list_spaces": {
965 const spaces = await folios.spaces(asker, audience);
966 if (spaces === null) return { text: "Spaces couldn't be listed just now.", outcome: "error" };
967 if (!spaces.length) return { text: "There are no spaces everyone here can read.", outcome: "allowed" };
968 return { text: untrusted("list_spaces", spaces.map(spaceLine).join("\n")), outcome: "allowed" };
969 }
970 case "search_artifacts": {
971 const query = text("query", 200);
972 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
973 const kind = text("kind", 20) || null;
974 if (kind && !isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}: it is doc, slides, design or dashboard.`, outcome: "refused" };
975 let spaceId: string | null = null;
976 if (text("space", 200)) {
977 const space = findSpace((await folios.spaces(asker, audience)) ?? [], text("space", 200));
978 if (!space) return this.withheld();
979 spaceId = space.id;
980 }
981 const found = await folios.search(asker, audience, { query, kind: kind as FolioKind | null, space_id: spaceId, project: text("project", 200).toLowerCase() || null });
982 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
983 return { text: untrusted(`search_artifacts "${query}"`, found), outcome: "allowed" };
984 }
985 case "stale_artifacts": {
986 const found = await folios.stale(asker, audience, text("repo", 200).toLowerCase() || null);
987 if (found === null) return { text: "Out-of-date artifacts couldn't be listed just now.", outcome: "error" };
988 return { text: untrusted("stale_artifacts", found), outcome: "allowed" };
989 }
990 case "read_artifact": {
991 const id = folioRef(text("id", 500));
992 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
993 const found = await folios.read(asker, audience, id);
994 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
995 const read = found.value;
996 if (!this.audience.shared || !read.audience_can_read) this.privateRead = true;
997 if (!read.audience_can_read) return this.notForEveryone(asker, read.folio, folios, "found");
998 return { text: untrusted(`read_artifact ${id}`, folioReadText(read)), outcome: "allowed" };
999 }
1000 case "create_artifact": {
1001 const kind = text("kind", 20) || "doc";
1002 if (!isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}.`, outcome: "refused" };
1003 if (kind !== "doc") return { text: NOT_YET, outcome: "refused" };
1004 const title = text("title", 200);
1005 const template = text("template", 100) || null;
1006 const markdown = String(input.content ?? "").slice(0, 100_000);
1007 if (!title) return { text: "An artifact needs a title.", outcome: "refused" };
1008 if (!markdown.trim() && !template) return { text: "Give its content as Markdown, or a template.", outcome: "refused" };
1009 const parentGiven = text("parent", 500);
1010 const parent = parentGiven ? folioRef(parentGiven) : null;
1011 if (parentGiven && !parent) return { text: "Give the parent doc's id or link.", outcome: "refused" };
1012 const place = await this.whereFor(input.where, asker, folios);
1013 if (!place.ok) return { text: place.message, outcome: "refused" };
1014 const make = (where: FolioWhere) =>
1015 folios.create(asker, { kind, title, markdown: template ? null : markdown, template_id: template, where, parent_id: parent, source: sourceLink(text("source", 2000)) });
1016 let made = await make(place.where);
1017 // The General space by default, unless the person who asked can't add there: then their Private.
1018 if (!made.ok && place.fallback && made.code === "forbidden") made = await make("private");
1019 if (!made.ok) return { text: `It couldn't be made: ${made.message}`, outcome: "refused" };
1020 const ref = made.value;
1021 if (this.othersHere(asker)) {
1022 const check = await folios.read(asker, audience, ref.id).catch(() => null);
1023 if (!check?.ok || !check.value.audience_can_read) return this.notForEveryone(asker, ref, folios, "made");
1024 }
1025 return { text: `Wrote ${ref.title} (${ref.path}, id ${ref.id}). Link it.`, outcome: "allowed" };
1026 }
1027 case "edit_artifact": {
1028 const id = folioRef(text("id", 500));
1029 const markdown = String(input.markdown ?? "").slice(0, 100_000);
1030 if (!id || !markdown.trim()) return { text: "Give the artifact's id or link, and the Markdown.", outcome: "refused" };
1031 const kind = text("target", 20);
1032 const target: DocEditTarget | null =
1033 kind === "append"
1034 ? { kind: "append" }
1035 : kind === "document"
1036 ? { kind: "document" }
1037 : kind === "section" && text("heading", 300)
1038 ? { kind: "section", heading: text("heading", 300) }
1039 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
1040 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
1041 : null;
1042 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
1043 const edit: FolioAgentEdit = { kind: "doc", target, markdown, note: text("note", 300) || null, suggest_only: input.suggest_only === true, marks_current: input.marks_current === true };
1044 const done = await folios.edit(asker, id, edit);
1045 if (!done.ok) return { text: `That didn't work: ${done.message}`, outcome: "refused" };
1046 return { text: editMessage(done.value, !this.notHere.has(done.value.folio.id)), outcome: "allowed" };
1047 }
1048 case "share_artifact": {
1049 if (this.audience.shared) return { text: "You can share only in a direct message or a private channel. Ask the person to use Share on the artifact instead.", outcome: "refused" };
1050 const id = folioRef(text("id", 500));
1051 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
1052 const role = input.role === "view" || input.role === "comment" ? input.role : null;
1053 if (!role) return { text: "You can share to view or comment only. For more, ask the person to use Share.", outcome: "refused" };
1054 const named = (Array.isArray(input.people) ? input.people : []).filter((p): p is string => typeof p === "string").map((p) => p.trim().replace(/^@/, "").toLowerCase()).filter(Boolean).slice(0, 50);
1055 const people = named.map((n) => this.audience.members.find((m) => m.username.toLowerCase() === n || m.id === n) ?? n);
1056 const outside = people.filter((p): p is string => typeof p === "string");
1057 if (outside.length) return { text: `${outside.map((n) => `@${n}`).join(", ")} ${outside.length === 1 ? "isn't" : "aren't"} in this conversation: you can share only with people in it.`, outcome: "refused" };
1058 const users = [...new Map((people as User[]).filter((u) => u.id !== asker.id).map((u) => [u.id, u])).values()];
1059 if (!users.length) return { text: "Name who to share it with: people in this conversation besides the person who asked.", outcome: "refused" };
1060 const done = await folios.share(asker, audience, id, users.map((u) => u.id), role);
1061 if (!done.ok) return { text: `It couldn't be shared: ${done.message}`, outcome: "refused" };
1062 return { text: `Shared with ${users.map((u) => `@${u.username}`).join(", ")}: they can ${role} it.`, outcome: "allowed" };
1063 }
1064 case "make_file":
1065 return this.makeFile(input, asker, folios);
1066 default:
1067 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1068 }
1069 }
1070
1071 /**
1072 * A file someone asked for (files.ts), kept with a doc: the one named,
1073 * which the asker must be able to edit, or a new one holding the content.
1074 * Where not everyone here can read that doc, the link goes to the asker
1075 * directly, as for any artifact.
1076 */
1077 private async makeFile(input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
1078 if (!folios.attach) return { text: "There is no tool called make_file here.", outcome: "refused" };
1079 const title = String(input.title ?? "").trim().slice(0, 200);
1080 const made = makeFile({ format: input.format, title, content: input.content, sheets: input.sheets });
1081 if (!made.ok) return { text: made.message, outcome: "refused" };
1082 const file = made.file;
1083 const audience = this.folioAudience();
1084 const given = String(input.artifact ?? "").trim().slice(0, 500);
1085 let ref: FolioRef;
1086 let hidden = false;
1087 if (given) {
1088 const id = folioRef(given);
1089 if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" };
1090 const found = await folios.read(asker, audience, id);
1091 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
1092 if (!found.value.can.attach) return { text: "They can't edit that doc, so nothing can be attached to it for them. Leave artifact out to make a new doc.", outcome: "refused" };
1093 ref = found.value.folio;
1094 if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true;
1095 hidden = !found.value.audience_can_read;
1096 } else {
1097 const place = await this.whereFor(input.where, asker, folios);
1098 if (!place.ok) return { text: place.message, outcome: "refused" };
1099 const body = docBody(file.format, title, input);
1100 const make = (where: FolioWhere) => folios.create(asker, { kind: "doc", title, markdown: body, template_id: null, where, parent_id: null, source: null });
1101 let created = await make(place.where);
1102 if (!created.ok && place.fallback && created.code === "forbidden") created = await make("private");
1103 if (!created.ok) return { text: `The doc to keep it in couldn't be made: ${created.message}`, outcome: "refused" };
1104 ref = created.value;
1105 if (this.othersHere(asker)) {
1106 const check = await folios.read(asker, audience, ref.id).catch(() => null);
1107 hidden = !check?.ok || !check.value.audience_can_read;
1108 }
1109 }
1110 const kept = await folios.attach(asker, ref.id, { name: file.name, content_type: file.content_type, bytes: file.bytes });
1111 if (!kept.ok) return { text: `The file was made but couldn't be kept: ${kept.message}`, outcome: "refused" };
1112 const size = sizeLabel(kept.value.bytes);
1113 // The doc links its file, so whoever opens the doc finds it.
1114 await folios
1115 .edit(asker, ref.id, { kind: "doc", target: { kind: "append" }, markdown: `**File:** [${kept.value.name.replace(/[[\]]/g, "")}](${kept.value.url}) (${size})`, note: `Attached ${kept.value.name}`, suggest_only: false, marks_current: false })
1116 .catch(() => null);
1117 if (hidden) return this.notForEveryone(asker, ref, folios, "made");
1118 return {
1119 text: `Made ${kept.value.name} (${file.summary}, ${size}) and kept it with the doc ${ref.title} (${ref.path}). Give them the file's link, ${kept.value.url}, and the doc's if it helps.`,
1120 outcome: "allowed",
1121 };
1122 }
1123
1124 /**
1125 * Where a new artifact goes:
1126 * - a space named by its name or id, among those everyone here can read;
1127 * - "private": the asker's Private;
1128 * - "conversation": Private, plus `view` for this conversation's people;
1129 * - nothing: the conversation in a DM or private channel, and in a public
1130 * channel the General space if the asker can add there (`fallback`:
1131 * their Private if the artifacts service says they can't).
1132 */
1133 private async whereFor(given: unknown, asker: User, folios: FoliosPorts): Promise<{ ok: true; where: FolioWhere; fallback: boolean } | { ok: false; message: string }> {
1134 const named = typeof given === "object" && given !== null ? (given as { space?: unknown }).space : given;
1135 const wanted = typeof named === "string" ? named.trim().slice(0, 200) : "";
1136 const others = this.audience.members.filter((m) => m.id !== asker.id).map((m) => m.id);
1137 const byDefault = async (): Promise<{ ok: true; where: FolioWhere; fallback: boolean }> => {
1138 if (!this.audience.shared) return { ok: true, where: others.length ? { conversation: this.audience.members.map((m) => m.id) } : "private", fallback: false };
1139 const spaces = (await folios.spaces(asker, this.folioAudience())) ?? [];
1140 const general = spaces.find((s) => s.slug === "general") ?? spaces.find((s) => s.name.toLowerCase() === "general");
1141 return general && general.can.suggest ? { ok: true, where: { space_id: general.id }, fallback: true } : { ok: true, where: "private", fallback: false };
1142 };
1143 if (!wanted) return byDefault();
1144 const lower = wanted.toLowerCase();
1145 if (typeof given === "string" && lower === "private") return { ok: true, where: "private", fallback: false };
1146 // A public channel has no list of people to share with: its default instead.
1147 if (typeof given === "string" && lower === "conversation") return byDefault();
1148 const space = findSpace((await folios.spaces(asker, this.folioAudience())) ?? [], wanted);
1149 if (space) return { ok: true, where: { space_id: space.id }, fallback: false };
1150 // An id the asker gave, for a space not everyone here can read: the artifacts service checks it.
1151 if (/^spc_[A-Za-z0-9]+$/.test(wanted)) return { ok: true, where: { space_id: wanted }, fallback: false };
1152 return { ok: false, message: `There's no space called ${wanted} that everyone here can read. Use list_spaces, or put it in "private" or "conversation".` };
1153 }
1154
1155 /**
1156 * An artifact someone here can't read: the link goes to the asker
1157 * directly, and the agent says only that it found or made something,
1158 * never what.
1159 */
1160 private async notForEveryone(asker: User, folio: FolioRef, folios: FoliosPorts, what: "found" | "made"): Promise<ToolResult> {
1161 this.notHere.add(folio.id);
1162 const who = `@${asker.username}`;
1163 const note =
1164 what === "made"
1165 ? "I made this for you. Not everyone in the conversation you asked from can open it, so here is the link:"
1166 : "Here is what you asked about. Not everyone in the conversation you asked from can open it, so here is the link:";
1167 const sent = await folios.sendLink(asker, { title: folio.title, path: folio.path }, note).catch(() => false);
1168 const lead = `Not everyone in this conversation can read this artifact, so ${what === "made" ? "it" : "its content"} isn't shown here. Don't quote it, name it or describe it here;`;
1169 const text = sent
1170 ? `${lead} say you ${what} it and that you've sent the link to ${who} directly.`
1171 : what === "made"
1172 ? `${lead} say you made it and that ${who} will find it under Artifacts, in their Private or shared with them.`
1173 : `${lead} say you found it but can't share it here, and ask ${who} to message you directly.`;
1174 return { text, outcome: what === "made" ? "allowed" : "withheld" };
1175 }
1176
1177 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
1178 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1179 const actions = this.actions;
1180 const offered = this.definitions().some((tool) => tool.name === name);
1181 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1182 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
1183 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1184 switch (name) {
1185 case "remember": {
1186 const fact = text("fact", 2000);
1187 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
1188 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
1189 return said(await actions.remember(fact, scope, this.privateRead));
1190 }
1191 case "forget":
1192 return said(await actions.forget(text("id", 100)));
1193 case "draft_issue": {
1194 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
1195 const repo = await this.audience.repo(input.repo);
1196 if (!repo) return this.withheld();
1197 const title = text("title", 200);
1198 const body = text("body", 20_000);
1199 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
1200 const labels = Array.isArray(input.labels)
1201 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
1202 : [];
1203 return said(await actions.draftIssue(repo, { title, body, labels }));
1204 }
1205 case "comment":
1206 case "review_pull": {
1207 const asker = this.audience.asker;
1208 if (!asker || !this.audience.codeAllowed()) return this.withheld();
1209 const repo = await this.audience.repo(input.repo);
1210 if (!repo) return this.withheld();
1211 const number = Math.floor(Number(input.number));
1212 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
1213 const body = text("body", 20_000);
1214 if (name === "comment") {
1215 if (!body) return { text: "Say what to comment.", outcome: "refused" };
1216 return said(await actions.comment!(repo, asker, number, body));
1217 }
1218 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
1219 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
1220 return said(await actions.review!(repo, asker, number, verdict, body));
1221 }
1222 case "start_session": {
1223 const title = text("title", 120);
1224 const goal = text("goal", 8000);
1225 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
1226 return said(await actions.startSession!(title, goal));
1227 }
1228 case "post_update": {
1229 const note = text("text", 2000);
1230 if (!note) return { text: "Say what to post.", outcome: "refused" };
1231 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
1232 this.updates++;
1233 return said(await actions.postUpdate!(note));
1234 }
1235 case "use_subagent": {
1236 const helper = text("name", 60).toLowerCase();
1237 const brief = text("brief", 8000);
1238 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
1239 return said(await actions.useSubagent!(helper, brief));
1240 }
1241 case "bring_in": {
1242 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
1243 const brief = text("brief", 8000);
1244 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
1245 if (this.context.notConsult.includes(handle)) return { text: `You can't bring in @${handle} here: they sent you this work, or it is you.`, outcome: "refused" };
1246 return said(await actions.bringIn!(handle, brief));
1247 }
1248 case "hand_off": {
1249 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
1250 const brief = text("brief", 8000);
1251 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
1252 if (this.context.notConsult.includes(handle)) {
1253 return { text: `You can't hand work to @${handle}: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
1254 }
1255 if (this.handOffs >= MAX_HAND_OFFS) return { text: `You've handed off to ${MAX_HAND_OFFS} colleagues from this message; hand off the rest later.`, outcome: "refused" };
1256 const done = await actions.handOff!(handle, brief);
1257 if (done.ok) this.handOffs++;
1258 return said(done);
1259 }
1260 default:
1261 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1262 }
1263 }
1264
1265 /**
1266 * The computer's tools, each gated by its ability (`computer:shell` for
1267 * commands, `computer:files` for files) before the runner is asked. A
1268 * computer that could not wake because the plan refused is said plainly,
1269 * so the agent reports it rather than trying another way.
1270 */
1271 private async computerCall(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1272 const asker = this.audience.asker;
1273 const ports = this.computer;
1274 if (!ports || !asker || !this.definitions().some((tool) => tool.name === name)) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1275 const own = this.abilities("computer").find(({ ability }) => ability.tools.includes(name));
1276 if (!own) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1277 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1278 const failed = (done: { code: string; message: string }, what: string): ToolResult =>
1279 done.code === "payment_required"
1280 ? { text: `Your computer couldn't wake: ${done.message} Say so in your report; don't try another way.`, outcome: "refused" }
1281 : { text: `${what}: ${done.message}`, outcome: done.code === "not_found" || done.code === "invalid" ? "refused" : "error" };
1282 switch (name) {
1283 case "run_command": {
1284 const command = text("command", 8000);
1285 if (!command) return { text: "Give the command to run.", outcome: "refused" };
1286 const cwd = text("cwd", 500) || null;
1287 const timeout = Number.isFinite(Number(input.timeout_seconds)) && Number(input.timeout_seconds) > 0 ? Math.min(1800, Math.floor(Number(input.timeout_seconds))) : null;
1288 const gated = await this.gate(own, { tool: name, args: input, summary: `Run \`${command.split("\n")[0].slice(0, 80)}\` on its computer`, keys: [...COMPUTER_ASKED_WORDS, own.ability.label] }, asker);
1289 if (!gated.ok) return gated.result;
1290 const done = await ports.exec(command, cwd, timeout);
1291 if (!done.ok) return failed(done, "The command couldn't run");
1292 const ran = done.value;
1293 const how = [`exit ${ran.exit_code}`, `${(ran.duration_ms / 1000).toFixed(1)} s`, ran.timed_out ? "killed at the timeout" : null, ran.truncated ? "output cut" : null].filter(Boolean).join(", ");
1294 return { text: `$ ${ran.cmd}\n(${how}; in ${ran.cwd})\n${untrusted("run_command on your computer", ran.output || "(no output)")}`, outcome: "allowed" };
1295 }
1296 case "computer_read_file": {
1297 const path = text("path", 1000);
1298 if (!path) return { text: "Give the file's path under /home/agent.", outcome: "refused" };
1299 const gated = await this.gate(own, { tool: name, args: input, summary: `Read ${path} on its computer`, keys: [path, "read", "file", "open", "look", own.ability.label] }, asker);
1300 if (!gated.ok) return gated.result;
1301 const done = await ports.readFile(path);
1302 if (!done.ok) return failed(done, "The file couldn't be read");
1303 return { text: untrusted(`${done.value.path} on your computer`, done.value.text), outcome: "allowed" };
1304 }
1305 case "computer_write_file": {
1306 const path = text("path", 1000);
1307 const body = String(input.text ?? "");
1308 if (!path) return { text: "Give the file's path under /home/agent.", outcome: "refused" };
1309 const gated = await this.gate(own, { tool: name, args: input, summary: `Write ${path} on its computer`, keys: [path, "write", "save", "create", "file", "note", own.ability.label] }, asker);
1310 if (!gated.ok) return gated.result;
1311 const done = await ports.writeFile(path, body);
1312 if (!done.ok) return failed(done, "The file couldn't be written");
1313 return { text: `Wrote ${done.value.bytes} bytes to ${done.value.path}.`, outcome: "allowed" };
1314 }
1315 default:
1316 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1317 }
1318 }
1319
1320 /**
1321 * The rule for one ability, applied before a call: Never refuses and
1322 * names the rule; the asker's own connection must exist; Ask first posts
1323 * the card and tells the agent to wait; "alone when asked for it" is
1324 * alone only when what the person said names the item or the ability.
1325 */
1326 private async gate(found: { ability: Ability; source: AbilitySource }, call: { tool: string; args: Record<string, unknown>; summary: string; keys: string[] }, asker: User): Promise<Gated> {
1327 const { ability, source } = found;
1328 const ports = this.abilityPorts!;
1329 const rule = `${source.name}: ${ability.label}`;
1330 const refused = (text: string): Gated => ({ ok: false, result: { text, outcome: "refused" } });
1331 if (!source.connected) return refused(`${source.name} isn't connected to this workspace, so you can't ${ability.label.toLowerCase()} there. Say so, and use request_ability if they want it connected.`);
1332 if (ability.level === "never") {
1333 ports.refused({ ability, source, rule: `${ability.id}=never`, call: call.summary });
1334 return refused(`Not allowed: your abilities say "${rule}" is Never. Tell them plainly you aren't allowed to, without trying another way; an owner can change it on your Abilities tab.`);
1335 }
1336 if (ability.credentials === "asker" && !(await ports.askerConnected(source.id, asker))) {
1337 const posted = await ports.connect(source, ability);
1338 ports.refused({ ability, source, rule: `${ability.id}=asker-not-connected`, call: call.summary });
1339 return refused(
1340 `This runs on @${asker.username}'s own ${source.name} connection, and they haven't connected one. ${posted ? "A Connect card was posted: tell them to press it, then ask you again." : "Ask them to connect it under their Integrations settings, then ask you again."}`,
1341 );
1342 }
1343 let note: string | null = null;
1344 if (ability.level === "asked") {
1345 if (askedFor(this.said, call.keys)) return { ok: true };
1346 note = `${ability.label} in ${source.name} runs on its own only when asked for it, and this wasn't.`;
1347 } else if (ability.level !== "ask") return { ok: true };
1348 const id = await ports.askFirst({ ability, source, tool: call.tool, args: call.args, summary: call.summary, note });
1349 ports.refused({ ability, source, rule: `${ability.id}=${ability.level}`, call: call.summary });
1350 if (!id) return refused(`"${rule}" is ${levelWords(ability.level)}, and the card asking for it couldn't be posted just now. Say what you'd do and ask them to allow it.`);
1351 return refused(
1352 `${note ? `${note} ` : ""}"${rule}" is ${levelWords(ability.level)}: a card was posted asking @${asker.username} (or an owner) to allow it. Don't do it another way. Say in a sentence that it's waiting on their OK, and carry on with the rest.${this.context.session ? " Your session pauses at the end of this step until they answer; the result comes to you then." : ""}`,
1353 );
1354 }
1355
1356 /** The ability `tool` on the connector `provider`, among the agent's. */
1357 private integrationAbility(provider: string, tool: string): { ability: Ability; source: AbilitySource } | null {
1358 return this.abilities("integration").find(({ ability, source }) => source.id === provider.toLowerCase() && ability.tools.includes(tool)) ?? null;
1359 }
1360
1361 /** The outside tools: each checked against the agent's abilities for the system that knows the item. */
1362 private async outside(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1363 const asker = this.audience.asker;
1364 const ports = this.abilityPorts;
1365 if (!ports || !this.sections || !asker || !this.actions || !this.definitions().some((tool) => tool.name === name)) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1366 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1367 const reference = text("reference", 500);
1368 const notFound = (): ToolResult => ({ text: `No connected integration knows ${reference || "that"}. If it's in a system that isn't connected, say so; request_ability lets them ask for it.`, outcome: "refused" });
1369 switch (name) {
1370 case "request_ability": {
1371 const needs = text("needs", 120).toLowerCase();
1372 const why = text("why", 500);
1373 if (!needs || !why) return { text: "Say what is needed and why.", outcome: "refused" };
1374 const abilityFound = this.abilities("integration").find(({ ability }) => ability.id === needs) ?? this.abilities("mcp").find(({ ability }) => ability.id === needs) ?? null;
1375 const connector = abilityFound ? null : needs.replace(/^integration:/, "").split(":")[0]!;
1376 const posted = await ports.request({ connector, ability: abilityFound?.ability ?? null, source: abilityFound?.source ?? null, why });
1377 if (!posted) return { text: "The request card couldn't be posted just now. Say what's needed and that an owner can add it from the Marketplace or your Abilities tab.", outcome: "error" };
1378 return { text: "A Request card was posted. Say in a sentence what it asks for, and that the owners will see it once they press it.", outcome: "allowed" };
1379 }
1380 case "lookup_outside": {
1381 if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1382 const found = await ports.lookup(asker, reference);
1383 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1384 const item = found.value;
1385 const own = this.integrationAbility(item.provider, "lookup_outside");
1386 if (!own) return notFound();
1387 const gated = await this.gate(own, { tool: name, args: input, summary: `Read ${item.key} in ${own.source.name}`, keys: [item.key, reference, own.ability.label] }, asker);
1388 if (!gated.ok) return gated.result;
1389 return { text: untrusted(`${own.source.name} ${item.key}`, `${item.key}: ${item.title}${item.status ? ` [${item.status}]` : ""}
1390${item.url}
1391
1392${item.body}`), outcome: "allowed" };
1393 }
1394 case "import_outside": {
1395 if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1396 if (!this.audience.codeAllowed()) return this.withheld();
1397 const repo = await this.audience.repo(input.repo);
1398 if (!repo) return this.withheld();
1399 const found = await ports.lookup(asker, reference);
1400 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1401 const own = this.integrationAbility(found.value.provider, "import_outside");
1402 if (!own) return { text: `Importing from ${found.value.provider} isn't one of your abilities.`, outcome: "refused" };
1403 const gated = await this.gate(own, { tool: name, args: input, summary: `Import ${found.value.key} from ${own.source.name} into ${repo.namespace}/${repo.name}`, keys: [found.value.key, reference, "import"] }, asker);
1404 if (!gated.ok) return gated.result;
1405 const done = await ports.import(asker, repo, reference);
1406 if (!done.ok) return { text: `It couldn't be imported: ${done.message}`, outcome: "refused" };
1407 const path = `/${repo.namespace}/${repo.name}/issues/${done.value.number}`;
1408 return { text: `${done.value.created ? "Opened" : "Already imported as"} ${repo.namespace}/${repo.name}#${done.value.number} from ${done.value.item.key}. Link it: ${path}`, outcome: "allowed" };
1409 }
1410 case "act_outside": {
1411 const action = input.action === "resolve" ? "resolve" : input.action === "comment" ? "comment" : null;
1412 const body = String(input.text ?? "").trim().slice(0, 8000);
1413 if (!reference || !action) return { text: "Give the item, and whether to comment or resolve.", outcome: "refused" };
1414 if (!body) return { text: "Say what to write.", outcome: "refused" };
1415 const found = await ports.lookup(asker, reference);
1416 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1417 const item = found.value;
1418 const own = this.abilities("integration").find(({ ability, source }) => source.id === item.provider.toLowerCase() && ability.id.endsWith(`:${action}`)) ?? null;
1419 if (!own) return { text: `${action === "resolve" ? "Resolving" : "Commenting on"} items in ${item.provider} isn't one of your abilities.`, outcome: "refused" };
1420 const summary = action === "resolve" ? `Resolve ${item.key} in ${own.source.name}` : `Comment on ${item.key} in ${own.source.name}`;
1421 const gated = await this.gate(own, { tool: name, args: input, summary, keys: [item.key, reference, action, own.ability.label] }, asker);
1422 if (!gated.ok) return gated.result;
1423 const done = await ports.act(asker, reference, action, body);
1424 if (!done.ok) return { text: `It didn't work: ${done.message}`, outcome: "refused" };
1425 return { text: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} in ${own.source.name} (${done.value.url}).`, outcome: "allowed" };
1426 }
1427 default:
1428 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1429 }
1430 }
1431
1432 /** A tool of one of the agent's MCP servers, gated by its ability. */
1433 private async mcp(entry: { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }, input: Record<string, unknown>): Promise<ToolResult> {
1434 const asker = this.audience.asker;
1435 const ports = this.abilityPorts;
1436 if (!ports || !asker) return { text: `There is no tool called ${entry.def.name} here.`, outcome: "refused" };
1437 const summary = `Call ${entry.tool.name} on ${entry.server.name}`;
1438 const gated = await this.gate({ ability: entry.ability, source: entry.source }, { tool: entry.def.name, args: input, summary, keys: [entry.tool.name, entry.server.name] }, asker);
1439 if (!gated.ok) return gated.result;
1440 const done = await ports.mcp(entry.server, entry.tool, input);
1441 if (!done.ok) return { text: `${entry.server.name} didn't do it: ${done.message}`, outcome: "error" };
1442 return { text: untrusted(`${entry.server.name} ${entry.tool.name}`, done.value), outcome: "allowed" };
1443 }
1444
1445 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
1446 if (name === "list_repositories") {
1447 const repos = [...(await this.audience.repos()).values()];
1448 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
1449 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
1450 }
1451 if (name === "search_code") {
1452 const query = String(input.query ?? "").trim();
1453 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
1454 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
1455 if (input.repo && !only) return this.withheld();
1456 const allowed = await this.audience.repos();
1457 // Whatever search returns, only hits in allowed repositories come through.
1458 const hits = (await this.ports.searchCode(viewer, query, only)).filter((hit) => allowed.has(hit.repo.toLowerCase()) && (!only || hit.repo.toLowerCase() === `${only.namespace}/${only.name}`.toLowerCase()));
1459 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
1460 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
1461 }
1462 if (name === "recent_activity") {
1463 const one = input.repo ? await this.audience.repo(input.repo) : null;
1464 if (input.repo && !one) return this.withheld();
1465 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
1466 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
1467 const pulls = await this.ports.recentPulls(repos, viewer);
1468 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
1469 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
1470 }
1471 // The rest name one repository; it must be on the allow-list.
1472 const repo = await this.audience.repo(input.repo);
1473 if (!repo) return this.withheld();
1474 const full = `${repo.namespace}/${repo.name}`;
1475 switch (name) {
1476 case "read_file": {
1477 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
1478 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
1479 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
1480 const file = await this.ports.readFile(repo, viewer, ref, path);
1481 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
1482 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
1483 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
1484 }
1485 case "list_issues": {
1486 const state = input.state === "closed" ? "closed" : "open";
1487 const issues = await this.ports.listIssues(repo, viewer, state);
1488 if (!issues) return this.withheld();
1489 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
1490 return { text: untrusted(`list_issues ${full}`, issues.slice(0, 30).map((i) => `#${i.number} [${i.state}] ${i.title}${i.labels.length ? ` (${i.labels.join(", ")})` : ""}`).join("\n")), outcome: "allowed" };
1491 }
1492 case "get_issue": {
1493 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
1494 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
1495 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
1496 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
1497 }
1498 case "get_pull": {
1499 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
1500 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
1501 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
1502 }
1503 default:
1504 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1505 }
1506 }
1507}
1508
1509/** What an agent hears when it asks for a kind that isn't built yet. */
1510const NOT_YET = "Slides, designs and dashboards aren't available yet: only docs can be made for now. Say so, and offer to write it as a doc instead.";
1511
1512/**
1513 * A folio id from an id or any artifact link (`/acme/-/artifacts/runbook-fol_…`,
1514 * with or without the site and a query); null when there is none.
1515 */
1516/**
1517 * What a doc made to hold a file says: the Markdown the file was made from
1518 * (less a first heading repeating the title, which the doc has), or a
1519 * preview of a spreadsheet's rows.
1520 */
1521export function docBody(format: MakeFileFormat, title: string, input: Record<string, unknown>): string {
1522 let body = "";
1523 if (format === "xlsx" || format === "csv") {
1524 const read = readSheets(input.sheets);
1525 if (read.ok) body = read.sheets.map((sheet) => `${read.sheets.length > 1 ? `## ${sheet.name}\n\n` : ""}${previewTable(sheet)}`).join("\n\n");
1526 } else {
1527 const markdown = String(input.content ?? "");
1528 const first = /^\s*#\s+(.+?)\s*#*\s*(?:\n|$)/.exec(markdown);
1529 body = (first && first[1]!.trim().toLowerCase() === title.trim().toLowerCase() ? markdown.slice(first[0].length).trimStart() : markdown).slice(0, 100_000);
1530 }
1531 return body.trim() ? body : "The file is attached below.";
1532}
1533
1534export function folioRef(given: string): string | null {
1535 const last = given.trim().split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? "";
1536 return folioIdFrom(last);
1537}
1538
1539/** Where an artifact was written up from: a thread's link, cut to the site path the artifacts service keeps; null when it isn't one. */
1540export function sourceLink(given: string): { title: string; href: string } | null {
1541 let href = given.trim();
1542 if (!href) return null;
1543 if (/^https?:\/\//i.test(href)) {
1544 try {
1545 const url = new URL(href);
1546 href = `${url.pathname}${url.search}${url.hash}`;
1547 } catch {
1548 return null;
1549 }
1550 }
1551 return href.startsWith("/") && !href.startsWith("//") ? { title: "A conversation", href } : null;
1552}
1553
1554/** A space by its id, address or name (any case), among those given. */
1555function findSpace(spaces: FolioSpaceLine[], given: string): FolioSpaceLine | null {
1556 const wanted = given.trim().toLowerCase();
1557 return spaces.find((s) => s.id === given.trim()) ?? spaces.find((s) => s.slug.toLowerCase() === wanted) ?? spaces.find((s) => s.name.toLowerCase() === wanted) ?? null;
1558}
1559
1560function spaceLine(s: FolioSpaceLine): string {
1561 const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
1562 const projects = s.projects.length ? `; about ${s.projects.join(", ")}` : "";
1563 return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
1564}
1565
1566/** An artifact as the agent reads it: where it is, what it may do, and its content (a doc's Markdown, with its top-level block ids). */
1567export function folioReadText(read: FolioAgentRead): string {
1568 const f = read.folio;
1569 const can = read.can.edit ? "you can edit it" : read.can.suggest ? "you can suggest edits" : "you can only read it";
1570 const where = read.space ? `in the ${read.space.name} space` : "not in a space";
1571 const blocks = read.blocks?.length ? `\nTop-level blocks: ${read.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ")}` : "";
1572 return `# ${f.title} (${f.path}, id ${f.id})\nA ${f.kind}, ${where}; ${can}. Edited ${f.edited_at.slice(0, 16)}.${blocks}\n\n${read.content}`;
1573}
1574
1575/** What an edit did, naming the artifact only where everyone here can read it. */
1576function editMessage(result: FolioAgentEditResult, name: boolean): string {
1577 const on = name ? ` on ${result.folio.title} (${result.folio.path})` : "";
1578 switch (result.mode) {
1579 case "applied":
1580 return `Changed${on}. It's in its history as yours.`;
1581 case "suggested":
1582 return `Suggested${on}: people accept or reject it there.${name ? " Link it so they can." : ""}`;
1583 case "proposed":
1584 return `Proposed a change${on}: a person previews and applies it.`;
1585 }
1586}
1587
1588function messageLine(m: FoundMessage): string {
1589 const where = m.channel ? `#${m.channel}` : "a direct message";
1590 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
1591}