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
@@ -0,0 +1,127 @@
1
+ import type {
2
+ AcceptanceReport,
3
+ } from "../../shared/types.ts";
4
+
5
+ function extractBalancedJson(text: string, start: number): string | undefined {
6
+ let depth = 0;
7
+ let inString = false;
8
+ let escaped = false;
9
+ for (let i = start; i < text.length; i++) {
10
+ const char = text[i]!;
11
+ if (inString) {
12
+ if (escaped) escaped = false;
13
+ else if (char === "\\") escaped = true;
14
+ else if (char === "\"") inString = false;
15
+ continue;
16
+ }
17
+ if (char === "\"") {
18
+ inString = true;
19
+ continue;
20
+ }
21
+ if (char === "{") depth++;
22
+ if (char === "}") {
23
+ depth--;
24
+ if (depth === 0) return text.slice(start, i + 1);
25
+ }
26
+ }
27
+ return undefined;
28
+ }
29
+
30
+ export function parseAcceptanceReport(output: string): { report?: AcceptanceReport; error?: string } {
31
+ const fenced = [...output.matchAll(/```acceptance-report\s*\n([\s\S]*?)```/gi)]
32
+ .map((match) => match[1]?.trim())
33
+ .filter((value): value is string => Boolean(value));
34
+ const parseErrors: string[] = [];
35
+ for (const body of fenced) {
36
+ try {
37
+ const parsed = JSON.parse(body) as unknown;
38
+ const report = (parsed && typeof parsed === "object" && "acceptance" in parsed)
39
+ ? (parsed as { acceptance?: unknown }).acceptance
40
+ : parsed;
41
+ if (isAcceptanceReport(report)) return { report };
42
+ parseErrors.push("acceptance-report block does not contain a valid acceptance report");
43
+ } catch (error) {
44
+ parseErrors.push(error instanceof Error ? error.message : String(error));
45
+ }
46
+ }
47
+ if (parseErrors.length > 0) return { error: `Failed to parse acceptance-report: ${parseErrors.join("; ")}` };
48
+ const markerIndex = output.search(/ACCEPTANCE_REPORT\s*:/i);
49
+ if (markerIndex !== -1) {
50
+ const jsonStart = output.indexOf("{", markerIndex);
51
+ if (jsonStart !== -1) {
52
+ const json = extractBalancedJson(output, jsonStart);
53
+ if (json) {
54
+ try {
55
+ const parsed = JSON.parse(json) as unknown;
56
+ if (isAcceptanceReport(parsed)) return { report: parsed };
57
+ } catch (error) {
58
+ return { error: error instanceof Error ? error.message : String(error) };
59
+ }
60
+ }
61
+ }
62
+ }
63
+ return { error: "Structured acceptance report not found." };
64
+ }
65
+
66
+ export function stripAcceptanceReport(output: string): string {
67
+ return output
68
+ .replace(/\n?```acceptance-report\s*\n[\s\S]*?```\s*$/i, "")
69
+ .replace(/\n?ACCEPTANCE_REPORT\s*:\s*\{[\s\S]*\}\s*$/i, "")
70
+ .trimEnd();
71
+ }
72
+
73
+ function isStringArray(value: unknown): value is string[] {
74
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
75
+ }
76
+
77
+ function isCriterionReport(value: unknown): value is NonNullable<AcceptanceReport["criteriaSatisfied"]>[number] {
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
79
+ const criterion = value as { id?: unknown; status?: unknown; evidence?: unknown };
80
+ if (criterion.id !== undefined && typeof criterion.id !== "string") return false;
81
+ if (criterion.status !== "satisfied" && criterion.status !== "not-satisfied" && criterion.status !== "not-applicable") return false;
82
+ return typeof criterion.evidence === "string" && criterion.evidence.trim().length > 0;
83
+ }
84
+
85
+ function isCommandReport(value: unknown): value is NonNullable<AcceptanceReport["commandsRun"]>[number] {
86
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
87
+ const command = value as { command?: unknown; result?: unknown; summary?: unknown };
88
+ return typeof command.command === "string"
89
+ && (command.result === "passed" || command.result === "failed" || command.result === "not-run")
90
+ && typeof command.summary === "string";
91
+ }
92
+
93
+ function isAcceptanceReport(value: unknown): value is AcceptanceReport {
94
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
95
+ const report = value as {
96
+ criteriaSatisfied?: unknown;
97
+ changedFiles?: unknown;
98
+ testsAddedOrUpdated?: unknown;
99
+ commandsRun?: unknown;
100
+ validationOutput?: unknown;
101
+ residualRisks?: unknown;
102
+ noStagedFiles?: unknown;
103
+ diffSummary?: unknown;
104
+ reviewFindings?: unknown;
105
+ manualNotes?: unknown;
106
+ notes?: unknown;
107
+ };
108
+ if (report.criteriaSatisfied !== undefined && (!Array.isArray(report.criteriaSatisfied) || !report.criteriaSatisfied.every(isCriterionReport))) return false;
109
+ if (report.changedFiles !== undefined && !isStringArray(report.changedFiles)) return false;
110
+ if (report.testsAddedOrUpdated !== undefined && !isStringArray(report.testsAddedOrUpdated)) return false;
111
+ if (report.commandsRun !== undefined && (!Array.isArray(report.commandsRun) || !report.commandsRun.every(isCommandReport))) return false;
112
+ if (report.validationOutput !== undefined && !isStringArray(report.validationOutput)) return false;
113
+ if (report.residualRisks !== undefined && !isStringArray(report.residualRisks)) return false;
114
+ if (report.noStagedFiles !== undefined && typeof report.noStagedFiles !== "boolean") return false;
115
+ if (report.diffSummary !== undefined && typeof report.diffSummary !== "string") return false;
116
+ if (report.reviewFindings !== undefined && !isStringArray(report.reviewFindings)) return false;
117
+ if (report.manualNotes !== undefined && typeof report.manualNotes !== "string") return false;
118
+ if (report.notes !== undefined && typeof report.notes !== "string") return false;
119
+ return report.criteriaSatisfied !== undefined
120
+ || report.changedFiles !== undefined
121
+ || report.testsAddedOrUpdated !== undefined
122
+ || report.commandsRun !== undefined
123
+ || report.residualRisks !== undefined
124
+ || report.manualNotes !== undefined
125
+ || report.reviewFindings !== undefined;
126
+ }
127
+
@@ -0,0 +1,22 @@
1
+ export {
2
+ acceptanceSelfReviewConfig,
3
+ formatAcceptancePrompt,
4
+ resolveEffectiveAcceptance,
5
+ shouldRunAcceptanceFinalization,
6
+ validateAcceptanceInput,
7
+ } from "./acceptance-contract.ts";
8
+ export {
9
+ parseAcceptanceReport,
10
+ stripAcceptanceReport,
11
+ } from "./acceptance-reports.ts";
12
+ export {
13
+ acceptanceFailureMessage,
14
+ evaluateAcceptance,
15
+ } from "./acceptance-evaluation.ts";
16
+ export {
17
+ attachFinalizationToLedger,
18
+ buildFinalizationProcessFailureLedger,
19
+ createFinalizationProcessFailureTurn,
20
+ createFinalizationTurn,
21
+ formatAcceptanceFinalizationPrompt,
22
+ } from "./acceptance-finalization.ts";
@@ -0,0 +1,101 @@
1
+ import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
2
+ import type { ChainOutputMap, ChainOutputMapEntry, SingleResult } from "../../shared/types.ts";
3
+ import { getSingleResultOutput } from "../../shared/utils.ts";
4
+ import { DynamicFanoutError, hasDynamicFanoutFields, type DynamicFanoutConfig, validateDynamicStepShape } from "./dynamic-fanout.ts";
5
+
6
+ const OUTPUT_REF_PATTERN = /\{outputs\.([^}]*)\}/g;
7
+ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
+
9
+ export class ChainOutputValidationError extends Error {}
10
+
11
+ function outputNamesForStep(step: ChainStep): string[] {
12
+ if (isParallelStep(step)) return step.parallel.map((task) => task.as).filter((name): name is string => Boolean(name));
13
+ if (isDynamicParallelStep(step)) return [step.collect.as];
14
+ const name = (step as SequentialStep).as;
15
+ return name ? [name] : [];
16
+ }
17
+
18
+ function taskTemplatesForStep(step: ChainStep): string[] {
19
+ if (isParallelStep(step)) return step.parallel.map((task) => task.task ?? "{previous}");
20
+ if (isDynamicParallelStep(step)) return [step.parallel.task ?? "{previous}", step.parallel.label ?? ""].filter(Boolean);
21
+ return [(step as SequentialStep).task ?? "{previous}"];
22
+ }
23
+
24
+ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
25
+ const available = new Set<string>();
26
+ const seen = new Set<string>();
27
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
28
+ const step = steps[stepIndex]!;
29
+ if (hasDynamicFanoutFields(step)) {
30
+ if (!isDynamicParallelStep(step)) {
31
+ throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
32
+ }
33
+ try {
34
+ validateDynamicStepShape(step, stepIndex, dynamicFanoutConfig);
35
+ } catch (error) {
36
+ if (error instanceof DynamicFanoutError) throw new ChainOutputValidationError(error.message);
37
+ throw error;
38
+ }
39
+ if (!available.has(step.expand.from.output)) {
40
+ throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
41
+ }
42
+ }
43
+ for (const name of outputNamesForStep(step)) {
44
+ if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
45
+ throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${stepIndex + 1}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
46
+ }
47
+ if (seen.has(name)) {
48
+ throw new ChainOutputValidationError(`Duplicate chain output name '${name}'. Each as name must be unique.`);
49
+ }
50
+ seen.add(name);
51
+ }
52
+ for (const template of taskTemplatesForStep(step)) {
53
+ for (const match of template.matchAll(OUTPUT_REF_PATTERN)) {
54
+ const rawReference = match[0];
55
+ const name = match[1]!;
56
+ if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
57
+ throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
58
+ }
59
+ if (!available.has(name)) {
60
+ throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
61
+ }
62
+ }
63
+ }
64
+ for (const name of outputNamesForStep(step)) {
65
+ available.add(name);
66
+ }
67
+ }
68
+ }
69
+
70
+ export function resolveOutputReferences(template: string, outputs: ChainOutputMap): string {
71
+ return template.replace(OUTPUT_REF_PATTERN, (rawReference, name: string) => {
72
+ if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
73
+ throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}'. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
74
+ }
75
+ const entry = outputs[name];
76
+ if (!entry) throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}'.`);
77
+ return entry.text;
78
+ });
79
+ }
80
+
81
+ function compactStructuredText(value: unknown): string {
82
+ return JSON.stringify(value);
83
+ }
84
+
85
+ export function outputEntryFromResult(result: SingleResult, stepIndex: number): ChainOutputMapEntry {
86
+ return {
87
+ text: result.structuredOutput !== undefined ? compactStructuredText(result.structuredOutput) : getSingleResultOutput(result),
88
+ ...(result.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
89
+ agent: result.agent,
90
+ stepIndex,
91
+ };
92
+ }
93
+
94
+ export function outputEntryFromAsyncResult(result: { agent: string; output: string; structuredOutput?: unknown }, stepIndex: number): ChainOutputMapEntry {
95
+ return {
96
+ text: result.structuredOutput !== undefined ? compactStructuredText(result.structuredOutput) : result.output,
97
+ ...(result.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
98
+ agent: result.agent,
99
+ stepIndex,
100
+ };
101
+ }
@@ -66,6 +66,17 @@ const READ_ONLY_BUILTIN_TOOLS = new Set([
66
66
  "contact_supervisor",
67
67
  ]);
68
68
 
69
+ export type CompletionPolicy = "none" | "mutation-guard" | "acceptance-contract";
70
+
71
+ interface CompletionPolicyInput {
72
+ agent: string;
73
+ task: string;
74
+ completionGuardEnabled: boolean;
75
+ usesAcceptanceContract: boolean;
76
+ tools?: string[];
77
+ mcpDirectTools?: string[];
78
+ }
79
+
69
80
  interface CompletionMutationGuardInput {
70
81
  agent: string;
71
82
  task: string;
@@ -134,10 +145,22 @@ export function hasMutationToolCall(messages: Message[]): boolean {
134
145
  return false;
135
146
  }
136
147
 
148
+ export function resolveCompletionPolicy(input: CompletionPolicyInput): CompletionPolicy {
149
+ if (input.usesAcceptanceContract) return "acceptance-contract";
150
+ if (!input.completionGuardEnabled) return "none";
151
+ if (declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)) return "none";
152
+ return expectsImplementationMutation(input.agent, input.task) ? "mutation-guard" : "none";
153
+ }
154
+
137
155
  export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
138
- const expectedMutation = declaresOnlyReadOnlyTools(input.tools, input.mcpDirectTools)
139
- ? false
140
- : expectsImplementationMutation(input.agent, input.task);
156
+ const expectedMutation = resolveCompletionPolicy({
157
+ agent: input.agent,
158
+ task: input.task,
159
+ completionGuardEnabled: true,
160
+ usesAcceptanceContract: false,
161
+ tools: input.tools,
162
+ mcpDirectTools: input.mcpDirectTools,
163
+ }) === "mutation-guard";
141
164
  const attemptedMutation = hasMutationToolCall(input.messages);
142
165
  return {
143
166
  expectedMutation,
@@ -0,0 +1,293 @@
1
+ import type { DynamicParallelStep, ParallelTaskItem } from "../../shared/settings.ts";
2
+ import type { ArtifactPaths, ChainOutputMap, JsonSchemaObject, SingleResult } from "../../shared/types.ts";
3
+ import { getSingleResultOutput } from "../../shared/utils.ts";
4
+ import { validateStructuredOutputValue } from "./structured-output.ts";
5
+
6
+ export class DynamicFanoutError extends Error {}
7
+
8
+ export interface DynamicFanoutConfig {
9
+ maxItems?: number;
10
+ allowRunnerFields?: boolean;
11
+ }
12
+
13
+ export interface DynamicMaterializedItem {
14
+ index: number;
15
+ key: string;
16
+ idKey: string;
17
+ item: unknown;
18
+ }
19
+
20
+ export interface DynamicCollectedResult {
21
+ key: string;
22
+ index: number;
23
+ item: unknown;
24
+ agent: string;
25
+ exitCode: number | null;
26
+ text: string;
27
+ structured?: unknown;
28
+ error?: string;
29
+ outputPath?: string;
30
+ artifactPaths?: ArtifactPaths;
31
+ }
32
+
33
+ export interface DynamicMaterializedGroup {
34
+ items: DynamicMaterializedItem[];
35
+ parallel: ParallelTaskItem[];
36
+ collectedOnEmpty?: DynamicCollectedResult[];
37
+ }
38
+
39
+ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
40
+ const ITEM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
41
+ const ITEM_REF_PATTERN = /\{([A-Za-z_][A-Za-z0-9_]*)(?:\.([^{}]+))?\}/g;
42
+ const RESERVED_TEMPLATE_NAMES = new Set(["task", "previous", "chain_dir", "outputs"]);
43
+ const DYNAMIC_STEP_KEYS = new Set(["expand", "parallel", "collect", "concurrency", "failFast", "phase", "label", "acceptance"]);
44
+ const RUNNER_DYNAMIC_STEP_KEYS = new Set([...DYNAMIC_STEP_KEYS, "effectiveAcceptance"]);
45
+ const DYNAMIC_EXPAND_KEYS = new Set(["from", "item", "key", "maxItems", "onEmpty"]);
46
+ const DYNAMIC_EXPAND_FROM_KEYS = new Set(["output", "path"]);
47
+ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outputSchema", "cwd", "output", "outputMode", "reads", "progress", "skill", "model", "acceptance"]);
48
+ const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
49
+ ...DYNAMIC_PARALLEL_KEYS,
50
+ "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
51
+ "structuredOutput", "structuredOutputSchema", "tools", "extensions", "mcpDirectTools", "completionGuard", "systemPrompt",
52
+ "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance",
53
+ ]);
54
+ const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
55
+
56
+ export function isSafeOutputName(name: string): boolean {
57
+ return SAFE_OUTPUT_NAME_PATTERN.test(name);
58
+ }
59
+
60
+ export function assertJsonPointer(pointer: string, label: string): void {
61
+ if (pointer === "") return;
62
+ if (!pointer.startsWith("/")) {
63
+ throw new DynamicFanoutError(`${label} must be a JSON Pointer starting with '/'.`);
64
+ }
65
+ for (const segment of pointer.slice(1).split("/")) {
66
+ if (/~(?![01])/.test(segment)) {
67
+ throw new DynamicFanoutError(`${label} contains invalid JSON Pointer escape.`);
68
+ }
69
+ }
70
+ }
71
+
72
+ function decodePointerSegment(segment: string): string {
73
+ return segment.replace(/~1/g, "/").replace(/~0/g, "~");
74
+ }
75
+
76
+ export function resolveJsonPointer(value: unknown, pointer: string, label: string): unknown {
77
+ assertJsonPointer(pointer, label);
78
+ if (pointer === "") return value;
79
+ let current = value;
80
+ for (const rawSegment of pointer.slice(1).split("/")) {
81
+ const segment = decodePointerSegment(rawSegment);
82
+ if (Array.isArray(current)) {
83
+ if (!/^(0|[1-9][0-9]*)$/.test(segment)) {
84
+ throw new DynamicFanoutError(`${label} segment '${segment}' does not address an array index.`);
85
+ }
86
+ const index = Number(segment);
87
+ if (index >= current.length) throw new DynamicFanoutError(`${label} does not exist.`);
88
+ current = current[index];
89
+ continue;
90
+ }
91
+ if (!current || typeof current !== "object") {
92
+ throw new DynamicFanoutError(`${label} does not exist.`);
93
+ }
94
+ const record = current as Record<string, unknown>;
95
+ if (!Object.prototype.hasOwnProperty.call(record, segment)) {
96
+ throw new DynamicFanoutError(`${label} does not exist.`);
97
+ }
98
+ current = record[segment];
99
+ }
100
+ return current;
101
+ }
102
+
103
+ function scalarToKey(value: unknown, label: string): string {
104
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
105
+ const key = String(value);
106
+ if (!key.trim()) throw new DynamicFanoutError(`${label} resolved to an empty key.`);
107
+ if (/[\u0000-\u001F\u007F]/.test(key)) throw new DynamicFanoutError(`${label} resolved to an unsafe key.`);
108
+ if (key.length > 200) throw new DynamicFanoutError(`${label} resolved to a key longer than 200 characters.`);
109
+ return key;
110
+ }
111
+ throw new DynamicFanoutError(`${label} must resolve to a string, number, or boolean.`);
112
+ }
113
+
114
+ export function normalizeItemKeyForId(key: string): string {
115
+ const normalized = key
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9]+/g, "-")
118
+ .replace(/^-+|-+$/g, "")
119
+ .slice(0, 80);
120
+ return normalized || "item";
121
+ }
122
+
123
+ function valueToTemplateText(value: unknown, reference: string): string {
124
+ if (value === undefined) throw new DynamicFanoutError(`Unresolved item reference '${reference}'.`);
125
+ if (typeof value === "string") return value;
126
+ if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
127
+ return JSON.stringify(value);
128
+ }
129
+
130
+ function resolveItemPath(item: unknown, pathText: string | undefined, reference: string): unknown {
131
+ if (!pathText) return item;
132
+ const pointer = `/${pathText.split(".").map((segment) => segment.replace(/~/g, "~0").replace(/\//g, "~1")).join("/")}`;
133
+ return resolveJsonPointer(item, pointer, reference);
134
+ }
135
+
136
+ export function resolveItemTemplate(template: string, itemName: string, item: unknown): string {
137
+ return template.replace(ITEM_REF_PATTERN, (raw, name: string, pathText: string | undefined) => {
138
+ if (name !== itemName) return raw;
139
+ if (pathText !== undefined && (!pathText.trim() || pathText.includes(".."))) {
140
+ throw new DynamicFanoutError(`Invalid item reference '${raw}'.`);
141
+ }
142
+ return valueToTemplateText(resolveItemPath(item, pathText, raw), raw);
143
+ });
144
+ }
145
+
146
+ function assertOnlyKeys(value: unknown, allowed: Set<string>, label: string): void {
147
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new DynamicFanoutError(`${label} must be an object.`);
148
+ for (const key of Object.keys(value)) {
149
+ if (!allowed.has(key)) throw new DynamicFanoutError(`${label} does not support field '${key}'.`);
150
+ }
151
+ }
152
+
153
+ export function assertNoUnresolvedItemReferences(template: string, itemName: string, label: string): void {
154
+ for (const match of template.matchAll(/\{([^{}]*)\}/g)) {
155
+ const raw = match[0]!;
156
+ const reference = match[1]!;
157
+ if (reference === itemName || reference.startsWith(`${itemName}.`)) {
158
+ if (!ITEM_REF_PATTERN.test(raw) || reference === `${itemName}.` || reference.includes("..")) {
159
+ throw new DynamicFanoutError(`Invalid item reference '${raw}' in ${label}.`);
160
+ }
161
+ ITEM_REF_PATTERN.lastIndex = 0;
162
+ continue;
163
+ }
164
+ ITEM_REF_PATTERN.lastIndex = 0;
165
+ const name = reference.match(/^[A-Za-z_][A-Za-z0-9_]*/)?.[0];
166
+ if (name === itemName) throw new DynamicFanoutError(`Invalid item reference '${raw}' in ${label}.`);
167
+ if (name && RESERVED_TEMPLATE_NAMES.has(name)) continue;
168
+ if (name) throw new DynamicFanoutError(`Unsupported template reference '${raw}' in ${label}.`);
169
+ }
170
+ ITEM_REF_PATTERN.lastIndex = 0;
171
+ if (template.includes(`{${itemName}.}`) || new RegExp(`\\{${itemName}(?:\\.|$)[^}]*$`).test(template)) {
172
+ throw new DynamicFanoutError(`Invalid item reference in ${label}.`);
173
+ }
174
+ }
175
+
176
+ export function hasDynamicFanoutFields(step: unknown): boolean {
177
+ return !!step && typeof step === "object" && !Array.isArray(step)
178
+ && (Object.prototype.hasOwnProperty.call(step, "expand") || Object.prototype.hasOwnProperty.call(step, "collect"));
179
+ }
180
+
181
+ export function validateDynamicStepShape(step: DynamicParallelStep, stepIndex: number, config: DynamicFanoutConfig = {}): void {
182
+ const prefix = `Dynamic chain step ${stepIndex + 1}`;
183
+ assertOnlyKeys(step, config.allowRunnerFields ? RUNNER_DYNAMIC_STEP_KEYS : DYNAMIC_STEP_KEYS, prefix);
184
+ if (!step.expand || !step.expand.from) throw new DynamicFanoutError(`${prefix} requires expand.from.`);
185
+ assertOnlyKeys(step.expand, DYNAMIC_EXPAND_KEYS, `${prefix} expand`);
186
+ assertOnlyKeys(step.expand.from, DYNAMIC_EXPAND_FROM_KEYS, `${prefix} expand.from`);
187
+ if (!isSafeOutputName(step.expand.from.output)) throw new DynamicFanoutError(`${prefix} has invalid expand.from.output '${step.expand.from.output}'.`);
188
+ assertJsonPointer(step.expand.from.path, `${prefix} expand.from.path`);
189
+ if (step.expand.key !== undefined) assertJsonPointer(step.expand.key, `${prefix} expand.key`);
190
+ const itemName = step.expand.item ?? "item";
191
+ if (!ITEM_NAME_PATTERN.test(itemName)) throw new DynamicFanoutError(`${prefix} has invalid expand.item '${itemName}'.`);
192
+ if (step.expand.maxItems === undefined && config.maxItems === undefined) {
193
+ throw new DynamicFanoutError(`${prefix} requires expand.maxItems or config.chain.dynamicFanout.maxItems.`);
194
+ }
195
+ if (step.expand.maxItems !== undefined && (!Number.isInteger(step.expand.maxItems) || step.expand.maxItems < 0)) {
196
+ throw new DynamicFanoutError(`${prefix} expand.maxItems must be an integer >= 0.`);
197
+ }
198
+ if (config.maxItems !== undefined && (!Number.isInteger(config.maxItems) || config.maxItems < 0)) {
199
+ throw new DynamicFanoutError("config.chain.dynamicFanout.maxItems must be an integer >= 0.");
200
+ }
201
+ if (!step.parallel || Array.isArray(step.parallel)) throw new DynamicFanoutError(`${prefix} requires a single parallel template object and cannot mix dynamic expand/collect with static parallel arrays.`);
202
+ assertOnlyKeys(step.parallel, config.allowRunnerFields ? RUNNER_DYNAMIC_PARALLEL_KEYS : DYNAMIC_PARALLEL_KEYS, `${prefix} parallel`);
203
+ if ("expand" in (step.parallel as object)) throw new DynamicFanoutError(`${prefix} does not support nested dynamic fanout.`);
204
+ if (!step.parallel.agent) throw new DynamicFanoutError(`${prefix} parallel.agent is required.`);
205
+ if (!step.collect?.as || !isSafeOutputName(step.collect.as)) throw new DynamicFanoutError(`${prefix} requires collect.as with a safe output name.`);
206
+ assertOnlyKeys(step.collect, DYNAMIC_COLLECT_KEYS, `${prefix} collect`);
207
+ for (const [label, template] of [
208
+ ["parallel.task", step.parallel.task],
209
+ ["parallel.label", step.parallel.label],
210
+ ] as const) {
211
+ if (template) assertNoUnresolvedItemReferences(template, itemName, `${prefix} ${label}`);
212
+ }
213
+ }
214
+
215
+ export function resolveDynamicFanoutItems(step: DynamicParallelStep, outputs: ChainOutputMap, stepIndex: number, config: DynamicFanoutConfig = {}): DynamicMaterializedItem[] {
216
+ validateDynamicStepShape(step, stepIndex, config);
217
+ const sourceName = step.expand.from.output;
218
+ const source = outputs[sourceName];
219
+ if (!source) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} references unknown output '${sourceName}'.`);
220
+ if (source.structured === undefined) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} requires structured output '${sourceName}'.`);
221
+ const value = resolveJsonPointer(source.structured, step.expand.from.path, `Dynamic chain step ${stepIndex + 1} expand.from.path`);
222
+ if (!Array.isArray(value)) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} expand.from.path must resolve to an array.`);
223
+ const maxItems = step.expand.maxItems ?? config.maxItems;
224
+ if (maxItems === undefined) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} requires an effective maxItems.`);
225
+ if (value.length > maxItems) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} resolved ${value.length} items, exceeding maxItems ${maxItems}.`);
226
+ const seen = new Set<string>();
227
+ const seenIds = new Set<string>();
228
+ return value.map((item, index) => {
229
+ const key = step.expand.key === undefined
230
+ ? String(index)
231
+ : scalarToKey(resolveJsonPointer(item, step.expand.key, `Dynamic chain step ${stepIndex + 1} expand.key`), `Dynamic chain step ${stepIndex + 1} expand.key`);
232
+ if (seen.has(key)) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} produced duplicate item key '${key}'.`);
233
+ seen.add(key);
234
+ const idKey = normalizeItemKeyForId(key);
235
+ if (seenIds.has(idKey)) throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} produced colliding item id '${idKey}'.`);
236
+ seenIds.add(idKey);
237
+ return { index, key, idKey, item };
238
+ });
239
+ }
240
+
241
+ export function materializeDynamicParallelStep(step: DynamicParallelStep, outputs: ChainOutputMap, stepIndex: number, config: DynamicFanoutConfig = {}): DynamicMaterializedGroup {
242
+ const items = resolveDynamicFanoutItems(step, outputs, stepIndex, config);
243
+ if (items.length === 0) {
244
+ if ((step.expand.onEmpty ?? "skip") === "fail") {
245
+ throw new DynamicFanoutError(`Dynamic chain step ${stepIndex + 1} source array is empty.`);
246
+ }
247
+ return { items, parallel: [], collectedOnEmpty: [] };
248
+ }
249
+ const itemName = step.expand.item ?? "item";
250
+ const parallel = items.map((entry) => {
251
+ const task = resolveItemTemplate(step.parallel.task ?? "{previous}", itemName, entry.item);
252
+ const label = step.parallel.label ? resolveItemTemplate(step.parallel.label, itemName, entry.item) : undefined;
253
+ return {
254
+ ...step.parallel,
255
+ task,
256
+ ...(label !== undefined ? { label } : {}),
257
+ };
258
+ });
259
+ return { items, parallel };
260
+ }
261
+
262
+ export function collectDynamicResults(
263
+ step: DynamicParallelStep,
264
+ items: DynamicMaterializedItem[],
265
+ results: Array<Pick<SingleResult, "agent" | "exitCode" | "error" | "structuredOutput" | "artifactPaths" | "savedOutputPath"> & { output?: string; finalOutput?: string }>,
266
+ ): DynamicCollectedResult[] {
267
+ return items.map((entry, index) => {
268
+ const result = results[index];
269
+ const text = result
270
+ ? ("output" in result && typeof result.output === "string" ? result.output : getSingleResultOutput(result as SingleResult))
271
+ : "";
272
+ return {
273
+ key: entry.key,
274
+ index: entry.index,
275
+ item: entry.item,
276
+ agent: result?.agent ?? step.parallel.agent,
277
+ exitCode: result?.exitCode ?? null,
278
+ text,
279
+ ...(result?.structuredOutput !== undefined ? { structured: result.structuredOutput } : {}),
280
+ ...(result?.error ? { error: result.error } : {}),
281
+ ...(result?.savedOutputPath ? { outputPath: result.savedOutputPath } : {}),
282
+ ...(result?.artifactPaths ? { artifactPaths: result.artifactPaths } : {}),
283
+ };
284
+ });
285
+ }
286
+
287
+ export function validateDynamicCollection(schema: JsonSchemaObject | undefined, value: DynamicCollectedResult[]): void {
288
+ if (!schema) return;
289
+ const validation = validateStructuredOutputValue(schema, value);
290
+ if (validation.status === "invalid") {
291
+ throw new DynamicFanoutError(`Collected output validation failed: ${validation.message}`);
292
+ }
293
+ }
@@ -1,6 +1,13 @@
1
+ import type { DynamicCollectSpec, DynamicExpandSpec } from "../../shared/settings.ts";
2
+ import type { JsonSchemaObject, ResolvedAcceptanceConfig } from "../../shared/types.ts";
3
+
1
4
  export interface RunnerSubagentStep {
2
5
  agent: string;
3
6
  task: string;
7
+ phase?: string;
8
+ label?: string;
9
+ outputName?: string;
10
+ structured?: boolean;
4
11
  cwd?: string;
5
12
  model?: string;
6
13
  thinking?: string;
@@ -18,6 +25,13 @@ export interface RunnerSubagentStep {
18
25
  outputMode?: "inline" | "file-only";
19
26
  sessionFile?: string;
20
27
  maxSubagentDepth?: number;
28
+ structuredOutput?: {
29
+ schema: JsonSchemaObject;
30
+ schemaPath: string;
31
+ outputPath: string;
32
+ };
33
+ structuredOutputSchema?: JsonSchemaObject;
34
+ effectiveAcceptance?: ResolvedAcceptanceConfig;
21
35
  }
22
36
 
23
37
  export interface ParallelStepGroup {
@@ -27,17 +41,33 @@ export interface ParallelStepGroup {
27
41
  worktree?: boolean;
28
42
  }
29
43
 
30
- export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
44
+ export interface DynamicRunnerGroup {
45
+ expand: DynamicExpandSpec;
46
+ parallel: RunnerSubagentStep;
47
+ collect: DynamicCollectSpec;
48
+ concurrency?: number;
49
+ failFast?: boolean;
50
+ phase?: string;
51
+ label?: string;
52
+ }
53
+
54
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
31
55
 
32
56
  export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
33
57
  return "parallel" in step && Array.isArray(step.parallel);
34
58
  }
35
59
 
60
+ export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGroup {
61
+ return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
62
+ }
63
+
36
64
  export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
37
65
  const flat: RunnerSubagentStep[] = [];
38
66
  for (const step of steps) {
39
67
  if (isParallelGroup(step)) {
40
68
  for (const task of step.parallel) flat.push(task);
69
+ } else if (isDynamicRunnerGroup(step)) {
70
+ continue;
41
71
  } else {
42
72
  flat.push(step);
43
73
  }