fix(tools): let subagents use asynchronously-registered extension tools, including under ext:#157
Merged
Conversation
Subagents could not use tools provided by MCP extensions (e.g. pi-mcp-extension), even though those tools work in the main agent. pi-mcp registers its tools asynchronously from its session_start handler, after connecting to MCP servers — never during synchronous extension load. runAgent, however, snapshotted extension.tools.keys() into a static `tools:` allowlist right after loader.reload() and before bindExtensions() fires session_start. In pi-mono that allowlist (allowedToolNames) is frozen at construction and gates the tool *registry* itself, so MCP tools — registered later — were dropped permanently, not just deactivated. Scope extension tools via the session's live denylist instead of a frozen allowlist, matching how the main agent stays in sync. When extensions load and there is no `ext:` narrowing, pass `tools: undefined` (allowlist unset, so the live isAllowedTool gate admits tools whenever they register) and express scope as `excludeTools` = this extension's own orchestration tools + built-ins the agent didn't ask for + disallowedTools. Because an unset allowlist only activates pi's four default built-ins at turn 1, repair the active set post-bind via setActiveToolsByName(getAllTools()); tools registered later (lazy MCP servers) auto-activate through pi's refresh path. The static-allowlist path is retained unchanged for noExtensions/isolated and for `ext:` narrowing, where the exact set is known up front. May solve issue #125. I (the human) tested this manually by setting my local checkout of pi-subagents as the extension, then asking the LLM to spawn a subagent and use a search tool. Previously, it couldn't -- and now it can. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… tools Enforce ext: on the active set via a live predicate instead of a frozen allowlist, matching pi's own dynamic-tool-loading idiom. Guard turn 1 by wrapping beforeToolCall, since before_agent_start widens the tool set after that turn's tools are snapshotted. Add a pi@latest reachability guard for that hook. Fixes #125. Refs #141.
tintinweb
marked this pull request as ready for review
July 22, 2026 17:33
tintinweb
added a commit
to codesoda/pi-subagents
that referenced
this pull request
Jul 22, 2026
…ed result wait abortable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Subagents silently lost every extension tool that registers asynchronously — after extension load. This is a category, not one broken extension:
pi-mcpregisters fromsession_start(once its MCP servers connect),context-modefrombefore_agent_start. Both do that deliberately, because eagerly spawning an MCP bridge during extension discovery orphans child processes on pi's non-agent code paths (--help, config, trust probing).Builds on #141 by @philipmw (cherry-picked). This adds the half that #141 explicitly left open:
ext:selectors.Fixes #125.
Root cause
Verified against pi-coding-agent 0.80.10,
agent-session.js:1943:allowedToolNamesgates tool registration, not merely the active set, and it's frozen at construction.runAgentsnapshottedextension.tools.keys()into that allowlist right afterloader.reload()— i.e. beforebindExtensions()firessession_start. Anything registering later was dropped permanently.Approach
Whenever extensions are in play, leave
allowedToolNamesunset so pi's live gate admits tools whenever they register, and express the name-stable permanent scope (our own orchestration tools, built-ins the agent didn't ask for,disallowed_tools:) asexcludeTools, which pi re-applies on every registry refresh.extensions: false/isolated: truekeep the static allowlist — nothing can register asynchronously there, and a hard registry gate is the right boundary for a sandbox.ext:narrowing then can't be a registry allowlist, because you can't list a tool that doesn't exist yet. Ite** set instead — what the model actually sees — via a predicate re-derived from the loader's live extensionmaps, the same mapsregisterToolwrites into.Two enforcement points, because neither covers the whole picture:
turn_endre-narrows the active set. pi emitsturn_endimmediately beforeprepareNextTurnre-snapshotsagent.state.tools, and session listeners run synchronously, so the narrow lands in time for turns 2..N.beforeToolCallvetoes out-of-scope calls. Turn 1 can't be narrowed at all:before_agent_startfiresay widen the tool set, butcreateContextSnapshot()freezes that turn's tools immediately after — there's nowindow. A call-time check is the only correct guard there.Both are installed on the session and deliberately not torn down, so resumed and steered turns stay scoped. pi's
dispose()clears_eventListeners, so they die with the session.resumeAgentandagent-manager.tsneeded no changes.This collapses #141's dual mode into a single path and removes the comment explaining why there were two. Net: 78 lines of code.
Alignment with upstream
pi 0.80.9 added Dynamic Tool Loading — register every tool, keep a subset active, call
setActiveToolsas things become relevant. That's exactly the shape used here, arrived at independently for adifferent use case, which is good evidence this is the pi-idiomatic approach rather than a workaround. The
{likewise pi's sanctioned shape (pi.on("tool_call")in the extensions Quick Start); pi turns it into an errortool result atagent-loop.js:419`.One thing genuinely reaches past the documented surface:
ExtensionBindingshas notool_callhook, so an SDK caller constructing a child session has no way to inject a veto. We wrap thebeforeToolCallproperty pi installs in theAgentSessionconstructor, chaining to the prior hook so extensiontool_callhandlers still fire. Verifiedsigned once (:136),_installAgentToolHooks()runs once in the constructor (:153) and is never re-run by_buildRuntime/reload().Because unit tests assert that against a hand-written mock and so can't catch upstream drift,
test/e2e/tool-veto-reachability.e2e.test.tsguards it against a real session, wired into the existingcompat-latest-pijob — following the precedentset by #152's
modelRuntimeguard.Evidence
New e2e fixture
ext-lazy.mjsregisters fromsession_start, reproducing the exact timing. Run against eachext:ext:(#125's config)ext:stays mutedMeasured, not asserted — the middle row is why #141 alone doesn't close #125.
Testing
effectiveAllowedTools()mock helper was replaced: it reimplemented pi'sisAllowedTool, so assertions routed through it tested the helper rather than pi.lastToolsPassed()now returns the session's real active set.ext:opt-in flip fails 7 tests (3 new, 4 pre-existing); deleting the veto fails the reachability guard.Behavior changes
ext:tracks late registration both ways — selecting a lazy extension surfaces its tools; leaving one out keeps it muted whenever it registers.ext:in use, an unselected extension registering atbefore_agent_start.Checked and unchanged: out-of-scope tools now sit in pi's registry, but
_rebuildSystemPromptwalks only active names, so they never reach the prompt; memory tools are folded intotoolNamesbefore scoping, somemory:agents keep read/write.