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
2 changes: 1 addition & 1 deletion docs/agents/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Profiles accept `mcp: on | off` (missing means `off`; `true` reads as `on`; no `inherit`). A child with `mcp: on` loads `builtin:tool-search` and `builtin:mcp` with an `McpHost` of its own (`createSubagentMcpExtensions()`), so it connects the servers in `mcp.json` (project ones under the same trust rules) and nothing else of the normal session. They are named in `additionalExtensionPaths` (`SUBAGENT_MCP_EXTENSION_PATHS`), which loads a built-in under `noExtensions` as well and keeps the `builtin:mcp` path the host and the policies recognise; that also bypasses `-builtin:` settings, so each factory reads its switch on every load, as the child's Code mode does, and `scopeSubagentExtensions()` never drops them. Without Code mode the host registers `codemode` servers as `deferred` (`codemodeAvailable` is the child's Code mode, not the sandbox), and the allowlist adds `tool_search`. An allowlist admits MCP tools by name (`mcp__*` and the resource tools), since servers connect long after it is fixed. `createSubagentMcpPolicyExtension()` then blocks an MCP-named tool another extension registers unless the profile selected it, and, while the child's built-in tools are read-only (Explore, Plan), every MCP tool without `readOnlyHint: true`, codemode scripts included; a profile of extension tools alone is not read-only. A chat-only profile rejects `mcp: on`. The snapshot keeps `mcp: true`, so reload, resume and RPC reopen load the same; an older snapshot loads no MCP. A run prompts the SDK session directly, so `prepareChildMcp()` calls the wrapper's `prepareMcpForRun()` first: it waits for servers with `direct` tools as a sent prompt does, and abortSubagent, the parent's Stop and Stop in the child's chat all end the wait and abort the run before it starts (the wrapper's `isRunning()` is false meanwhile, so `steer_subagent` is refused). Each child connects its own servers (stdio servers start again); the wrapper disposes its host when it closes, and idle release applies. The Agents panel switches it under Resources, disabled for a chat-only draft unless it is already on (the switch can still turn it off), with a line saying why; a save that names no value keeps the file's, and a profile that never had it gets no key. `lib/subagent-mcp.integration.test.mjs` pins this against a real stdio server.
- The global `builtInEnabled` in `~/.pi/agent/agents/settings.json` is off when the file or field is absent and when the file is malformed (fail closed); writes are atomic and keep unknown fields. The inline extension factory is always present, so a reload can apply the switch, but registers no tools while off; after changing it the user must reload the session explicitly, and `Agent` dispatch re-checks the setting so a stale call cannot start a subagent. When on, only a recognized legacy `pi-subagents` extension registering a reserved tool (`Agent`, `get_subagent_result`, `steer_subagent`) is removed, along with the conflict errors it caused; unrelated extensions stay. ADR 0003.
- The three tools register with `exposure: "model-only"`, so `ctx.executeTool()` (a codemode script) cannot start, collect or steer a subagent: a script-started run would record the nested call id (`<codemode call>/<n>`) as its `parentToolCallId`, which no transcript entry carries, and the chat would lose its link to the child session. `lib/subagent-extension.integration.test.mjs` pins this against a real `AgentSession`.
- A run stays in `getSubagentRuns()` from dispatch until its result entry is written, so `get()` reports a persisted `running` / `queued` status with no result and no running wrapper as `interrupted`: its process stopped and nothing will finish it (otherwise `get_subagent_result({ wait: true })` polls forever after a restart).
- A run stays in `getSubagentRuns()` from dispatch until its result entry is written, so `get()` reports a persisted `running` / `queued` status with no result and no running wrapper as `interrupted`: its process stopped and nothing will finish it (otherwise `get_subagent_result({ wait: true })` polls forever after a restart). Settling moves the record to `getSettledSubagentRuns()` (64, insertion order) rather than dropping it: the child's wrapper is released once it goes idle and the file lookup can still miss a session that settled seconds ago, and with both gone `get_subagent_result` answered `Subagent not found` about an id this process had just handed out. A read of the session file still wins whenever it works, so the record only fills the hole that lookup leaves, and a `resume` that settles again overwrites it.
- Built-in profiles (`general-purpose`, `explore`, `plan`) are switched off by name in that file's `disabledBuiltIns`, never by copying them to a `.md` file (ADR 0005). `builtInProfiles()` stamps `enabled` onto them so the panel, the `Agent` tool description and `resolveSubagentProfile` agree. Writes are minimal edits that keep names they did not touch, including ones no built-in claims; reading fails *open* (the feature switch has already failed closed). `PATCH /api/subagents/profiles` with `scope: "builtin"` writes it; `PUT`/`DELETE` refuse that scope. A same-name file replaces the built-in outright and is switched off through its own frontmatter. Only the switch is live for a built-in; the rest of the form is read-only.
- A positive `max_turns` allows one wrap-up turn only when the SDK would otherwise continue. The limiter chains the SDK's awaited `finishTurn` hook and ends unfinished work without cancelling a completed answer. The wrap-up turn sets `steeringMode` to `all` so a steer queued earlier cannot hold the instruction back. An `end` decision alone does not stop the run: AgentSession starts another run for anything still queued, so the limiter clears the queues, lists those messages in the turn-limit error, and refuses `steer_subagent` from then on. Exhaustion is a failed run with that error and any partial output. A request that ends with `stopReason: "aborted"` is `aborted` even when the controller did not ask for it (Stop in the child's own chat); a provider error is `failed`.
- `notifyParent` skips a background run's completion notification when the parent already collected that result with `get_subagent_result`, which marks it consumed. The mark names the run (`sessionId` + `completedAt`), never just the session, since `resume` reuses the session id; `resume` keeps the mark. While the parent `isRunning()` (often still inside that poll) the notification is held and the mark re-checked; an idle parent is notified at once.
Expand Down
59 changes: 59 additions & 0 deletions lib/subagent-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,65 @@ test("resume reuses the persisted child session and keeps its session id", async
assert.equal(execution.run.resumed, true);
});

