From 504c98038631e7eaaa3eed5d909180d7e5a387a6 Mon Sep 17 00:00:00 2001 From: nangsontay Date: Thu, 23 Jul 2026 10:49:58 +0700 Subject: [PATCH 1/2] fix(proxy/ccr): self-heal dangling headroom_retrieve reference after model switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching models mid-session (/model in Claude Code) or restarting the proxy rotated the model-scoped in-memory sticky session key, so the proxy stopped injecting headroom_retrieve while the client transcript still carried a tool_reference naming it — Anthropic 400'd every subsequent turn ("Tool reference 'headroom_retrieve' not found in available tools"). Injection now self-heals from the transcript: transcript_references_ccr_tool scans about-to-forward messages for a bare headroom_retrieve in anthropic tool_reference/tool_use blocks (one level of nesting) or an openai-chat assistant.tool_calls entry, and a new transcript_requires_tool flag forces (re-)injection through should_inject_ccr_tool and apply_session_sticky_ccr_tool (decision inject_transcript_recovery), recording sticky state so subsequent turns resume normal sticky replay. Exact bare-name match only, so a client-owned mcp__headroom__headroom_retrieve never triggers proxy injection. The openai /v1/responses path never proxy-injects the tool, so recovery is N/A there. --- docs/content/docs/troubleshooting.mdx | 27 +++ headroom/proxy/ccr_marker_policy.py | 94 +++++++++- headroom/proxy/handlers/anthropic.py | 21 +++ headroom/proxy/handlers/openai.py | 12 ++ headroom/proxy/helpers.py | 42 ++++- headroom/proxy/tool_injection_logging.py | 1 + tests/test_ccr_tool_always_on.py | 221 +++++++++++++++++++++++ 7 files changed, 407 insertions(+), 11 deletions(-) diff --git a/docs/content/docs/troubleshooting.mdx b/docs/content/docs/troubleshooting.mdx index 22ef73ae89..cac895275c 100644 --- a/docs/content/docs/troubleshooting.mdx +++ b/docs/content/docs/troubleshooting.mdx @@ -213,6 +213,33 @@ See [issue #746](https://github.com/headroomlabs-ai/headroom/issues/746) for the deployment. See [issue #2028](https://github.com/headroomlabs-ai/headroom/issues/2028). +## `Tool reference 'headroom_retrieve' not found` after a `/model` switch + +**Symptom**: Through the proxy, switching models mid-session (`/model` in Claude Code) — +or restarting the proxy — makes every subsequent turn fail with: + +``` +400 Tool reference 'headroom_retrieve' not found in available tools +``` + +**Cause**: Once a session has done CCR compression, the proxy registers the +`headroom_retrieve` retrieval tool and the client transcript starts carrying a +`tool_reference` block naming it. Anthropic validates every `tool_reference` in the +messages against the request's `tools` array. The proxy's sticky guarantee that keeps +re-injecting the tool was keyed by a **model-scoped, in-memory** session id, so a +`/model` switch (or a proxy restart) rotated/lost that key — the tool stopped being +injected while the transcript kept re-sending the reference, leaving it dangling. + +**Fix**: Upgrade to a build that self-heals this. The proxy now scans the +about-to-forward transcript for a dangling `headroom_retrieve` `tool_reference`/`tool_use` +and re-injects the tool independent of tracker state, so `/model` switches and restarts +recover automatically (logged as `decision=inject_transcript_recovery`). + +**Workarounds on older versions**: + +- `/model` back to the model you started the session on, or +- `/clear` to start a fresh session (drops the dangling reference). + ## Remote Control unavailable through custom ANTHROPIC_BASE_URL **Symptom**: When Claude Code runs with `ANTHROPIC_BASE_URL` set to a custom host (for example, Headroom), the Remote Control menu is absent. diff --git a/headroom/proxy/ccr_marker_policy.py b/headroom/proxy/ccr_marker_policy.py index ca1be0c8af..e82e8f4968 100644 --- a/headroom/proxy/ccr_marker_policy.py +++ b/headroom/proxy/ccr_marker_policy.py @@ -35,11 +35,101 @@ def should_inject_ccr_tool( configured_inject_tool: bool, frozen_message_count: int, has_compressed_content: bool, + transcript_requires_tool: bool = False, ) -> tuple[bool, bool]: - """Decide whether the CCR retrieval tool must be injected this turn.""" + """Decide whether the CCR retrieval tool must be injected this turn. + + ``transcript_requires_tool`` forces injection when the about-to-forward + transcript already names ``headroom_retrieve`` but tracker state was lost + (a ``/model`` switch or proxy restart) — the dangling reference would + otherwise 400. It does NOT set ``is_marker_override``: that flag stays + specific to fresh #1006 markers so the caller can log each cause distinctly. + """ inject_tool = configured_inject_tool if inject_tool and frozen_message_count > 0: inject_tool = False is_marker_override = not inject_tool and has_compressed_content - return (inject_tool or is_marker_override), is_marker_override + should_inject = inject_tool or is_marker_override or transcript_requires_tool + return should_inject, is_marker_override + + +def transcript_references_ccr_tool( + messages: list[dict[str, Any]] | None, + *, + tool_name: str | None = None, + provider: Literal["anthropic", "openai", "google"] = "anthropic", +) -> bool: + """Whether the about-to-forward transcript already names the CCR retrieve tool. + + Once an Anthropic ``tool_reference``/``tool_use`` block, or an assistant + ``tool_calls`` entry (OpenAI chat), names ``headroom_retrieve``, the request's + ``tools`` array MUST still carry that tool or Anthropic 400s ("Tool reference + 'headroom_retrieve' not found in available tools"). The sticky guarantee is + model-scoped and in-memory, so a ``/model`` switch or proxy restart loses it + while the client transcript keeps re-sending the reference. This scan lets + injection self-heal from the transcript, per request, independent of tracker + state. + + ``provider="google"`` is accepted for signature parity with the sibling + policy fns but currently falls through the Anthropic matcher — Gemini's + ``functionCall`` parts are not matched (no google CCR handler calls this yet). + + Only the exact bare ``headroom_retrieve`` name matches — a client-owned + ``mcp__headroom__headroom_retrieve`` (registered via MCP, lifecycle owned by + the client) must not trigger proxy injection. Tolerates string content and + malformed blocks. + """ + if not messages: + return False + if tool_name is None: + from headroom.ccr.tool_injection import CCR_TOOL_NAME + + tool_name = CCR_TOOL_NAME + + for msg in messages: + if not isinstance(msg, dict): + continue + if provider == "openai": + if _openai_message_references_tool(msg, tool_name): + return True + elif _anthropic_content_references_tool(msg.get("content"), tool_name): + return True + return False + + +def _anthropic_content_references_tool(content: Any, tool_name: str) -> bool: + """Match a bare ``tool_name`` in tool_reference/tool_use blocks (one level deep).""" + if not isinstance(content, list): + return False + for block in content: + if not isinstance(block, dict): + continue + if block.get("type") in ("tool_reference", "tool_use") and block.get("name") == tool_name: + return True + # tool_search_tool_result / tool_result nest blocks one level down. + nested = block.get("content") + if isinstance(nested, list): + for inner in nested: + if ( + isinstance(inner, dict) + and inner.get("type") in ("tool_reference", "tool_use") + and inner.get("name") == tool_name + ): + return True + return False + + +def _openai_message_references_tool(msg: dict[str, Any], tool_name: str) -> bool: + """Match a bare ``tool_name`` in an assistant message's ``tool_calls`` (chat shape).""" + tool_calls = msg.get("tool_calls") + if not isinstance(tool_calls, list): + return False + for call in tool_calls: + if not isinstance(call, dict): + continue + fn = call.get("function") + name = fn.get("name") if isinstance(fn, dict) else call.get("name") + if name == tool_name: + return True + return False diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index a0fe245a8f..da9714afb4 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -1849,6 +1849,7 @@ class _DeferredCompressionResult: from headroom.proxy.helpers import ( has_new_ccr_markers, should_inject_ccr_tool, + transcript_references_ccr_tool, ) # #1850: only markers NEW this turn justify overriding the @@ -1863,10 +1864,22 @@ class _DeferredCompressionResult: provider="anthropic", ) + # Self-heal a dangling headroom_retrieve reference: a /model + # switch or proxy restart rotates the model-scoped sticky key, + # so the tracker stops injecting while the client transcript + # still carries a tool_reference/tool_use naming the tool. + # Anthropic 400s every such turn. Scanning the about-to-forward + # transcript recovers injection independent of tracker state. + transcript_requires_tool = transcript_references_ccr_tool( + optimized_messages, + provider="anthropic", + ) + should_inject, is_marker_override = should_inject_ccr_tool( configured_inject_tool=configured_inject_tool, frozen_message_count=frozen_message_count, has_compressed_content=has_new_compressed_content, + transcript_requires_tool=transcript_requires_tool, ) if should_inject: if is_marker_override: @@ -1876,6 +1889,13 @@ class _DeferredCompressionResult: f"(frozen_message_count={frozen_message_count}); injecting to " "prevent unredeemable markers (#1006)" ) + elif transcript_requires_tool: + logger.info( + f"[{request_id}] CCR: recovering headroom_retrieve from " + "transcript — a dangling tool_reference exists but sticky " + "tracker state was lost (/model switch or restart); " + "re-injecting to avoid a 400" + ) from headroom.proxy.helpers import apply_session_sticky_ccr_tool tools, ccr_tool_injected = apply_session_sticky_ccr_tool( @@ -1884,6 +1904,7 @@ class _DeferredCompressionResult: request_id=request_id, existing_tools=tools, has_compressed_content_this_turn=has_new_compressed_content, + transcript_requires_tool=transcript_requires_tool, ) if ccr_tool_injected: logger.debug( diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 3637d7d637..8b07164fd7 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -3230,6 +3230,7 @@ async def handle_openai_chat( from headroom.proxy.helpers import ( apply_session_sticky_ccr_tool, has_new_ccr_markers, + transcript_references_ccr_tool, ) # #1850: markers replayed from overlay_cached_prefix are @@ -3242,12 +3243,23 @@ async def handle_openai_chat( previous_forwarded_messages=openai_prefix_tracker.get_last_forwarded_messages(), provider="openai", ) + + # Self-heal a dangling headroom_retrieve reference after tracker + # state loss (proxy restart): the chat history still carries a + # prior headroom_retrieve tool call in assistant.tool_calls. + # OpenAI does not hard-400 on this the way Anthropic does, but + # re-injecting restores marker redeemability regardless. + transcript_requires_tool = transcript_references_ccr_tool( + optimized_messages, + provider="openai", + ) tools, ccr_tool_injected = apply_session_sticky_ccr_tool( provider="openai", session_id=openai_session_id, request_id=request_id, existing_tools=tools, has_compressed_content_this_turn=has_new_compressed_content, + transcript_requires_tool=transcript_requires_tool, ) if ccr_tool_injected: logger.debug( diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 7dfc7d91bb..206f882a2e 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -77,6 +77,9 @@ from headroom.proxy.ccr_marker_policy import ( should_inject_ccr_tool as _should_inject_ccr_tool, ) +from headroom.proxy.ccr_marker_policy import ( + transcript_references_ccr_tool as transcript_references_ccr_tool, # noqa: F401 - compatibility export +) from headroom.proxy.ccr_session_tracker import SessionCcrTracker as _SessionCcrTracker from headroom.proxy.internal_header_policy import ( INTERNAL_HEADER_PREFIX, @@ -2263,6 +2266,7 @@ def should_inject_ccr_tool( configured_inject_tool: bool, frozen_message_count: int, has_compressed_content: bool, + transcript_requires_tool: bool = False, ) -> tuple[bool, bool]: """Decide whether the ``headroom_retrieve`` tool must be injected this turn. @@ -2276,6 +2280,11 @@ def should_inject_ccr_tool( that case we override the deferral and inject anyway (one cache miss is cheaper than dropped content). + ``transcript_requires_tool`` similarly overrides the deferral when the + transcript still carries a dangling ``headroom_retrieve`` reference but the + sticky tracker state was lost (a ``/model`` switch or proxy restart) — the + reference would otherwise 400. + Returns ``(should_inject, is_marker_override)``. ``is_marker_override`` is True only when injection happens *because* of new markers despite a deferral, so the caller can log the override distinctly. @@ -2284,6 +2293,7 @@ def should_inject_ccr_tool( configured_inject_tool=configured_inject_tool, frozen_message_count=frozen_message_count, has_compressed_content=has_compressed_content, + transcript_requires_tool=transcript_requires_tool, ) @@ -2294,6 +2304,7 @@ def apply_session_sticky_ccr_tool( request_id: str | None, existing_tools: list[dict[str, Any]] | None, has_compressed_content_this_turn: bool, + transcript_requires_tool: bool = False, ) -> tuple[list[dict[str, Any]], bool]: """Apply sticky-on CCR retrieval-tool injection per :class:`SessionCcrTracker`. @@ -2304,13 +2315,16 @@ def apply_session_sticky_ccr_tool( Logic: * If ``session_id`` is None: tracker is bypassed and the per-turn - ``has_compressed_content_this_turn`` flag drives the decision - verbatim (matching legacy behaviour for WS / pre-session paths). + ``has_compressed_content_this_turn`` flag (or ``transcript_requires_tool``) + drives the decision verbatim (matching legacy behaviour for WS paths). * If the session has previously done CCR (``has_done_ccr``): ALWAYS inject the recorded golden bytes — even if this turn has no fresh compression. That is the load-bearing PR-B7 fix. - * Otherwise, inject only when this turn produced compressed content. - The first injection records the golden bytes for future turns. + * Otherwise, inject when this turn produced compressed content OR + ``transcript_requires_tool`` is set (the transcript still names the tool + but tracker state was lost — a ``/model`` switch or restart). The first + injection records the golden bytes so subsequent turns resume the normal + sticky-replay path. Tools whose name already equals ``CCR_TOOL_NAME`` (e.g. the client pre-registered it via MCP) are not re-appended; the client's bytes @@ -2344,7 +2358,7 @@ def apply_session_sticky_ccr_tool( # No session_id (e.g. WS path): per-turn decision drives directly. if not session_id: - if not has_compressed_content_this_turn: + if not has_compressed_content_this_turn and not transcript_requires_tool: log_tool_injection_decision( provider=provider, session_id=None, @@ -2358,7 +2372,11 @@ def apply_session_sticky_ccr_tool( log_tool_injection_decision( provider=provider, session_id=None, - decision="inject_first_time", + decision=( + "inject_transcript_recovery" + if transcript_requires_tool and not has_compressed_content_this_turn + else "inject_first_time" + ), tool_definition_bytes_count=len(replay.canonical_bytes), request_id=request_id, ) @@ -2408,8 +2426,10 @@ def apply_session_sticky_ccr_tool( ) return tools_out, True - # Fresh session — only inject when this turn produced compressed content. - if not has_compressed_content_this_turn: + # Fresh session — inject when this turn produced compressed content, or when + # the transcript still references the tool but tracker state was lost + # (transcript recovery: a /model switch or proxy restart). + if not has_compressed_content_this_turn and not transcript_requires_tool: log_tool_injection_decision( provider=provider, session_id=session_id, @@ -2425,7 +2445,11 @@ def apply_session_sticky_ccr_tool( log_tool_injection_decision( provider=provider, session_id=session_id, - decision="inject_first_time", + decision=( + "inject_transcript_recovery" + if transcript_requires_tool and not has_compressed_content_this_turn + else "inject_first_time" + ), tool_definition_bytes_count=len(replay.canonical_bytes), request_id=request_id, ) diff --git a/headroom/proxy/tool_injection_logging.py b/headroom/proxy/tool_injection_logging.py index b6e9baa1d6..df15f82abc 100644 --- a/headroom/proxy/tool_injection_logging.py +++ b/headroom/proxy/tool_injection_logging.py @@ -8,6 +8,7 @@ ToolInjectionDecision = Literal[ "inject_first_time", "inject_sticky_replay", + "inject_transcript_recovery", "skip", "skip_disabled_via_env", ] diff --git a/tests/test_ccr_tool_always_on.py b/tests/test_ccr_tool_always_on.py index 4bc457b626..46a25baef5 100644 --- a/tests/test_ccr_tool_always_on.py +++ b/tests/test_ccr_tool_always_on.py @@ -373,3 +373,224 @@ def test_ccrtoolinjector_session_has_done_ccr_kwarg(): tools, was = injector.inject_tool_definition(None, session_has_done_ccr=True) assert was is True assert _has_ccr_tool(tools) + + +# ─── Transcript-scan recovery (dangling tool_reference after /model switch) ── +# Covers headroom/proxy/ccr_marker_policy.transcript_references_ccr_tool plus the +# transcript_requires_tool wiring through apply_session_sticky_ccr_tool. Regression +# guard for the 400 "Tool reference 'headroom_retrieve' not found in available tools". + +from headroom.proxy.helpers import ( # noqa: E402 + should_inject_ccr_tool, + transcript_references_ccr_tool, +) + + +def _anthropic_tool_reference_msg(name=CCR_TOOL_NAME): + return {"role": "user", "content": [{"type": "tool_reference", "name": name}]} + + +def _anthropic_tool_use_msg(name=CCR_TOOL_NAME): + return { + "role": "assistant", + "content": [ + {"type": "text", "text": "looking that up"}, + {"type": "tool_use", "id": "toolu_1", "name": name, "input": {"hash": "abc"}}, + ], + } + + +def _openai_tool_call_msg(name=CCR_TOOL_NAME): + return { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "call_1", "type": "function", "function": {"name": name, "arguments": "{}"}} + ], + } + + +# --- policy: transcript_references_ccr_tool --------------------------------- + + +def test_transcript_ref_matches_anthropic_tool_reference(): + assert transcript_references_ccr_tool([_anthropic_tool_reference_msg()]) is True + + +def test_transcript_ref_matches_anthropic_tool_use(): + assert transcript_references_ccr_tool([_anthropic_tool_use_msg()]) is True + + +def test_transcript_ref_matches_nested_one_level(): + """tool_search_tool_result / tool_result nest the reference one level down.""" + msg = { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_9", + "content": [{"type": "tool_reference", "name": CCR_TOOL_NAME}], + } + ], + } + assert transcript_references_ccr_tool([msg]) is True + + +def test_transcript_ref_ignores_mcp_prefixed_name(): + """Client-owned mcp__headroom__headroom_retrieve must NOT trigger injection.""" + ref = _anthropic_tool_reference_msg(name="mcp__headroom__headroom_retrieve") + use = _anthropic_tool_use_msg(name="mcp__headroom__headroom_retrieve") + assert transcript_references_ccr_tool([ref, use]) is False + + +def test_transcript_ref_tolerates_string_and_malformed_content(): + messages = [ + {"role": "user", "content": "plain string content"}, + {"role": "assistant", "content": [None, "loose text", {"type": "text"}]}, + "not-a-dict", + {"no_content_key": True}, + ] + assert transcript_references_ccr_tool(messages) is False + + +def test_transcript_ref_empty_and_none(): + assert transcript_references_ccr_tool(None) is False + assert transcript_references_ccr_tool([]) is False + + +def test_transcript_ref_matches_openai_tool_calls(): + assert transcript_references_ccr_tool([_openai_tool_call_msg()], provider="openai") is True + + +def test_transcript_ref_openai_ignores_mcp_prefixed_name(): + msg = _openai_tool_call_msg(name="mcp__headroom__headroom_retrieve") + assert transcript_references_ccr_tool([msg], provider="openai") is False + + +# --- policy: should_inject_ccr_tool transcript override --------------------- + + +def test_should_inject_transcript_override_beats_frozen_deferral(): + """Frozen prefix defers config injection; a dangling reference overrides it.""" + should_inject, is_marker_override = should_inject_ccr_tool( + configured_inject_tool=True, + frozen_message_count=5, + has_compressed_content=False, + transcript_requires_tool=True, + ) + assert should_inject is True + # is_marker_override stays specific to fresh #1006 markers, not this path. + assert is_marker_override is False + + +def test_should_inject_no_transcript_no_markers_stays_deferred(): + should_inject, is_marker_override = should_inject_ccr_tool( + configured_inject_tool=True, + frozen_message_count=5, + has_compressed_content=False, + transcript_requires_tool=False, + ) + assert should_inject is False + assert is_marker_override is False + + +# --- integration: apply_session_sticky_ccr_tool transcript recovery --------- + + +def test_transcript_recovery_injects_on_fresh_tracker(caplog): + """/model-switch shape: dangling ref, fresh tracker, no fresh markers → inject.""" + import logging + + with caplog.at_level(logging.INFO): + tools, injected = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id="rotated-session-key", + request_id="rec-1", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=True, + ) + assert injected is True + assert _has_ccr_tool(tools) + assert "inject_transcript_recovery" in caplog.text + + +def test_transcript_recovery_records_sticky_state(): + """After recovery injects, the next turn replays without needing the flag.""" + session_id = "recovery-then-sticky" + _tools1, injected1 = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id=session_id, + request_id="rec-a", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=True, + ) + assert injected1 is True + + # Subsequent turn: no dangling ref, no fresh markers — sticky replay covers it. + tools2, injected2 = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id=session_id, + request_id="rec-b", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=False, + ) + assert injected2 is True + assert _has_ccr_tool(tools2) + + +def test_transcript_recovery_no_session_id_path(): + tools, injected = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id=None, + request_id="rec-ws", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=True, + ) + assert injected is True + assert _has_ccr_tool(tools) + + +def test_reference_free_request_unchanged(): + """No dangling ref + fresh tracker + no markers → still no injection.""" + tools, injected = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id="fresh-no-ref", + request_id="rec-none", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=False, + ) + assert injected is False + assert not _has_ccr_tool(tools) + + +def test_transcript_recovery_skips_when_client_already_has_tool(): + """Client already provides the tool — no dangling ref, so do not double up.""" + client_tool = {"name": CCR_TOOL_NAME, "input_schema": {"type": "object"}} + tools, injected = apply_session_sticky_ccr_tool( + provider="anthropic", + session_id="client-owned", + request_id="rec-dup", + existing_tools=[client_tool], + has_compressed_content_this_turn=False, + transcript_requires_tool=True, + ) + assert injected is False + assert len(tools) == 1 + + +def test_transcript_recovery_openai_chat_shape(): + tools, injected = apply_session_sticky_ccr_tool( + provider="openai", + session_id="openai-rotated", + request_id="rec-oai", + existing_tools=[], + has_compressed_content_this_turn=False, + transcript_requires_tool=True, + ) + assert injected is True + assert _has_ccr_tool(tools) From 2c2c6919018042e1b4a830f554e5e6180190761f Mon Sep 17 00:00:00 2001 From: nangsontay Date: Thu, 23 Jul 2026 17:13:45 +0700 Subject: [PATCH 2/2] refactor(proxy/ccr): dedup tool-match predicate in transcript scan Extract a local matches() helper in _anthropic_content_references_tool so the tool_reference/tool_use type+name check is written once and reused for both top-level and one-level-nested blocks. --- headroom/proxy/ccr_marker_policy.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/headroom/proxy/ccr_marker_policy.py b/headroom/proxy/ccr_marker_policy.py index e82e8f4968..3a38210c3f 100644 --- a/headroom/proxy/ccr_marker_policy.py +++ b/headroom/proxy/ccr_marker_policy.py @@ -102,21 +102,23 @@ def _anthropic_content_references_tool(content: Any, tool_name: str) -> bool: """Match a bare ``tool_name`` in tool_reference/tool_use blocks (one level deep).""" if not isinstance(content, list): return False + + def matches(block: Any) -> bool: + return ( + isinstance(block, dict) + and block.get("type") in ("tool_reference", "tool_use") + and block.get("name") == tool_name + ) + for block in content: if not isinstance(block, dict): continue - if block.get("type") in ("tool_reference", "tool_use") and block.get("name") == tool_name: + if matches(block): return True # tool_search_tool_result / tool_result nest blocks one level down. nested = block.get("content") - if isinstance(nested, list): - for inner in nested: - if ( - isinstance(inner, dict) - and inner.get("type") in ("tool_reference", "tool_use") - and inner.get("name") == tool_name - ): - return True + if isinstance(nested, list) and any(matches(inner) for inner in nested): + return True return False