Skip to content

feat(loop): resolve_turn_tools — rebind a turn's tools mid-run (0.11.0) - #37

Merged
zhanghanduo merged 1 commit into
mainfrom
feat/resolve-turn-tools
Sep 15, 2026
Merged

zhanghanduo merged 1 commit into
mainfrom
feat/resolve-turn-tools

Conversation

@zhanghanduo

Copy link
Copy Markdown
Collaborator

run_agent_loop freezes tool_map / tool_names / llm_with_tools once, above
the turn loop. Everything downstream can therefore narrow a turn's tools but
nothing can widen them: a tool the run did not start with can never become
callable, however the caller's state changed.

This adds one optional seam so a caller can answer "which tools does this turn
have" per turn, and moves those three variables inside the loop.

The seam

@dataclass(frozen=True)
class TurnToolSet:
    tools: Sequence[Any]                    # the callable set for this turn
    visible: frozenset[str] | None = None   # narrow what the LLM is shown

AgentLoopHooks(resolve_turn_tools=lambda cfg, metadata, turn: TurnToolSet | None)

Returning None — and not installing the hook at all — is the existing
behaviour, byte for byte: the frozen set is reused and nothing is rebound.

Three details that are deliberate

visible=None does not touch _llm_allowed_tools. That key is a caller
channel: an observer writes it in on_before_llm, which the engine fires
after it has already read the key, so an observer's narrowing lands on the
next turn. Had visible=None written the key unconditionally, it would have
cleared a narrowing the caller was mid-way through applying. A test pins it.

visible narrows the callable set too, not just the schema list. It reuses
_llm_allowed_tools, which the executor also consults, so a non-visible tool is
refused rather than run. That makes it "off this turn", not "deferred" — a
deferred tool stays callable and belongs in tools with the schema filtered
further out.

Rebinding is skipped when the resolved names are unchanged, since binding
allocates a client wrapper per turn and the common case resolves to the same
set. Identity of the bound object is preserved across turns that do not change.

Why the caller cannot do this itself

llm_with_tools is built from tools before the loop and captured by the retry
and streaming paths; there is no later point where a caller can substitute it
without reimplementing the turn body. The three variables are engine-local by
construction, which is why the seam has to be here rather than in a wrapper.

Tests

6 new, in tests/test_resolve_turn_tools.py. The two that carry the change:
a tool absent from tools= is bound and executed on turn 2; and a tool
present at turn 1 is refused from turn 2 after the hook drops it. Also pinned:
no hook keeps the tools frozen, the hook is called every turn with the turn
number, and the visible=None non-interference above.

Full suite green. Version bumped to 0.11.0 (0.10.0 is taken by the image change).

Consumer

ApodexHarness EvoHarness 2.0 M2: "install an MCP pack mid-run and the model
calls its tool on the next turn". That repo pins ==0.11.0, so this needs to be
merged and published before its PR can drop a local [tool.uv.sources] line.
Acceptance there is a real MCP subprocess introduced by a stdin frame during
turn 1 and called on turn 2.

Generated with Claude Code
via Very Happy

Co-Authored-By: Claude noreply@anthropic.com

… mid-run

The loop freezes `tool_map` / `tool_names` / `llm_with_tools` once, before the
turn loop, so a run cannot bind a tool it did not start with. `resolve_turn_tools`
is an optional hook called at the top of every turn: returning a `TurnToolSet`
rebuilds those three from the new full set (the request built below, the parser's
known-name set and the landing check all read it that same turn); returning
`None` — the default — keeps the current binding and rebinds nothing, so a run
whose tools never move pays no per-turn cost and sees no prompt-cache churn.

`TurnToolSet.visible` optionally narrows the turn through the existing
`_llm_allowed_tools` channel, which hides AND forbids the rest; a tool that must
stay callable while hidden (deferred loading) leaves it `None` and is filtered
at the LLM boundary instead. `visible=None` does not touch `_llm_allowed_tools`,
so an observer's own narrowing survives.

This is the engine seam EvoHarness M2 needs to add or reconnect a tool at a turn
boundary; without a hook supplied, behaviour is byte-for-byte unchanged.

MINOR: new capability, additive. `TurnToolSet` exported from
`agent_core.runtime.loop`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@zhanghanduo
zhanghanduo marked this pull request as ready for review September 15, 2026 02:44
@zhanghanduo
zhanghanduo merged commit 0d16278 into main Sep 15, 2026
5 checks passed
@zhanghanduo
zhanghanduo deleted the feat/resolve-turn-tools branch September 15, 2026 02:44
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.

1 participant