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