@bastani/atomic 0.8.24-alpha.2 → 0.8.24-alpha.3

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 (55) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -1
  3. package/dist/builtin/intercom/CHANGELOG.md +6 -0
  4. package/dist/builtin/intercom/package.json +1 -1
  5. package/dist/builtin/mcp/CHANGELOG.md +6 -0
  6. package/dist/builtin/mcp/package.json +1 -1
  7. package/dist/builtin/subagents/CHANGELOG.md +10 -0
  8. package/dist/builtin/subagents/README.md +132 -21
  9. package/dist/builtin/subagents/package.json +1 -1
  10. package/dist/builtin/subagents/prompts/parallel-context-build.md +4 -2
  11. package/dist/builtin/subagents/prompts/parallel-handoff-plan.md +3 -1
  12. package/dist/builtin/subagents/skills/subagent/SKILL.md +49 -11
  13. package/dist/builtin/subagents/src/agents/agent-management.ts +79 -16
  14. package/dist/builtin/subagents/src/agents/agents.ts +47 -16
  15. package/dist/builtin/subagents/src/agents/chain-serializer.ts +114 -0
  16. package/dist/builtin/subagents/src/extension/schemas.ts +139 -3
  17. package/dist/builtin/subagents/src/runs/background/async-execution.ts +92 -6
  18. package/dist/builtin/subagents/src/runs/background/async-status.ts +11 -1
  19. package/dist/builtin/subagents/src/runs/background/run-status.ts +4 -1
  20. package/dist/builtin/subagents/src/runs/background/subagent-runner.ts +529 -32
  21. package/dist/builtin/subagents/src/runs/foreground/chain-execution.ts +361 -118
  22. package/dist/builtin/subagents/src/runs/foreground/execution.ts +75 -7
  23. package/dist/builtin/subagents/src/runs/foreground/subagent-executor.ts +33 -0
  24. package/dist/builtin/subagents/src/runs/shared/acceptance.ts +611 -0
  25. package/dist/builtin/subagents/src/runs/shared/chain-outputs.ts +101 -0
  26. package/dist/builtin/subagents/src/runs/shared/dynamic-fanout.ts +293 -0
  27. package/dist/builtin/subagents/src/runs/shared/parallel-utils.ts +29 -1
  28. package/dist/builtin/subagents/src/runs/shared/pi-args.ts +11 -0
  29. package/dist/builtin/subagents/src/runs/shared/structured-output.ts +79 -0
  30. package/dist/builtin/subagents/src/runs/shared/subagent-prompt-runtime.ts +52 -2
  31. package/dist/builtin/subagents/src/runs/shared/workflow-graph.ts +206 -0
  32. package/dist/builtin/subagents/src/shared/formatters.ts +2 -2
  33. package/dist/builtin/subagents/src/shared/settings.ts +53 -4
  34. package/dist/builtin/subagents/src/shared/types.ts +226 -0
  35. package/dist/builtin/subagents/src/shared/utils.ts +2 -1
  36. package/dist/builtin/subagents/src/slash/slash-commands.ts +41 -3
  37. package/dist/builtin/subagents/src/tui/render.ts +152 -34
  38. package/dist/builtin/web-access/CHANGELOG.md +6 -0
  39. package/dist/builtin/web-access/package.json +1 -1
  40. package/dist/builtin/workflows/CHANGELOG.md +6 -0
  41. package/dist/builtin/workflows/package.json +1 -1
  42. package/dist/builtin/workflows/skills/create-spec/SKILL.md +1 -1
  43. package/dist/builtin/workflows/src/tui/stage-chat-view.ts +0 -1
  44. package/dist/core/slash-commands.d.ts.map +1 -1
  45. package/dist/core/slash-commands.js +1 -0
  46. package/dist/core/slash-commands.js.map +1 -1
  47. package/dist/core/system-prompt.d.ts.map +1 -1
  48. package/dist/core/system-prompt.js +4 -3
  49. package/dist/core/system-prompt.js.map +1 -1
  50. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  51. package/dist/modes/interactive/interactive-mode.js +1 -1
  52. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  53. package/docs/usage.md +1 -0
  54. package/docs/workflows.md +173 -0
  55. package/package.json +1 -1
