@@ -118,6 +118,125 @@ def tool_names_by_call_id(messages: list[Message]) -> dict[str, str]:
118118_tool_names_by_call_id = tool_names_by_call_id
119119
120120
121+ # ---------------------------------------------------------------------------
122+ # Elided-tool-result mini card
123+ #
124+ # Tier 1 used to leave ONLY ``OMITTED_TOOL_RESULT_PLACEHOLDER``, dropping the
125+ # call's arguments and every source URL — precisely the two things a later turn
126+ # needs in order not to re-issue a query it already ran. Tier 2's summary does
127+ # preserve both, but Tier 2 only fires when Tier 1 did not free enough, so on a
128+ # Tier1-only turn the model saw strictly less than it had to.
129+ #
130+ # Both fields are free: the arguments are on the requesting assistant message,
131+ # the URLs are in the body about to be discarded. No LLM call, no extra storage,
132+ # and no second model-visible index — the card names the call, it does not offer
133+ # a way to fetch anything (that stays with the recovery footnote below it).
134+ #
135+ # The budget matters: a single web_search body can carry dozens of URLs, and an
136+ # unbounded card would hand back the context Tier 1 just freed. Fill args first
137+ # (they identify the call), then URLs until the budget runs out.
138+ # ---------------------------------------------------------------------------
139+
140+ _MINI_CARD_ARGS_MAX_CHARS = 120
141+ _MINI_CARD_BODY_MAX_CHARS = 400
142+ _MINI_CARD_MAX_URLS = 3
143+ _WHITESPACE_RE = re .compile (r"\s+" )
144+
145+
146+ def _args_preview (raw : object ) -> str :
147+ """Collapse a tool call's arguments to one short single-line preview.
148+
149+ ``bash`` commands and ``web_fetch`` payloads carry newlines and heredocs; a
150+ multi-line card would cost more rows than the body it replaces.
151+ """
152+ rendered = raw if isinstance (raw , str ) else str (raw or "" )
153+ collapsed = _WHITESPACE_RE .sub (" " , rendered ).strip ()
154+ if len (collapsed ) <= _MINI_CARD_ARGS_MAX_CHARS :
155+ return collapsed
156+ return collapsed [:_MINI_CARD_ARGS_MAX_CHARS ] + "\u2026 "
157+
158+
159+ def _tool_args_by_call_id (messages : list [Message ]) -> dict [str , str ]:
160+ """Map ``tool_call_id`` → bounded preview of the arguments it was sent.
161+
162+ Kept private, unlike :func:`tool_names_by_call_id`: no product facade
163+ resolves arguments by call id, so there is no older spelling to honour.
164+ """
165+ out : dict [str , str ] = {}
166+ for msg in messages :
167+ if not is_assistant_msg (msg ):
168+ continue
169+ for tc_value in cast (list [Any ], msg .get ("tool_calls" ) or []):
170+ if not isinstance (tc_value , dict ):
171+ continue
172+ tc = cast (dict [str , Any ], tc_value )
173+ fn_value = tc .get ("function" )
174+ fn = cast (dict [str , Any ], fn_value ) if isinstance (fn_value , dict ) else None
175+ raw = (
176+ fn .get ("arguments" , tc .get ("args" , "" ))
177+ if fn is not None
178+ else tc .get ("arguments" , tc .get ("args" , "" ))
179+ )
180+ tid = tc .get ("id" ) or (fn .get ("id" ) if fn is not None else None )
181+ if not isinstance (tid , str ) or not tid :
182+ continue
183+ preview = _args_preview (raw )
184+ if preview :
185+ out [tid ] = preview
186+ return out
187+
188+
189+ def _elided_tool_card (tool_name : str , args_preview : str , content : str ) -> str :
190+ """Render the card lines that stand in for a discarded tool body.
191+
192+ Returns ``""`` when there is nothing worth saying (no name, no arguments, no
193+ URLs), so the caller falls back to the bare placeholder rather than emitting
194+ an empty line.
195+ """
196+ budget = _MINI_CARD_BODY_MAX_CHARS
197+ lines : list [str ] = []
198+ if tool_name or args_preview :
199+ call_line = (
200+ f"[Called: { tool_name } ({ args_preview } )]"
201+ if args_preview
202+ else f"[Called: { tool_name } ]"
203+ )
204+ lines .append (call_line )
205+ budget -= len (call_line )
206+
207+ urls : list [str ] = []
208+ for url in dict .fromkeys (URL_RE .findall (content )):
209+ if len (urls ) >= _MINI_CARD_MAX_URLS :
210+ break
211+ # A web_fetch card would otherwise print its own url twice.
212+ if url in args_preview :
213+ continue
214+ cost = len (url ) + 3 # " | " separator
215+ if cost > budget :
216+ break
217+ urls .append (url )
218+ budget -= cost
219+ if urls :
220+ lines .append ("[Source URLs] " + " | " .join (urls ))
221+ return "\n " .join (lines )
222+
223+
224+ def _message_recovery_ref (message : Message ) -> str :
225+ """Return a handle that already backs this body, so we never store it twice.
226+
227+ ``spill_refs`` wins over ``result_store_ref``: a ref pinned by an EARLIER
228+ compaction pass describes the content that is actually still on the message,
229+ whereas the loop-cap handle describes the pre-truncation body upstream shed.
230+ Reading the latter first would re-spill a body that is already stored, and —
231+ worse — would pin the wrong handle into the recovery index.
232+ """
233+ refs = [r for r in (message .get ("spill_refs" ) or []) if r ]
234+ canonical = str (message .get ("result_store_ref" ) or "" )
235+ if canonical and canonical not in refs :
236+ refs .append (canonical )
237+ return refs [0 ] if refs else ""
238+
239+
121240def _condense (content : str , max_chars : int ) -> str :
122241 """Head + tail + URLs of *content*, never longer than the original."""
123242 prefix = f"[Compressed tool result: { len (content ):,} characters]\n "
@@ -434,10 +553,17 @@ def should_compact(
434553
435554
436555class KeepLastNToolResultsCompactor :
437- """Replace older ``ToolMessage`` bodies with a short placeholder.
438-
439- Keeps the last ``keep_tool_result`` tool results verbatim and replaces
440- the content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`.
556+ """Replace older ``ToolMessage`` bodies with a short mini card.
557+
558+ Keeps the last ``keep_tool_result`` tool results verbatim and replaces the
559+ content of every earlier one with :data:`OMITTED_TOOL_RESULT_PLACEHOLDER`
560+ followed by a bounded card naming the call (tool + arguments preview) and up
561+ to :data:`_MINI_CARD_MAX_URLS` source URLs found in the discarded body, then
562+ the recovery pointer when the body was spilled. The card is free — both
563+ fields already exist in the history and in the body — and it is what keeps a
564+ later turn from re-issuing a query whose result it can no longer see. When no
565+ spill is configured and the card would not be shorter than the body it
566+ replaces, the body is kept verbatim instead.
441567 ``SystemMessage``, ``HumanMessage``, and every ``AIMessage`` (including
442568 its thinking trace) are left intact, so the model retains its full
443569 chain of reasoning and tool-call metadata while dropping the bulk of
@@ -489,7 +615,10 @@ def compact(
489615 if len (keep_set ) == len (tool_indices ):
490616 return messages
491617
618+ # Names and arguments are needed unconditionally now: the mini card names
619+ # the call it replaced even when nothing is protected and nothing spills.
492620 id_to_name = tool_names_by_call_id (messages )
621+ id_to_args = _tool_args_by_call_id (messages )
493622
494623 out : list [Message ] = []
495624 for idx , msg in enumerate (messages ):
@@ -505,12 +634,12 @@ def compact(
505634 ):
506635 out .append (msg )
507636 continue
637+ call_id = msg .get ("tool_call_id" , "" )
508638 placeholder = OMITTED_TOOL_RESULT_PLACEHOLDER
509- spill_path = str (msg . get ( "result_store_ref" ) or "" )
639+ spill_path = _message_recovery_ref (msg )
510640 if not spill_path and self ._spill is not None :
511- tool_name = id_to_name .get (msg .get ("tool_call_id" , "" ), "tool" )
512641 try :
513- spill_path = self ._spill (tool_name , content )
642+ spill_path = self ._spill (id_to_name . get ( call_id , "tool" ) , content )
514643 except Exception :
515644 spill_path = None
516645 # A configured spill callback is a promise that discarded content
@@ -520,9 +649,28 @@ def compact(
520649 if self ._spill is not None and not spill_path :
521650 out .append (msg )
522651 continue
652+ card = _elided_tool_card (
653+ id_to_name .get (call_id , "" ), id_to_args .get (call_id , "" ), content ,
654+ )
655+ if card :
656+ placeholder += "\n " + card
523657 if spill_path :
524658 placeholder += f"\n [Full text] { spill_path } "
525- replacement = tool_msg (placeholder , msg .get ("tool_call_id" , "" ))
659+ # Without a pointer, replacing the body DESTROYS it, so a card that
660+ # is not even shorter is a pure loss and we keep the body. With one
661+ # we always replace, even when the card is longer: the pointer only
662+ # reaches the model through ``spill_refs`` → the Tier 2 recovery
663+ # index, so keeping the body here would strand the spilled full text
664+ # as unrecoverable — and such a body is itself already a truncated
665+ # preview, not the full content.
666+ #
667+ # Reachable only when NO spill callback is configured: a configured
668+ # one that declined already returned the body verbatim above, at any
669+ # size. That is why this needs no minimum-size threshold of its own.
670+ if not spill_path and len (placeholder ) >= len (content ):
671+ out .append (msg )
672+ continue
673+ replacement = tool_msg (placeholder , call_id )
526674 if spill_path :
527675 # The text is for the model; this is for us. ``TieredCompactor``
528676 # collects refs from the field, so nothing has to recognise a
0 commit comments