@bacnh85/pi-subagent 0.1.2 → 0.3.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/agent-format.md +59 -0
- package/agents/general-purpose.md +13 -0
- package/agents.ts +42 -2
- package/index.ts +176 -26
- package/package.json +3 -2
- package/render.ts +14 -4
- package/runner.ts +24 -9
package/agent-format.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Agent Definition Format
|
|
2
|
+
|
|
3
|
+
Sub-agents are defined as Markdown files with YAML frontmatter.
|
|
4
|
+
|
|
5
|
+
## File Location
|
|
6
|
+
|
|
7
|
+
| Location | Scope |
|
|
8
|
+
|----------|-------|
|
|
9
|
+
| `~/.pi/agent/agents/*.md` | User-level (all projects) |
|
|
10
|
+
| `.pi/agents/*.md` | Project-level |
|
|
11
|
+
| `<skill>/agents/*.md` | Bundled with pi-sugagents |
|
|
12
|
+
|
|
13
|
+
Project agents override user agents with the same name when `agentScope: "both"`.
|
|
14
|
+
|
|
15
|
+
## Frontmatter Fields
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
---
|
|
19
|
+
name: my-agent # Required. Unique identifier (kebab-case).
|
|
20
|
+
description: ... # Required. When to use this agent.
|
|
21
|
+
tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
|
|
22
|
+
model: claude-haiku-4-5 # Optional. Model ID. Defaults to parent's model.
|
|
23
|
+
---
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Only `name` and `description` are required.
|
|
27
|
+
|
|
28
|
+
## Body
|
|
29
|
+
|
|
30
|
+
The body after frontmatter becomes the agent's **entire system prompt**. No pi defaults, no AGENTS.md files, no skills — only what you write here. Keep it focused.
|
|
31
|
+
|
|
32
|
+
## Available Tools
|
|
33
|
+
|
|
34
|
+
Built-in pi tool names: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`
|
|
35
|
+
|
|
36
|
+
The `subagent` tool is never available to sub-agents (prevents accidental recursion). Sub-agents run at one level of delegation only; they cannot spawn further sub-agents.
|
|
37
|
+
|
|
38
|
+
Custom/extension tools are NOT available to sub-agents by default (each runs in an isolated in-memory session with no extensions).
|
|
39
|
+
|
|
40
|
+
## Model Resolution
|
|
41
|
+
|
|
42
|
+
Model IDs are resolved via `getModel("provider", "id")`. Common values:
|
|
43
|
+
- `claude-haiku-4-5` (Anthropic Haiku — fast, cheap)
|
|
44
|
+
- `claude-sonnet-4-20250514` (Anthropic Sonnet — balanced)
|
|
45
|
+
- `gpt-4o` (OpenAI)
|
|
46
|
+
- Any model available in your pi configuration.
|
|
47
|
+
|
|
48
|
+
If not specified, defaults to the parent session's model.
|
|
49
|
+
|
|
50
|
+
## Token Budget
|
|
51
|
+
|
|
52
|
+
Each sub-agent runs with:
|
|
53
|
+
- **System prompt**: agent body only (~200-1K tokens typical)
|
|
54
|
+
- **No AGENTS.md**: saves 500-5K tokens
|
|
55
|
+
- **No extensions/skills loaded**: saves 200-1K tokens
|
|
56
|
+
- **Thinking off**: saves reasoning overhead
|
|
57
|
+
- **No compaction**: avoids compaction token cost
|
|
58
|
+
|
|
59
|
+
This is ~10x leaner than spawning a full `pi` process.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: general-purpose
|
|
3
|
+
description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
|
|
4
|
+
tools: read, bash, edit, write, grep, find, ls
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
You are a capable coding assistant running as a sub-agent. Complete the delegated task efficiently and return a concise summary of your findings or changes.
|
|
8
|
+
|
|
9
|
+
Guidelines:
|
|
10
|
+
- Use available tools to investigate and act on the task.
|
|
11
|
+
- If the task involves searching, use grep and find to locate relevant code.
|
|
12
|
+
- If the task involves implementation, make focused changes following existing patterns.
|
|
13
|
+
- Return a structured summary: what you found, what you changed, and any recommendations.
|
package/agents.ts
CHANGED
|
@@ -34,6 +34,8 @@ interface AgentCache {
|
|
|
34
34
|
bundledDir: string;
|
|
35
35
|
agents: AgentConfig[];
|
|
36
36
|
projectAgentsDir: string | null;
|
|
37
|
+
/** File-level signature per directory (name:mtime:size for each .md file) */
|
|
38
|
+
dirSignatures: Map<string, string>;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
let _cache: AgentCache | null = null;
|
|
@@ -98,6 +100,26 @@ function isDirectory(p: string): boolean {
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/** Build a stable signature for agent .md files in a directory.
|
|
104
|
+
* Returns "missing" if the directory doesn't exist, or a sorted
|
|
105
|
+
* list of `name:mtimeMs:size` entries that catches both content
|
|
106
|
+
* edits and add/remove/rename operations. */
|
|
107
|
+
function dirSignature(dir: string): string {
|
|
108
|
+
try {
|
|
109
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
110
|
+
.filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
|
|
111
|
+
.map((e) => {
|
|
112
|
+
const file = path.join(dir, e.name);
|
|
113
|
+
const st = fs.statSync(file);
|
|
114
|
+
return `${e.name}:${st.mtimeMs}:${st.size}`;
|
|
115
|
+
})
|
|
116
|
+
.sort();
|
|
117
|
+
return `exists:${entries.join("|")}`;
|
|
118
|
+
} catch {
|
|
119
|
+
return "missing";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
102
124
|
let currentDir = cwd;
|
|
103
125
|
while (true) {
|
|
@@ -124,14 +146,25 @@ export function discoverAgents(
|
|
|
124
146
|
const userDir = path.join(getAgentDir(), "agents");
|
|
125
147
|
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
126
148
|
|
|
127
|
-
// Check cache
|
|
149
|
+
// Check cache (with file-signature invalidation so editing agent .md files auto-detects changes)
|
|
128
150
|
if (
|
|
129
151
|
_cache &&
|
|
130
152
|
_cache.userDir === userDir &&
|
|
131
153
|
_cache.projectDir === projectAgentsDir &&
|
|
132
154
|
_cache.bundledDir === bundledAgentsDir
|
|
133
155
|
) {
|
|
134
|
-
|
|
156
|
+
let stale = false;
|
|
157
|
+
for (const [dir, cachedSig] of _cache.dirSignatures) {
|
|
158
|
+
if (dirSignature(dir) !== cachedSig) {
|
|
159
|
+
stale = true;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (!stale) {
|
|
164
|
+
return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir };
|
|
165
|
+
}
|
|
166
|
+
// Cache is stale — rebuild below
|
|
167
|
+
_cache = null;
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
@@ -152,12 +185,19 @@ export function discoverAgents(
|
|
|
152
185
|
|
|
153
186
|
const agents = Array.from(agentMap.values());
|
|
154
187
|
|
|
188
|
+
const dirSignatures = new Map<string, string>();
|
|
189
|
+
for (const dir of [userDir, projectAgentsDir, bundledAgentsDir]) {
|
|
190
|
+
if (!dir) continue;
|
|
191
|
+
dirSignatures.set(dir, dirSignature(dir));
|
|
192
|
+
}
|
|
193
|
+
|
|
155
194
|
_cache = {
|
|
156
195
|
userDir,
|
|
157
196
|
projectDir: projectAgentsDir,
|
|
158
197
|
bundledDir: bundledAgentsDir,
|
|
159
198
|
agents,
|
|
160
199
|
projectAgentsDir,
|
|
200
|
+
dirSignatures,
|
|
161
201
|
};
|
|
162
202
|
|
|
163
203
|
return { agents, projectAgentsDir };
|
package/index.ts
CHANGED
|
@@ -66,20 +66,34 @@ function truncateParallelOutput(output: string): string {
|
|
|
66
66
|
return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
interface ResolvedModel {
|
|
70
|
+
model: Model | null;
|
|
71
|
+
attempted: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
function resolveModel(
|
|
70
75
|
modelName: string | undefined,
|
|
71
76
|
parentModel: Model | undefined,
|
|
72
|
-
):
|
|
77
|
+
): ResolvedModel {
|
|
78
|
+
const attempted: string[] = [];
|
|
73
79
|
if (modelName) {
|
|
74
80
|
// Try as provider/id first, then fall back to anthropic/id
|
|
75
81
|
const parts = modelName.split("/");
|
|
76
82
|
if (parts.length === 2) {
|
|
77
|
-
|
|
83
|
+
attempted.push(modelName);
|
|
84
|
+
const found = getModel(parts[0], parts[1]) ?? null;
|
|
85
|
+
if (found) return { model: found, attempted };
|
|
86
|
+
} else {
|
|
87
|
+
// Assume Anthropic shorthand
|
|
88
|
+
attempted.push(`anthropic/${modelName}`);
|
|
89
|
+
const found = getModel("anthropic", modelName) ?? null;
|
|
90
|
+
if (found) return { model: found, attempted };
|
|
78
91
|
}
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
} else if (parentModel) {
|
|
93
|
+
attempted.push(`${parentModel.provider}/${parentModel.id}`);
|
|
94
|
+
return { model: parentModel, attempted };
|
|
81
95
|
}
|
|
82
|
-
return
|
|
96
|
+
return { model: null, attempted };
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
// ---------------------------------------------------------------------------
|
|
@@ -90,12 +104,14 @@ const TaskItem = Type.Object({
|
|
|
90
104
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
91
105
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
92
106
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
107
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this task" })),
|
|
93
108
|
});
|
|
94
109
|
|
|
95
110
|
const ChainItem = Type.Object({
|
|
96
111
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
97
112
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
98
113
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
114
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in milliseconds for this step" })),
|
|
99
115
|
});
|
|
100
116
|
|
|
101
117
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -123,6 +139,8 @@ const SubagentParams = Type.Object({
|
|
|
123
139
|
}),
|
|
124
140
|
),
|
|
125
141
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
|
|
142
|
+
timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
|
|
143
|
+
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
126
144
|
});
|
|
127
145
|
|
|
128
146
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +164,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
164
|
if (event.reason === "reload") invalidateAgentCache();
|
|
147
165
|
});
|
|
148
166
|
|
|
167
|
+
// Proactively steer agents toward sub-agent delegation when users mention it
|
|
168
|
+
pi.on("before_agent_start", async (event) => {
|
|
169
|
+
const prompt = event.prompt.toLowerCase();
|
|
170
|
+
if (/\b(delegate to|use a subagent|run in parallel|spawn an agent|scout|review this|chain|worker agent)\b/.test(prompt)) {
|
|
171
|
+
return {
|
|
172
|
+
systemPrompt:
|
|
173
|
+
event.systemPrompt +
|
|
174
|
+
"\n\nThe subagent tool is available for delegating tasks to specialized agents with isolated context. Use /subagent to list available agents. Bundled: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback). Modes: single, parallel (max 8), chain.",
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Inject available agent list into system prompt on every session
|
|
180
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
181
|
+
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
182
|
+
if (discovery.agents.length > 0) {
|
|
183
|
+
const names = discovery.agents.map(a => a.name).join(", ");
|
|
184
|
+
return {
|
|
185
|
+
systemPrompt:
|
|
186
|
+
event.systemPrompt +
|
|
187
|
+
`\n\nAvailable sub-agents: ${names}. Use /subagent for details.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
149
192
|
// Resolve bundled agents directory relative to this extension file
|
|
150
193
|
const bundledAgentsDir = path.resolve(__dirname, "agents");
|
|
151
194
|
|
|
@@ -232,7 +275,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
232
275
|
`To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" or "project".`,
|
|
233
276
|
].join(" "),
|
|
234
277
|
parameters: SubagentParams,
|
|
235
|
-
|
|
278
|
+
promptSnippet: "Delegate tasks to specialized sub-agents (scout, reviewer, worker, general-purpose)",
|
|
279
|
+
promptGuidelines: [
|
|
280
|
+
"Use subagent to delegate work that would flood the main context with search results or file contents.",
|
|
281
|
+
"Modes: single {agent, task}, parallel {tasks: [...]} (max 8, 4 concurrent), chain {chain: [...]} (sequential with {previous}).",
|
|
282
|
+
"Bundled agents: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback).",
|
|
283
|
+
"Use /subagent to list all available agents or /subagent <name> for agent details.",
|
|
284
|
+
],
|
|
236
285
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
237
286
|
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
238
287
|
const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
|
|
@@ -321,6 +370,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
321
370
|
agentName: string,
|
|
322
371
|
task: string,
|
|
323
372
|
cwd: string | undefined,
|
|
373
|
+
parentSignal?: AbortSignal,
|
|
374
|
+
timeoutMs?: number,
|
|
324
375
|
): Promise<SubAgentResult> {
|
|
325
376
|
const agent = agents.find((a) => a.name === agentName);
|
|
326
377
|
|
|
@@ -337,35 +388,76 @@ export default function (pi: ExtensionAPI) {
|
|
|
337
388
|
};
|
|
338
389
|
}
|
|
339
390
|
|
|
340
|
-
const
|
|
341
|
-
if (!model) {
|
|
391
|
+
const resolved = resolveModel(agent.model, ctx.model);
|
|
392
|
+
if (!resolved.model) {
|
|
393
|
+
const tried = resolved.attempted.join(", ") || "none";
|
|
394
|
+
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
342
395
|
return {
|
|
343
396
|
agent: agentName,
|
|
344
397
|
task,
|
|
345
398
|
exitCode: 1,
|
|
346
399
|
messages: [],
|
|
347
|
-
stderr: `
|
|
400
|
+
stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
|
|
348
401
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
349
|
-
errorMessage:
|
|
402
|
+
errorMessage: `No model resolved (tried: ${tried})`,
|
|
350
403
|
};
|
|
351
404
|
}
|
|
352
405
|
|
|
353
406
|
// Inject parent's API key so --api-key and other runtime overrides work
|
|
354
|
-
await injectApiKey(model);
|
|
355
|
-
|
|
356
|
-
|
|
407
|
+
await injectApiKey(resolved.model);
|
|
408
|
+
|
|
409
|
+
// Resolve tools; strip "subagent" to prevent accidental recursion.
|
|
410
|
+
// Sub-agents cannot spawn further sub-agents (one level of delegation only).
|
|
411
|
+
const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
412
|
+
let tools = agent.tools ?? defaultTools;
|
|
413
|
+
tools = tools.filter((t) => t !== "subagent");
|
|
414
|
+
|
|
415
|
+
// Build timeout + parent signal into a combined AbortSignal
|
|
416
|
+
let combinedSignal = parentSignal;
|
|
417
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
418
|
+
let timeoutController: AbortController | undefined;
|
|
419
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
420
|
+
timeoutController = new AbortController();
|
|
421
|
+
timeoutId = setTimeout(() => {
|
|
422
|
+
timeoutController!.abort();
|
|
423
|
+
}, timeoutMs);
|
|
424
|
+
// Combine with parent signal if present (Node 20+ AbortSignal.any)
|
|
425
|
+
if (parentSignal && typeof (AbortSignal as any).any === "function") {
|
|
426
|
+
combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
|
|
427
|
+
} else if (parentSignal) {
|
|
428
|
+
combinedSignal = timeoutController.signal;
|
|
429
|
+
// Link parent to timeout: if parent aborts, also abort our timeout controller
|
|
430
|
+
if (parentSignal.aborted) timeoutController.abort();
|
|
431
|
+
else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
|
|
432
|
+
} else {
|
|
433
|
+
combinedSignal = timeoutController.signal;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
357
436
|
|
|
358
|
-
|
|
437
|
+
const result = await runSubAgent({
|
|
359
438
|
cwd: cwd ?? ctx.cwd,
|
|
360
439
|
systemPrompt: agent.systemPrompt,
|
|
361
440
|
task,
|
|
362
441
|
tools,
|
|
363
|
-
model,
|
|
442
|
+
model: resolved.model,
|
|
364
443
|
authStorage,
|
|
365
444
|
modelRegistry,
|
|
366
|
-
signal,
|
|
445
|
+
signal: combinedSignal,
|
|
367
446
|
agentName,
|
|
368
447
|
});
|
|
448
|
+
|
|
449
|
+
// Clean up timeout
|
|
450
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
451
|
+
|
|
452
|
+
// Detect timeout: our timeout controller fired, not the parent
|
|
453
|
+
const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
|
|
454
|
+
if (timedOut) {
|
|
455
|
+
result.exitCode = 1;
|
|
456
|
+
result.stopReason = "timeout";
|
|
457
|
+
if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return result;
|
|
369
461
|
}
|
|
370
462
|
|
|
371
463
|
// --- Chain mode ---
|
|
@@ -377,7 +469,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
377
469
|
const step = params.chain[i];
|
|
378
470
|
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
379
471
|
|
|
380
|
-
const result = await runOne(
|
|
472
|
+
const result = await runOne(
|
|
473
|
+
step.agent, taskWithContext, step.cwd,
|
|
474
|
+
signal, step.timeout ?? params.timeout,
|
|
475
|
+
);
|
|
381
476
|
results.push(result);
|
|
382
477
|
|
|
383
478
|
const isError = isFailedResult(result);
|
|
@@ -389,13 +484,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
484
|
details: makeDetails("chain")(results),
|
|
390
485
|
});
|
|
391
486
|
}
|
|
487
|
+
// Include successful previous step outputs in the error content
|
|
488
|
+
const prevCount = i;
|
|
489
|
+
let contentText = `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
490
|
+
if (prevCount > 0) {
|
|
491
|
+
const prevSummaries = results
|
|
492
|
+
.slice(0, prevCount)
|
|
493
|
+
.map((r, j) => {
|
|
494
|
+
const out = getResultOutput(r).slice(0, 500);
|
|
495
|
+
return `Step ${j + 1} (${r.agent}): ${out}`;
|
|
496
|
+
})
|
|
497
|
+
.join("\n");
|
|
498
|
+
contentText = `Chain stopped at step ${i + 1}/${params.chain.length}. ${prevCount} previous step(s) succeeded:\n\n${prevSummaries}\n\nError at step ${i + 1} (${step.agent}): ${errorMsg}`;
|
|
499
|
+
}
|
|
392
500
|
return {
|
|
393
|
-
content: [
|
|
394
|
-
{
|
|
395
|
-
type: "text",
|
|
396
|
-
text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}`,
|
|
397
|
-
},
|
|
398
|
-
],
|
|
501
|
+
content: [{ type: "text", text: contentText }],
|
|
399
502
|
details: makeDetails("chain")(results),
|
|
400
503
|
isError: true,
|
|
401
504
|
};
|
|
@@ -434,6 +537,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
434
537
|
};
|
|
435
538
|
}
|
|
436
539
|
|
|
540
|
+
const abortOnFailure = params.abortOnFailure ?? false;
|
|
541
|
+
const parallelController = new AbortController();
|
|
542
|
+
|
|
543
|
+
// Combine parent signal with parallel abort controller
|
|
544
|
+
let parallelSignal: AbortSignal = parallelController.signal;
|
|
545
|
+
if (signal) {
|
|
546
|
+
// Always link parent abort into parallelController so queued tasks see aborted state
|
|
547
|
+
if (signal.aborted) parallelController.abort();
|
|
548
|
+
else signal.addEventListener("abort", () => parallelController.abort(), { once: true });
|
|
549
|
+
if (typeof (AbortSignal as any).any === "function") {
|
|
550
|
+
parallelSignal = (AbortSignal as any).any([signal, parallelController.signal]);
|
|
551
|
+
} else {
|
|
552
|
+
parallelSignal = parallelController.signal;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
437
556
|
const allResults: SubAgentResult[] = new Array(params.tasks.length);
|
|
438
557
|
// Initialize placeholder results for streaming
|
|
439
558
|
for (let i = 0; i < params.tasks.length; i++) {
|
|
@@ -467,14 +586,40 @@ export default function (pi: ExtensionAPI) {
|
|
|
467
586
|
params.tasks,
|
|
468
587
|
MAX_CONCURRENCY,
|
|
469
588
|
async (t, index) => {
|
|
470
|
-
|
|
589
|
+
// Skip if already aborted by sibling failure or parent abort
|
|
590
|
+
if (parallelSignal.aborted || parallelController.signal.aborted) {
|
|
591
|
+
const skippedResult: SubAgentResult = {
|
|
592
|
+
agent: t.agent,
|
|
593
|
+
task: t.task,
|
|
594
|
+
exitCode: 1,
|
|
595
|
+
messages: [],
|
|
596
|
+
stderr: "",
|
|
597
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
598
|
+
stopReason: "aborted",
|
|
599
|
+
errorMessage: parallelController.signal.aborted
|
|
600
|
+
? "Cancelled: sibling task failed"
|
|
601
|
+
: "Cancelled: parent operation aborted",
|
|
602
|
+
};
|
|
603
|
+
allResults[index] = skippedResult;
|
|
604
|
+
emitParallelUpdate();
|
|
605
|
+
return skippedResult;
|
|
606
|
+
}
|
|
607
|
+
const result = await runOne(
|
|
608
|
+
t.agent, t.task, t.cwd,
|
|
609
|
+
parallelSignal, t.timeout ?? params.timeout,
|
|
610
|
+
);
|
|
471
611
|
allResults[index] = result;
|
|
612
|
+
// Early-abort: if this task failed and abortOnFailure is set
|
|
613
|
+
if (abortOnFailure && isFailedResult(result)) {
|
|
614
|
+
parallelController.abort();
|
|
615
|
+
}
|
|
472
616
|
emitParallelUpdate();
|
|
473
617
|
return result;
|
|
474
618
|
},
|
|
475
619
|
);
|
|
476
620
|
|
|
477
621
|
const successCount = results.filter((r) => !isFailedResult(r)).length;
|
|
622
|
+
const cancelCount = results.filter((r) => r.stopReason === "aborted" && r.errorMessage?.includes("Cancelled")).length;
|
|
478
623
|
const summaries = results.map((r) => {
|
|
479
624
|
const output = truncateParallelOutput(getResultOutput(r));
|
|
480
625
|
const status = isFailedResult(r)
|
|
@@ -483,11 +628,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
628
|
return `### [${r.agent}] ${status}\n\n${output}`;
|
|
484
629
|
});
|
|
485
630
|
|
|
631
|
+
let headerText = `Parallel: ${successCount}/${results.length} succeeded`;
|
|
632
|
+
if (cancelCount > 0) headerText += ` (${cancelCount} cancelled)`;
|
|
486
633
|
return {
|
|
487
634
|
content: [
|
|
488
635
|
{
|
|
489
636
|
type: "text",
|
|
490
|
-
text:
|
|
637
|
+
text: `${headerText}\n\n${summaries.join("\n\n---\n\n")}`,
|
|
491
638
|
},
|
|
492
639
|
],
|
|
493
640
|
details: makeDetails("parallel")(results),
|
|
@@ -496,7 +643,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
496
643
|
|
|
497
644
|
// --- Single mode ---
|
|
498
645
|
if (params.agent && params.task) {
|
|
499
|
-
const result = await runOne(
|
|
646
|
+
const result = await runOne(
|
|
647
|
+
params.agent, params.task, params.cwd,
|
|
648
|
+
signal, params.timeout,
|
|
649
|
+
);
|
|
500
650
|
const isError = isFailedResult(result);
|
|
501
651
|
|
|
502
652
|
if (onUpdate) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-subagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
"agents.ts",
|
|
31
31
|
"runner.ts",
|
|
32
32
|
"render.ts",
|
|
33
|
-
"agents/"
|
|
33
|
+
"agents/",
|
|
34
|
+
"agent-format.md"
|
|
34
35
|
],
|
|
35
36
|
"pi": {
|
|
36
37
|
"extensions": [
|
package/render.ts
CHANGED
|
@@ -180,10 +180,14 @@ export function renderSingleResult(
|
|
|
180
180
|
const mdTheme = getMarkdownTheme();
|
|
181
181
|
const container = new Container();
|
|
182
182
|
let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
183
|
-
if (isError && result.stopReason)
|
|
183
|
+
if (isError && result.stopReason) {
|
|
184
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
185
|
+
header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
186
|
+
}
|
|
184
187
|
container.addChild(new Text(header, 0, 0));
|
|
185
188
|
if (isError && result.errorMessage) {
|
|
186
|
-
|
|
189
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
190
|
+
container.addChild(new Text(theme.fg(messageColor, `Error: ${result.errorMessage}`), 0, 0));
|
|
187
191
|
}
|
|
188
192
|
container.addChild(new Spacer(1));
|
|
189
193
|
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
@@ -218,8 +222,14 @@ export function renderSingleResult(
|
|
|
218
222
|
|
|
219
223
|
// Collapsed
|
|
220
224
|
let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
|
|
221
|
-
if (isError && result.stopReason)
|
|
222
|
-
|
|
225
|
+
if (isError && result.stopReason) {
|
|
226
|
+
const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
227
|
+
text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
|
|
228
|
+
}
|
|
229
|
+
if (isError && result.errorMessage) {
|
|
230
|
+
const messageColor = result.stopReason === "timeout" ? "warning" : "error";
|
|
231
|
+
text += `\n${theme.fg(messageColor, `Error: ${result.errorMessage}`)}`;
|
|
232
|
+
}
|
|
223
233
|
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
224
234
|
else {
|
|
225
235
|
text += `\n${renderDisplayItems(displayItems, theme, COLLAPSED_ITEM_COUNT)}`;
|
package/runner.ts
CHANGED
|
@@ -66,6 +66,7 @@ export async function runSubAgent(options: {
|
|
|
66
66
|
modelRegistry: ModelRegistry;
|
|
67
67
|
signal?: AbortSignal;
|
|
68
68
|
agentName?: string;
|
|
69
|
+
onUpdate?: (text: string) => void;
|
|
69
70
|
}): Promise<SubAgentResult> {
|
|
70
71
|
const {
|
|
71
72
|
cwd,
|
|
@@ -77,6 +78,7 @@ export async function runSubAgent(options: {
|
|
|
77
78
|
modelRegistry,
|
|
78
79
|
signal,
|
|
79
80
|
agentName = "subagent",
|
|
81
|
+
onUpdate,
|
|
80
82
|
} = options;
|
|
81
83
|
|
|
82
84
|
const result: SubAgentResult = {
|
|
@@ -123,6 +125,7 @@ export async function runSubAgent(options: {
|
|
|
123
125
|
|
|
124
126
|
try {
|
|
125
127
|
// Wire abort signal
|
|
128
|
+
let cleanupAbort: (() => void) | undefined;
|
|
126
129
|
if (signal) {
|
|
127
130
|
const onAbort = () => session.abort();
|
|
128
131
|
if (signal.aborted) {
|
|
@@ -134,10 +137,7 @@ export async function runSubAgent(options: {
|
|
|
134
137
|
return result;
|
|
135
138
|
}
|
|
136
139
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
137
|
-
|
|
138
|
-
// Cleanup listener on completion
|
|
139
|
-
const cleanup = () => signal.removeEventListener("abort", onAbort);
|
|
140
|
-
// We'll clean up in finally via a flag
|
|
140
|
+
cleanupAbort = () => signal.removeEventListener("abort", onAbort);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// Collect all messages and usage stats from events
|
|
@@ -190,6 +190,7 @@ export async function runSubAgent(options: {
|
|
|
190
190
|
result.exitCode = 0;
|
|
191
191
|
return result;
|
|
192
192
|
} finally {
|
|
193
|
+
cleanupAbort?.();
|
|
193
194
|
try {
|
|
194
195
|
session.dispose();
|
|
195
196
|
} catch {
|
|
@@ -210,13 +211,26 @@ export async function runSubAgent(options: {
|
|
|
210
211
|
// ---------------------------------------------------------------------------
|
|
211
212
|
|
|
212
213
|
export function getFinalOutput(messages: Message[]): string {
|
|
214
|
+
// Prefer the last assistant message with non-empty text and NO tool calls (pure final answer).
|
|
213
215
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
214
216
|
const msg = messages[i];
|
|
215
|
-
if (msg.role
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
if (msg.role !== "assistant") continue;
|
|
218
|
+
const texts: string[] = [];
|
|
219
|
+
let hasToolCalls = false;
|
|
220
|
+
for (const part of msg.content) {
|
|
221
|
+
if (part.type === "text" && part.text.trim()) texts.push(part.text);
|
|
222
|
+
else if (part.type === "toolCall") hasToolCalls = true;
|
|
219
223
|
}
|
|
224
|
+
if (texts.length > 0 && !hasToolCalls) return texts.join("");
|
|
225
|
+
}
|
|
226
|
+
// Fallback: last assistant message with any non-empty text (even if it also has tool calls).
|
|
227
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
228
|
+
const msg = messages[i];
|
|
229
|
+
if (msg.role !== "assistant") continue;
|
|
230
|
+
const texts = msg.content
|
|
231
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text" && p.text.trim().length > 0)
|
|
232
|
+
.map((p) => p.text);
|
|
233
|
+
if (texts.length > 0) return texts.join("");
|
|
220
234
|
}
|
|
221
235
|
return "";
|
|
222
236
|
}
|
|
@@ -225,7 +239,8 @@ export function isFailedResult(result: SubAgentResult): boolean {
|
|
|
225
239
|
return (
|
|
226
240
|
result.exitCode !== 0 ||
|
|
227
241
|
result.stopReason === "error" ||
|
|
228
|
-
result.stopReason === "aborted"
|
|
242
|
+
result.stopReason === "aborted" ||
|
|
243
|
+
result.stopReason === "timeout"
|
|
229
244
|
);
|
|
230
245
|
}
|
|
231
246
|
|