Skip to content
996 linesCodeBlameRaw
1/**
2 * What an agent can read while it replies: code, issues, pull requests,
3 * chat, the roster, and its colleagues (docs.g1t.sh/guides/agent-access/,
4 * "What an agent can and can't know").
5 *
6 * Every tool goes through the reply's `Audience` before it reads
7 * anything, and the check is here, in code:
8 * - code tools are offered only when the audience may read code at all,
9 * and a repository is used only when it is on the audience's allow-list;
10 * - chat tools ask the chat service, which works out the audience from the
11 * conversation itself;
12 * - what the audience may not see comes back as one neutral line,
13 * `WITHHELD`, the same for a thing that is private and a thing that does
14 * not exist, and never names it.
15 *
16 * Whatever a tool returns is wrapped as untrusted data: text in files,
17 * issues and messages is never an instruction to the agent.
18 *
19 * Pure apart from its ports, so the rules are tested adversarially.
20 */
21import type { DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, User } from "@g1t/contracts";
22
23import { FOLIO_KINDS, folioIdFrom, isFolioKind } from "../../../packages/contracts/src/folios.ts";
24import { type Audience, type RepoRef, WITHHELD } from "./audience.ts";
25
26/** One tool, as the Messages API takes it. */
27export type ToolDef = { name: string; description: string; input_schema: Record<string, unknown> };
28
29export type FoundMessage = { channel: string | null; channel_id: string; id: string; author: string; body: string; created_at: string };
30
31/** What the tools reach outside this module. */
32export interface ToolPorts {
33 readFile(repo: RepoRef, viewer: User, ref: string, path: string): Promise<{ text: string | null; size: number } | null>;
34 searchCode(viewer: User, query: string, repo: RepoRef | null): Promise<{ repo: string; path: string; snippet: string }[]>;
35 listIssues(repo: RepoRef, viewer: User, state: "open" | "closed"): Promise<{ number: number; title: string; state: string; labels: string[] }[] | null>;
36 getIssue(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; state: string; body: string; comments: { author: string; body: string }[] } | null>;
37 getPull(repo: RepoRef, number: number, viewer: User): Promise<{ number: number; title: string; status: string; body: string; checks: string | null } | null>;
38 recentPulls(repos: RepoRef[], viewer: User): Promise<{ repo: string; number: number; title: string; status: string; updated_at: string }[]>;
39 /** Chat's own audience rule applies; null when the search failed. */
40 searchMessages(query: string): Promise<FoundMessage[] | null>;
41 /** Null when the audience may not read it (or it does not exist). */
42 readThread(channelId: string, id: string): Promise<FoundMessage[] | null>;
43 roster(viewer: User | null): Promise<string>;
44 consult(handle: string, question: string): Promise<{ ok: true; colleague: string; answer: string } | { ok: false; message: string }>;
45 /**
46 * The workspace's artifacts (Artifacts mode), as the docs service lets
47 * this agent use them for the person it acts for and everyone who will
48 * read the answer. Absent where there is no docs service.
49 */
50 folios?: FoliosPorts;
51}
52
53/** A space as an agent sees it, with what it may do there for the person it acts for. */
54export type FolioSpaceLine = {
55 id: string;
56 slug: string;
57 name: string;
58 description: string | null;
59 kind: string;
60 projects: string[];
61 can: { read: boolean; suggest: boolean; edit: boolean };
62};
63
64/** Where a new artifact goes: a space, its asker's Private, or Private shared with the conversation's people. */
65export type FolioWhere = { space_id: string } | "private" | { conversation: string[] };
66
67/** A call's answer: the value, or the docs service's error code and sentence. */
68export type FolioDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string };
69
70/**
71 * Artifacts, as an agent uses them. Every call names the person it acts
72 * for, and the reads also who reads the answer; the docs service checks
73 * both.
74 */
75export interface FoliosPorts {
76 /** Spaces everyone here can read; null when the docs service couldn't answer. */
77 spaces(viewer: User, audience: FolioAudience): Promise<FolioSpaceLine[] | null>;
78 /** Passages closest in meaning to `query`; `spaces` (required reading) first. Null when the docs service couldn't answer. */
79 recall(viewer: User, audience: FolioAudience, query: string, spaces: string[], kinds?: FolioKind[]): Promise<FolioPassage[] | null>;
80 /** Artifacts matching `query` (words and meaning), as lines with links. */
81 search(viewer: User, audience: FolioAudience, input: { query: string; kind: FolioKind | null; space_id: string | null; project: string | null }): Promise<string | null>;
82 read(viewer: User, audience: FolioAudience, folioId: string): Promise<FolioDone<FolioAgentRead>>;
83 /** Artifacts possibly out of date since code they cite changed. */
84 stale(viewer: User, audience: FolioAudience, repo: string | null): Promise<string | null>;
85 create(
86 viewer: User,
87 input: { kind: FolioKind; title: string; markdown: string | null; template_id: string | null; where: FolioWhere; parent_id: string | null; source: { title: string; href: string } | null },
88 ): Promise<FolioDone<FolioRef>>;
89 edit(viewer: User, folioId: string, edit: FolioAgentEdit): Promise<FolioDone<FolioAgentEditResult>>;
90 /** `view` or `comment` for people already in this conversation. */
91 share(viewer: User, audience: FolioAudience, folioId: string, userIds: string[], role: "view" | "comment"): Promise<FolioDone<null>>;
92 /** Sends the asker a link directly, as a message from the agent in their DM with it; false when it couldn't. */
93 sendLink(asker: User, link: { title: string; path: string }, note: string): Promise<boolean>;
94}
95
96/**
97 * What an agent may do, beyond reading: remember, file an issue for the
98 * person who asked, and start or shape work. Each is checked here before it
99 * runs (the audience, the asker, the hop limit) and again by the service
100 * that does it.
101 */
102export interface ActionPorts {
103 /**
104 * `onlyForAsker`: this turn read an artifact the whole workspace can't
105 * read, so the fact is kept for the person who asked alone.
106 */
107 remember(body: string, scope: "workspace" | "channel" | "person" | null, onlyForAsker?: boolean): Promise<{ ok: boolean; message: string }>;
108 forget(id: string): Promise<{ ok: boolean; message: string }>;
109 /**
110 * Posts a draft issue as a card in the conversation, with File issue and
111 * Discard: whoever presses File files it as themselves, if they can read
112 * the repository. Nothing is filed by the agent.
113 */
114 draftIssue(repo: RepoRef, input: { title: string; body: string; labels: string[] }): Promise<{ ok: boolean; message: string }>;
115 /**
116 * Comments on an issue or pull request, or reviews a pull request, as the
117 * agent on behalf of the person who asked. Reviews are advisory: they
118 * never count toward required approvals.
119 */
120 comment?(repo: RepoRef, asker: User, number: number, body: string): Promise<{ ok: boolean; message: string }>;
121 review?(repo: RepoRef, asker: User, number: number, verdict: "comment" | "approve" | "request_changes", body: string): Promise<{ ok: boolean; message: string }>;
122 /** From chat: spins off a session for real work. */
123 startSession?(title: string, goal: string): Promise<{ ok: boolean; message: string }>;
124 /** In a session: a short progress note in its thread. */
125 postUpdate?(text: string): Promise<{ ok: boolean; message: string }>;
126 /** In a session: one of the agent's own subagents takes part of the work. */
127 useSubagent?(name: string, brief: string): Promise<{ ok: boolean; message: string }>;
128 /** In a session: a colleague works on part of it, paid from this session's budget. */
129 bringIn?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
130 /** From chat: a colleague takes the work on for the person who asked, here or in a group message. */
131 handOff?(handle: string, brief: string): Promise<{ ok: boolean; message: string }>;
132}
133
134/** The most hand-offs one reply makes. */
135export const MAX_HAND_OFFS = 2;
136
137/** The most tool calls one reply makes. */
138export const MAX_TOOL_CALLS = 8;
139/** The most tool calls one step of a session makes. */
140export const MAX_SESSION_TOOL_CALLS = 24;
141/** The most of a file or result an answer is given, in characters. */
142const MAX_RESULT = 20_000;
143
144export type ToolCall = {
145 tool: string;
146 /** Its arguments, with long text cut, as recorded. */
147 args: string;
148 outcome: "allowed" | "withheld" | "refused" | "error";
149 bytes: number;
150};
151
152export type ToolResult = { text: string; outcome: ToolCall["outcome"] };
153
154/**
155 * Text a tool read, marked as data. Anything in it that looks like the
156 * closing mark is defused, so content can't end the block early.
157 */
158export function untrusted(source: string, content: string): string {
159 const safe = (text: string) => text.replace(/<\/?untrusted/gi, (mark) => mark.replace("<", "&lt;"));
160 const body = content.length > MAX_RESULT ? `${content.slice(0, MAX_RESULT)}\n[cut: ${content.length - MAX_RESULT} more characters]` : content;
161 return `<untrusted source="${safe(source).replace(/"/g, "'")}">\n${safe(body)}\n</untrusted>`;
162}
163
164/** Arguments as recorded: every string cut to 120 characters. */
165export function redact(args: unknown): string {
166 const cut = (value: unknown): unknown => {
167 if (typeof value === "string") return value.length > 120 ? `${value.slice(0, 120)}…` : value;
168 if (Array.isArray(value)) return value.slice(0, 10).map(cut);
169 if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 10).map(([k, v]) => [k, cut(v)]));
170 return value;
171 };
172 return JSON.stringify(cut(args ?? {})).slice(0, 1000);
173}
174
175const CODE_TOOLS: ToolDef[] = [
176 {
177 name: "list_repositories",
178 description: "The workspace's repositories everyone in this conversation can read. Start here to know what you can look at.",
179 input_schema: { type: "object", properties: {} },
180 },
181 {
182 name: "search_code",
183 description: "Search code on default branches. Optionally only in one repository (`name` or `workspace/name`).",
184 input_schema: { type: "object", properties: { query: { type: "string" }, repo: { type: "string" } }, required: ["query"] },
185 },
186 {
187 name: "read_file",
188 description: "Read a file from a repository, at its default branch or a ref.",
189 input_schema: { type: "object", properties: { repo: { type: "string" }, path: { type: "string" }, ref: { type: "string" } }, required: ["repo", "path"] },
190 },
191 {
192 name: "list_issues",
193 description: "A repository's newest issues, open by default.",
194 input_schema: { type: "object", properties: { repo: { type: "string" }, state: { type: "string", enum: ["open", "closed"] } }, required: ["repo"] },
195 },
196 {
197 name: "get_issue",
198 description: "One issue with its comments.",
199 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
200 },
201 {
202 name: "get_pull",
203 description: "One pull request: what it changes, its status and checks.",
204 input_schema: { type: "object", properties: { repo: { type: "string" }, number: { type: "integer" } }, required: ["repo", "number"] },
205 },
206 {
207 name: "recent_activity",
208 description: "Recently merged and open pull requests, in one repository or across those you can read.",
209 input_schema: { type: "object", properties: { repo: { type: "string" } } },
210 },
211];
212
213const CHAT_TOOLS: ToolDef[] = [
214 {
215 name: "search_messages",
216 description: "Search chat messages this conversation's people can all read.",
217 input_schema: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
218 },
219 {
220 name: "read_thread",
221 description: "Read a chat thread by its channel id and a message id in it (from search_messages).",
222 input_schema: { type: "object", properties: { channel: { type: "string" }, id: { type: "string" } }, required: ["channel", "id"] },
223 },
224 {
225 name: "workspace_roster",
226 description: "The workspace's people and agents: names, teams, titles and roles.",
227 input_schema: { type: "object", properties: {} },
228 },
229];
230
231const ASK_COLLEAGUE: ToolDef = {
232 name: "ask_colleague",
233 description:
234 "Ask a colleague agent a quick question, privately: their answer comes back to you alone, they do no work in the conversation, and the work stays yours. Use it when their role knows something yours doesn't. To give them the work itself, use hand_off.",
235 input_schema: { type: "object", properties: { handle: { type: "string" }, question: { type: "string" } }, required: ["handle", "question"] },
236};
237
238const HAND_OFF: ToolDef = {
239 name: "hand_off",
240 description:
241 "Hand work to a colleague agent, for the person who asked: they take it on and answer that person themselves, with that person's access. If they are a member of this conversation (a channel or group message), your brief is posted here; otherwise a group message opens (or is reused) with the person who asked, you and them, your brief goes there, and a card here links to it. It is the only way to get a colleague working: an @mention in your message wakes nobody. Give their handle and a complete brief written to them: what is wanted, why, what done looks like, and what they need from this conversation. For a quick question you answer with, use ask_colleague instead.",
242 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
243};
244
245const REMEMBER: ToolDef = {
246 name: "remember",
247 description:
248 "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.",
249 input_schema: {
250 type: "object",
251 properties: { fact: { type: "string" }, scope: { type: "string", enum: ["workspace", "channel", "person"] } },
252 required: ["fact"],
253 },
254};
255
256const FORGET: ToolDef = {
257 name: "forget",
258 description: "Forget one of the notes under 'What you remember', by its id, when it is wrong or out of date.",
259 input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
260};
261
262const DRAFT_ISSUE: ToolDef = {
263 name: "draft_issue",
264 description:
265 "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.",
266 input_schema: {
267 type: "object",
268 properties: {
269 repo: { type: "string" },
270 title: { type: "string" },
271 body: { type: "string" },
272 labels: { type: "array", items: { type: "string" } },
273 },
274 required: ["repo", "title", "body"],
275 },
276};
277
278const COMMENT: ToolDef = {
279 name: "comment",
280 description:
281 "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.",
282 input_schema: {
283 type: "object",
284 properties: { repo: { type: "string" }, number: { type: "integer" }, body: { type: "string" } },
285 required: ["repo", "number", "body"],
286 },
287};
288
289const REVIEW_PULL: ToolDef = {
290 name: "review_pull",
291 description:
292 "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.",
293 input_schema: {
294 type: "object",
295 properties: {
296 repo: { type: "string" },
297 number: { type: "integer" },
298 verdict: { type: "string", enum: ["comment", "approve", "request_changes"] },
299 body: { type: "string" },
300 },
301 required: ["repo", "number", "verdict", "body"],
302 },
303};
304
305const START_SESSION: ToolDef = {
306 name: "start_session",
307 description:
308 "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.",
309 input_schema: { type: "object", properties: { title: { type: "string" }, goal: { type: "string" } }, required: ["title", "goal"] },
310};
311
312const POST_UPDATE: ToolDef = {
313 name: "post_update",
314 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.",
315 input_schema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
316};
317
318const USE_SUBAGENT: ToolDef = {
319 name: "use_subagent",
320 description:
321 "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.",
322 input_schema: { type: "object", properties: { name: { type: "string" }, brief: { type: "string" } }, required: ["name", "brief"] },
323};
324
325const BRING_IN: ToolDef = {
326 name: "bring_in",
327 description:
328 "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.",
329 input_schema: { type: "object", properties: { handle: { type: "string" }, brief: { type: "string" } }, required: ["handle", "brief"] },
330};
331
332/** What every artifact tool's description starts from, so the model never mixes them up with workflow run artifacts. */
333const ARTIFACTS =
334 "Artifacts are the workspace's own documents, made and shared in its Artifacts section: docs now, and later slides, designs and dashboards. They are not a workflow run's build artifacts.";
335
336const FOLIO_TOOLS: ToolDef[] = [
337 {
338 name: "search_artifacts",
339 description: `${ARTIFACTS} Search the artifacts everyone in this conversation can read (specs, runbooks, policies, onboarding, decisions), by words and meaning, plus projects' docs. Optionally only one kind, one space (its name or id from list_spaces) or one project (\`workspace/name\`). Each result has its link and id. Look here first for how things work and what was decided.`,
340 input_schema: {
341 type: "object",
342 properties: {
343 query: { type: "string" },
344 kind: { type: "string", enum: [...FOLIO_KINDS] },
345 space: { type: "string", description: "A space's name or id." },
346 project: { type: "string", description: "A repository, `workspace/name`." },
347 },
348 required: ["query"],
349 },
350 },
351 {
352 name: "read_artifact",
353 description: `${ARTIFACTS} Read one artifact by its id (fol_…) or its link (…/-/artifacts/<name>-fol_…). A doc comes back as Markdown with the ids of its top-level blocks (for edit_artifact), and says what you may do with it.`,
354 input_schema: { type: "object", properties: { id: { type: "string", description: "Its id or link." } }, required: ["id"] },
355 },
356 {
357 name: "list_spaces",
358 description: `${ARTIFACTS} The spaces whose artifacts everyone in this conversation can read, with what you may do in each (read, suggest, edit).`,
359 input_schema: { type: "object", properties: {} },
360 },
361 {
362 name: "stale_artifacts",
363 description: `${ARTIFACTS} Artifacts possibly out of date because code they cite changed. Optionally only for one repository (\`workspace/name\`). Start here when keeping the docs current: read each, then bring it up to date with edit_artifact and marks_current.`,
364 input_schema: { type: "object", properties: { repo: { type: "string" } } },
365 },
366];
367
368const FOLIO_WRITE_TOOLS: ToolDef[] = [
369 {
370 name: "create_artifact",
371 description: `${ARTIFACTS} Make a new artifact ("write this up"). Only kind "doc" can be made for now. Give a title and its content as Markdown (or a template id). where: a space (its name or id from list_spaces) as { "space": "..." }, "private" for the person who asked alone, or "conversation" for them plus view access for this conversation's people. Left out: shared with this conversation in a direct message or private channel, the General space in a public channel. It belongs to the person who asked, and you can keep editing it. When it comes from a conversation, set source to that thread's link.`,
372 input_schema: {
373 type: "object",
374 properties: {
375 kind: { type: "string", enum: [...FOLIO_KINDS] },
376 title: { type: "string" },
377 content: { type: "string", description: "Markdown." },
378 template: { type: "string", description: "A template id, instead of content." },
379 where: {
380 description: '{ "space": "<name or id>" }, "private" or "conversation".',
381 anyOf: [
382 { type: "string", enum: ["private", "conversation"] },
383 { type: "object", properties: { space: { type: "string" } }, required: ["space"] },
384 ],
385 },
386 parent: { type: "string", description: "A doc to put it under: its id or link." },
387 source: { type: "string", description: "The link of the thread it was written up from." },
388 },
389 required: ["kind", "title"],
390 },
391 },
392 {
393 name: "edit_artifact",
394 description: `${ARTIFACTS} Change a doc: replace a section (by its heading), a range of top-level blocks (ids from read_artifact), the whole doc, or add to the end. Where you may edit, it applies at once and shows in its history as yours; elsewhere it becomes a suggestion people accept or reject inline. Read it first. Write Markdown. Only docs can be changed this way for now.`,
395 input_schema: {
396 type: "object",
397 properties: {
398 id: { type: "string", description: "Its id or link." },
399 target: { type: "string", enum: ["append", "section", "blocks", "document"] },
400 heading: { type: "string", description: "For target section: the heading's text." },
401 from_block: { type: "string" },
402 to_block: { type: "string" },
403 markdown: { type: "string" },
404 note: { type: "string", description: "Why, in a line, for the history or the suggestion." },
405 marks_current: { type: "boolean", description: "This edit brings a doc marked possibly out of date up to date: it clears the mark when it applies or is accepted." },
406 suggest_only: { type: "boolean", description: "Suggest even where you could edit." },
407 },
408 required: ["id", "target", "markdown"],
409 },
410 },
411 {
412 name: "share_artifact",
413 description: `${ARTIFACTS} Let people in this conversation view or comment on an artifact, when the person who asked has full access to it. Only in a direct message or a private channel, and only with people already in it. You can't give edit or full access, or change who else can open it: for that, ask the person to use Share.`,
414 input_schema: {
415 type: "object",
416 properties: {
417 id: { type: "string", description: "Its id or link." },
418 people: { type: "array", items: { type: "string" }, description: "Usernames of people in this conversation." },
419 role: { type: "string", enum: ["view", "comment"] },
420 },
421 required: ["id", "people", "role"],
422 },
423 },
424];
425
426const FOLIO_NAMES = new Set([...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS].map((tool) => tool.name));
427
428const CODE_NAMES = new Set(CODE_TOOLS.map((tool) => tool.name));
429
430export type ToolContext = {
431 /** The agent replying. */
432 agentId: string;
433 /** Handles nobody may consult from here: the agent itself, and whoever sent it the work. */
434 notConsult: string[];
435 /** Hops so far: a consult is one more, and none is offered at the limit. */
436 hops: number;
437 maxHops: number;
438 /** Whether this is a session's step (more calls, session tools) or a reply. */
439 session?: boolean;
440 /** Told of every call as it is made, for a session's transcript. */
441 onCall?: (call: ToolCall) => void;
442};
443
444export class ToolBox {
445 /** Every call this reply made, shared with the tool boxes of colleagues it consults: one budget for the reply. */
446 readonly calls: ToolCall[];
447 private readonly audience: Audience;
448 private readonly ports: ToolPorts;
449 private readonly context: ToolContext;
450
451 private readonly actions: ActionPorts | null;
452 /** Updates posted in this step. */
453 private updates = 0;
454 /** Hand-offs made in this reply. */
455 private handOffs = 0;
456 /** Artifacts read or made in this turn that not everyone here can read: never named here. */
457 private readonly notHere = new Set<string>();
458 /**
459 * Whether this turn read an artifact the whole workspace can't: then what
460 * it remembers is kept for the person who asked alone.
461 */
462 private privateRead = false;
463
464 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
465 this.audience = audience;
466 this.ports = ports;
467 this.context = context;
468 this.calls = calls;
469 this.actions = actions;
470 }
471
472 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
473 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
474 return new ToolBox(this.audience, ports, context, this.calls);
475 }
476
477 /** The most calls this box makes. */
478 get maxCalls(): number {
479 return this.context.session ? MAX_SESSION_TOOL_CALLS : MAX_TOOL_CALLS;
480 }
481
482 /** Whether the person who asked can be acted for: resolved, and able to read code here. */
483 private canFile(): boolean {
484 return !!this.actions && !!this.audience.asker && this.audience.codeAllowed();
485 }
486
487 /**
488 * The tools offered: no code tools for an audience that can't read code,
489 * no consults or hand-offs at the hop limit, session tools only in a
490 * session, and a spin-off only from chat.
491 */
492 definitions(): ToolDef[] {
493 const roomForHop = this.context.hops + 1 <= this.context.maxHops;
494 const actions = this.actions;
495 return [
496 ...(this.audience.codeAllowed() ? CODE_TOOLS : []),
497 ...CHAT_TOOLS,
498 // Artifacts are for everyone, Code or not: the docs service decides what this person and audience can read.
499 ...(this.ports.folios && this.audience.asker ? FOLIO_TOOLS : []),
500 ...(this.ports.folios && this.audience.asker && actions ? FOLIO_WRITE_TOOLS : []),
501 ...(roomForHop ? [ASK_COLLEAGUE] : []),
502 ...(actions ? [REMEMBER, FORGET] : []),
503 ...(this.canFile() ? [DRAFT_ISSUE] : []),
504 ...(this.canFile() && actions?.comment ? [COMMENT] : []),
505 ...(this.canFile() && actions?.review ? [REVIEW_PULL] : []),
506 ...(actions?.startSession && !this.context.session ? [START_SESSION] : []),
507 ...(actions?.handOff && !this.context.session && roomForHop ? [HAND_OFF] : []),
508 ...(actions?.postUpdate && this.context.session ? [POST_UPDATE] : []),
509 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
510 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
511 ];
512 }
513
514 /** Whether another call may be made. */
515 get spent(): boolean {
516 return this.calls.length >= this.maxCalls;
517 }
518
519 async run(name: string, input: Record<string, unknown>): Promise<ToolResult> {
520 const result = await this.attempt(name, input);
521 const call: ToolCall = { tool: name, args: redact(input), outcome: result.outcome, bytes: result.text.length };
522 this.calls.push(call);
523 this.context.onCall?.(call);
524 return result;
525 }
526
527 private async attempt(name: string, input: Record<string, unknown>): Promise<ToolResult> {
528 let result: ToolResult;
529 const what = this.context.session ? "step" : "reply";
530 if (this.spent) result = { text: `No more tool calls in this ${what} (at most ${this.maxCalls}). Answer with what you have.`, outcome: "refused" };
531 else {
532 try {
533 result = await this.dispatch(name, input ?? {});
534 } catch (error) {
535 console.error("agents: a tool failed", name, String(error));
536 result = { text: "That didn't work just now. Answer with what you have.", outcome: "error" };
537 }
538 }
539 return result;
540 }
541
542 private withheld(): ToolResult {
543 return { text: WITHHELD, outcome: "withheld" };
544 }
545
546 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
547 const asker = this.audience.asker;
548 if (FOLIO_NAMES.has(name)) {
549 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
550 return this.folios(name, input, asker, this.ports.folios);
551 }
552 if (CODE_NAMES.has(name)) {
553 // Not offered, and refused if asked for anyway: the check is here, not in the prompt.
554 if (!this.audience.codeAllowed() || !asker) return this.withheld();
555 return this.code(name, input, asker);
556 }
557 switch (name) {
558 case "search_messages": {
559 const query = String(input.query ?? "").trim();
560 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
561 const found = await this.ports.searchMessages(query);
562 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
563 if (!found.length) return { text: "No messages found.", outcome: "allowed" };
564 return { text: untrusted(`search_messages "${query}"`, found.map(messageLine).join("\n")), outcome: "allowed" };
565 }
566 case "read_thread": {
567 const thread = await this.ports.readThread(String(input.channel ?? ""), String(input.id ?? ""));
568 if (!thread || !thread.length) return this.withheld();
569 return { text: untrusted("read_thread", thread.map(messageLine).join("\n")), outcome: "allowed" };
570 }
571 case "workspace_roster":
572 return { text: untrusted("workspace_roster", await this.ports.roster(asker)), outcome: "allowed" };
573 case "ask_colleague": {
574 const handle = String(input.handle ?? "").trim().replace(/^@/, "").toLowerCase();
575 const question = String(input.question ?? "").trim();
576 if (!handle || !question) return { text: "Name the colleague and the question.", outcome: "refused" };
577 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" };
578 if (this.context.notConsult.includes(handle)) {
579 return { text: `You can't consult @${handle} here: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
580 }
581 const answer = await this.ports.consult(handle, question.slice(0, 2000));
582 if (!answer.ok) return { text: answer.message, outcome: "refused" };
583 return { text: untrusted(`@${answer.colleague}'s answer`, answer.answer), outcome: "allowed" };
584 }
585 default:
586 return this.act(name, input);
587 }
588 }
589
590 /**
591 * What the workspace's artifacts say about `query`, for this person and
592 * this audience, before the agent answers: no tool call, nothing counted
593 * against its tools. Empty when there is no docs service or nothing
594 * relevant.
595 */
596 async recall(query: string | null, spaces: string[]): Promise<FolioPassage[]> {
597 const folios = this.ports.folios;
598 const asker = this.audience.asker;
599 if (!folios || !asker || !query) return [];
600 try {
601 return (await folios.recall(asker, this.folioAudience(), query, spaces)) ?? [];
602 } catch (error) {
603 console.error("agents: artifacts recall failed", String(error));
604 return [];
605 }
606 }
607
608 /** Who reads what an agent says here, as the docs service takes it. */
609 private folioAudience(): FolioAudience {
610 return this.audience.shared ? { kind: "workspace" } : { kind: "people", user_ids: this.audience.members.map((m) => m.id) };
611 }
612
613 /** Whether anyone besides the person who asked reads what is said here. */
614 private othersHere(asker: User): boolean {
615 return this.audience.shared || this.audience.members.some((m) => m.id !== asker.id);
616 }
617
618 private async folios(name: string, input: Record<string, unknown>, asker: User, folios: FoliosPorts): Promise<ToolResult> {
619 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
620 const audience = this.folioAudience();
621 switch (name) {
622 case "list_spaces": {
623 const spaces = await folios.spaces(asker, audience);
624 if (spaces === null) return { text: "Spaces couldn't be listed just now.", outcome: "error" };
625 if (!spaces.length) return { text: "There are no spaces everyone here can read.", outcome: "allowed" };
626 return { text: untrusted("list_spaces", spaces.map(spaceLine).join("\n")), outcome: "allowed" };
627 }
628 case "search_artifacts": {
629 const query = text("query", 200);
630 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
631 const kind = text("kind", 20) || null;
632 if (kind && !isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}: it is doc, slides, design or dashboard.`, outcome: "refused" };
633 let spaceId: string | null = null;
634 if (text("space", 200)) {
635 const space = findSpace((await folios.spaces(asker, audience)) ?? [], text("space", 200));
636 if (!space) return this.withheld();
637 spaceId = space.id;
638 }
639 const found = await folios.search(asker, audience, { query, kind: kind as FolioKind | null, space_id: spaceId, project: text("project", 200).toLowerCase() || null });
640 if (found === null) return { text: "Search didn't work just now.", outcome: "error" };
641 return { text: untrusted(`search_artifacts "${query}"`, found), outcome: "allowed" };
642 }
643 case "stale_artifacts": {
644 const found = await folios.stale(asker, audience, text("repo", 200).toLowerCase() || null);
645 if (found === null) return { text: "Out-of-date artifacts couldn't be listed just now.", outcome: "error" };
646 return { text: untrusted("stale_artifacts", found), outcome: "allowed" };
647 }
648 case "read_artifact": {
649 const id = folioRef(text("id", 500));
650 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
651 const found = await folios.read(asker, audience, id);
652 if (!found.ok) return found.code === "not_found" || found.code === "forbidden" ? this.withheld() : { text: found.message, outcome: "refused" };
653 const read = found.value;
654 if (!this.audience.shared || !read.audience_can_read) this.privateRead = true;
655 if (!read.audience_can_read) return this.notForEveryone(asker, read.folio, folios, "found");
656 return { text: untrusted(`read_artifact ${id}`, folioReadText(read)), outcome: "allowed" };
657 }
658 case "create_artifact": {
659 const kind = text("kind", 20) || "doc";
660 if (!isFolioKind(kind)) return { text: `There is no kind of artifact called ${kind}.`, outcome: "refused" };
661 if (kind !== "doc") return { text: NOT_YET, outcome: "refused" };
662 const title = text("title", 200);
663 const template = text("template", 100) || null;
664 const markdown = String(input.content ?? "").slice(0, 100_000);
665 if (!title) return { text: "An artifact needs a title.", outcome: "refused" };
666 if (!markdown.trim() && !template) return { text: "Give its content as Markdown, or a template.", outcome: "refused" };
667 const parentGiven = text("parent", 500);
668 const parent = parentGiven ? folioRef(parentGiven) : null;
669 if (parentGiven && !parent) return { text: "Give the parent doc's id or link.", outcome: "refused" };
670 const place = await this.whereFor(input.where, asker, folios);
671 if (!place.ok) return { text: place.message, outcome: "refused" };
672 const make = (where: FolioWhere) =>
673 folios.create(asker, { kind, title, markdown: template ? null : markdown, template_id: template, where, parent_id: parent, source: sourceLink(text("source", 2000)) });
674 let made = await make(place.where);
675 // The General space by default, unless the person who asked can't add there: then their Private.
676 if (!made.ok && place.fallback && made.code === "forbidden") made = await make("private");
677 if (!made.ok) return { text: `It couldn't be made: ${made.message}`, outcome: "refused" };
678 const ref = made.value;
679 if (this.othersHere(asker)) {
680 const check = await folios.read(asker, audience, ref.id).catch(() => null);
681 if (!check?.ok || !check.value.audience_can_read) return this.notForEveryone(asker, ref, folios, "made");
682 }
683 return { text: `Wrote ${ref.title} (${ref.path}, id ${ref.id}). Link it.`, outcome: "allowed" };
684 }
685 case "edit_artifact": {
686 const id = folioRef(text("id", 500));
687 const markdown = String(input.markdown ?? "").slice(0, 100_000);
688 if (!id || !markdown.trim()) return { text: "Give the artifact's id or link, and the Markdown.", outcome: "refused" };
689 const kind = text("target", 20);
690 const target: DocEditTarget | null =
691 kind === "append"
692 ? { kind: "append" }
693 : kind === "document"
694 ? { kind: "document" }
695 : kind === "section" && text("heading", 300)
696 ? { kind: "section", heading: text("heading", 300) }
697 : kind === "blocks" && text("from_block", 100) && text("to_block", 100)
698 ? { kind: "blocks", from_block: text("from_block", 100), to_block: text("to_block", 100) }
699 : null;
700 if (!target) return { text: "Say what to change: append, a section by its heading, blocks by their ids, or the whole document.", outcome: "refused" };
701 const edit: FolioAgentEdit = { kind: "doc", target, markdown, note: text("note", 300) || null, suggest_only: input.suggest_only === true, marks_current: input.marks_current === true };
702 const done = await folios.edit(asker, id, edit);
703 if (!done.ok) return { text: `That didn't work: ${done.message}`, outcome: "refused" };
704 return { text: editMessage(done.value, !this.notHere.has(done.value.folio.id)), outcome: "allowed" };
705 }
706 case "share_artifact": {
707 if (this.audience.shared) return { text: "You can share only in a direct message or a private channel. Ask the person to use Share on the artifact instead.", outcome: "refused" };
708 const id = folioRef(text("id", 500));
709 if (!id) return { text: "Give the artifact's id (fol_…) or its link.", outcome: "refused" };
710 const role = input.role === "view" || input.role === "comment" ? input.role : null;
711 if (!role) return { text: "You can share to view or comment only. For more, ask the person to use Share.", outcome: "refused" };
712 const named = (Array.isArray(input.people) ? input.people : []).filter((p): p is string => typeof p === "string").map((p) => p.trim().replace(/^@/, "").toLowerCase()).filter(Boolean).slice(0, 50);
713 const people = named.map((n) => this.audience.members.find((m) => m.username.toLowerCase() === n || m.id === n) ?? n);
714 const outside = people.filter((p): p is string => typeof p === "string");
715 if (outside.length) return { text: `${outside.map((n) => `@${n}`).join(", ")} ${outside.length === 1 ? "isn't" : "aren't"} in this conversation: you can share only with people in it.`, outcome: "refused" };
716 const users = [...new Map((people as User[]).filter((u) => u.id !== asker.id).map((u) => [u.id, u])).values()];
717 if (!users.length) return { text: "Name who to share it with: people in this conversation besides the person who asked.", outcome: "refused" };
718 const done = await folios.share(asker, audience, id, users.map((u) => u.id), role);
719 if (!done.ok) return { text: `It couldn't be shared: ${done.message}`, outcome: "refused" };
720 return { text: `Shared with ${users.map((u) => `@${u.username}`).join(", ")}: they can ${role} it.`, outcome: "allowed" };
721 }
722 default:
723 return { text: `There is no tool called ${name}.`, outcome: "refused" };
724 }
725 }
726
727 /**
728 * Where a new artifact goes:
729 * - a space named by its name or id, among those everyone here can read;
730 * - "private": the asker's Private;
731 * - "conversation": Private, plus `view` for this conversation's people;
732 * - nothing: the conversation in a DM or private channel, and in a public
733 * channel the General space if the asker can add there (`fallback`:
734 * their Private if the docs service says they can't).
735 */
736 private async whereFor(given: unknown, asker: User, folios: FoliosPorts): Promise<{ ok: true; where: FolioWhere; fallback: boolean } | { ok: false; message: string }> {
737 const named = typeof given === "object" && given !== null ? (given as { space?: unknown }).space : given;
738 const wanted = typeof named === "string" ? named.trim().slice(0, 200) : "";
739 const others = this.audience.members.filter((m) => m.id !== asker.id).map((m) => m.id);
740 const byDefault = async (): Promise<{ ok: true; where: FolioWhere; fallback: boolean }> => {
741 if (!this.audience.shared) return { ok: true, where: others.length ? { conversation: this.audience.members.map((m) => m.id) } : "private", fallback: false };
742 const spaces = (await folios.spaces(asker, this.folioAudience())) ?? [];
743 const general = spaces.find((s) => s.slug === "general") ?? spaces.find((s) => s.name.toLowerCase() === "general");
744 return general && general.can.suggest ? { ok: true, where: { space_id: general.id }, fallback: true } : { ok: true, where: "private", fallback: false };
745 };
746 if (!wanted) return byDefault();
747 const lower = wanted.toLowerCase();
748 if (typeof given === "string" && lower === "private") return { ok: true, where: "private", fallback: false };
749 // A public channel has no list of people to share with: its default instead.
750 if (typeof given === "string" && lower === "conversation") return byDefault();
751 const space = findSpace((await folios.spaces(asker, this.folioAudience())) ?? [], wanted);
752 if (space) return { ok: true, where: { space_id: space.id }, fallback: false };
753 // An id the asker gave, for a space not everyone here can read: the docs service checks it.
754 if (/^spc_[A-Za-z0-9]+$/.test(wanted)) return { ok: true, where: { space_id: wanted }, fallback: false };
755 return { ok: false, message: `There's no space called ${wanted} that everyone here can read. Use list_spaces, or put it in "private" or "conversation".` };
756 }
757
758 /**
759 * An artifact someone here can't read: the link goes to the asker
760 * directly, and the agent says only that it found or made something,
761 * never what.
762 */
763 private async notForEveryone(asker: User, folio: FolioRef, folios: FoliosPorts, what: "found" | "made"): Promise<ToolResult> {
764 this.notHere.add(folio.id);
765 const who = `@${asker.username}`;
766 const note =
767 what === "made"
768 ? "I made this for you. Not everyone in the conversation you asked from can open it, so here is the link:"
769 : "Here is what you asked about. Not everyone in the conversation you asked from can open it, so here is the link:";
770 const sent = await folios.sendLink(asker, { title: folio.title, path: folio.path }, note).catch(() => false);
771 const lead = `Not everyone in this conversation can read this artifact, so ${what === "made" ? "it" : "its content"} isn't shown here. Don't quote it, name it or describe it here;`;
772 const text = sent
773 ? `${lead} say you ${what} it and that you've sent the link to ${who} directly.`
774 : what === "made"
775 ? `${lead} say you made it and that ${who} will find it under Artifacts, in their Private or shared with them.`
776 : `${lead} say you found it but can't share it here, and ask ${who} to message you directly.`;
777 return { text, outcome: what === "made" ? "allowed" : "withheld" };
778 }
779
780 /** Doing, not reading: memory, issues, sessions. Each refused unless offered. */
781 private async act(name: string, input: Record<string, unknown>): Promise<ToolResult> {
782 const actions = this.actions;
783 const offered = this.definitions().some((tool) => tool.name === name);
784 if (!actions || !offered) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
785 const said = (answer: { ok: boolean; message: string }): ToolResult => ({ text: answer.message, outcome: answer.ok ? "allowed" : "refused" });
786 const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
787 switch (name) {
788 case "remember": {
789 const fact = text("fact", 2000);
790 if (!fact) return { text: "Say what to remember.", outcome: "refused" };
791 const scope = input.scope === "workspace" || input.scope === "channel" || input.scope === "person" ? input.scope : null;
792 return said(await actions.remember(fact, scope, this.privateRead));
793 }
794 case "forget":
795 return said(await actions.forget(text("id", 100)));
796 case "draft_issue": {
797 if (!this.audience.asker || !this.audience.codeAllowed()) return this.withheld();
798 const repo = await this.audience.repo(input.repo);
799 if (!repo) return this.withheld();
800 const title = text("title", 200);
801 const body = text("body", 20_000);
802 if (!title || !body) return { text: "An issue needs a title and a body.", outcome: "refused" };
803 const labels = Array.isArray(input.labels)
804 ? input.labels.filter((l): l is string => typeof l === "string").map((l) => l.trim()).filter(Boolean).slice(0, 5)
805 : [];
806 return said(await actions.draftIssue(repo, { title, body, labels }));
807 }
808 case "comment":
809 case "review_pull": {
810 const asker = this.audience.asker;
811 if (!asker || !this.audience.codeAllowed()) return this.withheld();
812 const repo = await this.audience.repo(input.repo);
813 if (!repo) return this.withheld();
814 const number = Math.floor(Number(input.number));
815 if (!Number.isFinite(number) || number < 1) return { text: "Give the issue or pull request's number.", outcome: "refused" };
816 const body = text("body", 20_000);
817 if (name === "comment") {
818 if (!body) return { text: "Say what to comment.", outcome: "refused" };
819 return said(await actions.comment!(repo, asker, number, body));
820 }
821 const verdict = input.verdict === "approve" || input.verdict === "request_changes" ? input.verdict : "comment";
822 if (!body && verdict !== "approve") return { text: "A review needs its text.", outcome: "refused" };
823 return said(await actions.review!(repo, asker, number, verdict, body));
824 }
825 case "start_session": {
826 const title = text("title", 120);
827 const goal = text("goal", 8000);
828 if (!title || !goal) return { text: "A session needs a title and a goal.", outcome: "refused" };
829 return said(await actions.startSession!(title, goal));
830 }
831 case "post_update": {
832 const note = text("text", 2000);
833 if (!note) return { text: "Say what to post.", outcome: "refused" };
834 if (this.updates >= 3) return { text: "You've posted enough updates for this step; carry on with the work.", outcome: "refused" };
835 this.updates++;
836 return said(await actions.postUpdate!(note));
837 }
838 case "use_subagent": {
839 const helper = text("name", 60).toLowerCase();
840 const brief = text("brief", 8000);
841 if (!helper || !brief) return { text: "Name the subagent and give it a brief.", outcome: "refused" };
842 return said(await actions.useSubagent!(helper, brief));
843 }
844 case "bring_in": {
845 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
846 const brief = text("brief", 8000);
847 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
848 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" };
849 return said(await actions.bringIn!(handle, brief));
850 }
851 case "hand_off": {
852 const handle = text("handle", 60).replace(/^@/, "").toLowerCase();
853 const brief = text("brief", 8000);
854 if (!handle || !brief) return { text: "Name the colleague and give them a brief.", outcome: "refused" };
855 if (this.context.notConsult.includes(handle)) {
856 return { text: `You can't hand work to @${handle}: they sent you this work, or it is you. Answer with what you have.`, outcome: "refused" };
857 }
858 if (this.handOffs >= MAX_HAND_OFFS) return { text: `You've handed off to ${MAX_HAND_OFFS} colleagues from this message; hand off the rest later.`, outcome: "refused" };
859 const done = await actions.handOff!(handle, brief);
860 if (done.ok) this.handOffs++;
861 return said(done);
862 }
863 default:
864 return { text: `There is no tool called ${name}.`, outcome: "refused" };
865 }
866 }
867
868 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
869 if (name === "list_repositories") {
870 const repos = [...(await this.audience.repos()).values()];
871 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
872 return { text: untrusted("list_repositories", repos.map((repo) => `${repo.namespace}/${repo.name}${repo.isPrivate ? " (private)" : ""}`).join("\n")), outcome: "allowed" };
873 }
874 if (name === "search_code") {
875 const query = String(input.query ?? "").trim();
876 if (query.length < 2) return { text: "Search for at least two characters.", outcome: "refused" };
877 const only = input.repo === undefined || input.repo === null || input.repo === "" ? null : await this.audience.repo(input.repo);
878 if (input.repo && !only) return this.withheld();
879 const allowed = await this.audience.repos();
880 // Whatever search returns, only hits in allowed repositories come through.
881 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()));
882 if (!hits.length) return { text: "No code found.", outcome: "allowed" };
883 return { text: untrusted(`search_code "${query}"`, hits.slice(0, 10).map((hit) => `${hit.repo}:${hit.path}\n${hit.snippet}`).join("\n\n")), outcome: "allowed" };
884 }
885 if (name === "recent_activity") {
886 const one = input.repo ? await this.audience.repo(input.repo) : null;
887 if (input.repo && !one) return this.withheld();
888 const repos = one ? [one] : [...(await this.audience.repos()).values()].slice(0, 20);
889 if (!repos.length) return { text: "There are no repositories everyone here can read.", outcome: "allowed" };
890 const pulls = await this.ports.recentPulls(repos, viewer);
891 if (!pulls.length) return { text: "No recent pull requests.", outcome: "allowed" };
892 return { text: untrusted("recent_activity", pulls.map((p) => `${p.repo}#${p.number} ${p.status}: ${p.title} (${p.updated_at})`).join("\n")), outcome: "allowed" };
893 }
894 // The rest name one repository; it must be on the allow-list.
895 const repo = await this.audience.repo(input.repo);
896 if (!repo) return this.withheld();
897 const full = `${repo.namespace}/${repo.name}`;
898 switch (name) {
899 case "read_file": {
900 const path = String(input.path ?? "").trim().replace(/^\/+/, "");
901 if (!path || path.split("/").some((part) => part === "..")) return { text: "Give a path inside the repository.", outcome: "refused" };
902 const ref = typeof input.ref === "string" && input.ref.trim() ? input.ref.trim() : repo.defaultBranch;
903 const file = await this.ports.readFile(repo, viewer, ref, path);
904 if (!file) return { text: `No file ${path} at ${ref} in ${full}.`, outcome: "allowed" };
905 if (file.text === null) return { text: `${full}:${path} is binary or too large to read (${file.size} bytes).`, outcome: "allowed" };
906 return { text: untrusted(`${full}:${path}@${ref}`, file.text), outcome: "allowed" };
907 }
908 case "list_issues": {
909 const state = input.state === "closed" ? "closed" : "open";
910 const issues = await this.ports.listIssues(repo, viewer, state);
911 if (!issues) return this.withheld();
912 if (!issues.length) return { text: `No ${state} issues in ${full}.`, outcome: "allowed" };
913 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" };
914 }
915 case "get_issue": {
916 const issue = await this.ports.getIssue(repo, Math.floor(Number(input.number)), viewer);
917 if (!issue) return { text: `No such issue in ${full}.`, outcome: "allowed" };
918 const comments = issue.comments.map((c) => `@${c.author}: ${c.body}`).join("\n\n");
919 return { text: untrusted(`${full}#${issue.number}`, `#${issue.number} [${issue.state}] ${issue.title}\n\n${issue.body}${comments ? `\n\nComments:\n\n${comments}` : ""}`), outcome: "allowed" };
920 }
921 case "get_pull": {
922 const pull = await this.ports.getPull(repo, Math.floor(Number(input.number)), viewer);
923 if (!pull) return { text: `No such pull request in ${full}.`, outcome: "allowed" };
924 return { text: untrusted(`${full}#${pull.number}`, `#${pull.number} [${pull.status}] ${pull.title}\n\n${pull.body}${pull.checks ? `\n\nChecks: ${pull.checks}` : ""}`), outcome: "allowed" };
925 }
926 default:
927 return { text: `There is no tool called ${name}.`, outcome: "refused" };
928 }
929 }
930}
931
932/** What an agent hears when it asks for a kind that isn't built yet. */
933const NOT_YET = "Slides, designs and dashboards aren't available yet: only docs can be made for now. Say so, and offer to write it as a doc instead.";
934
935/**
936 * A folio id from an id or any artifact link (`/acme/-/artifacts/runbook-fol_…`,
937 * with or without the site and a query); null when there is none.
938 */
939export function folioRef(given: string): string | null {
940 const last = given.trim().split(/[?#]/)[0].split("/").filter(Boolean).at(-1) ?? "";
941 return folioIdFrom(last);
942}
943
944/** Where an artifact was written up from: a thread's link, cut to the site path the docs service keeps; null when it isn't one. */
945export function sourceLink(given: string): { title: string; href: string } | null {
946 let href = given.trim();
947 if (!href) return null;
948 if (/^https?:\/\//i.test(href)) {
949 try {
950 const url = new URL(href);
951 href = `${url.pathname}${url.search}${url.hash}`;
952 } catch {
953 return null;
954 }
955 }
956 return href.startsWith("/") && !href.startsWith("//") ? { title: "A conversation", href } : null;
957}
958
959/** A space by its id, address or name (any case), among those given. */
960function findSpace(spaces: FolioSpaceLine[], given: string): FolioSpaceLine | null {
961 const wanted = given.trim().toLowerCase();
962 return spaces.find((s) => s.id === given.trim()) ?? spaces.find((s) => s.slug.toLowerCase() === wanted) ?? spaces.find((s) => s.name.toLowerCase() === wanted) ?? null;
963}
964
965function spaceLine(s: FolioSpaceLine): string {
966 const can = s.can.edit ? "you can edit" : s.can.suggest ? "you can suggest edits" : "read only";
967 const projects = s.projects.length ? `; about ${s.projects.join(", ")}` : "";
968 return `- ${s.name} (id ${s.id}, ${s.kind}; ${can}${projects})${s.description ? `: ${s.description}` : ""}`;
969}
970
971/** An artifact as the agent reads it: where it is, what it may do, and its content (a doc's Markdown, with its top-level block ids). */
972export function folioReadText(read: FolioAgentRead): string {
973 const f = read.folio;
974 const can = read.can.edit ? "you can edit it" : read.can.suggest ? "you can suggest edits" : "you can only read it";
975 const where = read.space ? `in the ${read.space.name} space` : "not in a space";
976 const blocks = read.blocks?.length ? `\nTop-level blocks: ${read.blocks.map((b) => `${b.id} ${b.type}${b.level ? ` ${b.level}` : ""}`).join(", ")}` : "";
977 return `# ${f.title} (${f.path}, id ${f.id})\nA ${f.kind}, ${where}; ${can}. Edited ${f.edited_at.slice(0, 16)}.${blocks}\n\n${read.content}`;
978}
979
980/** What an edit did, naming the artifact only where everyone here can read it. */
981function editMessage(result: FolioAgentEditResult, name: boolean): string {
982 const on = name ? ` on ${result.folio.title} (${result.folio.path})` : "";
983 switch (result.mode) {
984 case "applied":
985 return `Changed${on}. It's in its history as yours.`;
986 case "suggested":
987 return `Suggested${on}: people accept or reject it there.${name ? " Link it so they can." : ""}`;
988 case "proposed":
989 return `Proposed a change${on}: a person previews and applies it.`;
990 }
991}
992
993function messageLine(m: FoundMessage): string {
994 const where = m.channel ? `#${m.channel}` : "a direct message";
995 return `[${m.created_at.slice(0, 16)} in ${where}, channel ${m.channel_id}, message ${m.id}] @${m.author}: ${m.body}`;
996}