@narumitw/pi-subagents 0.13.0 → 0.14.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.
@@ -0,0 +1,453 @@
1
+ import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ discoverAgents,
5
+ type AgentConfig,
6
+ type AgentScope,
7
+ type SubagentThinkingLevel,
8
+ } from "./agents.js";
9
+ import {
10
+ buildFanInContext,
11
+ getResultFinalOutput,
12
+ mapWithConcurrencyLimit,
13
+ runSingleAgent,
14
+ type OnUpdateCallback,
15
+ type SingleResult,
16
+ type SubagentDetails,
17
+ } from "./runner.js";
18
+ import { DEFAULT_MAX_CONTEXT_BYTES, truncateUtf8 } from "./limits.js";
19
+ import type { SubagentParams } from "./params.js";
20
+ import { readSubagentSettings, resolveSubagentThinkingLevel } from "./settings.js";
21
+
22
+ const MAX_PARALLEL_TASKS = 8;
23
+ const MAX_CONCURRENCY = 4;
24
+ export const FALLBACK_TIMEOUT_MS = 10 * 60 * 1000;
25
+ const STATUS_KEY = "subagents";
26
+ const activeStatuses = new Map<string, string>();
27
+
28
+ export function parsePositiveInteger(value: string | undefined): number | undefined {
29
+ if (!value) return undefined;
30
+ const parsed = Number.parseInt(value, 10);
31
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
32
+ }
33
+
34
+ export function resolveDefaultSubagentTimeoutMs(): number {
35
+ return parsePositiveInteger(process.env.PI_SUBAGENT_TIMEOUT_MS) ?? FALLBACK_TIMEOUT_MS;
36
+ }
37
+
38
+ export function assertSubagentDepthAllowed(): void {
39
+ const depth = Number.parseInt(process.env.PI_SUBAGENT_DEPTH ?? "0", 10) || 0;
40
+ const maxDepth = parsePositiveInteger(process.env.PI_SUBAGENT_MAX_DEPTH) ?? 1;
41
+ if (depth >= maxDepth) {
42
+ throw new Error(`Subagent recursion depth limit reached (${maxDepth})`);
43
+ }
44
+ }
45
+
46
+ interface StatusContext {
47
+ ui: { setStatus: (key: string, value: string | undefined) => void };
48
+ }
49
+
50
+ function startSubagentStatus(ctx: StatusContext, toolCallId: string, status: string) {
51
+ let cleared = false;
52
+
53
+ const update = (nextStatus: string) => {
54
+ if (cleared) return;
55
+ activeStatuses.set(toolCallId, nextStatus);
56
+ publishSubagentStatus(ctx);
57
+ };
58
+
59
+ update(status);
60
+
61
+ return {
62
+ update,
63
+ clear() {
64
+ if (cleared) return;
65
+ cleared = true;
66
+ activeStatuses.delete(toolCallId);
67
+ publishSubagentStatus(ctx);
68
+ },
69
+ };
70
+ }
71
+
72
+ function publishSubagentStatus(ctx: StatusContext) {
73
+ const statuses = [...activeStatuses.values()];
74
+ if (statuses.length === 0) {
75
+ ctx.ui.setStatus(STATUS_KEY, undefined);
76
+ return;
77
+ }
78
+
79
+ const suffix = statuses.length > 1 ? ` +${statuses.length - 1}` : "";
80
+ ctx.ui.setStatus(STATUS_KEY, `${statuses[0]}${suffix}`);
81
+ }
82
+
83
+ function singleStatus(agent: string): string {
84
+ return `${agent}`;
85
+ }
86
+
87
+ function chainStatus(step: number, total: number, agent?: string): string {
88
+ return `chain ${step}/${total}${agent ? ` ${agent}` : ""}`;
89
+ }
90
+
91
+ function parallelStatus(done: number, total: number, running: number): string {
92
+ return `parallel ${done}/${total} done${running > 0 ? ` ${running} running` : ""}`;
93
+ }
94
+
95
+ function fanInStatus(agent: string): string {
96
+ return `fan-in ${agent}`;
97
+ }
98
+
99
+ export async function executeSubagent(
100
+ toolCallId: string,
101
+ params: SubagentParams,
102
+ signal: AbortSignal | undefined,
103
+ onUpdate: AgentToolUpdateCallback<SubagentDetails> | undefined,
104
+ ctx: ExtensionContext,
105
+ ): Promise<AgentToolResult<SubagentDetails> & { isError?: boolean }> {
106
+ assertSubagentDepthAllowed();
107
+ const agentScope: AgentScope = params.agentScope ?? "user";
108
+ const config = readSubagentSettings();
109
+ const discovery = discoverAgents(ctx.cwd, agentScope, config);
110
+ const agents = discovery.agents;
111
+ const confirmProjectAgents = params.confirmProjectAgents ?? true;
112
+ const resolveTimeoutMs = (agentName: string, localTimeoutMs?: number) =>
113
+ localTimeoutMs ??
114
+ params.timeoutMs ??
115
+ agents.find((agent) => agent.name === agentName)?.timeoutMs ??
116
+ resolveDefaultSubagentTimeoutMs();
117
+ const resolveThinkingLevel = (agentName: string, localThinkingLevel?: SubagentThinkingLevel) =>
118
+ resolveSubagentThinkingLevel(agents, agentName, params.thinkingLevel, localThinkingLevel);
119
+
120
+ const hasChain = (params.chain?.length ?? 0) > 0;
121
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
122
+ const hasSingle = Boolean(params.agent && params.task);
123
+ const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
124
+
125
+ const makeDetails =
126
+ (mode: "single" | "parallel" | "chain") =>
127
+ (results: SingleResult[], aggregator?: SingleResult): SubagentDetails => ({
128
+ mode,
129
+ agentScope,
130
+ projectAgentsDir: discovery.projectAgentsDir,
131
+ results,
132
+ aggregator,
133
+ });
134
+
135
+ if (modeCount !== 1 || (params.aggregator && !hasTasks)) {
136
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
137
+ const reason =
138
+ modeCount !== 1
139
+ ? "Provide exactly one mode."
140
+ : "Aggregator is only valid with parallel tasks.";
141
+ return {
142
+ content: [
143
+ {
144
+ type: "text",
145
+ text: `Invalid parameters. ${reason}\nAvailable agents: ${available}`,
146
+ },
147
+ ],
148
+ details: makeDetails("single")([]),
149
+ };
150
+ }
151
+
152
+ if (agentScope === "project" || agentScope === "both") {
153
+ const requestedAgentNames = new Set<string>();
154
+ if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
155
+ if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
156
+ if (params.aggregator) requestedAgentNames.add(params.aggregator.agent);
157
+ if (params.agent) requestedAgentNames.add(params.agent);
158
+
159
+ const projectAgentsRequested = Array.from(requestedAgentNames)
160
+ .map((name) => agents.find((a) => a.name === name))
161
+ .filter((a): a is AgentConfig => a?.source === "project");
162
+
163
+ if (projectAgentsRequested.length > 0) {
164
+ if (!ctx.isProjectTrusted()) {
165
+ throw new Error("Project-local subagent definitions require a trusted project");
166
+ }
167
+ if (confirmProjectAgents && ctx.hasUI) {
168
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
169
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
170
+ const ok = await ctx.ui.confirm(
171
+ "Run project-local agents?",
172
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
173
+ );
174
+ if (!ok) {
175
+ return {
176
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
177
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
178
+ };
179
+ }
180
+ }
181
+ }
182
+ }
183
+
184
+ if (params.chain && params.chain.length > 0) {
185
+ const results: SingleResult[] = [];
186
+ let previousOutput = "";
187
+ const status = startSubagentStatus(ctx, toolCallId, chainStatus(0, params.chain.length));
188
+
189
+ try {
190
+ for (let i = 0; i < params.chain.length; i++) {
191
+ const step = params.chain[i];
192
+ status.update(chainStatus(i + 1, params.chain.length, step.agent));
193
+ const taskWithContext = truncateUtf8(
194
+ step.task.replace(/\{previous\}/g, previousOutput),
195
+ DEFAULT_MAX_CONTEXT_BYTES,
196
+ ).text;
197
+
198
+ // Create update callback that includes all previous results
199
+ const chainUpdate: OnUpdateCallback | undefined = onUpdate
200
+ ? (partial) => {
201
+ // Combine completed results with current streaming result
202
+ const currentResult = partial.details?.results[0];
203
+ if (currentResult) {
204
+ const allResults = [...results, currentResult];
205
+ onUpdate({
206
+ content: partial.content,
207
+ details: makeDetails("chain")(allResults),
208
+ });
209
+ }
210
+ }
211
+ : undefined;
212
+
213
+ const result = await runSingleAgent(
214
+ ctx.cwd,
215
+ agents,
216
+ step.agent,
217
+ taskWithContext,
218
+ step.cwd,
219
+ i + 1,
220
+ signal,
221
+ resolveThinkingLevel(step.agent, step.thinkingLevel),
222
+ resolveTimeoutMs(step.agent, step.timeoutMs),
223
+ chainUpdate,
224
+ makeDetails("chain"),
225
+ );
226
+ results.push(result);
227
+
228
+ const isError =
229
+ result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
230
+ if (isError) {
231
+ const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
232
+ return {
233
+ content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
234
+ details: { ...makeDetails("chain")(results), isError: true },
235
+ isError: true,
236
+ };
237
+ }
238
+ previousOutput = getResultFinalOutput(result);
239
+ }
240
+ return {
241
+ content: [{ type: "text", text: getResultFinalOutput(results[results.length - 1]) || "(no output)" }],
242
+ details: makeDetails("chain")(results),
243
+ };
244
+ } finally {
245
+ status.clear();
246
+ }
247
+ }
248
+
249
+ if (params.tasks && params.tasks.length > 0) {
250
+ if (params.tasks.length > MAX_PARALLEL_TASKS)
251
+ return {
252
+ content: [
253
+ {
254
+ type: "text",
255
+ text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
256
+ },
257
+ ],
258
+ details: makeDetails("parallel")([]),
259
+ };
260
+
261
+ const status = startSubagentStatus(ctx, toolCallId, parallelStatus(0, params.tasks.length, params.tasks.length));
262
+
263
+ try {
264
+ // Track all results for streaming updates
265
+ const allResults: SingleResult[] = new Array(params.tasks.length);
266
+
267
+ // Initialize placeholder results
268
+ for (let i = 0; i < params.tasks.length; i++) {
269
+ allResults[i] = {
270
+ agent: params.tasks[i].agent,
271
+ agentSource: "unknown",
272
+ task: params.tasks[i].task,
273
+ exitCode: -1, // -1 = still running
274
+ messages: [],
275
+ stderr: "",
276
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
277
+ thinkingLevel: resolveThinkingLevel(params.tasks[i].agent, params.tasks[i].thinkingLevel),
278
+ finalOutput: "",
279
+ };
280
+ }
281
+
282
+ let doneCount = 0;
283
+ let runningCount = params.tasks.length;
284
+
285
+ const emitParallelUpdate = () => {
286
+ status.update(parallelStatus(doneCount, allResults.length, runningCount));
287
+ if (onUpdate) {
288
+ onUpdate({
289
+ content: [
290
+ {
291
+ type: "text",
292
+ text: `Parallel: ${doneCount}/${allResults.length} done, ${runningCount} running...`,
293
+ },
294
+ ],
295
+ details: makeDetails("parallel")([...allResults]),
296
+ });
297
+ }
298
+ };
299
+
300
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
301
+ const result = await runSingleAgent(
302
+ ctx.cwd,
303
+ agents,
304
+ t.agent,
305
+ t.task,
306
+ t.cwd,
307
+ undefined,
308
+ signal,
309
+ resolveThinkingLevel(t.agent, t.thinkingLevel),
310
+ resolveTimeoutMs(t.agent, t.timeoutMs),
311
+ // Per-task update callback
312
+ (partial) => {
313
+ if (partial.details?.results[0]) {
314
+ allResults[index] = { ...partial.details.results[0], exitCode: -1 };
315
+ emitParallelUpdate();
316
+ }
317
+ },
318
+ makeDetails("parallel"),
319
+ );
320
+ allResults[index] = result;
321
+ doneCount += 1;
322
+ runningCount -= 1;
323
+ emitParallelUpdate();
324
+ return result;
325
+ }, signal, (task, index) => {
326
+ const skipped: SingleResult = {
327
+ ...allResults[index],
328
+ task: task.task,
329
+ exitCode: 130,
330
+ stopReason: "aborted",
331
+ aborted: true,
332
+ errorMessage: "Subagent was not started because the parent call was aborted",
333
+ };
334
+ allResults[index] = skipped;
335
+ doneCount += 1;
336
+ runningCount -= 1;
337
+ emitParallelUpdate();
338
+ return skipped;
339
+ });
340
+
341
+ let aggregatorResult: SingleResult | undefined;
342
+ if (params.aggregator && !signal?.aborted) {
343
+ const aggregator = params.aggregator;
344
+ status.update(fanInStatus(aggregator.agent));
345
+ const fanInContext = buildFanInContext(results);
346
+ const aggregatorTask = truncateUtf8(
347
+ aggregator.task.includes("{previous}")
348
+ ? aggregator.task.replace(/\{previous\}/g, fanInContext)
349
+ : `${aggregator.task}\n\nParallel task outputs:\n\n${fanInContext}`,
350
+ DEFAULT_MAX_CONTEXT_BYTES,
351
+ ).text;
352
+ aggregatorResult = await runSingleAgent(
353
+ ctx.cwd,
354
+ agents,
355
+ aggregator.agent,
356
+ aggregatorTask,
357
+ aggregator.cwd,
358
+ undefined,
359
+ signal,
360
+ resolveThinkingLevel(aggregator.agent, aggregator.thinkingLevel),
361
+ resolveTimeoutMs(aggregator.agent, aggregator.timeoutMs),
362
+ (partial) => {
363
+ status.update(fanInStatus(aggregator.agent));
364
+ if (onUpdate && partial.details?.results[0]) {
365
+ onUpdate({
366
+ content: partial.content,
367
+ details: makeDetails("parallel")(results, partial.details.results[0]),
368
+ });
369
+ }
370
+ },
371
+ makeDetails("parallel"),
372
+ );
373
+ }
374
+
375
+ const successCount = results.filter((r) => r.exitCode === 0).length;
376
+ const summaries = results.map((r) => {
377
+ const output = getResultFinalOutput(r);
378
+ const error = r.errorMessage || r.stderr.trim();
379
+ const summaryText = output || error;
380
+ const preview = summaryText.slice(0, 160) + (summaryText.length > 160 ? "..." : "");
381
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
382
+ });
383
+ const aggregatorOutput = aggregatorResult ? getResultFinalOutput(aggregatorResult) : "";
384
+ const aggregatorError = aggregatorResult?.errorMessage || aggregatorResult?.stderr.trim() || "";
385
+ return {
386
+ content: [
387
+ {
388
+ type: "text",
389
+ text: aggregatorResult
390
+ ? aggregatorOutput || aggregatorError || `(aggregator ${aggregatorResult.agent} produced no output)`
391
+ : `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
392
+ },
393
+ ],
394
+ details: {
395
+ ...makeDetails("parallel")(results, aggregatorResult),
396
+ isError: aggregatorResult
397
+ ? aggregatorResult.exitCode !== 0 ||
398
+ aggregatorResult.stopReason === "error" ||
399
+ aggregatorResult.stopReason === "aborted"
400
+ : false,
401
+ },
402
+ isError: aggregatorResult
403
+ ? aggregatorResult.exitCode !== 0 ||
404
+ aggregatorResult.stopReason === "error" ||
405
+ aggregatorResult.stopReason === "aborted"
406
+ : undefined,
407
+ };
408
+ } finally {
409
+ status.clear();
410
+ }
411
+ }
412
+
413
+ if (params.agent && params.task) {
414
+ const status = startSubagentStatus(ctx, toolCallId, singleStatus(params.agent));
415
+
416
+ try {
417
+ const result = await runSingleAgent(
418
+ ctx.cwd,
419
+ agents,
420
+ params.agent,
421
+ params.task,
422
+ params.cwd,
423
+ undefined,
424
+ signal,
425
+ resolveThinkingLevel(params.agent, params.thinkingLevel),
426
+ resolveTimeoutMs(params.agent, params.timeoutMs),
427
+ onUpdate,
428
+ makeDetails("single"),
429
+ );
430
+ const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
431
+ if (isError) {
432
+ const errorMsg = result.errorMessage || result.stderr || getResultFinalOutput(result) || "(no output)";
433
+ return {
434
+ content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
435
+ details: { ...makeDetails("single")([result]), isError: true },
436
+ isError: true,
437
+ };
438
+ }
439
+ return {
440
+ content: [{ type: "text", text: getResultFinalOutput(result) || "(no output)" }],
441
+ details: makeDetails("single")([result]),
442
+ };
443
+ } finally {
444
+ status.clear();
445
+ }
446
+ }
447
+
448
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
449
+ return {
450
+ content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
451
+ details: makeDetails("single")([]),
452
+ };
453
+ }