mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(ccr): skip CCR when model calls headroom_retrieve alongside user tools (#839)
## Summary - When the LLM calls `headroom_retrieve` **and** a non-CCR tool (e.g. `read_file`) in the same turn, the previous code attempted a continuation with only the CCR result - Anthropic requires every `tool_use` block to have a matching `tool_result` — the continuation was rejected with 400, a round-trip was wasted, and the original response (with unresolved `headroom_retrieve`) was returned anyway - Fix: if `other_calls` is non-empty alongside `ccr_calls`, log a warning and return the original response immediately — no continuation attempted ## Root cause `_parse_ccr_tool_calls` correctly separates CCR and non-CCR calls, but `handle_response` never checked `other_calls` before building the continuation. `_create_tool_result_message` only adds results for CCR calls, leaving the non-CCR `tool_use` blocks without matching `tool_result` entries. ## Files changed - `headroom/ccr/response_handler.py` — guard at top of `while` loop in `handle_response` - `tests/test_ccr_response_handler.py` — regression test: asserts `api_call_count == 0` and original response returned unchanged when model uses mixed tools ## Test plan - [x] `pytest tests/test_ccr_response_handler.py::TestCCRResponseHandling::test_handle_response_mixed_tools_skips_ccr` — passes - [x] `pytest tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_tool_injection.py tests/test_ccr_tool_always_on.py` — 85 passed - [x] Pre-commit hooks (ruff, mypy) — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8db5efc6f9
commit
30078f8465
2 changed files with 59 additions and 0 deletions
|
|
@ -432,6 +432,19 @@ class CCRResponseHandler:
|
|||
# No CCR tool calls, we're done
|
||||
break
|
||||
|
||||
# If the model called CCR alongside non-CCR tools, we cannot build
|
||||
# a valid continuation — every tool_use in the assistant message
|
||||
# requires a matching tool_result, but we only have CCR results.
|
||||
# Skip CCR handling and let the client resolve all tool calls.
|
||||
if other_calls:
|
||||
logger.warning(
|
||||
"CCR: Skipping CCR handling — model called %d non-CCR tool(s) "
|
||||
"alongside headroom_retrieve. Cannot create a valid continuation "
|
||||
"without results for the other tools. Client must handle all tool calls.",
|
||||
len(other_calls),
|
||||
)
|
||||
break
|
||||
|
||||
rounds += 1
|
||||
with self._retrieval_count_lock:
|
||||
self._retrieval_count += len(ccr_calls)
|
||||
|
|
|
|||
|
|
@ -478,6 +478,52 @@ class TestCCRResponseHandling:
|
|||
|
||||
assert result == response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_response_mixed_tools_skips_ccr(self):
|
||||
"""When CCR and non-CCR tools are called together, skip CCR.
|
||||
|
||||
Building a valid continuation is impossible without results for the
|
||||
non-CCR tools (Anthropic requires every tool_use to have a
|
||||
tool_result). Skipping CCR avoids a wasted 400 API call and returns
|
||||
the original response immediately so the client can resolve all
|
||||
tool calls itself.
|
||||
"""
|
||||
store = get_compression_store()
|
||||
hash_key = store.store(original="[1,2,3]", compressed="[]")
|
||||
|
||||
handler = CCRResponseHandler()
|
||||
|
||||
mixed_response = {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "ccr_call",
|
||||
"name": CCR_TOOL_NAME,
|
||||
"input": {"hash": hash_key},
|
||||
},
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "user_call",
|
||||
"name": "read_file",
|
||||
"input": {"path": "/etc/config"},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
api_call_count = 0
|
||||
|
||||
async def mock_api_call(messages, tools):
|
||||
nonlocal api_call_count
|
||||
api_call_count += 1
|
||||
return {"content": [{"type": "text", "text": "continuation"}]}
|
||||
|
||||
result = await handler.handle_response(mixed_response, [], None, mock_api_call, "anthropic")
|
||||
|
||||
# CCR skipped — no continuation call made (avoids the 400 API round-trip)
|
||||
assert api_call_count == 0, "should not attempt continuation with mixed tools"
|
||||
# Original response returned unchanged so client can handle all tool calls
|
||||
assert result is mixed_response
|
||||
|
||||
|
||||
class TestCCRResponseHandlerStats:
|
||||
"""Test handler statistics."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue