pi-subagents 0.25.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +175 -19
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/skills/pi-subagents/SKILL.md +60 -17
  7. package/src/agents/agent-management.ts +71 -15
  8. package/src/agents/agent-serializer.ts +13 -2
  9. package/src/agents/agents.ts +88 -17
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +2 -0
  12. package/src/extension/index.ts +5 -2
  13. package/src/extension/schemas.ts +132 -6
  14. package/src/intercom/result-intercom.ts +5 -0
  15. package/src/runs/background/async-execution.ts +88 -6
  16. package/src/runs/background/async-status.ts +11 -1
  17. package/src/runs/background/run-status.ts +10 -1
  18. package/src/runs/background/subagent-runner.ts +665 -39
  19. package/src/runs/foreground/chain-execution.ts +369 -118
  20. package/src/runs/foreground/execution.ts +392 -19
  21. package/src/runs/foreground/subagent-executor.ts +126 -3
  22. package/src/runs/shared/acceptance-contract.ts +318 -0
  23. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  24. package/src/runs/shared/acceptance-finalization.ts +173 -0
  25. package/src/runs/shared/acceptance-reports.ts +127 -0
  26. package/src/runs/shared/acceptance.ts +22 -0
  27. package/src/runs/shared/chain-outputs.ts +101 -0
  28. package/src/runs/shared/completion-guard.ts +26 -3
  29. package/src/runs/shared/dynamic-fanout.ts +293 -0
  30. package/src/runs/shared/parallel-utils.ts +33 -1
  31. package/src/runs/shared/pi-args.ts +11 -0
  32. package/src/runs/shared/structured-output.ts +77 -0
  33. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  34. package/src/runs/shared/workflow-graph.ts +210 -0
  35. package/src/shared/formatters.ts +2 -2
  36. package/src/shared/settings.ts +53 -4
  37. package/src/shared/types.ts +265 -1
  38. package/src/shared/utils.ts +7 -0
  39. package/src/slash/slash-commands.ts +41 -3
  40. package/src/tui/render.ts +178 -45
@@ -3,7 +3,9 @@
3
3
  */
4
4
 
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync } from "node:fs";
6
+ import { existsSync, mkdtempSync, unlinkSync } from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
7
9
  import type { Message } from "@earendil-works/pi-ai";
8
10
  import type { AgentConfig } from "../../agents/agents.ts";
