Skip to content
717 linesCodeBlameRaw
1/**
2 * What an agent can read while it replies: code, issues, pull requests,
3 * chat, the roster, and its colleagues (docs/WORKSPACE.md, "What an agent
4 * can and can't know", "Agents know each other").
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 { DocAudience, DocEditTarget, User } from "@g1t/contracts";
22
23import { type Audience, type RepoRef, WITHHELD } from "./audience.ts";
24
25/** One tool, as the Messages API takes it. */
26export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> };
27
28export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string };
29
30/** What the tools reach outside this module. */
31export interface ToolPorts {
32 readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>;
33 searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>;
34 listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>;
35 getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>;
36 getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>;
37 recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>;
38 /** Chat's own audience rule applies; null when the search failed. */
39 searchMessages(query: string): Promise<FoundMessage[] | null>;
40 /** Null when the audience may not read it (or it does not exist). */
41 readThread(channelId: string, id: string): Promise<FoundMessage[] | null>;
42 roster(viewer: User | null): Promise<string>;
43 consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>;
44 /**
45 * The workspace's Docs, as the docs service lets this agent use them for
46 * the person it acts for and everyone who will read the answer. Absent
47 * where there is no docs service.
48 */
49 docs?: DocsPorts;
50}
51
52/** Docs, as an agent uses them. Every call names the person it acts for and who reads the answer; the docs service checks both. */
53export interface DocsPorts {
54 spaces(viewer: User, audience: DocAudience): Promise<string | null>;
55 search(viewer: User, audience: DocAudience, query: string, project: string | null): Promise<string | null>;
56 read(viewer: User, audience: DocAudience, pageId: string): Promise<string | null>;
57 /** Pages possibly out of date since code they cite changed, with the change. */
58 stale(viewer: User, audience: DocAudience, repo: string | null): Promise<string | null>;
59 edit(viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note: string | null; marks_current: boolean }, suggestOnly: boolean): Promise<{ ok: boolean; message: string }>;
60 create(viewer: User, input: { space_id: string | null; parent_id: string | null; title: string; markdown: string; source: { title: string; href: string } | null }): Promise<{ ok: boolean; message: string }>;
61}
62
63/**
64 * What an agent may do, beyond reading: remember, file an issue for the
65 * person who asked, and start or shape work. Each is checked here before it
66 * runs (the audience, the asker, the hop limit) and again by the service
67 * that does it.
68 */
69export interface ActionPorts {
70 remember(body: string, scope: "workspace" | "channel" | "person" | null): Promise<{ ok: boolean; message: string }>;
71 forget(id: string): Promise<{ ok: boolean; message: string }>;
72 /**
73 * Posts a draft issue as a card in the conversation, with File issue and
74 * Discard: whoever presses File files it as themselves, if they can read
75 * the repository. Nothing is filed by the agent.
76 */
77 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
78 /**
79 * Comments on an issue or pull request, or reviews a pull request, as the
80 * agent on behalf of the person who asked. Reviews are advisory: they
81 * never count toward required approvals.
82 */
83 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
84 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
85 /** From chat: spins off a session for real work. */
86 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
87 /** In a session: a short progress note in its thread. */
88 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
89 /** In a session: one of the agent's own subagents takes part of the work. */
90 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
91 /** In a session: a colleague works on part of it, paid from this session's budget. */
92 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
93}
94
95/** The most tool calls one reply makes. */
96export const MAX_TOOL_CALLS = 8;
97/** The most tool calls one step of a session makes. */
98export const MAX_SESSION_TOOL_CALLS = 24;
99/** The most of a file or result an answer is given, in characters. */
100const MAX_RESULT = 20_000;
101
102export type ToolCall = {
103 tool: string;
104 /** Its arguments, with long text cut, as recorded. */
105 args: string;
106 outcome: "allowed" | "withheld" | "refused" | "error";
107 bytes: number;
108};
109
110export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
111
112/**
113 * Text a tool read, marked as data. Anything in it that looks like the
114 * closing mark is defused, so content can't end the block early.
115 */
116export function untrusted(source: string, content: string): string {
117 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
118 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
119 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
120}
121
122/** Arguments as recorded: every string cut to 120 characters. */
123export function redact(args: unknown): string {
124 const cut = (value: unknown): unknown => {
125 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
126 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
127 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
128 return value;
129 };
130 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
131}
132
133const CODE_TOOLS: ToolDef[] = [
134 {
135 name: "list_repositories",
136 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
137 input_schema: { type: "object", properties: {} },
138 },
139 {
140 name: "search_code",
141 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
142 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
143 },
144 {
145 name: "read_file",
146 description: "Read a file from a repository, at its default branch or a ref.",
147 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
148 },
149 {
150 name: "list_issues",
151 description: "A repository's newest issues, open by default.",
152 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
153 },
154 {
155 name: "get_issue",
156 description: "One issue with its comments.",
157 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
158 },
159 {
160 name: "get_pull",
161 description: "One pull request: what it changes, its status and checks.",
162 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
163 },
164 {
165 name: "recent_activity",
166 description: "Recently merged and open pull requests, in one repository or across those you can read.",
167 input_schema: { type: "object", properties: { repo: { type: "string" } } },
168 },
169];
170
171const CHAT_TOOLS: ToolDef[] = [
172 {
173 name: "search_messages",
174 description: "Search chat messages this conversation's people can all read.",
175 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
176 },
177 {
178 name: "read_thread",
179 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
180 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
181 },
182 {
183 name: "workspace_roster",
184 description: "The workspace's people and agents: names, teams, titles and roles.",
185 input_schema: { type: "object", properties: {} },
186 },
187];
188
189const ASK_COLLEAGUE: ToolDef = {
190 name: "ask_colleague",
191 description:
192 "Ask another agent of the workspace a question and get their answer here, without handing the work over. Use it when their role knows something yours doesn't.",
193 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
194};
195
196const REMEMBER: ToolDef = {
197 name: "remember",
198 description:
199 "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.",
200 input_schema: {
201 type: "object",
202 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
203 required: ["fact"],
204 },
205};
206
207const FORGET: ToolDef = {
208 name: "forget",
209 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
210 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
211};
212
213const DRAFT_ISSUE: ToolDef = {
214 name: "draft_issue",
215 description:
216 "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.",
217 input_schema: {
218 type: "object",
219 properties: {
220 repo: { type: "string" },
221 title: { type: "string" },
222 body: { type: "string" },
223 labels: { type: "array", items: { type: "string" } },
224 },
225 required: ["repo", "title", "body"],
226 },
227};
228
229const COMMENT: ToolDef = {
230 name: "comment",
231 description:
232 "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.",
233 input_schema: {
234 type: "object",
235 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
236 required: ["repo", "number", "body"],
237 },
238};
239
240const REVIEW_PULL: ToolDef = {
241 name: "review_pull",
242 description:
243 "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.",
244 input_schema: {
245 type: "object",
246 properties: {
247 repo: { type: "string" },
248 number: { type: "integer" },
249 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
250 body: { type: "string" },
251 },
252 required: ["repo", "number", "verdict", "body"],
253 },
254};
255
256const START_SESSION: ToolDef = {
257 name: "start_session",
258 description:
259 "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.",
260 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
261};
262
263const POST_UPDATE: ToolDef = {
264 name: "post_update",
265 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.",
266 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
267};
268
269const USE_SUBAGENT: ToolDef = {
270 name: "use_subagent",
271 description:
272 "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.",
273 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
274};
275
276const BRING_IN: ToolDef = {
277 name: "bring_in",
278 description:
279 "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.",
280 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
281};
282
283const DOCS_TOOLS: ToolDef[] = [
284 {
285 name: "search_docs",
286 description:
287 "Search the workspace's Docs (specs, runbooks, policies, onboarding, decisions) that everyone in this conversation can read. Optionally only pages about one project (`workspace/name`). Look here first for how things work and what was decided.",
288 input_schema: { type: "object", properties: { query: { type: "string" }, project: { type: "string" } }, required: ["query"] },
289 },
290 {
291 name: "read_page",
292 description: "Read a Docs page as Markdown, with the ids of its top-level blocks (for editing), by its id from search_docs or a link.",
293 input_schema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] },
294 },
295 {
296 name: "stale_pages",
297 description:
298 "Docs pages possibly out of date because code they cite changed, each with the change (pull request or commit) and the paths. Optionally only for one repository (`workspace/name`). Start here when keeping the docs current.",
299 input_schema: { type: "object", properties: { repo: { type: "string" } } },
300 },
301 {
302 name: "list_doc_spaces",
303 description: "The Docs spaces you can read here, with what you may do in each (read, suggest, edit).",
304 input_schema: { type: "object", properties: {} },
305 },
306];
307
308const DOCS_WRITE_TOOLS: ToolDef[] = [
309 {
310 name: "edit_page",
311 description:
312 "Change a Docs page: replace a section (by its heading), a range of top-level blocks (ids from read_page), the whole page, or add to the end. Where the space lets agents edit, it applies at once and shows in the page's history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read the page first. Write Markdown.",
313 input_schema: {
314 type: "object",
315 properties: {
316 page: { type: "string" },
317 target: { type: "string", enum: ["append", "section", "blocks", "document"] },
318 heading: { type: "string", description: "For target section: the heading's text." },
319 from_block: { type: "string" },
320 to_block: { type: "string" },
321 markdown: { type: "string" },
322 note: { type: "string", description: "Why, in a line, for the history or the suggestion." },
323 marks_current: { type: "boolean", description: "This edit brings a page marked possibly out of date up to date: it clears the mark when it applies or is accepted." },
324 suggest_only: { type: "boolean", description: "Suggest even where you could edit." },
325 },
326 required: ["page", "target", "markdown"],
327 },
328 },
329 {
330 name: "create_page",
331 description:
332 "Write a new Docs page (\"write this up\"): a title and Markdown, in a space (its id from list_doc_spaces; the General space when left out), optionally under a parent page. Link where it came from when it came from a conversation.",
333 input_schema: {
334 type: "object",
335 properties: {
336 title: { type: "string" },
337 markdown: { type: "string" },
338 space: { type: "string" },
339 parent: { type: "string" },
340 source_title: { type: "string" },
341 source_href: { type: "string" },
342 },
343 required: ["title", "markdown"],
344 },
345 },
346];
347
348const DOCS_NAMES = new Set([...DOCS_TOOLS, ...DOCS_WRITE_TOOLS].map((tool) => tool.name));
349
350const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
351
352export type ToolContext = {
353 /** The agent replying. */
354 agentId: string;
355 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
356 notConsult: string[];
357 /** Hops so far: a consult is one more, and none is offered at the limit. */
358 hops: number;
359 maxHops: number;
360 /** Whether this is a session's step (more calls, session tools) or a reply. */
361 session?: boolean;
362 /** Told of every call as it is made, for a session's transcript. */
363 onCall?: (call: ToolCall) => void;
364};
365
366export class ToolBox {
367 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
368 readonly calls: ToolCall[];
369 private readonly audience: Audience;
370 private readonly ports: ToolPorts;
371 private readonly context: ToolContext;
372
373 private readonly actions: ActionPorts | null;
374 /** Updates posted in this step. */
375 private updates = 0;
376
377 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
378 this.audience = audience;
379 this.ports = ports;
380 this.context = context;
381 this.calls = calls;
382 this.actions = actions;
383 }
384
385 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
386 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
387 return new ToolBox(this.audience, ports, context, this.calls);
388 }
389
390 /** The most calls this box makes. */
391 get maxCalls(): number {
392 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
393 }
394
395 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
396 private canFile(): boolean {
397 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
398 }
399
400 /**
401 * The tools offered: no code tools for an audience that can't read code,
402 * no consults or hand-offs at the hop limit, session tools only in a
403 * session, and a spin-off only from chat.
404 */
405 definitions(): ToolDef[] {
406 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
407 const actions = this.actions;
408 return [
409 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
410 ...CHAT_TOOLS,
411 // Docs are for everyone, Code or not: the docs service decides what this person and audience can read.
412 ...(this.ports.docs && this.audience.asker ? DOCS_TOOLS : []),
413 ...(this.ports.docs && this.audience.asker && actions ? DOCS_WRITE_TOOLS : []),
414 ...(roomForHop ? [ASK_COLLEAGUE] : []),
415 ...(actions ? [REMEMBER, FORGET] : []),
416 ...(this.canFile() ? [DRAFT_ISSUE] : []),
417 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
418 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
419 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
420 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
421 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
422 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
423 ];
424 }
425
426 /** Whether another call may be made. */
427 get spent(): boolean {
428 return this.calls.length >= this.maxCalls;
429 }
430
431 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
432 const result = await this.attempt(name, input);
433 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
434 this.calls.push(call);
435 this.context.onCall?.(call);
436 return result;
437 }
438
439 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
440 let result: ToolResult;
441 const what = this.context.session ? "step" : "reply";
442 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
443 else {
444 try {
445 result = await this.dispatch(name, input ?? {});
446 } catch (error) {
447 console.error("agents: a tool failed", name, String(error));
448 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
449 }
450 }
451 return result;
452 }
453
454 private withheld(): ToolResult {
455 return { text: WITHHELD, outcome: "withheld" };
456 }
457
458 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
459 const asker = this.audience.asker;
460 if (DOCS_NAMES.has(name)) {
461 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.docs) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
462 return this.docs(name, input, asker, this.ports.docs);
463 }
464 if (CODE_NAMES.has(name)) {
465 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
466 if (!this.audience.codeAllowed() || !asker) return this.withheld();
467 return this.code(name, input, asker);
468 }
469 switch (name) {
470 case "search_messages": {
471 const query = String(input.query ?? "").trim();
472 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
473 const found = await this.ports.searchMessages(query);
474 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
475 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
476 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
477 }
478 case "read_thread": {
479 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
480 if (!thread || !thread.length) return this.withheld();
481 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
482 }
483 case "workspace_roster":
484 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
485 case "ask_colleague": {
486 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
487 const question = String(input.question ?? "").trim();
488 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
489 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" };
490 if (this.context.notConsult.includes(handle)) {
491 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
492 }
493 const answer = await this.ports.consult(handle, question.slice(0, 2000));
494 if (!answer.ok) return { text: answer.message, outcome: "refused" };
495 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
496 }
497 default:
498 return this.act(name, input);
499 }
500 }
501
502 /** Who reads what an agent says here, as the docs service takes it. */
503 private docAudience(): DocAudience {
504 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
505 }
506
507 private async docs(name: string, input: Record<string, unknown>, asker: User, docs: DocsPorts): Promise<ToolResult> {
508 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
509 const audience = this.docAudience();
510 const read = (source: string, found: string | null): ToolResult => (found === null ? this.withheld() : { text: untrusted(source, found), outcome: "allowed" });
511 switch (name) {
512 case "list_doc_spaces":
513 return read("list_doc_spaces", await docs.spaces(asker, audience));
514 case "search_docs": {
515 const query = text("query", 200);
516 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
517 const project = text("project", 200).toLowerCase() || null;
518 return read(`search_docs "${query}"`, await docs.search(asker, audience, query, project));
519 }
520 case "stale_pages":
521 return read("stale_pages", await docs.stale(asker, audience, text("repo", 200).toLowerCase() || null));
522 case "read_page": {
523 const page = pageId(text("page", 300));
524 if (!page) return { text: "Give the page's id or link.", outcome: "refused" };
525 return read(`read_page ${page}`, await docs.read(asker, audience, page));
526 }
527 case "edit_page": {
528 const page = pageId(text("page", 300));
529 const markdown = String(input.markdown ?? "").slice(0, 100_000);
530 if (!page || !markdown.trim()) return { text: "Give the page and the Markdown.", outcome: "refused" };
531 const kind = text("target", 20);
532 const target: DocEditTarget | null =
533 kind === "append"
534 ? { kind: "append" }
535 : kind === "document"
536 ? { kind: "document" }
537 : kind === "section" && text("heading", 300)
538 ? { kind: "section", heading: text("heading", 300) }
539 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
540 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
541 : null;
542 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
543 const done = await docs.edit(asker, page, { target, markdown, note: text("note", 300) || null, marks_current: input.marks_current === true }, input.suggest_only === true);
544 return { text: done.message, outcome: done.ok ? "allowed" : "refused" };
545 }
546 case "create_page": {
547 const title = text("title", 200);
548 const markdown = String(input.markdown ?? "").slice(0, 100_000);
549 if (!title || !markdown.trim()) return { text: "A page needs a title and its Markdown.", outcome: "refused" };
550 const href = text("source_href", 2000);
551 const done = await docs.create(asker, {
552 space_id: text("space", 100) || null,
553 parent_id: pageId(text("parent", 300)),
554 title,
555 markdown,
556 source: href.startsWith("/") ? { title: text("source_title", 200) || "Where this came from", href } : null,
557 });
558 return { text: done.message, outcome: done.ok ? "allowed" : "refused" };
559 }
560 default:
561 return { text: `There is no tool called ${name}.`, outcome: "refused" };
562 }
563 }
564
565 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
566 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
567 const actions = this.actions;
568 const offered = this.definitions().some((tool) => tool.name === name);
569 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
570 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
571 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
572 switch (name) {
573 case "remember": {
574 const fact = text("fact", 2000);
575 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
576 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
577 return said(await actions.remember(fact, scope));
578 }
579 case "forget":
580 return said(await actions.forget(text("id", 100)));
581 case "draft_issue": {
582 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
583 const repo = await this.audience.repo(input.repo);
584 if (!repo) return this.withheld();
585 const title = text("title", 200);
586 const body = text("body", 20_000);
587 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
588 const labels = Array.isArray(input.labels)
589 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
590 : [];
591 return said(await actions.draftIssue(repo, { title, body, labels }));
592 }
593 case "comment":
594 case "review_pull": {
595 const asker = this.audience.asker;
596 if (!asker || !this.audience.codeAllowed()) return this.withheld();
597 const repo = await this.audience.repo(input.repo);
598 if (!repo) return this.withheld();
599 const number = Math.floor(Number(input.number));
600 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
601 const body = text("body", 20_000);
602 if (name === "comment") {
603 if (!body) return { text: "Say what to comment.", outcome: "refused" };
604 return said(await actions.comment!(repo, asker, number, body));
605 }
606 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
607 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
608 return said(await actions.review!(repo, asker, number, verdict, body));
609 }
610 case "start_session": {
611 const title = text("title", 120);
612 const goal = text("goal", 8000);
613 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
614 return said(await actions.startSession!(title, goal));
615 }
616 case "post_update": {
617 const note = text("text", 2000);
618 if (!note) return { text: "Say what to post.", outcome: "refused" };
619 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
620 this.updates++;
621 return said(await actions.postUpdate!(note));
622 }
623 case "use_subagent": {
624 const helper = text("name", 60).toLowerCase();
625 const brief = text("brief", 8000);
626 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
627 return said(await actions.useSubagent!(helper, brief));
628 }
629 case "bring_in": {
630 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
631 const brief = text("brief", 8000);
632 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
633 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" };
634 return said(await actions.bringIn!(handle, brief));
635 }
636 default:
637 return { text: `There is no tool called ${name}.`, outcome: "refused" };
638 }
639 }
640
641 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
642 if (name === "list_repositories") {
643 const repos = [...(await this.audience.repos()).values()];
644 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
645 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
646 }
647 if (name === "search_code") {
648 const query = String(input.query ?? "").trim();
649 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
650 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
651 if (input.repo && !only) return this.withheld();
652 const allowed = await this.audience.repos();
653 // Whatever search returns, only hits in allowed repositories come through.
654 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()));
655 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
656 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
657 }
658 if (name === "recent_activity") {
659 const one = input.repo ? await this.audience.repo(input.repo) : null;
660 if (input.repo && !one) return this.withheld();
661 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
662 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
663 const pulls = await this.ports.recentPulls(repos, viewer);
664 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
665 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
666 }
667 // The rest name one repository; it must be on the allow-list.
668 const repo = await this.audience.repo(input.repo);
669 if (!repo) return this.withheld();
670 const full = `${repo.namespace}/${repo.name}`;
671 switch (name) {
672 case "read_file": {
673 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
674 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
675 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
676 const file = await this.ports.readFile(repo, viewer, ref, path);
677 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
678 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
679 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
680 }
681 case "list_issues": {
682 const state = input.state === "closed" ? "closed" : "open";
683 const issues = await this.ports.listIssues(repo, viewer, state);
684 if (!issues) return this.withheld();
685 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
686 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" };
687 }
688 case "get_issue": {
689 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
690 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
691 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
692 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
693 }
694 case "get_pull": {
695 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
696 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
697 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
698 }
699 default:
700 return { text: `There is no tool called ${name}.`, outcome: "refused" };
701 }
702 }
703}
704
705/** A page id from an id or a Docs link (`/acme/-/docs/general/runbook-pg_123`); null when there is none. */
706export function pageId(given: string): string | null {
707 const text = given.trim();
708 if (!text) return null;
709 const last = text.split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? text;
710 const match = last.match(/(?:^|-)([a-z]{2,4}_[A-Za-z0-9]+)$/);
711 return match ? match[1] : /^[A-Za-z0-9_-]{3,80}$/.test(last) ? last : null;
712}
713
714function messageLine(m: FoundMessage): string {
715 const where = m.channel ? `#${m.channel}` : "a direct message";
716 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
717}