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