From e1393670ed00b6a6fe4603100758214482dc85d1 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sat, 25 Apr 2026 08:52:43 +1000 Subject: [PATCH 1/2] fix(agent): add time-based recovery to compression anti-thrashing protection (#14694) The anti-thrashing guard permanently disabled auto-compression after 2 ineffective compressions (<10% savings each), with no way to recover during a long session. Add a 300-second cooldown: once enough time has elapsed since the last compression, reset the ineffective counter so the session gets another chance. This preserves the rapid-fire guard while allowing recovery when significant new context has accumulated. --- agent/context_compressor.py | 15 +++++++++ tests/agent/test_context_compressor.py | 43 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index ef40cbfafb..d33cc17107 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -297,6 +297,7 @@ class ContextCompressor(ContextEngine): self._last_summary_error = None self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 + self._last_compression_time = 0.0 def update_model( self, @@ -389,6 +390,7 @@ class ContextCompressor(ContextEngine): # Anti-thrashing: track whether last compression was effective self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 + self._last_compression_time: float = 0.0 self._summary_failure_cooldown_until: float = 0.0 self._last_summary_error: Optional[str] = None @@ -407,6 +409,18 @@ class ContextCompressor(ContextEngine): tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens if tokens < self.threshold_tokens: return False + # Anti-thrashing: time-based recovery — if enough time has passed since + # the last compression, reset the ineffective counter so the session + # gets another chance. 300s is enough for significant new context to + # accumulate, making another attempt worthwhile. + if (self._ineffective_compression_count >= 2 + and self._last_compression_time > 0 + and time.monotonic() - self._last_compression_time >= 300): + self._ineffective_compression_count = 0 + if not self.quiet_mode: + logger.info( + "Anti-thrashing cooldown expired — re-enabling auto-compression" + ) # Anti-thrashing: back off if recent compressions were ineffective if self._ineffective_compression_count >= 2: if not self.quiet_mode: @@ -1279,6 +1293,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio saved_estimate = display_tokens - new_estimate # Anti-thrashing: track compression effectiveness + self._last_compression_time = time.monotonic() savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0 self._last_compression_savings_pct = savings_pct if savings_pct < 10: diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 8072a58d98..389188a5cd 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -1,5 +1,7 @@ """Tests for agent/context_compressor.py — compression logic, thresholds, truncation fallback.""" +import time + import pytest from unittest.mock import patch, MagicMock @@ -38,6 +40,47 @@ class TestShouldCompress: assert compressor.should_compress(prompt_tokens=50000) is False +class TestAntiThrashingRecovery: + """Anti-thrashing protection should recover after a cooldown period (#14694).""" + + def test_anti_thrash_blocks_after_two_ineffective(self, compressor): + """Two ineffective compressions should block further auto-compression.""" + compressor._ineffective_compression_count = 2 + compressor.last_prompt_tokens = 90000 + assert compressor.should_compress() is False + + def test_anti_thrash_recovers_after_cooldown(self, compressor): + """After 300s cooldown, the anti-thrashing counter resets and + compression is re-enabled. This test fails without the fix.""" + compressor._ineffective_compression_count = 2 + compressor._last_compression_time = time.monotonic() - 301 + compressor.last_prompt_tokens = 90000 + assert compressor.should_compress() is True + assert compressor._ineffective_compression_count == 0 + + def test_anti_thrash_still_blocks_within_cooldown(self, compressor): + """Within 300s the block should remain active.""" + compressor._ineffective_compression_count = 2 + compressor._last_compression_time = time.monotonic() - 60 + compressor.last_prompt_tokens = 90000 + assert compressor.should_compress() is False + + def test_anti_thrash_no_recovery_without_timestamp(self, compressor): + """If no compression has ever run (_last_compression_time == 0), + the time-based recovery should not trigger.""" + compressor._ineffective_compression_count = 2 + compressor._last_compression_time = 0.0 + compressor.last_prompt_tokens = 90000 + assert compressor.should_compress() is False + + def test_session_reset_clears_compression_time(self, compressor): + """on_session_reset() should reset _last_compression_time.""" + compressor._last_compression_time = 12345.0 + compressor._ineffective_compression_count = 2 + compressor.on_session_reset() + assert compressor._last_compression_time == 0.0 + assert compressor._ineffective_compression_count == 0 + class TestUpdateFromResponse: def test_updates_fields(self, compressor): From 55a4c5385f562d8437fbf4ad739b949ae9fc4c5d Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sat, 25 Apr 2026 10:08:53 +1000 Subject: [PATCH 2/2] fix(agent): recover anti-thrashing after prompt growth --- agent/context_compressor.py | 41 ++++++++++++++++++-------- tests/agent/test_context_compressor.py | 25 ++++++++++++++-- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d33cc17107..3a15ed0c47 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -288,6 +288,10 @@ class ContextCompressor(ContextEngine): def name(self) -> str: return "compressor" + def _recovery_growth_tokens(self) -> int: + """How much new prompt growth warrants another compression attempt.""" + return max(int(self.threshold_tokens * 0.15), 8_000) + def on_session_reset(self) -> None: """Reset all per-session state for /new or /reset.""" super().on_session_reset() @@ -298,6 +302,7 @@ class ContextCompressor(ContextEngine): self._last_compression_savings_pct = 100.0 self._ineffective_compression_count = 0 self._last_compression_time = 0.0 + self._last_compression_prompt_tokens = 0 def update_model( self, @@ -391,6 +396,7 @@ class ContextCompressor(ContextEngine): self._last_compression_savings_pct: float = 100.0 self._ineffective_compression_count: int = 0 self._last_compression_time: float = 0.0 + self._last_compression_prompt_tokens: int = 0 self._summary_failure_cooldown_until: float = 0.0 self._last_summary_error: Optional[str] = None @@ -409,18 +415,28 @@ class ContextCompressor(ContextEngine): tokens = prompt_tokens if prompt_tokens is not None else self.last_prompt_tokens if tokens < self.threshold_tokens: return False - # Anti-thrashing: time-based recovery — if enough time has passed since - # the last compression, reset the ineffective counter so the session - # gets another chance. 300s is enough for significant new context to - # accumulate, making another attempt worthwhile. - if (self._ineffective_compression_count >= 2 - and self._last_compression_time > 0 - and time.monotonic() - self._last_compression_time >= 300): - self._ineffective_compression_count = 0 - if not self.quiet_mode: - logger.info( - "Anti-thrashing cooldown expired — re-enabling auto-compression" - ) + # Anti-thrashing recovery: allow another attempt after either enough + # wall-clock time has passed or enough new prompt growth has + # accumulated to make compression worthwhile again. + if self._ineffective_compression_count >= 2: + recovered = False + if (self._last_compression_time > 0 + and time.monotonic() - self._last_compression_time >= 300): + recovered = True + if not self.quiet_mode: + logger.info( + "Anti-thrashing cooldown expired — re-enabling auto-compression" + ) + elif (self._last_compression_prompt_tokens > 0 + and tokens - self._last_compression_prompt_tokens >= self._recovery_growth_tokens()): + recovered = True + if not self.quiet_mode: + logger.info( + "Anti-thrashing reset after prompt growth (%d new tokens)", + tokens - self._last_compression_prompt_tokens, + ) + if recovered: + self._ineffective_compression_count = 0 # Anti-thrashing: back off if recent compressions were ineffective if self._ineffective_compression_count >= 2: if not self.quiet_mode: @@ -1294,6 +1310,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Anti-thrashing: track compression effectiveness self._last_compression_time = time.monotonic() + self._last_compression_prompt_tokens = display_tokens savings_pct = (saved_estimate / display_tokens * 100) if display_tokens > 0 else 0 self._last_compression_savings_pct = savings_pct if savings_pct < 10: diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 389188a5cd..5429516a1d 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -65,20 +65,39 @@ class TestAntiThrashingRecovery: compressor.last_prompt_tokens = 90000 assert compressor.should_compress() is False + def test_anti_thrash_recovers_after_meaningful_prompt_growth(self, compressor): + """Large prompt growth should re-enable compression even before cooldown.""" + compressor._ineffective_compression_count = 2 + compressor._last_compression_time = time.monotonic() - 60 + compressor._last_compression_prompt_tokens = 90_000 + compressor.last_prompt_tokens = 105_000 + assert compressor.should_compress() is True + assert compressor._ineffective_compression_count == 0 + + def test_anti_thrash_small_growth_does_not_reset(self, compressor): + """Minor growth should not immediately re-enable compression.""" + compressor._ineffective_compression_count = 2 + compressor._last_compression_time = time.monotonic() - 60 + compressor._last_compression_prompt_tokens = 90_000 + compressor.last_prompt_tokens = 94_000 + assert compressor.should_compress() is False + def test_anti_thrash_no_recovery_without_timestamp(self, compressor): - """If no compression has ever run (_last_compression_time == 0), - the time-based recovery should not trigger.""" + """Without time or growth metadata, recovery should not trigger.""" compressor._ineffective_compression_count = 2 compressor._last_compression_time = 0.0 + compressor._last_compression_prompt_tokens = 0 compressor.last_prompt_tokens = 90000 assert compressor.should_compress() is False def test_session_reset_clears_compression_time(self, compressor): - """on_session_reset() should reset _last_compression_time.""" + """on_session_reset() should reset anti-thrash recovery metadata.""" compressor._last_compression_time = 12345.0 + compressor._last_compression_prompt_tokens = 90000 compressor._ineffective_compression_count = 2 compressor.on_session_reset() assert compressor._last_compression_time == 0.0 + assert compressor._last_compression_prompt_tokens == 0 assert compressor._ineffective_compression_count == 0