Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 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 | /** The most tool calls one reply makes. */ | |
| 47 | export const MAX_TOOL_CALLS = 8; | |
| 48 | /** The most of a file or result an answer is given, in characters. */ | |
| 49 | const MAX_RESULT = 20_000; | |
| 50 | ||
| 51 | export 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 | ||
| 59 | export 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 | */ | |
| 65 | export function untrusted(source: string, content: string): string { | |
| 66 | const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "<")); | |
| 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. */ | |
| 72 | export 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 | ||
| 82 | const 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 | ||
| 120 | const 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 | ||
| 138 | const 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 | ||
| 145 | const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name)); | |
| 146 | ||
| 147 | export 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 | ||
| 157 | export 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 | ||
| 313 | function 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.