9
11
  import {
@@ -13,10 +15,13 @@ import {
13
15
  writeMetadata,
14
16
  } from "../../shared/artifacts.ts";
15
17
  import {
18
+ type AcceptanceFinalizationTurn,
19
+ type AcceptanceLedger,
16
20
  type AgentProgress,
17
21
  type ArtifactPaths,
18
22
  type ControlEvent,
19
23
  type ModelAttempt,
24
+ type ResolvedAcceptanceConfig,
20
25
  type RunSyncOptions,
21
26
  type SingleResult,
22
27
  type Usage,
@@ -39,13 +44,15 @@ import {
39
44
  detectSubagentError,
40
45
  extractToolArgsPreview,
41
46
  extractTextFromContent,
47
+ formatResourceLimitExceeded,
42
48
  } from "../../shared/utils.ts";
43
49
  import { buildSkillInjection, resolveSkillsWithFallback } from "../../agents/skills.ts";
44
- import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
50
+ import { evaluateCompletionMutationGuard, resolveCompletionPolicy, type CompletionPolicy } from "../shared/completion-guard.ts";
45
51
  import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
46
52
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
47
53
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
48
54
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
55
+ import { readStructuredOutput } from "../shared/structured-output.ts";
49
56
  import { captureSingleOutputSnapshot, formatSavedOutputReference, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
50
57
  import {
51
58
  buildModelCandidates,
@@ -63,8 +70,23 @@ import {
63
70
  shouldEscalateMutatingFailures,
64
71
  summarizeRecentMutatingFailures,
65
72
  } from "../shared/long-running-guard.ts";
73
+ import {
74
+ acceptanceFailureMessage,
75
+ acceptanceSelfReviewConfig,
76
+ attachFinalizationToLedger,
77
+ buildFinalizationProcessFailureLedger,
78
+ createFinalizationProcessFailureTurn,
79
+ createFinalizationTurn,
80
+ evaluateAcceptance,
81
+ formatAcceptanceFinalizationPrompt,
82
+ formatAcceptancePrompt,
83
+ resolveEffectiveAcceptance,
84
+ shouldRunAcceptanceFinalization,
85
+ stripAcceptanceReport,
86
+ } from "../shared/acceptance.ts";
66
87
 
67
88
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
89
+ const acceptanceOutputByResult = new WeakMap<SingleResult, string>();
68
90
 
69
91
  function emptyUsage(): Usage {
70
92
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
@@ -87,6 +109,54 @@ function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
87
109
  }
88
110
  }
89
111
 
112
+ const FOREGROUND_TIMEOUT_EXIT_CODE = 124;
113
+
114
+ function formatForegroundTimeoutMessage(timeoutMs: number | undefined): string {
115
+ return timeoutMs ? `Timed out after ${timeoutMs}ms.` : "Timed out.";
116
+ }
117
+
118
+ function createTimedOutResult(agent: string, task: string, options: RunSyncOptions): SingleResult {
119
+ const message = formatForegroundTimeoutMessage(options.timeoutMs);
120
+ return {
121
+ agent,
122
+ task,
123
+ exitCode: FOREGROUND_TIMEOUT_EXIT_CODE,
124
+ messages: [],
125
+ usage: emptyUsage(),
126
+ error: message,
127
+ finalOutput: message,
128
+ timedOut: true,
129
+ progress: {
130
+ index: options.index ?? 0,
131
+ agent,
132
+ status: "failed",
133
+ task,
134
+ recentTools: [],
135
+ recentOutput: [message],
136
+ toolCount: 0,
137
+ tokens: 0,
138
+ durationMs: 0,
139
+ lastActivityAt: Date.now(),
140
+ },
141
+ progressSummary: {
142
+ toolCount: 0,
143
+ tokens: 0,
144
+ durationMs: 0,
145
+ },
146
+ };
147
+ }
148
+
149
+ function stripAcceptanceReportsFromMessages(messages: Message[] | undefined): void {
150
+ for (const message of messages ?? []) {
151
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
152
+ for (const part of message.content) {
153
+ if (part.type === "text" && "text" in part && typeof part.text === "string") {
154
+ part.text = stripAcceptanceReport(part.text);
155
+ }
156
+ }
157
+ }
158
+ }
159
+
90
160
  function snapshotProgress(progress: AgentProgress): AgentProgress {
91
161
  return {
92
162
  ...progress,
@@ -133,6 +203,8 @@ async function runSingleAttempt(
133
203
  artifactPaths?: ArtifactPaths;
134
204
  attemptNotes: string[];
135
205
  outputSnapshot?: SingleOutputSnapshot;
206
+ originalTask?: string;
207
+ completionPolicy: CompletionPolicy;
136
208
  },
137
209
  ): Promise<SingleResult> {
138
210
  const modelArg = applyThinkingSuffix(model, agent.thinking);
@@ -162,11 +234,12 @@ async function runSingleAttempt(
162
234
  parentControlInbox: options.nestedRoute?.controlInbox,
163
235
  parentRootRunId: options.nestedRoute?.rootRunId,
164
236
  parentCapabilityToken: options.nestedRoute?.capabilityToken,
237
+ structuredOutput: options.structuredOutput,
165
238
  });
166
239
 
167
240
  const result: SingleResult = {
168
241
  agent: agent.name,
169
- task,
242
+ task: shared.originalTask ?? task,
170
243
  exitCode: 0,
171
244
  messages: [],
172
245
  usage: emptyUsage(),
@@ -176,6 +249,13 @@ async function runSingleAttempt(
176
249
  skillsWarning: shared.skillsWarning,
177
250
  };
178
251
  const startTime = Date.now();
252
+ if (options.structuredOutput) {
253
+ try {
254
+ if (existsSync(options.structuredOutput.outputPath)) unlinkSync(options.structuredOutput.outputPath);
255
+ } catch {
256
+ // Missing/stale structured-output files are handled after the child exits.
257
+ }
258
+ }
179
259
  const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
180
260
  let interruptedByControl = false;
181
261
  const allControlEvents: ControlEvent[] = [];
@@ -221,6 +301,12 @@ async function runSingleAttempt(
221
301
  let detached = false;
222
302
  let intercomStarted = false;
223
303
  let assistantError: string | undefined;
304
+ let timedOut = false;
305
+ let resourceLimited = false;
306
+ let timeoutTimer: NodeJS.Timeout | undefined;
307
+ let timeoutEscalationTimer: NodeJS.Timeout | undefined;
308
+ let resourceLimitTimer: NodeJS.Timeout | undefined;
309
+ let resourceLimitEscalationTimer: NodeJS.Timeout | undefined;
224
310
  let removeAbortListener: (() => void) | undefined;
225
311
  let removeInterruptListener: (() => void) | undefined;
226
312
  let activityTimer: NodeJS.Timeout | undefined;
@@ -292,6 +378,22 @@ async function runSingleAttempt(
292
378
  settled = true;
293
379
  clearFinalDrainTimers();
294
380
  clearStdioGuard();
381
+ if (timeoutTimer) {
382
+ clearTimeout(timeoutTimer);
383
+ timeoutTimer = undefined;
384
+ }
385
+ if (timeoutEscalationTimer) {
386
+ clearTimeout(timeoutEscalationTimer);
387
+ timeoutEscalationTimer = undefined;
388
+ }
389
+ if (resourceLimitTimer) {
390
+ clearTimeout(resourceLimitTimer);
391
+ resourceLimitTimer = undefined;
392
+ }
393
+ if (resourceLimitEscalationTimer) {
394
+ clearTimeout(resourceLimitEscalationTimer);
395
+ resourceLimitEscalationTimer = undefined;
396
+ }
295
397
  if (activityTimer) {
296
398
  clearInterval(activityTimer);
297
399
  activityTimer = undefined;
@@ -386,6 +488,26 @@ async function runSingleAttempt(
386
488
  };
387
489
 
388
490
 
491
+ const triggerResourceLimit = (kind: "maxExecutionTimeMs" | "maxTokens", limit: number, observed?: number) => {
492
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
493
+ resourceLimited = true;
494
+ const message = formatResourceLimitExceeded({ agent: agent.name, kind, limit, observed });
495
+ result.resourceLimitExceeded = { kind, limit, ...(observed !== undefined ? { observed } : {}), message };
496
+ result.error = message;
497
+ result.finalOutput = message;
498
+ progress.status = "failed";
499
+ progress.durationMs = Date.now() - startTime;
500
+ appendRecentOutput(progress, [message]);
501
+ progress.activityState = undefined;
502
+ fireUpdate();
503
+ trySignalChild(proc, "SIGINT");
504
+ resourceLimitEscalationTimer = setTimeout(() => {
505
+ if (settled || processClosed || detached) return;
506
+ trySignalChild(proc, "SIGTERM");
507
+ }, 1000);
508
+ resourceLimitEscalationTimer.unref?.();
509
+ };
510
+
389
511
  const emitUpdateSnapshot = (text: string) => {
390
512
  if (!options.onUpdate || processClosed) return;
391
513
  const progressSnapshot = snapshotProgress(progress);
@@ -470,6 +592,9 @@ async function runSingleAttempt(
470
592
  result.usage.cacheWrite += u.cacheWrite || 0;
471
593
  result.usage.cost += u.cost?.total || 0;
472
594
  progress.tokens = result.usage.input + result.usage.output;
595
+ if (options.maxTokens !== undefined && progress.tokens >= options.maxTokens) {
596
+ triggerResourceLimit("maxTokens", options.maxTokens, progress.tokens);
597
+ }
473
598
  }
474
599
  if (!result.model && evt.message.model) result.model = evt.message.model;
475
600
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
@@ -598,9 +723,45 @@ async function runSingleAttempt(
598
723
  }
599
724
  }
600
725
 
726
+ if (options.timeoutAt !== undefined) {
727
+ const triggerTimeout = () => {
728
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
729
+ timedOut = true;
730
+ const message = formatForegroundTimeoutMessage(options.timeoutMs);
731
+ result.timedOut = true;
732
+ result.error = message;
733
+ result.finalOutput = message;
734
+ progress.status = "failed";
735
+ progress.durationMs = Date.now() - startTime;
736
+ appendRecentOutput(progress, [message]);
737
+ progress.activityState = undefined;
738
+ fireUpdate();
739
+ trySignalChild(proc, "SIGINT");
740
+ timeoutEscalationTimer = setTimeout(() => {
741
+ if (settled || processClosed || detached) return;
742
+ trySignalChild(proc, "SIGTERM");
743
+ }, 1000);
744
+ timeoutEscalationTimer.unref?.();
745
+ };
746
+ const delay = options.timeoutAt - Date.now();
747
+ if (delay <= 0) triggerTimeout();
748
+ else {
749
+ timeoutTimer = setTimeout(triggerTimeout, delay);
750
+ timeoutTimer.unref?.();
751
+ }
752
+ }
753
+
754
+ if (options.maxExecutionTimeMs !== undefined) {
755
+ const maxExecutionTimeMs = options.maxExecutionTimeMs;
756
+ resourceLimitTimer = setTimeout(() => {
757
+ triggerResourceLimit("maxExecutionTimeMs", maxExecutionTimeMs);
758
+ }, maxExecutionTimeMs);
759
+ resourceLimitTimer.unref?.();
760
+ }
761
+
601
762
  if (options.interruptSignal) {
602
763
  const interrupt = () => {
603
- if (processClosed || detached || settled) return;
764
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
604
765
  interruptedByControl = true;
605
766
  progress.status = "running";
606
767
  progress.durationMs = Date.now() - startTime;
@@ -622,6 +783,40 @@ async function runSingleAttempt(
622
783
  }
623
784
  });
624
785
  result.exitCode = exitCode;
786
+ if (result.resourceLimitExceeded) {
787
+ result.exitCode = 1;
788
+ result.error = result.error ?? result.resourceLimitExceeded.message;
789
+ result.finalOutput = result.finalOutput || result.error;
790
+ if (result.progress) {
791
+ result.progress.status = "failed";
792
+ result.progress.activityState = undefined;
793
+ result.progress.durationMs = Date.now() - startTime;
794
+ }
795
+ result.progressSummary = {
796
+ toolCount: progress.toolCount,
797
+ tokens: progress.tokens,
798
+ durationMs: result.progress?.durationMs ?? Date.now() - startTime,
799
+ };
800
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
801
+ return result;
802
+ }
803
+ if (result.timedOut) {
804
+ result.exitCode = FOREGROUND_TIMEOUT_EXIT_CODE;
805
+ result.error = result.error ?? formatForegroundTimeoutMessage(options.timeoutMs);
806
+ result.finalOutput = result.finalOutput || result.error;
807
+ if (result.progress) {
808
+ result.progress.status = "failed";
809
+ result.progress.activityState = undefined;
810
+ result.progress.durationMs = Date.now() - startTime;
811
+ }
812
+ result.progressSummary = {
813
+ toolCount: progress.toolCount,
814
+ tokens: progress.tokens,
815
+ durationMs: result.progress?.durationMs ?? Date.now() - startTime,
816
+ };
817
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
818
+ return result;
819
+ }
625
820
  if (interruptedByControl) {
626
821
  result.exitCode = 0;
627
822
  result.interrupted = true;
@@ -655,6 +850,21 @@ async function runSingleAttempt(
655
850
  : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
656
851
  }
657
852
  }
853
+ if (options.structuredOutput && result.exitCode === 0 && !result.error) {
854
+ const structured = readStructuredOutput({
855
+ schema: options.structuredOutput.schema,
856
+ schemaPath: options.structuredOutput.schemaPath,
857
+ outputPath: options.structuredOutput.outputPath,
858
+ });
859
+ result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
860
+ result.structuredOutputPath = options.structuredOutput.outputPath;
861
+ if (structured.error) {
862
+ result.exitCode = 1;
863
+ result.error = structured.error;
864
+ } else {
865
+ result.structuredOutput = structured.value;
866
+ }
867
+ }
658
868
 
659
869
  progress.status = result.exitCode === 0 ? "completed" : "failed";
660
870
  progress.durationMs = Date.now() - startTime;
@@ -671,17 +881,19 @@ async function runSingleAttempt(
671
881
  durationMs: progress.durationMs,
672
882
  };
673
883
 
674
- let fullOutput = getFinalOutput(result.messages);
675
- const completionGuard = result.exitCode === 0 && !result.error && agent.completionGuard !== false
884
+ const acceptanceOutput = getFinalOutput(result.messages);
885
+ let fullOutput = stripAcceptanceReport(acceptanceOutput);
886
+ const completionGuard = result.exitCode === 0 && !result.error && shared.completionPolicy === "mutation-guard"
676
887
  ? evaluateCompletionMutationGuard({
677
888
  agent: agent.name,
678
- task,
889
+ task: shared.originalTask ?? task,
679
890
  messages: result.messages,
680
891
  tools: agent.tools,
681
892
  mcpDirectTools: agent.mcpDirectTools,
682
893
  })
683
894
  : undefined;
684
- if (completionGuard?.triggered && !observedMutationAttempt) {
895
+ const completionGuardTriggered = completionGuard?.triggered === true && !observedMutationAttempt;
896
+ if (completionGuardTriggered) {
685
897
  result.exitCode = 1;
686
898
  result.error = "Subagent completed without making edits for an implementation task.\nIt appears to have returned planning or scratchpad output instead of applying changes.";
687
899
  progress.status = "failed";
@@ -699,7 +911,7 @@ async function runSingleAttempt(
699
911
  }
700
912
  if (options.outputPath && result.exitCode === 0) {
701
913
  const resolvedOutput = resolveSingleOutput(options.outputPath, fullOutput, shared.outputSnapshot);
702
- fullOutput = resolvedOutput.fullOutput;
914
+ fullOutput = stripAcceptanceReport(resolvedOutput.fullOutput);
703
915
  result.savedOutputPath = resolvedOutput.savedPath;
704
916
  result.outputSaveError = resolvedOutput.saveError;
705
917
  if (resolvedOutput.savedPath) {
@@ -707,6 +919,7 @@ async function runSingleAttempt(
707
919
  }
708
920
  }
709
921
  artifactOutputByResult.set(result, fullOutput);
922
+ acceptanceOutputByResult.set(result, acceptanceOutput);
710
923
  result.outputMode = options.outputMode ?? "inline";
711
924
  result.finalOutput = options.outputMode === "file-only" && result.savedOutputPath && result.outputReference
712
925
  ? result.outputReference.message
@@ -729,6 +942,99 @@ async function runSingleAttempt(
729
942
  return result;
730
943
  }
731
944
 
945
+ async function runAcceptanceFinalizationLoop(input: {
946
+ runtimeCwd: string;
947
+ agent: AgentConfig;
948
+ result: SingleResult;
949
+ initialLedger: AcceptanceLedger;
950
+ initialOutput: string;
951
+ acceptance: ResolvedAcceptanceConfig;
952
+ options: RunSyncOptions;
953
+ systemPrompt: string;
954
+ resolvedSkillNames?: string[];
955
+ skillsWarning?: string;
956
+ }): Promise<AcceptanceLedger> {
957
+ const sessionFile = input.result.sessionFile ?? input.options.sessionFile;
958
+ const maxTurns = input.acceptance.finalization.maxTurns;
959
+ const turns: AcceptanceFinalizationTurn[] = [];
960
+ if (!sessionFile) {
961
+ const message = "Acceptance finalization requires a session file for same-session continuation.";
962
+ turns.push(createFinalizationProcessFailureTurn({ turn: 1, prompt: "", message }));
963
+ return buildFinalizationProcessFailureLedger({ initialLedger: input.initialLedger, turns, maxTurns, message });
964
+ }
965
+
966
+ const selfReviewAcceptance = acceptanceSelfReviewConfig(input.acceptance);
967
+ let previousFailure = acceptanceFailureMessage(input.initialLedger);
968
+ let authoritativeLedger = input.initialLedger;
969
+ for (let turn = 1; turn <= maxTurns; turn++) {
970
+ const prompt = formatAcceptanceFinalizationPrompt({
971
+ acceptance: input.acceptance,
972
+ initialOutput: input.initialOutput,
973
+ initialLedger: input.initialLedger,
974
+ turn,
975
+ maxTurns,
976
+ ...(previousFailure ? { previousFailure } : {}),
977
+ });
978
+ const finalizationOptions: RunSyncOptions = { ...input.options, sessionFile, outputMode: "inline" };
979
+ delete finalizationOptions.sessionDir;
980
+ delete finalizationOptions.outputPath;
981
+ delete finalizationOptions.structuredOutput;
982
+ delete finalizationOptions.onUpdate;
983
+ finalizationOptions.allowIntercomDetach = false;
984
+ const finalizationResult = await runSingleAttempt(
985
+ input.runtimeCwd,
986
+ input.agent,
987
+ prompt,
988
+ input.result.model,
989
+ finalizationOptions,
990
+ {
991
+ sessionEnabled: true,
992
+ systemPrompt: input.systemPrompt,
993
+ resolvedSkillNames: input.resolvedSkillNames,
994
+ skillsWarning: input.skillsWarning,
995
+ attemptNotes: [],
996
+ originalTask: prompt,
997
+ completionPolicy: "acceptance-contract",
998
+ },
999
+ );
1000
+ sumUsage(input.result.usage, finalizationResult.usage);
1001
+ input.result.progressSummary = {
1002
+ toolCount: (input.result.progressSummary?.toolCount ?? 0) + (finalizationResult.progressSummary?.toolCount ?? 0),
1003
+ tokens: input.result.usage.input + input.result.usage.output,
1004
+ durationMs: (input.result.progressSummary?.durationMs ?? 0) + (finalizationResult.progressSummary?.durationMs ?? 0),
1005
+ };
1006
+ if (finalizationResult.controlEvents?.length) {
1007
+ input.result.controlEvents = [...(input.result.controlEvents ?? []), ...finalizationResult.controlEvents];
1008
+ }
1009
+ const rawOutput = acceptanceOutputByResult.get(finalizationResult) ?? getFinalOutput(finalizationResult.messages) ?? finalizationResult.finalOutput ?? "";
1010
+ if (finalizationResult.exitCode !== 0 || finalizationResult.error || finalizationResult.detached || finalizationResult.interrupted) {
1011
+ const message = finalizationResult.error ?? "Acceptance finalization turn did not complete successfully.";
1012
+ turns.push(createFinalizationProcessFailureTurn({ turn, prompt, rawOutput, message }));
1013
+ return buildFinalizationProcessFailureLedger({ initialLedger: input.initialLedger, turns, maxTurns, message });
1014
+ }
1015
+ const selfReviewLedger = await evaluateAcceptance({
1016
+ acceptance: selfReviewAcceptance,
1017
+ output: rawOutput,
1018
+ cwd: input.options.cwd ?? input.runtimeCwd,
1019
+ });
1020
+ authoritativeLedger = selfReviewLedger;
1021
+ turns.push(createFinalizationTurn({ turn, prompt, rawOutput, ledger: selfReviewLedger }));
1022
+ const failure = acceptanceFailureMessage(selfReviewLedger);
1023
+ if (!failure) {
1024
+ authoritativeLedger = input.acceptance === selfReviewAcceptance
1025
+ ? selfReviewLedger
1026
+ : await evaluateAcceptance({
1027
+ acceptance: input.acceptance,
1028
+ output: rawOutput,
1029
+ cwd: input.options.cwd ?? input.runtimeCwd,
1030
+ });
1031
+ return attachFinalizationToLedger({ initialLedger: input.initialLedger, authoritativeLedger, turns, status: "completed", maxTurns });
1032
+ }
1033
+ previousFailure = failure;
1034
+ }
1035
+ return attachFinalizationToLedger({ initialLedger: input.initialLedger, authoritativeLedger, turns, status: "failed", maxTurns });
1036
+ }
1037
+
732
1038
  /**
733
1039
  * Run a subagent synchronously (blocking until complete)
734
1040
  */
@@ -762,8 +1068,31 @@ export async function runSync(
762
1068
  error: outputModeValidationError,
763
1069
  };
764
1070
  }
1071
+ if (options.timeoutAt !== undefined && Date.now() >= options.timeoutAt) {
1072
+ return createTimedOutResult(agentName, task, options);
1073
+ }
1074
+ const effectiveOptions: RunSyncOptions = {
1075
+ ...options,
1076
+ maxExecutionTimeMs: options.maxExecutionTimeMs ?? agent.maxExecutionTimeMs,
1077
+ maxTokens: options.maxTokens ?? agent.maxTokens,
1078
+ };
765
1079
 
766
- const shareEnabled = options.share === true;
1080
+ const shareEnabled = effectiveOptions.share === true;
1081
+ const effectiveAcceptance = resolveEffectiveAcceptance({
1082
+ explicit: options.acceptance,
1083
+ agentName,
1084
+ task,
1085
+ mode: options.acceptanceContext?.mode ?? "single",
1086
+ async: options.acceptanceContext?.async,
1087
+ dynamic: options.acceptanceContext?.dynamic,
1088
+ dynamicGroup: options.acceptanceContext?.dynamicGroup,
1089
+ });
1090
+ if (shouldRunAcceptanceFinalization(effectiveAcceptance) && !options.sessionFile) {
1091
+ const sessionDir = options.sessionDir ?? mkdtempSync(path.join(os.tmpdir(), "pi-subagent-finalization-"));
1092
+ options.sessionFile = path.join(sessionDir, "session.jsonl");
1093
+ }
1094
+ const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance);
1095
+ const taskWithAcceptance = acceptancePrompt ? `${task}\n${acceptancePrompt}` : task;
767
1096
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
768
1097
  const skillNames = options.skills ?? agent.skills ?? [];
769
1098
  const skillCwd = options.cwd ?? runtimeCwd;
@@ -799,13 +1128,13 @@ export async function runSync(
799
1128
 
800
1129
  let artifactPathsResult: ArtifactPaths | undefined;
801
1130
  let jsonlPath: string | undefined;
802
- if (options.artifactsDir && options.artifactConfig?.enabled !== false) {
803
- artifactPathsResult = getArtifactPaths(options.artifactsDir, options.runId, agentName, options.index);
804
- ensureArtifactsDir(options.artifactsDir);
805
- if (options.artifactConfig?.includeInput !== false) {
806
- writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`);
1131
+ if (effectiveOptions.artifactsDir && effectiveOptions.artifactConfig?.enabled !== false) {
1132
+ artifactPathsResult = getArtifactPaths(effectiveOptions.artifactsDir, effectiveOptions.runId, agentName, effectiveOptions.index);
1133
+ ensureArtifactsDir(effectiveOptions.artifactsDir);
1134
+ if (effectiveOptions.artifactConfig?.includeInput !== false) {
1135
+ writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${taskWithAcceptance}`);
807
1136
  }
808
- if (options.artifactConfig?.includeJsonl !== false) {
1137
+ if (effectiveOptions.artifactConfig?.includeJsonl !== false) {
809
1138
  jsonlPath = artifactPathsResult.jsonlPath;
810
1139
  }
811
1140
  }
@@ -815,8 +1144,8 @@ export async function runSync(
815
1144
  for (let i = 0; i < modelsToTry.length; i++) {
816
1145
  const candidate = modelsToTry[i];
817
1146
  if (candidate) attemptedModels.push(candidate);
818
- const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
819
- const result = await runSingleAttempt(runtimeCwd, agent, task, candidate, options, {
1147
+ const outputSnapshot = captureSingleOutputSnapshot(effectiveOptions.outputPath);
1148
+ const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, effectiveOptions, {
820
1149
  sessionEnabled,
821
1150
  systemPrompt,
822
1151
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
@@ -825,6 +1154,15 @@ export async function runSync(
825
1154
  artifactPaths: artifactPathsResult,
826
1155
  attemptNotes,
827
1156
  outputSnapshot,
1157
+ originalTask: task,
1158
+ completionPolicy: resolveCompletionPolicy({
1159
+ agent: agent.name,
1160
+ task,
1161
+ completionGuardEnabled: agent.completionGuard !== false,
1162
+ usesAcceptanceContract: effectiveAcceptance.explicit,
1163
+ tools: agent.tools,
1164
+ mcpDirectTools: agent.mcpDirectTools,
1165
+ }),
828
1166
  });
829
1167
  lastResult = result;
830
1168
  sumUsage(aggregateUsage, result.usage);
@@ -842,7 +1180,7 @@ export async function runSync(
842
1180
  if (attemptSucceeded) {
843
1181
  break;
844
1182
  }
845
- if (!isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
1183
+ if (result.timedOut || result.resourceLimitExceeded || !isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
846
1184
  break;
847
1185
  }
848
1186
  attemptNotes.push(formatModelAttemptNote(attempt, modelsToTry[i + 1]));
@@ -914,5 +1252,40 @@ export async function runSync(
914
1252
  if (sessionFile) result.sessionFile = sessionFile;
915
1253
  }
916
1254
 
1255
+ const initialAcceptanceOutput = acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "";
1256
+ const acceptanceForInitialReport = shouldRunAcceptanceFinalization(effectiveAcceptance)
1257
+ ? acceptanceSelfReviewConfig(effectiveAcceptance)
1258
+ : effectiveAcceptance;
1259
+ const initialAcceptance = await evaluateAcceptance({
1260
+ acceptance: acceptanceForInitialReport,
1261
+ output: initialAcceptanceOutput,
1262
+ cwd: options.cwd ?? runtimeCwd,
1263
+ });
1264
+ result.acceptance = initialAcceptance;
1265
+ if (shouldRunAcceptanceFinalization(effectiveAcceptance) && result.exitCode === 0 && !result.detached && !result.interrupted) {
1266
+ result.acceptance = await runAcceptanceFinalizationLoop({
1267
+ runtimeCwd,
1268
+ agent,
1269
+ result,
1270
+ initialLedger: initialAcceptance,
1271
+ initialOutput: initialAcceptanceOutput,
1272
+ acceptance: effectiveAcceptance,
1273
+ options,
1274
+ systemPrompt,
1275
+ resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
1276
+ ...(missingSkills.length > 0 ? { skillsWarning: `Skills not found: ${missingSkills.join(", ")}` } : {}),
1277
+ });
1278
+ }
1279
+ const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
1280
+ stripAcceptanceReportsFromMessages(result.messages);
1281
+ if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted) {
1282
+ result.exitCode = 1;
1283
+ result.error = result.error ? `${result.error}\n${acceptanceFailure}` : acceptanceFailure;
1284
+ if (result.progress) {
1285
+ result.progress.status = "failed";
1286
+ result.progress.error = result.error;
1287
+ }
1288
+ }
1289
+
917
1290
  return result;
918
1291
  }