Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2507,7 +2507,13 @@ def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
if not self._db:
return []
try:
return self._db.get_messages_as_conversation(session_id)
# repair_alternation: this load feeds LIVE REPLAY. A durable
# user;user wedge (e.g. a turn that persisted no assistant row)
# would otherwise re-trigger the pre-request repair on every
# request forever — heal it once at the restore boundary.
return self._db.get_messages_as_conversation(
session_id, repair_alternation=True
)
except Exception as e:
logger.debug("Could not load messages from DB: %s", e)
return []
Expand Down
8 changes: 6 additions & 2 deletions hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,9 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
resolved_meta = self._session_db.get_session(self.session_id)
if resolved_meta:
session_meta = resolved_meta
restored = self._session_db.get_messages_as_conversation(self.session_id)
restored = self._session_db.get_messages_as_conversation(
self.session_id, repair_alternation=True
)
if restored:
restored = [m for m in restored if m.get("role") != "session_meta"]
self.conversation_history = restored
Expand Down Expand Up @@ -484,7 +486,9 @@ def _preload_resumed_session(self) -> bool:
if resolved_meta:
session_meta = resolved_meta

restored = self._session_db.get_messages_as_conversation(self.session_id)
restored = self._session_db.get_messages_as_conversation(
self.session_id, repair_alternation=True
)
if restored:
restored = [m for m in restored if m.get("role") != "session_meta"]
self.conversation_history = restored
Expand Down
26 changes: 26 additions & 0 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4581,6 +4581,7 @@ def get_messages_as_conversation(
session_id: str,
include_ancestors: bool = False,
include_inactive: bool = False,
repair_alternation: bool = False,
) -> List[Dict[str, Any]]:
"""
Load messages in the OpenAI conversation format (role + content dicts).
Expand All @@ -4589,6 +4590,16 @@ def get_messages_as_conversation(
By default only active messages are returned. Pass
``include_inactive=True`` to load soft-deleted (rewound) rows
as well. See :meth:`rewind_to_message`.

``repair_alternation=True`` runs ``repair_message_sequence`` over the
loaded list before returning it. Callers that restore a session for
LIVE REPLAY should pass it: a durable alternation violation (e.g. a
``user;user`` pair left by a turn that persisted no assistant row)
otherwise re-triggers the pre-request defensive repair on every
single request for the rest of the session's life — the repair
mutates only the per-request list, never the stored transcript.
Inspection/export consumers keep the default and see the transcript
verbatim.
"""
session_ids = [session_id]
if include_ancestors:
Expand Down Expand Up @@ -4685,6 +4696,21 @@ def get_messages_as_conversation(
# assistant reply immediately following it, so a polluted session
# resumes clean even if stray rows exist.
messages = _strip_background_review_harness(messages)
if repair_alternation and messages:
# Lazy import: hermes_state already depends on agent.* (see
# sanitize_context above), but keep this optional path from
# widening the import surface at module load.
from agent.agent_runtime_helpers import repair_message_sequence

repaired = repair_message_sequence(None, messages)
if repaired:
logger.info(
"Repaired %d message-alternation violation(s) while "
"restoring session %s — durable transcript kept them, "
"see repair_message_sequence",
repaired,
session_id,
)
return messages

def get_conversation_root(self, session_id: str) -> str:
Expand Down
1 change: 1 addition & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
AUTHOR_MAP = {
"doogie@spark.local": "SAMBAS123", # PR #64986 salvage (gateway: multiplex primary bot token scope)
"evefromwayback@gmail.com": "evefromwayback", # PR #64611 salvage (agent: never load install-tree AGENTS.md as project context)
"Regina@Andreys-Mini.true.true": "Rival", # PR #64935/#64936 salvage (state: restore-boundary alternation repair; agent: turn-overlap tripwire)
"41409874+2751738943@users.noreply.github.com": "2751738943", # PR #54785 salvage (tui: post-turn completion ownership routing)
"Burgunthy@users.noreply.github.com": "Burgunthy", # PR #20096 salvage (gateway: profile-based routing for inbound messages)
"75556242+webtecnica@users.noreply.github.com": "webtecnica", # PR #63360 salvage (nous: restore inference-api base_url)
Expand Down
13 changes: 11 additions & 2 deletions tests/gateway/test_35809_auto_reset_clean_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,17 @@ def _make_source():

def _bloat(n):
# Stand-in for the oversized, post-compression "child" transcript that
# could not be compressed any further (#35809).
return [{"role": "user", "content": "x" * 2000} for _ in range(n)]
# could not be compressed any further (#35809). Alternates roles so the
# fixture is a valid conversation: load_transcript is a live-replay
# restore site and heals alternation violations on load (#64934), so a
# degenerate all-user transcript would be merged into one message.
return [
{
"role": "user" if i % 2 == 0 else "assistant",
"content": "x" * 2000,
}
for i in range(n)
]


class TestAutoResetLoadsCleanContext:
Expand Down
72 changes: 72 additions & 0 deletions tests/hermes_state/test_restore_alternation_repair.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""get_messages_as_conversation(repair_alternation=True) — heal durable
alternation violations at the restore boundary.

A turn that persists a user row but no assistant row (e.g. its reply was
suppressed, or two concurrent turns interleaved their flushes) leaves a
``user;user`` pair in state.db. Without repair at restore, the defensive
pre-request ``repair_message_sequence`` re-fires on EVERY request for the
rest of the session's life, because it mutates only the per-request list.

Default (``repair_alternation=False``) must stay verbatim: inspection and
export consumers (trace upload, context guard) read the transcript as-is.
"""

import pytest

from hermes_state import SessionDB


@pytest.fixture()
def db(tmp_path):
db_path = tmp_path / "test_state.db"
session_db = SessionDB(db_path=db_path)
yield session_db
session_db.close()


def _seed_wedged_session(db, session_id="s1"):
"""assistant → user → user (no assistant row between): the durable wedge."""
db.create_session(session_id, "system prompt")
db.append_message(session_id=session_id, role="user", content="first ask")
db.append_message(session_id=session_id, role="assistant", content="first reply")
db.append_message(session_id=session_id, role="user", content="unanswered turn")
db.append_message(session_id=session_id, role="user", content="next turn")
db.append_message(session_id=session_id, role="assistant", content="next reply")


def test_default_load_is_verbatim(db):
_seed_wedged_session(db)
messages = db.get_messages_as_conversation("s1")
roles = [m["role"] for m in messages]
assert roles == ["user", "assistant", "user", "user", "assistant"]


def test_repair_alternation_merges_user_pair(db):
_seed_wedged_session(db)
messages = db.get_messages_as_conversation("s1", repair_alternation=True)
roles = [m["role"] for m in messages]
assert roles == ["user", "assistant", "user", "assistant"]
# Both user texts survive, merged in order — no user input is lost.
merged = messages[2]["content"]
assert "unanswered turn" in merged and "next turn" in merged
assert merged.index("unanswered turn") < merged.index("next turn")


def test_repaired_load_is_stable_under_prerequest_repair(db):
"""The restored list must yield ZERO further repairs — this is the whole
point: the pre-request defensive repair stops firing every turn."""
from agent.agent_runtime_helpers import repair_message_sequence

_seed_wedged_session(db)
messages = db.get_messages_as_conversation("s1", repair_alternation=True)
assert repair_message_sequence(None, messages) == 0


def test_repair_noop_on_clean_transcript(db):
db.create_session("s2", "system prompt")
db.append_message(session_id="s2", role="user", content="ask")
db.append_message(session_id="s2", role="assistant", content="reply")
verbatim = db.get_messages_as_conversation("s2")
repaired = db.get_messages_as_conversation("s2", repair_alternation=True)
assert [m["role"] for m in repaired] == [m["role"] for m in verbatim]
assert [m["content"] for m in repaired] == [m["content"] for m in verbatim]
Loading