pi-subagents 0.25.0 → 0.27.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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +129 -17
  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 +32 -17
  7. package/src/agents/agent-management.ts +57 -15
  8. package/src/agents/agent-serializer.ts +3 -2
  9. package/src/agents/agents.ts +47 -16
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +1 -0
  12. package/src/extension/index.ts +1 -0
  13. package/src/extension/schemas.ts +138 -5
  14. package/src/runs/background/async-execution.ts +84 -6
  15. package/src/runs/background/async-status.ts +11 -1
  16. package/src/runs/background/run-status.ts +10 -1
  17. package/src/runs/background/subagent-runner.ts +600 -31
  18. package/src/runs/foreground/chain-execution.ts +325 -118
  19. package/src/runs/foreground/execution.ts +222 -10
  20. package/src/runs/foreground/subagent-executor.ts +67 -0
  21. package/src/runs/shared/acceptance-contract.ts +291 -0
  22. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  23. package/src/runs/shared/acceptance-finalization.ts +161 -0
  24. package/src/runs/shared/acceptance-reports.ts +127 -0
  25. package/src/runs/shared/acceptance.ts +22 -0
  26. package/src/runs/shared/chain-outputs.ts +101 -0
  27. package/src/runs/shared/completion-guard.ts +26 -3
  28. package/src/runs/shared/dynamic-fanout.ts +293 -0
  29. package/src/runs/shared/parallel-utils.ts +31 -1
  30. package/src/runs/shared/pi-args.ts +11 -0
  31. package/src/runs/shared/structured-output.ts +77 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  33. package/src/runs/shared/workflow-graph.ts +206 -0
  34. package/src/shared/formatters.ts +2 -2
  35. package/src/shared/settings.ts +53 -4
  36. package/src/shared/types.ts +250 -0
  37. package/src/slash/slash-commands.ts +41 -3
  38. package/src/tui/render.ts +162 -34
@@ -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,
@@ -41,11 +46,12 @@ import {
41
46
  extractTextFromContent,
42
47
  } from "../../shared/utils.ts";
43
48
  import { buildSkillInjection, resolveSkillsWithFallback } from "../../agents/skills.ts";
44
- import { evaluateCompletionMutationGuard } from "../shared/completion-guard.ts";
49
+ import { evaluateCompletionMutationGuard, resolveCompletionPolicy, type CompletionPolicy } from "../shared/completion-guard.ts";
45
50
  import { getPiSpawnCommand } from "../shared/pi-spawn.ts";
46
51
  import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
