pi-subagents 0.3.0 → 0.3.2

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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Chain execution logic for subagent tool
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
8
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
9
+ import type { AgentConfig } from "./agents.js";
10
+ import { ChainClarifyComponent, type ChainClarifyResult, type BehaviorOverride } from "./chain-clarify.js";
11
+ import {
12
+ resolveChainTemplates,
13
+ createChainDir,
14
+ removeChainDir,
15
+ resolveStepBehavior,
16
+ resolveParallelBehaviors,
17
+ buildChainInstructions,
18
+ createParallelDirs,
19
+ aggregateParallelOutputs,
20
+ isParallelStep,
21
+ type StepOverrides,
22
+ type ChainStep,
23
+ type SequentialStep,
24
+ type ParallelTaskResult,
25
+ type ResolvedTemplates,
26
+ } from "./settings.js";
27
+ import { runSync } from "./execution.js";
28
+ import { buildChainSummary } from "./formatters.js";
29
+ import { getFinalOutput, mapConcurrent } from "./utils.js";
30
+ import {
31
+ type AgentProgress,
32
+ type ArtifactConfig,
33
+ type ArtifactPaths,
34
+ type Details,
35
+ type SingleResult,
36
+ MAX_CONCURRENCY,
37
+ } from "./types.js";
38
+
39
+ export interface ChainExecutionParams {
40
+ chain: ChainStep[];
41
+ agents: AgentConfig[];
42
+ ctx: ExtensionContext;
43
+ signal?: AbortSignal;
44
+ runId: string;
45
+ cwd?: string;
46
+ shareEnabled: boolean;
47
+ sessionDirForIndex: (idx?: number) => string | undefined;
48
+ artifactsDir: string;
49
+ artifactConfig: ArtifactConfig;
50
+ includeProgress?: boolean;
51
+ clarify?: boolean;
52
+ onUpdate?: (r: AgentToolResult<Details>) => void;
53
+ }
54
+
55
+ export interface ChainExecutionResult {
56
+ content: Array<{ type: "text"; text: string }>;
57
+ details: Details;
58
+ isError?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Execute a chain of subagent steps
63
+ */
64
+ export async function executeChain(params: ChainExecutionParams): Promise<ChainExecutionResult> {
65
+ const {
66
+ chain: chainSteps,
67
+ agents,
68
+ ctx,
69
+ signal,
70
+ runId,
71
+ cwd,
72
+ shareEnabled,
73
+ sessionDirForIndex,
74
+ artifactsDir,
75
+ artifactConfig,
76
+ includeProgress,
77
+ clarify,
78
+ onUpdate,
79
+ } = params;
80
+
81
+ const allProgress: AgentProgress[] = [];
82
+ const allArtifactPaths: ArtifactPaths[] = [];
83
+
84
+ // Compute chain metadata for observability
85
+ const chainAgents: string[] = chainSteps.map((step) =>
86
+ isParallelStep(step)
87
+ ? `[${step.parallel.map((t) => t.agent).join("+")}]`
88
+ : (step as SequentialStep).agent,
89
+ );
90
+ const totalSteps = chainSteps.length;
91
+
92
+ // Get original task from first step
93
+ const firstStep = chainSteps[0]!;
94
+ const originalTask = isParallelStep(firstStep)
95
+ ? firstStep.parallel[0]!.task!
96
+ : (firstStep as SequentialStep).task!;
97
+
98
+ // Create chain directory
99
+ const chainDir = createChainDir(runId);
100
+
101
+ // Check if chain has any parallel steps
102
+ const hasParallelSteps = chainSteps.some(isParallelStep);
103
+
104
+ // Resolve templates (parallel-aware)
105
+ let templates: ResolvedTemplates = resolveChainTemplates(chainSteps);
106
+
107
+ // For TUI: only show if no parallel steps (TUI v1 doesn't support parallel display)
108
+ const shouldClarify = clarify !== false && ctx.hasUI && !hasParallelSteps;
109
+
110
+ // Behavior overrides from TUI (set if TUI is shown, undefined otherwise)
111
+ let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
112
+
113
+ if (shouldClarify) {
114
+ // Sequential-only chain: use existing TUI
115
+ const seqSteps = chainSteps as SequentialStep[];
116
+
117
+ // Load agent configs for sequential steps
118
+ const agentConfigs: AgentConfig[] = [];
119
+ for (const step of seqSteps) {
120
+ const config = agents.find((a) => a.name === step.agent);
121
+ if (!config) {
122
+ removeChainDir(chainDir);
123
+ return {
124
+ content: [{ type: "text", text: `Unknown agent: ${step.agent}` }],
125
+ isError: true,
126
+ details: { mode: "chain" as const, results: [] },
127
+ };
128
+ }
129
+ agentConfigs.push(config);
130
+ }
131
+
132
+ // Build step overrides
133
+ const stepOverrides: StepOverrides[] = seqSteps.map((step) => ({
134
+ output: step.output,
135
+ reads: step.reads,
136
+ progress: step.progress,
137
+ }));
138
+
139
+ // Pre-resolve behaviors for TUI display
140
+ const resolvedBehaviors = agentConfigs.map((config, i) =>
141
+ resolveStepBehavior(config, stepOverrides[i]!),
142
+ );
143
+
144
+ // Flatten templates for TUI (all strings for sequential)
145
+ const flatTemplates = templates as string[];
146
+
147
+ const result = await ctx.ui.custom<ChainClarifyResult>(
148
+ (tui, theme, _kb, done) =>
149
+ new ChainClarifyComponent(
150
+ tui,
151
+ theme,
152
+ agentConfigs,
153
+ flatTemplates,
154
+ originalTask,
155
+ chainDir,
156
+ resolvedBehaviors,
157
+ done,
158
+ ),
159
+ {
160
+ overlay: true,
161
+ overlayOptions: { anchor: "center", width: 84, maxHeight: "80%" },
162
+ },
163
+ );
164
+
165
+ if (!result || !result.confirmed) {
166
+ removeChainDir(chainDir);
167
+ return {
168
+ content: [{ type: "text", text: "Chain cancelled" }],
169
+ details: { mode: "chain", results: [] },
170
+ };
171
+ }
172
+ // Update templates from TUI result
173
+ templates = result.templates;
174
+ // Store behavior overrides from TUI (used below in sequential step execution)
175
+ tuiBehaviorOverrides = result.behaviorOverrides;
176
+ }
177
+
178
+ // Execute chain (handles both sequential and parallel steps)
179
+ const results: SingleResult[] = [];
180
+ let prev = "";
181
+ let globalTaskIndex = 0; // For unique artifact naming
182
+ let progressCreated = false; // Track if progress.md has been created
183
+
184
+ for (let stepIndex = 0; stepIndex < chainSteps.length; stepIndex++) {
185
+ const step = chainSteps[stepIndex]!;
186
+ const stepTemplates = templates[stepIndex]!;
187
+
188
+ if (isParallelStep(step)) {
189
+ // === PARALLEL STEP EXECUTION ===
190
+ const parallelTemplates = stepTemplates as string[];
191
+ const concurrency = step.concurrency ?? MAX_CONCURRENCY;
192
+ const failFast = step.failFast ?? false;
193
+
194
+ // Create subdirectories for parallel outputs
195
+ const agentNames = step.parallel.map((t) => t.agent);
196
+ createParallelDirs(chainDir, stepIndex, step.parallel.length, agentNames);
197
+
198
+ // Resolve behaviors for parallel tasks
199
+ const parallelBehaviors = resolveParallelBehaviors(step.parallel, agents, stepIndex);
200
+
201
+ // If any parallel task has progress enabled and progress.md hasn't been created,
202
+ // create it now to avoid race conditions
203
+ const anyNeedsProgress = parallelBehaviors.some((b) => b.progress);
204
+ if (anyNeedsProgress && !progressCreated) {
205
+ const progressPath = path.join(chainDir, "progress.md");
206
+ fs.writeFileSync(progressPath, "# Progress\n\n## Status\nIn Progress\n\n## Tasks\n\n## Files Changed\n\n## Notes\n");
207
+ progressCreated = true;
208
+ }
209
+
210
+ // Track if we should abort remaining tasks (for fail-fast)
211
+ let aborted = false;
212
+
213
+ // Execute parallel tasks
214
+ const parallelResults = await mapConcurrent(
215
+ step.parallel,
216
+ concurrency,
217
+ async (task, taskIndex) => {
218
+ if (aborted && failFast) {
219
+ // Return a placeholder for skipped tasks
220
+ return {
221
+ agent: task.agent,
222
+ task: "(skipped)",
223
+ exitCode: -1,
224
+ messages: [],
225
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 },
226
+ error: "Skipped due to fail-fast",
227
+ } as SingleResult;
228
+ }
229
+
230
+ // Build task string
231
+ const taskTemplate = parallelTemplates[taskIndex] ?? "{previous}";
232
+ const templateHasPrevious = taskTemplate.includes("{previous}");
233
+ let taskStr = taskTemplate;
234
+ taskStr = taskStr.replace(/\{task\}/g, originalTask);
235
+ taskStr = taskStr.replace(/\{previous\}/g, prev);
236
+ taskStr = taskStr.replace(/\{chain_dir\}/g, chainDir);
237
+
238
+ // Add chain instructions (include previous summary only if not already in template)
239
+ const behavior = parallelBehaviors[taskIndex]!;
240
+ // For parallel, no single "first progress" - each manages independently
241
+ taskStr += buildChainInstructions(behavior, chainDir, false, templateHasPrevious ? undefined : prev);
242
+
243
+ const r = await runSync(ctx.cwd, agents, task.agent, taskStr, {
244
+ cwd: task.cwd ?? cwd,
245
+ signal,
246
+ runId,
247
+ index: globalTaskIndex + taskIndex,
248
+ sessionDir: sessionDirForIndex(globalTaskIndex + taskIndex),
249
+ share: shareEnabled,
250
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
251
+ artifactConfig,
252
+ onUpdate: onUpdate
253
+ ? (p) => {
254
+ // Use concat instead of spread for better performance
255
+ const stepResults = p.details?.results || [];
256
+ const stepProgress = p.details?.progress || [];
257
+ onUpdate({
258
+ ...p,
259
+ details: {
260
+ mode: "chain",
261
+ results: results.concat(stepResults),
262
+ progress: allProgress.concat(stepProgress),
263
+ chainAgents,
264
+ totalSteps,
265
+ currentStepIndex: stepIndex,
266
+ },
267
+ });
268
+ }
269
+ : undefined,
270
+ });
271
+
272
+ if (r.exitCode !== 0 && failFast) {
273
+ aborted = true;
274
+ }
275
+
276
+ return r;
277
+ },
278
+ );
279
+
280
+ // Update global task index
281
+ globalTaskIndex += step.parallel.length;
282
+
283
+ // Collect results and progress
284
+ for (const r of parallelResults) {
285
+ results.push(r);
286
+ if (r.progress) allProgress.push(r.progress);
287
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
288
+ }
289
+
290
+ // Check for failures (track original task index for better error messages)
291
+ const failures = parallelResults
292
+ .map((r, originalIndex) => ({ ...r, originalIndex }))
293
+ .filter((r) => r.exitCode !== 0 && r.exitCode !== -1);
294
+ if (failures.length > 0) {
295
+ const failureSummary = failures
296
+ .map((f) => `- Task ${f.originalIndex + 1} (${f.agent}): ${f.error || "failed"}`)
297
+ .join("\n");
298
+ const errorMsg = `Parallel step ${stepIndex + 1} failed:\n${failureSummary}`;
299
+ const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
300
+ index: stepIndex,
301
+ error: errorMsg,
302
+ });
303
+ return {
304
+ content: [{ type: "text", text: summary }],
305
+ details: {
306
+ mode: "chain",
307
+ results,
308
+ progress: includeProgress ? allProgress : undefined,
309
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
310
+ chainAgents,
311
+ totalSteps,
312
+ currentStepIndex: stepIndex,
313
+ },
314
+ isError: true,
315
+ };
316
+ }
317
+
318
+ // Aggregate outputs for {previous}
319
+ const taskResults: ParallelTaskResult[] = parallelResults.map((r, i) => ({
320
+ agent: r.agent,
321
+ taskIndex: i,
322
+ output: getFinalOutput(r.messages),
323
+ exitCode: r.exitCode,
324
+ error: r.error,
325
+ }));
326
+ prev = aggregateParallelOutputs(taskResults);
327
+ } else {
328
+ // === SEQUENTIAL STEP EXECUTION ===
329
+ const seqStep = step as SequentialStep;
330
+ const stepTemplate = stepTemplates as string;
331
+
332
+ // Get agent config
333
+ const agentConfig = agents.find((a) => a.name === seqStep.agent);
334
+ if (!agentConfig) {
335
+ removeChainDir(chainDir);
336
+ return {
337
+ content: [{ type: "text", text: `Unknown agent: ${seqStep.agent}` }],
338
+ isError: true,
339
+ details: { mode: "chain" as const, results: [] },
340
+ };
341
+ }
342
+
343
+ // Build task string (check if template has {previous} before replacement)
344
+ const templateHasPrevious = stepTemplate.includes("{previous}");
345
+ let stepTask = stepTemplate;
346
+ stepTask = stepTask.replace(/\{task\}/g, originalTask);
347
+ stepTask = stepTask.replace(/\{previous\}/g, prev);
348
+ stepTask = stepTask.replace(/\{chain_dir\}/g, chainDir);
349
+
350
+ // Resolve behavior (TUI overrides take precedence over step config)
351
+ const tuiOverride = tuiBehaviorOverrides?.[stepIndex];
352
+ const stepOverride: StepOverrides = {
353
+ output: tuiOverride?.output !== undefined ? tuiOverride.output : seqStep.output,
354
+ reads: tuiOverride?.reads !== undefined ? tuiOverride.reads : seqStep.reads,
355
+ progress: tuiOverride?.progress !== undefined ? tuiOverride.progress : seqStep.progress,
356
+ };
357
+ const behavior = resolveStepBehavior(agentConfig, stepOverride);
358
+
359
+ // Determine if this is the first agent to create progress.md
360
+ const isFirstProgress = behavior.progress && !progressCreated;
361
+ if (isFirstProgress) {
362
+ progressCreated = true;
363
+ }
364
+
365
+ // Add chain instructions (include previous summary only if not already in template)
366
+ stepTask += buildChainInstructions(behavior, chainDir, isFirstProgress, templateHasPrevious ? undefined : prev);
367
+
368
+ // Run step
369
+ const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
370
+ cwd: seqStep.cwd ?? cwd,
371
+ signal,
372
+ runId,
373
+ index: globalTaskIndex,
374
+ sessionDir: sessionDirForIndex(globalTaskIndex),
375
+ share: shareEnabled,
376
+ artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
377
+ artifactConfig,
378
+ onUpdate: onUpdate
379
+ ? (p) => {
380
+ // Use concat instead of spread for better performance
381
+ const stepResults = p.details?.results || [];
382
+ const stepProgress = p.details?.progress || [];
383
+ onUpdate({
384
+ ...p,
385
+ details: {
386
+ mode: "chain",
387
+ results: results.concat(stepResults),
388
+ progress: allProgress.concat(stepProgress),
389
+ chainAgents,
390
+ totalSteps,
391
+ currentStepIndex: stepIndex,
392
+ },
393
+ });
394
+ }
395
+ : undefined,
396
+ });
397
+
398
+ globalTaskIndex++;
399
+ results.push(r);
400
+ if (r.progress) allProgress.push(r.progress);
401
+ if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
402
+
403
+ // On failure, leave chain_dir for debugging
404
+ if (r.exitCode !== 0) {
405
+ const summary = buildChainSummary(chainSteps, results, chainDir, "failed", {
406
+ index: stepIndex,
407
+ error: r.error || "Chain failed",
408
+ });
409
+ return {
410
+ content: [{ type: "text", text: summary }],
411
+ details: {
412
+ mode: "chain",
413
+ results,
414
+ progress: includeProgress ? allProgress : undefined,
415
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
416
+ chainAgents,
417
+ totalSteps,
418
+ currentStepIndex: stepIndex,
419
+ },
420
+ isError: true,
421
+ };
422
+ }
423
+
424
+ prev = getFinalOutput(r.messages);
425
+ }
426
+ }
427
+
428
+ // Chain complete - return summary with paths
429
+ // Chain dir left for inspection (cleaned up after 24h)
430
+ const summary = buildChainSummary(chainSteps, results, chainDir, "completed");
431
+
432
+ return {
433
+ content: [{ type: "text", text: summary }],
434
+ details: {
435
+ mode: "chain",
436
+ results,
437
+ progress: includeProgress ? allProgress : undefined,
438
+ artifacts: allArtifactPaths.length ? { dir: artifactsDir, files: allArtifactPaths } : undefined,
439
+ chainAgents,
440
+ totalSteps,
441
+ // currentStepIndex omitted for completed chains
442
+ },
443
+ };
444
+ }