# NousResearch/hermes-agent · Topic-bound skills lost after session reset

## Open now

- Nothing open on GitHub.

A bug where topic-bound or auto-loaded skills are not re-injected following a /new or /reset session reset, with multiple fix attempts targeting session and gateway components.

## TL;DR

- 6 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #18205. Verify it addresses your case.
- **Recommended:** read PR #18205 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-18205`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.29`: the share of member 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.

## Closed / duplicate issues

- #6508: Bug: Topic-bound skill not re-injected after /new session reset
- #18461: Bug: auto_skill not loaded for topic-bound skills after session reset or gateway restart

## Prior & competing PRs

- **#6520: fix(session): re-inject topic skills after /new reset**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#6521`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#18205: fix(gateway): re-inject topic-bound skill after /new or /reset**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `gateway/session.py`, `scripts/release.py`, `tests/gateway/test_fresh_reset_skill_injection.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -5282,7 +5282,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g
             _is_new_session = (
                 session_entry.created_at == session_entry.updated_at
                 or getattr(session_entry, "was_auto_reset", False)
    +            or getattr(session_entry, "is_fresh_reset", False)
             )
    +        # Consume the is_fresh_reset flag immediately so it doesn't leak
    +        # onto subsequent messages in the same session (issue #6508).
    +        if getattr(session_entry, "is_fresh_reset", False):
    +            session_entry.is_fresh_reset = False
             if _is_new_session:
                 await self.hooks.emit("session:start", {
                     "platform": source.platform.value if source.platform else "",
    ```
  - `gateway/session.py`

    ```diff
    @@ -458,6 +458,15 @@ class SessionEntry:
         was_auto_reset: bool = False
         auto_reset_reason: Optional[str] = None  # "idle" or "daily"
         reset_had_activity: bool = False  # whether the expired session had any messages
    +
    +    # Set by reset_session() when the user explicitly sends /new or /reset.
    +    # Consumed once by _handle_message_with_agent to trigger topic/channel
    +    # skill re-injection on the first message of the new session.  We can't
    +    # reuse was_auto_reset for this because that flag fires the "session
    +    # expired due to inactivity" user-facing notice and a misleading
    +    # context-note prepend — both wrong for an explicit manual reset.
    +    # See issue #6508.
    +    is_fresh_reset: bool = False
         
         # Set by the background expiry watcher after it finalizes an expired
         # session (invoking on_session_finalize hooks and evicting the cached
    ```

    ```diff
    @@ -508,6 +517,7 @@ def to_dict(self) -> Dict[str, Any]:
                     if self.last_resume_marked_at
                     else None
                 ),
    +            "is_fresh_reset": self.is_fresh_reset,
             }
             if self.origin:
                 result["origin"] = self.origin.to_dict()
    ```

    ```diff
    @@ -556,6 +566,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
                 resume_pending=data.get("resume_pending", False),
                 resume_reason=data.get("resume_reason"),
                 last_resume_marked_at=last_resume_marked_at,
    +            is_fresh_reset=data.get("is_fresh_reset", False),
             )
     
     
    ```

    ```diff
    @@ -1132,6 +1143,7 @@ def reset_session(self, session_key: str) -> Optional[SessionEntry]:
                     display_name=old_entry.display_name,
                     platform=old_entry.platform,
                     chat_type=old_entry.chat_type,
    +                is_fresh_reset=True,
                 )
     
                 self._entries[session_key] = new_entry
    ```
  - `scripts/release.py`

    ```diff
    @@ -426,6 +426,8 @@
         "[redacted:email]": "ogzerber",
         "[redacted:email]": "cola-runner",
         "[redacted:email]": "ygd58",
    +    "[redacted:email]": "warabe1122",
    +    "[redacted:email]": "willy-scr",
         "[redacted:email]": "vominh1919",
         "[redacted:email]": "iamagenius00",
         "[redacted:email]": "cresslank",
    ```
  - `tests/gateway/test_fresh_reset_skill_injection.py`

    ```diff
    @@ -0,0 +1,201 @@
    +"""Regression tests for topic/channel skill auto-injection after /new or /reset.
    +
    +Covers the fix for issue #6508.
    +
    +Before the fix:
    +    1. User sends ``/new`` — ``reset_session`` creates a fresh SessionEntry
    +       with ``created_at == updated_at``.
    +    2. User sends the next message.
    +    3. ``get_or_create_session`` finds the entry and bumps
    +       ``entry.updated_at = now`` (microseconds after ``created_at``).
    +    4. ``_handle_message_with_agent`` checks
    +       ``_is_new_session = (created_at == updated_at) or was_auto_reset``.
    +       Both are False → ``_is_new_session = False`` → topic/channel skills
    +       are silently skipped for the first message of a manually reset session.
    +
    +After the fix:
    +    ``reset_session`` stamps the new entry with ``is_fresh_reset=True``.
    +    ``_handle_message_with_agent`` ORs this into ``_is_new_session`` and
    +    consumes the flag immediately after the check, so subsequent messages
    +    are treated as continuing the session and the flag does not leak.
    +
    +We use ``was_auto_reset`` for surprise resets (idle/daily/suspended) and
    +``is_fresh_reset`` for user-initiated resets because the former also drives
    +a "Session automatically reset due to inactivity" user-facing notice and
    +a context-note prepend into the agent's prompt — both wrong for an explicit
    +/new or /reset.
    +"""
    +import pytest
    +
    +from gateway.config import GatewayConfig, Platform
    +from gateway.session import SessionEntry, SessionSource, SessionStore
    +
    +
    +def _make_store(tmp_path):
    +    return SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
    +
    +
    +def _make_source(chat_id="123", user_id="u1"):
    +    return SessionSource(
    +        platform=Platform.TELEGRAM,
    +        chat_id=chat_id,
    +        user_id=user_id,
    +    )
    +
    +
    +def _is_new_session(entry) -> bool:
    +    """Mirror of the predicate in ``_handle_message_with_agent``.
    +
    +    Kept in-sync with the production check so this test fails loudly if the
    +    upstream logic regresses.
    +    """
    +    return (
    +        entry.created_at == entry.updated_at
    +        or getattr(entry, "was_auto_reset", False)
    +        or getattr(entry, "is_fresh_reset", False)
    +    )
    +
    +
    +# ---------------------------------------------------------------------------
    +# reset_session stamps is_fresh_reset=True
    +# ---------------------------------------------------------------------------
    +
    +class TestResetSessionStampsFreshReset:
    +    def test_reset_session_sets_is_fresh_reset_true(self, tmp_path):
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        store.get_or_create_session(source)
    +        session_key = store._generate_session_key(source)
    +
    +        new_entry = store.reset_session(session_key)
    +
    +        assert new_entry is not None
    +        assert new_entry.is_fresh_reset is True
    +
    +    def test_reset_session_unknown_key_returns_none(self, tmp_path):
    +        store = _make_store(tmp_path)
    +        assert store.reset_session("unknown:key") is None
    +
    +    def test_fresh_session_does_not_have_is_fresh_reset(self, tmp_path):
    +        """A vanilla first-time session should not carry the flag."""
    +        store = _make_store(tmp_path)
    +        entry = store.get_or_create_session(_make_source())
    +        assert entry.is_fresh_reset is False
    +
    +
    +# ---------------------------------------------------------------------------
    +# Core regression: _is_new_session stays True after updated_at bump
    +# ---------------------------------------------------------------------------
    +
    +class TestIsNewSessionSurvivesUpdatedAtBump:
    +    def test_is_new_session_true_after_reset_then_next_message(self, tmp_path):
    +        """The actual bug: _is_new_session was False on message after /reset."""
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        store.get_or_create_session(source)
    +        session_key = store._generate_session_key(source)
    +
    +        # User sends /reset
    +        store.reset_session(session_key)
    +
    +        # Next inbound message — get_or_create_session bumps updated_at
    +        entry = store.get_or_create_session(source)
    +
    +        # Before the fix: created_at != updated_at, was_auto_reset=False → False
    +        # After the fix: is_fresh_reset=True carries the signal through the bump
    +        assert _is_new_session(entry) is True
    +
    +    def test_flag_consumed_after_first_read(self, tmp_path):
    +        """After the message handler consumes is_fresh_reset, the NEXT
    +        message should not be treated as a new session (skill re-injection
    +        must not fire a second time).
    +        """
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        store.get_or_create_session(source)
    +        session_key = store._generate_session_key(source)
    +        store.reset_session(session_key)
    +
    +        # First message — handler consumes the flag
    +        entry = store.get_or_create_session(source)
    +        assert _is_new_session(entry) is True
    +        entry.is_fresh_reset = False  # what _handle_message_with_agent does
    +
    +        # Second message — must not be treated as new
    +        entry = store.get_or_create_session(source)
    +        assert _is_new_session(entry) is False
    +
    +
    +# ---------------------------------------------------------------------------
    +# Vanilla-session behavior is unchanged
    +# ---------------------------------------------------------------------------
    +
    +class TestVanillaBehaviorUnaffected:
    +    def test_ongoing_session_not_flagged_as_new(self, tmp_path):
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        store.get_or_create_session(source)
    +
    +        # Second message on the same session — updated_at bumps,
    +        # is_fresh_reset was never set
    +        entry = store.get_or_create_session(source)
    +        assert entry.is_fresh_reset is False
    +        assert _is_new_session(entry) is False
    +
    +    def test_idle_auto_reset_does_not_set_is_fresh_reset(self, tmp_path):
    +        """Idle/daily auto-resets use was_auto_reset — confirm they do NOT
    +        also set is_fresh_reset (which would double-fire the skill path and
    +        not leak through the auto-reset guard).
    +        """
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        entry = store.get_or_create_session(source)
    +
    +        # Simulate the auto-reset code path: get_or_create_session's internal
    +        # branch that sets was_auto_reset does NOT touch is_fresh_reset.
    +        # Construct a fresh entry the same way that branch does.
    +        store._entries.pop(store._generate_session_key(source))
    +        fresh = SessionEntry(
    +            session_key=entry.session_key,
    +            session_id="new_id",
    +            created_at=entry.created_at,
    +            updated_at=entry.created_at,
    +            origin=source,
    +            was_auto_reset=True,
    +            auto_reset_reason="idle",
    +        )
    +        assert fresh.is_fresh_reset is False
    +        assert fresh.was_auto_reset is True
    +
    +
    +# ---------------------------------------------------------------------------
    +# Persistence through sessions.json round-trip
    +# ---------------------------------------------------------------------------
    +
    +class TestPersistence:
    +    def test_is_fresh_reset_survives_to_dict_from_dict(self, tmp_path):
    +        """Protect against the gateway restarting between /reset and the
    +        next message — the flag must be persisted in sessions.json.
    +        """
    +        store = _make_store(tmp_path)
    +        source = _make_source()
    +        store.get_or_create_session(source)
    +        session_key = store._generate_session_key(source)
    +        new_entry = store.reset_session(session_key)
    +
    +        assert new_entry.is_fresh_reset is True
    +        restored = SessionEntry.from_dict(new_entry.to_dict())
    +        assert restored.is_fresh_reset is True
    +
    +    def test_default_false_when_missing_from_dict(self, tmp_path):
    +        """Older sessions.json files written before this field existed must
    +        load cleanly with is_fresh_reset defaulting to False.
    +        """
    +        data = {
    +            "session_key": "telegram:1:123",
    +            "session_id": "sess1",
    +            "created_at": "2026-01-01T00:00:00",
    +            "updated_at": "2026-01-01T00:00:00",
    +        }
    +        entry = SessionEntry.from_dict(data)
    +        assert entry.is_fresh_reset is False
    ```
- **#6521: fix(gateway): re-inject topic-bound skill after /new session reset**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#10856`
- **#10856: fix(gateway): re-inject topic-bound skill after /new session reset**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#18205`

## Fix status

- Merged to main in PR #18205 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-01`.
- Subsystem: `gateway`
