7777from 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+ )
8083from headroom .proxy .ccr_session_tracker import SessionCcrTracker as _SessionCcrTracker
8184from 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 )
0 commit comments