@@ -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
+ }
@@ -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
+ }
@@ -3,6 +3,10 @@ import type { CodexFastModeResolvedSettings, CodexFastModeScope } from "@bastani
3
3
  export interface RunnerSubagentStep {
4
4
  agent: string;
5
5
  task: string;
6
+ phase?: string;
7
+ label?: string;
8
+ outputName?: string;
9
+ structured?: boolean;
6
10
  cwd?: string;
7
11
  model?: string;
8
12
  thinking?: string;
@@ -25,6 +29,13 @@ export interface RunnerSubagentStep {
25
29
  sessionFile?: string;
26
30
  maxSubagentDepth?: number;
27
31
  workflowStageSubagentGuard?: boolean;
32
+ structuredOutput?: {
33
+ schema: import("../../shared/types.ts").JsonSchemaObject;
34
+ schemaPath: string;
35
+ outputPath: string;
36
+ };
37
+ structuredOutputSchema?: import("../../shared/types.ts").JsonSchemaObject;
38
+ effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
28
39
  }
29
40
 
30
41
  export interface ParallelStepGroup {
@@ -34,17 +45,34 @@ export interface ParallelStepGroup {
34
45
  worktree?: boolean;
35
46
  }
36
47
 
37
- export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
48
+ export interface DynamicRunnerGroup {
49
+ expand: import("../../shared/settings.ts").DynamicExpandSpec;
50
+ parallel: RunnerSubagentStep;
51
+ collect: import("../../shared/settings.ts").DynamicCollectSpec;
52
+ concurrency?: number;
53
+ failFast?: boolean;
54
+ phase?: string;
55
+ label?: string;
56
+ effectiveAcceptance?: import("../../shared/types.ts").ResolvedAcceptanceConfig;
57
+ }
58
+
59
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
38
60
 
39
61
  export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
40
62
  return "parallel" in step && Array.isArray(step.parallel);
41
63
  }
42
64
 
65
+ export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGroup {
66
+ return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
67
+ }
68
+
43
69
  export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
44
70
  const flat: RunnerSubagentStep[] = [];
45
71
  for (const step of steps) {
46
72
  if (isParallelGroup(step)) {
47
73
  for (const task of step.parallel) flat.push(task);
74
+ } else if (isDynamicRunnerGroup(step)) {
75
+ continue;
48
76
  } else {
49
77
  flat.push(step);
50
78
  }
@@ -11,6 +11,8 @@ import {
11
11
  } from "@bastani/atomic";
12
12
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
13
13
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
14
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
15
+ import type { JsonSchemaObject } from "../../shared/types.ts";
14
16
 
15
17
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
16
18
  const TASK_ARG_LIMIT = 8000;
@@ -68,6 +70,11 @@ interface BuildPiArgsInput {
68
70
  parentCapabilityToken?: string;
69
71
  codexFastModeSettings?: CodexFastModeResolvedSettings;
70
72
  codexFastModeScope?: CodexFastModeScope;
73
+ structuredOutput?: {
74
+ schema: JsonSchemaObject;
75
+ schemaPath: string;
76
+ outputPath: string;
77
+ };
71
78
  }
72
79
 
73
80
  interface BuildPiArgsResult {
@@ -247,6 +254,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
247
254
  } else {
248
255
  env.MCP_DIRECT_TOOLS = "__none__";
249
256
  }
257
+ if (input.structuredOutput) {
258
+ env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
259
+ env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
260
+ }
250
261
 
251
262
  return { args, env, tempDir };
252
263
  }
@@ -0,0 +1,79 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { APP_NAME } from "@bastani/atomic";
5
+ import { Compile } from "typebox/compile";
6
+ import type { JsonSchemaObject } from "../../shared/types.ts";
7
+
8
+ const ENV_PREFIX = APP_NAME.toUpperCase();
9
+ export const STRUCTURED_OUTPUT_SCHEMA_ENV = `${ENV_PREFIX}_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA`;
10
+ export const STRUCTURED_OUTPUT_CAPTURE_ENV = `${ENV_PREFIX}_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE`;
11
+
12
+ export interface StructuredOutputRuntime {
13
+ schema: JsonSchemaObject;
14
+ schemaPath: string;
15
+ outputPath: string;
16
+ }
17
+
18
+ interface CompiledJsonSchema {
19
+ Check(value: unknown): boolean;
20
+ Errors(value: unknown): Iterable<{ instancePath?: string; message?: string }>;
21
+ }
22
+
23
+ export function assertJsonSchemaObject(schema: unknown, label = "outputSchema"): asserts schema is JsonSchemaObject {
24
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
25
+ throw new Error(`${label} must be a JSON Schema object.`);
26
+ }
27
+ }
28
+
29
+ export function createStructuredOutputRuntime(schema: JsonSchemaObject, baseDir?: string): StructuredOutputRuntime {
30
+ assertJsonSchemaObject(schema);
31
+ const rootDir = baseDir ?? os.tmpdir();
32
+ fs.mkdirSync(rootDir, { recursive: true });
33
+ const dir = fs.mkdtempSync(path.join(rootDir, "pi-subagent-structured-"));
34
+ const schemaPath = path.join(dir, "schema.json");
35
+ const outputPath = path.join(dir, "output.json");
36
+ fs.writeFileSync(schemaPath, JSON.stringify(schema), { mode: 0o600 });
37
+ return { schema, schemaPath, outputPath };
38
+ }
39
+
40
+ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): { status: "valid" } | { status: "invalid"; message: string } {
41
+ let validator: CompiledJsonSchema;
42
+ try {
43
+ validator = (Compile as (schema: unknown) => CompiledJsonSchema)(schema);
44
+ } catch (error) {
45
+ return { status: "invalid", message: `invalid outputSchema: ${error instanceof Error ? error.message : String(error)}` };
46
+ }
47
+ if (validator.Check(value)) return { status: "valid" };
48
+ const errors = [...validator.Errors(value)]
49
+ .slice(0, 8)
50
+ .map((error) => {
51
+ const pathText = error.instancePath ? error.instancePath.replace(/^\//, "").replace(/\//g, ".") : "root";
52
+ return `${pathText}: ${error.message}`;
53
+ });
54
+ return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
55
+ }
56
+
57
+ export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
58
+ if (!fs.existsSync(runtime.outputPath)) {
59
+ return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
60
+ }
61
+ let value: unknown;
62
+ try {
63
+ value = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
64
+ } catch (error) {
65
+ return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
66
+ }
67
+ const validation = validateStructuredOutputValue(runtime.schema, value);
68
+ if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
69
+ return { value };
70
+ }
71
+
72
+ export function cleanupStructuredOutputRuntime(runtime: StructuredOutputRuntime | undefined): void {
73
+ if (!runtime) return;
74
+ try {
75
+ fs.rmSync(path.dirname(runtime.schemaPath), { recursive: true, force: true });
76
+ } catch {
77
+ // Best-effort temp cleanup.
78
+ }
79
+ }
@@ -1,3 +1,5 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { ExtensionAPI } from "@bastani/atomic";
2
4
  import { getEnvValue } from "@bastani/atomic";
