headroom/tests/test_websearch_tool_result_protection.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

185 lines
5.7 KiB
Python
Raw Normal View History

fix(proxy): protect WebSearch/WebFetch tool results from lossy compression (#2115) ## Description `WebSearch` and `WebFetch` tool results can be large reference payloads whose exact formatting matters. This PR keeps those web-tool outputs verbatim through both the chat/router path and the OpenAI Responses path, including cross-turn dedup, while leaving ordinary compressible tools such as `Bash` unchanged by default. Closes #1810 ## Changes Made - Added `WebSearch`, `WebFetch`, `web_search`, and `web_fetch` to the default excluded tools. - Added a verbatim-only excluded-tool subset for web payloads so those outputs bypass lossy compression, lossless JSON rewriting, and cross-turn dedup folding. - Updated the OpenAI Responses adapter to track protected call IDs for verbatim web outputs. - Added regressions for Anthropic-style tool results, OpenAI Responses tool outputs, cross-turn dedup, and unchanged `Bash` compression behavior. - Merged current `main` and removed unrelated dependency floor changes from the PR diff. ## Testing ```text uv run --extra dev python -m pytest tests/test_websearch_tool_result_protection.py tests/test_content_router_exclude_tools.py tests/test_openai_responses_compression_units.py::test_openai_responses_adapter_keeps_websearch_output_verbatim tests/test_responses_cross_turn_dedup.py::test_protected_websearch_outputs_do_not_fold -q 13 passed uv run --extra dev mypy headroom/transforms/content_router.py headroom/proxy/handlers/openai.py Success: no issues found in 2 source files git diff --check headroomlabs/main...HEAD # no output ``` The local pre-commit hook also passed on the pushed cleanup/type-fix commit. ## Review Readiness - [x] Ready for review - [x] Regression tests added --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 11:52:26 -04:00
"""Regression tests for web-tool result passthrough."""
from __future__ import annotations
from headroom.config import DEFAULT_EXCLUDE_TOOLS
from headroom.proxy.server import HeadroomProxy, ProxyConfig
from headroom.transforms.content_detector import ContentType
from headroom.transforms.content_router import (
CompressionStrategy,
ContentRouter,
RouterCompressionResult,
RoutingDecision,
)
class _Tokenizer:
def count_text(self, text: str) -> int:
return max(1, len(text) // 4)
def _messages(tool_name: str, payload: str) -> list[dict[str, object]]:
return [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "tool-1",
"name": tool_name,
"input": {},
}
],
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "tool-1",
"content": payload,
}
],
},
]
def _router() -> ContentRouter:
proxy = HeadroomProxy(
ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
code_aware_enabled=False,
mode="token",
)
)
router = proxy.anthropic_pipeline.transforms[-1]
assert isinstance(router, ContentRouter)
router.config.min_section_tokens = 1
router.config.min_chars_for_block_compression = 1
return router
def test_web_tools_are_default_exclusions() -> None:
assert {"WebSearch", "WebFetch", "web_search", "web_fetch"} <= DEFAULT_EXCLUDE_TOOLS
def test_web_tool_results_bypass_compressor() -> None:
router = _router()
calls = 0
def fake_compress(*args: object, **kwargs: object) -> RouterCompressionResult:
nonlocal calls
calls += 1
content = str(args[0])
return RouterCompressionResult(
compressed="mutated",
original=content,
strategy_used=CompressionStrategy.TEXT,
routing_log=[
RoutingDecision(ContentType.PLAIN_TEXT, CompressionStrategy.TEXT, 100, 10)
],
)
router.compress = fake_compress # type: ignore[method-assign]
payload = (
"{\n"
' "results": [\n'
' {"title": "Headroom", "snippet": "reference payload reference payload reference payload"},\n'
' {"title": "Docs", "snippet": "structured web payload with spacing that must remain verbatim"}\n'
" ],\n"
' "source": "web"\n'
"}"
)
for tool_name in ("WebSearch", "WebFetch", "web_search", "web_fetch"):
messages = _messages(tool_name, payload)
result = router.apply(messages, _Tokenizer())
tool_result = result.messages[1]["content"][0] # type: ignore[index]
assert tool_result["content"] == payload # type: ignore[index]
assert "router:excluded:tool" in result.transforms_applied
assert calls == 0
def test_web_tool_results_stay_verbatim_outside_token_age_window() -> None:
router = _router()
calls = 0
def fake_compress(*args: object, **kwargs: object) -> RouterCompressionResult:
nonlocal calls
calls += 1
content = str(args[0])
return RouterCompressionResult(
compressed="mutated",
original=content,
strategy_used=CompressionStrategy.TEXT,
routing_log=[
RoutingDecision(ContentType.PLAIN_TEXT, CompressionStrategy.TEXT, 100, 10)
],
)
router.compress = fake_compress # type: ignore[method-assign]
payload = (
"{\n"
' "results": [\n'
' {"title": "Headroom", "snippet": "reference payload reference payload reference payload"}\n'
" ]\n"
"}"
)
messages = _messages("WebSearch", payload)
messages.extend({"role": "user", "content": f"later turn {i}"} for i in range(18))
result = router.apply(messages, _Tokenizer())
tool_result = result.messages[1]["content"][0] # type: ignore[index]
assert tool_result["content"] == payload # type: ignore[index]
assert calls == 0
def test_web_tool_results_skip_cross_turn_dedup() -> None:
router = _router()
payload = (
"{\n"
' "results": [\n'
' {"title": "Headroom", "snippet": "structured web payload with spacing that must remain verbatim"}\n'
" ]\n"
"}"
)
messages = _messages("WebSearch", payload) + _messages("WebSearch", payload)
result = router.apply(messages, _Tokenizer())
first = result.messages[1]["content"][0] # type: ignore[index]
second = result.messages[3]["content"][0] # type: ignore[index]
assert first["content"] == payload # type: ignore[index]
assert second["content"] == payload # type: ignore[index]
def test_bash_remains_compressible() -> None:
router = _router()
calls = 0
def fake_compress(*args: object, **kwargs: object) -> RouterCompressionResult:
nonlocal calls
calls += 1
content = str(args[0])
return RouterCompressionResult(
compressed="compressed bash output",
original=content,
strategy_used=CompressionStrategy.TEXT,
routing_log=[
RoutingDecision(ContentType.PLAIN_TEXT, CompressionStrategy.TEXT, 100, 10)
],
)
router.compress = fake_compress # type: ignore[method-assign]
payload = "bash output " * 100
result = router.apply(_messages("Bash", payload), _Tokenizer())
assert calls == 1
assert "router:excluded:tool" not in result.transforms_applied