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
@@ -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,15 @@ export interface RunnerSubagentStep {
18
25
  outputMode?: "inline" | "file-only";
19
26
  sessionFile?: string;
20
27
  maxSubagentDepth?: number;
28
+ maxExecutionTimeMs?: number;
29
+ maxTokens?: number;
30
+ structuredOutput?: {
31
+ schema: JsonSchemaObject;
32
+ schemaPath: string;
33
+ outputPath: string;
34
+ };
35
+ structuredOutputSchema?: JsonSchemaObject;
36
+ effectiveAcceptance?: ResolvedAcceptanceConfig;
21
37
  }
22
38
 
23
39
  export interface ParallelStepGroup {
@@ -27,17 +43,33 @@ export interface ParallelStepGroup {
27
43
  worktree?: boolean;
28
44
  }
29
45
 
30
- export type RunnerStep = RunnerSubagentStep | ParallelStepGroup;
46
+ export interface DynamicRunnerGroup {
47
+ expand: DynamicExpandSpec;
48
+ parallel: RunnerSubagentStep;
49
+ collect: DynamicCollectSpec;
50
+ concurrency?: number;
51
+ failFast?: boolean;
52
+ phase?: string;
53
+ label?: string;
54
+ }
55
+
56
+ export type RunnerStep = RunnerSubagentStep | ParallelStepGroup | DynamicRunnerGroup;
31
57
 
32
58
  export function isParallelGroup(step: RunnerStep): step is ParallelStepGroup {
33
59
  return "parallel" in step && Array.isArray(step.parallel);
34
60
  }
35
61
 
62
+ export function isDynamicRunnerGroup(step: RunnerStep): step is DynamicRunnerGroup {
63
+ return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
64
+ }
65
+
36
66
  export function flattenSteps(steps: RunnerStep[]): RunnerSubagentStep[] {
37
67
  const flat: RunnerSubagentStep[] = [];
38
68
  for (const step of steps) {
39
69
  if (isParallelGroup(step)) {
40
70
  for (const task of step.parallel) flat.push(task);
71
+ } else if (isDynamicRunnerGroup(step)) {
72
+ continue;
41
73
  } else {
42
74
  flat.push(step);
43
75
  }
@@ -4,6 +4,8 @@ import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { encodeNestedPathEnv, parseNestedPathEnv, type NestedPathEntry } from "./nested-path.ts";
6
6
  import { resolveMcpDirectToolNames } from "./mcp-direct-tool-allowlist.ts";
7
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV } from "./structured-output.ts";
8
+ import type { JsonSchemaObject } from "../../shared/types.ts";
7
9
 
8
10
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"];
9
11
  const TASK_ARG_LIMIT = 8000;
@@ -54,6 +56,11 @@ interface BuildPiArgsInput {
54
56
  parentDepth?: number;
55
57
  parentPath?: NestedPathEntry[];
56
58
  parentCapabilityToken?: string;
59
+ structuredOutput?: {
60
+ schema: JsonSchemaObject;
61
+ schemaPath: string;
62
+ outputPath: string;
63
+ };
57
64
  }
58
65
 
59
66
  interface BuildPiArgsResult {
@@ -204,6 +211,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
204
211
  } else {
205
212
  env.MCP_DIRECT_TOOLS = "__none__";
206
213
  }
214
+ if (input.structuredOutput) {
215
+ env[STRUCTURED_OUTPUT_CAPTURE_ENV] = input.structuredOutput.outputPath;
216
+ env[STRUCTURED_OUTPUT_SCHEMA_ENV] = input.structuredOutput.schemaPath;
217
+ }
207
218
 
208
219
  return { args, env, tempDir };
209
220
  }
@@ -0,0 +1,77 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { Compile } from "typebox/compile";
5
+ import type { JsonSchemaObject } from "../../shared/types.ts";
6
+
7
+ export const STRUCTURED_OUTPUT_SCHEMA_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_SCHEMA";
8
+ export const STRUCTURED_OUTPUT_CAPTURE_ENV = "PI_SUBAGENT_STRUCTURED_OUTPUT_CAPTURE";
9
+
10
+ export interface StructuredOutputRuntime {
11
+ schema: JsonSchemaObject;
12
+ schemaPath: string;
13
+ outputPath: string;
14
+ }
15
+
16
+ interface CompiledJsonSchema {
17
+ Check(value: unknown): boolean;
18
+ Errors(value: unknown): Iterable<{ instancePath?: string; message?: string }>;
19
+ }
20
+
21
+ export function assertJsonSchemaObject(schema: unknown, label = "outputSchema"): asserts schema is JsonSchemaObject {
22
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
23
+ throw new Error(`${label} must be a JSON Schema object.`);
24
+ }
25
+ }
26
+
27
+ export function createStructuredOutputRuntime(schema: JsonSchemaObject, baseDir?: string): StructuredOutputRuntime {
28
+ assertJsonSchemaObject(schema);
29
+ const rootDir = baseDir ?? os.tmpdir();
30
+ fs.mkdirSync(rootDir, { recursive: true });
31
+ const dir = fs.mkdtempSync(path.join(rootDir, "pi-subagent-structured-"));
32
+ const schemaPath = path.join(dir, "schema.json");
33
+ const outputPath = path.join(dir, "output.json");
34
+ fs.writeFileSync(schemaPath, JSON.stringify(schema), { mode: 0o600 });
35
+ return { schema, schemaPath, outputPath };
36
+ }
37
+
38
+ export function validateStructuredOutputValue(schema: JsonSchemaObject, value: unknown): { status: "valid" } | { status: "invalid"; message: string } {
39
+ let validator: CompiledJsonSchema;
40
+ try {
41
+ validator = (Compile as (schema: unknown) => CompiledJsonSchema)(schema);
42
+ } catch (error) {
43
+ return { status: "invalid", message: `invalid outputSchema: ${error instanceof Error ? error.message : String(error)}` };
44
+ }
45
+ if (validator.Check(value)) return { status: "valid" };
46
+ const errors = [...validator.Errors(value)]
47
+ .slice(0, 8)
48
+ .map((error) => {
49
+ const pathText = error.instancePath ? error.instancePath.replace(/^\//, "").replace(/\//g, ".") : "root";
50
+ return `${pathText}: ${error.message}`;
51
+ });
52
+ return { status: "invalid", message: errors.join("; ") || "schema validation failed" };
53
+ }
54
+
55
+ export function readStructuredOutput(runtime: StructuredOutputRuntime): { value?: unknown; error?: string } {
56
+ if (!fs.existsSync(runtime.outputPath)) {
57
+ return { error: "Missing structured_output call; this step has outputSchema and must finish by calling structured_output." };
58
+ }
59
+ let value: unknown;
60
+ try {
61
+ value = JSON.parse(fs.readFileSync(runtime.outputPath, "utf-8"));
62
+ } catch (error) {
63
+ return { error: `Failed to read structured output: ${error instanceof Error ? error.message : String(error)}` };
64
+ }
65
+ const validation = validateStructuredOutputValue(runtime.schema, value);
66
+ if (validation.status === "invalid") return { error: `Structured output validation failed: ${validation.message}` };
67
+ return { value };
68
+ }
69
+
70
+ export function cleanupStructuredOutputRuntime(runtime: StructuredOutputRuntime | undefined): void {
71
+ if (!runtime) return;
72
+ try {
73
+ fs.rmSync(path.dirname(runtime.schemaPath), { recursive: true, force: true });
74
+ } catch {
75
+ // Best-effort temp cleanup.
76
+ }
77
+ }
@@ -1,10 +1,20 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
4
  import { SUBAGENT_FANOUT_CHILD_ENV } from "./pi-args.ts";
5
+ import { STRUCTURED_OUTPUT_CAPTURE_ENV, STRUCTURED_OUTPUT_SCHEMA_ENV, validateStructuredOutputValue } from "./structured-output.ts";
6
+ import type { JsonSchemaObject } from "../../shared/types.ts";
3
7
 
4
8
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
5
9
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
6
10
  export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
7
11
 
12
+ const STRUCTURED_OUTPUT_INSTRUCTIONS = [
13
+ "This subagent step has a strict structured output contract.",
14
+ "Your final action must be to call the `structured_output` tool with JSON matching the provided schema.",
15
+ "Do not rely on prose-only completion; if you do not call `structured_output`, the parent will fail this step.",
16
+ ].join("\n");
17
+
8
18
  export const CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS = [
9
19
  "You are a child subagent, not the parent orchestrator.",
10
20
  "The parent session owns delegation, orchestration, review fanout, and follow-up worker launches.",
@@ -94,7 +104,8 @@ export function rewriteSubagentPrompt(
94
104
  rewritten = stripSubagentOrchestrationSkill(rewritten);
95
105
  rewritten = stripChildBoundaryInstructions(rewritten);
96
106
  const boundary = options.fanoutChild ? CHILD_FANOUT_BOUNDARY_INSTRUCTIONS : CHILD_SUBAGENT_BOUNDARY_INSTRUCTIONS;
97
- return `${boundary}\n\n${rewritten}`;
107
+ const structured = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV] ? `\n\n${STRUCTURED_OUTPUT_INSTRUCTIONS}` : "";
108
+ return `${boundary}${structured}\n\n${rewritten}`;
98
109
  }
99
110
 
100
111
  function isParentOnlySubagentMessage(message: unknown): boolean {
@@ -143,13 +154,52 @@ export function stripParentOnlySubagentMessages(messages: unknown[]): unknown[]
143
154
  }
144
155
 
145
156
  export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
146
- pi.on("context", (event) => {
157
+ const structuredOutputPath = process.env[STRUCTURED_OUTPUT_CAPTURE_ENV];
158
+ const structuredSchemaPath = process.env[STRUCTURED_OUTPUT_SCHEMA_ENV];
159
+ if (structuredOutputPath && structuredSchemaPath) {
160
+ const schema = JSON.parse(fs.readFileSync(structuredSchemaPath, "utf-8")) as JsonSchemaObject;
161
+ const parameters = {
162
+ type: "object",
163
+ properties: { value: schema },
164
+ required: ["value"],
165
+ additionalProperties: false,
166
+ };
167
+ const registerTool = pi.registerTool as unknown as (tool: {
168
+ name: string;
169
+ label: string;
170
+ description: string;
171
+ parameters: unknown;
172
+ execute: (_id: string, params: { value: unknown }) => Promise<unknown>;
173
+ }) => void;
174
+ registerTool({
175
+ name: "structured_output",
176
+ label: "Structured Output",
177
+ description: "Submit the required final structured output for this subagent step. This terminates the step.",
178
+ parameters: parameters as never,
179
+ async execute(_id: string, params: { value: unknown }) {
180
+ const validation = validateStructuredOutputValue(schema, params.value);
181
+ if (validation.status === "invalid") {
182
+ throw new Error(`Structured output validation failed: ${validation.message}`);
183
+ }
184
+ fs.mkdirSync(path.dirname(structuredOutputPath), { recursive: true });
185
+ fs.writeFileSync(structuredOutputPath, JSON.stringify(params.value), { mode: 0o600 });
186
+ return {
187
+ content: [{ type: "text", text: "Structured output captured." }],
188
+ details: { path: structuredOutputPath },
189
+ terminate: true,
190
+ };
191
+ },
192
+ });
193
+ }
194
+
195
+ const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown) => unknown) => void;
196
+ onRuntimeEvent("context", (event: { messages: unknown[] }) => {
147
197
  const messages = stripParentOnlySubagentMessages(event.messages);
148
198
  if (messages === event.messages) return undefined;
149
199
  return { messages };
150
200
  });
151
201
 
152
- pi.on("before_agent_start", async (event) => {
202
+ onRuntimeEvent("before_agent_start", async (event: { systemPrompt: string }) => {
153
203
  const intercomSessionName = process.env[SUBAGENT_INTERCOM_SESSION_NAME_ENV]?.trim();
154
204
  if (intercomSessionName && typeof pi.setSessionName === "function") {
155
205
  pi.setSessionName(intercomSessionName);