fix(proxy/anthropic): inject headroom_retrieve whenever a CCR marker is present, not only for new markers (#2848)
## Description
On a frozen-prefix turn that replays an existing `<<ccr:hash>>` marker,
the proxy did not inject the `headroom_retrieve` tool, so the agent held
a marker it could not redeem. When it tried, the Anthropic API rejected
the whole request:
```text
API Error: 400 Tool reference 'headroom_retrieve' not found in available tools
```
This was a frequent, user-visible failure in Claude Code.
### Root cause
The sticky tool-injection gate in `handlers/anthropic.py` was driven by
`has_new_ccr_markers(...)` -- markers created THIS turn only:
```python
has_new_compressed_content = has_new_ccr_markers(
current_detected_hashes=injector.detected_hashes,
previous_forwarded_messages=prefix_tracker.get_last_forwarded_messages(),
provider="anthropic",
)
tools, ccr_tool_injected = apply_session_sticky_ccr_tool(
...,
has_compressed_content_this_turn=has_new_compressed_content,
)
```
`apply_session_sticky_ccr_tool` returns early with `decision="skip"` for
a session it considers fresh when `not
has_compressed_content_this_turn`. A marker replayed from the frozen
prefix is "historical" (already in `previous_forwarded_messages`), so
`has_new_ccr_markers` returns `False`, and on a fresh session the tool
is skipped even though the request carries a redeemable marker. The
`SessionCcrTracker` is per-process, so every proxy restart makes live
sessions look fresh again and re-arms the failure mid-conversation.
Anything that instructs the model to retrieve later (a project
instruction saying "call `headroom_retrieve` with the hash before
asserting an exact value") lands on this path by construction.
### Fix
Drive the gate from `injector.has_compressed_content` -- whether the
forwarded request carries ANY CCR marker, new or replayed -- instead of
new-markers-only. `#1850` narrowed the first-time gate to new markers to
avoid arming a session that never compressed, but a present marker means
the session HAS compressed, and a replayed marker is exactly as
unredeemable as a fresh one. Since a new marker is also a present
marker, `has_new_compressed_content or injector.has_compressed_content`
collapses to `injector.has_compressed_content`, so the now-redundant
`has_new_ccr_markers` call is removed.
The cache argument cuts in favor of this: toggling the tool in and out
of the tools array between turns is what busts the tools cache segment.
Injecting consistently whenever markers exist is the cache-stable
option, and it removes a hard 400 in exchange for at most one cache
miss. The frozen message prefix is still replayed byte-identical, so the
prompt-cache prefix is unaffected; only the tools array gains a stable
entry.
Fixes #2766
## 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
- `headroom/proxy/handlers/anthropic.py`: the sticky CCR tool-injection
gate now passes
`has_compressed_content_this_turn=injector.has_compressed_content` (any
marker present) instead of the new-markers-only signal, and the
now-redundant `has_new_ccr_markers` computation/import is dropped.
- `tests/test_proxy/test_anthropic_ccr_deferred_injection.py`: the two
tests that encoded the superseded `#1850` behavior (a replayed
historical marker forwarded WITHOUT the tool) now assert the tool IS
injected, with updated rationale. One was renamed from
`..._when_tool_injection_is_deferred` to
`..._and_injects_retrieve_tool`. The byte-identical message-prefix
replay assertions are unchanged.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
# Fail-before (source fix stashed, updated tests kept):
tests/test_proxy/test_anthropic_ccr_deferred_injection.py
::test_cache_mode_compresses_delta_but_replays_cached_prefix_when_markers_are_historical FAILED
::test_cache_mode_exact_prefix_replay_forwards_cached_compressed_prefix_and_injects_retrieve_tool FAILED
assert [tool["name"] for tool in forwarded["tools"]] == ["headroom_retrieve"]
KeyError: 'tools'
# Pass-after (fix applied):
tests/test_proxy/test_anthropic_ccr_deferred_injection.py 15 passed
# Broader CCR suites:
tests/test_proxy/test_ccr_frozen_prefix_coupling.py tests/test_ccr_tool_always_on.py
tests/test_ccr_session_tracker.py tests/test_ccr_tool_injection.py 61 passed
tests/test_ccr_marker_policy.py tests/test_anthropic_ccr_workspace_unbound.py
tests/test_ccr_tool_calls.py tests/test_corrupt_golden_bytes_recovery.py 21 passed
# uvx ruff@0.15.17 check -> All checks passed!
# uvx mypy@1.20.2 headroom/proxy/handlers/anthropic.py -> Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: traced the gate (`has_new_ccr_markers` ->
`apply_session_sticky_ccr_tool` fresh-session `skip`) and confirmed
`injector.has_compressed_content` reflects any marker present in the
forwarded messages (`len(_detected_hashes) > 0` after
`scan_for_markers`). Reproduced the exact bug in the handler harness: a
cache-mode frozen replay where `fake_tracker._last_forwarded_messages`
already holds the marker (so `has_new` is `False`) on a session the
reset tracker considers fresh, with the marker forwarded to upstream.
Fail-before with `git stash push headroom/proxy/handlers/anthropic.py`
and rerunning the two replay tests (the forwarded body has no `tools`),
pass-after with `git stash pop` (the body carries `headroom_retrieve`).
- Observed result: on a replayed-marker turn the forwarded request now
includes `"tools": [{"name": "headroom_retrieve", ...}]`, so the agent
can redeem the hash and Anthropic no longer 400s. The frozen message
prefix is still replayed byte-identical (`forwarded["messages"]`
unchanged). Sessions that never compressed still get no tool (no marker
-> `has_compressed_content` is `False`).
- Not tested: a live multi-turn Claude Code session across a real proxy
restart (no live provider here). The gate is exercised end-to-end
through the handler via the TestClient harness, reproducing the
historical-marker-on-fresh-session desync the issue describes.
## 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
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Additional Notes
This deliberately reworks the `#1850` deferral for historical markers,
so it changes two tests that encoded "tool absent on frozen replay."
That behavior was the source of the 400: a marker in the prompt with no
tool to redeem it is a hard failure, whereas a re-injected tool is a
stable, cheap entry in the tools array. The reporter validated the same
change locally (33 requests, 0 errors, 0 `skip`). Scope is the Anthropic
interactive path where the bug was reported; the stateless batch path (a
separate `CCRToolInjector.process_request` gated on `tokens_saved > 0`)
is unchanged.
This commit is contained in:
@@ -1873,27 +1873,31 @@ class AnthropicHandlerMixin:
|
||||
# dropping the gate cannot start injecting into non-CCR
|
||||
# conversations.
|
||||
if configured_inject_tool:
|
||||
from headroom.proxy.helpers import (
|
||||
apply_session_sticky_ccr_tool,
|
||||
has_new_ccr_markers,
|
||||
)
|
||||
from headroom.proxy.helpers import apply_session_sticky_ccr_tool
|
||||
|
||||
# #1850: markers replayed from the previously-forwarded
|
||||
# prefix (overlay_cached_prefix) are historical; only
|
||||
# markers NEW this turn may drive a first-time injection,
|
||||
# else a replayed marker injects the tool into a session
|
||||
# that never actually compressed.
|
||||
has_new_compressed_content = has_new_ccr_markers(
|
||||
current_detected_hashes=injector.detected_hashes,
|
||||
previous_forwarded_messages=prefix_tracker.get_last_forwarded_messages(),
|
||||
provider="anthropic",
|
||||
)
|
||||
# Inject whenever the request carries ANY CCR marker, new or
|
||||
# replayed from the frozen prefix. #1850 narrowed the
|
||||
# first-time gate to markers created THIS turn to avoid
|
||||
# arming a session that never compressed, but a replayed
|
||||
# marker is exactly as unredeemable as a fresh one: the agent
|
||||
# redeems hashes it was handed turns ago (project instructions
|
||||
# can even tell it to), and if `headroom_retrieve` is absent
|
||||
# Anthropic rejects the whole request with 400 "Tool reference
|
||||
# 'headroom_retrieve' not found in available tools" (#2766). A
|
||||
# present marker means the session HAS compressed, so this
|
||||
# cannot start injecting into non-CCR conversations. It is also
|
||||
# the cache-stable choice: toggling the tool in and out of the
|
||||
# tools array between turns is what busts the tools cache
|
||||
# segment, whereas injecting consistently whenever markers
|
||||
# exist keeps it stable. The `SessionCcrTracker` is
|
||||
# per-process, so a proxy restart mid-conversation makes live
|
||||
# sessions look fresh again, which is what re-armed the 400.
|
||||
tools, ccr_tool_injected = apply_session_sticky_ccr_tool(
|
||||
provider="anthropic",
|
||||
session_id=session_id,
|
||||
request_id=request_id,
|
||||
existing_tools=tools,
|
||||
has_compressed_content_this_turn=has_new_compressed_content,
|
||||
has_compressed_content_this_turn=injector.has_compressed_content,
|
||||
)
|
||||
if ccr_tool_injected:
|
||||
logger.debug(
|
||||
|
||||
@@ -592,16 +592,17 @@ def test_cache_mode_compresses_delta_but_replays_cached_prefix_when_markers_are_
|
||||
assert response.status_code == 200
|
||||
assert len(captured.get("compression_calls", [])) == 1
|
||||
forwarded = captured["body"]
|
||||
# Tool injection is deferred (no CCR tool this turn), but the frozen
|
||||
# prefix was cached COMPRESSED last turn. Replay it byte-identical so the
|
||||
# prompt cache still hits instead of busting on original bytes (#1850);
|
||||
# the historical marker does not force tool injection back on. Tool absent
|
||||
# AND cache intact.
|
||||
# The frozen prefix was cached COMPRESSED last turn, so it is replayed
|
||||
# byte-identical to keep the prompt cache warm. The replayed marker is
|
||||
# still redeemable this turn, so `headroom_retrieve` MUST be present or
|
||||
# Anthropic 400s "Tool reference 'headroom_retrieve' not found" (#2766);
|
||||
# injecting it whenever a marker exists is itself cache-stable (toggling
|
||||
# is what busts the tools segment). Message prefix replayed AND tool present.
|
||||
assert forwarded["messages"] == previous_forwarded_messages
|
||||
assert "tools" not in forwarded
|
||||
assert [tool["name"] for tool in forwarded["tools"]] == ["headroom_retrieve"]
|
||||
|
||||
|
||||
def test_cache_mode_exact_prefix_replay_forwards_cached_compressed_prefix_when_tool_injection_is_deferred(
|
||||
def test_cache_mode_exact_prefix_replay_forwards_cached_compressed_prefix_and_injects_retrieve_tool(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
@@ -684,11 +685,13 @@ def test_cache_mode_exact_prefix_replay_forwards_cached_compressed_prefix_when_t
|
||||
assert response.status_code == 200
|
||||
assert captured.get("compression_calls", []) == []
|
||||
forwarded = captured["body"]
|
||||
# Deferred injection (no CCR tool), single frozen message cached
|
||||
# COMPRESSED last turn: replay it so the cache holds instead of busting
|
||||
# on original bytes (#1850). Tool absent AND cache intact.
|
||||
# Single frozen message cached COMPRESSED last turn: replay it
|
||||
# byte-identical so the cache holds instead of busting on original bytes.
|
||||
# The replayed marker is still redeemable, so `headroom_retrieve` must be
|
||||
# present this turn or Anthropic 400s "Tool reference 'headroom_retrieve'
|
||||
# not found" (#2766). Message prefix replayed AND tool present.
|
||||
assert forwarded["messages"] == previous_forwarded_messages
|
||||
assert "tools" not in forwarded
|
||||
assert [tool["name"] for tool in forwarded["tools"]] == ["headroom_retrieve"]
|
||||
|
||||
|
||||
def test_token_mode_cached_messages_skip_cache_update_when_pipeline_result_is_unchanged(
|
||||
|
||||
Reference in New Issue
Block a user