From af716c32f69d67a62305e4305cac4629af2aa65f Mon Sep 17 00:00:00 2001 From: golldyck <127680312+golldyck@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:28:08 +0300 Subject: [PATCH] fix(compaction): skip compression when it can't reduce tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compress_trajectory (and _async) replaced the compressible middle region with a [CONTEXT SUMMARY] turn without checking that the region is actually larger than the summary. When a large protected system prompt dominates the budget, the compressible middle can be tiny; replacing e.g. a 2-token middle with a ~60-token summary GROWS the trajectory (tokens_saved negative), marks it was_compressed, and still spends a summarization call — the opposite of the intent, on exactly the hard over-budget cases. Add a net-savings guard mirroring the code's own comment (net_savings = region_tokens - summary_target_tokens): if the safely-compressible region is no larger than summary_target_tokens, return the trajectory unchanged. Applied to both the sync and async paths. Add sync+async regression tests. --- tests/test_trajectory_compressor.py | 64 +++++++++++++++++++++++++++++ trajectory_compressor.py | 24 +++++++++++ 2 files changed, 88 insertions(+) diff --git a/tests/test_trajectory_compressor.py b/tests/test_trajectory_compressor.py index 8fcbfc38cfef4..b4e4c876a3f45 100644 --- a/tests/test_trajectory_compressor.py +++ b/tests/test_trajectory_compressor.py @@ -628,3 +628,67 @@ def test_snap_boundary_falls_back_to_backward(self): {"from": "tool", "value": "a"}, ] assert tc._snap_boundary(trajectory, 1, 0, 1) == 0 + + +# --------------------------------------------------------------------------- +# TrajectoryCompressor — compression must never increase the token count +# --------------------------------------------------------------------------- + + +class TestCompressionNetSavingsGuard: + """When the compressible middle is no larger than the summary that would + replace it, compression cannot help — it must be skipped rather than grow + the trajectory (and burn a summarization call).""" + + def _tiny_middle_trajectory(self): + # Large protected head (system+human), tiny compressible middle. + big = "w " * 400 # ~200 tokens each (1 token / 4 chars) + small = "ok " * 2 + return [ + {"from": "system", "value": big}, # protected (first_system) + {"from": "human", "value": big}, # protected (first_human) + {"from": "gpt", "value": small}, # protected (first_gpt) + {"from": "tool", "value": small}, # protected (first_tool) + {"from": "gpt", "value": small}, # compressible middle + {"from": "tool", "value": small}, # compressible middle + {"from": "gpt", "value": small}, # protected (last 2) + {"from": "human", "value": small}, # protected (last 2) + ] + + def _config(self): + config = CompressionConfig() + config.protect_last_n_turns = 2 + config.summary_target_tokens = 20 + config.target_max_tokens = 100 # trajectory is far over this + return config + + def test_sync_skips_compression_when_middle_smaller_than_summary(self): + tc = _make_compressor(self._config()) + tc._generate_summary = MagicMock( + return_value="[CONTEXT SUMMARY]: " + "blah " * 30 + ) + trajectory = self._tiny_middle_trajectory() + before = sum(tc.count_turn_tokens(trajectory)) + + compressed, metrics = tc.compress_trajectory(trajectory) + + assert metrics.was_compressed is False + assert compressed == trajectory + assert sum(tc.count_turn_tokens(compressed)) == before + tc._generate_summary.assert_not_called() + + @pytest.mark.asyncio + async def test_async_skips_compression_when_middle_smaller_than_summary(self): + tc = _make_compressor(self._config()) + tc._generate_summary_async = AsyncMock( + return_value="[CONTEXT SUMMARY]: " + "blah " * 30 + ) + trajectory = self._tiny_middle_trajectory() + before = sum(tc.count_turn_tokens(trajectory)) + + compressed, metrics = await tc.compress_trajectory_async(trajectory) + + assert metrics.was_compressed is False + assert compressed == trajectory + assert sum(tc.count_turn_tokens(compressed)) == before + tc._generate_summary_async.assert_not_called() diff --git a/trajectory_compressor.py b/trajectory_compressor.py index 45d2386e933c5..1b447811f3e38 100644 --- a/trajectory_compressor.py +++ b/trajectory_compressor.py @@ -831,6 +831,18 @@ def compress_trajectory( metrics.still_over_limit = total_tokens > self.config.target_max_tokens return trajectory, metrics + # If the region we can safely compress is no larger than the summary + # that would replace it, compression cannot reduce the token count -- + # it would grow the trajectory and still spend a summarization call. + if ( + sum(turn_tokens[compress_start:compress_until]) + <= self.config.summary_target_tokens + ): + metrics.compressed_tokens = total_tokens + metrics.compressed_turns = len(trajectory) + metrics.still_over_limit = total_tokens > self.config.target_max_tokens + return trajectory, metrics + # Record compression region metrics.turns_compressed_start_idx = compress_start metrics.turns_compressed_end_idx = compress_until @@ -946,6 +958,18 @@ async def compress_trajectory_async( metrics.still_over_limit = total_tokens > self.config.target_max_tokens return trajectory, metrics + # If the region we can safely compress is no larger than the summary + # that would replace it, compression cannot reduce the token count -- + # it would grow the trajectory and still spend a summarization call. + if ( + sum(turn_tokens[compress_start:compress_until]) + <= self.config.summary_target_tokens + ): + metrics.compressed_tokens = total_tokens + metrics.compressed_turns = len(trajectory) + metrics.still_over_limit = total_tokens > self.config.target_max_tokens + return trajectory, metrics + # Record compression region metrics.turns_compressed_start_idx = compress_start metrics.turns_compressed_end_idx = compress_until