mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
The anti-thrash guard (_ineffective_compression_count) was in-memory
only: a fresh compressor bound to a resumed, already-compacted session
started with compression_count=0 and a disarmed guard, so a
near-threshold session could legally re-compact once per process
restart, forever.
Persist the counter through the durable session-state channel,
mirroring the failure-cooldown (#54465) and fallback-streak (af7dceaf7)
pattern:
- hermes_state.py: sessions.compression_ineffective_count column
(declarative reconciliation adds it on existing DBs) +
get/set_compression_ineffective_count accessors.
- context_compressor.py: every strike/clear verdict routes through
_record_ineffective_compression_verdict() which writes through to the
session row (no-change verdicts skip the DB write);
bind_session_state() loads the persisted value; the compression
rotation boundary carries the counter onto the child row;
update_model()'s reset also clears the durable copy; the
ineffective-only fast path in _automatic_compression_blocked() is
removed because the counter is now durable and another agent's clear
must unblock a stale local snapshot.
- conversation_compression.py: _refresh_persisted_compression_guards
re-reads the counter alongside cooldown + fallback streak.
Reset semantics are unchanged: any real provider reading below the
threshold still clears the counter — and now clears it durably too.
Resolves the residual gap identified in #54923 by @lanyusea (the
second-threshold mechanism was superseded by persisting the existing
guard state).
Co-authored-by: lanyusea <lanyusea@gmail.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""Idle-triggered compaction: interaction with the compression guards.
|
|
|
|
The idle trigger (``agent/turn_context.py``, opt-in via
|
|
``compression.idle_compact_after_seconds``) does not compress anything
|
|
itself — it routes through ``agent._compress_context`` (the forwarder to
|
|
``agent.conversation_compression.compress_context``), which owns ALL of the
|
|
automatic-compaction guards:
|
|
|
|
- the per-session compression lock (added after the idle-compaction PR was
|
|
written — an idle-triggered compress racing a turn-triggered one on the
|
|
same session_id must be serialized, not forked),
|
|
- the persisted summary-failure cooldown,
|
|
- the anti-thrash / fallback-streak breaker.
|
|
|
|
These tests pin that composition end-to-end with a real ``AIAgent`` wired to
|
|
a real ``SessionDB``: when a guard says no, the idle path must be a strict
|
|
no-op for the turn (no compressor call, no session rotation, no flush
|
|
re-baseline, no user-message re-anchor).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import types
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from hermes_state import SessionDB
|
|
|
|
from agent.turn_context import build_turn_context
|
|
|
|
from tests.agent.test_compression_concurrent_fork import _build_agent_with_db
|
|
|
|
|
|
def _prep_idle_agent(db: SessionDB, session_id: str, *, idle_after: int = 60,
|
|
idle_gap: float = 3600.0):
|
|
"""Real AIAgent (mock compressor) primed so the idle trigger is eligible."""
|
|
agent = _build_agent_with_db(db, session_id)
|
|
agent.compression_enabled = True
|
|
agent.compression_idle_compact_after_seconds = idle_after
|
|
agent._last_activity_ts = time.time() - idle_gap
|
|
# The idle block reads these from the compressor; give the MagicMock real
|
|
# numbers so the floor computation and the preflight gate behave.
|
|
agent.context_compressor.threshold_tokens = 100_000
|
|
agent.context_compressor.summary_target_ratio = 0.20
|
|
agent.context_compressor.protect_first_n = 2
|
|
agent.context_compressor.protect_last_n = 2
|
|
# No active failure cooldown unless a test installs one.
|
|
agent.context_compressor.get_active_compression_failure_cooldown = (
|
|
lambda *a, **k: None
|
|
)
|
|
agent._cached_system_prompt = "SYSTEM"
|
|
return agent
|
|
|
|
|
|
def _run_prologue(agent, history, user_message="hello again"):
|
|
"""Invoke ``build_turn_context`` the way ``conversation_loop`` does.
|
|
|
|
The token-threshold preflight gate is pinned False so these tests
|
|
exercise the IDLE trigger in isolation (the preflight path has its own
|
|
coverage in ``test_turn_context.py``).
|
|
"""
|
|
with patch("agent.auxiliary_client.set_runtime_main", lambda *a, **k: None), \
|
|
patch("agent.turn_context._should_run_preflight_estimate",
|
|
return_value=False), \
|
|
patch("agent.turn_context.estimate_request_tokens_rough",
|
|
return_value=999_999):
|
|
return build_turn_context(
|
|
agent=agent,
|
|
user_message=user_message,
|
|
system_message=None,
|
|
conversation_history=history,
|
|
task_id=None,
|
|
stream_callback=None,
|
|
persist_user_message=None,
|
|
restore_or_build_system_prompt=lambda *a, **k: None,
|
|
install_safe_stdio=lambda: None,
|
|
sanitize_surrogates=lambda s: s,
|
|
summarize_user_message_for_log=lambda s: str(s),
|
|
set_session_context=lambda _sid: None,
|
|
set_current_write_origin=lambda _o: None,
|
|
ra=lambda: types.SimpleNamespace(_set_interrupt=lambda *a, **k: None),
|
|
)
|
|
|
|
|
|
def _history(n: int = 20) -> list:
|
|
return [{"role": "user", "content": f"m{i}"} for i in range(n)]
|
|
|
|
|
|
def test_idle_compaction_runs_through_guarded_path_and_releases_lock(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Happy path: the idle trigger reaches the real ``compress_context``.
|
|
|
|
It must acquire + release the per-session lock, invoke the compressor
|
|
exactly once, and (rotation mode) rotate the session — proving the trigger
|
|
is wired through the guarded entrypoint rather than calling the compressor
|
|
directly.
|
|
"""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_HAPPY"
|
|
db.create_session(sid, source="cli")
|
|
agent = _prep_idle_agent(db, sid)
|
|
|
|
ctx = _run_prologue(agent, _history())
|
|
|
|
agent.context_compressor.compress.assert_called_once()
|
|
# Rotation mode (in_place=False in the shared fixture) creates a child.
|
|
assert agent.session_id != sid
|
|
# The lock keyed on the OLD session id must not leak.
|
|
assert db.get_compression_lock_holder(sid) is None
|
|
# The turn continues on the compacted transcript, with the user-message
|
|
# anchor pointing at a live user row in the rebuilt list.
|
|
assert 0 <= ctx.current_turn_user_idx < len(ctx.messages)
|
|
assert ctx.messages[ctx.current_turn_user_idx].get("role") == "user"
|
|
|
|
|
|
def test_idle_compaction_status_suppressed_when_engine_opts_out(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Quiet context engines silence the 💤 idle-resume status line too.
|
|
|
|
The idle emit routes through ``automatic_compaction_status_message``
|
|
(phase="idle") the same way the preflight and pre-API emits do — an
|
|
engine with ``emit_automatic_compaction_status = False`` gets a fully
|
|
silent idle compaction, while the compaction itself still runs.
|
|
"""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_QUIET"
|
|
db.create_session(sid, source="cli")
|
|
agent = _prep_idle_agent(db, sid)
|
|
agent.context_compressor.emit_automatic_compaction_status = False
|
|
events = []
|
|
agent.status_callback = lambda ev, msg: events.append((ev, msg))
|
|
|
|
_run_prologue(agent, _history())
|
|
|
|
agent.context_compressor.compress.assert_called_once()
|
|
assert not any(
|
|
"Resumed after" in str(msg) for _ev, msg in events
|
|
), f"idle status leaked despite quiet engine: {events}"
|
|
|
|
|
|
def test_idle_compaction_status_emitted_by_default(tmp_path: Path) -> None:
|
|
"""Control: the default engine keeps the 💤 idle-resume status line."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_LOUD"
|
|
db.create_session(sid, source="cli")
|
|
agent = _prep_idle_agent(db, sid)
|
|
# MagicMock would auto-create the hook attributes as truthy mocks; pin
|
|
# the default-engine surface explicitly.
|
|
agent.context_compressor.emit_automatic_compaction_status = True
|
|
del agent.context_compressor.get_automatic_compaction_status_message
|
|
events = []
|
|
agent.status_callback = lambda ev, msg: events.append((ev, msg))
|
|
|
|
_run_prologue(agent, _history())
|
|
|
|
agent.context_compressor.compress.assert_called_once()
|
|
assert any(
|
|
ev == "lifecycle" and "Resumed after" in str(msg) for ev, msg in events
|
|
), f"expected idle status line, got: {events}"
|
|
|
|
|
|
def test_idle_compaction_defers_to_held_compression_lock(tmp_path: Path) -> None:
|
|
"""An idle-triggered compress racing another path must sit the round out.
|
|
|
|
The per-session lock landed after the idle-compaction PR: when another
|
|
path (turn-triggered preflight, background-review fork) already holds the
|
|
lock, ``compress_context`` returns the input list unchanged. The idle
|
|
block must treat that skip as a strict no-op: no compressor call, no
|
|
rotation, no flush re-baseline, anchor untouched.
|
|
"""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_LOCKED"
|
|
db.create_session(sid, source="cli")
|
|
assert db.try_acquire_compression_lock(sid, "external_holder") is True
|
|
|
|
agent = _prep_idle_agent(db, sid)
|
|
history = _history()
|
|
|
|
ctx = _run_prologue(agent, history)
|
|
|
|
# Skipped: the compressor never ran and the session did not rotate.
|
|
agent.context_compressor.compress.assert_not_called()
|
|
assert agent.session_id == sid
|
|
# The external holder still owns the lock (we must not have stolen or
|
|
# released someone else's lease).
|
|
assert db.get_compression_lock_holder(sid) == "external_holder"
|
|
# Turn state untouched: full history + this turn's user message, anchor on
|
|
# the just-appended message, flush baseline not re-baselined to None-then-
|
|
# doubled semantics.
|
|
assert len(ctx.messages) == len(history) + 1
|
|
assert ctx.current_turn_user_idx == len(ctx.messages) - 1
|
|
assert ctx.messages[ctx.current_turn_user_idx]["content"] == "hello again"
|
|
|
|
|
|
def test_idle_compaction_respects_anti_thrash_breaker(tmp_path: Path) -> None:
|
|
"""A tripped ineffective-compression breaker must block the idle trigger.
|
|
|
|
The breaker lives in ``ContextCompressor._automatic_compression_blocked``
|
|
and is consulted by ``compress_context`` for every non-forced entrypoint.
|
|
The idle path is an automatic entrypoint, so two prior ineffective
|
|
compactions must silence it too.
|
|
"""
|
|
from agent.context_compressor import ContextCompressor
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_THRASH"
|
|
db.create_session(sid, source="cli")
|
|
agent = _prep_idle_agent(db, sid)
|
|
|
|
with patch(
|
|
"agent.context_compressor.get_model_context_length", return_value=100_000
|
|
):
|
|
compressor = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.85,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
quiet_mode=True,
|
|
)
|
|
compressor.bind_session_state(db, sid)
|
|
# Trip the breaker durably (#54923: the strike counter now round-trips
|
|
# state.db, and the gate re-reads durable rows before honoring a block).
|
|
db.set_compression_ineffective_count(sid, 2)
|
|
compressor._ineffective_compression_count = 2 # breaker tripped
|
|
compressor.compress = MagicMock()
|
|
agent.context_compressor = compressor
|
|
|
|
ctx = _run_prologue(agent, _history())
|
|
|
|
compressor.compress.assert_not_called()
|
|
assert agent.session_id == sid
|
|
assert len(ctx.messages) == len(_history()) + 1
|
|
|
|
|
|
def test_idle_compaction_respects_persisted_failure_cooldown(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""An active summary-failure cooldown must gate the idle trigger up front.
|
|
|
|
The idle predicate itself consults
|
|
``get_active_compression_failure_cooldown`` — with a persisted cooldown in
|
|
state.db the trigger must not even reach ``_compress_context``.
|
|
"""
|
|
from agent.context_compressor import ContextCompressor
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_COOLDOWN"
|
|
db.create_session(sid, source="cli")
|
|
db.record_compression_failure_cooldown(sid, 4_000_000_000.0, "timeout")
|
|
|
|
agent = _prep_idle_agent(db, sid)
|
|
with patch(
|
|
"agent.context_compressor.get_model_context_length", return_value=100_000
|
|
):
|
|
compressor = ContextCompressor(
|
|
model="test/model",
|
|
threshold_percent=0.85,
|
|
protect_first_n=2,
|
|
protect_last_n=2,
|
|
quiet_mode=True,
|
|
)
|
|
compressor.bind_session_state(db, sid)
|
|
compressor.compress = MagicMock()
|
|
agent.context_compressor = compressor
|
|
agent._compress_context = MagicMock()
|
|
|
|
ctx = _run_prologue(agent, _history())
|
|
|
|
agent._compress_context.assert_not_called()
|
|
compressor.compress.assert_not_called()
|
|
assert agent.session_id == sid
|
|
assert len(ctx.messages) == len(_history()) + 1
|
|
|
|
|
|
def test_idle_compaction_disabled_by_default(tmp_path: Path) -> None:
|
|
"""With the default config (0) a huge idle gap must never trigger."""
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
sid = "IDLE_OFF"
|
|
db.create_session(sid, source="cli")
|
|
agent = _prep_idle_agent(db, sid, idle_after=0, idle_gap=10_000_000.0)
|
|
agent._compress_context = MagicMock()
|
|
|
|
ctx = _run_prologue(agent, _history())
|
|
|
|
agent._compress_context.assert_not_called()
|
|
agent.context_compressor.compress.assert_not_called()
|
|
assert agent.session_id == sid
|
|
assert len(ctx.messages) == len(_history()) + 1
|