Files
hermes-agent/tests/agent/test_compression_anti_thrash_persistence.py
Aniruddha Adak f778c0d941 fix(compression): structural no-ops defer retries instead of striking the breaker
Fixes #93022. A short session (protection window >= transcript) hits the
"insufficient messages" / "no compressible window" branches twice and
permanently trips the anti-thrash breaker, even though nothing was
eligible to compress - compression was never attempted, so there is
nothing "ineffective" to score. The session then rides past the
threshold with no compaction possible (recovery probes only soften,
not fix, the misclassification).

Distinguish "nothing eligible right now" from "attempted and
underperformed":

- New transient _structural_no_op_backoff_until (in-memory, 300s)
  armed by _record_structural_no_op() at the three structural no-op
  sites: insufficient_messages, no_compressible_window,
  empty_post_handoff_window. No strikes accumulate; auto-compaction
  resumes on its own once the backoff lapses or the transcript outgrows
  the protection window.
- The backoff gates should_compress via
  _automatic_compression_blocked_locally and surfaces in
  _compression_block_reason as "structural_backoff:<seconds>".
- #40803's frozen-CLI guarantee is preserved: a transcript that can
  never shrink retries at most once per backoff window instead of
  every turn.
- force=True (/compress) clears an active backoff before attempting;
  record_completed_compaction() lifts it - both prove the transcript
  is compressible/being worked.
- Genuine attempted-but-underperformed verdicts still strike the
  durable ineffective counter unchanged.

Tests: new tests/agent/test_context_compressor_structural_backoff.py;
updated the two tests that asserted the old strike-on-noop behavior.
2026-08-23 18:27:07 -07:00

213 lines
8.7 KiB
Python

