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
@@ -20,9 +20,11 @@ import {
20
20
  createParallelDirs,
21
21
  suppressProgressForReadOnlyTask,
22
22
  aggregateParallelOutputs,
23
+ isDynamicParallelStep,
23
24
  isParallelStep,
24
25
  type StepOverrides,
25
26
  type ChainStep,
27
+ type ParallelStep,
26
28
  type SequentialStep,
27
29
  type ParallelTaskResult,
28
30
  type ResolvedStepBehavior,
@@ -59,6 +61,11 @@ import {
59
61
  } from "../../shared/types.ts";
60
62
  import { resolveModelCandidate } from "../shared/model-fallback.ts";
61
63
  import { validateFileOnlyOutputMode } from "../shared/single-output.ts";
64
+ import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
65
+ import { ChainOutputValidationError, outputEntryFromResult, resolveOutputReferences, validateChainOutputBindings } from "../shared/chain-outputs.ts";
66
+ import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
67
+ import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelStep, validateDynamicCollection, type DynamicCollectedResult } from "../shared/dynamic-fanout.ts";
68
+ import type { ChainOutputMap } from "../../shared/types.ts";
62
69
 
63
70
  interface ChainExecutionDetailsInput {
64
71
  results: SingleResult[];
@@ -67,12 +74,18 @@ interface ChainExecutionDetailsInput {
67
74
  allArtifactPaths: ArtifactPaths[];
68
75
  artifactsDir: string;
69
76
  chainAgents: string[];
77
+ chainSteps: ChainStep[];
70
78
  totalSteps: number;
71
79
  currentStepIndex?: number;
80
+ runId: string;
81
+ outputs?: ChainOutputMap;
82
+ currentFlatIndex?: number;
83
+ dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
84
+ dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused" | "detached" | "timed-out"; error?: string; acceptance?: SingleResult["acceptance"] }>;
72
85
  }
73
86
 
74
87
  interface ParallelChainRunInput {
75
- step: Exclude<ChainStep, SequentialStep>;
88
+ step: ParallelStep;
76
89
  parallelTemplates: string[];
77
90
  parallelBehaviors: ResolvedStepBehavior[];
78
91
  agents: AgentConfig[];
@@ -90,6 +103,8 @@ interface ParallelChainRunInput {
90
103
  sessionFileForIndex?: (idx?: number) => string | undefined;
91
104
  shareEnabled: boolean;
92
105
  artifactConfig: ArtifactConfig;
106
+ timeoutMs?: number;
107
+ timeoutAt?: number;
93
108
  artifactsDir: string;
94
109
  signal?: AbortSignal;
95
110
  onUpdate?: (r: AgentToolResult<Details>) => void;
@@ -105,12 +120,20 @@ interface ParallelChainRunInput {
105
120
  lastActivityAt?: number;
106
121
  currentTool?: string;
107
122
  currentToolStartedAt?: number;
123
+ currentPath?: string;
124
+ turnCount?: number;
125
+ tokens?: number;
126
+ toolCount?: number;
108
127
  interrupt?: () => boolean;
109
128
  };
110
129
  results: SingleResult[];
111
130
  allProgress: AgentProgress[];
131
+ outputs: ChainOutputMap;
112
132
  chainAgents: string[];
133
+ chainSteps: ChainStep[];
113
134
  totalSteps: number;
135
+ dynamicChildren?: ChainExecutionDetailsInput["dynamicChildren"];
136
+ dynamicGroupStatuses?: ChainExecutionDetailsInput["dynamicGroupStatuses"];
114
137
  worktreeSetup?: WorktreeSetup;
115
138
  maxSubagentDepth: number;
116
139
  nestedRoute?: NestedRouteInfo;
@@ -125,6 +148,17 @@ function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details
125
148
  chainAgents: input.chainAgents,
126
149
  totalSteps: input.totalSteps,
127
150
  currentStepIndex: input.currentStepIndex,
151
+ outputs: input.outputs,
152
+ workflowGraph: buildWorkflowGraphSnapshot({
153
+ runId: input.runId,
154
+ mode: "chain",
155
+ steps: input.chainSteps,
156
+ results: input.results,
157
+ currentStepIndex: input.currentStepIndex,
158
+ currentFlatIndex: input.currentFlatIndex,
159
+ dynamicChildren: input.dynamicChildren,
160
+ dynamicGroupStatuses: input.dynamicGroupStatuses,
161
+ }),
128
162
  });
129
163
  }
