Skip to content
1,595 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 wanted = name.toLowerCase();
868 // By its full name; else, a plugin's skill by its bare name when only one plugin brings it.
869 const matches = this.shelf.filter((skill) => skill.name.toLowerCase() === wanted);
870 const bare = matches.length ? [] : this.shelf.filter((skill) => skill.name.toLowerCase().replace(/^[^:]+:/, "") === wanted);
871 const entry = matches[0] ?? (bare.length === 1 ? bare[0] : undefined);
872 if (!entry) {
873 const names = this.shelf.map((skill) => skill.name).join(", ");
874 return { text: `You have no skill called ${name || "that"}. Your skills: ${names || "none"}.`, outcome: "refused" };
875 }
876 const offered = new Set(this.definitions().map((tool) => tool.name));
877 if (entry.kind === "foundational") {
878 if (file) return { text: `${entry.name} is one of g1t's skills and has no files; its instructions are all there is.`, outcome: "refused" };
879 return { text: skillBlock(entry.skill, offered), outcome: "allowed" };
880 }
881 const stored = this.readSkill ? await this.readSkill(entry.id, entry.version) : null;
882 if (!stored) return { text: `${entry.name} couldn't be read just now. Do the work as you would without it.`, outcome: "error" };
883 return { text: skillText(entry, stored, offered, file), outcome: "allowed" };
884 }
885
886 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
887 const asker = this.audience.asker;
888 if (OUTSIDE_NAMES.has(name)) return this.outside(name, input);
889 if (COMPUTER_NAMES.has(name)) return this.computerCall(name, input);
890 const mcp = this.mcpTools().find((entry) => entry.def.name === name);
891 if (mcp) return this.mcp(mcp, input);
892 if (FOLIO_NAMES.has(name)) {
893 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
894 return this.folios(name, input, asker, this.ports.folios);
895 }
896 if (CODE_NAMES.has(name)) {
897 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
898 if (!this.audience.codeAllowed() || !asker) return this.withheld();
899 return this.code(name, input, asker);
900 }
901 switch (name) {
902 case "use_skill":
903 return this.skill(String(input.name ?? "").trim(), typeof input.file === "string" && input.file.trim() ? input.file.trim() : null);
904 case "search_messages": {
905 const query = String(input.query ?? "").trim();
906 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
907 const found = await this.ports.searchMessages(query);
908 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
909 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
910 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
911 }
912 case "read_thread": {
913 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
914 if (!thread || !thread.length) return this.withheld();
915 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
916 }
917 case "workspace_roster":
918 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
919 case "ask_colleague": {
920 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
921 const question = String(input.question ?? "").trim();
922 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
923 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" };
924 if (this.context.notConsult.includes(handle)) {
925 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
926 }
927 const answer = await this.ports.consult(handle, question.slice(0, 2000));
928 if (!answer.ok) return { text: answer.message, outcome: "refused" };
929 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
930 }
931 default:
932 return this.act(name, input);
933 }
934 }
935
936 /**
937 * What the workspace's artifacts say about `query`, for this person and
938 * this audience, before the agent answers: no tool call, nothing counted
939 * against its tools. Empty when there is no artifacts service or nothing
940 * relevant.
941 */
942 async recall(query: string | null, spaces: string[]): Promise<FolioPassage[]> {
943 const folios = this.ports.folios;
944 const asker = this.audience.asker;
945 if (!folios || !asker || !query) return [];
946 try {
947 return (await folios.recall(asker, this.folioAudience(), query, spaces)) ?? [];
948 } catch (error) {
949 console.error("agents: artifacts recall failed", String(error));
950 return [];
951 }
952 }
953
954 /** Who reads what an agent says here, as the artifacts service takes it. */
955 private folioAudience(): FolioAudience {
956 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
957 }
958
959 /** Whether anyone besides the person who asked reads what is said here. */
960 private othersHere(asker: User): boolean {
961 return this.audience.shared || this.audience.members.some((m) => m.id !== asker.id);
962 }
963
964 private async folios(name: string, input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
965 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
966 const audience = this.folioAudience();
967 switch (name) {
968 case "list_spaces": {
969 const spaces = await folios.spaces(asker, audience);
970 if (spaces === null) return { text: "Spaces couldn't be listed just now.", outcome: "error" };
971 if (!spaces.length) return { text: "There are no spaces everyone here can read.", outcome: "allowed" };
972 return { text: untrusted("list_spaces", spaces.map(spaceLine).join("\n")), outcome: "allowed" };
973 }
974 case "search_artifacts": {
975 const query = text("query", 200);
976 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
977 const kind = text("kind", 20) || null;
978 if (kind && !isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}: it is doc, slides, design or dashboard.`, outcome: "refused" };
979 let spaceId: string | null = null;
980 if (text("space", 200)) {
981 const space = findSpace((await folios.spaces(asker, audience)) ?? [], text("space", 200));
982 if (!space) return this.withheld();
983 spaceId = space.id;
984 }
985 const found = await folios.search(asker, audience, { query, kind: kind as FolioKind | null, space_id: spaceId, project: text("project", 200).toLowerCase() || null });
986 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
987 return { text: untrusted(`search_artifacts "${query}"`, found), outcome: "allowed" };
988 }
989 case "stale_artifacts": {
990 const found = await folios.stale(asker, audience, text("repo", 200).toLowerCase() || null);
991 if (found === null) return { text: "Out-of-date artifacts couldn't be listed just now.", outcome: "error" };
992 return { text: untrusted("stale_artifacts", found), outcome: "allowed" };
993 }
994 case "read_artifact": {
995 const id = folioRef(text("id", 500));
996 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
997 const found = await folios.read(asker, audience, id);
998 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
999 const read = found.value;
1000 if (!this.audience.shared || !read.audience_can_read) this.privateRead = true;
1001 if (!read.audience_can_read) return this.notForEveryone(asker, read.folio, folios, "found");
1002 return { text: untrusted(`read_artifact ${id}`, folioReadText(read)), outcome: "allowed" };
1003 }
1004 case "create_artifact": {
1005 const kind = text("kind", 20) || "doc";
1006 if (!isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}.`, outcome: "refused" };
1007 if (kind !== "doc") return { text: NOT_YET, outcome: "refused" };
1008 const title = text("title", 200);
1009 const template = text("template", 100) || null;
1010 const markdown = String(input.content ?? "").slice(0, 100_000);
1011 if (!title) return { text: "An artifact needs a title.", outcome: "refused" };
1012 if (!markdown.trim() && !template) return { text: "Give its content as Markdown, or a template.", outcome: "refused" };
1013 const parentGiven = text("parent", 500);
1014 const parent = parentGiven ? folioRef(parentGiven) : null;
1015 if (parentGiven && !parent) return { text: "Give the parent doc's id or link.", outcome: "refused" };
1016 const place = await this.whereFor(input.where, asker, folios);
1017 if (!place.ok) return { text: place.message, outcome: "refused" };
1018 const make = (where: FolioWhere) =>
1019 folios.create(asker, { kind, title, markdown: template ? null : markdown, template_id: template, where, parent_id: parent, source: sourceLink(text("source", 2000)) });
1020 let made = await make(place.where);
1021 // The General space by default, unless the person who asked can't add there: then their Private.
1022 if (!made.ok && place.fallback && made.code === "forbidden") made = await make("private");
1023 if (!made.ok) return { text: `It couldn't be made: ${made.message}`, outcome: "refused" };
1024 const ref = made.value;
1025 if (this.othersHere(asker)) {
1026 const check = await folios.read(asker, audience, ref.id).catch(() => null);
1027 if (!check?.ok || !check.value.audience_can_read) return this.notForEveryone(asker, ref, folios, "made");
1028 }
1029 return { text: `Wrote ${ref.title} (${ref.path}, id ${ref.id}). Link it.`, outcome: "allowed" };
1030 }
1031 case "edit_artifact": {
1032 const id = folioRef(text("id", 500));
1033 const markdown = String(input.markdown ?? "").slice(0, 100_000);
1034 if (!id || !markdown.trim()) return { text: "Give the artifact's id or link, and the Markdown.", outcome: "refused" };
1035 const kind = text("target", 20);
1036 const target: DocEditTarget | null =
1037 kind === "append"
1038 ? { kind: "append" }
1039 : kind === "document"
1040 ? { kind: "document" }
1041 : kind === "section" && text("heading", 300)
1042 ? { kind: "section", heading: text("heading", 300) }
1043 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
1044 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
1045 : null;
1046 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
1047 const edit: FolioAgentEdit = { kind: "doc", target, markdown, note: text("note", 300) || null, suggest_only: input.suggest_only === true, marks_current: input.marks_current === true };
1048 const done = await folios.edit(asker, id, edit);
1049 if (!done.ok) return { text: `That didn't work: ${done.message}`, outcome: "refused" };
1050 return { text: editMessage(done.value, !this.notHere.has(done.value.folio.id)), outcome: "allowed" };
1051 }
1052 case "share_artifact": {
1053 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" };
1054 const id = folioRef(text("id", 500));
1055 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
1056 const role = input.role === "view" || input.role === "comment" ? input.role : null;
1057 if (!role) return { text: "You can share to view or comment only. For more, ask the person to use Share.", outcome: "refused" };
1058 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);
1059 const people = named.map((n) => this.audience.members.find((m) => m.username.toLowerCase() === n || m.id === n) ?? n);
1060 const outside = people.filter((p): p is string => typeof p === "string");
1061 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" };
1062 const users = [...new Map((people as User[]).filter((u) => u.id !== asker.id).map((u) => [u.id, u])).values()];
1063 if (!users.length) return { text: "Name who to share it with: people in this conversation besides the person who asked.", outcome: "refused" };
1064 const done = await folios.share(asker, audience, id, users.map((u) => u.id), role);
1065 if (!done.ok) return { text: `It couldn't be shared: ${done.message}`, outcome: "refused" };
1066 return { text: `Shared with ${users.map((u) => `@${u.username}`).join(", ")}: they can ${role} it.`, outcome: "allowed" };
1067 }
1068 case "make_file":
1069 return this.makeFile(input, asker, folios);
1070 default:
1071 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1072 }
1073 }
1074
1075 /**
1076 * A file someone asked for (files.ts), kept with a doc: the one named,
1077 * which the asker must be able to edit, or a new one holding the content.
1078 * Where not everyone here can read that doc, the link goes to the asker
1079 * directly, as for any artifact.
1080 */
1081 private async makeFile(input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
1082 if (!folios.attach) return { text: "There is no tool called make_file here.", outcome: "refused" };
1083 const title = String(input.title ?? "").trim().slice(0, 200);
1084 const made = makeFile({ format: input.format, title, content: input.content, sheets: input.sheets });
1085 if (!made.ok) return { text: made.message, outcome: "refused" };
1086 const file = made.file;
1087 const audience = this.folioAudience();
1088 const given = String(input.artifact ?? "").trim().slice(0, 500);
1089 let ref: FolioRef;
1090 let hidden = false;
1091 if (given) {
1092 const id = folioRef(given);
1093 if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" };
1094 const found = await folios.read(asker, audience, id);
1095 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
1096 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" };
1097 ref = found.value.folio;
1098 if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true;
1099 hidden = !found.value.audience_can_read;
1100 } else {
1101 const place = await this.whereFor(input.where, asker, folios);
1102 if (!place.ok) return { text: place.message, outcome: "refused" };
1103 const body = docBody(file.format, title, input);
1104 const make = (where: FolioWhere) => folios.create(asker, { kind: "doc", title, markdown: body, template_id: null, where, parent_id: null, source: null });
1105 let created = await make(place.where);
1106 if (!created.ok && place.fallback && created.code === "forbidden") created = await make("private");
1107 if (!created.ok) return { text: `The doc to keep it in couldn't be made: ${created.message}`, outcome: "refused" };
1108 ref = created.value;
1109 if (this.othersHere(asker)) {
1110 const check = await folios.read(asker, audience, ref.id).catch(() => null);
1111 hidden = !check?.ok || !check.value.audience_can_read;
1112 }
1113 }
1114 const kept = await folios.attach(asker, ref.id, { name: file.name, content_type: file.content_type, bytes: file.bytes });
1115 if (!kept.ok) return { text: `The file was made but couldn't be kept: ${kept.message}`, outcome: "refused" };
1116 const size = sizeLabel(kept.value.bytes);
1117 // The doc links its file, so whoever opens the doc finds it.
1118 await folios
1119 .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 })
1120 .catch(() => null);
1121 if (hidden) return this.notForEveryone(asker, ref, folios, "made");
1122 return {
1123 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.`,
1124 outcome: "allowed",
1125 };
1126 }
1127
1128 /**
1129 * Where a new artifact goes:
1130 * - a space named by its name or id, among those everyone here can read;
1131 * - "private": the asker's Private;
1132 * - "conversation": Private, plus `view` for this conversation's people;
1133 * - nothing: the conversation in a DM or private channel, and in a public
1134 * channel the General space if the asker can add there (`fallback`:
1135 * their Private if the artifacts service says they can't).
1136 */
1137 private async whereFor(given: unknown, asker: User, folios: FoliosPorts): Promise<{ ok: true; where: FolioWhere; fallback: boolean } | { ok: false; message: string }> {
1138 const named = typeof given === "object" && given !== null ? (given as { space?: unknown }).space : given;
1139 const wanted = typeof named === "string" ? named.trim().slice(0, 200) : "";
1140 const others = this.audience.members.filter((m) => m.id !== asker.id).map((m) => m.id);
1141 const byDefault = async (): Promise<{ ok: true; where: FolioWhere; fallback: boolean }> => {
1142 if (!this.audience.shared) return { ok: true, where: others.length ? { conversation: this.audience.members.map((m) => m.id) } : "private", fallback: false };
1143 const spaces = (await folios.spaces(asker, this.folioAudience())) ?? [];
1144 const general = spaces.find((s) => s.slug === "general") ?? spaces.find((s) => s.name.toLowerCase() === "general");
1145 return general && general.can.suggest ? { ok: true, where: { space_id: general.id }, fallback: true } : { ok: true, where: "private", fallback: false };
1146 };
1147 if (!wanted) return byDefault();
1148 const lower = wanted.toLowerCase();
1149 if (typeof given === "string" && lower === "private") return { ok: true, where: "private", fallback: false };
1150 // A public channel has no list of people to share with: its default instead.
1151 if (typeof given === "string" && lower === "conversation") return byDefault();
1152 const space = findSpace((await folios.spaces(asker, this.folioAudience())) ?? [], wanted);
1153 if (space) return { ok: true, where: { space_id: space.id }, fallback: false };
1154 // An id the asker gave, for a space not everyone here can read: the artifacts service checks it.
1155 if (/^spc_[A-Za-z0-9]+$/.test(wanted)) return { ok: true, where: { space_id: wanted }, fallback: false };
1156 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".` };
1157 }
1158
1159 /**
1160 * An artifact someone here can't read: the link goes to the asker
1161 * directly, and the agent says only that it found or made something,
1162 * never what.
1163 */
1164 private async notForEveryone(asker: User, folio: FolioRef, folios: FoliosPorts, what: "found" | "made"): Promise<ToolResult> {
1165 this.notHere.add(folio.id);
1166 const who = `@${asker.username}`;
1167 const note =
1168 what === "made"
1169 ? "I made this for you. Not everyone in the conversation you asked from can open it, so here is the link:"
1170 : "Here is what you asked about. Not everyone in the conversation you asked from can open it, so here is the link:";
1171 const sent = await folios.sendLink(asker, { title: folio.title, path: folio.path }, note).catch(() => false);
1172 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;`;
1173 const text = sent
1174 ? `${lead} say you ${what} it and that you've sent the link to ${who} directly.`
1175 : what === "made"
1176 ? `${lead} say you made it and that ${who} will find it under Artifacts, in their Private or shared with them.`
1177 : `${lead} say you found it but can't share it here, and ask ${who} to message you directly.`;
1178 return { text, outcome: what === "made" ? "allowed" : "withheld" };
1179 }
1180
1181 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
1182 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1183 const actions = this.actions;
1184 const offered = this.definitions().some((tool) => tool.name === name);
1185 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1186 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
1187 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1188 switch (name) {
1189 case "remember": {
1190 const fact = text("fact", 2000);
1191 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
1192 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
1193 return said(await actions.remember(fact, scope, this.privateRead));
1194 }
1195 case "forget":
1196 return said(await actions.forget(text("id", 100)));
1197 case "draft_issue": {
1198 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
1199 const repo = await this.audience.repo(input.repo);
1200 if (!repo) return this.withheld();
1201 const title = text("title", 200);
1202 const body = text("body", 20_000);
1203 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
1204 const labels = Array.isArray(input.labels)
1205 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
1206 : [];
1207 return said(await actions.draftIssue(repo, { title, body, labels }));
1208 }
1209 case "comment":
1210 case "review_pull": {
1211 const asker = this.audience.asker;
1212 if (!asker || !this.audience.codeAllowed()) return this.withheld();
1213 const repo = await this.audience.repo(input.repo);
1214 if (!repo) return this.withheld();
1215 const number = Math.floor(Number(input.number));
1216 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
1217 const body = text("body", 20_000);
1218 if (name === "comment") {
1219 if (!body) return { text: "Say what to comment.", outcome: "refused" };
1220 return said(await actions.comment!(repo, asker, number, body));
1221 }
1222 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
1223 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
1224 return said(await actions.review!(repo, asker, number, verdict, body));
1225 }
1226 case "start_session": {
1227 const title = text("title", 120);
1228 const goal = text("goal", 8000);
1229 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
1230 return said(await actions.startSession!(title, goal));
1231 }
1232 case "post_update": {
1233 const note = text("text", 2000);
1234 if (!note) return { text: "Say what to post.", outcome: "refused" };
1235 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
1236 this.updates++;
1237 return said(await actions.postUpdate!(note));
1238 }
1239 case "use_subagent": {
1240 const helper = text("name", 60).toLowerCase();
1241 const brief = text("brief", 8000);
1242 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
1243 return said(await actions.useSubagent!(helper, brief));
1244 }
1245 case "bring_in": {
1246 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
1247 const brief = text("brief", 8000);
1248 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
1249 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" };
1250 return said(await actions.bringIn!(handle, brief));
1251 }
1252 case "hand_off": {
1253 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
1254 const brief = text("brief", 8000);
1255 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
1256 if (this.context.notConsult.includes(handle)) {
1257 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" };
1258 }
1259 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" };
1260 const done = await actions.handOff!(handle, brief);
1261 if (done.ok) this.handOffs++;
1262 return said(done);
1263 }
1264 default:
1265 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1266 }
1267 }
1268
1269 /**
1270 * The computer's tools, each gated by its ability (`computer:shell` for
1271 * commands, `computer:files` for files) before the runner is asked. A
1272 * computer that could not wake because the plan refused is said plainly,
1273 * so the agent reports it rather than trying another way.
1274 */
1275 private async computerCall(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1276 const asker = this.audience.asker;
1277 const ports = this.computer;
1278 if (!ports || !asker || !this.definitions().some((tool) => tool.name === name)) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1279 const own = this.abilities("computer").find(({ ability }) => ability.tools.includes(name));
1280 if (!own) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1281 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1282 const failed = (done: { code: string; message: string }, what: string): ToolResult =>
1283 done.code === "payment_required"
1284 ? { text: `Your computer couldn't wake: ${done.message} Say so in your report; don't try another way.`, outcome: "refused" }
1285 : { text: `${what}: ${done.message}`, outcome: done.code === "not_found" || done.code === "invalid" ? "refused" : "error" };
1286 switch (name) {
1287 case "run_command": {
1288 const command = text("command", 8000);
1289 if (!command) return { text: "Give the command to run.", outcome: "refused" };
1290 const cwd = text("cwd", 500) || null;
1291 const timeout = Number.isFinite(Number(input.timeout_seconds)) && Number(input.timeout_seconds) > 0 ? Math.min(1800, Math.floor(Number(input.timeout_seconds))) : null;
1292 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);
1293 if (!gated.ok) return gated.result;
1294 const done = await ports.exec(command, cwd, timeout);
1295 if (!done.ok) return failed(done, "The command couldn't run");
1296 const ran = done.value;
1297 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(", ");
1298 return { text: `$ ${ran.cmd}\n(${how}; in ${ran.cwd})\n${untrusted("run_command on your computer", ran.output || "(no output)")}`, outcome: "allowed" };
1299 }
1300 case "computer_read_file": {
1301 const path = text("path", 1000);
1302 if (!path) return { text: "Give the file's path under /home/agent.", outcome: "refused" };
1303 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);
1304 if (!gated.ok) return gated.result;
1305 const done = await ports.readFile(path);
1306 if (!done.ok) return failed(done, "The file couldn't be read");
1307 return { text: untrusted(`${done.value.path} on your computer`, done.value.text), outcome: "allowed" };
1308 }
1309 case "computer_write_file": {
1310 const path = text("path", 1000);
1311 const body = String(input.text ?? "");
1312 if (!path) return { text: "Give the file's path under /home/agent.", outcome: "refused" };
1313 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);
1314 if (!gated.ok) return gated.result;
1315 const done = await ports.writeFile(path, body);
1316 if (!done.ok) return failed(done, "The file couldn't be written");
1317 return { text: `Wrote ${done.value.bytes} bytes to ${done.value.path}.`, outcome: "allowed" };
1318 }
1319 default:
1320 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1321 }
1322 }
1323
1324 /**
1325 * The rule for one ability, applied before a call: Never refuses and
1326 * names the rule; the asker's own connection must exist; Ask first posts
1327 * the card and tells the agent to wait; "alone when asked for it" is
1328 * alone only when what the person said names the item or the ability.
1329 */
1330 private async gate(found: { ability: Ability; source: AbilitySource }, call: { tool: string; args: Record<string, unknown>; summary: string; keys: string[] }, asker: User): Promise<Gated> {
1331 const { ability, source } = found;
1332 const ports = this.abilityPorts!;
1333 const rule = `${source.name}: ${ability.label}`;
1334 const refused = (text: string): Gated => ({ ok: false, result: { text, outcome: "refused" } });
1335 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.`);
1336 if (ability.level === "never") {
1337 ports.refused({ ability, source, rule: `${ability.id}=never`, call: call.summary });
1338 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.`);
1339 }
1340 if (ability.credentials === "asker" && !(await ports.askerConnected(source.id, asker))) {
1341 const posted = await ports.connect(source, ability);
1342 ports.refused({ ability, source, rule: `${ability.id}=asker-not-connected`, call: call.summary });
1343 return refused(
1344 `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."}`,
1345 );
1346 }
1347 let note: string | null = null;
1348 if (ability.level === "asked") {
1349 if (askedFor(this.said, call.keys)) return { ok: true };
1350 note = `${ability.label} in ${source.name} runs on its own only when asked for it, and this wasn't.`;
1351 } else if (ability.level !== "ask") return { ok: true };
1352 const id = await ports.askFirst({ ability, source, tool: call.tool, args: call.args, summary: call.summary, note });
1353 ports.refused({ ability, source, rule: `${ability.id}=${ability.level}`, call: call.summary });
1354 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.`);
1355 return refused(
1356 `${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." : ""}`,
1357 );
1358 }
1359
1360 /** The ability `tool` on the connector `provider`, among the agent's. */
1361 private integrationAbility(provider: string, tool: string): { ability: Ability; source: AbilitySource } | null {
1362 return this.abilities("integration").find(({ ability, source }) => source.id === provider.toLowerCase() && ability.tools.includes(tool)) ?? null;
1363 }
1364
1365 /** The outside tools: each checked against the agent's abilities for the system that knows the item. */
1366 private async outside(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1367 const asker = this.audience.asker;
1368 const ports = this.abilityPorts;
1369 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" };
1370 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1371 const reference = text("reference", 500);
1372 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" });
1373 switch (name) {
1374 case "request_ability": {
1375 const needs = text("needs", 120).toLowerCase();
1376 const why = text("why", 500);
1377 if (!needs || !why) return { text: "Say what is needed and why.", outcome: "refused" };
1378 const abilityFound = this.abilities("integration").find(({ ability }) => ability.id === needs) ?? this.abilities("mcp").find(({ ability }) => ability.id === needs) ?? null;
1379 const connector = abilityFound ? null : needs.replace(/^integration:/, "").split(":")[0]!;
1380 const posted = await ports.request({ connector, ability: abilityFound?.ability ?? null, source: abilityFound?.source ?? null, why });
1381 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" };
1382 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" };
1383 }
1384 case "lookup_outside": {
1385 if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1386 const found = await ports.lookup(asker, reference);
1387 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1388 const item = found.value;
1389 const own = this.integrationAbility(item.provider, "lookup_outside");
1390 if (!own) return notFound();
1391 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);
1392 if (!gated.ok) return gated.result;
1393 return { text: untrusted(`${own.source.name} ${item.key}`, `${item.key}: ${item.title}${item.status ? ` [${item.status}]` : ""}
1394${item.url}
1395
1396${item.body}`), outcome: "allowed" };
1397 }
1398 case "import_outside": {
1399 if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1400 if (!this.audience.codeAllowed()) return this.withheld();
1401 const repo = await this.audience.repo(input.repo);
1402 if (!repo) return this.withheld();
1403 const found = await ports.lookup(asker, reference);
1404 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1405 const own = this.integrationAbility(found.value.provider, "import_outside");
1406 if (!own) return { text: `Importing from ${found.value.provider} isn't one of your abilities.`, outcome: "refused" };
1407 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);
1408 if (!gated.ok) return gated.result;
1409 const done = await ports.import(asker, repo, reference);
1410 if (!done.ok) return { text: `It couldn't be imported: ${done.message}`, outcome: "refused" };
1411 const path = `/${repo.namespace}/${repo.name}/issues/${done.value.number}`;
1412 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" };
1413 }
1414 case "act_outside": {
1415 const action = input.action === "resolve" ? "resolve" : input.action === "comment" ? "comment" : null;
1416 const body = String(input.text ?? "").trim().slice(0, 8000);
1417 if (!reference || !action) return { text: "Give the item, and whether to comment or resolve.", outcome: "refused" };
1418 if (!body) return { text: "Say what to write.", outcome: "refused" };
1419 const found = await ports.lookup(asker, reference);
1420 if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1421 const item = found.value;
1422 const own = this.abilities("integration").find(({ ability, source }) => source.id === item.provider.toLowerCase() && ability.id.endsWith(`:${action}`)) ?? null;
1423 if (!own) return { text: `${action === "resolve" ? "Resolving" : "Commenting on"} items in ${item.provider} isn't one of your abilities.`, outcome: "refused" };
1424 const summary = action === "resolve" ? `Resolve ${item.key} in ${own.source.name}` : `Comment on ${item.key} in ${own.source.name}`;
1425 const gated = await this.gate(own, { tool: name, args: input, summary, keys: [item.key, reference, action, own.ability.label] }, asker);
1426 if (!gated.ok) return gated.result;
1427 const done = await ports.act(asker, reference, action, body);
1428 if (!done.ok) return { text: `It didn't work: ${done.message}`, outcome: "refused" };
1429 return { text: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} in ${own.source.name} (${done.value.url}).`, outcome: "allowed" };
1430 }
1431 default:
1432 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1433 }
1434 }
1435
1436 /** A tool of one of the agent's MCP servers, gated by its ability. */
1437 private async mcp(entry: { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }, input: Record<string, unknown>): Promise<ToolResult> {
1438 const asker = this.audience.asker;
1439 const ports = this.abilityPorts;
1440 if (!ports || !asker) return { text: `There is no tool called ${entry.def.name} here.`, outcome: "refused" };
1441 const summary = `Call ${entry.tool.name} on ${entry.server.name}`;
1442 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);
1443 if (!gated.ok) return gated.result;
1444 const done = await ports.mcp(entry.server, entry.tool, input);
1445 if (!done.ok) return { text: `${entry.server.name} didn't do it: ${done.message}`, outcome: "error" };
1446 return { text: untrusted(`${entry.server.name} ${entry.tool.name}`, done.value), outcome: "allowed" };
1447 }
1448
1449 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
1450 if (name === "list_repositories") {
1451 const repos = [...(await this.audience.repos()).values()];
1452 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
1453 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
1454 }
1455 if (name === "search_code") {
1456 const query = String(input.query ?? "").trim();
1457 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
1458 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
1459 if (input.repo && !only) return this.withheld();
1460 const allowed = await this.audience.repos();
1461 // Whatever search returns, only hits in allowed repositories come through.
1462 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()));
1463 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
1464 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
1465 }
1466 if (name === "recent_activity") {
1467 const one = input.repo ? await this.audience.repo(input.repo) : null;
1468 if (input.repo && !one) return this.withheld();
1469 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
1470 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
1471 const pulls = await this.ports.recentPulls(repos, viewer);
1472 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
1473 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
1474 }
1475 // The rest name one repository; it must be on the allow-list.
1476 const repo = await this.audience.repo(input.repo);
1477 if (!repo) return this.withheld();
1478 const full = `${repo.namespace}/${repo.name}`;
1479 switch (name) {
1480 case "read_file": {
1481 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
1482 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
1483 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
1484 const file = await this.ports.readFile(repo, viewer, ref, path);
1485 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
1486 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
1487 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
1488 }
1489 case "list_issues": {
1490 const state = input.state === "closed" ? "closed" : "open";
1491 const issues = await this.ports.listIssues(repo, viewer, state);
1492 if (!issues) return this.withheld();
1493 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
1494 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" };
1495 }
1496 case "get_issue": {
1497 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
1498 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
1499 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
1500 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
1501 }
1502 case "get_pull": {
1503 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
1504 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
1505 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
1506 }
1507 default:
1508 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1509 }
1510 }
1511}
1512
1513/** What an agent hears when it asks for a kind that isn't built yet. */
1514const 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.";
1515
1516/**
1517 * A folio id from an id or any artifact link (`/acme/-/artifacts/runbook-fol_…`,
1518 * with or without the site and a query); null when there is none.
1519 */
1520/**
1521 * What a doc made to hold a file says: the Markdown the file was made from
1522 * (less a first heading repeating the title, which the doc has), or a
1523 * preview of a spreadsheet's rows.
1524 */
1525export function docBody(format: MakeFileFormat, title: string, input: Record<string, unknown>): string {
1526 let body = "";
1527 if (format === "xlsx" || format === "csv") {
1528 const read = readSheets(input.sheets);
1529 if (read.ok) body = read.sheets.map((sheet) => `${read.sheets.length > 1 ? `## ${sheet.name}\n\n` : ""}${previewTable(sheet)}`).join("\n\n");
1530 } else {
1531 const markdown = String(input.content ?? "");
1532 const first = /^\s*#\s+(.+?)\s*#*\s*(?:\n|$)/.exec(markdown);
1533 body = (first && first[1]!.trim().toLowerCase() === title.trim().toLowerCase() ? markdown.slice(first[0].length).trimStart() : markdown).slice(0, 100_000);
1534 }
1535 return body.trim() ? body : "The file is attached below.";
1536}
1537
1538export function folioRef(given: string): string | null {
1539 const last = given.trim().split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? "";
1540 return folioIdFrom(last);
1541}
1542
1543/** 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. */
1544export function sourceLink(given: string): { title: string; href: string } | null {
1545 let href = given.trim();
1546 if (!href) return null;
1547 if (/^https?:\/\//i.test(href)) {
1548 try {
1549 const url = new URL(href);
1550 href = `${url.pathname}${url.search}${url.hash}`;
1551 } catch {
1552 return null;
1553 }
1554 }
1555 return href.startsWith("/") && !href.startsWith("//") ? { title: "A conversation", href } : null;
1556}
1557
1558/** A space by its id, address or name (any case), among those given. */
1559function findSpace(spaces: FolioSpaceLine[], given: string): FolioSpaceLine | null {
1560 const wanted = given.trim().toLowerCase();
1561 return spaces.find((s) => s.id === given.trim()) ?? spaces.find((s) => s.slug.toLowerCase() === wanted) ?? spaces.find((s) => s.name.toLowerCase() === wanted) ?? null;
1562}
1563
1564function spaceLine(s: FolioSpaceLine): string {
1565 const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
1566 const projects = s.projects.length ? `; about ${s.projects.join(", ")}` : "";
1567 return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
1568}
1569
1570/** 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). */
1571export function folioReadText(read: FolioAgentRead): string {
1572 const f = read.folio;
1573 const can = read.can.edit ? "you can edit it" : read.can.suggest ? "you can suggest edits" : "you can only read it";
1574 const where = read.space ? `in the ${read.space.name} space` : "not in a space";
1575 const blocks = read.blocks?.length ? `\nTop-level blocks: ${read.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ")}` : "";
1576 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}`;
1577}
1578
1579/** What an edit did, naming the artifact only where everyone here can read it. */
1580function editMessage(result: FolioAgentEditResult, name: boolean): string {
1581 const on = name ? ` on ${result.folio.title} (${result.folio.path})` : "";
1582 switch (result.mode) {
1583 case "applied":
1584 return `Changed${on}. It's in its history as yours.`;
1585 case "suggested":
1586 return `Suggested${on}: people accept or reject it there.${name ? " Link it so they can." : ""}`;
1587 case "proposed":
1588 return `Proposed a change${on}: a person previews and applies it.`;
1589 }
1590}
1591
1592function messageLine(m: FoundMessage): string {
1593 const where = m.channel ? `#${m.channel}` : "a direct message";
1594 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
1595}