fix(compression): stop compaction thrash — 75% trigger floor under 512K, no summary output cap, reasoning-trace exclusion (#60989)

Sessions on sub-512K-context models were spending most of their wall-clock
re-summarizing: the 50% trigger left too little post-compaction headroom
(the incompressible floor — system prompt, tool schemas, protected tail,
rolling summary — ate most of the reclaimed space), so compaction re-fired
every 1-2 turns. Three compounding defects fixed:

- Threshold floor: models with context windows below 512K now trigger at
  >=75% of the window (raise-only — a higher configured value or per-model
  autoraise like Codex gpt-5.5's 85% always wins). Re-derived on
  update_model() in both directions.
- No max_tokens on the summary call: the summary budget is prompt guidance
  only ("Target ~N tokens"). The wire cap truncated summaries mid-section
  on the Anthropic Messages / NVIDIA NIM paths (thinking models burn the
  cap on reasoning first), yielding truncated or thinking-only summaries
  and compaction loops. Summary token ceiling lowered 12K -> 10K to keep
  the guidance within the intended 1K-10K envelope.
- Reasoning traces excluded end-to-end: inline <think>/<reasoning> blocks
  are now stripped from assistant content before serialization to the
  summarizer, and from the summarizer's own output before the summary is
  stored (previously a thinking summarizer model's trace was persisted in
  _previous_summary and re-fed into every iterative update, compounding
  bloat). Native reasoning fields were already excluded.

Verified E2E with real imports against a temp HERMES_HOME: threshold table
across 64K-1M windows, override interactions (user 0.85 wins, spark 0.70
raised, gpt-5.5 0.85 kept), full compress() round-trip with a thinking
summarizer, and wire-kwargs capture proving no max_tokens is sent.
This commit is contained in:
Teknium 2026-07-08 11:56:17 -07:00 committed by GitHub
parent 8e734810df
commit 76381e2a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 321 additions and 40 deletions

View file

@ -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 (<think>, <reasoning>, 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
# (<think>...</think> 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())

View file

@ -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%

View file

@ -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

View file

@ -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 ``<think>``-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": "<think>INLINE_TRACE</think>visible answer"},
{"role": "assistant", "content": "<reasoning>VARIANT_TRACE</reasoning>other 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 = "<think>OUTPUT_TRACE</think>\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 = "<think>only reasoning, no body</think>"
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

View file

@ -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."""

View file

@ -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)
# ---------------------------------------------------------------------------