Skip to content

fix(tools): let subagents use asynchronously-registered extension tools, including under ext:#157

Merged
tintinweb merged 2 commits into
masterfrom
async-ext-tools
Jul 22, 2026
Merged

fix(tools): let subagents use asynchronously-registered extension tools, including under ext:#157
tintinweb merged 2 commits into
masterfrom
async-ext-tools

Conversation

@tintinweb

Copy link
Copy Markdown
Owner

Summary

Subagents silently lost every extension tool that registers asynchronously — after extension load. This is a category, not one broken extension: pi-mcp registers from session_start (once its MCP servers connect), context-mode from before_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:

const isAllowedTool = (name) =>
  (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name);

allowedToolNames gates tool registration, not merely the active set, and it's frozen at construction. runAgent snapshotted extension.tools.keys() into that allowlist right after loader.reload() — i.e. before bindExtensions() fires session_start. Anything registering later was dropped permanently.

Approach

Whenever extensions are in play, leave allowedToolNames unset 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:) as excludeTools, which pi re-applies on every registry refresh. extensions: false / isolated: true keep 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 maps registerTool writes into.

Two enforcement points, because neither covers the whole picture:

  • turn_end re-narrows the active set. pi emits turn_end immediately before prepareNextTurn re-snapshots agent.state.tools, and session listeners run synchronously, so the narrow lands in time for turns 2..N.
  • beforeToolCall vetoes out-of-scope calls. Turn 1 can't be narrowed at all: before_agent_start firesay widen the tool set, but createContextSnapshot() 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. resumeAgent and agent-manager.ts needed 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 setActiveTools as things become relevant. That's exactly the shape used here, arrived at independently for a
different 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: ExtensionBindings has no tool_call hook, so an SDK caller constructing a child session has no way to inject a veto. We wrap the beforeToolCall property pi installs in the
AgentSession constructor, chaining to the prior hook so extension tool_call handlers 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.ts guards it against a real session, wired into the existing compat-latest-pi job — following the precedent
set by #152's modelRuntime guard.

Evidence

New e2e fixture ext-lazy.mjs registers from session_start, reproducing the exact timing. Run against each

scenario master +#141 this PR
lazy tool, no ext:
lazy tool under ext: (#125's config)
lazy tool under unselected ext: stays muted

Measured, not asserted — the middle row is why #141 alone doesn't close #125.

Testing

  • 743 passed / 5 skipped across 42 files, up from the 731 baseline (+8 unit, +3 e2e template scenarios, +t and typecheck clean.
  • fix: let subagents see asynchronously-registered extension tools (MCP) #141's tests are carried over, including its bind-ordering assertion. Its effectiveAllowedTools() mock helper was replaced: it reimplemented pi's isAllowedTool, so assertions routed through it tested the helper rather than pi.
    lastToolsPassed() now returns the session's real active set.
  • Mutation-tested: disabling the ext: opt-in flip fails 7 tests (3 new, 4 pre-existing); deleting the veto fails the reachability guard.
  • Also confirmed fix: let subagents see asynchronously-registered extension tools (MCP) #141 passes the full suite on its own (it had only been run against `test/agent-runner.test.

Behavior changes

  1. Lazily-registered extension tools now reach subagents.
  2. ext: tracks late registration both ways — selecting a lazy extension surfaces its tools; leaving one out keeps it muted whenever it registers.
  3. A subagent's tool surface can now grow mid-session (previously frozen at spawn). Intended, but it is a loo
  4. New failure mode: an out-of-scope call returns a tool error. Narrow window — turn 1, ext: in use, an unselected extension registering at before_agent_start.

Checked and unchanged: out-of-scope tools now sit in pi's registry, but _rebuildSystemPrompt walks only active names, so they never reach the prompt; memory tools are folded into toolNames before scoping, so memory: agents keep read/write.

philipmw and others added 2 commits July 18, 2026 22:44
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
tintinweb marked this pull request as ready for review July 22, 2026 17:33
@tintinweb
tintinweb merged commit 92dcc52 into master Jul 22, 2026
2 checks passed
tintinweb added a commit to codesoda/pi-subagents that referenced this pull request Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unable to use lazily loaded extension tools in subagents

2 participants