47
52
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
48
53
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
54
+ import { readStructuredOutput } from "../shared/structured-output.ts";
49
55
  import { captureSingleOutputSnapshot, formatSavedOutputReference, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
50
56
  import {
51
57
  buildModelCandidates,
@@ -63,8 +69,23 @@ import {
63
69
  shouldEscalateMutatingFailures,
64
70
  summarizeRecentMutatingFailures,
65
71
  } from "../shared/long-running-guard.ts";
72
+ import {
73
+ acceptanceFailureMessage,
74
+ acceptanceSelfReviewConfig,
75
+ attachFinalizationToLedger,
76
+ buildFinalizationProcessFailureLedger,
77
+ createFinalizationProcessFailureTurn,
78
+ createFinalizationTurn,
79
+ evaluateAcceptance,
80
+ formatAcceptanceFinalizationPrompt,
81
+ formatAcceptancePrompt,
82
+ resolveEffectiveAcceptance,
83
+ shouldRunAcceptanceFinalization,
84
+ stripAcceptanceReport,
85
+ } from "../shared/acceptance.ts";
66
86
 
67
87
  const artifactOutputByResult = new WeakMap<SingleResult, string>();
88
+ const acceptanceOutputByResult = new WeakMap<SingleResult, string>();
68
89
 
69
90
  function emptyUsage(): Usage {
70
91
  return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
@@ -87,6 +108,17 @@ function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
87
108
  }
88
109
  }
89
110
 
111
+ function stripAcceptanceReportsFromMessages(messages: Message[] | undefined): void {
112
+ for (const message of messages ?? []) {
113
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
114
+ for (const part of message.content) {
115
+ if (part.type === "text" && "text" in part && typeof part.text === "string") {
116
+ part.text = stripAcceptanceReport(part.text);
117
+ }
118
+ }
119
+ }
120
+ }
121
+
90
122
  function snapshotProgress(progress: AgentProgress): AgentProgress {
91
123
  return {
92
124
  ...progress,
@@ -133,6 +165,8 @@ async function runSingleAttempt(
133
165
  artifactPaths?: ArtifactPaths;
134
166
  attemptNotes: string[];
135
167
  outputSnapshot?: SingleOutputSnapshot;
168
+ originalTask?: string;
169
+ completionPolicy: CompletionPolicy;
136
170
  },
137
171
  ): Promise<SingleResult> {
138
172
  const modelArg = applyThinkingSuffix(model, agent.thinking);
@@ -162,11 +196,12 @@ async function runSingleAttempt(
162
196
  parentControlInbox: options.nestedRoute?.controlInbox,
163
197
  parentRootRunId: options.nestedRoute?.rootRunId,
164
198
  parentCapabilityToken: options.nestedRoute?.capabilityToken,
199
+ structuredOutput: options.structuredOutput,
165
200
  });
166
201
 
167
202
  const result: SingleResult = {
168
203
  agent: agent.name,
169
- task,
204
+ task: shared.originalTask ?? task,
170
205
  exitCode: 0,
171
206
  messages: [],
172
207
  usage: emptyUsage(),
@@ -176,6 +211,13 @@ async function runSingleAttempt(
176
211
  skillsWarning: shared.skillsWarning,
177
212
  };
178
213
  const startTime = Date.now();
214
+ if (options.structuredOutput) {
215
+ try {
216
+ if (existsSync(options.structuredOutput.outputPath)) unlinkSync(options.structuredOutput.outputPath);
217
+ } catch {
218
+ // Missing/stale structured-output files are handled after the child exits.
219
+ }
220
+ }
179
221
  const controlConfig = options.controlConfig ?? DEFAULT_CONTROL_CONFIG;
180
222
  let interruptedByControl = false;
181
223
  const allControlEvents: ControlEvent[] = [];
@@ -655,6 +697,21 @@ async function runSingleAttempt(
655
697
  : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`;
656
698
  }
657
699
  }
700
+ if (options.structuredOutput && result.exitCode === 0 && !result.error) {
701
+ const structured = readStructuredOutput({
702
+ schema: options.structuredOutput.schema,
703
+ schemaPath: options.structuredOutput.schemaPath,
704
+ outputPath: options.structuredOutput.outputPath,
705
+ });
706
+ result.structuredOutputSchemaPath = options.structuredOutput.schemaPath;
707
+ result.structuredOutputPath = options.structuredOutput.outputPath;
708
+ if (structured.error) {
709
+ result.exitCode = 1;
710
+ result.error = structured.error;
711
+ } else {
712
+ result.structuredOutput = structured.value;
713
+ }
714
+ }
658
715
 
659
716
  progress.status = result.exitCode === 0 ? "completed" : "failed";
660
717
  progress.durationMs = Date.now() - startTime;
@@ -671,17 +728,19 @@ async function runSingleAttempt(
671
728
  durationMs: progress.durationMs,
672
729
  };
673
730
 
674
- let fullOutput = getFinalOutput(result.messages);
675
- const completionGuard = result.exitCode === 0 && !result.error && agent.completionGuard !== false
731
+ const acceptanceOutput = getFinalOutput(result.messages);
732
+ let fullOutput = stripAcceptanceReport(acceptanceOutput);
733
+ const completionGuard = result.exitCode === 0 && !result.error && shared.completionPolicy === "mutation-guard"
676
734
  ? evaluateCompletionMutationGuard({
677
735
  agent: agent.name,
678
- task,
736
+ task: shared.originalTask ?? task,
679
737
  messages: result.messages,
680
738
  tools: agent.tools,
681
739
  mcpDirectTools: agent.mcpDirectTools,
682
740
  })
683
741
  : undefined;
684
- if (completionGuard?.triggered && !observedMutationAttempt) {
742
+ const completionGuardTriggered = completionGuard?.triggered === true && !observedMutationAttempt;
743
+ if (completionGuardTriggered) {
685
744
  result.exitCode = 1;
686
745
  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
746
  progress.status = "failed";
@@ -699,7 +758,7 @@ async function runSingleAttempt(
699
758
  }
700
759
  if (options.outputPath && result.exitCode === 0) {
701
760
  const resolvedOutput = resolveSingleOutput(options.outputPath, fullOutput, shared.outputSnapshot);
702
- fullOutput = resolvedOutput.fullOutput;
761
+ fullOutput = stripAcceptanceReport(resolvedOutput.fullOutput);
703
762
  result.savedOutputPath = resolvedOutput.savedPath;
704
763
  result.outputSaveError = resolvedOutput.saveError;
705
764
  if (resolvedOutput.savedPath) {
@@ -707,6 +766,7 @@ async function runSingleAttempt(
707
766
  }
708
767
  }
709
768
  artifactOutputByResult.set(result, fullOutput);
769
+ acceptanceOutputByResult.set(result, acceptanceOutput);
710
770
  result.outputMode = options.outputMode ?? "inline";
711
771
  result.finalOutput = options.outputMode === "file-only" && result.savedOutputPath && result.outputReference
712
772
  ? result.outputReference.message
@@ -729,6 +789,99 @@ async function runSingleAttempt(
729
789
  return result;
730
790
  }
731
791
 
792
+ async function runAcceptanceFinalizationLoop(input: {
793
+ runtimeCwd: string;
794
+ agent: AgentConfig;
795
+ result: SingleResult;
796
+ initialLedger: AcceptanceLedger;
797
+ initialOutput: string;
798
+ acceptance: ResolvedAcceptanceConfig;
799
+ options: RunSyncOptions;
800
+ systemPrompt: string;
801
+ resolvedSkillNames?: string[];
802
+ skillsWarning?: string;
803
+ }): Promise<AcceptanceLedger> {
804
+ const sessionFile = input.result.sessionFile ?? input.options.sessionFile;
805
+ const maxTurns = input.acceptance.finalization.maxTurns;
806
+ const turns: AcceptanceFinalizationTurn[] = [];
807
+ if (!sessionFile) {
808
+ const message = "Acceptance finalization requires a session file for same-session continuation.";
809
+ turns.push(createFinalizationProcessFailureTurn({ turn: 1, prompt: "", message }));
810
+ return buildFinalizationProcessFailureLedger({ initialLedger: input.initialLedger, turns, maxTurns, message });
811
+ }
812
+
813
+ const selfReviewAcceptance = acceptanceSelfReviewConfig(input.acceptance);
814
+ let previousFailure = acceptanceFailureMessage(input.initialLedger);
815
+ let authoritativeLedger = input.initialLedger;
816
+ for (let turn = 1; turn <= maxTurns; turn++) {
817
+ const prompt = formatAcceptanceFinalizationPrompt({
818
+ acceptance: input.acceptance,
819
+ initialOutput: input.initialOutput,
820
+ initialLedger: input.initialLedger,
821
+ turn,
822
+ maxTurns,
823
+ ...(previousFailure ? { previousFailure } : {}),
824
+ });
825
+ const finalizationOptions: RunSyncOptions = { ...input.options, sessionFile, outputMode: "inline" };
826
+ delete finalizationOptions.sessionDir;
827
+ delete finalizationOptions.outputPath;
828
+ delete finalizationOptions.structuredOutput;
829
+ delete finalizationOptions.onUpdate;
830
+ finalizationOptions.allowIntercomDetach = false;
831
+ const finalizationResult = await runSingleAttempt(
832
+ input.runtimeCwd,
833
+ input.agent,
834
+ prompt,
835
+ input.result.model,
836
+ finalizationOptions,
837
+ {
838
+ sessionEnabled: true,
839
+ systemPrompt: input.systemPrompt,
840
+ resolvedSkillNames: input.resolvedSkillNames,
841
+ skillsWarning: input.skillsWarning,
842
+ attemptNotes: [],
843
+ originalTask: prompt,
844
+ completionPolicy: "acceptance-contract",
845
+ },
846
+ );
847
+ sumUsage(input.result.usage, finalizationResult.usage);
848
+ input.result.progressSummary = {
849
+ toolCount: (input.result.progressSummary?.toolCount ?? 0) + (finalizationResult.progressSummary?.toolCount ?? 0),
850
+ tokens: input.result.usage.input + input.result.usage.output,
851
+ durationMs: (input.result.progressSummary?.durationMs ?? 0) + (finalizationResult.progressSummary?.durationMs ?? 0),
852
+ };
853
+ if (finalizationResult.controlEvents?.length) {
854
+ input.result.controlEvents = [...(input.result.controlEvents ?? []), ...finalizationResult.controlEvents];
855
+ }
856
+ const rawOutput = acceptanceOutputByResult.get(finalizationResult) ?? getFinalOutput(finalizationResult.messages) ?? finalizationResult.finalOutput ?? "";
857
+ if (finalizationResult.exitCode !== 0 || finalizationResult.error || finalizationResult.detached || finalizationResult.interrupted) {
858
+ const message = finalizationResult.error ?? "Acceptance finalization turn did not complete successfully.";
859
+ turns.push(createFinalizationProcessFailureTurn({ turn, prompt, rawOutput, message }));
860
+ return buildFinalizationProcessFailureLedger({ initialLedger: input.initialLedger, turns, maxTurns, message });
861
+ }
862
+ const selfReviewLedger = await evaluateAcceptance({
863
+ acceptance: selfReviewAcceptance,
864
+ output: rawOutput,
865
+ cwd: input.options.cwd ?? input.runtimeCwd,
866
+ });
867
+ authoritativeLedger = selfReviewLedger;
868
+ turns.push(createFinalizationTurn({ turn, prompt, rawOutput, ledger: selfReviewLedger }));
869
+ const failure = acceptanceFailureMessage(selfReviewLedger);
870
+ if (!failure) {
871
+ authoritativeLedger = input.acceptance === selfReviewAcceptance
872
+ ? selfReviewLedger
873
+ : await evaluateAcceptance({
874
+ acceptance: input.acceptance,
875
+ output: rawOutput,
876
+ cwd: input.options.cwd ?? input.runtimeCwd,
877
+ });
878
+ return attachFinalizationToLedger({ initialLedger: input.initialLedger, authoritativeLedger, turns, status: "completed", maxTurns });
879
+ }
880
+ previousFailure = failure;
881
+ }
882
+ return attachFinalizationToLedger({ initialLedger: input.initialLedger, authoritativeLedger, turns, status: "failed", maxTurns });
883
+ }
884
+
732
885
  /**
733
886
  * Run a subagent synchronously (blocking until complete)
734
887
  */
@@ -764,6 +917,21 @@ export async function runSync(
764
917
  }
765
918
 
766
919
  const shareEnabled = options.share === true;
920
+ const effectiveAcceptance = resolveEffectiveAcceptance({
921
+ explicit: options.acceptance,
922
+ agentName,
923
+ task,
924
+ mode: options.acceptanceContext?.mode ?? "single",
925
+ async: options.acceptanceContext?.async,
926
+ dynamic: options.acceptanceContext?.dynamic,
927
+ dynamicGroup: options.acceptanceContext?.dynamicGroup,
928
+ });
929
+ if (shouldRunAcceptanceFinalization(effectiveAcceptance) && !options.sessionFile) {
930
+ const sessionDir = options.sessionDir ?? mkdtempSync(path.join(os.tmpdir(), "pi-subagent-finalization-"));
931
+ options.sessionFile = path.join(sessionDir, "session.jsonl");
932
+ }
933
+ const acceptancePrompt = formatAcceptancePrompt(effectiveAcceptance);
934
+ const taskWithAcceptance = acceptancePrompt ? `${task}\n${acceptancePrompt}` : task;
767
935
  const sessionEnabled = Boolean(options.sessionFile || options.sessionDir) || shareEnabled;
768
936
  const skillNames = options.skills ?? agent.skills ?? [];
769
937
  const skillCwd = options.cwd ?? runtimeCwd;
@@ -803,7 +971,7 @@ export async function runSync(
803
971
  artifactPathsResult = getArtifactPaths(options.artifactsDir, options.runId, agentName, options.index);
804
972
  ensureArtifactsDir(options.artifactsDir);
805
973
  if (options.artifactConfig?.includeInput !== false) {
806
- writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`);
974
+ writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${taskWithAcceptance}`);
807
975
  }
808
976
  if (options.artifactConfig?.includeJsonl !== false) {
809
977
  jsonlPath = artifactPathsResult.jsonlPath;
@@ -816,7 +984,7 @@ export async function runSync(
816
984
  const candidate = modelsToTry[i];
817
985
  if (candidate) attemptedModels.push(candidate);
818
986
  const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
819
- const result = await runSingleAttempt(runtimeCwd, agent, task, candidate, options, {
987
+ const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, options, {
820
988
  sessionEnabled,
821
989
  systemPrompt,
822
990
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
@@ -825,6 +993,15 @@ export async function runSync(
825
993
  artifactPaths: artifactPathsResult,
826
994
  attemptNotes,
827
995
  outputSnapshot,
996
+ originalTask: task,
997
+ completionPolicy: resolveCompletionPolicy({
998
+ agent: agent.name,
999
+ task,
1000
+ completionGuardEnabled: agent.completionGuard !== false,
1001
+ usesAcceptanceContract: effectiveAcceptance.explicit,
1002
+ tools: agent.tools,
1003
+ mcpDirectTools: agent.mcpDirectTools,
1004
+ }),
828
1005
  });
829
1006
  lastResult = result;
830
1007
  sumUsage(aggregateUsage, result.usage);
@@ -914,5 +1091,40 @@ export async function runSync(
914
1091
  if (sessionFile) result.sessionFile = sessionFile;
915
1092
  }
916
1093
 
1094
+ const initialAcceptanceOutput = acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "";
1095
+ const acceptanceForInitialReport = shouldRunAcceptanceFinalization(effectiveAcceptance)
1096
+ ? acceptanceSelfReviewConfig(effectiveAcceptance)
1097
+ : effectiveAcceptance;
1098
+ const initialAcceptance = await evaluateAcceptance({
1099
+ acceptance: acceptanceForInitialReport,
1100
+ output: initialAcceptanceOutput,
1101
+ cwd: options.cwd ?? runtimeCwd,
1102
+ });
1103
+ result.acceptance = initialAcceptance;
1104
+ if (shouldRunAcceptanceFinalization(effectiveAcceptance) && result.exitCode === 0 && !result.detached && !result.interrupted) {
1105
+ result.acceptance = await runAcceptanceFinalizationLoop({
1106
+ runtimeCwd,
1107
+ agent,
1108
+ result,
1109
+ initialLedger: initialAcceptance,
1110
+ initialOutput: initialAcceptanceOutput,
1111
+ acceptance: effectiveAcceptance,
1112
+ options,
1113
+ systemPrompt,
1114
+ resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
1115
+ ...(missingSkills.length > 0 ? { skillsWarning: `Skills not found: ${missingSkills.join(", ")}` } : {}),
1116
+ });
1117
+ }
1118
+ const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
1119
+ stripAcceptanceReportsFromMessages(result.messages);
1120
+ if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted) {
1121
+ result.exitCode = 1;
1122
+ result.error = result.error ? `${result.error}\n${acceptanceFailure}` : acceptanceFailure;
1123
+ if (result.progress) {
1124
+ result.progress.status = "failed";
1125
+ result.progress.error = result.error;
1126
+ }
1127
+ }
1128
+
917
1129
  return result;
918
1130
  }
@@ -21,6 +21,7 @@ import {
21
21
  writeInitialProgressFile,
22
22
  getStepAgents,
23
23
  isParallelStep,
24
+ isDynamicParallelStep,
24
25
  resolveStepBehavior,
25
26
  suppressProgressForReadOnlyTask,
26
27
  taskDisallowsFileUpdates,
@@ -52,6 +53,7 @@ import { resolveSubagentRunId, type ResolvedSubagentRunId } from "../background/
52
53
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
53
54
  import { inspectSubagentStatus } from "../background/run-status.ts";
54
55
  import { applyForceTopLevelAsyncOverride } from "../background/top-level-async.ts";
56
+ import { validateAcceptanceInput } from "../shared/acceptance.ts";
55
57
  import {
56
58
  cleanupWorktrees,
57
59
  createWorktrees,
@@ -63,6 +65,7 @@ import {
63
65
  } from "../shared/worktree.ts";
64
66
  import {
65
67
  type AgentProgress,
68
+ type AcceptanceInput,
66
69
  type ArtifactConfig,
67
70
  type ArtifactPaths,
68
71
  type ControlConfig,
@@ -103,6 +106,7 @@ interface TaskParam {
103
106
  progress?: boolean;
104
107
  model?: string;
105
108
  skill?: string | string[] | boolean;
109
+ acceptance?: AcceptanceInput;
106
110
  }
107
111
 
108
112
  export interface SubagentParamsLike {
@@ -134,6 +138,7 @@ export interface SubagentParamsLike {
134
138
  outputMode?: "inline" | "file-only";
135
139
  agentScope?: unknown;
136
140
  chainDir?: string;
141
+ acceptance?: AcceptanceInput;
137
142
  }
138
143
 
139
144
  interface ExecutorDeps {
@@ -756,6 +761,36 @@ async function maybeBuildForegroundIntercomReceipt(input: {
756
761
  };
757
762
  }
758
763
 
764
+ function validationErrorResult(mode: Details["mode"], text: string): AgentToolResult<Details> {
765
+ return { content: [{ type: "text", text }], isError: true, details: { mode, results: [] } };
766
+ }
767
+
768
+ function validateAcceptanceForExecution(params: SubagentParamsLike): AgentToolResult<Details> | null {
769
+ const topLevelErrors = validateAcceptanceInput(params.acceptance);
770
+ if (topLevelErrors.length > 0) return validationErrorResult("single", topLevelErrors.join(" "));
771
+ for (const [index, task] of (params.tasks ?? []).entries()) {
772
+ const errors = validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`);
773
+ if (errors.length > 0) return validationErrorResult("parallel", errors.join(" "));
774
+ }
775
+ for (const [stepIndex, step] of (params.chain ?? []).entries()) {
776
+ if (isParallelStep(step)) {
777
+ if (Object.hasOwn(step, "acceptance")) return validationErrorResult("chain", `chain[${stepIndex}].acceptance is not supported on static parallel groups; set acceptance on each parallel task.`);
778
+ for (const [taskIndex, task] of step.parallel.entries()) {
779
+ const errors = validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`);
780
+ if (errors.length > 0) return validationErrorResult("chain", errors.join(" "));
781
+ }
782
+ } else if (isDynamicParallelStep(step)) {
783
+ if (Object.hasOwn(step, "acceptance")) return validationErrorResult("chain", `chain[${stepIndex}].acceptance is not supported on dynamic fanout groups; set acceptance on chain[${stepIndex}].parallel.acceptance for each materialized child.`);
784
+ const errors = validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`);
785
+ if (errors.length > 0) return validationErrorResult("chain", errors.join(" "));
786
+ } else {
787
+ const stepErrors = validateAcceptanceInput(step.acceptance, `chain[${stepIndex}].acceptance`);
788
+ if (stepErrors.length > 0) return validationErrorResult("chain", stepErrors.join(" "));
789
+ }
790
+ }
791
+ return null;
792
+ }
793
+
759
794
  function validateExecutionInput(
760
795
  params: SubagentParamsLike,
761
796
  agents: AgentConfig[],
@@ -764,6 +799,9 @@ function validateExecutionInput(
764
799
  hasSingle: boolean,
765
800
  allowClarifyTaskPrompt: boolean,
766
801
  ): AgentToolResult<Details> | null {
802
+ const acceptanceError = validateAcceptanceForExecution(params);
803
+ if (acceptanceError) return acceptanceError;
804
+
767
805
  if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
768
806
  return {
769
807
  content: [
@@ -816,6 +854,12 @@ function validateExecutionInput(
816
854
  details: { mode: "chain" as const, results: [] },
817
855
  };
818
856
  }
857
+ } else if (isDynamicParallelStep(firstStep)) {
858
+ return {
859
+ content: [{ type: "text", text: "First step in chain cannot be dynamic fanout; expand.from requires a prior structured named output" }],
860
+ isError: true,
861
+ details: { mode: "chain" as const, results: [] },
862
+ };
819
863
  } else if (!(firstStep as SequentialStep).task && !params.task && !allowClarifyTaskPrompt) {
820
864
  return {
821
865
  content: [{ type: "text", text: "First step in chain must have a task" }],
@@ -977,6 +1021,10 @@ function collectChainSessionFiles(
977
1021
  }
978
1022
  continue;
979
1023
  }
1024
+ if (isDynamicParallelStep(step)) {
1025
+ sessionFiles.push(undefined);
1026
+ continue;
1027
+ }
980
1028
  sessionFiles.push(sessionFileForIndex(flatIndex));
981
1029
  flatIndex++;
982
1030
  }
@@ -995,6 +1043,15 @@ function wrapChainTasksForFork(chain: ChainStep[], context: SubagentParamsLike["
995
1043
  })),
996
1044
  };
