Skip to content
Open
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
3 changes: 3 additions & 0 deletions docs/agents/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
- An `extensions:` list (`[codegraph, "@team/adapter"]` or `codegraph, @team/adapter`) limits which extensions a child keeps, as pi-subagents does: `scopeSubagentExtensions()` (`lib/subagents.ts`) is an `extensionsOverride` that drops the rest after the SDK loaded them, together with the providers and models their factories queued. A dropped extension binds no handlers, tools or commands, but its factory has already run once, so this scopes the child; it is not a sandbox. Selecting paths before import would mean reaching into SDK internals on every reload (#1035). Names resolve like `ext:` selectors (directory, file or package name, case-insensitive), and a name two sources claim keeps neither. Path entries load nothing: a project profile must not load code that project trust gates. pi-web's own `<inline:…>` extensions always stay. `*` keeps every extension, `none` / `false` switch extensions off, and `load_extensions: false` wins over a list; the list parses as leniently as `skills:`. The SDK reapplies the override on every reload, and the snapshot keeps the names, so resume and RPC reopen get the same scope through `subagentExtensionLoaderOptions()`. `tools: ext:` still picks tools among the extensions that remain. The Agents panel shows the list read-only under the extensions switch, and a save keeps it as authored. `lib/subagent-extension-scope.integration.test.mjs` pins spawn, reload, resume and reopen against a real `AgentSession`.
- A child that loads extensions without `ext:` selectors (the Agents panel's extensions switch) gets no `tools` allowlist: the SDK fixes one when the session is created, so a tool an extension registers at `session_start` or later (pi's `dynamic-tools` example) would never be callable (#883). `subagentToolOptions()` (`lib/subagents.ts`) passes `noTools: "builtin"` with the profile's built-ins as `+name` entries and excludes the reserved tools and the SDK's other built-ins (`SDK_BUILTIN_TOOLS` mirrors pi's unexported list), so no extension can switch on one the profile leaves out. The snapshot's `allExtensionTools` makes reopen do the same; an older snapshot keeps its allowlist. `ext:` selectors still pass the names resolved at spawn: the SDK admits tools by name, with no source-aware hook, so a selected extension's late tools stay out. Such an allowlist also excludes every MCP tool it does not name (`mcp__*` and the resource tools): pi keeps those registered for codemode and `tool_search` unless the list names one, so a script could otherwise call an unselected extension's MCP tool; `ext:` selectors that `disallowed_tools` cancels out admit no extension tool. `lib/subagent-codemode-scope.integration.test.mjs` pins this. `lib/subagent-late-tools.integration.test.mjs` pins spawn, reload and reopen.

## Delegation policy
- The delegation policy reaches the model as a `<delegation>` block at the end of every run's system prompt (`subagentDelegationBlock()`, `withSubagentDelegationPrompt()`, appended by a `before_agent_start` handler that returns `systemPrompt`), not as the `Agent` tool's `promptGuidelines`: pi flattens those into the one flat `<rules>` list that every tool and pi's own "be concise" share, where a subagent policy reads as generic advice. The block is built from the enabled profiles: `explore` and `plan` get a rule of their own, the other enabled subagents are listed by name.

## What the model may choose when spawning a subagent
- `Agent` offers `subagent_type`, `prompt`, `description`, `run_in_background`, `resume`, `input_files`, `model`, and `isolation`. Thinking level, turn limit, and parent-context inheritance belong to the profile (`thinking`, `max_turns`, `inherit_context` in its frontmatter, editable in the Agents panel); the one exception is a level a call writes into its own `model` (below). A per-call value for any of them used to win over the profile while never appearing in the tool description, so a model guessing `max_turns: 8` or `thinking: "high"` silently discarded what the profile had decided. The runtime reads `thinking` (falling back to the parent's level), `max_turns`, and `inherit_context` from the profile, never from a parameter of that name. pi's validator allows keys the schema does not name, so a call that still sends one is accepted and the key ignored; transcripts holding such calls render and resume as before.
- `model` resolves as `request ?? profile`: the profile decides by default, and one run may go to a different model, which the tool description informs by listing each profile's own model and thinking level. A model reference, from the call or the profile, reads as pi's `--model` does: `provider/modelId` or a bare id, optionally ending in `:<thinking level>` (#1188). The whole reference is tried first, so an id that contains a colon (`qwen3:8b`) still matches, and a suffix that is no level is refused, never dropped. The level travels with its model: a call's `model: "x:high"` sets that run's thinking over the profile's, which is how one call picks a model and its level together without a separate `thinking` parameter for a model to guess; a call naming a model without a level keeps the profile's `thinking`. Within a profile, `thinking:` wins over a level on its own `model:`, as pi's `--thinking` does over `--model x:level`. pi clamps the level to what the model supports. The Agents panel shows such a profile model as its base model (not unavailable) with the reference as written; choosing another model there drops the level, which the Thinking field sets. `input_files` stays a per-call parameter: the host inlines those UTF-8 files from the session cwd into the delegated task (`lib/subagent-input.ts`, bounded count and size), which is input preparation, not a profile setting. The size bound is the child model's: about a tenth of its context window by pi's chars/4, counted in bytes, never more than 512 KiB, and 512 KiB when the window is unknown (0). A copy at the flat limit (about 131k tokens) arrives in the child's first message and is over half of what a 272k-token model holds before pi compacts, so a child could compact before its first tool call (#1199). A larger set is refused before any worktree or child session exists, with an error telling the model to pass fewer or smaller files, or name the paths in `prompt` if the subagent can read them. That early check only looks the model up (the call's, else the profile's, else the parent's) and never fails on it: the child's model is still resolved, and a bad reference refused, after the child's `createAgentSessionServices()`, which reloads models.json into the shared runtime and registers the child's own extension providers. A model added in Settings › Models since the parent opened, or one only the child's extensions provide, therefore still runs, with the flat limit. `isolation` stays a string, not a `"worktree"` literal, so a model that fills every optional field with `""` can still start a subagent; only `"worktree"` takes effect.
Expand Down
70 changes: 70 additions & 0 deletions lib/subagent-extension.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ function textOf(message) {
return message.content.map((block) => block.text ?? "").join("");
}

// The system message reaches the provider as text: a forced prompt as `content`, the structured
// sections as `sections`, already rendered.
function systemText(message) {
if (!message) return "";
const content = typeof message.content === "string" ? message.content : textOf(message);
const sections = Object.values(message.sections ?? {}).join("\n\n");
return `${content}\n\n${sections}`;
}

// A codemode script must not start, collect, or steer subagents: a run started from a script
// would carry a nested parentToolCallId that no transcript entry has, so the chat could not
// link to its session. The model itself keeps all three tools and can still call them.
Expand Down Expand Up @@ -278,3 +287,64 @@ test("a session with the subagent feature switched off registers no control tool
await rm(dir, { recursive: true, force: true });
}
});

// The system prompt the provider receives, as the model reads it.
async function sentSystemPrompt(session, faux) {
let system = null;
faux.setResponses([
(context) => {
system = context.messages.find((message) => message.role === "system");
return fauxAssistantMessage([fauxText("done")]);
},
]);
await session.prompt("Say done.");
return systemText(system);
}

// The delegation policy has to reach the model where it reads it. As the tool's
// `promptGuidelines` it lands in the one flat `<rules>` list, beside pi's own rules, where a
// subagent policy reads as generic advice; the block rides on the text of the prompt instead.
test("the prompt the model receives carries the delegation block, not the flat rules list", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-subagent-policy-"));
let session;
try {
const faux = fauxProvider({ models: [{ id: "faux-model" }] });
const { runtime } = recordingRuntime();
session = await createSession(dir, faux, [createSubagentExtension(runtime, () => [])]);
const text = await sentSystemPrompt(session, faux);

assert.match(text, /<delegation>\n- `Agent` runs a whole task in a fresh session/);
assert.match(text, /- Do not duplicate work already delegated to a running subagent\.\n<\/delegation>/);
// The rules list keeps pi's own rules, and no longer carries the policy.
assert.match(text, /- Be concise in your responses/);
assert.doesNotMatch(text, /Use Agent for a focused task/);
} finally {
session?.dispose();
await rm(dir, { recursive: true, force: true });
}
});

// A prompt another extension forced earlier is carried along, not replaced: the handler reads
// the rendered prompt, which already holds what the other extension forced.
test("a prompt another extension forced earlier is kept, and the block still rides on it", async () => {
const dir = await mkdtemp(join(tmpdir(), "pi-web-subagent-forced-"));
let session;
try {
const faux = fauxProvider({ models: [{ id: "faux-model" }] });
const { runtime } = recordingRuntime();
const voice = (pi) => {
pi.on("before_agent_start", (event) => ({ systemPrompt: `${event.systemPrompt}\n\nTalk like a caveman.` }));
};
session = await createSession(dir, faux, [voice, createSubagentExtension(runtime, () => [])]);
const text = await sentSystemPrompt(session, faux);

assert.match(text, /Talk like a caveman\./);
assert.match(text, /<delegation>\n- `Agent` runs a whole task in a fresh session/);
// The tool's snippet names the block, so only the closing tag counts the block itself.
assert.equal(text.split("</delegation>").length - 1, 1, "the block is appended once");
assert.ok(text.indexOf("Talk like a caveman.") < text.indexOf("<delegation>\n"), "the other prompt comes first");
} finally {
session?.dispose();
await rm(dir, { recursive: true, force: true });
}
});
106 changes: 93 additions & 13 deletions lib/subagent-extension.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ import { createJiti } from "jiti";
const {
createSubagentExtension,
preferPiWebSubagentExtension,
SUBAGENT_DELEGATION_BLOCK,
withSubagentDelegationPrompt,
subagentFinalText,
subagentNotificationText,
SUBAGENT_NOTIFICATION_PREFIX,
} = await createJiti(import.meta.url).import("./subagent-extension.ts");

// The extension registers tools and one event handler; the fake collects both.
function fakePi({ tools = new Map(), sent = [], handlers = new Map() } = {}) {
return {
registerTool(tool) { tools.set(tool.name, tool); },
sendMessage(message, options) { sent.push({ message, options }); },
on(type, handler) { handlers.set(type, handler); },
};
}

async function loadTools(runtime, getProfiles = () => [], isEnabled = () => true) {
const tools = new Map();
const sent = [];
const handlers = new Map();
const extension = createSubagentExtension(runtime, getProfiles, isEnabled);
await extension.factory({
registerTool(tool) { tools.set(tool.name, tool); },
sendMessage(message, options) { sent.push({ message, options }); },
});
return { tools, sent };
await extension.factory(fakePi({ tools, sent, handlers }));
return { tools, sent, handlers };
}

function run(overrides = {}) {
Expand Down Expand Up @@ -67,10 +76,7 @@ test("integrated extension exposes the legacy-compatible tool names", async () =
async steer() {},
async notifyParent() {},
}, () => []);
await extension.factory({
registerTool(tool) { tools.set(tool.name, tool); },
sendMessage() {},
});
await extension.factory(fakePi({ tools }));

assert.deepEqual([...tools.keys()], ["Agent", "get_subagent_result", "steer_subagent"]);
const result = await tools.get("Agent").execute(
Expand Down Expand Up @@ -119,15 +125,89 @@ test("integrated extension registers no tools while its feature is disabled", as
};
const extension = createSubagentExtension(runtime, () => [], () => enabled);
const disabledTools = new Map();
await extension.factory({ registerTool(tool) { disabledTools.set(tool.name, tool); } });
await extension.factory(fakePi({ tools: disabledTools }));
assert.deepEqual([...disabledTools], []);

enabled = true;
const enabledTools = new Map();
await extension.factory({ registerTool(tool) { enabledTools.set(tool.name, tool); } });
await extension.factory(fakePi({ tools: enabledTools }));
assert.deepEqual([...enabledTools.keys()], ["Agent", "get_subagent_result", "steer_subagent"]);
});

