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:
Ashish 2026-06-10 19:12:26 -07:00 committed by GitHub
parent 8db5efc6f9
commit 30078f8465
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 59 additions and 0 deletions

View file

@ -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)

View file

@ -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."""