oira666_pi-subagent 0.3.10 → 0.3.11
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/README.md +10 -2
- package/index.ts +45 -38
- package/package.json +1 -1
- package/runner.ts +203 -95
- package/shims.d.ts +0 -1
- package/types.ts +79 -19
package/README.md
CHANGED
|
@@ -130,7 +130,7 @@ The Markdown body becomes the agent's system prompt (appended to Pi's default, n
|
|
|
130
130
|
|
|
131
131
|
## Delegation Guards
|
|
132
132
|
|
|
133
|
-
Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer.
|
|
133
|
+
Depth and cycle guards prevent runaway recursive delegation. Layer availability is evaluated for the child being launched: depth 1 is the first layer, and `PI_SUBAGENT_MAX_DEPTH` is the last layer. The bundled `team-lead` agent sets `last-layer: disabled` so it cannot consume the final delegation layer. Cycle checks are applied per task: in a mixed parallel call, cyclic tasks fail while legal siblings still run.
|
|
134
134
|
|
|
135
135
|
| Config | Default | Description |
|
|
136
136
|
| ------------------------------ | ------- | ------------------------------------------------ |
|
|
@@ -160,6 +160,12 @@ cancellation, the runner terminates the child process tree and bounds cleanup;
|
|
|
160
160
|
even if a wedged OS process never reports `close`, the tool returns an error
|
|
161
161
|
result so every waiting parent can settle.
|
|
162
162
|
|
|
163
|
+
RPC completion is based on Pi's `agent_settled` event—not `agent_end`.
|
|
164
|
+
`agent_end` is only a low-level run boundary and may be followed by Pi's normal
|
|
165
|
+
provider retry, overflow compaction, or queued continuation. Rejected prompt
|
|
166
|
+
commands, signal exits, and processes that exit before `agent_settled` are
|
|
167
|
+
reported immediately as failures.
|
|
168
|
+
|
|
163
169
|
| Env Var | Default | Description |
|
|
164
170
|
| --- | --- | --- |
|
|
165
171
|
| `PI_SUBAGENT_STARTUP_TIMEOUT` | `120000` | Milliseconds allowed to reach the first model turn; `0` disables |
|
|
@@ -190,6 +196,7 @@ The same detection also runs after navigating the session tree in the TUI (Esc n
|
|
|
190
196
|
- TUI mode asks: **Resume subagents?**
|
|
191
197
|
- Non-UI modes (`pi -p`, JSON/RPC) resume automatically.
|
|
192
198
|
- Already-finished subagents are reused as completed; unfinished ones continue from their own saved sessions.
|
|
199
|
+
- Durable child refs retain final output, own usage, model, and tool counts, so completed siblings survive a JSON/session restart without becoming `(no output)` or losing accounting.
|
|
193
200
|
- Nested subagents use the same mechanism recursively.
|
|
194
201
|
- Provider fallback goes through the selected model's effective Pi provider, so custom providers, custom APIs, auth-derived endpoints, headers, and provider-scoped environment are preserved.
|
|
195
202
|
- Pending resume state and delayed callbacks are discarded on `/resume`, `/new`, `/fork`, and `/reload`, preventing stale work from an old runtime from leaking into the replacement session.
|
|
@@ -220,6 +227,7 @@ Naming is deliberately unambiguous: `agent` (in `subagents`) selects an agent
|
|
|
220
227
|
subagent *instance* by its unique name.
|
|
221
228
|
|
|
222
229
|
- All resumes in one call run **in parallel**.
|
|
230
|
+
- The preferred shape is `{"resumes":[...]}`. For compatibility, the common single-item shorthand `{"subagent":"name","task":"..."}` is normalized automatically before validation.
|
|
223
231
|
- Names are unique within one delegation tree (everything spawned from one
|
|
224
232
|
top-level session) and are persisted in a registry file under the subagent
|
|
225
233
|
session root, so they survive restarts: you can resume a subagent in a later
|
|
@@ -336,7 +344,7 @@ interface SingleResult {
|
|
|
336
344
|
agent: string; // agent name
|
|
337
345
|
agentSource: "user" | "project" | "builtin" | "unknown";
|
|
338
346
|
task: string; // task string passed to this agent
|
|
339
|
-
exitCode: number; // 0 = success, >0 = error, -1 = still running
|
|
347
|
+
exitCode: number; // 0 = process success, >0 = error, -1 = still running
|
|
340
348
|
messages: Message[]; // full conversation history of the subagent
|
|
341
349
|
stderr: string;
|
|
342
350
|
usage: UsageStats; // this agent's OWN token usage only
|
package/index.ts
CHANGED
|
@@ -76,6 +76,8 @@ import {
|
|
|
76
76
|
getNestedSubagentResults,
|
|
77
77
|
isResultError,
|
|
78
78
|
isSubagentDetails,
|
|
79
|
+
prepareResumeArguments,
|
|
80
|
+
subagentDetailsHaveErrors,
|
|
79
81
|
isSubagentToolName,
|
|
80
82
|
RESUME_SUBAGENTS_TOOL_NAME,
|
|
81
83
|
SUBAGENT_TOOL_NAME,
|
|
@@ -442,7 +444,14 @@ function liveDetailsSignature(details: SubagentDetails): string {
|
|
|
442
444
|
function collectLiveUsageSummary(details: SubagentDetails): SubagentUsageSummary {
|
|
443
445
|
const summary = emptyUsageSummary();
|
|
444
446
|
for (const result of details.results) {
|
|
447
|
+
if (result.subtreeUsageSummary) {
|
|
448
|
+
addUsageSummary(summary, result.subtreeUsageSummary);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
445
451
|
addUsageSummary(summary, usageSummaryFromUsage(result.usage));
|
|
452
|
+
if (result.priorDescendantUsageSummary) {
|
|
453
|
+
addUsageSummary(summary, result.priorDescendantUsageSummary);
|
|
454
|
+
}
|
|
446
455
|
|
|
447
456
|
const completedNested = getNestedSubagentResults(result.messages ?? []);
|
|
448
457
|
const completedNestedIds = new Set<string>();
|
|
@@ -519,15 +528,6 @@ function collectCombinedUsageStatusLine(
|
|
|
519
528
|
return formatCombinedUsageStatusLine(parentUsage, subagents.subagentCount);
|
|
520
529
|
}
|
|
521
530
|
|
|
522
|
-
function getCycleViolations(
|
|
523
|
-
requestedNames: Set<string>,
|
|
524
|
-
ancestorAgentStack: string[],
|
|
525
|
-
): string[] {
|
|
526
|
-
if (requestedNames.size === 0 || ancestorAgentStack.length === 0) return [];
|
|
527
|
-
const stackSet = new Set(ancestorAgentStack);
|
|
528
|
-
return Array.from(requestedNames).filter((name) => stackSet.has(name));
|
|
529
|
-
}
|
|
530
|
-
|
|
531
531
|
/** Get project-local agents referenced by the current request. */
|
|
532
532
|
function getRequestedProjectAgents(
|
|
533
533
|
agents: AgentConfig[],
|
|
@@ -983,6 +983,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
983
983
|
const activeSubagents = new Map<number, { agent: string; task: string; taskIndex: number; handle: RunningSubagentHandle; name?: string }>();
|
|
984
984
|
/** Names with a resume in flight in this process (same-process race guard). */
|
|
985
985
|
const activeResumeNames = new Set<string>();
|
|
986
|
+
/** Tool calls whose execute result requested an actual Pi error result. */
|
|
987
|
+
const forcedErrorToolCallIds = new Set<string>();
|
|
986
988
|
const activeSubagentUsageSummaries = new Map<string, SubagentUsageSummary>();
|
|
987
989
|
const latestBroadcastTargets = {
|
|
988
990
|
all: [] as BroadcastTarget[],
|
|
@@ -1515,6 +1517,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1515
1517
|
latestSessionCtx = undefined;
|
|
1516
1518
|
resumeModelRegistry = undefined;
|
|
1517
1519
|
activeSubagentUsageSummaries.clear();
|
|
1520
|
+
forcedErrorToolCallIds.clear();
|
|
1518
1521
|
activeSubagents.clear();
|
|
1519
1522
|
});
|
|
1520
1523
|
|
|
@@ -1655,6 +1658,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1655
1658
|
scheduleSessionTask(() => updateCombinedUsageStatus(ctx), 0);
|
|
1656
1659
|
});
|
|
1657
1660
|
|
|
1661
|
+
// A custom tool must throw to make Pi set isError, but throwing would discard
|
|
1662
|
+
// the durable subagent details needed for crash-resume. Convert our internal
|
|
1663
|
+
// outcome marker through Pi's supported tool_result middleware instead.
|
|
1664
|
+
pi.on("tool_result", (event: any) => {
|
|
1665
|
+
if (!isSubagentToolName(event?.toolName)) return;
|
|
1666
|
+
const detailsFailed = subagentDetailsHaveErrors(event.details);
|
|
1667
|
+
const forced = forcedErrorToolCallIds.delete(event.toolCallId);
|
|
1668
|
+
if (forced || detailsFailed) return { isError: true };
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1658
1671
|
pi.on("tool_execution_end", (event, ctx) => {
|
|
1659
1672
|
latestSessionCtx = ctx;
|
|
1660
1673
|
if (isSubagentToolName(event.toolName)) {
|
|
@@ -1776,6 +1789,7 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
|
|
|
1776
1789
|
parameters: SubagentParams,
|
|
1777
1790
|
|
|
1778
1791
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1792
|
+
const toolResult = await (async () => {
|
|
1779
1793
|
try {
|
|
1780
1794
|
recordToolCallStart(toolCallId);
|
|
1781
1795
|
updateLatestBroadcastTargets(undefined);
|
|
@@ -1797,6 +1811,7 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
|
|
|
1797
1811
|
},
|
|
1798
1812
|
],
|
|
1799
1813
|
details: makeDetails("single")([]),
|
|
1814
|
+
isError: true,
|
|
1800
1815
|
};
|
|
1801
1816
|
}
|
|
1802
1817
|
|
|
@@ -1813,35 +1828,20 @@ ${agentList}\n\n${subagentsGuidance}${resumeGuidance ? `\n\n${resumeGuidance}` :
|
|
|
1813
1828
|
onUpdate?.(partial);
|
|
1814
1829
|
};
|
|
1815
1830
|
|
|
1816
|
-
//
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1831
|
+
// Apply whole-call security preflight only to tasks that can actually
|
|
1832
|
+
// run. Cyclic tasks become aligned structured failures in the runner;
|
|
1833
|
+
// they must not block legal siblings or consume durable names.
|
|
1834
|
+
const cyclicTaskIndexes = new Set<number>();
|
|
1820
1835
|
if (preventCycles) {
|
|
1821
|
-
const
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
);
|
|
1825
|
-
if (cycleViolations.length > 0) {
|
|
1826
|
-
const stackText =
|
|
1827
|
-
ancestorAgentStack.length > 0
|
|
1828
|
-
? ancestorAgentStack.join(" -> ")
|
|
1829
|
-
: "(root)";
|
|
1830
|
-
return {
|
|
1831
|
-
content: [
|
|
1832
|
-
{
|
|
1833
|
-
type: "text",
|
|
1834
|
-
text: `Blocked: delegation cycle detected. Requested agent(s) already in the delegation stack: ${cycleViolations.join(", ")}.
|
|
1835
|
-
Current stack: ${stackText}
|
|
1836
|
-
|
|
1837
|
-
This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A).`,
|
|
1838
|
-
},
|
|
1839
|
-
],
|
|
1840
|
-
details: makeDetails(executionMode)([]),
|
|
1841
|
-
isError: true,
|
|
1842
|
-
};
|
|
1843
|
-
}
|
|
1836
|
+
const stack = new Set(ancestorAgentStack);
|
|
1837
|
+
tasks.forEach((task, index) => {
|
|
1838
|
+
if (stack.has(task.agent)) cyclicTaskIndexes.add(index);
|
|
1839
|
+
});
|
|
1844
1840
|
}
|
|
1841
|
+
const requested = new Set<string>();
|
|
1842
|
+
tasks.forEach((task, index) => {
|
|
1843
|
+
if (!cyclicTaskIndexes.has(index)) requested.add(task.agent);
|
|
1844
|
+
});
|
|
1845
1845
|
|
|
1846
1846
|
const requestedProjectAgents = getRequestedProjectAgents(
|
|
1847
1847
|
agents,
|
|
@@ -1907,7 +1907,7 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
1907
1907
|
if (currentNamesFile) {
|
|
1908
1908
|
const pendingAllocation = tasks
|
|
1909
1909
|
.map((task, index) => ({ task, index }))
|
|
1910
|
-
.filter(({ index }) => !names[index]);
|
|
1910
|
+
.filter(({ index }) => !cyclicTaskIndexes.has(index) && !names[index]);
|
|
1911
1911
|
if (pendingAllocation.length > 0) {
|
|
1912
1912
|
try {
|
|
1913
1913
|
const allocated = await allocateSubagentNames(
|
|
@@ -1980,7 +1980,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
1980
1980
|
isError: true,
|
|
1981
1981
|
};
|
|
1982
1982
|
}
|
|
1983
|
-
|
|
1983
|
+
})();
|
|
1984
|
+
if ((toolResult as any)?.isError === true) forcedErrorToolCallIds.add(toolCallId);
|
|
1985
|
+
return toolResult;
|
|
1984
1986
|
},
|
|
1985
1987
|
|
|
1986
1988
|
renderCall: (args, theme, context) => renderCall(args, theme, context),
|
|
@@ -2003,8 +2005,10 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
2003
2005
|
'Example: { resumes: [{ subagent: "code-writer-01", task: "Also update the tests." }] }',
|
|
2004
2006
|
].join("\n"),
|
|
2005
2007
|
parameters: ResumeSubagentsParams,
|
|
2008
|
+
prepareArguments: prepareResumeArguments,
|
|
2006
2009
|
|
|
2007
2010
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
2011
|
+
const toolResult = await (async () => {
|
|
2008
2012
|
const markedNames: string[] = [];
|
|
2009
2013
|
try {
|
|
2010
2014
|
recordToolCallStart(toolCallId);
|
|
@@ -2192,6 +2196,9 @@ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A)
|
|
|
2192
2196
|
}
|
|
2193
2197
|
}
|
|
2194
2198
|
}
|
|
2199
|
+
})();
|
|
2200
|
+
if ((toolResult as any)?.isError === true) forcedErrorToolCallIds.add(toolCallId);
|
|
2201
|
+
return toolResult;
|
|
2195
2202
|
},
|
|
2196
2203
|
|
|
2197
2204
|
renderCall: (args, theme, context) => renderResumeCall(args, theme, context),
|
package/package.json
CHANGED
package/runner.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { spawn } from "node:child_process";
|
|
|
8
8
|
import * as fs from "node:fs";
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
|
+
import { StringDecoder } from "node:string_decoder";
|
|
11
12
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
12
13
|
import type { Message } from "@mariozechner/pi-ai";
|
|
13
14
|
import type { AgentConfig } from "./agents.js";
|
|
@@ -15,11 +16,14 @@ import {
|
|
|
15
16
|
type LiveLogEntry,
|
|
16
17
|
type SingleResult,
|
|
17
18
|
type SubagentDetails,
|
|
19
|
+
type SubagentUsageSummary,
|
|
18
20
|
MAX_LIVE_LOG_ENTRIES,
|
|
19
21
|
emptyUsage,
|
|
20
22
|
extractToolCalls,
|
|
21
23
|
getFinalOutput,
|
|
22
24
|
getNestedSubagentErrorSummary,
|
|
25
|
+
isResultError,
|
|
26
|
+
isResultSuccess,
|
|
23
27
|
isSubagentDetails,
|
|
24
28
|
isSubagentToolName,
|
|
25
29
|
} from "./types.js";
|
|
@@ -38,7 +42,7 @@ import {
|
|
|
38
42
|
} from "./shared.js";
|
|
39
43
|
|
|
40
44
|
const SIGKILL_TIMEOUT_MS = 5000;
|
|
41
|
-
const
|
|
45
|
+
const RETRY_WAIT_GRACE_MS = 60_000;
|
|
42
46
|
const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; // only for startup (before first assistant turn)
|
|
43
47
|
const SUBAGENT_STARTUP_TIMEOUT_ENV = "PI_SUBAGENT_STARTUP_TIMEOUT";
|
|
44
48
|
// Once startup succeeds, a child can otherwise remain alive forever if Pi loses
|
|
@@ -57,16 +61,24 @@ const SUBAGENT_PI_COMMAND_ENV = "PI_SUBAGENT_PI_COMMAND";
|
|
|
57
61
|
const SUBAGENT_PI_ARGS_PREFIX_ENV = "PI_SUBAGENT_PI_ARGS_PREFIX";
|
|
58
62
|
const MAX_CAPTURED_STDERR_CHARS = 64_000;
|
|
59
63
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
function priorDescendantUsage(result: SingleResult | undefined): SubagentUsageSummary | undefined {
|
|
65
|
+
if (!result) return undefined;
|
|
66
|
+
if (result.priorDescendantUsageSummary) return { ...result.priorDescendantUsageSummary };
|
|
67
|
+
const subtree = result.subtreeUsageSummary;
|
|
68
|
+
if (!subtree) return undefined;
|
|
69
|
+
const own = result.usage ?? emptyUsage();
|
|
70
|
+
const descendants = {
|
|
71
|
+
subagentCount: Math.max(0, subtree.subagentCount - 1),
|
|
72
|
+
inputTokens: Math.max(0, subtree.inputTokens - own.input),
|
|
73
|
+
outputTokens: Math.max(0, subtree.outputTokens - own.output),
|
|
74
|
+
cacheReadTokens: Math.max(0, subtree.cacheReadTokens - own.cacheRead),
|
|
75
|
+
cacheWriteTokens: Math.max(0, subtree.cacheWriteTokens - own.cacheWrite),
|
|
76
|
+
costUsd: Math.max(0, subtree.costUsd - own.cost),
|
|
77
|
+
turns: Math.max(0, subtree.turns - own.turns),
|
|
78
|
+
};
|
|
79
|
+
return descendants.subagentCount > 0 || descendants.inputTokens > 0 || descendants.outputTokens > 0 || descendants.costUsd > 0
|
|
80
|
+
? descendants
|
|
81
|
+
: undefined;
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
function appendBoundedStderr(result: SingleResult, text: string): void {
|
|
@@ -478,8 +490,12 @@ export function processJsonLine(line: string, result: SingleResult): boolean {
|
|
|
478
490
|
result.usage.contextTokens = usage.totalTokens || 0;
|
|
479
491
|
}
|
|
480
492
|
if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
|
|
481
|
-
if (msg.stopReason)
|
|
482
|
-
|
|
493
|
+
if (msg.stopReason) {
|
|
494
|
+
result.stopReason = msg.stopReason;
|
|
495
|
+
// A later successful retry supersedes the prior transport error. Keep
|
|
496
|
+
// only the error attached to the latest terminal assistant message.
|
|
497
|
+
result.errorMessage = msg.errorMessage || undefined;
|
|
498
|
+
}
|
|
483
499
|
}
|
|
484
500
|
return true;
|
|
485
501
|
}
|
|
@@ -664,6 +680,8 @@ export interface RunAgentOptions {
|
|
|
664
680
|
startupTimeoutMsOverride?: number;
|
|
665
681
|
/** Test/debug override for post-startup semantic inactivity timeout. */
|
|
666
682
|
idleTimeoutMsOverride?: number;
|
|
683
|
+
/** Test/debug override for graceful-stop to SIGKILL escalation. */
|
|
684
|
+
terminationTimeoutMsOverride?: number;
|
|
667
685
|
/** Called once the child RPC process is ready to receive steering messages. */
|
|
668
686
|
onHandle?: RunningSubagentStartedCallback;
|
|
669
687
|
}
|
|
@@ -694,6 +712,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
694
712
|
piCommandOverride,
|
|
695
713
|
startupTimeoutMsOverride,
|
|
696
714
|
idleTimeoutMsOverride,
|
|
715
|
+
terminationTimeoutMsOverride,
|
|
697
716
|
} = opts;
|
|
698
717
|
|
|
699
718
|
const agent = agents.find((a) => a.name === agentName);
|
|
@@ -738,6 +757,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
738
757
|
liveToolExecutions: initialResult?.liveToolExecutions,
|
|
739
758
|
liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
|
|
740
759
|
liveNestedSubagents: initialResult?.liveNestedSubagents ? { ...initialResult.liveNestedSubagents } : undefined,
|
|
760
|
+
priorDescendantUsageSummary: priorDescendantUsage(initialResult),
|
|
741
761
|
sessionDir,
|
|
742
762
|
};
|
|
743
763
|
|
|
@@ -755,6 +775,19 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
755
775
|
|
|
756
776
|
emitUpdate();
|
|
757
777
|
|
|
778
|
+
// Enforce cycle prevention per task rather than rejecting an entire parallel
|
|
779
|
+
// call. Legal siblings can still run while the cyclic task returns a normal
|
|
780
|
+
// structured failure.
|
|
781
|
+
if (preventCycles && parentAgentStack.includes(agentName)) {
|
|
782
|
+
const stackText = parentAgentStack.length > 0 ? parentAgentStack.join(" -> ") : "(root)";
|
|
783
|
+
result.exitCode = 1;
|
|
784
|
+
result.stopReason = "error";
|
|
785
|
+
result.errorMessage = `Delegation cycle detected: agent "${agentName}" is already in the delegation stack (${stackText}).`;
|
|
786
|
+
result.stderr = result.errorMessage;
|
|
787
|
+
emitUpdate();
|
|
788
|
+
return result;
|
|
789
|
+
}
|
|
790
|
+
|
|
758
791
|
// Write system prompt to temp file if needed
|
|
759
792
|
let promptTmpDir: string | null = null;
|
|
760
793
|
let promptTmpPath: string | null = null;
|
|
@@ -817,17 +850,29 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
817
850
|
});
|
|
818
851
|
|
|
819
852
|
let buffer = "";
|
|
853
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
820
854
|
let resolved = false;
|
|
821
|
-
let hangTimer: ReturnType<typeof setTimeout> | undefined;
|
|
822
855
|
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
|
823
856
|
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
857
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
824
858
|
let receivedFirstEvent = false;
|
|
859
|
+
let agentSettled = false;
|
|
825
860
|
let forcedExitCode: number | undefined;
|
|
826
861
|
let lastNestedProgressSignature: string | undefined;
|
|
827
862
|
let abortHandler: (() => void) | undefined;
|
|
863
|
+
const promptRequestId = `pi-subagent-${process.pid}-${Date.now()}-${attempt}`;
|
|
864
|
+
let steeringRequest = 0;
|
|
828
865
|
|
|
829
|
-
const sendRpc = (command: Record<string, unknown>) => {
|
|
830
|
-
|
|
866
|
+
const sendRpc = (command: Record<string, unknown>): boolean => {
|
|
867
|
+
try {
|
|
868
|
+
if (!proc.stdin?.writable) return false;
|
|
869
|
+
proc.stdin.write(`${JSON.stringify(command)}\n`);
|
|
870
|
+
return true;
|
|
871
|
+
} catch (err) {
|
|
872
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
873
|
+
appendBoundedStderr(result, `[pi-subagent] RPC stdin write failed: ${message}\n`);
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
831
876
|
};
|
|
832
877
|
|
|
833
878
|
opts.onHandle?.({
|
|
@@ -839,12 +884,15 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
839
884
|
// them to its own (grand)children. `session.prompt()` emits the
|
|
840
885
|
// `input` event first and still queues the message as a steering
|
|
841
886
|
// message while the child is streaming.
|
|
842
|
-
sendRpc({
|
|
887
|
+
sendRpc({
|
|
888
|
+
id: `${promptRequestId}-steer-${++steeringRequest}`,
|
|
889
|
+
type: "prompt",
|
|
890
|
+
message,
|
|
891
|
+
streamingBehavior: "steer",
|
|
892
|
+
});
|
|
843
893
|
},
|
|
844
894
|
});
|
|
845
895
|
|
|
846
|
-
sendRpc({ type: "prompt", message: prompt });
|
|
847
|
-
|
|
848
896
|
// Startup timeout: kill the process if it never produces its first
|
|
849
897
|
// model-turn event. Once startup succeeds, the semantic-inactivity
|
|
850
898
|
// watchdog below takes over.
|
|
@@ -859,11 +907,10 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
859
907
|
const doResolve = (code: number) => {
|
|
860
908
|
if (resolved) return;
|
|
861
909
|
resolved = true;
|
|
862
|
-
if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
|
|
863
910
|
if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
|
|
864
911
|
if (idleTimer) { clearTimeout(idleTimer); idleTimer = undefined; }
|
|
912
|
+
if (killTimer) { clearTimeout(killTimer); killTimer = undefined; }
|
|
865
913
|
if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
|
|
866
|
-
if (buffer.trim()) flushLine(buffer);
|
|
867
914
|
resolve(code);
|
|
868
915
|
};
|
|
869
916
|
|
|
@@ -895,7 +942,8 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
895
942
|
|
|
896
943
|
const forceStopAndSettle = (settleCode = forcedExitCode ?? 1) => {
|
|
897
944
|
stopChild(false);
|
|
898
|
-
|
|
945
|
+
if (killTimer) clearTimeout(killTimer);
|
|
946
|
+
killTimer = setTimeout(() => {
|
|
899
947
|
if (resolved) return;
|
|
900
948
|
stopChild(true);
|
|
901
949
|
// Never depend exclusively on a close event from a wedged child.
|
|
@@ -904,12 +952,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
904
952
|
proc.stderr?.destroy();
|
|
905
953
|
proc.unref();
|
|
906
954
|
doResolve(settleCode);
|
|
907
|
-
}, SIGKILL_TIMEOUT_MS);
|
|
908
|
-
};
|
|
909
|
-
|
|
910
|
-
/** Cancel the terminal-stop hang guard when new work appears. */
|
|
911
|
-
const cancelHangGuard = () => {
|
|
912
|
-
if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
|
|
955
|
+
}, terminationTimeoutMsOverride ?? SIGKILL_TIMEOUT_MS);
|
|
913
956
|
};
|
|
914
957
|
|
|
915
958
|
const idleTimeoutMs = (() => {
|
|
@@ -920,64 +963,71 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
920
963
|
return parsed !== null ? parsed : DEFAULT_IDLE_TIMEOUT_MS;
|
|
921
964
|
})();
|
|
922
965
|
|
|
923
|
-
const noteSemanticActivity = () => {
|
|
966
|
+
const noteSemanticActivity = (minimumQuietPeriodMs = 0) => {
|
|
924
967
|
if (!receivedFirstEvent || idleTimeoutMs === 0 || resolved) return;
|
|
925
968
|
if (idleTimer) clearTimeout(idleTimer);
|
|
969
|
+
const quietPeriodMs = Math.max(idleTimeoutMs, minimumQuietPeriodMs);
|
|
926
970
|
idleTimer = setTimeout(() => {
|
|
927
971
|
if (resolved) return;
|
|
928
|
-
const message = `Subagent inactivity timeout: no new RPC activity for ${
|
|
972
|
+
const message = `Subagent inactivity timeout: no new RPC activity for ${quietPeriodMs}ms.`;
|
|
929
973
|
forcedExitCode = 1;
|
|
930
974
|
result.stopReason = "error";
|
|
931
975
|
result.errorMessage = message;
|
|
932
976
|
appendBoundedStderr(result, `\n[pi-subagent] Killed: ${message}`);
|
|
933
977
|
emitUpdate();
|
|
934
978
|
forceStopAndSettle();
|
|
935
|
-
},
|
|
936
|
-
};
|
|
937
|
-
|
|
938
|
-
/**
|
|
939
|
-
* Schedule a hang guard: if the child process produced a terminal
|
|
940
|
-
* stopReason (agent finished) but doesn't exit on its own (due to
|
|
941
|
-
* open handles like MCP connections, dangling timers, etc.),
|
|
942
|
-
* force-kill it so the parent doesn't hang forever.
|
|
943
|
-
*
|
|
944
|
-
* The guard is reset on every new activity and only armed for
|
|
945
|
-
* truly terminal stop reasons (not "tool_use").
|
|
946
|
-
*/
|
|
947
|
-
const scheduleHangGuard = () => {
|
|
948
|
-
if (resolved) return;
|
|
949
|
-
cancelHangGuard();
|
|
950
|
-
hangTimer = setTimeout(() => {
|
|
951
|
-
if (resolved) return;
|
|
952
|
-
// Process produced all output but won't exit — force-kill its tree.
|
|
953
|
-
forceStopAndSettle(forcedExitCode ?? 0);
|
|
954
|
-
}, HANG_GUARD_DELAY_MS);
|
|
979
|
+
}, quietPeriodMs);
|
|
955
980
|
};
|
|
956
981
|
|
|
957
982
|
const flushLine = (line: string) => {
|
|
958
983
|
let event: any;
|
|
959
984
|
try { event = JSON.parse(line); } catch { event = null; }
|
|
985
|
+
|
|
986
|
+
if (
|
|
987
|
+
event?.type === "response" &&
|
|
988
|
+
event.id === promptRequestId &&
|
|
989
|
+
event.command === "prompt"
|
|
990
|
+
) {
|
|
991
|
+
if (event.success !== true) {
|
|
992
|
+
const message = `Subagent prompt rejected: ${typeof event.error === "string" ? event.error : "unknown RPC error"}`;
|
|
993
|
+
forcedExitCode = 1;
|
|
994
|
+
result.stopReason = "error";
|
|
995
|
+
result.errorMessage = message;
|
|
996
|
+
appendBoundedStderr(result, `[pi-subagent] ${message}\n`);
|
|
997
|
+
forceStopAndSettle(1);
|
|
998
|
+
}
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// agent_end is only a low-level run boundary. Pi may now auto-retry,
|
|
1003
|
+
// compact-and-retry, or process a queued continuation. Killing here was
|
|
1004
|
+
// the direct cause of the WebSocket failures in the inspected session.
|
|
960
1005
|
if (event?.type === "agent_end") {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1006
|
+
noteSemanticActivity(
|
|
1007
|
+
event.willRetry === true ? RETRY_WAIT_GRACE_MS : 0,
|
|
1008
|
+
);
|
|
964
1009
|
return;
|
|
965
1010
|
}
|
|
1011
|
+
|
|
1012
|
+
if (event?.type === "agent_settled") {
|
|
1013
|
+
agentSettled = true;
|
|
1014
|
+
if (result.stopReason === "length" && !result.errorMessage) {
|
|
1015
|
+
result.errorMessage = "Subagent output was incomplete because the model reached its output limit.";
|
|
1016
|
+
}
|
|
1017
|
+
const settledCode = forcedExitCode ?? (isResultError({ ...result, exitCode: 0 }) ? 1 : 0);
|
|
1018
|
+
// RPC mode remains alive waiting for more commands. Terminate its
|
|
1019
|
+
// process tree, but do not report completion until close (or bounded
|
|
1020
|
+
// SIGKILL escalation) confirms it stopped.
|
|
1021
|
+
forceStopAndSettle(settledCode);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
966
1025
|
const accepted = processJsonLine(line, result);
|
|
967
1026
|
if (accepted) {
|
|
968
1027
|
// Cancel the startup timer as soon as the subprocess proves it has
|
|
969
1028
|
// reached the LLM-call phase. Two conditions qualify:
|
|
970
|
-
// 1. A turn has started (turn_start sets turnInProgress=true)
|
|
971
|
-
//
|
|
972
|
-
// MCP adapters), and sent its first request to the LLM. The LLM
|
|
973
|
-
// may now take any amount of time to respond (especially with
|
|
974
|
-
// extended thinking enabled) and must NOT be killed by the startup
|
|
975
|
-
// timer.
|
|
976
|
-
// 2. A complete assistant turn has arrived (turns > 0) — the LLM
|
|
977
|
-
// already responded; startup trivially succeeded.
|
|
978
|
-
// User message echoes alone (before turn_start) don't qualify:
|
|
979
|
-
// the process could still stall before dispatching the LLM call,
|
|
980
|
-
// e.g. in a hanging before_agent_start extension hook.
|
|
1029
|
+
// 1. A turn has started (turn_start sets turnInProgress=true).
|
|
1030
|
+
// 2. A complete assistant turn has arrived (turns > 0).
|
|
981
1031
|
if (!receivedFirstEvent && (result.usage.turns > 0 || result.turnInProgress)) {
|
|
982
1032
|
receivedFirstEvent = true;
|
|
983
1033
|
if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
|
|
@@ -994,22 +1044,24 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
994
1044
|
}
|
|
995
1045
|
if (semanticActivity) noteSemanticActivity();
|
|
996
1046
|
emitUpdate();
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1047
|
+
} else if (receivedFirstEvent) {
|
|
1048
|
+
if (event?.type === "message_update" || event?.type === "tool_execution_update") {
|
|
1049
|
+
// Streaming deltas are intentionally not retained in result.messages,
|
|
1050
|
+
// but they prove the model/tool is still making real progress.
|
|
1051
|
+
noteSemanticActivity();
|
|
1052
|
+
} else if (event?.type === "auto_retry_start") {
|
|
1053
|
+
const delayMs = Number.isFinite(event.delayMs) ? Math.max(0, Number(event.delayMs)) : 0;
|
|
1054
|
+
noteSemanticActivity(delayMs + RETRY_WAIT_GRACE_MS);
|
|
1055
|
+
} else if (
|
|
1056
|
+
event?.type === "auto_retry_end" ||
|
|
1057
|
+
event?.type === "compaction_start" ||
|
|
1058
|
+
event?.type === "compaction_end" ||
|
|
1059
|
+
event?.type === "summarization_retry_scheduled" ||
|
|
1060
|
+
event?.type === "summarization_retry_attempt_start" ||
|
|
1061
|
+
event?.type === "summarization_retry_finished"
|
|
1062
|
+
) {
|
|
1063
|
+
noteSemanticActivity();
|
|
1005
1064
|
}
|
|
1006
|
-
} else if (
|
|
1007
|
-
receivedFirstEvent &&
|
|
1008
|
-
(event?.type === "message_update" || event?.type === "tool_execution_update")
|
|
1009
|
-
) {
|
|
1010
|
-
// Streaming deltas are intentionally not retained in result.messages,
|
|
1011
|
-
// but they prove the model/tool is still making real progress.
|
|
1012
|
-
noteSemanticActivity();
|
|
1013
1065
|
}
|
|
1014
1066
|
};
|
|
1015
1067
|
|
|
@@ -1021,7 +1073,7 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
1021
1073
|
startupTimer = setTimeout(() => {
|
|
1022
1074
|
if (resolved || receivedFirstEvent) return;
|
|
1023
1075
|
startupTimedOut = true;
|
|
1024
|
-
const message = `Subagent startup timeout: no
|
|
1076
|
+
const message = `Subagent startup timeout: no model turn after ${startupTimeoutMs}ms.`;
|
|
1025
1077
|
forcedExitCode = 1;
|
|
1026
1078
|
result.stopReason = "error";
|
|
1027
1079
|
result.errorMessage = message;
|
|
@@ -1031,10 +1083,16 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
1031
1083
|
}
|
|
1032
1084
|
|
|
1033
1085
|
proc.stdout.on("data", (chunk: Buffer) => {
|
|
1034
|
-
buffer +=
|
|
1086
|
+
buffer += stdoutDecoder.write(chunk);
|
|
1035
1087
|
const lines = buffer.split("\n");
|
|
1036
1088
|
buffer = lines.pop() || "";
|
|
1037
|
-
for (
|
|
1089
|
+
for (let line of lines) {
|
|
1090
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
1091
|
+
flushLine(line);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
proc.stdout.on("end", () => {
|
|
1095
|
+
buffer += stdoutDecoder.end();
|
|
1038
1096
|
});
|
|
1039
1097
|
|
|
1040
1098
|
let stderrBuffer = "";
|
|
@@ -1064,18 +1122,47 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
1064
1122
|
stderrBuffer = "";
|
|
1065
1123
|
};
|
|
1066
1124
|
|
|
1067
|
-
|
|
1125
|
+
const classifyUnexpectedExit = (code: number | null, exitSignal: NodeJS.Signals | null): number => {
|
|
1126
|
+
if (forcedExitCode !== undefined) return forcedExitCode;
|
|
1127
|
+
if (agentSettled) return isResultError({ ...result, exitCode: 0 }) ? 1 : (code ?? 0);
|
|
1128
|
+
|
|
1129
|
+
const message = exitSignal
|
|
1130
|
+
? `Subagent process exited from signal ${exitSignal} before agent_settled.`
|
|
1131
|
+
: `Subagent process exited with code ${code ?? "null"} before agent_settled.`;
|
|
1132
|
+
result.stopReason = "error";
|
|
1133
|
+
result.errorMessage = message;
|
|
1134
|
+
appendBoundedStderr(result, `[pi-subagent] ${message}\n`);
|
|
1135
|
+
return code !== null && code !== 0 ? code : 1;
|
|
1136
|
+
};
|
|
1137
|
+
|
|
1138
|
+
const flushRemainingStdout = () => {
|
|
1139
|
+
if (!buffer.trim()) return;
|
|
1140
|
+
const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
|
|
1141
|
+
buffer = "";
|
|
1142
|
+
flushLine(line);
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
proc.on("close", (code, exitSignal) => {
|
|
1146
|
+
flushRemainingStdout();
|
|
1068
1147
|
flushRemainingStderr();
|
|
1069
|
-
doResolve(
|
|
1148
|
+
if (!resolved) doResolve(classifyUnexpectedExit(code, exitSignal));
|
|
1070
1149
|
});
|
|
1071
1150
|
|
|
1072
|
-
proc.on("exit", (code) => {
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1151
|
+
proc.on("exit", (code, exitSignal) => {
|
|
1152
|
+
if (resolved) return;
|
|
1153
|
+
// `close` waits for stdio. A descendant can inherit stdout and keep it
|
|
1154
|
+
// open after the immediate Pi process exits, so bound that drain while
|
|
1155
|
+
// still allowing normal buffered JSONL to arrive before settlement.
|
|
1156
|
+
forceStopAndSettle(classifyUnexpectedExit(code, exitSignal));
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
proc.stdin?.on("error", (err) => {
|
|
1160
|
+
if (resolved) return;
|
|
1161
|
+
forcedExitCode = 1;
|
|
1162
|
+
result.stopReason = "error";
|
|
1163
|
+
result.errorMessage = `Subagent RPC stdin failed: ${err.message}`;
|
|
1164
|
+
appendBoundedStderr(result, `[pi-subagent] ${result.errorMessage}\n`);
|
|
1165
|
+
forceStopAndSettle(1);
|
|
1079
1166
|
});
|
|
1080
1167
|
|
|
1081
1168
|
proc.on("error", (err) => {
|
|
@@ -1095,6 +1182,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
1095
1182
|
if (signal.aborted) abortHandler();
|
|
1096
1183
|
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
1097
1184
|
}
|
|
1185
|
+
|
|
1186
|
+
if (!wasAborted && !resolved && !sendRpc({ id: promptRequestId, type: "prompt", message: prompt })) {
|
|
1187
|
+
forcedExitCode = 1;
|
|
1188
|
+
result.stopReason = "error";
|
|
1189
|
+
result.errorMessage = "Failed to write the initial prompt to the subagent RPC process.";
|
|
1190
|
+
forceStopAndSettle(1);
|
|
1191
|
+
}
|
|
1098
1192
|
});
|
|
1099
1193
|
|
|
1100
1194
|
const noProgress = result.messages.length <= initialMessageCount;
|
|
@@ -1117,6 +1211,13 @@ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleR
|
|
|
1117
1211
|
|
|
1118
1212
|
result.exitCode = exitCode;
|
|
1119
1213
|
result.toolCalls = extractToolCalls(result.messages); // populate from parsed messages
|
|
1214
|
+
if (result.exitCode === 0 && isResultError(result)) {
|
|
1215
|
+
result.exitCode = 1;
|
|
1216
|
+
}
|
|
1217
|
+
if (result.stopReason === "length" && !result.errorMessage) {
|
|
1218
|
+
result.errorMessage = "Subagent output was incomplete because the model reached its output limit.";
|
|
1219
|
+
if (!result.stderr.trim()) result.stderr = result.errorMessage;
|
|
1220
|
+
}
|
|
1120
1221
|
if (wasAborted) {
|
|
1121
1222
|
result.exitCode = 130;
|
|
1122
1223
|
result.stopReason = "aborted";
|
|
@@ -1196,6 +1297,7 @@ export async function executeParallelSubprocess(
|
|
|
1196
1297
|
): Promise<{
|
|
1197
1298
|
content: Array<{ type: "text"; text: string }>;
|
|
1198
1299
|
details: SubagentDetails;
|
|
1300
|
+
isError?: boolean;
|
|
1199
1301
|
}> {
|
|
1200
1302
|
const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
|
|
1201
1303
|
const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
|
|
@@ -1224,6 +1326,7 @@ export async function executeParallelSubprocess(
|
|
|
1224
1326
|
},
|
|
1225
1327
|
],
|
|
1226
1328
|
details: makeDetails([]),
|
|
1329
|
+
isError: true,
|
|
1227
1330
|
};
|
|
1228
1331
|
}
|
|
1229
1332
|
|
|
@@ -1270,7 +1373,7 @@ export async function executeParallelSubprocess(
|
|
|
1270
1373
|
try {
|
|
1271
1374
|
results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
|
|
1272
1375
|
const previousResult = resumeResults?.[index];
|
|
1273
|
-
if (previousResult
|
|
1376
|
+
if (previousResult && isResultSuccess(previousResult)) {
|
|
1274
1377
|
allResults[index] = previousResult;
|
|
1275
1378
|
emitProgress();
|
|
1276
1379
|
return previousResult;
|
|
@@ -1321,11 +1424,15 @@ export async function executeParallelSubprocess(
|
|
|
1321
1424
|
if (heartbeat) clearInterval(heartbeat);
|
|
1322
1425
|
}
|
|
1323
1426
|
|
|
1324
|
-
const successCount = results.filter(
|
|
1427
|
+
const successCount = results.filter(isResultSuccess).length;
|
|
1325
1428
|
const summaries = results.map((r) => {
|
|
1326
|
-
const
|
|
1429
|
+
const succeeded = isResultSuccess(r);
|
|
1430
|
+
const output = succeeded
|
|
1431
|
+
? getFinalOutput(r.messages, r.finalOutput)
|
|
1432
|
+
: r.errorMessage || r.stderr || getFinalOutput(r.messages, r.finalOutput);
|
|
1327
1433
|
const nameTag = r.name ? ` • name: ${r.name}` : "";
|
|
1328
|
-
|
|
1434
|
+
const status = succeeded ? "completed" : r.exitCode === -1 ? "unfinished" : "failed";
|
|
1435
|
+
return `[${r.agent}${nameTag}] ${status}: ${output || "(no output)"}`;
|
|
1329
1436
|
});
|
|
1330
1437
|
|
|
1331
1438
|
return {
|
|
@@ -1336,5 +1443,6 @@ export async function executeParallelSubprocess(
|
|
|
1336
1443
|
},
|
|
1337
1444
|
],
|
|
1338
1445
|
details: makeDetails(results),
|
|
1446
|
+
...(successCount === results.length ? {} : { isError: true }),
|
|
1339
1447
|
};
|
|
1340
1448
|
}
|
package/shims.d.ts
CHANGED
package/types.ts
CHANGED
|
@@ -58,7 +58,8 @@ export type LiveLogEntry =
|
|
|
58
58
|
export const MAX_LIVE_LOG_ENTRIES = 6;
|
|
59
59
|
|
|
60
60
|
/** Result of a single subagent invocation. Live results include rich fields;
|
|
61
|
-
* durable parent-session refs
|
|
61
|
+
* durable parent-session refs retain the compact completion/outcome fields needed
|
|
62
|
+
* for crash-resume while omitting full transcripts and live-only state. */
|
|
62
63
|
export interface SingleResult {
|
|
63
64
|
agent: string;
|
|
64
65
|
agentSource: "user" | "project" | "builtin" | "unknown";
|
|
@@ -77,6 +78,10 @@ export interface SingleResult {
|
|
|
77
78
|
errorMessage?: string;
|
|
78
79
|
/** Cached final assistant text so durable details can omit full transcripts. */
|
|
79
80
|
finalOutput?: string;
|
|
81
|
+
/** Own + descendant usage retained when full nested transcripts are omitted. */
|
|
82
|
+
subtreeUsageSummary?: SubagentUsageSummary;
|
|
83
|
+
/** Live resume baseline for pre-crash descendants whose transcripts were compacted. */
|
|
84
|
+
priorDescendantUsageSummary?: SubagentUsageSummary;
|
|
80
85
|
/** Number of stderr characters omitted from the durable parent-session details. */
|
|
81
86
|
stderrTruncatedChars?: number;
|
|
82
87
|
/** Session directory used by this subagent process, when persisted. */
|
|
@@ -224,6 +229,8 @@ function buildUsageTreeNode(result: SingleResult): UsageTreeNode {
|
|
|
224
229
|
|
|
225
230
|
const aggregatedUsage = emptyUsage();
|
|
226
231
|
addUsage(aggregatedUsage, ownUsage);
|
|
232
|
+
const priorDescendantUsage = usageSummaryToUsageStats(result.priorDescendantUsageSummary);
|
|
233
|
+
if (priorDescendantUsage) addUsage(aggregatedUsage, priorDescendantUsage);
|
|
227
234
|
for (const child of children) addUsage(aggregatedUsage, child.aggregatedUsage);
|
|
228
235
|
|
|
229
236
|
const aggregatedToolCalls: ToolCallCounts = { ...ownToolCalls };
|
|
@@ -250,27 +257,31 @@ export function compactSingleResultForDurableDetails(result: SingleResult): Sing
|
|
|
250
257
|
agentSource: result.agentSource,
|
|
251
258
|
task: result.task,
|
|
252
259
|
exitCode: result.exitCode,
|
|
260
|
+
usage: result.usage ?? emptyUsage(),
|
|
261
|
+
toolCalls: result.toolCalls ?? {},
|
|
262
|
+
completedTurns: result.completedTurns ?? 0,
|
|
263
|
+
finalOutput: result.finalOutput ?? getFinalOutput(result.messages ?? []),
|
|
264
|
+
subtreeUsageSummary: result.subtreeUsageSummary ?? buildUsageSummary([result]),
|
|
253
265
|
...(result.name !== undefined ? { name: result.name } : {}),
|
|
254
266
|
...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}),
|
|
255
267
|
...(result.stopReason !== undefined ? { stopReason: result.stopReason } : {}),
|
|
256
268
|
...(result.errorMessage !== undefined ? { errorMessage: result.errorMessage } : {}),
|
|
269
|
+
...(result.model !== undefined ? { model: result.model } : {}),
|
|
257
270
|
...(result.sessionDir !== undefined ? { sessionDir: result.sessionDir } : {}),
|
|
258
271
|
...(result.sessionId !== undefined ? { sessionId: result.sessionId } : {}),
|
|
259
272
|
};
|
|
260
|
-
//
|
|
261
|
-
//
|
|
273
|
+
// Full transcripts and live process state stay non-enumerable so parent
|
|
274
|
+
// sessions remain compact. Completion output and own usage above must survive
|
|
275
|
+
// a JSON round-trip; otherwise crash-resume reuses a successful sibling as
|
|
276
|
+
// "(no output)" with zero accounting.
|
|
262
277
|
Object.defineProperties(ref, {
|
|
263
278
|
messages: { value: [], enumerable: false },
|
|
264
279
|
stderr: { value: "", enumerable: false },
|
|
265
|
-
usage: { value: result.usage ?? emptyUsage(), enumerable: false },
|
|
266
|
-
toolCalls: { value: {}, enumerable: false },
|
|
267
|
-
model: { value: result.model, enumerable: false },
|
|
268
|
-
finalOutput: { value: result.finalOutput ?? getFinalOutput(result.messages ?? []), enumerable: false },
|
|
269
280
|
stderrTruncatedChars: { value: result.stderrTruncatedChars ?? Math.max(0, (result.stderr?.length ?? 0)), enumerable: false },
|
|
270
|
-
|
|
271
|
-
turnInProgress: { value: result.turnInProgress, enumerable: false },
|
|
281
|
+
turnInProgress: { value: false, enumerable: false },
|
|
272
282
|
liveLog: { value: [], enumerable: false },
|
|
273
283
|
liveNestedSubagents: { value: undefined, enumerable: false },
|
|
284
|
+
priorDescendantUsageSummary: { value: undefined, enumerable: false },
|
|
274
285
|
});
|
|
275
286
|
return ref as SingleResult;
|
|
276
287
|
}
|
|
@@ -312,7 +323,17 @@ export function usageSummaryFromUsage(usage: UsageStats | undefined): SubagentUs
|
|
|
312
323
|
export function buildUsageSummary(results: SingleResult[]): SubagentUsageSummary {
|
|
313
324
|
const total = emptyUsageSummary();
|
|
314
325
|
for (const result of results) {
|
|
326
|
+
// Completed siblings restored from durable details no longer have their
|
|
327
|
+
// nested transcripts. Reuse the persisted subtree total instead of
|
|
328
|
+
// silently dropping descendant accounting during a crash-resume rebuild.
|
|
329
|
+
if (result.subtreeUsageSummary) {
|
|
330
|
+
addUsageSummary(total, result.subtreeUsageSummary);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
315
333
|
addUsageSummary(total, usageSummaryFromUsage(result.usage));
|
|
334
|
+
if (result.priorDescendantUsageSummary) {
|
|
335
|
+
addUsageSummary(total, result.priorDescendantUsageSummary);
|
|
336
|
+
}
|
|
316
337
|
for (const nested of getNestedSubagentResults(result.messages ?? [])) {
|
|
317
338
|
addUsageSummary(total, nested.details.usageSummary ?? buildUsageSummary(nested.details.results));
|
|
318
339
|
}
|
|
@@ -402,9 +423,46 @@ export function buildSubagentDetails(
|
|
|
402
423
|
return details as SubagentDetails;
|
|
403
424
|
}
|
|
404
425
|
|
|
405
|
-
/**
|
|
426
|
+
/** Terminal stop reasons that never represent a complete successful task. */
|
|
427
|
+
const INCOMPLETE_STOP_REASONS = new Set([
|
|
428
|
+
"error",
|
|
429
|
+
"aborted",
|
|
430
|
+
"length",
|
|
431
|
+
"incomplete",
|
|
432
|
+
"max_tokens",
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
/** Whether a result represents an error or incomplete run. */
|
|
406
436
|
export function isResultError(r: SingleResult): boolean {
|
|
407
|
-
|
|
437
|
+
// -1 is the live "still running" sentinel, not a failure.
|
|
438
|
+
return r.exitCode > 0 || INCOMPLETE_STOP_REASONS.has(r.stopReason ?? "");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Whether a result is fully settled and successful. */
|
|
442
|
+
export function isResultSuccess(r: SingleResult): boolean {
|
|
443
|
+
return r.exitCode === 0 && !isResultError(r);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Whether durable details contain any failed/incomplete direct child. */
|
|
447
|
+
export function subagentDetailsHaveErrors(value: unknown): boolean {
|
|
448
|
+
return isSubagentDetails(value) && value.results.some((result) => isResultError(result));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Normalize common legacy/model-generated resume argument shorthands. */
|
|
452
|
+
export function prepareResumeArguments(args: unknown): unknown {
|
|
453
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
454
|
+
const record = args as Record<string, unknown>;
|
|
455
|
+
if (
|
|
456
|
+
record.resumes === undefined &&
|
|
457
|
+
typeof record.subagent === "string" &&
|
|
458
|
+
typeof record.task === "string"
|
|
459
|
+
) {
|
|
460
|
+
return { resumes: [{ subagent: record.subagent, task: record.task }] };
|
|
461
|
+
}
|
|
462
|
+
if (record.resumes && typeof record.resumes === "object" && !Array.isArray(record.resumes)) {
|
|
463
|
+
return { ...record, resumes: [record.resumes] };
|
|
464
|
+
}
|
|
465
|
+
return args;
|
|
408
466
|
}
|
|
409
467
|
|
|
410
468
|
/** Check whether a value looks like SubagentDetails. */
|
|
@@ -476,13 +534,14 @@ function collectSubagentErrorLinesFromDetails(
|
|
|
476
534
|
}
|
|
477
535
|
const nested = getNestedSubagentResults(result.messages ?? []);
|
|
478
536
|
for (const child of nested) {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
537
|
+
// Older pi-subagent versions returned an unsupported `isError` field
|
|
538
|
+
// from execute(), so Pi persisted the outer tool result as successful.
|
|
539
|
+
// Inspect durable child outcomes regardless of that unreliable flag.
|
|
540
|
+
collectSubagentErrorLinesFromDetails(
|
|
541
|
+
child.details,
|
|
542
|
+
lines,
|
|
543
|
+
`${prefix}${result.agent} -> `,
|
|
544
|
+
);
|
|
486
545
|
}
|
|
487
546
|
}
|
|
488
547
|
}
|
|
@@ -491,7 +550,8 @@ function collectSubagentErrorLinesFromDetails(
|
|
|
491
550
|
export function getNestedSubagentErrorSummary(messages: Message[]): string | null {
|
|
492
551
|
const lines: string[] = [];
|
|
493
552
|
for (const nested of getNestedSubagentResults(messages)) {
|
|
494
|
-
|
|
553
|
+
// Do not trust the outer tool-result error bit: releases before Pi 0.83
|
|
554
|
+
// could persist failed subagents with isError=false.
|
|
495
555
|
collectSubagentErrorLinesFromDetails(nested.details, lines);
|
|
496
556
|
}
|
|
497
557
|
if (lines.length === 0) return null;
|