Skip to content
706 linesCodeBlameRaw

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 */
Docs: a workspace knowledge base people and agents write together21import type { DocAudience, DocEditTarget, User } from "@g1t/contracts";
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)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 }>;
Docs: a workspace knowledge base people and agents write together44 /**
45 * The workspace's Docs, as the docs service lets this agent use them for
46 * the person it acts for and everyone who will read the answer. Absent
47 * where there is no docs service.
48 */
49 docs?: DocsPorts;
50}
51
52/** Docs, as an agent uses them. Every call names the person it acts for and who reads the answer; the docs service checks both. */
53export interface DocsPorts {
54 spaces(viewer: User, audience: DocAudience): Promise<string | null>;
55 search(viewer: User, audience: DocAudience, query: string, project: string | null): Promise<string | null>;
56 read(viewer: User, audience: DocAudience, pageId: string): Promise<string | null>;
57 edit(viewer: User, pageId: string, edit: { target: DocEditTarget; markdown: string; note: string | null }, suggestOnly: boolean): Promise<{ ok: boolean; message: string }>;
58 create(viewer: User, input: { space_id: string | null; parent_id: string | null; title: string; markdown: string; source: { title: string; href: string } | null }): Promise<{ ok: boolean; message: string }>;
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)59}
60
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked61/**
62 * What an agent may do, beyond reading: remember, file an issue for the
63 * person who asked, and start or shape work. Each is checked here before it
64 * runs (the audience, the asker, the hop limit) and again by the service
65 * that does it.
66 */
67export interface ActionPorts {
68 remember(body: string, scope: "workspace" | "channel" | "person" | null): Promise<{ ok: boolean; message: string }>;
69 forget(id: string): Promise<{ ok: boolean; message: string }>;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar70 /**
71 * Posts a draft issue as a card in the conversation, with File issue and
72 * Discard: whoever presses File files it as themselves, if they can read
73 * the repository. Nothing is filed by the agent.
74 */
75 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
76 /**
77 * Comments on an issue or pull request, or reviews a pull request, as the
78 * agent on behalf of the person who asked. Reviews are advisory: they
79 * never count toward required approvals.
80 */
81 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
82 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked83 /** From chat: spins off a session for real work. */
84 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
85 /** In a session: a short progress note in its thread. */
86 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
87 /** In a session: one of the agent's own subagents takes part of the work. */
88 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
89 /** In a session: a colleague works on part of it, paid from this session's budget. */
90 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
91}
92
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)93/** The most tool calls one reply makes. */
94export const MAX_TOOL_CALLS = 8;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked95/** The most tool calls one step of a session makes. */
96export const MAX_SESSION_TOOL_CALLS = 24;
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)97/** The most of a file or result an answer is given, in characters. */
98const MAX_RESULT = 20_000;
99
100export type ToolCall = {
101 tool: string;
102 /** Its arguments, with long text cut, as recorded. */
103 args: string;
104 outcome: "allowed" | "withheld" | "refused" | "error";
105 bytes: number;
106};
107
108export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
109
110/**
111 * Text a tool read, marked as data. Anything in it that looks like the
112 * closing mark is defused, so content can't end the block early.
113 */
114export function untrusted(source: string, content: string): string {
115 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
116 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
117 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
118}
119
120/** Arguments as recorded: every string cut to 120 characters. */
121export function redact(args: unknown): string {
122 const cut = (value: unknown): unknown => {
123 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
124 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
125 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
126 return value;
127 };
128 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
129}
130
131const CODE_TOOLS: ToolDef[] = [
132 {
133 name: "list_repositories",
134 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
135 input_schema: { type: "object", properties: {} },
136 },
137 {
138 name: "search_code",
139 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
140 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
141 },
142 {
143 name: "read_file",
144 description: "Read a file from a repository, at its default branch or a ref.",
145 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
146 },
147 {
148 name: "list_issues",
149 description: "A repository's newest issues, open by default.",
150 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
151 },
152 {
153 name: "get_issue",
154 description: "One issue with its comments.",
155 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
156 },
157 {
158 name: "get_pull",
159 description: "One pull request: what it changes, its status and checks.",
160 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
161 },
162 {
163 name: "recent_activity",
164 description: "Recently merged and open pull requests, in one repository or across those you can read.",
165 input_schema: { type: "object", properties: { repo: { type: "string" } } },
166 },
167];
168
169const CHAT_TOOLS: ToolDef[] = [
170 {
171 name: "search_messages",
172 description: "Search chat messages this conversation's people can all read.",
173 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
174 },
175 {
176 name: "read_thread",
177 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
178 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
179 },
180 {
181 name: "workspace_roster",
182 description: "The workspace's people and agents: names, teams, titles and roles.",
183 input_schema: { type: "object", properties: {} },
184 },
185];
186
187const ASK_COLLEAGUE: ToolDef = {
188 name: "ask_colleague",
189 description:
190 "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.",
191 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
192};
193
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked194const REMEMBER: ToolDef = {
195 name: "remember",
196 description:
197 "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.",
198 input_schema: {
199 type: "object",
200 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
201 required: ["fact"],
202 },
203};
204
205const FORGET: ToolDef = {
206 name: "forget",
207 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
208 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
209};
210
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar211const DRAFT_ISSUE: ToolDef = {
212 name: "draft_issue",
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked213 description:
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar214 "Draft an issue (a bug report or a feature request) for a repository with what you found. It appears in the conversation as a card with File issue and Discard buttons: the person files it themselves with one press, so don't ask them to confirm in words. Write it for the team that will fix it: what happens, what should happen, steps or evidence, and where in the code it likely is.",
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked215 input_schema: {
216 type: "object",
217 properties: {
218 repo: { type: "string" },
219 title: { type: "string" },
220 body: { type: "string" },
221 labels: { type: "array", items: { type: "string" } },
222 },
223 required: ["repo", "title", "body"],
224 },
225};
226
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar227const COMMENT: ToolDef = {
228 name: "comment",
229 description:
230 "Comment on an issue or pull request, as yourself on behalf of the person you're working for. Use it when the comment belongs on the issue or pull request (findings, a test plan, a question for its author), not for chatting.",
231 input_schema: {
232 type: "object",
233 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
234 required: ["repo", "number", "body"],
235 },
236};
237
238const REVIEW_PULL: ToolDef = {
239 name: "review_pull",
240 description:
241 "Review a pull request on the pull request itself, as yourself on behalf of the person you're working for: approve, request changes, or just comment, with your review in the body. Your review is advisory: people still give the approvals a merge needs. Read the change first.",
242 input_schema: {
243 type: "object",
244 properties: {
245 repo: { type: "string" },
246 number: { type: "integer" },
247 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
248 body: { type: "string" },
249 },
250 required: ["repo", "number", "verdict", "body"],
251 },
252};
253
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked254const START_SESSION: ToolDef = {
255 name: "start_session",
256 description:
257 "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.",
258 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
259};
260
261const POST_UPDATE: ToolDef = {
262 name: "post_update",
263 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.",
264 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
265};
266
267const USE_SUBAGENT: ToolDef = {
268 name: "use_subagent",
269 description:
270 "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.",
271 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
272};
273
274const BRING_IN: ToolDef = {
275 name: "bring_in",
276 description:
277 "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.",
278 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
279};
280
Docs: a workspace knowledge base people and agents write together281const DOCS_TOOLS: ToolDef[] = [
282 {
283 name: "search_docs",
284 description:
285 "Search the workspace's Docs (specs, runbooks, policies, onboarding, decisions) that everyone in this conversation can read. Optionally only pages about one project (`workspace/name`). Look here first for how things work and what was decided.",
286 input_schema: { type: "object", properties: { query: { type: "string" }, project: { type: "string" } }, required: ["query"] },
287 },
288 {
289 name: "read_page",
290 description: "Read a Docs page as Markdown, with the ids of its top-level blocks (for editing), by its id from search_docs or a link.",
291 input_schema: { type: "object", properties: { page: { type: "string" } }, required: ["page"] },
292 },
293 {
294 name: "list_doc_spaces",
295 description: "The Docs spaces you can read here, with what you may do in each (read, suggest, edit).",
296 input_schema: { type: "object", properties: {} },
297 },
298];
299
300const DOCS_WRITE_TOOLS: ToolDef[] = [
301 {
302 name: "edit_page",
303 description:
304 "Change a Docs page: replace a section (by its heading), a range of top-level blocks (ids from read_page), the whole page, or add to the end. Where the space lets agents edit, it applies at once and shows in the page's history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read the page first. Write Markdown.",
305 input_schema: {
306 type: "object",
307 properties: {
308 page: { type: "string" },
309 target: { type: "string", enum: ["append", "section", "blocks", "document"] },
310 heading: { type: "string", description: "For target section: the heading's text." },
311 from_block: { type: "string" },
312 to_block: { type: "string" },
313 markdown: { type: "string" },
314 note: { type: "string", description: "Why, in a line, for the history or the suggestion." },
315 suggest_only: { type: "boolean", description: "Suggest even where you could edit." },
316 },
317 required: ["page", "target", "markdown"],
318 },
319 },
320 {
321 name: "create_page",
322 description:
323 "Write a new Docs page (\"write this up\"): a title and Markdown, in a space (its id from list_doc_spaces; the General space when left out), optionally under a parent page. Link where it came from when it came from a conversation.",
324 input_schema: {
325 type: "object",
326 properties: {
327 title: { type: "string" },
328 markdown: { type: "string" },
329 space: { type: "string" },
330 parent: { type: "string" },
331 source_title: { type: "string" },
332 source_href: { type: "string" },
333 },
334 required: ["title", "markdown"],
335 },
336 },
337];
338
339const DOCS_NAMES = new Set([...DOCS_TOOLS, ...DOCS_WRITE_TOOLS].map((tool) => tool.name));
340
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)341const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
342
343export type ToolContext = {
344 /** The agent replying. */
345 agentId: string;
346 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
347 notConsult: string[];
348 /** Hops so far: a consult is one more, and none is offered at the limit. */
349 hops: number;
350 maxHops: number;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked351 /** Whether this is a session's step (more calls, session tools) or a reply. */
352 session?: boolean;
353 /** Told of every call as it is made, for a session's transcript. */
354 onCall?: (call: ToolCall) => void;
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)355};
356
357export class ToolBox {
358 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
359 readonly calls: ToolCall[];
360 private readonly audience: Audience;
361 private readonly ports: ToolPorts;
362 private readonly context: ToolContext;
363
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked364 private readonly actions: ActionPorts | null;
365 /** Updates posted in this step. */
366 private updates = 0;
367
368 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
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)369 this.audience = audience;
370 this.ports = ports;
371 this.context = context;
372 this.calls = calls;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked373 this.actions = actions;
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)374 }
375
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked376 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
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)377 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
378 return new ToolBox(this.audience, ports, context, this.calls);
379 }
380
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked381 /** The most calls this box makes. */
382 get maxCalls(): number {
383 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
384 }
385
386 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
387 private canFile(): boolean {
388 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
389 }
390
391 /**
392 * The tools offered: no code tools for an audience that can't read code,
393 * no consults or hand-offs at the hop limit, session tools only in a
394 * session, and a spin-off only from chat.
395 */
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)396 definitions(): ToolDef[] {
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked397 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
398 const actions = this.actions;
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)399 return [
400 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
401 ...CHAT_TOOLS,
Docs: a workspace knowledge base people and agents write together402 // Docs are for everyone, Code or not: the docs service decides what this person and audience can read.
403 ...(this.ports.docs && this.audience.asker ? DOCS_TOOLS : []),
404 ...(this.ports.docs && this.audience.asker && actions ? DOCS_WRITE_TOOLS : []),
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked405 ...(roomForHop ? [ASK_COLLEAGUE] : []),
406 ...(actions ? [REMEMBER, FORGET] : []),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar407 ...(this.canFile() ? [DRAFT_ISSUE] : []),
408 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
409 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked410 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
411 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
412 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
413 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
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)414 ];
415 }
416
417 /** Whether another call may be made. */
418 get spent(): boolean {
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked419 return this.calls.length >= this.maxCalls;
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)420 }
421
422 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked423 const result = await this.attempt(name, input);
424 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
425 this.calls.push(call);
426 this.context.onCall?.(call);
427 return result;
428 }
429
430 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
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)431 let result: ToolResult;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked432 const what = this.context.session ? "step" : "reply";
433 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
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)434 else {
435 try {
436 result = await this.dispatch(name, input ?? {});
437 } catch (error) {
438 console.error("agents: a tool failed", name, String(error));
439 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
440 }
441 }
442 return result;
443 }
444
445 private withheld(): ToolResult {
446 return { text: WITHHELD, outcome: "withheld" };
447 }
448
449 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
450 const asker = this.audience.asker;
Docs: a workspace knowledge base people and agents write together451 if (DOCS_NAMES.has(name)) {
452 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.docs) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
453 return this.docs(name, input, asker, this.ports.docs);
454 }
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)455 if (CODE_NAMES.has(name)) {
456 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
457 if (!this.audience.codeAllowed() || !asker) return this.withheld();
458 return this.code(name, input, asker);
459 }
460 switch (name) {
461 case "search_messages": {
462 const query = String(input.query ?? "").trim();
463 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
464 const found = await this.ports.searchMessages(query);
465 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
466 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
467 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
468 }
469 case "read_thread": {
470 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
471 if (!thread || !thread.length) return this.withheld();
472 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
473 }
474 case "workspace_roster":
475 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
476 case "ask_colleague": {
477 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
478 const question = String(input.question ?? "").trim();
479 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
480 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" };
481 if (this.context.notConsult.includes(handle)) {
482 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
483 }
484 const answer = await this.ports.consult(handle, question.slice(0, 2000));
485 if (!answer.ok) return { text: answer.message, outcome: "refused" };
486 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
487 }
488 default:
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked489 return this.act(name, input);
490 }
491 }
492
Docs: a workspace knowledge base people and agents write together493 /** Who reads what an agent says here, as the docs service takes it. */
494 private docAudience(): DocAudience {
495 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
496 }
497
498 private async docs(name: string, input: Record<string, unknown>, asker: User, docs: DocsPorts): Promise<ToolResult> {
499 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
500 const audience = this.docAudience();
501 const read = (source: string, found: string | null): ToolResult => (found === null ? this.withheld() : { text: untrusted(source, found), outcome: "allowed" });
502 switch (name) {
503 case "list_doc_spaces":
504 return read("list_doc_spaces", await docs.spaces(asker, audience));
505 case "search_docs": {
506 const query = text("query", 200);
507 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
508 const project = text("project", 200).toLowerCase() || null;
509 return read(`search_docs "${query}"`, await docs.search(asker, audience, query, project));
510 }
511 case "read_page": {
512 const page = pageId(text("page", 300));
513 if (!page) return { text: "Give the page's id or link.", outcome: "refused" };
514 return read(`read_page ${page}`, await docs.read(asker, audience, page));
515 }
516 case "edit_page": {
517 const page = pageId(text("page", 300));
518 const markdown = String(input.markdown ?? "").slice(0, 100_000);
519 if (!page || !markdown.trim()) return { text: "Give the page and the Markdown.", outcome: "refused" };
520 const kind = text("target", 20);
521 const target: DocEditTarget | null =
522 kind === "append"
523 ? { kind: "append" }
524 : kind === "document"
525 ? { kind: "document" }
526 : kind === "section" && text("heading", 300)
527 ? { kind: "section", heading: text("heading", 300) }
528 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
529 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
530 : null;
531 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
532 const done = await docs.edit(asker, page, { target, markdown, note: text("note", 300) || null }, input.suggest_only === true);
533 return { text: done.message, outcome: done.ok ? "allowed" : "refused" };
534 }
535 case "create_page": {
536 const title = text("title", 200);
537 const markdown = String(input.markdown ?? "").slice(0, 100_000);
538 if (!title || !markdown.trim()) return { text: "A page needs a title and its Markdown.", outcome: "refused" };
539 const href = text("source_href", 2000);
540 const done = await docs.create(asker, {
541 space_id: text("space", 100) || null,
542 parent_id: pageId(text("parent", 300)),
543 title,
544 markdown,
545 source: href.startsWith("/") ? { title: text("source_title", 200) || "Where this came from", href } : null,
546 });
547 return { text: done.message, outcome: done.ok ? "allowed" : "refused" };
548 }
549 default:
550 return { text: `There is no tool called ${name}.`, outcome: "refused" };
551 }
552 }
553
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked554 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
555 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
556 const actions = this.actions;
557 const offered = this.definitions().some((tool) => tool.name === name);
558 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
559 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
560 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
561 switch (name) {
562 case "remember": {
563 const fact = text("fact", 2000);
564 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
565 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
566 return said(await actions.remember(fact, scope));
567 }
568 case "forget":
569 return said(await actions.forget(text("id", 100)));
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar570 case "draft_issue": {
571 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked572 const repo = await this.audience.repo(input.repo);
573 if (!repo) return this.withheld();
574 const title = text("title", 200);
575 const body = text("body", 20_000);
576 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
577 const labels = Array.isArray(input.labels)
578 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
579 : [];
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar580 return said(await actions.draftIssue(repo, { title, body, labels }));
581 }
582 case "comment":
583 case "review_pull": {
584 const asker = this.audience.asker;
585 if (!asker || !this.audience.codeAllowed()) return this.withheld();
586 const repo = await this.audience.repo(input.repo);
587 if (!repo) return this.withheld();
588 const number = Math.floor(Number(input.number));
589 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
590 const body = text("body", 20_000);
591 if (name === "comment") {
592 if (!body) return { text: "Say what to comment.", outcome: "refused" };
593 return said(await actions.comment!(repo, asker, number, body));
594 }
595 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
596 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
597 return said(await actions.review!(repo, asker, number, verdict, body));
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked598 }
599 case "start_session": {
600 const title = text("title", 120);
601 const goal = text("goal", 8000);
602 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
603 return said(await actions.startSession!(title, goal));
604 }
605 case "post_update": {
606 const note = text("text", 2000);
607 if (!note) return { text: "Say what to post.", outcome: "refused" };
608 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
609 this.updates++;
610 return said(await actions.postUpdate!(note));
611 }
612 case "use_subagent": {
613 const helper = text("name", 60).toLowerCase();
614 const brief = text("brief", 8000);
615 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
616 return said(await actions.useSubagent!(helper, brief));
617 }
618 case "bring_in": {
619 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
620 const brief = text("brief", 8000);
621 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
622 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" };
623 return said(await actions.bringIn!(handle, brief));
624 }
625 default:
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)626 return { text: `There is no tool called ${name}.`, outcome: "refused" };
627 }
628 }
629
630 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
631 if (name === "list_repositories") {
632 const repos = [...(await this.audience.repos()).values()];
633 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
634 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
635 }
636 if (name === "search_code") {
637 const query = String(input.query ?? "").trim();
638 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
639 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
640 if (input.repo && !only) return this.withheld();
641 const allowed = await this.audience.repos();
642 // Whatever search returns, only hits in allowed repositories come through.
643 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()));
644 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
645 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
646 }
647 if (name === "recent_activity") {
648 const one = input.repo ? await this.audience.repo(input.repo) : null;
649 if (input.repo && !one) return this.withheld();
650 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
651 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
652 const pulls = await this.ports.recentPulls(repos, viewer);
653 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
654 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
655 }
656 // The rest name one repository; it must be on the allow-list.
657 const repo = await this.audience.repo(input.repo);
658 if (!repo) return this.withheld();
659 const full = `${repo.namespace}/${repo.name}`;
660 switch (name) {
661 case "read_file": {
662 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
663 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
664 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
665 const file = await this.ports.readFile(repo, viewer, ref, path);
666 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
667 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
668 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
669 }
670 case "list_issues": {
671 const state = input.state === "closed" ? "closed" : "open";
672 const issues = await this.ports.listIssues(repo, viewer, state);
673 if (!issues) return this.withheld();
674 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
675 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" };
676 }
677 case "get_issue": {
678 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
679 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
680 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
681 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
682 }
683 case "get_pull": {
684 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
685 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
686 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
687 }
688 default:
689 return { text: `There is no tool called ${name}.`, outcome: "refused" };
690 }
691 }
692}
693
Docs: a workspace knowledge base people and agents write together694/** A page id from an id or a Docs link (`/acme/-/docs/general/runbook-pg_123`); null when there is none. */
695export function pageId(given: string): string | null {
696 const text = given.trim();
697 if (!text) return null;
698 const last = text.split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? text;
699 const match = last.match(/(?:^|-)([a-z]{2,4}_[A-Za-z0-9]+)$/);
700 return match ? match[1] : /^[A-Za-z0-9_-]{3,80}$/.test(last) ? last : null;
701}
702
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)703function messageLine(m: FoundMessage): string {
704 const where = m.channel ? `#${m.channel}` : "a direct message";
705 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
706}

This file's history is long; its oldest lines are credited to the oldest commit read.