@mono-agent/agent-runtime 0.15.3 → 0.15.4

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 (36) hide show
  1. package/README.md +43 -6
  2. package/package.json +5 -1
  3. package/src/agent/tools/agent-tool.js +859 -0
  4. package/src/agent/tools/bash.js +241 -123
  5. package/src/agent/tools/exec.js +238 -0
  6. package/src/agent/tools/index.js +10 -3
  7. package/src/agent/tools/node-repl.js +231 -95
  8. package/src/agent/tools/pi-bridge.js +115 -24
  9. package/src/agent/tools/shared/process-runner.js +162 -0
  10. package/src/agent/tools/shared/semaphore.js +73 -0
  11. package/src/agent/tools/web-browser-render.js +221 -0
  12. package/src/agent/tools/web-controller.js +160 -0
  13. package/src/agent/tools/web-fetch.js +653 -68
  14. package/src/agent/tools/web-search.js +568 -16
  15. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  16. package/src/ai/providers/pi-native/turn-runner.js +60 -5
  17. package/src/ai/providers/pi-native.js +49 -5
  18. package/src/ai/runtime/router.js +302 -166
  19. package/src/ai/types.js +52 -1
  20. package/src/runtime.js +51 -1
  21. package/types/agent/tools/agent-tool.d.ts +60 -0
  22. package/types/agent/tools/bash.d.ts +55 -7
  23. package/types/agent/tools/exec.d.ts +53 -0
  24. package/types/agent/tools/index.d.ts +5 -3
  25. package/types/agent/tools/node-repl.d.ts +28 -3
  26. package/types/agent/tools/pi-bridge.d.ts +6 -2
  27. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  28. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  29. package/types/agent/tools/web-browser-render.d.ts +16 -0
  30. package/types/agent/tools/web-controller.d.ts +20 -0
  31. package/types/agent/tools/web-fetch.d.ts +74 -5
  32. package/types/agent/tools/web-search.d.ts +81 -5
  33. package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
  34. package/types/ai/providers/pi-native.d.ts +12 -0
  35. package/types/ai/runtime/router.d.ts +23 -3
  36. package/types/ai/types.d.ts +163 -1