"""Anti-thrash state must survive process restarts (#54923).
The guard in ``_automatic_compression_blocked_locally()`` trips only after two
consecutive compactions that fail to bring the real prompt under the
threshold. Historically ``_ineffective_compression_count`` was in-memory only:
a fresh compressor bound to a resumed (already-compacted) session started at
``compression_count == 0`` with a disarmed guard, so a near-threshold session
could legally re-compact once per process restart, forever — the exact
residual @lanyusea identified in #54923.
The counter now round-trips the durable session-state channel exactly like
``compression_failure_cooldown_until`` (#54465) and the fallback streak
(af7dceaf7):
* every verdict (strike or clear) writes through to the session row,
* ``bind_session_state()`` loads the persisted value, so a fresh compressor
on a resumed session inherits an armed (1) or tripped (2) guard,
* the reset semantics are unchanged — a real provider reading below the
threshold still clears the counter (update_from_response), and that clear
is durable too.
"""
from pathlib import Path
from unittest.mock import patch
from agent.context_compressor import ContextCompressor
from hermes_state import SessionDB
def _compressor(db: SessionDB | None = None, session_id: str = "") -> ContextCompressor:
with patch(
"agent.context_compressor.get_model_context_length",
return_value=100_000,
):
cc = ContextCompressor(
model="test/model",
threshold_percent=0.85,
protect_first_n=2,
protect_last_n=2,
quiet_mode=True,
)
if db is not None:
cc.bind_session_state(db, session_id)
return cc
def _db(tmp_path: Path) -> SessionDB:
return SessionDB(db_path=tmp_path / "state.db")
class TestCounterRoundTripsBindSessionState:
def test_fresh_compressor_inherits_tripped_guard_after_restart(self, tmp_path):
"""A restart must not disarm a tripped anti-thrash breaker."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
first = _compressor(db, "s1")
# Two compactions that failed to clear the threshold — judged on the
# provider's real prompt counts, exactly as conversation_loop drives it.
for _ in range(2):
first._verify_compaction_cleared_threshold = True
first.update_from_response({"prompt_tokens": first.threshold_tokens + 1})
assert first._ineffective_compression_count == 2
assert first.should_compress(10**9) is False
# Process restart: a brand-new compressor binds the same session.
second = _compressor(db, "s1")
assert second.compression_count == 0 # the #54923 precondition
assert second._ineffective_compression_count == 2
assert second.should_compress(10**9) is False, (
"a fresh compressor on a resumed session must inherit the "
"tripped anti-thrash guard instead of re-compacting"
)
def test_rebind_to_other_session_does_not_leak_counter(self, tmp_path):
"""The counter is per-session: switching sessions must not carry it."""
db = _db(tmp_path)
db.create_session("hot", source="cli")
db.create_session("cold", source="cli")
db.set_compression_ineffective_count("hot", 2)
cc = _compressor(db, "hot")
assert cc._ineffective_compression_count == 2
cc.bind_session_state(db, "cold")
assert cc._ineffective_compression_count == 0
class TestResetSemanticsPreserved:
def test_real_dip_below_threshold_clears_counter_durably(self, tmp_path):
"""The L1466-1474 contract survives: any real provider reading below
the threshold clears the latch — and now clears the durable copy, so
a restart cannot resurrect voided strikes."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
cc = _compressor(db, "s1")
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert cc._ineffective_compression_count == 1
assert db.get_compression_ineffective_count("s1") == 1
# An ordinary fitting response (not post-compaction) clears the latch.
cc.update_from_response({"prompt_tokens": cc.threshold_tokens - 1})
assert cc._ineffective_compression_count == 0
assert db.get_compression_ineffective_count("s1") == 0
# And a restart sees the cleared state.
fresh = _compressor(db, "s1")
assert fresh._ineffective_compression_count == 0
assert fresh.should_compress(10**9) is True
def test_post_compaction_clearing_reading_resets_durably(self, tmp_path):
"""The post-compaction success verdict (real tokens under threshold)
also zeroes the durable strike count."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
cc = _compressor(db, "s1")
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert db.get_compression_ineffective_count("s1") == 1
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens - 1})
assert cc._ineffective_compression_count == 0
assert db.get_compression_ineffective_count("s1") == 0
def test_update_model_reset_writes_through(self, tmp_path):
"""update_model() voids strikes judged against the old threshold; the
durable copy must not resurrect them on the next restart."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
cc = _compressor(db, "s1")
db.set_compression_ineffective_count("s1", 2)
cc._ineffective_compression_count = 2
cc.update_model("other/model", 100_000)
assert cc._ineffective_compression_count == 0
assert db.get_compression_ineffective_count("s1") == 0
class TestStrikesPersistFromEveryVerdictSite:
def test_no_op_compaction_branch_does_not_strike(self, tmp_path):
"""The insufficient-messages branch is a structural no-op (#93022).
Nothing was eligible to compress, so no ineffective strike is
recorded — durable or in-memory. The transient structural backoff
arms instead; the breaker must stay untouched so a short session
can still auto-compact once it grows real compressible material.
"""
db = _db(tmp_path)
db.create_session("s1", source="cli")
cc = _compressor(db, "s1")
# 3 tiny messages < minimum window → the #40803/#93022 no-op branch.
msgs = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
out = cc.compress(msgs, current_tokens=10**9)
assert out == msgs
assert cc._ineffective_compression_count == 0
assert db.get_compression_ineffective_count("s1") == 0
assert cc._structural_no_op_backoff_until > 0.0
def test_persist_failure_is_swallowed_and_memory_still_advances(self, tmp_path):
"""A DB write failure must not break the in-memory guard."""
db = _db(tmp_path)
db.create_session("s1", source="cli")
cc = _compressor(db, "s1")
with patch.object(
db,
"set_compression_ineffective_count",
side_effect=Exception("disk full"),
):
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert cc._ineffective_compression_count == 1
assert db.get_compression_ineffective_count("s1") == 0
class TestCompressionBoundaryCarry:
def test_rotation_boundary_carries_counter_onto_child_row(self, tmp_path):
"""Session-id rotation must not launder an armed guard through the
fresh child row (same carry contract as the fallback streak)."""
db = _db(tmp_path)
db.create_session("parent", source="cli")
cc = _compressor(db, "parent")
cc._verify_compaction_cleared_threshold = True
cc.update_from_response({"prompt_tokens": cc.threshold_tokens + 1})
assert db.get_compression_ineffective_count("parent") == 1
db.create_session("child", source="cli", parent_session_id="parent")
cc.on_session_start(
"child",
boundary_reason="compression",
old_session_id="parent",
session_db=db,
)
assert cc._session_id == "child"
assert cc._ineffective_compression_count == 1
# Persisted onto the child row so a restart right after rotation
# still inherits the armed guard.
assert db.get_compression_ineffective_count("child") == 1