Files
hermes-agent/hermes_state_rewind.py
teknium1 ad01b93b26 fix(state): /undo and /retry work again after a turn that got no reply
A turn that ends with no assistant reply (non-retryable provider error,
interrupt) leaves an unanswered user row in the transcript. The next ask
makes the live history `user;user`, and the pre-request alternation repair
merges the pair into ONE turn in place while both rows stay stored. From
then on the warm history has one user turn fewer than the durable
projection, and `rewind_user_turn` refused every /undo N and /retry with
"session history changed before the rewind could be persisted" for the
rest of the session; resume re-merges, so it never healed.

Rewind now addresses turns on the same alternation-repaired projection the
live process holds (the load `get_resume_conversations` already uses). The
merged turn keeps the first row's identity, so /undo 1 soft-deletes both
stored rows of the pair and the reply; the in-txn payload pin compares
against the STORED first row, so the pin stays strict (no prefix matching).
The gateway surface, which has no warm history, gets the same turn
boundaries as CLI and TUI instead of undoing only the second half.

Slimmer redo of #115511 by @MohamadKanso: same direction (repaired durable
load), without the `_unmerged_content` marker on live history dicts and
without relaxing `_split_rewind_target` to a startswith match.

Co-authored-by: Mohamad Kanso <91088196+MohamadKanso@users.noreply.github.com>
2026-09-20 12:12:26 -07:00

130 lines
8.1 KiB
Python

