@arhen/pi-core-subagent 1.1.23 → 1.2.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/README.md +96 -4
- package/package.json +6 -3
- package/src/index.ts +115 -26
package/README.md
CHANGED
|
@@ -4,16 +4,27 @@
|
|
|
4
4
|
[](./LICENSE)
|
|
5
5
|
[](https://github.com/earendil-works/pi)
|
|
6
6
|
|
|
7
|
-
Minimalist pi extension: **fast in-process subagents** with single / parallel /
|
|
7
|
+
Minimalist pi extension: **fast in-process subagents** with single / parallel / graph modes, background runs, cancellation, intercom (child↔leader) and an agent↔agent mailbox.
|
|
8
8
|
|
|
9
9
|
Built for one job: delegate work to isolated subagents **without bloating the parent context**.
|
|
10
10
|
|
|
11
|
+
One rule underneath everything else:
|
|
12
|
+
|
|
13
|
+
> **A task is a graph, not a checklist.** Nodes are workers, edges are data dependencies. The edge both gates the dependent *and* hands it the upstream output — so "the coordinator forgot to pass X" stops being a failure mode. Where there are no edges, there is no graph: flat fan-out stays flat.
|
|
14
|
+
|
|
15
|
+
That is [Graph Protocol](#graph-protocol), applied to the runtime rather than to the prompt.
|
|
16
|
+
|
|
11
17
|

|
|
12
18
|
|
|
13
19
|
*The `subagent` tool call plus the live above-editor widget: per-agent activity, tool counts, turns, token counters and timers.*
|
|
14
20
|
|
|
15
21
|
## Design principles
|
|
16
22
|
|
|
23
|
+
- **The delegation is the graph.** `needs` declares edges; the scheduler runs each wave of ready tasks in parallel and gates the rest. One code path for single, parallel, chain and graph — `chain` is just `needs: [previous]`. ([why](#why-waves-instead-of-more-agents))
|
|
24
|
+
- **Edges carry data, not just order.** An upstream task's output is prepended to its dependents' prompts automatically. The coordinator cannot forget to pass it, because it never passes it.
|
|
25
|
+
- **A bad graph fails before it spawns.** Unknown ids, self-edges and cycles are rejected at call time — never halfway through a run with three children already burning tokens.
|
|
26
|
+
- **Proof is an exit code, never a self-report.** Tasks are asked for a runnable `Verify:` command; the leader checks `git diff --stat`. Agents auditing their own work score ~0. ([why](#why-9-is-a-verification-command-not-a-self-report))
|
|
27
|
+
- **No ceremony without edges.** Six independent reviewers stay six independent reviewers — no waves, no gates, no graph vocabulary imposed on flat work.
|
|
17
28
|
- **No agent files, no discovery.** The leader defines every subagent inline per call — name, system prompt, toolset. Nothing is read from or written to disk.
|
|
18
29
|
- **Two toolsets only.** Read-only (`read, grep, find, ls` — default) or write (`read, grep, find, ls, bash, edit, write` — `write: true`). No per-agent tool config surface.
|
|
19
30
|
- **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
|
|
@@ -64,6 +75,38 @@ Chain — `{previous}` is replaced with the prior agent's output:
|
|
|
64
75
|
}
|
|
65
76
|
```
|
|
66
77
|
|
|
78
|
+
## Graph mode — `needs`
|
|
79
|
+
|
|
80
|
+
`parallel` runs everything at once; `chain` runs everything one at a time. Most real work is neither. Give a task an `id` and list the ids it `needs`:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"tasks": [
|
|
85
|
+
{ "id": "api", "agent": "api-mapper", "task": "Map every route in src/api/" },
|
|
86
|
+
{ "id": "db", "agent": "db-mapper", "task": "Map the schema in src/db/" },
|
|
87
|
+
{ "id": "doc", "agent": "writer", "needs": ["api", "db"], "write": true,
|
|
88
|
+
"task": "Write ARCHITECTURE.md from the maps above. Verify: test -s ARCHITECTURE.md" }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
wave 1: api ∥ db → gate → wave 2: doc
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
What the edge does:
|
|
98
|
+
|
|
99
|
+
- **Gates** — `doc` starts only after both `api` and `db` finish.
|
|
100
|
+
- **Carries** — `api`'s and `db`'s outputs are prepended to `doc`'s prompt as `## Output of api` / `## Output of db`. You do not pass them yourself, and you cannot forget to.
|
|
101
|
+
- **Skips on breakage** — if an upstream fails or is aborted, dependents are marked aborted instead of running against a prompt with a hole in it.
|
|
102
|
+
|
|
103
|
+
Rules:
|
|
104
|
+
|
|
105
|
+
- Tasks with no unmet `needs` run together, throttled by `concurrency`.
|
|
106
|
+
- Unknown ids, self-edges and cycles are rejected **before any child spawns**.
|
|
107
|
+
- `chain` is exactly `needs: [previous]` — same scheduler, kept for convenience. `{previous}` still expands.
|
|
108
|
+
- Zero `needs` anywhere = plain parallel. No ceremony added to flat fan-out.
|
|
109
|
+
|
|
67
110
|
Background + intercom:
|
|
68
111
|
|
|
69
112
|
```json
|
|
@@ -80,7 +123,7 @@ Background + intercom:
|
|
|
80
123
|
|
|
81
124
|
| Tool | Purpose |
|
|
82
125
|
|---|---|
|
|
83
|
-
| `subagent` | single / `tasks` (parallel) / `chain` (`{previous}`); `background:true` fire-and-forget; `allowIntercom:true` enables child talk tools; `notifyPerTask: true` wakes you as each task completes (default off) |
|
|
126
|
+
| `subagent` | single / `tasks` (parallel or graph via `needs`) / `chain` (`{previous}`); `background:true` fire-and-forget; `allowIntercom:true` enables child talk tools; `notifyPerTask: true` wakes you as each task completes (default off) |
|
|
84
127
|
| `subagent_status` | live per-task snapshot (non-blocking) |
|
|
85
128
|
| `subagent_result` | full output of a run or one task |
|
|
86
129
|
| `await_subagent` | block until a run finishes (optional `timeoutMs`) |
|
|
@@ -89,7 +132,7 @@ Background + intercom:
|
|
|
89
132
|
|
|
90
133
|
### Per-task fields
|
|
91
134
|
|
|
92
|
-
`agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id
|
|
135
|
+
`agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `background`, `notifyPerTask`, `allowIntercom`, `concurrency`.
|
|
93
136
|
|
|
94
137
|
### Child talk tools (when `allowIntercom: true`)
|
|
95
138
|
|
|
@@ -115,12 +158,61 @@ Read-only pane over the session's subagents:
|
|
|
115
158
|
- Background completion: 3-line notice. Full text only via `subagent_result`.
|
|
116
159
|
- Children: isolated sessions; talk tools injected only when `allowIntercom`; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`.
|
|
117
160
|
|
|
161
|
+
## What this is built on
|
|
162
|
+
|
|
163
|
+
### Graph Protocol
|
|
164
|
+
<a id="graph-protocol"></a>
|
|
165
|
+
|
|
166
|
+
`needs` is an implementation of [Graph Protocol](https://gist.github.com/r17x/90eb2f7be93932b5693753aedb09c01a) — a delegation discipline that treats a task as a graph (`Delegation<A, E, R>`) rather than a checklist. Its ten sections map onto this extension as follows:
|
|
167
|
+
|
|
168
|
+
| § | Protocol | Here |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| §1 | nodes, domains, edges | one `agent` owns one task; `needs` are the edges |
|
|
171
|
+
| §2 | happy path as execution graph, waves + gates | wave scheduler: `ready = tasks whose needs are settled` |
|
|
172
|
+
| §3 | one worker or many | `single` vs `tasks` |
|
|
173
|
+
| §4 | break points: wrong context, **missing input**, misinterpretation | missing input is structurally impossible — the edge carries the output |
|
|
174
|
+
| §5 | R: subgraph, method, **verification command**, WHY | prompt guidelines require a runnable `Verify:` line per task |
|
|
175
|
+
| §6 | structured at the boundary | in: upstream outputs prepended as named blocks. out: prose (see below) |
|
|
176
|
+
| §7 | observe without changing the graph | the widget and `/subagents peek` are read-only |
|
|
177
|
+
| §8 | worker attention acquired and released | spawn → terminal status; aborted upstream releases dependents immediately |
|
|
178
|
+
| §9 | prove it: delegated vs implemented | **deliberately not self-reported** — see below |
|
|
179
|
+
| §10 | prompt = subgraph, return = implemented graph | prompt yes; return kept as prose |
|
|
180
|
+
|
|
181
|
+
### Why §9 is a verification command, not a self-report
|
|
182
|
+
|
|
183
|
+
The protocol asks the coordinator to compare the delegated subgraph against the graph the worker says it implemented. We implement the comparison against **the filesystem and the exit code**, not against the worker's account of itself, because self-reports carry close to zero signal about exactly the failure §9 exists to catch:
|
|
184
|
+
|
|
185
|
+
- Asked to audit its own work against 34 real violations, an agent reported **0** — at 90–100 confidence. A *fresh* instance of the same model shown the same output caught 7 (p = 0.0156). A deterministic checker caught all 34. ([Armalo Labs, 2026](https://www.armalo.ai/labs/research/2026-06-11-zero-bit-self-audit))
|
|
186
|
+
- Across 9,876 τ2-bench and 1,879 AppWorld trajectories, "false success" reached **75.8%** of self-assessing coding-agent failures; adding an LLM judge scored **0.54–0.65 AUROC** (0.5 = coin flip). ([arXiv:2606.09863](https://doi.org/10.48550/arxiv.2606.09863))
|
|
187
|
+
- LLM judges reading agent traces can be flipped by rewriting the trace — the exact surface a self-reported graph exposes. ([arXiv:2601.14691](https://arxiv.org/html/2601.14691))
|
|
188
|
+
|
|
189
|
+
So §9 in practice is two things you already have:
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
# in the task text — the worker must prove it, not claim it
|
|
193
|
+
Verify: npx tsc --noEmit && bun test
|
|
194
|
+
|
|
195
|
+
# in the leader, after the run — ground truth, not narrative
|
|
196
|
+
git diff --stat
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If files outside a worker's subgraph were touched, the diff says so. A structured return schema would only add a second, less trustworthy witness.
|
|
200
|
+
|
|
201
|
+
### Why waves instead of "more agents"
|
|
202
|
+
|
|
203
|
+
Flat fan-out is not free — orchestration cost is `critical path + α × cross-agent communication`, and ignoring the second term is what makes added agents *lose* to a single one:
|
|
204
|
+
|
|
205
|
+
- Dependency-graph partitioning vs flat file-parallel spawning across 28 real repos: **+14.0% pass rate, 2.10× wall-clock, −35% API cost**, with the largest gains on the most dependency-dense projects. Flat parallel inflated cost 60% for a 1.56× speedup; an agent-team baseline was fastest but scored *below sequential* on code quality. ([arXiv:2606.00953](https://arxiv.org/html/2606.00953v1))
|
|
206
|
+
- Dynamic task graphs across 300 trials: 47.5% of baseline token cost, 79.7% accuracy vs 57.6% for a static graph — and, notably, **static tied dynamic when the structure was genuinely known up front**, which is the case `needs` targets. The "frontier" (ready set) in this scheduler is theirs. ([arXiv:2605.06320](https://arxiv.org/html/2605.06320))
|
|
207
|
+
|
|
208
|
+
The corollary is in the design principles: when there are no edges, don't draw a graph. Six independent reviewers stay six independent reviewers.
|
|
209
|
+
|
|
118
210
|
## Development
|
|
119
211
|
|
|
120
212
|
```sh
|
|
121
213
|
bun install # dev deps (typecheck/test only; runtime uses pi's bundled SDK)
|
|
122
214
|
npx tsc --noEmit
|
|
123
|
-
bun test # pure-logic
|
|
215
|
+
bun test # pure-logic tests (wave scheduling, edge payload, mailbox, failure classification, watchdog)
|
|
124
216
|
```
|
|
125
217
|
|
|
126
218
|
Runtime state: runs persist to `<parent-session>.subagents.json` sidecar; restored (non-terminal → aborted) on session start.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "pi extension: fast in-process subagents with
|
|
5
|
+
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "./src/index.ts",
|
|
8
8
|
"files": [
|
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
"pi-extension",
|
|
17
17
|
"pi-package",
|
|
18
18
|
"subagent",
|
|
19
|
-
"agent-orchestration"
|
|
19
|
+
"agent-orchestration",
|
|
20
|
+
"task-graph",
|
|
21
|
+
"dag",
|
|
22
|
+
"graph-protocol"
|
|
20
23
|
],
|
|
21
24
|
"peerDependencies": {
|
|
22
25
|
"@earendil-works/pi-ai": "^0.84.2",
|
package/src/index.ts
CHANGED
|
@@ -55,6 +55,8 @@ interface TaskInput {
|
|
|
55
55
|
/** Name the leader invents for this subagent (display + mailbox addressing). */
|
|
56
56
|
agent: string;
|
|
57
57
|
task: string;
|
|
58
|
+
/** Task ids this task depends on. The edge carries the upstream output into this prompt. */
|
|
59
|
+
needs?: string[];
|
|
58
60
|
/** System prompt the leader writes for this agent. Optional — a minimal default is used. */
|
|
59
61
|
prompt?: string;
|
|
60
62
|
/** true = write toolset; false/omitted = read-only toolset. */
|
|
@@ -73,6 +75,8 @@ interface TaskSnapshot {
|
|
|
73
75
|
task: string;
|
|
74
76
|
cwd: string;
|
|
75
77
|
status: TaskStatus;
|
|
78
|
+
/** Resolved dependency edges (task ids). Empty/absent = wave 1. */
|
|
79
|
+
needs?: string[];
|
|
76
80
|
sessionId?: string;
|
|
77
81
|
sessionFile?: string;
|
|
78
82
|
model?: string;
|
|
@@ -241,10 +245,12 @@ export function colorNums(text: string, theme: Theme): string {
|
|
|
241
245
|
*/
|
|
242
246
|
function themedTaskLine(task: TaskSnapshot, theme: Theme, activity = ""): string {
|
|
243
247
|
const tail = `${taskStatsWithUsage(task)} · ${taskTimer(task)}`;
|
|
248
|
+
// Queued task with unmet needs: show the gate it's waiting on instead of empty stats.
|
|
249
|
+
const gate = task.status === "queued" && task.needs?.length ? `${theme.fg("muted", `↳ waits ${task.needs.join(",")}`)} · ` : "";
|
|
244
250
|
if (TERMINAL.includes(task.status)) {
|
|
245
251
|
return theme.fg("dim", `${statusIcon(task.status)} ${task.agent} · ${tail}`);
|
|
246
252
|
}
|
|
247
|
-
return `${statusIcon(task.status)} ${task.agent} · ${activity}${colorNums(tail, theme)}`;
|
|
253
|
+
return `${statusIcon(task.status)} ${task.agent} · ${gate}${activity}${colorNums(tail, theme)}`;
|
|
248
254
|
}
|
|
249
255
|
/**
|
|
250
256
|
* Human-readable activity line: "Read src/index.ts", "Grep wrapSingleLine".
|
|
@@ -339,7 +345,9 @@ function makeSummary(run: RunSnapshot): string {
|
|
|
339
345
|
const usage = formatUsage(run.aggregateUsage);
|
|
340
346
|
if (usage) lines.push(`Usage: ${usage}`);
|
|
341
347
|
for (const task of run.tasks) {
|
|
342
|
-
|
|
348
|
+
// Edges are named so the leader can compare what it delegated against what came back.
|
|
349
|
+
const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
|
|
350
|
+
lines.push(`\n## ${task.agent}${edge} ${statusIcon(task.status)}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}`);
|
|
343
351
|
}
|
|
344
352
|
// Ceiling on the WHOLE summary — 16 tasks × 24KB would otherwise flood the parent context.
|
|
345
353
|
return truncateText(lines.join("\n"));
|
|
@@ -425,6 +433,62 @@ export function validateThinking(model: Model<Api> | undefined, level: string |
|
|
|
425
433
|
// Cached catalog removed: agents are defined inline by the leader per call,
|
|
426
434
|
// so there is nothing to inject into the parent context. Zero per-request cost.
|
|
427
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Resolve dependency edges (Graph Protocol §2). Returns one id list per task,
|
|
438
|
+
* in input order. Chain mode is just `needs: [previous]`, so both modes run
|
|
439
|
+
* through the same wave scheduler.
|
|
440
|
+
*
|
|
441
|
+
* Throws on unknown ids, self-edges, and cycles — a bad graph must fail before
|
|
442
|
+
* any child is spawned, never halfway through a run.
|
|
443
|
+
*/
|
|
444
|
+
export function resolveNeeds(inputs: { id?: string; needs?: string[] }[], mode: RunMode): string[][] {
|
|
445
|
+
const ids = inputs.map((input, index) => input.id ?? `task_${index + 1}`);
|
|
446
|
+
const known = new Set(ids);
|
|
447
|
+
const edges = inputs.map((input, index) => {
|
|
448
|
+
if (mode === "chain") return index === 0 ? [] : [ids[index - 1] as string];
|
|
449
|
+
const needs = input.needs ?? [];
|
|
450
|
+
for (const need of needs) {
|
|
451
|
+
if (!known.has(need)) throw new Error(`Task ${ids[index]} needs unknown task id: ${need}`);
|
|
452
|
+
if (need === ids[index]) throw new Error(`Task ${ids[index]} cannot need itself.`);
|
|
453
|
+
}
|
|
454
|
+
return [...new Set(needs)];
|
|
455
|
+
});
|
|
456
|
+
// Kahn's algorithm: if any task never becomes ready, the remainder is a cycle.
|
|
457
|
+
const done = new Set<string>();
|
|
458
|
+
let progress = true;
|
|
459
|
+
while (progress) {
|
|
460
|
+
progress = false;
|
|
461
|
+
for (const [index, id] of ids.entries()) {
|
|
462
|
+
if (done.has(id)) continue;
|
|
463
|
+
if ((edges[index] as string[]).every((need) => done.has(need))) {
|
|
464
|
+
done.add(id);
|
|
465
|
+
progress = true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (done.size !== ids.length) {
|
|
470
|
+
throw new Error(`Cycle in subagent needs: ${ids.filter((id) => !done.has(id)).join(", ")}`);
|
|
471
|
+
}
|
|
472
|
+
return edges;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Graph Protocol §6: the edge carries the upstream output, not just ordering.
|
|
477
|
+
* Upstream results are prepended verbatim; `{previous}` stays supported so old
|
|
478
|
+
* chain prompts keep working (it expands to the first need's output).
|
|
479
|
+
*/
|
|
480
|
+
export function applyUpstream(task: string, needs: string[], outputs: Map<string, string>): string {
|
|
481
|
+
if (needs.length === 0) {
|
|
482
|
+
return task.includes("{previous}")
|
|
483
|
+
? `${task.replace(/\{previous\}/g, () => "")}\n\n(Note: {previous} was empty — no prior step output existed yet.)`
|
|
484
|
+
: task;
|
|
485
|
+
}
|
|
486
|
+
const first = outputs.get(needs[0] as string) ?? "";
|
|
487
|
+
const body = task.replace(/\{previous\}/g, () => first); // replacer fn: no $ corruption
|
|
488
|
+
const blocks = needs.map((need) => `## Output of ${need}\n${outputs.get(need) ?? "(no output)"}`);
|
|
489
|
+
return `${blocks.join("\n\n")}\n\n---\n\n${body}`;
|
|
490
|
+
}
|
|
491
|
+
|
|
428
492
|
async function mapWithConcurrency<T>(items: T[], concurrency: number, fn: (item: T, index: number) => Promise<void>): Promise<void> {
|
|
429
493
|
let next = 0;
|
|
430
494
|
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, async () => {
|
|
@@ -745,6 +809,9 @@ class SubagentManager {
|
|
|
745
809
|
this.updateTask(run, task, {
|
|
746
810
|
status: "starting",
|
|
747
811
|
startedAt: Date.now(),
|
|
812
|
+
// Upstream outputs were spliced in by the scheduler; the snapshot must show
|
|
813
|
+
// the prompt the child actually receives.
|
|
814
|
+
task: input.task,
|
|
748
815
|
model: input.model,
|
|
749
816
|
thinking,
|
|
750
817
|
tools,
|
|
@@ -927,6 +994,7 @@ class SubagentManager {
|
|
|
927
994
|
ids.add(input.id);
|
|
928
995
|
}
|
|
929
996
|
}
|
|
997
|
+
const edges = resolveNeeds(inputs, mode);
|
|
930
998
|
|
|
931
999
|
const run: RunSnapshot = {
|
|
932
1000
|
id: newId("run"),
|
|
@@ -944,6 +1012,7 @@ class SubagentManager {
|
|
|
944
1012
|
task: input.task,
|
|
945
1013
|
cwd: input.cwd ?? ctx.cwd,
|
|
946
1014
|
status: "queued" as TaskStatus,
|
|
1015
|
+
needs: edges[index],
|
|
947
1016
|
model: input.model,
|
|
948
1017
|
thinking: input.thinking,
|
|
949
1018
|
tools: input.tools ?? (input.write ? WRITE_TOOLS : READONLY_TOOLS),
|
|
@@ -973,33 +1042,44 @@ class SubagentManager {
|
|
|
973
1042
|
run.startedAt = Date.now();
|
|
974
1043
|
this.updateRun(run, ctx, onUpdate);
|
|
975
1044
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
previous = task.finalText ?? "";
|
|
994
|
-
}
|
|
995
|
-
} else {
|
|
996
|
-
await mapWithConcurrency(run.tasks, run.mode === "single" ? 1 : run.concurrency, async (task) => {
|
|
1045
|
+
// One wave scheduler for every mode. A wave is the set of tasks whose needs
|
|
1046
|
+
// are all satisfied; the loop boundary between waves IS the gate. Chain mode
|
|
1047
|
+
// reaches here as needs: [previous], so it needs no special case.
|
|
1048
|
+
const outputs = new Map<string, string>();
|
|
1049
|
+
const settled = new Set<string>();
|
|
1050
|
+
let remaining = run.tasks.filter((t) => !TERMINAL.includes(t.status));
|
|
1051
|
+
for (const task of run.tasks) {
|
|
1052
|
+
if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
while (remaining.length > 0) {
|
|
1056
|
+
const ready = remaining.filter((t) => (t.needs ?? []).every((need) => settled.has(need)));
|
|
1057
|
+
// resolveNeeds() rejects cycles up front, so an empty frontier here means every
|
|
1058
|
+
// remaining task is downstream of one that never settled (canceled mid-run).
|
|
1059
|
+
if (ready.length === 0) break;
|
|
1060
|
+
|
|
1061
|
+
await mapWithConcurrency(ready, run.mode === "single" ? 1 : run.concurrency, async (task) => {
|
|
997
1062
|
const index = run.tasks.indexOf(task);
|
|
998
|
-
|
|
1063
|
+
const input = inputs[index]!;
|
|
1064
|
+
const needs = task.needs ?? [];
|
|
1065
|
+
// An upstream failure means this task's input never existed. Running it anyway
|
|
1066
|
+
// burns a full child session on a prompt with a hole in it.
|
|
1067
|
+
const broken = needs.filter((need) => !outputs.has(need));
|
|
1068
|
+
if (broken.length > 0) {
|
|
1069
|
+
this.updateTask(run, task, { status: "aborted", error: `Skipped: upstream task(s) did not complete: ${broken.join(", ")}`, endedAt: Date.now() }, ctx, onUpdate);
|
|
1070
|
+
} else {
|
|
1071
|
+
await this.runChild(run, task, { ...input, task: applyUpstream(input.task, needs, outputs) }, ctx, signal, onUpdate);
|
|
1072
|
+
}
|
|
999
1073
|
if (run.notifyPerTask && run.background && TERMINAL.includes(task.status)) {
|
|
1000
1074
|
this.notifyTask(run, task, task.status as "completed" | "failed" | "aborted");
|
|
1001
1075
|
}
|
|
1002
1076
|
});
|
|
1077
|
+
|
|
1078
|
+
for (const task of ready) {
|
|
1079
|
+
settled.add(task.id);
|
|
1080
|
+
if (task.status === "completed") outputs.set(task.id, task.finalText ?? "");
|
|
1081
|
+
}
|
|
1082
|
+
remaining = remaining.filter((t) => !settled.has(t.id));
|
|
1003
1083
|
}
|
|
1004
1084
|
|
|
1005
1085
|
const failed = run.tasks.some((t) => t.status === "failed");
|
|
@@ -1145,6 +1225,12 @@ const TaskItem = Type.Object({
|
|
|
1145
1225
|
cwd: Type.Optional(Type.String({ description: "Working directory for this task. Default: current project." })),
|
|
1146
1226
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
|
|
1147
1227
|
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout (ms)" })),
|
|
1228
|
+
needs: Type.Optional(
|
|
1229
|
+
Type.Array(Type.String(), {
|
|
1230
|
+
description:
|
|
1231
|
+
"Task ids this task depends on (requires those tasks to declare id). It starts only after they finish, and their outputs are prepended to its prompt. Tasks with no unmet needs run together as a wave.",
|
|
1232
|
+
}),
|
|
1233
|
+
),
|
|
1148
1234
|
});
|
|
1149
1235
|
|
|
1150
1236
|
type SubagentParamsShape = {
|
|
@@ -1267,12 +1353,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1267
1353
|
pi.registerTool<typeof SubagentParams, RunDetails>({
|
|
1268
1354
|
name: "subagent",
|
|
1269
1355
|
label: "Subagent",
|
|
1270
|
-
description: "Define and run isolated subagents (own context, own session). You invent the agent: name, optional system prompt, toolset (read-only default, write:true for edits). Modes: single, parallel (tasks), chain ({previous}). background:true fire-and-forgets with completion notice. allowIntercom:true lets children ask you questions and message each other.\n\nExamples (copy these shapes):\nSingle: subagent({ agent: \"reviewer\", prompt: \"You review code for correctness\", task: \"Review src/auth.ts\" })\nParallel: subagent({ tasks: [{ agent: \"mapper\", task: \"Map all API routes\" }, { agent: \"critic\", task: \"Review auth for vulnerabilities\" }] })\nChain: subagent({ chain: [{ agent: \"planner\", task: \"Plan the change\" }, { agent: \"doer\", write: true, task: \"Execute: {previous}\" }] })\nBackground: subagent({ agent: \"auditor\", task: \"Audit deps\", background: true })",
|
|
1356
|
+
description: "Define and run isolated subagents (own context, own session). You invent the agent: name, optional system prompt, toolset (read-only default, write:true for edits). Modes: single, parallel (tasks), chain ({previous}). Tasks with `needs` form a dependency graph: each wave of tasks with satisfied needs runs in parallel, and an upstream task's output is prepended to its dependents' prompts. background:true fire-and-forgets with completion notice. allowIntercom:true lets children ask you questions and message each other.\n\nExamples (copy these shapes):\nSingle: subagent({ agent: \"reviewer\", prompt: \"You review code for correctness\", task: \"Review src/auth.ts\" })\nParallel: subagent({ tasks: [{ agent: \"mapper\", task: \"Map all API routes\" }, { agent: \"critic\", task: \"Review auth for vulnerabilities\" }] })\nGraph: subagent({ tasks: [{ id: \"api\", agent: \"api-mapper\", task: \"Map API routes\" }, { id: \"db\", agent: \"db-mapper\", task: \"Map DB schema\" }, { id: \"doc\", agent: \"writer\", needs: [\"api\", \"db\"], write: true, task: \"Write ARCHITECTURE.md. Verify: test -s ARCHITECTURE.md\" }] })\nChain: subagent({ chain: [{ agent: \"planner\", task: \"Plan the change\" }, { agent: \"doer\", write: true, task: \"Execute: {previous}\" }] })\nBackground: subagent({ agent: \"auditor\", task: \"Audit deps\", background: true })",
|
|
1271
1357
|
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
1272
1358
|
promptGuidelines: [
|
|
1273
1359
|
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
1274
1360
|
"Decompose parallelizable work: if the request has 2+ independent sub-tasks (separate files, separate concerns, independent research/review), spawn N agents with a SINGLE call: subagent({ tasks: [{agent, task}, ...] }). NEVER make multiple parallel subagent calls for parallel work — one call, one run, N tasks.",
|
|
1275
1361
|
"If independent sub-tasks are sequential (each builds on the previous one's output), use chain mode with {previous}.",
|
|
1362
|
+
"When some tasks depend on others but not all do, give tasks an `id` and list `needs`. Independent tasks then still run in parallel while dependents wait, and each dependent receives its upstream outputs automatically — do not re-describe them in the prompt.",
|
|
1363
|
+
"Give every task a way to check itself: end the task text with a runnable command, e.g. 'Verify: npx tsc --noEmit && bun test'. A subagent's own claim of success is not evidence.",
|
|
1276
1364
|
"Define each subagent yourself: an invented name, a focused system prompt (prompt:), and a toolset — read-only (default) or write (write:true).",
|
|
1277
1365
|
"Prefer read-only subagents unless the task explicitly needs edits.",
|
|
1278
1366
|
"Use background:true for long-running work; you'll be notified on completion.",
|
|
@@ -1294,10 +1382,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1294
1382
|
},
|
|
1295
1383
|
renderCall(args, theme) {
|
|
1296
1384
|
// ponytail: args stream in partially, so mode is unknowable until JSON closes. Show "preparing…" instead of a wrong "single ?".
|
|
1385
|
+
const hasEdges = args.tasks?.some((t: any) => t.needs?.length);
|
|
1297
1386
|
const mode = args.chain?.length
|
|
1298
1387
|
? `chain ${args.chain.length}`
|
|
1299
1388
|
: args.tasks?.length
|
|
1300
|
-
?
|
|
1389
|
+
? `${hasEdges ? "graph" : "parallel"} ${args.tasks.length}`
|
|
1301
1390
|
: args.agent
|
|
1302
1391
|
? `single ${args.agent}`
|
|
1303
1392
|
: "preparing…";
|