3
5
  import {
@@ -6,9 +8,17 @@ import {
6
8
  SUBAGENT_INHERIT_SKILLS_ENV,
7
9
  SUBAGENT_INTERCOM_SESSION_NAME_ENV,
8
10
  } from "./pi-args.ts";
11
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
12
+ import type { JsonSchemaObject } from "../../shared/types.ts";
9
13
 
10
14
  export { SUBAGENT_INTERCOM_SESSION_NAME_ENV } from "./pi-args.ts";
11
15
 
16
+ const STRUCTURED_OUTPUT_INSTRUCTIONS = [
17
+ "This subagent step has a strict structured output contract.",
18
+ "Your final action must be to call the `structured_output` tool with JSON matching the provided schema.",
19
+ "Do not rely on prose-only completion; if you do not call `structured_output`, the parent will fail this step.",
20
+ ].join("\n");
21
+
12
22
  export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
13
23
  "You are a child subagent, not the parent orchestrator.",
14
24
  "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
@@ -98,7 +108,8 @@ export function rewriteSubagentPrompt(
98
108
  rewritten = stripSubagentOrchestrationSkill(rewritten);
99
109
  rewritten = stripChildBoundaryInstructions(rewritten);
100
110
  const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
101
- return `${boundary}\n\n${rewritten}`;
111
+ const structured = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV] ? `\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}` : "";
112
+ return `${boundary}${structured}\n\n${rewritten}`;
102
113
  }
103
114
 
104
115
  function isParentOnlySubagentMessage(message: unknown): boolean {
@@ -147,7 +158,46 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
147
158
  }
148
159
 
149
160
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
150
- pi.on("context", (event) => {
161
+ const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
162
+ const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
163
+ if (structuredOutputPath && structuredSchemaPath) {
164
+ const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
165
+ const parameters = {
166
+ type: "object",
167
+ properties: { value: schema },
168
+ required: ["value"],
169
+ additionalProperties: false,
170
+ };
171
+ const registerTool = pi.registerTool as unknown as (tool: {
172
+ name: string;
173
+ label: string;
174
+ description: string;
175
+ parameters: unknown;
176
+ execute: (_id: string, params: { value: unknown }) => Promise<unknown>;
177
+ }) => void;
178
+ registerTool({
179
+ name: "structured_output",
180
+ label: "Structured Output",
181
+ description: "Submit the required final structured output for this subagent step. This terminates the step.",
182
+ parameters: parameters as never,
183
+ async execute(_id: string, params: { value: unknown }) {
184
+ const validation = validateStructuredOutputValue(schema, params.value);
185
+ if (validation.status === "invalid") {
186
+ throw new Error(`Structured output validation failed: ${validation.message}`);
187
+ }
188
+ fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
189
+ fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
190
+ return {
191
+ content: [{ type: "text", text: "Structured output captured." }],
192
+ details: { path: structuredOutputPath },
193
+ terminate: true,
194
+ };
195
+ },
196
+ });
197
+ }
198
+
199
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
200
+ onRuntimeEvent("context", (event: { messages: unknown[] }) => {
151
201
  const messages = stripParentOnlySubagentMessages(event.messages);
152
202
  if (messages === event.messages) return undefined;
153
203
  return { messages };