@ferris1225/pi-subagents 0.31.0 → 0.32.2

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/src/spawn.ts CHANGED
@@ -1,977 +1,562 @@
1
- /**
2
- * Sub-agent dispatch: each agent runs as an isolated `pi` child process
3
- * (`--mode json -p --no-session`). The agent's system prompt (the .md body) is
4
- * written to a temp file and passed via `--append-system-prompt` (which accepts a
5
- * file path). The task itself is sent through the child's stdin pipe, not another
6
- * temp file or command-line argument. Child stdout is a JSON-lines event stream;
7
- * we accumulate assistant messages from `message_end` events.
8
- *
9
- * Adapted from the official pi example `examples/extensions/subagent`.
10
- */
11
-
12
- import { spawn, type ChildProcess } from "node:child_process";
13
- import { randomUUID } from "node:crypto";
14
- import { existsSync, mkdirSync, readdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
15
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
16
- import { tmpdir } from "node:os";
17
- import { basename, join } from "node:path";
18
- import { StringDecoder } from "node:string_decoder";
19
- import type { Message } from "@earendil-works/pi-ai";
20
- import type { AgentConfig, AgentSource } from "./agents.ts";
21
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
22
-
23
- /**
24
- * Limits are configurable: see maxConcurrency in config.ts (default 4, via
25
- * /subagents-setup or pi-subagents.json).
26
- */
27
- /** Default thinking level for sub-agents. pi clamps it to the resolved model's support. */
28
- export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
29
- export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
30
- export const SUBAGENT_KILL_GRACE_MS = 5_000;
31
- /** Default idle watchdog: terminate a child whose stdout goes silent for this
32
- * many milliseconds. 0 disables it. The actual value comes from config
33
- * (idleTimeoutSec); this constant is only a fallback for tests. */
34
- export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
35
-
36
- /** Backoff schedule for retrying a child that exited before any model or tool
37
- * activity — the signature of a concurrent pi startup race, where several
38
- * sub-agents contending for pi's startup lock lose and exit with nothing on
39
- * stdout. Bounded and short so persistent launch failures are not amplified
40
- * while the startup lock clears. */
41
- export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
42
- /** A genuine startup race fails well before a model request can complete. */
43
- export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
44
-
45
- /** Backoff schedule (ms) for retrying a run whose configured model failed at the
46
- * provider level with a TRANSIENT error (503/429/timeout/network/overloaded/...)
47
- * i.e. NOT a terminal error (quota exhausted, billing, invalid API key). The same
48
- * model is relaunched (each relaunch gets its own startup-retry inner loop), so a
49
- * one-off provider hiccup recovers without demoting the configured agent model.
50
- *
51
- * This sits OUTSIDE pi-ai's per-request provider retry (default 3 attempts, 2/4/8s
52
- * backoff): when the provider still can't recover after its own retries, the
53
- * child exits carrying the final error, and this layer relaunches the whole run
54
- * up to len(delays) more times before falling back to the main-window model.
55
- *
56
- * Bounded and capped so a stubborn outage does not stall a dispatch forever. */
57
- export const SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
58
-
59
- export interface UsageStats {
60
- input: number;
61
- output: number;
62
- cacheRead: number;
63
- cacheWrite: number;
64
- cost: number;
65
- contextTokens: number;
66
- turns: number;
67
- }
68
-
69
- export interface SingleResult {
70
- agent: string;
71
- agentSource: AgentSource | "unknown";
72
- task: string;
73
- exitCode: number; // -1 = still running
74
- messages: Message[];
75
- stderr: string;
76
- usage: UsageStats;
77
- model?: string;
78
- /** Effective thinking strength this run was launched with. */
79
- thinking?: string;
80
- stopReason?: string;
81
- errorMessage?: string;
82
- /** Model the run degraded from: set when a failed run was retried with the main-window model. */
83
- modelFallbackFrom?: string;
84
- /** True when the result was synthesized from a thrown exception (spawn infra,
85
- * temp-file/fs errors, delivery bugs) instead of being produced by the agent
86
- * process. A dispatch failure is never a model-level failure. */
87
- dispatchFailed?: boolean;
88
- /** How many times the run was relaunched after a silent, zero-activity startup
89
- * exit (a concurrent pi startup race) before it produced a result. Set only when
90
- * the run actually recovered after retrying, so callers can surface it. */
91
- startupRetries?: number;
92
- /** How many times the SAME configured model was relaunched after a transient
93
- * provider-level failure (503/429/timeout/network/...) before the run produced
94
- * a result. Set on recovery and on fall-back to the main-window model; left
95
- * undefined for a terminal (quota/billing/invalid-key) error that short-
96
- * circuits before any retry, since no relaunch happened. */
97
- modelRetries?: number;
98
- /** Tool calls that failed inside the run (from tool_execution_end events). A
99
- * clean process exit can still hide a failed build/test/tool — the completion
100
- * message must surface these so the main agent is never misled by a rosy final
101
- * text (e.g. a worker that ended with "keep waiting" while its build failed). */
102
- failedTools?: Array<{ toolName: string; error: string }>;
103
- /** The pi session id this run used (every run is session-backed so a
104
- * model-level failure can be resumed on another model without re-scanning). */
105
- sessionId?: string;
106
- /** Directory holding the run's pi session file. Preserved across the initial
107
- * attempt and any resume attempts; kept on disk only when a model-level
108
- * failure is handed back, so a later `resume` can pick up the context. */
109
- sessionDir?: string;
110
- /** True when the result was produced by resuming an earlier session (a
111
- * model-level fallback or an explicit resume) rather than a fresh start. */
112
- resumed?: boolean;
113
- }
114
-
115
- export interface SubagentDetails {
116
- mode: "single" | "parallel";
117
- results: SingleResult[];
118
- /** The tool returned immediately while the child process continues in the background. */
119
- background?: boolean;
120
- }
121
-
122
- export type SubagentLiveEvent =
123
- | { kind: "status"; status: "queued" | "running" | "done" | "failed" }
124
- | { kind: "usage"; usage: UsageStats; model?: string }
125
- | { kind: "tool_start"; toolName: string; args: unknown }
126
- | { kind: "tool_end"; toolName: string; isError: boolean }
127
- | { kind: "thinking" }
128
- | { kind: "text" };
129
-
130
- function emptyUsage(): UsageStats {
131
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
132
- }
133
-
134
- export function getFinalOutput(messages: Message[]): string {
135
- for (let i = messages.length - 1; i >= 0; i--) {
136
- const msg = messages[i];
137
- if (msg.role === "assistant") {
138
- for (const part of msg.content) {
139
- if (part.type === "text") return part.text;
140
- }
141
- }
142
- }
143
- return "";
144
- }
145
-
146
- /**
147
- * Parse the machine-readable verdict a reviewer emits (see agents/reviewer.md).
148
- * Only the LAST standalone `VERDICT: REVIEW_PASS/FAIL` line counts, so a report
149
- * that merely discusses the tokens cannot be misclassified. Returns undefined
150
- * when no verdict marker is present, so non-review agents are never mistaken
151
- * for reviews.
152
- */
153
- export function reviewVerdict(output: string): "pass" | "fail" | undefined {
154
- const lines = output.split("\n");
155
- for (let index = lines.length - 1; index >= 0; index--) {
156
- const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
157
- if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
158
- }
159
- return undefined;
160
- }
161
-
162
- /** Tool errors are usually the trailing lines of a long output (build logs);
163
- * keep the last non-empty lines, clipped to RESULT_LINE_MAX each. */
164
- export function extractToolErrorText(content: unknown): string {
165
- const parts = Array.isArray(content) ? content : [];
166
- const text = parts
167
- .filter(
168
- (part): part is { type: "text"; text: string } =>
169
- typeof part === "object" &&
170
- part !== null &&
171
- (part as { type?: unknown }).type === "text" &&
172
- typeof (part as { text?: unknown }).text === "string",
173
- )
174
- .map((part) => part.text)
175
- .join("\n");
176
- return text
177
- .split("\n")
178
- .map((line) => line.trim())
179
- .filter(Boolean)
180
- .slice(-3)
181
- .map((line) => (line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line))
182
- .join("\n");
183
- }
184
-
185
- /** Hard cap for a single line inside a truncated result (minified blobs must not blow up). */
186
- export const RESULT_LINE_MAX = 200;
187
-
188
- export interface TruncatedOutput {
189
- /** The result text that fits in the completion message. */
190
- text: string;
191
- /** True when lines were dropped or shortened, so the full text is written to disk. */
192
- truncated: boolean;
193
- }
194
-
195
- /** Cap result text for the main conversation: keep the first `maxLines` lines, at most RESULT_LINE_MAX chars each. */
196
- export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
197
- const lines = output.split("\n");
198
- if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
199
- return { text: output, truncated: false };
200
- }
201
- const kept = lines.slice(0, maxLines).map((line) =>
202
- line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
203
- );
204
- return { text: kept.join("\n"), truncated: true };
205
- }
206
-
207
- /** Persist the full result where the main agent can read it on demand. Returns the file path.
208
- * Results are grouped under a per-project subdirectory so concurrent projects don't
209
- * litter a single flat folder. */
210
- export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
211
- const projectSlug = cwd
212
- ? basename(cwd).replace(/[^\w.-]+/g, "_") || "default"
213
- : "default";
214
- const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
215
- mkdirSync(dir, { recursive: true });
216
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
217
- // A random suffix keeps same-millisecond writes from clobbering each other.
218
- const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
219
- const filePath = join(dir, `${unique}-${safeName}.md`);
220
- writeFileSync(filePath, output, "utf8");
221
- return filePath;
222
- }
223
-
224
- export function isFailedResult(result: SingleResult): boolean {
225
- return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
226
- }
227
-
228
- /**
229
- * True when a failed run never got usable output from its model: the provider
230
- * rejected the call before the model produced any text (bad model id, auth,
231
- * thinking level, quota, ...). Task-level failures — the model worked and the
232
- * task failed — and aborts/timeouts are NOT model-level and must not degrade.
233
- */
234
- export function isModelLevelFailure(result: SingleResult): boolean {
235
- if (!isFailedResult(result)) return false;
236
- if (result.stopReason === "aborted") return false;
237
- // A result synthesized from a thrown exception (spawn infra, fs, delivery
238
- // bugs) never came from the provider: it is a dispatch failure, not a
239
- // model-level one, and must not be handed back as a model problem.
240
- if (result.dispatchFailed) return false;
241
- // An idle timeout (stdout went silent) signals a stalled provider connection,
242
- // not a task-level failure: allow model fallback even if the model produced
243
- // partial output before going quiet.
244
- if (result.errorMessage?.includes("idle timeout")) return true;
245
- // The model produced text: the failure belongs to the task, not the model.
246
- if (getFinalOutput(result.messages)) return false;
247
- // Require evidence the failure came from the model/provider (an error
248
- // message or stderr), not from the child process failing to start.
249
- return result.messages.length > 0 || result.stderr.trim().length > 0;
250
- }
251
-
252
- /** Patterns that signal a TERMINAL provider/account error: retrying the same
253
- * model (or falling back to the main-window model under the same account) cannot
254
- * fix it, so the run skips both run-level retry and model fallback and is handed
255
- * back to the main agent. This is the complement of pi-ai's transient-error set
256
- * (429/5xx/overloaded/network/timeout/...): anything NOT matching here is treated
257
- * as transient and retried on the same model before degrading.
258
- *
259
- * Mirrors pi-ai's NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN (quota/billing/
260
- * subscription-limit text) and adds the auth/credential failures the user cited
261
- * ("key无效"). Auth is account-scoped, so a fallback model on the same provider
262
- * would fail identically — hand it to the main agent immediately. */
263
- const TERMINAL_MODEL_ERROR_PATTERN =
264
- /insufficient_quota|quota\s+exceeded|exceeded[^.\n]{0,40}quota|out\s+of\s+budget|billing|usage\s+limit|usage_limit|gousagelimiterror|freeusagelimiterror|monthly\s+usage\s+limit\s+reached|available\s+balance|invalid\s+(?:api\s+)?key|incorrect\s+api\s+key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission\s+denied/i;
265
-
266
- /** True when a model-level failure carries a TERMINAL error message — quota
267
- * exhaustion, billing, an invalid API key, auth rejection. Such a run is NEVER
268
- * retried on the same model and never falls back to the main-window model: the
269
- * account is the bottleneck, so it is handed back to the main agent to fix.
270
- *
271
- * Caller must first confirm `isModelLevelFailure(result)` aborts and
272
- * dispatch-crafted results never reach this classifier. */
273
- export function isTerminalModelError(result: SingleResult): boolean {
274
- const message = result.errorMessage?.trim();
275
- if (message) return TERMINAL_MODEL_ERROR_PATTERN.test(message);
276
- // Only consult stderr when there is no structured errorMessage: pi-ai surfaces
277
- // provider errors via message_end -> errorMessage, so a transient errorMessage
278
- // (e.g. "503 Service Unavailable") must not be overridden by noisy stderr that
279
- // happens to mention a terminal-looking word (an npm warning, a proxy banner).
280
- // This keeps transient failures retryable even when stderr is chatty.
281
- const stderr = result.stderr.trim();
282
- return stderr.length > 0 && TERMINAL_MODEL_ERROR_PATTERN.test(stderr);
283
- }
284
-
285
- /**
286
- * True when a failed run produced NO model, tool, output, or usage activity
287
- * within the startup window — the signature of a concurrent pi startup race,
288
- * where the child lost pi's startup lock and exited before doing anything.
289
- * Such a run is safe to relaunch: nothing was mutated and no provider call
290
- * completed, so retrying cannot duplicate work.
291
- *
292
- * Fails closed: any final output, assistant message, usage, stderr, structured
293
- * error message, idle-timeout, abort, dispatch crash, or run that outlived the
294
- * startup window disqualifies the run from retry (it either did real work or
295
- * carries a real error that belongs to model fallback / normal failure
296
- * delivery instead). Only a clean, SILENT, fast, zero-activity exit retries.
297
- */
298
- export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
299
- if (result.exitCode === 0) return false;
300
- if (result.stopReason === "aborted") return false;
301
- if (result.dispatchFailed) return false;
302
- if (result.errorMessage?.includes("idle timeout")) return false;
303
- if (getFinalOutput(result.messages)) return false;
304
- if (result.messages.length > 0) return false;
305
- const usage = result.usage;
306
- if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
307
- if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
308
- // Any stderr or structured error could be a real provider/config error (auth,
309
- // bad model id, quota, ...) that must not be amplified by retry. A silent
310
- // zero-activity exit — no stdout, no stderr, no error message — is the race.
311
- if (result.stderr.trim().length > 0) return false;
312
- if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
313
- return true;
314
- }
315
-
316
- /** Error surfaced when every startup-retry attempt still exited with no
317
- * activity. Tells the main agent the dispatch never reached a model and what to
318
- * do (retry, or lower maxConcurrency). */
319
- export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
320
- return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child exited before any model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
321
- }
322
-
323
- /** Wait out a startup-retry backoff. Resolves false immediately (do not retry)
324
- * when the signal is or becomes aborted during the wait, so cancellation never
325
- * delays delivering the last result. The timer is unref'd so it cannot keep the
326
- * event loop alive on shutdown. */
327
- export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
328
- if (delayMs <= 0) return !signal?.aborted;
329
- if (!signal) {
330
- return new Promise<boolean>((resolve) => {
331
- const timer = setTimeout(() => resolve(true), delayMs);
332
- if (typeof timer.unref === "function") timer.unref();
333
- });
334
- }
335
- if (signal.aborted) return false;
336
- return new Promise<boolean>((resolve) => {
337
- let settled = false;
338
- const finish = (shouldRetry: boolean): void => {
339
- if (settled) return;
340
- settled = true;
341
- clearTimeout(timer);
342
- signal.removeEventListener("abort", onAbort);
343
- resolve(shouldRetry);
344
- };
345
- const onAbort = (): void => finish(false);
346
- const timer = setTimeout(() => finish(true), delayMs);
347
- if (typeof timer.unref === "function") timer.unref();
348
- signal.addEventListener("abort", onAbort, { once: true });
349
- });
350
- }
351
-
352
- export function getResultOutput(result: SingleResult): string {
353
- if (isFailedResult(result)) {
354
- const error = result.errorMessage || result.stderr;
355
- const partial = getFinalOutput(result.messages);
356
- if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
357
- return error || partial || "(no output)";
358
- }
359
- return getFinalOutput(result.messages) || "(no output)";
360
- }
361
-
362
- /**
363
- * True when a pi session file for `sessionId` already exists in `sessionDir`.
364
- * Every sub-agent run is session-backed; the FIRST attempt creates the session
365
- * (`--session-id`) and every later attempt on the same session RESUMES it
366
- * (`--session`), so a model-level retry or fallback picks up the prior context
367
- * instead of re-scanning. The session file is named `<timestamp>Z_<id>.jsonl`
368
- * (pi's convention), so a suffix match is exact and cheap.
369
- */
370
- export function sessionExists(sessionDir: string, sessionId: string): boolean {
371
- try {
372
- return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
373
- } catch {
374
- return false;
375
- }
376
- }
377
-
378
- /**
379
- * Build the continuation prompt sent to a RESUMED sub-agent session. The model
380
- * sees the full prior history (loaded by `--session`) plus this new user turn,
381
- * so it continues from where it stopped. Steering it not to redo finished work
382
- * is what saves the re-scan the user wants to avoid.
383
- *
384
- * `reason` is a short clause describing why the session is resuming
385
- * ("a transient provider error" / "your previous model hit a quota or auth
386
- * limit, so a different model is now continuing").
387
- */
388
- export function buildResumePrompt(task: string, reason: string): string {
389
- return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
390
- }
391
-
392
- /** Reason clause for resuming on a DIFFERENT model after the configured model
393
- * failed at the provider level (quota/auth/overloaded/...). */
394
- export function buildFallbackResumeReason(fromModel?: string): string {
395
- return fromModel
396
- ? `your previous model (${fromModel}) hit a quota, billing, or auth limit, so a different model is now continuing`
397
- : "your previous model became unavailable, so a different model is now continuing";
398
- }
399
-
400
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
401
- const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
402
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
403
- const filePath = join(dir, `prompt-${safeName}.md`);
404
- await writeFile(filePath, prompt, "utf8");
405
- return { dir, filePath };
406
- }
407
-
408
- /** Resolve how to invoke the SAME pi build as the current process. */
409
- export function getPiInvocation(args: string[]): { command: string; args: string[] } {
410
- const currentScript = process.argv[1];
411
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
412
- if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
413
- return { command: process.execPath, args: [currentScript, ...args] };
414
- }
415
- const execName = basename(process.execPath).toLowerCase();
416
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
417
- if (!isGenericRuntime) return { command: process.execPath, args };
418
- return { command: "pi", args };
419
- }
420
-
421
- /** Terminate the child and any tool processes it left behind. */
422
- function terminateProcessTree(proc: ChildProcess, force: boolean): void {
423
- if (process.platform === "win32" && proc.pid !== undefined) {
424
- const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
425
- stdio: "ignore",
426
- windowsHide: true,
427
- });
428
- const fallback = (): void => {
429
- try {
430
- proc.kill(force ? "SIGKILL" : "SIGTERM");
431
- } catch {
432
- /* process may already be gone */
433
- }
434
- };
435
- killer.on("error", fallback);
436
- killer.on("close", (code) => {
437
- if (code !== 0) fallback();
438
- });
439
- return;
440
- }
441
-
442
- try {
443
- proc.kill(force ? "SIGKILL" : "SIGTERM");
444
- } catch {
445
- /* process may already be gone */
446
- }
447
- }
448
-
449
- export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
450
- const raw = env[DEPTH_ENV_VAR];
451
- const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
452
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
453
- }
454
-
455
- export interface RunSingleOptions {
456
- defaultCwd: string;
457
- agent: AgentConfig | undefined;
458
- agentName: string;
459
- task: string;
460
- cwd?: string;
461
- /** Thinking level passed to the child pi process. */
462
- thinkingLevel?: ThinkingLevel;
463
- /** Idle timeout in ms: terminate the child if its stdout produces no activity
464
- * for this duration. 0 (the default) disables the idle watchdog. */
465
- idleTimeoutMs?: number;
466
- /** Startup-retry backoff schedule (ms) for silent, zero-activity child exits
467
- * (a concurrent pi startup race). Defaults to SUBAGENT_STARTUP_RETRY_DELAYS_MS;
468
- * pass a shorter array in tests to keep them fast. */
469
- startupRetryDelaysMs?: readonly number[];
470
- /** Run-level backoff schedule (ms) for relaunching the SAME configured model
471
- * after a transient provider-level failure (503/429/timeout/network/...). Each
472
- * relaunch gets its own startup-retry inner loop. Defaults to
473
- * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
474
- * test wants to assert only the fallback path runs once). */
475
- runLevelRetryDelaysMs?: readonly number[];
476
- /** Directory holding this run's pi session. When set (with sessionId), the
477
- * child is session-backed: it creates the session on the first attempt and
478
- * RESUMES it on any later attempt (model-level retry/fallback), so a model
479
- * switch inherits the prior context. When unset, the child runs ephemerally
480
- * (--no-session) and cannot be resumed. The caller owns the directory's
481
- * lifecycle; runSingleAgent neither creates nor removes it. */
482
- sessionDir?: string;
483
- /** Pi session id paired with sessionDir. The first attempt creates it
484
- * (--session-id); later attempts resume (--session) once the session file
485
- * exists. */
486
- sessionId?: string;
487
- /** Text sent to the child via stdin. Defaults to `Task: ${task}`. A resumed
488
- * attempt passes a continuation prompt (see buildResumePrompt) so the model
489
- * picks up the prior session instead of starting the task over. */
490
- stdinText?: string;
491
- signal?: AbortSignal;
492
- onLive?: (e: SubagentLiveEvent) => void;
493
- makeDetails: (results: SingleResult[]) => SubagentDetails;
494
- env?: NodeJS.ProcessEnv;
495
- }
496
-
497
- /** Spawn one agent as an isolated pi child process and collect its output. */
498
- export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
499
- const {
500
- agent,
501
- agentName,
502
- task,
503
- cwd,
504
- thinkingLevel = SUBAGENT_THINKING_LEVEL,
505
- idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
506
- signal,
507
- onLive,
508
- makeDetails,
509
- } = options;
510
-
511
- if (!agent) {
512
- return {
513
- agent: agentName,
514
- agentSource: "unknown",
515
- task,
516
- exitCode: 1,
517
- messages: [],
518
- stderr: `Unknown agent: "${agentName}".`,
519
- usage: emptyUsage(),
520
- };
521
- }
522
-
523
- // Defense in depth: even if another extension ignores the depth marker, a
524
- // child process can never expose a tool named `subagent` back to its model.
525
- const args: string[] = ["--mode", "json", "-p", "--exclude-tools", "subagent"];
526
- // Session-backed: every run persists its pi session so a model-level retry or
527
- // fallback can RESUME it (--session) instead of re-scanning from scratch. The
528
- // first attempt creates the session (--session-id); once the session file
529
- // exists, later attempts resume it. No sessionDir → ephemeral (--no-session).
530
- if (options.sessionDir && options.sessionId) {
531
- args.push("--session-dir", options.sessionDir);
532
- args.push(
533
- sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id",
534
- options.sessionId,
535
- );
536
- } else {
537
- args.push("--no-session");
538
- }
539
- if (agent.model) args.push("--model", agent.model);
540
- // The configured level is clamped adaptively per model by pi.
541
- args.push("--thinking", thinkingLevel);
542
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
543
-
544
- let tmpPromptDir: string | null = null;
545
- let tmpPromptPath: string | null = null;
546
-
547
- const hasSession = Boolean(options.sessionDir && options.sessionId);
548
- const currentResult: SingleResult = {
549
- agent: agentName,
550
- agentSource: agent.source,
551
- task,
552
- exitCode: 0,
553
- messages: [],
554
- stderr: "",
555
- usage: emptyUsage(),
556
- model: agent.model,
557
- thinking: thinkingLevel,
558
- sessionId: options.sessionId,
559
- sessionDir: options.sessionDir,
560
- resumed: hasSession && sessionExists(options.sessionDir as string, options.sessionId as string),
561
- };
562
-
563
- try {
564
- if (agent.systemPrompt.trim()) {
565
- const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
566
- tmpPromptDir = tmp.dir;
567
- tmpPromptPath = tmp.filePath;
568
- args.push("--append-system-prompt", tmpPromptPath);
569
- }
570
-
571
- let wasAborted = false;
572
-
573
- // Increment depth so nested sub-agents can be guarded against runaway recursion.
574
- const childDepth = currentSubagentDepth(options.env) + 1;
575
- const childEnv: NodeJS.ProcessEnv = {
576
- ...(options.env ?? process.env),
577
- [DEPTH_ENV_VAR]: String(childDepth),
578
- };
579
-
580
- const exitCode = await new Promise<number>((resolve) => {
581
- const invocation = getPiInvocation(args);
582
- const proc = spawn(invocation.command, invocation.args, {
583
- cwd: cwd ?? options.defaultCwd,
584
- shell: false,
585
- stdio: ["pipe", "pipe", "pipe"],
586
- env: childEnv,
587
- });
588
- let buffer = "";
589
- let closed = false;
590
- let termSent = false;
591
- let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
592
- let abortHandler: (() => void) | undefined;
593
- let lastActivityAt = Date.now();
594
- let idleTimer: ReturnType<typeof setInterval> | undefined;
595
-
596
- const finish = (code: number | null): void => {
597
- if (closed) return;
598
- closed = true;
599
- if (forceKillTimer) clearTimeout(forceKillTimer);
600
- if (idleTimer) clearInterval(idleTimer);
601
- if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
602
- resolve(code ?? 1);
603
- };
604
-
605
- const terminate = (): void => {
606
- if (closed) return;
607
- if (!termSent) {
608
- termSent = true;
609
- terminateProcessTree(proc, false);
610
- }
611
- if (!forceKillTimer) {
612
- forceKillTimer = setTimeout(() => {
613
- if (!closed) terminateProcessTree(proc, true);
614
- }, SUBAGENT_KILL_GRACE_MS);
615
- }
616
- };
617
-
618
- const processLine = (line: string): void => {
619
- if (!line.trim()) return;
620
- let event: any;
621
- try {
622
- event = JSON.parse(line);
623
- } catch {
624
- return;
625
- }
626
-
627
- // Live event: agent started
628
- if (event.type === "agent_start" || event.type === "turn_start") {
629
- if (onLive) {
630
- try {
631
- onLive({ kind: "status", status: "running" });
632
- } catch { /* never throw from event handling */ }
633
- }
634
- }
635
-
636
- // Live event: streamed assistant reasoning / output text
637
- if (event.type === "message_update") {
638
- const t = event.assistantMessageEvent?.type;
639
- if (t === "thinking_delta" || t === "text_delta") {
640
- if (onLive) {
641
- try {
642
- onLive({ kind: t === "thinking_delta" ? "thinking" : "text" });
643
- } catch { /* never throw from event handling */ }
644
- }
645
- }
646
- }
647
-
648
- // Live event: tool execution started
649
- if (event.type === "tool_execution_start") {
650
- if (onLive) {
651
- try {
652
- onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
653
- } catch { /* never throw from event handling */ }
654
- }
655
- }
656
-
657
- // Live event: tool execution ended
658
- if (event.type === "tool_execution_end") {
659
- if (onLive) {
660
- try {
661
- onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
662
- } catch { /* never throw from event handling */ }
663
- }
664
- if (event.isError) {
665
- (currentResult.failedTools ??= []).push({
666
- toolName: event.toolName ?? "unknown",
667
- error: extractToolErrorText(event.result?.content),
668
- });
669
- }
670
- }
671
-
672
- if (event.type === "message_end" && event.message) {
673
- const msg = event.message as Message;
674
- currentResult.messages.push(msg);
675
- if (msg.role === "assistant") {
676
- currentResult.usage.turns++;
677
- const usage = (msg as any).usage;
678
- if (usage) {
679
- currentResult.usage.input += usage.input || 0;
680
- currentResult.usage.output += usage.output || 0;
681
- currentResult.usage.cacheRead += usage.cacheRead || 0;
682
- currentResult.usage.cacheWrite += usage.cacheWrite || 0;
683
- currentResult.usage.cost += usage.cost?.total || 0;
684
- currentResult.usage.contextTokens = usage.totalTokens || 0;
685
- }
686
- if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
687
- if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
688
- if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
689
- }
690
- // Live event: usage snapshot after accumulation
691
- if (onLive) {
692
- try {
693
- onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
694
- } catch { /* never throw from event handling */ }
695
- }
696
- }
697
-
698
- if (event.type === "tool_result_end" && event.message) {
699
- currentResult.messages.push(event.message as Message);
700
- }
701
- };
702
- // Send the task (or a continuation prompt for a resumed session) through
703
- // the child stdin pipe instead of the command line. This avoids OS
704
- // argument-length limits and requires no extra temp file for the data.
705
- proc.stdin?.on("error", () => undefined);
706
- proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
707
-
708
- // Decode stdout through a StringDecoder so multi-byte UTF-8 characters
709
- // (CJK, emoji) split across chunk boundaries never produce U+FFFD
710
- // replacement characters — a corrupted JSON line would drop the whole
711
- // message (including a reviewer's verdict line) from parsing.
712
- const stdoutDecoder = new StringDecoder("utf8");
713
- proc.stdout.on("data", (data) => {
714
- lastActivityAt = Date.now();
715
- buffer += stdoutDecoder.write(data);
716
- const lines = buffer.split("\n");
717
- buffer = lines.pop() || "";
718
- for (const line of lines) processLine(line);
719
- });
720
-
721
- proc.stderr.on("data", (data) => {
722
- currentResult.stderr += data.toString();
723
- });
724
-
725
- proc.on("close", (code) => {
726
- // Flush any bytes still held by the decoder (a trailing incomplete
727
- // multi-byte sequence) before processing the final buffer.
728
- buffer += stdoutDecoder.end();
729
- if (buffer.trim()) processLine(buffer);
730
- // A null exit code means the process was terminated by a signal and
731
- // must be reported as failure, never as a false clean completion.
732
- const failed =
733
- code !== 0 ||
734
- wasAborted ||
735
- (signal?.aborted ?? false) ||
736
- currentResult.stopReason === "error" ||
737
- currentResult.stopReason === "aborted";
738
- if (onLive) {
739
- try {
740
- onLive({ kind: "status", status: failed ? "failed" : "done" });
741
- } catch { /* never throw from event handling */ }
742
- }
743
- finish(code);
744
- });
745
-
746
- proc.on("error", () => {
747
- // Spawn itself failed; close may never fire, so finish the run here.
748
- currentResult.stopReason = "error";
749
- currentResult.errorMessage ??= "Failed to start the sub-agent process.";
750
- if (onLive) {
751
- try {
752
- onLive({ kind: "status", status: "failed" });
753
- } catch { /* never throw from event handling */ }
754
- }
755
- finish(1);
756
- });
757
-
758
- if (idleTimeoutMs > 0) {
759
- const checkInterval = Math.min(10_000, Math.floor(idleTimeoutMs / 3));
760
- idleTimer = setInterval(() => {
761
- if (closed) return;
762
- if (Date.now() - lastActivityAt >= idleTimeoutMs) {
763
- if (idleTimer) clearInterval(idleTimer);
764
- currentResult.stopReason = "error";
765
- currentResult.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
766
- terminate();
767
- }
768
- }, checkInterval);
769
- }
770
-
771
- if (signal) {
772
- abortHandler = (): void => {
773
- wasAborted = true;
774
- terminate();
775
- };
776
- if (signal.aborted) abortHandler();
777
- else signal.addEventListener("abort", abortHandler, { once: true });
778
- }
779
- });
780
-
781
- currentResult.exitCode = exitCode;
782
- if (wasAborted) {
783
- currentResult.stopReason = "aborted";
784
- currentResult.errorMessage ??= "Subagent was aborted";
785
- if (onLive) {
786
- try {
787
- onLive({ kind: "status", status: "failed" });
788
- } catch { /* never throw from event handling */ }
789
- }
790
- }
791
- return currentResult;
792
- } finally {
793
- if (tmpPromptPath)
794
- try {
795
- unlinkSync(tmpPromptPath);
796
- } catch {
797
- /* ignore */
798
- }
799
- if (tmpPromptDir)
800
- try {
801
- rmdirSync(tmpPromptDir);
802
- } catch {
803
- /* ignore */
804
- }
805
- }
806
- }
807
-
808
- /**
809
- * Run one agent with three layers of resilience, all session-backed so a model
810
- * switch RESUMES the prior context instead of re-scanning from scratch:
811
- *
812
- * 1. Startup retry (inner loop): a concurrent pi startup race can make the child
813
- * exit before any model/tool activity. Relaunch with backoff so the startup
814
- * lock clears. The SAME model is retried — the race is in the host, not the
815
- * model — and only a clean, silent, zero-activity exit qualifies (see
816
- * isRetryableStartupFailure), so retrying can never duplicate real work.
817
- * 2. Run-level retry on the SAME configured model (middle): when the provider
818
- * rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
819
- * NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
820
- * — RESUME the session on the same model up to
821
- * SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
822
- * preserves any work done before the hiccup.
823
- * 3. Model fallback (outer): when the same model still fails, RESUME the session
824
- * once on the main window's current model — the fallback inherits the prior
825
- * context, so the user never pays to re-scan after a model switch.
826
- *
827
- * Terminal model errors short-circuit to the caller: the account is the
828
- * bottleneck, so neither same-model retry nor a same-account fallback can help.
829
- * The session is preserved on disk (the result carries sessionId/sessionDir) so
830
- * a later manual resume on a working model can continue without re-scanning.
831
- *
832
- * The fallback is per-run only and never persisted: a transient provider hiccup
833
- * must not silently downgrade the configured agent model.
834
- */
835
- export async function runSingleAgentWithModelFallback(
836
- options: RunSingleOptions,
837
- fallbackModelRef?: string,
838
- ): Promise<SingleResult> {
839
- const agent = options.agent;
840
- const launchedRef = agent?.model;
841
- const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
842
- // Run-level retry is opted out of with an explicit empty array (e.g. a test
843
- // that wants to assert ONLY the fallback path runs once); undefined means
844
- // "use the default 5-attempt transient-error schedule".
845
- const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
846
-
847
- const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
848
- let lastResult: SingleResult;
849
- let retries = 0;
850
- for (let attempt = 0; ; attempt++) {
851
- const start = Date.now();
852
- lastResult = await runSingleAgent(opts);
853
- const durationMs = Date.now() - start;
854
- if (!isRetryableStartupFailure(lastResult, durationMs)) {
855
- if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
856
- return lastResult;
857
- }
858
- const delay = startupDelays[attempt];
859
- if (delay === undefined) {
860
- // Exhausted: the agent never reached a model. Surface the concurrency-race
861
- // cause as a dispatch-level failure (no model was ever reached, so this
862
- // must NOT trigger run-level retry or model fallback) so the main agent
863
- // can retry or lower maxConcurrency.
864
- lastResult.errorMessage = formatStartupRetryExhaustedError(
865
- lastResult.model ?? opts.agent?.model ?? "default",
866
- attempt + 1,
867
- );
868
- lastResult.stopReason ??= "error";
869
- lastResult.dispatchFailed = true;
870
- return lastResult;
871
- }
872
- // Flip the live status back to running so the widget does not flash a
873
- // false "failed" while we wait out the backoff and relaunch the child.
874
- try {
875
- opts.onLive?.({ kind: "status", status: "running" });
876
- } catch { /* never throw from event handling */ }
877
- const shouldRetry = await waitForStartupRetry(delay, opts.signal);
878
- if (!shouldRetry) return lastResult;
879
- retries++;
880
- }
881
- };
882
-
883
- // One pi session backs the whole logical run, shared by the initial attempt
884
- // and every resume (model-level retry / fallback), so a model switch inherits
885
- // the prior context instead of re-scanning. Created here unless the caller
886
- // passed one in (an explicit resume of a handed-back session).
887
- const sessionId = options.sessionId ?? randomUUID();
888
- const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
889
- const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
890
-
891
- let modelRetries = 0;
892
- let result: SingleResult | undefined;
893
- try {
894
- result = await runWithStartupRetry(baseOptions);
895
-
896
- // A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
897
- // neither a same-model retry nor a same-account fallback can help. Skip the
898
- // automatic retry/fallback and hand the run back — the session is preserved
899
- // (see finally) so a later manual resume on a working model can continue
900
- // without re-scanning.
901
- const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
902
-
903
- // A TRANSIENT provider failure (503/429/timeout/network/...) usually
904
- // recovers on a relaunch. RESUME the same session on the same configured
905
- // model up to runDelays.length more times with backoff — resuming (not
906
- // restarting) preserves any work done before the hiccup. This sits outside
907
- // pi-ai's per-request provider retry, which by then already tried and gave up.
908
- if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
909
- const retryOpts: RunSingleOptions = {
910
- ...baseOptions,
911
- stdinText: buildResumePrompt(options.task, "a transient provider error"),
912
- };
913
- for (let attempt = 0; ; attempt++) {
914
- const delay = runDelays[attempt];
915
- if (delay === undefined) break;
916
- try {
917
- options.onLive?.({ kind: "status", status: "running" });
918
- } catch { /* never throw from event handling */ }
919
- const shouldRetry = await waitForStartupRetry(delay, options.signal);
920
- if (!shouldRetry) break;
921
- const retried = await runWithStartupRetry(retryOpts);
922
- modelRetries++;
923
- // failedTools reflect ONLY the final attempt (each runSingleAgent call
924
- // accumulates its own), so the completion message stays accurate.
925
- result = retried;
926
- if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
927
- }
928
- }
929
-
930
- // Transient retries exhausted (or none) and still a model-level failure:
931
- // RESUME the session on the main window's current model exactly once. The
932
- // fallback inherits the prior context (no re-scan). Skipped when there is no
933
- // fallback ref, it equals the configured model, or the failure is terminal
934
- // (a same-account fallback would fail identically — leave it for manual resume).
935
- if (
936
- !terminal &&
937
- agent &&
938
- launchedRef &&
939
- fallbackModelRef &&
940
- launchedRef !== fallbackModelRef &&
941
- isModelLevelFailure(result)
942
- ) {
943
- const retried = await runWithStartupRetry({
944
- ...baseOptions,
945
- stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
946
- agent: { ...agent, model: fallbackModelRef },
947
- });
948
- // The fallback replaces the result wholesale: failedTools reflect ONLY the
949
- // fallback (final) attempt — stale build errors from the first model are
950
- // not merged, so a clean final attempt is never misattributed a failure.
951
- result = { ...retried, modelFallbackFrom: launchedRef };
952
- }
953
-
954
- // A terminal failure takes the bare result (no retries or fallback ran, so
955
- // modelRetries stays undefined — matching the contract callers assert).
956
- return terminal ? result : { ...result, modelRetries };
957
- } finally {
958
- // Keep the session on disk only for a model-level failure that did real work
959
- // and is being handed back, so a later `resume` can continue it. A provider
960
- // rejection before any work (no messages/tools/output) has nothing to resume,
961
- // so it is cleaned up along with every success and task-level failure. Never
962
- // remove a caller-provided sessionDir (an explicit resume owns its dir).
963
- if (result) {
964
- result.sessionId ??= sessionId;
965
- result.sessionDir ??= sessionDir;
966
- }
967
- const hasWork =
968
- !!result &&
969
- (result.messages.length > 1 ||
970
- (result.failedTools?.length ?? 0) > 0 ||
971
- Boolean(getFinalOutput(result.messages)));
972
- const keep = !!result && isModelLevelFailure(result) && hasWork;
973
- if (!keep && !options.sessionDir) {
974
- await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
975
- }
976
- }
977
- }
1
+ /**
2
+ * Sub-agent result handling and resilient RPC launch orchestration.
3
+ *
4
+ * The process transport itself lives in rpc-run.ts. Each attempt starts pi in
5
+ * persistent `--mode rpc`, sends commands over strict LF-delimited JSONL, and
6
+ * settles only on `agent_settled`. This module preserves the existing startup
7
+ * retry, same-model retry, model fallback, accounting, and result formatting
8
+ * contracts around those attempts.
9
+ */
10
+
11
+ import { randomUUID } from "node:crypto";
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { mkdtemp, rm } from "node:fs/promises";
14
+ import { tmpdir } from "node:os";
15
+ import { basename, join } from "node:path";
16
+ import type { Message } from "@earendil-works/pi-ai";
17
+ import type { AgentConfig } from "./agents.ts";
18
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
19
+ import {
20
+ currentSubagentDepth,
21
+ DEPTH_ENV_VAR,
22
+ extractToolErrorText,
23
+ getPiInvocation,
24
+ RpcRunControl,
25
+ runRpcAgentAttempt,
26
+ sessionExists,
27
+ SUBAGENT_KILL_GRACE_MS,
28
+ type RpcSingleResult,
29
+ type SubagentLiveEvent,
30
+ type SubagentRecordEvent,
31
+ type UsageStats,
32
+ } from "./rpc-run.ts";
33
+
34
+ export {
35
+ currentSubagentDepth,
36
+ DEPTH_ENV_VAR,
37
+ extractToolErrorText,
38
+ getPiInvocation,
39
+ RpcRunControl,
40
+ sessionExists,
41
+ SUBAGENT_KILL_GRACE_MS,
42
+ };
43
+ export type { SubagentLiveEvent, SubagentRecordEvent, UsageStats };
44
+
45
+ export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
46
+ /** 0 disables the watchdog; dispatch supplies the configured timeout. */
47
+ export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
48
+ export const SUBAGENT_STARTUP_RETRY_DELAYS_MS = [250, 750, 1500] as const;
49
+ export const MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS = 2000;
50
+ export const SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000, 30_000] as const;
51
+
52
+ export interface SingleResult extends RpcSingleResult {}
53
+
54
+ export interface SubagentDetails {
55
+ mode: "single" | "parallel";
56
+ results: SingleResult[];
57
+ background?: boolean;
58
+ }
59
+
60
+ export function getFinalOutput(messages: Message[]): string {
61
+ for (let i = messages.length - 1; i >= 0; i--) {
62
+ const msg = messages[i];
63
+ if (msg.role === "assistant") {
64
+ for (const part of msg.content) {
65
+ if (part.type === "text") return part.text;
66
+ }
67
+ }
68
+ }
69
+ return "";
70
+ }
71
+
72
+ /** Only the last standalone reviewer verdict line counts. */
73
+ export function reviewVerdict(output: string): "pass" | "fail" | undefined {
74
+ const lines = output.split("\n");
75
+ for (let index = lines.length - 1; index >= 0; index--) {
76
+ const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
77
+ if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ export const RESULT_LINE_MAX = 200;
83
+
84
+ export interface TruncatedOutput {
85
+ text: string;
86
+ truncated: boolean;
87
+ }
88
+
89
+ export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
90
+ const lines = output.split("\n");
91
+ if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
92
+ return { text: output, truncated: false };
93
+ }
94
+ const kept = lines.slice(0, maxLines).map((line) =>
95
+ line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
96
+ );
97
+ return { text: kept.join("\n"), truncated: true };
98
+ }
99
+
100
+ export function writeResultArtifact(output: string, agentName: string, cwd?: string): string {
101
+ const projectSlug = cwd ? basename(cwd).replace(/[^\w.-]+/g, "_") || "default" : "default";
102
+ const dir = join(tmpdir(), "pi-subagents-results", projectSlug);
103
+ mkdirSync(dir, { recursive: true });
104
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
105
+ const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
106
+ const filePath = join(dir, `${unique}-${safeName}.md`);
107
+ writeFileSync(filePath, output, "utf8");
108
+ return filePath;
109
+ }
110
+
111
+ export function isFailedResult(result: SingleResult): boolean {
112
+ if (result.parked) return false;
113
+ return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
114
+ }
115
+
116
+ function lastAssistantMessage(messages: Message[]): Extract<Message, { role: "assistant" }> | undefined {
117
+ for (let index = messages.length - 1; index >= 0; index--) {
118
+ const message = messages[index];
119
+ if (message.role === "assistant") return message;
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ function assistantText(message: Extract<Message, { role: "assistant" }>): string {
125
+ return message.content
126
+ .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text")
127
+ .map((part) => part.text)
128
+ .join("");
129
+ }
130
+
131
+ export function isModelLevelFailure(result: SingleResult): boolean {
132
+ if (!isFailedResult(result)) return false;
133
+ if (result.stopReason === "aborted") return false;
134
+ if (result.dispatchFailed) return false;
135
+ if (result.integrationStatus === "retained") return false;
136
+ if (result.errorMessage?.includes("idle timeout")) return true;
137
+ if (result.rpcPromptRejected) return true;
138
+
139
+ // Classification belongs to the final assistant turn, not the whole attempt.
140
+ // Earlier useful text or failed tool calls are retained session history and
141
+ // must not hide a later provider error (for example a second-turn 503).
142
+ const finalAssistant = lastAssistantMessage(result.messages);
143
+ if (finalAssistant) {
144
+ if (finalAssistant.stopReason !== "error") return false;
145
+ if (assistantText(finalAssistant).trim()) return false;
146
+ return Boolean(
147
+ finalAssistant.errorMessage?.trim() ||
148
+ result.errorMessage?.trim() ||
149
+ result.stderr.trim() ||
150
+ finalAssistant.content.length === 0
151
+ );
152
+ }
153
+
154
+ if ((result.failedTools?.length ?? 0) > 0) return false;
155
+ return Boolean(result.errorMessage?.trim()) || result.stderr.trim().length > 0;
156
+ }
157
+
158
+ const TERMINAL_MODEL_ERROR_PATTERN =
159
+ /insufficient_quota|quota\s+exceeded|exceeded[^.\n]{0,40}quota|out\s+of\s+budget|billing|usage\s+limit|usage_limit|gousagelimiterror|freeusagelimiterror|monthly\s+usage\s+limit\s+reached|available\s+balance|invalid\s+(?:api\s+)?key|incorrect\s+api\s+key|unauthori[sz]ed|\b401\b|\b403\b|forbidden|permission\s+denied/i;
160
+ const PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN =
161
+ /model[_ -]?not[_ -]?found|no\s+models?\s+(?:found|matched)|(?:model|provider)[^.\n]{0,80}(?:not\s+found|unknown|does\s+not\s+exist|unsupported|invalid)|(?:not\s+found|unknown|unsupported|invalid)[^.\n]{0,40}(?:model|provider)|\b404\b/i;
162
+
163
+ export function isTerminalModelError(result: SingleResult): boolean {
164
+ const message = result.errorMessage?.trim();
165
+ if (message) return TERMINAL_MODEL_ERROR_PATTERN.test(message);
166
+ const stderr = result.stderr.trim();
167
+ return stderr.length > 0 && TERMINAL_MODEL_ERROR_PATTERN.test(stderr);
168
+ }
169
+
170
+ /** A permanent failure of this model/provider reference (stale id, unknown
171
+ * provider, 404 config route). Skip same-candidate backoff, but keep advancing
172
+ * through backup and current-main candidates. */
173
+ export function isPermanentModelCandidateError(result: SingleResult): boolean {
174
+ const message = result.errorMessage?.trim();
175
+ if (message) return PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(message);
176
+ const stderr = result.stderr.trim();
177
+ return stderr.length > 0 && PERMANENT_MODEL_CANDIDATE_ERROR_PATTERN.test(stderr);
178
+ }
179
+
180
+ export function isRetryableStartupFailure(result: SingleResult, durationMs: number): boolean {
181
+ if (result.exitCode === 0) return false;
182
+ if (result.stopReason === "aborted") return false;
183
+ if (result.dispatchFailed) return false;
184
+ if (result.errorMessage?.includes("idle timeout")) return false;
185
+ if (getFinalOutput(result.messages)) return false;
186
+ if (result.messages.length > 0) return false;
187
+ const usage = result.usage;
188
+ if (usage.turns || usage.input || usage.output || usage.cacheRead || usage.cacheWrite || usage.cost) return false;
189
+ if (durationMs > MAX_SUBAGENT_STARTUP_FAILURE_DURATION_MS) return false;
190
+ if (result.stderr.trim().length > 0) return false;
191
+ if (result.errorMessage && result.errorMessage.trim().length > 0) return false;
192
+ return true;
193
+ }
194
+
195
+ export function formatStartupRetryExhaustedError(model: string, attempts: number): string {
196
+ return `Subagent failed to start after ${attempts} attempt${attempts === 1 ? "" : "s"} on ${model}: the child exited before any model, tool, output, or usage activity. This is typically a concurrent pi startup race (several sub-agents starting at once). Retry the dispatch, or temporarily lower maxConcurrency in /subagents-setup.`;
197
+ }
198
+
199
+ export async function waitForStartupRetry(delayMs: number, signal?: AbortSignal): Promise<boolean> {
200
+ if (delayMs <= 0) return !signal?.aborted;
201
+ if (!signal) {
202
+ return new Promise<boolean>((resolve) => {
203
+ const timer = setTimeout(() => resolve(true), delayMs);
204
+ if (typeof timer.unref === "function") timer.unref();
205
+ });
206
+ }
207
+ if (signal.aborted) return false;
208
+ return new Promise<boolean>((resolve) => {
209
+ let settled = false;
210
+ const finish = (shouldRetry: boolean): void => {
211
+ if (settled) return;
212
+ settled = true;
213
+ clearTimeout(timer);
214
+ signal.removeEventListener("abort", onAbort);
215
+ resolve(shouldRetry);
216
+ };
217
+ const onAbort = (): void => finish(false);
218
+ const timer = setTimeout(() => finish(true), delayMs);
219
+ if (typeof timer.unref === "function") timer.unref();
220
+ signal.addEventListener("abort", onAbort, { once: true });
221
+ });
222
+ }
223
+
224
+ async function waitForControlledRetry(
225
+ delayMs: number,
226
+ signal: AbortSignal | undefined,
227
+ control: RpcRunControl | undefined,
228
+ ): Promise<boolean> {
229
+ let remaining = delayMs;
230
+ while (remaining > 0) {
231
+ if (control?.isParkRequested() || control?.isStopRequested()) return false;
232
+ const slice = Math.min(remaining, 50);
233
+ if (!(await waitForStartupRetry(slice, signal))) return false;
234
+ remaining -= slice;
235
+ }
236
+ return !signal?.aborted && !control?.isParkRequested() && !control?.isStopRequested();
237
+ }
238
+
239
+ export function getResultOutput(result: SingleResult): string {
240
+ if (isFailedResult(result)) {
241
+ const error = result.errorMessage || result.stderr;
242
+ const partial = getFinalOutput(result.messages);
243
+ if (error && partial) return `${error}\n\n--- Partial output ---\n${partial}`;
244
+ return error || partial || "(no output)";
245
+ }
246
+ return getFinalOutput(result.messages) || "(no output)";
247
+ }
248
+
249
+ export function buildResumePrompt(task: string, reason: string): string {
250
+ return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
251
+ }
252
+
253
+ export function buildFallbackResumeReason(fromModel?: string): string {
254
+ return fromModel
255
+ ? `the previous model (${fromModel}) failed at the model/provider level, so the next model in its configured pool is continuing`
256
+ : "the previous model failed at the model/provider level, so the next model in its configured pool is continuing";
257
+ }
258
+
259
+ export interface RunSingleOptions {
260
+ defaultCwd: string;
261
+ agent: AgentConfig | undefined;
262
+ agentName: string;
263
+ task: string;
264
+ cwd?: string;
265
+ thinkingLevel?: ThinkingLevel;
266
+ idleTimeoutMs?: number;
267
+ startupRetryDelaysMs?: readonly number[];
268
+ runLevelRetryDelaysMs?: readonly number[];
269
+ sessionDir?: string;
270
+ sessionId?: string;
271
+ /** Initial RPC prompt. Kept under the old name to limit caller churn. */
272
+ stdinText?: string;
273
+ signal?: AbortSignal;
274
+ onLive?: (event: SubagentLiveEvent) => void;
275
+ /** Receives the raw streamed text/thinking deltas for the inspector
276
+ * transcript; forwarded to the transport alongside onLive. */
277
+ onRecord?: (event: SubagentRecordEvent) => void;
278
+ makeDetails: (results: SingleResult[]) => SubagentDetails;
279
+ env?: NodeJS.ProcessEnv;
280
+ /** Stable logical-generation controller shared across retry attempts. */
281
+ control?: RpcRunControl;
282
+ }
283
+
284
+ function controlledDisposition(options: RunSingleOptions, base?: SingleResult): SingleResult | undefined {
285
+ const control = options.control;
286
+ if (!control?.isParkRequested() && !control?.isStopRequested()) return undefined;
287
+ const result: SingleResult = base ?? {
288
+ agent: options.agentName,
289
+ agentSource: options.agent?.source ?? "unknown",
290
+ task: control.getObjective(),
291
+ exitCode: 0,
292
+ messages: [],
293
+ stderr: "",
294
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
295
+ model: options.agent?.model,
296
+ thinking: options.thinkingLevel,
297
+ sessionId: options.sessionId,
298
+ sessionDir: options.sessionDir,
299
+ };
300
+ result.task = control.getObjective();
301
+ if (control.isParkRequested()) {
302
+ result.parked = true;
303
+ result.exitCode = 0;
304
+ result.stopReason = undefined;
305
+ result.errorMessage = undefined;
306
+ } else {
307
+ result.parked = undefined;
308
+ result.exitCode = 1;
309
+ result.stopReason = "aborted";
310
+ result.errorMessage = control.getStopMessage();
311
+ }
312
+ return result;
313
+ }
314
+
315
+ /** Spawn one RPC attempt and wait for stable settlement. */
316
+ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
317
+ const {
318
+ agent,
319
+ agentName,
320
+ thinkingLevel = SUBAGENT_THINKING_LEVEL,
321
+ idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
322
+ control,
323
+ } = options;
324
+ if (!agent) {
325
+ return {
326
+ agent: agentName,
327
+ agentSource: "unknown",
328
+ task: options.task,
329
+ exitCode: 1,
330
+ messages: [],
331
+ stderr: `Unknown agent: "${agentName}".`,
332
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
333
+ };
334
+ }
335
+
336
+ const disposition = controlledDisposition(options);
337
+ if (disposition) return disposition;
338
+ const objective = control?.getObjective() ?? options.task;
339
+ let prompt = options.stdinText ?? `Task: ${objective}`;
340
+ if (control && objective !== options.task) {
341
+ prompt = options.sessionDir && sessionExists(options.sessionDir, options.sessionId ?? "")
342
+ ? `Abandon the previous objective. New objective: ${objective}`
343
+ : `Task: ${objective}`;
344
+ }
345
+ const result = await runRpcAgentAttempt({
346
+ defaultCwd: options.defaultCwd,
347
+ agent,
348
+ agentName,
349
+ task: objective,
350
+ cwd: options.cwd,
351
+ thinkingLevel,
352
+ idleTimeoutMs,
353
+ sessionDir: options.sessionDir,
354
+ sessionId: options.sessionId,
355
+ prompt,
356
+ signal: options.signal,
357
+ onLive: options.onLive,
358
+ onRecord: options.onRecord,
359
+ env: options.env,
360
+ control,
361
+ });
362
+ result.task = control?.getObjective() ?? result.task;
363
+ return result;
364
+ }
365
+
366
+ /**
367
+ * Run one logical generation across an ordered model pool. Every candidate gets
368
+ * startup retries plus same-model retries for transient provider failures;
369
+ * terminal model errors skip those retries and advance immediately. All
370
+ * candidates resume the same retained pi session.
371
+ */
372
+ export async function runSingleAgentWithModelFallback(
373
+ options: RunSingleOptions,
374
+ fallbackModelRefs: readonly string[] = [],
375
+ ): Promise<SingleResult> {
376
+ const agent = options.agent;
377
+ const launchedRef = agent?.model;
378
+ const startupDelays = options.startupRetryDelaysMs ?? SUBAGENT_STARTUP_RETRY_DELAYS_MS;
379
+ const runDelays = options.runLevelRetryDelaysMs ?? SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS;
380
+
381
+ const sessionId = options.sessionId ?? randomUUID();
382
+ const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
383
+ const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
384
+
385
+ const dispatchFailure = async (error: unknown): Promise<SingleResult> => {
386
+ const errorMessage = error instanceof Error ? error.message : String(error);
387
+ const hasSession = sessionExists(sessionDir, sessionId);
388
+ if (!hasSession && !options.sessionDir) {
389
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
390
+ }
391
+ return {
392
+ agent: options.agentName,
393
+ agentSource: options.agent?.source ?? "unknown",
394
+ task: options.control?.getObjective() ?? options.task,
395
+ exitCode: 1,
396
+ messages: [],
397
+ stderr: errorMessage,
398
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
399
+ model: options.agent?.model,
400
+ thinking: options.thinkingLevel,
401
+ stopReason: "error",
402
+ errorMessage,
403
+ dispatchFailed: true,
404
+ ...(hasSession || options.sessionDir ? { sessionId, sessionDir } : {}),
405
+ };
406
+ };
407
+
408
+ const runWithStartupRetry = async (opts: RunSingleOptions): Promise<SingleResult> => {
409
+ let lastResult: SingleResult;
410
+ let retries = 0;
411
+ for (let attempt = 0; ; attempt++) {
412
+ const immediate = controlledDisposition(opts);
413
+ if (immediate) {
414
+ if (immediate.parked && !options.sessionDir && !sessionExists(sessionDir, sessionId)) {
415
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
416
+ immediate.sessionId = undefined;
417
+ immediate.sessionDir = undefined;
418
+ }
419
+ return immediate;
420
+ }
421
+ const start = Date.now();
422
+ try {
423
+ lastResult = await runSingleAgent(opts);
424
+ } catch (error) {
425
+ const failed = await dispatchFailure(error);
426
+ return controlledDisposition(opts, failed) ?? failed;
427
+ }
428
+ const durationMs = Date.now() - start;
429
+ const controlled = controlledDisposition(opts, lastResult);
430
+ if (controlled) return controlled;
431
+ if (lastResult.parked || lastResult.stopReason === "aborted") return lastResult;
432
+ if (!isRetryableStartupFailure(lastResult, durationMs)) {
433
+ if (retries > 0 && !isFailedResult(lastResult)) lastResult.startupRetries = retries;
434
+ return lastResult;
435
+ }
436
+ const delay = startupDelays[attempt];
437
+ if (delay === undefined) {
438
+ lastResult.errorMessage = formatStartupRetryExhaustedError(
439
+ lastResult.model ?? opts.agent?.model ?? "default",
440
+ attempt + 1,
441
+ );
442
+ lastResult.stopReason ??= "error";
443
+ lastResult.dispatchFailed = true;
444
+ return lastResult;
445
+ }
446
+ opts.control?.markRetrying();
447
+ try {
448
+ opts.onLive?.({ kind: "status", status: "running" });
449
+ } catch {
450
+ /* never throw from event handling */
451
+ }
452
+ if (!(await waitForControlledRetry(delay, opts.signal, opts.control))) {
453
+ return controlledDisposition(opts, lastResult) ?? lastResult;
454
+ }
455
+ retries++;
456
+ }
457
+ };
458
+
459
+ const fallbackRefs: string[] = [];
460
+ const seenRefs = new Set<string>();
461
+ if (launchedRef?.trim()) seenRefs.add(launchedRef.trim());
462
+ for (const candidate of fallbackModelRefs) {
463
+ const ref = candidate.trim();
464
+ if (!ref || seenRefs.has(ref)) continue;
465
+ seenRefs.add(ref);
466
+ fallbackRefs.push(ref);
467
+ }
468
+
469
+ const candidates: Array<{ agent: AgentConfig | undefined; ref?: string }> = [
470
+ { agent, ref: launchedRef?.trim() || undefined },
471
+ ];
472
+ if (agent) {
473
+ for (const ref of fallbackRefs) candidates.push({ agent: { ...agent, model: ref }, ref });
474
+ }
475
+
476
+ let modelRetries = 0;
477
+ let fallbackUsed = false;
478
+ let result: SingleResult | undefined;
479
+
480
+ const finish = async (settled: SingleResult): Promise<SingleResult> => {
481
+ const persistedSession = sessionExists(sessionDir, sessionId);
482
+ if (!settled.dispatchFailed || persistedSession || options.sessionDir) {
483
+ settled.sessionId ??= sessionId;
484
+ settled.sessionDir ??= sessionDir;
485
+ } else {
486
+ await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
487
+ settled.sessionId = undefined;
488
+ settled.sessionDir = undefined;
489
+ }
490
+ settled.task = options.control?.getObjective() ?? settled.task;
491
+ if (fallbackUsed && launchedRef) settled.modelFallbackFrom = launchedRef;
492
+ settled.modelRetries = modelRetries;
493
+ options.control?.markSettled();
494
+ return settled;
495
+ };
496
+
497
+ for (let candidateIndex = 0; candidateIndex < candidates.length; candidateIndex++) {
498
+ const candidate = candidates[candidateIndex];
499
+ fallbackUsed ||= candidateIndex > 0;
500
+ const previousModel = result?.model ?? candidates[candidateIndex - 1]?.ref;
501
+ const candidateOptions: RunSingleOptions = {
502
+ ...baseOptions,
503
+ agent: candidate.agent,
504
+ ...(candidateIndex > 0
505
+ ? {
506
+ stdinText: buildResumePrompt(
507
+ options.control?.getObjective() ?? options.task,
508
+ buildFallbackResumeReason(previousModel),
509
+ ),
510
+ }
511
+ : {}),
512
+ };
513
+ try {
514
+ options.onLive?.({
515
+ kind: "model",
516
+ model: candidate.ref,
517
+ ...(candidateIndex > 0 && launchedRef ? { fallbackFrom: launchedRef } : {}),
518
+ });
519
+ } catch {
520
+ /* never throw from event handling */
521
+ }
522
+
523
+ result = await runWithStartupRetry(candidateOptions);
524
+ if (result.parked || result.stopReason === "aborted") return result;
525
+ if (!isModelLevelFailure(result)) return finish(result);
526
+
527
+ if (!isTerminalModelError(result) && !isPermanentModelCandidateError(result)) {
528
+ const retryOptions: RunSingleOptions = {
529
+ ...candidateOptions,
530
+ stdinText: buildResumePrompt(
531
+ options.control?.getObjective() ?? options.task,
532
+ "a transient provider error on the same model",
533
+ ),
534
+ };
535
+ for (const delay of runDelays) {
536
+ baseOptions.control?.markRetrying();
537
+ try {
538
+ options.onLive?.({ kind: "status", status: "running" });
539
+ } catch {
540
+ /* never throw from event handling */
541
+ }
542
+ if (!(await waitForControlledRetry(delay, options.signal, options.control))) {
543
+ return controlledDisposition(baseOptions, result) ?? result;
544
+ }
545
+ result = await runWithStartupRetry(retryOptions);
546
+ modelRetries++;
547
+ if (result.parked || result.stopReason === "aborted") return result;
548
+ if (
549
+ !isModelLevelFailure(result) ||
550
+ isTerminalModelError(result) ||
551
+ isPermanentModelCandidateError(result)
552
+ ) break;
553
+ }
554
+ }
555
+
556
+ if (!isModelLevelFailure(result)) return finish(result);
557
+ // Transient exhaustion plus terminal/permanent candidate errors advance
558
+ // to the next configured candidate. Ordinary task/tool failures returned above.
559
+ }
560
+
561
+ return finish(result ?? (await dispatchFailure("No model candidate was attempted.")));
562
+ }