feat(loop): resolve_turn_tools — rebind a turn's tools mid-run (0.11.0) - #37
Merged
Merged
Conversation
… 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>
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.
run_agent_loopfreezestool_map/tool_names/llm_with_toolsonce, abovethe 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
Returning
None— and not installing the hook at all — is the existingbehaviour, byte for byte: the frozen set is reused and nothing is rebound.
Three details that are deliberate
visible=Nonedoes not touch_llm_allowed_tools. That key is a callerchannel: an observer writes it in
on_before_llm, which the engine firesafter it has already read the key, so an observer's narrowing lands on the
next turn. Had
visible=Nonewritten the key unconditionally, it would havecleared a narrowing the caller was mid-way through applying. A test pins it.
visiblenarrows the callable set too, not just the schema list. It reuses_llm_allowed_tools, which the executor also consults, so a non-visible tool isrefused rather than run. That makes it "off this turn", not "deferred" — a
deferred tool stays callable and belongs in
toolswith the schema filteredfurther 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_toolsis built fromtoolsbefore the loop and captured by the retryand 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 toolpresent 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=Nonenon-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 bemerged 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