Files
hermes-agent/tests/agent/test_compaction_events.py
kshitijk4poor b862bfaaf0 fix(compression): keep a running attempt's record when an overlapping call stops at a gate
Review threads on #136050 and #136051 (agent/conversation_compression.py,
"Overlapping calls erase attempt details" / "Skipped calls erase active
records"). `_begin_compression_attempt` cleared the compressor's three
attempt-telemetry fields and rewrote its seed at entry, before the
automatic gate and the session lease. A second automatic call that stopped
at the gate (e.g. a cooldown while overflow recovery ran with
`bypass_cooldown=True`) therefore erased the running attempt's record. That
attempt could still commit, but logged `method: none` with no counts and no
fallback class, so a fallback commit read as a clean shared-metric success.

The previous-attempt clear (#118580) now runs in `_run_summary_phase`, under
the lease, just before the summary dispatch. The same statement writes this
attempt's own seed, so the entry-time seed write goes. Calls that stop at a
gate, the lease, or a stale snapshot never touch the shared fields.

A rewrite that survives a later bookkeeping failure (`failed_not_indexed`
after the atomic archive) also gets its counts finalized from the saved
transcript, using the same `rewritten` result that names the Relay mark
(thread "Saved tails disappear from counts" on #136051).
2026-10-10 20:18:10 +05:30

487 lines
23 KiB
Python

"""``hermes.compaction`` v1 records: one content-free record per history rewrite, on every path.
The record is the contract a Relay consumer reads, so these tests pin the field set, the closed enums,
the ``compaction`` vs ``compaction.attempt`` name rule, and that publishing can never change what a
compaction returns.
"""
import json
import logging
import os
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from agent import compaction_events
from agent.context_compressor import ContextCompressor
from agent.conversation_compression import CompressionCheckpointUnavailable, CompressionCommitFence, compress_context
from agent.transports.codex_app_server_session import TurnResult
SECRET = "TOPSECRET_TRANSCRIPT_TEXT"
V1_FIELDS = {
"harness", "kind", "scope", "official", "method", "trigger", "trigger_class", "overflow_reason", "outcome",
"failure_class", "attempt_id", "session_id", "tokens_before", "tokens_after", "tokens_reclaimed",
"token_count_method", "messages_before", "messages_after", "items_dropped", "context_limit", "threshold_tokens",
"protected_head_tokens", "middle_window_tokens", "protected_tail_tokens", "duration_ms",
"summary_generation_ms", "aux_call_duration_ms", "queue_wait_ms", "commit_ms", "summarizer_provider",
"summarizer_model", "tool_results_pruned", "reasoning_items_pruned", "summary_input_omitted_chars",
"compression_count", "in_place", "session_rotated", "split_status", "cache_break", "has_focus_topic",
}
@pytest.fixture
def published(monkeypatch):
"""Every (session_id, mark name, payload) handed to the Relay emitter."""
marks = []
monkeypatch.setattr("agent.relay_compaction.compaction_target_available", lambda *_args, **_kwargs: True)
monkeypatch.setattr(
"agent.relay_compaction.emit_compaction_mark",
lambda session_id, name, data, **_kwargs: marks.append((session_id, name, data)) or True,
)
return marks
class _TodoStore:
def format_for_injection(self):
return ""
class _Agent:
def __init__(self, compressor):
self.context_compressor = compressor
self.session_id = "session-events-test"
self.platform = "cli"
self.model = "test/main-model"
self.provider = "test-provider"
self.tools = []
self._compression_feasibility_checked = True
self.compression_in_place = False
self._memory_manager = None
self._session_db = None
self._todo_store = _TodoStore()
self._cached_system_prompt = None
def _emit_status(self, _message):
pass
def _emit_warning(self, _message):
pass
def _invalidate_system_prompt(self):
self._cached_system_prompt = None
def _build_system_prompt(self, system_message):
return system_message
def commit_memory_session(self, _messages):
pass
def _compressor(**kwargs):
with patch("agent.context_compressor.get_model_context_length", return_value=100_000):
compressor = ContextCompressor(
model="test/main-model", provider="test-provider", threshold_percent=0.50, quiet_mode=True,
config_context_length=100_000, **kwargs,
)
compressor.tail_token_budget = 10
return compressor
def _messages():
filler = " ".join(["context"] * 200)
msgs = [{"role": "system", "content": "system prompt"}]
for idx in range(10):
msgs.append({"role": "user", "content": f"user message {idx} {SECRET} {filler}"})
msgs.append({"role": "assistant", "content": f"assistant reply {idx} {SECRET} {filler}"})
return msgs
def test_committed_summary_publishes_one_compaction_mark_with_the_v1_shape(published):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
compress_context(
agent, _messages(), "system prompt", approx_tokens=80_000, trigger="pre_api",
focus_topic=f"focus on {SECRET}",
)
[(session_id, name, payload)] = published
assert (session_id, name) == ("session-events-test", "compaction")
assert set(payload) == V1_FIELDS
assert payload["harness"] == "hermes"
assert (payload["kind"], payload["scope"], payload["official"]) == ("summarize", "history", True)
assert (payload["method"], payload["outcome"], payload["failure_class"]) == ("llm_summary", "committed", "none")
assert (payload["trigger"], payload["trigger_class"]) == ("pre_api", "auto")
assert payload["tokens_reclaimed"] == payload["tokens_before"] - payload["tokens_after"] > 0
assert payload["token_count_method"] == "estimate_rough"
assert payload["compression_count"] == 1 and payload["cache_break"] is True
assert payload["has_focus_topic"] is True
raw = json.dumps(payload)
assert SECRET not in raw and "SANITIZED SUMMARY" not in raw and "focus on" not in raw
def test_deterministic_fallback_is_a_committed_compaction_with_its_method(published):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "_generate_summary", return_value=None):
compress_context(agent, _messages(), "system prompt", approx_tokens=80_000)
[(_sid, name, payload)] = published
assert name == "compaction"
assert (payload["method"], payload["failure_class"]) == ("deterministic_fallback", "summary_generation_failed")
assert payload["items_dropped"] > 0
assert payload["trigger"] == "unknown" # an unlabelled automatic caller
def test_failed_summary_is_an_attempt_mark_not_a_compaction(published):
agent = _Agent(_compressor())
agent.context_compressor.abort_on_summary_failure = True
with patch.object(agent.context_compressor, "_generate_summary", return_value=None):
compress_context(agent, _messages(), "system prompt", approx_tokens=80_000, trigger="post_tool")
[(_sid, name, payload)] = published
assert name == "compaction.attempt"
assert (payload["outcome"], payload["failure_class"]) == ("failed", "summary_generation_aborted")
assert payload["cache_break"] is False and payload["in_place"] is None
def test_an_engine_that_returns_an_empty_transcript_publishes_one_failed_attempt_mark(published):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "compress", return_value=[]):
returned, _ = compress_context(agent, _messages(), "system prompt", approx_tokens=80_000, trigger="pre_api")
assert returned == _messages()
[(session_id, name, payload)] = published
assert (session_id, name) == ("session-events-test", "compaction.attempt")
assert (payload["outcome"], payload["failure_class"], payload["method"]) == ("failed", "empty_transcript", "none")
def test_a_raising_emitter_never_changes_the_compaction_result(monkeypatch, caplog):
def explode(*_args, **_kwargs):
raise RuntimeError("relay down")
monkeypatch.setattr("agent.relay_compaction.emit_compaction_mark", explode)
monkeypatch.setattr(compaction_events, "_warned", False)
results = []
for _ in range(2):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
with caplog.at_level(logging.DEBUG, logger="agent.compaction_events"):
results.append(compress_context(agent, _messages(), "system prompt", approx_tokens=80_000))
assert all(len(compressed) < len(_messages()) for compressed, _ in results)
failures = [r for r in caplog.records if r.getMessage() == "compaction event publish failed"]
assert [r.levelno for r in failures] == [logging.WARNING, logging.DEBUG]
assert failures[0].exc_info is not None
def test_prune_without_relay_target_skips_transcript_estimates(monkeypatch):
agent = _Agent(_compressor())
messages = _messages()
monkeypatch.setattr("agent.relay_compaction.compaction_target_available", lambda *_args, **_kwargs: False)
with patch("agent.compaction_events.estimate_messages_tokens_rough") as estimate:
compaction_events.publish_prune(agent, messages, messages[:-1], 1)
estimate.assert_not_called()
def test_overflow_attempt_records_its_classified_overflow_reason(published):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
compress_context(
agent, _messages(), "system prompt", approx_tokens=80_000, bypass_cooldown=True, trigger="overflow",
overflow_reason="context_overflow",
)
[(_sid, name, payload)] = published
assert name == "compaction"
assert (payload["trigger"], payload["trigger_class"], payload["overflow_reason"]) == (
"overflow", "overflow", "context_overflow",
)
def test_gate_blocked_attempt_publishes_exactly_one_blocked_attempt_mark(published):
compressor = _compressor()
agent = _Agent(compressor)
with patch.object(type(compressor), "_compression_block_reason", return_value="cooldown:42"), \
patch.object(type(compressor), "_automatic_compression_blocked", return_value=True):
compress_context(agent, _messages(), "system prompt", approx_tokens=80_000, trigger="idle")
[(_sid, name, payload)] = published
assert name == "compaction.attempt"
assert (payload["outcome"], payload["failure_class"], payload["trigger"]) == ("blocked", "blocked:cooldown", "idle")
def _codex_agent(result, auto_mode):
agent = SimpleNamespace(
api_mode="codex_app_server", codex_app_server_auto_compaction=auto_mode, session_id="codex-session",
platform="cli", model="gpt-test", provider="openai-codex", _cached_system_prompt="cached prompt",
_codex_session=SimpleNamespace(compact_thread=lambda: result, close=lambda: None),
_compression_activity_heartbeat_interval=0.1,
context_compressor=SimpleNamespace(
compression_count=0, last_compression_rough_tokens=0, last_prompt_tokens=1, last_completion_tokens=0,
awaiting_real_usage_after_compression=False,
),
)
agent._touch_activity = lambda *_args, **_kwargs: None
agent._emit_status = agent._emit_warning = lambda _message: None
return agent
@pytest.mark.parametrize(
("auto_mode", "force", "result", "name", "outcome"),
[
("native", False, TurnResult(thread_id="t1"), "compaction.attempt", "skipped"),
("hermes", True, TurnResult(thread_id="t1", turn_id="c1"), "compaction", "committed"),
("hermes", True, TurnResult(thread_id="t1", error="boom"), "compaction.attempt", "failed"),
],
)
def test_codex_route_publishes_one_provider_record_per_exit(published, auto_mode, force, result, name, outcome):
compress_context(_codex_agent(result, auto_mode), [{"role": "user", "content": SECRET}], "system", force=force)
[(session_id, mark, payload)] = published
assert (session_id, mark, payload["outcome"]) == ("codex-session", name, outcome)
assert (payload["kind"], payload["scope"], payload["official"]) == ("provider_native", "provider", True)
assert "boom" not in json.dumps(payload)
def _raising_codex_agent():
def _compact_thread():
raise RuntimeError(f"boom {SECRET}")
agent = _codex_agent(None, "hermes")
agent._codex_session = SimpleNamespace(compact_thread=_compact_thread, close=lambda: None)
return agent
def _checkpoint_required_codex_agent():
agent = _codex_agent(TurnResult(thread_id="t1"), "hermes")
agent.compression_checkpoint_required = True
return agent
@pytest.mark.parametrize(
("make_agent", "error"),
[
pytest.param(_raising_codex_agent, RuntimeError, id="compact-thread-raises"),
pytest.param(_checkpoint_required_codex_agent, CompressionCheckpointUnavailable, id="checkpoint-required"),
],
)
def test_codex_route_that_raises_publishes_one_failed_attempt_mark(published, make_agent, error):
with pytest.raises(error):
compress_context(make_agent(), [{"role": "user", "content": SECRET}], "system", force=True)
[(session_id, mark, payload)] = published
assert (session_id, mark) == ("codex-session", "compaction.attempt")
assert (payload["kind"], payload["outcome"], payload["failure_class"]) == ("provider_native", "failed", "exception")
assert SECRET not in json.dumps(payload) and "boom" not in json.dumps(payload)
def test_codex_native_compaction_observed_by_hermes_is_an_unofficial_provider_compaction(published):
from agent.codex_runtime import _record_codex_app_server_compaction
agent = _codex_agent(None, "native")
agent.event_callback = None
_record_codex_app_server_compaction(agent, TurnResult(thread_id="t1", turn_id="n1", compacted=True))
[(_sid, name, payload)] = published
assert name == "compaction"
assert (payload["kind"], payload["official"], payload["trigger"]) == ("provider_native", False, "provider")
assert payload["trigger_class"] == "provider" and payload["compression_count"] == 1
@pytest.mark.parametrize(
("outcome", "name", "expected"),
[
("absorbed", "compaction", ("committed", "llm_summary", "none")),
("defrag", "compaction", ("committed", "llm_summary", "none")),
("stale_generation", "compaction.attempt", ("skipped", "none", "stale_generation")),
("summarize_failed", "compaction.attempt", ("failed", "none", "summarize_failed")),
],
)
def test_micro_compaction_pass_publishes_one_record(published, outcome, name, expected):
compressor = _compressor()
compressor._session_id = "micro-session"
compressor._emit_micro_compaction_telemetry(
outcome=outcome, messages_before=12, messages_after=10, tokens_before=900, tokens_after=600, duration_ms=5,
)
[(session_id, mark, payload)] = published
assert (session_id, mark) == ("micro-session", name)
assert (payload["outcome"], payload["method"], payload["failure_class"]) == expected
assert (payload["kind"], payload["official"], payload["trigger"]) == ("micro_summarize", False, "between_turns")
assert payload["tokens_reclaimed"] == 300
def _prune_after_tool_results(persist_disabled):
"""Drive the post-tool caller with the attempt budget spent, so it takes the proactive prune branch."""
from agent.turn_preflight import compress_after_tool_results
compressor = _compressor(
proactive_prune_tokens=48_000, proactive_prune_min_result_chars=8_000, protect_first_n=2, protect_last_n=4,
)
compressor.last_prompt_tokens = 120_000
agent = SimpleNamespace(
context_compressor=compressor, compression_enabled=True, session_id="prune-session", tools=[],
_persist_disabled=persist_disabled, _usage_anchor=None, _compression_feasibility_checked=True,
_warn_context_overflow_blocked=lambda *_args: None,
)
msgs = [{"role": "system", "content": "sys"}]
for i in range(8):
msgs.append({"role": "assistant", "content": "", "tool_calls": [
{"id": f"c{i}", "type": "function", "function": {"name": "terminal", "arguments": "{}"}}
]})
msgs.append({"role": "tool", "tool_call_id": f"c{i}", "content": (chr(65 + i) + SECRET) * 600 if i < 3 else "ok"})
verdict = compress_after_tool_results(
agent, messages=msgs, system_message="sys", user_message="ask", active_system_prompt="sys",
conversation_history=None, compression_attempts=3, max_compression_attempts=3, effective_task_id="task",
final_response=None, turn_exit_reason=None, current_turn_user_idx=0,
)
return msgs, verdict.messages
def test_committed_proactive_prune_publishes_one_record(published):
msgs, pruned = _prune_after_tool_results(persist_disabled=False)
assert pruned is not msgs
[(session_id, name, payload)] = published
assert (session_id, name) == ("prune-session", "compaction")
assert (payload["kind"], payload["method"], payload["trigger"]) == (
"prune_tool_results", "deterministic_prune", "proactive_prune",
)
assert payload["tool_results_pruned"] > 0 and payload["tokens_reclaimed"] > 0
assert payload["token_count_method"] == "estimate_rough"
assert payload["messages_before"] == payload["messages_after"] == len(msgs)
assert SECRET not in json.dumps(payload)
def test_a_persistence_detached_fork_publishes_nothing(published):
"""A background-review fork shares the live session id but rewrites only its own transcript."""
agent = _Agent(_compressor())
agent._persist_disabled = True
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
compressed, _ = compress_context(agent, _messages(), "system prompt", approx_tokens=80_000, trigger="pre_api")
msgs, pruned = _prune_after_tool_results(persist_disabled=True)
assert len(compressed) < len(_messages()) and pruned is not msgs
assert published == []
def test_an_attempt_aborted_after_a_commit_marks_only_its_own_attempt(published):
agent = _Agent(_compressor())
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
compress_context(agent, _messages(), "system prompt", approx_tokens=80_000, trigger="pre_api")
compress_context(
agent, _messages(), "system prompt", approx_tokens=80_000, trigger="post_tool",
snapshot_is_current=lambda: False,
)
[(_, first, committed), (_, second, aborted)] = published
assert (first, second) == ("compaction", "compaction.attempt")
assert aborted["attempt_id"] != committed["attempt_id"]
assert (aborted["trigger"], aborted["failure_class"], aborted["method"]) == ("post_tool", "snapshot_stale", "none")
assert aborted["tokens_reclaimed"] is None and aborted["cache_break"] is False
def test_rotation_publishes_after_the_split_with_the_attempts_own_session(published):
"""A rotating commit re-points the agent at a child session and its memory flush resets the compressor's
telemetry; the record is still built from this attempt's telemetry and names the session it started in."""
from hermes_state import SessionDB
from run_agent import AIAgent
with tempfile.TemporaryDirectory() as tmpdir, patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
agent = AIAgent(
api_key="test-key", base_url="https://openrouter.ai/api/v1", model="test/model", quiet_mode=True,
session_db=SessionDB(db_path=Path(tmpdir) / "state.db"), session_id="rotating-session",
skip_context_files=True, skip_memory=True,
)
agent.compression_in_place = False
agent.context_compressor.tail_token_budget = 10
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"):
agent._compress_context(_messages(), "sys", approx_tokens=80_000, trigger="pre_api")
agent.close()
assert agent.session_id != "rotating-session"
[(session_id, name, payload)] = published
assert (session_id, name) == ("rotating-session", "compaction")
assert (payload["split_status"], payload["session_rotated"], payload["in_place"]) == (
"rotated_committed", True, False,
)
assert payload["tokens_after"] is not None and payload["method"] == "llm_summary"
def test_a_split_that_fails_after_rewriting_history_is_still_a_compaction(published):
"""The in-place commit stored the compacted transcript, then a later bookkeeping write failed: the model
sees the compacted history next, so Relay must reset freshness, and the record says what failed."""
from hermes_state import SessionDB
from run_agent import AIAgent
with tempfile.TemporaryDirectory() as tmpdir, patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
db = SessionDB(db_path=Path(tmpdir) / "state.db")
db.create_session("in-place-session", "cli", model="test/model")
agent = AIAgent(
api_key="test-key", base_url="https://openrouter.ai/api/v1", model="test/model", quiet_mode=True,
session_db=db, session_id="in-place-session", skip_context_files=True, skip_memory=True,
)
agent.compression_in_place = True
agent.context_compressor.tail_token_budget = 10
with patch.object(agent.context_compressor, "_generate_summary", return_value="SANITIZED SUMMARY"), \
patch.object(SessionDB, "update_system_prompt", side_effect=OSError("disk full")):
compressed, _ = agent._compress_context(_messages(), "sys", approx_tokens=80_000, trigger="pre_api")
agent.close()
assert len(compressed) < len(_messages())
[(_sid, name, payload)] = published
assert name == "compaction"
assert (payload["outcome"], payload["failure_class"], payload["split_status"]) == (
"committed", "session_split_failed", "failed_not_indexed",
)
assert (payload["in_place"], payload["session_rotated"], payload["cache_break"]) == (None, None, True)
def test_an_in_place_engine_whose_split_fails_after_the_archive_is_still_a_compaction(published):
"""An engine may compact its input list in place, so the returned list IS the input. After the atomic
archive stored it, a later bookkeeping failure must still mark a compaction (freshness reset)."""
from hermes_state import SessionDB
from run_agent import AIAgent
def _in_place(msgs, **_kwargs):
compressor = agent.context_compressor
compressor._active_compression_telemetry = compressor._last_compression_telemetry = (
compressor._begin_compression_telemetry(current_tokens=80_000)
)
compressor._active_compression_telemetry.update(messages_before=len(msgs), messages_after=999)
msgs[:] = [msgs[0], {"role": "assistant", "content": "SANITIZED SUMMARY"}, msgs[-1]]
return msgs
with tempfile.TemporaryDirectory() as tmpdir, patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
db = SessionDB(db_path=Path(tmpdir) / "state.db")
db.create_session("in-place-engine", "cli", model="test/model")
agent = AIAgent(
api_key="test-key", base_url="https://openrouter.ai/api/v1", model="test/model", quiet_mode=True,
session_db=db, session_id="in-place-engine", skip_context_files=True, skip_memory=True,
)
agent.compression_in_place = True
messages = _messages()
with patch.object(type(agent.context_compressor), "compress", side_effect=_in_place), \
patch.object(SessionDB, "update_system_prompt", side_effect=OSError("disk full")):
# A caller-owned fence takes the direct path, where the engine mutates the caller's list itself.
compressed, _ = agent._compress_context(
messages, "sys", approx_tokens=80_000, trigger="pre_api", commit_fence=CompressionCommitFence(),
)
agent.close()
assert compressed is messages and len(compressed) < len(_messages())
[(_sid, name, payload)] = published
assert (name, payload["outcome"], payload["split_status"]) == ("compaction", "committed", "failed_not_indexed")
# The candidate's counts are replaced by the transcript that survived.
assert payload["messages_after"] == len(compressed)