Skip to content
316 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/** The most tool calls one reply makes. */
47export const MAX_TOOL_CALLS = 8;
48/** The most of a file or result an answer is given, in characters. */
49const MAX_RESULT = 20_000;
50
51export type ToolCall = {
52 tool: string;
53 /** Its arguments, with long text cut, as recorded. */
54 args: string;
55 outcome: "allowed" | "withheld" | "refused" | "error";
56 bytes: number;
57};
58
59export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
60
61/**
62 * Text a tool read, marked as data. Anything in it that looks like the
63 * closing mark is defused, so content can't end the block early.
64 */
65export function untrusted(source: string, content: string): string {
66 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
67 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
68 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
69}
70
71/** Arguments as recorded: every string cut to 120 characters. */
72export function redact(args: unknown): string {
73 const cut = (value: unknown): unknown => {
74 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
75 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
76 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
77 return value;
78 };
79 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
80}
81
82const CODE_TOOLS: ToolDef[] = [
83 {
84 name: "list_repositories",
85 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
86 input_schema: { type: "object", properties: {} },
87 },
88 {
89 name: "search_code",
90 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
91 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
92 },
93 {
94 name: "read_file",
95 description: "Read a file from a repository, at its default branch or a ref.",
96 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
97 },
98 {
99 name: "list_issues",
100 description: "A repository's newest issues, open by default.",
101 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
102 },
103 {
104 name: "get_issue",
105 description: "One issue with its comments.",
106 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
107 },
108 {
109 name: "get_pull",
110 description: "One pull request: what it changes, its status and checks.",
111 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
112 },
113 {
114 name: "recent_activity",
115 description: "Recently merged and open pull requests, in one repository or across those you can read.",
116 input_schema: { type: "object", properties: { repo: { type: "string" } } },
117 },
118];
119
120const CHAT_TOOLS: ToolDef[] = [
121 {
122 name: "search_messages",
123 description: "Search chat messages this conversation's people can all read.",
124 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
125 },
126 {
127 name: "read_thread",
128 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
129 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
130 },
131 {
132 name: "workspace_roster",
133 description: "The workspace's people and agents: names, teams, titles and roles.",
134 input_schema: { type: "object", properties: {} },
135 },
136];
137
138const ASK_COLLEAGUE: ToolDef = {
139 name: "ask_colleague",
140 description:
141 "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.",
142 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
143};
144
145const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
146
147export type ToolContext = {
148 /** The agent replying. */
149 agentId: string;
150 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
151 notConsult: string[];
152 /** Hops so far: a consult is one more, and none is offered at the limit. */
153 hops: number;
154 maxHops: number;
155};
156
157export class ToolBox {
158 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
159 readonly calls: ToolCall[];
160 private readonly audience: Audience;
161 private readonly ports: ToolPorts;
162 private readonly context: ToolContext;
163
164 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = []) {
165 this.audience = audience;
166 this.ports = ports;
167 this.context = context;
168 this.calls = calls;
169 }
170
171 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further. */
172 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
173 return new ToolBox(this.audience, ports, context, this.calls);
174 }
175
176 /** The tools this reply is offered: no code tools for an audience that can't read code, no consults at the hop limit. */
177 definitions(): ToolDef[] {
178 return [
179 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
180 ...CHAT_TOOLS,
181 ...(this.context.hops + 1 <= this.context.maxHops ? [ASK_COLLEAGUE] : []),
182 ];
183 }
184
185 /** Whether another call may be made. */
186 get spent(): boolean {
187 return this.calls.length >= MAX_TOOL_CALLS;
188 }
189
190 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
191 let result: ToolResult;
192 if (this.spent) result = { text: `No more tool calls in this reply (at most ${MAX_TOOL_CALLS}). Answer with what you have.`, outcome: "refused" };
193 else {
194 try {
195 result = await this.dispatch(name, input ?? {});
196 } catch (error) {
197 console.error("agents: a tool failed", name, String(error));
198 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
199 }
200 }
201 this.calls.push({ tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length });
202 return result;
203 }
204
205 private withheld(): ToolResult {
206 return { text: WITHHELD, outcome: "withheld" };
207 }
208
209 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
210 const asker = this.audience.asker;
211 if (CODE_NAMES.has(name)) {
212 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
213 if (!this.audience.codeAllowed() || !asker) return this.withheld();
214 return this.code(name, input, asker);
215 }
216 switch (name) {
217 case "search_messages": {
218 const query = String(input.query ?? "").trim();
219 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
220 const found = await this.ports.searchMessages(query);
221 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
222 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
223 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
224 }
225 case "read_thread": {
226 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
227 if (!thread || !thread.length) return this.withheld();
228 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
229 }
230 case "workspace_roster":
231 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
232 case "ask_colleague": {
233 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
234 const question = String(input.question ?? "").trim();
235 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
236 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" };
237 if (this.context.notConsult.includes(handle)) {
238 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
239 }
240 const answer = await this.ports.consult(handle, question.slice(0, 2000));
241 if (!answer.ok) return { text: answer.message, outcome: "refused" };
242 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
243 }
244 default:
245 return { text: `There is no tool called ${name}.`, outcome: "refused" };
246 }
247 }
248
249 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
250 if (name === "list_repositories") {
251 const repos = [...(await this.audience.repos()).values()];
252 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
253 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
254 }
255 if (name === "search_code") {
256 const query = String(input.query ?? "").trim();
257 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
258 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
259 if (input.repo && !only) return this.withheld();
260 const allowed = await this.audience.repos();
261 // Whatever search returns, only hits in allowed repositories come through.
262 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()));
263 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
264 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
265 }
266 if (name === "recent_activity") {
267 const one = input.repo ? await this.audience.repo(input.repo) : null;
268 if (input.repo && !one) return this.withheld();
269 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
270 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
271 const pulls = await this.ports.recentPulls(repos, viewer);
272 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
273 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
274 }
275 // The rest name one repository; it must be on the allow-list.
276 const repo = await this.audience.repo(input.repo);
277 if (!repo) return this.withheld();
278 const full = `${repo.namespace}/${repo.name}`;
279 switch (name) {
280 case "read_file": {
281 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
282 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
283 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
284 const file = await this.ports.readFile(repo, viewer, ref, path);
285 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
286 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
287 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
288 }
289 case "list_issues": {
290 const state = input.state === "closed" ? "closed" : "open";
291 const issues = await this.ports.listIssues(repo, viewer, state);
292 if (!issues) return this.withheld();
293 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
294 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" };
295 }
296 case "get_issue": {
297 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
298 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
299 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
300 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
301 }
302 case "get_pull": {
303 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
304 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
305 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
306 }
307 default:
308 return { text: `There is no tool called ${name}.`, outcome: "refused" };
309 }
310 }
311}
312
313function messageLine(m: FoundMessage): string {
314 const where = m.channel ? `#${m.channel}` : "a direct message";
315 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
316}