2026-01-06 23:16:58 -08:00
|
|
|
"""Tests for the parser module.
|
|
|
|
|
|
|
|
|
|
Tests all parsing and analysis functions:
|
|
|
|
|
- compute_hash: Content hashing
|
|
|
|
|
- detect_waste_signals: Waste signal detection
|
|
|
|
|
- is_rag_content: RAG content detection
|
|
|
|
|
- parse_message_to_blocks: Single message parsing
|
|
|
|
|
- parse_messages: Multi-message parsing
|
|
|
|
|
- find_tool_units: Tool call/response pairing
|
|
|
|
|
- get_message_content_text: Content extraction
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from unittest.mock import Mock
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
import pytest
|
|
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
from headroom.parser import (
|
fix(agno): tolerate streaming tool-call SDK objects in parser (#1312) (#1336)
## Description
`HeadroomAgnoModel` blows up as soon as you stream a response that
includes a tool call:
```
ERROR Error in Agent run: 'ChoiceDeltaToolCall' object has no attribute 'get'
```
When streaming, Agno hands us `Message.tool_calls` as the raw OpenAI SDK
objects (`ChoiceDeltaToolCall`), not the OpenAI-style dicts we get on
the non-streaming path. Those objects are pydantic models — attribute
access only, no `.get()`. Our shared parser in `headroom/parser.py`
walks `tool_calls` and calls `tc.get("function", {})` / `tc.get("id")`,
so it throws `AttributeError`, and the Agno wrapper surfaces that as a
`RunErrorEvent` that kills the run.
I reproduced the exact error against `parse_message_to_blocks` with a
stand-in object before writing the fix.
Closes #1312
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `parser.py`: added a small `_coerce_tool_call_to_dict()` helper that
takes a tool_call which might be a dict or a provider SDK object and
returns the canonical OpenAI dict (reading `.function.name` /
`.function.arguments` via `getattr`). Wired it into both `.get()` sites,
`parse_message_to_blocks` and `find_tool_units`. Dicts pass straight
through (same object, no copy); `None` or anything unexpected degrades
to `{}` instead of raising. The proxy, langchain, and strands
integrations go through this same parser, so they get the same
hardening.
- `integrations/agno/model.py`: normalize `tool_calls` to dicts in
`_convert_messages_to_openai`, so the Agno `Message` objects we rebuild
and hand back also carry clean dicts and Agno's own re-serialization
can't trip over the same thing.
## Testing
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_parser.py -q
93 passed
# 87 existing + 6 new regression tests in TestStreamingToolCallObjects.
$ python -m pytest tests/test_integrations/agno/test_model.py -q
59 skipped
# These skip locally because agno isn't installed here
# (pytestmark = skipif(not AGNO_AVAILABLE)); they run in CI. The new
# test_convert_messages_normalizes_streaming_tool_call_objects is in this file.
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, local clone. agno and the Rust
`headroom._core` extension aren't installed/built in this checkout.
- Exact command / steps: built a stand-in `ChoiceDeltaToolCall`
(attribute access, no `.get()`, nested `.function.name`/`.arguments`)
matching the OpenAI SDK streaming type, ran it through
`parse_message_to_blocks` and `find_tool_units` before and after the
change, then ran the parser suite.
- Observed result: before the fix I got `AttributeError:
'ChoiceDeltaToolCall' object has no attribute 'get'` — the exact error
from the issue. After the fix the same input produces a proper
`tool_call` block (correct `tool_call_id` / `function_name`) and
`find_tool_units` pairs the assistant call with its tool response.
Parser suite is green at 93 passed.
- Not tested: a full live `agent.run(stream=True)` against a real
OpenAI-compatible backend, since agno isn't installed here. That path is
covered by the Agno test in CI. I reproduced the failure at the parser
boundary instead, which is where the actual crash happens.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
- No docs change — this is an internal robustness fix at the parsing
boundary, no user-facing API.
- CHANGELOG.md is generated from the Conventional Commit subject via
release-please, so the `fix(agno):` commit gets picked up on its own.
- I went with two layers (parser + the Agno boundary) on purpose so
neither our pipeline nor Agno's re-serialization can hit it. Since the
parser helper is shared, the proxy/langchain/strands paths are covered
too.
2026-06-24 20:22:15 +05:30
|
|
|
_coerce_tool_call_to_dict,
|
2026-01-06 23:16:58 -08:00
|
|
|
compute_hash,
|
|
|
|
|
detect_waste_signals,
|
2026-01-10 15:33:44 -08:00
|
|
|
find_tool_units,
|
|
|
|
|
get_message_content_text,
|
2026-01-06 23:16:58 -08:00
|
|
|
is_rag_content,
|
|
|
|
|
parse_message_to_blocks,
|
|
|
|
|
parse_messages,
|
|
|
|
|
)
|
|
|
|
|
|
fix(agno): tolerate streaming tool-call SDK objects in parser (#1312) (#1336)
## Description
`HeadroomAgnoModel` blows up as soon as you stream a response that
includes a tool call:
```
ERROR Error in Agent run: 'ChoiceDeltaToolCall' object has no attribute 'get'
```
When streaming, Agno hands us `Message.tool_calls` as the raw OpenAI SDK
objects (`ChoiceDeltaToolCall`), not the OpenAI-style dicts we get on
the non-streaming path. Those objects are pydantic models — attribute
access only, no `.get()`. Our shared parser in `headroom/parser.py`
walks `tool_calls` and calls `tc.get("function", {})` / `tc.get("id")`,
so it throws `AttributeError`, and the Agno wrapper surfaces that as a
`RunErrorEvent` that kills the run.
I reproduced the exact error against `parse_message_to_blocks` with a
stand-in object before writing the fix.
Closes #1312
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `parser.py`: added a small `_coerce_tool_call_to_dict()` helper that
takes a tool_call which might be a dict or a provider SDK object and
returns the canonical OpenAI dict (reading `.function.name` /
`.function.arguments` via `getattr`). Wired it into both `.get()` sites,
`parse_message_to_blocks` and `find_tool_units`. Dicts pass straight
through (same object, no copy); `None` or anything unexpected degrades
to `{}` instead of raising. The proxy, langchain, and strands
integrations go through this same parser, so they get the same
hardening.
- `integrations/agno/model.py`: normalize `tool_calls` to dicts in
`_convert_messages_to_openai`, so the Agno `Message` objects we rebuild
and hand back also carry clean dicts and Agno's own re-serialization
can't trip over the same thing.
## Testing
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_parser.py -q
93 passed
# 87 existing + 6 new regression tests in TestStreamingToolCallObjects.
$ python -m pytest tests/test_integrations/agno/test_model.py -q
59 skipped
# These skip locally because agno isn't installed here
# (pytestmark = skipif(not AGNO_AVAILABLE)); they run in CI. The new
# test_convert_messages_normalizes_streaming_tool_call_objects is in this file.
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, local clone. agno and the Rust
`headroom._core` extension aren't installed/built in this checkout.
- Exact command / steps: built a stand-in `ChoiceDeltaToolCall`
(attribute access, no `.get()`, nested `.function.name`/`.arguments`)
matching the OpenAI SDK streaming type, ran it through
`parse_message_to_blocks` and `find_tool_units` before and after the
change, then ran the parser suite.
- Observed result: before the fix I got `AttributeError:
'ChoiceDeltaToolCall' object has no attribute 'get'` — the exact error
from the issue. After the fix the same input produces a proper
`tool_call` block (correct `tool_call_id` / `function_name`) and
`find_tool_units` pairs the assistant call with its tool response.
Parser suite is green at 93 passed.
- Not tested: a full live `agent.run(stream=True)` against a real
OpenAI-compatible backend, since agno isn't installed here. That path is
covered by the Agno test in CI. I reproduced the failure at the parser
boundary instead, which is where the actual crash happens.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
- No docs change — this is an internal robustness fix at the parsing
boundary, no user-facing API.
- CHANGELOG.md is generated from the Conventional Commit subject via
release-please, so the `fix(agno):` commit gets picked up on its own.
- I went with two layers (parser + the Agno boundary) on purpose so
neither our pipeline nor Agno's re-serialization can hit it. Since the
parser helper is shared, the proxy/langchain/strands paths are covered
too.
2026-06-24 20:22:15 +05:30
|
|
|
# --- Streaming SDK tool-call objects (issue #1312) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeDeltaToolCallFunction:
|
|
|
|
|
"""Mimics openai.types...ChoiceDeltaToolCallFunction: attribute access,
|
|
|
|
|
no `.get()`."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, name: str, arguments: str) -> None:
|
|
|
|
|
self.name = name
|
|
|
|
|
self.arguments = arguments
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _FakeChoiceDeltaToolCall:
|
|
|
|
|
"""Mimics the OpenAI SDK streaming tool-call object that the Agno
|
|
|
|
|
wrapper surfaces. It is a Pydantic-style model — attribute access only,
|
|
|
|
|
crucially with NO `.get()` — which is exactly what triggered issue
|
|
|
|
|
#1312 (`'ChoiceDeltaToolCall' object has no attribute 'get'`)."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, id: str, name: str, arguments: str, index: int = 0) -> None:
|
|
|
|
|
self.id = id
|
|
|
|
|
self.index = index
|
|
|
|
|
self.type = "function"
|
|
|
|
|
self.function = _FakeDeltaToolCallFunction(name, arguments)
|
|
|
|
|
|
|
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
# --- Fixtures ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_tokenizer():
|
|
|
|
|
"""Mock tokenizer that returns predictable token counts."""
|
|
|
|
|
tokenizer = Mock()
|
|
|
|
|
# Simple mock: 1 token per 4 characters
|
|
|
|
|
tokenizer.count_text = Mock(side_effect=lambda text: len(text) // 4 + 1)
|
|
|
|
|
return tokenizer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def system_message():
|
|
|
|
|
"""Basic system message."""
|
|
|
|
|
return {"role": "system", "content": "You are a helpful assistant."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def user_message():
|
|
|
|
|
"""Basic user message."""
|
|
|
|
|
return {"role": "user", "content": "Hello, how are you?"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def assistant_message():
|
|
|
|
|
"""Basic assistant message."""
|
|
|
|
|
return {"role": "assistant", "content": "I'm doing well, thank you!"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def tool_call_message():
|
|
|
|
|
"""Assistant message with tool calls."""
|
|
|
|
|
return {
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": "call_abc123",
|
|
|
|
|
"type": "function",
|
2026-01-10 15:33:44 -08:00
|
|
|
"function": {"name": "search_user", "arguments": '{"user_id": "12345"}'},
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def tool_result_message():
|
|
|
|
|
"""Tool result message."""
|
|
|
|
|
return {
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "call_abc123",
|
2026-01-10 15:33:44 -08:00
|
|
|
"content": '{"id": "12345", "name": "Alice", "email": "alice@example.com"}',
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def multimodal_message():
|
|
|
|
|
"""User message with multimodal content (list format)."""
|
|
|
|
|
return {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "Analyze this image:"},
|
|
|
|
|
{"type": "image", "source": {"type": "base64", "data": "..."}},
|
2026-01-10 15:33:44 -08:00
|
|
|
{"type": "text", "text": "What do you see?"},
|
|
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def rag_user_message():
|
|
|
|
|
"""User message containing RAG content markers."""
|
|
|
|
|
return {
|
|
|
|
|
"role": "user",
|
2026-01-10 15:33:44 -08:00
|
|
|
"content": "[Document 1] Here is the relevant context from our knowledge base. [Source: docs/manual.md]",
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def html_waste_text():
|
|
|
|
|
"""Text containing HTML noise."""
|
|
|
|
|
return "<div class='container'><p>Hello</p><!-- comment --></div>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def base64_waste_text():
|
|
|
|
|
"""Text containing base64 encoded data."""
|
|
|
|
|
return "Data: " + "A" * 60 + "=="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def whitespace_waste_text():
|
|
|
|
|
"""Text with excessive whitespace."""
|
|
|
|
|
return "Line 1\n\n\n\nLine 2 extra spaces"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def json_bloat_text():
|
|
|
|
|
"""Text containing large JSON block (>500 chars).
|
|
|
|
|
|
|
|
|
|
Uses spaces and punctuation to avoid base64 pattern matching.
|
|
|
|
|
"""
|
|
|
|
|
# Use content that won't match base64 pattern (needs non-base64 chars)
|
|
|
|
|
content = "This is a long text value. " * 25 # ~675 chars
|
|
|
|
|
return '{"data": "' + content + '"}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- TestComputeHash ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestComputeHash:
|
|
|
|
|
"""Tests for compute_hash function."""
|
|
|
|
|
|
|
|
|
|
def test_consistent_hash(self):
|
|
|
|
|
"""Same text produces same hash."""
|
|
|
|
|
text = "Hello, world!"
|
|
|
|
|
hash1 = compute_hash(text)
|
|
|
|
|
hash2 = compute_hash(text)
|
|
|
|
|
assert hash1 == hash2
|
|
|
|
|
|
|
|
|
|
def test_different_texts_different_hashes(self):
|
|
|
|
|
"""Different texts produce different hashes."""
|
|
|
|
|
hash1 = compute_hash("Hello")
|
|
|
|
|
hash2 = compute_hash("World")
|
|
|
|
|
assert hash1 != hash2
|
|
|
|
|
|
|
|
|
|
def test_hash_length_16(self):
|
|
|
|
|
"""Hash is truncated to 16 characters."""
|
|
|
|
|
text = "Any text content"
|
|
|
|
|
hash_result = compute_hash(text)
|
|
|
|
|
assert len(hash_result) == 16
|
|
|
|
|
|
|
|
|
|
def test_empty_string_hash(self):
|
|
|
|
|
"""Empty string produces valid hash."""
|
|
|
|
|
hash_result = compute_hash("")
|
|
|
|
|
assert len(hash_result) == 16
|
|
|
|
|
assert hash_result.isalnum()
|
|
|
|
|
|
|
|
|
|
def test_unicode_text_hash(self):
|
|
|
|
|
"""Unicode text produces valid hash."""
|
|
|
|
|
hash_result = compute_hash("Hello \\u4e16\\u754c")
|
|
|
|
|
assert len(hash_result) == 16
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- TestDetectWasteSignals ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestDetectWasteSignals:
|
|
|
|
|
"""Tests for detect_waste_signals function."""
|
|
|
|
|
|
|
|
|
|
def test_detect_html_tags(self, mock_tokenizer, html_waste_text):
|
|
|
|
|
"""Detects HTML tags as waste."""
|
|
|
|
|
signals = detect_waste_signals(html_waste_text, mock_tokenizer)
|
|
|
|
|
assert signals.html_noise_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_detect_html_comments(self, mock_tokenizer):
|
|
|
|
|
"""Detects HTML comments as waste."""
|
|
|
|
|
text = "Some text <!-- this is a comment --> more text"
|
|
|
|
|
signals = detect_waste_signals(text, mock_tokenizer)
|
|
|
|
|
assert signals.html_noise_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_detect_base64(self, mock_tokenizer, base64_waste_text):
|
|
|
|
|
"""Detects base64 encoded content as waste."""
|
|
|
|
|
signals = detect_waste_signals(base64_waste_text, mock_tokenizer)
|
|
|
|
|
assert signals.base64_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_detect_excessive_whitespace(self, mock_tokenizer, whitespace_waste_text):
|
2026-06-17 21:44:25 -03:00
|
|
|
"""Detects excessive whitespace as waste.
|
|
|
|
|
|
|
|
|
|
The fixture has a 4-newline run and a 6-space run. Each run collapses
|
|
|
|
|
to a single space when normalized, so there are real savings to report:
|
|
|
|
|
ws_text "\\n\\n\\n\\n " (10 chars -> 3 tokens) vs normalized " "
|
|
|
|
|
(2 chars -> 1 token) = 2 tokens saved with the mock tokenizer.
|
|
|
|
|
"""
|
2026-01-06 23:16:58 -08:00
|
|
|
signals = detect_waste_signals(whitespace_waste_text, mock_tokenizer)
|
2026-06-17 21:44:25 -03:00
|
|
|
assert signals.whitespace_tokens == 2
|
|
|
|
|
|
|
|
|
|
def test_whitespace_savings_scale_with_run_length(self, mock_tokenizer):
|
|
|
|
|
"""Long whitespace runs report proportionally large savings.
|
|
|
|
|
|
|
|
|
|
Regression guard: normalizing must collapse each matched run to a
|
|
|
|
|
single space, not just join the runs together (which left the runs
|
|
|
|
|
intact and made the signal always report 0).
|
|
|
|
|
"""
|
|
|
|
|
text = "ERROR" + " " * 200 + "stack" + "\n" * 60 + "end"
|
|
|
|
|
signals = detect_waste_signals(text, mock_tokenizer)
|
|
|
|
|
# 260 whitespace chars collapse to 2 spaces; with ~1 token/4 chars the
|
|
|
|
|
# savings are large and clearly non-zero.
|
|
|
|
|
assert signals.whitespace_tokens > 50
|
2026-01-06 23:16:58 -08:00
|
|
|
|
|
|
|
|
def test_detect_json_bloat(self, mock_tokenizer, json_bloat_text):
|
|
|
|
|
"""Detects large JSON blocks as bloat."""
|
|
|
|
|
# Need to ensure the mock returns >500 tokens for JSON bloat
|
|
|
|
|
# The JSON pattern requires the matched block to have >500 tokens
|
|
|
|
|
mock_tokenizer.count_text = Mock(side_effect=lambda text: len(text))
|
|
|
|
|
signals = detect_waste_signals(json_bloat_text, mock_tokenizer)
|
|
|
|
|
assert signals.json_bloat_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_empty_text_no_waste(self, mock_tokenizer):
|
|
|
|
|
"""Empty text returns zero waste signals."""
|
|
|
|
|
signals = detect_waste_signals("", mock_tokenizer)
|
|
|
|
|
assert signals.total() == 0
|
|
|
|
|
|
|
|
|
|
def test_combined_waste_signals(self, mock_tokenizer):
|
|
|
|
|
"""Multiple waste types are detected together."""
|
|
|
|
|
text = "<div>Hello</div> " + "B" * 60 + "== and <!-- comment -->"
|
|
|
|
|
signals = detect_waste_signals(text, mock_tokenizer)
|
|
|
|
|
assert signals.html_noise_tokens > 0
|
|
|
|
|
assert signals.base64_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_clean_text_no_waste(self, mock_tokenizer):
|
|
|
|
|
"""Clean text produces minimal waste signals."""
|
|
|
|
|
text = "This is a normal sentence without any waste."
|
|
|
|
|
signals = detect_waste_signals(text, mock_tokenizer)
|
|
|
|
|
assert signals.html_noise_tokens == 0
|
|
|
|
|
assert signals.base64_tokens == 0
|
|
|
|
|
assert signals.json_bloat_tokens == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- TestIsRagContent ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestIsRagContent:
|
|
|
|
|
"""Tests for is_rag_content function."""
|
|
|
|
|
|
|
|
|
|
def test_document_markers(self):
|
|
|
|
|
"""Detects [Document N] markers."""
|
|
|
|
|
text = "[Document 1] This is the first document. [Document 2] Second document."
|
|
|
|
|
assert is_rag_content(text) is True
|
|
|
|
|
|
|
|
|
|
def test_source_markers(self):
|
|
|
|
|
"""Detects [Source: ...] markers."""
|
|
|
|
|
text = "[Source: knowledge_base/docs.md] Here is the information."
|
|
|
|
|
assert is_rag_content(text) is True
|
|
|
|
|
|
|
|
|
|
def test_context_tags(self):
|
|
|
|
|
"""Detects <context> and <document> tags."""
|
|
|
|
|
assert is_rag_content("<context>Retrieved content here</context>") is True
|
|
|
|
|
assert is_rag_content("<document>Document content</document>") is True
|
|
|
|
|
|
|
|
|
|
def test_retrieved_from_marker(self):
|
|
|
|
|
"""Detects 'Retrieved from:' marker."""
|
|
|
|
|
text = "Retrieved from: https://example.com/docs\nHere is the content."
|
|
|
|
|
assert is_rag_content(text) is True
|
|
|
|
|
|
|
|
|
|
def test_knowledge_base_marker(self):
|
|
|
|
|
"""Detects 'From the knowledge base:' marker."""
|
|
|
|
|
text = "From the knowledge base: This is relevant information."
|
|
|
|
|
assert is_rag_content(text) is True
|
|
|
|
|
|
|
|
|
|
def test_not_rag_content(self):
|
|
|
|
|
"""Regular text is not detected as RAG content."""
|
|
|
|
|
text = "Hello, how can I help you today?"
|
|
|
|
|
assert is_rag_content(text) is False
|
|
|
|
|
|
|
|
|
|
def test_case_insensitive(self):
|
|
|
|
|
"""RAG detection is case insensitive."""
|
|
|
|
|
assert is_rag_content("[DOCUMENT 1] Content") is True
|
|
|
|
|
assert is_rag_content("retrieved FROM: somewhere") is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- TestParseMessageToBlocks ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestParseMessageToBlocks:
|
|
|
|
|
"""Tests for parse_message_to_blocks function."""
|
|
|
|
|
|
|
|
|
|
def test_system_message_block(self, mock_tokenizer, system_message):
|
|
|
|
|
"""System message creates system block."""
|
|
|
|
|
blocks = parse_message_to_blocks(system_message, 0, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "system"
|
|
|
|
|
assert blocks[0].text == "You are a helpful assistant."
|
|
|
|
|
assert blocks[0].source_index == 0
|
|
|
|
|
|
|
|
|
|
def test_user_message_block(self, mock_tokenizer, user_message):
|
|
|
|
|
"""User message creates user block."""
|
|
|
|
|
blocks = parse_message_to_blocks(user_message, 1, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "user"
|
|
|
|
|
assert blocks[0].text == "Hello, how are you?"
|
|
|
|
|
assert blocks[0].source_index == 1
|
|
|
|
|
|
|
|
|
|
def test_assistant_message_block(self, mock_tokenizer, assistant_message):
|
|
|
|
|
"""Assistant message creates assistant block."""
|
|
|
|
|
blocks = parse_message_to_blocks(assistant_message, 2, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "assistant"
|
|
|
|
|
assert blocks[0].text == "I'm doing well, thank you!"
|
|
|
|
|
|
|
|
|
|
def test_tool_result_block(self, mock_tokenizer, tool_result_message):
|
|
|
|
|
"""Tool result creates tool_result block with tool_call_id."""
|
|
|
|
|
blocks = parse_message_to_blocks(tool_result_message, 3, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "tool_result"
|
|
|
|
|
assert blocks[0].flags.get("tool_call_id") == "call_abc123"
|
|
|
|
|
|
|
|
|
|
def test_rag_detection_in_user_message(self, mock_tokenizer, rag_user_message):
|
|
|
|
|
"""User message with RAG markers creates rag block."""
|
|
|
|
|
blocks = parse_message_to_blocks(rag_user_message, 0, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "rag"
|
|
|
|
|
|
|
|
|
|
def test_multimodal_content(self, mock_tokenizer, multimodal_message):
|
|
|
|
|
"""Multimodal content (list with text parts) is extracted."""
|
|
|
|
|
blocks = parse_message_to_blocks(multimodal_message, 0, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert "Analyze this image:" in blocks[0].text
|
|
|
|
|
assert "What do you see?" in blocks[0].text
|
|
|
|
|
|
|
|
|
|
def test_tool_calls_create_separate_blocks(self, mock_tokenizer, tool_call_message):
|
|
|
|
|
"""Tool calls create separate tool_call blocks."""
|
|
|
|
|
blocks = parse_message_to_blocks(tool_call_message, 0, mock_tokenizer)
|
|
|
|
|
# Should have tool_call blocks (no content block since content is None)
|
|
|
|
|
tool_call_blocks = [b for b in blocks if b.kind == "tool_call"]
|
|
|
|
|
assert len(tool_call_blocks) == 1
|
|
|
|
|
assert tool_call_blocks[0].flags.get("tool_call_id") == "call_abc123"
|
|
|
|
|
assert tool_call_blocks[0].flags.get("function_name") == "search_user"
|
|
|
|
|
assert "search_user" in tool_call_blocks[0].text
|
|
|
|
|
|
|
|
|
|
def test_empty_message_creates_block(self, mock_tokenizer):
|
|
|
|
|
"""Empty message (no content or tool_calls) creates minimal block."""
|
|
|
|
|
empty_msg = {"role": "assistant"}
|
|
|
|
|
blocks = parse_message_to_blocks(empty_msg, 0, mock_tokenizer)
|
|
|
|
|
assert len(blocks) == 1
|
|
|
|
|
assert blocks[0].kind == "unknown"
|
|
|
|
|
assert blocks[0].text == ""
|
|
|
|
|
|
|
|
|
|
def test_message_with_content_and_tool_calls(self, mock_tokenizer):
|
|
|
|
|
"""Message with both content and tool_calls creates multiple blocks."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": "Let me search for that.",
|
2026-01-10 15:33:44 -08:00
|
|
|
"tool_calls": [{"id": "call_xyz", "function": {"name": "search", "arguments": "{}"}}],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
blocks = parse_message_to_blocks(msg, 0, mock_tokenizer)
|
|
|
|
|
kinds = [b.kind for b in blocks]
|
|
|
|
|
assert "assistant" in kinds
|
|
|
|
|
assert "tool_call" in kinds
|
|
|
|
|
|
|
|
|
|
def test_waste_signals_in_flags(self, mock_tokenizer, html_waste_text):
|
|
|
|
|
"""Waste signals are added to block flags."""
|
|
|
|
|
msg = {"role": "user", "content": html_waste_text}
|
|
|
|
|
blocks = parse_message_to_blocks(msg, 0, mock_tokenizer)
|
|
|
|
|
assert "waste_signals" in blocks[0].flags
|
|
|
|
|
assert blocks[0].flags["waste_signals"]["html_noise"] > 0
|
|
|
|
|
|
|
|
|
|
def test_content_hash_generated(self, mock_tokenizer, user_message):
|
|
|
|
|
"""Content hash is generated for blocks."""
|
|
|
|
|
blocks = parse_message_to_blocks(user_message, 0, mock_tokenizer)
|
|
|
|
|
assert len(blocks[0].content_hash) == 16
|
|
|
|
|
|
|
|
|
|
def test_tokens_estimated(self, mock_tokenizer, user_message):
|
|
|
|
|
"""Token count is estimated."""
|
|
|
|
|
blocks = parse_message_to_blocks(user_message, 0, mock_tokenizer)
|
|
|
|
|
assert blocks[0].tokens_est > 0
|
|
|
|
|
|
|
|
|
|
|
fix(agno): tolerate streaming tool-call SDK objects in parser (#1312) (#1336)
## Description
`HeadroomAgnoModel` blows up as soon as you stream a response that
includes a tool call:
```
ERROR Error in Agent run: 'ChoiceDeltaToolCall' object has no attribute 'get'
```
When streaming, Agno hands us `Message.tool_calls` as the raw OpenAI SDK
objects (`ChoiceDeltaToolCall`), not the OpenAI-style dicts we get on
the non-streaming path. Those objects are pydantic models — attribute
access only, no `.get()`. Our shared parser in `headroom/parser.py`
walks `tool_calls` and calls `tc.get("function", {})` / `tc.get("id")`,
so it throws `AttributeError`, and the Agno wrapper surfaces that as a
`RunErrorEvent` that kills the run.
I reproduced the exact error against `parse_message_to_blocks` with a
stand-in object before writing the fix.
Closes #1312
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `parser.py`: added a small `_coerce_tool_call_to_dict()` helper that
takes a tool_call which might be a dict or a provider SDK object and
returns the canonical OpenAI dict (reading `.function.name` /
`.function.arguments` via `getattr`). Wired it into both `.get()` sites,
`parse_message_to_blocks` and `find_tool_units`. Dicts pass straight
through (same object, no copy); `None` or anything unexpected degrades
to `{}` instead of raising. The proxy, langchain, and strands
integrations go through this same parser, so they get the same
hardening.
- `integrations/agno/model.py`: normalize `tool_calls` to dicts in
`_convert_messages_to_openai`, so the Agno `Message` objects we rebuild
and hand back also carry clean dicts and Agno's own re-serialization
can't trip over the same thing.
## Testing
- [x] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ python -m pytest tests/test_parser.py -q
93 passed
# 87 existing + 6 new regression tests in TestStreamingToolCallObjects.
$ python -m pytest tests/test_integrations/agno/test_model.py -q
59 skipped
# These skip locally because agno isn't installed here
# (pytestmark = skipif(not AGNO_AVAILABLE)); they run in CI. The new
# test_convert_messages_normalizes_streaming_tool_call_objects is in this file.
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.10, local clone. agno and the Rust
`headroom._core` extension aren't installed/built in this checkout.
- Exact command / steps: built a stand-in `ChoiceDeltaToolCall`
(attribute access, no `.get()`, nested `.function.name`/`.arguments`)
matching the OpenAI SDK streaming type, ran it through
`parse_message_to_blocks` and `find_tool_units` before and after the
change, then ran the parser suite.
- Observed result: before the fix I got `AttributeError:
'ChoiceDeltaToolCall' object has no attribute 'get'` — the exact error
from the issue. After the fix the same input produces a proper
`tool_call` block (correct `tool_call_id` / `function_name`) and
`find_tool_units` pairs the assistant call with its tool response.
Parser suite is green at 93 passed.
- Not tested: a full live `agent.run(stream=True)` against a real
OpenAI-compatible backend, since agno isn't installed here. That path is
covered by the Agno test in CI. I reproduced the failure at the parser
boundary instead, which is where the actual crash happens.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
- No docs change — this is an internal robustness fix at the parsing
boundary, no user-facing API.
- CHANGELOG.md is generated from the Conventional Commit subject via
release-please, so the `fix(agno):` commit gets picked up on its own.
- I went with two layers (parser + the Agno boundary) on purpose so
neither our pipeline nor Agno's re-serialization can hit it. Since the
parser helper is shared, the proxy/langchain/strands paths are covered
too.
2026-06-24 20:22:15 +05:30
|
|
|
class TestStreamingToolCallObjects:
|
|
|
|
|
"""Regression coverage for issue #1312: streaming integrations (Agno
|
|
|
|
|
over OpenAILike) can hand the parser raw OpenAI SDK `ChoiceDeltaToolCall`
|
|
|
|
|
objects instead of OpenAI-format dicts. The parser called `.get()` on
|
|
|
|
|
them and crashed the whole agent run with
|
|
|
|
|
`'ChoiceDeltaToolCall' object has no attribute 'get'`. Both the parser
|
|
|
|
|
call sites must now tolerate attribute-style tool-call objects."""
|
|
|
|
|
|
|
|
|
|
def test_coerce_dict_is_passthrough_identity(self):
|
|
|
|
|
d = {"id": "call_1", "function": {"name": "f", "arguments": "{}"}}
|
|
|
|
|
# A dict must be returned untouched (same object) — no needless copy.
|
|
|
|
|
assert _coerce_tool_call_to_dict(d) is d
|
|
|
|
|
|
|
|
|
|
def test_coerce_sdk_object_flattens_to_openai_dict(self):
|
|
|
|
|
tc = _FakeChoiceDeltaToolCall("call_1", "search", '{"q": "x"}')
|
|
|
|
|
out = _coerce_tool_call_to_dict(tc)
|
|
|
|
|
assert out == {
|
|
|
|
|
"id": "call_1",
|
|
|
|
|
"type": "function",
|
|
|
|
|
"function": {"name": "search", "arguments": '{"q": "x"}'},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def test_coerce_object_with_dict_function(self):
|
|
|
|
|
# Some providers nest a dict `function` on an attribute-style object.
|
|
|
|
|
class _TC:
|
|
|
|
|
id = "call_2"
|
|
|
|
|
type = "function"
|
|
|
|
|
function = {"name": "g", "arguments": "1"}
|
|
|
|
|
|
|
|
|
|
out = _coerce_tool_call_to_dict(_TC())
|
|
|
|
|
assert out["function"] == {"name": "g", "arguments": "1"}
|
|
|
|
|
|
|
|
|
|
def test_coerce_none_degrades_to_empty_dict(self):
|
|
|
|
|
assert _coerce_tool_call_to_dict(None) == {}
|
|
|
|
|
|
|
|
|
|
def test_parse_message_to_blocks_with_sdk_tool_call(self, mock_tokenizer):
|
|
|
|
|
"""The original crash site: parsing an assistant message whose
|
|
|
|
|
tool_calls are SDK objects must produce a tool_call block, not
|
|
|
|
|
raise AttributeError."""
|
|
|
|
|
tc = _FakeChoiceDeltaToolCall("call_abc", "dummy_tool", '{"query": "test"}')
|
|
|
|
|
msg = {"role": "assistant", "content": "", "tool_calls": [tc]}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(msg, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
tool_call_blocks = [b for b in blocks if b.kind == "tool_call"]
|
|
|
|
|
assert len(tool_call_blocks) == 1
|
|
|
|
|
assert tool_call_blocks[0].flags.get("tool_call_id") == "call_abc"
|
|
|
|
|
assert tool_call_blocks[0].flags.get("function_name") == "dummy_tool"
|
|
|
|
|
assert "dummy_tool" in tool_call_blocks[0].text
|
|
|
|
|
|
|
|
|
|
def test_find_tool_units_with_sdk_tool_call(self):
|
|
|
|
|
"""The second `.get()` site: find_tool_units must still pair an
|
|
|
|
|
SDK-object tool_call with its tool response message."""
|
|
|
|
|
tc = _FakeChoiceDeltaToolCall("call_abc", "dummy_tool", "{}")
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "assistant", "content": "", "tool_calls": [tc]},
|
|
|
|
|
{"role": "tool", "content": "result", "tool_call_id": "call_abc"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
|
|
|
|
|
assert units == [(0, [1])]
|
|
|
|
|
|
|
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
# --- TestParseMessages ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestParseMessages:
|
|
|
|
|
"""Tests for parse_messages function."""
|
|
|
|
|
|
|
|
|
|
def test_parse_all_messages(self, mock_tokenizer, sample_messages):
|
|
|
|
|
"""All messages are parsed into blocks."""
|
|
|
|
|
blocks, breakdown, waste = parse_messages(sample_messages, mock_tokenizer)
|
|
|
|
|
assert len(blocks) >= len(sample_messages)
|
|
|
|
|
|
|
|
|
|
def test_block_breakdown(self, mock_tokenizer, sample_messages):
|
|
|
|
|
"""Block breakdown counts tokens per kind."""
|
|
|
|
|
blocks, breakdown, waste = parse_messages(sample_messages, mock_tokenizer)
|
|
|
|
|
assert "system" in breakdown
|
|
|
|
|
assert "user" in breakdown
|
|
|
|
|
assert "assistant" in breakdown
|
|
|
|
|
assert all(v > 0 for v in breakdown.values())
|
|
|
|
|
|
|
|
|
|
def test_waste_signals_accumulated(self, mock_tokenizer):
|
|
|
|
|
"""Waste signals are accumulated across messages."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "<div>HTML here</div>"},
|
|
|
|
|
{"role": "assistant", "content": "More <span>HTML</span>"},
|
|
|
|
|
]
|
|
|
|
|
blocks, breakdown, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.html_noise_tokens > 0
|
|
|
|
|
|
|
|
|
|
def test_empty_messages(self, mock_tokenizer):
|
|
|
|
|
"""Empty message list returns empty results."""
|
|
|
|
|
blocks, breakdown, waste = parse_messages([], mock_tokenizer)
|
|
|
|
|
assert blocks == []
|
|
|
|
|
assert breakdown == {}
|
|
|
|
|
assert waste.total() == 0
|
|
|
|
|
|
|
|
|
|
def test_multiple_tool_calls_parsed(self, mock_tokenizer, sample_messages_with_tools):
|
|
|
|
|
"""Messages with tool calls are parsed correctly."""
|
|
|
|
|
blocks, breakdown, waste = parse_messages(sample_messages_with_tools, mock_tokenizer)
|
|
|
|
|
tool_call_blocks = [b for b in blocks if b.kind == "tool_call"]
|
|
|
|
|
tool_result_blocks = [b for b in blocks if b.kind == "tool_result"]
|
|
|
|
|
assert len(tool_call_blocks) >= 1
|
|
|
|
|
assert len(tool_result_blocks) >= 1
|
|
|
|
|
|
|
|
|
|
|
feat: detect re-served tool results as over-compression waste signal (#854)
Closes #853
## What
Adds a `reread` waste signal: identical `tool_result` content appearing
at more than one message position means the agent re-fetched something
already in context — the dominant failure signature of over-compression
(Manus context-engineering; JetBrains "Complexity Trap",
arXiv:2508.21433). Per-request savings can't see this cost; this signal
makes it visible.
- `WasteSignals.reread_tokens` — new field, in `total()`, exported as
`"reread"` in `to_dict()`.
- `parse_messages()` groups `tool_result` blocks by their **existing**
`content_hash` and counts every repeat beyond the first serve. No new
hashing or tokenization; one O(blocks) dict pass.
- `REREAD_MIN_TOKENS = 50` guard: short outputs ("ok", empty diffs)
legitimately repeat and are skipped. Duplicates within a single message
(same `source_index`) are not counted.
- Works across all formats the parser already normalizes to
`tool_result` blocks: OpenAI `role=tool`, Anthropic `tool_result`,
Strands/Bedrock `toolResult` (#813/#815).
- Flows through existing generic plumbing with zero handler changes:
pipeline → `RequestOutcome.waste_signals` → Prometheus
`headroom_waste_signal_tokens_total{signal="reread"}` → dashboard "Waste
Detected" panel. Dashboard gains label/color entries for the new key.
## Tests
7 new tests in `tests/test_parser.py::TestRereadDetection` (red before,
green after): OpenAI + Anthropic format detection, repeat-counting
semantics (first serve free), single-occurrence, short-duplicate guard,
same-message guard, `total()`/`to_dict()` participation. Updated 2
exact-shape assertions in `tests/test_config.py`.
Local runs: `tests/test_parser.py` (72 passed), `tests/test_config.py` +
outcome/reporting/observability/storage/proxy-hooks suites (190 passed),
`tests/test_canonical_pipeline.py` +
`tests/test_proxy_pipeline_lifecycle.py` (11 passed). `ruff check` +
`ruff format --check` clean.
## Real behavior proof
**Setup:** macOS (Darwin 25.5), Python 3.11.9, this branch, real proxy
server (`python -m headroom.proxy.server --port 18970
--anthropic-api-url http://127.0.0.1:18971`) with a local mock Anthropic
upstream returning a canned `/v1/messages` response (no real key
needed).
**Steps:** POSTed an Anthropic-format conversation to the live proxy:
agent fetches a 14 KB JSON log array via `get_logs` tool, then fetches
the identical content again under a different `tool_use_id` (the
re-read).
**Observed result** — `curl http://127.0.0.1:18970/metrics` after the
request:
```
# HELP headroom_waste_signal_tokens_total Tokens attributed to detected waste signals
# TYPE headroom_waste_signal_tokens_total counter
headroom_waste_signal_tokens_total{signal="json_bloat"} 9858
headroom_waste_signal_tokens_total{signal="reread"} 4935
```
`reread` = 4935 tokens, exactly the second serve of the ~4.9k-token tool
result (json_bloat counts both occurrences ≈ 2×). `/stats` shows the
same: `"waste_signals": {"json_bloat": 9858, "reread": 4935, ...}` —
which is what the dashboard panel renders.
Also verified the negative path live: a conversation whose tool results
contain non-compressible plain code text produced no waste-signal
entries (the pipeline only attributes waste when compression actually
engaged, unchanged behavior).
**Not tested:** Gemini `functionResponse` path (parser doesn't produce
`tool_result` blocks for it — pre-existing gap tracked in #819);
dashboard rendering only verified via the `/stats` payload the panel
binds to, not a browser screenshot.
## Out of scope (per #853)
Tool-call argument matching, compression-marker attribution,
tokens-per-task metric, cache hit-rate panel.
---------
Co-authored-by: Ash Rhodes <ashley.rhodes@king.com>
2026-06-11 20:07:04 +02:00
|
|
|
# --- TestRereadDetection ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRereadDetection:
|
|
|
|
|
"""Tests for cross-message re-read detection in parse_messages."""
|
|
|
|
|
|
|
|
|
|
LARGE_CONTENT = "def handler(event):\n return process(event)\n" * 10 # > 200 chars
|
|
|
|
|
|
|
|
|
|
def _expected_tokens(self, text):
|
|
|
|
|
"""Mirror mock_tokenizer + message overhead used for tool_result blocks."""
|
|
|
|
|
return len(text) // 4 + 1 + 4
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _filler(n):
|
|
|
|
|
"""Interleaved turns that push a repeat beyond the polling gap."""
|
|
|
|
|
return [
|
|
|
|
|
{"role": "assistant" if i % 2 == 0 else "user", "content": f"step {i} of the task"}
|
|
|
|
|
for i in range(n)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def test_reread_detected_openai_tool_messages(self, mock_tokenizer):
|
|
|
|
|
"""Identical large tool outputs far apart count as re-read."""
|
|
|
|
|
messages = (
|
|
|
|
|
[{"role": "tool", "tool_call_id": "c1", "content": self.LARGE_CONTENT}]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [{"role": "tool", "tool_call_id": "c2", "content": self.LARGE_CONTENT}]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_reread_detected_anthropic_tool_result_blocks(self, mock_tokenizer):
|
|
|
|
|
"""Anthropic-format tool_result parts are matched by content, not id."""
|
|
|
|
|
part = {"type": "tool_result", "tool_use_id": "t1", "content": self.LARGE_CONTENT}
|
|
|
|
|
part2 = {"type": "tool_result", "tool_use_id": "t2", "content": self.LARGE_CONTENT}
|
|
|
|
|
messages = (
|
|
|
|
|
[{"role": "user", "content": [part]}]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [{"role": "user", "content": [part2]}]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_three_occurrences_count_repeats_only(self, mock_tokenizer):
|
|
|
|
|
"""First serve is free; every distant repeat is counted."""
|
|
|
|
|
msg = {"role": "tool", "tool_call_id": "c", "content": self.LARGE_CONTENT}
|
|
|
|
|
messages = [dict(msg)] + self._filler(4) + [dict(msg)] + self._filler(4) + [dict(msg)]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 2 * self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_single_occurrence_no_signal(self, mock_tokenizer):
|
|
|
|
|
"""One large tool result is not a re-read."""
|
|
|
|
|
messages = [{"role": "tool", "tool_call_id": "c1", "content": self.LARGE_CONTENT}]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_short_duplicates_ignored(self, mock_tokenizer):
|
|
|
|
|
"""Trivially short outputs (\"ok\") legitimately repeat and are skipped."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "c2", "content": "ok"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "c3", "content": "ok"},
|
|
|
|
|
]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_same_message_duplicates_ignored(self, mock_tokenizer):
|
|
|
|
|
"""Duplicates within a single message are not a re-read."""
|
|
|
|
|
part = {"type": "tool_result", "tool_use_id": "t1", "content": self.LARGE_CONTENT}
|
|
|
|
|
part2 = {"type": "tool_result", "tool_use_id": "t2", "content": self.LARGE_CONTENT}
|
|
|
|
|
messages = [{"role": "user", "content": [part, part2]}]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_mixed_same_message_duplicate_not_counted(self, mock_tokenizer):
|
|
|
|
|
"""A duplicate inside the original message stays excluded even when
|
|
|
|
|
a later message also re-serves the content."""
|
|
|
|
|
part = {"type": "tool_result", "tool_use_id": "t1", "content": self.LARGE_CONTENT}
|
|
|
|
|
part2 = {"type": "tool_result", "tool_use_id": "t2", "content": self.LARGE_CONTENT}
|
|
|
|
|
part3 = {"type": "tool_result", "tool_use_id": "t3", "content": self.LARGE_CONTENT}
|
|
|
|
|
messages = (
|
|
|
|
|
[{"role": "user", "content": [part, part2]}]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [{"role": "user", "content": [part3]}]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_adjacent_polling_repeats_ignored(self, mock_tokenizer):
|
|
|
|
|
"""Back-to-back identical results (poll loop) are not re-reads."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "tool", "tool_call_id": "c1", "content": self.LARGE_CONTENT},
|
|
|
|
|
{"role": "assistant", "content": "Still pending, checking again."},
|
|
|
|
|
{"role": "tool", "tool_call_id": "c2", "content": self.LARGE_CONTENT},
|
|
|
|
|
]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_polling_chain_never_accumulates(self, mock_tokenizer):
|
|
|
|
|
"""Each poll advances the baseline — long chains stay at zero."""
|
|
|
|
|
msg = {"role": "tool", "tool_call_id": "c", "content": self.LARGE_CONTENT}
|
|
|
|
|
nudge = {"role": "assistant", "content": "polling"}
|
|
|
|
|
messages = [dict(msg), dict(nudge), dict(msg), dict(nudge), dict(msg), dict(nudge)]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_distant_repeat_after_polling_chain_counts(self, mock_tokenizer):
|
|
|
|
|
"""A far repeat counts even when earlier repeats were polling."""
|
|
|
|
|
msg = {"role": "tool", "tool_call_id": "c", "content": self.LARGE_CONTENT}
|
|
|
|
|
messages = (
|
|
|
|
|
[dict(msg), {"role": "assistant", "content": "polling"}, dict(msg)]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [dict(msg)]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_reread_in_total_and_dict(self):
|
|
|
|
|
"""reread_tokens participates in total() and to_dict()."""
|
|
|
|
|
from headroom.config import WasteSignals
|
|
|
|
|
|
|
|
|
|
ws = WasteSignals(reread_tokens=42)
|
|
|
|
|
assert ws.total() == 42
|
|
|
|
|
assert ws.to_dict()["reread"] == 42
|
|
|
|
|
|
|
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
# --- TestFindToolUnits ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestFindToolUnits:
|
|
|
|
|
"""Tests for find_tool_units function."""
|
|
|
|
|
|
|
|
|
|
def test_finds_tool_call_and_responses(self, sample_messages_with_tools):
|
|
|
|
|
"""Finds matching tool call and response pairs."""
|
|
|
|
|
units = find_tool_units(sample_messages_with_tools)
|
|
|
|
|
assert len(units) >= 1
|
|
|
|
|
# Each unit is (assistant_index, [tool_response_indices])
|
|
|
|
|
assistant_idx, response_indices = units[0]
|
|
|
|
|
assert response_indices # Should have at least one response
|
|
|
|
|
|
|
|
|
|
def test_multiple_tool_calls_same_assistant(self):
|
|
|
|
|
"""Multiple tool calls from same assistant are grouped."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Search both"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{"id": "call_1", "function": {"name": "search", "arguments": "{}"}},
|
2026-01-10 15:33:44 -08:00
|
|
|
{"id": "call_2", "function": {"name": "fetch", "arguments": "{}"}},
|
|
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "result 1"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_2", "content": "result 2"},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert len(units) == 1
|
|
|
|
|
assistant_idx, response_indices = units[0]
|
|
|
|
|
assert len(response_indices) == 2
|
|
|
|
|
|
|
|
|
|
def test_no_tool_units(self):
|
|
|
|
|
"""Returns empty list when no tool calls present."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "Hello"},
|
|
|
|
|
{"role": "user", "content": "Hi"},
|
|
|
|
|
{"role": "assistant", "content": "Hello!"},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert units == []
|
|
|
|
|
|
|
|
|
|
def test_orphaned_tool_response(self):
|
|
|
|
|
"""Tool response without matching assistant is not included."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "Hello"},
|
|
|
|
|
{"role": "user", "content": "Hi"},
|
|
|
|
|
# Orphaned tool response - no assistant with tool_calls
|
|
|
|
|
{"role": "tool", "tool_call_id": "orphan_call", "content": "orphaned"},
|
|
|
|
|
{"role": "assistant", "content": "I don't have tools."},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert units == []
|
|
|
|
|
|
|
|
|
|
def test_tool_response_order_sorted(self):
|
|
|
|
|
"""Tool response indices are sorted."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Do two things"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{"id": "call_a", "function": {"name": "first", "arguments": "{}"}},
|
|
|
|
|
{"id": "call_b", "function": {"name": "second", "arguments": "{}"}},
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_b", "content": "second result"},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_a", "content": "first result"},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert len(units) == 1
|
|
|
|
|
_, response_indices = units[0]
|
|
|
|
|
assert response_indices == sorted(response_indices)
|
|
|
|
|
|
2026-02-02 00:26:25 +05:30
|
|
|
def test_anthropic_format_tool_use_and_result(self):
|
|
|
|
|
"""Finds Anthropic format tool_use/tool_result pairs in content blocks."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "system", "content": "You are helpful."},
|
|
|
|
|
{"role": "user", "content": "Take a screenshot"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "Let me take a screenshot."},
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_use",
|
|
|
|
|
"id": "toolu_123",
|
|
|
|
|
"name": "browser_screenshot",
|
|
|
|
|
"input": {},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_123",
|
|
|
|
|
"content": "Screenshot taken successfully",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{"role": "user", "content": "Thanks!"},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert len(units) == 1
|
|
|
|
|
assistant_idx, response_indices = units[0]
|
|
|
|
|
assert assistant_idx == 2
|
|
|
|
|
assert response_indices == [3]
|
|
|
|
|
|
|
|
|
|
def test_anthropic_format_multiple_tool_uses(self):
|
|
|
|
|
"""Finds multiple Anthropic format tool_use blocks from same assistant."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Do two things"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_use", "id": "toolu_a", "name": "first", "input": {}},
|
|
|
|
|
{"type": "tool_use", "id": "toolu_b", "name": "second", "input": {}},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_a", "content": "first done"},
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_b", "content": "second done"},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert len(units) == 1
|
|
|
|
|
assistant_idx, response_indices = units[0]
|
|
|
|
|
assert assistant_idx == 1
|
|
|
|
|
assert response_indices == [2]
|
|
|
|
|
|
|
|
|
|
def test_anthropic_format_orphaned_tool_result(self):
|
|
|
|
|
"""Anthropic tool_result without matching tool_use is not included."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Hi"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "orphan_toolu",
|
|
|
|
|
"content": "orphaned result",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{"role": "assistant", "content": "Hello!"},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert units == []
|
|
|
|
|
|
|
|
|
|
def test_mixed_openai_and_anthropic_formats(self):
|
|
|
|
|
"""Both OpenAI and Anthropic formats can coexist (edge case)."""
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Do things"},
|
|
|
|
|
# OpenAI format
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
2026-02-01 14:40:29 -08:00
|
|
|
"tool_calls": [
|
|
|
|
|
{"id": "call_1", "function": {"name": "openai_tool", "arguments": "{}"}}
|
|
|
|
|
],
|
2026-02-02 00:26:25 +05:30
|
|
|
},
|
|
|
|
|
{"role": "tool", "tool_call_id": "call_1", "content": "openai result"},
|
|
|
|
|
# Anthropic format
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
2026-02-01 14:40:29 -08:00
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_use", "id": "toolu_2", "name": "anthropic_tool", "input": {}}
|
|
|
|
|
],
|
2026-02-02 00:26:25 +05:30
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
2026-02-01 14:40:29 -08:00
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_2", "content": "anthropic result"}
|
|
|
|
|
],
|
2026-02-02 00:26:25 +05:30
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
units = find_tool_units(messages)
|
|
|
|
|
assert len(units) == 2
|
|
|
|
|
# First unit: OpenAI format (assistant at 1, tool response at 2)
|
|
|
|
|
assert units[0] == (1, [2])
|
|
|
|
|
# Second unit: Anthropic format (assistant at 3, user with tool_result at 4)
|
|
|
|
|
assert units[1] == (3, [4])
|
|
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
|
|
|
|
|
# --- TestGetMessageContentText ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
class TestGetMessageContentText:
|
|
|
|
|
"""Tests for get_message_content_text function."""
|
|
|
|
|
|
|
|
|
|
def test_string_content(self):
|
|
|
|
|
"""Extracts string content directly."""
|
|
|
|
|
msg = {"role": "user", "content": "Hello, world!"}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert text == "Hello, world!"
|
|
|
|
|
|
|
|
|
|
def test_list_content(self):
|
|
|
|
|
"""Extracts text from list content (multimodal)."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "First part"},
|
|
|
|
|
{"type": "image", "source": {}},
|
|
|
|
|
{"type": "text", "text": "Second part"},
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert "First part" in text
|
|
|
|
|
assert "Second part" in text
|
|
|
|
|
|
|
|
|
|
def test_none_content(self):
|
|
|
|
|
"""Returns empty string for None content."""
|
|
|
|
|
msg = {"role": "assistant", "content": None}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert text == ""
|
|
|
|
|
|
|
|
|
|
def test_mixed_content_list(self):
|
|
|
|
|
"""Handles list with both dict and string items."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "Dict text"},
|
|
|
|
|
"Plain string",
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert "Dict text" in text
|
|
|
|
|
assert "Plain string" in text
|
|
|
|
|
|
|
|
|
|
def test_missing_content_key(self):
|
|
|
|
|
"""Returns empty string when content key is missing."""
|
|
|
|
|
msg = {"role": "user"}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert text == ""
|
|
|
|
|
|
|
|
|
|
def test_non_text_type_skipped(self):
|
|
|
|
|
"""Non-text types in list are skipped."""
|
|
|
|
|
msg = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "image", "data": "..."},
|
|
|
|
|
{"type": "text", "text": "Only this"},
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert text == "Only this"
|
|
|
|
|
|
|
|
|
|
def test_empty_list_content(self):
|
|
|
|
|
"""Empty list content returns empty string."""
|
|
|
|
|
msg = {"role": "user", "content": []}
|
|
|
|
|
text = get_message_content_text(msg)
|
|
|
|
|
assert text == ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Additional fixtures for complex tests ---
|
|
|
|
|
|
2026-01-10 15:33:44 -08:00
|
|
|
|
2026-01-06 23:16:58 -08:00
|
|
|
@pytest.fixture
|
|
|
|
|
def sample_messages():
|
|
|
|
|
"""Basic conversation messages."""
|
|
|
|
|
return [
|
|
|
|
|
{"role": "system", "content": "You are a helpful assistant."},
|
|
|
|
|
{"role": "user", "content": "Hello, how are you?"},
|
|
|
|
|
{"role": "assistant", "content": "I'm doing well, thank you!"},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def sample_messages_with_tools():
|
|
|
|
|
"""Conversation with tool calls and responses."""
|
|
|
|
|
return [
|
|
|
|
|
{"role": "system", "content": "You are a helpful assistant with tools."},
|
|
|
|
|
{"role": "user", "content": "Search for user 12345"},
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [
|
|
|
|
|
{
|
|
|
|
|
"id": "call_123",
|
|
|
|
|
"type": "function",
|
2026-01-10 15:33:44 -08:00
|
|
|
"function": {"name": "search_user", "arguments": '{"user_id": "12345"}'},
|
2026-01-06 23:16:58 -08:00
|
|
|
}
|
2026-01-10 15:33:44 -08:00
|
|
|
],
|
2026-01-06 23:16:58 -08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"role": "tool",
|
|
|
|
|
"tool_call_id": "call_123",
|
2026-01-10 15:33:44 -08:00
|
|
|
"content": '{"id": "12345", "name": "Alice", "email": "alice@example.com"}',
|
2026-01-06 23:16:58 -08:00
|
|
|
},
|
|
|
|
|
{"role": "assistant", "content": "I found user Alice with ID 12345."},
|
|
|
|
|
]
|
fix(parser): detect waste signals in Anthropic tool_result content blocks (#815)
## Description
The dashboard's "What Headroom Removed" panel (waste signals) stays
permanently empty for Anthropic-format traffic.
`parse_message_to_blocks()` only extracted text from content parts with
`type == "text"`, so the `tool_result` blocks that carry the bulk of
agentic conversations (Claude Code, and aider/cursor/copilot in
anthropic mode) were invisible to `detect_waste_signals()`. The pipeline
then reported `waste_signals=None` and `/stats` returned
`"waste_signals": {}` forever.
This PR emits a dedicated `tool_result` Block per Anthropic
`tool_result` content part — handling both string-form content and the
nested text-block-list form — with waste detection and a `tool_call_id`
pairing flag. OpenAI chat-completions behavior is unchanged (parity test
included).
Fixes #813
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/parser.py`: new `_extract_tool_result_text()` helper;
`parse_message_to_blocks()` collects `tool_result` content parts and
emits a `Block(kind="tool_result")` per part with waste signals and
`tool_call_id` flags
- `tests/test_parser.py`: 7 new tests — nested text-list form, string
form, mixed text+tool_result, empty content, non-text inner blocks,
`parse_messages` aggregation, and waste parity with the OpenAI `role:
"tool"` format
## Testing
- [x] Unit tests pass (`pytest tests/test_parser.py` — 60 passed)
- [x] Linting passes (`ruff check`, `ruff format --check`)
- [x] New tests added for new functionality
- [x] Manual testing performed (real pipeline run below)
Also ran `tests/test_pipeline.py`, `tests/test_canonical_pipeline.py`,
`tests/test_proxy_pipeline_lifecycle.py`: 3 failures there are
pre-existing on a clean `upstream/main` checkout (verified via `git
stash`) and unrelated to this change.
## Test Output
```
$ pytest tests/test_parser.py -q
60 passed in 0.14s
$ ruff check headroom/parser.py tests/test_parser.py
All checks passed!
```
## Real behavior proof
Real `TransformPipeline` (CacheAligner + ContentRouter, same
construction as the proxy server) over an Anthropic-format conversation
with four large JSON `tool_result` blocks:
```
# before this fix
before=37265 after=16013 saved=21252
transforms: ['router:protected:user_message', 'router:tool_result:smart_crusher']
waste_signals: None <- SmartCrusher removed 21k tokens, dashboard shows nothing
# after this fix (identical input)
before=37265 after=16013 saved=21252
transforms: ['router:protected:user_message', 'router:tool_result:smart_crusher']
waste_signals: {'json_bloat': 37140, 'html_noise': 0, 'base64': 0, 'whitespace': 0, 'dynamic_date': 0, 'repetition': 0}
```
Parser-level parity (same JSON payload, both wire formats):
```
anthropic tool_result waste total: 0 -> 1745 after fix
openai role:"tool" waste total: 1745 (unchanged)
```
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] CHANGELOG.md — not edited manually; release-please generates
entries from the conventional `fix:` commit
## Additional Notes
Scoped to the Anthropic `tool_result` parsing bug per issue #813. Two
related-but-separate gaps noted there: `handle_openai_responses` (codex)
never computes waste signals at all, and Gemini `functionResponse` parts
are preserved verbatim — both deserve their own issues/PRs.
---------
Co-authored-by: Ash Rhodes <ashley.rhodes@king.com>
2026-06-11 04:06:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Anthropic tool_result content blocks (chopratejas/headroom#813) ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def big_json_payload():
|
|
|
|
|
"""JSON blob large enough to trip the json_bloat detector (>500 tokens)."""
|
|
|
|
|
return "{" + ",".join(f'"key_{i}": "value padding text {i}"' for i in range(200)) + "}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAnthropicToolResultBlocks:
|
|
|
|
|
"""Anthropic Messages format nests tool output in tool_result content blocks."""
|
|
|
|
|
|
|
|
|
|
def test_tool_result_with_nested_text_list(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_01",
|
|
|
|
|
"content": [{"type": "text", "text": big_json_payload}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
tool_blocks = [b for b in blocks if b.kind == "tool_result"]
|
|
|
|
|
assert len(tool_blocks) == 1
|
|
|
|
|
assert tool_blocks[0].text == big_json_payload
|
|
|
|
|
assert tool_blocks[0].flags["tool_call_id"] == "toolu_01"
|
|
|
|
|
assert tool_blocks[0].flags["waste_signals"]["json_bloat"] > 0
|
|
|
|
|
|
|
|
|
|
def test_tool_result_with_string_content(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_02",
|
|
|
|
|
"content": big_json_payload,
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
tool_blocks = [b for b in blocks if b.kind == "tool_result"]
|
|
|
|
|
assert len(tool_blocks) == 1
|
|
|
|
|
assert tool_blocks[0].text == big_json_payload
|
|
|
|
|
assert tool_blocks[0].flags["waste_signals"]["json_bloat"] > 0
|
|
|
|
|
|
|
|
|
|
def test_mixed_text_and_tool_result(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "Here is the output:"},
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_03",
|
|
|
|
|
"content": [{"type": "text", "text": big_json_payload}],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert [b.kind for b in blocks] == ["user", "tool_result"]
|
|
|
|
|
assert blocks[0].text == "Here is the output:"
|
|
|
|
|
assert blocks[1].text == big_json_payload
|
|
|
|
|
|
|
|
|
|
def test_empty_tool_result_content_emits_no_block(self, mock_tokenizer):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_04", "content": []},
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_05"},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
# Nothing extractable: keep the container block so every message
|
|
|
|
|
# still yields at least one block.
|
|
|
|
|
assert [b.kind for b in blocks] == ["user"]
|
|
|
|
|
|
|
|
|
|
def test_tool_result_only_message_skips_container_block(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_result", "tool_use_id": "toolu_08", "content": big_json_payload}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert [b.kind for b in blocks] == ["tool_result"]
|
|
|
|
|
|
|
|
|
|
def test_tool_result_dict_content_serialized_as_json(self, mock_tokenizer):
|
|
|
|
|
rows = {"rows": [{"id": i, "padding": "x" * 30} for i in range(120)]}
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "tool_use_id": "toolu_09", "content": rows}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
tool_blocks = [b for b in blocks if b.kind == "tool_result"]
|
|
|
|
|
assert len(tool_blocks) == 1
|
|
|
|
|
assert tool_blocks[0].text.startswith('{"rows":')
|
|
|
|
|
assert tool_blocks[0].flags["waste_signals"]["json_bloat"] > 0
|
|
|
|
|
|
|
|
|
|
def test_missing_tool_use_id_yields_none_flag(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "content": big_json_payload}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert blocks[0].kind == "tool_result"
|
|
|
|
|
assert blocks[0].flags["tool_call_id"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestStrandsToolResultBlocks:
|
|
|
|
|
"""Strands/Bedrock converse format: toolResult content parts."""
|
|
|
|
|
|
|
|
|
|
def test_strands_text_content(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"toolResult": {
|
|
|
|
|
"toolUseId": "strands_01",
|
|
|
|
|
"content": [{"text": big_json_payload}],
|
|
|
|
|
"status": "success",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert [b.kind for b in blocks] == ["tool_result"]
|
|
|
|
|
assert blocks[0].text == big_json_payload
|
|
|
|
|
assert blocks[0].flags["tool_call_id"] == "strands_01"
|
|
|
|
|
assert blocks[0].flags["waste_signals"]["json_bloat"] > 0
|
|
|
|
|
|
|
|
|
|
def test_strands_json_content(self, mock_tokenizer):
|
|
|
|
|
rows = {"rows": [{"id": i, "padding": "x" * 30} for i in range(120)]}
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"toolResult": {"toolUseId": "strands_02", "content": [{"json": rows}]}}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert [b.kind for b in blocks] == ["tool_result"]
|
|
|
|
|
assert blocks[0].flags["waste_signals"]["json_bloat"] > 0
|
|
|
|
|
|
|
|
|
|
def test_non_text_inner_blocks_skipped(self, mock_tokenizer):
|
|
|
|
|
message = {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_06",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "image", "source": {"type": "base64", "data": "abc"}},
|
|
|
|
|
{"type": "text", "text": "small result"},
|
|
|
|
|
"raw string piece",
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
blocks = parse_message_to_blocks(message, 0, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
tool_blocks = [b for b in blocks if b.kind == "tool_result"]
|
|
|
|
|
assert len(tool_blocks) == 1
|
|
|
|
|
assert tool_blocks[0].text == "small result\nraw string piece"
|
|
|
|
|
|
|
|
|
|
def test_parse_messages_aggregates_tool_result_waste(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
messages = [
|
|
|
|
|
{"role": "user", "content": "Run the tool"},
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "toolu_07",
|
|
|
|
|
"content": [{"type": "text", "text": big_json_payload}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
_, breakdown, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert waste.json_bloat_tokens > 0
|
|
|
|
|
assert breakdown["tool_result"] > 0
|
|
|
|
|
|
|
|
|
|
def test_waste_parity_with_openai_tool_role(self, mock_tokenizer, big_json_payload):
|
|
|
|
|
anthropic_messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_result",
|
|
|
|
|
"tool_use_id": "t1",
|
|
|
|
|
"content": [{"type": "text", "text": big_json_payload}],
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
openai_messages = [{"role": "tool", "tool_call_id": "t1", "content": big_json_payload}]
|
|
|
|
|
|
|
|
|
|
_, _, anthropic_waste = parse_messages(anthropic_messages, mock_tokenizer)
|
|
|
|
|
_, _, openai_waste = parse_messages(openai_messages, mock_tokenizer)
|
|
|
|
|
|
|
|
|
|
assert anthropic_waste.total() > 0
|
|
|
|
|
assert anthropic_waste.total() == openai_waste.total()
|
feat(parser): detect re-issued identical tool calls as reread waste (#909)
Fixes #908
## Problem
Reread waste detection matches `tool_result` blocks by exact
`content_hash` only. Two gaps hide a common waste pattern — the agent
re-issuing the *same tool call* and paying full price for a
near-identical result:
1. **Byte-different results escape matching.** Same tool, same
arguments, but the second result differs trivially (embedded mtimes,
timestamps, ordering) → different hash, zero reread counted.
2. **Anthropic `tool_use` parts were dropped entirely** in
`parse_message_to_blocks` — only OpenAI-style `message.tool_calls`
produced `tool_call` blocks, so Anthropic/Strands traffic had no
call-side record at all.
## Fix
- Parse Anthropic `tool_use` / Strands `toolUse` content parts into
`tool_call` blocks (same shape as the OpenAI path: `function_name`,
`tool_call_id` flags).
- Tag every `tool_call` block with a canonical `call_key` = hash(name +
arguments re-serialized with sorted keys), so `'{"path": "a.py",
"lines": 100}'` (OpenAI JSON string) and `{"lines": 100, "path":
"a.py"}` (Anthropic dict) hash equal — covered by a cross-format parity
test.
- Second reread pass in `parse_messages` groups calls by `call_key`:
repeat invocations past the existing `REREAD_ADJACENT_GAP` polling guard
count their **result** tokens into `reread_tokens`, subject to the
existing `REREAD_MIN_TOKENS` floor. Results already counted by the
content-hash pass are skipped, so byte-identical repeats are never
double-counted.
No new `WasteSignals` field — a byte-different re-fetch of an identical
call is reread waste by the existing definition. Detection is
Python-only (`parser.py`); no Rust parity surface.
## Proof
Re-reading the same file twice, 7 messages apart, second serve differing
only by an mtime line:
```
main: tool_call blocks: 2, reread_tokens: 0
this branch: tool_call blocks: 2, reread_tokens: 381
```
## Testing
- 11 new tests (`TestCallArgMatchReread`): changed-result repeat counted
(OpenAI + Anthropic + Strands formats), byte-identical repeat counted
exactly once, polling gap skipped, different args not matched, sub-floor
results skipped, repeat without result ignored, canonical-key
normalization, cross-format call_key parity.
- Full `tests/test_parser.py` suite: 87 passed. Consumer regression
sweep (reporting, config, request outcome, read lifecycle,
observability, storage): 188 passed.
- `ruff check` + `ruff format --check` + `mypy headroom/parser.py`
clean.
Co-authored-by: integration-check <integration@local>
2026-06-13 00:16:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- TestCallArgMatchReread ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCallArgMatchReread:
|
|
|
|
|
"""Tests for re-issued-call (arg-match) reread detection in parse_messages."""
|
|
|
|
|
|
|
|
|
|
LARGE_CONTENT = "def handler(event):\n return process(event)\n" * 10 # > 200 chars
|
|
|
|
|
CHANGED_CONTENT = LARGE_CONTENT + "# mtime 1718000000\n"
|
|
|
|
|
|
|
|
|
|
def _expected_tokens(self, text):
|
|
|
|
|
"""Mirror mock_tokenizer + message overhead used for tool_result blocks."""
|
|
|
|
|
return len(text) // 4 + 1 + 4
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _filler(n):
|
|
|
|
|
"""Interleaved turns that push a repeat beyond the polling gap."""
|
|
|
|
|
return [
|
|
|
|
|
{"role": "assistant" if i % 2 == 0 else "user", "content": f"step {i} of the task"}
|
|
|
|
|
for i in range(n)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _openai_call(call_id, name, arguments):
|
|
|
|
|
return {
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": None,
|
|
|
|
|
"tool_calls": [{"id": call_id, "function": {"name": name, "arguments": arguments}}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _openai_result(call_id, content):
|
|
|
|
|
return {"role": "tool", "tool_call_id": call_id, "content": content}
|
|
|
|
|
|
|
|
|
|
def test_canonical_call_key_normalizes_serialization(self):
|
|
|
|
|
"""Reordered JSON-string args, dict args, and spaced JSON hash equal."""
|
|
|
|
|
from headroom.parser import _canonical_call_key
|
|
|
|
|
|
|
|
|
|
k1 = _canonical_call_key("read_file", '{"path": "a.py", "lines": 100}')
|
|
|
|
|
k2 = _canonical_call_key("read_file", '{"lines":100,"path":"a.py"}')
|
|
|
|
|
k3 = _canonical_call_key("read_file", {"path": "a.py", "lines": 100})
|
|
|
|
|
assert k1 == k2 == k3
|
|
|
|
|
assert _canonical_call_key("read_file", '{"path": "b.py", "lines": 100}') != k1
|
|
|
|
|
assert _canonical_call_key("grep", '{"path": "a.py", "lines": 100}') != k1
|
|
|
|
|
|
|
|
|
|
def test_reissued_call_changed_result_counts(self, mock_tokenizer):
|
|
|
|
|
"""Identical call re-issued far apart counts even when result bytes differ."""
|
|
|
|
|
messages = (
|
|
|
|
|
[
|
|
|
|
|
self._openai_call("c1", "read_file", '{"path": "a.py", "lines": 100}'),
|
|
|
|
|
self._openai_result("c1", self.LARGE_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [
|
|
|
|
|
self._openai_call("c2", "read_file", '{"lines":100,"path":"a.py"}'),
|
|
|
|
|
self._openai_result("c2", self.CHANGED_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.CHANGED_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_identical_result_not_double_counted(self, mock_tokenizer):
|
|
|
|
|
"""Byte-identical repeat is counted once (content-hash pass wins)."""
|
|
|
|
|
messages = (
|
|
|
|
|
[
|
|
|
|
|
self._openai_call("c1", "read_file", '{"path": "a.py"}'),
|
|
|
|
|
self._openai_result("c1", self.LARGE_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [
|
|
|
|
|
self._openai_call("c2", "read_file", '{"path": "a.py"}'),
|
|
|
|
|
self._openai_result("c2", self.LARGE_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.LARGE_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_adjacent_reissue_is_polling(self, mock_tokenizer):
|
|
|
|
|
"""Back-to-back identical calls (poll loop) are not re-reads."""
|
|
|
|
|
messages = [
|
|
|
|
|
self._openai_call("c1", "check_ci", '{"run": 7}'),
|
|
|
|
|
self._openai_result("c1", self.LARGE_CONTENT),
|
|
|
|
|
self._openai_call("c2", "check_ci", '{"run": 7}'),
|
|
|
|
|
self._openai_result("c2", self.CHANGED_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_different_args_not_matched(self, mock_tokenizer):
|
|
|
|
|
"""Same tool with different arguments is not a re-issued call."""
|
|
|
|
|
messages = (
|
|
|
|
|
[
|
|
|
|
|
self._openai_call("c1", "read_file", '{"path": "a.py"}'),
|
|
|
|
|
self._openai_result("c1", self.LARGE_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [
|
|
|
|
|
self._openai_call("c2", "read_file", '{"path": "b.py"}'),
|
|
|
|
|
self._openai_result("c2", self.CHANGED_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_small_result_ignored(self, mock_tokenizer):
|
|
|
|
|
"""Repeat of a call whose result is trivially small is skipped."""
|
|
|
|
|
messages = (
|
|
|
|
|
[
|
|
|
|
|
self._openai_call("c1", "run_tests", "{}"),
|
|
|
|
|
self._openai_result("c1", "ok"),
|
|
|
|
|
]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [
|
|
|
|
|
self._openai_call("c2", "run_tests", "{}"),
|
|
|
|
|
self._openai_result("c2", "ok again"),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_repeat_call_without_result_not_counted(self, mock_tokenizer):
|
|
|
|
|
"""A re-issued call with no recorded result contributes nothing."""
|
|
|
|
|
messages = (
|
|
|
|
|
[
|
|
|
|
|
self._openai_call("c1", "read_file", '{"path": "a.py"}'),
|
|
|
|
|
self._openai_result("c1", self.LARGE_CONTENT),
|
|
|
|
|
]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [self._openai_call("c2", "read_file", '{"path": "a.py"}')]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == 0
|
|
|
|
|
|
|
|
|
|
def test_anthropic_tool_use_produces_tool_call_blocks(self, mock_tokenizer):
|
|
|
|
|
"""Anthropic tool_use parts become tool_call blocks with call metadata."""
|
|
|
|
|
messages = [
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "text", "text": "Reading the file now."},
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_use",
|
|
|
|
|
"id": "t1",
|
|
|
|
|
"name": "read_file",
|
|
|
|
|
"input": {"path": "a.py"},
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
blocks, _, _ = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
tool_calls = [b for b in blocks if b.kind == "tool_call"]
|
|
|
|
|
assert len(tool_calls) == 1
|
|
|
|
|
assert tool_calls[0].flags["function_name"] == "read_file"
|
|
|
|
|
assert tool_calls[0].flags["tool_call_id"] == "t1"
|
|
|
|
|
assert tool_calls[0].flags["call_key"]
|
|
|
|
|
|
|
|
|
|
def test_anthropic_reissued_call_changed_result_counts(self, mock_tokenizer):
|
|
|
|
|
"""Full Anthropic-format flow: re-issued tool_use with drifted result."""
|
|
|
|
|
|
|
|
|
|
def call(uid):
|
|
|
|
|
return {
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [
|
|
|
|
|
{"type": "tool_use", "id": uid, "name": "read_file", "input": {"path": "a.py"}}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def result(uid, content):
|
|
|
|
|
return {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"type": "tool_result", "tool_use_id": uid, "content": content}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
messages = (
|
|
|
|
|
[call("t1"), result("t1", self.LARGE_CONTENT)]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [call("t2"), result("t2", self.CHANGED_CONTENT)]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.CHANGED_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_strands_tooluse_matched(self, mock_tokenizer):
|
|
|
|
|
"""Strands/Bedrock toolUse/toolResult format is matched the same way."""
|
|
|
|
|
|
|
|
|
|
def call(uid):
|
|
|
|
|
return {
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [{"toolUse": {"toolUseId": uid, "name": "search", "input": {"q": "x"}}}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
def result(uid, content):
|
|
|
|
|
return {
|
|
|
|
|
"role": "user",
|
|
|
|
|
"content": [{"toolResult": {"toolUseId": uid, "content": [{"text": content}]}}],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
messages = (
|
|
|
|
|
[call("s1"), result("s1", self.LARGE_CONTENT)]
|
|
|
|
|
+ self._filler(4)
|
|
|
|
|
+ [call("s2"), result("s2", self.CHANGED_CONTENT)]
|
|
|
|
|
)
|
|
|
|
|
_, _, waste = parse_messages(messages, mock_tokenizer)
|
|
|
|
|
assert waste.reread_tokens == self._expected_tokens(self.CHANGED_CONTENT)
|
|
|
|
|
|
|
|
|
|
def test_cross_format_call_key_parity(self, mock_tokenizer):
|
|
|
|
|
"""OpenAI JSON-string args and Anthropic dict input produce the same call_key."""
|
|
|
|
|
openai_msgs = [self._openai_call("c1", "read_file", '{"lines": 100, "path": "a.py"}')]
|
|
|
|
|
anthropic_msgs = [
|
|
|
|
|
{
|
|
|
|
|
"role": "assistant",
|
|
|
|
|
"content": [
|
|
|
|
|
{
|
|
|
|
|
"type": "tool_use",
|
|
|
|
|
"id": "t1",
|
|
|
|
|
"name": "read_file",
|
|
|
|
|
"input": {"path": "a.py", "lines": 100},
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
o_blocks, _, _ = parse_messages(openai_msgs, mock_tokenizer)
|
|
|
|
|
a_blocks, _, _ = parse_messages(anthropic_msgs, mock_tokenizer)
|
|
|
|
|
o_key = [b for b in o_blocks if b.kind == "tool_call"][0].flags["call_key"]
|
|
|
|
|
a_key = [b for b in a_blocks if b.kind == "tool_call"][0].flags["call_key"]
|
|
|
|
|
assert o_key == a_key
|