4141# leaving the action displayed as "running" forever.
4242MIN_KEEP_RECENT_EVENTS = 2
4343
44+ # Smallest fold worth an LLM call. Summarization is a blocking ~15s round trip;
45+ # collapsing a couple of hundred tokens with one is a straight loss and the
46+ # threshold is breached again on the very next event, so we prune instead.
47+ MIN_FOLD_TOKENS = 2000
48+
4449# Event kinds that summarization must NEVER collapse — they are kept verbatim in
4550# tail_events forever, so the contract they carry survives any number of
4651# summarization passes. `requirements` (from set_requirement) defines the task's
@@ -217,6 +222,7 @@ def log(
217222 action_output : Optional [dict ] = None ,
218223 platform : Optional [str ] = None ,
219224 continue_work : Optional [bool ] = None ,
225+ question : Optional [dict ] = None ,
220226 ) -> int :
221227 """
222228 Append a new event to the stream and trigger summarization if needed.
@@ -249,6 +255,9 @@ def log(
249255 continue_work: For AGENT_MESSAGE events: True when this is a
250256 mid-run progress update and the agent keeps working after
251257 sending it (drives the UI's persistent "Working…" row).
258+ question: For AGENT_MESSAGE events: suggested-response payload
259+ (``{"options": [...], "allow_free_text": bool}``) when the
260+ message is a question the UI should pin above the composer.
252261
253262 Returns:
254263 The zero-based index of the event within ``tail_events``.
@@ -270,6 +279,7 @@ def log(
270279 action_output = action_output ,
271280 platform = platform ,
272281 continue_work = continue_work ,
282+ question = question ,
273283 )
274284 rec = EventRecord (event = ev )
275285
@@ -298,9 +308,19 @@ def log_action_end(self, name: str, status: str, extra: str = "") -> int:
298308 # ───────────────────── summarization & pruning ───────────────────────
299309
300310 def _externalize_message (
301- self , message : str , * , action_name : str | None = None
311+ self ,
312+ message : str ,
313+ * ,
314+ action_name : str | None = None ,
315+ force : bool = False ,
302316 ) -> str :
303- """Persist overly long messages to a temp file and return a pointer event."""
317+ """Persist overly long messages to a temp file and return a pointer event.
318+
319+ `force` overrides the retrieval-action exemption below. It is used by
320+ `_shrink_pinned_oversize`, where the agent has already consumed the
321+ content in its own turn and the only thing left to do with an oversized
322+ event is stop paying for it every prompt.
323+ """
304324 if len (message ) <= MAX_EVENT_INLINE_CHARS or self .temp_dir is None :
305325 return message
306326
@@ -309,7 +329,12 @@ def _externalize_message(
309329 # send the agent chasing a pointer to a pointer. ("grep" / "stream
310330 # read" are legacy names kept for safety; the live actions are
311331 # grep_files / read_file.)
312- if action_name in ("grep_files" , "read_file" , "grep" , "stream read" ):
332+ if not force and action_name in (
333+ "grep_files" ,
334+ "read_file" ,
335+ "grep" ,
336+ "stream read" ,
337+ ):
313338 return message
314339
315340 try :
@@ -388,6 +413,53 @@ def _find_token_cutoff(self, events: List[EventRecord], keep_tokens: int) -> int
388413 )
389414 return cutoff
390415
416+ def _shrink_pinned_oversize (self , cutoff : int ) -> int :
417+ """Externalize oversized events in the surviving tail, in place.
418+
419+ MIN_KEEP_RECENT_EVENTS pins the newest events so the UI (which mirrors
420+ `tail_events`) never loses an `action_end` in the tick it arrives — an
421+ action purged that early renders as "running" forever. But the pin is
422+ blind to size: when a retrieval action returns a huge payload (grep_files
423+ and read_file are exempt from log-time externalization, because they ARE
424+ how the agent reads externalized content back), the pin holds tens of
425+ thousands of tokens verbatim and a summarization pass cannot get under
426+ the threshold. The next event re-triggers it and the SAME chunk gets
427+ folded on the second try — one entirely wasted blocking LLM call per
428+ oversized event.
429+
430+ Shrinking in place satisfies both constraints: the record survives with
431+ its `action_id` intact so the UI still pairs start↔end, and its message
432+ becomes a pointer the agent can re-read on demand. Caller holds the lock.
433+
434+ Returns the number of tokens reclaimed.
435+ """
436+ if self .temp_dir is None :
437+ return 0
438+
439+ reclaimed = 0
440+ for rec in self .tail_events [cutoff :]:
441+ message = rec .event .message
442+ if len (message ) <= MAX_EVENT_INLINE_CHARS :
443+ continue
444+ pointer = self ._externalize_message (
445+ message , action_name = rec .event .action_name , force = True
446+ )
447+ if pointer is message :
448+ # Externalization failed (already logged); leave the event alone.
449+ continue
450+ before = get_cached_token_count (rec )
451+ rec .event .message = pointer
452+ rec ._cached_tokens = None
453+ reclaimed += before - get_cached_token_count (rec )
454+
455+ if reclaimed :
456+ self ._total_tokens -= reclaimed
457+ logger .info (
458+ f"[EventStream] Collapsed oversized pinned event(s) in place, "
459+ f"reclaiming { reclaimed } tokens (now { self ._total_tokens } )"
460+ )
461+ return reclaimed
462+
391463 def summarize_by_LLM (self ) -> None :
392464 """
393465 Summarize the oldest tail events using the language model.
@@ -406,6 +478,17 @@ def summarize_by_LLM(self) -> None:
406478 self .tail_events , self .tail_keep_after_summarize_tokens
407479 )
408480
481+ # Collapse anything oversized that the recent-event pin is holding
482+ # verbatim BEFORE deciding whether an LLM call is warranted — that alone
483+ # often drops the stream back under the threshold for free.
484+ if self ._shrink_pinned_oversize (cutoff ):
485+ if self ._total_tokens < self .summarize_at_tokens :
486+ return
487+ # Budget changed; the fold boundary moves with it.
488+ cutoff = self ._find_token_cutoff (
489+ self .tail_events , self .tail_keep_after_summarize_tokens
490+ )
491+
409492 if cutoff <= 0 :
410493 # Nothing old enough to summarize
411494 return
@@ -419,6 +502,29 @@ def summarize_by_LLM(self) -> None:
419502 # Everything old enough to summarize is protected — nothing to collapse.
420503 return
421504
505+ chunk_tokens = sum (get_cached_token_count (r ) for r in chunk )
506+ if chunk_tokens < MIN_FOLD_TOKENS :
507+ # The foldable region is smaller than the LLM call is worth — the tail
508+ # is dominated by events we're required to keep (protected kinds, or
509+ # the recent-event pin). Prune the chunk without a summary rather than
510+ # burn ~15s and a full prompt to reclaim a rounding error. Losing this
511+ # little detail is cheaper than the alternative, which is re-triggering
512+ # on every subsequent log() call.
513+ logger .warning (
514+ f"[EventStream] Foldable region is only { chunk_tokens } tokens "
515+ f"(< { MIN_FOLD_TOKENS } ); pruning { len (chunk )} event(s) without an "
516+ f"LLM call. Tail is dominated by pinned/protected events."
517+ )
518+ self ._total_tokens -= chunk_tokens
519+ self .tail_events = protected + self .tail_events [cutoff :]
520+ self ._append_summarization_notice (
521+ folded_events = len (chunk ),
522+ folded_tokens = chunk_tokens ,
523+ summary = None ,
524+ )
525+ self ._session_sync_points .clear ()
526+ return
527+
422528 first_ts = chunk [0 ].ts
423529 last_ts = chunk [- 1 ].ts
424530 window = f"{ first_ts .isoformat ()} to { last_ts .isoformat ()} "
@@ -448,8 +554,13 @@ def summarize_by_LLM(self) -> None:
448554 logger .info (
449555 f"[EventStream] Running synchronous summarization ({ self ._total_tokens } tokens)"
450556 )
557+ # json_mode=False: this prompt asks for a prose summary, and
558+ # forcing a provider's JSON mode onto it degenerates (DeepSeek
559+ # returns whitespace-only output that reads as empty).
451560 llm_output = self .llm .generate_response (
452- user_prompt = prompt , prompt_name = "EVENT_STREAM_SUMMARIZATION"
561+ user_prompt = prompt ,
562+ prompt_name = "EVENT_STREAM_SUMMARIZATION" ,
563+ json_mode = False ,
453564 )
454565 new_summary = (llm_output or "" ).strip ()
455566
@@ -465,8 +576,8 @@ def summarize_by_LLM(self) -> None:
465576
466577 # Apply summary and prune events
467578 self .head_summary = new_summary
468- # Calculate tokens being removed from the snapshotted chunk
469- removed_tokens = sum ( get_cached_token_count ( r ) for r in chunk )
579+ # Tokens being removed from the snapshotted chunk (measured above).
580+ removed_tokens = chunk_tokens
470581 self ._total_tokens -= removed_tokens
471582 # Keep protected events verbatim at the front of the surviving tail.
472583 self .tail_events = protected + self .tail_events [cutoff :]
@@ -492,7 +603,7 @@ def summarize_by_LLM(self) -> None:
492603 # Fallback: drop the oldest chunk without generating a summary so that
493604 # _total_tokens falls below the threshold. Without this, every subsequent
494605 # log() call would immediately re-trigger summarization and flood the logs.
495- removed_tokens = sum ( get_cached_token_count ( r ) for r in chunk )
606+ removed_tokens = chunk_tokens
496607 self ._total_tokens -= removed_tokens
497608 # Keep protected events verbatim even on the no-LLM prune fallback.
498609 self .tail_events = protected + self .tail_events [cutoff :]
0 commit comments