Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions extensions/subagents/docs/design-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Source: `extensions/subagents/` (`index.ts`, `manager.ts`, `prompt.ts`,
| Tool | Parameters | Behavior |
|---|---|---|
| `subagent_spawn` | `prompt`, `title`, `working_dir?`, `model?`, `provider?`, `reasoning_effort?` | Fire-and-forget spawn. Returns immediately with an id (`sa-N`). Enforces `MAX_RUNNING = 4` with a synchronous reservation so parallel tool calls can't race past the cap. Validates `working_dir`, resolves model against the registry (inherit parent model/thinking level by default), truncates title to 160 chars. |
| `subagent_wait` | `ids[]` (max 64) | Blocks until all listed subagents settle; respects the tool `AbortSignal`; streams `Waiting for ...` via `onUpdate`. Marks the awaited results "consumed" so they are not also auto-delivered. Output budgets: 48KB total, 16KB per agent, with per-section fallbacks (`[omitted: ...]`). Errors on unknown ids (lists known ids). |
| `subagent_wait` | `ids[]` (max 64) | Blocks until all listed subagents settle; respects the tool `AbortSignal`; streams `Waiting for ...` via `onUpdate`. Marks the awaited results "consumed" so they are not also auto-delivered. The hard output ceiling is 48 KiB, with a 16 KiB static per-agent cap. When Pi reports authoritative parent context usage, projections may narrow to 50% of the remaining headroom after fixed wrapper text; short results yield unused bytes to longer siblings. Unknown or invalid usage falls back to the static caps. Errors on unknown ids (lists known ids). |
| `subagent_cancel` | `ids[]` | Aborts running subagents (marks consumed first to avoid duplicate delivery), waits for settlement, reports per-id `Cancelled ...` / `was already <status>`. Partial transcripts remain on disk. |
| `subagent_check` | `id` | Non-blocking peek: status line, turn count, error text, up to 2KB/20 lines of latest output (includes the live streaming assistant message). Does not consume the result. |
| `subagent_list` | — | One `describeSubagent()` line per agent: `id [status] "title" (provider/model, ctx%, elapsed, cwd)`. |
Expand Down Expand Up @@ -81,8 +81,13 @@ the parent conversation.
{ deliverAs: "followUp", triggerTurn: true })`; a separate session entry renders the
report at its actual completion point.
Content is built by `buildSubagentResultMessage` (`Subagent sa-N "title"
finished/failed.` + optional `Error:` line + output truncated to 24KB/600 lines with a
pointer to the child session file for the full transcript).
finished/failed.` + optional `Error:` line). Automatic batches have a 48 KiB
hard ceiling and a 24 KiB static per-result cap. As with `subagent_wait`, Pi's
authoritative context usage can narrow the projection budget dynamically; the
fixed headers, separators, and guidance are charged before result bytes are
allocated. Oversized results retain roughly 75% head + 25% tail and point to an exact,
content-addressed final-answer artifact below the Pi agent cache; the parent
can page it with Pi's native `read` instead of parsing the child session JSONL).

### 1.4 UI (carried over into v2 essentially as-is)

Expand Down Expand Up @@ -144,7 +149,9 @@ Common denominator all three can supply:
token usage, errors;
- a way to send a follow-up/steering user message into a live session;
- an interrupt operation;
- a final result text per run;
- a final result text per run; oversized parent projections preserve both the
head and tail, while the exact final text remains available as a plain-text
artifact for native `read` pagination;
- metadata: backend name, model identifier, session/log file path (pi session file,
Claude session id + projects dir JSONL, Codex rollout path), working dir.

