diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index ede5e72d7..7e01915e5 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -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( diff --git a/tests/test_proxy/test_anthropic_ccr_deferred_injection.py b/tests/test_proxy/test_anthropic_ccr_deferred_injection.py index edccdbee2..46a334113 100644 --- a/tests/test_proxy/test_anthropic_ccr_deferred_injection.py +++ b/tests/test_proxy/test_anthropic_ccr_deferred_injection.py @@ -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(