@mystilleef/pi-subagent 0.3.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/LICENSE +21 -0
- package/README.md +45 -0
- package/package.json +58 -0
- package/src/agent-cache.ts +41 -0
- package/src/agents.ts +178 -0
- package/src/cancel-command.ts +49 -0
- package/src/child-events.ts +32 -0
- package/src/index.ts +70 -0
- package/src/normalize.ts +109 -0
- package/src/process.ts +661 -0
- package/src/progress-state.ts +233 -0
- package/src/progress.ts +221 -0
- package/src/prompt-contract.ts +10 -0
- package/src/result-details.ts +97 -0
- package/src/run-command.ts +46 -0
- package/src/run-registry.ts +53 -0
- package/src/run.ts +53 -0
- package/src/subagent-orchestrator.ts +377 -0
- package/src/summary.ts +63 -0
- package/src/termination.ts +209 -0
- package/src/types.ts +59 -0
- package/src/ui.ts +218 -0
- package/src/utils.ts +165 -0
- package/tsconfig.json +30 -0
package/src/process.ts
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages subagent execution by spawning and orchestrating child `pi` processes.
|
|
3
|
+
* Handles JSON-mode event streaming, resource tracking, and lifecycle management
|
|
4
|
+
* including timeouts and termination signals.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
import { getModel, type Message } from "@earendil-works/pi-ai";
|
|
11
|
+
import type { AgentConfig, ThinkingLevel } from "./agents.js";
|
|
12
|
+
import { parseChildEventLine } from "./child-events.js";
|
|
13
|
+
import { makeToolPreview } from "./progress.js";
|
|
14
|
+
import { isToolCallPart } from "./progress-state.js";
|
|
15
|
+
import { appendSubagentResultContract } from "./prompt-contract.js";
|
|
16
|
+
import {
|
|
17
|
+
getProcessTreeSpawnOptions,
|
|
18
|
+
terminateChildProcess,
|
|
19
|
+
} from "./termination.js";
|
|
20
|
+
import type {
|
|
21
|
+
OnUpdateCallback,
|
|
22
|
+
SingleResult,
|
|
23
|
+
StreamingProgress,
|
|
24
|
+
SubagentDetails,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
import { getFinalOutput } from "./ui.js";
|
|
27
|
+
import {
|
|
28
|
+
detectMessageError,
|
|
29
|
+
getPiInvocation,
|
|
30
|
+
getSubagentDepth,
|
|
31
|
+
resolveAgentSkillArgs,
|
|
32
|
+
subagentDepthEnv,
|
|
33
|
+
truncateOutput,
|
|
34
|
+
writePromptToTempFile,
|
|
35
|
+
} from "./utils.js";
|
|
36
|
+
|
|
37
|
+
const MAX_STDERR_BYTES = 10_000;
|
|
38
|
+
const AGENT_END_GRACE_MS = 250;
|
|
39
|
+
|
|
40
|
+
const MAX_SUBAGENT_DEPTH = 1;
|
|
41
|
+
export const TOOL_RESULT_FAILED_MESSAGE = "Subagent tool result failed.";
|
|
42
|
+
|
|
43
|
+
type RuntimeResult = SingleResult & { messages: Message[] };
|
|
44
|
+
|
|
45
|
+
interface SubagentState {
|
|
46
|
+
result: RuntimeResult;
|
|
47
|
+
spawnError?: Error;
|
|
48
|
+
wasAborted: boolean;
|
|
49
|
+
agentEndGraceTimer?: ReturnType<typeof setTimeout>;
|
|
50
|
+
terminationPromise?: Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Appends data to a string while ensuring the result does not exceed a maximum byte limit.
|
|
55
|
+
* Used to prevent memory exhaustion when capturing child process stderr.
|
|
56
|
+
*/
|
|
57
|
+
function appendWithByteLimit(
|
|
58
|
+
current: string,
|
|
59
|
+
data: string,
|
|
60
|
+
max: number,
|
|
61
|
+
): string {
|
|
62
|
+
if (current.length >= max) return current;
|
|
63
|
+
return current + data.slice(0, max - current.length);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Attempts to resolve the context window token limit for a given message's model.
|
|
68
|
+
* Rationale: Subagent usage reporting needs context window awareness to provide
|
|
69
|
+
* meaningful "context full" indicators to the parent.
|
|
70
|
+
*/
|
|
71
|
+
function resolveContextWindowTokens(msg: Message): number | undefined {
|
|
72
|
+
const m = msg as unknown as Record<string, unknown>;
|
|
73
|
+
if (typeof m.provider !== "string" || typeof m.model !== "string") return;
|
|
74
|
+
try {
|
|
75
|
+
const { contextWindow } = getModel(m.provider as never, m.model as never);
|
|
76
|
+
return Number.isFinite(contextWindow) && contextWindow > 0
|
|
77
|
+
? contextWindow
|
|
78
|
+
: undefined;
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Normalizes AbortSignal reasons into human-readable strings.
|
|
86
|
+
*/
|
|
87
|
+
function getAbortReason(signal: AbortSignal): string {
|
|
88
|
+
const { reason } = signal;
|
|
89
|
+
if (reason instanceof Error && reason.message) return reason.message;
|
|
90
|
+
if (typeof reason === "string" && reason.trim()) return reason;
|
|
91
|
+
return "abort";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verifies if the agent produced any textual output or final response.
|
|
96
|
+
* Precondition: Called after process exit to distinguish between clean completion
|
|
97
|
+
* and silent failures where the process exited 0 but did nothing.
|
|
98
|
+
*/
|
|
99
|
+
function hasCompletedAgentOutput(result: RuntimeResult): boolean {
|
|
100
|
+
if (result.finalOutput.trim()) return true;
|
|
101
|
+
return result.messages.some(
|
|
102
|
+
(msg) =>
|
|
103
|
+
msg.role === "assistant" &&
|
|
104
|
+
msg.content.some(
|
|
105
|
+
(part) => part.type === "text" && Boolean(part.text?.trim()),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Determines the exit code for processes terminated via the agent_end timeout.
|
|
112
|
+
* Rationale: `pi` processes in JSON mode might hang after finishing their task;
|
|
113
|
+
* we force-kill them after a grace period and treat it as success (0) if they
|
|
114
|
+
* actually produced output.
|
|
115
|
+
*/
|
|
116
|
+
function getAgentEndTimeoutExitCode(
|
|
117
|
+
result: RuntimeResult,
|
|
118
|
+
spawnError: Error | undefined,
|
|
119
|
+
): number | undefined {
|
|
120
|
+
if (result.termination?.cancelReason !== "agent_end_timeout") return;
|
|
121
|
+
if (spawnError) return;
|
|
122
|
+
if (result.stopReason === "error" || result.stopReason === "aborted") return;
|
|
123
|
+
if (result.errorMessage?.trim()) return;
|
|
124
|
+
return hasCompletedAgentOutput(result) ? 0 : 1;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Orchestrates the cleanup and exit code capture of a child process.
|
|
129
|
+
* Safety: Implements a dual-timer strategy (idle and hard) to ensure streams
|
|
130
|
+
* are destroyed and promises settled even if the process or its pipes hang.
|
|
131
|
+
*/
|
|
132
|
+
async function waitForSubagentProcess(
|
|
133
|
+
proc: ChildProcess,
|
|
134
|
+
idleMs = 100,
|
|
135
|
+
hardMs = 5_000,
|
|
136
|
+
): Promise<number | null> {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
let exitCode: number | null = null;
|
|
139
|
+
let exited = false;
|
|
140
|
+
let settled = false;
|
|
141
|
+
let idleTimer: NodeJS.Timeout | undefined;
|
|
142
|
+
|
|
143
|
+
const done = () => {
|
|
144
|
+
if (settled) return;
|
|
145
|
+
settled = true;
|
|
146
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
147
|
+
resolve(exitCode);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const destroyStreams = () => {
|
|
151
|
+
proc.stdout?.destroy();
|
|
152
|
+
proc.stderr?.destroy();
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const armIdleTimer = () => {
|
|
156
|
+
if (!exited) return;
|
|
157
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
158
|
+
idleTimer = setTimeout(destroyStreams, idleMs);
|
|
159
|
+
idleTimer.unref?.();
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
proc.on("close", done);
|
|
163
|
+
proc.on("error", () => {
|
|
164
|
+
exitCode = 1;
|
|
165
|
+
exited = true;
|
|
166
|
+
done();
|
|
167
|
+
});
|
|
168
|
+
proc.on("exit", (code) => {
|
|
169
|
+
exitCode = code;
|
|
170
|
+
exited = true;
|
|
171
|
+
armIdleTimer();
|
|
172
|
+
const hardTimer = setTimeout(destroyStreams, hardMs);
|
|
173
|
+
hardTimer.unref?.();
|
|
174
|
+
});
|
|
175
|
+
proc.stdout?.on("data", armIdleTimer);
|
|
176
|
+
proc.stderr?.on("data", armIdleTimer);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function buildModelDisplay(
|
|
181
|
+
parentModel: { provider: string; id: string } | undefined,
|
|
182
|
+
thinking: ThinkingLevel,
|
|
183
|
+
): string | undefined {
|
|
184
|
+
if (parentModel) {
|
|
185
|
+
return `${parentModel.provider}/${parentModel.id}${thinking ? `:${thinking}` : ""}`;
|
|
186
|
+
}
|
|
187
|
+
return thinking ? `thinking:${thinking}` : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function initRuntimeResult(
|
|
191
|
+
agentName: string,
|
|
192
|
+
source: "user" | "project" | "unknown",
|
|
193
|
+
task: string,
|
|
194
|
+
modelDisplay: string | undefined,
|
|
195
|
+
): RuntimeResult {
|
|
196
|
+
return {
|
|
197
|
+
agent: agentName,
|
|
198
|
+
agentSource: source,
|
|
199
|
+
task,
|
|
200
|
+
exitCode: 0,
|
|
201
|
+
finalOutput: "",
|
|
202
|
+
messages: [],
|
|
203
|
+
stderr: "",
|
|
204
|
+
usage: {
|
|
205
|
+
input: 0,
|
|
206
|
+
output: 0,
|
|
207
|
+
cacheRead: 0,
|
|
208
|
+
cacheWrite: 0,
|
|
209
|
+
cost: 0,
|
|
210
|
+
contextTokens: 0,
|
|
211
|
+
turns: 0,
|
|
212
|
+
},
|
|
213
|
+
model: modelDisplay,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function addMessageToResult(result: RuntimeResult, msg: Message): void {
|
|
218
|
+
result.messages.push(msg);
|
|
219
|
+
result.finalOutput = truncateOutput(getFinalOutput(result.messages));
|
|
220
|
+
|
|
221
|
+
if (msg.role === "toolResult" && msg.isError) {
|
|
222
|
+
result.errorMessage ||= TOOL_RESULT_FAILED_MESSAGE;
|
|
223
|
+
} else if (result.errorMessage === TOOL_RESULT_FAILED_MESSAGE) {
|
|
224
|
+
result.errorMessage = undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (msg.role !== "assistant") return;
|
|
228
|
+
result.usage.turns++;
|
|
229
|
+
|
|
230
|
+
const { usage } = msg;
|
|
231
|
+
if (usage) {
|
|
232
|
+
result.usage.input += usage.input || 0;
|
|
233
|
+
result.usage.output += usage.output || 0;
|
|
234
|
+
result.usage.cacheRead += usage.cacheRead || 0;
|
|
235
|
+
result.usage.cacheWrite += usage.cacheWrite || 0;
|
|
236
|
+
result.usage.cost += usage.cost?.total || 0;
|
|
237
|
+
result.usage.contextTokens = usage.totalTokens || 0;
|
|
238
|
+
result.usage.contextWindowTokens =
|
|
239
|
+
resolveContextWindowTokens(msg) ?? result.usage.contextWindowTokens;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!result.model && msg.model) result.model = msg.model;
|
|
243
|
+
if (msg.stopReason) result.stopReason = msg.stopReason;
|
|
244
|
+
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Standardized error result generator.
|
|
249
|
+
*/
|
|
250
|
+
function createErrorResult(
|
|
251
|
+
agent: string,
|
|
252
|
+
source: "user" | "project" | "unknown",
|
|
253
|
+
task: string,
|
|
254
|
+
error: string,
|
|
255
|
+
model?: string,
|
|
256
|
+
): SingleResult {
|
|
257
|
+
return {
|
|
258
|
+
agent,
|
|
259
|
+
agentSource: source,
|
|
260
|
+
task,
|
|
261
|
+
exitCode: 1,
|
|
262
|
+
finalOutput: "",
|
|
263
|
+
stderr: error,
|
|
264
|
+
usage: {
|
|
265
|
+
input: 0,
|
|
266
|
+
output: 0,
|
|
267
|
+
cacheRead: 0,
|
|
268
|
+
cacheWrite: 0,
|
|
269
|
+
cost: 0,
|
|
270
|
+
contextTokens: 0,
|
|
271
|
+
turns: 0,
|
|
272
|
+
},
|
|
273
|
+
model,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function errorForUnknownAgent(
|
|
278
|
+
agentName: string,
|
|
279
|
+
agents: AgentConfig[],
|
|
280
|
+
task: string,
|
|
281
|
+
): SingleResult {
|
|
282
|
+
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
283
|
+
return createErrorResult(
|
|
284
|
+
agentName,
|
|
285
|
+
"unknown",
|
|
286
|
+
task,
|
|
287
|
+
`Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function errorForDepthLimit(
|
|
292
|
+
agentName: string,
|
|
293
|
+
source: "user" | "project" | "unknown",
|
|
294
|
+
task: string,
|
|
295
|
+
depth: number,
|
|
296
|
+
model?: string,
|
|
297
|
+
): SingleResult {
|
|
298
|
+
return createErrorResult(
|
|
299
|
+
agentName,
|
|
300
|
+
source,
|
|
301
|
+
task,
|
|
302
|
+
`Subagent nesting limit reached (depth ${depth}/${MAX_SUBAGENT_DEPTH}).`,
|
|
303
|
+
model,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function cleanupTempPrompt(tmpPrompt: {
|
|
308
|
+
dir: string;
|
|
309
|
+
filePath: string;
|
|
310
|
+
}): Promise<void> {
|
|
311
|
+
try {
|
|
312
|
+
await fs.promises.unlink(tmpPrompt.filePath);
|
|
313
|
+
await fs.promises.rmdir(tmpPrompt.dir);
|
|
314
|
+
} catch {
|
|
315
|
+
/* ignore */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function findRecentMessagesAnchor(messages: Message[]): number {
|
|
320
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
321
|
+
const msg = messages[i];
|
|
322
|
+
if (
|
|
323
|
+
msg?.role === "assistant" &&
|
|
324
|
+
msg.content.some(
|
|
325
|
+
(c) => c.type === "text" && (c as { text?: string }).text?.trim(),
|
|
326
|
+
)
|
|
327
|
+
) {
|
|
328
|
+
return i;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return -1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Derives current execution progress from accumulated messages.
|
|
336
|
+
* Maps tool calls to UI-safe previews for real-time feedback.
|
|
337
|
+
*/
|
|
338
|
+
function deriveStreamingProgress(messages: Message[]): StreamingProgress {
|
|
339
|
+
const toolCalls: { id: string; preview: string }[] = [];
|
|
340
|
+
let lastToolPreview: string | undefined;
|
|
341
|
+
for (const msg of messages) {
|
|
342
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
343
|
+
for (const part of msg.content) {
|
|
344
|
+
if (!isToolCallPart(part)) continue;
|
|
345
|
+
const preview = sanitizeProgressPreview(
|
|
346
|
+
makeToolPreview(part.name, part.arguments),
|
|
347
|
+
part.name,
|
|
348
|
+
);
|
|
349
|
+
toolCalls.push({ id: part.id, preview });
|
|
350
|
+
lastToolPreview = preview;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return { activityText: lastToolPreview, toolCalls, lastToolPreview };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Prevents leaking secrets in the CLI progress display.
|
|
358
|
+
* Redacts values if the preview contains sensitive keywords.
|
|
359
|
+
*/
|
|
360
|
+
function sanitizeProgressPreview(preview: string, toolName: string): string {
|
|
361
|
+
return /secret|token|password/i.test(preview) ? toolName : preview;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function makeEmitUpdate(
|
|
365
|
+
result: RuntimeResult,
|
|
366
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
367
|
+
makeDetails: (
|
|
368
|
+
results: RuntimeResult[],
|
|
369
|
+
options?: { includeMessages?: boolean; recentMessages?: Message[] },
|
|
370
|
+
) => SubagentDetails,
|
|
371
|
+
): () => void {
|
|
372
|
+
return () => {
|
|
373
|
+
const msgs = result.messages;
|
|
374
|
+
const anchorIdx = findRecentMessagesAnchor(msgs);
|
|
375
|
+
const recentMessages =
|
|
376
|
+
anchorIdx >= 0 ? msgs.slice(anchorIdx) : msgs.slice(-5);
|
|
377
|
+
const progress = deriveStreamingProgress(msgs);
|
|
378
|
+
result.progress = progress;
|
|
379
|
+
onUpdate?.({
|
|
380
|
+
content: [
|
|
381
|
+
{ type: "text", text: progress.activityText ?? "(running...)" },
|
|
382
|
+
],
|
|
383
|
+
details: makeDetails([result], {
|
|
384
|
+
includeMessages: true,
|
|
385
|
+
recentMessages,
|
|
386
|
+
}),
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function makeRequestTerminator(
|
|
392
|
+
proc: ChildProcess,
|
|
393
|
+
terminateOptions: {
|
|
394
|
+
tree: boolean;
|
|
395
|
+
platform: NodeJS.Platform;
|
|
396
|
+
processTreeDetached: boolean;
|
|
397
|
+
},
|
|
398
|
+
state: SubagentState,
|
|
399
|
+
): (reason: string) => Promise<unknown> {
|
|
400
|
+
return (reason: string) => {
|
|
401
|
+
state.terminationPromise ??= terminateChildProcess(proc, {
|
|
402
|
+
...terminateOptions,
|
|
403
|
+
reason,
|
|
404
|
+
}).then((metadata) => {
|
|
405
|
+
state.result.termination = metadata;
|
|
406
|
+
});
|
|
407
|
+
return state.terminationPromise;
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function clearGraceTimer(state: SubagentState): void {
|
|
412
|
+
if (!state.agentEndGraceTimer) return;
|
|
413
|
+
clearTimeout(state.agentEndGraceTimer);
|
|
414
|
+
state.agentEndGraceTimer = undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function processEventLine(
|
|
418
|
+
line: string,
|
|
419
|
+
state: SubagentState,
|
|
420
|
+
emitUpdate: () => void,
|
|
421
|
+
requestTermination: (reason: string) => Promise<unknown>,
|
|
422
|
+
): void {
|
|
423
|
+
const parseResult = parseChildEventLine(line);
|
|
424
|
+
if (parseResult.kind !== "known") return;
|
|
425
|
+
const { event } = parseResult;
|
|
426
|
+
|
|
427
|
+
if (
|
|
428
|
+
(event.type === "message_end" || event.type === "tool_result_end") &&
|
|
429
|
+
event.message
|
|
430
|
+
) {
|
|
431
|
+
addMessageToResult(state.result, event.message as Message);
|
|
432
|
+
emitUpdate();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (event.type !== "agent_end") return;
|
|
436
|
+
|
|
437
|
+
if (state.result.messages.length === 0 && Array.isArray(event.messages)) {
|
|
438
|
+
for (const msg of event.messages as Message[]) {
|
|
439
|
+
addMessageToResult(state.result, msg);
|
|
440
|
+
}
|
|
441
|
+
emitUpdate();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (state.agentEndGraceTimer || state.terminationPromise) return;
|
|
445
|
+
state.agentEndGraceTimer = setTimeout(() => {
|
|
446
|
+
state.agentEndGraceTimer = undefined;
|
|
447
|
+
void requestTermination("agent_end_timeout");
|
|
448
|
+
}, AGENT_END_GRACE_MS);
|
|
449
|
+
state.agentEndGraceTimer.unref?.();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function setupAbortHandler(
|
|
453
|
+
signal: AbortSignal | undefined,
|
|
454
|
+
state: SubagentState,
|
|
455
|
+
clearTimer: () => void,
|
|
456
|
+
requestTermination: (reason: string) => Promise<unknown>,
|
|
457
|
+
): (() => void) | undefined {
|
|
458
|
+
if (!signal) return undefined;
|
|
459
|
+
const onAbort = () => {
|
|
460
|
+
state.wasAborted = true;
|
|
461
|
+
clearTimer();
|
|
462
|
+
void requestTermination(getAbortReason(signal));
|
|
463
|
+
};
|
|
464
|
+
if (signal.aborted) {
|
|
465
|
+
onAbort();
|
|
466
|
+
} else {
|
|
467
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
468
|
+
}
|
|
469
|
+
return onAbort;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildPiArgs(
|
|
473
|
+
agent: AgentConfig,
|
|
474
|
+
task: string,
|
|
475
|
+
parentModel: { provider: string; id: string } | undefined,
|
|
476
|
+
thinking: ThinkingLevel,
|
|
477
|
+
resolvedSkills: { args: string[] },
|
|
478
|
+
tmpPrompt: { filePath: string } | null,
|
|
479
|
+
): string[] {
|
|
480
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
481
|
+
if (parentModel) {
|
|
482
|
+
args.push("--provider", parentModel.provider, "--model", parentModel.id);
|
|
483
|
+
}
|
|
484
|
+
args.push("--thinking", thinking);
|
|
485
|
+
if (agent.tools?.length) args.push("--tools", agent.tools.join(","));
|
|
486
|
+
if (agent.skills) args.push("--no-skills", ...resolvedSkills.args);
|
|
487
|
+
if (tmpPrompt) {
|
|
488
|
+
args.push("--append-system-prompt", tmpPrompt.filePath);
|
|
489
|
+
}
|
|
490
|
+
const taskPrompt = task
|
|
491
|
+
? `Task: ${task}`
|
|
492
|
+
: "Run according to your system prompt. If no explicit task was provided, use the default context described there.";
|
|
493
|
+
args.push(appendSubagentResultContract(taskPrompt));
|
|
494
|
+
return args;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function setupChildProcess(
|
|
498
|
+
proc: ChildProcess,
|
|
499
|
+
state: SubagentState,
|
|
500
|
+
emitUpdate: () => void,
|
|
501
|
+
requestTermination: (reason: string) => Promise<unknown>,
|
|
502
|
+
): void {
|
|
503
|
+
proc.once("error", (error) => {
|
|
504
|
+
state.spawnError = error;
|
|
505
|
+
state.result.stderr = appendWithByteLimit(
|
|
506
|
+
state.result.stderr,
|
|
507
|
+
error.message,
|
|
508
|
+
MAX_STDERR_BYTES,
|
|
509
|
+
);
|
|
510
|
+
});
|
|
511
|
+
if (proc.stdout) {
|
|
512
|
+
readline
|
|
513
|
+
.createInterface({ input: proc.stdout })
|
|
514
|
+
.on("line", (line) =>
|
|
515
|
+
processEventLine(line, state, emitUpdate, requestTermination),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (proc.stderr) {
|
|
519
|
+
proc.stderr.on("data", (data) => {
|
|
520
|
+
state.result.stderr = appendWithByteLimit(
|
|
521
|
+
state.result.stderr,
|
|
522
|
+
data.toString(),
|
|
523
|
+
MAX_STDERR_BYTES,
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function finalizeResult(
|
|
530
|
+
state: SubagentState,
|
|
531
|
+
startedAt: number,
|
|
532
|
+
): Promise<SingleResult> {
|
|
533
|
+
state.result.durationMs = Date.now() - startedAt;
|
|
534
|
+
clearGraceTimer(state);
|
|
535
|
+
if (state.terminationPromise) await state.terminationPromise;
|
|
536
|
+
if (state.spawnError) state.result.exitCode = 1;
|
|
537
|
+
if (detectMessageError(state.result.messages)) {
|
|
538
|
+
state.result.errorMessage ||= TOOL_RESULT_FAILED_MESSAGE;
|
|
539
|
+
}
|
|
540
|
+
const agentEndTimeoutExitCode = getAgentEndTimeoutExitCode(
|
|
541
|
+
state.result,
|
|
542
|
+
state.spawnError,
|
|
543
|
+
);
|
|
544
|
+
if (agentEndTimeoutExitCode !== undefined) {
|
|
545
|
+
state.result.exitCode = agentEndTimeoutExitCode;
|
|
546
|
+
}
|
|
547
|
+
if (state.wasAborted) throw new Error("Subagent was aborted");
|
|
548
|
+
return state.result;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Executes a single subagent task.
|
|
553
|
+
*
|
|
554
|
+
* Rationale: Subagents run in isolated child processes to protect the parent's
|
|
555
|
+
* context window and allow specialized system prompts/tools without polluting
|
|
556
|
+
* the main conversation.
|
|
557
|
+
*
|
|
558
|
+
* Safety:
|
|
559
|
+
* - Enforces a strict recursion limit (depth 1) via environment variables.
|
|
560
|
+
* - Uses temporary prompt files to pass large system prompts without shell limits.
|
|
561
|
+
* - Streams JSON events from the child to provide real-time UI updates to the parent.
|
|
562
|
+
* - Implements aggressive process tree termination to prevent orphan processes.
|
|
563
|
+
*
|
|
564
|
+
* Side Effects: Spawns a child process and writes/deletes temporary files in `/tmp`.
|
|
565
|
+
*/
|
|
566
|
+
export async function runSingleAgent(
|
|
567
|
+
defaultCwd: string,
|
|
568
|
+
agents: AgentConfig[],
|
|
569
|
+
agentName: string,
|
|
570
|
+
task: string,
|
|
571
|
+
signal: AbortSignal | undefined,
|
|
572
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
573
|
+
makeDetails: (
|
|
574
|
+
results: RuntimeResult[],
|
|
575
|
+
options?: { includeMessages?: boolean; recentMessages?: Message[] },
|
|
576
|
+
) => SubagentDetails,
|
|
577
|
+
parentModel: { provider: string; id: string } | undefined,
|
|
578
|
+
parentThinking: ThinkingLevel,
|
|
579
|
+
): Promise<SingleResult> {
|
|
580
|
+
const agent = agents.find((a) => a.name === agentName);
|
|
581
|
+
if (!agent) return errorForUnknownAgent(agentName, agents, task);
|
|
582
|
+
|
|
583
|
+
const depth = getSubagentDepth();
|
|
584
|
+
if (depth >= MAX_SUBAGENT_DEPTH) {
|
|
585
|
+
return errorForDepthLimit(agentName, agent.source, task, depth);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const thinking = agent.thinking ?? parentThinking;
|
|
589
|
+
const modelDisplay = buildModelDisplay(parentModel, thinking);
|
|
590
|
+
const resolvedSkills = agent.skills
|
|
591
|
+
? await resolveAgentSkillArgs(defaultCwd, agent.skills)
|
|
592
|
+
: { args: [] };
|
|
593
|
+
if ("error" in resolvedSkills) {
|
|
594
|
+
return createErrorResult(
|
|
595
|
+
agentName,
|
|
596
|
+
agent.source,
|
|
597
|
+
task,
|
|
598
|
+
resolvedSkills.error,
|
|
599
|
+
modelDisplay,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const startedAt = Date.now();
|
|
604
|
+
const state: SubagentState = {
|
|
605
|
+
result: initRuntimeResult(agentName, agent.source, task, modelDisplay),
|
|
606
|
+
wasAborted: false,
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
let tmpPrompt: { dir: string; filePath: string } | null = null;
|
|
610
|
+
try {
|
|
611
|
+
tmpPrompt = agent.systemPrompt.trim()
|
|
612
|
+
? await writePromptToTempFile(agent.name, agent.systemPrompt)
|
|
613
|
+
: null;
|
|
614
|
+
const args = buildPiArgs(
|
|
615
|
+
agent,
|
|
616
|
+
task,
|
|
617
|
+
parentModel,
|
|
618
|
+
thinking,
|
|
619
|
+
resolvedSkills,
|
|
620
|
+
tmpPrompt,
|
|
621
|
+
);
|
|
622
|
+
const invocation = getPiInvocation(args);
|
|
623
|
+
const terminateOptions = {
|
|
624
|
+
tree: true,
|
|
625
|
+
platform: process.platform,
|
|
626
|
+
processTreeDetached: process.platform !== "win32",
|
|
627
|
+
};
|
|
628
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
629
|
+
cwd: defaultCwd,
|
|
630
|
+
shell: invocation.command === "pi" && process.platform === "win32",
|
|
631
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
632
|
+
env: { ...process.env, ...subagentDepthEnv() },
|
|
633
|
+
...getProcessTreeSpawnOptions(terminateOptions.tree),
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
const processDone = waitForSubagentProcess(proc);
|
|
637
|
+
const emitUpdate = makeEmitUpdate(state.result, onUpdate, makeDetails);
|
|
638
|
+
const requestTermination = makeRequestTerminator(
|
|
639
|
+
proc,
|
|
640
|
+
terminateOptions,
|
|
641
|
+
state,
|
|
642
|
+
);
|
|
643
|
+
setupChildProcess(proc, state, emitUpdate, requestTermination);
|
|
644
|
+
|
|
645
|
+
const onAbort = setupAbortHandler(
|
|
646
|
+
signal,
|
|
647
|
+
state,
|
|
648
|
+
() => clearGraceTimer(state),
|
|
649
|
+
requestTermination,
|
|
650
|
+
);
|
|
651
|
+
try {
|
|
652
|
+
state.result.exitCode = (await processDone) ?? 0;
|
|
653
|
+
return await finalizeResult(state, startedAt);
|
|
654
|
+
} finally {
|
|
655
|
+
clearGraceTimer(state);
|
|
656
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
if (tmpPrompt) await cleanupTempPrompt(tmpPrompt);
|
|
660
|
+
}
|
|
661
|
+
}
|