mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
8e734810df
commit
76381e2a8e
6 changed files with 321 additions and 40 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
186
tests/agent/test_compression_small_ctx_threshold_floor.py
Normal file
186
tests/agent/test_compression_small_ctx_threshold_floor.py
Normal 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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue