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:
Abhay Singh
2026-08-08 11:44:59 +05:30
committed by GitHub
parent 1f5fefffd3
commit 3808f60ca6
2 changed files with 33 additions and 26 deletions
+19 -15
View File
@@ -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(