NousResearch/hermes-agent

Long-session continuity and compression handling improvements

Cluster 23307 · agent. Data as of

1 open item. Fix merged in #119888 on 23 Sep 2026. Check it covers your case.

The short answer

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

These items address how sessions behave during and after context compression, including persisting compressed context to the gateway and surfacing compression information in session lists and resume lookups.

Open issues1

  • Issue#23307Improve long-session continuity across compactions

Open PRs0

No open pull requests.

Fixes on record

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

  • #12960 fix(sessions): surface compression tips in session lists and resume lookups

    merged to main

    files: hermes_cli/main.py, hermes_state.py, tests/test_hermes_state.py

    Show the diff
    • hermes_cli/main.py
      @@ -693,6 +693,10 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
           - If it looks like a session ID (contains underscore + hex), try direct lookup first.
           - Otherwise, treat it as a title and use resolve_session_by_title (auto-latest).
           - Falls back to the other method if the first doesn't match.
      +    - If the resolved session is a compression root, follow the chain forward
      +      to the latest continuation. Users who remember the old root ID (e.g.
      +      from an exit summary printed before the bug fix, or from notes) get
      +      resumed at the live tip instead of a stale parent with no messages.
           """
           try:
               from hermes_state import SessionDB
      @@ -701,14 +705,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]:
       
               # Try as exact session ID first
               session = db.get_session(name_or_id)
      +        resolved_id: Optional[str] = None
               if session:
      -            db.close()
      -            return session["id"]
      +            resolved_id = session["id"]
      +        else:
      +            # Try as title (with auto-latest for lineage)
      +            resolved_id = db.resolve_session_by_title(name_or_id)
      +
      +        if resolved_id:
      +            # Project forward through compression chain so resumes land on
      +            # the live tip instead of a dead compressed parent.
      +            try:
      +                resolved_id = db.get_compression_tip(resolved_id) or resolved_id
      +            except Exception:
      +                pass
       
      -        # Try as title (with auto-latest for lineage)
      -        session_id = db.resolve_session_by_title(name_or_id)
               db.close()
      -        return session_id
      +        return resolved_id
           except Exception:
               pass
           return None
    • hermes_state.py
      @@ -723,6 +723,42 @@ def get_next_title_in_lineage(self, base_title: str) -> str:
       
               return f"{base} #{max_num + 1}"
       
      +    def get_compression_tip(self, session_id: str) -> Optional[str]:
      +        """Walk the compression-continuation chain forward and return the tip.
      +
      +        A compression continuation is a child session where:
      +        1. The parent's ``end_reason = 'compression'``
      +        2. The child was created AFTER the parent was ended (started_at >= ended_at)
      +
      +        The second condition distinguishes compression continuations from
      +        delegate subagents or branch children, which can also have a
      +        ``parent_session_id`` but were created while the parent was still live.
      +
      +        Returns the session_id of the latest continuation in the chain, or the
      +        input ``session_id`` if it isn't part of a compression chain (or if the
      +        input itself doesn't exist).
      +        """
      +        current = session_id
      +        # Bound the walk defensively — compression chains this deep are
      +        # pathological and shouldn't happen in practice. 100 = plenty.
      +        for _ in range(100):
      +            with self._lock:
      +                cursor = self._conn.execute(
      +                    "SELECT id FROM sessions "
      +                    "WHERE parent_session_id = ? "
      +                    "  AND started_at >= ("
      +                    "      SELECT ended_at FROM sessions "
      +                    "      WHERE id = ? AND end_reason = 'compression'"
      +                    "  ) "
      +                    "ORDER BY started_at DESC LIMIT 1",
      +                    (current, current),
      +                )
      +                row = cursor.fetchone()
      +            if row is None:
      +                return current
      +            current = row["id"]
      +        return current
      +
           def list_sessions_rich(
               self,
               source: str = None,
      @@ -730,6 +766,7 @@ def list_sessions_rich(
               limit: int = 20,
               offset: int = 0,
               include_children: bool = False,
      +        project_compression_tips: bool = True,
           ) -> List[Dict[str, Any]]:
               """List sessions with preview (first user message) and last active timestamp.
       
      @@ -741,6 +778,14 @@ def list_sessions_rich(
       
               By default, child sessions (subagent runs, compression continuations)
               are excluded.  Pass ``include_children=True`` to include them.
      +
      +        With ``project_compression_tips=True`` (default), sessions that are
      +        roots of compression chains are projected forward to their latest
      +        continuation — one logical conversation = one list entry, showing the
      +        live continuation's id/message_count/title/last_active. This prevents
      +        compressed continuations from being invisible to users while keeping
      +        delegate subagents and branches hidden. Pass ``False`` to return the
      +        raw root rows (useful for admin/debug UIs).
               """
               where_clauses = []
               params = []
      @@ -791,8 +836,77 @@ def list_sessions_rich(
                       s["preview"] = ""
                   sessions.append(s)
       
      +        # Project compression roots forward to their tips. Each row whose
      +        # end_reason is 'compression' has a continuation child; replace the
      +        # surfaced fields (id, message_count, title, last_active, ended_at,
      +        # end_reason, preview) with the tip's values so the list entry acts
      +        # as the live conversation. Keep the root's started_at to preserve
      +        # chronological ordering by original conversation start.
      +        if project_compression_tips and not include_children:
      +            projected = []
      +            for s in sessions:
      +                if s.get("end_reason") != "compression":
      +                    projected.append(s)
      +                    continue
      +                tip_id = self.get_compression_tip(s["id"])
      +                if tip_id == s["id"]:
      +                    projected.append(s)
      +                    continue
      +                tip_row = self._get_session_rich_row(tip_id)
      +                if not tip_row:
      +                    projected.append(s)
      +                    continue
      +                # Preserve the root's started_at for stable sort order, but
      +                # surface the tip's identity and activity data.
      +                merged = dict(s)
      +                for key in (
      +                    "id", "ended_at", "end_reason", "message_count",
      +                    "tool_call_count", "title", "last_active", "preview",
      +                    "model", "system_prompt",
      +                ):
      +                    if key in tip_row:
      +                        merged[key] = tip_row[key]
      +                merged["_lineage_root_id"] = s["id"]
      +                projected.append(merged)
      +            sessions = projected
      +
               return sessions
       
      +    def _get_session_rich_row(self, session_id: str) -> Optional[Dict[str, Any]]:
      +        """Fetch a single session with the same enriched columns as
      +        ``list_sessions_rich`` (preview + last_active). Returns None if the
      +        session doesn't exist.
      +        """
      +        query = """
      +            SELECT s.*,
      +                COALESCE(
      +                    (SELECT SUBSTR(REPLACE(REPLACE(m.content, X'0A', ' '), X'0D', ' '), 1, 63)
      +                     FROM messages m
      +                     WHERE m.session_id = s.id AND m.role = 'user' AND m.content IS NOT NULL
      +                     ORDER BY m.timestamp, m.id LIMIT 1),
      +                    ''
      +                ) AS _preview_raw,
      +                COALESCE(
      +                    (SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id),
      +                    s.started_at
      +                ) AS last_active
      +            FROM sessions s
      +            WHERE s.id = ?
      +        """
      +        with self._lock:
      +            cursor = self._conn.execute(query, (session_id,))
      +            row = cursor.fetchone()
      +        if not row:
      +            return None
      +        s = dict(row)
      +        raw = s.pop("_preview_raw", "").strip()
      +        if raw:
      +            text = raw[:60]
      +            s["preview"] = text + ("..." if len(raw) > 60 else "")
      +        else:
      +            s["preview"] = ""
      +        return s
      +
           # =========================================================================
           # Message storage
           # =========================================================================
    • tests/test_hermes_state.py
      @@ -1381,6 +1381,178 @@ def test_preview_newlines_collapsed(self, db):
               assert "Line one Line two" in sessions[0]["preview"]
       
       
      +class TestCompressionChainProjection:
      +    """Tests for lineage-aware list_sessions_rich — compressed conversations
      +    surface as their live continuation tip, not the dead parent root.
      +    """
      +
      +    def _build_compression_chain(self, db, t0: float):
      +        """Helper: builds root -> delegate -> compression-child -> tip chain.
      +
      +        Returns (root_id, delegate_id, mid_id, tip_id).
      +        """
      +        import time as _time
      +        # Root that gets compressed
      +        db.create_session("root1", "cli")
      +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "root1"))
      +        db.append_message("root1", "user", "help me refactor auth")
      +
      +        # Delegate subagent spawned while root1 was live (before it ended)
      +        db.create_session("delegate1", "cli", parent_session_id="root1")
      +        db._conn.execute(
      +            "UPDATE sessions SET started_at=?, ended_at=? WHERE id=?",
      +            (t0 + 600, t0 + 650, "delegate1"),
      +        )
      +        db.append_message("delegate1", "user", "delegate task")
      +
      +        # root1 compressed at t0+1800
      +        t_compress_root = t0 + 1800
      +        db._conn.execute(
      +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
      +            (t_compress_root, "compression", "root1"),
      +        )
      +
      +        # Continuation mid created 1s after parent ended
      +        db.create_session("mid1", "cli", parent_session_id="root1")
      +        db._conn.execute(
      +            "UPDATE sessions SET started_at=? WHERE id=?",
      +            (t_compress_root + 1, "mid1"),
      +        )
      +        db.append_message("mid1", "user", "continuing")
      +
      +        # mid1 also compressed
      +        t_compress_mid = t_compress_root + 1800
      +        db._conn.execute(
      +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
      +            (t_compress_mid, "compression", "mid1"),
      +        )
      +
      +        # Tip — latest continuation
      +        db.create_session("tip1", "cli", parent_session_id="mid1")
      +        db._conn.execute(
      +            "UPDATE sessions SET started_at=? WHERE id=?",
      +            (t_compress_mid + 1, "tip1"),
      +        )
      +        db.append_message("tip1", "user", "latest message")
      +
      +        db._conn.commit()
      +        return ("root1", "delegate1", "mid1", "tip1")
      +
      +    def test_get_compression_tip_walks_full_chain(self, db):
      +        import time as _time
      +        self._build_compression_chain(db, _time.time() - 3600)
      +        assert db.get_compression_tip("root1") == "tip1"
      +        assert db.get_compression_tip("mid1") == "tip1"
      +        assert db.get_compression_tip("tip1") == "tip1"
      +
      +    def test_get_compression_tip_returns_self_for_uncompressed(self, db):
      +        db.create_session("solo", "cli")
      +        assert db.get_compression_tip("solo") == "solo"
      +
      +    def test_get_compression_tip_skips_delegate_children(self, db):
      +        """Delegate subagents have parent_session_id set but were created
      +        BEFORE the parent ended. They must not be followed as compression
      +        continuations — the started_at >= ended_at guard handles this.
      +        """
      +        import time as _time
      +        self._build_compression_chain(db, _time.time() - 3600)
      +        # delegate1 is a child of root1 but NOT a compression continuation.
      +        # root1's tip must be tip1 (via mid1), not delegate1.
      +        assert db.get_compression_tip("root1") == "tip1"
      +
      +    def test_list_surfaces_tip_for_compressed_root(self, db):
      +        """The list must show the tip's id/message_count/preview in place of
      +        the root row, so users can see and resume the live conversation.
      +        """
      +        import time as _time
      +        self._build_compression_chain(db, _time.time() - 3600)
      +        # Add an uncompressed root for comparison.
      +        db.create_session("solo", "cli")
      +        db.append_message("solo", "user", "standalone")
      +        db._conn.commit()
      +
      +        sessions = db.list_sessions_rich(source="cli", limit=20)
      +        ids = [s["id"] for s in sessions]
      +        # Only top-level conversations appear: tip1 (projected from root1) + solo.
      +        # Delegate children, mid1, and the dead root1 must NOT be in the list.
      +        assert "tip1" in ids
      +        assert "solo" in ids
      +        assert "root1" not in ids
      +        assert "mid1" not in ids
      +        assert "delegate1" not in ids
      +
      +        tip_row = next(s for s in sessions if s["id"] == "tip1")
      +        # The row surfaces the tip's identity but preserves the root's start
      +        # timestamp for stable ordering and lineage tracking.
      +        assert tip_row["_lineage_root_id"] == "root1"
      +        assert tip_row["preview"].startswith("latest message")
      +        assert tip_row["ended_at"] is None  # tip is still live
      +        assert tip_row["end_reason"] is None
      +
      +    def test_list_without_projection_returns_raw_root(self, db):
      +        """project_compression_tips=False returns the raw parent-NULL root
      +        rows — useful for admin/debug UIs.
      +        """
      +        import time as _time
      +        self._build_compression_chain(db, _time.time() - 3600)
      +        sessions = db.list_sessions_rich(
      +            source="cli", limit=20, project_compression_tips=False
      +        )
      +        ids = [s["id"] for s in sessions]
      +        assert "root1" in ids
      +        assert "tip1" not in ids
      +
      +        root_row = next(s for s in sessions if s["id"] == "root1")
      +        assert root_row["end_reason"] == "compression"
      +        assert "_lineage_root_id" not in root_row
      +
      +    def test_list_preserves_sort_by_started_at(self, db):
      +        """Chronological ordering uses the ROOT's started_at (conversation
      +        start), not the tip's. This keeps lineage entries stable in the list
      +        even as new compressions push the tip forward in time.
      +        """
      +        import time as _time
      +        t0 = _time.time() - 3600
      +        self._build_compression_chain(db, t0)
      +
      +        # Create a newer standalone session that should sort above the lineage
      +        # if we used tip.started_at, but below if we correctly use root.started_at.
      +        t_between = t0 + 120  # between root1 and its compression
      +        db.create_session("newer", "cli")
      +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t_between, "newer"))
      +        db.append_message("newer", "user", "newer session started after root1")
      +        db._conn.commit()
      +
      +        sessions = db.list_sessions_rich(source="cli", limit=20)
      +        ids_in_order = [s["id"] for s in sessions]
      +        # 'newer' started AFTER root1 but BEFORE tip1's actual started_at.
      +        # Correct ordering (by root started_at): newer > tip1's lineage entry.
      +        assert ids_in_order.index("newer") < ids_in_order.index("tip1")
      +
      +    def test_list_handles_broken_chain_gracefully(self, db):
      +        """A compression root with no child (e.g. DB corruption or a partial
      +        end_session call that didn't finish creating the child) must not
      +        crash the list — it should fall back to surfacing the root as-is.
      +        """
      +        import time as _time
      +        t0 = _time.time() - 100
      +        db.create_session("orphan", "cli")
      +        db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (t0, "orphan"))
      +        db._conn.execute(
      +            "UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
      +            (t0 + 10, "compression", "orphan"),
      +        )
      +        db._conn.commit()
      +
      +        sessions = db.list_sessions_rich(source="cli", limit=10)
      +        ids = [s["id"] for s in sessions]
      +        assert "orphan" in ids
      +        row = next(s for s in sessions if s["id"] == "orphan")
      +        # No tip means no projection — row stays raw.
      +        assert "_lineage_root_id" not in row
      +        assert row["end_reason"] == "compression"
      +
      +
       # =========================================================================
       # Session source exclusion (--source flag for third-party isolation)
       # =========================================================================
  • #119888 hindsight moves to the plugin catalog (auto-migrated on first start / hermes update)

    merged to main

    files: .github/workflows/tests-os.yml, .github/workflows/tests.yml, COMPAT_MANIFEST.md, CONTRIBUTING.es.md, CONTRIBUTING.md, Dockerfile, compat_manifest.json, hermes_cli/config_defaults.py, hermes_cli/memory_setup.py, hermes_cli/subcommands/memory.py, hermes_cli/tips.py, hermes_cli/update_cmd.py, hermes_cli/web_server_memory.py, nix/checks.nix, nix/hermes-agent.nix, nix/moduleCommon.nix, nix/packages.nix, plugins/AGENTS.md, plugins/memory/__init__.py, plugins/memory/hindsight/README.md (+30 more)

1 PR closed without merging

From the close comments: done elsewhere 1

  • #23308 feat: improve long-session continuity across compactions · done elsewhere → 9d799e0, 4cbf862, dc75038, 22efc81, 49596b7, ad8c533, 924bc67, 192ef93, #119888, #12960
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.50: 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

Subsystem
agent
Merged PRs
#12960, #119888
Closed issues
0
Attempts
1 PR closed without merging
Fix link checked
23 Sep 2026

For your agent

The same facts, typed.