diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 562cb3385..f1096acc5 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2857,6 +2857,11 @@ _TOOL_SEARCH_CORE_TOOLS = frozenset( "webfetch", "question", "skill", + # A client's own tool-search/schema-fetch tool (Claude Code's ``ToolSearch``). + # It resolves tools the client keeps in its local registry and never puts in + # the request body (TaskCreate, WebFetch, …), so deferring it hides the only + # tool that can load them and they become permanently unreachable. + "toolsearch", } ) _TOOL_SEARCH_DEFAULT_TYPE = "tool_search_tool_regex_20251119" @@ -2900,8 +2905,18 @@ def inject_tool_search_deferral( last_resident_real: dict[str, Any] | None = None resident_has_cache_control = False + # Clients disagree on casing for the same tool: Claude Code sends ``Bash`` / + # ``ToolSearch`` where opencode sends ``bash``. Compare case-insensitively so + # the exemption applies to both — an exact match silently deferred *every* + # tool for PascalCase clients, including their own tool-search tool. + core_lower = {name.lower() for name in core_tools} + for tool in tools: - if not isinstance(tool, dict) or tool.get("type") or tool.get("name") in core_tools: + if ( + not isinstance(tool, dict) + or tool.get("type") + or str(tool.get("name") or "").lower() in core_lower + ): # Non-dict, server/typed tools (web_search, computer, …), and core # tools stay resident and unchanged. out.append(tool) @@ -3011,6 +3026,11 @@ def inject_tool_search_deferral_openai( out: list[Any] = [{"type": _OPENAI_TOOL_SEARCH_TYPE}] deferred = 0 + # Case-insensitive for the same reason as the Anthropic path above: the + # resident-name sets are lowercase, clients are not required to be. + resident_lower = {name.lower() for name in core_tools} | { + name.lower() for name in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES + } for tool in tools: if not isinstance(tool, dict): out.append(tool) @@ -3020,9 +3040,7 @@ def inject_tool_search_deferral_openai( # trained to search namespaces / MCP servers). Everything else — core # coding tools and other hosted tools — stays resident. deferrable = ( - ttype == "function" - and tool.get("name") not in core_tools - and tool.get("name") not in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES + ttype == "function" and str(tool.get("name") or "").lower() not in resident_lower ) or ttype == "mcp" if deferrable and not tool.get("defer_loading"): new_tool = dict(tool) diff --git a/tests/test_issue_746_tool_search.py b/tests/test_issue_746_tool_search.py index b07216073..337447dfa 100644 --- a/tests/test_issue_746_tool_search.py +++ b/tests/test_issue_746_tool_search.py @@ -257,3 +257,45 @@ def test_non_dict_and_typed_tools_stay_resident() -> None: out = inject_tool_search_deferral(tools) typed = [t for t in out if t.get("type") == "web_search_20250305"] assert len(typed) == 1 and typed[0].get("defer_loading") is None + + +# --------------------------------------------------------------------------- +# PascalCase clients (Claude Code). The core-tool exemption is spelled in +# lowercase, so an exact-match comparison never fired for Claude Code: every +# tool was deferred, including Claude Code's own ``ToolSearch``. +# --------------------------------------------------------------------------- + + +def _claude_code_tools() -> list[dict]: + """Claude Code's surface: PascalCase built-ins, its ToolSearch, MCP tools.""" + names = ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "ToolSearch"] + [ + f"mcp__srv__t{i}" for i in range(12) + ] + return [{"name": n, "description": n, "input_schema": {}} for n in names] + + +def test_core_tools_match_case_insensitively() -> None: + # Without a case-insensitive match, routine edit/read/run loops each pay a + # search round-trip — the exact thing _TOOL_SEARCH_CORE_TOOLS exists to avoid. + out = inject_tool_search_deferral(_claude_code_tools()) + by_name = {t.get("name"): t for t in out if "name" in t} + for name in ("Bash", "Read", "Write", "Edit", "Glob", "Grep"): + assert by_name[name].get("defer_loading") is None, name + # MCP tools are still deferred — the token saving is preserved. + assert by_name["mcp__srv__t0"].get("defer_loading") is True + + +def test_client_tool_search_tool_is_never_deferred() -> None: + # ToolSearch is the client's own schema fetcher for tools that never appear + # in the request body (TaskCreate, WebFetch, …). Deferring it hides the only + # tool that can load them, so they become permanently unreachable. + out = inject_tool_search_deferral(_claude_code_tools()) + by_name = {t.get("name"): t for t in out if "name" in t} + assert by_name["ToolSearch"].get("defer_loading") is None + + +def test_resident_real_tool_survives_pascal_case_surface() -> None: + # The injected search tool is typed and does not satisfy the invariant on its + # own; Anthropic 400s when every real tool is deferred. + out = inject_tool_search_deferral(_claude_code_tools()) + assert any(not t.get("type") and not t.get("defer_loading") for t in out) diff --git a/tests/test_openai_tool_search_deferral.py b/tests/test_openai_tool_search_deferral.py index afde1adac..070ddbd60 100644 --- a/tests/test_openai_tool_search_deferral.py +++ b/tests/test_openai_tool_search_deferral.py @@ -143,3 +143,17 @@ def test_noop_when_nothing_deferrable(): def test_noop_for_non_list(): assert inject_tool_search_deferral_openai(None, "gpt-5.5") is None + + +def test_resident_names_match_case_insensitively(): + # The resident-name sets are lowercase; clients are not required to be. An + # exact match deferred every tool for a PascalCase client, including its own + # tool-search tool. Mirrors the Anthropic-side fix. + tools = [_fn(n) for n in ("Bash", "Read", "Edit", "Terminal", "ToolSearch")] + [ + _fn(f"slack_{i}") for i in range(10) + ] + out = inject_tool_search_deferral_openai(tools, "gpt-5.5") + by_name = {t.get("name"): t for t in out if "name" in t} + for name in ("Bash", "Read", "Edit", "Terminal", "ToolSearch"): + assert by_name[name].get("defer_loading") is None, name + assert by_name["slack_0"].get("defer_loading") is True