mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(compression): refresh gateway activity during compaction
Refresh the agent activity tracker while context compression is blocked in the auxiliary summarizer so gateway watchdogs do not report inactivity during long compactions. Add regression coverage for successful heartbeats, exception cleanup, touch failures, and strict-signature compressor fallback. (cherry picked from commit c09e58b7709cc60c5b454701f0ecf840e759222f)
This commit is contained in:
parent
ea0fd393db
commit
928bcdde24
3 changed files with 259 additions and 1 deletions
|
|
@ -94,6 +94,140 @@ def _count_children(db: SessionDB, parent_sid: str) -> int:
|
|||
return len(rows)
|
||||
|
||||
|
||||
def _wait_for_touch(touch_calls: list[str], value: str, timeout: float = 1.0) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if value in touch_calls:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
pytest.fail(f"Timed out waiting for touch activity {value!r}; calls={touch_calls!r}")
|
||||
|
||||
|
||||
def test_compression_activity_heartbeat_touches_agent_during_long_compress(tmp_path: Path) -> None:
|
||||
"""Long compression must refresh agent activity so gateway watchdogs do not fire."""
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
session_id = "HEARTBEAT_TEST"
|
||||
db.create_session(session_id, source="test")
|
||||
|
||||
agent = _build_agent_with_db(db, session_id)
|
||||
agent._compression_activity_heartbeat_interval = 0.1
|
||||
touch_calls: list[str] = []
|
||||
agent._touch_activity = lambda desc: touch_calls.append(desc)
|
||||
|
||||
def _slow_compress(*_a, **_kw):
|
||||
_wait_for_touch(touch_calls, "context compression in progress")
|
||||
return [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] summary"},
|
||||
{"role": "user", "content": "tail"},
|
||||
]
|
||||
|
||||
agent.context_compressor.compress.side_effect = _slow_compress
|
||||
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
|
||||
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
||||
|
||||
assert touch_calls[0] == "context compression started"
|
||||
assert "context compression in progress" in touch_calls
|
||||
assert touch_calls[-1] == "context compression completed"
|
||||
assert db.get_compression_lock_holder(session_id) is None
|
||||
|
||||
|
||||
def test_compression_activity_heartbeat_stops_on_compress_exception(tmp_path: Path) -> None:
|
||||
"""Exception paths must stop the heartbeat and release the compression lock."""
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
session_id = "HEARTBEAT_FAIL_TEST"
|
||||
db.create_session(session_id, source="test")
|
||||
|
||||
agent = _build_agent_with_db(db, session_id)
|
||||
agent._compression_activity_heartbeat_interval = 0.1
|
||||
touch_calls: list[str] = []
|
||||
agent._touch_activity = lambda desc: touch_calls.append(desc)
|
||||
|
||||
def _failing_compress(*_a, **_kw):
|
||||
_wait_for_touch(touch_calls, "context compression in progress")
|
||||
raise RuntimeError("compress boom")
|
||||
|
||||
agent.context_compressor.compress.side_effect = _failing_compress
|
||||
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
|
||||
with pytest.raises(RuntimeError, match="compress boom"):
|
||||
agent._compress_context(messages, "sys", approx_tokens=120_000)
|
||||
|
||||
assert touch_calls[0] == "context compression started"
|
||||
assert "context compression in progress" in touch_calls
|
||||
assert touch_calls[-1] == "context compression failed"
|
||||
assert db.get_compression_lock_holder(session_id) is None
|
||||
|
||||
|
||||
def test_compression_activity_heartbeat_ignores_touch_errors(tmp_path: Path) -> None:
|
||||
"""Activity touch failures must not affect compression success semantics."""
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
session_id = "HEARTBEAT_TOUCH_ERROR_TEST"
|
||||
db.create_session(session_id, source="test")
|
||||
|
||||
agent = _build_agent_with_db(db, session_id)
|
||||
agent._compression_activity_heartbeat_interval = 0.1
|
||||
agent._touch_activity = lambda _desc: (_ for _ in ()).throw(RuntimeError("touch boom"))
|
||||
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
|
||||
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
||||
|
||||
assert compressed[0]["content"] == "[CONTEXT COMPACTION] summary"
|
||||
assert db.get_compression_lock_holder(session_id) is None
|
||||
|
||||
|
||||
def test_compression_activity_heartbeat_strict_signature_fallback_releases_lock(tmp_path: Path) -> None:
|
||||
"""Strict compressor signatures still fall back while heartbeat cleanup runs."""
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
session_id = "HEARTBEAT_TYPEERROR_TEST"
|
||||
db.create_session(session_id, source="test")
|
||||
|
||||
agent = _build_agent_with_db(db, session_id)
|
||||
agent._compression_activity_heartbeat_interval = "not-a-number"
|
||||
touch_calls: list[str] = []
|
||||
agent._touch_activity = lambda desc: touch_calls.append(desc)
|
||||
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]
|
||||
|
||||
def _strict_compress(*_args, **kwargs):
|
||||
if "focus_topic" in kwargs or "force" in kwargs:
|
||||
raise TypeError("unexpected keyword")
|
||||
return [
|
||||
{"role": "user", "content": "[CONTEXT COMPACTION] strict summary"},
|
||||
{"role": "user", "content": "tail"},
|
||||
]
|
||||
|
||||
agent.context_compressor.compress.side_effect = _strict_compress
|
||||
|
||||
compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=120_000)
|
||||
|
||||
assert compressed[0]["content"] == "[CONTEXT COMPACTION] strict summary"
|
||||
assert touch_calls[0] == "context compression started"
|
||||
assert touch_calls[-1] == "context compression completed"
|
||||
assert db.get_compression_lock_holder(session_id) is None
|
||||
assert agent.context_compressor.compress.call_count == 2
|
||||
|
||||
|
||||
def test_compression_activity_heartbeat_nonfinite_interval_falls_back(tmp_path: Path) -> None:
|
||||
"""Non-finite heartbeat intervals must not reach Event.wait()."""
|
||||
from agent.conversation_compression import _CompressionActivityHeartbeat
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "state.db")
|
||||
session_id = "HEARTBEAT_NONFINITE_INTERVAL_TEST"
|
||||
db.create_session(session_id, source="test")
|
||||
|
||||
agent = _build_agent_with_db(db, session_id)
|
||||
touch_calls: list[str] = []
|
||||
agent._touch_activity = lambda desc: touch_calls.append(desc)
|
||||
|
||||
heartbeat = _CompressionActivityHeartbeat(agent, interval_seconds=float("inf"))
|
||||
|
||||
assert heartbeat._interval_seconds == 60.0
|
||||
heartbeat.start()
|
||||
heartbeat.stop()
|
||||
assert touch_calls == ["context compression started", "context compression completed"]
|
||||
|
||||
|
||||
|
||||
def test_concurrent_compression_does_not_fork_session(tmp_path: Path) -> None:
|
||||
"""Two AIAgents that share a session_id MUST NOT both rotate it.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue