@arhen/pi-core-subagent 1.3.1 → 1.3.3
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 +132 -11
- package/package.json +1 -1
- package/src/child.ts +1 -1
- package/src/index.ts +26 -50
package/README.md
CHANGED
|
@@ -14,6 +14,21 @@ One rule underneath everything else:
|
|
|
14
14
|
|
|
15
15
|
That is [Graph Protocol](#graph-protocol), applied to the runtime rather than to the prompt.
|
|
16
16
|
|
|
17
|
+
```mermaid
|
|
18
|
+
flowchart LR
|
|
19
|
+
subgraph w1["wave 1 — runs in parallel"]
|
|
20
|
+
api["api<br/><i>api-mapper</i>"]
|
|
21
|
+
db["db<br/><i>db-mapper</i>"]
|
|
22
|
+
end
|
|
23
|
+
gate{{"gate"}}
|
|
24
|
+
subgraph w2["wave 2"]
|
|
25
|
+
doc["doc<br/><i>writer</i>"]
|
|
26
|
+
end
|
|
27
|
+
api -- "route map" --> gate
|
|
28
|
+
db -- "schema map" --> gate
|
|
29
|
+
gate -- "both outputs<br/>prepended to the prompt" --> doc
|
|
30
|
+
```
|
|
31
|
+
|
|
17
32
|

|
|
18
33
|
|
|
19
34
|
*The `subagent` tool call plus the live above-editor widget: per-agent activity, tool counts, turns, token counters and timers.*
|
|
@@ -33,6 +48,29 @@ That is [Graph Protocol](#graph-protocol), applied to the runtime rather than to
|
|
|
33
48
|
- **No silent hangs** — watchdog aborts children that produce no events for 3 minutes.
|
|
34
49
|
- **No default runtime cap** — tasks run until done, stalled (watchdog), or aborted by the user. `maxRuntimeMs` is opt-in (default 0 = unlimited).
|
|
35
50
|
|
|
51
|
+
## How it runs
|
|
52
|
+
|
|
53
|
+
Children are not subprocesses. They are separate `AgentSession`s inside the same pi process — which is why spawning is instant, and why a child's transcript never lands in your context:
|
|
54
|
+
|
|
55
|
+
```mermaid
|
|
56
|
+
flowchart TB
|
|
57
|
+
subgraph proc["one OS process — no spawn, no IPC"]
|
|
58
|
+
direction TB
|
|
59
|
+
L["<b>leader</b><br/>your session, your context"]
|
|
60
|
+
subgraph kids["isolated child sessions"]
|
|
61
|
+
direction LR
|
|
62
|
+
A["api-mapper"]
|
|
63
|
+
B["db-mapper"]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
L -- "task text in" --> A
|
|
67
|
+
L -- "task text in" --> B
|
|
68
|
+
A -. "final answer only" .-> L
|
|
69
|
+
B -. "final answer only" .-> L
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The dotted arrows are the whole point: a child may burn 200k tokens reading files, and the leader receives only its final answer.
|
|
73
|
+
|
|
36
74
|
## Install
|
|
37
75
|
|
|
38
76
|
```sh
|
|
@@ -103,18 +141,82 @@ subagent graph 3
|
|
|
103
141
|
|
|
104
142
|
`✎` marks a write-toolset task; `←` lists its edges. With no `needs` anywhere the wave line is omitted entirely.
|
|
105
143
|
|
|
106
|
-
What
|
|
144
|
+
### What one edge does
|
|
145
|
+
|
|
146
|
+
An edge is not just ordering. It is a delivery:
|
|
147
|
+
|
|
148
|
+
```mermaid
|
|
149
|
+
sequenceDiagram
|
|
150
|
+
participant S as scheduler
|
|
151
|
+
participant A as api
|
|
152
|
+
participant D as db
|
|
153
|
+
participant W as doc
|
|
154
|
+
|
|
155
|
+
Note over S,D: wave 1 — both start together
|
|
156
|
+
S->>A: "Map every route in src/api/"
|
|
157
|
+
S->>D: "Map the schema in src/db/"
|
|
158
|
+
A-->>S: route map
|
|
159
|
+
Note right of W: doc is queued,<br/>waiting at the gate
|
|
160
|
+
D-->>S: schema map
|
|
161
|
+
Note over S: gate opens: every need settled
|
|
162
|
+
S->>W: ## Output of api<br/><route map><br/><br/>## Output of db<br/><schema map><br/>---<br/>"Write ARCHITECTURE.md…"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The leader never copies those outputs into the prompt — so it cannot forget to.
|
|
166
|
+
|
|
167
|
+
### One scheduler, four shapes
|
|
168
|
+
|
|
169
|
+
Single, parallel, chain and graph are not four code paths. They are four shapes of the same wave loop:
|
|
170
|
+
|
|
171
|
+
```mermaid
|
|
172
|
+
flowchart LR
|
|
173
|
+
subgraph one["single"]
|
|
174
|
+
direction TB
|
|
175
|
+
s1(("a"))
|
|
176
|
+
end
|
|
177
|
+
subgraph par["parallel — no needs"]
|
|
178
|
+
direction TB
|
|
179
|
+
p1(("a")) ~~~ p2(("b")) ~~~ p3(("c"))
|
|
180
|
+
end
|
|
181
|
+
subgraph ch["chain — needs: [previous]"]
|
|
182
|
+
direction TB
|
|
183
|
+
c1(("a")) --> c2(("b")) --> c3(("c"))
|
|
184
|
+
end
|
|
185
|
+
subgraph gr["graph — needs"]
|
|
186
|
+
direction TB
|
|
187
|
+
g1(("a")) --> g2(("b"))
|
|
188
|
+
g1 --> g3(("c"))
|
|
189
|
+
g2 --> g4(("d"))
|
|
190
|
+
g3 --> g4
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### The loop
|
|
195
|
+
|
|
196
|
+
```mermaid
|
|
197
|
+
flowchart TD
|
|
198
|
+
start(["subagent call"]) --> validate{"graph valid?<br/><small>unknown id · self-edge · cycle</small>"}
|
|
199
|
+
validate -- no --> reject["reject the call<br/><b>zero children spawned</b>"]
|
|
200
|
+
validate -- yes --> loop{"tasks left?"}
|
|
201
|
+
loop -- no --> done(["run finished"])
|
|
202
|
+
loop -- yes --> ready["frontier =<br/>tasks whose needs are all settled"]
|
|
203
|
+
ready --> spawn["run that wave in parallel<br/><small>throttled by concurrency</small>"]
|
|
204
|
+
spawn --> collect["record each output<br/>mark tasks settled"]
|
|
205
|
+
collect --> loop
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Two consequences worth stating plainly:
|
|
107
209
|
|
|
108
|
-
- **
|
|
109
|
-
- **
|
|
110
|
-
- **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.
|
|
210
|
+
- **A bad graph costs nothing.** Validation happens before the first spawn, never halfway through with three children already burning tokens.
|
|
211
|
+
- **A broken upstream stops its branch.** If a need fails or is aborted, its dependents are marked aborted rather than run against a prompt with a hole in it:
|
|
111
212
|
|
|
112
|
-
|
|
213
|
+
```mermaid
|
|
214
|
+
flowchart LR
|
|
215
|
+
api["api ✓"] --> doc
|
|
216
|
+
db["db ✗ failed"] --> doc["doc ⏹ skipped<br/><small>never spawned</small>"]
|
|
217
|
+
```
|
|
113
218
|
|
|
114
|
-
|
|
115
|
-
- Unknown ids, self-edges and cycles are rejected **before any child spawns**.
|
|
116
|
-
- `chain` is exactly `needs: [previous]` — same scheduler, kept for convenience. `{previous}` still expands.
|
|
117
|
-
- Zero `needs` anywhere = plain parallel. No ceremony added to flat fan-out.
|
|
219
|
+
And the rule that keeps this from becoming ceremony: **zero `needs` anywhere = plain parallel.** No waves, no gates, no graph vocabulary imposed on flat work.
|
|
118
220
|
|
|
119
221
|
Background + intercom:
|
|
120
222
|
|
|
@@ -132,7 +234,7 @@ Background + intercom:
|
|
|
132
234
|
|
|
133
235
|
| Tool | Purpose |
|
|
134
236
|
|---|---|
|
|
135
|
-
| `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) |
|
|
237
|
+
| `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 (background runs only; default off) |
|
|
136
238
|
| `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path |
|
|
137
239
|
| `subagent_result` | full output of a run or one task |
|
|
138
240
|
| `await_subagent` | block until a run finishes (optional `timeoutMs`) |
|
|
@@ -163,7 +265,16 @@ Read-only pane over the session's subagents:
|
|
|
163
265
|
|
|
164
266
|
## Watching a child from outside
|
|
165
267
|
|
|
166
|
-
|
|
268
|
+
A child has no terminal of its own — but it does write a real transcript file, and that file is the seam every external viewer can use:
|
|
269
|
+
|
|
270
|
+
```mermaid
|
|
271
|
+
flowchart LR
|
|
272
|
+
child["child session<br/><small>no TTY</small>"] -- writes --> file[("session.jsonl")]
|
|
273
|
+
file -- "peek · enter" --> pane["in-pi tail"]
|
|
274
|
+
file -- "tail -f" --> term["any terminal pane<br/><small>herdr · tmux · zellij</small>"]
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`subagent_status` returns that path for every running child:
|
|
167
278
|
|
|
168
279
|
```sh
|
|
169
280
|
tail -f /path/from/subagent_status.jsonl
|
|
@@ -212,6 +323,16 @@ The protocol asks the coordinator to compare the delegated subgraph against the
|
|
|
212
323
|
- 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))
|
|
213
324
|
- 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))
|
|
214
325
|
|
|
326
|
+
The shape of the problem:
|
|
327
|
+
|
|
328
|
+
```mermaid
|
|
329
|
+
flowchart TD
|
|
330
|
+
W["worker finishes"] --> Q{"who says it's correct?"}
|
|
331
|
+
Q -- "the worker itself" --> S["self-report<br/><b>0 of 34 caught</b><br/><small>at 90–100 confidence</small>"]
|
|
332
|
+
Q -- "another model reading the trace" --> J["LLM judge<br/><b>0.54–0.65 AUROC</b><br/><small>0.5 = coin flip</small>"]
|
|
333
|
+
Q -- "the machine" --> D["exit code + git diff<br/><b>34 of 34 caught</b>"]
|
|
334
|
+
```
|
|
335
|
+
|
|
215
336
|
So §9 in practice is two things you already have:
|
|
216
337
|
|
|
217
338
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"type": "module",
|
|
5
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",
|
package/src/child.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
|
|
|
100
100
|
.map((m) => `from ${m.from}: ${m.text}`)
|
|
101
101
|
.join("\n");
|
|
102
102
|
const capped = body.length > 4000 ? body.slice(0, 4000).replace(/[\uD800-\uDBFF]$/, "") : body; // multibyte-safe
|
|
103
|
-
return { content: [{ type: "text" as const, text:
|
|
103
|
+
return { content: [{ type: "text" as const, text: capped }], details: { messages } };
|
|
104
104
|
},
|
|
105
105
|
},
|
|
106
106
|
];
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
17
17
|
import { StringEnum, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
18
18
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
19
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
20
|
-
import { Type } from "typebox";
|
|
20
|
+
import { Type, type Static } from "typebox";
|
|
21
21
|
import { join } from "node:path";
|
|
22
22
|
import { CHILD_TALK_TOOLS, createChildTools, createWatchdog, type ChildHandlers } from "./child.ts";
|
|
23
23
|
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
@@ -50,24 +50,6 @@ interface UsageStats {
|
|
|
50
50
|
turns: number;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
interface TaskInput {
|
|
54
|
-
id?: string;
|
|
55
|
-
/** Name the leader invents for this subagent (display + mailbox addressing). */
|
|
56
|
-
agent: string;
|
|
57
|
-
task: string;
|
|
58
|
-
/** Task ids this task depends on. The edge carries the upstream output into this prompt. */
|
|
59
|
-
needs?: string[];
|
|
60
|
-
/** System prompt the leader writes for this agent. Optional — a minimal default is used. */
|
|
61
|
-
prompt?: string;
|
|
62
|
-
/** true = write toolset; false/omitted = read-only toolset. */
|
|
63
|
-
write?: boolean;
|
|
64
|
-
tools?: string[];
|
|
65
|
-
model?: string;
|
|
66
|
-
thinking?: string;
|
|
67
|
-
cwd?: string;
|
|
68
|
-
maxRuntimeMs?: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
53
|
interface TaskSnapshot {
|
|
72
54
|
id: string;
|
|
73
55
|
runId: string;
|
|
@@ -1215,25 +1197,31 @@ class SubagentManager {
|
|
|
1215
1197
|
awaitRun(runId: string, timeoutMs?: number): Promise<RunSnapshot | undefined> {
|
|
1216
1198
|
const run = this.runs.get(runId);
|
|
1217
1199
|
if (!run) return Promise.resolve(undefined);
|
|
1218
|
-
run.
|
|
1219
|
-
|
|
1200
|
+
if (TERMINAL.includes(run.status)) {
|
|
1201
|
+
run.awaited = true;
|
|
1202
|
+
return Promise.resolve(cloneRun(run));
|
|
1203
|
+
}
|
|
1220
1204
|
const settled = new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1221
1205
|
const prev = this.settlers.get(runId);
|
|
1222
1206
|
this.settlers.set(runId, (r) => {
|
|
1223
1207
|
prev?.(r);
|
|
1224
1208
|
resolve(r);
|
|
1225
1209
|
});
|
|
1226
|
-
// Settle may have run between the terminal check and wiring.
|
|
1227
|
-
if (TERMINAL.includes(run.status)) resolve(cloneRun(run));
|
|
1228
1210
|
});
|
|
1229
|
-
if (
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1211
|
+
if (timeoutMs) {
|
|
1212
|
+
return Promise.race([
|
|
1213
|
+
settled,
|
|
1214
|
+
new Promise<RunSnapshot | undefined>((resolve) => {
|
|
1215
|
+
const timer = setTimeout(() => resolve(this.runs.get(runId) ? cloneRun(this.runs.get(runId)!) : undefined), timeoutMs);
|
|
1216
|
+
settled.then(() => clearTimeout(timer));
|
|
1217
|
+
}),
|
|
1218
|
+
]);
|
|
1219
|
+
}
|
|
1220
|
+
// Awaiting to completion: parent gets the real result, so suppress the
|
|
1221
|
+
// completion notice. On timeout we resolve a snapshot and leave awaited
|
|
1222
|
+
// unset, so the parent still receives the completion notification.
|
|
1223
|
+
run.awaited = true;
|
|
1224
|
+
return settled;
|
|
1237
1225
|
}
|
|
1238
1226
|
}
|
|
1239
1227
|
|
|
@@ -1255,29 +1243,13 @@ const TaskItem = Type.Object({
|
|
|
1255
1243
|
needs: Type.Optional(Type.Array(Type.String(), { description: "Ids of tasks this one waits for; their outputs are prepended to this prompt." })),
|
|
1256
1244
|
});
|
|
1257
1245
|
|
|
1258
|
-
type SubagentParamsShape = {
|
|
1259
|
-
agent?: string;
|
|
1260
|
-
task?: string;
|
|
1261
|
-
prompt?: string;
|
|
1262
|
-
write?: boolean;
|
|
1263
|
-
tasks?: TaskInput[];
|
|
1264
|
-
chain?: TaskInput[];
|
|
1265
|
-
model?: string;
|
|
1266
|
-
thinking?: string;
|
|
1267
|
-
cwd?: string;
|
|
1268
|
-
tools?: string[];
|
|
1269
|
-
concurrency?: number;
|
|
1270
|
-
maxRuntimeMs?: number;
|
|
1271
|
-
background?: boolean;
|
|
1272
|
-
allowIntercom?: boolean;
|
|
1273
|
-
notifyPerTask?: boolean;
|
|
1274
|
-
};
|
|
1275
1246
|
|
|
1276
1247
|
const SubagentParams = Type.Object({
|
|
1277
1248
|
agent: Type.Optional(Type.String({ minLength: 1, description: "Name you invent for this subagent (single mode)" })),
|
|
1278
1249
|
task: Type.Optional(Type.String({ minLength: 1, description: "Task (single mode)" })),
|
|
1279
1250
|
prompt: Type.Optional(Type.String({ description: "System prompt for this agent (single mode)" })),
|
|
1280
1251
|
write: Type.Optional(Type.Boolean({ description: "true = write toolset; default false = read-only (single mode)" })),
|
|
1252
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset) (single mode)" })),
|
|
1281
1253
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
|
|
1282
1254
|
chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
|
|
1283
1255
|
model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
|
|
@@ -1286,10 +1258,14 @@ const SubagentParams = Type.Object({
|
|
|
1286
1258
|
concurrency: Type.Optional(Type.Number({ description: `Parallel concurrency (default ${DEFAULT_CONCURRENCY}, max ${MAX_CONCURRENCY})` })),
|
|
1287
1259
|
maxRuntimeMs: Type.Optional(Type.Number({ description: "Per-task timeout, ms. Omit for no cap (default): tasks run until done, stalled, or user-aborted." })),
|
|
1288
1260
|
background: Type.Optional(Type.Boolean({ description: "Fire-and-forget: return immediately with a runId; you'll be notified on completion" })),
|
|
1289
|
-
notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes,
|
|
1261
|
+
notifyPerTask: Type.Optional(Type.Boolean({ description: "Wake you (queued follow-up turn) as each task completes — background runs only, since blocking runs can't be woken mid-tool. Default false." })),
|
|
1290
1262
|
allowIntercom: Type.Optional(Type.Boolean({ description: "Let children ask you questions, notify you, and message sibling subagents" })),
|
|
1291
1263
|
});
|
|
1292
1264
|
|
|
1265
|
+
/** Derived from the schemas — single source of truth, no hand-maintained mirror. */
|
|
1266
|
+
type TaskInput = Static<typeof TaskItem>;
|
|
1267
|
+
type SubagentParamsShape = Static<typeof SubagentParams>;
|
|
1268
|
+
|
|
1293
1269
|
const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
|
|
1294
1270
|
const ResultParam = Type.Object({
|
|
1295
1271
|
runId: Type.String(),
|
|
@@ -1391,7 +1367,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1391
1367
|
parameters: SubagentParams,
|
|
1392
1368
|
executionMode: "parallel", // sibling subagent calls run concurrently, not serialized
|
|
1393
1369
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1394
|
-
const typed = params as
|
|
1370
|
+
const typed = params as SubagentParamsShape;
|
|
1395
1371
|
if (typed.background) {
|
|
1396
1372
|
const details = manager.startInBackground(typed, ctx);
|
|
1397
1373
|
return {
|