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.
- package/CHANGELOG.md +21 -0
- package/README.md +129 -17
- package/package.json +1 -1
- package/prompts/parallel-context-build.md +3 -1
- package/prompts/parallel-handoff-plan.md +3 -1
- package/skills/pi-subagents/SKILL.md +32 -17
- package/src/agents/agent-management.ts +57 -15
- package/src/agents/agent-serializer.ts +3 -2
- package/src/agents/agents.ts +47 -16
- package/src/agents/chain-serializer.ts +120 -0
- package/src/extension/fanout-child.ts +1 -0
- package/src/extension/index.ts +1 -0
- package/src/extension/schemas.ts +138 -5
- package/src/runs/background/async-execution.ts +84 -6
- package/src/runs/background/async-status.ts +11 -1
- package/src/runs/background/run-status.ts +10 -1
- package/src/runs/background/subagent-runner.ts +600 -31
- package/src/runs/foreground/chain-execution.ts +325 -118
- package/src/runs/foreground/execution.ts +222 -10
- package/src/runs/foreground/subagent-executor.ts +67 -0
- package/src/runs/shared/acceptance-contract.ts +291 -0
- package/src/runs/shared/acceptance-evaluation.ts +221 -0
- package/src/runs/shared/acceptance-finalization.ts +161 -0
- package/src/runs/shared/acceptance-reports.ts +127 -0
- package/src/runs/shared/acceptance.ts +22 -0
- package/src/runs/shared/chain-outputs.ts +101 -0
- package/src/runs/shared/completion-guard.ts +26 -3
- package/src/runs/shared/dynamic-fanout.ts +293 -0
- package/src/runs/shared/parallel-utils.ts +31 -1
- package/src/runs/shared/pi-args.ts +11 -0
- package/src/runs/shared/structured-output.ts +77 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
- package/src/runs/shared/workflow-graph.ts +206 -0
- package/src/shared/formatters.ts +2 -2
- package/src/shared/settings.ts +53 -4
- package/src/shared/types.ts +250 -0
- package/src/slash/slash-commands.ts +41 -3
- package/src/tui/render.ts +162 -34
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { isDynamicParallelStep, isParallelStep, type ChainStep, type SequentialStep } from "../../shared/settings.ts";
|
|
2
|
+
import type { SingleResult, SubagentRunMode, WorkflowGraphNode, WorkflowGraphSnapshot, WorkflowNodeStatus } from "../../shared/types.ts";
|
|
3
|
+
|
|
4
|
+
export interface WorkflowGraphBuildInput {
|
|
5
|
+
runId: string;
|
|
6
|
+
mode?: SubagentRunMode;
|
|
7
|
+
steps: ChainStep[];
|
|
8
|
+
results?: Array<Pick<SingleResult, "exitCode" | "detached" | "interrupted" | "error" | "acceptance">>;
|
|
9
|
+
currentFlatIndex?: number;
|
|
10
|
+
currentStepIndex?: number;
|
|
11
|
+
stepStatuses?: Array<{ status?: string; error?: string }>;
|
|
12
|
+
dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
|
|
13
|
+
dynamicGroupStatuses?: Record<number, { status: WorkflowNodeStatus; error?: string; acceptance?: SingleResult["acceptance"] }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeStatus(status: string | undefined): WorkflowNodeStatus | undefined {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case "complete":
|
|
19
|
+
case "completed":
|
|
20
|
+
return "completed";
|
|
21
|
+
case "running":
|
|
22
|
+
return "running";
|
|
23
|
+
case "failed":
|
|
24
|
+
return "failed";
|
|
25
|
+
case "paused":
|
|
26
|
+
return "paused";
|
|
27
|
+
case "detached":
|
|
28
|
+
return "detached";
|
|
29
|
+
case "pending":
|
|
30
|
+
return "pending";
|
|
31
|
+
default:
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resultStatus(result: Pick<SingleResult, "exitCode" | "detached" | "interrupted"> | undefined): WorkflowNodeStatus | undefined {
|
|
37
|
+
if (!result) return undefined;
|
|
38
|
+
if (result.detached) return "detached";
|
|
39
|
+
if (result.interrupted) return "paused";
|
|
40
|
+
return result.exitCode === 0 ? "completed" : "failed";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nodeStatus(input: WorkflowGraphBuildInput, flatIndex: number): WorkflowNodeStatus {
|
|
44
|
+
return normalizeStatus(input.stepStatuses?.[flatIndex]?.status)
|
|
45
|
+
?? resultStatus(input.results?.[flatIndex])
|
|
46
|
+
?? (input.currentFlatIndex === flatIndex ? "running" : "pending");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pushPhase(phases: WorkflowGraphSnapshot["phases"], phase: string | undefined, nodeId: string): void {
|
|
50
|
+
if (!phase) return;
|
|
51
|
+
let group = phases.find((candidate) => candidate.title === phase);
|
|
52
|
+
if (!group) {
|
|
53
|
+
group = { title: phase, nodeIds: [] };
|
|
54
|
+
phases.push(group);
|
|
55
|
+
}
|
|
56
|
+
group.nodeIds.push(nodeId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function seqLabel(step: SequentialStep, stepIndex: number): string {
|
|
60
|
+
return step.label?.trim() || step.agent || `Step ${stepIndex + 1}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function summarizeParallelStatuses(statuses: WorkflowNodeStatus[]): WorkflowNodeStatus {
|
|
64
|
+
if (statuses.some((status) => status === "running")) return "running";
|
|
65
|
+
if (statuses.some((status) => status === "failed")) return "failed";
|
|
66
|
+
if (statuses.some((status) => status === "paused")) return "paused";
|
|
67
|
+
if (statuses.some((status) => status === "detached")) return "detached";
|
|
68
|
+
if (statuses.length > 0 && statuses.every((status) => status === "completed")) return "completed";
|
|
69
|
+
if (statuses.some((status) => status === "completed")) return "running";
|
|
70
|
+
return "pending";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildWorkflowGraphSnapshot(input: WorkflowGraphBuildInput): WorkflowGraphSnapshot {
|
|
74
|
+
const nodes: WorkflowGraphNode[] = [];
|
|
75
|
+
const phases: WorkflowGraphSnapshot["phases"] = [];
|
|
76
|
+
let flatIndex = 0;
|
|
77
|
+
let currentNodeId: string | undefined;
|
|
78
|
+
|
|
79
|
+
for (let stepIndex = 0; stepIndex < input.steps.length; stepIndex++) {
|
|
80
|
+
const step = input.steps[stepIndex]!;
|
|
81
|
+
if (isParallelStep(step)) {
|
|
82
|
+
const groupId = `step-${stepIndex}`;
|
|
83
|
+
const children: WorkflowGraphNode[] = [];
|
|
84
|
+
const childStatuses: WorkflowNodeStatus[] = [];
|
|
85
|
+
for (let taskIndex = 0; taskIndex < step.parallel.length; taskIndex++) {
|
|
86
|
+
const task = step.parallel[taskIndex]!;
|
|
87
|
+
const status = nodeStatus(input, flatIndex);
|
|
88
|
+
childStatuses.push(status);
|
|
89
|
+
const childId = `step-${stepIndex}-agent-${taskIndex}`;
|
|
90
|
+
const child: WorkflowGraphNode = {
|
|
91
|
+
id: childId,
|
|
92
|
+
kind: "agent",
|
|
93
|
+
agent: task.agent,
|
|
94
|
+
phase: task.phase,
|
|
95
|
+
label: task.label?.trim() || task.agent || `Agent ${taskIndex + 1}`,
|
|
96
|
+
status,
|
|
97
|
+
flatIndex,
|
|
98
|
+
stepIndex,
|
|
99
|
+
outputName: task.as,
|
|
100
|
+
structured: Boolean(task.outputSchema),
|
|
101
|
+
acceptanceStatus: input.results?.[flatIndex]?.acceptance?.status,
|
|
102
|
+
error: input.stepStatuses?.[flatIndex]?.error ?? input.results?.[flatIndex]?.error,
|
|
103
|
+
};
|
|
104
|
+
children.push(child);
|
|
105
|
+
pushPhase(phases, task.phase, childId);
|
|
106
|
+
if (status === "running" || input.currentFlatIndex === flatIndex) currentNodeId = childId;
|
|
107
|
+
flatIndex++;
|
|
108
|
+
}
|
|
109
|
+
const groupStatus = summarizeParallelStatuses(childStatuses);
|
|
110
|
+
if (input.currentStepIndex === stepIndex && !currentNodeId) currentNodeId = groupId;
|
|
111
|
+
nodes.push({
|
|
112
|
+
id: groupId,
|
|
113
|
+
kind: "parallel-group",
|
|
114
|
+
label: step.parallel.length === 1 ? "Parallel task" : `Parallel group (${step.parallel.length})`,
|
|
115
|
+
status: groupStatus,
|
|
116
|
+
stepIndex,
|
|
117
|
+
children,
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isDynamicParallelStep(step)) {
|
|
123
|
+
const groupId = `step-${stepIndex}`;
|
|
124
|
+
const materialized = input.dynamicChildren?.[stepIndex] ?? [];
|
|
125
|
+
const groupOverride = input.dynamicGroupStatuses?.[stepIndex];
|
|
126
|
+
const children: WorkflowGraphNode[] = [];
|
|
127
|
+
const childStatuses: WorkflowNodeStatus[] = [];
|
|
128
|
+
for (let taskIndex = 0; taskIndex < materialized.length; taskIndex++) {
|
|
129
|
+
const task = materialized[taskIndex]!;
|
|
130
|
+
const status = nodeStatus(input, task.flatIndex);
|
|
131
|
+
childStatuses.push(status);
|
|
132
|
+
const childId = `step-${stepIndex}-item-${task.itemKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
133
|
+
const child: WorkflowGraphNode = {
|
|
134
|
+
id: childId,
|
|
135
|
+
kind: "agent",
|
|
136
|
+
agent: task.agent,
|
|
137
|
+
phase: step.parallel.phase ?? step.phase,
|
|
138
|
+
label: task.label?.trim() || step.parallel.label?.trim() || `${task.agent} ${task.itemKey}`,
|
|
139
|
+
status,
|
|
140
|
+
flatIndex: task.flatIndex,
|
|
141
|
+
stepIndex,
|
|
142
|
+
itemKey: task.itemKey,
|
|
143
|
+
outputName: task.outputName,
|
|
144
|
+
structured: task.structured,
|
|
145
|
+
acceptanceStatus: input.results?.[task.flatIndex]?.acceptance?.status,
|
|
146
|
+
error: input.stepStatuses?.[task.flatIndex]?.error ?? input.results?.[task.flatIndex]?.error ?? task.error,
|
|
147
|
+
};
|
|
148
|
+
children.push(child);
|
|
149
|
+
pushPhase(phases, child.phase, childId);
|
|
150
|
+
if (status === "running" || input.currentFlatIndex === task.flatIndex) currentNodeId = childId;
|
|
151
|
+
}
|
|
152
|
+
const groupStatus = groupOverride?.status ?? (children.length > 0 ? summarizeParallelStatuses(childStatuses) : (input.currentStepIndex === stepIndex ? "running" : "pending"));
|
|
153
|
+
if (input.currentStepIndex === stepIndex && !currentNodeId) currentNodeId = groupId;
|
|
154
|
+
nodes.push({
|
|
155
|
+
id: groupId,
|
|
156
|
+
kind: "dynamic-parallel-group",
|
|
157
|
+
label: step.label?.trim() || step.parallel.label?.trim() || `Dynamic fanout (${step.collect.as})`,
|
|
158
|
+
status: groupStatus,
|
|
159
|
+
stepIndex,
|
|
160
|
+
outputName: step.collect.as,
|
|
161
|
+
structured: Boolean(step.collect.outputSchema),
|
|
162
|
+
acceptanceStatus: groupOverride?.acceptance?.status,
|
|
163
|
+
error: groupOverride?.error,
|
|
164
|
+
dynamic: {
|
|
165
|
+
sourceOutput: step.expand.from.output,
|
|
166
|
+
sourcePath: step.expand.from.path,
|
|
167
|
+
itemName: step.expand.item ?? "item",
|
|
168
|
+
maxItems: step.expand.maxItems,
|
|
169
|
+
collectAs: step.collect.as,
|
|
170
|
+
},
|
|
171
|
+
children,
|
|
172
|
+
});
|
|
173
|
+
if (materialized.length > 0) flatIndex = Math.max(flatIndex, ...materialized.map((child) => child.flatIndex + 1));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const seq = step as SequentialStep;
|
|
178
|
+
const status = nodeStatus(input, flatIndex);
|
|
179
|
+
const id = `step-${stepIndex}`;
|
|
180
|
+
nodes.push({
|
|
181
|
+
id,
|
|
182
|
+
kind: "step",
|
|
183
|
+
agent: seq.agent,
|
|
184
|
+
phase: seq.phase,
|
|
185
|
+
label: seqLabel(seq, stepIndex),
|
|
186
|
+
status,
|
|
187
|
+
flatIndex,
|
|
188
|
+
stepIndex,
|
|
189
|
+
outputName: seq.as,
|
|
190
|
+
structured: Boolean(seq.outputSchema),
|
|
191
|
+
acceptanceStatus: input.results?.[flatIndex]?.acceptance?.status,
|
|
192
|
+
error: input.stepStatuses?.[flatIndex]?.error ?? input.results?.[flatIndex]?.error,
|
|
193
|
+
});
|
|
194
|
+
pushPhase(phases, seq.phase, id);
|
|
195
|
+
if (status === "running" || input.currentFlatIndex === flatIndex || input.currentStepIndex === stepIndex) currentNodeId = id;
|
|
196
|
+
flatIndex++;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
runId: input.runId,
|
|
201
|
+
mode: input.mode ?? "chain",
|
|
202
|
+
phases,
|
|
203
|
+
nodes,
|
|
204
|
+
currentNodeId,
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/shared/formatters.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import type { Usage, SingleResult } from "./types.ts";
|
|
8
8
|
import type { ChainStep } from "./settings.ts";
|
|
9
|
-
import { isParallelStep } from "./settings.ts";
|
|
9
|
+
import { isDynamicParallelStep, isParallelStep } from "./settings.ts";
|
|
10
10
|
import { splitKnownThinkingSuffix, THINKING_LEVELS } from "./model-info.ts";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -63,7 +63,7 @@ export function buildChainSummary(
|
|
|
63
63
|
failedStep?: { index: number; error: string },
|
|
64
64
|
): string {
|
|
65
65
|
const stepNames = steps
|
|
66
|
-
.map((step) => (isParallelStep(step) ? `parallel[${step.parallel.length}]` : step.agent))
|
|
66
|
+
.map((step) => (isParallelStep(step) ? `parallel[${step.parallel.length}]` : isDynamicParallelStep(step) ? `expand:${step.parallel.agent}` : step.agent))
|
|
67
67
|
.join(" → ");
|
|
68
68
|
|
|
69
69
|
const totalDuration = results.reduce((sum, r) => sum + (r.progress?.durationMs || 0), 0);
|
package/src/shared/settings.ts
CHANGED
|
@@ -6,7 +6,7 @@ import * as fs from "node:fs";
|
|
|
6
6
|
import * as path from "node:path";
|
|
7
7
|
import type { AgentConfig } from "../agents/agents.ts";
|
|
8
8
|
import { normalizeSkillInput } from "../agents/skills.ts";
|
|
9
|
-
import { CHAIN_RUNS_DIR, type OutputMode } from "./types.ts";
|
|
9
|
+
import { CHAIN_RUNS_DIR, type AcceptanceInput, type JsonSchemaObject, type OutputMode } from "./types.ts";
|
|
10
10
|
const CHAIN_DIR_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
11
11
|
const INITIAL_PROGRESS_CONTENT = "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n";
|
|
12
12
|
|
|
@@ -44,6 +44,10 @@ function normalizeOutputOverride(output: string | false | undefined): string | f
|
|
|
44
44
|
export interface SequentialStep {
|
|
45
45
|
agent: string;
|
|
46
46
|
task?: string;
|
|
47
|
+
phase?: string;
|
|
48
|
+
label?: string;
|
|
49
|
+
as?: string;
|
|
50
|
+
outputSchema?: JsonSchemaObject;
|
|
47
51
|
cwd?: string;
|
|
48
52
|
output?: string | false;
|
|
49
53
|
outputMode?: OutputMode;
|
|
@@ -51,12 +55,17 @@ export interface SequentialStep {
|
|
|
51
55
|
progress?: boolean;
|
|
52
56
|
skill?: string | string[] | false;
|
|
53
57
|
model?: string;
|
|
58
|
+
acceptance?: AcceptanceInput;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
/** Parallel task item within a parallel step */
|
|
57
|
-
interface ParallelTaskItem {
|
|
62
|
+
export interface ParallelTaskItem {
|
|
58
63
|
agent: string;
|
|
59
64
|
task?: string;
|
|
65
|
+
phase?: string;
|
|
66
|
+
label?: string;
|
|
67
|
+
as?: string;
|
|
68
|
+
outputSchema?: JsonSchemaObject;
|
|
60
69
|
cwd?: string;
|
|
61
70
|
count?: number;
|
|
62
71
|
output?: string | false;
|
|
@@ -65,18 +74,48 @@ interface ParallelTaskItem {
|
|
|
65
74
|
progress?: boolean;
|
|
66
75
|
skill?: string | string[] | false;
|
|
67
76
|
model?: string;
|
|
77
|
+
acceptance?: AcceptanceInput;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface DynamicExpandSpec {
|
|
81
|
+
from: {
|
|
82
|
+
output: string;
|
|
83
|
+
path: string;
|
|
84
|
+
};
|
|
85
|
+
item?: string;
|
|
86
|
+
key?: string;
|
|
87
|
+
maxItems?: number;
|
|
88
|
+
onEmpty?: "skip" | "fail";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type DynamicParallelTemplate = Omit<ParallelTaskItem, "as" | "count">;
|
|
92
|
+
|
|
93
|
+
export interface DynamicCollectSpec {
|
|
94
|
+
as: string;
|
|
95
|
+
outputSchema?: JsonSchemaObject;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DynamicParallelStep {
|
|
99
|
+
expand: DynamicExpandSpec;
|
|
100
|
+
parallel: DynamicParallelTemplate;
|
|
101
|
+
collect: DynamicCollectSpec;
|
|
102
|
+
concurrency?: number;
|
|
103
|
+
failFast?: boolean;
|
|
104
|
+
phase?: string;
|
|
105
|
+
label?: string;
|
|
68
106
|
}
|
|
69
107
|
|
|
70
108
|
/** Parallel step: multiple agents running concurrently */
|
|
71
|
-
interface ParallelStep {
|
|
109
|
+
export interface ParallelStep {
|
|
72
110
|
parallel: ParallelTaskItem[];
|
|
73
111
|
concurrency?: number;
|
|
74
112
|
failFast?: boolean;
|
|
75
113
|
worktree?: boolean;
|
|
114
|
+
cwd?: string;
|
|
76
115
|
}
|
|
77
116
|
|
|
78
117
|
/** Union type for chain steps */
|
|
79
|
-
export type ChainStep = SequentialStep | ParallelStep;
|
|
118
|
+
export type ChainStep = SequentialStep | ParallelStep | DynamicParallelStep;
|
|
80
119
|
|
|
81
120
|
// =============================================================================
|
|
82
121
|
// Type Guards
|
|
@@ -86,11 +125,18 @@ export function isParallelStep(step: ChainStep): step is ParallelStep {
|
|
|
86
125
|
return "parallel" in step && Array.isArray((step as ParallelStep).parallel);
|
|
87
126
|
}
|
|
88
127
|
|
|
128
|
+
export function isDynamicParallelStep(step: ChainStep): step is DynamicParallelStep {
|
|
129
|
+
return "expand" in step && "collect" in step && "parallel" in step && !Array.isArray((step as { parallel?: unknown }).parallel);
|
|
130
|
+
}
|
|
131
|
+
|
|
89
132
|
/** Get all agent names in a step (single for sequential, multiple for parallel) */
|
|
90
133
|
export function getStepAgents(step: ChainStep): string[] {
|
|
91
134
|
if (isParallelStep(step)) {
|
|
92
135
|
return step.parallel.map((t) => t.agent);
|
|
93
136
|
}
|
|
137
|
+
if (isDynamicParallelStep(step)) {
|
|
138
|
+
return [step.parallel.agent];
|
|
139
|
+
}
|
|
94
140
|
return [step.agent];
|
|
95
141
|
}
|
|
96
142
|
|
|
@@ -160,6 +206,9 @@ export function resolveChainTemplates(
|
|
|
160
206
|
return "{previous}";
|
|
161
207
|
});
|
|
162
208
|
}
|
|
209
|
+
if (isDynamicParallelStep(step)) {
|
|
210
|
+
return step.parallel.task ?? "{previous}";
|
|
211
|
+
}
|
|
163
212
|
// Sequential step: existing logic
|
|
164
213
|
const seq = step as SequentialStep;
|
|
165
214
|
if (seq.task) return seq.task;
|