Expand Down Expand Up @@ -564,8 +571,14 @@ Recommendation: (a) during development, rename to final names when v2 replaces v
7. **Binary/SDK discovery + failure UX.** When `codex`/`claude` isn't installed or has
no credentials, should `subagent_spawn` fail fast with a clear tool error (proposed),
or should the backends be hidden from the `agent` enum dynamically?
8. **Result truncation budgets.** Keep v1's numbers (24KB result message, 48KB wait
total, 16KB per agent, 2KB check preview) unchanged?
8. **Result projection budgets (resolved).** Keep the 24 KiB automatic per-result,
48 KiB batch, 16 KiB wait per-agent, and 2 KiB check ceilings as static safety
caps. For automatic delivery and explicit waits, narrow the projection when Pi's
authoritative parent-context reading shows less headroom: spend at most 50% of
the remaining tokens (estimated at four UTF-8 bytes each), subtract fixed wrapper
text first, and distribute the remainder across the batch. The runtime does not
maintain a second token counter; missing or stale Pi usage falls back to static
caps. Exact content remains in the artifact regardless of projection size.
9. **Effect version pinning.** Effect v4 is beta — pin an exact `4.0.0-beta.x` and
accept manual bumps, or track the beta dist-tag?
10. **Persistence across reloads.** v1 loses all subagents on `session_shutdown`
Expand Down
129 changes: 128 additions & 1 deletion extensions/subagents/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import type {
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { PLAN_MODE_CHANNEL } from "../shared/plan-mode-state.ts";
import subagents, { createSubagentResultDispatcher } from "./index.ts";
import subagents, {
createSubagentResultDispatcher,
truncatedOutput,
} from "./index.ts";
import { projectResult } from "./src/result-artifact.ts";

const emptySessionManager = { getBranch: () => [] };

Expand Down Expand Up @@ -78,6 +82,129 @@ test("subagent results render before the hidden wake-up message", () => {
]);
});

test("automatic result projection keeps both ends and persists the exact final answer", () => {
const finalText = `BEGIN\n${"evidence\n".repeat(100)}FINAL-VERDICT`;
let persisted = "";
const text = truncatedOutput(
{
id: "sa-3",
origin: "model",
backend: "pi",
title: "inspect",
prompt: "inspect",
cwd: process.cwd(),
status: "done",
createdAt: 0,
settledAt: 1_000,
meta: { backend: "pi" },
usage: {},
transcript: [],
liveTools: [],
queued: [],
finalText,
turns: 1,
},
120,
(content) => {
persisted = content;
return "/tmp/subagent-final.txt";
},
);

assert.equal(persisted, finalText);
assert.match(text, /^BEGIN/);
assert.match(text, /FINAL-VERDICT/);
assert.match(text, /Full final answer: "\/tmp\/subagent-final\.txt"/);
});

test("automatic result delivery shrinks a batch against authoritative parent headroom", () => {
const budgets: number[] = [];
const pi = {
appendEntry() {},
sendMessage() {},
} as unknown as ExtensionAPI;
const dispatch = createSubagentResultDispatcher(
pi,
(_snap, maxBytes) => {
budgets.push(maxBytes);
return "projected";
},
() => ({ tokens: 98_000, contextWindow: 100_000 }),
);
const snapshot = (id: string) => ({
id,
origin: "model" as const,
backend: "pi" as const,
title: id,
prompt: "inspect",
cwd: process.cwd(),
status: "done" as const,
createdAt: 0,
settledAt: 1_000,
meta: { backend: "pi" as const },
usage: {},
transcript: [],
liveTools: [],
queued: [],
finalText: "x".repeat(40 * 1024),
turns: 1,
});

dispatch([snapshot("sa-1"), snapshot("sa-2")]);

assert.deepEqual(budgets, [2048, 2048]);
});

test("automatic result wrappers and projections stay inside the shared batch cap", () => {
let delivered = "";
const pi = {
appendEntry(_customType: string, data: { content: string }) {
delivered = data.content;
},
sendMessage() {},
} as unknown as ExtensionAPI;
const dispatch = createSubagentResultDispatcher(
pi,
(snap, maxBytes) =>
projectResult(snap.finalText, {
maxBytes,
maxLines: 600,
writeArtifact: () => `/tmp/${snap.id}.txt`,
}).text,
);
const snapshot = (id: string) => ({
id,
origin: "model" as const,
backend: "pi" as const,
title: `long report ${id}`,
prompt: "inspect",
cwd: process.cwd(),
status: "done" as const,
createdAt: 0,
settledAt: 1_000,
meta: { backend: "pi" as const },
usage: {},
transcript: [],
liveTools: [],
queued: [],
finalText: `BEGIN-${id}\n${"evidence\n".repeat(10_000)}END-${id}`,
turns: 1,
});

dispatch([
snapshot("sa-1"),
snapshot("sa-2"),
snapshot("sa-3"),
snapshot("sa-4"),
]);

assert.ok(Buffer.byteLength(delivered, "utf8") <= 48 * 1024);
for (const id of ["sa-1", "sa-2", "sa-3", "sa-4"]) {
assert.match(delivered, new RegExp(`BEGIN-${id}`));
assert.match(delivered, new RegExp(`END-${id}`));
}
});

test("the visible subagent result entry renders the completed report", () => {
const renderers = new Map<string, EntryRenderer>();
const pi = {
Expand Down
Loading
Loading