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