130
164
 
@@ -191,7 +225,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
191
225
  templateHasPrevious ? undefined : input.prev,
192
226
  );
193
227
 
194
- let taskStr = taskTemplate;
228
+ let taskStr = resolveOutputReferences(taskTemplate, input.outputs);
195
229
  taskStr = taskStr.replace(/\{task\}/g, input.originalTask);
196
230
  taskStr = taskStr.replace(/\{previous\}/g, input.prev);
197
231
  taskStr = taskStr.replace(/\{chain_dir\}/g, input.chainDir);
@@ -226,10 +260,14 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
226
260
  };
227
261
  }
228
262
 
263
+ const structuredRuntime = task.outputSchema
264
+ ? createStructuredOutputRuntime(task.outputSchema, path.join(input.chainDir, "structured-output"))
265
+ : undefined;
229
266
  const result = await runSync(input.ctx.cwd, input.agents, task.agent, taskStr, {
230
267
  cwd: taskCwd,
231
268
  signal: input.signal,
232
269
  interruptSignal: interruptController.signal,
270
+ ...(input.timeoutMs !== undefined && input.timeoutAt !== undefined ? { timeoutMs: input.timeoutMs, timeoutAt: input.timeoutAt } : {}),
233
271
  allowIntercomDetach: taskAgentConfig?.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
234
272
  intercomEvents: input.intercomEvents,
235
273
  runId: input.runId,
@@ -242,6 +280,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
242
280
  outputPath,
243
281
  outputMode: behavior.outputMode,
244
282
  maxSubagentDepth,
283
+ maxExecutionTimeMs: taskAgentConfig?.maxExecutionTimeMs,
284
+ maxTokens: taskAgentConfig?.maxTokens,
245
285
  controlConfig: input.controlConfig,
246
286
  onControlEvent: input.onControlEvent,
247
287
  intercomSessionName: input.childIntercomTarget?.(task.agent, input.globalTaskIndex + taskIndex),
@@ -251,6 +291,9 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
251
291
  availableModels: input.availableModels,
252
292
  preferredModelProvider: input.ctx.model?.provider,
253
293
  skills: behavior.skills === false ? [] : behavior.skills,
294
+ structuredOutput: structuredRuntime,
295
+ acceptance: task.acceptance,
296
+ acceptanceContext: { mode: "chain" },
254
297
  onUpdate: input.onUpdate
255
298
  ? (progressUpdate) => {
256
299
  const stepResults = progressUpdate.details?.results || [];
@@ -279,6 +322,17 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
279
322
  chainAgents: input.chainAgents,
280
323
  totalSteps: input.totalSteps,
281
324
  currentStepIndex: input.stepIndex,
325
+ outputs: input.outputs,
326
+ workflowGraph: buildWorkflowGraphSnapshot({
327
+ runId: input.runId,
328
+ mode: "chain",
329
+ steps: input.chainSteps,
330
+ results: input.results.concat(stepResults),
331
+ currentStepIndex: input.stepIndex,
332
+ currentFlatIndex: input.globalTaskIndex + taskIndex,
333
+ dynamicChildren: input.dynamicChildren,
334
+ dynamicGroupStatuses: input.dynamicGroupStatuses,
335
+ }),
282
336
  },
283
337
  });
284
338
  }
@@ -329,14 +383,20 @@ interface ChainExecutionParams {
329
383
  lastActivityAt?: number;
330
384
  currentTool?: string;
331
385
  currentToolStartedAt?: number;
386
+ currentPath?: string;
387
+ turnCount?: number;
388
+ tokens?: number;
389
+ toolCount?: number;
332
390
  interrupt?: () => boolean;
333
391
  };
334
392
  chainSkills?: string[];
335
393
  chainDir?: string;
394
+ dynamicFanoutMaxItems?: number;
336
395
  maxSubagentDepth: number;
337
396
  nestedRoute?: NestedRouteInfo;
338
397
  worktreeSetupHook?: string;
339
398
  worktreeSetupHookTimeoutMs?: number;
399
+ timeoutMs?: number;
340
400
  }
341
401
 
342
402
  interface ChainExecutionResult {
@@ -380,22 +440,60 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
380
440
  } = params;