test("a settled background run stays findable when its wrapper and file both go missing", async () => {
// The wrapper is released once the idle child lets go, and the file lookup can still miss a
// session that settled moments ago. Nothing was left to answer for an id this process handed
// out, so `get_subagent_result` reported "Subagent not found" for a run that had just finished.
const entries = [
{ type: "custom", customType: "pi-web:subagent", data: {
version: 1,
parentSessionId: "parent",
parentSessionPath: "/tmp/parent.jsonl",
parentToolCallId: "old-call",
profile: "explore",
description: "old task",
task: "old",
runInBackground: true,
createdAt: "2026-01-01T00:00:00.000Z",
resourceSnapshot: { version: 1, appendSystemPrompt: [], tools: [], loadSkills: false, loadExtensions: false },
} },
{ type: "custom", customType: "pi-web:subagent-result", data: {
version: 1, status: "completed", completedAt: "2026-01-01T00:01:00.000Z", result: "old result",
} },
];
const childInner = {
sessionId: "settled-child",
sessionFile: "/tmp/settled-child.jsonl",
sessionManager: { getEntries: () => entries, appendCustomEntry: (type, data) => entries.push({ type: "custom", customType: type, data }) },
prompt: async () => {},
getLastAssistantText: () => "new result",
abort: async () => {},
};
const parent = { inner: { sessionManager: { getSessionId: () => "parent" } }, sessionFile: "/tmp/parent.jsonl", cwd: "/tmp", isAlive: () => true, isRunning: () => true, waitUntilReady: async () => {} };
let forgotten = false;
const child = { inner: childInner, sessionFile: childInner.sessionFile, cwd: "/tmp", isAlive: () => !forgotten, isRunning: () => false, waitUntilReady: async () => {} };
const controller = createSubagentController({
getSession: (id) => forgotten ? undefined : (id === "settled-child" ? child : parent),
registerSession: () => {},
reopenSession: async () => child,
resolveSessionPath: async () => (forgotten ? null : childInner.sessionFile),
invalidateSessionList: () => {},
isBuiltInSubagentsEnabled: () => true,
});

const execution = await controller.extensionRuntime.resume({
parentContext: parent.inner,
parentToolCallId: "new-call",
sessionId: "settled-child",
task: "continue this",
description: "Continue task",
});
await execution.completion;
forgotten = true;

const run = await controller.extensionRuntime.get("settled-child");
assert.equal(run?.status, "completed");
assert.equal(run?.result, "new result");

// An id nothing ever ran is still simply unknown.
assert.equal(await controller.extensionRuntime.get("never-ran"), null);
});