@@ -0,0 +1,859 @@
1
+ // The `Agent` built-in: delegate a self-contained task to a subagent that runs
2
+ // independently and reports back.
3
+ //
4
+ // Deliberately NOT built with `createBuiltinTool`. That wrapper normalizes
5
+ // filesystem params (meaningless here), tracks file writes (N/A), and — the
6
+ // real hazard — rethrows any result text matching /^Error:/ as a tool failure,
7
+ // which would reclassify a subagent whose *final answer* happens to start with
8
+ // "Error:" and throw away its activity log. This mirrors
9
+ // `createStructuredOutputTool` instead, building the pi tool object directly;
10
+ // the shared approval gate and bloat guard still wrap it, because those are
11
+ // applied to the whole tool array in `getPiBuiltinTools`.
12
+
13
+ // @ts-check
14
+
15
+ import { homedir } from "node:os";
16
+
17
+ import { createCountingSemaphore } from "./shared/semaphore.js";
18
+
19
+ /** @typedef {import('../../ai/types.js').RuntimeSubagentDefinition} RuntimeSubagentDefinition */
20
+ /** @typedef {import('../../ai/types.js').RuntimeSubagentsOptions} RuntimeSubagentsOptions */
21
+
22
+ export const GENERAL_PURPOSE_SUBAGENT = "general-purpose";
23
+
24
+ /**
25
+ * Read-only by default. A profile that needs a shell or writes must say so in
26
+ * config: widening a subagent's reach is an operator decision, not one the
27
+ * model makes at call time.
28
+ */
29
+ export const DEFAULT_SUBAGENT_TOOLS = Object.freeze(["Read", "Glob", "Grep", "WebFetch", "WebSearch"]);
30
+
31
+ /**
32
+ * Never available to a subagent, whatever a profile asks for. `Agent` is the
33
+ * third independent recursion lock; the rest would let a helper hijack the
34
+ * user's conversation or post to a channel on the main agent's behalf.
35
+ */
36
+ export const SUBAGENT_HARD_DENY = Object.freeze([
37
+ "Agent",
38
+ "AskUser",
39
+ "SlackSendMessage",
40
+ "TelegramSendMessage",
41
+ "TelegramSendFile",
42
+ ]);
43
+
44
+ const DEFAULT_MAX_CONCURRENT = 5;
45
+ const DEFAULT_MAX_PER_TURN = 20;
46
+ const DEFAULT_MAX_TURNS = 100;
47
+ const DEFAULT_TIMEOUT_MS = 300_000;
48
+ /** Shape an authored subagent's name must take, mirroring a configured one. */
49
+ const INLINE_NAME_RE = /^[a-z0-9][a-z0-9-]{0,39}$/u;
50
+ /** Effort levels a caller may pin on an authored subagent. Mirrors EFFORT_LEVELS in @mono-agent/config. */
51
+ const EFFORT_LEVELS = Object.freeze(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
52
+ /** Grace after the abort signal before the deadline stops waiting on a runner. */
53
+ const DEADLINE_GRACE_MS = 5_000;
54
+ /** Sentinel distinguishing "deadline won the race" from a real child result. */
55
+ const DEADLINE = Symbol("subagent-deadline");
56
+
57
+ // Kept an order of magnitude under the bloat guard's 256 KiB default: when that
58
+ // guard fires it replaces the whole payload with an artifact pointer, which
59
+ // would discard the subagent's answer entirely.
60
+ const ANSWER_MAX_CHARS = 12_000;
61
+ const LOG_MAX_LINES = 60;
62
+ const LOG_HEAD_LINES = 25;
63
+ const LOG_TAIL_LINES = 30;
64
+ const LOG_LINE_MAX_CHARS = 160;
65
+ const RESULT_MAX_BYTES = 24_000;
66
+ /** Aggregate ceiling for every Agent result in one logical turn. */
67
+ const TURN_RESULT_MAX_BYTES = 120_000;
68
+ /** Bound on retained per-run budget entries for a long-lived host. */
69
+ const MAX_TRACKED_RUNS = 32;
70
+
71
+ const DESCRIPTION_BASE = `Delegate a self-contained task to a subagent that works independently and reports back.
72
+
73
+ Use this when a task is (a) well-scoped, (b) likely to need many tool calls or a lot of reading you do not want in your own context, and (c) answerable with a written summary. Good: "find every call site of X and summarize the patterns", "read these 12 files and report which handle Y". Bad: anything needing back-and-forth, anything where you need raw output rather than a summary, or a task you could finish in one or two tool calls yourself.
74
+
75
+ Hard constraints, plan around them:
76
+ - The subagent starts with an EMPTY context. It cannot see this conversation, the user's message, or your earlier tool results. Put everything it needs in \`prompt\`.
77
+ - It cannot ask you or the user anything. One shot.
78
+ - It cannot spawn subagents of its own.
79
+ - You get its final written answer plus a compact log of what it did. You do NOT get its raw tool output.
80
+ - It is read-only by default and cannot send messages to any channel.
81
+
82
+ State exactly what you want back ("return a bullet list of file:line and a one-line description each"), or you will get an unusable ramble.`;
83
+
84
+ /**
85
+ * @param {RuntimeSubagentsOptions} subagents
86
+ * @param {ReadonlyArray<RuntimeSubagentDefinition>} definitions
87
+ * @param {ReadonlyArray<string>|null} ceiling Tools an authored subagent may request, or null when authoring is off.
88
+ * @returns {string}
89
+ */
90
+ function toolDescription(subagents, definitions, ceiling) {
91
+ const maxConcurrent = positiveInt(subagents.maxConcurrent, DEFAULT_MAX_CONCURRENT);
92
+ const parallel = `\n\nIssue several Agent calls in ONE message to run them in parallel (up to ${maxConcurrent} at a time). Subagents run concurrently and independently.`;
93
+ const named = definitions.length === 0
94
+ ? ""
95
+ : `\n\nAvailable subagents:\n${definitions.map((d) => `- ${d.name}: ${d.description}`).join("\n")}\n- ${GENERAL_PURPOSE_SUBAGENT}: read-only researcher inheriting the main model. Used when \`name\` is omitted.`;
96
+ // The ceiling is listed because the model has no other way to discover it: a
97
+ // tool it cannot see is indistinguishable from one it forgot to ask for.
98
+ const inline = ceiling === null
99
+ ? ""
100
+ : `\n\nYou can also build a specialist on the spot instead of picking a profile: pass \`systemPrompt\` with its full instructions, a kebab-case \`name\`, and the \`tools\` it needs. Do that when no profile fits the task — a dedicated prompt beats stuffing constraints into \`prompt\`. Tools you may grant: ${ceiling.join(", ")}. Anything else is dropped. Omit \`tools\` for a read-only helper.`;
101
+ return `${DESCRIPTION_BASE}${parallel}${named}${inline}`;
102
+ }
103
+
104
+ /**
105
+ * Per-logical-run call and byte budget, shared across router attempts.
106
+ * @param {*} subagents The run-scoped options object, stable across attempts.
107
+ * @param {string|undefined} parentRunId
108
+ */
109
+ function budgetForRun(subagents, parentRunId) {
110
+ const store = subagents.__budgets instanceof Map ? subagents.__budgets : new Map();
111
+ if (!(subagents.__budgets instanceof Map)) {
112
+ Object.defineProperty(subagents, "__budgets", { value: store, enumerable: false, configurable: true });
113
+ }
114
+ const key = parentRunId ?? "unkeyed";
115
+ const existing = store.get(key);
116
+ if (existing !== undefined) return existing;
117
+ // Oldest-first eviction: Map preserves insertion order.
118
+ while (store.size >= MAX_TRACKED_RUNS) {
119
+ const oldest = store.keys().next();
120
+ if (oldest.done) break;
121
+ store.delete(oldest.value);
122
+ }
123
+ const fresh = { total: 0, bytes: 0, warnedQueued: false };
124
+ store.set(key, fresh);
125
+ return fresh;
126
+ }
127
+
128
+ /** @param {*} value @param {number} fallback @returns {number} */
129
+ function positiveInt(value, fallback) {
130
+ return Number.isInteger(value) && value > 0 ? value : fallback;
131
+ }
132
+
133
+ /**
134
+ * Build the `Agent` tool, or null when subagents are unavailable for this run.
135
+ *
136
+ * @param {RuntimeSubagentsOptions|null|undefined} subagents
137
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, onEvent?: (event: *) => void}} [context]
138
+ * @returns {*|null}
139
+ */
140
+ export function createAgentTool(subagents, context = {}) {
141
+ if (!subagents || typeof subagents.run !== "function") return null;
142
+ // Structural recursion lock #1: a subagent's own tool set never contains
143
+ // `Agent`, regardless of what any host-supplied `run` forwards.
144
+ if (positiveInt(subagents.depth, 0) > 0 || Number(subagents.depth || 0) > 0) return null;
145
+
146
+ const definitions = Array.isArray(subagents.definitions) ? subagents.definitions.filter(Boolean) : [];
147
+ const maxConcurrent = positiveInt(subagents.maxConcurrent, DEFAULT_MAX_CONCURRENT);
148
+ const maxPerTurn = positiveInt(subagents.maxPerTurn, DEFAULT_MAX_PER_TURN);
149
+ const names = definitions.map((definition) => definition.name);
150
+
151
+ const slots = createCountingSemaphore(maxConcurrent);
152
+ // Budget state hangs off the shared `subagents` options object, NOT this
153
+ // closure: getPiBuiltinTools runs once per ROUTER ATTEMPT, so a closure-local
154
+ // counter would reset on every same-model retry and failover, multiplying the
155
+ // effective ceiling by the number of attempts. Keyed by parent run so a later
156
+ // logical turn starts fresh, and bounded so a long-lived host cannot grow it.
157
+ const budget = budgetForRun(subagents, context.parentRunId);
158
+
159
+ // The ceiling doubles as the authoring switch: null means the closed schema
160
+ // this tool has always had, with `name` restricted to configured profiles.
161
+ const ceiling = inlineCeiling(subagents.inline);
162
+
163
+ const parameters = {
164
+ type: "object",
165
+ properties: {
166
+ prompt: {
167
+ type: "string",
168
+ minLength: 1,
169
+ description: "The complete, self-contained task. The subagent sees NONE of this conversation — restate all needed context, file paths, and the exact shape of the answer you want back.",
170
+ },
171
+ // A free-string `name` and a closed enum are mutually exclusive, and no
172
+ // JSON Schema conditional expresses "enum unless systemPrompt is present"
173
+ // portably across providers. Keeping the enum whenever authoring is off
174
+ // means turning the feature off is a true return to the old contract.
175
+ ...(ceiling !== null
176
+ ? {
177
+ name: {
178
+ type: "string",
179
+ pattern: INLINE_NAME_RE.source,
180
+ description: `A configured profile's name, or the name to give a subagent you author here with \`systemPrompt\`. Omit for ${GENERAL_PURPOSE_SUBAGENT}.`,
181
+ },
182
+ systemPrompt: {
183
+ type: "string",
184
+ minLength: 1,
185
+ maxLength: 8_000,
186
+ description: "Build a specialist for this one task: its complete instructions. Requires `name`. Omit to use a configured profile instead.",
187
+ },
188
+ tools: {
189
+ type: "array",
190
+ items: { type: "string", minLength: 1 },
191
+ maxItems: 20,
192
+ description: `Tools the subagent you author needs, e.g. ["Read","Edit","Bash"]. Only usable with \`systemPrompt\`. Available: ${ceiling.join(", ")}. Omit for a read-only helper.`,
193
+ },
194
+ effort: {
195
+ type: "string",
196
+ enum: [...EFFORT_LEVELS],
197
+ description: "Reasoning effort for the subagent you author. Only usable with `systemPrompt`. Omit to inherit yours.",
198
+ },
199
+ }
200
+ : {}),
201
+ ...(ceiling === null && names.length > 0
202
+ ? {
203
+ name: {
204
+ type: "string",
205
+ enum: [...names, GENERAL_PURPOSE_SUBAGENT],
206
+ description: `Which subagent profile to use. Omit for ${GENERAL_PURPOSE_SUBAGENT}.`,
207
+ },
208
+ }
209
+ : {}),
210
+ description: {
211
+ type: "string",
212
+ maxLength: 80,
213
+ description: "3-6 word label for this task, shown in the activity log.",
214
+ },
215
+ },
216
+ required: ["prompt"],
217
+ additionalProperties: false,
218
+ };
219
+
220
+ return {
221
+ name: "Agent",
222
+ label: "Agent",
223
+ description: toolDescription(subagents, definitions, ceiling),
224
+ parameters,
225
+ // MUST stay undefined. pi-agent-core's agent loop makes the ENTIRE batch
226
+ // sequential when any tool in it declares executionMode "sequential"
227
+ // (dist/agent-loop.js:289), which would serialize every parallel Agent call.
228
+ executionMode: undefined,
229
+ /**
230
+ * @param {string} toolCallId
231
+ * @param {{prompt: string, name?: string, description?: string, systemPrompt?: string, tools?: ReadonlyArray<string>, effort?: string}} params
232
+ * @param {AbortSignal} [signal]
233
+ */
234
+ async execute(toolCallId, params, signal) {
235
+ if (signal?.aborted) throw new Error("tool execution aborted");
236
+
237
+ const authored = ceiling !== null && typeof params?.systemPrompt === "string" && params.systemPrompt.trim().length > 0;
238
+ if (!authored && (params?.tools !== undefined || params?.effort !== undefined)) {
239
+ throw new Error("Error: `tools` and `effort` only apply when you supply `systemPrompt` to build a subagent. A configured profile brings its own.");
240
+ }
241
+ const { profile, droppedTools } = authored
242
+ ? buildInlineProfile(params, ceiling, names)
243
+ : { profile: resolveProfile(definitions, params?.name), droppedTools: [] };
244
+ if (profile === null) {
245
+ const available = [...names, GENERAL_PURPOSE_SUBAGENT].join(", ");
246
+ throw new Error(`Error: unknown subagent "${params?.name}". Available: ${available}.`);
247
+ }
248
+
249
+ // The concurrency cap bounds resources, not cost: a delegation loop can
250
+ // fire calls serially across turns without ever contending the semaphore.
251
+ // This counter is the actual runaway guard.
252
+ if (budget.total >= maxPerTurn) {
253
+ throw new Error(
254
+ `Error: subagent budget for this turn is exhausted (${maxPerTurn} of ${maxPerTurn} used). Do the remaining work yourself.`,
255
+ );
256
+ }
257
+ budget.total += 1;
258
+ const callIndex = budget.total;
259
+
260
+ if (slots.inFlight() >= maxConcurrent && !budget.warnedQueued) {
261
+ budget.warnedQueued = true;
262
+ context.onEvent?.({
263
+ type: "runtime_warning",
264
+ warning_kind: "subagent_queued",
265
+ message: `Subagent concurrency limit (${maxConcurrent}) reached; further Agent calls queue.`,
266
+ });
267
+ }
268
+
269
+ const releaseSlot = await slots.acquire(signal);
270
+ // The parent can abort while this call was queued; without rechecking, an
271
+ // already-cancelled turn would still spawn a child.
272
+ if (signal?.aborted) {
273
+ releaseSlot();
274
+ throw new Error("tool execution aborted");
275
+ }
276
+ // The timeout starts only AFTER a slot is held. Started earlier, a call
277
+ // queued behind five long-running siblings would time out having never run.
278
+ const timeoutMs = positiveInt(profile.timeoutMs, positiveInt(subagents.timeoutMs, DEFAULT_TIMEOUT_MS));
279
+ const maxTurns = positiveInt(profile.maxTurns, positiveInt(subagents.maxTurns, DEFAULT_MAX_TURNS));
280
+
281
+ const controller = new AbortController();
282
+ let timedOut = false;
283
+ const onParentAbort = () => controller.abort();
284
+ if (signal?.aborted) controller.abort();
285
+ else signal?.addEventListener("abort", onParentAbort, { once: true });
286
+ const timer = setTimeout(() => {
287
+ timedOut = true;
288
+ // Ask first — a cooperative runner settles and we keep its partial text.
289
+ if (!controller.signal.aborted) controller.abort();
290
+ // Then stop waiting regardless, after a short grace period.
291
+ setTimeout(() => fireDeadline?.(), DEADLINE_GRACE_MS).unref?.();
292
+ }, timeoutMs);
293
+
294
+ /** @type {(() => void)|undefined} */
295
+ let fireDeadline;
296
+ // A signal only ASKS a runner to stop. A runner that ignores both its
297
+ // abort signal and its deadline would otherwise keep `execute()` pending
298
+ // forever, holding its permit and wedging every queued sibling. Racing an
299
+ // enforceable deadline lets the slot go; the abandoned work is left to
300
+ // settle on its own and its late result is ignored.
301
+ const deadline = new Promise((resolve) => { fireDeadline = () => resolve(DEADLINE); });
302
+
303
+ const collector = createActivityCollector({
304
+ callId: toolCallId,
305
+ profileName: profile.name,
306
+ callIndex,
307
+ ...(params.description === undefined ? {} : { label: params.description }),
308
+ ...(context.onEvent === undefined ? {} : { emit: context.onEvent }),
309
+ });
310
+ collector.started();
311
+ const startedAt = Date.now();
312
+ /** @type {*} */
313
+ let result;
314
+ /** @type {unknown} */
315
+ let thrown;
316
+ let abandoned = false;
317
+ try {
318
+ const running = subagents.run({
319
+ systemPrompt: profile.systemPrompt,
320
+ prompt: params.prompt,
321
+ definition: profile,
322
+ ...(context.model === undefined ? {} : { model: context.model }),
323
+ ...(context.executionMode === undefined ? {} : { executionMode: context.executionMode }),
324
+ ...(context.cwd === undefined ? {} : { cwd: context.cwd }),
325
+ ...(context.parentRunId === undefined ? {} : { parentRunId: context.parentRunId }),
326
+ // Inherited, never widened: a profile cannot loosen confinement.
327
+ ...(context.sandboxPolicy === undefined ? {} : { sandboxPolicy: context.sandboxPolicy }),
328
+ ...(context.sandboxEngine === undefined ? {} : { sandboxEngine: context.sandboxEngine }),
329
+ abortSignal: controller.signal,
330
+ maxTurns,
331
+ callId: toolCallId,
332
+ callIndex,
333
+ depth: positiveInt(subagents.depth, 0) + 1,
334
+ onEvent: collector.observe,
335
+ });
336
+ // Never let an abandoned runner surface as an unhandled rejection.
337
+ void Promise.resolve(running).catch(() => undefined);
338
+ const settled = await Promise.race([running, deadline]);
339
+ if (settled === DEADLINE) {
340
+ timedOut = true;
341
+ abandoned = true;
342
+ } else {
343
+ result = settled;
344
+ }
345
+ } catch (error) {
346
+ thrown = error;
347
+ } finally {
348
+ clearTimeout(timer);
349
+ signal?.removeEventListener("abort", onParentAbort);
350
+ releaseSlot();
351
+ }
352
+
353
+ // The parent turn being cancelled is not a subagent outcome — surface it
354
+ // as an aborted tool call the way every other built-in does. Close any
355
+ // still-open child activity first so the operator surfaces do not keep a
356
+ // spinner running for a tool that will never report.
357
+ if (signal?.aborted && !timedOut) {
358
+ collector.drain("parent turn cancelled");
359
+ collector.finished({ status: "cancelled", durationMs: Date.now() - startedAt });
360
+ throw new Error("tool execution aborted");
361
+ }
362
+ collector.drain("subagent ended before this tool reported");
363
+
364
+ const durationMs = Date.now() - startedAt;
365
+ const outcome = classifyOutcome({ result, thrown, timedOut, abandoned });
366
+ collector.finished({ status: outcome.status, durationMs });
367
+ // Each result is individually capped, but the parent's context sees the
368
+ // SUM. The description encourages parallel calls, so twenty valid results
369
+ // would otherwise land ~480KB in one batch. Later calls get whatever
370
+ // budget remains.
371
+ const remaining = Math.max(0, TURN_RESULT_MAX_BYTES - budget.bytes);
372
+ const text = formatSubagentResult({
373
+ profileName: profile.name,
374
+ label: params.description,
375
+ outcome,
376
+ durationMs,
377
+ activity: collector.entries(),
378
+ maxBytes: Math.min(RESULT_MAX_BYTES, remaining),
379
+ ...(context.cwd === undefined ? {} : { cwd: context.cwd }),
380
+ ...(droppedTools.length === 0 ? {} : {
381
+ notice: `${droppedTools.join(", ")} ${droppedTools.length === 1 ? "is" : "are"} not available to a subagent you build; it ran with ${profile.allowedTools.join(", ")}.`,
382
+ }),
383
+ });
384
+ budget.bytes += Buffer.byteLength(text, "utf8");
385
+ // `details.subagent.status` is the load-bearing signal: pi hardcodes
386
+ // isError:false for every resolved execute(), so the pi-native
387
+ // `tool_result` hook reads this to restore the error flag. A top-level
388
+ // `error` field here would be silently ignored.
389
+ return {
390
+ content: [{ type: "text", text }],
391
+ details: {
392
+ tool: "Agent",
393
+ subagent: { name: profile.name, callIndex, status: outcome.status, toolCalls: collector.entries().length },
394
+ },
395
+ };
396
+ },
397
+ };
398
+ }
399
+
400
+ /**
401
+ * The tools an authored subagent may be granted, or null when authoring is off.
402
+ *
403
+ * A host that enables authoring without stating a ceiling gets the read-only
404
+ * default rather than every built-in: the same reasoning as `normalizeProfile`,
405
+ * one layer up. Only `enabled: false` turns authoring off, so a bare-kernel
406
+ * caller keeps the capability at its safest setting instead of losing it.
407
+ *
408
+ * @param {{enabled?: boolean, allowedTools?: ReadonlyArray<string>}|undefined} inline
409
+ * @returns {ReadonlyArray<string>|null}
410
+ */
411
+ function inlineCeiling(inline) {
412
+ if (inline?.enabled === false) return null;
413
+ if (inline === undefined || inline === null) return null;
414
+ const configured = Array.isArray(inline.allowedTools) && inline.allowedTools.length > 0
415
+ ? inline.allowedTools
416
+ : DEFAULT_SUBAGENT_TOOLS;
417
+ return configured.filter((tool) => !SUBAGENT_HARD_DENY.includes(tool));
418
+ }
419
+
420
+ /**
421
+ * Build a one-off profile from what the model authored at call time.
422
+ *
423
+ * The ceiling is the escalation guard: a child's `allowedTools` become its
424
+ * actual tool set, so without an intersection the model could grant a helper a
425
+ * tool its own policy denies it.
426
+ *
427
+ * @param {{name?: string, systemPrompt?: string, description?: string, tools?: ReadonlyArray<string>, effort?: string}} params
428
+ * @param {ReadonlyArray<string>} ceiling
429
+ * @param {ReadonlyArray<string>} configuredNames
430
+ * @returns {{profile: RuntimeSubagentDefinition, droppedTools: string[]}}
431
+ */
432
+ function buildInlineProfile(params, ceiling, configuredNames) {
433
+ const name = typeof params.name === "string" ? params.name.trim() : "";
434
+ if (!INLINE_NAME_RE.test(name)) {
435
+ throw new Error("Error: a subagent you build needs a `name` — lowercase kebab-case, e.g. \"css-refactorer\". It labels the run in the activity log.");
436
+ }
437
+ if (configuredNames.includes(name) || name === GENERAL_PURPOSE_SUBAGENT) {
438
+ throw new Error(`Error: "${name}" is already a configured subagent. Drop \`systemPrompt\` to use it, or pick a different name.`);
439
+ }
440
+ const requested = Array.isArray(params.tools) ? params.tools.map((tool) => String(tool).trim()) : undefined;
441
+ const readOnly = DEFAULT_SUBAGENT_TOOLS.filter((tool) => ceiling.includes(tool));
442
+ const droppedTools = requested === undefined ? [] : requested.filter((tool) => !ceiling.includes(tool));
443
+ // An empty list would reach `normalizeProfile`, whose "no tools named" branch
444
+ // substitutes the full read-only default — WIDER than a narrow ceiling. A
445
+ // request that survives nothing therefore has to land on the read-only set
446
+ // already clamped to the ceiling, or fail outright.
447
+ const granted = requested === undefined ? readOnly : requested.filter((tool) => ceiling.includes(tool));
448
+ const effective = granted.length > 0 ? granted : readOnly;
449
+ if (effective.length === 0) {
450
+ throw new Error(`Error: no tools available to a subagent you build (this agent allows ${ceiling.join(", ") || "none"}). Use a configured profile, or do this yourself.`);
451
+ }
452
+ return {
453
+ profile: normalizeProfile({
454
+ name,
455
+ description: params.description ?? name,
456
+ systemPrompt: String(params.systemPrompt),
457
+ allowedTools: effective,
458
+ ...(params.effort === undefined ? {} : { effort: String(params.effort) }),
459
+ }),
460
+ droppedTools,
461
+ };
462
+ }
463
+
464
+ /**
465
+ * @param {ReadonlyArray<RuntimeSubagentDefinition>} definitions
466
+ * @param {string|undefined} name
467
+ * @returns {RuntimeSubagentDefinition|null}
468
+ */
469
+ function resolveProfile(definitions, name) {
470
+ if (name === undefined || name === null || name === GENERAL_PURPOSE_SUBAGENT) {
471
+ return normalizeProfile({
472
+ name: GENERAL_PURPOSE_SUBAGENT,
473
+ description: "Read-only researcher inheriting the main model.",
474
+ systemPrompt: "You are a focused research subagent. Work only from the task you were given — you cannot see the parent conversation and cannot ask anyone anything. Investigate with the tools you have, then finish with a written answer in exactly the shape the task requested. Cite file:line where relevant. Never modify files.",
475
+ });
476
+ }
477
+ const found = definitions.find((definition) => definition.name === name);
478
+ return found === undefined ? null : normalizeProfile(found);
479
+ }
480
+
481
+ /**
482
+ * Materialize a profile's effective tool boundary in the KERNEL, not just in
483
+ * whichever host happened to build the definitions.
484
+ *
485
+ * An omitted `allowedTools` is documented as "the safe read-only default set",
486
+ * but forwarding `undefined` to `getPiBuiltinTools` means the allow-all sentinel
487
+ * — every built-in, including Bash, Write, and Exec. A bare-kernel caller
488
+ * supplying a profile without tools would therefore silently get the widest
489
+ * possible child. The hard-deny list is unioned here for the same reason: it
490
+ * must hold on every path, not only the configured-app one.
491
+ *
492
+ * @param {RuntimeSubagentDefinition} definition
493
+ * @returns {RuntimeSubagentDefinition}
494
+ */
495
+ function normalizeProfile(definition) {
496
+ const allowed = Array.isArray(definition.allowedTools) && definition.allowedTools.length > 0
497
+ ? definition.allowedTools.filter((tool) => !SUBAGENT_HARD_DENY.includes(tool))
498
+ : DEFAULT_SUBAGENT_TOOLS;
499
+ const denied = [...new Set([...(definition.disallowedTools ?? []), ...SUBAGENT_HARD_DENY])];
500
+ return { ...definition, allowedTools: allowed, disallowedTools: denied };
501
+ }
502
+
503
+ /** Hard cap on any single forwarded payload, so the operator wire's binary-search reducer never has to run. */
504
+ const WIRE_CONTENT_MAX_CHARS = 2_000;
505
+
506
+ /**
507
+ * Translates the child's raw provider events into (a) a bounded per-tool-call
508
+ * log for the parent's context and (b) `subagent_activity` events on the
509
+ * parent's operator stream.
510
+ *
511
+ * Tool ids are namespaced `agent:<callId>:<toolUseId>` because both the TUI
512
+ * (`toolPanels`) and the web store (`upsertToolCall`) key tool state FLATLY on
513
+ * the id — two subagents running Read concurrently would otherwise collapse
514
+ * into a single panel.
515
+ *
516
+ * The child's assistant text and thinking are deliberately dropped: the
517
+ * responder pipes `assistantTextFromRuntimeEvent` straight into the parent's
518
+ * answer body, so forwarding them would splice a subagent's prose into the
519
+ * main agent's reply. Its text reaches the parent through the tool result.
520
+ *
521
+ * @param {{callId: string, profileName: string, callIndex: number, label?: string, emit?: (event: *) => void}} options
522
+ */
523
+ function createActivityCollector({ callId, profileName, callIndex, label, emit }) {
524
+ /** @type {Map<string, {name: string, args: unknown, startedAt: number, ms?: number}>} */
525
+ const open = new Map();
526
+ /** @type {Array<{name: string, args: unknown, ms?: number, isError: boolean}>} */
527
+ const done = [];
528
+ const subagent = { id: callId, name: profileName, callIndex, ...(label === undefined ? {} : { label }) };
529
+
530
+ /** @param {*} event */
531
+ const publish = (event) => {
532
+ if (emit === undefined) return;
533
+ try {
534
+ emit({ type: "subagent_activity", subagent, ...event });
535
+ } catch {
536
+ // Operator telemetry is additive; never fail a subagent over it.
537
+ }
538
+ };
539
+
540
+ return {
541
+ entries: () => done,
542
+ /** Lifecycle bookends so the subagent is visible before its first tool call. */
543
+ started() {
544
+ publish({
545
+ phase: "agent_started",
546
+ id: `agent:${callId}`,
547
+ name: `Agent(${profileName})`,
548
+ arguments: { name: profileName, ...(label === undefined ? {} : { description: label }) },
549
+ });
550
+ },
551
+ /** @param {{status: string, durationMs: number}} outcome */
552
+ finished({ status, durationMs }) {
553
+ publish({
554
+ phase: "agent_completed",
555
+ id: `agent:${callId}`,
556
+ name: `Agent(${profileName})`,
557
+ isError: status !== "ok",
558
+ executionMs: durationMs,
559
+ content: `${status} · ${done.length} tool call${done.length === 1 ? "" : "s"}`,
560
+ });
561
+ },
562
+ /** Close any tool left open when a run ends abnormally, exactly once. */
563
+ drain(reason) {
564
+ for (const [id, entry] of open) {
565
+ done.push({ name: entry.name, args: entry.args, ms: Date.now() - entry.startedAt, isError: true });
566
+ publish({
567
+ phase: "completed",
568
+ id: `agent:${callId}:${id}`,
569
+ name: `${profileName}▸${entry.name}`,
570
+ isError: true,
571
+ content: reason,
572
+ });
573
+ }
574
+ open.clear();
575
+ },
576
+ /** @param {*} event */
577
+ observe(event) {
578
+ const type = event?.type;
579
+ // Providers legitimately forward multiple blocks in one message; the
580
+ // Claude bridges do routinely. Inspecting only content[0] dropped every
581
+ // tool call after the first.
582
+ const blocks = Array.isArray(event?.message?.content) ? event.message.content : [];
583
+ if (type === "assistant") {
584
+ for (const block of blocks) {
585
+ if (block?.type !== "tool_use" || typeof block.id !== "string") continue;
586
+ const name = String(block.name ?? "?");
587
+ open.set(block.id, { name, args: block.input, startedAt: Date.now() });
588
+ publish({
589
+ phase: "started",
590
+ id: `agent:${callId}:${block.id}`,
591
+ name: `${profileName}▸${name}`,
592
+ arguments: block.input,
593
+ });
594
+ }
595
+ return;
596
+ }
597
+ if (type === "tool_timing" && typeof event.tool_use_id === "string") {
598
+ const entry = open.get(event.tool_use_id);
599
+ if (entry && typeof event.execution_ms === "number") entry.ms = event.execution_ms;
600
+ return;
601
+ }
602
+ if (type === "user") {
603
+ for (const block of blocks) {
604
+ if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
605
+ const entry = open.get(block.tool_use_id);
606
+ open.delete(block.tool_use_id);
607
+ const ms = entry?.ms ?? (entry ? Date.now() - entry.startedAt : undefined);
608
+ const isError = block.is_error === true;
609
+ done.push({ name: entry?.name ?? "?", args: entry?.args, ms, isError });
610
+ publish({
611
+ phase: "completed",
612
+ id: `agent:${callId}:${block.tool_use_id}`,
613
+ name: `${profileName}▸${entry?.name ?? "?"}`,
614
+ isError,
615
+ ...(ms === undefined ? {} : { executionMs: ms }),
616
+ ...(summarizeForWire(block.content) === undefined ? {} : { content: summarizeForWire(block.content) }),
617
+ });
618
+ }
619
+ return;
620
+ }
621
+ // Child warnings are worth surfacing; everything else (context usage,
622
+ // partial tool output, cost) stays inside the subagent for now.
623
+ if (type === "runtime_warning" && emit !== undefined) {
624
+ try {
625
+ emit({ ...event, subagentId: callId });
626
+ } catch {
627
+ // additive
628
+ }
629
+ }
630
+ },
631
+ };
632
+ }
633
+
634
+ /** @param {unknown} value */
635
+ function summarizeForWire(value) {
636
+ if (value === undefined || value === null) return undefined;
637
+ const text = typeof value === "string" ? value : safeJson(value);
638
+ return text.length <= WIRE_CONTENT_MAX_CHARS ? text : `${text.slice(0, WIRE_CONTENT_MAX_CHARS)}…`;
639
+ }
640
+
641
+ /** @param {unknown} value */
642
+ function safeJson(value) {
643
+ try {
644
+ return JSON.stringify(value) ?? String(value);
645
+ } catch {
646
+ return String(value);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * @param {{result: *, thrown: unknown, timedOut: boolean, abandoned?: boolean}} input
652
+ * @returns {{status: string, answer: string, reason?: string}}
653
+ */
654
+ function classifyOutcome({ result, thrown, timedOut, abandoned = false }) {
655
+ if (abandoned) {
656
+ return {
657
+ status: "timeout",
658
+ answer: "",
659
+ reason: "the subagent ignored its deadline and was abandoned; its slot was released",
660
+ };
661
+ }
662
+ if (timedOut) {
663
+ return { status: "timeout", answer: typeof result?.text === "string" ? result.text : "", reason: "the subagent exceeded its time budget" };
664
+ }
665
+ if (thrown !== undefined) {
666
+ return { status: "failed", answer: "", reason: thrown instanceof Error ? thrown.message : String(thrown) };
667
+ }
668
+ if (result?.cancelled === true) {
669
+ return { status: "cancelled", answer: typeof result.text === "string" ? result.text : "", reason: "the subagent run was cancelled" };
670
+ }
671
+ if (result?.error || result?.failureKind) {
672
+ const kind = result.failureKind ? `${result.failureKind}: ` : "";
673
+ return { status: "failed", answer: typeof result.text === "string" ? result.text : "", reason: `${kind}${String(result.error ?? "")}`.trim() };
674
+ }
675
+ const answer = typeof result?.text === "string" ? result.text.trim() : "";
676
+ if (answer.length === 0) {
677
+ return { status: "empty", answer: "", reason: "the subagent produced no final answer" };
678
+ }
679
+ return { status: "ok", answer };
680
+ }
681
+
682
+ /**
683
+ * A subagent that fails, times out, or says nothing still returns its activity
684
+ * log: that log is the most useful artifact of a failed delegation, and a
685
+ * thrown tool error would discard it.
686
+ *
687
+ * @param {{profileName: string, label?: string, outcome: {status: string, answer: string, reason?: string}, durationMs: number, activity: ReadonlyArray<{name: string, args: unknown, ms?: number, isError: boolean}>, maxBytes?: number, cwd?: string, notice?: string}} input
688
+ * @returns {string}
689
+ */
690
+ export function formatSubagentResult({ profileName, label, outcome, durationMs, activity, maxBytes = RESULT_MAX_BYTES, cwd, notice }) {
691
+ const seconds = (durationMs / 1000).toFixed(1);
692
+ const calls = `${activity.length} tool call${activity.length === 1 ? "" : "s"}`;
693
+ const header = `<subagent: ${profileName}${label ? ` · ${label}` : ""} · ${outcome.status} · ${calls} · ${seconds}s>`;
694
+ const parts = [header];
695
+ // Surfaced before the answer: a request the runtime silently declined would
696
+ // otherwise have the caller re-request it on every future call.
697
+ if (notice !== undefined) parts.push(`note: ${truncate(notice, 300)}`);
698
+ if (outcome.reason !== undefined) parts.push(`reason: ${truncate(outcome.reason, 500)}`);
699
+ if (outcome.answer.length > 0) parts.push("", truncate(outcome.answer, ANSWER_MAX_CHARS));
700
+ if (activity.length > 0) parts.push("", "<activity>", ...renderActivity(activity, cwd), "</activity>");
701
+ // A fully spent turn budget still returns the header + reason, so the model
702
+ // learns the delegation happened and why it was truncated.
703
+ const floor = 512;
704
+ return capBytes(parts.join("\n"), Math.max(floor, maxBytes));
705
+ }
706
+
707
+ /**
708
+ * Head-and-tail elision: the informative parts of a delegation trace are what
709
+ * it opened with and what it concluded with; the middle is usually a read loop.
710
+ * @param {ReadonlyArray<{name: string, args: unknown, ms?: number, isError: boolean}>} activity
711
+ * @param {string} [cwd]
712
+ * @returns {string[]}
713
+ */
714
+ function renderActivity(activity, cwd) {
715
+ const line = (entry, index) => {
716
+ const args = summarizeArgs(entry.name, entry.args, cwd);
717
+ const status = entry.isError ? "error" : "ok";
718
+ const ms = entry.ms === undefined ? "" : ` ${formatMs(entry.ms)}`;
719
+ return truncate(`${index + 1}. ${entry.name}${args ? ` ${args}` : ""} → ${status}${ms}`, LOG_LINE_MAX_CHARS);
720
+ };
721
+ if (activity.length <= LOG_MAX_LINES) return activity.map(line);
722
+ const head = activity.slice(0, LOG_HEAD_LINES).map(line);
723
+ const tail = activity.slice(activity.length - LOG_TAIL_LINES).map((entry, offset) =>
724
+ line(entry, activity.length - LOG_TAIL_LINES + offset));
725
+ const elided = activity.length - LOG_HEAD_LINES - LOG_TAIL_LINES;
726
+ return [...head, `… ${elided} call${elided === 1 ? "" : "s"} elided …`, ...tail];
727
+ }
728
+
729
+ /** @param {number} ms */
730
+ function formatMs(ms) {
731
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
732
+ }
733
+
734
+ /** Bound on the argv elements read for an `Exec` summary, before truncation. */
735
+ const ARGV_PREVIEW_MAX_CHARS = 200;
736
+
737
+ /**
738
+ * Show local paths relative to the agent root (and collapse the operator's home
739
+ * directory to `~`), mirroring what the chat ledger does for the same tool
740
+ * arguments. Absolute machine layout has no meaning to the parent model and
741
+ * leaks the operator's account name into its context and every operator surface.
742
+ *
743
+ * Duplicated rather than imported: `@mono-agent/agent-runtime` deliberately has
744
+ * no internal dependencies, so it cannot reach `agent-contracts`.
745
+ *
746
+ * @param {string} value
747
+ * @param {string|undefined} cwd
748
+ * @returns {string}
749
+ */
750
+ function relativizePaths(value, cwd) {
751
+ let result = value;
752
+ for (const [root, replacement] of [[cwd, ""], [safeHomedir(), "~/"]]) {
753
+ if (root === undefined || root === "" || root === "/") continue;
754
+ const normalized = root.endsWith("/") ? root.slice(0, -1) : root;
755
+ result = result.replaceAll(`${normalized}/`, replacement);
756
+ }
757
+ return result;
758
+ }
759
+
760
+ /** @returns {string|undefined} */
761
+ function safeHomedir() {
762
+ try {
763
+ return homedir();
764
+ } catch {
765
+ return undefined;
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Tool-aware one-liners keep the log readable where a generic JSON dump would
771
+ * blow the per-line budget on a single Write payload.
772
+ * @param {string} name
773
+ * @param {unknown} args
774
+ * @param {string} [cwd] Agent root; paths are shown relative to it.
775
+ * @returns {string}
776
+ */
777
+ function summarizeArgs(name, args, cwd) {
778
+ if (args === null || typeof args !== "object") return "";
779
+ const record = /** @type {Record<string, unknown>} */ (args);
780
+ const pick = (key) => (typeof record[key] === "string" ? relativizePaths(String(record[key]), cwd) : undefined);
781
+ switch (name) {
782
+ case "Read":
783
+ case "Write":
784
+ case "Edit":
785
+ return pick("file_path") ?? "";
786
+ case "Bash":
787
+ return quote(pick("command") ?? "");
788
+ case "Exec":
789
+ // Exec carries no `command`. Rendering only `executable` collapsed every
790
+ // line to `Exec "rg"`, saying nothing about what the subagent actually ran.
791
+ return quote(execArgv(record, cwd));
792
+ case "Grep":
793
+ return [pick("pattern") && `pattern=${quote(String(pick("pattern")))}`, pick("path") && `path=${pick("path")}`]
794
+ .filter(Boolean).join(" ");
795
+ case "Glob":
796
+ return pick("pattern") ?? "";
797
+ case "WebFetch":
798
+ return pick("url") ?? "";
799
+ case "WebSearch":
800
+ return quote(pick("query") ?? "");
801
+ default: {
802
+ const scalars = Object.entries(record)
803
+ .filter(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
804
+ .slice(0, 2)
805
+ .map(([key, value]) => `${key}=${typeof value === "string" ? quote(relativizePaths(value, cwd)) : String(value)}`);
806
+ return scalars.join(" ");
807
+ }
808
+ }
809
+ }
810
+
811
+ /**
812
+ * Space-joined executable and arguments, so the summary reads like the command
813
+ * line it stands in for. A non-string element ends the run: a hole makes the
814
+ * rest of an argv positionally meaningless, and a truthful prefix beats a
815
+ * spliced line.
816
+ *
817
+ * @param {Record<string, unknown>} record
818
+ * @param {string|undefined} cwd
819
+ * @returns {string}
820
+ */
821
+ function execArgv(record, cwd) {
822
+ const executable = typeof record.executable === "string" ? record.executable : "";
823
+ if (executable === "") return "";
824
+ const parts = [executable];
825
+ let budget = ARGV_PREVIEW_MAX_CHARS;
826
+ for (const arg of Array.isArray(record.args) ? record.args : []) {
827
+ if (typeof arg !== "string" || budget <= 0) break;
828
+ parts.push(arg);
829
+ budget -= arg.length + 1;
830
+ }
831
+ return relativizePaths(parts.join(" "), cwd);
832
+ }
833
+
834
+ /** @param {string} value */
835
+ function quote(value) {
836
+ const trimmed = truncate(value.replace(/\s+/gu, " ").trim(), 60);
837
+ return trimmed.length === 0 ? "" : `"${trimmed}"`;
838
+ }
839
+
840
+ /** @param {string} value @param {number} max */
841
+ function truncate(value, max) {
842
+ const text = String(value ?? "");
843
+ if (text.length <= max) return text;
844
+ return `${text.slice(0, max)}… [truncated ${text.length - max} chars]`;
845
+ }
846
+
847
+ /**
848
+ * Final backstop so a pathological subagent cannot push the tool result into
849
+ * bloat-guard territory, where the whole payload would be replaced.
850
+ * @param {string} value @param {number} maxBytes
851
+ */
852
+ function capBytes(value, maxBytes) {
853
+ if (Buffer.byteLength(value, "utf8") <= maxBytes) return value;
854
+ const buffer = Buffer.from(value, "utf8").subarray(0, maxBytes - 32);
855
+ // Decode lossily, then drop a trailing replacement char so a multi-byte code
856
+ // point split at the boundary never lands in the output.
857
+ const decoded = buffer.toString("utf8").replace(/�$/u, "");
858
+ return `${decoded}\n… [result truncated]`;
859
+ }