381
441
  const chainSkills = chainSkillsParam ?? [];
382
442
 
443
+ const results: SingleResult[] = [];
444
+ const outputs: ChainOutputMap = {};
445
+ const dynamicChildren: ChainExecutionDetailsInput["dynamicChildren"] = {};
446
+ const dynamicGroupStatuses: ChainExecutionDetailsInput["dynamicGroupStatuses"] = {};
383
447
  const allProgress: AgentProgress[] = [];
384
448
  const allArtifactPaths: ArtifactPaths[] = [];
385
449
 
386
450
  const chainAgents: string[] = chainSteps.map((step) =>
387
451
  isParallelStep(step)
388
452
  ? `[${step.parallel.map((t) => t.agent).join("+")}]`
453
+ : isDynamicParallelStep(step)
454
+ ? `expand:${step.parallel.agent}`
389
455
  : (step as SequentialStep).agent,
390
456
  );
391
457
  const totalSteps = chainSteps.length;
392
458
 
459
+ const makeDetailsInput = (overrides: Pick<Partial<ChainExecutionDetailsInput>, "currentStepIndex" | "currentFlatIndex"> = {}): ChainExecutionDetailsInput => ({
460
+ results,
461
+ ...(includeProgress !== undefined ? { includeProgress } : {}),
462
+ allProgress,
463
+ allArtifactPaths,
464
+ artifactsDir,
465
+ chainAgents,
466
+ chainSteps,
467
+ totalSteps,
468
+ runId,
469
+ outputs,
470
+ dynamicChildren,
471
+ dynamicGroupStatuses,
472
+ ...overrides,
473
+ });
474
+
393
475
  const firstStep = chainSteps[0]!;
394
476
  const originalTask = params.task
395
- ?? (isParallelStep(firstStep) ? firstStep.parallel[0]!.task! : (firstStep as SequentialStep).task!);
477
+ ?? (isParallelStep(firstStep)
478
+ ? firstStep.parallel[0]!.task!
479
+ : isDynamicParallelStep(firstStep)
480
+ ? firstStep.parallel.task!
481
+ : (firstStep as SequentialStep).task!);
482
+ try {
483
+ validateChainOutputBindings(chainSteps, { maxItems: params.dynamicFanoutMaxItems });
484
+ } catch (error) {
485
+ if (error instanceof ChainOutputValidationError) {
486
+ return {
487
+ content: [{ type: "text", text: error.message }],
488
+ isError: true,
489
+ details: buildChainExecutionDetails(makeDetailsInput()),
490
+ };
491
+ }
492
+ throw error;
493
+ }
396
494
 
397
495
  const chainDir = createChainDir(runId, chainDirBase);
398
- const hasParallelSteps = chainSteps.some(isParallelStep);
496
+ const hasParallelSteps = chainSteps.some((step) => isParallelStep(step) || isDynamicParallelStep(step));
399
497
  let templates: ResolvedTemplates = resolveChainTemplates(chainSteps);
400
498
  const shouldClarify = clarify !== false && ctx.hasUI && !hasParallelSteps;
401
499
  let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
@@ -412,7 +510,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
412
510
  return {
413
511
  content: [{ type: "text", text: `Unknown agent: ${step.agent}` }],
414
512
  isError: true,
415
- details: { mode: "chain" as const, results: [] },
513
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: seqSteps.indexOf(step) })),
416
514
  };
417
515
  }
418
516
  agentConfigs.push(config);
@@ -457,7 +555,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
457
555
  removeChainDir(chainDir);
458
556
  return {
459
557
  content: [{ type: "text", text: "Chain cancelled" }],
460
- details: { mode: "chain", results: [] },
558
+ details: buildChainExecutionDetails(makeDetailsInput()),
461
559
  };
462
560
  }
463
561
 
@@ -479,7 +577,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
479
577
  });
480
578
  return {
481
579
  content: [{ type: "text", text: "Launching in background..." }],
482
- details: { mode: "chain", results: [] },
580
+ details: buildChainExecutionDetails(makeDetailsInput()),
483
581
  requestedAsync: { chain: updatedChain, chainSkills },
484
582
  };
485
583
  }
@@ -488,7 +586,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
488
586
  tuiBehaviorOverrides = result.behaviorOverrides;
489
587
  }
490
588
 
491
- const results: SingleResult[] = [];
589
+ const timeoutAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
492
590
  let prev = "";