// The delegation policy has to reach the model where it reads it. pi flattens a tool's
// `promptGuidelines` into the one flat `<rules>` list that every tool and pi's own "be concise"
// share, so the policy rides on the text of the run's prompt instead, in a block of its own.
test("every run's prompt carries the delegation block, and the tool's rules do not", async () => {
const runtime = {
async start() { throw new Error("unused"); },
async get() { return null; },
async steer() {},
async notifyParent() {},
};
const { tools, handlers } = await loadTools(runtime);
const agent = tools.get("Agent");

assert.equal(agent.promptGuidelines, undefined, "the policy is no longer a rule beside \"be concise\"");
assert.match(agent.promptSnippet, /delegate work to subagents instead of doing it yourself/);
assert.match(agent.promptSnippet, /`<delegation>` section of the system prompt/);

const handler = handlers.get("before_agent_start");
assert.ok(handler, "the extension forces the prompt with the policy on it");
const forced = await handler({ systemPrompt: "Base prompt." });
assert.equal(forced.systemPrompt, `Base prompt.\n\n${SUBAGENT_DELEGATION_BLOCK}`);

// The block is the policy the rules used to carry, in a form the model reads as a section.
assert.match(SUBAGENT_DELEGATION_BLOCK, /^- `Agent` runs a whole task in a fresh session and returns only the result, so this conversation stays small\./m);
assert.match(SUBAGENT_DELEGATION_BLOCK, /^- Use several background `Agent` calls in one response for independent parallel work;/m);
assert.match(SUBAGENT_DELEGATION_BLOCK, /^- Do not duplicate work already delegated to a running subagent\.$/m);
assert.deepEqual(SUBAGENT_DELEGATION_BLOCK.split("\n").map((line) => line.split(" ")[0]), [
"<delegation>", "-", "-", "-", "</delegation>",
]);
// No subagent named means no rule for one: the empty provider must not invent an agent.
assert.doesNotMatch(SUBAGENT_DELEGATION_BLOCK, /`explore`|`plan`/);
});

