Source lock. This chapter describes pi-subagents 0.18.0 at
3f9d35cd078d18a141eb5a6d8f4fc5010d756280against Pi 0.84.2 at914cf1472e715297caa30db4b9535d534a9eb718. Line links in the source index are pinned to those commits.
Delegation becomes useful to an agent only when it crosses a host boundary the model already understands. pi-subagents does not add a second, parallel protocol for that. It enters Pi through the ordinary extension mechanism, publishes delegation as an ordinary Pi tool, and implements the tool’s behavior with child AgentSessions.
The central trace is:
package.json pi.extensions
→ default extension factory
→ child-context guard
→ TypeBox schema + defineTool(...)
→ pi.registerTool(...)
→ Pi validates a model call
→ Agent.execute(...)
→ pi-subagents schedules or runs a child session
Recall
Before reading on, retrieve two ideas from Pi’s tool architecture:
- What does the model need in order to request a tool call?
- What must the runtime add in order to execute that request and show progress?
Hold your answer. By the end of the chapter, you should be able to place name, description, parameters, execute, onUpdate, and AbortSignal on the correct side of the host/extension boundary.
Predict
The package manifest contains this declaration:
{
"pi": {
"extensions": ["./src/index.ts"]
}
}
And src/index.ts exports this shape:
export default function (pi: ExtensionAPI) {
if (inChildSessionContext()) return;
// registrations follow
}
Predict before inspecting:
- Does the manifest point to a tool, a session, or a factory?
- Why must the factory return early while a child session is being constructed?
- Which layer should reject malformed tool arguments:
defineTool,registerTool, or Pi’s agent loop?
Inspect: follow the host seam
1. The manifest selects an extension entry point
The manifest names ./src/index.ts under pi.extensions; it does not name Agent, AgentManager, or an executable. Pi resolves declared paths relative to the package root, imports the entry module’s default export, requires it to be an ExtensionFactory, creates an ExtensionAPI, and calls the factory with that API. The factory type is simply (pi: ExtensionAPI) => void | Promise<void>.
This is the first important boundary: the package declares where integration begins; the factory declares what to register. Dependencies reinforce the split. Pi’s packages are peers, while TypeBox and the subagent implementation’s other runtime libraries are package dependencies.
2. The factory refuses recursive top-level installation
The very first behavioral decision in the factory is the child-context guard. A child agent is itself built with Pi’s createAgentSession, and its resource loader loads normal extensions. Without a guard, loading pi-subagents inside that child would create another top-level manager and another set of handlers. Repeating that at every generation would confuse ownership and leak lifecycle work.
The guard is not a global boolean. child-context.ts stores true in Node’s AsyncLocalStorage while child resource loading and session creation run. inChildSessionContext() reads only the current asynchronous branch, so unrelated top-level work can continue concurrently. The runner wraps both loader.reload() and createAgentSession(...) in runInChildSessionContext(...); tests verify that the mark survives an await, disappears afterward, and makes the extension factory a no-op.
This guard does not ban nested delegation. It prevents the root extension from reinstalling itself. When an agent explicitly allows subagents and remains below the depth cap, agent-runner.ts separately builds ownership-scoped nested tools and passes them through customTools. Recursion is therefore admitted as a capability, not as an accidental consequence of extension loading.
3. defineTool preserves a ToolDefinition
At the root factory, const agentTool = defineTool({...}) defines the model-facing and runtime-facing halves together:
name,description,promptSnippet, andpromptGuidelinestell Pi how to expose the capability to the model.parameters: Type.Object({...})describes accepted input: requiredprompt,description, andsubagent_type, plus optional model, thinking, turn, background, resume, isolation, context, and conditionally enabled fields.label,renderCall, andrenderResultcontrol the human-facing TUI.executesupplies the operation.
defineTool is deliberately small: at the pinned Pi commit it returns the object with a type cast that preserves TypeBox parameter inference when the definition is stored or passed through an array. It does not register the tool and does not perform runtime validation.
The actual contract is Pi’s ToolDefinition<TParams, TDetails, TState>. Its execute signature is:
execute(
toolCallId: string,
params: Static<TParams>,
signal: AbortSignal | undefined,
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
ctx: ExtensionContext,
): Promise<AgentToolResult<TDetails>>;
Pi’s wrapper later converts this product-level definition into the AgentTool understood by the core agent loop. It forwards the first four arguments and injects the current ExtensionContext as the fifth. This is why pi-subagents can read the current model, model registry, working directory, session manager, and UI without teaching the core loop about extension contexts.
4. Registration publishes the definition to Pi
pi-subagents does not register agentTool immediately because another path reuses the same definition. Near the end of the factory it wraps execute to attach pending nested-model usage, then calls:
const registeredAgentTool = withUsageReporting(agentTool);
pi.registerTool(registeredAgentTool);
Pi’s registerTool stores the definition in the current extension’s tools map under tool.name and asks the bound runtime to refresh its tool registry. Registration is valid during initial extension loading; after binding, refresh makes later registrations visible too.
The renderer, /agents command, and session handlers use the same ExtensionAPI, but they are adjacent host seams, not the delegation transport. The notification renderer gives custom completion messages a UI. session_start captures the current context and starts optional RPC/scheduling integration. session_before_switch and session_shutdown clean up. /agents exposes management. The model crosses into delegation through the registered Agent tool.
Explain: what happens when execute runs
Pi owns the initial call envelope. Before invoking the tool, its agent loop finds the tool, applies any prepareArguments, and validates the arguments against the schema. It then runs the optional pre-call hook. That hook receives the validated object and may mutate it; Pi does not revalidate those mutations. If the call is not blocked or aborted, Pi calls execute with the resulting parameters.
Each callback value has a distinct role:
toolCallIdcorrelates one invocation with updates and persisted results.paramspassed Pi’s initial schema validation, but may include later mutations from a pre-call hook.signalis cooperative cancellation. Pi passes it; the implementation must forward it to abort-aware work or check it.onUpdateaccepts partialAgentToolResults. Pi emits each accepted partial astool_execution_update; updates made after theexecutepromise settles are ignored.ctxis the current host context, not configuration captured at extension-load time.
The returned AgentToolResult contains model-visible text or images in content and renderer/state data in details. It may also carry nested usage, newly added tool names, or a batch-level terminate hint. AgentToolResult returned by execute has no isError field. Within execute, throw to make Pi normalize the call as failed; separately, a post-call tool_result hook may override isError during finalization.
pi-subagents supplies everything inside the operation. Its execute reloads custom agent definitions, resolves or rejects the requested type, merges invocation settings, resolves and scopes the model, and then chooses a branch: schedule, resume, background spawn, or foreground spawn-and-wait. Background execution returns an agent ID promptly. Foreground execution forwards signal into the manager and uses onUpdate to stream spinner/activity details before returning the child’s final text.
Cancellation semantics follow the product behavior, not merely the host signature. A foreground call is tied to the tool call’s signal. A background child is intentionally detached once spawned, so interrupting the parent’s turn does not silently kill work whose ID was already returned. Likewise, cancelling a get_subagent_result wait cancels the wait, not the child.
Boundary and failure map
Use this map when a delegation call behaves unexpectedly:
| Observation | Owning boundary |
|---|---|
| Entry module is not loaded | Pi package discovery / manifest path |
| Factory runs inside every child | AsyncLocalStorage guard or missing wrapped construction path |
| Model emits malformed arguments | Pi preparation and initial TypeBox validation; later tool_call hook mutations are not revalidated |
| Requested agent type/model is refused | pi-subagents dispatch and scope policy inside execute |
| Partial UI never changes | Extension did not call onUpdate, or called it after settlement |
| Esc does not stop foreground work | A downstream operation failed to honor the forwarded signal |
| Background work survives Esc | Intended detached-background semantics |
| Tool result looks successful despite failure text | Returning failure text from execute is still success; throw there, or use an explicit post-call hook to set isError |
| Child creates duplicate managers/handlers | Root factory re-entered during child extension loading |
One subtle case is startup failure. The background spawn branch deliberately allows manager.spawn(...) to throw: returning an explanatory string would tell Pi that the tool succeeded even though no agent started. The same distinction should guide any new Pi tool.
Practice
Suppose you add a smaller delegate_review tool. It validates a target path, launches a foreground reviewer, streams a status line, and should stop when the user cancels.
Assign each responsibility to Pi host or extension implementation:
- Convert the TypeBox schema into validated
params. - Decide which reviewer configuration to use.
- Ignore late progress updates.
- Pass cancellation into the reviewer run.
- Mark a thrown reviewer-startup exception as a failed tool result.
- Format reviewer-specific progress details.
Feedback
1, 3, and 5 belong to Pi. The agent loop validates before execution, scopes update acceptance to the live promise, and converts thrown errors into isError: true outcomes. 2, 4, and 6 belong to the extension: policy and orchestration are its behavior, cancellation is cooperative and must be forwarded, and structured details are tool-specific.
The key trap is number 5: the extension chooses whether to throw, but Pi owns the conversion of that throw into the standardized failed execution result.
Now predict the effect of deleting only the child-context guard while leaving customTools unchanged.
Feedback
Nested tools would still be injected intentionally, but each child resource load could also install a fresh top-level pi-subagents activation. The defect is not simply “more delegation”; it is duplicate ownership—new managers, event handlers, commands, and lifecycle cleanup inside child sessions.
Transferable insight
A deep extension does not need a deep host fork. Find the host’s narrowest stable seam, express the new capability in its native contract, and keep policy behind that boundary. Here the seam is ToolDefinition: Pi owns discovery, initial validation, call identity, updates, cancellation transport, context injection, failure normalization, and rendering hooks; pi-subagents owns what delegation means.
The recursion guard completes the design. Any extension that constructs more instances of its own host must distinguish loading host resources from installing root ownership. Scope that distinction to the asynchronous construction branch, then inject only the capabilities the child is allowed to receive.
Next: Chapter 3 follows that execute call into runAgent(), where pi-subagents assembles an ordinary child AgentSession from Pi primitives.
Exact source index
pi-subagents — 3f9d35cd078d18a141eb5a6d8f4fc5010d756280
package.json: Pi entry point and dependencies, manifestsrc/index.ts: factory and child guardsrc/child-context.ts: async-local marker andtest/child-context.test.ts: guard evidencesrc/agent-runner.ts: guarded resource load, nested tool injection, and guarded session construction/bindingsrc/index.ts:Agentdefinition and schema,execute, and usage wrapper plus registrationsrc/index.ts: session lifecycle seam, shutdown cleanup, and/agentsregistrationsrc/index.ts: abortableget_subagent_resultwait andsrc/abortable.ts: cancellation leaves underlying work running
Pi — 914cf1472e715297caa30db4b9535d534a9eb718
loader.ts: import and invoke the default factory and resolve manifest entriesextensions/types.ts:ToolDefinitionanddefineTool,ExtensionContext, andExtensionAPI.registerToolloader.ts: store registered definitions and refreshtool-definition-wrapper.ts: injectExtensionContextextensions/wrapper.ts: supply the live runner context to registered toolsagent/types.ts: result, update, and execute contractsagent-loop.ts: validation/abort boundary and update/error handling