"""Carrier-aware user-turn rewind (``/undo``, ``/retry``) — the ONE implementation behind the CLI, the
gateway and the TUI. Rewind is a persisted-history operation: the durable transcript is the authority,
the warm (in-memory) history only has to agree with it. A composite compaction carrier (retained
summary + live human ask in one row) keeps its hidden handoff scaffold as the new head."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
_HISTORY_CHANGED = "session history changed before the rewind could be persisted"
class RewindTargetUnavailableError(ValueError):
"""The requested user turn is not a rewindable target of the active transcript: no user turns, an
ordinal past the newest one, a row that is not user-originated, or a plain turn where the caller
required a compaction carrier. Surfaces map this to their own "nothing to undo" message."""
@dataclass
class RewindOutcome:
prefix: List[Dict[str, Any]] # history to install: the warm prefix when ``warm_history`` was given, else durable
live_view: Dict[str, Any] # canonical live projection of the rewound turn (prefill / retry source)
live_text: str # lossless retry text when ``require_retryable``, else the display flattening (prefill)
rewound_count: int
turns_undone: int
def _user_indices(messages: List[Dict[str, Any]]) -> List[int]:
from agent.context_compressor import user_originated_turn_view
return [i for i, m in enumerate(messages) if user_originated_turn_view(m) is not None]
def _comparison_content(message: Dict[str, Any]) -> Any:
"""Project content the way the durable row stores it (flush projection, then the read-side sanitize) so a
warm row and its durable twin compare equal."""
from agent.session_persistence import _durable_content
from hermes_state_messages import SessionMessagesMixin
return SessionMessagesMixin._loaded_view_content(message.get("role"), _durable_content(message.get("content")))
class SessionRewindMixin:
"""``SessionDB`` mixin: soft-delete from one user turn onward, validated against the warm history."""
def rewind_user_turn(
self, session_id: str, user_ordinal: int, *, warm_history: Optional[List[Dict[str, Any]]] = None,
require_retryable: bool = False, require_composite: bool = False, adopt_row_ids: bool = False,
) -> RewindOutcome:
"""Rewind the active transcript to just before user turn ``user_ordinal`` (0 = oldest; negative counts
back from the newest and clamps to the oldest, so ``-n`` is ``/undo n``). ``warm_history`` (CLI/TUI):
the in-memory view must have the same user turns and the same live target text as the durable
transcript, else ``RuntimeError`` and nothing changes; its (richer) prefix is what gets installed.
``require_retryable``: the live payload must be losslessly replayable as text (``ValueError`` from
:func:`retryable_user_text` before any write). ``require_composite``: the target must be a compaction
carrier. ``adopt_row_ids`` (TUI): copy durable ``_row_id`` identities onto the installed warm prefix so
clients can address follow-ups by row; the CLI leaves its history shape alone. Out-of-range /
wrong-shape targets raise :class:`RewindTargetUnavailableError`."""
from agent.context_compressor import (
_DB_PERSISTED_MARKER, history_before_user_originated_turn, retryable_user_text,
split_user_originated_turn, user_originated_turn_view)
from agent.message_content import flatten_message_text
from agent.session_persistence import _is_ephemeral_scaffolding
expected_active_ids = self.get_active_message_ids(session_id)
stored = self.get_messages_as_conversation(session_id, include_row_ids=True)
# Live replay (the pre-request repair, a resume) merges a stored ``user;user`` pair — an ask whose turn
# ended with no reply, then the next ask — into ONE turn while both rows stay stored. Address turns on
# that same repaired projection or the warm history is a turn short of the transcript forever
# (#115493); the merged turn keeps the first row's identity, so the rewind starts at that row.
durable = self.get_messages_as_conversation(session_id, include_row_ids=True, repair_alternation=True)
durable_user = _user_indices(durable)
if user_ordinal < 0:
user_ordinal = max(len(durable_user) + user_ordinal, 0)
if user_ordinal >= len(durable_user):
raise RewindTargetUnavailableError("target user message is no longer in session history")
target_index = durable_user[user_ordinal]
target = durable[target_index]
durable_prefix, live_view = history_before_user_originated_turn(durable, target_index)
scaffold, _ = split_user_originated_turn(target)
if require_composite and scaffold is None:
raise RewindTargetUnavailableError("target user message is not a compaction carrier")
prefix = durable_prefix
if warm_history is not None:
warm = [m for m in warm_history if not _is_ephemeral_scaffolding(m)]
warm_user = _user_indices(warm)
if len(warm_user) != len(durable_user):
raise RuntimeError(_HISTORY_CHANGED)
prefix, warm_live_view = history_before_user_originated_turn(warm, warm_user[user_ordinal])
if _comparison_content(live_view) != _comparison_content(warm_live_view):
raise RuntimeError(_HISTORY_CHANGED)
# Retry re-sends the stored bytes: ``"".join`` of the text parts, never the "\n"-joined display
# flattening (wire bytes == stored bytes; ``"ab"`` must not come back as ``"a\nb"``).
live_text = retryable_user_text(live_view.get("content")) if require_retryable else None
target_row_id = target.get("_row_id")
if not isinstance(target_row_id, int):
raise RuntimeError("rewind target has no durable row identity")
# The in-txn payload pin compares against the STORED row, which for a merged turn holds only the
# first ask, never the merged text the live views carry.
stored_view = next(
(user_originated_turn_view(m) for m in stored if m.get("_row_id") == target_row_id), None)
if stored_view is None:
raise RuntimeError(_HISTORY_CHANGED)
try:
result = self.rewind_to_message(
session_id, target_row_id, preserve_compaction_handoff=scaffold is not None,
expected_active_ids=expected_active_ids, expected_target_content=stored_view.get("content"))
except ValueError as exc: # target vanished / changed role under us: same class of failure as out-of-range
raise RewindTargetUnavailableError(str(exc)) from exc
if scaffold is not None:
replacement_id = result.get("replacement_message_id")
if not isinstance(replacement_id, int) or not durable_prefix:
raise RuntimeError("rewind did not retain its compaction handoff")
durable_prefix[-1].update({"_row_id": replacement_id, _DB_PERSISTED_MARKER: True})
prefix[-1] = durable_prefix[-1]
if adopt_row_ids and prefix is not durable_prefix and len(prefix) == len(durable_prefix) and all(
warm.get("role") == durable_message.get("role")
and bool(warm.get("display_kind")) == bool(durable_message.get("display_kind"))
and _comparison_content(warm) == _comparison_content(durable_message)
for warm, durable_message in zip(prefix, durable_prefix)
):
# Clients address follow-ups by durable row id: keep the richer warm content, adopt the identities.
for warm, durable_message in zip(prefix, durable_prefix):
if isinstance(row_id := durable_message.get("_row_id"), int):
warm["_row_id"] = row_id
return RewindOutcome(
prefix=prefix, live_view=live_view,
live_text=live_text if live_text is not None else flatten_message_text(live_view.get("content")),
rewound_count=int(result.get("rewound_count", 0)), turns_undone=len(durable_user) - user_ordinal)