Skip to content
554 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 { 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
46/**
47 * What an agent may do, beyond reading: remember, file an issue for the
48 * person who asked, and start or shape work. Each is checked here before it
49 * runs (the audience, the asker, the hop limit) and again by the service
50 * that does it.
51 */
52export interface ActionPorts {
53 remember(body: string, scope: "workspace" | "channel" | "person" | null): Promise<{ ok: boolean; message: string }>;
54 forget(id: string): Promise<{ ok: boolean; message: string }>;
55 /**
56 * Posts a draft issue as a card in the conversation, with File issue and
57 * Discard: whoever presses File files it as themselves, if they can read
58 * the repository. Nothing is filed by the agent.
59 */
60 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
61 /**
62 * Comments on an issue or pull request, or reviews a pull request, as the
63 * agent on behalf of the person who asked. Reviews are advisory: they
64 * never count toward required approvals.
65 */
66 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
67 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
68 /** From chat: spins off a session for real work. */
69 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
70 /** In a session: a short progress note in its thread. */
71 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
72 /** In a session: one of the agent's own subagents takes part of the work. */
73 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
74 /** In a session: a colleague works on part of it, paid from this session's budget. */
75 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
76}
77
78/** The most tool calls one reply makes. */
79export const MAX_TOOL_CALLS = 8;
80/** The most tool calls one step of a session makes. */
81export const MAX_SESSION_TOOL_CALLS = 24;
82/** The most of a file or result an answer is given, in characters. */
83const MAX_RESULT = 20_000;
84
85export type ToolCall = {
86 tool: string;
87 /** Its arguments, with long text cut, as recorded. */
88 args: string;
89 outcome: "allowed" | "withheld" | "refused" | "error";
90 bytes: number;
91};
92
93export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
94
95/**
96 * Text a tool read, marked as data. Anything in it that looks like the
97 * closing mark is defused, so content can't end the block early.
98 */
99export function untrusted(source: string, content: string): string {
100 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
101 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
102 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
103}
104
105/** Arguments as recorded: every string cut to 120 characters. */
106export function redact(args: unknown): string {
107 const cut = (value: unknown): unknown => {
108 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
109 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
110 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
111 return value;
112 };
113 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
114}
115
116const CODE_TOOLS: ToolDef[] = [
117 {
118 name: "list_repositories",
119 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
120 input_schema: { type: "object", properties: {} },
121 },
122 {
123 name: "search_code",
124 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
125 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
126 },
127 {
128 name: "read_file",
129 description: "Read a file from a repository, at its default branch or a ref.",
130 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
131 },
132 {
133 name: "list_issues",
134 description: "A repository's newest issues, open by default.",
135 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
136 },
137 {
138 name: "get_issue",
139 description: "One issue with its comments.",
140 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
141 },
142 {
143 name: "get_pull",
144 description: "One pull request: what it changes, its status and checks.",
145 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
146 },
147 {
148 name: "recent_activity",
149 description: "Recently merged and open pull requests, in one repository or across those you can read.",
150 input_schema: { type: "object", properties: { repo: { type: "string" } } },
151 },
152];
153
154const CHAT_TOOLS: ToolDef[] = [
155 {
156 name: "search_messages",
157 description: "Search chat messages this conversation's people can all read.",
158 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
159 },
160 {
161 name: "read_thread",
162 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
163 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
164 },
165 {
166 name: "workspace_roster",
167 description: "The workspace's people and agents: names, teams, titles and roles.",
168 input_schema: { type: "object", properties: {} },
169 },
170];
171
172const ASK_COLLEAGUE: ToolDef = {
173 name: "ask_colleague",
174 description:
175 "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.",
176 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
177};
178
179const REMEMBER: ToolDef = {
180 name: "remember",
181 description:
182 "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.",
183 input_schema: {
184 type: "object",
185 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
186 required: ["fact"],
187 },
188};
189
190const FORGET: ToolDef = {
191 name: "forget",
192 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
193 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
194};
195
196const DRAFT_ISSUE: ToolDef = {
197 name: "draft_issue",
198 description:
199 "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.",
200 input_schema: {
201 type: "object",
202 properties: {
203 repo: { type: "string" },
204 title: { type: "string" },
205 body: { type: "string" },
206 labels: { type: "array", items: { type: "string" } },
207 },
208 required: ["repo", "title", "body"],
209 },
210};
211
212const COMMENT: ToolDef = {
213 name: "comment",
214 description:
215 "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.",
216 input_schema: {
217 type: "object",
218 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
219 required: ["repo", "number", "body"],
220 },
221};
222
223const REVIEW_PULL: ToolDef = {
224 name: "review_pull",
225 description:
226 "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.",
227 input_schema: {
228 type: "object",
229 properties: {
230 repo: { type: "string" },
231 number: { type: "integer" },
232 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
233 body: { type: "string" },
234 },
235 required: ["repo", "number", "verdict", "body"],
236 },
237};
238
239const START_SESSION: ToolDef = {
240 name: "start_session",
241 description:
242 "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.",
243 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
244};
245
246const POST_UPDATE: ToolDef = {
247 name: "post_update",
248 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.",
249 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
250};
251
252const USE_SUBAGENT: ToolDef = {
253 name: "use_subagent",
254 description:
255 "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.",
256 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
257};
258
259const BRING_IN: ToolDef = {
260 name: "bring_in",
261 description:
262 "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.",
263 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
264};
265
266const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
267
268export type ToolContext = {
269 /** The agent replying. */
270 agentId: string;
271 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
272 notConsult: string[];
273 /** Hops so far: a consult is one more, and none is offered at the limit. */
274 hops: number;
275 maxHops: number;
276 /** Whether this is a session's step (more calls, session tools) or a reply. */
277 session?: boolean;
278 /** Told of every call as it is made, for a session's transcript. */
279 onCall?: (call: ToolCall) => void;
280};
281
282export class ToolBox {
283 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
284 readonly calls: ToolCall[];
285 private readonly audience: Audience;
286 private readonly ports: ToolPorts;
287 private readonly context: ToolContext;
288
289 private readonly actions: ActionPorts | null;
290 /** Updates posted in this step. */
291 private updates = 0;
292
293 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
294 this.audience = audience;
295 this.ports = ports;
296 this.context = context;
297 this.calls = calls;
298 this.actions = actions;
299 }
300
301 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
302 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
303 return new ToolBox(this.audience, ports, context, this.calls);
304 }
305
306 /** The most calls this box makes. */
307 get maxCalls(): number {
308 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
309 }
310
311 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
312 private canFile(): boolean {
313 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
314 }
315
316 /**
317 * The tools offered: no code tools for an audience that can't read code,
318 * no consults or hand-offs at the hop limit, session tools only in a
319 * session, and a spin-off only from chat.
320 */
321 definitions(): ToolDef[] {
322 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
323 const actions = this.actions;
324 return [
325 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
326 ...CHAT_TOOLS,
327 ...(roomForHop ? [ASK_COLLEAGUE] : []),
328 ...(actions ? [REMEMBER, FORGET] : []),
329 ...(this.canFile() ? [DRAFT_ISSUE] : []),
330 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
331 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
332 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
333 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
334 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
335 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
336 ];
337 }
338
339 /** Whether another call may be made. */
340 get spent(): boolean {
341 return this.calls.length >= this.maxCalls;
342 }
343
344 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
345 const result = await this.attempt(name, input);
346 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
347 this.calls.push(call);
348 this.context.onCall?.(call);
349 return result;
350 }
351
352 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
353 let result: ToolResult;
354 const what = this.context.session ? "step" : "reply";
355 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
356 else {
357 try {
358 result = await this.dispatch(name, input ?? {});
359 } catch (error) {
360 console.error("agents: a tool failed", name, String(error));
361 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
362 }
363 }
364 return result;
365 }
366
367 private withheld(): ToolResult {
368 return { text: WITHHELD, outcome: "withheld" };
369 }
370
371 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
372 const asker = this.audience.asker;
373 if (CODE_NAMES.has(name)) {
374 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
375 if (!this.audience.codeAllowed() || !asker) return this.withheld();
376 return this.code(name, input, asker);
377 }
378 switch (name) {
379 case "search_messages": {
380 const query = String(input.query ?? "").trim();
381 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
382 const found = await this.ports.searchMessages(query);
383 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
384 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
385 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
386 }
387 case "read_thread": {
388 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
389 if (!thread || !thread.length) return this.withheld();
390 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
391 }
392 case "workspace_roster":
393 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
394 case "ask_colleague": {
395 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
396 const question = String(input.question ?? "").trim();
397 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
398 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" };
399 if (this.context.notConsult.includes(handle)) {
400 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
401 }
402 const answer = await this.ports.consult(handle, question.slice(0, 2000));
403 if (!answer.ok) return { text: answer.message, outcome: "refused" };
404 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
405 }
406 default:
407 return this.act(name, input);
408 }
409 }
410
411 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
412 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
413 const actions = this.actions;
414 const offered = this.definitions().some((tool) => tool.name === name);
415 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
416 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
417 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
418 switch (name) {
419 case "remember": {
420 const fact = text("fact", 2000);
421 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
422 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
423 return said(await actions.remember(fact, scope));
424 }
425 case "forget":
426 return said(await actions.forget(text("id", 100)));
427 case "draft_issue": {
428 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
429 const repo = await this.audience.repo(input.repo);
430 if (!repo) return this.withheld();
431 const title = text("title", 200);
432 const body = text("body", 20_000);
433 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
434 const labels = Array.isArray(input.labels)
435 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
436 : [];
437 return said(await actions.draftIssue(repo, { title, body, labels }));
438 }
439 case "comment":
440 case "review_pull": {
441 const asker = this.audience.asker;
442 if (!asker || !this.audience.codeAllowed()) return this.withheld();
443 const repo = await this.audience.repo(input.repo);
444 if (!repo) return this.withheld();
445 const number = Math.floor(Number(input.number));
446 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
447 const body = text("body", 20_000);
448 if (name === "comment") {
449 if (!body) return { text: "Say what to comment.", outcome: "refused" };
450 return said(await actions.comment!(repo, asker, number, body));
451 }
452 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
453 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
454 return said(await actions.review!(repo, asker, number, verdict, body));
455 }
456 case "start_session": {
457 const title = text("title", 120);
458 const goal = text("goal", 8000);
459 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
460 return said(await actions.startSession!(title, goal));
461 }
462 case "post_update": {
463 const note = text("text", 2000);
464 if (!note) return { text: "Say what to post.", outcome: "refused" };
465 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
466 this.updates++;
467 return said(await actions.postUpdate!(note));
468 }
469 case "use_subagent": {
470 const helper = text("name", 60).toLowerCase();
471 const brief = text("brief", 8000);
472 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
473 return said(await actions.useSubagent!(helper, brief));
474 }
475 case "bring_in": {
476 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
477 const brief = text("brief", 8000);
478 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
479 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" };
480 return said(await actions.bringIn!(handle, brief));
481 }
482 default:
483 return { text: `There is no tool called ${name}.`, outcome: "refused" };
484 }
485 }
486
487 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
488 if (name === "list_repositories") {
489 const repos = [...(await this.audience.repos()).values()];
490 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
491 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
492 }
493 if (name === "search_code") {
494 const query = String(input.query ?? "").trim();
495 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
496 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
497 if (input.repo && !only) return this.withheld();
498 const allowed = await this.audience.repos();
499 // Whatever search returns, only hits in allowed repositories come through.
500 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()));
501 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
502 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
503 }
504 if (name === "recent_activity") {
505 const one = input.repo ? await this.audience.repo(input.repo) : null;
506 if (input.repo && !one) return this.withheld();
507 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
508 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
509 const pulls = await this.ports.recentPulls(repos, viewer);
510 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
511 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
512 }
513 // The rest name one repository; it must be on the allow-list.
514 const repo = await this.audience.repo(input.repo);
515 if (!repo) return this.withheld();
516 const full = `${repo.namespace}/${repo.name}`;
517 switch (name) {
518 case "read_file": {
519 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
520 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
521 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
522 const file = await this.ports.readFile(repo, viewer, ref, path);
523 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
524 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
525 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
526 }
527 case "list_issues": {
528 const state = input.state === "closed" ? "closed" : "open";
529 const issues = await this.ports.listIssues(repo, viewer, state);
530 if (!issues) return this.withheld();
531 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
532 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" };
533 }
534 case "get_issue": {
535 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
536 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
537 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
538 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
539 }
540 case "get_pull": {
541 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
542 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
543 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
544 }
545 default:
546 return { text: `There is no tool called ${name}.`, outcome: "refused" };
547 }
548 }
549}
550
551function messageLine(m: FoundMessage): string {
552 const where = m.channel ? `#${m.channel}` : "a direct message";
553 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
554}