const profile = (overrides = {}) => ({
name: "explore",
displayName: "Explore",
description: "Inspect the codebase",
systemPrompt: "Explore.",
tools: ["read", "grep"],
inheritContext: false,
runInBackground: false,
enabled: true,
scope: "builtin",
...overrides,
});

test("the delegation block names the enabled profiles and leaves the disabled ones out", async () => {
const runtime = {
async start() { throw new Error("unused"); },
async get() { return null; },
async steer() {},
async notifyParent() {},
};
const profiles = [
profile(),
profile({ name: "plan", displayName: "Plan", description: "Plan a change", tools: ["read"] }),
profile({ name: "general-purpose", displayName: "General purpose", description: "Do the work", tools: ["read", "bash", "edit"] }),
profile({ name: "hidden", displayName: "Hidden", description: "Must stay hidden", enabled: false }),
];
const { handlers } = await loadTools(runtime, () => profiles);
const forced = await handlers.get("before_agent_start")({ systemPrompt: "Base." });
const block = forced.systemPrompt.split("<delegation>")[1];

// The two agents with a rule of their own are not repeated in the list, and the list is names
// only: a description read once in the rules needs no second copy.
assert.match(block, /`explore` for any question about existing code: where a symbol lives/);
assert.match(block, /`plan` before an edit that spans several files or steps: it reads the repository/);
assert.match(block, /^- Other subagents: `general-purpose`\.$/m);
assert.doesNotMatch(block, /Inspect the codebase/);
assert.doesNotMatch(block, /`hidden`/);
assert.equal(forced.systemPrompt.split("<delegation>").length - 1, 1, "the block is appended once");
assert.equal(forced.systemPrompt, withSubagentDelegationPrompt("Base.", profiles));
});