493
591
  let globalTaskIndex = 0;
494
592
  let progressCreated = false;
@@ -506,16 +604,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
506
604
  if (worktreeTaskCwdConflict) {
507
605
  return buildChainExecutionErrorResult(
508
606
  `parallel chain step ${stepIndex + 1}: ${formatWorktreeTaskCwdConflict(worktreeTaskCwdConflict, parallelCwd)}`,
509
- {
510
- results,
511
- includeProgress,
512
- allProgress,
513
- allArtifactPaths,
514
- artifactsDir,
515
- chainAgents,
516
- totalSteps,
517
- currentStepIndex: stepIndex,
518
- },
607
+ makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }),
519
608
  );
520
609
  }
521
610
  try {
@@ -527,16 +616,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
527
616
  });
528
617
  } catch (error) {
529
618
  const message = error instanceof Error ? error.message : String(error);
530
- return buildChainExecutionErrorResult(message, {
531
- results,
532
- includeProgress,
533
- allProgress,
534
- allArtifactPaths,
535
- artifactsDir,
536
- chainAgents,
537
- totalSteps,
538
- currentStepIndex: stepIndex,
539
- });
619
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
540
620
  }
541
621
  }
542
622
 
@@ -550,16 +630,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
550
630
  ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
551
631
  : undefined;
