hermes - ✅(Solved) Fix `--resume` loads empty chat after context compression; exit banner points at wrong session id [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#15000Fetched 2026-04-25 06:25:22
View on GitHub
Comments
0
Participants
1
Timeline
9
Reactions
0
Author
Participants
Timeline (top)
labeled ×4referenced ×3closed ×1cross-referenced ×1

When a chat session triggers one or more context compressions, hermes forks the session into a chain of child sessions (parent_session_id linking each new session to the previous) and resets the SQLite flush cursor. Messages saved in the messages table end up under the latest descendant session id, while the original session id (the one shown in the exit banner) has zero rows.

hermes --resume <original-id> then loads no history and the user sees a blank chat. The conversation data is not actually lost — it's under another session id — but there's no in-product path to discover that.

Error Message

Exception ignored in: <function BaseSubprocessTransport.del at 0x...> Traceback (most recent call last): File ".../asyncio/base_subprocess.py", line 126, in del self.close() ... RuntimeError: Event loop is closed

Root Cause

Root cause (source-level)

Fix Action

Fix / Workaround

Workaround for users hitting this today

PR fix notes

PR #15052: fix: P1 batch — Discord wildcard, ACP+MCP, NO_PROXY bypass, resume-after-compression

Description (problem / solution / changelog)

Four high-impact P1 fixes, salvaged with contributor attribution preserved via rebase-merge.

Fixes

  • #14920 — Discord wildcard "*" silently drops all messages Salvaged from @mrunmayee17's PR #14930 (allowed_channels). Extended to cover free_response_channels and ignored_channels which had the same "*" literal-in-set bug, and added regression tests for all three.
  • #14986 — MCP tools invisible in ACP sessions (hardcoded toolsets) Salvaged from @camaragon's PR #14709 unchanged. ACP sessions now expand enabled_toolsets with configured MCP servers at agent creation, and the tool-surface refresh after dynamic MCP server registration preserves the additions.
  • #14966 — _build_keepalive_http_client ignores NO_PROXY → 502 on local endpoints Salvaged from @shamork's PR #14546 unchanged. Added regression tests for _get_proxy_for_base_url() and the full keepalive-client path.
  • #15000 — --resume <id> loads empty chat after context compression New fix (no contributor PR existed). SessionDB.resolve_resume_session_id() walks the parent_session_id chain forward and redirects resume targets to the first descendant with messages. Wired into all three CLI resume entry points (_preload_resumed_session, _init_agent, /resume).

Also closed

  • #14933, #14938 — DeepSeek V4 reasoning_content issues closed as unactionable (both submitted with empty issue bodies; asked reporters to re-file with a reproducer).
  • PRs #14930, #14709, #14546 will be closed with credit referencing this PR after merge.
  • PR #14885 (duplicate NO_PROXY fix by @fqx) will be closed with credit — @shamork's implementation was preferred because it reuses stdlib urllib.request.proxy_bypass_environment() which correctly handles wildcards, leading dots, and CIDR-like patterns that #14885's custom matcher missed.

Attribution

FixContributorEmail → GH
#14920@mrunmayee17[email protected]
#14986@camaragonnoreply form
#14966@shamork[email protected]

All three added to scripts/release.py AUTHOR_MAP in the tail commit.

Validation

Test fileResult
tests/gateway/test_discord_allowed_channels.py15/15 ✓
tests/acp/test_session.py + tests/acp/test_server.py83/83 ✓
tests/run_agent/test_create_openai_client_proxy_env.py9/9 ✓ (4 new NO_PROXY tests)
tests/hermes_state/test_resolve_resume_session_id.py8/8 ✓ (new file)

E2E for #15000: reproduced the exact 6-session compression chain from the issue body (5 empty + 1 with 119 messages); resolve_resume_session_id correctly redirects to the 5th session from any of the 5 empty ones and returns the msg-bearing session unchanged.

Merge method

Rebase merge — each contributor's cherry-picked commit preserves their authorship. Squash would flatten all seven commits into one authored by us, losing credit.

Closes #14920, #14966, #14986, #15000 Supersedes #14546, #14709, #14885, #14930

Changed files

  • acp_adapter/server.py (modified, +9/-3)
  • acp_adapter/session.py (modified, +28/-1)
  • cli.py (modified, +49/-0)
  • gateway/platforms/discord.py (modified, +13/-4)
  • hermes_state.py (modified, +65/-0)
  • run_agent.py (modified, +26/-5)
  • scripts/release.py (modified, +4/-0)
  • tests/acp/test_server.py (modified, +7/-1)
  • tests/acp/test_session.py (modified, +37/-0)
  • tests/gateway/test_discord_allowed_channels.py (added, +104/-0)
  • tests/hermes_state/test_resolve_resume_session_id.py (added, +96/-0)
  • tests/run_agent/test_create_openai_client_proxy_env.py (modified, +76/-1)

Code Example

Resume this session with:
     hermes --resume <ORIGINAL_SESSION_ID>
   Session:  <ORIGINAL_SESSION_ID>
   Duration: 1h 2m 28s
   Messages: 45 (2 user, 42 tool calls)

---

SELECT id, parent_session_id, message_count, end_reason
FROM sessions
WHERE started_at > 1777013052 AND started_at < 1777016000
ORDER BY started_at;

---

20260424_143733_1abb38 | (null)                 | 0   | compression
20260424_144905_134d65 | 20260424_143733_1abb38 | 0   | compression
20260424_145134_bba836 | 20260424_144905_134d65 | 0   | compression
20260424_145312_a3c90c | 20260424_145134_bba836 | 0   | compression
20260424_145830_389a35 | 20260424_145312_a3c90c | 119 | compression
20260424_153153_efc612 | 20260424_145830_389a35 | 0   | compression

---

self._session_db.create_session(
    self.session_id,                       # NEW id
    source=...,
    model=self.model,
    parent_session_id=old_session_id,      # old id becomes parent
)
...
self._last_flushed_db_idx = 0              # flush cursor reset

---

def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
    with self._lock:
        cursor = self._conn.execute(
            "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id",
            (session_id,),
        )

---

WITH RECURSIVE chain(id) AS (
     SELECT id FROM sessions WHERE id = :root
     UNION ALL
     SELECT s.id FROM sessions s JOIN chain c
        ON s.parent_session_id = c.id OR s.id = (SELECT parent_session_id FROM sessions WHERE id = c.id)
   )
   SELECT * FROM messages WHERE session_id IN chain ORDER BY timestamp, id;

---

Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x...>
Traceback (most recent call last):
  File ".../asyncio/base_subprocess.py", line 126, in __del__
    self.close()
  ...
RuntimeError: Event loop is closed

---

# Find the session that actually holds the messages:
sqlite3 $HERMES_HOME/state.db \
  "SELECT id, message_count, datetime(started_at,'unixepoch','localtime')
     FROM sessions
    WHERE message_count > 0
    ORDER BY started_at DESC LIMIT 5;"

# Resume the descendant that actually has the data:
hermes --resume <DESCENDANT_ID>
RAW_BUFFERClick to expand / collapse

Summary

When a chat session triggers one or more context compressions, hermes forks the session into a chain of child sessions (parent_session_id linking each new session to the previous) and resets the SQLite flush cursor. Messages saved in the messages table end up under the latest descendant session id, while the original session id (the one shown in the exit banner) has zero rows.

hermes --resume <original-id> then loads no history and the user sees a blank chat. The conversation data is not actually lost — it's under another session id — but there's no in-product path to discover that.

Environment

  • Hermes Agent v0.10.0 (2026.4.16)
  • Python 3.11.13
  • macOS Darwin 24.6.0 (Apple Silicon)
  • MCP servers in use: grab-manager (stdio), chrome-devtools (stdio), jsreverser (stdio), MongoDB (stdio)

Reproduction

  1. Start a chat long enough (or with enough tool output, e.g. chrome-devtools DOM snapshots) to trigger the built-in context compression at least once — the TUI prints ⟳ compacting context….
  2. After at least one compression, exit hermes (Ctrl+D / /quit).
  3. Exit banner prints:
    Resume this session with:
      hermes --resume <ORIGINAL_SESSION_ID>
    Session:  <ORIGINAL_SESSION_ID>
    Duration: 1h 2m 28s
    Messages: 45 (2 user, 42 tool calls)
  4. Run hermes --resume <ORIGINAL_SESSION_ID> → chat opens with empty history.

In my repro the session went through 4 compressions, producing 6 linked sessions. Only the 5th (longest-lived) actually received message rows:

SELECT id, parent_session_id, message_count, end_reason
FROM sessions
WHERE started_at > 1777013052 AND started_at < 1777016000
ORDER BY started_at;
20260424_143733_1abb38 | (null)                 | 0   | compression
20260424_144905_134d65 | 20260424_143733_1abb38 | 0   | compression
20260424_145134_bba836 | 20260424_144905_134d65 | 0   | compression
20260424_145312_a3c90c | 20260424_145134_bba836 | 0   | compression
20260424_145830_389a35 | 20260424_145312_a3c90c | 119 | compression
20260424_153153_efc612 | 20260424_145830_389a35 | 0   | compression

Exit banner pointed at 20260424_143733_1abb38 (0 messages). Actual transcript lives at 20260424_145830_389a35. hermes --resume 20260424_145830_389a35 correctly restores the conversation, confirming the data itself is intact.

Root cause (source-level)

1. Compression creates a new session and resets the flush cursor

run_agent.py:7760-7779:

self._session_db.create_session(
    self.session_id,                       # NEW id
    source=...,
    model=self.model,
    parent_session_id=old_session_id,      # old id becomes parent
)
...
self._last_flushed_db_idx = 0              # flush cursor reset

From this point on, every append_message() writes under the new session_id. The old session row stays at message_count = 0 unless messages were already flushed to it before compression fired.

2. get_messages does not follow the parent chain

hermes_state.py:866-884:

def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
    with self._lock:
        cursor = self._conn.execute(
            "SELECT * FROM messages WHERE session_id = ? ORDER BY timestamp, id",
            (session_id,),
        )

Only the exact session_id is queried — there's no WITH RECURSIVE walk over parent_session_id. So resuming the head of the chain returns zero rows even though descendants hold the history.

3. Exit banner shows the original id, not the final descendant

The banner emits the user-facing "original" session id (before any compression). With (1) and (2), this id is the worst possible one to resume from once compression has happened — it's the only guaranteed-empty one in the chain.

Expected behavior

Either:

  • (A) --resume <ID> should walk parent_session_id to reassemble the full transcript, or
  • (B) The exit banner should print the id of the last descendant that actually holds the messages (the current self.session_id at exit), so the printed command resumes the right session without the user having to query SQLite.

(B) is the smaller/safer fix. (A) additionally unifies the UX so either the original id or any descendant id resumes the full chain.

Actual behavior

  • Exit banner: points at the original pre-compression session id.
  • hermes --resume <original-id>: loads 0 messages. User perceives "chat history lost".
  • Data is still in the DB under a descendant id, but there's no in-product path to discover that.

Suggested fix

Minimal (Option B)

At exit time, use the live self.session_id (after N compressions = last descendant id) in the banner, not the original id.

More complete (A + B)

  1. Make Session.get_messages() walk the chain with a recursive CTE, ordered by timestamp:
    WITH RECURSIVE chain(id) AS (
      SELECT id FROM sessions WHERE id = :root
      UNION ALL
      SELECT s.id FROM sessions s JOIN chain c
         ON s.parent_session_id = c.id OR s.id = (SELECT parent_session_id FROM sessions WHERE id = c.id)
    )
    SELECT * FROM messages WHERE session_id IN chain ORDER BY timestamp, id;
  2. Collapse a compression chain into a single logical "conversation" in hermes sessions list so users don't see six rows for one chat.
  3. Continue printing the latest descendant id in the exit banner for legacy callers.

Related observation (likely separate bug)

On exit Python emits:

Exception ignored in: <function BaseSubprocessTransport.__del__ at 0x...>
Traceback (most recent call last):
  File ".../asyncio/base_subprocess.py", line 126, in __del__
    self.close()
  ...
RuntimeError: Event loop is closed

Stdio-transport MCP subprocesses (chrome-devtools, grab-manager, jsreverser) aren't closed explicitly before the asyncio loop shuts down, so Python falls back to their __del__ finalizer, which races with an already-closed loop. Harmless (Exception ignored), but cosmetically noisy at every exit. Fix: explicit close() / await exit() on every registered MCP ClientSession in the shutdown path before the loop closes.

Workaround for users hitting this today

# Find the session that actually holds the messages:
sqlite3 $HERMES_HOME/state.db \
  "SELECT id, message_count, datetime(started_at,'unixepoch','localtime')
     FROM sessions
    WHERE message_count > 0
    ORDER BY started_at DESC LIMIT 5;"

# Resume the descendant that actually has the data:
hermes --resume <DESCENDANT_ID>

extent analysis

TL;DR

To fix the issue of lost chat history after compression, update the exit banner to print the last descendant session id that holds the messages, or modify get_messages to walk the parent chain and reassemble the full transcript.

Guidance

  • Identify the last descendant session id that holds the messages by querying the sessions table in the SQLite database.
  • Update the exit banner to print the last descendant session id instead of the original session id.
  • Consider modifying get_messages to use a recursive CTE to walk the parent chain and reassemble the full transcript.
  • Use the provided workaround to find and resume the descendant session that holds the data.

Example

WITH RECURSIVE chain(id) AS (
  SELECT id FROM sessions WHERE id = :root
  UNION ALL
  SELECT s.id FROM sessions s JOIN chain c
     ON s.parent_session_id = c.id OR s.id = (SELECT parent_session_id FROM sessions WHERE id = c.id)
)
SELECT * FROM messages WHERE session_id IN chain ORDER BY timestamp, id;

Notes

The provided workaround can be used to find and resume the descendant session that holds the data. However, a more complete fix would involve updating the exit banner and/or modifying get_messages to walk the parent chain.

Recommendation

Apply the minimal fix (Option B) by updating the exit banner to print the last descendant session id, as it is a smaller and safer change. This will allow users to resume the correct session and access their chat history.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

Either:

  • (A) --resume <ID> should walk parent_session_id to reassemble the full transcript, or
  • (B) The exit banner should print the id of the last descendant that actually holds the messages (the current self.session_id at exit), so the printed command resumes the right session without the user having to query SQLite.

(B) is the smaller/safer fix. (A) additionally unifies the UX so either the original id or any descendant id resumes the full chain.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

hermes - ✅(Solved) Fix `--resume` loads empty chat after context compression; exit banner points at wrong session id [1 pull requests, 1 participants]