Skip to content
1,117 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 { DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, User } from "@g1t/contracts";
22
23import { FOLIO_KINDS, folioIdFrom, isFolioKind } from "../../../packages/contracts/src/folios.ts";
24import type { MakeFileFormat } from "../../../packages/contracts/src/skills.ts";
25import { type Audience, type RepoRef, WITHHELD } from "./audience.ts";
26import { makeFile, previewTable, readSheets, sizeLabel } from "./files.ts";
27
28/** One tool, as the Messages API takes it. */
29export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> };
30
31export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string };
32
33/** What the tools reach outside this module. */
34export interface ToolPorts {
35 readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>;
36 searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>;
37 listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>;
38 getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>;
39 getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>;
40 recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>;
41 /** Chat's own audience rule applies; null when the search failed. */
42 searchMessages(query: string): Promise<FoundMessage[] | null>;
43 /** Null when the audience may not read it (or it does not exist). */
44 readThread(channelId: string, id: string): Promise<FoundMessage[] | null>;
45 roster(viewer: User | null): Promise<string>;
46 consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>;
47 /**
48 * The workspace's artifacts (Artifacts mode), as the artifacts service lets
49 * this agent use them for the person it acts for and everyone who will
50 * read the answer. Absent where there is no artifacts service.
51 */
52 folios?: FoliosPorts;
53}
54
55/** A space as an agent sees it, with what it may do there for the person it acts for. */
56export type FolioSpaceLine = {
57 id: string;
58 slug: string;
59 name: string;
60 description: string | null;
61 kind: string;
62 projects: string[];
63 can: { read: boolean; suggest: boolean; edit: boolean };
64};
65
66/** Where a new artifact goes: a space, its asker's Private, or Private shared with the conversation's people. */
67export type FolioWhere = { space_id: string } | "private" | { conversation: string[] };
68
69/** A call's answer: the value, or the artifacts service's error code and sentence. */
70export type FolioDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string };
71
72/**
73 * Artifacts, as an agent uses them. Every call names the person it acts
74 * for, and the reads also who reads the answer; the artifacts service checks
75 * both.
76 */
77export interface FoliosPorts {
78 /** Spaces everyone here can read; null when the artifacts service couldn't answer. */
79 spaces(viewer: User, audience: FolioAudience): Promise<FolioSpaceLine[] | null>;
80 /** Passages closest in meaning to `query`; `spaces` (required reading) first. Null when the artifacts service couldn't answer. */
81 recall(viewer: User, audience: FolioAudience, query: string, spaces: string[], kinds?: FolioKind[]): Promise<FolioPassage[] | null>;
82 /** Artifacts matching `query` (words and meaning), as lines with links. */
83 search(viewer: User, audience: FolioAudience, input: { query: string; kind: FolioKind | null; space_id: string | null; project: string | null }): Promise<string | null>;
84 read(viewer: User, audience: FolioAudience, folioId: string): Promise<FolioDone<FolioAgentRead>>;
85 /** Artifacts possibly out of date since code they cite changed. */
86 stale(viewer: User, audience: FolioAudience, repo: string | null): Promise<string | null>;
87 create(
88 viewer: User,
89 input: { kind: FolioKind; title: string; markdown: string | null; template_id: string | null; where: FolioWhere; parent_id: string | null; source: { title: string; href: string } | null },
90 ): Promise<FolioDone<FolioRef>>;
91 edit(viewer: User, folioId: string, edit: FolioAgentEdit): Promise<FolioDone<FolioAgentEditResult>>;
92 /** `view` or `comment` for people already in this conversation. */
93 share(viewer: User, audience: FolioAudience, folioId: string, userIds: string[], role: "view" | "comment"): Promise<FolioDone<null>>;
94 /**
95 * Keeps a file the agent made with a doc the asker can edit; `url` is
96 * where it is served (on the usercontent origin). Absent where files
97 * can't be kept.
98 */
99 attach?(viewer: User, folioId: string, file: { name: string; content_type: string; bytes: Uint8Array }): Promise<FolioDone<{ url: string; name: string; bytes: number }>>;
100 /** Sends the asker a link directly, as a message from the agent in their DM with it; false when it couldn't. */
101 sendLink(asker: User, link: { title: string; path: string }, note: string): Promise<boolean>;
102}
103
104/**
105 * What an agent may do, beyond reading: remember, file an issue for the
106 * person who asked, and start or shape work. Each is checked here before it
107 * runs (the audience, the asker, the hop limit) and again by the service
108 * that does it.
109 */
110export interface ActionPorts {
111 /**
112 * `onlyForAsker`: this turn read an artifact the whole workspace can't
113 * read, so the fact is kept for the person who asked alone.
114 */
115 remember(body: string, scope: "workspace" | "channel" | "person" | null, onlyForAsker?: boolean): Promise<{ ok: boolean; message: string }>;
116 forget(id: string): Promise<{ ok: boolean; message: string }>;
117 /**
118 * Posts a draft issue as a card in the conversation, with File issue and
119 * Discard: whoever presses File files it as themselves, if they can read
120 * the repository. Nothing is filed by the agent.
121 */
122 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
123 /**
124 * Comments on an issue or pull request, or reviews a pull request, as the
125 * agent on behalf of the person who asked. Reviews are advisory: they
126 * never count toward required approvals.
127 */
128 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
129 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
130 /** From chat: spins off a session for real work. */
131 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
132 /** In a session: a short progress note in its thread. */
133 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
134 /** In a session: one of the agent's own subagents takes part of the work. */
135 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
136 /** In a session: a colleague works on part of it, paid from this session's budget. */
137 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
138 /** From chat: a colleague takes the work on for the person who asked, here or in a group message. */
139 handOff?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
140}
141
142/** The most hand-offs one reply makes. */
143export const MAX_HAND_OFFS = 2;
144
145/** The most tool calls one reply makes. */
146export const MAX_TOOL_CALLS = 8;
147/** The most tool calls one step of a session makes. */
148export const MAX_SESSION_TOOL_CALLS = 24;
149/** The most of a file or result an answer is given, in characters. */
150const MAX_RESULT = 20_000;
151
152export type ToolCall = {
153 tool: string;
154 /** Its arguments, with long text cut, as recorded. */
155 args: string;
156 outcome: "allowed" | "withheld" | "refused" | "error";
157 bytes: number;
158};
159
160export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
161
162/**
163 * Text a tool read, marked as data. Anything in it that looks like the
164 * closing mark is defused, so content can't end the block early.
165 */
166export function untrusted(source: string, content: string): string {
167 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
168 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
169 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
170}
171
172/** Arguments as recorded: every string cut to 120 characters. */
173export function redact(args: unknown): string {
174 const cut = (value: unknown): unknown => {
175 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
176 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
177 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
178 return value;
179 };
180 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
181}
182
183const CODE_TOOLS: ToolDef[] = [
184 {
185 name: "list_repositories",
186 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
187 input_schema: { type: "object", properties: {} },
188 },
189 {
190 name: "search_code",
191 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
192 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
193 },
194 {
195 name: "read_file",
196 description: "Read a file from a repository, at its default branch or a ref.",
197 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
198 },
199 {
200 name: "list_issues",
201 description: "A repository's newest issues, open by default.",
202 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
203 },
204 {
205 name: "get_issue",
206 description: "One issue with its comments.",
207 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
208 },
209 {
210 name: "get_pull",
211 description: "One pull request: what it changes, its status and checks.",
212 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
213 },
214 {
215 name: "recent_activity",
216 description: "Recently merged and open pull requests, in one repository or across those you can read.",
217 input_schema: { type: "object", properties: { repo: { type: "string" } } },
218 },
219];
220
221const CHAT_TOOLS: ToolDef[] = [
222 {
223 name: "search_messages",
224 description: "Search chat messages this conversation's people can all read.",
225 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
226 },
227 {
228 name: "read_thread",
229 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
230 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
231 },
232 {
233 name: "workspace_roster",
234 description: "The workspace's people and agents: names, teams, titles and roles.",
235 input_schema: { type: "object", properties: {} },
236 },
237];
238
239const ASK_COLLEAGUE: ToolDef = {
240 name: "ask_colleague",
241 description:
242 "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.",
243 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
244};
245
246const HAND_OFF: ToolDef = {
247 name: "hand_off",
248 description:
249 "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.",
250 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
251};
252
253const REMEMBER: ToolDef = {
254 name: "remember",
255 description:
256 "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.",
257 input_schema: {
258 type: "object",
259 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
260 required: ["fact"],
261 },
262};
263
264const FORGET: ToolDef = {
265 name: "forget",
266 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
267 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
268};
269
270const DRAFT_ISSUE: ToolDef = {
271 name: "draft_issue",
272 description:
273 "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.",
274 input_schema: {
275 type: "object",
276 properties: {
277 repo: { type: "string" },
278 title: { type: "string" },
279 body: { type: "string" },
280 labels: { type: "array", items: { type: "string" } },
281 },
282 required: ["repo", "title", "body"],
283 },
284};
285
286const COMMENT: ToolDef = {
287 name: "comment",
288 description:
289 "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.",
290 input_schema: {
291 type: "object",
292 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
293 required: ["repo", "number", "body"],
294 },
295};
296
297const REVIEW_PULL: ToolDef = {
298 name: "review_pull",
299 description:
300 "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.",
301 input_schema: {
302 type: "object",
303 properties: {
304 repo: { type: "string" },
305 number: { type: "integer" },
306 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
307 body: { type: "string" },
308 },
309 required: ["repo", "number", "verdict", "body"],
310 },
311};
312
313const START_SESSION: ToolDef = {
314 name: "start_session",
315 description:
316 "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.",
317 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
318};
319
320const POST_UPDATE: ToolDef = {
321 name: "post_update",
322 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.",
323 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
324};
325
326const USE_SUBAGENT: ToolDef = {
327 name: "use_subagent",
328 description:
329 "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.",
330 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
331};
332
333const BRING_IN: ToolDef = {
334 name: "bring_in",
335 description:
336 "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.",
337 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
338};
339
340/** What every artifact tool's description starts from, so the model never mixes them up with workflow run artifacts. */
341const ARTIFACTS =
342 "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.";
343
344const FOLIO_TOOLS: ToolDef[] = [
345 {
346 name: "search_artifacts",
347 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.`,
348 input_schema: {
349 type: "object",
350 properties: {
351 query: { type: "string" },
352 kind: { type: "string", enum: [...FOLIO_KINDS] },
353 space: { type: "string", description: "A space's name or id." },
354 project: { type: "string", description: "A repository, `workspace/name`." },
355 },
356 required: ["query"],
357 },
358 },
359 {
360 name: "read_artifact",
361 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.`,
362 input_schema: { type: "object", properties: { id: { type: "string", description: "Its id or link." } }, required: ["id"] },
363 },
364 {
365 name: "list_spaces",
366 description: `${ARTIFACTS} The spaces whose artifacts everyone in this conversation can read, with what you may do in each (read, suggest, edit).`,
367 input_schema: { type: "object", properties: {} },
368 },
369 {
370 name: "stale_artifacts",
371 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.`,
372 input_schema: { type: "object", properties: { repo: { type: "string" } } },
373 },
374];
375
376const FOLIO_WRITE_TOOLS: ToolDef[] = [
377 {
378 name: "create_artifact",
379 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.`,
380 input_schema: {
381 type: "object",
382 properties: {
383 kind: { type: "string", enum: [...FOLIO_KINDS] },
384 title: { type: "string" },
385 content: { type: "string", description: "Markdown." },
386 template: { type: "string", description: "A template id, instead of content." },
387 where: {
388 description: '{ "space": "<name or id>" }, "private" or "conversation".',
389 anyOf: [
390 { type: "string", enum: ["private", "conversation"] },
391 { type: "object", properties: { space: { type: "string" } }, required: ["space"] },
392 ],
393 },
394 parent: { type: "string", description: "A doc to put it under: its id or link." },
395 source: { type: "string", description: "The link of the thread it was written up from." },
396 },
397 required: ["kind", "title"],
398 },
399 },
400 {
401 name: "edit_artifact",
402 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.`,
403 input_schema: {
404 type: "object",
405 properties: {
406 id: { type: "string", description: "Its id or link." },
407 target: { type: "string", enum: ["append", "section", "blocks", "document"] },
408 heading: { type: "string", description: "For target section: the heading's text." },
409 from_block: { type: "string" },
410 to_block: { type: "string" },
411 markdown: { type: "string" },
412 note: { type: "string", description: "Why, in a line, for the history or the suggestion." },
413 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." },
414 suggest_only: { type: "boolean", description: "Suggest even where you could edit." },
415 },
416 required: ["id", "target", "markdown"],
417 },
418 },
419 {
420 name: "share_artifact",
421 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.`,
422 input_schema: {
423 type: "object",
424 properties: {
425 id: { type: "string", description: "Its id or link." },
426 people: { type: "array", items: { type: "string" }, description: "Usernames of people in this conversation." },
427 role: { type: "string", enum: ["view", "comment"] },
428 },
429 required: ["id", "people", "role"],
430 },
431 },
432];
433
434/**
435 * Files people asked for (the Documents, Data and Files and media skills):
436 * written here, kept with a doc the person who asked owns or can edit, and
437 * served from the usercontent origin like an upload.
438 */
439const MAKE_FILE: ToolDef = {
440 name: "make_file",
441 description:
442 '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.',
443 input_schema: {
444 type: "object",
445 properties: {
446 format: { type: "string", enum: ["pdf", "docx", "xlsx", "csv", "md"] },
447 title: { type: "string" },
448 content: { type: "string", description: "Markdown, for pdf, docx and md." },
449 sheets: {
450 type: "array",
451 description: "For xlsx and csv.",
452 items: {
453 type: "object",
454 properties: { name: { type: "string" }, rows: { type: "array", items: { type: "array", items: {} } } },
455 required: ["rows"],
456 },
457 },
458 artifact: { type: "string", description: "A doc to attach it to: its id or link. Left out: a new doc." },
459 where: {
460 description: '{ "space": "<name or id>" }, "private" or "conversation", for a new doc.',
461 anyOf: [{ type: "string" }, { type: "object", properties: { space: { type: "string" } }, required: ["space"] }],
462 },
463 },
464 required: ["format", "title"],
465 },
466};
467
468const FOLIO_NAMES = new Set([...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE].map((tool) => tool.name));
469
470/** Every tool an agent may be offered, by name: what skills may name (@g1t/contracts skills.ts). */
471export const TOOL_NAMES: ReadonlySet<string> = new Set(
472 [...CODE_TOOLS, ...CHAT_TOOLS, ...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE, ASK_COLLEAGUE, HAND_OFF, REMEMBER, FORGET, DRAFT_ISSUE, COMMENT, REVIEW_PULL, START_SESSION, POST_UPDATE, USE_SUBAGENT, BRING_IN].map((tool) => tool.name),
473);
474
475const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
476
477export type ToolContext = {
478 /** The agent replying. */
479 agentId: string;
480 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
481 notConsult: string[];
482 /** Hops so far: a consult is one more, and none is offered at the limit. */
483 hops: number;
484 maxHops: number;
485 /** Whether this is a session's step (more calls, session tools) or a reply. */
486 session?: boolean;
487 /** Told of every call as it is made, for a session's transcript. */
488 onCall?: (call: ToolCall) => void;
489};
490
491export class ToolBox {
492 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
493 readonly calls: ToolCall[];
494 private readonly audience: Audience;
495 private readonly ports: ToolPorts;
496 private readonly context: ToolContext;
497
498 private readonly actions: ActionPorts | null;
499 /** Updates posted in this step. */
500 private updates = 0;
501 /** Hand-offs made in this reply. */
502 private handOffs = 0;
503 /** Artifacts read or made in this turn that not everyone here can read: never named here. */
504 private readonly notHere = new Set<string>();
505 /**
506 * Whether this turn read an artifact the whole workspace can't: then what
507 * it remembers is kept for the person who asked alone.
508 */
509 private privateRead = false;
510
511 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
512 this.audience = audience;
513 this.ports = ports;
514 this.context = context;
515 this.calls = calls;
516 this.actions = actions;
517 }
518
519 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
520 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
521 return new ToolBox(this.audience, ports, context, this.calls);
522 }
523
524 /** The most calls this box makes. */
525 get maxCalls(): number {
526 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
527 }
528
529 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
530 private canFile(): boolean {
531 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
532 }
533
534 /**
535 * The tools offered: no code tools for an audience that can't read code,
536 * no consults or hand-offs at the hop limit, session tools only in a
537 * session, and a spin-off only from chat.
538 */
539 definitions(): ToolDef[] {
540 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
541 const actions = this.actions;
542 return [
543 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
544 ...CHAT_TOOLS,
545 // Artifacts are for everyone, Code or not: the artifacts service decides what this person and audience can read.
546 ...(this.ports.folios && this.audience.asker ? FOLIO_TOOLS : []),
547 ...(this.ports.folios && this.audience.asker && actions ? FOLIO_WRITE_TOOLS : []),
548 ...(this.ports.folios?.attach && this.audience.asker && actions ? [MAKE_FILE] : []),
549 ...(roomForHop ? [ASK_COLLEAGUE] : []),
550 ...(actions ? [REMEMBER, FORGET] : []),
551 ...(this.canFile() ? [DRAFT_ISSUE] : []),
552 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
553 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
554 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
555 ...(actions?.handOff && !this.context.session && roomForHop ? [HAND_OFF] : []),
556 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
557 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
558 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
559 ];
560 }
561
562 /** Whether another call may be made. */
563 get spent(): boolean {
564 return this.calls.length >= this.maxCalls;
565 }
566
567 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
568 const result = await this.attempt(name, input);
569 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
570 this.calls.push(call);
571 this.context.onCall?.(call);
572 return result;
573 }
574
575 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
576 let result: ToolResult;
577 const what = this.context.session ? "step" : "reply";
578 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
579 else {
580 try {
581 result = await this.dispatch(name, input ?? {});
582 } catch (error) {
583 console.error("agents: a tool failed", name, String(error));
584 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
585 }
586 }
587 return result;
588 }
589
590 private withheld(): ToolResult {
591 return { text: WITHHELD, outcome: "withheld" };
592 }
593
594 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
595 const asker = this.audience.asker;
596 if (FOLIO_NAMES.has(name)) {
597 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
598 return this.folios(name, input, asker, this.ports.folios);
599 }
600 if (CODE_NAMES.has(name)) {
601 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
602 if (!this.audience.codeAllowed() || !asker) return this.withheld();
603 return this.code(name, input, asker);
604 }
605 switch (name) {
606 case "search_messages": {
607 const query = String(input.query ?? "").trim();
608 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
609 const found = await this.ports.searchMessages(query);
610 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
611 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
612 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
613 }
614 case "read_thread": {
615 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
616 if (!thread || !thread.length) return this.withheld();
617 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
618 }
619 case "workspace_roster":
620 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
621 case "ask_colleague": {
622 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
623 const question = String(input.question ?? "").trim();
624 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
625 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" };
626 if (this.context.notConsult.includes(handle)) {
627 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
628 }
629 const answer = await this.ports.consult(handle, question.slice(0, 2000));
630 if (!answer.ok) return { text: answer.message, outcome: "refused" };
631 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
632 }
633 default:
634 return this.act(name, input);
635 }
636 }
637
638 /**
639 * What the workspace's artifacts say about `query`, for this person and
640 * this audience, before the agent answers: no tool call, nothing counted
641 * against its tools. Empty when there is no artifacts service or nothing
642 * relevant.
643 */
644 async recall(query: string | null, spaces: string[]): Promise<FolioPassage[]> {
645 const folios = this.ports.folios;
646 const asker = this.audience.asker;
647 if (!folios || !asker || !query) return [];
648 try {
649 return (await folios.recall(asker, this.folioAudience(), query, spaces)) ?? [];
650 } catch (error) {
651 console.error("agents: artifacts recall failed", String(error));
652 return [];
653 }
654 }
655
656 /** Who reads what an agent says here, as the artifacts service takes it. */
657 private folioAudience(): FolioAudience {
658 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
659 }
660
661 /** Whether anyone besides the person who asked reads what is said here. */
662 private othersHere(asker: User): boolean {
663 return this.audience.shared || this.audience.members.some((m) => m.id !== asker.id);
664 }
665
666 private async folios(name: string, input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
667 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
668 const audience = this.folioAudience();
669 switch (name) {
670 case "list_spaces": {
671 const spaces = await folios.spaces(asker, audience);
672 if (spaces === null) return { text: "Spaces couldn't be listed just now.", outcome: "error" };
673 if (!spaces.length) return { text: "There are no spaces everyone here can read.", outcome: "allowed" };
674 return { text: untrusted("list_spaces", spaces.map(spaceLine).join("\n")), outcome: "allowed" };
675 }
676 case "search_artifacts": {
677 const query = text("query", 200);
678 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
679 const kind = text("kind", 20) || null;
680 if (kind && !isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}: it is doc, slides, design or dashboard.`, outcome: "refused" };
681 let spaceId: string | null = null;
682 if (text("space", 200)) {
683 const space = findSpace((await folios.spaces(asker, audience)) ?? [], text("space", 200));
684 if (!space) return this.withheld();
685 spaceId = space.id;
686 }
687 const found = await folios.search(asker, audience, { query, kind: kind as FolioKind | null, space_id: spaceId, project: text("project", 200).toLowerCase() || null });
688 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
689 return { text: untrusted(`search_artifacts "${query}"`, found), outcome: "allowed" };
690 }
691 case "stale_artifacts": {
692 const found = await folios.stale(asker, audience, text("repo", 200).toLowerCase() || null);
693 if (found === null) return { text: "Out-of-date artifacts couldn't be listed just now.", outcome: "error" };
694 return { text: untrusted("stale_artifacts", found), outcome: "allowed" };
695 }
696 case "read_artifact": {
697 const id = folioRef(text("id", 500));
698 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
699 const found = await folios.read(asker, audience, id);
700 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
701 const read = found.value;
702 if (!this.audience.shared || !read.audience_can_read) this.privateRead = true;
703 if (!read.audience_can_read) return this.notForEveryone(asker, read.folio, folios, "found");
704 return { text: untrusted(`read_artifact ${id}`, folioReadText(read)), outcome: "allowed" };
705 }
706 case "create_artifact": {
707 const kind = text("kind", 20) || "doc";
708 if (!isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}.`, outcome: "refused" };
709 if (kind !== "doc") return { text: NOT_YET, outcome: "refused" };
710 const title = text("title", 200);
711 const template = text("template", 100) || null;
712 const markdown = String(input.content ?? "").slice(0, 100_000);
713 if (!title) return { text: "An artifact needs a title.", outcome: "refused" };
714 if (!markdown.trim() && !template) return { text: "Give its content as Markdown, or a template.", outcome: "refused" };
715 const parentGiven = text("parent", 500);
716 const parent = parentGiven ? folioRef(parentGiven) : null;
717 if (parentGiven && !parent) return { text: "Give the parent doc's id or link.", outcome: "refused" };
718 const place = await this.whereFor(input.where, asker, folios);
719 if (!place.ok) return { text: place.message, outcome: "refused" };
720 const make = (where: FolioWhere) =>
721 folios.create(asker, { kind, title, markdown: template ? null : markdown, template_id: template, where, parent_id: parent, source: sourceLink(text("source", 2000)) });
722 let made = await make(place.where);
723 // The General space by default, unless the person who asked can't add there: then their Private.
724 if (!made.ok && place.fallback && made.code === "forbidden") made = await make("private");
725 if (!made.ok) return { text: `It couldn't be made: ${made.message}`, outcome: "refused" };
726 const ref = made.value;
727 if (this.othersHere(asker)) {
728 const check = await folios.read(asker, audience, ref.id).catch(() => null);
729 if (!check?.ok || !check.value.audience_can_read) return this.notForEveryone(asker, ref, folios, "made");
730 }
731 return { text: `Wrote ${ref.title} (${ref.path}, id ${ref.id}). Link it.`, outcome: "allowed" };
732 }
733 case "edit_artifact": {
734 const id = folioRef(text("id", 500));
735 const markdown = String(input.markdown ?? "").slice(0, 100_000);
736 if (!id || !markdown.trim()) return { text: "Give the artifact's id or link, and the Markdown.", outcome: "refused" };
737 const kind = text("target", 20);
738 const target: DocEditTarget | null =
739 kind === "append"
740 ? { kind: "append" }
741 : kind === "document"
742 ? { kind: "document" }
743 : kind === "section" && text("heading", 300)
744 ? { kind: "section", heading: text("heading", 300) }
745 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
746 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
747 : null;
748 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
749 const edit: FolioAgentEdit = { kind: "doc", target, markdown, note: text("note", 300) || null, suggest_only: input.suggest_only === true, marks_current: input.marks_current === true };
750 const done = await folios.edit(asker, id, edit);
751 if (!done.ok) return { text: `That didn't work: ${done.message}`, outcome: "refused" };
752 return { text: editMessage(done.value, !this.notHere.has(done.value.folio.id)), outcome: "allowed" };
753 }
754 case "share_artifact": {
755 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" };
756 const id = folioRef(text("id", 500));
757 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
758 const role = input.role === "view" || input.role === "comment" ? input.role : null;
759 if (!role) return { text: "You can share to view or comment only. For more, ask the person to use Share.", outcome: "refused" };
760 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);
761 const people = named.map((n) => this.audience.members.find((m) => m.username.toLowerCase() === n || m.id === n) ?? n);
762 const outside = people.filter((p): p is string => typeof p === "string");
763 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" };
764 const users = [...new Map((people as User[]).filter((u) => u.id !== asker.id).map((u) => [u.id, u])).values()];
765 if (!users.length) return { text: "Name who to share it with: people in this conversation besides the person who asked.", outcome: "refused" };
766 const done = await folios.share(asker, audience, id, users.map((u) => u.id), role);
767 if (!done.ok) return { text: `It couldn't be shared: ${done.message}`, outcome: "refused" };
768 return { text: `Shared with ${users.map((u) => `@${u.username}`).join(", ")}: they can ${role} it.`, outcome: "allowed" };
769 }
770 case "make_file":
771 return this.makeFile(input, asker, folios);
772 default:
773 return { text: `There is no tool called ${name}.`, outcome: "refused" };
774 }
775 }
776
777 /**
778 * A file someone asked for (files.ts), kept with a doc: the one named,
779 * which the asker must be able to edit, or a new one holding the content.
780 * Where not everyone here can read that doc, the link goes to the asker
781 * directly, as for any artifact.
782 */
783 private async makeFile(input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
784 if (!folios.attach) return { text: "There is no tool called make_file here.", outcome: "refused" };
785 const title = String(input.title ?? "").trim().slice(0, 200);
786 const made = makeFile({ format: input.format, title, content: input.content, sheets: input.sheets });
787 if (!made.ok) return { text: made.message, outcome: "refused" };
788 const file = made.file;
789 const audience = this.folioAudience();
790 const given = String(input.artifact ?? "").trim().slice(0, 500);
791 let ref: FolioRef;
792 let hidden = false;
793 if (given) {
794 const id = folioRef(given);
795 if (!id) return { text: "Give the doc's id (fol_…) or its link, or leave artifact out for a new doc.", outcome: "refused" };
796 const found = await folios.read(asker, audience, id);
797 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
798 if (!found.value.can.edit) return { text: "You can't edit that doc for them, so nothing can be attached to it. Leave artifact out to make a new doc.", outcome: "refused" };
799 ref = found.value.folio;
800 if (!this.audience.shared || !found.value.audience_can_read) this.privateRead = true;
801 hidden = !found.value.audience_can_read;
802 } else {
803 const place = await this.whereFor(input.where, asker, folios);
804 if (!place.ok) return { text: place.message, outcome: "refused" };
805 const body = docBody(file.format, title, input);
806 const make = (where: FolioWhere) => folios.create(asker, { kind: "doc", title, markdown: body, template_id: null, where, parent_id: null, source: null });
807 let created = await make(place.where);
808 if (!created.ok && place.fallback && created.code === "forbidden") created = await make("private");
809 if (!created.ok) return { text: `The doc to keep it in couldn't be made: ${created.message}`, outcome: "refused" };
810 ref = created.value;
811 if (this.othersHere(asker)) {
812 const check = await folios.read(asker, audience, ref.id).catch(() => null);
813 hidden = !check?.ok || !check.value.audience_can_read;
814 }
815 }
816 const kept = await folios.attach(asker, ref.id, { name: file.name, content_type: file.content_type, bytes: file.bytes });
817 if (!kept.ok) return { text: `The file was made but couldn't be kept: ${kept.message}`, outcome: "refused" };
818 const size = sizeLabel(kept.value.bytes);
819 // The doc links its file, so whoever opens the doc finds it.
820 await folios
821 .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 })
822 .catch(() => null);
823 if (hidden) return this.notForEveryone(asker, ref, folios, "made");
824 return {
825 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.`,
826 outcome: "allowed",
827 };
828 }
829
830 /**
831 * Where a new artifact goes:
832 * - a space named by its name or id, among those everyone here can read;
833 * - "private": the asker's Private;
834 * - "conversation": Private, plus `view` for this conversation's people;
835 * - nothing: the conversation in a DM or private channel, and in a public
836 * channel the General space if the asker can add there (`fallback`:
837 * their Private if the artifacts service says they can't).
838 */
839 private async whereFor(given: unknown, asker: User, folios: FoliosPorts): Promise<{ ok: true; where: FolioWhere; fallback: boolean } | { ok: false; message: string }> {
840 const named = typeof given === "object" && given !== null ? (given as { space?: unknown }).space : given;
841 const wanted = typeof named === "string" ? named.trim().slice(0, 200) : "";
842 const others = this.audience.members.filter((m) => m.id !== asker.id).map((m) => m.id);
843 const byDefault = async (): Promise<{ ok: true; where: FolioWhere; fallback: boolean }> => {
844 if (!this.audience.shared) return { ok: true, where: others.length ? { conversation: this.audience.members.map((m) => m.id) } : "private", fallback: false };
845 const spaces = (await folios.spaces(asker, this.folioAudience())) ?? [];
846 const general = spaces.find((s) => s.slug === "general") ?? spaces.find((s) => s.name.toLowerCase() === "general");
847 return general && general.can.suggest ? { ok: true, where: { space_id: general.id }, fallback: true } : { ok: true, where: "private", fallback: false };
848 };
849 if (!wanted) return byDefault();
850 const lower = wanted.toLowerCase();
851 if (typeof given === "string" && lower === "private") return { ok: true, where: "private", fallback: false };
852 // A public channel has no list of people to share with: its default instead.
853 if (typeof given === "string" && lower === "conversation") return byDefault();
854 const space = findSpace((await folios.spaces(asker, this.folioAudience())) ?? [], wanted);
855 if (space) return { ok: true, where: { space_id: space.id }, fallback: false };
856 // An id the asker gave, for a space not everyone here can read: the artifacts service checks it.
857 if (/^spc_[A-Za-z0-9]+$/.test(wanted)) return { ok: true, where: { space_id: wanted }, fallback: false };
858 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".` };
859 }
860
861 /**
862 * An artifact someone here can't read: the link goes to the asker
863 * directly, and the agent says only that it found or made something,
864 * never what.
865 */
866 private async notForEveryone(asker: User, folio: FolioRef, folios: FoliosPorts, what: "found" | "made"): Promise<ToolResult> {
867 this.notHere.add(folio.id);
868 const who = `@${asker.username}`;
869 const note =
870 what === "made"
871 ? "I made this for you. Not everyone in the conversation you asked from can open it, so here is the link:"
872 : "Here is what you asked about. Not everyone in the conversation you asked from can open it, so here is the link:";
873 const sent = await folios.sendLink(asker, { title: folio.title, path: folio.path }, note).catch(() => false);
874 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;`;
875 const text = sent
876 ? `${lead} say you ${what} it and that you've sent the link to ${who} directly.`
877 : what === "made"
878 ? `${lead} say you made it and that ${who} will find it under Artifacts, in their Private or shared with them.`
879 : `${lead} say you found it but can't share it here, and ask ${who} to message you directly.`;
880 return { text, outcome: what === "made" ? "allowed" : "withheld" };
881 }
882
883 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
884 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
885 const actions = this.actions;
886 const offered = this.definitions().some((tool) => tool.name === name);
887 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
888 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
889 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
890 switch (name) {
891 case "remember": {
892 const fact = text("fact", 2000);
893 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
894 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
895 return said(await actions.remember(fact, scope, this.privateRead));
896 }
897 case "forget":
898 return said(await actions.forget(text("id", 100)));
899 case "draft_issue": {
900 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
901 const repo = await this.audience.repo(input.repo);
902 if (!repo) return this.withheld();
903 const title = text("title", 200);
904 const body = text("body", 20_000);
905 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
906 const labels = Array.isArray(input.labels)
907 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
908 : [];
909 return said(await actions.draftIssue(repo, { title, body, labels }));
910 }
911 case "comment":
912 case "review_pull": {
913 const asker = this.audience.asker;
914 if (!asker || !this.audience.codeAllowed()) return this.withheld();
915 const repo = await this.audience.repo(input.repo);
916 if (!repo) return this.withheld();
917 const number = Math.floor(Number(input.number));
918 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
919 const body = text("body", 20_000);
920 if (name === "comment") {
921 if (!body) return { text: "Say what to comment.", outcome: "refused" };
922 return said(await actions.comment!(repo, asker, number, body));
923 }
924 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
925 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
926 return said(await actions.review!(repo, asker, number, verdict, body));
927 }
928 case "start_session": {
929 const title = text("title", 120);
930 const goal = text("goal", 8000);
931 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
932 return said(await actions.startSession!(title, goal));
933 }
934 case "post_update": {
935 const note = text("text", 2000);
936 if (!note) return { text: "Say what to post.", outcome: "refused" };
937 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
938 this.updates++;
939 return said(await actions.postUpdate!(note));
940 }
941 case "use_subagent": {
942 const helper = text("name", 60).toLowerCase();
943 const brief = text("brief", 8000);
944 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
945 return said(await actions.useSubagent!(helper, brief));
946 }
947 case "bring_in": {
948 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
949 const brief = text("brief", 8000);
950 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
951 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" };
952 return said(await actions.bringIn!(handle, brief));
953 }
954 case "hand_off": {
955 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
956 const brief = text("brief", 8000);
957 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
958 if (this.context.notConsult.includes(handle)) {
959 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" };
960 }
961 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" };
962 const done = await actions.handOff!(handle, brief);
963 if (done.ok) this.handOffs++;
964 return said(done);
965 }
966 default:
967 return { text: `There is no tool called ${name}.`, outcome: "refused" };
968 }
969 }
970
971 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
972 if (name === "list_repositories") {
973 const repos = [...(await this.audience.repos()).values()];
974 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
975 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
976 }
977 if (name === "search_code") {
978 const query = String(input.query ?? "").trim();
979 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
980 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
981 if (input.repo && !only) return this.withheld();
982 const allowed = await this.audience.repos();
983 // Whatever search returns, only hits in allowed repositories come through.
984 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()));
985 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
986 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
987 }
988 if (name === "recent_activity") {
989 const one = input.repo ? await this.audience.repo(input.repo) : null;
990 if (input.repo && !one) return this.withheld();
991 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
992 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
993 const pulls = await this.ports.recentPulls(repos, viewer);
994 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
995 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
996 }
997 // The rest name one repository; it must be on the allow-list.
998 const repo = await this.audience.repo(input.repo);
999 if (!repo) return this.withheld();
1000 const full = `${repo.namespace}/${repo.name}`;
1001 switch (name) {
1002 case "read_file": {
1003 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
1004 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
1005 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
1006 const file = await this.ports.readFile(repo, viewer, ref, path);
1007 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
1008 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
1009 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
1010 }
1011 case "list_issues": {
1012 const state = input.state === "closed" ? "closed" : "open";
1013 const issues = await this.ports.listIssues(repo, viewer, state);
1014 if (!issues) return this.withheld();
1015 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
1016 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" };
1017 }
1018 case "get_issue": {
1019 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
1020 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
1021 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
1022 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
1023 }
1024 case "get_pull": {
1025 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
1026 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
1027 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
1028 }
1029 default:
1030 return { text: `There is no tool called ${name}.`, outcome: "refused" };
1031 }
1032 }
1033}
1034
1035/** What an agent hears when it asks for a kind that isn't built yet. */
1036const 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.";
1037
1038/**
1039 * A folio id from an id or any artifact link (`/acme/-/artifacts/runbook-fol_…`,
1040 * with or without the site and a query); null when there is none.
1041 */
1042/**
1043 * What a doc made to hold a file says: the Markdown the file was made from
1044 * (less a first heading repeating the title, which the doc has), or a
1045 * preview of a spreadsheet's rows.
1046 */
1047export function docBody(format: MakeFileFormat, title: string, input: Record<string, unknown>): string {
1048 let body = "";
1049 if (format === "xlsx" || format === "csv") {
1050 const read = readSheets(input.sheets);
1051 if (read.ok) body = read.sheets.map((sheet) => `${read.sheets.length > 1 ? `## ${sheet.name}\n\n` : ""}${previewTable(sheet)}`).join("\n\n");
1052 } else {
1053 const markdown = String(input.content ?? "");
1054 const first = /^\s*#\s+(.+?)\s*#*\s*(?:\n|$)/.exec(markdown);
1055 body = (first && first[1]!.trim().toLowerCase() === title.trim().toLowerCase() ? markdown.slice(first[0].length).trimStart() : markdown).slice(0, 100_000);
1056 }
1057 return body.trim() ? body : "The file is attached below.";
1058}
1059
1060export function folioRef(given: string): string | null {
1061 const last = given.trim().split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? "";
1062 return folioIdFrom(last);
1063}
1064
1065/** 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. */
1066export function sourceLink(given: string): { title: string; href: string } | null {
1067 let href = given.trim();
1068 if (!href) return null;
1069 if (/^https?:\/\//i.test(href)) {
1070 try {
1071 const url = new URL(href);
1072 href = `${url.pathname}${url.search}${url.hash}`;
1073 } catch {
1074 return null;
1075 }
1076 }
1077 return href.startsWith("/") && !href.startsWith("//") ? { title: "A conversation", href } : null;
1078}
1079
1080/** A space by its id, address or name (any case), among those given. */
1081function findSpace(spaces: FolioSpaceLine[], given: string): FolioSpaceLine | null {
1082 const wanted = given.trim().toLowerCase();
1083 return spaces.find((s) => s.id === given.trim()) ?? spaces.find((s) => s.slug.toLowerCase() === wanted) ?? spaces.find((s) => s.name.toLowerCase() === wanted) ?? null;
1084}
1085
1086function spaceLine(s: FolioSpaceLine): string {
1087 const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
1088 const projects = s.projects.length ? `; about ${s.projects.join(", ")}` : "";
1089 return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
1090}
1091
1092/** 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). */
1093export function folioReadText(read: FolioAgentRead): string {
1094 const f = read.folio;
1095 const can = read.can.edit ? "you can edit it" : read.can.suggest ? "you can suggest edits" : "you can only read it";
1096 const where = read.space ? `in the ${read.space.name} space` : "not in a space";
1097 const blocks = read.blocks?.length ? `\nTop-level blocks: ${read.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ")}` : "";
1098 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}`;
1099}
1100
1101/** What an edit did, naming the artifact only where everyone here can read it. */
1102function editMessage(result: FolioAgentEditResult, name: boolean): string {
1103 const on = name ? ` on ${result.folio.title} (${result.folio.path})` : "";
1104 switch (result.mode) {
1105 case "applied":
1106 return `Changed${on}. It's in its history as yours.`;
1107 case "suggested":
1108 return `Suggested${on}: people accept or reject it there.${name ? " Link it so they can." : ""}`;
1109 case "proposed":
1110 return `Proposed a change${on}: a person previews and applies it.`;
1111 }
1112}
1113
1114function messageLine(m: FoundMessage): string {
1115 const where = m.channel ? `#${m.channel}` : "a direct message";
1116 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
1117}