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