diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 45eb25e1ca0..d952470fac2 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -193,8 +193,10 @@ _HISTORICAL_SUMMARY_PREFIXES = ( _MIN_SUMMARY_TOKENS = 2000 # Proportion of compressed content to allocate for summary _SUMMARY_RATIO = 0.20 -# Absolute ceiling for summary tokens (even on very large context windows) -_SUMMARY_TOKENS_CEILING = 12_000 +# Absolute ceiling for summary tokens (even on very large context windows). +# Summaries must stay within a 1K-10K token envelope — anything larger is +# itself a context-pressure source and slows every compaction. +_SUMMARY_TOKENS_CEILING = 10_000 # Placeholder used when pruning old tool results _PRUNED_TOOL_PLACEHOLDER = "[Old tool output cleared to save context space]" @@ -227,6 +229,16 @@ _AUTO_FOCUS_MAX_CHARS = 700 # back the old large-tool-output case where nothing can be compacted. _MAX_TAIL_MESSAGE_FLOOR = 8 +# Models with context windows below this get their compression threshold +# floored at ``_SMALL_CTX_THRESHOLD_PERCENT`` (raise-only — an explicitly +# higher user/model threshold always wins). At the default 50% trigger a +# 128K-262K model compacts with only ~64-131K consumed; the incompressible +# floor (system prompt + tool schemas + protected tail + rolling summary) +# eats most of the reclaimed headroom, so compaction re-fires every 1-2 +# turns and the session spends most of its wall-clock summarizing. +_SMALL_CTX_WINDOW_LIMIT = 512_000 +_SMALL_CTX_THRESHOLD_PERCENT = 0.75 + _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+") @@ -883,6 +895,18 @@ class ContextCompressor(ContextEngine): self.provider = provider self.api_mode = api_mode self.context_length = context_length + # Re-apply the small-context threshold floor for the NEW window, + # starting from the originally-configured percent (not the possibly + # floored live value) so a small -> large switch drops back to the + # configured threshold and a large -> small switch gains the floor. + # Guard with getattr: compressors unpickled/constructed before this + # attribute existed fall back to the live value. + _configured_pct = getattr( + self, "_configured_threshold_percent", self.threshold_percent, + ) + self.threshold_percent = self._effective_threshold_percent( + context_length, _configured_pct, + ) # max_tokens=None here means "caller didn't specify" → keep the existing # output reservation. A switch that genuinely changes the output budget # passes the new value explicitly. (#43547) @@ -945,6 +969,23 @@ class ContextCompressor(ContextEngine): return None return ivalue if ivalue > 0 else None + @staticmethod + def _effective_threshold_percent( + context_length: int, threshold_percent: float, + ) -> float: + """Apply the small-context threshold floor (raise-only). + + Models under ``_SMALL_CTX_WINDOW_LIMIT`` (512K) trigger at no less + than ``_SMALL_CTX_THRESHOLD_PERCENT`` (75%) of the window. An + explicitly higher threshold (user config or per-model autoraise, + e.g. Codex gpt-5.5's 85%) always wins; only lower values are raised. + Large-context models keep the configured value — at 512K+ the default + 50% trigger already leaves ample post-compaction headroom. + """ + if context_length and context_length < _SMALL_CTX_WINDOW_LIMIT: + return max(threshold_percent, _SMALL_CTX_THRESHOLD_PERCENT) + return threshold_percent + @staticmethod def _compute_threshold_tokens( context_length: int, threshold_percent: float, max_tokens: int | None = None, @@ -1032,6 +1073,18 @@ class ContextCompressor(ContextEngine): config_context_length=config_context_length, provider=provider, ) + # Small-context threshold floor: models under 512K trigger at >=75% + # so compaction doesn't fire with half the window still free (the + # incompressible floor makes 50%-triggered compaction thrash on + # 128K-262K models). Raise-only; must run AFTER context_length is + # resolved and BEFORE threshold_tokens is derived. The pre-floor + # value is kept so update_model() can re-derive for a new window + # (switching small -> large must drop back to the configured value). + self._configured_threshold_percent = self.threshold_percent + self.threshold_percent = self._effective_threshold_percent( + self.context_length, self.threshold_percent, + ) + threshold_percent = self.threshold_percent # Floor: never compress below MINIMUM_CONTEXT_LENGTH tokens even if # the percentage would suggest a lower value. This prevents premature # compression on large-context models at 50% while keeping the % sane @@ -1410,11 +1463,26 @@ class ContextCompressor(ContextEngine): (API keys, tokens, passwords) from leaking into the summary that gets sent to the auxiliary model and persisted across compactions. """ + # Lazy import (matches title_generator.py) — agent_runtime_helpers + # pulls in heavy transitive imports we don't want at module load. + from agent.agent_runtime_helpers import strip_think_blocks + parts = [] for msg in turns: role = msg.get("role", "unknown") content = redact_sensitive_text(msg.get("content") or "") content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content) + # Strip inline reasoning blocks (, , etc.) from + # assistant content before it reaches the summarizer. Reasoning + # traces are transient scratch work — feeding them to the aux + # model wastes summarizer context and risks scratch-work + # conclusions being preserved as facts in the summary. The native + # ``reasoning`` message field is already excluded (only + # ``content`` is serialized); this closes the inline-tag path + # used when native thinking is disabled or the provider inlines + # traces into content. + if role == "assistant" and content: + content = strip_think_blocks(None, content) # Tool results: keep enough content for the summarizer if role == "tool": @@ -1880,7 +1948,15 @@ This compaction should PRIORITISE preserving all information related to the focu "api_mode": self.api_mode, }, "messages": [{"role": "user", "content": prompt}], - "max_tokens": int(summary_budget * 1.3), + # NO max_tokens: the output cap must never truncate a summary. + # ``summary_budget`` is prompt-level guidance only ("Target ~N + # tokens" above). Most OpenAI-compatible wires already omit the + # param (see _build_call_kwargs), but the Anthropic Messages + # wire and NVIDIA NIM forward it — a hard cap there cut + # summaries mid-section (thinking models burn the cap on + # reasoning first), producing truncated/thinking-only + # summaries and compaction loops. Omitting lets the adapter + # fall back to the model's native output ceiling. # timeout resolved from auxiliary.compression.timeout config by call_llm } if self.summary_model: @@ -1920,6 +1996,16 @@ This compaction should PRIORITISE preserving all information related to the focu f"(provider={self.provider or 'auto'} " f"model={self.summary_model or self.model})" ) + # Strip reasoning blocks the summarizer model may have emitted + # (... etc. from thinking models like MiniMax, + # DeepSeek, QwQ). Without this the trace is stored in + # _previous_summary, injected into the conversation, AND fed back + # into every subsequent iterative-update prompt — compounding + # token bloat across compactions. Mirrors title_generator.py. + from agent.agent_runtime_helpers import strip_think_blocks + stripped = strip_think_blocks(None, content).strip() + if stripped: + content = stripped # Redact the summary output as well — the summarizer LLM may # ignore prompt instructions and echo back secrets verbatim. summary = redact_sensitive_text(content.strip()) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ed7300da71d..69acc686805 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -409,6 +409,8 @@ compression: # Trigger compression at this % of model's context limit (default: 0.50 = 50%) # Lower values = more aggressive compression, higher values = compress later + # Models with context windows below 512K are floored at 0.75 (raise-only) so + # compaction doesn't fire with half the window still free; set above 0.75 to override. threshold: 0.50 # Existing Codex gpt-5.5 behavior: raise Hermes' compaction trigger to 85% diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e24cc220f4f..7c83c6f167a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1397,7 +1397,11 @@ DEFAULT_CONFIG = { "compression": { "enabled": True, - "threshold": 0.50, # compress when context usage exceeds this ratio + "threshold": 0.50, # compress when context usage exceeds this ratio. + # Models with context windows below 512K are + # floored at 0.75 (raise-only) so compaction + # doesn't fire with half the window still free; + # set this above 0.75 to override the floor. "target_ratio": 0.20, # fraction of threshold to preserve as recent tail "protect_last_n": 20, # minimum recent messages to keep uncompressed "hygiene_hard_message_limit": 5000, # gateway session-hygiene force-compress threshold by message count diff --git a/tests/agent/test_compression_small_ctx_threshold_floor.py b/tests/agent/test_compression_small_ctx_threshold_floor.py new file mode 100644 index 00000000000..03c64a00cbd --- /dev/null +++ b/tests/agent/test_compression_small_ctx_threshold_floor.py @@ -0,0 +1,186 @@ +"""Compression hygiene: small-context threshold floor, reasoning-trace +exclusion, and bounded summary size. + +Covers the July 2026 compression tuning pass: + +1. Reasoning traces (native ``reasoning`` field AND inline ````-style + blocks) must never reach the summarizer prompt, and traces emitted BY the + summarizer model must never be stored in the summary. +2. Head/tail protection budgets stay proportionate (tail = 20% of threshold). +3. Summary token budget is bounded to the 1K-10K envelope. +4. Models with context windows below 512K get their compression threshold + floored at 75% (raise-only — a higher configured value always wins). +""" + +from unittest.mock import patch + +import agent.context_compressor as cc +from agent.context_compressor import ContextCompressor + + +def _make(ctx: int, pct: float = 0.50) -> ContextCompressor: + with patch.object(cc, "get_model_context_length", return_value=ctx): + return ContextCompressor( + model="test/model", threshold_percent=pct, quiet_mode=True, + ) + + +class TestSmallContextThresholdFloor: + def test_sub_512k_floors_to_75_percent(self): + for ctx in (128_000, 200_000, 262_144, 511_999): + comp = _make(ctx, pct=0.50) + assert comp.threshold_percent == 0.75, ctx + assert comp.threshold_tokens == int(ctx * 0.75), ctx + + def test_512k_and_above_keep_configured_percent(self): + for ctx in (512_000, 1_000_000): + comp = _make(ctx, pct=0.50) + assert comp.threshold_percent == 0.50, ctx + assert comp.threshold_tokens == int(ctx * 0.50), ctx + + def test_raise_only_higher_config_wins(self): + # Explicit 85% (user config or Codex gpt-5.5 autoraise) is not lowered. + comp = _make(128_000, pct=0.85) + assert comp.threshold_percent == 0.85 + + def test_degenerate_minimum_window_still_uses_85(self): + # 64K window: the MINIMUM_CONTEXT_LENGTH floor pushes the threshold + # to/over the window, so the 85% degenerate-window guard still rules. + comp = _make(64_000, pct=0.50) + assert comp.threshold_tokens == 54_400 # 85% of 64000 + + def test_update_model_rederives_floor_both_directions(self): + comp = _make(128_000, pct=0.50) + assert comp.threshold_percent == 0.75 + # small -> large: back to the configured 50% + comp.update_model("big", 1_000_000) + assert comp.threshold_percent == 0.50 + assert comp.threshold_tokens == 500_000 + # large -> small: floor re-applies + comp.update_model("small", 200_000) + assert comp.threshold_percent == 0.75 + assert comp.threshold_tokens == 150_000 + + +class TestReasoningExcludedFromSummarizer: + def test_serializer_drops_inline_think_blocks(self): + comp = _make(128_000) + turns = [ + {"role": "user", "content": "do the thing"}, + {"role": "assistant", "content": "INLINE_TRACEvisible answer"}, + {"role": "assistant", "content": "VARIANT_TRACEother answer"}, + ] + ser = comp._serialize_for_summary(turns) + assert "INLINE_TRACE" not in ser + assert "VARIANT_TRACE" not in ser + assert "visible answer" in ser + assert "other answer" in ser + + def test_serializer_excludes_native_reasoning_field(self): + comp = _make(128_000) + turns = [{"role": "assistant", "content": "done", "reasoning": "NATIVE_TRACE"}] + ser = comp._serialize_for_summary(turns) + assert "NATIVE_TRACE" not in ser + assert "done" in ser + + def test_summarizer_output_think_block_stripped_before_store(self): + comp = _make(128_000) + + class FakeMsg: + content = "OUTPUT_TRACE\n## Active Task\nUser asked X" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + with patch.object(cc, "call_llm", return_value=FakeResp()): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + assert out is not None + assert "OUTPUT_TRACE" not in out + assert "## Active Task" in out + # The iterative-update seed must be clean too, or the trace compounds + # across every subsequent compaction. + assert "OUTPUT_TRACE" not in (comp._previous_summary or "") + + def test_thinking_only_summarizer_response_not_blanked(self): + # If stripping removes everything (degenerate model output), keep the + # raw content instead of storing an empty summary. + comp = _make(128_000) + + class FakeMsg: + content = "only reasoning, no body" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + with patch.object(cc, "call_llm", return_value=FakeResp()): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + # Falls back to unstripped content rather than an empty summary body. + assert out is not None and out.strip() + + +class TestSummaryBudgetEnvelope: + def test_no_max_tokens_wire_cap_on_summary_call(self): + """The summary budget is PROMPT GUIDANCE only ("Target ~N tokens"). + + A wire-level max_tokens cap truncates summaries mid-section on the + Anthropic Messages / NVIDIA NIM paths (which forward the param), and + thinking models burn the cap on reasoning before emitting the summary + body — producing truncated or thinking-only summaries and compaction + loops. The call must NOT carry max_tokens. + """ + comp = _make(128_000) + captured = {} + + class FakeMsg: + content = "## Active Task\nUser asked X" + + class FakeChoice: + message = FakeMsg() + + class FakeResp: + choices = [FakeChoice()] + + def fake_call_llm(**kw): + captured.update(kw) + return FakeResp() + + with patch.object(cc, "call_llm", side_effect=fake_call_llm): + out = comp._generate_summary([{"role": "user", "content": "hi"}]) + assert out is not None + assert "max_tokens" not in captured + # The budget still lands as prompt guidance, within the envelope. + prompt = captured["messages"][0]["content"] + import re + m = re.search(r"Target ~(\d+) tokens", prompt) + assert m, "prompt-level token target guidance missing" + assert 1_000 <= int(m.group(1)) <= 10_000 + + def test_budget_capped_at_10k_even_on_1m_window(self): + comp = _make(1_000_000) + huge = [{"role": "assistant", "content": "x" * 8000} for _ in range(200)] + assert comp._compute_summary_budget(huge) <= 10_000 + assert comp.max_summary_tokens <= 10_000 + + def test_budget_floor_stays_in_envelope(self): + comp = _make(1_000_000) + tiny = [{"role": "user", "content": "hi"}] + budget = comp._compute_summary_budget(tiny) + assert 1_000 <= budget <= 10_000 + + def test_ceiling_constant_within_envelope(self): + assert 1_000 <= cc._SUMMARY_TOKENS_CEILING <= 10_000 + assert 1_000 <= cc._MIN_SUMMARY_TOKENS <= 10_000 + + +class TestTailBudgetProportionality: + def test_tail_budget_is_target_ratio_of_threshold(self): + comp = _make(128_000) + assert comp.tail_token_budget == int(comp.threshold_tokens * comp.summary_target_ratio) + # Sanity: tail protection stays a modest slice of the window (<= 20%). + assert comp.tail_token_budget <= comp.context_length * 0.20 diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 75b22bb8a80..252737e3e85 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -2305,8 +2305,8 @@ class TestSummaryTargetRatio: """Tail token budget should be threshold_tokens * summary_target_ratio.""" with patch("agent.context_compressor.get_model_context_length", return_value=200_000): c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40) - # 200K * 0.50 threshold * 0.40 ratio = 40K - assert c.tail_token_budget == 40_000 + # 200K < 512K → threshold floored at 75%: 150K * 0.40 ratio = 60K + assert c.tail_token_budget == 60_000 with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000): c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.40) @@ -2314,14 +2314,14 @@ class TestSummaryTargetRatio: assert c.tail_token_budget == 200_000 def test_summary_cap_scales_with_context(self): - """Max summary tokens should be 5% of context, capped at 12K.""" + """Max summary tokens should be 5% of context, capped at 10K.""" with patch("agent.context_compressor.get_model_context_length", return_value=200_000): c = ContextCompressor(model="test", quiet_mode=True) assert c.max_summary_tokens == 10_000 # 200K * 0.05 with patch("agent.context_compressor.get_model_context_length", return_value=1_000_000): c = ContextCompressor(model="test", quiet_mode=True) - assert c.max_summary_tokens == 12_000 # capped at 12K ceiling + assert c.max_summary_tokens == 10_000 # capped at 10K ceiling def test_ratio_clamped(self): """Ratio should be clamped to [0.10, 0.80].""" @@ -2333,20 +2333,20 @@ class TestSummaryTargetRatio: c = ContextCompressor(model="test", quiet_mode=True, summary_target_ratio=0.95) assert c.summary_target_ratio == 0.80 - def test_default_threshold_is_50_percent(self): - """Default compression threshold should be 50%, with a 64K floor.""" + def test_default_threshold_floored_at_75_percent_below_512k(self): + """Sub-512K models get the 75% small-context threshold floor.""" with patch("agent.context_compressor.get_model_context_length", return_value=100_000): c = ContextCompressor(model="test", quiet_mode=True) - assert c.threshold_percent == 0.50 - # 50% of 100K = 50K, but the floor is 64K - assert c.threshold_tokens == 64_000 + assert c.threshold_percent == 0.75 + # 75% of 100K = 75K, above the 64K minimum floor + assert c.threshold_tokens == 75_000 - def test_threshold_floor_does_not_apply_above_128k(self): - """On large-context models the 50% percentage is used directly.""" - with patch("agent.context_compressor.get_model_context_length", return_value=200_000): + def test_configured_threshold_used_at_512k_and_above(self): + """At 512K+ the configured (default 50%) percentage is used directly.""" + with patch("agent.context_compressor.get_model_context_length", return_value=512_000): c = ContextCompressor(model="test", quiet_mode=True) - # 50% of 200K = 100K, which is above the 64K floor - assert c.threshold_tokens == 100_000 + assert c.threshold_percent == 0.50 + assert c.threshold_tokens == 256_000 def test_default_protect_last_n_is_20(self): """Default protect_last_n should be 20.""" diff --git a/tests/run_agent/test_infinite_compaction_loop.py b/tests/run_agent/test_infinite_compaction_loop.py index 31a7a12ba16..52a2efa813a 100644 --- a/tests/run_agent/test_infinite_compaction_loop.py +++ b/tests/run_agent/test_infinite_compaction_loop.py @@ -34,6 +34,9 @@ def _make_compressor(**kwargs) -> ContextCompressor: quiet_mode=True, ) defaults.update(kwargs) + # NOTE: 96K < 512K, so the small-context floor raises the effective + # threshold_percent to 0.75 → threshold_tokens = 72_000. Tests use + # 73_000 as the "over threshold" probe value. with patch("agent.context_compressor.get_model_context_length", return_value=96000): return ContextCompressor(**defaults) @@ -68,14 +71,14 @@ class TestCompressNoOpRegistersIneffective: ) # A large session that passes the min_for_compress check messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 # Mock _find_tail_cut_by_tokens to return head_end, # causing compress_start >= compress_end original = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 1, ( f"Expected ineffective_compression_count >= 1, got {comp._ineffective_compression_count}" @@ -88,10 +91,10 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._last_compression_savings_pct == 0.0 @@ -102,14 +105,14 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - comp.compress(messages, current_tokens=65_000) - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count >= 2 - assert not comp.should_compress(65_000), ( + assert not comp.should_compress(73_000), ( "should_compress should return False after 2+ ineffective compressions" ) @@ -120,11 +123,11 @@ class TestCompressNoOpRegistersIneffective: config_context_length=96000, ) messages = _build_session(10, words_per_turn=10) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 original_cut = comp._find_tail_cut_by_tokens comp._find_tail_cut_by_tokens = lambda msgs, he: he # force no-op - result = comp.compress(messages, current_tokens=65_000) + result = comp.compress(messages, current_tokens=73_000) assert len(result) == len(messages), ( f"Expected unchanged message count {len(messages)}, got {len(result)}" @@ -214,9 +217,9 @@ class TestEffectiveCompressionResetsCounter: ) messages = _build_session(30, words_per_turn=100) comp._generate_summary = MagicMock(return_value="Compacted summary of earlier turns.") - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 - comp.compress(messages, current_tokens=65_000) + comp.compress(messages, current_tokens=73_000) assert comp._ineffective_compression_count == 0, ( f"Expected 0 ineffective compressions with effective compression, " @@ -234,16 +237,16 @@ class TestAntiThrashing: def test_ineffective_count_2_blocks(self): """_ineffective_compression_count >= 2 -> should_compress returns False.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 2 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_ineffective_count_1_allows(self): """_ineffective_compression_count = 1 -> should_compress still True.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._ineffective_compression_count = 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_below_threshold_allows(self): """Tokens below threshold -> should_compress returns False regardless.""" @@ -266,23 +269,23 @@ class TestCooldownGuard: """A future cooldown deadline -> should_compress returns False even when tokens are over threshold.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() + 60 - assert not comp.should_compress(65_000) + assert not comp.should_compress(73_000) def test_expired_cooldown_allows(self): """A past cooldown deadline -> compression resumes normally.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 comp._summary_failure_cooldown_until = time.monotonic() - 1 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) def test_no_cooldown_allows(self): """The default (no cooldown set) does not block compression.""" comp = _make_compressor(config_context_length=96000) - comp.last_prompt_tokens = 65_000 + comp.last_prompt_tokens = 73_000 assert comp._summary_failure_cooldown_until == 0.0 - assert comp.should_compress(65_000) + assert comp.should_compress(73_000) # ---------------------------------------------------------------------------