mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
test(anthropic): strengthen typeless tool search regression
This commit is contained in:
parent
7e817c255c
commit
5531117df5
3 changed files with 100 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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``.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue