| 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 | */ |
| 21 | import type { User } from "@g1t/contracts"; |
| 22 | |
| 23 | import { type Audience, type RepoRef, WITHHELD } from "./audience.ts"; |
| 24 | |
| 25 | /** One tool, as the Messages API takes it. */ |
| 26 | export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> }; |
| 27 | |
| 28 | export 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. */ |
| 31 | export 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 | */ |
| 52 | export 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. */ |
| 68 | export const MAX_TOOL_CALLS = 8; |
| 69 | /** The most tool calls one step of a session makes. */ |
| 70 | export const MAX_SESSION_TOOL_CALLS = 24; |
| 71 | /** The most of a file or result an answer is given, in characters. */ |
| 72 | const MAX_RESULT = 20_000; |
| 73 | |
| 74 | export 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 | |
| 82 | export 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 | */ |
| 88 | export function untrusted(source: string, content: string): string { |
| 89 | const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "<")); |
| 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. */ |
| 95 | export 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 | |
| 105 | const 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 | |
| 143 | const 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 | |
| 161 | const 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 | |
| 168 | const 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 | |
| 179 | const 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 | |
| 185 | const 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 | |
| 201 | const 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 | |
| 208 | const 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 | |
| 214 | const 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 | |
| 221 | const 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 | |
| 228 | const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name)); |
| 229 | |
| 230 | export 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 | |
| 244 | export 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 | |
| 497 | function 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 | } |