Skip to content

Commit c629874

Browse files
authored
Merge pull request #26 from nangsontay/fix/ccr-transcript-tool-reinjection
fix(proxy/ccr): self-heal dangling headroom_retrieve reference after …
2 parents ec057c5 + 504c980 commit c629874

7 files changed

Lines changed: 407 additions & 11 deletions

File tree

docs/content/docs/troubleshooting.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,33 @@ See [issue #746](https://github.com/headroomlabs-ai/headroom/issues/746) for the
213213
deployment. See [issue #2028](https://github.com/headroomlabs-ai/headroom/issues/2028).
214214
</Callout>
215215

216+
## `Tool reference 'headroom_retrieve' not found` after a `/model` switch
217+
218+
**Symptom**: Through the proxy, switching models mid-session (`/model` in Claude Code) —
219+
or restarting the proxy — makes every subsequent turn fail with:
220+
221+
```
222+
400 Tool reference 'headroom_retrieve' not found in available tools
223+
```
224+
225+
**Cause**: Once a session has done CCR compression, the proxy registers the
226+
`headroom_retrieve` retrieval tool and the client transcript starts carrying a
227+
`tool_reference` block naming it. Anthropic validates every `tool_reference` in the
228+
messages against the request's `tools` array. The proxy's sticky guarantee that keeps
229+
re-injecting the tool was keyed by a **model-scoped, in-memory** session id, so a
230+
`/model` switch (or a proxy restart) rotated/lost that key — the tool stopped being
231+
injected while the transcript kept re-sending the reference, leaving it dangling.
232+
233+
**Fix**: Upgrade to a build that self-heals this. The proxy now scans the
234+
about-to-forward transcript for a dangling `headroom_retrieve` `tool_reference`/`tool_use`
235+
and re-injects the tool independent of tracker state, so `/model` switches and restarts
236+
recover automatically (logged as `decision=inject_transcript_recovery`).
237+
238+
**Workarounds on older versions**:
239+
240+
- `/model` back to the model you started the session on, or
241+
- `/clear` to start a fresh session (drops the dangling reference).
242+
216243
## Remote Control unavailable through custom ANTHROPIC_BASE_URL
217244

218245
**Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent.

headroom/proxy/ccr_marker_policy.py

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,101 @@ def should_inject_ccr_tool(
3535
configured_inject_tool: bool,
3636
frozen_message_count: int,
3737
has_compressed_content: bool,
38+
transcript_requires_tool: bool = False,
3839
) -> tuple[bool, bool]:
39-
"""Decide whether the CCR retrieval tool must be injected this turn."""
40+
"""Decide whether the CCR retrieval tool must be injected this turn.
41+
42+
``transcript_requires_tool`` forces injection when the about-to-forward
43+
transcript already names ``headroom_retrieve`` but tracker state was lost
44+
(a ``/model`` switch or proxy restart) — the dangling reference would
45+
otherwise 400. It does NOT set ``is_marker_override``: that flag stays
46+
specific to fresh #1006 markers so the caller can log each cause distinctly.
47+
"""
4048

4149
inject_tool = configured_inject_tool
4250
if inject_tool and frozen_message_count > 0:
4351
inject_tool = False
4452
is_marker_override = not inject_tool and has_compressed_content
45-
return (inject_tool or is_marker_override), is_marker_override
53+
should_inject = inject_tool or is_marker_override or transcript_requires_tool
54+
return should_inject, is_marker_override
55+
56+
57+
def transcript_references_ccr_tool(
58+
messages: list[dict[str, Any]] | None,
59+
*,
60+
tool_name: str | None = None,
61+
provider: Literal["anthropic", "openai", "google"] = "anthropic",
62+
) -> bool:
63+
"""Whether the about-to-forward transcript already names the CCR retrieve tool.
64+
65+
Once an Anthropic ``tool_reference``/``tool_use`` block, or an assistant
66+
``tool_calls`` entry (OpenAI chat), names ``headroom_retrieve``, the request's
67+
``tools`` array MUST still carry that tool or Anthropic 400s ("Tool reference
68+
'headroom_retrieve' not found in available tools"). The sticky guarantee is
69+
model-scoped and in-memory, so a ``/model`` switch or proxy restart loses it
70+
while the client transcript keeps re-sending the reference. This scan lets
71+
injection self-heal from the transcript, per request, independent of tracker
72+
state.
73+
74+
``provider="google"`` is accepted for signature parity with the sibling
75+
policy fns but currently falls through the Anthropic matcher — Gemini's
76+
``functionCall`` parts are not matched (no google CCR handler calls this yet).
77+
78+
Only the exact bare ``headroom_retrieve`` name matches — a client-owned
79+
``mcp__headroom__headroom_retrieve`` (registered via MCP, lifecycle owned by
80+
the client) must not trigger proxy injection. Tolerates string content and
81+
malformed blocks.
82+
"""
83+
if not messages:
84+
return False
85+
if tool_name is None:
86+
from headroom.ccr.tool_injection import CCR_TOOL_NAME
87+
88+
tool_name = CCR_TOOL_NAME
89+
90+
for msg in messages:
91+
if not isinstance(msg, dict):
92+
continue
93+
if provider == "openai":
94+
if _openai_message_references_tool(msg, tool_name):
95+
return True
96+
elif _anthropic_content_references_tool(msg.get("content"), tool_name):
97+
return True
98+
return False
99+
100+
101+
def _anthropic_content_references_tool(content: Any, tool_name: str) -> bool:
102+
"""Match a bare ``tool_name`` in tool_reference/tool_use blocks (one level deep)."""
103+
if not isinstance(content, list):
104+
return False
105+
for block in content:
106+
if not isinstance(block, dict):
107+
continue
108+
if block.get("type") in ("tool_reference", "tool_use") and block.get("name") == tool_name:
109+
return True
110+
# tool_search_tool_result / tool_result nest blocks one level down.
111+
nested = block.get("content")
112+
if isinstance(nested, list):
113+
for inner in nested:
114+
if (
115+
isinstance(inner, dict)
116+
and inner.get("type") in ("tool_reference", "tool_use")
117+
and inner.get("name") == tool_name
118+
):
119+
return True
120+
return False
121+
122+
123+
def _openai_message_references_tool(msg: dict[str, Any], tool_name: str) -> bool:
124+
"""Match a bare ``tool_name`` in an assistant message's ``tool_calls`` (chat shape)."""
125+
tool_calls = msg.get("tool_calls")
126+
if not isinstance(tool_calls, list):
127+
return False
128+
for call in tool_calls:
129+
if not isinstance(call, dict):
130+
continue
131+
fn = call.get("function")
132+
name = fn.get("name") if isinstance(fn, dict) else call.get("name")
133+
if name == tool_name:
134+
return True
135+
return False

headroom/proxy/handlers/anthropic.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1947,6 +1947,7 @@ class _DeferredCompressionResult:
19471947
from headroom.proxy.helpers import (
19481948
has_new_ccr_markers,
19491949
should_inject_ccr_tool,
1950+
transcript_references_ccr_tool,
19501951
)
19511952

19521953
# #1850: only markers NEW this turn justify overriding the
@@ -1961,10 +1962,22 @@ class _DeferredCompressionResult:
19611962
provider="anthropic",
19621963
)
19631964

1965+
# Self-heal a dangling headroom_retrieve reference: a /model
1966+
# switch or proxy restart rotates the model-scoped sticky key,
1967+
# so the tracker stops injecting while the client transcript
1968+
# still carries a tool_reference/tool_use naming the tool.
1969+
# Anthropic 400s every such turn. Scanning the about-to-forward
1970+
# transcript recovers injection independent of tracker state.
1971+
transcript_requires_tool = transcript_references_ccr_tool(
1972+
optimized_messages,
1973+
provider="anthropic",
1974+
)
1975+
19641976
should_inject, is_marker_override = should_inject_ccr_tool(
19651977
configured_inject_tool=configured_inject_tool,
19661978
frozen_message_count=frozen_message_count,
19671979
has_compressed_content=has_new_compressed_content,
1980+
transcript_requires_tool=transcript_requires_tool,
19681981
)
19691982
if should_inject:
19701983
if is_marker_override:
@@ -1974,6 +1987,13 @@ class _DeferredCompressionResult:
19741987
f"(frozen_message_count={frozen_message_count}); injecting to "
19751988
"prevent unredeemable markers (#1006)"
19761989
)
1990+
elif transcript_requires_tool:
1991+
logger.info(
1992+
f"[{request_id}] CCR: recovering headroom_retrieve from "
1993+
"transcript — a dangling tool_reference exists but sticky "
1994+
"tracker state was lost (/model switch or restart); "
1995+
"re-injecting to avoid a 400"
1996+
)
19771997
from headroom.proxy.helpers import apply_session_sticky_ccr_tool
19781998

