From 34678d2f2edd46cf930b8d3f6164133e79995eb4 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 6 Jul 2026 17:23:46 +0800 Subject: [PATCH] fix(compression): skip empty post-handoff summary windows --- agent/context_compressor.py | 33 ++++++++++++++ ...t_context_compressor_summary_continuity.py | 45 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e282a6c7a451..76c4260b69f8 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4521,6 +4521,39 @@ This compaction should PRIORITISE preserving all information related to the focu ) telemetry["chunk_count"] = 1 if turns_to_summarize else 0 + if not turns_to_summarize: + # The newest handoff summary consumed the entire compressible + # window (every window row was a standalone handoff that strips + # to None, and nothing follows it before compress_end) — there + # is nothing new to summarize. Skip the summary call entirely: + # without this guard the empty window still reached + # _generate_summary, wasting an aux LLM call that aborts + # noisily on empty input (#59496). Mirrors the sibling + # "no compressable window" guard above (#40803): record an + # ineffective strike through the durable write-through helper + # so the anti-thrash breaker in should_compress() can stop the + # loop — this shape cannot shrink, so every subsequent turn + # would otherwise re-fire the same no-op. The rehydrated + # _previous_summary is deliberately KEPT (not rolled back as + # the summary-abort path does for #57835): it came from a + # handoff genuinely present in this transcript, which is + # returned unchanged. + telemetry["failure_class"] = "empty_post_handoff_window" + self._record_ineffective_compression_verdict( + self._ineffective_compression_count + 1, + ) + self._last_compression_savings_pct = 0.0 + if not self.quiet_mode: + logger.warning( + "Compression skipped: latest context summary leaves no " + "new turns to summarize in window %d-%d. " + "ineffective_compression_count=%d", + compress_start, + compress_end, + self._ineffective_compression_count, + ) + return messages + if not self.quiet_mode: logger.info( "Context compression triggered (%d tokens >= %d threshold)", diff --git a/tests/agent/test_context_compressor_summary_continuity.py b/tests/agent/test_context_compressor_summary_continuity.py index 18b4c76b6256..642ee2dbdc08 100644 --- a/tests/agent/test_context_compressor_summary_continuity.py +++ b/tests/agent/test_context_compressor_summary_continuity.py @@ -725,3 +725,48 @@ def test_metadata_summary_decay_also_rehydrates_previous_summary(): assert "metadata-only prior summary" in prompt # Grounding may prepend a task-snapshot section; pin the fresh body. assert (compressor._previous_summary or "").endswith("fresh summary") + + +def test_empty_post_handoff_window_noops_without_summary_call(): + """A latest handoff that consumes the window must not trigger an empty summary. + + Regression test from PR #59526 (#59496), fixture adapted to current main: + the standalone handoff sits alone in the compressible window, strips to + None via _strip_context_summary_handoff_message, and leaves + turns_to_summarize empty — the guard must skip _generate_summary + entirely instead of wasting an aux LLM call on empty input. + """ + compressor = _compressor() + old_summary = "WINDOW-END-SUMMARY durable facts already captured" + messages = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": f"{SUMMARY_PREFIX}\n{old_summary}"}, + {"role": "assistant", "content": "recent tail response"}, + {"role": "user", "content": "tail request"}, + {"role": "assistant", "content": "tail answer"}, + {"role": "user", "content": "latest tail request"}, + {"role": "assistant", "content": "latest tail answer"}, + ] + + with ( + patch.object(compressor, "_find_tail_cut_by_tokens", return_value=2), + patch.object(compressor, "_generate_summary") as mock_generate_summary, + ): + result = compressor.compress(messages, current_tokens=90_000) + + mock_generate_summary.assert_not_called() + assert result == messages + # The rehydrated summary state is deliberately kept: the handoff is + # genuinely present in the returned (unchanged) transcript. + assert compressor._previous_summary == old_summary + assert compressor.compression_count == 0 + # Mirrors the sibling no-compressible-window guard (#40803): the shape + # cannot shrink, so it counts as an ineffective strike (routed through + # the durable write-through helper) to arm the anti-thrash breaker. + assert compressor._ineffective_compression_count == 1 + assert compressor._last_compression_savings_pct == 0.0 + assert compressor._last_summary_dropped_count == 0 + assert compressor._last_summary_fallback_used is False + assert compressor._last_compress_aborted is False + telemetry = compressor._last_compression_telemetry or {} + assert telemetry.get("failure_class") == "empty_post_handoff_window"