pi-cohort 7.4.0 → 7.4.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/CHANGELOG.md +13 -0
- package/README.md +3 -1
- package/package.json +6 -6
- package/src/runs/background/subagent-runner.ts +82 -3
- package/src/runs/foreground/execution.ts +72 -17
- package/src/runs/foreground/subagent-executor.ts +10 -9
- package/src/runs/shared/forward-flags.ts +2 -2
- package/src/runs/shared/single-output.ts +10 -0
- package/src/runs/shared/structured-output.ts +36 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +64 -6
- package/src/shared/utils.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [7.4.2] - 2026-10-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Children with `outputSchema` get one in-process follow-up after a normal completion without a `structured_output` call; a valid follow-up call succeeds like a first-turn call. (#25)
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- Pi >=1.3.0 is required. (#25)
|
|
10
|
+
|
|
11
|
+
## [7.4.1] - 2026-10-19
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Non-BLOCKED file-only failures and save errors return an artifact pointer or an explicit not-preserved reason instead of the child reply inline; BLOCKED handoffs and inline mode are unchanged. (#24)
|
|
15
|
+
|
|
3
16
|
## [7.4.0] - 2026-10-19
|
|
4
17
|
|
|
5
18
|
### Added
|
package/README.md
CHANGED
|
@@ -33,6 +33,8 @@ Four independent extensions for the [pi coding agent](https://github.com/earendi
|
|
|
33
33
|
|
|
34
34
|
## Installation
|
|
35
35
|
|
|
36
|
+
Requires Pi >=1.3.0.
|
|
37
|
+
|
|
36
38
|
```bash
|
|
37
39
|
pi install npm:pi-cohort
|
|
38
40
|
```
|
|
@@ -45,7 +47,7 @@ Children and detached coordinators use the originating parent's resolved Node ex
|
|
|
45
47
|
|
|
46
48
|
External execution backends are currently unsupported on Windows, for both foreground and background runs. Native execution remains supported.
|
|
47
49
|
|
|
48
|
-
`pi-cohort/execution-backend` requires Pi >=
|
|
50
|
+
`pi-cohort/execution-backend` requires Pi >=1.3.0 and is the public API for Pi extensions that add child execution surfaces. Pi's TypeScript-aware runtime loads the API; bare Node is not a supported execution path. Consumers outside Pi must supply a TypeScript-aware loader such as [jiti](https://github.com/unjs/jiti).
|
|
49
51
|
|
|
50
52
|
An adapter registers its backend in extension load order. To make that backend available to background runs, registration also supplies an optional reload descriptor:
|
|
51
53
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cohort",
|
|
3
|
-
"version": "7.4.
|
|
3
|
+
"version": "7.4.2",
|
|
4
4
|
"description": "Delegate Pi work to focused child agents: code review, scouting, implementation, parallel audits, saved chains, and background jobs.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"peerDependencies": {
|
|
64
64
|
"@earendil-works/pi-agent-core": "*",
|
|
65
65
|
"@earendil-works/pi-ai": "*",
|
|
66
|
-
"@earendil-works/pi-coding-agent": ">=
|
|
66
|
+
"@earendil-works/pi-coding-agent": ">=1.3.0",
|
|
67
67
|
"@earendil-works/pi-tui": "*",
|
|
68
68
|
"typebox": "*"
|
|
69
69
|
},
|
|
@@ -88,10 +88,10 @@
|
|
|
88
88
|
"jiti": "^2.7.0"
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
|
-
"@earendil-works/pi-agent-core": "^
|
|
92
|
-
"@earendil-works/pi-ai": "^
|
|
93
|
-
"@earendil-works/pi-coding-agent": "^
|
|
94
|
-
"@earendil-works/pi-tui": "^
|
|
91
|
+
"@earendil-works/pi-agent-core": "^1.3.0",
|
|
92
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
93
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
94
|
+
"@earendil-works/pi-tui": "^1.3.0",
|
|
95
95
|
"typebox": "^1.3.11"
|
|
96
96
|
}
|
|
97
97
|
}
|
|
@@ -7,7 +7,7 @@ import type { Message } from "@earendil-works/pi-ai";
|
|
|
7
7
|
import { writeAtomicJson } from "../../shared/atomic-json.ts";
|
|
8
8
|
import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
|
|
9
9
|
import { getPiSpawnCommand, parsePiRuntime, PiRuntimeLaunchError, type PiRuntimeIdentity } from "../shared/pi-spawn.ts";
|
|
10
|
-
import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
|
|
10
|
+
import { captureSingleOutputSnapshot, finalizeSingleOutput, formatFileOnlyFailure, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
|
|
11
11
|
import {
|
|
12
12
|
type ActivityState,
|
|
13
13
|
type ArtifactConfig,
|
|
@@ -228,6 +228,7 @@ type ChildMessage = Message & {
|
|
|
228
228
|
|
|
229
229
|
interface ChildEvent {
|
|
230
230
|
type?: string;
|
|
231
|
+
aborted?: boolean;
|
|
231
232
|
message?: ChildMessage;
|
|
232
233
|
toolName?: string;
|
|
233
234
|
args?: Record<string, unknown>;
|
|
@@ -255,6 +256,8 @@ function runPiStreaming(
|
|
|
255
256
|
childEventContext?: ChildEventContext,
|
|
256
257
|
registerInterrupt?: (interrupt: (() => void) | undefined) => void,
|
|
257
258
|
onChildEvent?: (event: ChildEvent) => void,
|
|
259
|
+
structuredRun = false,
|
|
260
|
+
controlConfig: ResolvedControlConfig = DEFAULT_CONTROL_CONFIG,
|
|
258
261
|
): Promise<RunPiStreamingResult> {
|
|
259
262
|
return new Promise((resolve, reject) => {
|
|
260
263
|
const spawnSpec = getPiSpawnCommand(args, runtime);
|
|
@@ -321,6 +324,13 @@ function runPiStreaming(
|
|
|
321
324
|
appendChildEvent(event);
|
|
322
325
|
onChildEvent?.(event);
|
|
323
326
|
|
|
327
|
+
if (structuredRun && event.type === "agent_settled") {
|
|
328
|
+
clearSettlementWait();
|
|
329
|
+
cleanTerminalAssistantStopReceived = !event.aborted && !assistantError && !error && !interrupted;
|
|
330
|
+
if (event.aborted && !error) error = "Subagent aborted before structured settlement.";
|
|
331
|
+
startFinalDrain();
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
if (event.type === "tool_execution_start" && event.toolName) {
|
|
325
335
|
observedMutationAttempt = observedMutationAttempt || isMutatingTool(event.toolName, event.args);
|
|
326
336
|
const toolArgs = extractToolArgsPreview(event.args ?? {});
|
|
@@ -351,7 +361,8 @@ function runPiStreaming(
|
|
|
351
361
|
if (stopReason === "stop" && !hasToolCall) {
|
|
352
362
|
if (!event.message.errorMessage && extractTextFromContent(event.message.content).trim()) assistantError = undefined;
|
|
353
363
|
cleanTerminalAssistantStopReceived ||= !event.message.errorMessage;
|
|
354
|
-
|
|
364
|
+
if (structuredRun) startSettlementWait();
|
|
365
|
+
else startFinalDrain();
|
|
355
366
|
}
|
|
356
367
|
}
|
|
357
368
|
};
|
|
@@ -379,8 +390,31 @@ function runPiStreaming(
|
|
|
379
390
|
let finalDrainTimer: NodeJS.Timeout | undefined;
|
|
380
391
|
let finalHardKillTimer: NodeJS.Timeout | undefined;
|
|
381
392
|
let settled = false;
|
|
393
|
+
let lastStreamActivityAt = Date.now();
|
|
394
|
+
let settlementWaitTimer: NodeJS.Timeout | undefined;
|
|
395
|
+
function clearSettlementWait(): void {
|
|
396
|
+
if (settlementWaitTimer) clearInterval(settlementWaitTimer);
|
|
397
|
+
settlementWaitTimer = undefined;
|
|
398
|
+
}
|
|
399
|
+
function startSettlementWait(): void {
|
|
400
|
+
if (settlementWaitTimer || settled || childExited) return;
|
|
401
|
+
settlementWaitTimer = setInterval(() => {
|
|
402
|
+
if (settled || childExited || interrupted) return;
|
|
403
|
+
const silenceMs = Date.now() - lastStreamActivityAt;
|
|
404
|
+
if (silenceMs >= controlConfig.inFlightSilenceCeilingMs) {
|
|
405
|
+
error ??= "Subagent awaiting structured settlement exceeded its silence ceiling. Forcing termination.";
|
|
406
|
+
cleanTerminalAssistantStopReceived = false;
|
|
407
|
+
startFinalDrain();
|
|
408
|
+
}
|
|
409
|
+
if (silenceMs >= controlConfig.inFlightSilenceKillMs) {
|
|
410
|
+
forcedTerminationSignal = trySignalChild(child, "SIGKILL") || forcedTerminationSignal;
|
|
411
|
+
}
|
|
412
|
+
}, Math.min(1000, controlConfig.inFlightSilenceCeilingMs / 4));
|
|
413
|
+
settlementWaitTimer.unref?.();
|
|
414
|
+
}
|
|
382
415
|
const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
|
|
383
416
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
417
|
+
lastStreamActivityAt = Date.now();
|
|
384
418
|
const text = chunk.toString();
|
|
385
419
|
stdoutBuf += text;
|
|
386
420
|
const lines = stdoutBuf.split("\n");
|
|
@@ -389,11 +423,13 @@ function runPiStreaming(
|
|
|
389
423
|
});
|
|
390
424
|
|
|
391
425
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
426
|
+
lastStreamActivityAt = Date.now();
|
|
392
427
|
processStderrText(chunk.toString());
|
|
393
428
|
});
|
|
394
429
|
registerInterrupt?.(() => {
|
|
395
430
|
if (settled) return;
|
|
396
431
|
interrupted = true;
|
|
432
|
+
clearSettlementWait();
|
|
397
433
|
if (!error) error = "Interrupted. Waiting for explicit next action.";
|
|
398
434
|
trySignalChild(child, "SIGINT");
|
|
399
435
|
setTimeout(() => {
|
|
@@ -401,6 +437,7 @@ function runPiStreaming(
|
|
|
401
437
|
}, 1000).unref?.();
|
|
402
438
|
});
|
|
403
439
|
const clearDrainTimers = () => {
|
|
440
|
+
clearSettlementWait();
|
|
404
441
|
if (finalDrainTimer) {
|
|
405
442
|
clearTimeout(finalDrainTimer);
|
|
406
443
|
finalDrainTimer = undefined;
|
|
@@ -590,6 +627,7 @@ export interface SingleStepContext {
|
|
|
590
627
|
onChildEvent?: (event: ChildEvent) => void;
|
|
591
628
|
executionBackendCoordinator?: DetachedExecutionBackendCoordinator;
|
|
592
629
|
executionBackendConfig?: DetachedExecutionBackendConfig;
|
|
630
|
+
controlConfig?: ResolvedControlConfig;
|
|
593
631
|
}
|
|
594
632
|
|
|
595
633
|
/** Execute one detached-runner leaf. */
|
|
@@ -909,6 +947,8 @@ export async function runSingleStep(
|
|
|
909
947
|
{ eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
|
|
910
948
|
ctx.registerInterrupt,
|
|
911
949
|
ctx.onChildEvent,
|
|
950
|
+
effectiveStructuredOutput !== undefined,
|
|
951
|
+
ctx.controlConfig ?? DEFAULT_CONTROL_CONFIG,
|
|
912
952
|
);
|
|
913
953
|
} catch (error) {
|
|
914
954
|
if (!(error instanceof PiRuntimeLaunchError)) throw error;
|
|
@@ -1007,6 +1047,8 @@ export async function runSingleStep(
|
|
|
1007
1047
|
let acceptanceCanFailRun = false;
|
|
1008
1048
|
let effectiveFinalExitCode = resolvedOutput.saveError ? 1 : finalResult?.exitCode ?? 1;
|
|
1009
1049
|
let effectiveFinalError = finalResult?.error;
|
|
1050
|
+
let preservedAt: string | undefined;
|
|
1051
|
+
let notPreservedReason = "artifact output disabled";
|
|
1010
1052
|
if (resolvedOutput.saveError) {
|
|
1011
1053
|
effectiveFinalError = effectiveFinalError
|
|
1012
1054
|
? `${effectiveFinalError}\n${resolvedOutput.saveError}`
|
|
@@ -1037,7 +1079,14 @@ export async function runSingleStep(
|
|
|
1037
1079
|
|
|
1038
1080
|
if (artifactPaths && ctx.artifactConfig?.enabled !== false) {
|
|
1039
1081
|
if (ctx.artifactConfig?.includeOutput !== false) {
|
|
1040
|
-
|
|
1082
|
+
try {
|
|
1083
|
+
fs.writeFileSync(artifactPaths.outputPath, output, "utf-8");
|
|
1084
|
+
preservedAt = artifactPaths.outputPath;
|
|
1085
|
+
} catch (error) {
|
|
1086
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
+
notPreservedReason = `artifact write failed: ${message}`;
|
|
1088
|
+
throw error;
|
|
1089
|
+
}
|
|
1041
1090
|
}
|
|
1042
1091
|
if (ctx.artifactConfig?.includeMetadata !== false) {
|
|
1043
1092
|
fs.writeFileSync(
|
|
@@ -1063,11 +1112,14 @@ export async function runSingleStep(
|
|
|
1063
1112
|
effectiveFinalError = effectiveFinalError ? `${effectiveFinalError}\n${message}` : message;
|
|
1064
1113
|
}
|
|
1065
1114
|
|
|
1115
|
+
const outputBeforeExternalFinalization = outputForSummary;
|
|
1116
|
+
let externalOutputPersisted = false;
|
|
1066
1117
|
// Native runs have runPiStreaming write outputFile live; external runs persist it
|
|
1067
1118
|
// before their surface can be destructively closed as delivered.
|
|
1068
1119
|
if (externalOwner) {
|
|
1069
1120
|
try {
|
|
1070
1121
|
fs.writeFileSync(ctx.outputFile, outputForSummary, "utf-8");
|
|
1122
|
+
externalOutputPersisted = true;
|
|
1071
1123
|
} catch (error) {
|
|
1072
1124
|
const message = safeExternalFailure("output persistence", error);
|
|
1073
1125
|
effectiveFinalExitCode = 1;
|
|
@@ -1090,6 +1142,30 @@ export async function runSingleStep(
|
|
|
1090
1142
|
executionSurface = { handle: finishResult.handle, retained: finishResult.retained };
|
|
1091
1143
|
}
|
|
1092
1144
|
|
|
1145
|
+
if (step.outputMode === "file-only"
|
|
1146
|
+
&& (effectiveFinalExitCode !== 0 || resolvedOutput.saveError)
|
|
1147
|
+
&& !blockedLine(effectiveFinalError ?? "")) {
|
|
1148
|
+
const pointer = formatFileOnlyFailure({
|
|
1149
|
+
outputPath: step.outputPath,
|
|
1150
|
+
saveError: resolvedOutput.saveError,
|
|
1151
|
+
...(preservedAt !== undefined ? { preservedAt } : { notPreservedReason }),
|
|
1152
|
+
});
|
|
1153
|
+
outputForSummary = attemptNotes.length > 0
|
|
1154
|
+
? `${attemptNotes.join("\n")}\n\n${pointer}`
|
|
1155
|
+
: pointer;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// finish can turn a saved success into a failure after the pre-close log write.
|
|
1159
|
+
if (externalOutputPersisted && outputForSummary !== outputBeforeExternalFinalization) {
|
|
1160
|
+
try {
|
|
1161
|
+
fs.writeFileSync(ctx.outputFile, outputForSummary, "utf-8");
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
const message = safeExternalFailure("output persistence", error);
|
|
1164
|
+
effectiveFinalExitCode = 1;
|
|
1165
|
+
effectiveFinalError = effectiveFinalError ? `${effectiveFinalError}\n${message}` : message;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1093
1169
|
return {
|
|
1094
1170
|
agent: step.agent,
|
|
1095
1171
|
output: outputForSummary,
|
|
@@ -1891,6 +1967,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
1891
1967
|
flatIndex: fi, flatStepCount: Math.max(statusPayload.steps.length, 1),
|
|
1892
1968
|
outputFile: path.join(asyncDir, `output-${fi}.log`),
|
|
1893
1969
|
runtime: config.runtime,
|
|
1970
|
+
controlConfig: config.controlConfig,
|
|
1894
1971
|
forwardedFlags: config.forwardedFlags,
|
|
1895
1972
|
executionBackendCoordinator,
|
|
1896
1973
|
executionBackendConfig,
|
|
@@ -2147,6 +2224,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2147
2224
|
flatIndex: fi, flatStepCount: flatSteps.length,
|
|
2148
2225
|
outputFile: path.join(asyncDir, `output-${fi}.log`),
|
|
2149
2226
|
runtime: config.runtime,
|
|
2227
|
+
controlConfig: config.controlConfig,
|
|
2150
2228
|
forwardedFlags: config.forwardedFlags,
|
|
2151
2229
|
executionBackendCoordinator,
|
|
2152
2230
|
executionBackendConfig,
|
|
@@ -2325,6 +2403,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
|
|
|
2325
2403
|
flatIndex, flatStepCount: flatSteps.length,
|
|
2326
2404
|
outputFile: path.join(asyncDir, `output-${flatIndex}.log`),
|
|
2327
2405
|
runtime: config.runtime,
|
|
2406
|
+
controlConfig: config.controlConfig,
|
|
2328
2407
|
forwardedFlags: config.forwardedFlags,
|
|
2329
2408
|
executionBackendCoordinator,
|
|
2330
2409
|
executionBackendConfig,
|
|
@@ -46,7 +46,7 @@ import { PINNED_AGENT_DIR_OVERRIDE } from "../../shared/agent-dir.ts";
|
|
|
46
46
|
import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
|
|
47
47
|
import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
|
|
48
48
|
import { buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
|
|
49
|
-
import { captureSingleOutputSnapshot, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
49
|
+
import { captureSingleOutputSnapshot, formatFileOnlyFailure, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
50
50
|
import {
|
|
51
51
|
buildModelCandidates,
|
|
52
52
|
formatModelAttemptNote,
|
|
@@ -192,13 +192,18 @@ async function runSingleAttempt(
|
|
|
192
192
|
let activityTimer: NodeJS.Timeout | undefined;
|
|
193
193
|
|
|
194
194
|
|
|
195
|
-
//
|
|
196
|
-
// give it a short grace period to flush naturally, then clean it up.
|
|
195
|
+
// Structured continuations can outlive a prose stop; drain only after settlement.
|
|
197
196
|
const FINAL_STOP_GRACE_MS = 1000;
|
|
198
197
|
const HARD_KILL_MS = 3000;
|
|
199
198
|
let childExited = false;
|
|
200
199
|
let forcedTerminationSignal = false;
|
|
201
200
|
let cleanTerminalAssistantStopReceived = false;
|
|
201
|
+
let structuredSettlementPending = Boolean(options.structuredOutput);
|
|
202
|
+
let cleanStructuredSettlementReceived = false;
|
|
203
|
+
let silenceHardKillTimer: NodeJS.Timeout | undefined;
|
|
204
|
+
const cleanCompletionReceived = () => options.structuredOutput
|
|
205
|
+
? cleanStructuredSettlementReceived && !options.signal?.aborted && !interruptedByControl && !silenceKillRequested
|
|
206
|
+
: cleanTerminalAssistantStopReceived;
|
|
202
207
|
let finalDrainTimer: NodeJS.Timeout | undefined;
|
|
203
208
|
let finalHardKillTimer: NodeJS.Timeout | undefined;
|
|
204
209
|
const clearFinalDrainTimers = () => {
|
|
@@ -218,7 +223,7 @@ async function runSingleAttempt(
|
|
|
218
223
|
const termSent = trySignalChild(proc, "SIGTERM");
|
|
219
224
|
if (!termSent) return;
|
|
220
225
|
forcedTerminationSignal = true;
|
|
221
|
-
if (!
|
|
226
|
+
if (!cleanCompletionReceived() && !assistantError) {
|
|
222
227
|
result.error = result.error ?? `Subagent process did not exit within ${FINAL_STOP_GRACE_MS}ms after its final message. Forcing termination.`;
|
|
223
228
|
}
|
|
224
229
|
finalHardKillTimer = setTimeout(() => {
|
|
@@ -240,17 +245,19 @@ async function runSingleAttempt(
|
|
|
240
245
|
result.error = result.error ??
|
|
241
246
|
`Subagent killed: in-flight turn produced no output for ${Math.round(silenceMs / 1000)}s ` +
|
|
242
247
|
`(exceeded inFlightSilenceKillMs=${controlConfig.inFlightSilenceKillMs}ms). Likely wedged in a tool call.`;
|
|
243
|
-
|
|
248
|
+
silenceHardKillTimer = setTimeout(() => {
|
|
244
249
|
if (settled || processClosed) return;
|
|
245
250
|
forcedTerminationSignal = trySignalChild(proc, "SIGKILL") || forcedTerminationSignal;
|
|
246
251
|
}, HARD_KILL_MS);
|
|
247
|
-
|
|
252
|
+
silenceHardKillTimer.unref?.();
|
|
248
253
|
};
|
|
249
254
|
|
|
250
255
|
|
|
251
256
|
const finish = (code: number) => {
|
|
252
257
|
if (settled) return;
|
|
253
258
|
settled = true;
|
|
259
|
+
structuredSettlementPending = false;
|
|
260
|
+
if (silenceHardKillTimer) clearTimeout(silenceHardKillTimer);
|
|
254
261
|
clearFinalDrainTimers();
|
|
255
262
|
clearStdioGuard();
|
|
256
263
|
if (activityTimer) {
|
|
@@ -326,19 +333,33 @@ async function runSingleAttempt(
|
|
|
326
333
|
return true;
|
|
327
334
|
};
|
|
328
335
|
const updateActivityState = (now: number): boolean => {
|
|
336
|
+
if (structuredSettlementPending
|
|
337
|
+
&& now - (progress.lastProductiveSignalAt ?? startTime) >= controlConfig.inFlightSilenceCeilingMs) {
|
|
338
|
+
result.error ??= "Subagent awaiting structured settlement exceeded its silence ceiling. Forcing termination.";
|
|
339
|
+
startFinalDrain();
|
|
340
|
+
}
|
|
341
|
+
if (!controlConfig.enabled && structuredSettlementPending && shouldSilenceKill({
|
|
342
|
+
turnOpen: progress.turnOpen || structuredSettlementPending,
|
|
343
|
+
lastProductiveSignalAt: progress.lastProductiveSignalAt,
|
|
344
|
+
startedAt: startTime,
|
|
345
|
+
now,
|
|
346
|
+
killMs: controlConfig.inFlightSilenceKillMs,
|
|
347
|
+
})) {
|
|
348
|
+
requestSilenceKill(now - (progress.lastProductiveSignalAt ?? startTime));
|
|
349
|
+
}
|
|
329
350
|
if (!controlConfig.enabled) return false;
|
|
330
351
|
const idleState = deriveActivityState({
|
|
331
352
|
config: controlConfig,
|
|
332
353
|
startedAt: startTime,
|
|
333
354
|
lastActivityAt: progress.lastActivityAt,
|
|
334
355
|
now,
|
|
335
|
-
inFlightTurn: progress.turnOpen,
|
|
356
|
+
inFlightTurn: progress.turnOpen || structuredSettlementPending,
|
|
336
357
|
lastProductiveSignalAt: progress.lastProductiveSignalAt,
|
|
337
358
|
});
|
|
338
359
|
if (idleState === "needs_attention") {
|
|
339
360
|
const notified = progress.activityState === "needs_attention" ? false : emitNeedsAttention(now);
|
|
340
361
|
if (shouldSilenceKill({
|
|
341
|
-
turnOpen: progress.turnOpen,
|
|
362
|
+
turnOpen: progress.turnOpen || structuredSettlementPending,
|
|
342
363
|
lastProductiveSignalAt: progress.lastProductiveSignalAt,
|
|
343
364
|
startedAt: startTime,
|
|
344
365
|
now,
|
|
@@ -383,9 +404,9 @@ async function runSingleAttempt(
|
|
|
383
404
|
const processLine = (line: string) => {
|
|
384
405
|
if (!line.trim()) return;
|
|
385
406
|
jsonlWriter.writeLine(line);
|
|
386
|
-
let evt: { type?: string; message?: Message; toolName?: string; args?: unknown };
|
|
407
|
+
let evt: { type?: string; message?: Message; toolName?: string; args?: unknown; aborted?: boolean };
|
|
387
408
|
try {
|
|
388
|
-
evt = JSON.parse(line) as
|
|
409
|
+
evt = JSON.parse(line) as typeof evt;
|
|
389
410
|
} catch {
|
|
390
411
|
// Non-JSON stdout lines are expected; only structured events are parsed.
|
|
391
412
|
return;
|
|
@@ -407,6 +428,14 @@ async function runSingleAttempt(
|
|
|
407
428
|
progress.lastProductiveSignalAt = lifecycle.lastProductiveSignalAt;
|
|
408
429
|
updateActivityState(now);
|
|
409
430
|
|
|
431
|
+
if (options.structuredOutput && evt.type === "agent_settled") {
|
|
432
|
+
structuredSettlementPending = false;
|
|
433
|
+
cleanStructuredSettlementReceived = !evt.aborted && !assistantError
|
|
434
|
+
&& !options.signal?.aborted && !interruptedByControl && !silenceKillRequested;
|
|
435
|
+
if (evt.aborted) result.error ??= "Subagent settlement aborted.";
|
|
436
|
+
startFinalDrain();
|
|
437
|
+
}
|
|
438
|
+
|
|
410
439
|
if (evt.type === "tool_execution_start") {
|
|
411
440
|
const toolArgs = evt.args && typeof evt.args === "object" && !Array.isArray(evt.args)
|
|
412
441
|
? evt.args as Record<string, unknown>
|
|
@@ -455,14 +484,14 @@ async function runSingleAttempt(
|
|
|
455
484
|
if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
|
|
456
485
|
const assistantText = extractTextFromContent(evt.message.content);
|
|
457
486
|
appendRecentOutput(progress, assistantText.split("\n").slice(-10));
|
|
458
|
-
//
|
|
487
|
+
// Unstructured runs still drain on the final assistant message.
|
|
459
488
|
const stopReason = (evt.message as { stopReason?: string }).stopReason;
|
|
460
489
|
const hasToolCall = Array.isArray(evt.message.content)
|
|
461
490
|
&& evt.message.content.some((part) => (part as { type?: string }).type === "toolCall");
|
|
462
491
|
if (stopReason === "stop" && !hasToolCall) {
|
|
463
492
|
if (!evt.message.errorMessage && assistantText.trim()) assistantError = undefined;
|
|
464
493
|
cleanTerminalAssistantStopReceived ||= !evt.message.errorMessage;
|
|
465
|
-
startFinalDrain();
|
|
494
|
+
if (!options.structuredOutput) startFinalDrain();
|
|
466
495
|
}
|
|
467
496
|
}
|
|
468
497
|
updateActivityState(now);
|
|
@@ -499,7 +528,7 @@ async function runSingleAttempt(
|
|
|
499
528
|
}
|
|
500
529
|
};
|
|
501
530
|
|
|
502
|
-
if (controlConfig.enabled) {
|
|
531
|
+
if (controlConfig.enabled || options.structuredOutput) {
|
|
503
532
|
activityTimer = setInterval(() => {
|
|
504
533
|
if (processClosed || settled) return;
|
|
505
534
|
const now = Date.now();
|
|
@@ -514,13 +543,20 @@ async function runSingleAttempt(
|
|
|
514
543
|
let stderrBuf = "";
|
|
515
544
|
|
|
516
545
|
const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
|
|
546
|
+
const refreshStructuredWait = () => {
|
|
547
|
+
if (!structuredSettlementPending) return;
|
|
548
|
+
progress.lastActivityAt = Date.now();
|
|
549
|
+
progress.lastProductiveSignalAt = progress.lastActivityAt;
|
|
550
|
+
};
|
|
517
551
|
proc.stdout.on("data", (d) => {
|
|
552
|
+
refreshStructuredWait();
|
|
518
553
|
buf += d.toString();
|
|
519
554
|
const lines = buf.split("\n");
|
|
520
555
|
buf = lines.pop() || "";
|
|
521
556
|
lines.forEach(processLine);
|
|
522
557
|
});
|
|
523
558
|
proc.stderr.on("data", (d) => {
|
|
559
|
+
refreshStructuredWait();
|
|
524
560
|
stderrBuf += d.toString();
|
|
525
561
|
});
|
|
526
562
|
proc.on("exit", () => {
|
|
@@ -537,7 +573,7 @@ async function runSingleAttempt(
|
|
|
537
573
|
processClosed = true;
|
|
538
574
|
if (buf.trim()) processLine(buf);
|
|
539
575
|
if (!result.error && assistantError) result.error = assistantError;
|
|
540
|
-
const forcedDrainAfterFinalSuccess = forcedTerminationSignal &&
|
|
576
|
+
const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanCompletionReceived() && !result.error;
|
|
541
577
|
if (code !== 0 && stderrBuf.trim() && !result.error && !forcedDrainAfterFinalSuccess) {
|
|
542
578
|
result.error = stderrBuf.trim();
|
|
543
579
|
}
|
|
@@ -815,6 +851,8 @@ export async function runSync(
|
|
|
815
851
|
} satisfies SingleResult;
|
|
816
852
|
|
|
817
853
|
let postLoopError: unknown;
|
|
854
|
+
let preservedAt: string | undefined;
|
|
855
|
+
let notPreservedReason = "artifact output disabled";
|
|
818
856
|
try {
|
|
819
857
|
result.usage = aggregateUsage;
|
|
820
858
|
result.attemptedModels = attemptedModels.length > 0 ? attemptedModels : undefined;
|
|
@@ -834,7 +872,14 @@ export async function runSync(
|
|
|
834
872
|
if (artifactPathsResult && options.artifactConfig?.enabled !== false) {
|
|
835
873
|
result.artifactPaths = artifactPathsResult;
|
|
836
874
|
if (options.artifactConfig?.includeOutput !== false) {
|
|
837
|
-
|
|
875
|
+
try {
|
|
876
|
+
writeArtifact(artifactPathsResult.outputPath, artifactOutputByResult.get(result) ?? result.finalOutput ?? "");
|
|
877
|
+
preservedAt = artifactPathsResult.outputPath;
|
|
878
|
+
} catch (error) {
|
|
879
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
880
|
+
notPreservedReason = `artifact write failed: ${message}`;
|
|
881
|
+
throw error;
|
|
882
|
+
}
|
|
838
883
|
}
|
|
839
884
|
if (options.artifactConfig?.includeMetadata !== false) {
|
|
840
885
|
writeMetadata(artifactPathsResult.metadataPath, {
|
|
@@ -912,9 +957,19 @@ export async function runSync(
|
|
|
912
957
|
: safeMessage;
|
|
913
958
|
result.executionSurface ??= { handle: externalOwner.surface, retained: true };
|
|
914
959
|
}
|
|
915
|
-
} else if (postLoopError) {
|
|
916
|
-
throw postLoopError;
|
|
917
960
|
}
|
|
918
961
|
|
|
962
|
+
if (options.outputMode === "file-only"
|
|
963
|
+
&& (result.exitCode !== 0 || result.outputSaveError)
|
|
964
|
+
&& !blockedLine(result.error ?? "")) {
|
|
965
|
+
result.finalOutput = formatFileOnlyFailure({
|
|
966
|
+
outputPath: options.outputPath,
|
|
967
|
+
saveError: result.outputSaveError,
|
|
968
|
+
...(preservedAt !== undefined ? { preservedAt } : { notPreservedReason }),
|
|
969
|
+
});
|
|
970
|
+
result.truncation = undefined;
|
|
971
|
+
}
|
|
972
|
+
if (!externalOwner && postLoopError) throw postLoopError;
|
|
973
|
+
|
|
919
974
|
return result;
|
|
920
975
|
}
|
|
@@ -39,6 +39,7 @@ import { createForkContextResolver } from "../../shared/fork-context.ts";
|
|
|
39
39
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
40
40
|
import { formatControlNoticeMessage, resolveControlConfig, shouldNotifyControlEvent } from "../shared/subagent-control.ts";
|
|
41
41
|
import { finalizeSingleOutput, injectSingleOutputInstruction, normalizeTopLevelOutput, resolveParallelTaskOutputPath, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
|
|
42
|
+
import { blockedLine } from "../shared/completion-guard.ts";
|
|
42
43
|
import { compactForegroundDetails, getSingleResultOutput, mapConcurrent, readStatus, resolveChildCwd } from "../../shared/utils.ts";
|
|
43
44
|
import { resolveSubagentResultStatus } from "../shared/result-children.ts";
|
|
44
45
|
import { buildRevivedAsyncTask, resolveAsyncResumeTarget } from "../background/async-resume.ts";
|
|
@@ -2064,7 +2065,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
2064
2065
|
if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
|
|
2065
2066
|
|
|
2066
2067
|
const fullOutput = getSingleResultOutput(r);
|
|
2067
|
-
const
|
|
2068
|
+
const displayOutput = r.outputMode === "file-only" ? r.finalOutput : finalizeSingleOutput({
|
|
2068
2069
|
fullOutput,
|
|
2069
2070
|
truncatedOutput: r.truncation?.text,
|
|
2070
2071
|
outputPath,
|
|
@@ -2073,7 +2074,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
2073
2074
|
savedPath: r.savedOutputPath,
|
|
2074
2075
|
outputReference: r.outputReference,
|
|
2075
2076
|
saveError: r.outputSaveError,
|
|
2076
|
-
});
|
|
2077
|
+
}).displayOutput;
|
|
2077
2078
|
const details = compactForegroundDetails({
|
|
2078
2079
|
mode: "single",
|
|
2079
2080
|
runId,
|
|
@@ -2096,14 +2097,14 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
|
|
|
2096
2097
|
};
|
|
2097
2098
|
}
|
|
2098
2099
|
|
|
2099
|
-
if (r.exitCode !== 0)
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2100
|
+
if (r.exitCode !== 0) {
|
|
2101
|
+
const text = r.outputMode === "file-only" && !blockedLine(r.error ?? "")
|
|
2102
|
+
? `FAILED (exit code ${r.exitCode})${r.error ? `: ${r.error}` : ""}\n\n${r.finalOutput ?? ""}`
|
|
2103
|
+
: r.error || "Failed";
|
|
2104
|
+
return { content: [{ type: "text", text }], details, isError: true };
|
|
2105
|
+
}
|
|
2105
2106
|
return {
|
|
2106
|
-
content: [{ type: "text", text:
|
|
2107
|
+
content: [{ type: "text", text: displayOutput || "(no output)" }],
|
|
2107
2108
|
details,
|
|
2108
2109
|
};
|
|
2109
2110
|
}
|
|
@@ -3,7 +3,7 @@ export interface ForwardFlagsConfig {
|
|
|
3
3
|
}
|
|
4
4
|
|
|
5
5
|
// Core pi flag names -> value arity. This is the UNION of recognized core flags
|
|
6
|
-
// across the supported pi range (0.74-
|
|
6
|
+
// across the supported pi range (0.74-1.3), transcribed from
|
|
7
7
|
// @earendil-works/pi-coding-agent dist/cli/args.js. A name present here is never
|
|
8
8
|
// forwarded to children. Direction matters: an entry that is core in a NEWER pi
|
|
9
9
|
// but absent from an OLDER installed pi is safe (worst case we skip forwarding a
|
|
@@ -14,7 +14,7 @@ type Arity = "boolean" | "value" | "value-guarded" | "value-guarded-print";
|
|
|
14
14
|
export const RECOGNIZED_PI_FLAGS: Record<string, Arity> = {
|
|
15
15
|
help: "boolean", version: "boolean", continue: "boolean", resume: "boolean",
|
|
16
16
|
"no-session": "boolean", "no-tools": "boolean", "no-builtin-tools": "boolean",
|
|
17
|
-
"no-extensions": "boolean", "no-skills": "boolean", "no-prompt-templates": "boolean",
|
|
17
|
+
"no-extensions": "boolean", "no-mcp": "boolean", "no-skills": "boolean", "no-prompt-templates": "boolean",
|
|
18
18
|
"no-themes": "boolean", "no-context-files": "boolean", verbose: "boolean",
|
|
19
19
|
approve: "boolean", "no-approve": "boolean", offline: "boolean",
|
|
20
20
|
mode: "value", provider: "value", model: "value", "api-key": "value",
|
|
@@ -106,6 +106,16 @@ export function formatSavedOutputReference(savedPath: string, fullOutput: string
|
|
|
106
106
|
};
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export function formatFileOnlyFailure(params: {
|
|
110
|
+
outputPath?: string;
|
|
111
|
+
saveError?: string;
|
|
112
|
+
} & ({ preservedAt: string; notPreservedReason?: never } | { preservedAt?: never; notPreservedReason: string })): string {
|
|
113
|
+
const pointer = params.preservedAt !== undefined
|
|
114
|
+
? `Full reply preserved at ${params.preservedAt}`
|
|
115
|
+
: `Full reply not preserved (${params.notPreservedReason})`;
|
|
116
|
+
return params.saveError ? `Output file error: ${params.outputPath}\n${params.saveError}\n\n${pointer}` : pointer;
|
|
117
|
+
}
|
|
118
|
+
|
|
109
119
|
export function validateFileOnlyOutputMode(outputMode: OutputMode | undefined, outputPath: string | undefined, context: string): string | undefined {
|
|
110
120
|
if (outputMode === "file-only" && !outputPath) {
|
|
111
121
|
return `${context} sets outputMode: "file-only" but does not configure an output file. Set output to a path or use outputMode: "inline".`;
|
|
@@ -55,13 +55,48 @@ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: u
|
|
|
55
55
|
return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const VALIDATION_PREFIX = "Structured output validation failed:";
|
|
59
|
+
const PARAMETER_VALIDATION_PREFIX = 'Validation failed for tool "structured_output":';
|
|
60
|
+
const PARSE_VALIDATION_PREFIX = 'Invalid arguments for tool "structured_output":';
|
|
61
|
+
const INVALID_ARGUMENTS = "Invalid structured_output arguments";
|
|
62
|
+
|
|
63
|
+
function normalizeValidationDiagnostic(content: string): string {
|
|
64
|
+
let diagnostic = content.split("Received arguments:", 1)[0].trim();
|
|
65
|
+
for (;;) {
|
|
66
|
+
if (diagnostic.startsWith(PARSE_VALIDATION_PREFIX)) return INVALID_ARGUMENTS;
|
|
67
|
+
const prefix = [VALIDATION_PREFIX, PARAMETER_VALIDATION_PREFIX].find((prefix) => diagnostic.startsWith(prefix));
|
|
68
|
+
if (!prefix) break;
|
|
69
|
+
diagnostic = diagnostic.slice(prefix.length).trim();
|
|
70
|
+
}
|
|
71
|
+
return diagnostic || INVALID_ARGUMENTS;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getStructuredOutputValidationDiagnostic(content: unknown): string | undefined {
|
|
75
|
+
if (typeof content !== "string") return undefined;
|
|
76
|
+
if (![VALIDATION_PREFIX, PARAMETER_VALIDATION_PREFIX, PARSE_VALIDATION_PREFIX].some((prefix) => content.startsWith(prefix))) return undefined;
|
|
77
|
+
return normalizeValidationDiagnostic(content);
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
|
|
59
81
|
if (!fs.existsSync(runtime.outputPath)) {
|
|
60
82
|
return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
|
|
61
83
|
}
|
|
62
84
|
let value: unknown;
|
|
63
85
|
try {
|
|
64
|
-
|
|
86
|
+
const envelope: unknown = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
|
|
87
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
88
|
+
throw new Error("Expected a capture envelope object.");
|
|
89
|
+
}
|
|
90
|
+
const capture = envelope as Record<string, unknown>;
|
|
91
|
+
if (capture.status === "error") {
|
|
92
|
+
if (typeof capture.message !== "string" || !capture.message.trim()) {
|
|
93
|
+
throw new Error("Error capture requires a nonempty message.");
|
|
94
|
+
}
|
|
95
|
+
return { error: `${VALIDATION_PREFIX} ${normalizeValidationDiagnostic(capture.message)}` };
|
|
96
|
+
}
|
|
97
|
+
if (capture.status !== "success") throw new Error("Unknown capture status.");
|
|
98
|
+
if (!Object.hasOwn(capture, "value")) throw new Error("Success capture requires a value.");
|
|
99
|
+
value = capture.value;
|
|
65
100
|
} catch (error) {
|
|
66
101
|
return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
|
|
67
102
|
}
|
|
@@ -2,11 +2,30 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
|
|
5
|
-
import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, STRUCTURED_OUTPUT_TOOL_NAME, validateStructuredOutputValue } from "./structured-output.ts";
|
|
5
|
+
import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, STRUCTURED_OUTPUT_TOOL_NAME, getStructuredOutputValidationDiagnostic, validateStructuredOutputValue } from "./structured-output.ts";
|
|
6
6
|
import type { JsonSchemaObject } from "../../shared/types.ts";
|
|
7
7
|
|
|
8
8
|
const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
|
|
9
9
|
const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
|
|
10
|
+
let reasked = false;
|
|
11
|
+
let attempted = false;
|
|
12
|
+
|
|
13
|
+
function lastAssistantStopReason(messages: unknown[]): string | undefined {
|
|
14
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
15
|
+
const message = messages[i] as { role?: string; stopReason?: string };
|
|
16
|
+
if (message?.role === "assistant") return message.stopReason;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function reaskMessage(schema: JsonSchemaObject) {
|
|
22
|
+
return {
|
|
23
|
+
type: "custom_message",
|
|
24
|
+
customType: "pi-cohort:structured-output-reask",
|
|
25
|
+
display: true,
|
|
26
|
+
content: "You finished without calling `structured_output`. Call `structured_output` now with a `value` matching this schema, and send no other text:\n\n" + JSON.stringify(schema, null, 2),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
10
29
|
|
|
11
30
|
const STRUCTURED_OUTPUT_INSTRUCTIONS = [
|
|
12
31
|
"This subagent step has a strict structured output contract.",
|
|
@@ -176,6 +195,10 @@ function collectSubagentToolCallIds(entries: readonly unknown[]): Set<string> {
|
|
|
176
195
|
}
|
|
177
196
|
|
|
178
197
|
export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
198
|
+
const onRuntimeEvent = pi.on as unknown as (
|
|
199
|
+
event: string,
|
|
200
|
+
handler: (event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => unknown,
|
|
201
|
+
) => void;
|
|
179
202
|
const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
|
|
180
203
|
const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
|
|
181
204
|
if (structuredOutputPath && structuredSchemaPath) {
|
|
@@ -199,12 +222,13 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
199
222
|
description: "Submit the required final structured output for this subagent step. This terminates the step.",
|
|
200
223
|
parameters: parameters as never,
|
|
201
224
|
async execute(_id: string, params: { value: unknown }) {
|
|
225
|
+
attempted = true;
|
|
202
226
|
const validation = validateStructuredOutputValue(schema, params.value);
|
|
203
227
|
if (validation.status === "invalid") {
|
|
204
228
|
throw new Error(`Structured output validation failed: ${validation.message}`);
|
|
205
229
|
}
|
|
206
230
|
fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
|
|
207
|
-
fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
|
|
231
|
+
fs.writeFileSync(structuredOutputPath, JSON.stringify({ status: "success", value: params.value }), { mode: 0o600 });
|
|
208
232
|
return {
|
|
209
233
|
content: [{ type: "text", text: "Structured output captured." }],
|
|
210
234
|
details: { path: structuredOutputPath },
|
|
@@ -212,13 +236,47 @@ export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
|
|
|
212
236
|
};
|
|
213
237
|
},
|
|
214
238
|
});
|
|
239
|
+
onRuntimeEvent("tool_execution_start", (event) => {
|
|
240
|
+
const e = event as { toolName: string };
|
|
241
|
+
if (e.toolName === STRUCTURED_OUTPUT_TOOL_NAME) attempted = true;
|
|
242
|
+
});
|
|
243
|
+
onRuntimeEvent("tool_execution_end", (event) => {
|
|
244
|
+
const e = event as { toolName: string; isError: boolean; result?: { content?: { type: string; text?: string }[] } };
|
|
245
|
+
if (e.toolName !== STRUCTURED_OUTPUT_TOOL_NAME) return;
|
|
246
|
+
attempted = true;
|
|
247
|
+
if (!e.isError) return;
|
|
248
|
+
const text = e.result?.content?.filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n");
|
|
249
|
+
const message = getStructuredOutputValidationDiagnostic(text);
|
|
250
|
+
if (message === undefined) return;
|
|
251
|
+
try {
|
|
252
|
+
const capture = JSON.parse(fs.readFileSync(structuredOutputPath, "utf-8"));
|
|
253
|
+
if (capture?.status === "success") return;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
fs.writeFileSync(structuredOutputPath, JSON.stringify({ status: "error", message }), { mode: 0o600 });
|
|
259
|
+
} catch {
|
|
260
|
+
// The parent owns capture-read errors when recording the attempt fails.
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
onRuntimeEvent("agent_before_settle", (event) => {
|
|
264
|
+
const e = event as { outcome: string; entries: unknown[]; context: { contextMessages: unknown[] } };
|
|
265
|
+
if (reasked || attempted) return undefined;
|
|
266
|
+
if (e.outcome !== "completed") return undefined;
|
|
267
|
+
if (lastAssistantStopReason(e.context.contextMessages) === "length") return undefined;
|
|
268
|
+
try {
|
|
269
|
+
fs.statSync(structuredOutputPath);
|
|
270
|
+
return undefined;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return undefined;
|
|
273
|
+
}
|
|
274
|
+
reasked = true;
|
|
275
|
+
return { entries: [...e.entries, reaskMessage(schema)], continue: true };
|
|
276
|
+
});
|
|
215
277
|
}
|
|
216
278
|
|
|
217
279
|
let inheritedSubagentToolCallIds: ReadonlySet<string> = new Set<string>();
|
|
218
|
-
const onRuntimeEvent = pi.on as unknown as (
|
|
219
|
-
event: string,
|
|
220
|
-
handler: (event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => unknown,
|
|
221
|
-
) => void;
|
|
222
280
|
onRuntimeEvent("session_start", (_event: unknown, ctx?: { sessionManager?: { getBranch(): unknown[] } }) => {
|
|
223
281
|
// Establishes the inherited/own boundary once, before the child's own first turn: any
|
|
224
282
|
// `subagent` call already in the branch at this point came from the (forked) parent.
|
package/src/shared/utils.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as fs from "node:fs";
|
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import type { Message } from "@earendil-works/pi-ai";
|
|
9
|
+
import { getStructuredOutputValidationDiagnostic, STRUCTURED_OUTPUT_TOOL_NAME } from "../runs/shared/structured-output.ts";
|
|
9
10
|
import { formatToolCall } from "./formatters.ts";
|
|
10
11
|
import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult, ToolCallSummary } from "./types.ts";
|
|
11
12
|
|
|
@@ -293,6 +294,7 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
|
|
|
293
294
|
}
|
|
294
295
|
|
|
295
296
|
const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 1 : 0;
|
|
297
|
+
let hasLaterStructuredOutputSuccess = false;
|
|
296
298
|
|
|
297
299
|
for (let i = messages.length - 1; i >= scanStart; i--) {
|
|
298
300
|
const msg = messages[i];
|
|
@@ -300,9 +302,15 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
|
|
|
300
302
|
const toolName = "toolName" in msg && typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
301
303
|
const isError = "isError" in msg && msg.isError === true;
|
|
302
304
|
|
|
305
|
+
if (toolName === STRUCTURED_OUTPUT_TOOL_NAME && !isError) {
|
|
306
|
+
hasLaterStructuredOutputSuccess = true;
|
|
307
|
+
}
|
|
308
|
+
|
|
303
309
|
if (isError) {
|
|
304
310
|
const text = msg.content.find((c) => c.type === "text");
|
|
305
311
|
const details = text && "text" in text ? text.text : undefined;
|
|
312
|
+
if (toolName === STRUCTURED_OUTPUT_TOOL_NAME && hasLaterStructuredOutputSuccess
|
|
313
|
+
&& getStructuredOutputValidationDiagnostic(details) !== undefined) continue;
|
|
306
314
|
const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
|
|
307
315
|
return {
|
|
308
316
|
hasError: true,
|