diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index d98b67be3..ecd57f242 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -2811,6 +2811,13 @@ _TOOL_SEARCH_DEFAULT_NAME = "tool_search_tool_regex" _TOOL_SEARCH_MIN_TOOLS = 12 +def _tool_search_resident_key(name: Any) -> str: + """Normalize a client tool name for resident-tool membership checks.""" + # Oh My Pi prefixes every built-in with ``_``. Strip only leading namespace + # markers so internal separators such as ``mcp__server__read`` stay intact. + return str(name or "").lower().lstrip("_") + + def anthropic_first_party_tool_search_supported(api_base_url: str | None) -> bool: """Return whether Anthropic server-side tool search is valid for this upstream.""" from headroom.providers.claude.runtime import is_custom_anthropic_base_url @@ -2872,17 +2879,17 @@ 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} + # Clients disagree on casing and leading namespace markers for the same tool: + # Claude Code sends ``Bash``, opencode sends ``bash``, and Oh My Pi sends + # ``_bash``. Normalize both the configured names and each candidate so the + # exemption applies consistently across clients. + core_keys = {_tool_search_resident_key(name) for name in core_tools} for tool in tools: if ( not isinstance(tool, dict) or tool.get("type") - or str(tool.get("name") or "").lower() in core_lower + or _tool_search_resident_key(tool.get("name")) in core_keys ): # Non-dict, server/typed tools (web_search, computer, …), and core # tools stay resident and unchanged. @@ -3259,10 +3266,10 @@ 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 + # Normalize for the same reason as the Anthropic path above: clients may use + # different casing or a leading namespace marker for the same resident tool. + resident_keys = {_tool_search_resident_key(name) for name in core_tools} | { + _tool_search_resident_key(name) for name in _OPENAI_TOOL_SEARCH_RESIDENT_NAMES } for tool in tools: if not isinstance(tool, dict): @@ -3273,7 +3280,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 str(tool.get("name") or "").lower() not in resident_lower + ttype == "function" and _tool_search_resident_key(tool.get("name")) not in resident_keys ) 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 4b31cb7f8..b3321a010 100644 --- a/tests/test_issue_746_tool_search.py +++ b/tests/test_issue_746_tool_search.py @@ -350,6 +350,42 @@ def test_resident_real_tool_survives_pascal_case_surface() -> None: assert any(not t.get("type") and not t.get("defer_loading") for t in out) +def _omp_tools() -> list[dict]: + """Oh My Pi's 12-tool surface: underscore-prefixed built-ins plus typed tools.""" + named = [ + "_hub", + "_edit", + "_task", + "_todo", + "_eval", + "_read", + "_bash", + "_glob", + "_grep", + "_write", + ] + return [ + *[{"name": name, "description": name, "input_schema": {}} for name in named], + {"type": "computer_20250124", "name": "computer"}, + {"type": "web_search_20250305", "name": "web_search"}, + ] + + +def test_core_tools_match_leading_underscore_namespace() -> None: + tools = _omp_tools() + assert len(tools) == _TOOL_SEARCH_MIN_TOOLS + + out = inject_tool_search_deferral(tools) + + by_name = {tool.get("name"): tool for tool in out if isinstance(tool, dict)} + for name in ("_edit", "_task", "_read", "_bash", "_glob", "_grep", "_write"): + assert by_name[name].get("defer_loading") is None, name + for name in ("_hub", "_todo", "_eval"): + assert by_name[name].get("defer_loading") is True, name + for name in ("computer", "web_search"): + assert by_name[name].get("defer_loading") is None, name + + # --------------------------------------------------------------------------- # Tool-search history repair (#2805) # diff --git a/tests/test_openai_tool_search_deferral.py b/tests/test_openai_tool_search_deferral.py index 32486d06c..38241ded0 100644 --- a/tests/test_openai_tool_search_deferral.py +++ b/tests/test_openai_tool_search_deferral.py @@ -123,6 +123,20 @@ def test_terminal_helper_remains_deferrable(): assert helper.get("defer_loading") is True +def test_prefixed_core_and_terminal_names_stay_resident(): + resident = ["_bash", "_read", "_write", "_edit", "_glob", "_grep", "_terminal"] + noncore = ["_hub", "_todo", "_eval", "mcp__server__read", "terminal_helper"] + tools = [_fn(name) for name in resident + noncore] + + out = inject_tool_search_deferral_openai(tools, "gpt-5.6-terra") + + by_name = {tool["name"]: tool for tool in out if tool.get("type") == "function"} + for name in resident: + assert by_name[name].get("defer_loading") is None, name + for name in noncore: + assert by_name[name].get("defer_loading") is True, name + + def test_defers_mcp_server(): tools = [_fn(n) for n in _CORE] + [{"type": "mcp", "server_label": "sentry"}] tools += [_fn(f"x{i}") for i in range(8)]