552
632
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Parallel chain step ${stepIndex + 1} task ${taskIndex + 1} (${step.parallel[taskIndex]!.agent})`);
553
- if (validationError) return buildChainExecutionErrorResult(validationError, {
554
- results,
555
- includeProgress,
556
- allProgress,
557
- allArtifactPaths,
558
- artifactsDir,
559
- chainAgents,
560
- totalSteps,
561
- currentStepIndex: stepIndex,
562
- });
633
+ if (validationError) return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
563
634
  }
564
635
  progressCreated = ensureParallelProgressFile(chainDir, progressCreated, parallelBehaviors);
565
636
  createParallelDirs(chainDir, stepIndex, step.parallel.length, agentNames);
@@ -584,12 +655,17 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
584
655
  shareEnabled,
585
656
  artifactConfig,
586
657
  artifactsDir,
658
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
587
659
  signal,
588
660
  onUpdate,
589
661
  results,
590
662
  allProgress,
663
+ outputs,
591
664
  chainAgents,
665
+ chainSteps,
592
666
  totalSteps,
667
+ dynamicChildren,
668
+ dynamicGroupStatuses,
593
669
  controlConfig,
594
670
  onControlEvent,
595
671
  childIntercomTarget,
@@ -606,21 +682,27 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
606
682
  if (result.progress) allProgress.push(result.progress);
607
683
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
608
684
  }
609
-
610
- const interrupted = parallelResults.find((result) => result.interrupted);
685
+ const timedOutIndexInStep = parallelResults.findIndex((result) => result.timedOut);
686
+ const timedOut = timedOutIndexInStep >= 0 ? parallelResults[timedOutIndexInStep] : undefined;
687
+ if (timedOut) {
688
+ return {
689
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${timedOut.agent}): ${timedOut.error ?? "timeout expired"}` }],
690
+ isError: true,
691
+ details: buildChainExecutionDetails(makeDetailsInput({
692
+ currentStepIndex: stepIndex,
693
+ currentFlatIndex: globalTaskIndex - step.parallel.length + timedOutIndexInStep,
694
+ })),
695
+ };
696
+ }
697
+ const interruptedIndexInStep = parallelResults.findIndex((result) => result.interrupted);
698
+ const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
611
699
  if (interrupted) {
612
700
  return {
613
701
  content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` }],
614
- details: buildChainExecutionDetails({
615
- results,
616
- includeProgress,
617
- allProgress,
618
- allArtifactPaths,
619
- artifactsDir,
620
- chainAgents,
621
- totalSteps,
702
+ details: buildChainExecutionDetails(makeDetailsInput({
622
703
  currentStepIndex: stepIndex,
623
- }),
704
+ currentFlatIndex: globalTaskIndex - step.parallel.length + interruptedIndexInStep,
705
+ })),
624
706
  };
625
707
  }
626
708
  const detachedIndexInStep = parallelResults.findIndex((result) => result.detached);
@@ -628,16 +710,10 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
628
710
  if (detached) {
629
711
  return {
630
712
  content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
631
- details: buildChainExecutionDetails({
632
- results,
633
- includeProgress,
634
- allProgress,
635
- allArtifactPaths,
636
- artifactsDir,
637
- chainAgents,
638
- totalSteps,
713
+ details: buildChainExecutionDetails(makeDetailsInput({
639
714
  currentStepIndex: stepIndex,
640
- }),
715
+ currentFlatIndex: globalTaskIndex - step.parallel.length + detachedIndexInStep,
716
+ })),
641
717
  };
642
718
  }
643
719
 
@@ -656,19 +732,18 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
656
732
  return {
657
733
  content: [{ type: "text", text: summary }],
658
734
  isError: true,
659
- details: buildChainExecutionDetails({
660
- results,
661
- includeProgress,
662
- allProgress,
663
- allArtifactPaths,
664
- artifactsDir,
665
- chainAgents,
666
- totalSteps,
735
+ details: buildChainExecutionDetails(makeDetailsInput({
667
736
  currentStepIndex: stepIndex,
668
- }),
737
+ currentFlatIndex: globalTaskIndex - step.parallel.length + failures[0]!.originalIndex,
738
+ })),
669
739
  };
670
740
  }
671
741
 
742
+ for (let taskIndex = 0; taskIndex < parallelResults.length; taskIndex++) {
743
+ const outputName = step.parallel[taskIndex]?.as;
744
+ if (outputName) outputs[outputName] = outputEntryFromResult(parallelResults[taskIndex]!, stepIndex);
745
+ }
746
+
672
747
  const taskResults: ParallelTaskResult[] = parallelResults.map((result, i) => {
673
748
  const outputTarget = parallelBehaviors[i]?.output;
674
749
  const outputTargetPath = typeof outputTarget === "string"
@@ -694,6 +769,198 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
694
769
  } finally {
695
770
  if (worktreeSetup) cleanupWorktrees(worktreeSetup);
696
771
  }
772
+ } else if (isDynamicParallelStep(step)) {
773
+ if (Object.hasOwn(step, "acceptance")) {
774
+ const message = `Dynamic fanout step ${stepIndex + 1} does not support group-level acceptance; set acceptance on the child template instead.`;
775
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
776
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
777
+ }
778
+ let materialized: ReturnType<typeof materializeDynamicParallelStep>;
779
+ try {
780
+ materialized = materializeDynamicParallelStep(step, outputs, stepIndex, { maxItems: params.dynamicFanoutMaxItems });
781
+ } catch (error) {
782
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
783
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
784
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
785
+ }
786
+
787
+ dynamicChildren[stepIndex] = materialized.items.map((item, itemIndex) => ({
788
+ agent: step.parallel.agent,
789
+ label: materialized.parallel[itemIndex]?.label,
790
+ flatIndex: globalTaskIndex + itemIndex,
791
+ itemKey: item.key,
792
+ structured: Boolean(step.parallel.outputSchema),
793
+ }));
794
+
795
+ if (materialized.parallel.length === 0) {
796
+ const collection: DynamicCollectedResult[] = [];
797
+ try {
798
+ validateDynamicCollection(step.collect.outputSchema, collection);
799
+ } catch (error) {
800
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
801
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
802
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
803
+ }
804
+ outputs[step.collect.as] = {
805
+ text: JSON.stringify(collection),
806
+ structured: collection,
807
+ agent: step.parallel.agent,
808
+ stepIndex,
809
+ };
810
+ dynamicGroupStatuses[stepIndex] = { status: "completed" };
811
+ prev = "Dynamic fanout produced 0 results.";
812
+ continue;
813
+ }
814
+
815
+ const dynamicParallelStep: ParallelStep = {
816
+ parallel: materialized.parallel,
817
+ concurrency: step.concurrency,
818
+ failFast: step.failFast,
819
+ };
820
+ const parallelTemplates = materialized.parallel.map((task) => task.task ?? "{previous}");
821
+ const parallelBehaviors = resolveParallelBehaviors(dynamicParallelStep.parallel, agents, stepIndex, chainSkills)
822
+ .map((behavior, taskIndex) => suppressProgressForReadOnlyTask(behavior, parallelTemplates[taskIndex] ?? dynamicParallelStep.parallel[taskIndex]?.task, originalTask));
823
+
824
+ for (let taskIndex = 0; taskIndex < dynamicParallelStep.parallel.length; taskIndex++) {
825
+ const behavior = parallelBehaviors[taskIndex]!;
826
+ const outputPath = typeof behavior.output === "string"
827
+ ? (path.isAbsolute(behavior.output) ? behavior.output : path.join(chainDir, behavior.output))
828
+ : undefined;
829
+ const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Dynamic chain step ${stepIndex + 1} item ${taskIndex + 1} (${dynamicParallelStep.parallel[taskIndex]!.agent})`);
830
+ if (validationError) {
831
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: validationError };
832
+ return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex + taskIndex }));
833
+ }
834
+ }
835
+
836
+ progressCreated = ensureParallelProgressFile(chainDir, progressCreated, parallelBehaviors);
837
+ createParallelDirs(chainDir, stepIndex, dynamicParallelStep.parallel.length, dynamicParallelStep.parallel.map((task) => task.agent));
838
+ const parallelResults = await runParallelChainTasks({
839
+ step: dynamicParallelStep,
840
+ parallelTemplates,
841
+ parallelBehaviors,
842
+ agents,
843
+ stepIndex,
844
+ availableModels,
845
+ chainDir,
846
+ prev,
847
+ originalTask,
848
+ ctx,
849
+ intercomEvents,
850
+ cwd,
851
+ runId,
852
+ globalTaskIndex,
853
+ sessionDirForIndex,
854
+ sessionFileForIndex,
855
+ shareEnabled,
856
+ artifactConfig,
857
+ artifactsDir,
858
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
859
+ signal,
860
+ onUpdate,
861
+ results,
862
+ allProgress,
863
+ outputs,
864
+ chainAgents,
865
+ chainSteps,
866
+ totalSteps,
867
+ dynamicChildren,
868
+ dynamicGroupStatuses,
869
+ controlConfig,
870
+ onControlEvent,
871
+ childIntercomTarget,
872
+ orchestratorIntercomTarget,
873
+ foregroundControl,
874
+ nestedRoute: params.nestedRoute,
875
+ maxSubagentDepth: params.maxSubagentDepth,
876
+ });
877
+ globalTaskIndex += dynamicParallelStep.parallel.length;
878
+
879
+ for (const result of parallelResults) {
880
+ results.push(result);
881
+ if (result.progress) allProgress.push(result.progress);
882
+ if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
883
+ }
884
+ const collected = collectDynamicResults(step, materialized.items, parallelResults);
885
+ const timedOutIndexInStep = parallelResults.findIndex((result) => result.timedOut);
886
+ const timedOut = timedOutIndexInStep >= 0 ? parallelResults[timedOutIndexInStep] : undefined;
887
+ if (timedOut) {
888
+ dynamicGroupStatuses[stepIndex] = { status: "timed-out", error: timedOut.error };
889
+ return {
890
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${timedOut.agent}): ${timedOut.error ?? "timeout expired"}` }],
891
+ isError: true,
892
+ details: buildChainExecutionDetails(makeDetailsInput({
893
+ currentStepIndex: stepIndex,
894
+ currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + timedOutIndexInStep,
895
+ })),
896
+ };
897
+ }
898
+ const interruptedIndexInStep = parallelResults.findIndex((result) => result.interrupted);
899
+ const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
900
+ if (interrupted) {
901
+ return {
902
+ content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${interrupted.agent}). Waiting for explicit next action.` }],
903
+ details: buildChainExecutionDetails(makeDetailsInput({
904
+ currentStepIndex: stepIndex,
905
+ currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + interruptedIndexInStep,
906
+ })),
907
+ };
908
+ }
909
+ const detachedIndexInStep = parallelResults.findIndex((result) => result.detached);
910
+ const detached = detachedIndexInStep >= 0 ? parallelResults[detachedIndexInStep] : undefined;
911
+ if (detached) {
912
+ return {
913
+ content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${detached.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
914
+ details: buildChainExecutionDetails(makeDetailsInput({
915
+ currentStepIndex: stepIndex,
916
+ currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + detachedIndexInStep,
917
+ })),
918
+ };
919
+ }
920
+ const failures = parallelResults
921
+ .map((result, originalIndex) => ({ ...result, originalIndex }))
922
+ .filter((result) => result.exitCode !== 0 && result.exitCode !== -1);
923
+ if (failures.length > 0) {
924
+ const failureSummary = failures
925
+ .map((failure) => `- Item ${failure.originalIndex + 1} (${failure.agent}, key ${materialized.items[failure.originalIndex]?.key ?? failure.originalIndex}): ${failure.error || "failed"}`)
926
+ .join("\n");
927
+ const errorMsg = `Dynamic step ${stepIndex + 1} failed:\n${failureSummary}`;
928
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: errorMsg };
929
+ const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
930
+ index: stepIndex,
931
+ error: errorMsg,
932
+ });
933
+ return {
934
+ content: [{ type: "text", text: summary }],
935
+ isError: true,
936
+ details: buildChainExecutionDetails(makeDetailsInput({
937
+ currentStepIndex: stepIndex,
938
+ currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + failures[0]!.originalIndex,
939
+ })),
940
+ };
941
+ }
942
+ try {
943
+ validateDynamicCollection(step.collect.outputSchema, collected);
944
+ } catch (error) {
945
+ const message = error instanceof DynamicFanoutError ? error.message : error instanceof Error ? error.message : String(error);
946
+ dynamicGroupStatuses[stepIndex] = { status: "failed", error: message };
947
+ return buildChainExecutionErrorResult(message, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length }));
948
+ }
949
+ outputs[step.collect.as] = {
950
+ text: JSON.stringify(collected),
951
+ structured: collected,
952
+ agent: step.parallel.agent,
953
+ stepIndex,
954
+ };
955
+ dynamicGroupStatuses[stepIndex] = { status: "completed" };
956
+ const taskResults: ParallelTaskResult[] = parallelResults.map((result, i) => ({
957
+ agent: result.agent,
958
+ taskIndex: i,
959
+ output: getSingleResultOutput(result),
960
+ exitCode: result.exitCode,
961
+ error: result.error,
962
+ }));
963
+ prev = aggregateParallelOutputs(taskResults, (i, agent) => `=== Dynamic Item ${i + 1} (${agent}, key ${materialized.items[i]?.key ?? i}) ===`);
697
964
  } else {
698
965
  const seqStep = step as SequentialStep;
699
966
  const stepTemplate = stepTemplates as string;
@@ -704,7 +971,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
704
971
  return {
705
972
  content: [{ type: "text", text: `Unknown agent: ${seqStep.agent}` }],
706
973
  isError: true,
707
- details: { mode: "chain" as const, results: [] },
974
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex })),
708
975
  };
