@taicho-ai/framework 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +4 -0
  2. package/package.json +48 -0
  3. package/src/coaching/patterns.ts +103 -0
  4. package/src/coaching/proposal.ts +29 -0
  5. package/src/coaching/retrieval.ts +27 -0
  6. package/src/coaching/supersede.ts +14 -0
  7. package/src/coaching/teach.ts +74 -0
  8. package/src/core/agent-status.ts +79 -0
  9. package/src/core/auth/constants.ts +53 -0
  10. package/src/core/auth/login.ts +110 -0
  11. package/src/core/auth/pkce.ts +18 -0
  12. package/src/core/auth/profile.ts +38 -0
  13. package/src/core/auth/refresh.ts +57 -0
  14. package/src/core/auth/status.ts +26 -0
  15. package/src/core/command-guard.ts +126 -0
  16. package/src/core/conversation-artifacts.ts +40 -0
  17. package/src/core/conversation-replay.ts +160 -0
  18. package/src/core/costs.ts +97 -0
  19. package/src/core/discovery.ts +22 -0
  20. package/src/core/draft.ts +6 -0
  21. package/src/core/e2e-model.ts +315 -0
  22. package/src/core/embed.ts +221 -0
  23. package/src/core/events.ts +133 -0
  24. package/src/core/firecrawl.ts +25 -0
  25. package/src/core/headless.ts +260 -0
  26. package/src/core/instrument.ts +88 -0
  27. package/src/core/logger.ts +143 -0
  28. package/src/core/mcp/adapter.ts +34 -0
  29. package/src/core/mcp/manager.ts +145 -0
  30. package/src/core/mcp/oauth.ts +119 -0
  31. package/src/core/memory.ts +13 -0
  32. package/src/core/mock-model.ts +70 -0
  33. package/src/core/model.ts +91 -0
  34. package/src/core/pricing.ts +27 -0
  35. package/src/core/providers/openai-codex.ts +67 -0
  36. package/src/core/registry.ts +67 -0
  37. package/src/core/run.ts +909 -0
  38. package/src/core/schedule-cli.ts +55 -0
  39. package/src/core/scheduler.ts +356 -0
  40. package/src/core/tasks.ts +108 -0
  41. package/src/core/team-cli.ts +118 -0
  42. package/src/core/team-routing.ts +58 -0
  43. package/src/core/tools.ts +1104 -0
  44. package/src/core/turn-audit.ts +100 -0
  45. package/src/core/verification.ts +102 -0
  46. package/src/core/workflow-run.ts +119 -0
  47. package/src/index.ts +8 -0
  48. package/src/knowledge/retrieval.ts +73 -0
  49. package/src/knowledge/sync.ts +28 -0
  50. package/src/skills/retrieval.ts +22 -0
  51. package/src/store/annotations.ts +107 -0
  52. package/src/store/artifacts.ts +338 -0
  53. package/src/store/config.ts +187 -0
  54. package/src/store/conversation.ts +107 -0
  55. package/src/store/db.ts +34 -0
  56. package/src/store/files.ts +51 -0
  57. package/src/store/knowledge.ts +201 -0
  58. package/src/store/mcp-store.ts +65 -0
  59. package/src/store/migrate.ts +278 -0
  60. package/src/store/plans.ts +260 -0
  61. package/src/store/policy.ts +66 -0
  62. package/src/store/prefs.ts +64 -0
  63. package/src/store/roster.ts +308 -0
  64. package/src/store/run-transcript.ts +103 -0
  65. package/src/store/schedules.ts +94 -0
  66. package/src/store/seed-skills.ts +75 -0
  67. package/src/store/skills.ts +89 -0
  68. package/src/store/sources.ts +58 -0
  69. package/src/store/spend-ledger.ts +114 -0
  70. package/src/store/task-state.ts +267 -0
  71. package/src/store/teams.ts +227 -0
  72. package/src/store/thread.ts +72 -0
  73. package/src/store/trace.ts +98 -0
  74. package/src/store/vectors.ts +26 -0
  75. package/src/store/workflows.ts +144 -0
