From 3e6067d474a3eb8b072c4a3e2daf3441a5211b59 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:08:17 +0800 Subject: [PATCH 1/8] fix(agent): reserve the tool-search meta-tools from the off switch tools.disabled_tools could name tool_search and tool_call, and their absence from an array is how the fold reads "this request has no search route": it answers by shipping every schema. So an off switch there did not slim a folded request, it unfolded it -- and mid-turn, because the switch is read once per assembled array, which is the first segment of the prompt-cache prefix. One measured turn went from 12 tools to 61 between two model calls. The pair joins the MCP resource and prompt meta-tools in the reserved set: names the loop owns for its lifetime, reported once and ignored where the array is built. tools.tool_search.enabled is the switch that speaks for both. The settings page needs no change: ext.list already reports both as builtin, and the page draws a fixed control for those. The four packaged agents drop their now-inert rows, so tool_call joins their faces and the ledger tests record it; each also pins toolSearch.enabled to false, a no-op against today's default that keeps a curated face off the fold. Co-authored-by: Claude (claude-opus-5[1m]) --- agents/raven-code/config.json | 5 +-- agents/raven-oncall/config.json | 5 +-- agents/raven-ppt/config.json | 5 +-- agents/raven-research/config.json | 5 +-- raven/agent/loop/wiring.py | 50 ++++++++++++++++++-------- tests/test_agent_loop_tool_search.py | 31 ++++++++++++++-- tests/test_agents_code_launcher.py | 25 ++++++++++--- tests/test_agents_oncall_launcher.py | 8 +++++ tests/test_agents_ppt_launcher.py | 28 +++++++++++---- tests/test_agents_research_launcher.py | 8 +++++ tests/test_context_engine_factory.py | 7 +++- tests/test_tool_search.py | 5 +-- 12 files changed, 144 insertions(+), 38 deletions(-) diff --git a/agents/raven-code/config.json b/agents/raven-code/config.json index 13c782521..7043d7b42 100644 --- a/agents/raven-code/config.json +++ b/agents/raven-code/config.json @@ -35,12 +35,13 @@ "maxResults": 5 } }, + "toolSearch": { + "enabled": false + }, "disabledTools": [ "spawn", "message", "hub", - "tool_search", - "tool_call", "deep_research", "image_generate", "text_to_speech", diff --git a/agents/raven-oncall/config.json b/agents/raven-oncall/config.json index 42949724e..c99002247 100644 --- a/agents/raven-oncall/config.json +++ b/agents/raven-oncall/config.json @@ -34,14 +34,15 @@ "maxResults": 5 } }, + "toolSearch": { + "enabled": false + }, "disabledTools": [ "spawn", "cron", "read_skill", "use_skill", "hub", - "tool_search", - "tool_call", "deep_research", "image_generate", "text_to_speech", diff --git a/agents/raven-ppt/config.json b/agents/raven-ppt/config.json index e1d6ae270..1a1268825 100644 --- a/agents/raven-ppt/config.json +++ b/agents/raven-ppt/config.json @@ -80,6 +80,9 @@ "maxResults": 10 } }, + "toolSearch": { + "enabled": false + }, "disabledTools": [ "create_playbook", "cron", @@ -95,8 +98,6 @@ "run_subagent_dag", "spawn", "text_to_speech", - "tool_call", - "tool_search", "video_generate", "browser_click", "browser_navigate", diff --git a/agents/raven-research/config.json b/agents/raven-research/config.json index b1cfe2bbe..a0dfed63c 100644 --- a/agents/raven-research/config.json +++ b/agents/raven-research/config.json @@ -123,6 +123,9 @@ "maxResults": 5 } }, + "toolSearch": { + "enabled": false + }, "disabledTools": [ "exec", "spawn", @@ -131,8 +134,6 @@ "read_skill", "use_skill", "find_skill", - "tool_search", - "tool_call", "image_generate", "text_to_speech", "video_generate", diff --git a/raven/agent/loop/wiring.py b/raven/agent/loop/wiring.py index 7360d1d02..19dd69ce2 100644 --- a/raven/agent/loop/wiring.py +++ b/raven/agent/loop/wiring.py @@ -82,12 +82,14 @@ def _report_reserved_disabled_tools(self) -> None: preference expressed by destroying its subject is one that cannot be reversed: nothing remembered what to put back. - The MCP meta-tools are the one exemption, and it is theirs by ownership - rather than by policy: :meth:`_sync_mcp_meta_tools` registers them while - some connected server serves resources or prompts and withdraws them when - none does, so it owns those five names for the life of the loop. An entry - naming one is a preference nothing can act on -- reported here once, - ignored where the array is built. + Two groups are exempt, and both are theirs by ownership rather than by + policy. :meth:`_sync_mcp_meta_tools` registers the five MCP meta-tools + while some connected server serves resources or prompts and withdraws them + when none does, so it owns those names for the life of the loop. + ``tool_search`` and ``tool_call`` are owned the same way by + :meth:`_register_default_tools`, and ``tools.tool_search.enabled`` is the + switch that speaks for them. An entry naming either group is a preference + nothing can act on -- reported here once, ignored where the array is built. Run after :meth:`_register_default_tools` and after MCP connect, so an entry naming a tool from either group is resolvable by the time it is @@ -98,6 +100,7 @@ def _report_reserved_disabled_tools(self) -> None: is about the entry the operator can actually see -- most of them are in the config file, which is no longer copied into ``_disabled_tools``. """ + from raven.agent.tools.tool_search import META_TOOL_NAMES from raven.config.live import disabled_tool_names from raven.mcp.prompts import PROMPT_TOOL_NAMES from raven.mcp.resources import RESOURCE_TOOL_NAMES @@ -105,11 +108,12 @@ def _report_reserved_disabled_tools(self) -> None: entries = set(disabled_tool_names(self._live_config)) | self._disabled_tools if not entries: return - reserved = RESOURCE_TOOL_NAMES | PROMPT_TOOL_NAMES + mcp_reserved = RESOURCE_TOOL_NAMES | PROMPT_TOOL_NAMES for entry in sorted(entries): if entry in self._disabled_tools_reserved_warned: continue - if any(name in reserved for name in self.tools.resolve_configured(entry)): + resolved = self.tools.resolve_configured(entry) + if any(name in mcp_reserved for name in resolved): self._disabled_tools_reserved_warned.add(entry) logger.warning( "tools.disabled_tools names '{}', which raven registers and withdraws on its " @@ -117,16 +121,33 @@ def _report_reserved_disabled_tools(self) -> None: "no effect; remove it to keep the config honest.", entry, ) + elif any(name in META_TOOL_NAMES for name in resolved): + self._disabled_tools_reserved_warned.add(entry) + logger.warning( + "tools.disabled_tools names '{}', which raven owns for the life of the loop: " + "tool_call is the only route to a tool whose schema is not in the array, and " + "tool_search follows tools.tool_search.enabled. The entry has no effect; " + "remove it, or turn the fold off with that setting.", + entry, + ) def _withheld_tool_names(self) -> frozenset[str]: """Which tools are not on offer right now, read live. The registry asks this when it assembles a tool array. Reserved names are - removed here rather than at the switch: ``_sync_mcp_meta_tools`` owns those - five for the life of the loop (it registers them while some connected - server serves resources or prompts and withdraws them when none does), so - an entry naming one is a preference the loop cannot honour -- reported - above, ignored here. + removed here rather than at the switch: ``_sync_mcp_meta_tools`` owns the + five MCP meta-tools for the life of the loop (it registers them while some + connected server serves resources or prompts and withdraws them when none + does), so an entry naming one is a preference the loop cannot honour -- + reported above, ignored here. + + ``tool_search`` and ``tool_call`` are reserved for that reason and one + more. Their absence from the array is how ``ToolSearchStrategy`` reads + "this request has no search route", and it answers by shipping every + schema instead -- so an off switch here would not slim the array, it would + unfold it, and mid-turn at that, because this source is read once per + assembly. The switch that speaks for the fold is + ``tools.tool_search.enabled``. Constructor-supplied names are unioned in because an eval harness passes them directly rather than through a config file; a file-less run would @@ -134,6 +155,7 @@ def _withheld_tool_names(self) -> frozenset[str]: list in there -- see the note in ``__init__`` on why that made the switch one-way. """ + from raven.agent.tools.tool_search import META_TOOL_NAMES from raven.config.live import disabled_tool_names from raven.mcp.prompts import PROMPT_TOOL_NAMES from raven.mcp.resources import RESOURCE_TOOL_NAMES @@ -143,7 +165,7 @@ def _withheld_tool_names(self) -> frozenset[str]: for entry in configured: withheld.update(self.tools.resolve_configured(entry)) withheld.update(self._unconfigured_tool_names()) - return frozenset(withheld - (RESOURCE_TOOL_NAMES | PROMPT_TOOL_NAMES)) + return frozenset(withheld - (RESOURCE_TOOL_NAMES | PROMPT_TOOL_NAMES | META_TOOL_NAMES)) def _unconfigured_tool_names(self) -> set[str]: """Registered tools whose config asks for nothing right now, read live. diff --git a/tests/test_agent_loop_tool_search.py b/tests/test_agent_loop_tool_search.py index a5c30fec4..2b0cb2268 100644 --- a/tests/test_agent_loop_tool_search.py +++ b/tests/test_agent_loop_tool_search.py @@ -53,7 +53,7 @@ def workspace(): yield Path(td) -def _make_loop(workspace: Path, cfg, strategies=None) -> AgentLoop: +def _make_loop(workspace: Path, cfg, strategies=None, disabled_tools=None) -> AgentLoop: return AgentLoop( provider=_StubProvider(), workspace=workspace, @@ -62,7 +62,12 @@ def _make_loop(workspace: Path, cfg, strategies=None) -> AgentLoop: # loop only registers it when a search key resolves. Supplying one keeps # the subject of the test present for the right reason. policy=TurnPolicy(max_iterations=2), - tools=ToolWiring(restrict_to_workspace=True, tool_search_config=cfg, search_api_key="test-serper-key"), + tools=ToolWiring( + restrict_to_workspace=True, + tool_search_config=cfg, + search_api_key="test-serper-key", + disabled_tools=disabled_tools or [], + ), engine=EngineWiring(strategies=strategies), ) @@ -146,3 +151,25 @@ async def test_tool_call_does_not_name_tool_search_when_it_is_absent(workspace) folded = _make_loop(workspace, ToolSearchConfig(enabled=True, compaction_threshold=5)) out = await folded.tools.execute("tool_call", {"name": "no_such_tool"}) assert "tool_search lists what is currently loaded" in out + + +@pytest.mark.asyncio +async def test_disabled_tools_cannot_withhold_the_meta_tools(workspace) -> None: + # The off switch reaches every other name, but not these two. The strategy + # reads their absence as "this request has no search route" and ships every + # schema instead, so an entry here would unfold the array rather than slim + # it -- and mid-turn at that, since the switch is read once per assembly. + loop = _make_loop( + workspace, + ToolSearchConfig(enabled=True, compaction_threshold=5), + disabled_tools=["tool_search", "tool_call", "web_search"], + ) + assert loop.tools.offers_by_name("tool_search") + assert loop.tools.offers_by_name("tool_call") + assert not loop.tools.offers_by_name("web_search"), "every other name still switches off" + + tools = loop.tools.get_definitions() + _, out, _ = await loop.strategies.before_llm_call([], tools, "stub") + names = {t["function"]["name"] for t in out} + assert {"tool_search", "tool_call"} <= names + assert len(names) < len(tools), "the fold must still fold, not fall through to passthrough" diff --git a/tests/test_agents_code_launcher.py b/tests/test_agents_code_launcher.py index f50b50215..7e8bd6d06 100644 --- a/tests/test_agents_code_launcher.py +++ b/tests/test_agents_code_launcher.py @@ -860,12 +860,21 @@ def test_a_relative_state_root_resolves_under_the_host_agent_home(grounded, tmp_ "use_skill", } +#: The one trunk name this product cannot hold out. Raven reserves the +#: tool-search meta-pair from ``tools.disabledTools``: the fold reads their +#: absence from an array as "this request has no search route" and answers by +#: shipping every schema, so an off switch there would unfold the array rather +#: than slim it. ``tool_call`` registers whatever the fold is doing and joins +#: the face; ``tool_search`` follows ``tools.toolSearch.enabled``, off here, so +#: it does not. +TRUNK_RESERVED = {"tool_call"} + #: The product's visible tool face, hermetically rebuilt from the render: #: the fork's config intent minus the ledgered pending waves, plus the -#: opened skill lane. Trunk also grew tools the fork never had; the withheld -#: ones must be disabled by the product config, not by luck -- the two -#: playbook tools only register outside this hermetic fixture, so their -#: disable rows are the pin. +#: opened skill lane and the reserved name above. Trunk also grew tools the +#: fork never had; the withheld ones must be disabled by the product config, +#: not by luck -- the two playbook tools only register outside this hermetic +#: fixture, so their disable rows are the pin. VENDORED_TOOL_FACE = { "ask_user", "edit_file", @@ -877,6 +886,7 @@ def test_a_relative_state_root_resolves_under_the_host_agent_home(grounded, tmp_ "read_file", "read_skill", "todo", + "tool_call", "use_skill", "web_fetch", "write_file", @@ -911,7 +921,12 @@ def test_the_face_arithmetic_is_the_ledger(): """The literal above is not free-standing: it is the measured fork intent minus the ledgered pending waves, respelled -- so a tool can only leave or join the face by moving on this ledger.""" - expected = (FORK_CONFIG_INTENT - PENDING_WAVE_TOOLS - set(RESPELLED)) | set(RESPELLED.values()) | SKILL_LANE_TOOLS + expected = ( + (FORK_CONFIG_INTENT - PENDING_WAVE_TOOLS - set(RESPELLED)) + | set(RESPELLED.values()) + | SKILL_LANE_TOOLS + | TRUNK_RESERVED + ) assert VENDORED_TOOL_FACE == expected diff --git a/tests/test_agents_oncall_launcher.py b/tests/test_agents_oncall_launcher.py index e639c70aa..1e8f554d9 100644 --- a/tests/test_agents_oncall_launcher.py +++ b/tests/test_agents_oncall_launcher.py @@ -278,6 +278,13 @@ def test_a_preexisting_own_registry_stays(grounded, tmp_path): #: step was removed on 2026-09-06, so the instance that needs the machine now #: writes the row itself. Listed explicitly, not folded in: a tool reaching this #: face without a line saying why is the drift this guard exists to catch. +#: +#: tool_call is the one name this face carries that no config row put there: +#: raven reserves the tool-search meta-pair from tools.disabledTools, because +#: the fold reads their absence from an array as "this request has no search +#: route" and answers by shipping every schema. tool_call registers whatever +#: the fold is doing; tool_search follows tools.toolSearch.enabled, off in this +#: product's config, so it stays out. VENDORED_TOOL_FACE = { "ask_user", "edit_file", @@ -301,6 +308,7 @@ def test_a_preexisting_own_registry_stays(grounded, tmp_path): "ops_submit", "ops_tune_status", "read_file", + "tool_call", "web_fetch", "write_file", } diff --git a/tests/test_agents_ppt_launcher.py b/tests/test_agents_ppt_launcher.py index 745ae7522..e66a82e75 100644 --- a/tests/test_agents_ppt_launcher.py +++ b/tests/test_agents_ppt_launcher.py @@ -144,9 +144,9 @@ def test_the_roster_row_is_the_vendored_twins_modulo_the_ledgered_deltas(): #: Trunk tools the fork loop never registered under this product's config; #: every one is held out of the face by a config row, not by luck (the code -#: family's ledger discipline). tool_call/tool_search additionally keep the -#: fork's face: its meta-pair registers only under tools.toolSearch.enabled, -#: default False and never set by this config (the threshold only folds). +#: family's ledger discipline). The meta-pair left this set: raven reserves +#: tool_call/tool_search from tools.disabledTools, so the rows that used to +#: hold them out are gone and tool_call joins the face (see TRUNK_RESERVED). TRUNK_HELD_OUT = { "browser_click", "browser_navigate", @@ -169,8 +169,6 @@ def test_the_roster_row_is_the_vendored_twins_modulo_the_ledgered_deltas(): "read_skill", "run_subagent_dag", "spawn", - "tool_call", - "tool_search", } @@ -218,6 +216,14 @@ def test_the_roster_row_is_the_vendored_twins_modulo_the_ledgered_deltas(): } +# tools rows the sealed fork's config does not carry: trunk folds a catalog +# past tools.toolSearch.compactionThreshold by default, and this product opts +# out. Its face is about twenty tools, so the fold could never trigger -- all +# the default would do is register a tool_search the product can no longer +# disable (raven reserves the meta-pair), and a curated face does not carry a +# tool it will never ship. +TRUNK_ONLY_TOOLS = {"toolSearch": {"enabled": False}} + # Engine-slice keys the fork's tools.ppt schema never had. The second reader's own # reasoning effort: the fork read every page at the author's setting, which litellm # dropped for this model anyway, so a GLM thought at its default for 100 to 350 seconds @@ -254,6 +260,9 @@ def test_the_config_is_the_forks_modulo_the_swap_ledger(): fork_tools = dict(theirs["tools"]) retired = fork_tools.pop("ppt") assert retired == {**slice_, **TRUNK_DROPPED_SLICE} + for key, value in TRUNK_ONLY_TOOLS.items(): + assert ours["tools"].pop(key) == value, key + assert key not in fork_tools, f"{key} is the fork's now; the ledger row is stale" ours_disabled = set(ours["tools"].pop("disabledTools")) fork_disabled = set(fork_tools.pop("disabledTools")) assert ours_disabled - fork_disabled == TRUNK_HELD_OUT @@ -1033,7 +1042,14 @@ def test_the_state_root_override_wins_and_the_default_sits_under_the_home(ground #: from the rendered slice key) joins only when a Serper key is present -- the #: fork's own refusal to register keyless search. Every trunk-new name is held #: out by the TRUNK_HELD_OUT config rows pinned above. -VENDORED_TOOL_FACE = FORK_CONFIG_INTENT | DECK_TOOLS +#: +#: TRUNK_RESERVED is the exception those rows cannot cover: raven reserves the +#: tool-search meta-pair from tools.disabledTools, because the fold reads their +#: absence from an array as "this request has no search route" and answers by +#: shipping every schema. tool_call registers whatever the fold is doing and so +#: joins the face; tool_search follows tools.toolSearch.enabled, off here. +TRUNK_RESERVED = {"tool_call"} +VENDORED_TOOL_FACE = FORK_CONFIG_INTENT | DECK_TOOLS | TRUNK_RESERVED KEY_GATED = {"web_search", "ppt_image_search"} diff --git a/tests/test_agents_research_launcher.py b/tests/test_agents_research_launcher.py index 1b0fea667..991b758cb 100644 --- a/tests/test_agents_research_launcher.py +++ b/tests/test_agents_research_launcher.py @@ -23,6 +23,13 @@ #: grew tools the fork never had (the deep_research offer stub above all, #: which would have a research agent offering to outsource research), and #: every one of them must be disabled by the product config, not by luck. +#: +#: tool_call is the one name this face carries that no config row put there: +#: raven reserves the tool-search meta-pair from tools.disabledTools, because +#: the fold reads their absence from an array as "this request has no search +#: route" and answers by shipping every schema. tool_call registers whatever +#: the fold is doing; tool_search follows tools.toolSearch.enabled, off in this +#: product's config, so it stays out. VENDORED_TOOL_FACE = { "read_file", "write_file", @@ -33,6 +40,7 @@ "web_search", "web_fetch", "ask_user", + "tool_call", } REPO = Path(__file__).resolve().parent.parent diff --git a/tests/test_context_engine_factory.py b/tests/test_context_engine_factory.py index b61494887..6a48ed422 100644 --- a/tests/test_context_engine_factory.py +++ b/tests/test_context_engine_factory.py @@ -642,7 +642,12 @@ async def test_withholding_the_whole_tool_table_retires_it_too(self, tmp_path: P assert "## Delegation" in await self._text(agent) self._disable(*every) - assert agent.tools.get_definitions() == [] + # Not empty any more: raven reserves the tool-search meta-pair from the + # off switch, so what an operator can withhold is everything else. Zero + # live delegation paths is what retires the section, and that is reached. + from raven.agent.tools.tool_search import META_TOOL_NAMES + + assert {d["function"]["name"] for d in agent.tools.get_definitions()} == set(META_TOOL_NAMES) assert "## Delegation" not in await self._text(agent) async def test_withholding_one_path_keeps_the_other_named(self, tmp_path: Path) -> None: diff --git a/tests/test_tool_search.py b/tests/test_tool_search.py index d5fb5f16b..c0e37f386 100644 --- a/tests/test_tool_search.py +++ b/tests/test_tool_search.py @@ -393,8 +393,9 @@ async def test_strategy_none_tools_passthrough() -> None: @pytest.mark.asyncio async def test_strategy_passthrough_when_meta_tools_absent() -> None: - # Above threshold but meta-tools missing (e.g. removed via disabled_tools): - # expose everything rather than strand cataloged tools. + # Above threshold but meta-tools missing (a dispatch charter that narrows the + # turn, or a host that never registered them -- tools.disabled_tools cannot + # reach them): expose everything rather than strand cataloged tools. reg, ctrl = _registry_with_n(40) reg.unregister("tool_search") reg.unregister(TOOL_CALL_NAME) From 9dff66d28930a472694295c257bddb84237ce93c Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:20:37 +0800 Subject: [PATCH 2/8] feat(agent): name the connected mcp servers a folded catalog hides A folded request carries the core tools and the meta-pair; every other schema is withheld. Nothing in the prompt says what is in the catalog: the runtime context named an MCP server only while it awaited authorization or was still connecting, so a connected one read as nothing at all and a model that did not think to search reported a capability it held as missing. One line per connected server, with its tool count, only while tool_search actually reaches the request -- below the fold those tools are in the array and the sentence would be false. That gate is ToolSearchController.search_visible, made public for it. The strategy also stops assuming the array's shape. Replay feeds a recorded array back through the same chain, so a corrupt recording arrived as an entry whose "function" is a string and ended the turn with a TypeError instead of reaching replay's own backstop. What it cannot read, it now counts as a tool and passes through untouched. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/agent/loop/mcp_glue.py | 17 ++++++++++++++- raven/agent/tools/tool_search.py | 36 ++++++++++++++++++++++++------- tests/test_agent_loop_mcp_sync.py | 33 ++++++++++++++++++++++++++++ tests/test_tool_search.py | 14 ++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/raven/agent/loop/mcp_glue.py b/raven/agent/loop/mcp_glue.py index 0e1fc7b41..cb782fdca 100644 --- a/raven/agent/loop/mcp_glue.py +++ b/raven/agent/loop/mcp_glue.py @@ -172,7 +172,7 @@ def _mcp_tool_notices(self) -> list[str]: """Host facts about MCP tools the definitions cannot carry. One line per enabled server whose tools are absent from this turn for a - reason the model cannot see, and the two reasons need different lines: + reason the model cannot see, and the three reasons need different lines: * ``auth_required`` -- the wait is on a person, and the answer names who can end it. Without this the model reads an unauthorized plugin as a @@ -181,6 +181,13 @@ def _mcp_tool_notices(self) -> list[str]: that overtakes it, which since ``prewarm_mcp`` is the ordinary shape of a first turn rather than a rarity: the turn no longer waits, so it can be assembled while servers are still coming up. + * ``connected`` while the catalog is folded -- the schemas are withheld on + purpose, and nothing else in the prompt names an installed server, so a + model that does not think to search reports a capability it holds as + missing. ``tool_search``'s own description says a catalog exists; only + this says what is in it. Gated on + ``ToolSearchController.search_visible`` because below the fold those + tools are in the array and the line would be false. Rendered into the runtime-context block, not the system prompt -- the set changes turn to turn and must never be cached with the prefix. @@ -195,6 +202,8 @@ def _mcp_tool_notices(self) -> list[str]: # that way -- an imperative here would be either ignored or a fence # violation. The fact alone is enough to stop it reporting a missing # capability. + controller = getattr(self, "tool_search_controller", None) + folded = controller is not None and controller.search_visible() notices = [] for snap in mgr.status(): if not snap.get("enabled", True): @@ -211,6 +220,12 @@ def _mcp_tool_notices(self) -> list[str]: f"from this turn's definitions and register themselves when the handshake " f"finishes, so they are available from a later turn without anyone acting." ) + elif folded and snap["connected"] and snap["tool_count"]: + notices.append( + f"MCP plugin '{snap['name']}': connected, {snap['tool_count']} tool(s). Their " + f"schemas are not in this turn's definitions because the catalog is folded -- " + f"tool_search finds them by keyword, tool_call invokes them by name." + ) return notices def prewarm_mcp(self) -> None: diff --git a/raven/agent/tools/tool_search.py b/raven/agent/tools/tool_search.py index 8802b281b..b9f8d96cf 100644 --- a/raven/agent/tools/tool_search.py +++ b/raven/agent/tools/tool_search.py @@ -262,15 +262,18 @@ def resolve_target(self, name: Any) -> _Target: return _Target(None, f"Error: '{name}' cannot be invoked via tool_call.") tool = self._registry.get(name) if tool is None or not self._registry.offers(tool): - tail = " -- tool_search lists what is currently loaded" if self._search_visible() else "" + tail = " -- tool_search lists what is currently loaded" if self.search_visible() else "" return _Target(None, absent_tool_error(name, tail=tail)) return _Target(tool, None) - def _search_visible(self) -> bool: + def search_visible(self) -> bool: """Whether ``tool_search`` reaches the model's tool list this turn. - The old shape of :meth:`tool_call_available`, kept but moved to the - meta-tool it is actually true of. ``tool_search`` is registered only + Public because it is also the honest gate on anything the host says + about the fold: ``_mcp_tool_notices`` tells the model a connected + server's schemas are withheld, and that sentence is true only while + this is. The old shape of :meth:`tool_call_available`, kept but moved to + the meta-tool it is actually true of. ``tool_search`` is registered only where the fold is configured on, withheld by the same off switch as anything else, and dropped from the request again below the threshold -- so a pointer at it is dishonest in three separate states, not one. @@ -435,6 +438,23 @@ async def execute(self, name: str, arguments: dict[str, Any] | None = None) -> s return await self._ctrl.call(name, arguments) +def _schema_name(entry: Any) -> str | None: + """The name a tool definition carries, or ``None`` for a shape this strategy + cannot read. + + The arrays raven assembles are always well formed; replay is what is not. + It feeds a recorded array back through this same chain, so a corrupt + recording arrives here as an entry whose ``function`` is a string -- and + that has to end at replay's own backstop as an unreplayable recording, + not as a TypeError thrown from a strategy that assumed the shape. An entry + this cannot read is counted as a tool and passed through untouched: the + fold may only withhold a schema it recognised. + """ + function = entry.get("function") if isinstance(entry, dict) else None + name = function.get("name") if isinstance(function, dict) else None + return name if isinstance(name, str) else None + + class ToolSearchStrategy(TokenStrategy): """``before_llm_call`` hook that compacts the tool list for large catalogs. @@ -470,16 +490,16 @@ async def before_llm_call( if not tools: return messages, tools, model self._ctrl.refresh() - catalog_size = sum(1 for t in tools if t["function"]["name"] not in META_TOOL_NAMES) + catalog_size = sum(1 for t in tools if _schema_name(t) not in META_TOOL_NAMES) if catalog_size <= self._compaction_threshold: - out = [t for t in tools if t["function"]["name"] != TOOL_SEARCH_NAME] + out = [t for t in tools if _schema_name(t) != TOOL_SEARCH_NAME] return messages, out, model - present = {t["function"]["name"] for t in tools} + present = {name for t in tools if (name := _schema_name(t)) is not None} if not META_TOOL_NAMES <= present: # Meta-tools unavailable (e.g. removed via disabled_tools): expose # everything rather than strand the cataloged tools behind a search # the model cannot invoke. return messages, tools, model visible = self._ctrl.visible_names() - out = [t for t in tools if t["function"]["name"] in visible] + out = [t for t in tools if (name := _schema_name(t)) is None or name in visible] return messages, out, model diff --git a/tests/test_agent_loop_mcp_sync.py b/tests/test_agent_loop_mcp_sync.py index e5fd1935c..64d83aad6 100644 --- a/tests/test_agent_loop_mcp_sync.py +++ b/tests/test_agent_loop_mcp_sync.py @@ -449,6 +449,39 @@ async def test_healthy_and_absent_managers_produce_no_notices(workspace) -> None assert loop._mcp_tool_notices() == [] +async def test_a_folded_catalog_names_its_connected_servers(workspace) -> None: + """A connected server is silent until the fold hides it. + + With the catalog folded the model has no inventory anywhere else -- nothing + in the prompt names an installed server -- so one that does not think to + search reports a capability it holds as missing.""" + from raven.config.schema import ToolSearchConfig + + loop = _loop( + workspace, + {"svc": MCPServerConfig(url="https://svc.test/mcp")}, + tool_search_config=ToolSearchConfig(enabled=True, compaction_threshold=0), + ) + with patch(_PATCH, new=_fake_connect(["search", "create"])): + await loop._connect_mcp() + + notes = loop._mcp_tool_notices() + assert len(notes) == 1, notes + assert "svc" in notes[0] and "2 tool(s)" in notes[0] + assert "tool_search" in notes[0] and "tool_call" in notes[0], "the line must name both routes" + + # The gate is the fold, not the connection: below the threshold those tools + # are in the array and the sentence would be false. + unfolded = _loop( + workspace, + {"svc": MCPServerConfig(url="https://svc.test/mcp")}, + tool_search_config=ToolSearchConfig(enabled=True, compaction_threshold=500), + ) + with patch(_PATCH, new=_fake_connect(["search", "create"])): + await unfolded._connect_mcp() + assert unfolded._mcp_tool_notices() == [] + + async def test_tool_notices_ride_the_runtime_context_block(workspace) -> None: from datetime import datetime diff --git a/tests/test_tool_search.py b/tests/test_tool_search.py index c0e37f386..a8ba730e5 100644 --- a/tests/test_tool_search.py +++ b/tests/test_tool_search.py @@ -405,6 +405,20 @@ async def test_strategy_passthrough_when_meta_tools_absent() -> None: assert {t["function"]["name"] for t in out} == {t["function"]["name"] for t in tools} +@pytest.mark.asyncio +async def test_an_unreadable_entry_passes_through_instead_of_raising() -> None: + # Replay feeds a recorded array back through this chain, so a corrupt + # recording arrives as an entry whose "function" is a string. That has to + # end at replay's own backstop as an unreplayable recording; a TypeError + # here ends the turn instead, and ends it somewhere that cannot say why. + reg, ctrl = _registry_with_n(40) + strat = ToolSearchStrategy(ctrl, compaction_threshold=25) + tools = [*reg.get_definitions(), {"function": "bad"}, "not-a-dict"] + _, out, _ = await strat.before_llm_call([], tools, "m") + assert {"function": "bad"} in out and "not-a-dict" in out, "what the fold cannot read, it keeps" + assert any(t.get("function", {}).get("name") == TOOL_CALL_NAME for t in out if isinstance(t, dict)) + + def test_registry_register_first_runs_before_others() -> None: from raven.contracts.token_strategy import TokenStrategy from raven.token_wise.registry import StrategyRegistry From 0a1adc31fe012d6c84d4a0989aab79eb1503376f Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:20:47 +0800 Subject: [PATCH 3/8] feat(config): fold a large tool catalog by default tools.tool_search.enabled ships on. It costs a deploy under the threshold nothing -- the strategy drops tool_search from every request while the catalog fits -- so the switch only starts deciding anything once a deploy carries more tools than one request should, which is where injecting every schema burns context that scales with tool count and moves the prompt-cache prefix on every MCP connect. The two prerequisites are in: neither meta-tool can be switched off, and a folded request now names the connected MCP servers whose schemas it withheld. "No config" stops being a third state: a host that wires none takes the schema's, which folds. The premise tests that read the old default -- the DAG controls' route, the token_wise socket's strategy list -- say so, and the controls' route is pinned in both states now that the switch has two. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/config/schema.py | 10 +++++- tests/test_agent_loop_tool_search.py | 10 ++++-- tests/test_core_runtime_swap.py | 9 ++++-- tests/test_subagent_dag_control_tools.py | 39 +++++++++++++++--------- 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/raven/config/schema.py b/raven/config/schema.py index bc0295e55..928769d66 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -1258,9 +1258,17 @@ class ToolSearchConfig(Base): there (``ToolRegistry.hide_from_schema``), and that has nothing to do with catalog size. Turning this off folds nothing; it does not take the name route away. + + On by default, which costs a deploy under the threshold nothing: the + strategy drops ``tool_search`` from every request while the catalog fits, so + the switch only starts deciding anything once a deploy has more tools than a + request should carry. Neither name can be taken away through + ``tools.disabled_tools`` -- their absence is how the fold reads "this request + has no search route", so an off switch there would unfold the array rather + than slim it. This setting is the one that speaks for both. """ - enabled: bool = False + enabled: bool = True compaction_threshold: int = 50 """Tool-catalog size that triggers compaction: at or below this many tools everything is exposed directly; above it, schemas are withheld.""" diff --git a/tests/test_agent_loop_tool_search.py b/tests/test_agent_loop_tool_search.py index 2b0cb2268..d1651bb45 100644 --- a/tests/test_agent_loop_tool_search.py +++ b/tests/test_agent_loop_tool_search.py @@ -99,10 +99,14 @@ def test_disabled_registers_no_search_but_keeps_tool_call(workspace) -> None: assert loop.tools.has("tool_call") -def test_none_config_registers_no_search_but_keeps_tool_call(workspace) -> None: +def test_none_config_takes_the_factory_default(workspace) -> None: + # A host that wires no tool-search config gets the schema's, and the schema + # ships the fold on -- so "no config" is the on case, not a third state. It + # costs a small catalog nothing: the strategy drops tool_search from every + # request until the catalog passes the threshold. loop = _make_loop(workspace, None) - assert not loop.tools.has("tool_search") - assert loop.strategies.get("tool_search") is None + assert loop.tools.has("tool_search") + assert loop.strategies.get("tool_search") is not None assert loop.tools.has("tool_call") diff --git a/tests/test_core_runtime_swap.py b/tests/test_core_runtime_swap.py index b649321a4..a1953eaec 100644 --- a/tests/test_core_runtime_swap.py +++ b/tests/test_core_runtime_swap.py @@ -263,8 +263,11 @@ def test_the_token_wise_socket_takes_the_strategy_the_config_names(tmp_path: Pat """token_wise is substitutable by configuration, not by instance: the door has no ``strategies=`` parameter and ``token_wise_stack.install_from_config`` is the only builder, so what a caller can do is name the strategies. A config - with cache optimisation on and usage tracking off yields a registry holding - exactly one CacheOptimizer, and the loop runs on that registry, not a copy.""" + with cache optimisation on and usage tracking off yields a registry whose only + token_wise member is a CacheOptimizer, and the loop runs on that registry, not a + copy. The fold ahead of it is not token_wise's to name: the loop inserts it first + so it filters the array before the breakpoint is marked on the last tool.""" + from raven.agent.tools.tool_search import ToolSearchStrategy from raven.core import runtime from raven.token_wise.cache_optimizer import CacheOptimizer @@ -276,7 +279,7 @@ def test_the_token_wise_socket_takes_the_strategy_the_config_names(tmp_path: Pat rt = runtime.build_runtime(config, ec_config, provider=_Provider()) assert rt.loop.strategies is rt.strategies - assert [type(s) for s in rt.loop.strategies.strategies] == [CacheOptimizer] + assert [type(s) for s in rt.loop.strategies.strategies] == [ToolSearchStrategy, CacheOptimizer] @pytest.mark.asyncio diff --git a/tests/test_subagent_dag_control_tools.py b/tests/test_subagent_dag_control_tools.py index cd720f938..aae3cb3a4 100644 --- a/tests/test_subagent_dag_control_tools.py +++ b/tests/test_subagent_dag_control_tools.py @@ -644,22 +644,31 @@ async def test_the_loop_registers_all_three_tools_outside_the_schema(workspace: async def test_a_default_loop_can_still_reach_the_hidden_tools(workspace: Path) -> None: - # Progressive disclosure is off by default, which used to take tool_call with - # it and leave all three controls advertised but unnameable -- a suspended - # node then waited out its whole adjudication timeout for a decision the - # model had no way to send. - loop = AgentLoop( - provider=_StubProvider(), - workspace=workspace, - model="stub", - # no tool_search_config: the default deploy - policy=TurnPolicy(max_iterations=2), - tools=ToolWiring(restrict_to_workspace=True), - ) + # tool_call used to ride the fold's switch, which left all three controls + # advertised but unnameable wherever the fold was off -- a suspended node + # then waited out its whole adjudication timeout for a decision the model + # had no way to send. Both states are pinned because the switch moved: the + # factory default now folds, and a deploy that turns it off must keep the + # route the bug was about. + def _loop(cfg) -> AgentLoop: + return AgentLoop( + provider=_StubProvider(), + workspace=workspace, + model="stub", + policy=TurnPolicy(max_iterations=2), + tools=ToolWiring(restrict_to_workspace=True, tool_search_config=cfg), + ) + + # No tool_search_config: the default deploy, which takes the schema's own. + default = _loop(None) + assert default.tools.has("tool_call") + assert default.strategies.get("tool_search") is not None, "the factory default folds" + assert default.dag_control_reachable() is True - assert loop.tools.has("tool_call") - assert loop.strategies.get("tool_search") is None, "the fold itself stays off" - assert loop.dag_control_reachable() is True + off = _loop(ToolSearchConfig(enabled=False)) + assert off.tools.has("tool_call") + assert off.strategies.get("tool_search") is None + assert off.dag_control_reachable() is True _HIDDEN = ("cancel_dag", "dag_status", "resolve_dag_node") From 99722c3a3e8d370408ef7809459d67829e197365 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:38:53 +0800 Subject: [PATCH 4/8] fix(agent): count what the fold withholds, not what the server registered The connected-server notice took its count from the manager snapshot, which counts every wrapper a server registered. Two of those are not hidden by the fold: one the operator switched off, which search() filters out and tool_call refuses, and one named in toolSearch.always_visible, whose schema is in the array already. A server with every tool switched off was announced as a searchable catalog that cannot answer -- the false capability metadata this notice exists to prevent, inverted. The count is now the server's names that this turn's array carries and the fold withholds, and a server with none of them gets no line. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/agent/loop/mcp_glue.py | 26 ++++++++++++++++++++------ tests/test_agent_loop_mcp_sync.py | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/raven/agent/loop/mcp_glue.py b/raven/agent/loop/mcp_glue.py index cb782fdca..88e155c42 100644 --- a/raven/agent/loop/mcp_glue.py +++ b/raven/agent/loop/mcp_glue.py @@ -189,6 +189,14 @@ def _mcp_tool_notices(self) -> list[str]: ``ToolSearchController.search_visible`` because below the fold those tools are in the array and the line would be false. + Its count is derived from what the fold actually withholds, not from the + manager's ``tool_count``: that one counts every wrapper the server + registered, including one the operator switched off (``search`` filters + it out and ``tool_call`` refuses it, so promising it is the false + capability metadata this line exists to prevent) and one named in + ``toolSearch.always_visible`` (whose schema is in the array, so it is + not hidden at all). A server with nothing withheld gets no line. + Rendered into the runtime-context block, not the system prompt -- the set changes turn to turn and must never be cached with the prefix. """ @@ -204,6 +212,10 @@ def _mcp_tool_notices(self) -> list[str]: # capability. controller = getattr(self, "tool_search_controller", None) folded = controller is not None and controller.search_visible() + withheld_by_fold: set[str] = set() + if folded: + shipped = {d["function"]["name"] for d in self.tools.get_definitions()} + withheld_by_fold = shipped - controller.visible_names() notices = [] for snap in mgr.status(): if not snap.get("enabled", True): @@ -220,12 +232,14 @@ def _mcp_tool_notices(self) -> list[str]: f"from this turn's definitions and register themselves when the handshake " f"finishes, so they are available from a later turn without anyone acting." ) - elif folded and snap["connected"] and snap["tool_count"]: - notices.append( - f"MCP plugin '{snap['name']}': connected, {snap['tool_count']} tool(s). Their " - f"schemas are not in this turn's definitions because the catalog is folded -- " - f"tool_search finds them by keyword, tool_call invokes them by name." - ) + elif folded and snap["connected"]: + hidden = [n for n in self.tools.names_from(snap["name"]) if n in withheld_by_fold] + if hidden: + notices.append( + f"MCP plugin '{snap['name']}': connected, {len(hidden)} tool(s). Their " + f"schemas are not in this turn's definitions because the catalog is folded " + f"-- tool_search finds them by keyword, tool_call invokes them by name." + ) return notices def prewarm_mcp(self) -> None: diff --git a/tests/test_agent_loop_mcp_sync.py b/tests/test_agent_loop_mcp_sync.py index 64d83aad6..6dbb99025 100644 --- a/tests/test_agent_loop_mcp_sync.py +++ b/tests/test_agent_loop_mcp_sync.py @@ -482,6 +482,33 @@ async def test_a_folded_catalog_names_its_connected_servers(workspace) -> None: assert unfolded._mcp_tool_notices() == [] +async def test_the_count_is_what_the_fold_withholds_not_what_the_server_registered(workspace) -> None: + """A switched-off tool is not findable, so counting it promises a capability + the model cannot reach -- the false metadata this line exists to prevent.""" + from raven.config.schema import ToolSearchConfig + + one_off = _loop( + workspace, + {"svc": MCPServerConfig(url="https://svc.test/mcp")}, + tool_search_config=ToolSearchConfig(enabled=True, compaction_threshold=0), + disabled_tools=["mcp_svc_create"], + ) + with patch(_PATCH, new=_fake_connect(["search", "create"])): + await one_off._connect_mcp() + notes = one_off._mcp_tool_notices() + assert len(notes) == 1 and "1 tool(s)" in notes[0], notes + + all_off = _loop( + workspace, + {"svc": MCPServerConfig(url="https://svc.test/mcp")}, + tool_search_config=ToolSearchConfig(enabled=True, compaction_threshold=0), + disabled_tools=["mcp_svc_search", "mcp_svc_create"], + ) + with patch(_PATCH, new=_fake_connect(["search", "create"])): + await all_off._connect_mcp() + assert all_off._mcp_tool_notices() == [], "a server with nothing withheld gets no line" + + async def test_tool_notices_ride_the_runtime_context_block(workspace) -> None: from datetime import datetime From 1bd5d68597c2ce5f27ff6941a6bf0df79bc4c97d Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:16:44 +0800 Subject: [PATCH 5/8] test(rpc): pin the fold off where the absent meta-tool row is exercised The case is about a tool_search the loop never registered still reaching the settings page, and it asserted the fixture was in that state. With the fold shipping on, the fixture no longer was -- so it now switches the fold off rather than leaning on the default, and the guard assertion keeps standing. Co-authored-by: Claude (claude-opus-5[1m]) --- tests/test_rpc_console.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/test_rpc_console.py b/tests/test_rpc_console.py index f15f7efd6..505aafe74 100644 --- a/tests/test_rpc_console.py +++ b/tests/test_rpc_console.py @@ -1541,7 +1541,7 @@ async def test_a_rejected_language_changes_nothing(tmp_path, monkeypatch) -> Non # --------------------------------------------------------------------------- -def _console_loop(workspace: Path, monkeypatch: pytest.MonkeyPatch, raw: dict): +def _console_loop(workspace: Path, monkeypatch: pytest.MonkeyPatch, raw: dict, **extra): """A real on-disk config, and a loop assembled from it the production way. Both halves matter. A hand-built stand-in is what let this defect hide: an @@ -1572,7 +1572,7 @@ def _console_loop(workspace: Path, monkeypatch: pytest.MonkeyPatch, raw: dict): provider=_StubProvider(), workspace=workspace, model="stub", - **wire(media_config=config.effective_media_config(), **kw), + **wire(media_config=config.effective_media_config(), **kw, **extra), ) # No withheld source installed here on purpose: AgentLoop installs its own, # which reads the live config -- the file above -- and unions the off switch @@ -2014,15 +2014,19 @@ async def test_ext_list_reports_tool_search_even_when_the_fold_is_off( """A meta-tool the loop skipped is still the page's to draw. ``tool_search`` is registered only when progressive tool disclosure is on - (``tools.toolSearch.enabled``, off by default); ``tool_call`` is registered - either way. Reporting only what the registry holds left a default install - with a card named after tool search containing the one meta-tool that is - not tool search, and nothing said the feature existed. The same reasoning - as ``_gated_tools``: absent from the list reads as deleted. + (``tools.toolSearch.enabled``, which ships on); ``tool_call`` is registered + either way. Reporting only what the registry holds left an install that + turned the fold off with a card named after tool search containing the one + meta-tool that is not tool search, and nothing said the feature existed. The + same reasoning as ``_gated_tools``: absent from the list reads as deleted. + + The fold is switched off here rather than left to the default, which is what + puts the tool in the absent state this case is about. """ from raven.agent.tools.tool_search import TOOL_CALL_NAME, TOOL_SEARCH_NAME + from raven.config.schema import ToolSearchConfig - loop = _console_loop(tmp_path, monkeypatch, {"providers": {}}) + loop = _console_loop(tmp_path, monkeypatch, {"providers": {}}, tool_search_config=ToolSearchConfig(enabled=False)) assert TOOL_SEARCH_NAME not in loop.tools.tool_names, ( "the fold is on in this fixture, so the absent case below is not being exercised" ) From e7ffd8e4c4034f2ffeafe44b7465f7c1cf674464 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:34:39 +0800 Subject: [PATCH 6/8] fix(agent): let the mcp notice state an inventory and promise no route The line told the model its schemas were folded away and named tool_search and tool_call as the way back. Neither claim is the runtime-context block's to make: it is rendered once per turn, while the fold is decided per model call against an array a before_iteration hook may narrow first (HookDecision.modified_tools) -- drop the meta-pair there and the strategy ships every schema, leaving the block directing the model to tools the request does not carry. What survives that timing is an inventory: this server is connected and offers N tools. The count is the server's tools this turn's array carries, so a switched -off one is still out of it, and the fold gate stays as noise control rather than as a claim. Co-authored-by: Claude (claude-opus-5[1m]) --- raven/agent/loop/mcp_glue.py | 46 +++++++++++++++---------------- tests/test_agent_loop_mcp_sync.py | 13 +++++---- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/raven/agent/loop/mcp_glue.py b/raven/agent/loop/mcp_glue.py index 88e155c42..934679996 100644 --- a/raven/agent/loop/mcp_glue.py +++ b/raven/agent/loop/mcp_glue.py @@ -181,21 +181,24 @@ def _mcp_tool_notices(self) -> list[str]: that overtakes it, which since ``prewarm_mcp`` is the ordinary shape of a first turn rather than a rarity: the turn no longer waits, so it can be assembled while servers are still coming up. - * ``connected`` while the catalog is folded -- the schemas are withheld on - purpose, and nothing else in the prompt names an installed server, so a - model that does not think to search reports a capability it holds as - missing. ``tool_search``'s own description says a catalog exists; only - this says what is in it. Gated on - ``ToolSearchController.search_visible`` because below the fold those - tools are in the array and the line would be false. - - Its count is derived from what the fold actually withholds, not from the + * ``connected`` while the catalog is folded -- nothing else in the prompt + names an installed server, so a model that does not think to search + reports a capability it holds as missing. ``tool_search``'s own + description says a catalog exists; only this says what is in it. + + This line states an inventory and promises no route, which is what keeps + it true. This block is rendered once per turn, while the fold is decided + per model call against an array a ``before_iteration`` hook may narrow + first (``HookDecision.modified_tools``) -- so a turn-level sentence about + what this request folded, or about reaching it through ``tool_search`` / + ``tool_call``, can be falsified after it is written. "This server is + connected and offers N tools" cannot. + + The count is the server's tools this turn's array carries, not the manager's ``tool_count``: that one counts every wrapper the server - registered, including one the operator switched off (``search`` filters - it out and ``tool_call`` refuses it, so promising it is the false - capability metadata this line exists to prevent) and one named in - ``toolSearch.always_visible`` (whose schema is in the array, so it is - not hidden at all). A server with nothing withheld gets no line. + registered, including one the operator switched off, which no surface + offers. The fold gate is only about noise -- below it every schema is in + the array and the line tells the model nothing it cannot see. Rendered into the runtime-context block, not the system prompt -- the set changes turn to turn and must never be cached with the prefix. @@ -212,10 +215,9 @@ def _mcp_tool_notices(self) -> list[str]: # capability. controller = getattr(self, "tool_search_controller", None) folded = controller is not None and controller.search_visible() - withheld_by_fold: set[str] = set() + offered: set[str] = set() if folded: - shipped = {d["function"]["name"] for d in self.tools.get_definitions()} - withheld_by_fold = shipped - controller.visible_names() + offered = {d["function"]["name"] for d in self.tools.get_definitions()} notices = [] for snap in mgr.status(): if not snap.get("enabled", True): @@ -233,13 +235,9 @@ def _mcp_tool_notices(self) -> list[str]: f"finishes, so they are available from a later turn without anyone acting." ) elif folded and snap["connected"]: - hidden = [n for n in self.tools.names_from(snap["name"]) if n in withheld_by_fold] - if hidden: - notices.append( - f"MCP plugin '{snap['name']}': connected, {len(hidden)} tool(s). Their " - f"schemas are not in this turn's definitions because the catalog is folded " - f"-- tool_search finds them by keyword, tool_call invokes them by name." - ) + mine = [n for n in self.tools.names_from(snap["name"]) if n in offered] + if mine: + notices.append(f"MCP plugin '{snap['name']}': connected, offering {len(mine)} tool(s).") return notices def prewarm_mcp(self) -> None: diff --git a/tests/test_agent_loop_mcp_sync.py b/tests/test_agent_loop_mcp_sync.py index 6dbb99025..4eb7b1de0 100644 --- a/tests/test_agent_loop_mcp_sync.py +++ b/tests/test_agent_loop_mcp_sync.py @@ -468,7 +468,10 @@ async def test_a_folded_catalog_names_its_connected_servers(workspace) -> None: notes = loop._mcp_tool_notices() assert len(notes) == 1, notes assert "svc" in notes[0] and "2 tool(s)" in notes[0] - assert "tool_search" in notes[0] and "tool_call" in notes[0], "the line must name both routes" + # No route named on purpose: this block is rendered once per turn, and what + # the request finally carries is decided per model call, after a hook may + # have narrowed the array. An inventory survives that; a promise does not. + assert "tool_search" not in notes[0] and "tool_call" not in notes[0] # The gate is the fold, not the connection: below the threshold those tools # are in the array and the sentence would be false. @@ -482,9 +485,9 @@ async def test_a_folded_catalog_names_its_connected_servers(workspace) -> None: assert unfolded._mcp_tool_notices() == [] -async def test_the_count_is_what_the_fold_withholds_not_what_the_server_registered(workspace) -> None: - """A switched-off tool is not findable, so counting it promises a capability - the model cannot reach -- the false metadata this line exists to prevent.""" +async def test_the_count_is_what_the_array_carries_not_what_the_server_registered(workspace) -> None: + """A switched-off tool is on no surface at all, so counting it advertises a + capability the model cannot reach however the request is finally assembled.""" from raven.config.schema import ToolSearchConfig one_off = _loop( @@ -506,7 +509,7 @@ async def test_the_count_is_what_the_fold_withholds_not_what_the_server_register ) with patch(_PATCH, new=_fake_connect(["search", "create"])): await all_off._connect_mcp() - assert all_off._mcp_tool_notices() == [], "a server with nothing withheld gets no line" + assert all_off._mcp_tool_notices() == [], "a server offering nothing gets no line" async def test_tool_notices_ride_the_runtime_context_block(workspace) -> None: From f339b7ce1f95bb6a898b55ecb13b3cc7e310a950 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:12:54 +0800 Subject: [PATCH 7/8] fix(agents): let the packaged products take the fold default They pinned tools.toolSearch.enabled to false, which was written to keep four curated faces byte-identical while the default flipped. That reasoning only holds while their catalogs stay small, and it is not their config that decides the size: each serves over `raven acp`, so a dispatcher can hand it per-session MCP servers, and an operator can add tools.mcpServers at any time. The pin would have held the fold open at exactly the catalog size it exists for. The faces record tool_search instead. It registers with the default and the strategy drops it from every request while the catalog fits, so it is in the registry these fixtures read and in no request the model sees. Co-authored-by: Claude (claude-opus-5[1m]) --- agents/raven-code/config.json | 3 --- agents/raven-oncall/config.json | 3 --- agents/raven-ppt/config.json | 3 --- agents/raven-research/config.json | 3 --- tests/test_agents_code_launcher.py | 19 ++++++++------ tests/test_agents_oncall_launcher.py | 1 + tests/test_agents_ppt_launcher.py | 34 ++++++++------------------ tests/test_agents_research_launcher.py | 1 + 8 files changed, 23 insertions(+), 44 deletions(-) diff --git a/agents/raven-code/config.json b/agents/raven-code/config.json index 7043d7b42..e869b1a17 100644 --- a/agents/raven-code/config.json +++ b/agents/raven-code/config.json @@ -35,9 +35,6 @@ "maxResults": 5 } }, - "toolSearch": { - "enabled": false - }, "disabledTools": [ "spawn", "message", diff --git a/agents/raven-oncall/config.json b/agents/raven-oncall/config.json index c99002247..e10c8eab7 100644 --- a/agents/raven-oncall/config.json +++ b/agents/raven-oncall/config.json @@ -34,9 +34,6 @@ "maxResults": 5 } }, - "toolSearch": { - "enabled": false - }, "disabledTools": [ "spawn", "cron", diff --git a/agents/raven-ppt/config.json b/agents/raven-ppt/config.json index 1a1268825..3a2a5e1bc 100644 --- a/agents/raven-ppt/config.json +++ b/agents/raven-ppt/config.json @@ -80,9 +80,6 @@ "maxResults": 10 } }, - "toolSearch": { - "enabled": false - }, "disabledTools": [ "create_playbook", "cron", diff --git a/agents/raven-research/config.json b/agents/raven-research/config.json index a0dfed63c..25df8ffb0 100644 --- a/agents/raven-research/config.json +++ b/agents/raven-research/config.json @@ -123,9 +123,6 @@ "maxResults": 5 } }, - "toolSearch": { - "enabled": false - }, "disabledTools": [ "exec", "spawn", diff --git a/tests/test_agents_code_launcher.py b/tests/test_agents_code_launcher.py index 7e8bd6d06..c7c52851f 100644 --- a/tests/test_agents_code_launcher.py +++ b/tests/test_agents_code_launcher.py @@ -860,14 +860,16 @@ def test_a_relative_state_root_resolves_under_the_host_agent_home(grounded, tmp_ "use_skill", } -#: The one trunk name this product cannot hold out. Raven reserves the -#: tool-search meta-pair from ``tools.disabledTools``: the fold reads their -#: absence from an array as "this request has no search route" and answers by -#: shipping every schema, so an off switch there would unfold the array rather -#: than slim it. ``tool_call`` registers whatever the fold is doing and joins -#: the face; ``tool_search`` follows ``tools.toolSearch.enabled``, off here, so -#: it does not. -TRUNK_RESERVED = {"tool_call"} +#: Two names this product's config no longer decides. ``tool_call`` is reserved +#: from ``tools.disabledTools``: its absence from an array is how the fold reads +#: "this request has no search route", so an off switch there would unfold the +#: array rather than slim it. ``tool_search`` registers with the shipped default +#: -- the fold is on, and this face sits far below the threshold, so the strategy +#: drops it from every request; it is in the registry the fixture reads and in no +#: request the model sees. Neither is pinned off here on purpose: an operator or +#: a dispatcher can attach MCP servers to this product at runtime, and pinning +#: the fold off would hold it open at exactly the size it exists for. +TRUNK_RESERVED = {"tool_call", "tool_search"} #: The product's visible tool face, hermetically rebuilt from the render: #: the fork's config intent minus the ledgered pending waves, plus the @@ -887,6 +889,7 @@ def test_a_relative_state_root_resolves_under_the_host_agent_home(grounded, tmp_ "read_skill", "todo", "tool_call", + "tool_search", "use_skill", "web_fetch", "write_file", diff --git a/tests/test_agents_oncall_launcher.py b/tests/test_agents_oncall_launcher.py index 1e8f554d9..29efcc7c7 100644 --- a/tests/test_agents_oncall_launcher.py +++ b/tests/test_agents_oncall_launcher.py @@ -309,6 +309,7 @@ def test_a_preexisting_own_registry_stays(grounded, tmp_path): "ops_tune_status", "read_file", "tool_call", + "tool_search", "web_fetch", "write_file", } diff --git a/tests/test_agents_ppt_launcher.py b/tests/test_agents_ppt_launcher.py index e66a82e75..55fdddefa 100644 --- a/tests/test_agents_ppt_launcher.py +++ b/tests/test_agents_ppt_launcher.py @@ -216,14 +216,6 @@ def test_the_roster_row_is_the_vendored_twins_modulo_the_ledgered_deltas(): } -# tools rows the sealed fork's config does not carry: trunk folds a catalog -# past tools.toolSearch.compactionThreshold by default, and this product opts -# out. Its face is about twenty tools, so the fold could never trigger -- all -# the default would do is register a tool_search the product can no longer -# disable (raven reserves the meta-pair), and a curated face does not carry a -# tool it will never ship. -TRUNK_ONLY_TOOLS = {"toolSearch": {"enabled": False}} - # Engine-slice keys the fork's tools.ppt schema never had. The second reader's own # reasoning effort: the fork read every page at the author's setting, which litellm # dropped for this model anyway, so a GLM thought at its default for 100 to 350 seconds @@ -260,9 +252,6 @@ def test_the_config_is_the_forks_modulo_the_swap_ledger(): fork_tools = dict(theirs["tools"]) retired = fork_tools.pop("ppt") assert retired == {**slice_, **TRUNK_DROPPED_SLICE} - for key, value in TRUNK_ONLY_TOOLS.items(): - assert ours["tools"].pop(key) == value, key - assert key not in fork_tools, f"{key} is the fork's now; the ledger row is stale" ours_disabled = set(ours["tools"].pop("disabledTools")) fork_disabled = set(fork_tools.pop("disabledTools")) assert ours_disabled - fork_disabled == TRUNK_HELD_OUT @@ -1036,19 +1025,16 @@ def test_the_state_root_override_wins_and_the_default_sits_under_the_home(ground "ppt_review", } -#: The product's visible tool face, hermetically rebuilt from the render: the -#: fork's config intent plus the deck tools as plugin contributions. The -#: key-gated pair (web_search from the merged tools.web slot, ppt_image_search -#: from the rendered slice key) joins only when a Serper key is present -- the -#: fork's own refusal to register keyless search. Every trunk-new name is held -#: out by the TRUNK_HELD_OUT config rows pinned above. -#: -#: TRUNK_RESERVED is the exception those rows cannot cover: raven reserves the -#: tool-search meta-pair from tools.disabledTools, because the fold reads their -#: absence from an array as "this request has no search route" and answers by -#: shipping every schema. tool_call registers whatever the fold is doing and so -#: joins the face; tool_search follows tools.toolSearch.enabled, off here. -TRUNK_RESERVED = {"tool_call"} +#: Two names this product's config no longer decides. ``tool_call`` is reserved +#: from ``tools.disabledTools``: its absence from an array is how the fold reads +#: "this request has no search route", so an off switch there would unfold the +#: array rather than slim it. ``tool_search`` registers with the shipped default +#: -- the fold is on, and this face sits far below the threshold, so the strategy +#: drops it from every request; it is in the registry the fixture reads and in no +#: request the model sees. Neither is pinned off here on purpose: an operator or +#: a dispatcher can attach MCP servers to this product at runtime, and pinning +#: the fold off would hold it open at exactly the size it exists for. +TRUNK_RESERVED = {"tool_call", "tool_search"} VENDORED_TOOL_FACE = FORK_CONFIG_INTENT | DECK_TOOLS | TRUNK_RESERVED KEY_GATED = {"web_search", "ppt_image_search"} diff --git a/tests/test_agents_research_launcher.py b/tests/test_agents_research_launcher.py index 991b758cb..a0b291166 100644 --- a/tests/test_agents_research_launcher.py +++ b/tests/test_agents_research_launcher.py @@ -41,6 +41,7 @@ "web_fetch", "ask_user", "tool_call", + "tool_search", } REPO = Path(__file__).resolve().parent.parent From b15a353851c7ca54369c651075c3697962d2fec7 Mon Sep 17 00:00:00 2001 From: gloryfromca <23442919+gloryfromca@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:20:30 +0800 Subject: [PATCH 8/8] fix(trajectory): keep the replay harness off the fold default A replay reproduces a request; it does not assemble one. The harness builds its loop with no tool-search config, so the new default reached it -- and a recording made above the threshold already IS the folded array, core plus the meta-pair. Served through the strategy again it counts as a catalog under the threshold, tool_search is dropped from what the replay sends, and every strict regression case halts on a divergence in the offered tool names before the first recorded reply. Measured through the harness's own construction: 11 tools live against 12 recorded, tool_search missing. The harness now spells the switch out, which is what it always effectively had, and a case replays a recording carrying tool_search so the next default that leaks in is caught here rather than by a reader. Two more from the same review. The reserved-tool warning offered one remedy for two names that are not owned the same way: turning the fold off does stop tool_search registering and leaves tool_call exactly where it was, so an operator who wrote tool_call was sent to a setting that changes nothing for it. It now answers per name. And the changelog records both user-visible changes: the fold shipping on, and disabledTools no longer reaching the pair. Co-authored-by: Claude (claude-opus-5[1m]) --- CHANGELOG.md | 13 +++++++++++ raven/agent/loop/wiring.py | 21 +++++++++++++---- raven/trajectory/replay.py | 12 ++++++++++ tests/test_agent_loop_tool_search.py | 21 +++++++++++++++++ tests/test_trajectory_replay.py | 35 ++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3607010c8..fe8c4c985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,19 @@ All notable changes to Raven are documented here. ### Changed +- Progressive tool disclosure ships on (`tools.toolSearch.enabled`). Below + `compactionThreshold` (50) nothing changes: the strategy drops `tool_search` + from every request while the catalog fits. Above it, most tool schemas are + withheld and reached through `tool_search` / `tool_call`, so context stops + scaling with tool count and an MCP connect no longer moves the cached prompt + prefix. Set `enabled: false` to keep the old shape. + +- `tools.disabledTools` can no longer take `tool_search` or `tool_call` away. + Their absence from a request is how the fold reads "no search route", so an + entry there did not slim a folded request, it unfolded it. An entry naming + either is reported once at startup and ignored; `tools.toolSearch.enabled` is + the switch that speaks for the pair. + - `ppt_generate_image` no longer answers a repeat ask from the file the last ask wrote. The tool exists for "that picture is not what I wanted, do it again", and that ask carries the same words as the first one, so the cache diff --git a/raven/agent/loop/wiring.py b/raven/agent/loop/wiring.py index 19dd69ce2..3dd18a7a8 100644 --- a/raven/agent/loop/wiring.py +++ b/raven/agent/loop/wiring.py @@ -100,7 +100,7 @@ def _report_reserved_disabled_tools(self) -> None: is about the entry the operator can actually see -- most of them are in the config file, which is no longer copied into ``_disabled_tools``. """ - from raven.agent.tools.tool_search import META_TOOL_NAMES + from raven.agent.tools.tool_search import META_TOOL_NAMES, TOOL_SEARCH_NAME from raven.config.live import disabled_tool_names from raven.mcp.prompts import PROMPT_TOOL_NAMES from raven.mcp.resources import RESOURCE_TOOL_NAMES @@ -123,12 +123,23 @@ def _report_reserved_disabled_tools(self) -> None: ) elif any(name in META_TOOL_NAMES for name in resolved): self._disabled_tools_reserved_warned.add(entry) + # One remedy per name, because the two are not owned the same + # way: tools.tool_search.enabled decides whether tool_search is + # registered at all, while tool_call is registered whatever that + # switch says -- it is the only route to a schema-hidden tool. + # A shared "turn the fold off" line sent an operator who wrote + # tool_call here to a setting that leaves it exactly where it was. + remedy = ( + "turn the fold off with tools.tool_search.enabled, which is what registers it" + if TOOL_SEARCH_NAME in resolved + else "this deploy has no switch for it: it is the only route to a tool whose " + "schema is not in the array, so it is registered whatever the fold is doing" + ) logger.warning( - "tools.disabled_tools names '{}', which raven owns for the life of the loop: " - "tool_call is the only route to a tool whose schema is not in the array, and " - "tool_search follows tools.tool_search.enabled. The entry has no effect; " - "remove it, or turn the fold off with that setting.", + "tools.disabled_tools names '{}', which raven owns for the life of the loop. " + "The entry has no effect; remove it, or {}.", entry, + remedy, ) def _withheld_tool_names(self) -> frozenset[str]: diff --git a/raven/trajectory/replay.py b/raven/trajectory/replay.py index 66d524608..c825fad7e 100644 --- a/raven/trajectory/replay.py +++ b/raven/trajectory/replay.py @@ -1070,6 +1070,7 @@ async def run_replay(bundle_dir: Path, mode: str = "warn") -> ReplayReport: from raven.agent.loop import AgentLoop from raven.agent.loop.bundles import ToolWiring + from raven.config.schema import ToolSearchConfig from raven.session.manager import SessionManager from raven.spine.message import ChatType, Source from raven.spine.turn import Origin, TurnRequest @@ -1109,6 +1110,17 @@ async def _drop_delta(_text: str) -> None: session_manager=sessions, tools=ToolWiring( restrict_to_workspace=True, + # A replay reproduces a request; it does not assemble one. + # The fold ships on, and a recording made above the + # threshold already IS the folded array -- core plus the + # meta-pair -- so serving it through the strategy again + # counts a catalog under the threshold and drops + # ``tool_search`` from what the replay sends. That reads as + # a divergence in the offered tool names, which halts every + # strict regression case before the first recorded reply. + # Spelled out rather than inherited: this harness is a + # deliberate bypass, not a deployment. + tool_search_config=ToolSearchConfig(enabled=False), ), ) # The loop's ContextBuilder starts the skill file watcher, a diff --git a/tests/test_agent_loop_tool_search.py b/tests/test_agent_loop_tool_search.py index d1651bb45..05409c85a 100644 --- a/tests/test_agent_loop_tool_search.py +++ b/tests/test_agent_loop_tool_search.py @@ -157,6 +157,27 @@ async def test_tool_call_does_not_name_tool_search_when_it_is_absent(workspace) assert "tool_search lists what is currently loaded" in out +def test_the_reserved_warning_names_the_remedy_each_tool_actually_has(workspace) -> None: + # The two are not owned the same way: turning the fold off does stop + # tool_search registering, and leaves tool_call exactly where it was. A + # shared remedy sent an operator who wrote tool_call to a setting that + # changes nothing for it. + from loguru import logger + + records: list[str] = [] + sink_id = logger.add(lambda message: records.append(message.record["message"]), level="WARNING") + try: + _make_loop(workspace, ToolSearchConfig(enabled=True), disabled_tools=["tool_search", "tool_call"]) + finally: + logger.remove(sink_id) + lines = { # one line per entry, keyed by the name it names + name: next((text for text in records if f"names '{name}'" in text), "") for name in ("tool_search", "tool_call") + } + assert "tools.tool_search.enabled" in lines["tool_search"], lines["tool_search"] + assert "tools.tool_search.enabled" not in lines["tool_call"], lines["tool_call"] + assert "no switch" in lines["tool_call"], lines["tool_call"] + + @pytest.mark.asyncio async def test_disabled_tools_cannot_withhold_the_meta_tools(workspace) -> None: # The off switch reaches every other name, but not these two. The strategy diff --git a/tests/test_trajectory_replay.py b/tests/test_trajectory_replay.py index d68dbf8cf..7e303ee44 100644 --- a/tests/test_trajectory_replay.py +++ b/tests/test_trajectory_replay.py @@ -580,6 +580,41 @@ async def test_run_replay_drives_the_loop_and_executes_nothing(tmp_path, monkeyp assert trace.enabled(), "suppression must not outlive the replay" +async def test_run_replay_serves_a_folded_recording_unchanged(tmp_path) -> None: + """A replay reproduces a request; it does not assemble one. + + The fold ships on, and a recording made above the threshold already IS the + folded array -- the always-visible core plus the meta-pair. Served through a + strategy that takes deployment defaults, that array counts as a catalog + under the threshold, ``tool_search`` is dropped from what the replay sends, + and every strict regression case halts on a divergence in the offered tool + names before the first recorded reply. The harness pins the fold off, and + this recording carries ``tool_search`` so the next default that leaks in is + caught here rather than by a reader. + """ + folded = [ + {"type": "function", "function": {"name": name, "parameters": {"type": "object", "properties": {}}}} + for name in ("read_file", "grep", "exec", "message", "ask_user", "tool_search", "tool_call") + ] + bundle = _make_bundle( + tmp_path, + llm_calls=[({"model": "stub", "messages": [], "tools": folded}, _llm_output(content="done"))], + turns=[{"content": "go", "channel": "cli", "chat_id": "direct"}], + ) + + report = await run_replay(bundle, mode="warn") + + # The report records the offered names, which is the field compare_llm_request + # diverges on. + assert report.llm_requests[0]["tools"] == [t["function"]["name"] for t in folded], ( + "the replay must send the recorded array" + ) + # warn, and the assertion is on the field rather than on completeness: this + # hand-built recording carries no rendered messages, so it diverges there by + # construction. The tools field is the one this case is about. + assert [d for d in report.divergences if d.field == "tools"] == [] + + async def test_run_replay_leaves_no_skill_watcher_threads(tmp_path) -> None: """The loop's ContextBuilder starts a skill file watcher; a replay must stop it, or repeated probes leak daemon threads that crash the process at