NousResearch/hermes-agent

Gateway retry/undo/reset commands not persisting

Cluster 210. Data as of

Nothing open on GitHub. Fix merged in #217 on 2 Mar 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #217. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #217. That is separate from the fix event.
Still open
Nothing open on GitHub.

The /retry, /undo, and /reset gateway commands were not functioning correctly, with transcript changes failing to persist and memories being silently lost. These members cover the bug report and associated fixes for this behavior.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #217 fix(gateway): persist transcript changes in /retry, /undo and fix /reset attribute

    merged to main

    files: gateway/run.py, gateway/session.py, tests/gateway/test_session.py

    Show the diff
    • gateway/run.py
      @@ -901,13 +901,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
               source = event.source
               
               # Get existing session key
      -        session_key = f"agent:main:{source.platform.value}:" + \
      -                      (f"dm" if source.chat_type == "dm" else f"{source.chat_type}:{source.chat_id}")
      +        session_key = self.session_store._generate_session_key(source)
               
               # Memory flush before reset: load the old transcript and let a
               # temporary agent save memories before the session is wiped.
               try:
      -            old_entry = self.session_store._sessions.get(session_key)
      +            old_entry = self.session_store._entries.get(session_key)
                   if old_entry:
                       old_history = self.session_store.load_transcript(old_entry.session_id)
                       if old_history:
      @@ -1135,7 +1134,7 @@ async def _handle_retry_command(self, event: MessageEvent) -> str:
               
               # Truncate history to before the last user message
               truncated = history[:last_user_idx]
      -        session_entry.conversation_history = truncated
      +        self.session_store.rewrite_transcript(session_entry.session_id, truncated)
               
               # Re-send by creating a fake text event with the old message
               retry_event = MessageEvent(
      @@ -1167,7 +1166,7 @@ async def _handle_undo_command(self, event: MessageEvent) -> str:
               
               removed_msg = history[last_user_idx].get("content", "")
               removed_count = len(history) - last_user_idx
      -        session_entry.conversation_history = history[:last_user_idx]
      +        self.session_store.rewrite_transcript(session_entry.session_id, history[:last_user_idx])
               
               preview = removed_msg[:40] + "..." if len(removed_msg) > 40 else removed_msg
               return f"↩️ Undid {removed_count} message(s).\nRemoved: \"{preview}\""
    • gateway/session.py
      @@ -567,6 +567,37 @@ def append_to_transcript(self, session_id: str, message: Dict[str, Any]) -> None
               with open(transcript_path, "a") as f:
                   f.write(json.dumps(message, ensure_ascii=False) + "\n")
           
      +    def rewrite_transcript(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
      +        """Replace a session's transcript with the given messages."""
      +        # Rewrite SQLite
      +        if self._db:
      +            try:
      +                self._db._conn.execute(
      +                    "DELETE FROM messages WHERE session_id = ?", (session_id,)
      +                )
      +                self._db._conn.execute(
      +                    "UPDATE sessions SET message_count = 0, tool_call_count = 0 WHERE id = ?",
      +                    (session_id,),
      +                )
      +                self._db._conn.commit()
      +                for msg in messages:
      +                    self._db.append_message(
      +                        session_id=session_id,
      +                        role=msg.get("role", "unknown"),
      +                        content=msg.get("content"),
      +                        tool_name=msg.get("tool_name"),
      +                        tool_calls=msg.get("tool_calls"),
      +                        tool_call_id=msg.get("tool_call_id"),
      +                    )
      +            except Exception as e:
      +                logger.debug("Session DB rewrite failed: %s", e)
      +
      +        # Rewrite legacy JSONL
      +        transcript_path = self.get_transcript_path(session_id)
      +        with open(transcript_path, "w") as f:
      +            for msg in messages:
      +                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
      +
           def load_transcript(self, session_id: str) -> List[Dict[str, Any]]:
               """Load all messages from a session's transcript."""
               # Try SQLite first
    • tests/gateway/test_session.py
      @@ -1,9 +1,13 @@
       """Tests for gateway session management."""
       
      +import json
       import pytest
      +from pathlib import Path
      +from unittest.mock import patch, MagicMock
       from gateway.config import Platform, HomeChannel, GatewayConfig, PlatformConfig
       from gateway.session import (
           SessionSource,
      +    SessionStore,
           build_session_context,
           build_session_context_prompt,
       )
      @@ -199,3 +203,59 @@ def test_whatsapp_prompt(self):
               prompt = build_session_context_prompt(ctx)
       
               assert "WhatsApp" in prompt or "whatsapp" in prompt.lower()
      +
      +
      +class TestSessionStoreRewriteTranscript:
      +    """Regression: /retry and /undo must persist truncated history to disk."""
      +
      +    @pytest.fixture()
      +    def store(self, tmp_path):
      +        config = GatewayConfig()
      +        with patch("gateway.session.SessionStore._ensure_loaded"):
      +            s = SessionStore(sessions_dir=tmp_path, config=config)
      +        s._db = None  # no SQLite for these tests
      +        s._loaded = True
      +        return s
      +
      +    def test_rewrite_replaces_jsonl(self, store, tmp_path):
      +        session_id = "test_session_1"
      +        # Write initial transcript
      +        for msg in [
      +            {"role": "user", "content": "hello"},
      +            {"role": "assistant", "content": "hi"},
      +            {"role": "user", "content": "undo this"},
      +            {"role": "assistant", "content": "ok"},
      +        ]:
      +            store.append_to_transcript(session_id, msg)
      +
      +        # Rewrite with truncated history
      +        store.rewrite_transcript(session_id, [
      +            {"role": "user", "content": "hello"},
      +            {"role": "assistant", "content": "hi"},
      +        ])
      +
      +        reloaded = store.load_transcript(session_id)
      +        assert len(reloaded) == 2
      +        assert reloaded[0]["content"] == "hello"
      +        assert reloaded[1]["content"] == "hi"
      +
      +    def test_rewrite_with_empty_list(self, store):
      +        session_id = "test_session_2"
      +        store.append_to_transcript(session_id, {"role": "user", "content": "hi"})
      +
      +        store.rewrite_transcript(session_id, [])
      +
      +        reloaded = store.load_transcript(session_id)
      +        assert reloaded == []
      +
      +
      +class TestSessionStoreEntriesAttribute:
      +    """Regression: /reset must access _entries, not _sessions."""
      +
      +    def test_entries_attribute_exists(self):
      +        config = GatewayConfig()
      +        with patch("gateway.session.SessionStore._ensure_loaded"):
      +            store = SessionStore(sessions_dir=Path("/tmp"), config=config)
      +        store._loaded = True
      +        assert hasattr(store, "_entries")
      +        assert not hasattr(store, "_sessions")

1 PR closed without merging

From the close comments: closed without comment 1

  • #213 fix(gateway): make /retry, /undo, and /reset actually work (fixes #210) · closed without comment
Closed issues · 1
  • Closed#210/retry and /undo gateway commands have no effect, /reset silently loses memories
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#217
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
2 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.