Source lock. This chapter describes pi-subagents 0.18.0 at
3f9d35cd078d18a141eb5a6d8f4fc5010d756280against Pi 0.84.2 at914cf1472e715297caa30db4b9535d534a9eb718. All source links are commit-pinned.
Nested delegation changes the question from “may the main model spawn an agent?” to “which agent may spawn which child, how far down, and who may control the result?” pi-subagents answers with an ownership tree. Every nested record has one immediate owner and a depth; normal Agent-tool trees also propagate a root session identity and depth cap, while direct manager, scheduler, or RPC roots may omit those optional fields. Control tools close over the owner that received them.
Recall
Retrieve the Chapter 2 host seam:
- Pi loads an extension factory and registers a
ToolDefinition. - Pi validates a tool call, supplies
AbortSignal, updates, and the currentExtensionContext. pi-subagentsimplements delegation by constructing childAgentSessions.
Now add one constraint: a child session must not activate another root pi-subagents manager. Nested capability is injected explicitly as custom tools instead.
Predict: audit this tree
Assume maxSubagentDepth: 3 and root session ID R:
main session depth 0
└─ A: coordinator depth 1, rootSessionId R
├─ B: writer depth 2, parentAgentId A, rootSessionId R
└─ C: reviewer depth 2, parentAgentId A, rootSessionId R
└─ D: evidence-checker depth 3, parentAgentId C, rootSessionId R
Agent A declares tools: read and allowed_subagents: writer, reviewer. Agent C independently declares allowed_subagents: evidence-checker. The writer has tools: read, write, bash.
Predict before inspecting:
- Can A write indirectly by delegating to B?
- Can B fetch C’s result if it learns C’s ID?
- Can A request a misspelled type and receive the project’s fallback agent?
- Does D receive nested orchestration tools?
- If the top-level background pool is full, is a background B queued?
Inspect: how the tree is built
1. Nesting is an explicit capability grant
Custom-agent frontmatter is parsed into AgentConfig.allowedSubagents. Omitted, empty, none, or false becomes undefined; all, *, or true becomes the wildcard "all"; otherwise a CSV becomes a type-name list. The default is therefore no nested tools.
During child session construction, agent-runner.ts injects nested tools only when all three conditions hold:
agent configured allowed_subagents
AND current depth < effective max depth
AND agent is not isolated
The injected set is exactly:
Agent— spawn or resume a child owned by this parent;get_subagent_result— inspect or wait for an owned background child;steer_subagent— guide an owned running child.
Pi’s SDK accepts these definitions as customTools. Its session registry merges custom definitions with registered extension tools, then subjects them to the session’s tool allow/deny gates. pi-subagents deliberately re-admits the three names only for an opted-in session; an agent at the cap receives none of them, including result and steer, because it could never have created a child to control.
The default cap is 2: main 0 → top-level subagent 1 → nested child 2. Values 0 and 1 disable nesting. A child must opt in independently to delegate again; increasing the cap alone never grants the tools.
2. Spawn resolution is strict and branch-local
Each nested Agent call rebuilds an agent registry from the inherited configuration root. It does not mutate the process-global registry, which matters when a worktree branch has a different set of agent files.
Resolution then uses resolveEnabledTypeIn, not top-level fallback policy. The requested string must identify exactly one enabled type: an exact spelling wins; otherwise one unambiguous case-insensitive match is accepted. Unknown, disabled, blank, or case-ambiguous names are rejected. After resolution, a narrow allowed_subagents set is checked again. A configured fallbackSubagent cannot substitute an agent the parent never named.
This makes the allowlist enforceable rather than descriptive. The tool’s description advertises available types, but runtime discovery, strict resolution, and the post-resolution membership check are the authority.
3. One manager records the tree; each node gets its own session
Top-level and nested spawns use the same AgentManager.spawn / spawnAndWait and runAgent path. The manager passes itself into every session’s nestedRuntime; when that session receives scoped tools, those tools call the same manager. There is one record map, not a new manager per generation.
Each record carries structural metadata:
depthdefaults to 1 for a top-level subagent and increments for each nested spawn;parentAgentIdis absent for top-level records and names the immediate owner for nested records;maxSubagentDepth, when present on the root path, carries the inherited branch cap;rootSessionId, when supplied by the spawning surface, propagates the main session identity through every generation.
Each node is still a distinct Pi AgentSession, built through createAgentSession and bindExtensions. The current child execution context is forwarded into the manager, so a grandchild gets the correct working directory, current model, model registry, and—when inherit_context is requested—the child’s conversation rather than a captured ancestor conversation. rootSessionId is not a shared conversation: it groups .output transcripts under the root session’s tasks/ directory.
4. Control is immediate-owner scoped
createNestedSubagentTools closes over one parentAgentId. Its ownsRecord predicate is exact:
record?.parentAgentId === parentAgentId
That check guards result retrieval, steering, and resume. Knowing a sibling’s or cousin’s ID is insufficient. In the predicted tree, B cannot fetch or steer C, A cannot directly operate D, and C can operate D. This is immediate ownership, not “same root tree” access.
Nested records also receive no top-level handle or alias and are filtered from coordinator UI, top-level result/steer tools, and top-level lifecycle reporting. Their supported control plane is the scoped tools held by their parent.
Explain: propagation, accounting, and teardown
Root identity and usage roll-up
When A in the prediction tree spawns B, the nested tool reads A’s rootSessionId, sets B’s parentAgentId to A, sets depth to A.depth + 1, and passes the same root ID onward. This repeats at every generation when the optional root ID exists; scheduler, RPC, and direct-manager root records can begin without one.
The manager records each child’s own message usage on that child’s record. Nested tooling also installs an onAssistantUsage callback that walks parentAgentId upward and adds the usage delta to every ancestor. That intentional double-booking makes hidden work visible in A’s lifetime total and still works for a great-grandchild. Code that needs each message exactly once uses the manager’s separate per-message usage callback, not ancestor totals.
Child lifetime is intended to follow parent lifetime
When a parent settles successfully or with an error, abortOwnedChildren(parentId) targets every direct queued or running child. Queued work is stopped before it starts. For a running child whose abort bridge is already attached, the manager’s signal reaches Pi; if that child then settles, its own settlement targets the next generation. Transitive cancellation is therefore cooperative and progresses one settled ownership edge at a time, not as an instantaneous tree-wide stop.
There is a more serious pre-session race at these commits. The manager can abort a record while runAgent() is still loading resources or creating the session. The abort bridge is installed only later and does not first check signal.aborted, so a listener attached after the signal fired will not receive that earlier abort. Such a record is already marked stopped, yet its newly created session may still enter prompt() and spend tokens. The intended ownership rule is clear; complete enforcement across construction is not proved and is currently violated by this path.
There is a second lifecycle obligation. runAgent calls session.bindExtensions(), so child extensions can allocate timers, watchers, or sockets during session_start. Before an evicted child session is disposed, the manager emits session_shutdown when handlers exist. Root shutdown snapshots and awaits the retained child sessions already attached at that moment; each handler sequence has a three-second ceiling so one hung extension cannot strand exit, and disposal still runs after failure or timeout. A child still in the pre-session construction race is absent from that snapshot and is not covered by this guarantee.
Deadlock avoidance leaves width unbounded
Only a background record with no parentAgentId occupies a maxConcurrent pool slot. Nested children bypass the pool even when backgrounded. Otherwise a parent holding the last slot and waiting for its queued child could deadlock forever. Unit tests pin the intended asymmetry: the nested child starts while a second top-level sibling queues.
This solves dependency deadlock, not resource governance. The depth cap bounds height, never fan-out. There is no per-parent maxChildren and nested work does not consume the top-level pool. A turn limit can reduce opportunities to delegate, but it is not a dedicated concurrent-child quota—one model turn may contain several tool calls. A wide malicious or mistaken fan-out remains a resource risk.
Security boundary, not sandbox
allowed_subagents is a capability and ownership boundary. It restricts reachable agent types, and parentAgentId restricts which records a caller may control. Neither mechanism confines the selected child’s side effects, and the pre-session abort race above prevents treating ownership teardown as complete containment.
The child runs with its own agent definition: its own tools, disallowed_tools, extensions, skills, isolated, model, and prompt policy are resolved in runAgent. The parent’s tool restrictions are not intersected with the child’s. In the prediction, read-only A can cause writes and commands through B because B has write and bash. Choosing B is therefore a privilege grant.
This is not an operating-system sandbox. The sessions live in the same Pi process and normally operate from the delegated working directory under the same user authority. isolated: true removes extensions, skills, and nested tools but still permits effective built-in tools and configured persistent memory. Optional worktree isolation separates Git working-copy changes; it does not turn ownership checks into process isolation. Select an allowlist as carefully as a direct tool allowlist.
E2E proof—and the remaining gaps
The pinned faux-model e2e is stronger than a unit wiring test: it runs the real Pi loader, real extension, real runAgent, and two real child sessions. Its first scenario proves that an opted-in middle agent actually sees all three injected tools, a leaf that did not opt in does not see Agent, and a marker travels worker → coordinator → main parent. Its second scenario backgrounds the nested worker, retrieves it by the returned ID, demonstrates that it was not stuck behind its waiting parent, and verifies that the worker’s streamed transcript lands beneath the root session.
Focused tests add evidence for strict no-fallback resolution, depth blocking, immediate-owner rejection, ancestor usage roll-up, branch-local config discovery, pool accounting, and bounded session_shutdown before disposal.
The proof is not broader than that:
- The e2e uses a deterministic faux model, not a live provider.
- Ownership rejection and ancestor accounting are unit-tested with a fake manager, not attacked across real concurrent sessions.
- The e2e does not give parent and child different tool privileges, so the non-inheritance claim rests on the runtime config path and pinned documentation.
- Shutdown is tested with a mocked
runAgent; large real fan-outs and hostile extension handlers are not stress-tested. - No test aborts during asynchronous child construction. Because abort forwarding is installed late without an already-aborted check, pre-session work can escape both the original signal and the root-shutdown session snapshot.
- No test establishes a horizontal resource ceiling, because the implementation contains none.
Practice: ownership-tree audit
Return to the prediction tree. Classify each operation as allowed, rejected, or not bounded:
- A spawns B, despite A lacking
writeandbash. - B calls
get_subagent_resultwith C’s ID. - A requests
wrtierwhile project fallback isgeneral-purpose. - C spawns D at depth 3 under cap 3.
- D spawns another child.
- A emits 50 background child calls while the top-level pool limit is 10.
Feedback
1 is allowed: B’s own definition supplies its capabilities. 2 is rejected because C’s parentAgentId is A, not B. 3 is rejected before spawn; nested dispatch never uses fallback. 4 is allowed because C is below the cap and independently opted in. 5 is rejected structurally: at depth 3, D receives no nested tools. 6 is not bounded by the top-level pool or depth cap; other limits may stop the run, but there is no per-parent width quota.
Now audit cleanup: C finishes while D is still running, and D has a child of its own.
Feedback
C’s settlement targets its direct child D. If D’s abort bridge is already attached and D cooperatively settles, D then targets its own child, so intended cleanup propagates one edge at a time. If D is aborted before that bridge is attached, the current race can let it continue. Later record eviction or root shutdown closes already attached retained sessions by emitting session_shutdown before disposal, subject to the bounded wait; it does not cover a session still being constructed outside the snapshot.
Transferable insight
Safe recursion needs two different controls. A capability edge decides whether a node may create children and which types it may select. An ownership edge decides who may observe, steer, resume, and clean up each created node. Depth, usage, transcripts, and teardown then propagate along those explicit edges.
Do not confuse that control plane with sandboxing. Delegation safely routes authority only when the reachable child capabilities are themselves acceptable—and a system that bypasses parent-held concurrency slots to avoid deadlock must introduce a separate width budget if bounded fan-out is a requirement.
Next: Chapter 8 follows a settled child back through foreground returns, background follow-ups, explicit retrieval, lifecycle events, and Pi’s TUI projections.
Exact source index
pi-subagents — 3f9d35cd078d18a141eb5a6d8f4fc5010d756280
README.md: nested delegation contract and privilege warningsrc/custom-agents.ts: parseallowed_subagentssrc/agent-types.ts: branch-local registry and strict enabled-type resolution,resolveEnabledTypeInsrc/agent-runner.ts: inject tools only after opt-in and below the cap, resolve each child’s own tool configuration, and construct/bind the child sessionsrc/nested-tools.ts: tree context and owner predicate,Agentstrict dispatch, depth, and propagation, and result/steer ownership checkssrc/agent-manager.ts: pool-slot rule and unbounded width, record metadata and shared manager path, transitive owned-child abort, and bounded child shutdowntest/nested-delegation-e2e.test.ts: real-stack scope/output proof and background/result/transcript prooftest/nested-tools.test.ts: strict, depth, and ownership tests, ancestor usage and transcript teststest/agent-manager.test.ts: nested child bypasses a full pool andtest/child-session-shutdown.test.ts: shutdown evidence