709
976
  }
710
977
 
@@ -734,7 +1001,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
734
1001
  templateHasPrevious ? undefined : prev,
735
1002
  );
736
1003
 
737
- let stepTask = stepTemplate;
1004
+ let stepTask = resolveOutputReferences(stepTemplate, outputs);
738
1005
  stepTask = stepTask.replace(/\{task\}/g, originalTask);
739
1006
  stepTask = stepTask.replace(/\{previous\}/g, prev);
740
1007
  stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
@@ -751,16 +1018,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
751
1018
  : undefined;
752
1019
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Chain step ${stepIndex + 1} (${seqStep.agent})`);
753
1020
  if (validationError) {
754
- return buildChainExecutionErrorResult(validationError, {
755
- results,
756
- includeProgress,
757
- allProgress,
758
- allArtifactPaths,
759
- artifactsDir,
760
- chainAgents,
761
- totalSteps,
762
- currentStepIndex: stepIndex,
763
- });
1021
+ return buildChainExecutionErrorResult(validationError, makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex }));
764
1022
  }
765
1023
  const maxSubagentDepth = resolveChildMaxSubagentDepth(params.maxSubagentDepth, agentConfig.maxSubagentDepth);
766
1024
  const interruptController = new AbortController();
@@ -778,10 +1036,14 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
778
1036
  };
779
1037
  }
780
1038
 
1039
+ const structuredRuntime = seqStep.outputSchema
1040
+ ? createStructuredOutputRuntime(seqStep.outputSchema, path.join(chainDir, "structured-output"))
1041
+ : undefined;
781
1042
  const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
782
1043
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
783
1044
  signal,
784
1045
  interruptSignal: interruptController.signal,
1046
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
785
1047
  allowIntercomDetach: agentConfig.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
786
1048
  intercomEvents,
787
1049
  runId,
@@ -794,6 +1056,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
794
1056
  outputPath,
795
1057
  outputMode: behavior.outputMode,
796
1058
  maxSubagentDepth,
1059
+ maxExecutionTimeMs: agentConfig.maxExecutionTimeMs,
1060
+ maxTokens: agentConfig.maxTokens,
797
1061
  controlConfig,
798
1062
  onControlEvent,
799
1063
  intercomSessionName: childIntercomTarget?.(seqStep.agent, globalTaskIndex),
@@ -803,6 +1067,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
803
1067
  availableModels,
804
1068
  preferredModelProvider: ctx.model?.provider,
805
1069
  skills: behavior.skills === false ? [] : behavior.skills,
1070
+ structuredOutput: structuredRuntime,
1071
+ acceptance: seqStep.acceptance,
1072
+ acceptanceContext: { mode: "chain" },
806
1073
  onUpdate: onUpdate
807
1074
  ? (p) => {
808
1075
  const stepResults = p.details?.results || [];
@@ -831,6 +1098,17 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
831
1098
  chainAgents,
832
1099
  totalSteps,
833
1100
  currentStepIndex: stepIndex,
1101
+ outputs,
1102
+ workflowGraph: buildWorkflowGraphSnapshot({
1103
+ runId,
1104
+ mode: "chain",
1105
+ steps: chainSteps,
1106
+ results: results.concat(stepResults),
1107
+ currentStepIndex: stepIndex,
1108
+ currentFlatIndex: globalTaskIndex,
1109
+ dynamicChildren,
1110
+ dynamicGroupStatuses,
1111
+ }),
834
1112
  },
835
1113
  });
836
1114
  }
@@ -847,34 +1125,23 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
847
1125
  if (r.progress) allProgress.push(r.progress);
848
1126
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
849
1127
 
1128
+ if (r.timedOut) {
1129
+ return {
1130
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${r.agent}): ${r.error ?? "timeout expired"}` }],
1131
+ isError: true,
1132
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
1133
+ };
1134
+ }
850
1135
  if (r.interrupted) {
851
1136
  return {
852
1137
  content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
853
- details: buildChainExecutionDetails({
854
- results,
855
- includeProgress,
856
- allProgress,
857
- allArtifactPaths,
858
- artifactsDir,
859
- chainAgents,
860
- totalSteps,
861
- currentStepIndex: stepIndex,
862
- }),
1138
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
863
1139
  };
864
1140
  }