test("a run collected and resumed in the same turn keeps its held notification suppressed", async () => {
const delivered = [];
let parentRunning = true;
Expand Down
55 changes: 45 additions & 10 deletions lib/subagent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ type StoredSubagentExecution = {

declare global {
var __piSubagentRuns: Map<string, StoredSubagentExecution> | undefined;
var __piSubagentSettledRuns: Map<string, SubagentRunInfo> | undefined;
var __piSubagentQueue: SubagentQueue<SubagentRunInfo> | undefined;
var __piSubagentConsumedResults: Map<string, string> | undefined;
}
Expand Down Expand Up @@ -147,6 +148,35 @@ function getSubagentRuns(): Map<string, StoredSubagentExecution> {
return globalThis.__piSubagentRuns;
}

/**
* Runs that finished and left the live registry. Their session id is already in the parent's
* hands — it is what the `Agent` call returned — so answering "Subagent not found" for one of
* them sends the parent chasing a session it knows exists: the wrapper is released once the
* child goes idle and the file lookup can miss a just-settled session, and with both gone
* there was nothing left to answer from. A read of the session file still wins whenever it
* works, so this only ever fills the hole that lookup leaves. Bounded and in insertion order:
* the last few results matter, an archive of them does not.
*/
const SETTLED_RUNS_LIMIT = 64;

function getSettledSubagentRuns(): Map<string, SubagentRunInfo> {
if (!globalThis.__piSubagentSettledRuns) globalThis.__piSubagentSettledRuns = new Map();
return globalThis.__piSubagentSettledRuns;
}

/** Take a finished run out of the live registry, leaving its result findable. */
function settleSubagentRun(run: SubagentRunInfo): void {
const settled = getSettledSubagentRuns();
settled.delete(run.sessionId);
settled.set(run.sessionId, run);
while (settled.size > SETTLED_RUNS_LIMIT) {
const oldest = settled.keys().next().value;
if (oldest === undefined) break;
settled.delete(oldest);
}
getSubagentRuns().delete(run.sessionId);
}

function getSubagentQueue(): SubagentQueue<SubagentRunInfo> {
if (!globalThis.__piSubagentQueue) globalThis.__piSubagentQueue = new SubagentQueue();
return globalThis.__piSubagentQueue;
Expand Down Expand Up @@ -476,7 +506,7 @@ export function createSubagentController(
await cleanupWorktree(parent.cwd, isolatedWorktree);
stored.run = result;
request.onUpdate?.(result);
getSubagentRuns().delete(initialRun.sessionId);
settleSubagentRun(stored.run);
dependencies.invalidateSessionList();
return result;
}
Expand Down Expand Up @@ -574,7 +604,7 @@ export function createSubagentController(
sessionManager.appendCustomEntry(SUBAGENT_RESULT_TYPE, persisted);
stored.run = result;
request.onUpdate?.(result);
getSubagentRuns().delete(initialRun.sessionId);
settleSubagentRun(stored.run);
dependencies.invalidateSessionList();
return result;
};
Expand All @@ -587,7 +617,7 @@ export function createSubagentController(
sessionManager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: finalResult.completedAt, ...(cleanupError ? { worktreeCleanupError: cleanupError } : {}) });
stored.run = finalResult;
request.onUpdate?.(finalResult);
getSubagentRuns().delete(initialRun.sessionId);
settleSubagentRun(stored.run);
dependencies.invalidateSessionList();
resolveCompletion(finalResult);
};
Expand Down Expand Up @@ -669,7 +699,7 @@ export function createSubagentController(
const result: SubagentRunInfo = { ...initialRun, status: "aborted", completedAt: new Date().toISOString() };
manager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: result.completedAt });
stored.run = result;
getSubagentRuns().delete(request.sessionId);
settleSubagentRun(stored.run);
resolveCompletion(result);
return result;
}
Expand Down Expand Up @@ -710,7 +740,7 @@ export function createSubagentController(
});
stored.run = result;
request.onUpdate?.(result);
getSubagentRuns().delete(request.sessionId);
settleSubagentRun(stored.run);
dependencies.invalidateSessionList();
return result;
};
Expand All @@ -720,7 +750,7 @@ export function createSubagentController(
manager.appendCustomEntry(SUBAGENT_RESULT_TYPE, { version: 1, status: "aborted", completedAt: result.completedAt });
stored.run = result;
request.onUpdate?.(result);
getSubagentRuns().delete(request.sessionId);
settleSubagentRun(stored.run);
dependencies.invalidateSessionList();
resolveCompletion(result);
};
Expand Down Expand Up @@ -749,10 +779,15 @@ export function createSubagentController(
if (run) return settleOrphanedRun(run);
}
const sessionPath = await dependencies.resolveSessionPath(sessionId);
if (!sessionPath) return null;
const manager = SessionManager.open(sessionPath);
const run = readSubagentRun(manager.getEntries() as unknown as SessionEntry[], sessionId, sessionPath);
return run && settleOrphanedRun(run);
if (sessionPath) {
const manager = SessionManager.open(sessionPath);
const run = readSubagentRun(manager.getEntries() as unknown as SessionEntry[], sessionId, sessionPath);
if (run) return settleOrphanedRun(run);
}
// Neither a live wrapper nor the session file answered. A run this process finished is
// still its own answer: the id came from us, and "not found" here is something the parent
// has no way to verify and every reason to act on.
return getSettledSubagentRuns().get(sessionId) ?? null;
}

async function steer(sessionId: string, message: string): Promise<void> {
Expand Down
Loading