997
1045
  }
1046
+ if (isDynamicParallelStep(step)) {
1047
+ return {
1048
+ ...step,
1049
+ parallel: {
1050
+ ...step.parallel,
1051
+ task: wrapForkTask(step.parallel.task ?? "{previous}"),
1052
+ },
1053
+ };
1054
+ }
998
1055
  const sequential = step as SequentialStep;
999
1056
  return {
1000
1057
  ...sequential,
@@ -1082,6 +1139,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1082
1139
  ...(task.outputMode !== undefined ? { outputMode: task.outputMode } : {}),
1083
1140
  ...(task.reads !== undefined && task.reads !== true ? { reads: task.reads } : {}),
1084
1141
  ...(task.progress !== undefined ? { progress: task.progress } : {}),
1142
+ ...(task.acceptance !== undefined ? { acceptance: task.acceptance } : {}),
1085
1143
  }));
1086
1144
  return executeAsyncChain(id, {
1087
1145
  chain: [{
@@ -1129,6 +1187,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1129
1187
  sessionRoot,
1130
1188
  chainSkills,
1131
1189
  sessionFilesByFlatIndex: collectChainSessionFiles(chain, sessionFileForIndex),
1190
+ dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1132
1191
  maxSubagentDepth: currentMaxSubagentDepth,
1133
1192
  worktreeSetupHook: deps.config.worktreeSetupHook,
1134
1193
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
@@ -1179,6 +1238,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1179
1238
  controlIntercomTarget,
1180
1239
  childIntercomTarget: childIntercomTarget ? (agent, index) => childIntercomTarget(agent, index) : undefined,
1181
1240
  nestedRoute,
1241
+ acceptance: params.acceptance,
1182
1242
  });
1183
1243
  }
1184
1244
 
@@ -1234,6 +1294,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1234
1294
  nestedRoute: foregroundControl?.nestedRoute,
1235
1295
  chainSkills,
1236
1296
  chainDir: params.chainDir,
1297
+ dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1237
1298
  maxSubagentDepth: currentMaxSubagentDepth,
1238
1299
  worktreeSetupHook: deps.config.worktreeSetupHook,
1239
1300
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
@@ -1269,6 +1330,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1269
1330
  sessionRoot,
1270
1331
  chainSkills: chainResult.requestedAsync.chainSkills,
1271
1332
  sessionFilesByFlatIndex: collectChainSessionFiles(asyncChain, sessionFileForIndex),
1333
+ dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1272
1334
  maxSubagentDepth: currentMaxSubagentDepth,
1273
1335
  worktreeSetupHook: deps.config.worktreeSetupHook,
1274
1336
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
@@ -1491,6 +1553,8 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1491
1553
  availableModels: input.availableModels,
1492
1554
  preferredModelProvider: input.ctx.model?.provider,
1493
1555
  skills: effectiveSkills === false ? [] : effectiveSkills,
1556
+ acceptance: task.acceptance,
1557
+ acceptanceContext: { mode: "parallel" },
1494
1558
  onUpdate: input.onUpdate
1495
1559
  ? (progressUpdate) => {
1496
1560
  const stepResults = progressUpdate.details?.results || [];
@@ -1680,6 +1744,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1680
1744
  ...(behaviorOverrides[i]?.outputMode !== undefined ? { outputMode: behaviorOverrides[i]!.outputMode } : {}),
1681
1745
  ...(behaviorOverrides[i]?.reads !== undefined ? { reads: behaviorOverrides[i]!.reads } : {}),
1682
1746
  ...(progress !== undefined ? { progress } : {}),
1747
+ ...(t.acceptance !== undefined ? { acceptance: t.acceptance } : {}),
1683
1748
  };
1684
1749
  });
1685
1750
  return executeAsyncChain(id, {
@@ -2053,6 +2118,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2053
2118
  availableModels,
2054
2119
  preferredModelProvider: currentProvider,
2055
2120
  skills: effectiveSkills,
2121
+ acceptance: params.acceptance,
2122
+ acceptanceContext: { mode: "single" },
2056
2123
  });
2057
2124
  if (foregroundControl?.currentIndex === 0) {
2058
2125
  foregroundControl.interrupt = undefined;