66
77from __future__ import annotations
88
9- import copy
109import hashlib
1110import json
1211import logging
1312import threading
14- import time
1513from collections import OrderedDict
1614from dataclasses import dataclass
1715
@@ -77,16 +75,22 @@ def _extract_tool_result_content(msg: dict) -> str | None:
7775
7876
7977def _swap_tool_result_content (msg : dict , new_content : str ) -> dict :
80- """Deep copy msg and replace tool result content with new_content."""
81- new_msg = copy .deepcopy (msg )
78+ """Shallow-copy msg and replace tool result content with new_content.
79+
80+ Only the message dict, its content list, and the swapped tool_result
81+ block are copied; sibling blocks stay shared refs with the input. Safe
82+ because nothing downstream mutates message/block dicts in place (Phase 2
83+ mutation audit) — a deepcopy here bought no safety, only cost, and ran
84+ inside the cache lock (perf review F4).
85+ """
8286 # OpenAI format
83- if new_msg .get ("role" ) == "tool" :
84- new_msg ["content" ] = new_content
85- return new_msg
87+ if msg .get ("role" ) == "tool" :
88+ return {** msg , "content" : new_content }
8689 # Anthropic format
87- content = new_msg .get ("content" )
90+ content = msg .get ("content" )
8891 if isinstance (content , list ):
89- for block in content :
92+ new_content_list = list (content )
93+ for i , block in enumerate (new_content_list ):
9094 if isinstance (block , dict ) and block .get ("type" ) == "tool_result" :
9195 inner = block .get ("content" )
9296 if isinstance (inner , list ):
@@ -95,11 +99,13 @@ def _swap_tool_result_content(msg: dict, new_content: str) -> dict:
9599 # multiple text blocks would produce a different joined
96100 # output on re-extraction (first text block replaced,
97101 # remaining text blocks still joined).
98- block [ "content" ] = [{"type" : "text" , "text" : new_content }]
102+ new_block_content : str | list = [{"type" : "text" , "text" : new_content }]
99103 else :
100- block ["content" ] = new_content
104+ new_block_content = new_content
105+ new_content_list [i ] = {** block , "content" : new_block_content }
101106 break
102- return new_msg
107+ return {** msg , "content" : new_content_list }
108+ return dict (msg )
103109
104110
105111class CompressionCache :
@@ -116,7 +122,7 @@ def __init__(self, max_entries: int = 10000) -> None:
116122 # session (Claude Code background tools, parallel agents, etc.) into
117123 # `asyncio.to_thread` workers — without this, two concurrent
118124 # requests for the same `session_id` race on `_cache`,
119- # `_stable_hashes`, `_first_seen`, and `_total_tokens_saved`. The
125+ # `_stable_hashes`, and `_total_tokens_saved`. The
120126 # observable failures are (a) lost-update on `_total_tokens_saved`,
121127 # (b) `OrderedDict mutated during iteration` in `apply_cached`, and
122128 # (c) lost stable-hash records that drop the next-turn cache lookup.
@@ -136,7 +142,6 @@ def __init__(self, max_entries: int = 10000) -> None:
136142 # `proxy/handlers/anthropic.py`) and `update_from_result`'s
137143 # "unchanged content" tracking.
138144 self ._stable_hashes : set [str ] = set ()
139- self ._first_seen : dict [str , float ] = {}
140145 self ._hits : int = 0
141146 self ._misses : int = 0
142147 self ._total_tokens_saved : int = 0
@@ -191,40 +196,6 @@ def mark_stable_from_messages(self, messages: list[dict], up_to: int) -> None:
191196 if content is not None :
192197 self ._stable_hashes .add (self .content_hash (content ))
193198
194- def should_defer_compression (
195- self ,
196- content_hash : str ,
197- ttl_seconds : float = 300.0 ,
198- batch_window : float = 30.0 ,
199- ) -> bool :
200- """Whether to defer compressing this content to avoid mid-TTL busts.
201-
202- Returns True if we have evidence this content has been re-sent
203- within the cache TTL window — recompressing it now would bust an
204- existing prefix-cache entry without TTL-amortizing the bust over
205- future turns. Returns False otherwise:
206-
207- - **First sight** of the content. Compress now: there is no
208- prefix-cache entry to preserve yet (this byte range was not in
209- a prior request), so compression carries no bust cost. Issue
210- #327: a previous version returned True here, which marked the
211- freshest tool_result on every turn as "stable" and effectively
212- disabled compression for typical Claude Code workloads where
213- each tool_result is unique-per-turn.
214- - **Near the TTL boundary**: compress now and amortize the bust
215- across future turns (batched recompression).
216- """
217- with self ._lock :
218- now = time .time ()
219- first_seen = self ._first_seen .get (content_hash )
220- if first_seen is None :
221- self ._first_seen [content_hash ] = now
222- return False # First time — compress now (no cache entry to preserve)
223- age = now - first_seen
224- if age >= ttl_seconds - batch_window :
225- return False # Near TTL boundary — compress now (batch window)
226- return True # Seen recently within TTL — defer to preserve cache
227-
228199 def get_stats (self ) -> dict :
229200 """Return cache statistics."""
230201 with self ._lock :
@@ -311,6 +282,78 @@ def apply_cached(self, messages: list[dict]) -> list[dict]:
311282 result .append (msg )
312283 return result
313284
285+ def prepare_turn (
286+ self , messages : list [dict ], tracker_frozen_count : int
287+ ) -> tuple [int , list [dict ]]:
288+ """Single-pass replacement for compute_frozen_count + mark_stable_from_messages + apply_cached.
289+
290+ Extracts and hashes each tool_result exactly ONCE instead of 3-4
291+ times per request (perf review F4). Returns ``(frozen_count,
292+ working_messages)``:
293+
294+ - ``frozen_count``: identical semantics to ``compute_frozen_count``
295+ plus the caller's ``min(frozen_message_count, cache_frozen_count)``
296+ clamp (including the trailing-message live-zone cap) — pass in
297+ the provider-confirmed ``tracker_frozen_count`` and get the final
298+ clamped value back directly.
299+ - ``working_messages``: identical to ``apply_cached(messages)`` —
300+ cached compressions swapped in where available. Computing this is
301+ free here (same walk); callers that skip compression this turn
302+ (e.g. deferred tool injection) may simply ignore it and use
303+ ``messages`` directly. Note: because the skip decision depends on
304+ the frozen count this method returns, the swap lookup runs before
305+ the caller can know it will skip — so hit/miss stats and the LRU
306+ recency touch fire on skip turns too (the old ``apply_cached``
307+ wasn't called there). Telemetry-only drift; the touched entries
308+ are genuinely in active use, so the recency signal stays honest.
309+
310+ Content extraction + hashing happen BEFORE the lock is acquired —
311+ the lock only needs to guard ``_cache``/``_stable_hashes`` access
312+ (perf review F4 lock-hygiene note).
313+ """
314+ # `hashes[i]` is None for non-tool_result messages or tool_results
315+ # with non-string/unextractable content (mirrors the "treat as
316+ # unstable" branch in the old `compute_frozen_count`).
317+ hashes : list [str | None ] = [None ] * len (messages )
318+ for i , msg in enumerate (messages ):
319+ if _is_tool_result_message (msg ):
320+ content = _extract_tool_result_content (msg )
321+ if content is not None :
322+ hashes [i ] = self .content_hash (content )
323+
324+ with self ._lock :
325+ # (a) compute_frozen_count equivalent, using pre-mark state —
326+ # matches the original call order (frozen count computed BEFORE
327+ # mark_stable_from_messages runs).
328+ count = 0
329+ for i , msg in enumerate (messages ):
330+ if _is_tool_result_message (msg ):
331+ h = hashes [i ]
332+ if h is None or (h not in self ._cache and h not in self ._stable_hashes ):
333+ break
334+ count += 1
335+ local_frozen_count = min (count , max (0 , len (messages ) - 1 ))
336+ frozen_count = min (tracker_frozen_count , local_frozen_count )
337+
338+ # (b) mark_stable_from_messages(messages, frozen_count) equivalent.
339+ for i in range (frozen_count ):
340+ h = hashes [i ]
341+ if h is not None :
342+ self ._stable_hashes .add (h )
343+
344+ # (c) apply_cached equivalent.
345+ working_messages : list [dict ] = []
346+ for i , msg in enumerate (messages ):
347+ h = hashes [i ]
348+ if h is not None :
349+ compressed = self .get_compressed (h )
350+ if compressed is not None :
351+ working_messages .append (_swap_tool_result_content (msg , compressed ))
352+ continue
353+ working_messages .append (msg )
354+
355+ return frozen_count , working_messages
356+
314357 def update_from_result (self , originals : list [dict ], compressed : list [dict ]) -> None :
315358 """Cache new compressions by comparing original and compressed messages.
316359
0 commit comments