@@ -0,0 +1,1104 @@
1
+ /** Per-agent toolset. Every tool carries an execute fn so the AI SDK includes tool RESULTS in
2
+ * response.messages (the manual loop pushes those back). execute closes over the RunContext,
3
+ * which is how create_agent awaits captain approval and delegate_task spawns child runs. */
4
+ import { tool, type ToolSet } from "ai";
5
+ import { z } from "zod";
6
+ import { readFile } from "node:fs/promises";
7
+ import { resolve, sep } from "node:path";
8
+ import type { AgentDef } from "@taicho-ai/contracts/agent";
9
+ import { effectiveTools, type TeamTools } from "@taicho-ai/contracts/team";
10
+ import type { PlanItemStatus } from "@taicho-ai/contracts/plan";
11
+ import type { RunContext, RunResult } from "./run";
12
+ import type { McpManager } from "./mcp/manager";
13
+ import type { VerificationVerdict } from "@taicho-ai/contracts/trace";
14
+ import { paths } from "../store/files";
15
+ import { loadWorkflow, seatsOf, orchestrationSlice } from "../store/workflows";
16
+ import { parseWorkflowDef } from "@taicho-ai/graph";
17
+ import { readTaskState } from "../store/task-state";
18
+ import { saveArtifact, readArtifact, readArtifactBody, listArtifacts } from "../store/artifacts";
19
+ import { annotateArtifact, listAnnotations } from "../store/annotations";
20
+ import { recordVerificationFailure } from "../coaching/patterns";
21
+ import { artifactHandle, parseHandle } from "@taicho-ai/contracts/artifact";
22
+ import { mergeDraft } from "./draft";
23
+ import { scrapeUrl } from "./firecrawl";
24
+ import { McpServerConfig } from "../store/config";
25
+ import { addMcpServer, applyMcpEnv } from "../store/mcp-store";
26
+ import { KbNode } from "@taicho-ai/contracts/knowledge";
27
+ import { writeNode, mkKbId, nodeExists, forgetNodes, reindexKnowledge, reembedAll } from "../store/knowledge";
28
+ import { putVector } from "../store/vectors";
29
+ import { searchKnowledge } from "../knowledge/retrieval";
30
+ import { getActiveSkills, mkSkillId, writeSkill } from "../store/skills";
31
+ import { rankSkills } from "../skills/retrieval";
32
+ import { Skill } from "@taicho-ai/contracts/skill";
33
+ import { classifyCommand, runShell, runSandboxed, isInsideWorkspace } from "./command-guard";
34
+ import { argsPreview, capJson } from "./instrument";
35
+ import { trace as otelTrace, context as otelContext, SpanStatusCode, ioAttrs } from "@taicho-ai/telemetry";
36
+
37
+ // read_artifact body cap: default returns metadata + summary only; a body read is capped so an
38
+ // uncapped read can't funnel a large payload back into context (the pollution this plan exists to kill).
39
+ const READ_ARTIFACT_CAP = 4000;
40
+ const READ_ARTIFACT_HARD_MAX = 20000;
41
+
42
+ /** Reduce a list of artifact handles to the LATEST version per logical id (highest @vN). save_artifact
43
+ * pushes a NEW versioned handle on every save, so a child that saves the same id twice hands back
44
+ * [out@v1, out@v2]. A verification verdict judged the child's FINAL output, so it must anchor to the
45
+ * newest version ONLY — annotating out@v1 too would smear a FAIL onto a version the checker never saw. */
46
+ function latestHandlePerId(handles: string[]): string[] {
47
+ const best = new Map<string, { handle: string; version: number }>();
48
+ for (const h of handles) {
49
+ const { id, version } = parseHandle(h);
50
+ const v = version ?? 0; // trace handles are always id@vN, but a bare id sorts below any concrete version
51
+ const cur = best.get(id);
52
+ if (!cur || v > cur.version) best.set(id, { handle: h, version: v });
53
+ }
54
+ return [...best.values()].map((b) => b.handle);
55
+ }
56
+
57
+ /** Apply the captain's card edits to a team proposal. ProposalCard returns only CHANGED, non-empty
58
+ * fields as strings; members/grant are captured as comma-separated lists and split back to arrays. */
59
+ function mergeTeamProposal(
60
+ base: { id: string; charter: string; lead?: string; members: string[]; grant: string[] },
61
+ edits: Record<string, string>,
62
+ ): { id: string; charter: string; lead?: string; members: string[]; grant: string[] } {
63
+ const list = (s: string) => s.split(",").map((x) => x.trim()).filter(Boolean);
64
+ return {
65
+ id: edits.id ? edits.id.trim() : base.id,
66
+ charter: edits.charter ?? base.charter,
67
+ lead: edits.lead !== undefined ? (edits.lead.trim() || undefined) : base.lead,
68
+ members: edits.members !== undefined ? list(edits.members) : base.members,
69
+ grant: edits.grant !== undefined ? list(edits.grant) : base.grant,
70
+ };
71
+ }
72
+
73
+ export function toolsForAgent(agent: AgentDef, ctx: RunContext, mcp?: McpManager, teamPolicy?: TeamTools): ToolSet {
74
+ const set: ToolSet = {};
75
+ // Plan 19: a team's tool policy layers over the member's own grant — `grant` ADDS, `deny` REMOVES and
76
+ // wins. Every gate below reads `has()` rather than agent.tools directly, so the policy applies
77
+ // uniformly (built-ins, KB tools, and the `mcp:<server>` refs resolved further down). The
78
+ // DEFAULT_WORKER_TOOLS floor is protected when the team file loads, not here.
79
+ const granted = new Set(effectiveTools(agent.tools, teamPolicy));
80
+ const has = (t: string) => granted.has(t);
81
+
82
+ // Legacy simple-markdown write, kept as a back-compat wrapper over the structured store (per the
83
+ // plan: save_artifact "replaces/wraps" it). Prefer save_artifact for provenance + hand-off.
84
+ if (has("write_artifact"))
85
+ set.write_artifact = tool({
86
+ description: "Write a simple markdown artifact and return its path. Prefer save_artifact for structured, versioned, provenance-tracked outputs you hand off by reference.",
87
+ inputSchema: z.object({
88
+ topicSlug: z.string().regex(/^[a-z0-9-]+$/, "lowercase, digits, hyphens only"),
89
+ markdown: z.string(),
90
+ }),
91
+ execute: async ({ topicSlug, markdown }) => {
92
+ const a = saveArtifact(ctx.ws, {
93
+ id: topicSlug, title: topicSlug, type: "document", role: "output",
94
+ producer: ctx.agentId, runId: ctx.runId, body: markdown,
95
+ });
96
+ const path = a.location.kind === "file" ? a.location.path : artifactHandle(a);
97
+ ctx.artifacts.push(artifactHandle(a)); // hand off by handle (id@vN) like save_artifact — an absolute path is un-resolvable to the parent
98
+ return { path }; // model still gets the concrete path for back-compat
99
+ },
100
+ });
101
+
102
+ if (has("save_artifact"))
103
+ set.save_artifact = tool({
104
+ description: "Save a work product to the shared artifact store as a structured, versioned, addressable artifact — the way to hand work to other agents (and the human) BY REFERENCE instead of dumping it into the conversation. Your identity + this run are recorded as provenance automatically. Give a `body` (local content) OR an `external` locator (a URI/ref into a system an MCP server fronts). Reusing an existing `id` saves a NEW immutable version. Returns the handle (id@vN) — pass it in delegate_task's inputArtifacts to hand it off.",
105
+ inputSchema: z.object({
106
+ title: z.string(),
107
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, "lowercase, digits, hyphens").optional().describe("stable logical id/slug; omit to derive from the title; reuse an existing id to save a new version"),
108
+ type: z.string().default("document").describe("free-form tag (e.g. dossier, script, dataset, notion-page) — NOT an enforced taxonomy"),
109
+ role: z.enum(["output", "input", "resource"]).default("output").describe("output = you produced it; input/resource = human- or ingest-provided"),
110
+ summary: z.string().optional().describe("a short summary of what this artifact is; readers see it before they pull the body"),
111
+ body: z.string().optional().describe("the artifact's local content (stored as bytes on disk)"),
112
+ external: z.string().optional().describe("a locator (URI/ref) when the work product lives in an external system; use INSTEAD of body"),
113
+ ext: z.string().optional().describe("file extension for the body (default md)"),
114
+ parents: z.array(z.string()).default([]).describe("handles of artifacts this one derives from (lineage)"),
115
+ }),
116
+ execute: async ({ title, id, type, role, summary, body, external, ext, parents }) => {
117
+ if (body === undefined && !external) return { error: "provide a `body` (local content) or an `external` locator" };
118
+ try {
119
+ const a = saveArtifact(ctx.ws, { id, title, type, role, summary, body, external, ext, parents, producer: ctx.agentId, runId: ctx.runId });
120
+ const handle = artifactHandle(a);
121
+ ctx.artifacts.push(handle);
122
+ ctx.notes.push(`saved artifact ${handle}`);
123
+ return { id: a.id, version: a.version, handle, location: a.location };
124
+ } catch (e) {
125
+ return { error: e instanceof Error ? e.message : String(e) };
126
+ }
127
+ },
128
+ });
129
+
130
+ if (has("read_artifact"))
131
+ set.read_artifact = tool({
132
+ description: "Fetch an artifact by handle ('id' for the latest version, or 'id@vN'). Returns metadata + summary by DEFAULT (cheap — keeps context thin). Pass includeBody:true to pull the body, which is size-capped and truncated with a marker; never dump a whole large artifact into context.",
133
+ inputSchema: z.object({
134
+ id: z.string().describe("artifact handle: 'id' (latest) or 'id@vN'"),
135
+ includeBody: z.boolean().default(false).describe("pull the body too (size-capped) — off by default"),
136
+ maxChars: z.number().int().positive().max(READ_ARTIFACT_HARD_MAX).default(READ_ARTIFACT_CAP).describe(`body cap in characters (max ${READ_ARTIFACT_HARD_MAX})`),
137
+ }),
138
+ execute: async ({ id, includeBody, maxChars }) => {
139
+ const a = readArtifact(ctx.ws, id);
140
+ if (!a) return { error: `no artifact "${id}"` };
141
+ const meta = {
142
+ id: a.id, version: a.version, handle: artifactHandle(a), title: a.title,
143
+ type: a.type, role: a.role, producer: a.producer, runId: a.runId,
144
+ parents: a.parents, summary: a.summary ?? null, location: a.location,
145
+ };
146
+ if (!includeBody) return { ...meta, bodyOmitted: true };
147
+ if (a.location.kind === "external")
148
+ return { ...meta, external: a.location.uri, note: "external artifact — its body lives in the fronting system; use that system's tools to fetch it" };
149
+ const buf = readArtifactBody(ctx.ws, id);
150
+ if (!buf) return { ...meta, error: "body bytes missing" };
151
+ const cap = Math.min(maxChars, READ_ARTIFACT_HARD_MAX);
152
+ const text = buf.toString("utf8");
153
+ const truncated = text.length > cap;
154
+ return {
155
+ ...meta, bytes: buf.length, truncated,
156
+ body: truncated
157
+ ? text.slice(0, cap) + `\n…[truncated ${text.length - cap} of ${text.length} chars — raise maxChars up to ${READ_ARTIFACT_HARD_MAX} or read a narrower artifact]`
158
+ : text,
159
+ };
160
+ },
161
+ });
162
+
163
+ if (has("list_artifacts"))
164
+ set.list_artifacts = tool({
165
+ description: "Discover artifacts in the shared store (the latest version of each). Filter by producer (agent id), type (free-form tag), role (output|input|resource), or q (substring over id/title/summary). Returns handles + summaries — read one with read_artifact.",
166
+ inputSchema: z.object({
167
+ producer: z.string().optional(),
168
+ type: z.string().optional(),
169
+ role: z.enum(["output", "input", "resource"]).optional(),
170
+ q: z.string().optional().describe("substring over id/title/summary"),
171
+ k: z.number().int().positive().max(50).default(20),
172
+ }),
173
+ execute: async ({ producer, type, role, q, k }) => ({
174
+ artifacts: listArtifacts(ctx.ws, { producer, type, role, q }).slice(0, k).map((a) => ({
175
+ handle: artifactHandle(a), id: a.id, version: a.version, title: a.title,
176
+ type: a.type, role: a.role, producer: a.producer, summary: a.summary ?? null,
177
+ })),
178
+ }),
179
+ });
180
+
181
+ if (has("annotate_artifact"))
182
+ set.annotate_artifact = tool({
183
+ description: "Leave feedback ON an artifact version (by handle 'id' for the latest, or 'id@vN'). An OPEN annotation becomes the input to a REVISION run: when the artifact is later handed to an agent by reference, your feedback rides along, that agent reads the artifact, addresses your points, and saves a NEW version. Use this to request changes, flag a problem, or sign off (kind:'approval'). Your identity is recorded as the author. Pin your feedback to the exact version you reviewed.",
184
+ inputSchema: z.object({
185
+ id: z.string().describe("artifact handle: 'id' (latest) or 'id@vN'"),
186
+ body: z.string().describe("the feedback — what to fix / what's wrong / what to change (or, for an approval, why it's accepted)"),
187
+ kind: z.enum(["feedback", "approval"]).default("feedback").describe("feedback = a change request that drives a revision; approval = sign-off"),
188
+ }),
189
+ execute: async ({ id, body, kind }) => {
190
+ try {
191
+ const a = annotateArtifact(ctx.ws, { target: id, author: ctx.agentId, body, kind });
192
+ ctx.notes.push(`annotated ${a.target} (${a.id})`);
193
+ return { annotationId: a.id, target: a.target, kind: a.kind, status: a.status };
194
+ } catch (e) {
195
+ return { error: e instanceof Error ? e.message : String(e) };
196
+ }
197
+ },
198
+ });
199
+
200
+ if (has("list_annotations"))
201
+ set.list_annotations = tool({
202
+ description: "List the feedback/annotations on an artifact (by handle 'id' for all versions, or 'id@vN' for one). Call this BEFORE you revise an artifact so you address every open point. Returns each annotation's author, body, kind, verdict (if it's a verification), and status (open/addressed/dismissed).",
203
+ inputSchema: z.object({
204
+ id: z.string().describe("artifact handle: 'id' (all versions) or 'id@vN' (that version)"),
205
+ status: z.enum(["open", "addressed", "dismissed"]).optional().describe("filter by status (default: all)"),
206
+ }),
207
+ execute: async ({ id, status }) => ({
208
+ annotations: listAnnotations(ctx.ws, id, { status }).map((a) => ({
209
+ annotationId: a.id, target: a.target, author: a.author, kind: a.kind,
210
+ body: a.body, verdict: a.verdict ?? null, status: a.status, resolvedBy: a.resolvedBy ?? null,
211
+ })),
212
+ }),
213
+ });
214
+
215
+ if (has("create_agent"))
216
+ set.create_agent = tool({
217
+ description: "Propose a NEW worker agent for the captain to approve. Give it a clear id, a one-line role, and an identity that defines its point of view.",
218
+ inputSchema: z.object({
219
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/),
220
+ role: z.string(),
221
+ identity: z.string(),
222
+ // Lifecycle contract (Plan 14): the worker ALWAYS gets the artifact-tool baseline
223
+ // (save_artifact/read_artifact/list_artifacts/annotate_artifact/…) so it can produce and hand
224
+ // off work BY REFERENCE. This field only ADDS extra capabilities on top — e.g. "delegate_task",
225
+ // "run_command", "ask_human", or an "mcp:<server>" ref — it does NOT replace the baseline. Omit
226
+ // it (or pass []) for a plain worker; you never need to list the artifact tools yourself.
227
+ tools: z.array(z.string()).optional().describe("EXTRA capabilities to grant on top of the always-present artifact baseline (e.g. delegate_task, run_command, ask_human, mcp:<server>); omit for a plain worker — never list the artifact tools, they are always granted"),
228
+ }),
229
+ execute: async (draft) => {
230
+ const decision = await ctx.requestApproval({ kind: "create_agent", draft });
231
+ if (decision.type === "reject") return { rejected: true, reason: "reject" };
232
+ const finalDraft = decision.type === "edit" ? mergeDraft(draft, decision.draft) : draft;
233
+ try {
234
+ const created = await ctx.createAgent(finalDraft);
235
+ return { created: created.id, role: created.role };
236
+ } catch {
237
+ return { error: `agent "${finalDraft.id}" already exists or could not be created` };
238
+ }
239
+ },
240
+ });
241
+
242
+ // Plan 22: the on-the-fly team route. "take editor, reporter and fact-check — that's the news team" →
243
+ // this tool → ONE approval card (the same one create_agent uses). Captain-gated by construction, and
244
+ // the grant can name only tools the PROPOSER already holds, so a model can't escalate itself by minting
245
+ // a team. NOT in DEFAULT_WORKER_TOOLS — root holds it; a lead would be granted it deliberately.
246
+ if (has("create_team"))
247
+ set.create_team = tool({
248
+ description:
249
+ "Propose a NEW team for the captain to approve, and optionally staff it in one step. A team groups agents so you can address the group and let it route work to the right member. Give it a clear id, a one-line charter, the member agent ids, an optional lead (a member the team routes to first), and any EXTRA tools to grant every member. The captain approves before the team exists.",
250
+ inputSchema: z.object({
251
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/),
252
+ charter: z.string(),
253
+ members: z.array(z.string()).default([]).describe("agent ids to place on the team"),
254
+ lead: z.string().optional().describe("a member the team routes to first; omit for capability-based routing"),
255
+ grant: z.array(z.string()).default([]).describe("extra tools to grant every member — you may only grant tools YOU already hold"),
256
+ }),
257
+ execute: async (draft) => {
258
+ // Capability ceiling: you cannot grant a team more than you hold. This is what makes the tool safe
259
+ // to expose despite a team granting capability — escalation is impossible before the captain looks.
260
+ const overreach = draft.grant.filter((t) => !agent.tools.includes(t));
261
+ if (overreach.length) return { rejected: true, reason: `cannot grant tools you do not hold: ${overreach.join(", ")}` };
262
+ const proposal = { id: draft.id, charter: draft.charter, lead: draft.lead, members: draft.members, grant: draft.grant };
263
+ const decision = await ctx.requestApproval({ kind: "create_team", draft: proposal });
264
+ if (decision.type === "reject") return { rejected: true, reason: "reject" };
265
+ const final = decision.type === "edit" ? mergeTeamProposal(proposal, decision.draft) : proposal;
266
+ try {
267
+ const team = await ctx.createTeam(final);
268
+ return { created: team.id, lead: team.lead, members: final.members };
269
+ } catch (e) {
270
+ return { error: e instanceof Error ? e.message : `team "${final.id}" could not be created` };
271
+ }
272
+ },
273
+ });
274
+
275
+ // Plan 23: READ a team's workflow, so root can brief the captain on how a team works. Read-only — the
276
+ // model never authors or edits a workflow (those stay captain-authored, which is what keeps the process
277
+ // stable). Not approval-gated: reading isn't privileged. Root holds it; a lead could be granted it too.
278
+ if (has("read_workflow"))
279
+ set.read_workflow = tool({
280
+ description:
281
+ "Read a team's WORKFLOW — how work moves through it and what each seat (member) does — so you can brief the captain on it. Read-only: workflows are captain-authored and you cannot change one from here. Returns the raw workflow markdown plus the seats it covers, or a note when the team has none.",
282
+ inputSchema: z.object({ team: z.string().describe("the team id whose workflow to read") }),
283
+ execute: async ({ team }) => {
284
+ const wf = loadWorkflow(ctx.ws, team);
285
+ if (!wf) return { team, workflow: null, note: `team "${team}" has no workflow — it runs on the agentic brief` };
286
+ const text = await readFile(paths.teamWorkflowFile(ctx.ws, team), "utf8");
287
+ return { team, seats: seatsOf(wf), hasOrchestration: !!orchestrationSlice(wf), workflow: text };
288
+ },
289
+ });
290
+
291
+ // Plan 25: run a team's STRUCTURED workflow (the engine walks the steps; human gates ask the captain).
292
+ if (has("run_workflow"))
293
+ set.run_workflow = tool({
294
+ description:
295
+ "Run a team's WORKFLOW — the engine walks its steps in order (research → verify → draft → sign-off → …), stopping at any human gate to ask the captain. Use when the captain asks to run a team's process. The team must have a structured workflow (a `steps:` block in its workflow.md); otherwise this returns a note. Returns the per-step outcome and the handles it produced.",
296
+ inputSchema: z.object({ team: z.string().describe("the team id whose workflow to run") }),
297
+ execute: async ({ team }) => {
298
+ if (!ctx.runWorkflow) return { error: "workflows are not available in this run" };
299
+ const state = await ctx.runWorkflow(team);
300
+ if (!state) return { team, ran: false, note: `team "${team}" has no structured workflow — nothing to run` };
301
+ return {
302
+ team,
303
+ ran: true,
304
+ status: state.status,
305
+ steps: state.steps.map((s) => ({ id: s.id, status: s.status, produced: s.produced, choice: s.choice })),
306
+ };
307
+ },
308
+ });
309
+
310
+ // Plan 25: PROPOSE a structured workflow for a team. The model drafts; the captain approves; the ENGINE
311
+ // writes the file (the model never writes workflow canon — Plan 23's rule, preserved). Existing prose
312
+ // lanes are kept. This is the twin of create_team.
313
+ if (has("propose_workflow"))
314
+ set.propose_workflow = tool({
315
+ description:
316
+ "Propose a structured workflow for a team — the ordered steps its work should follow. The captain APPROVES before it is saved; you never write the file yourself. Each step is exactly ONE kind: an agent step (run: <agent-id>, optional consumes/produces/brief), a check (check: <criteria>, optional on_fail), or a human gate (human: <title>, choices, routes). Existing prose lanes are preserved. Use this when the captain asks how a team should organise, or to set up a repeatable team process.",
317
+ inputSchema: z.object({
318
+ team: z.string().describe("the team id this workflow is for"),
319
+ name: z.string().describe("a short workflow name, e.g. daily-brief"),
320
+ brief: z.string().optional().describe("a workflow-wide instruction every step sees"),
321
+ steps: z.array(z.record(z.string(), z.any())).min(1).describe("the ordered steps; each carries exactly one of run/check/human/branch/over"),
322
+ }),
323
+ execute: async ({ team, name, brief, steps }) => {
324
+ try { parseWorkflowDef({ id: name, team, version: 1, brief, steps }); }
325
+ catch (e) { return { proposed: false, error: `invalid workflow: ${(e as Error).message}` }; }
326
+ const d = await ctx.requestApproval({ kind: "propose_workflow", draft: { team, name, brief, steps } });
327
+ if (d.type !== "approve" && d.type !== "edit") return { proposed: false, note: "the captain declined the workflow" };
328
+ if (!ctx.proposeWorkflow) return { proposed: false, error: "workflow authoring is not available in this run" };
329
+ const final = d.type === "edit"
330
+ ? { team: d.draft.team ?? team, name: d.draft.name ?? name, brief: d.draft.brief ?? brief, steps }
331
+ : { team, name, brief, steps };
332
+ ctx.proposeWorkflow(final);
333
+ return { proposed: true, saved: `teams/${final.team}/workflow.md`, steps: steps.length };
334
+ },
335
+ });
336
+
337
+ // Plan 25 Ph6: answer a workflow run that PARKED at a human gate (an unattended run waiting on the captain).
338
+ if (has("resume_workflow"))
339
+ set.resume_workflow = tool({
340
+ description:
341
+ "Answer a workflow run that PARKED at a human gate — a scheduled/unattended run waiting on the captain. Give the team, the parked run id, and the chosen option; the engine drives the workflow on from the gate. Use when the captain tells you how to answer a waiting workflow (see the boot notice or /workflows).",
342
+ inputSchema: z.object({
343
+ team: z.string().describe("the team whose workflow parked"),
344
+ runId: z.string().describe("the parked run id, e.g. wr_brief_1"),
345
+ choice: z.string().describe("the gate option to take, e.g. approve or revise"),
346
+ note: z.string().optional().describe("an optional note that rides back into the workflow"),
347
+ }),
348
+ execute: async ({ team, runId, choice, note }) => {
349
+ if (!ctx.resumeWorkflow) return { error: "workflows are not available in this run" };
350
+ const state = await ctx.resumeWorkflow(team, runId, choice, note);
351
+ if (!state) return { team, resumed: false, note: `team "${team}" has no structured workflow` };
352
+ return { team, runId, resumed: true, status: state.status, steps: state.steps.map((s) => ({ id: s.id, status: s.status })) };
353
+ },
354
+ });
355
+
356
+ // ---- Plan 18: the agent's live plan -------------------------------------------------------------
357
+ // NOT in DEFAULT_WORKER_TOOLS. A plan is not needed to produce an artifact, so it stays an opt-in
358
+ // grant under Plan 08 least privilege. Root holds all three; a worker or team lead asks for them.
359
+
360
+ if (has("write_plan"))
361
+ set.write_plan = tool({
362
+ description:
363
+ "Write (or REVISE) your plan: the checklist you work from and the captain watches. Call this ONCE " +
364
+ "up front for any multi-step goal, then again only when you genuinely change your mind about the " +
365
+ "SHAPE of the work — an identical list mints nothing. Item ids are stable across revisions, so a " +
366
+ "ticked item stays ticked. Items you hand to another agent via delegate_task(itemId:…) are ticked " +
367
+ "by the ENGINE from the child's real outcome; do not tick those yourself.",
368
+ inputSchema: z.object({
369
+ goal: z.string().describe("one line: what finishing this plan means"),
370
+ items: z.array(z.object({
371
+ id: z.string().describe("stable, lowercase, e.g. it_survey — reuse the id to keep an item's state across a revision"),
372
+ text: z.string(),
373
+ assignee: z.string().optional().describe("the agent or team you intend to hand this to"),
374
+ })).min(1),
375
+ }),
376
+ execute: async ({ goal, items }) => {
377
+ if (!ctx.plan) return { error: "plans are not available in this context" };
378
+ try {
379
+ const { handle, minted } = ctx.plan.write({ goal, items });
380
+ ctx.notes.push(minted ? `wrote plan ${handle}` : `plan ${handle} unchanged`);
381
+ ctx.emit?.({ plan: ctx.plan.read() ?? undefined });
382
+ return { handle, minted, note: minted ? undefined : "identical to the current version — no new version minted" };
383
+ } catch (e) {
384
+ return { error: e instanceof Error ? e.message : String(e) };
385
+ }
386
+ },
387
+ });
388
+
389
+ if (has("update_plan_item"))
390
+ set.update_plan_item = tool({
391
+ description:
392
+ "Tick an item on your plan. Use it for work YOU did. An item bound to a delegated run is owned by " +
393
+ "the engine: it goes done/failed from the child's real outcome (and, if you set criteria, only when " +
394
+ "an independent check agrees), and an attempt to mark it yourself is refused and recorded. " +
395
+ "`dropped` means you decided not to do it and requires a note saying why.",
396
+ inputSchema: z.object({
397
+ itemId: z.string(),
398
+ status: z.enum(["pending", "in_progress", "done", "failed", "blocked", "dropped"]),
399
+ note: z.string().optional(),
400
+ }),
401
+ execute: async ({ itemId, status, note }) => {
402
+ if (!ctx.plan) return { error: "plans are not available in this context" };
403
+ const r = ctx.plan.tick({ itemId, status, note, by: "model" });
404
+ if (!r.ok) {
405
+ ctx.notes.push(`plan tick refused (${itemId}): ${r.rejected}`);
406
+ return { rejected: r.rejected, boundRunId: r.boundRunId };
407
+ }
408
+ ctx.emit?.({ plan: ctx.plan.read() ?? undefined });
409
+ return { ok: true, plan: ctx.plan.read()?.counts };
410
+ },
411
+ });
412
+
413
+ if (has("read_plan"))
414
+ set.read_plan = tool({
415
+ description:
416
+ "Read a plan's current state — yours by default, or another agent's by handle ('p_x' for the latest " +
417
+ "version, 'p_x@v2' for a specific one). Returns the goal, the items with their statuses, and the counts.",
418
+ inputSchema: z.object({
419
+ handle: z.string().optional().describe("omit for your own live plan"),
420
+ }),
421
+ execute: async ({ handle }) => {
422
+ if (!ctx.plan) return { error: "plans are not available in this context" };
423
+ const s = ctx.plan.read(handle);
424
+ if (!s) return { error: handle ? `no plan "${handle}"` : "you have no plan yet — call write_plan" };
425
+ return {
426
+ handle: s.handle, goal: s.plan.goal, counts: s.counts,
427
+ items: s.items.map((i) => ({ id: i.id, text: i.text, status: i.status, assignee: i.assignee, engineOwned: !!i.boundRunId, note: i.note })),
428
+ };
429
+ },
430
+ });
431
+
432
+ if (has("delegate_task"))
433
+ set.delegate_task = tool({
434
+ description:
435
+ "Delegate a goal to another agent by id, or to a TEAM by its id — a team routes the goal to its " +
436
+ "lead, or to whichever member best fits the goal. Prefer addressing the team when one covers the " +
437
+ "work; you are not expected to know who sits on it. Hand work over BY REFERENCE via `inputArtifacts` " +
438
+ "(artifact handles the child reads with read_artifact) rather than pasting content into `context`; " +
439
+ "you get back the child's output artifact handles + a short summary — not its full text. " +
440
+ "Optionally pass `criteria` — a plain-language contract for what 'done' means (e.g. \"a markdown " +
441
+ "dossier with ≥5 cited, dated sources\"). When you set criteria, the child's output is judged by an " +
442
+ "independent check before you get it; a failing check triggers one automatic retry with feedback, " +
443
+ "and a still-failing result comes back with its failed verdict attached so you can see the caveat. " +
444
+ "Set criteria whenever the output has concrete requirements you'd otherwise have to re-check by hand.",
445
+ inputSchema: z.object({
446
+ to: z.string().describe("an agent id, or a team id (ids share one namespace, so a bare name is unambiguous)"),
447
+ goal: z.string(),
448
+ context: z.string().optional(),
449
+ criteria: z.string().optional().describe("acceptance criteria the output must meet; enables an independent check + one retry"),
450
+ inputArtifacts: z.array(z.string()).optional().describe("artifact handles ('id' or 'id@vN') to hand to the child by reference"),
451
+ itemId: z.string().optional().describe("an item on your plan this delegation fulfils; the ENGINE then ticks it from the child's real outcome (and only when the criteria check passes)"),
452
+ }),
453
+ execute: async ({ to, goal, context, criteria, inputArtifacts, itemId }, options) => {
454
+ // The AI SDK's toolCallId is the SAME id the instrument() wrapper stamps on this call's
455
+ // tool_start/tool_end span (Plan 02), so threading it into runChild lets the LIVE waterfall nest
456
+ // each child under the EXACT delegate_task span that spawned it — deterministic for concurrent
457
+ // delegations in one turn, mirroring the post-hoc childRunId linkage.
458
+ const spawnCallId = options?.toolCallId;
459
+ const budgetMsg = () => `work item budget (${agent.budgets.maxWorkItemsPerRequest}) exhausted`;
460
+ // Each delegation (initial AND the verification retry) consumes one work item — config
461
+ // disposes, so the retry is no new runaway vector.
462
+ ctx.workItems.n += 1;
463
+ if (ctx.workItems.n > agent.budgets.maxWorkItemsPerRequest) {
464
+ ctx.notes.push(`delegate refused: ${budgetMsg()}`);
465
+ return { error: budgetMsg() };
466
+ }
467
+ // Plan 19: `to` may be a team. Resolve it to the agent that will actually run BEFORE any guard
468
+ // fires, so the cycle/depth checks apply to that agent rather than to a team id that is never
469
+ // in the ancestry chain. Everything downstream — runChild, the checker, the retry — sees a
470
+ // plain agent id and does not know a team was involved.
471
+ const guard = ctx.resolveDelegation(to, goal);
472
+ if (!guard.ok) { ctx.notes.push(`delegate refused: ${guard.error}`); return { error: guard.error }; }
473
+ const target = guard.agentId;
474
+ if (guard.note) {
475
+ // Never a silent pick: rankAgents is a keyword match and will sometimes choose badly.
476
+ ctx.notes.push(guard.note);
477
+ ctx.emit?.({ note: guard.note });
478
+ }
479
+
480
+ // Plan 18: bind the plan item to this delegation. From here the ENGINE owns its terminal status —
481
+ // the model may narrate, but it may not declare a delegated item done. Bind BEFORE the child runs
482
+ // so a crash mid-delegation leaves an `in_progress` item for reconcilePlans to mark interrupted.
483
+ const bind = (status: PlanItemStatus, note?: string, boundRunId?: string) => {
484
+ if (!itemId || !ctx.plan) return;
485
+ const r = ctx.plan.tick({ itemId, status, note, by: "engine", boundRunId });
486
+ if (!r.ok) ctx.notes.push(`plan bind failed (${itemId}): ${r.rejected}`);
487
+ else ctx.emit?.({ plan: ctx.plan.read() ?? undefined });
488
+ };
489
+ bind("in_progress", `delegated to ${target}`, `pending:${spawnCallId ?? target}`);
490
+
491
+ // resolve input handles; drop (and note) any that don't exist rather than passing a dead ref.
492
+ const resolved: string[] = [], dropped: string[] = [];
493
+ for (const h of inputArtifacts ?? []) (readArtifact(ctx.ws, h) ? resolved : dropped).push(h);
494
+ if (dropped.length) ctx.notes.push(`delegate: dropped unknown input artifact(s) ${dropped.join(", ")}`);
495
+
496
+ // Spawn one child run (initial OR the verification retry). Both get the SAME input handles BY
497
+ // REFERENCE, and each spawn folds its spend + produced handles into this run's aggregate/graph.
498
+ const spawn = async (childContext?: string) => {
499
+ const child = await ctx.runChild({ to: target, goal, context: childContext, criteria, inputArtifacts: resolved, callId: spawnCallId, viaTeam: guard.viaTeam });
500
+ ctx.delegatedOut.push(child.runId);
501
+ ctx.childTraces.push(child.trace);
502
+ ctx.outputArtifacts.push(...child.trace.artifacts); // hand-off graph: handles the child produced
503
+ const agg = child.trace.aggregate ?? { tokens: child.trace.tokens, costUsd: child.trace.costUsd };
504
+ ctx.childSpend.tokens += agg.tokens;
505
+ ctx.childSpend.costUsd += agg.costUsd ?? 0;
506
+ return child;
507
+ };
508
+
509
+ // What the checker actually judges. Workers hand off BY REFERENCE (Plan 14) — the child's chat
510
+ // reply is a hand-off sentence ("Saved the brief as brief@v2"), NOT the deliverable, so judging
511
+ // `child.text` against content criteria false-fails EVERY artifact-producing worker (the checker
512
+ // literally never sees the content). Resolve the child's PRODUCED artifact bodies (latest version
513
+ // per id) and judge those; fall back to the chat text only when the child produced no artifact
514
+ // (a genuinely text-only delegation, today's behavior).
515
+ const candidateOutput = (c: RunResult): string => {
516
+ const bodies: string[] = [];
517
+ for (const h of latestHandlePerId(c.trace.artifacts)) {
518
+ try {
519
+ const body = readArtifactBody(ctx.ws, h);
520
+ if (body && body.length) bodies.push(`ARTIFACT ${h}:\n${body.toString("utf8")}`);
521
+ } catch { /* unreadable handle → skip, fall through to chat text if none resolve */ }
522
+ }
523
+ if (!bodies.length) return c.text;
524
+ return c.text ? `${c.text}\n\n${bodies.join("\n\n")}` : bodies.join("\n\n");
525
+ };
526
+
527
+ try {
528
+ let child = await spawn(context);
529
+ ctx.inputArtifacts.push(...resolved); // hand-off graph: handles I sent down (same set for any retry)
530
+
531
+ // No criteria ⇒ no check ⇒ today's trust-everything behavior, zero extra cost.
532
+ // Parent context gets handles + a summary, NOT the child's full body (the pollution vector).
533
+ if (!criteria) {
534
+ // No criteria ⇒ the checkbox follows the child's OUTCOME, which is still the truth about
535
+ // whether the run finished, not the model's claim about it.
536
+ bind(child.trace.outcome === "completed" ? "done" : child.trace.outcome === "blocked" ? "blocked" : "failed",
537
+ child.trace.outcome === "completed" ? undefined : `child run ${child.trace.outcome}`, child.runId);
538
+ return { to: target, team: guard.team, runId: child.runId, outputArtifacts: child.trace.artifacts, summary: child.text };
539
+ }
540
+ const criteriaText = criteria; // narrowed to string past the guard (kept for the surface closure)
541
+
542
+ // Plan 06 Phase 3 — surface a FAILED verdict two ways, then hand the caveat to the parent:
543
+ // (1) as an ANNOTATION on the artifact version the hand-off involved (the child's output, else
544
+ // the input it was revising) — the SAME object a human review produces, so a machine
545
+ // verdict rides the identical annotation → revision path (Plan 01 Ph4);
546
+ // (2) into COACHING — a REPEATED (agent, criteria) failure PROPOSES a policy note (captain-
547
+ // gated, inert until approved), never an auto-applied policy.
548
+ // A passing verdict does neither; either way the parent still gets the verdict attached below.
549
+ const surface = (v: VerificationVerdict, c: RunResult) => {
550
+ if (!v.pass) {
551
+ // Anchor the FAIL to the exact bytes the checker judged, WITHOUT smearing onto stale or
552
+ // merely-referenced handles. annotateArtifact pins a handle to a concrete version, so:
553
+ // - child produced output → its LATEST version PER id (a child that saved out@v1 then
554
+ // out@v2 is judged on out@v2; v1 must NOT also collect the verdict);
555
+ // - child produced nothing → the input(s) it was REVISING (they carry OPEN feedback), not
556
+ // every reference it was handed (a spec/source it only read shouldn't collect a FAIL);
557
+ // absent any open-feedback signal, fall back to the single most-recent input, never all.
558
+ let targets: string[];
559
+ if (c.trace.artifacts.length) {
560
+ targets = latestHandlePerId(c.trace.artifacts);
561
+ } else {
562
+ const revised = resolved.filter((h) => {
563
+ try { return listAnnotations(ctx.ws, h, { status: "open" }).length > 0; } catch { return false; }
564
+ });
565
+ targets = revised.length ? latestHandlePerId(revised) : resolved.slice(-1);
566
+ }
567
+ for (const h of targets) {
568
+ try {
569
+ const ann = annotateArtifact(ctx.ws, {
570
+ target: h, author: "checker",
571
+ body: v.reasons.length ? v.reasons.join("; ") : "output did not meet the acceptance criteria",
572
+ verdict: v,
573
+ });
574
+ ctx.notes.push(`verification verdict annotated on ${ann.target} (${ann.id})`);
575
+ } catch (e) {
576
+ ctx.notes.push(`verdict annotation skipped for ${h}: ${e instanceof Error ? e.message : String(e)}`);
577
+ }
578
+ }
579
+ try {
580
+ const { proposed } = recordVerificationFailure(ctx.ws, { targetAgent: target, criteria: criteriaText, runId: c.runId, reasons: v.reasons });
581
+ if (proposed) ctx.emit?.({ note: `⚑ ${target} keeps failing this check — proposed a coaching note (${proposed.id}) for your approval` });
582
+ } catch (e) {
583
+ ctx.notes.push(`coaching proposal skipped: ${e instanceof Error ? e.message : String(e)}`);
584
+ }
585
+ }
586
+ // Plan 18 × Plan 06: with criteria set, the box goes GREEN only when an INDEPENDENT model
587
+ // call agrees the child met them. The checkbox stops being a progress bar and becomes an
588
+ // assertion. A failed verdict carries its reasons onto the item.
589
+ bind(
590
+ v.pass ? "done" : c.trace.outcome === "blocked" ? "blocked" : "failed",
591
+ v.pass ? undefined : v.reasons.length ? v.reasons.join("; ") : "did not meet the acceptance criteria",
592
+ c.runId,
593
+ );
594
+ return { to: target, team: guard.team, runId: c.runId, outputArtifacts: c.trace.artifacts, summary: c.text, verification: v };
595
+ };
596
+
597
+ // Independent checker call, BEFORE the result reaches the parent's context. Its spend is
598
+ // real model spend this run caused → fold it into the aggregate (like child-run spend).
599
+ const first = await ctx.checkCriteria({ goal, criteria, output: candidateOutput(child) });
600
+ ctx.verifierSpend.tokens += first.tokens;
601
+ ctx.verifierSpend.costUsd += first.costUsd ?? 0; // null (subscription) folds as 0 in the sum; recorded value stays null
602
+ ctx.verifications.push({ criteria, verdict: first.verdict, runId: child.runId, retried: false, tokens: first.tokens, costUsd: first.costUsd, costNote: first.costNote, checkerError: first.checkerError });
603
+ // Plan 20: the judge never RAN — this verdict says nothing about the OUTPUT. Skip the retry
604
+ // (re-running the child is pointless when the checker is down and would burn a work item),
605
+ // skip annotation + coaching (they'd blame the artifact for an outage), settle the item from
606
+ // the child's REAL outcome like the no-criteria path, and surface the result UNVERIFIED.
607
+ if (first.checkerError) {
608
+ ctx.emit?.({ note: `⚠ verification checker unavailable — surfacing ${target} result UNVERIFIED (${first.verdict.reasons.join("; ")})` });
609
+ bind(child.trace.outcome === "completed" ? "done" : child.trace.outcome === "blocked" ? "blocked" : "failed",
610
+ "verification checker unavailable — settled from run outcome", child.runId);
611
+ return { to: target, team: guard.team, runId: child.runId, outputArtifacts: child.trace.artifacts, summary: child.text, verification: first.verdict };
612
+ }
613
+ let verdict = first.verdict;
614
+
615
+ if (!verdict.pass) {
616
+ ctx.emit?.({ note: `↻ ${target} output failed verification: ${verdict.reasons.join("; ")} — retrying once` });
617
+ // Exactly ONE bounded retry — consumes a work item like any delegation.
618
+ ctx.workItems.n += 1;
619
+ const overBudget = ctx.workItems.n > agent.budgets.maxWorkItemsPerRequest;
620
+ // Re-guard against the RESOLVED agent, never the original `to`. Passing a team id here would
621
+ // re-run the ranker, and a leadless team could hand the retry — carrying feedback about the
622
+ // FIRST agent's mistakes — to a different member entirely.
623
+ const retryGuard = ctx.resolveDelegation(target, goal);
624
+ if (overBudget || !retryGuard.ok) {
625
+ const why = overBudget ? budgetMsg() : !retryGuard.ok ? retryGuard.error : "retry blocked";
626
+ ctx.notes.push(`verification retry refused: ${why}`);
627
+ ctx.emit?.({ note: `⚠ ${target} result surfaced WITHOUT a passing verification (retry blocked: ${why})` });
628
+ return surface(verdict, child);
629
+ }
630
+ const feedback = "Your previous attempt did NOT meet the acceptance criteria. Fix these before returning:\n" +
631
+ verdict.reasons.map((r) => `- ${r}`).join("\n");
632
+ // Retry-spawn ALSO threads inputArtifacts (via the closure's `resolved`) so the retried
633
+ // child still receives the same input handles by reference.
634
+ const retry = await spawn(context ? `${context}\n\n${feedback}` : feedback);
635
+ const second = await ctx.checkCriteria({ goal, criteria, output: candidateOutput(retry) });
636
+ ctx.verifierSpend.tokens += second.tokens;
637
+ ctx.verifierSpend.costUsd += second.costUsd ?? 0; // null (subscription) folds as 0 in the sum; recorded value stays null
638
+ ctx.verifications.push({ criteria, verdict: second.verdict, runId: retry.runId, retried: true, tokens: second.tokens, costUsd: second.costUsd, costNote: second.costNote, checkerError: second.checkerError });
639
+ // Plan 20: the checker went down BETWEEN the first (real) fail and the retry's check. The
640
+ // retry may well have fixed the output — an outage verdict must not annotate/coach it.
641
+ if (second.checkerError) {
642
+ ctx.emit?.({ note: `⚠ verification checker unavailable on the retry — surfacing ${target} result UNVERIFIED` });
643
+ bind(retry.trace.outcome === "completed" ? "done" : retry.trace.outcome === "blocked" ? "blocked" : "failed",
644
+ "verification checker unavailable — settled from run outcome", retry.runId);
645
+ return { to: target, team: guard.team, runId: retry.runId, outputArtifacts: retry.trace.artifacts, summary: retry.text, verification: second.verdict };
646
+ }
647
+ child = retry;
648
+ verdict = second.verdict;
649
+ if (verdict.pass) ctx.emit?.({ note: `✓ ${target} passed verification after one retry` });
650
+ else ctx.emit?.({ note: `⚠ ${target} still failed verification after retry: ${verdict.reasons.join("; ")} — surfacing result with the failed verdict` });
651
+ }
652
+
653
+ // Criteria was set: always attach the verdict so the parent (and captain) sees the caveat.
654
+ // A failed verdict here ALSO lands the annotation + coaching side effects (via surface).
655
+ return surface(verdict, child);
656
+ } catch (e) {
657
+ const msg = e instanceof Error ? e.message : String(e);
658
+ ctx.notes.push(`delegate failed: ${msg}`);
659
+ // The item was bound `in_progress` before the spawn. Settle it here rather than stranding it
660
+ // until the next boot's reconcilePlans finds it and calls it "interrupted" — it isn't; the
661
+ // delegation genuinely failed, and this run is still alive to say so.
662
+ bind("failed", `delegation threw: ${msg}`);
663
+ return { error: msg };
664
+ }
665
+ },
666
+ });
667
+
668
+ // Plan 04 — background delegation. dispatch_task fires-and-forgets (returns a taskId immediately);
669
+ // check_task / await_task follow up. Results come back BY REFERENCE (summary + handles), never the
670
+ // inlined payload. Requires a host scheduler (the REPL wires ctx.dispatchTask); when unwired the
671
+ // tool cleanly reports it (a headless/unit context has no background runner).
672
+ if (has("dispatch_task"))
673
+ set.dispatch_task = tool({
674
+ description:
675
+ "Kick a goal off to another agent in the BACKGROUND and keep working — fire-and-forget. Returns " +
676
+ "a taskId immediately; the task runs off-turn. Use this (instead of delegate_task, which BLOCKS " +
677
+ "until the child returns) when you don't need the result right now — e.g. long research you'll " +
678
+ "check on later. Follow up with check_task(taskId) for status or await_task(taskId) to block on " +
679
+ "it when you finally need it. Results come back BY REFERENCE (a summary + artifact handles), never " +
680
+ "inlined. Hand inputs over with `inputArtifacts`. Unlike delegate_task, `criteria` here rides " +
681
+ "into the worker's brief but is NOT independently verified at settle (no checker, no retry on " +
682
+ "the background path). `to` may be an agent id or a team id, resolved the same way delegate_task resolves it.",
683
+ inputSchema: z.object({
684
+ to: z.string().describe("an agent id, or a team id (ids share one namespace)"),
685
+ goal: z.string(),
686
+ context: z.string().optional(),
687
+ criteria: z.string().optional().describe("acceptance criteria, passed to the worker in its brief; NOT independently checked on the background path (unlike delegate_task)"),
688
+ inputArtifacts: z.array(z.string()).optional().describe("artifact handles ('id' or 'id@vN') to hand to the task by reference"),
689
+ itemId: z.string().optional().describe("an item on your plan this task fulfils; the ENGINE ticks it when the task settles, possibly turns later"),
690
+ }),
691
+ execute: async ({ to, goal, context, criteria, inputArtifacts, itemId }) => {
692
+ if (!ctx.dispatchTask) return { error: "background dispatch is not available in this context — use delegate_task instead" };
693
+ const budgetMsg = () => `work item budget (${agent.budgets.maxWorkItemsPerRequest}) exhausted`;
694
+ // A dispatch consumes a work item like a delegation — config disposes fan-out either way.
695
+ ctx.workItems.n += 1;
696
+ if (ctx.workItems.n > agent.budgets.maxWorkItemsPerRequest) {
697
+ ctx.notes.push(`dispatch refused: ${budgetMsg()}`);
698
+ return { error: budgetMsg() };
699
+ }
700
+ // Plan 19: a team is resolved to a concrete agent HERE, on the dispatching turn — not later when
701
+ // the background run starts. The task record then names the agent that actually ran, and a
702
+ // membership change between dispatch and settle cannot silently move the work.
703
+ const guard = ctx.resolveDelegation(to, goal);
704
+ if (!guard.ok) { ctx.notes.push(`dispatch refused: ${guard.error}`); return { error: guard.error }; }
705
+ const target = guard.agentId;
706
+ if (guard.note) { ctx.notes.push(guard.note); ctx.emit?.({ note: guard.note }); }
707
+ // Resolve input handles up front; drop (and note) dead refs rather than dispatch a broken brief.
708
+ const resolved: string[] = [], dropped: string[] = [];
709
+ for (const h of inputArtifacts ?? []) (readArtifact(ctx.ws, h) ? resolved : dropped).push(h);
710
+ if (dropped.length) ctx.notes.push(`dispatch: dropped unknown input artifact(s) ${dropped.join(", ")}`);
711
+ const r = await ctx.dispatchTask({ to: target, goal, context, criteria, inputArtifacts: resolved });
712
+ if ("error" in r) { ctx.notes.push(`dispatch failed: ${r.error}`); return r; }
713
+ ctx.notes.push(`dispatched ${r.taskId} → ${target}`);
714
+ // Plan 18: bind the item to the TASK id. The dispatch returns immediately and the task settles
715
+ // off-turn — possibly several conversation turns later — so the settle path (REPL) is what ticks
716
+ // this item, via findPlanItemByBoundRun. The binding is what lets it find the item at all.
717
+ if (itemId && ctx.plan) {
718
+ const tick = ctx.plan.tick({ itemId, status: "in_progress", note: `dispatched to ${target}`, by: "engine", boundRunId: r.taskId });
719
+ if (!tick.ok) ctx.notes.push(`plan bind failed (${itemId}): ${tick.rejected}`);
720
+ else ctx.emit?.({ plan: ctx.plan.read() ?? undefined });
721
+ }
722
+ return { taskId: r.taskId, status: "queued", to: target, team: guard.team, note: `running in background — check_task("${r.taskId}") for status, or await_task to block on it` };
723
+ },
724
+ });
725
+
726
+ if (has("check_task"))
727
+ set.check_task = tool({
728
+ description: "Check a background task (from dispatch_task) WITHOUT blocking: returns its status (queued/running/completed/failed/interrupted/cancelled) plus a short summary and result handle when done. Reference-only — never the full payload; pull artifacts with read_artifact.",
729
+ inputSchema: z.object({ taskId: z.string() }),
730
+ execute: async ({ taskId }) => {
731
+ const t = readTaskState(ctx.ws, taskId);
732
+ if (!t) return { error: `no task "${taskId}"` };
733
+ return { taskId, status: t.status, to: t.agent, summary: t.summary ?? null, resultRef: t.resultRef ?? null, runId: t.rootRunId || null };
734
+ },
735
+ });
736
+
737
+ if (has("await_task"))
738
+ set.await_task = tool({
739
+ description: "BLOCK until a background task settles (bounded by a timeout), then return its final status + summary + result handle. Use when you dispatched work earlier and now genuinely need it before continuing. Reference-only — never the inlined payload.",
740
+ inputSchema: z.object({
741
+ taskId: z.string(),
742
+ timeoutMs: z.number().int().positive().max(600000).optional().describe("give up waiting after this many ms (default 120000)"),
743
+ }),
744
+ execute: async ({ taskId, timeoutMs }) => {
745
+ if (!ctx.awaitTask) {
746
+ // No scheduler wired: fall back to reading the record (already-settled tasks still resolve).
747
+ const t = readTaskState(ctx.ws, taskId);
748
+ if (!t) return { error: `no task "${taskId}"` };
749
+ return { taskId, status: t.status, summary: t.summary ?? null, resultRef: t.resultRef ?? null };
750
+ }
751
+ return { taskId, ...(await ctx.awaitTask(taskId, timeoutMs)) };
752
+ },
753
+ });
754
+
755
+ if (has("find_agents"))
756
+ set.find_agents = tool({
757
+ description: "Search the squad for agents whose role matches a capability. Returns top matches.",
758
+ inputSchema: z.object({ query: z.string(), k: z.number().int().positive().max(20).default(8) }),
759
+ execute: async ({ query, k }) => ({ matches: ctx.findAgents(query, k) }),
760
+ });
761
+
762
+ if (has("read_url"))
763
+ set.read_url = tool({
764
+ description: "Fetch a web page (e.g. an MCP server's setup docs) and return it as clean markdown. Requires FIRECRAWL_API_KEY in the environment.",
765
+ inputSchema: z.object({ url: z.string().url() }),
766
+ execute: async ({ url }) => {
767
+ const r = await scrapeUrl(url);
768
+ return "markdown" in r ? { markdown: r.markdown } : { error: r.error };
769
+ },
770
+ });
771
+
772
+ if (mcp && has("add_mcp_server"))
773
+ set.add_mcp_server = tool({
774
+ description: "Connect a NEW MCP server for the captain to approve. Provide a `url` for a remote/hosted server (with auth:'oauth' or headers), or a `command`/`args` for a local stdio server. Put any secret (API key, token) in `env` as { VAR_NAME: 'the-secret' } and reference it as ${VAR_NAME} in the url/headers — it is saved WITH the server and loaded into the environment immediately AND on every future boot, so the server just works now and after a restart. Returns the connection status + tool count; on error, fix the config and call again.",
775
+ inputSchema: z.object({
776
+ name: z.string().regex(/^[a-z][a-z0-9-]*$/, "lowercase id: letters, digits, hyphens"),
777
+ url: z.string().url().optional(),
778
+ auth: z.literal("oauth").optional(),
779
+ headers: z.record(z.string(), z.string()).optional(),
780
+ command: z.string().optional(),
781
+ args: z.array(z.string()).optional(),
782
+ env: z.record(z.string(), z.string()).optional(),
783
+ }),
784
+ execute: async ({ name, url, auth, headers, command, args, env }) => {
785
+ const raw = url
786
+ ? { url, ...(auth ? { auth } : {}), ...(headers ? { headers } : {}), ...(env ? { env } : {}) }
787
+ : command
788
+ ? { command, ...(args ? { args } : {}), ...(env ? { env } : {}) }
789
+ : null;
790
+ if (!raw) return { error: "provide a `url` (remote server) or a `command` (local stdio server)" };
791
+ const parsed = McpServerConfig.safeParse(raw);
792
+ if (!parsed.success) return { error: `invalid server config: ${parsed.error.issues[0]?.message ?? "unknown"}` };
793
+ const spec = parsed.data;
794
+ const decision = await ctx.requestApproval({ kind: "add_mcp", name, spec });
795
+ if (decision.type !== "approve") return { rejected: true };
796
+ addMcpServer(ctx.ws, name, spec);
797
+ applyMcpEnv(ctx.ws); // load the new server's env into process.env NOW, so ${VAR} resolves on this connect
798
+ const status = await mcp.addServer(name, spec);
799
+ return { name, status: status.status, toolCount: status.toolCount, error: status.error };
800
+ },
801
+ });
802
+
803
+ if (has("ask_human"))
804
+ set.ask_human = tool({
805
+ description: "Ask the human captain a clarifying question with 2-4 options when intent is ambiguous. The captain picks an option or types their own answer; you receive { answer } and continue.",
806
+ inputSchema: z.object({
807
+ question: z.string().describe("a single clear question"),
808
+ options: z.array(z.string()).min(2).max(4).describe("2-4 concrete choices"),
809
+ }),
810
+ execute: async ({ question, options }) => {
811
+ const d = await ctx.requestApproval({ kind: "ask_human", question, options });
812
+ return d.type === "answered" ? { answer: d.answer } : { cancelled: true };
813
+ },
814
+ });
815
+
816
+ if (has("remember"))
817
+ set.remember = tool({
818
+ description: "Save a durable fact / decision / entity to the squad's shared knowledgebase, optionally linking it to existing nodes with typed edges (rel e.g. relates_to, depends_on, contradicts). Returns the node id — recall first to get ids to link to.",
819
+ inputSchema: z.object({
820
+ title: z.string(),
821
+ content: z.string(),
822
+ kind: z.string().default("fact"),
823
+ summary: z.string().optional(),
824
+ edges: z.array(z.object({ to: z.string(), rel: z.string().default("relates_to") })).default([]),
825
+ }),
826
+ execute: async ({ title, content, kind, summary, edges }) => {
827
+ const requested = edges ?? [];
828
+ const valid = requested.filter((e) => nodeExists(ctx.db, e.to)); // drop dangling edge targets
829
+ const node = KbNode.parse({
830
+ id: mkKbId(), title, content, kind, summary, scope: "squad",
831
+ source: ctx.ingestSource ?? `${ctx.agentId}:${ctx.runId}`, edges: valid, created: new Date().toISOString(),
832
+ });
833
+ writeNode(ctx.ws, ctx.db, node);
834
+ if (ctx.embed) {
835
+ try { putVector(ctx.db, node.id, "kb", await ctx.embed(`${node.title}\n${node.summary ?? ""}\n${node.content}`)); }
836
+ catch { /* semantic index is best-effort; keyword+graph still works */ }
837
+ }
838
+ ctx.notes.push(`remembered ${node.id}`);
839
+ return { id: node.id, edgesAdded: valid.length, edgesDropped: requested.length - valid.length };
840
+ },
841
+ });
842
+
843
+ if (has("recall"))
844
+ set.recall = tool({
845
+ description: "Search the squad's shared knowledgebase and its typed-edge graph. Returns matching nodes plus their linked neighbors — by meaning (semantic when available) and by relationship.",
846
+ inputSchema: z.object({
847
+ query: z.string(),
848
+ k: z.number().int().positive().max(20).default(6),
849
+ hops: z.number().int().min(0).max(2).default(1),
850
+ rels: z.array(z.string()).optional(),
851
+ }),
852
+ execute: async ({ query, k, hops, rels }) => {
853
+ const r = await searchKnowledge({ db: ctx.db, query, embed: ctx.embed, k, hops, rels });
854
+ return { mode: r.mode, hits: r.hits.map((h) => ({ id: h.id, title: h.title, summary: h.summary, via: h.via, score: +h.score.toFixed(3) })) };
855
+ },
856
+ });
857
+
858
+ if (has("read_source"))
859
+ set.read_source = tool({
860
+ description: "Read an admin-authored source document from kb/sources/ so you can extract entities from it. `path` is like \"sources/architecture.md\" or \"architecture.md\".",
861
+ inputSchema: z.object({ path: z.string() }),
862
+ execute: async ({ path }) => {
863
+ const name = path.replace(/^sources\//, "");
864
+ const dir = paths.kbSourceDir(ctx.ws);
865
+ const full = resolve(dir, name);
866
+ if (full !== dir && !full.startsWith(dir + sep)) return { error: "path must be a file under kb/sources/" };
867
+ try { return { content: await readFile(full, "utf8") }; }
868
+ catch { return { error: `no such source: ${name}` }; }
869
+ },
870
+ });
871
+
872
+ if (has("forget"))
873
+ set.forget = tool({
874
+ description: "Prune the knowledgebase: cascade-delete nodes matching a filter, plus their edges and vectors. Filter by `kind` (e.g. decision), `sourcePrefix` (e.g. \"worker-x:\" for one assistant's memory, or \"sources/foo.md@\" for a doc), and/or explicit `ids`. At least one clause is required.",
875
+ inputSchema: z.object({
876
+ ids: z.array(z.string()).optional(),
877
+ kind: z.string().optional(),
878
+ sourcePrefix: z.string().optional(),
879
+ }),
880
+ execute: async ({ ids, kind, sourcePrefix }) => {
881
+ if (!ids?.length && !kind && !sourcePrefix) return { error: "provide at least one of ids, kind, or sourcePrefix" };
882
+ const r = forgetNodes(ctx.ws, ctx.db, { ids, kind, sourcePrefix });
883
+ ctx.notes.push(`forgot ${r.removedNodes} node(s)`);
884
+ return r;
885
+ },
886
+ });
887
+
888
+ if (has("reindex_knowledge"))
889
+ set.reindex_knowledge = tool({
890
+ description: "Rebuild the knowledge graph index from the canonical node files and refresh semantic vectors. Use after bulk hand-edits.",
891
+ inputSchema: z.object({}),
892
+ execute: async () => {
893
+ reindexKnowledge(ctx.ws, ctx.db);
894
+ const embedded = ctx.embed ? await reembedAll(ctx.db, ctx.embed) : 0;
895
+ return { reindexed: true, embedded };
896
+ },
897
+ });
898
+
899
+ if (has("propose_skill"))
900
+ set.propose_skill = tool({
901
+ description: "Propose a reusable skill (a reviewed step-by-step procedure for a repeatable operation) for the captain to approve. On approval it's saved and every agent can use it via use_skill.",
902
+ inputSchema: z.object({
903
+ name: z.string(),
904
+ description: z.string().describe("when to use this skill"),
905
+ body: z.string().describe("the step-by-step procedure"),
906
+ tags: z.array(z.string()).default([]),
907
+ }),
908
+ execute: async ({ name, description, body, tags }) => {
909
+ const draft = { name, description, body, tags: tags ?? [] };
910
+ const d = await ctx.requestApproval({ kind: "propose_skill", draft });
911
+ if (d.type !== "approve") return { rejected: true };
912
+ const skill = Skill.parse({ id: mkSkillId(), name, description, body, tags: draft.tags, status: "active", created: new Date().toISOString() });
913
+ writeSkill(ctx.ws, ctx.db, skill);
914
+ ctx.notes.push(`proposed skill ${skill.id}`);
915
+ return { id: skill.id };
916
+ },
917
+ });
918
+
919
+ if (has("run_command"))
920
+ set.run_command = tool({
921
+ description: "Run a shell command in the workspace. It runs in a restricted SANDBOX first (no network; writes confined to the workspace) — a command the sandbox completes returns straight away. The safety guard clears benign commands; anything it flags — OR any command proposed after UNTRUSTED content (a fetched web page or an MCP tool result) has entered this run — needs the captain's approval first. A command the sandbox can't complete escalates to a captain-approved unsandboxed run. Returns { exitCode, stdout, stderr, sandbox }.",
922
+ inputSchema: z.object({ command: z.string(), cwd: z.string().optional().describe("working directory (defaults to the workspace); a cwd outside the workspace can't be sandbox-confined, so it needs the captain's approval") }),
923
+ execute: async ({ command, cwd }) => {
924
+ const classify = ctx.classifyCommand ?? classifyCommand;
925
+ const sandboxed = ctx.runSandboxed ?? runSandboxed;
926
+ const run = ctx.runShell ?? runShell;
927
+ const workdir = cwd ?? ctx.ws;
928
+ const v = classify(command);
929
+ const tainted = ctx.untrusted?.entered === true;
930
+ // Sandbox-escape guard (Plan 08): the model can name its own `cwd`, but the sandbox's writable
931
+ // set is anchored to ctx.ws — never the model cwd. A cwd that resolves OUTSIDE realpath(ctx.ws)
932
+ // is therefore NOT auto-runnable: we route it to the captain (who sees WHERE it runs) rather
933
+ // than silently widening the writable set or letting an unconfined write slip through.
934
+ const escapesWs = cwd !== undefined && !isInsideWorkspace(ctx.ws, workdir);
935
+
936
+ // Explicit-review path — a dcg `block` verdict, the injection guard (untrusted content has
937
+ // already entered this run), OR a cwd that escapes the workspace routes the command to the
938
+ // captain's approval card. Critically, a dcg `allow` does NOT bypass the injection guard:
939
+ // ingest-untrusted-then-run-shell is the classic prompt-injection→execution chain, so once
940
+ // tainted the human MUST see the command. On approve it runs as the captain reviewed it (they
941
+ // inspected the exact command AND its cwd); on reject, nothing runs. No sandbox dance here — the
942
+ // human IS the gate for these.
943
+ if (v.decision !== "allow" || tainted || escapesWs) {
944
+ const parts: string[] = [];
945
+ if (tainted) parts.push(`untrusted content (${ctx.untrusted.sources.join(", ") || "external source"}) entered this run before this command — possible prompt-injection→execution`);
946
+ if (escapesWs) parts.push(`the working directory (${workdir}) is OUTSIDE the workspace (${ctx.ws}) — the sandbox can't confine writes there; approve to run it there UNSANDBOXED`);
947
+ if (v.decision !== "allow" && v.reason) parts.push(v.reason);
948
+ const reason = parts.join("; ") || "confirm to run";
949
+ const d = await ctx.requestApproval({ kind: "run_command", command, cwd: workdir, reason });
950
+ if (d.type !== "approve") return { rejected: true };
951
+ return { ...run(command, workdir), sandbox: "approved" as const };
952
+ }
953
+
954
+ // Auto-run path (dcg cleared it AND no untrusted content) — sandbox-then-escalate. Contain the
955
+ // command FIRST (least privilege: no network, writes confined to the workspace). A clean run —
956
+ // OR a benign non-zero exit the sandbox did NOT cause (e.g. `grep` no-match) — returns straight
957
+ // to the model with ZERO human friction. We ESCALATE (ask the captain to approve an unsandboxed
958
+ // run, never silently) only when either: the sandbox couldn't be enforced on this host, or it
959
+ // actively DENIED the command a privilege ("Operation not permitted" — a write-escape outside
960
+ // the workspace). NOTE (declared limitation): a network denial surfaces as an ordinary network
961
+ // error, indistinguishable from a real outage, so it is NOT auto-escalated — the model sees the
962
+ // failure and should reach the network via read_url / an MCP tool, not raw shell.
963
+ // Writable root is ctx.ws (NOT workdir) — the model's cwd never widens the confined write set.
964
+ const sb = sandboxed(command, workdir, ctx.ws);
965
+ const sandboxDenied = sb.enforced && sb.exitCode !== 0 && /operation not permitted|sandbox/i.test(sb.stderr);
966
+ if (sb.enforced && !sandboxDenied)
967
+ return { exitCode: sb.exitCode, stdout: sb.stdout, stderr: sb.stderr, sandbox: "enforced" as const };
968
+ const escalateReason = sb.enforced
969
+ ? `the sandbox DENIED this command an operation it needs (exit ${sb.exitCode}: "operation not permitted" — it tried to write outside the workspace). Approve to re-run WITHOUT the sandbox.`
970
+ : "no OS sandbox is enforced on this host — approve to run this command WITHOUT a sandbox.";
971
+ const d = await ctx.requestApproval({ kind: "run_command", command, cwd: workdir, reason: escalateReason });
972
+ if (d.type !== "approve")
973
+ return sb.enforced
974
+ ? { exitCode: sb.exitCode, stdout: sb.stdout, stderr: sb.stderr, sandbox: "enforced" as const, escalationDeclined: true }
975
+ : { rejected: true };
976
+ return { ...run(command, workdir), sandbox: "unsandboxed" as const };
977
+ },
978
+ });
979
+
980
+ // Skills are a universal agent capability (like the MCP-tools grant): every agent can discover and
981
+ // load reviewed procedures. Not gated by agent.tools; built-ins still win over MCP tools below.
982
+ set.find_skills = tool({
983
+ description: "Search the squad's reusable skills (reviewed procedures for repeatable operations) by what you're trying to do. Returns matching skill names + when to use them; call use_skill to load the full procedure.",
984
+ inputSchema: z.object({ query: z.string(), k: z.number().int().positive().max(20).default(6) }),
985
+ execute: async ({ query, k }) => ({ matches: rankSkills(getActiveSkills(ctx.db), query, k).map((h) => ({ id: h.id, name: h.name, description: h.description })) }),
986
+ });
987
+
988
+ set.use_skill = tool({
989
+ description: "Load the full step-by-step procedure for a skill by name, then follow it. Use this for repeatable operations so you do them the reviewed way with fewer mistakes.",
990
+ inputSchema: z.object({ name: z.string() }),
991
+ execute: async ({ name }) => {
992
+ const rows = getActiveSkills(ctx.db);
993
+ const s = rows.find((r) => r.name === name) ?? rows.find((r) => r.id === name);
994
+ return s ? { name: s.name, body: s.body } : { error: `no skill "${name}" — call find_skills to discover available skills` };
995
+ },
996
+ });
997
+
998
+ // Per-agent MCP allowlist (Plan 08 security hardening). An agent opts into MCP capability the same
999
+ // way it opts into a built-in: a `mcp:<server>` entry in agent.tools grants EVERY tool that server
1000
+ // exposes; `mcp:<server>/<tool>` grants exactly one. Ungranted MCP tools are NEVER handed to this
1001
+ // agent — previously every connected server's tools went to every agent ("gatekeeping can come
1002
+ // later"); this IS that gate. Built-ins already in `set` win (first-wins), so an MCP tool still
1003
+ // can't shadow a privileged built-in (e.g. a server tool namespacing to create_agent).
1004
+ const mcpToolNames = new Set<string>();
1005
+ if (mcp)
1006
+ for (const ref of granted) {
1007
+ if (!ref.startsWith("mcp:")) continue;
1008
+ for (const [k, v] of Object.entries(mcp.toolsForRef(ref.slice("mcp:".length))))
1009
+ if (!(k in set)) { set[k] = v; mcpToolNames.add(k); }
1010
+ }
1011
+
1012
+ // Untrusted-content sources (Plan 08 injection guard): every channel that can carry
1013
+ // attacker-influenceable text INTO this run. instrument() arms ctx.untrusted the moment one of these
1014
+ // returns; run_command then forces the captain's approval (a dcg `allow` no longer auto-runs it).
1015
+ // - read_url .................. a fetched web page
1016
+ // - ANY granted MCP tool ...... arbitrary server-returned content
1017
+ // - read_artifact ............. artifacts are the PRIMARY cross-agent hand-off (fetch a page →
1018
+ // save_artifact → read it elsewhere), so a read carries prior taint
1019
+ // - recall / search_knowledge . the shared KB can hold ingested (hence tainted) content
1020
+ // - read_source ............... admin-authored source docs are still externally-authored text
1021
+ // - delegate/await/dispatch/check_task — a child's summary is content a child run may have ingested
1022
+ // Deliberately conservative: touching ANY of these arms the guard, erring toward MORE approval, never
1023
+ // less. Names not present in `set` (ungranted tools) simply never fire — the instrument seam only
1024
+ // flips the guard for a tool that actually ran, so listing them all here is safe and future-proof.
1025
+ const untrustedSources = new Set<string>(mcpToolNames);
1026
+ for (const name of [
1027
+ "read_url", "read_artifact", "recall", "read_source",
1028
+ "delegate_task", "await_task", "dispatch_task", "check_task",
1029
+ // list_annotations surfaces feedback bodies — an agent may have ingested a page then annotated, so
1030
+ // annotation text is attacker-influenceable content entering this run (same reasoning as read_artifact).
1031
+ "list_annotations",
1032
+ ]) untrustedSources.add(name);
1033
+
1034
+ return instrument(set, ctx, untrustedSources);
1035
+ }
1036
+
1037
+ /** Wrap every tool's `execute()` — the ONE seam Plan 02 (accurate span bars) and Plan 10 (live
1038
+ * "working: read_url …" status) share. Emits `tool_start`/`tool_end` (with a redacted, capped
1039
+ * argsPreview) into the run's `spanEvents` (→ transcript.jsonl → waterfall) and a live typed phase
1040
+ * via `ctx.emitStep`. Rebuilds each tool object (never mutates the shared MCP tool instances). A
1041
+ * `delegate_task` result's `runId` is captured on `tool_end` so the waterfall can nest the child
1042
+ * run under its delegating tool span.
1043
+ *
1044
+ * Plan 08: this is also where the injection guard arms. When a tool in `untrustedSources` (read_url,
1045
+ * any granted MCP tool, read_artifact, recall/search_knowledge, read_source, or a delegation-result
1046
+ * tool — see the set built in toolsForAgent) RETURNS, we flip ctx.untrusted.entered — attacker-
1047
+ * influenceable content has now entered the run, so a later run_command must be human-confirmed.
1048
+ * Deliberately conservative: touching an untrusted source at all arms the guard (even an error return
1049
+ * means the run reacted to external I/O), which errs toward MORE approval, never less. */
1050
+ function instrument(set: ToolSet, ctx: RunContext, untrustedSources?: Set<string>): ToolSet {
1051
+ let seq = 0;
1052
+ const out: ToolSet = {};
1053
+ for (const [name, t] of Object.entries(set)) {
1054
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1055
+ const orig = (t as any).execute;
1056
+ if (typeof orig !== "function") { out[name] = t; continue; }
1057
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1058
+ const execute = async (input: unknown, options: any) => {
1059
+ const callId: string = options?.toolCallId ?? `${name}#${++seq}`;
1060
+ const preview = argsPreview(name, input);
1061
+ ctx.spanEvents?.push({ ts: new Date().toISOString(), kind: "tool_start", data: { tool: name, callId, argsPreview: preview, args: capJson(input) } });
1062
+ ctx.emitStep?.({ phase: "tool_start", tool: name, argsPreview: preview, callId });
1063
+ // Plan 16: taicho's own tool span — named `<tool> · <preview>` (e.g. "delegate_task · researcher:
1064
+ // …", "write_artifact · findings") so the trace reads meaningfully, carrying args in / result out,
1065
+ // and made ACTIVE so a delegated child run nests under this exact tool span.
1066
+ const tel = ctx.telemetry;
1067
+ const span = tel?.tracerFor(ctx.agentId).startSpan(preview ? `${name} · ${preview}` : name, {
1068
+ attributes: { "taicho.tool": name, ...(tel.captureContent ? ioAttrs("input", capJson(input)) : {}) },
1069
+ });
1070
+ let result: unknown, err: string | undefined;
1071
+ try {
1072
+ result = await (span
1073
+ ? otelContext.with(otelTrace.setSpan(otelContext.active(), span), () => orig(input, options))
1074
+ : orig(input, options));
1075
+ // Arm the injection guard once an untrusted source returns (see fn doc). Guarded on
1076
+ // ctx.untrusted so partial unit-test contexts without the field don't crash.
1077
+ if (ctx.untrusted && untrustedSources?.has(name)) {
1078
+ ctx.untrusted.entered = true;
1079
+ if (!ctx.untrusted.sources.includes(name)) ctx.untrusted.sources.push(name);
1080
+ }
1081
+ return result;
1082
+ } catch (e) {
1083
+ err = e instanceof Error ? e.message : String(e);
1084
+ span?.setStatus({ code: SpanStatusCode.ERROR, message: err });
1085
+ throw e;
1086
+ } finally {
1087
+ const childRunId =
1088
+ result && typeof result === "object" && typeof (result as { runId?: unknown }).runId === "string"
1089
+ ? (result as { runId: string }).runId
1090
+ : undefined;
1091
+ ctx.spanEvents?.push({ ts: new Date().toISOString(), kind: "tool_end", data: { tool: name, callId, ok: !err, error: err, childRunId, result: capJson(result) } });
1092
+ ctx.emitStep?.({ phase: "tool_end", tool: name, ok: !err, callId });
1093
+ if (span) {
1094
+ if (tel!.captureContent && !err) span.setAttributes(ioAttrs("output", capJson(result)));
1095
+ if (childRunId) span.setAttribute("taicho.child_run", childRunId);
1096
+ span.end();
1097
+ }
1098
+ }
1099
+ };
1100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1101
+ out[name] = { ...(t as any), execute } as any;
1102
+ }
1103
+ return out;
1104
+ }