fix(agent): stop the between-turns tool refresh from forking the cached prefix (#100336 defect b) - #100638
fix(agent): stop the between-turns tool refresh from forking the cached prefix (#100336 defect b)#100638JoaoMarcos44 wants to merge 2 commits into
Conversation
Exhaustion sweep — three passes over the remaining prefix bottlenecksPass 1 — concurrency and ordering holes in the mergeThe merge runs inside No duplicates, no lost tools, no moved slot, and a genuine deregistration still lands exactly once. Pinned as Pass 2 — the
|
|
Closing for now — not on quality. The analysis is correct (I re-verified: The reason to hold it is that it needs a maintainer policy decision that hasn't been made yet, and merging it now would pre-empt that decision in one direction:
Once the freeze-vs-flip policy is settled, this merge helper is the obvious building block for the freeze side and I'd be glad to see it re-opened or re-picked (authorship preserved) against that design. Thanks for the thorough sweep — the concurrency proof and the |
|
Reopening — Teknium decided the freeze direction (Sep 2 2026): availability-gated tools stay fixed for a session; Your |
Imma do this |
…ed prefix The per-turn MCP refresh re-derives `agent.tools` from live availability and publishes the result wholesale. Two kinds of bytes move as a result: * a tool whose `check_fn` merely flapped (headless browser probe, expired credential, docker blip) disappears from the array, and * a late-landing MCP tool splices into sorted position, which can be index 0. Providers that render `tools` ahead of the messages re-prefill the entire history behind any moved byte, so either case costs a full re-prefill of the session — the measured 2% cache hit in NousResearch#100336. The caller's own comment claimed the refresh "only ever extends a fresh request prefix"; it did not. `refresh_agent_mcp_tools(..., preserve_prefix=True)` makes that claim true. The live order becomes authoritative: existing tools keep their slot (fresh schemas still land), a tool that is still registered but momentarily unavailable is carried forward, a tool that genuinely left the registry is still dropped, and new tools are appended at the tail. Explicit `/reload-mcp` and the compaction boundary keep the plain rebuild. Refs NousResearch#100336
Exhaustion pass over the preserve_prefix merge: six threads refreshing while one availability probe flips randomly. The invariant asserted is positional, not set-based — a tool ahead of the flapping one must never move, or the provider re-prefills everything behind it. Refs NousResearch#100336
…mcp the re-probe hatch Policy: availability-gated tools (check_fn probes — Docker, HASS_TOKEN, OAuth…) are frozen for the life of a session. tools[] only changes on /new, /reload-mcp, or compaction. Two doors remained after #100638: * Gateway agent-cache eviction (LRU/idle sweep/cross-process invalidation) rebuilds a fresh AIAgent for the SAME session and agent_init re-derives agent.tools from live probes with no predecessor to preserve. Persist the session's resolved tool-name order in a new `sessions.tool_names` JSON column (declarative reconciliation, SCHEMA_VERSION 28), written alongside the system prompt and re-pinned on every published refresh (so /reload-mcp and compaction naturally reset it; /new mints a new row). On restore-for-existing-session the fresh definitions are folded onto the saved order via the SAME `_merge_preserving_prefix` helper — a probe-flipped tool is carried forward from the registry schema, a deregistered one dropped, new tools appended at the tail. * /reload-mcp (CLI, gateway, TUI RPC) now also calls `reprobe_tool_availability()` — drops the check_fn verdict cache and the get_tool_definitions memo — so a user can consciously pick up a credential/daemon that appeared mid-session. Docs updated.
2e9a094 to
f2b204f
Compare
…mcp the re-probe hatch Policy: availability-gated tools (check_fn probes — Docker, HASS_TOKEN, OAuth…) are frozen for the life of a session. tools[] only changes on /new, /reload-mcp, or compaction. Two doors remained after #100638: * Gateway agent-cache eviction (LRU/idle sweep/cross-process invalidation) rebuilds a fresh AIAgent for the SAME session and agent_init re-derives agent.tools from live probes with no predecessor to preserve. Persist the session's resolved tool-name order in a new `sessions.tool_names` JSON column (declarative reconciliation, SCHEMA_VERSION 28), written alongside the system prompt and re-pinned on every published refresh (so /reload-mcp and compaction naturally reset it; /new mints a new row). On restore-for-existing-session the fresh definitions are folded onto the saved order via the SAME `_merge_preserving_prefix` helper — a probe-flipped tool is carried forward from the registry schema, a deregistered one dropped, new tools appended at the tail. * /reload-mcp (CLI, gateway, TUI RPC) now also calls `reprobe_tool_availability()` — drops the check_fn verdict cache and the get_tool_definitions memo — so a user can consciously pick up a credential/daemon that appeared mid-session. Docs updated.
|
Merged via #101367 — your |
…mcp the re-probe hatch Policy: availability-gated tools (check_fn probes — Docker, HASS_TOKEN, OAuth…) are frozen for the life of a session. tools[] only changes on /new, /reload-mcp, or compaction. Two doors remained after NousResearch#100638: * Gateway agent-cache eviction (LRU/idle sweep/cross-process invalidation) rebuilds a fresh AIAgent for the SAME session and agent_init re-derives agent.tools from live probes with no predecessor to preserve. Persist the session's resolved tool-name order in a new `sessions.tool_names` JSON column (declarative reconciliation, SCHEMA_VERSION 28), written alongside the system prompt and re-pinned on every published refresh (so /reload-mcp and compaction naturally reset it; /new mints a new row). On restore-for-existing-session the fresh definitions are folded onto the saved order via the SAME `_merge_preserving_prefix` helper — a probe-flipped tool is carried forward from the registry schema, a deregistered one dropped, new tools appended at the tail. * /reload-mcp (CLI, gateway, TUI RPC) now also calls `reprobe_tool_availability()` — drops the check_fn verdict cache and the get_tool_definitions memo — so a user can consciously pick up a credential/daemon that appeared mid-session. Docs updated.
Summary
fixes #100336
This PR takes defect (b) of #100336 — "the tool listing embeds environment-dependent state". #100358 owns defect (a) (the nulled
system_prompt_hashon a/modelswitch) and explicitly scopes (b) out; the two are complementary and touch disjoint code.The reporter's second symptom is the one that fires without a model switch:
The shrink is real, it is reproducible, and it has a precise source: the per-turn MCP refresh republishes
agent.toolsfrom live availability, so an availability probe that flips silently removes a tool from a request prefix the provider has already cached.%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#00f0ff', 'mainBkg': '#0a0a16', 'primaryTextColor': '#ffffff', 'primaryBorderColor': '#ff007f', 'lineColor': '#00f0ff'}}}%% graph TD A[Turn N+1 Prologue] --> B[refresh_agent_mcp_tools] B --> C[get_tool_definitions] C --> D{check_fn probe<br/>TTL 30s / grace 60s} D -->|True| E[Tool in schema list] D -->|False| F[Tool omitted] F --> G[agent.tools = new_defs<br/>SHRUNKEN ARRAY] E --> H[Late MCP tool<br/>sorted into index 0] H --> G G --> I[Cached prefix forked] I --> J[Full re-prefill<br/>~136k tokens] A --> K[preserve_prefix=True] K --> L[Live order authoritative] L --> M[Flapped tool carried forward] L --> N[Deregistered tool dropped] L --> O[New tool appended at tail] M --> P[Prefix byte-identical<br/>cache holds] N --> P O --> PCause — line by line
agent/turn_context.py:645-659runs a tool-snapshot rebuild in every turn prologue whenever any MCP server is registered. Its comment states the contract it believes it has:The timing half of that is true. The content half is not, and nothing enforced it.
tools/mcp_tool.py:8226-8297—refresh_agent_mcp_tools:new_defs = get_tool_definitions(...)(line 8226) recomputes the array from scratch.model_tools.get_tool_definitionsmemoizes on(scope, toolsets, registry._generation, config mtime, …)—check_fnresults are not part of the key. Any registry generation bump (an MCP server reconnecting, a plugin load, a lazy-server adoption) invalidates the memo and forces a full recompute.registry.get_definitions(tools/registry.py:1061-1071), which drops every tool whosecheck_fncurrently returnsFalse._check_fn_cachedhas a 30 s TTL and a 60 s success-grace (tools/registry.py:269-417), so a probe that stays down past the grace is honoured — the exactcheck_fn ... returned Falselines the reporter sees 13 of.agent.tools = new_defs(line 8289) publishes the shrunken array wholesale.new_names - current(line 8297) — additions only. Removals are invisible to the caller, which is why the prologue's "only ever extends" comment survived.There is a second, independent prefix fork on the same line.
registry.get_definitionsiteratessorted(tool_names)(line 1061), so a genuinely new tool does not extend the array — it splices into sorted position, which foraaa_*-prefixed MCP tools is index 0. Even the intended additive case moved every byte of the tool block.Reproduction (no network, no provider)
Three registry tools, one with a flipping
check_fn, driven through the real refresh helper:Two forks per availability flap, a third for the late arrival, and
addedreports nothing on the way down.Consequence
toolsahead of the messages (vLLM and every OpenAI-compatible chat template; Anthropic's tool block sits before the messages too) re-prefills the entire conversation behind the moved byte. On the reporter's ~139k-token session that is ~136k tokens per incident — the measured 99% → 2% collapse, with no model switch involved./modelswitch this needs no user action. Any probe that flaps — a headless box's browser/CDP checks, a docker daemon blip, an OAuth credential whose refresh fails,x_search's xAI resolver — forks the prefix, then forks it again on recovery.Solution
refresh_agent_mcp_tools(..., preserve_prefix=True)— a new keyword used by the one caller that rebuilds inside a live conversation, the between-turns prologue. Under it the live array is authoritative rather than the freshly sorted one, and_merge_preserving_prefixfolds the new snapshot into it:The distinction that makes this safe is why a tool vanished. A
check_fnreturningFalsemeans the tool is still registered and only its availability probe flipped; a server shutting down means the entry left the registry and its handler is gone. The merge reads registry membership — snapshotted outside_agent_tools_lock, soregistry._lockis never nested under it — and keeps only the first class.Carrying an unavailable tool forward changes nothing about dispatch:
check_fngates exposure at snapshot time, never invocation (registry.dispatchdoes not consult it), every handler already owns its own unavailability error, and_check_fn_cached's 60 s success-grace already deliberately keeps flapping tools visible. This extends that same policy to the lifetime of a live session.Explicit
/reload-mcp(TUI, ACP, CLI, gateway) and the compaction boundary keep the plain rebuild — a user who just disabled a toolset expects it gone, and compaction resets the prefix anyway.Post-fix
Files
tools/mcp_tool.pypreserve_prefixkeyword +_merge_preserving_prefixhelperagent/turn_context.pypreserve_prefix=True; the "only ever extends" comment now describes what the code doestests/tools/test_refresh_agent_mcp_tools.pyTest plan
python -m pytest tests/tools/test_refresh_agent_mcp_tools.py -q— 13 passed (5 new)python -m pytest tests/tools/test_mcp_tool.py tests/test_compaction_tool_refresh.py -q— 103 passed, 1 failed; the failure (test_windows_location_vars_passed_without_secrets, missingProgramFilesin this Windows shell env) reproduces on unmodifiedmainand is unrelatedpython -m pytest tests/agent/test_turn_context.py tests/agent/test_turn_context_overflow_warning.py tests/test_get_tool_definitions_cache_isolation.py -q— 34 passedRefs #100336. Complements #100358 (defect a) — no overlapping files.