865
1141
  if (r.detached) {
866
1142
  return {
867
1143
  content: [{ type: "text", text: `Chain detached for intercom coordination at step ${stepIndex + 1} (${r.agent}). Reply to the supervisor request first. After the child exits, start a fresh follow-up if needed.` }],
868
- details: buildChainExecutionDetails({
869
- results,
870
- includeProgress,
871
- allProgress,
872
- allArtifactPaths,
873
- artifactsDir,
874
- chainAgents,
875
- totalSteps,
876
- currentStepIndex: stepIndex,
877
- }),
1144
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
878
1145
  };
879
1146
  }
880
1147
 
@@ -885,16 +1152,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
885
1152
  });
886
1153
  return {
887
1154
  content: [{ type: "text", text: summary }],
888
- details: buildChainExecutionDetails({
889
- results,
890
- includeProgress,
891
- allProgress,
892
- allArtifactPaths,
893
- artifactsDir,
894
- chainAgents,
895
- totalSteps,
896
- currentStepIndex: stepIndex,
897
- }),
1155
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
898
1156
  isError: true,
899
1157
  };
900
1158
  }
@@ -917,6 +1175,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
917
1175
  }
918
1176
  }
919
1177
 
1178
+ if (seqStep.as) outputs[seqStep.as] = outputEntryFromResult(r, stepIndex);
920
1179
  prev = getSingleResultOutput(r);
921
1180
  }
922
1181
  }
@@ -925,14 +1184,6 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
925
1184
 
926
1185
  return {
927
1186
  content: [{ type: "text", text: summary }],
928
- details: buildChainExecutionDetails({
929
- results,
930
- includeProgress,
931
- allProgress,
932
- allArtifactPaths,
933
- artifactsDir,
934
- chainAgents,
935
- totalSteps,
936
- }),
1187
+ details: buildChainExecutionDetails(makeDetailsInput()),
937
1188
  };
938
1189
  }