test("Agent tool description lists enabled effective profiles and refreshes when its factory reloads", async () => {
const runtime = {
async start() { throw new Error("unused"); },
Expand Down Expand Up @@ -163,7 +243,7 @@ test("Agent tool description lists enabled effective profiles and refreshes when
const extension = createSubagentExtension(runtime, () => profiles);

const firstTools = new Map();
await extension.factory({ registerTool(tool) { firstTools.set(tool.name, tool); }, sendMessage() {} });
await extension.factory(fakePi({ tools: firstTools }));
const first = firstTools.get("Agent");
assert.match(first.description, /- explore: Inspect the codebase \(Tools: read, grep\)/);
assert.doesNotMatch(first.description, /disabled-agent|Must stay hidden/);
Expand All @@ -183,7 +263,7 @@ test("Agent tool description lists enabled effective profiles and refreshes when
scope: "project",
}];
const reloadedTools = new Map();
await extension.factory({ registerTool(tool) { reloadedTools.set(tool.name, tool); }, sendMessage() {} });
await extension.factory(fakePi({ tools: reloadedTools }));
const reloaded = reloadedTools.get("Agent");
assert.match(reloaded.description, /- reviewer: Review a change \(Tools: none; Model: provider\/review-model; Thinking: high\)/);
assert.doesNotMatch(reloaded.description, /explore/);
Expand Down
Loading
Loading