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