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