test(anthropic): strengthen typeless tool search regression

This commit is contained in:
JerrettDavis 2026-08-13 19:52:18 -05:00
parent 7e817c255c
commit 5531117df5
3 changed files with 100 additions and 23 deletions

View file

@ -2883,10 +2883,9 @@ def inject_tool_search_deferral(
not isinstance(tool, dict)
or tool.get("type")
or str(tool.get("name") or "").lower() in core_lower
or str(tool.get("name") or "").lower().startswith(_TOOL_SEARCH_TOOL_TYPE_PREFIX)
):
# Non-dict, server/typed tools (web_search, computer, …), core tools,
# and any tool_search_tool_*-named tool stay resident and unchanged.
# Non-dict, server/typed tools (web_search, computer, …), and core
# tools stay resident and unchanged.
out.append(tool)
if isinstance(tool, dict) and not tool.get("type"):
last_resident_real = tool

View file

@ -337,6 +337,88 @@ def test_anthropic_third_party_upstream_strips_tool_search_tools():
)
def test_anthropic_direct_path_repairs_typeless_tool_search_regression():
"""Do not double-inject a typeless search tool; heal its stale history."""
tools = [
{
"name": f"mcp_tool_{index}",
"description": f"tool {index}",
"input_schema": {"type": "object", "properties": {}},
}
for index in range(20)
]
tools.append(
{
"name": "tool_search_tool_regex",
"description": "client-provided tool search",
"input_schema": {"type": "object", "properties": {}},
}
)
messages = [
{"role": "user", "content": "search for a tool"},
{
"role": "assistant",
"content": [
{
"type": "server_tool_use",
"id": "srvtoolu_regex",
"name": "tool_search_tool_regex",
"input": {"pattern": "regex"},
},
{
"type": "tool_search_tool_result",
"tool_use_id": "srvtoolu_regex",
"content": {
"type": "tool_search_tool_search_result",
"tool_references": [
{
"type": "tool_reference",
"tool_name": "tool_search_tool_regex",
}
],
},
},
],
},
{"role": "user", "content": "continue"},
]
request = _build_request(
{
"model": "claude-sonnet-4-6",
"max_tokens": 100,
"messages": messages,
"tools": tools,
},
{"authorization": "Bearer sk-ant-api-test"},
)
handler = _DummyAnthropicHandler()
import headroom.tokenizers as _tk
orig_get = _tk.get_tokenizer
_tk.get_tokenizer = lambda model: _DummyTokenizer()
try:
response = anyio.run(handler.handle_anthropic_messages, request)
finally:
_tk.get_tokenizer = orig_get
assert response.status_code == 200
_, _, _, forwarded_body = handler.captured
# The client-owned typeless entry suppresses Headroom's typed search-tool
# injection, and the tools array remains byte-for-byte equivalent.
assert forwarded_body["tools"] == tools
assert not any(tool.get("type") for tool in forwarded_body["tools"])
# The stale server-side round trip is removed before Anthropic validates it.
block_types = [
block.get("type")
for message in forwarded_body["messages"]
for block in message.get("content", [])
if isinstance(message.get("content"), list) and isinstance(block, dict)
]
assert "server_tool_use" not in block_types
assert "tool_search_tool_result" not in block_types
def test_anthropic_http_invalid_body_still_emits_stage_timings(stage_log_capture):
async def receive():
# Invalid JSON — produces ``ValueError`` from ``_read_request_json``.

View file

@ -488,14 +488,11 @@ def test_repair_strips_search_history_when_only_the_tool_is_missing() -> None:
# resolvable — but the typed server tool is not a valid deferred-tool target, so
# Anthropic rejected the request with 400.
#
# The three-part fix:
# The two-part fix:
# 1. ``inject_tool_search_deferral`` early-exit also fires on a name-prefix
# match, preventing double-injection when the client carries a typeless
# ``tool_search_tool_*`` entry.
# 2. The per-tool guard in ``inject_tool_search_deferral`` keeps
# ``tool_search_tool_*``-named tools resident even without a ``type``
# field, so they are never silently deferred.
# 3. ``strip_unsupported_tool_search_blocks`` excludes typed search tools
# 2. ``strip_unsupported_tool_search_blocks`` excludes typed search tools
# from the ``available`` set — they are the search mechanism, not targets.
# ---------------------------------------------------------------------------
@ -540,29 +537,28 @@ def _transcript_with_search_tool_regex_reference() -> list[dict]:
]
def test_inject_deferral_exits_early_on_typeless_tool_search_name() -> None:
@pytest.mark.parametrize(
"name",
[_TOOL_SEARCH_DEFAULT_NAME, "TOOL_SEARCH_TOOL_BM25"],
)
def test_inject_deferral_exits_early_on_typeless_tool_search_name(name: str) -> None:
# A client that sends tool_search_tool_regex without a ``type`` field should
# be treated as already using tool search (name-prefix guard), so Headroom
# must not inject a second search tool on top of it.
typeless_search = {"name": _TOOL_SEARCH_DEFAULT_NAME, "input_schema": {}}
typeless_search = {"name": name, "input_schema": {}}
tools = _tools(20) + [typeless_search]
result = inject_tool_search_deferral(tools)
assert result is tools # no injection
def test_inject_deferral_never_defers_tool_search_named_tool() -> None:
# Even if the early-exit fires only on the per-tool guard, a typeless
# tool_search_tool_* tool must stay resident so it can never pollute
# a tool_reference entry in the transcript.
typeless_search = {"name": _TOOL_SEARCH_DEFAULT_NAME, "input_schema": {}}
other_tools = _tools(20)
# Build a tools list where the typeless search tool sits among non-core tools
# but there's NO typed search tool to trigger the early exit by type.
# (In practice the early-exit by name fires first, but we add a typed web
# search so the list is ≥ 12 and yet the name-based guard is tested.)
tools_no_typed_search = other_tools + [typeless_search]
# Early exit by name should fire, returning unchanged.
assert inject_tool_search_deferral(tools_no_typed_search) is tools_no_typed_search
def test_inject_deferral_does_not_false_match_similar_typeless_tool_name() -> None:
# Keep ordinary tools whose names merely resemble the reserved prefix on the
# normal deferral path; the trailing underscore is part of the match.
tools = _tools(20) + [{"name": "tool_search_toolbox", "input_schema": {}}]
result = inject_tool_search_deferral(tools)
assert result is not tools
by_name = {tool.get("name"): tool for tool in result}
assert by_name["tool_search_toolbox"]["defer_loading"] is True
def test_repair_drops_search_tool_self_reference_when_inject_ran() -> None: