@d3ara1n/pi-subagent 3.0.0 → 3.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 +45 -12
- package/package.json +1 -1
- package/src/config.test.ts +19 -1
- package/src/config.ts +13 -0
- package/src/history.ts +3 -0
- package/src/index.ts +34 -13
- package/src/inheritance.test.ts +217 -0
- package/src/inheritance.ts +188 -0
- package/src/render-async.ts +21 -2
- package/src/render.test.ts +63 -0
- package/src/render.ts +25 -3
- package/src/roles.ts +9 -2
- package/src/run.test.ts +51 -1
- package/src/run.ts +28 -6
- package/src/spawn.test.ts +34 -10
- package/src/spawn.ts +44 -10
- package/src/types.ts +17 -0
- package/src/utils.test.ts +13 -0
- package/src/utils.ts +6 -0
- package/src/view.ts +15 -3
package/README.md
CHANGED
|
@@ -8,22 +8,30 @@ Provides a `subagent_delegate` tool that lets the main model offload tasks to sp
|
|
|
8
8
|
|
|
9
9
|
**The main model is the decision maker; subagents are executors.**
|
|
10
10
|
|
|
11
|
-
Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are
|
|
11
|
+
Your primary AI has the most complete context — it knows the full conversation history, project structure, and task at hand. Subagents are isolated by default to handle specific, well-defined tasks without polluting the main model's context window. A caller can explicitly opt into a filtered, text-only snapshot of the active parent branch when a task genuinely depends on prior dialogue.
|
|
12
12
|
|
|
13
13
|
This means:
|
|
14
14
|
- **Subagents don't plan** — the main model decides what needs to be done and provides a clear task description
|
|
15
15
|
- **Subagents don't orchestrate the overall plan** — the main model decides what to do and examines each result to pick the next move; nested delegation (worker → explorer) only offloads self-contained exploration/research inside one task
|
|
16
|
-
- **Subagents
|
|
16
|
+
- **Subagents are isolated by default** — give them a precise, self-contained task; use `inheritConversation` only when prior dialogue is necessary
|
|
17
17
|
- **Multiple subagents can run in parallel** — emit multiple `subagent_delegate` calls in one turn; pi executes them concurrently
|
|
18
18
|
- **Subagents can nest subagents** — a `worker` can delegate exploration to `explorer` without returning to the main model
|
|
19
19
|
|
|
20
20
|
> This design currently focuses on single-task delegation rather than chain pipelines or context-forking — those patterns fit better when subagents act as advisors (planner, oracle) rather than executors.
|
|
21
21
|
|
|
22
|
+
## Model Compatibility
|
|
23
|
+
|
|
24
|
+
Observations on how main models behave with this plugin, one family per subsection. Subagent role models are configured separately via [pi-model-roles](../pi-model-roles); the notes below concern the **main model** — the orchestrator that decides when to delegate.
|
|
25
|
+
|
|
26
|
+
### GPT family
|
|
27
|
+
|
|
28
|
+
**Not recommended.** As of GPT 5.6, GPT models delegate pathologically: they abandon built-in tools (`read`, `edit`, `write`) and MCP entirely and route everything through subagents — `explorer` to read files, `worker` to modify them, `reviewer` to verify each change, then worker → reviewer → worker correction loops over and over. Every task a direct handful of tool calls would finish turns into a long delegate chain, wasting large amounts of time and tokens. Use a main model that treats direct tool calls as the default and delegation as the exception.
|
|
29
|
+
|
|
22
30
|
## How it works
|
|
23
31
|
|
|
24
32
|
1. Main model calls the `subagent_delegate` tool with a role and task description
|
|
25
33
|
2. The extension resolves the role to a model via pi-model-roles
|
|
26
|
-
3. Spawns
|
|
34
|
+
3. Spawns a pi child process in RPC mode (`--mode rpc`, always without a reused parent session) with the configured model, tools, and system prompt. It is isolated by default; `inheritConversation: true` adds a filtered parent-branch snapshot to its initial stdin prompt. Agent events stream back over stdout while stdin carries the initial prompt and mid-run steering commands. Children are headless: interactive extension dialogs (`ctx.ui.select/confirm/input`) are answered automatically with `cancelled` (standard "user declined" semantics), so an extension that asks never hangs the run
|
|
27
35
|
4. **Real-time TUI progress** shows tool calls, turns, and elapsed time as the subagent runs
|
|
28
36
|
5. After completion, an **AI-generated one-line summary** is produced for compact display
|
|
29
37
|
6. Returns the result to the main model with usage statistics (turns, tokens, cost)
|
|
@@ -33,19 +41,19 @@ This means:
|
|
|
33
41
|
| Role | Model Role | Timeout | Tools | Can Delegate To | Description |
|
|
34
42
|
|------|-----------|---------|-------|-----------------|-------------|
|
|
35
43
|
| `explorer` | fast | 900s | read, find, grep, bash | — | Fast code exploration incl. git history inspection (read-only) |
|
|
36
|
-
| `reviewer` | heavy | 3600s | read, bash, grep, find |
|
|
44
|
+
| `reviewer` | heavy | 3600s | read, bash, grep, find, subagent_delegate | explorer, researcher | Deep code review, runs git/tests for evidence (read-only); delegates exploration & web verification |
|
|
37
45
|
| `worker` | default | 2400s | all (no whitelist) | explorer, researcher | Implementation — the only role that can modify files; full tool access (web, MCP, everything) |
|
|
38
|
-
| `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write,
|
|
46
|
+
| `researcher` | fast | 2400s | web_search, fetch_content, source_check, get_search_content, read, bash, edit, write, subagent_delegate | explorer | Web research + GitHub repo analysis; writes artifacts only inside its temp dir |
|
|
39
47
|
|
|
40
48
|
**Web tool naming**: `researcher`'s web tools use the community-standard names (`web_search`, `fetch_content`, `source_check`, `get_search_content`) shared by the most popular Pi web extensions — [pi-web-access](https://github.com/nicobailon/pi-web-access), `pi-web-tools`, `pi-browse`, and others. Install any of those and the researcher gets web access out of the box. If your web extension uses different tool names (e.g. `websearch`/`webfetch`) or you renamed the tools via a `toolNames` config, override `researcher.tools` in `agentOverrides` to match.
|
|
41
49
|
|
|
42
|
-
**Nested delegation**: `worker` and `researcher` can spawn their own subagents. This keeps the
|
|
50
|
+
**Nested delegation**: `worker`, `reviewer`, and `researcher` can spawn their own subagents. This keeps the caller's context clean — a worker can explore unfamiliar code via an `explorer` subagent without returning intermediate results, and a reviewer can verify third-party library behavior via a `researcher` subagent without leaving the diff.
|
|
43
51
|
|
|
44
52
|
**Parallel execution**: To run multiple subagents concurrently, emit multiple `subagent_delegate` calls in a single turn. Pi's framework executes them in parallel automatically, with each subagent getting its own TUI progress display.
|
|
45
53
|
|
|
46
54
|
## TUI Display
|
|
47
55
|
|
|
48
|
-
- **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks and tool calls (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model)
|
|
56
|
+
- **During execution**: the task's first line with a ⏳ (or ⏸ queued) indicator, a live stream of thinking blocks and tool calls (latest 5 collapsed, everything expanded), and a usage line (elapsed/budget time, turns, tokens, peak context, cost, model). Delegates using inherited conversation are marked in the tool-call title.
|
|
49
57
|
- **Collapsed result**: the task's first line, then `✓` + the AI-generated summary (or the first line of the output), then the usage line — no activity replay
|
|
50
58
|
- **Expanded result** (Ctrl+O): reference files, context size, the full task, the complete activity stream, the final output as rendered Markdown, and usage details
|
|
51
59
|
- **Fallback trace**: when a provider error (429, quota, timeout, ...) kills a run and it is retried on the role's `fallbackRole`, a `⚠ fallback: first attempt <model> failed (<reason>)` line appears in both views — also while the retry is running (see [Fallback observability](#fallback-observability))
|
|
@@ -65,7 +73,7 @@ A centered overlay covering most of the screen. A tab row across the top lists e
|
|
|
65
73
|
|
|
66
74
|
The **activity page** (default) is the run's live feed: a continuous, append-only list where each entry is static text with a state icon, and the only animated thing is the ellipsis on a running entry (`.` → `..` → `...`). Finishing freezes an entry in place — its position never changes, only the icon flips. Streamed assistant text grows in place as the run's last line and settles into plain terminal-colored text at the turn boundary. The feed is scrollable (`↑↓`, `PgUp/PgDn`, `Home`/`End`): the view pins to the end and auto-follows new entries; scrolling up unpins (a `⋮ N earlier` marker appears), and reaching the bottom again re-pins. Both foreground and background runs appear here; a foreground run stays listed while its delegate call blocks the main agent. A run leaves the view once its result is in the conversation — when the last one goes, the overlay shows a centered empty notice (with `Esc close` hinted) rather than shrinking away.
|
|
67
75
|
|
|
68
|
-
The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
|
|
76
|
+
The **brief page** shows the run's inputs and vitals at full width: the task and context verbatim (wrapped; head+tail elided beyond 20k chars), inherited-conversation size and truncation status when enabled (never its text), the reference file list annotated with `✓`/`·` for whether the child's tool calls actually touched each file, usage and time stats, the fallback trace, and a stderr tail on failures.
|
|
69
77
|
|
|
70
78
|
Steer input is modal so keys never conflict with typing: in browse mode `s` opens the editor, `Enter` queues the message into the focused run (only while it is running) and returns to browse, `Esc` cancels and clears. The message appears immediately in the feed as an `↩ steer:` entry and is delivered to the child after its current tool batch, before its next LLM call — the run keeps its progress. `Esc` in browse mode closes the overlay.
|
|
71
79
|
|
|
@@ -108,21 +116,26 @@ Edit `~/.pi/agent/settings.json`:
|
|
|
108
116
|
"summary": {
|
|
109
117
|
"role": "utility",
|
|
110
118
|
"enabled": true
|
|
119
|
+
},
|
|
120
|
+
"inheritance": {
|
|
121
|
+
"maxChars": 50000
|
|
111
122
|
}
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
```
|
|
115
126
|
|
|
116
|
-
All fields are optional. Defaults: `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`.
|
|
127
|
+
All fields are optional. Defaults: `maxConcurrency: 4`, `maxDepth: 3`, `maxTurns: 0` (unlimited), `maxCost: 0` (unlimited), `history.enabled: true`, `summary.role: "utility"`, `summary.enabled: true`, and `inheritance.maxChars: 50000`.
|
|
117
128
|
|
|
118
129
|
Timeouts are defined per role. Built-in defaults are `explorer: 900`, `reviewer: 3600`, `worker: 2400`, and `researcher: 2400` seconds. The timeout is active time — the clock pauses while the child is inside a nested `subagent_delegate` call, so delegate-capable roles need no extra headroom.
|
|
119
130
|
|
|
120
|
-
|
|
131
|
+
`maxConcurrency`, `maxDepth`, `maxTurns`, `maxCost`, and per-role `timeout` accept `0` for unlimited. Negative values are normalized to `0`; non-numeric or non-finite values fall back to their defaults. `inheritance.maxChars` is different: it must be a positive finite integer, and zero, negative, invalid, or non-finite values use the default. `maxConcurrency: 0` runs delegates without queuing, and `maxDepth: 0` permits unrestricted nesting.
|
|
121
132
|
|
|
122
133
|
### Agent Overrides
|
|
123
134
|
|
|
124
135
|
Override, disable, or add subagent roles via `agentOverrides`. Built-in and custom roles are treated equally — all descriptions, examples, and decision triggers feed into the LLM's prompt dynamically.
|
|
125
136
|
|
|
137
|
+
The built-in roles are defined in [`src/roles.ts`](src/roles.ts) — read them as reference templates when overriding. Each entry shows the exact shape and wording of every field (`role`, `description`, `examples`, `decisionTrigger`, `tools`/`subagentRoles`, `systemPrompt`, `timeout`, `fallbackRole`), so you can copy the built-in closest to what you want, paste it under `agentOverrides`, and adjust from a known-good starting point rather than writing a role from scratch.
|
|
138
|
+
|
|
126
139
|
```json
|
|
127
140
|
{
|
|
128
141
|
"subagent": {
|
|
@@ -161,7 +174,7 @@ Override, disable, or add subagent roles via `agentOverrides`. Built-in and cust
|
|
|
161
174
|
|
|
162
175
|
Configuring both on the same role is an error — the role is skipped with an error notification at session start.
|
|
163
176
|
|
|
164
|
-
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
|
|
177
|
+
**Optional fields:** `subagentRoles` (roles this role can spawn via delegate; absent means any available role, mirroring the `tools` default — declare it explicitly when a restricted role grants `subagent_delegate`), `timeout` (per-role active-time timeout in seconds; unset or `0` is unlimited, negative values normalize to `0`), `maxTurns` / `maxCost` (per-role budget overrides; unset uses the top-level `maxTurns` / `maxCost` setting, `0` is unlimited, negative values normalize to `0`), `fallbackRole` (backup pi-model-roles role the whole run is retried on after a provider error; unset means no retry — see [Fallback observability](#fallback-observability)).
|
|
165
178
|
|
|
166
179
|
Invalid custom roles (missing required fields) are skipped with an error notification at session start.
|
|
167
180
|
|
|
@@ -194,6 +207,26 @@ Delegate tasks that would generate many tool calls or verbose output to keep you
|
|
|
194
207
|
]
|
|
195
208
|
```
|
|
196
209
|
|
|
210
|
+
### Inheriting parent conversation
|
|
211
|
+
|
|
212
|
+
`inheritConversation` is opt-in and defaults to `false`. Use it when a delta task relies on the active parent dialogue and repeating that material would be impractical:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{
|
|
216
|
+
"role": "worker",
|
|
217
|
+
"task": "Implement the approved approach and satisfy the acceptance checklist above.",
|
|
218
|
+
"inheritConversation": true
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
At delegate execution, pi-subagent snapshots `buildContextEntries()` for the active branch. It serializes only compaction summaries, branch summaries, and text blocks from user and assistant messages. Thinking, images, tool calls and arguments, tool results, custom UI/state messages, bash messages, and model metadata are excluded. Newer compaction entries with a `retainedTail` are supported; older `firstKeptEntryId` compactions use the separately returned kept entries.
|
|
223
|
+
|
|
224
|
+
The inherited body is mechanically limited by `inheritance.maxChars` (default 50,000). When it is too long, pi-subagent retains summary context and the newest dialogue, inserting an omission marker. No model call summarizes this input. The child receives it in an independent `<inherited_conversation>` block after `files` and before explicit `context` and `task`; the task remains authoritative. Inherited conversation can be incomplete, so the child must report missing material rather than guess.
|
|
225
|
+
|
|
226
|
+
The tool-call title marks inherited runs. Expanded delegate input and `/subagent:view`'s brief page show the delivered character count plus `truncated` when applicable, next to the other input metadata; an enabled snapshot with no eligible text is shown as empty. The inherited body is never rendered or written to subagent history. History records only the safe inheritance flag, character count, and truncation status.
|
|
227
|
+
|
|
228
|
+
Keep the default isolated mode for focused work. Without inheritance, `task`, `context`, and `files` must be self-contained.
|
|
229
|
+
|
|
197
230
|
## Background Delegation
|
|
198
231
|
|
|
199
232
|
Three execution properties, kept separate:
|
|
@@ -254,7 +287,7 @@ Queued steers are visible in neither wait nor check results — they shape the r
|
|
|
254
287
|
|
|
255
288
|
Each tool row renders one aspect of the same decomposition the foreground row shows all at once (input · process · result · usage):
|
|
256
289
|
|
|
257
|
-
- **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
|
|
290
|
+
- **Background subagent_delegate row = input only.** Collapsed: `▶ sub-1 <task first line>`. Expanded: plus `@file` references, context size, inherited-conversation size/truncation metadata when enabled, and the full task text. Static — the run progresses invisibly until a subagent_wait/subagent_check row picks it up.
|
|
258
291
|
- **subagent_wait row = process + usage.** The input line shows the id list (or `(all)`) plus the timeout ceiling (`≤30s`) when one was given. One block per watched run: status line (`⏸ queued / ⏳ running` + id + task preview; bare, icon-free once terminal), a live activity stream (collapsed keeps the latest 5 items with a leading ellipsis; expanded shows everything) and a ticking usage bar. Once a run finishes, its process stream is replaced by a **status-only** result line (`✓ finished` / `⏲ budget-exceeded with the reason` / `⏱ timed out` / `⏹ cancelled with the reason` / `✗ <reason>`) — the output itself never appears in a subagent_wait row; expanded keeps the full process stream instead. A timed-out wait freezes the view.
|
|
259
292
|
- **subagent_check row = the result view.** Same block shape as subagent_wait's single-run view (no id — there is only one), but the result line shows `✓ <AI summary>` (or the budget/failure reason when the run stopped early) and the expanded view renders the **full output** — subagent_check is where the conclusion lives.
|
|
260
293
|
- **subagent_cancel row = confirmation only.** Collapsed: `⏹ sub-1 (worker): cancelled after 1 turn (~29s)` (or `• sub-1 (worker) already finished — nothing to cancel` for a no-op). Expanded adds the reason and the pointer to `subagent_check` — the partial output **never renders here**; it stays in the registry until a check row fetches it (layer contract: delegate = input, wait = process, cancel = intervention, check = result).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@d3ara1n/pi-subagent",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Role-based subagent orchestration for pi — delegates tasks to specialized pi child processes with configurable model roles",
|
|
6
6
|
"main": "src/index.ts",
|
package/src/config.test.ts
CHANGED
|
@@ -43,6 +43,19 @@ afterEach(() => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
describe("loadSubagentConfig", () => {
|
|
46
|
+
test("uses the inheritance default and accepts only positive finite integer maxChars", () => {
|
|
47
|
+
const { agentDir } = makeRoot();
|
|
48
|
+
writeSettings(agentDir, { subagent: { inheritance: { maxChars: 12 } } });
|
|
49
|
+
assert.equal(loadSubagentConfig().inheritance.maxChars, 12);
|
|
50
|
+
|
|
51
|
+
for (const maxChars of [0, -1, 0.5, 12.9, "500", false, null]) {
|
|
52
|
+
writeSettings(agentDir, { subagent: { inheritance: { maxChars } } });
|
|
53
|
+
assert.equal(loadSubagentConfig().inheritance.maxChars, DEFAULT_CONFIG.inheritance.maxChars);
|
|
54
|
+
}
|
|
55
|
+
writeSettingsText(agentDir, '{"subagent":{"inheritance":{"maxChars":1e999}}}');
|
|
56
|
+
assert.equal(loadSubagentConfig().inheritance.maxChars, DEFAULT_CONFIG.inheritance.maxChars);
|
|
57
|
+
});
|
|
58
|
+
|
|
46
59
|
test("preserves zero limits and clamps negative numeric limits to unlimited", () => {
|
|
47
60
|
const { agentDir } = makeRoot();
|
|
48
61
|
writeSettings(agentDir, {
|
|
@@ -99,6 +112,7 @@ describe("loadSubagentConfig", () => {
|
|
|
99
112
|
maxCost: 5,
|
|
100
113
|
history: { enabled: false },
|
|
101
114
|
summary: { enabled: false, role: "global-summary" },
|
|
115
|
+
inheritance: { maxChars: 1234 },
|
|
102
116
|
agentOverrides: { global: { disabled: true } },
|
|
103
117
|
},
|
|
104
118
|
});
|
|
@@ -112,7 +126,11 @@ describe("loadSubagentConfig", () => {
|
|
|
112
126
|
assert.equal(config.maxTurns, DEFAULT_CONFIG.maxTurns);
|
|
113
127
|
assert.equal(config.maxCost, DEFAULT_CONFIG.maxCost);
|
|
114
128
|
assert.deepEqual(config.history, DEFAULT_CONFIG.history);
|
|
115
|
-
assert.deepEqual(config.summary, {
|
|
129
|
+
assert.deepEqual(config.summary, {
|
|
130
|
+
enabled: DEFAULT_CONFIG.summary.enabled,
|
|
131
|
+
role: "project-summary",
|
|
132
|
+
});
|
|
133
|
+
assert.deepEqual(config.inheritance, DEFAULT_CONFIG.inheritance);
|
|
116
134
|
assert.deepEqual(config.agentOverrides, {});
|
|
117
135
|
});
|
|
118
136
|
});
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,12 @@ import type { SubagentConfig } from "./types.ts";
|
|
|
12
12
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
13
13
|
import { normalizeNonNegativeInteger, normalizeNonNegativeNumber } from "./utils.ts";
|
|
14
14
|
|
|
15
|
+
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
|
16
|
+
return typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value > 0
|
|
17
|
+
? value
|
|
18
|
+
: fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
function readSettingsFile(filePath: string): any {
|
|
16
22
|
try {
|
|
17
23
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
@@ -39,6 +45,7 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
|
|
|
39
45
|
|
|
40
46
|
const rawSummary = raw?.summary;
|
|
41
47
|
const rawHistory = raw?.history;
|
|
48
|
+
const rawInheritance = raw?.inheritance;
|
|
42
49
|
return {
|
|
43
50
|
maxConcurrency: normalizeNonNegativeInteger(raw.maxConcurrency, DEFAULT_CONFIG.maxConcurrency),
|
|
44
51
|
maxDepth: normalizeNonNegativeInteger(raw.maxDepth, DEFAULT_CONFIG.maxDepth),
|
|
@@ -51,6 +58,12 @@ export function loadSubagentConfig(cwd?: string): SubagentConfig {
|
|
|
51
58
|
role: rawSummary?.role ?? DEFAULT_CONFIG.summary.role,
|
|
52
59
|
enabled: rawSummary?.enabled ?? DEFAULT_CONFIG.summary.enabled,
|
|
53
60
|
},
|
|
61
|
+
inheritance: {
|
|
62
|
+
maxChars: normalizePositiveInteger(
|
|
63
|
+
rawInheritance?.maxChars,
|
|
64
|
+
DEFAULT_CONFIG.inheritance.maxChars,
|
|
65
|
+
),
|
|
66
|
+
},
|
|
54
67
|
agentOverrides: raw.agentOverrides ?? {},
|
|
55
68
|
};
|
|
56
69
|
}
|
package/src/history.ts
CHANGED
|
@@ -38,6 +38,9 @@ export function persistSubagentHistory(
|
|
|
38
38
|
role,
|
|
39
39
|
task,
|
|
40
40
|
timestamp: Date.now(),
|
|
41
|
+
inheritConversation: r.inheritConversation === true,
|
|
42
|
+
inheritedConversationChars: r.inheritedConversationChars,
|
|
43
|
+
inheritedConversationTruncated: r.inheritedConversationTruncated,
|
|
41
44
|
exitCode: r.exitCode,
|
|
42
45
|
stopReason: r.stopReason,
|
|
43
46
|
model: r.model,
|
package/src/index.ts
CHANGED
|
@@ -16,11 +16,7 @@
|
|
|
16
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
19
|
-
import type {
|
|
20
|
-
SubagentConfig,
|
|
21
|
-
SubagentResult,
|
|
22
|
-
SubagentRole,
|
|
23
|
-
} from "./types.ts";
|
|
19
|
+
import type { SubagentConfig, SubagentResult, SubagentRole } from "./types.ts";
|
|
24
20
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
25
21
|
import { loadSubagentConfig } from "./config.ts";
|
|
26
22
|
import { BUILTIN_ROLES } from "./roles.ts";
|
|
@@ -44,6 +40,7 @@ import {
|
|
|
44
40
|
} from "./utils.ts";
|
|
45
41
|
import { startSubagentRun, type RunHandle } from "./run.ts";
|
|
46
42
|
import { buildInboxReminder, injectReminder } from "./reminder.ts";
|
|
43
|
+
import { serializeInheritedConversation } from "./inheritance.ts";
|
|
47
44
|
import { renderDelegateCall, renderDelegateResult } from "./render.ts";
|
|
48
45
|
import { createViewPanel } from "./view.ts";
|
|
49
46
|
import {
|
|
@@ -149,11 +146,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
149
146
|
"- Delegate ONLY when a task involves significant work (heavy analysis, multi-step investigation, large-scope changes) AND you only care about the conclusion, not intermediate steps. A good test: the task would clutter your context with 3+ turns of raw tool output.",
|
|
150
147
|
"- DO NOT delegate simple tasks — a single read, a one-line edit, a basic grep, or straightforward changes touching 1-2 files. Just do them yourself; spawning a child process costs more than the task.",
|
|
151
148
|
"",
|
|
152
|
-
"
|
|
149
|
+
"DELEGATION CONTEXT MODES:",
|
|
153
150
|
"",
|
|
154
|
-
"-
|
|
155
|
-
|
|
156
|
-
"-
|
|
151
|
+
"- Self-contained (default): when task, context, and files contain everything the child needs, omit inheritConversation. The child receives no parent dialogue.",
|
|
152
|
+
'- Conversation-relative: when the task is intentionally written as a delta against this chat — e.g. "implement the approved approach", "review the requirements above", or "continue from our discussion" — set inheritConversation: true.',
|
|
153
|
+
"- Never send a conversation-relative task without inheritConversation: true. Either enable inheritance or rewrite the task to be self-contained.",
|
|
154
|
+
"- With inheritance enabled, keep task focused on the work to perform; do not duplicate the conversation into context. Use context for additional non-conversation background and files for source material.",
|
|
155
|
+
"- Inherited history may be compacted or truncated. If an older detail is essential and may fall outside the retained history, include it explicitly in task or context.",
|
|
157
156
|
"",
|
|
158
157
|
"AVAILABLE ROLES:",
|
|
159
158
|
...entries.map(([name, role]) => ` - ${name}: ${role.description}`),
|
|
@@ -304,7 +303,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
304
303
|
name: "subagent_delegate",
|
|
305
304
|
label: "Delegate to subagent",
|
|
306
305
|
description:
|
|
307
|
-
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents
|
|
306
|
+
"Delegate a task to a specialized subagent. By default the call blocks until the run finishes and returns the final output — intermediate tool output stays out of your context. With background: true it returns an id immediately and you collect the result later with subagent_wait/subagent_check. Subagents are isolated by default; inheritConversation optionally injects a filtered snapshot of the active parent branch.",
|
|
308
307
|
promptSnippet: "Delegate tasks to specialized subagents",
|
|
309
308
|
promptGuidelines: guidelines,
|
|
310
309
|
|
|
@@ -312,7 +311,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
312
311
|
role: Type.String({ description: "Subagent role to use" }),
|
|
313
312
|
task: Type.String({
|
|
314
313
|
description:
|
|
315
|
-
"The work to do
|
|
314
|
+
"The work to do. Without conversation inheritance it must be self-contained, with every requirement or constraint restated here or in `context`; with inheritance it may be a delta against that history. Instructions only — background material belongs in `context`, reference file paths in `files`.",
|
|
316
315
|
}),
|
|
317
316
|
context: Type.Optional(
|
|
318
317
|
Type.String({
|
|
@@ -326,6 +325,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
326
325
|
'Reference file paths for the subagent to read directly (e.g. ["src/auth.ts", "docs/api.md"]). Injected as @file attachments — content stays out of your context window. Prefer this over pasting file contents into context.',
|
|
327
326
|
}),
|
|
328
327
|
),
|
|
328
|
+
inheritConversation: Type.Optional(
|
|
329
|
+
Type.Boolean({
|
|
330
|
+
description:
|
|
331
|
+
"Opt in to a text-only, compaction-aware snapshot of the active parent conversation. Omit or false for isolation; true lets task be a delta against inherited history, which may be filtered or truncated.",
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
329
334
|
background: Type.Optional(
|
|
330
335
|
Type.Boolean({
|
|
331
336
|
description:
|
|
@@ -370,6 +375,13 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
370
375
|
);
|
|
371
376
|
}
|
|
372
377
|
|
|
378
|
+
const inheritedConversation = params.inheritConversation
|
|
379
|
+
? serializeInheritedConversation(
|
|
380
|
+
ctx.sessionManager.buildContextEntries(),
|
|
381
|
+
config.inheritance.maxChars,
|
|
382
|
+
)
|
|
383
|
+
: undefined;
|
|
384
|
+
|
|
373
385
|
const run = startSubagentRun({
|
|
374
386
|
id: `sub-${++runCounter}`,
|
|
375
387
|
toolCallId: _toolCallId,
|
|
@@ -378,6 +390,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
378
390
|
task: params.task,
|
|
379
391
|
context: params.context,
|
|
380
392
|
files: params.files,
|
|
393
|
+
inheritConversation: params.inheritConversation === true,
|
|
394
|
+
inheritedConversation: inheritedConversation?.text,
|
|
395
|
+
inheritedConversationTruncated: inheritedConversation?.truncated,
|
|
381
396
|
cwd: params.cwd ?? ctx.cwd,
|
|
382
397
|
depth: CURRENT_DEPTH + 1,
|
|
383
398
|
// Foreground runs die with the tool call; background runs outlive the turn.
|
|
@@ -430,6 +445,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
430
445
|
task: params.task,
|
|
431
446
|
context: params.context,
|
|
432
447
|
files: params.files,
|
|
448
|
+
inheritConversation: params.inheritConversation === true,
|
|
449
|
+
inheritedConversationChars: inheritedConversation?.text.length,
|
|
450
|
+
inheritedConversationTruncated: inheritedConversation?.truncated,
|
|
433
451
|
},
|
|
434
452
|
};
|
|
435
453
|
}
|
|
@@ -765,7 +783,8 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
765
783
|
|
|
766
784
|
// Terminal runs cannot be cancelled — point at check instead.
|
|
767
785
|
if (run.state === "finished" || run.state === "failed") {
|
|
768
|
-
const what =
|
|
786
|
+
const what =
|
|
787
|
+
run.state === "finished" ? "its result" : "the failure reason and partial output";
|
|
769
788
|
const text =
|
|
770
789
|
`${params.id} (${run.role}) already ${run.state} — nothing to cancel. ` +
|
|
771
790
|
`subagent_check(${params.id}) returns ${what}.`;
|
|
@@ -996,7 +1015,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
996
1015
|
// reported as already-terminal instead of cancelled.
|
|
997
1016
|
const targets =
|
|
998
1017
|
target === "all"
|
|
999
|
-
? [...backgroundRuns.values()].filter(
|
|
1018
|
+
? [...backgroundRuns.values()].filter(
|
|
1019
|
+
(r) => r.state === "queued" || r.state === "running",
|
|
1020
|
+
)
|
|
1000
1021
|
: [backgroundRuns.get(target)!];
|
|
1001
1022
|
if (targets.length === 0) {
|
|
1002
1023
|
ctx.ui.notify("No live background runs to cancel.", "info");
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** Tests for deterministic parent-conversation serialization. */
|
|
2
|
+
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { SessionManager, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { serializeInheritedConversation as createSnapshot } from "./inheritance.ts";
|
|
7
|
+
|
|
8
|
+
function serializeInheritedConversation(entries: SessionEntry[], maxChars: number): string {
|
|
9
|
+
return createSnapshot(entries, maxChars).text;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function entries(items: unknown[]): SessionEntry[] {
|
|
13
|
+
return items as SessionEntry[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const base = (type: string, id: string, extra: Record<string, unknown> = {}) => ({
|
|
17
|
+
type,
|
|
18
|
+
id,
|
|
19
|
+
parentId: null,
|
|
20
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
21
|
+
...extra,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("serializes only user/assistant text while filtering tool and UI state", () => {
|
|
25
|
+
const output = serializeInheritedConversation(
|
|
26
|
+
entries([
|
|
27
|
+
base("message", "u", { message: { role: "user", content: "Need a change" } }),
|
|
28
|
+
base("message", "a", {
|
|
29
|
+
message: {
|
|
30
|
+
role: "assistant",
|
|
31
|
+
content: [
|
|
32
|
+
{ type: "thinking", thinking: "private" },
|
|
33
|
+
{ type: "text", text: "I will delegate this." },
|
|
34
|
+
{
|
|
35
|
+
type: "toolCall",
|
|
36
|
+
id: "call",
|
|
37
|
+
name: "subagent_delegate",
|
|
38
|
+
arguments: { secret: "no" },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
base("message", "tool", {
|
|
44
|
+
message: {
|
|
45
|
+
role: "toolResult",
|
|
46
|
+
toolName: "bash",
|
|
47
|
+
content: [{ type: "text", text: "secret output" }],
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
base("custom_message", "ui", { content: "UI state" }),
|
|
51
|
+
base("custom", "state", { data: { token: "no" } }),
|
|
52
|
+
base("message", "bash", { message: { role: "bashExecution", output: "shell output" } }),
|
|
53
|
+
]),
|
|
54
|
+
10_000,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
assert.equal(output, "[user]\nNeed a change\n\n[assistant]\nI will delegate this.");
|
|
58
|
+
assert.ok(!output.includes("private"));
|
|
59
|
+
assert.ok(!output.includes("secret"));
|
|
60
|
+
assert.ok(!output.includes("UI state"));
|
|
61
|
+
assert.ok(!output.includes("shell output"));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("preserves active entry order, compaction summaries, and retained tails", () => {
|
|
65
|
+
const output = serializeInheritedConversation(
|
|
66
|
+
entries([
|
|
67
|
+
base("compaction", "c", {
|
|
68
|
+
summary: "Earlier work",
|
|
69
|
+
retainedTail: [
|
|
70
|
+
{ role: "compactionSummary", summary: "Retained compacted context" },
|
|
71
|
+
{ role: "user", content: "Kept request" },
|
|
72
|
+
{ role: "assistant", content: [{ type: "text", text: "Kept reply" }] },
|
|
73
|
+
{ role: "branchSummary", summary: "Retained branch context" },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
base("branch_summary", "b", { summary: "Abandoned branch" }),
|
|
77
|
+
base("message", "u", { message: { role: "user", content: "Current request" } }),
|
|
78
|
+
base("message", "a", {
|
|
79
|
+
message: { role: "assistant", content: [{ type: "text", text: "Current reply" }] },
|
|
80
|
+
}),
|
|
81
|
+
]),
|
|
82
|
+
10_000,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.equal(
|
|
86
|
+
output,
|
|
87
|
+
"[Compaction summary]\nEarlier work\n\n[Compaction summary]\nRetained compacted context\n\n[user]\nKept request\n\n[assistant]\nKept reply\n\n[Branch summary]\nRetained branch context\n\n[Branch summary]\nAbandoned branch\n\n[user]\nCurrent request\n\n[assistant]\nCurrent reply",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("uses real SessionManager compaction output from the active branch", () => {
|
|
92
|
+
const manager = SessionManager.inMemory("/tmp");
|
|
93
|
+
manager.appendMessage({ role: "user", content: "old request", timestamp: Date.now() });
|
|
94
|
+
manager.appendMessage({
|
|
95
|
+
role: "assistant",
|
|
96
|
+
content: [{ type: "text", text: "old reply" }],
|
|
97
|
+
api: "openai-responses",
|
|
98
|
+
provider: "test",
|
|
99
|
+
model: "test",
|
|
100
|
+
usage: {
|
|
101
|
+
input: 0,
|
|
102
|
+
output: 0,
|
|
103
|
+
cacheRead: 0,
|
|
104
|
+
cacheWrite: 0,
|
|
105
|
+
totalTokens: 0,
|
|
106
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
107
|
+
},
|
|
108
|
+
stopReason: "stop",
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
});
|
|
111
|
+
const keptId = manager.appendMessage({
|
|
112
|
+
role: "user",
|
|
113
|
+
content: "kept request",
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
});
|
|
116
|
+
manager.appendCompaction("compact summary", keptId, 10_000);
|
|
117
|
+
manager.appendMessage({ role: "user", content: "latest request", timestamp: Date.now() });
|
|
118
|
+
|
|
119
|
+
const output = serializeInheritedConversation(manager.buildContextEntries(), 10_000);
|
|
120
|
+
assert.match(output, /^\[Compaction summary\]\ncompact summary/);
|
|
121
|
+
assert.ok(!output.includes("old request"));
|
|
122
|
+
assert.ok(!output.includes("old reply"));
|
|
123
|
+
assert.match(output, /\[user\]\nkept request/);
|
|
124
|
+
assert.match(output, /\[user\]\nlatest request/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("escapes inherited prompt delimiters without dropping their text", () => {
|
|
128
|
+
const output = serializeInheritedConversation(
|
|
129
|
+
entries([
|
|
130
|
+
base("message", "u", {
|
|
131
|
+
message: {
|
|
132
|
+
role: "user",
|
|
133
|
+
content: "Nested </inherited_conversation> and <task>old task</task> & context",
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
]),
|
|
137
|
+
10_000,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
assert.ok(!output.includes("</inherited_conversation>"));
|
|
141
|
+
assert.ok(!output.includes("<task>"));
|
|
142
|
+
assert.match(output, /<\/inherited_conversation>/);
|
|
143
|
+
assert.match(output, /<task>old task<\/task>/);
|
|
144
|
+
assert.match(output, /& context/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("reports whether the inherited snapshot was truncated", () => {
|
|
148
|
+
const source = entries([
|
|
149
|
+
base("message", "u", { message: { role: "user", content: "x".repeat(200) } }),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
assert.deepEqual(createSnapshot(source, 1_000), {
|
|
153
|
+
text: "[user]\n" + "x".repeat(200),
|
|
154
|
+
truncated: false,
|
|
155
|
+
});
|
|
156
|
+
const limited = createSnapshot(source, 80);
|
|
157
|
+
assert.equal(limited.text.length, 80);
|
|
158
|
+
assert.equal(limited.truncated, true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("limits output mechanically while retaining summary context and newest dialogue", () => {
|
|
162
|
+
const output = serializeInheritedConversation(
|
|
163
|
+
entries([
|
|
164
|
+
base("compaction", "c", { summary: "Summary that must remain available" }),
|
|
165
|
+
base("message", "old", {
|
|
166
|
+
message: { role: "user", content: "old dialogue that may disappear" },
|
|
167
|
+
}),
|
|
168
|
+
base("message", "new", {
|
|
169
|
+
message: {
|
|
170
|
+
role: "assistant",
|
|
171
|
+
content: [{ type: "text", text: "newest dialogue must remain" }],
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
]),
|
|
175
|
+
120,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
assert.ok(output.length <= 120);
|
|
179
|
+
assert.match(output, /Compaction summary/);
|
|
180
|
+
assert.match(output, /omitted for length/);
|
|
181
|
+
assert.match(output, /\[assistant\]/);
|
|
182
|
+
assert.match(output, /dialogue must remain/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("honors every hard limit, including delimiter expansion and tiny bounds", () => {
|
|
186
|
+
const source = entries([
|
|
187
|
+
base("compaction", "c", { summary: `<summary>${"S".repeat(120)}</summary>` }),
|
|
188
|
+
base("message", "u", {
|
|
189
|
+
message: { role: "user", content: `<task>${"U".repeat(180)}</task>` },
|
|
190
|
+
}),
|
|
191
|
+
base("message", "a", {
|
|
192
|
+
message: { role: "assistant", content: [{ type: "text", text: "A".repeat(180) }] },
|
|
193
|
+
}),
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
for (let limit = 1; limit <= 300; limit += 1) {
|
|
197
|
+
assert.ok(serializeInheritedConversation(source, limit).length <= limit);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("redistributes unused summary budget to recent complete dialogue", () => {
|
|
202
|
+
const output = serializeInheritedConversation(
|
|
203
|
+
entries([
|
|
204
|
+
base("compaction", "c", { summary: "short" }),
|
|
205
|
+
base("message", "old", { message: { role: "user", content: "O".repeat(220) } }),
|
|
206
|
+
base("message", "new", {
|
|
207
|
+
message: { role: "assistant", content: `latest-${"N".repeat(80)}` },
|
|
208
|
+
}),
|
|
209
|
+
]),
|
|
210
|
+
240,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.equal(output.length, 240);
|
|
214
|
+
assert.match(output, /^\[Compaction summary\]\nshort/);
|
|
215
|
+
assert.match(output, /\[assistant\]\nlatest-/);
|
|
216
|
+
assert.match(output, /Earlier text in this message omitted/);
|
|
217
|
+
});
|