19791999
tools, ccr_tool_injected = apply_session_sticky_ccr_tool(
@@ -1982,6 +2002,7 @@ class _DeferredCompressionResult:
19822002
request_id=request_id,
19832003
existing_tools=tools,
19842004
has_compressed_content_this_turn=has_new_compressed_content,
2005+
transcript_requires_tool=transcript_requires_tool,
19852006
)
19862007
if ccr_tool_injected:
19872008
logger.debug(

headroom/proxy/handlers/openai.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3230,6 +3230,7 @@ async def handle_openai_chat(
32303230
from headroom.proxy.helpers import (
32313231
apply_session_sticky_ccr_tool,
32323232
has_new_ccr_markers,
3233+
transcript_references_ccr_tool,
32333234
)
32343235

32353236
# #1850: markers replayed from overlay_cached_prefix are
@@ -3242,12 +3243,23 @@ async def handle_openai_chat(
32423243
previous_forwarded_messages=openai_prefix_tracker.get_last_forwarded_messages(),
32433244
provider="openai",
32443245
)
3246+
3247+
# Self-heal a dangling headroom_retrieve reference after tracker
3248+
# state loss (proxy restart): the chat history still carries a
3249+
# prior headroom_retrieve tool call in assistant.tool_calls.
3250+
# OpenAI does not hard-400 on this the way Anthropic does, but
3251+
# re-injecting restores marker redeemability regardless.
3252+
transcript_requires_tool = transcript_references_ccr_tool(
3253+
optimized_messages,
3254+
provider="openai",
3255+
)
32453256
tools, ccr_tool_injected = apply_session_sticky_ccr_tool(
32463257
provider="openai",
32473258
session_id=openai_session_id,
32483259
request_id=request_id,
32493260
existing_tools=tools,
32503261
has_compressed_content_this_turn=has_new_compressed_content,
3262+
transcript_requires_tool=transcript_requires_tool,
32513263
)
32523264
if ccr_tool_injected:
32533265
logger.debug(

headroom/proxy/helpers.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
from headroom.proxy.ccr_marker_policy import (
7878
should_inject_ccr_tool as _should_inject_ccr_tool,
7979
)
80+
from headroom.proxy.ccr_marker_policy import (
81+
transcript_references_ccr_tool as transcript_references_ccr_tool, # noqa: F401 - compatibility export
82+
)
8083
from headroom.proxy.ccr_session_tracker import SessionCcrTracker as _SessionCcrTracker
8184
from headroom.proxy.ccr_session_tracker import (
8285
SessionExpansionDedupTracker as _SessionExpansionDedupTracker,
@@ -2324,6 +2327,7 @@ def should_inject_ccr_tool(
23242327
configured_inject_tool: bool,
23252328
frozen_message_count: int,
23262329
has_compressed_content: bool,
2330+
transcript_requires_tool: bool = False,
23272331
) -> tuple[bool, bool]:
23282332
"""Decide whether the ``headroom_retrieve`` tool must be injected this turn.
23292333
@@ -2337,6 +2341,11 @@ def should_inject_ccr_tool(
23372341
that case we override the deferral and inject anyway (one cache miss is
23382342
cheaper than dropped content).
23392343
2344+
``transcript_requires_tool`` similarly overrides the deferral when the
2345+
transcript still carries a dangling ``headroom_retrieve`` reference but the
2346+
sticky tracker state was lost (a ``/model`` switch or proxy restart) — the
2347+
reference would otherwise 400.
2348+
23402349
Returns ``(should_inject, is_marker_override)``. ``is_marker_override`` is
23412350
True only when injection happens *because* of new markers despite a deferral,
23422351
so the caller can log the override distinctly.
@@ -2345,6 +2354,7 @@ def should_inject_ccr_tool(
23452354
configured_inject_tool=configured_inject_tool,
23462355
frozen_message_count=frozen_message_count,
23472356
has_compressed_content=has_compressed_content,
2357+
transcript_requires_tool=transcript_requires_tool,
23482358
)
23492359

23502360

@@ -2355,6 +2365,7 @@ def apply_session_sticky_ccr_tool(
23552365
request_id: str | None,
23562366
existing_tools: list[dict[str, Any]] | None,
23572367
has_compressed_content_this_turn: bool,
2368+
transcript_requires_tool: bool = False,
23582369
) -> tuple[list[dict[str, Any]], bool]:
23592370
"""Apply sticky-on CCR retrieval-tool injection per :class:`SessionCcrTracker`.
23602371
@@ -2365,13 +2376,16 @@ def apply_session_sticky_ccr_tool(
23652376
Logic:
23662377
23672378
* If ``session_id`` is None: tracker is bypassed and the per-turn
2368-
``has_compressed_content_this_turn`` flag drives the decision
2369-
verbatim (matching legacy behaviour for WS / pre-session paths).
2379+
``has_compressed_content_this_turn`` flag (or ``transcript_requires_tool``)
2380+
drives the decision verbatim (matching legacy behaviour for WS paths).
23702381
* If the session has previously done CCR (``has_done_ccr``):
23712382
ALWAYS inject the recorded golden bytes — even if this turn has
23722383
no fresh compression. That is the load-bearing PR-B7 fix.
2373-
* Otherwise, inject only when this turn produced compressed content.
2374-
The first injection records the golden bytes for future turns.
2384+
* Otherwise, inject when this turn produced compressed content OR
2385+
``transcript_requires_tool`` is set (the transcript still names the tool
2386+
but tracker state was lost — a ``/model`` switch or restart). The first
2387+
injection records the golden bytes so subsequent turns resume the normal
2388+
sticky-replay path.
23752389
23762390
Tools whose name already equals ``CCR_TOOL_NAME`` (e.g. the client
23772391
pre-registered it via MCP) are not re-appended; the client's bytes
@@ -2405,7 +2419,7 @@ def apply_session_sticky_ccr_tool(
24052419

24062420
# No session_id (e.g. WS path): per-turn decision drives directly.
24072421
if not session_id:
2408-
if not has_compressed_content_this_turn:
2422+
if not has_compressed_content_this_turn and not transcript_requires_tool:
24092423
log_tool_injection_decision(
24102424
provider=provider,
24112425
session_id=None,
@@ -2419,7 +2433,11 @@ def apply_session_sticky_ccr_tool(
24192433
log_tool_injection_decision(
24202434
provider=provider,
24212435
session_id=None,
2422-
decision="inject_first_time",
2436+
decision=(
2437+
"inject_transcript_recovery"
2438+
if transcript_requires_tool and not has_compressed_content_this_turn
2439+
else "inject_first_time"
2440+
),
24232441
tool_definition_bytes_count=len(replay.canonical_bytes),
24242442
request_id=request_id,
24252443
)
@@ -2469,8 +2487,10 @@ def apply_session_sticky_ccr_tool(
24692487
)
24702488
return tools_out, True
24712489

2472-
# Fresh session — only inject when this turn produced compressed content.
2473-
if not has_compressed_content_this_turn:
2490+
# Fresh session — inject when this turn produced compressed content, or when
2491+
# the transcript still references the tool but tracker state was lost
2492+
# (transcript recovery: a /model switch or proxy restart).
2493+
if not has_compressed_content_this_turn and not transcript_requires_tool:
24742494
log_tool_injection_decision(
24752495
provider=provider,
24762496
session_id=session_id,
@@ -2486,7 +2506,11 @@ def apply_session_sticky_ccr_tool(
24862506
log_tool_injection_decision(
24872507
provider=provider,
24882508
session_id=session_id,
2489-
decision="inject_first_time",
2509+
decision=(
2510+
"inject_transcript_recovery"
2511+
if transcript_requires_tool and not has_compressed_content_this_turn
2512+
else "inject_first_time"
2513+
),
24902514
tool_definition_bytes_count=len(replay.canonical_bytes),
24912515
request_id=request_id,
24922516
)

headroom/proxy/tool_injection_logging.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
ToolInjectionDecision = Literal[
99
"inject_first_time",
1010
"inject_sticky_replay",
11+
"inject_transcript_recovery",
1112
"skip",
1213
"skip_disabled_via_env",
1314
]

0 commit comments

Comments
 (0)