diff --git a/agent/agent_init.py b/agent/agent_init.py index 495260d21886..da9d5746de1a 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1177,6 +1177,38 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: elif not agent.quiet_mode: print("🛠️ No tools loaded (all tools filtered out or unavailable)") + # ``valid_tool_names`` deliberately mirrors the model-visible schemas in + # ``agent.tools``. Tool Search can hide explicitly opted-in core tools + # behind its bridge, but those tools are still available to this session: + # prompt guidance and direct-call routing must not mistake schema deferral + # for toolset filtering. Only pay for a second (memoized) catalog read + # when the bridge proves assembly activated. + agent.available_tool_names = set(agent.valid_tool_names) + agent.deferred_tool_names = set() + try: + from tools.tool_search import BRIDGE_TOOL_NAMES + + if BRIDGE_TOOL_NAMES <= agent.valid_tool_names: + _available_defs = _ra().get_tool_definitions( + enabled_toolsets=enabled_toolsets, + disabled_toolsets=disabled_toolsets, + quiet_mode=True, + skip_tool_search_assembly=True, + ) or [] + _preassembly_tool_names = { + tool["function"]["name"] for tool in _available_defs + } + agent.available_tool_names = ( + _preassembly_tool_names | agent.valid_tool_names + ) + agent.deferred_tool_names = ( + _preassembly_tool_names - agent.valid_tool_names + ) + except Exception: + # Tool discovery is fail-soft throughout initialization. Falling back + # to the visible set preserves the pre-Tool-Search behavior. + pass + # Kanban worker/orchestrator lifecycle guidance is session-static: # the dispatcher decides at spawn time whether this process is a kanban # worker (kanban_show tool is present iff HERMES_KANBAN_TASK is set). @@ -1185,7 +1217,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: # (init + each context compression). from agent.prompt_builder import KANBAN_GUIDANCE agent._kanban_worker_guidance = ( - KANBAN_GUIDANCE if "kanban_show" in agent.valid_tool_names else "" + KANBAN_GUIDANCE if "kanban_show" in agent.available_tool_names else "" ) # Check tool requirements @@ -1925,6 +1957,7 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None: _wrapped = {"type": "function", "function": _schema} agent.tools.append(_wrapped) agent.valid_tool_names.add(_tname) + agent.available_tool_names.add(_tname) agent._context_engine_tool_names.add(_tname) _existing_tool_names.add(_tname) diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index cbcb85617013..97880ad2a3db 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4425,24 +4425,32 @@ def _perform_api_call(next_api_kwargs): for tc in assistant_message.tool_calls: logging.debug(f"Tool call: {tc.function.name} with args: {tc.function.arguments[:200]}...") + # Deferred schemas remain callable by name. This is an + # intentional direct-call route for runtime-owned core tools + # whose stable prompt guidance can name them even while Tool + # Search keeps their schemas out of the model-visible prefix. + callable_tool_names = set(agent.valid_tool_names) | set( + getattr(agent, "available_tool_names", ()) + ) + # Validate tool call names - detect model hallucinations # Repair mismatched tool names before validating for tc in assistant_message.tool_calls: - if tc.function.name not in agent.valid_tool_names: + if tc.function.name not in callable_tool_names: repaired = agent._repair_tool_call(tc.function.name) if repaired: print(f"{agent.log_prefix}🔧 Auto-repaired tool name: '{tc.function.name}' -> '{repaired}'") tc.function.name = repaired invalid_tool_calls = [ tc.function.name for tc in assistant_message.tool_calls - if tc.function.name not in agent.valid_tool_names + if tc.function.name not in callable_tool_names ] if invalid_tool_calls: # Track retries for invalid tool calls agent._invalid_tool_retries += 1 # Return helpful error to model — model can agent-correct next turn - available = ", ".join(sorted(agent.valid_tool_names)) + available = ", ".join(sorted(callable_tool_names)) invalid_name = invalid_tool_calls[0] invalid_preview = invalid_name[:80] + "..." if len(invalid_name) > 80 else invalid_name agent._buffer_vprint(f"⚠️ Unknown tool '{invalid_preview}' — sending error to model for agent-correction ({agent._invalid_tool_retries}/3)") @@ -4466,7 +4474,7 @@ def _perform_api_call(next_api_kwargs): messages.append(assistant_msg) for tc in assistant_message.tool_calls: _tc_name = tc.function.name - if _tc_name not in agent.valid_tool_names: + if _tc_name not in callable_tool_names: # A blank/whitespace-only name is not a typo the # model can fuzzy-correct toward a real tool — it is # almost always a weak open model echoing tool-call diff --git a/agent/memory_manager.py b/agent/memory_manager.py index c8b80a1514e2..a2eb849526b8 100644 --- a/agent/memory_manager.py +++ b/agent/memory_manager.py @@ -139,6 +139,9 @@ def inject_memory_provider_tools(agent: Any) -> int: continue tools.append({"type": "function", "function": schema}) valid_tool_names.add(tool_name) + available_tool_names = getattr(agent, "available_tool_names", None) + if available_tool_names is not None: + available_tool_names.add(tool_name) existing_tool_names.add(tool_name) added += 1 diff --git a/agent/system_prompt.py b/agent/system_prompt.py index b9b26e07abcb..04949ed9212b 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -132,6 +132,13 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # patch ``run_agent.get_toolset_for_tool`` and similar helpers, so # we resolve through ``_ra()`` to honor those patches. _r = _ra() + # Tool Search may defer schemas without removing the underlying tools from + # the session. Capability guidance must follow availability, not only the + # smaller model-visible schema set. Agents built by older/test call paths + # retain the historical behavior through this fallback. + available_tool_names = set(agent.valid_tool_names) | set( + getattr(agent, "available_tool_names", ()) + ) # Resolve the model's context window once so context-file caps can scale # to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars). @@ -186,11 +193,11 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # Tool-aware behavioral guidance: only inject when the tools are loaded tool_guidance = [] - if "memory" in agent.valid_tool_names: + if "memory" in available_tool_names: tool_guidance.append(MEMORY_GUIDANCE) - if "session_search" in agent.valid_tool_names: + if "session_search" in available_tool_names: tool_guidance.append(SESSION_SEARCH_GUIDANCE) - if "skill_manage" in agent.valid_tool_names: + if "skill_manage" in available_tool_names: tool_guidance.append(SKILLS_GUIDANCE) # Kanban worker/orchestrator lifecycle — only present when the # dispatcher spawned this process (kanban_show check_fn gates on @@ -199,7 +206,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) _kanban_guidance = getattr(agent, "_kanban_worker_guidance", None) if _kanban_guidance: tool_guidance.append(_kanban_guidance) - elif _kanban_guidance is None and "kanban_show" in agent.valid_tool_names: + elif _kanban_guidance is None and "kanban_show" in available_tool_names: # Fallback for code paths that bypass agent_init (rare). tool_guidance.append(KANBAN_GUIDANCE) if tool_guidance: @@ -214,11 +221,11 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) # tool_guidance because the content is multi-paragraph. The guidance is # rendered for the host platform so Windows/Linux hosts don't see # macOS-only wording (Mac, Space, cmd+s). - if "computer_use" in agent.valid_tool_names: + if "computer_use" in available_tool_names: from agent.prompt_builder import computer_use_guidance stable_parts.append(computer_use_guidance()) - nous_subscription_prompt = _r.build_nous_subscription_prompt(agent.valid_tool_names) + nous_subscription_prompt = _r.build_nous_subscription_prompt(available_tool_names) if nous_subscription_prompt: stable_parts.append(nous_subscription_prompt) # Tool-use enforcement: tells the model to actually call tools instead @@ -257,12 +264,12 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) if "gpt" in _model_lower or "codex" in _model_lower or "grok" in _model_lower: stable_parts.append(OPENAI_MODEL_EXECUTION_GUIDANCE) - has_skills_tools = any(name in agent.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) + has_skills_tools = any(name in available_tool_names for name in ['skills_list', 'skill_view', 'skill_manage']) if has_skills_tools: avail_toolsets = { toolset for toolset in ( - _r.get_toolset_for_tool(tool_name) for tool_name in agent.valid_tool_names + _r.get_toolset_for_tool(tool_name) for tool_name in available_tool_names ) if toolset } @@ -280,7 +287,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) except Exception: _compact_cats = frozenset() skills_prompt = _r.build_skills_system_prompt( - available_tools=agent.valid_tool_names, + available_tools=available_tool_names, available_toolsets=avail_toolsets, compact_categories=_compact_cats or None, ) diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 44b9a367c90e..ddefde427dfa 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1409,7 +1409,10 @@ def _execute(next_args: dict) -> Any: session_id=agent.session_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", - enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, + enabled_tools=list( + set(agent.valid_tool_names) + | set(getattr(agent, "available_tool_names", ())) + ) or None, skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, enabled_toolsets=getattr(agent, "enabled_toolsets", None), @@ -1451,7 +1454,10 @@ def _execute(next_args: dict) -> Any: session_id=agent.session_id or "", turn_id=getattr(agent, "_current_turn_id", "") or "", api_request_id=getattr(agent, "_current_api_request_id", "") or "", - enabled_tools=list(agent.valid_tool_names) if agent.valid_tool_names else None, + enabled_tools=list( + set(agent.valid_tool_names) + | set(getattr(agent, "available_tool_names", ())) + ) or None, skip_pre_tool_call_hook=True, skip_tool_request_middleware=True, enabled_toolsets=getattr(agent, "enabled_toolsets", None), diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e4bc2331771..668a3acadbbe 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1439,3 +1439,26 @@ updates: # binary_path: "" # "" = resolve op via PATH; else absolute path # cache_ttl_seconds: 300 # 0 disables BOTH cache layers # override_existing: true # resolved values win over existing env + +# ============================================================================= +# Tool search (progressive tool disclosure) +# ============================================================================= +# Replaces deferrable tool schemas in the model-facing tools array with three +# small bridge tools (tool_search / tool_describe / tool_call), cutting the +# schema "entry tax" every session pays on its first prefill. Deferred tools +# stay fully usable — the model finds them by search and calls them through +# the bridge, with guardrails/approvals/hooks firing identically. +# +# tools: +# tool_search: +# enabled: auto # auto | on | off. auto activates when deferrable +# # schemas would consume >= threshold_pct of context +# threshold_pct: 10.0 # auto-activation gate, % of context window +# search_default_limit: 5 # tool_search results when no limit is given +# max_search_limit: 20 # hard cap on requested results +# # By default only MCP and plugin tools are deferrable — built-in +# # ("core") toolsets always load. defer_toolsets opts named built-in +# # toolsets into deferral too, for installs where the built-in schemas +# # themselves are the bulk of the entry tax. Trade-off: turns that use +# # a deferred tool pay one extra bridge round-trip to discover it. +# # defer_toolsets: [session_search, delegation, skills, computer_use] diff --git a/tests/agent/test_system_prompt.py b/tests/agent/test_system_prompt.py index 6ebf2a61960a..0f21e4b56d09 100644 --- a/tests/agent/test_system_prompt.py +++ b/tests/agent/test_system_prompt.py @@ -4,6 +4,7 @@ from unittest.mock import patch from agent.system_prompt import build_system_prompt_parts +from agent.prompt_builder import SESSION_SEARCH_GUIDANCE def _make_agent(**overrides): @@ -99,3 +100,12 @@ def test_absent_without_tools(self, monkeypatch, tmp_path): monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) agent = _make_agent(valid_tool_names=[], platform="cli") assert "coding agent" not in _stable_prompt(agent) + + +def test_deferred_runtime_tool_keeps_capability_guidance(): + agent = _make_agent( + valid_tool_names=["tool_search", "tool_describe", "tool_call"], + available_tool_names={"session_search"}, + ) + + assert SESSION_SEARCH_GUIDANCE in _stable_prompt(agent) diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index da8a446bf8d8..ce1fe8607fab 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -1043,6 +1043,37 @@ def test_valid_tool_names_populated(self): ) assert a.valid_tool_names == {"web_search", "terminal"} + def test_deferred_tools_remain_session_available(self): + """Tool Search hides schemas without erasing session capabilities.""" + visible = _make_tool_defs( + "terminal", "tool_search", "tool_describe", "tool_call" + ) + full = _make_tool_defs("terminal", "session_search") + + def _defs(**kwargs): + return full if kwargs.get("skip_tool_search_assembly") else visible + + with ( + patch("run_agent.get_tool_definitions", side_effect=_defs), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-key-1234567890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + + assert a.valid_tool_names == { + "terminal", "tool_search", "tool_describe", "tool_call" + } + assert a.available_tool_names == { + "terminal", "session_search", "tool_search", "tool_describe", "tool_call" + } + assert a.deferred_tool_names == {"session_search"} + def test_session_id_auto_generated(self): """Session ID should be auto-generated in YYYYMMDD_HHMMSS_ format.""" with ( @@ -2247,6 +2278,22 @@ def test_single_tool_executed(self, agent): assert messages[0]["role"] == "tool" assert "search result" in messages[0]["content"] + def test_deferred_direct_call_uses_session_availability_allowlist(self, agent): + agent.available_tool_names = {"web_search", "deferred_plugin_tool"} + tc = _mock_tool_call( + name="deferred_plugin_tool", arguments='{"query":"prior decision"}', call_id="c1" + ) + messages = [] + + with patch("run_agent.handle_function_call", return_value="match") as mock_hfc: + agent._execute_tool_calls( + _mock_assistant_msg(content="", tool_calls=[tc]), messages, "task-1" + ) + + assert set(mock_hfc.call_args.kwargs["enabled_tools"]) == { + "web_search", "deferred_plugin_tool" + } + def test_sequential_memory_remove_notifies_provider_with_tool_result(self, agent): old_text = "stale preference entry" tc = _mock_tool_call( diff --git a/tests/tools/test_refresh_agent_mcp_tools.py b/tests/tools/test_refresh_agent_mcp_tools.py index da349474a33c..0940acb81cdb 100644 --- a/tests/tools/test_refresh_agent_mcp_tools.py +++ b/tests/tools/test_refresh_agent_mcp_tools.py @@ -44,6 +44,29 @@ def test_refresh_adds_late_landing_tools(monkeypatch): assert len(agent.tools) == 3 +def test_refresh_atomically_replaces_deferred_availability(monkeypatch): + """A reload cannot leave a removed deferred tool callable from stale state.""" + agent = _agent(["terminal", "tool_search", "tool_describe", "tool_call"]) + agent.available_tool_names = set(agent.valid_tool_names) | {"old_deferred"} + agent.deferred_tool_names = {"old_deferred"} + + visible = [_tool(n) for n in agent.valid_tool_names] + preassembly = [_tool("terminal"), _tool("new_deferred")] + + import model_tools + + def _defs(**kwargs): + return preassembly if kwargs.get("skip_tool_search_assembly") else visible + + monkeypatch.setattr(model_tools, "get_tool_definitions", _defs) + + mcp_tool.refresh_agent_mcp_tools(agent) + + assert "old_deferred" not in agent.available_tool_names + assert "new_deferred" in agent.available_tool_names + assert agent.deferred_tool_names == {"new_deferred"} + + def test_refresh_no_change_returns_empty_and_leaves_agent_untouched(monkeypatch): """No new tools → empty set, and the snapshot object is not swapped.""" agent = _agent(["read_file", "terminal"]) diff --git a/tests/tools/test_tool_search.py b/tests/tools/test_tool_search.py index 9c8c8a33c178..e830d5e413a3 100644 --- a/tests/tools/test_tool_search.py +++ b/tests/tools/test_tool_search.py @@ -128,6 +128,114 @@ def test_classify_keeps_unknown_in_visible(self): assert deferrable == [] +# --------------------------------------------------------------------------- +# defer_toolsets opt-in (built-in toolsets behind the bridge) +# --------------------------------------------------------------------------- + + +def _register_session_search(): + """Import a real built-in tool module so the registry holds its entry.""" + import tools.session_search_tool # noqa: F401 — registers at import + from tools.registry import registry + entry = registry.get_entry("session_search") + assert entry is not None, "session_search must be registered for these tests" + return entry.toolset + + +class TestDeferToolsetsOptIn: + """``defer_toolsets`` lets named BUILT-IN toolsets ride the bridge. + + Core protection stays the default: everything here requires the explicit + per-toolset opt-in, and the bridge/dispatch paths must agree with the + assembly about what was deferred (an opted-in tool that assembly strips + but dispatch rejects would be unreachable). + """ + + def test_config_parses_defer_toolsets(self): + from tools.tool_search import ToolSearchConfig + cfg = ToolSearchConfig.from_raw({ + "enabled": "on", + "defer_toolsets": ["session_search", " delegation ", "", None, 7], + }) + assert cfg.defer_toolsets == frozenset({"session_search", "delegation", "7"}) + + def test_config_defer_toolsets_defaults_empty(self): + from tools.tool_search import ToolSearchConfig + assert ToolSearchConfig.from_raw(None).defer_toolsets == frozenset() + assert ToolSearchConfig.from_raw(True).defer_toolsets == frozenset() + assert ToolSearchConfig.from_raw({"defer_toolsets": "not-a-list"}).defer_toolsets == frozenset() + + def test_core_tool_defers_only_with_opt_in(self): + from tools.tool_search import is_deferrable_tool_name + toolset = _register_session_search() + assert not is_deferrable_tool_name("session_search", frozenset()) + assert is_deferrable_tool_name("session_search", frozenset({toolset})) + + def test_bridge_tools_never_defer_even_with_opt_in(self): + from tools.tool_search import is_deferrable_tool_name, BRIDGE_TOOL_NAMES + for name in BRIDGE_TOOL_NAMES: + assert not is_deferrable_tool_name(name, frozenset({"tool_search", "core"})) + + def test_unknown_toolset_opt_in_is_harmless(self): + from tools.tool_search import is_deferrable_tool_name + _register_session_search() + assert not is_deferrable_tool_name( + "session_search", frozenset({"xx_no_such_toolset"}) + ) + + def test_assemble_strips_opted_in_core_toolset(self): + from tools.tool_search import ( + assemble_tool_defs, ToolSearchConfig, BRIDGE_TOOL_NAMES, + ) + toolset = _register_session_search() + defs = [_td("terminal", "Run a command"), + _td("session_search", "Search past sessions")] + cfg = ToolSearchConfig.from_raw( + {"enabled": "on", "defer_toolsets": [toolset]} + ) + result = assemble_tool_defs(defs, context_length=131072, config=cfg) + assert result.activated + names = {(td.get("function") or {}).get("name") for td in result.tool_defs} + assert "session_search" not in names # deferred + assert "terminal" in names # untouched core stays + assert BRIDGE_TOOL_NAMES <= names # bridge present + + def test_dispatch_paths_accept_opted_in_tool(self, monkeypatch): + """describe/tool_call must agree with assembly about the opt-in.""" + import tools.tool_search as ts + toolset = _register_session_search() + cfg = ts.ToolSearchConfig.from_raw( + {"enabled": "on", "defer_toolsets": [toolset]} + ) + monkeypatch.setattr(ts, "load_config", lambda: cfg) + + td = _td("session_search", "Search past sessions") + described = json.loads(ts.dispatch_tool_describe( + {"name": "session_search"}, current_tool_defs=[td])) + assert described.get("name") == "session_search" + assert "error" not in described + + name, args, err = ts.resolve_underlying_call( + {"name": "session_search", "arguments": {"query": "x"}}) + assert err is None + assert name == "session_search" + assert args == {"query": "x"} + + def test_dispatch_paths_reject_without_opt_in(self, monkeypatch): + import tools.tool_search as ts + _register_session_search() + cfg = ts.ToolSearchConfig.from_raw({"enabled": "on"}) + monkeypatch.setattr(ts, "load_config", lambda: cfg) + + described = json.loads(ts.dispatch_tool_describe( + {"name": "session_search"}, + current_tool_defs=[_td("session_search", "Search past sessions")])) + assert "error" in described + + _, _, err = ts.resolve_underlying_call({"name": "session_search", "arguments": {}}) + assert err is not None + + # --------------------------------------------------------------------------- # Token estimation + threshold gate # --------------------------------------------------------------------------- diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 329ddebdd73f..fba63266602a 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -5007,6 +5007,30 @@ def refresh_agent_mcp_tools( ) new_names = {t["function"]["name"] for t in new_defs} + # When Tool Search assembled a bridge surface, retain the pre-assembly + # session scope separately. A refresh must replace this snapshot together + # with ``tools``/``valid_tool_names``; otherwise a tool removed by a live + # toolset reload could remain callable through stale availability state. + new_available_names = set(new_names) + new_deferred_names: set[str] = set() + try: + from tools.tool_search import BRIDGE_TOOL_NAMES + + if BRIDGE_TOOL_NAMES <= new_names: + preassembly_defs = get_tool_definitions( + enabled_toolsets=enabled, + disabled_toolsets=disabled, + quiet_mode=True, + skip_tool_search_assembly=True, + ) or [] + preassembly_names = { + t["function"]["name"] for t in preassembly_defs + } + new_available_names.update(preassembly_names) + new_deferred_names = preassembly_names - new_names + except Exception: + pass + # Re-append the post-build injected families that get_tool_definitions does # NOT reproduce, so a refresh never strips them (memory-provider + context- # engine tools). Staged entirely on LOCALS — the live ``agent.tools`` / @@ -5016,6 +5040,7 @@ def refresh_agent_mcp_tools( # half-swap. ``staged_engine_names`` are the context-engine routing names # this rebuild actually appended (matching agent_init's dedup-aware add). staged_engine_names = _reinject_post_build_tools(agent, new_defs, new_names) + new_available_names.update(new_names) # Single atomic read-diff-publish so the returned ``added`` is consistent # with what was actually published, even under concurrent callers, and a @@ -5034,13 +5059,18 @@ def refresh_agent_mcp_tools( t["function"]["name"] for t in (getattr(agent, "tools", None) or []) } - if new_names == current: + current_available = set( + getattr(agent, "available_tool_names", current) + ) + if new_names == current and new_available_names == current_available: # No change → leave the live snapshot untouched (no churn), but # record the generation so an in-flight older caller can't clobber. agent._tool_snapshot_generation = max(published_gen, snapshot_generation) return set() agent.tools = new_defs agent.valid_tool_names = new_names + agent.available_tool_names = new_available_names + agent.deferred_tool_names = new_deferred_names # Publish context-engine routing names atomically with the snapshot. engine_names = getattr(agent, "_context_engine_tool_names", None) if isinstance(engine_names, set): diff --git a/tools/tool_search.py b/tools/tool_search.py index e885a5d7b88c..d01e0d1ba2ed 100644 --- a/tools/tool_search.py +++ b/tools/tool_search.py @@ -2,13 +2,19 @@ When enabled, MCP and non-core plugin tools are replaced in the model-visible tools array by three bridge tools — ``tool_search``, ``tool_describe``, -``tool_call`` — and surfaced on demand. Core Hermes tools never defer. +``tool_call`` — and surfaced on demand. Core Hermes tools never defer unless +their toolset is explicitly opted in via ``tools.tool_search.defer_toolsets``. Design constraints this module is built around (see ``openclaw-tool-search-report`` for the full rationale): -* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred. - Always-load means always-load. No exceptions. +* Core tools defined in ``toolsets._HERMES_CORE_TOOLS`` are *never* deferred + by default. The single exception is an explicit per-toolset opt-in: + ``defer_toolsets: [session_search, ...]`` pushes every tool of the named + built-in toolsets behind the bridge. This exists because on schema-heavy + installs the built-in toolsets themselves are the entry tax (10k+ tokens + of every session's first prefill), and only the user knows which of them + their sessions actually lean on. Opt-in, never inferred. * The threshold gate runs every assembly: when deferrable tools would consume less than ``threshold_pct`` of the model's context window (default 10%), tool search is a no-op and the tools array passes through unchanged. @@ -68,6 +74,11 @@ class ToolSearchConfig: threshold_pct: float # 0..100 — only used when enabled == "auto" search_default_limit: int max_search_limit: int + # Built-in toolsets explicitly opted in for deferral. Tools of these + # toolsets defer even when listed in ``_HERMES_CORE_TOOLS`` — the user + # is trading first-prefill size for an extra bridge round-trip on the + # turns that actually use them. Empty by default (no behavior change). + defer_toolsets: frozenset = frozenset() @classmethod def from_raw(cls, raw: Any) -> "ToolSearchConfig": @@ -106,11 +117,20 @@ def from_raw(cls, raw: Any) -> "ToolSearchConfig": search_default_limit = max(1, min(max_search_limit, _safe_int(raw.get("search_default_limit"), 5))) + defer_raw = raw.get("defer_toolsets") + defer_toolsets: frozenset = frozenset() + if isinstance(defer_raw, (list, tuple, set)): + defer_toolsets = frozenset( + str(item).strip() for item in defer_raw + if isinstance(item, (str, int)) and str(item).strip() + ) + return cls( enabled=enabled, threshold_pct=threshold_pct, search_default_limit=search_default_limit, max_search_limit=max_search_limit, + defer_toolsets=defer_toolsets, ) @@ -160,39 +180,56 @@ def _core_tool_names() -> frozenset[str]: return frozenset() -def is_deferrable_tool_name(name: str) -> bool: +def is_deferrable_tool_name(name: str, + defer_toolsets: Optional[frozenset] = None) -> bool: """Return True if a tool with this name is *eligible* for deferral. A tool is deferrable iff it is registered with an MCP toolset prefix - OR it is not in ``_HERMES_CORE_TOOLS``. Core tools are never deferred - even when their toolset is technically plugin-provided (this protects - against accidental shadowing). + OR it is not in ``_HERMES_CORE_TOOLS`` OR its toolset is explicitly + opted in via ``defer_toolsets``. Outside the opt-in, core tools are + never deferred even when their toolset is technically plugin-provided + (this protects against accidental shadowing). + + ``defer_toolsets=None`` resolves the opt-in set from user config — + the dispatch paths (``tool_describe`` / ``tool_call`` / session + scoping) must accept whatever the assembly deferred, so both sides + read the same source. Pass the set explicitly in per-tool loops to + avoid a config read per tool. """ if name in BRIDGE_TOOL_NAMES: return False - if name in _core_tool_names(): - return False - # Check registry toolset for MCP prefix. + if defer_toolsets is None: + defer_toolsets = load_config().defer_toolsets + entry = None try: from tools.registry import registry entry = registry.get_entry(name) - if entry is None: - return False - if entry.toolset.startswith("mcp-"): - return True - # Non-MCP, non-core → plugin tool, eligible. - return True except Exception: + entry = None + if defer_toolsets and entry is not None and entry.toolset in defer_toolsets: + # Explicit per-toolset opt-in overrides core protection. + return True + if name in _core_tool_names(): + return False + if entry is None: return False + if entry.toolset.startswith("mcp-"): + return True + # Non-MCP, non-core → plugin tool, eligible. + return True -def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: +def classify_tools(tool_defs: List[Dict[str, Any]], + defer_toolsets: Optional[frozenset] = None, + ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Split a tool-defs list into (visible, deferrable). ``visible`` retains every tool that must stay in the model-facing array: - every core tool, plus any tool we can't classify. ``deferrable`` is the - candidate set for catalog entry. + every core tool (minus ``defer_toolsets`` opt-ins), plus any tool we + can't classify. ``deferrable`` is the candidate set for catalog entry. """ + if defer_toolsets is None: + defer_toolsets = load_config().defer_toolsets visible: List[Dict[str, Any]] = [] deferrable: List[Dict[str, Any]] = [] for td in tool_defs: @@ -202,7 +239,7 @@ def classify_tools(tool_defs: List[Dict[str, Any]]) -> Tuple[List[Dict[str, Any] # Should never happen — bridge tools are added after classification — # but be defensive. continue - if is_deferrable_tool_name(name): + if is_deferrable_tool_name(name, defer_toolsets): deferrable.append(td) else: visible.append(td) @@ -535,9 +572,10 @@ def assemble_tool_defs( """Return the tool-defs list the model should actually see. When tool search is inactive (off, no deferrable tools, or below - threshold), this is a passthrough. When active, MCP and plugin tools - are stripped from the visible list and replaced with the three bridge - tools. Core tools are *never* deferred regardless of config. + threshold), this is a passthrough. When active, MCP and plugin tools — + plus any built-in toolsets opted in via ``defer_toolsets`` — are + stripped from the visible list and replaced with the three bridge + tools. Outside that explicit opt-in, core tools are never deferred. Idempotent: calling with bridge tools already in the input is a no-op (they classify as non-core/non-deferrable but their names are reserved, @@ -551,7 +589,7 @@ def assemble_tool_defs( incoming = [td for td in tool_defs if (td.get("function") or {}).get("name") not in BRIDGE_TOOL_NAMES] - visible, deferrable = classify_tools(incoming) + visible, deferrable = classify_tools(incoming, config.defer_toolsets) if not deferrable: return AssemblyResult(tool_defs=incoming, activated=False) @@ -619,7 +657,7 @@ def dispatch_tool_search(args: Dict[str, Any], else: limit = max(1, min(config.max_search_limit, _safe_int(raw_limit, config.search_default_limit))) - _, deferrable = classify_tools(current_tool_defs) + _, deferrable = classify_tools(current_tool_defs, config.defer_toolsets) catalog = build_catalog(deferrable) hits = search_catalog(catalog, query, limit=limit) return json.dumps({ @@ -670,9 +708,10 @@ def scoped_deferrable_names(tool_defs: List[Dict[str, Any]]) -> frozenset[str]: an out-of-scope tool via the bridge. """ names: set[str] = set() + defer_toolsets = load_config().defer_toolsets # resolve once, not per tool for td in tool_defs: name = (td.get("function") or {}).get("name", "") - if name and is_deferrable_tool_name(name): + if name and is_deferrable_tool_name(name, defer_toolsets): names.add(name) return frozenset(names)