NousResearch/hermes-agent

Adding plugin hooks for session boundary events

Cluster 5592 · gateway. Data as of

Nothing open on GitHub. Fix merged in #6129 on 8 Apr 2026. Check it covers your case.

The short answer

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

These issues and pull requests concern adding new plugin hooks that fire at session end and session reset boundaries. The work spans both CLI and gateway contexts, with some variation in naming conventions (on_session_finalize vs session:end, on_session_reset vs session:reset).

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.

  • #6129 feat(plugins): add on_session_finalize and on_session_reset hooks (CLI + gateway)

    merged to main

    files: cli.py, gateway/run.py, hermes_cli/plugins.py, tests/cli/test_session_boundary_hooks.py, tests/gateway/test_session_boundary_hooks.py

    Show the diff
    • cli.py
      @@ -612,6 +612,11 @@ def _run_cleanup():
               pass
           # Shut down memory provider (on_session_end + shutdown_all) at actual
           # session boundary — NOT per-turn inside run_conversation().
      +    try:
      +        from hermes_cli.plugins import invoke_hook as _invoke_hook
      +        _invoke_hook("on_session_finalize", session_id=_active_agent_ref.session_id if _active_agent_ref else None, platform="cli")
      +    except Exception:
      +        pass
           try:
               if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
                   _active_agent_ref.shutdown_memory_provider(
      @@ -3314,6 +3319,22 @@ def flush_tool_summary():
               flush_tool_summary()
               print()
           
      +    def _notify_session_boundary(self, event_type: str) -> None:
      +        """Fire a session-boundary plugin hook (on_session_finalize or on_session_reset).
      +
      +        Non-blocking — errors are caught and logged.  Safe to call from any
      +        lifecycle point (shutdown, /new, /reset).
      +        """
      +        try:
      +            from hermes_cli.plugins import invoke_hook as _invoke_hook
      +            _invoke_hook(
      +                event_type,
      +                session_id=self.agent.session_id if self.agent else None,
      +                platform=getattr(self, "platform", None) or "cli",
      +            )
      +        except Exception:
      +            pass
      +
           def new_session(self, silent=False):
               """Start a fresh session with a new session ID and cleared agent state."""
               if self.agent and self.conversation_history:
      @@ -3321,6 +3342,10 @@ def new_session(self, silent=False):
                       self.agent.flush_memories(self.conversation_history)
                   except (Exception, KeyboardInterrupt):
                       pass
      +            self._notify_session_boundary("on_session_finalize")
      +        elif self.agent:
      +            # First session or empty history — still finalize the old session
      +            self._notify_session_boundary("on_session_finalize")
       
               old_session_id = self.session_id
               if self._session_db and old_session_id:
      @@ -3365,6 +3390,7 @@ def new_session(self, silent=False):
                           )
                       except Exception:
                           pass
      +            self._notify_session_boundary("on_session_reset")
       
               if not silent:
                   print("(^_^)v New session started!")
    • gateway/run.py
      @@ -1481,6 +1481,14 @@ async def stop(self) -> None:
                       logger.debug("Interrupted running agent for session %s during shutdown", session_key[:20])
                   except Exception as e:
                       logger.debug("Failed interrupting agent during shutdown: %s", e)
      +            # Fire plugin on_session_finalize hook before memory shutdown
      +            try:
      +                from hermes_cli.plugins import invoke_hook as _invoke_hook
      +                _invoke_hook("on_session_finalize",
      +                             session_id=getattr(agent, 'session_id', None),
      +                             platform="gateway")
      +            except Exception:
      +                pass
                   # Shut down memory provider at actual session boundary
                   try:
                       if hasattr(agent, 'shutdown_memory_provider'):
      @@ -3274,6 +3282,15 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
               # the configured default instead of the previously switched model.
               self._session_model_overrides.pop(session_key, None)
       
      +        # Fire plugin on_session_finalize hook (session boundary)
      +        try:
      +            from hermes_cli.plugins import invoke_hook as _invoke_hook
      +            _old_sid = old_entry.session_id if old_entry else None
      +            _invoke_hook("on_session_finalize", session_id=_old_sid,
      +                         platform=source.platform.value if source.platform else "")
      +        except Exception:
      +            pass
      +
               # Emit session:end hook (session is ending)
               await self.hooks.emit("session:end", {
                   "platform": source.platform.value if source.platform else "",
      @@ -3287,7 +3304,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
                   "user_id": source.user_id,
                   "session_key": session_key,
               })
      -        
      +
               # Resolve session config info to surface to the user
               try:
                   session_info = self._format_session_info()
      @@ -3298,9 +3315,18 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
                   header = "✨ Session reset! Starting fresh."
               else:
                   # No existing session, just create one
      -            self.session_store.get_or_create_session(source, force_new=True)
      +            new_entry = self.session_store.get_or_create_session(source, force_new=True)
                   header = "✨ New session started!"
       
      +        # Fire plugin on_session_reset hook (new session guaranteed to exist)
      +        try:
      +            from hermes_cli.plugins import invoke_hook as _invoke_hook
      +            _new_sid = new_entry.session_id if new_entry else None
      +            _invoke_hook("on_session_reset", session_id=_new_sid,
      +                         platform=source.platform.value if source.platform else "")
      +        except Exception:
      +            pass
      +
               if session_info:
                   return f"{header}\n\n{session_info}"
               return header
    • hermes_cli/plugins.py
      @@ -61,6 +61,8 @@
           "post_api_request",
           "on_session_start",
           "on_session_end",
      +    "on_session_finalize",
      +    "on_session_reset",
       }
       
       ENTRY_POINTS_GROUP = "hermes_agent.plugins"
    • tests/cli/test_session_boundary_hooks.py
      @@ -0,0 +1,66 @@
      +import pytest
      +from unittest.mock import MagicMock, patch
      +from hermes_cli.plugins import VALID_HOOKS, PluginManager
      +import os
      +import shutil
      +import tempfile
      +from cli import HermesCLI
      +
      +
      +def test_session_hooks_in_valid_hooks():
      +    """Verify on_session_finalize and on_session_reset are registered as valid hooks."""
      +    assert "on_session_finalize" in VALID_HOOKS
      +    assert "on_session_reset" in VALID_HOOKS
      +
      +
      +@patch("hermes_cli.plugins.invoke_hook")
      +def test_session_finalize_on_reset(mock_invoke_hook):
      +    """Verify on_session_finalize fires when /new or /reset is used."""
      +    cli = HermesCLI()
      +    cli.agent = MagicMock()
      +    cli.agent.session_id = "test-session-id"
      +
      +    # Simulate /new command which triggers on_session_finalize for the old session
      +    cli.new_session(silent=True)
      +
      +    # Check if on_session_finalize was called for the old session
      +    mock_invoke_hook.assert_any_call(
      +        "on_session_finalize", session_id="test-session-id", platform="cli"
      +    )
      +    # Check if on_session_reset was called for the new session
      +    mock_invoke_hook.assert_any_call(
      +        "on_session_reset", session_id=cli.session_id, platform="cli"
      +    )
      +
      +
      +@patch("hermes_cli.plugins.invoke_hook")
      +def test_session_finalize_on_cleanup(mock_invoke_hook):
      +    """Verify on_session_finalize fires during CLI exit cleanup."""
      +    import cli as cli_mod
      +
      +    mock_agent = MagicMock()
      +    mock_agent.session_id = "cleanup-session-id"
      +    cli_mod._active_agent_ref = mock_agent
      +    cli_mod._cleanup_done = False
      +
      +    cli_mod._run_cleanup()
      +
      +    mock_invoke_hook.assert_any_call(
      +        "on_session_finalize", session_id="cleanup-session-id", platform="cli"
      +    )
      +
      +
      +@patch("hermes_cli.plugins.invoke_hook")
      +def test_hook_errors_are_caught(mock_invoke_hook):
      +    """Verify hook exceptions are caught and don't crash the agent."""
      +    mgr = PluginManager()
      +
      +    # Register a hook that raises
      +    def bad_callback(**kwargs):
      +        raise Exception("Hook failed")
      +
      +    mgr._hooks["on_session_finalize"] = [bad_callback]
      +
      +    # This should not raise
      +    results = mgr.invoke_hook("on_session_finalize", session_id="test", platform="cli")
      +    assert results == []
    • tests/gateway/test_session_boundary_hooks.py
      @@ -0,0 +1,158 @@
      +"""Tests that on_session_finalize and on_session_reset plugin hooks fire in the gateway."""
      +from datetime import datetime
      +from types import SimpleNamespace
      +from unittest.mock import AsyncMock, MagicMock, patch
      +
      +import pytest
      +
      +from gateway.config import GatewayConfig, Platform, PlatformConfig
      +from gateway.platforms.base import MessageEvent
      +from gateway.session import SessionEntry, SessionSource, build_session_key
      +
      +
      +def _make_source() -> SessionSource:
      +    return SessionSource(
      +        platform=Platform.TELEGRAM,
      +        user_id="u1",
      +        chat_id="c1",
      +        user_name="tester",
      +        chat_type="dm",
      +    )
      +
      +
      +def _make_event(text: str) -> MessageEvent:
      +    return MessageEvent(text=text, source=_make_source(), message_id="m1")
      +
      +
      +def _make_runner():
      +    from gateway.run import GatewayRunner
      +
      +    runner = object.__new__(GatewayRunner)
      +    runner.config = GatewayConfig(
      +        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
      +    )
      +    adapter = MagicMock()
      +    adapter.send = AsyncMock()
      +    runner.adapters = {Platform.TELEGRAM: adapter}
      +    runner._voice_mode = {}
      +    runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
      +    runner._session_model_overrides = {}
      +    runner._pending_model_notes = {}
      +    runner._background_tasks = set()
      +
      +    session_key = build_session_key(_make_source())
      +    session_entry = SessionEntry(
      +        session_key=session_key,
      +        session_id="sess-old",
      +        created_at=datetime.now(),
      +        updated_at=datetime.now(),
      +        platform=Platform.TELEGRAM,
      +        chat_type="dm",
      +    )
      +    new_session_entry = SessionEntry(
      +        session_key=session_key,
      +        session_id="sess-new",
      +        created_at=datetime.now(),
      +        updated_at=datetime.now(),
      +        platform=Platform.TELEGRAM,
      +        chat_type="dm",
      +    )
      +    runner.session_store = MagicMock()
      +    runner.session_store.get_or_create_session.return_value = new_session_entry
      +    runner.session_store.reset_session.return_value = new_session_entry
      +    runner.session_store._entries = {session_key: session_entry}
      +    runner.session_store._generate_session_key.return_value = session_key
      +    runner._running_agents = {}
      +    runner._pending_messages = {}
      +    runner._pending_approvals = {}
      +    runner._session_db = None
      +    runner._agent_cache_lock = None
      +    runner._is_user_authorized = lambda _source: True
      +    runner._format_session_info = lambda: ""
      +
      +    return runner
      +
      +
      [redacted:email]
      +@patch("hermes_cli.plugins.invoke_hook")
      +async def test_reset_fires_finalize_hook(mock_invoke_hook):
      +    """/new must fire on_session_finalize with the OLD session id."""
      +    runner = _make_runner()
      +
      +    await runner._handle_reset_command(_make_event("/new"))
      +
      +    mock_invoke_hook.assert_any_call(
      +        "on_session_finalize", session_id="sess-old", platform="telegram"
      +    )
      +
      +
      [redacted:email]
      +@patch("hermes_cli.plugins.invoke_hook")
      +async def test_reset_fires_reset_hook(mock_invoke_hook):
      +    """/new must fire on_session_reset with the NEW session id."""
      +    runner = _make_runner()
      +
      +    await runner._handle_reset_command(_make_event("/new"))
      +
      +    mock_invoke_hook.assert_any_call(
      +        "on_session_reset", session_id="sess-new", platform="telegram"
      +    )
      +
      +
      [redacted:email]
      +@patch("hermes_cli.plugins.invoke_hook")
      +async def test_finalize_before_reset(mock_invoke_hook):
      +    """on_session_finalize must fire before on_session_reset."""
      +    runner = _make_runner()
      +
      +    await runner._handle_reset_command(_make_event("/new"))
      +
      +    calls = [c for c in mock_invoke_hook.call_args_list
      +             if c[0][0] in ("on_session_finalize", "on_session_reset")]
      +    hook_names = [c[0][0] for c in calls]
      +    assert hook_names == ["on_session_finalize", "on_session_reset"]
      +
      +
      [redacted:email]
      +@patch("hermes_cli.plugins.invoke_hook")
      +async def test_shutdown_fires_finalize_for_active_agents(mock_invoke_hook):
      +    """Gateway stop() must fire on_session_finalize for each active agent."""
      +    from gateway.run import GatewayRunner
      +
      +    runner = object.__new__(GatewayRunner)
      +    runner._running = True
      +    runner._background_tasks = set()
      +    runner._pending_messages = {}
      +    runner._pending_approvals = {}
      +    runner._shutdown_event = MagicMock()
      +    runner.adapters = {}
      +    runner._exit_reason = "test"
      +
      +    agent1 = MagicMock()
      +    agent1.session_id = "sess-a"
      +    agent2 = MagicMock()
      +    agent2.session_id = "sess-b"
      +    runner._running_agents = {"key-a": agent1, "key-b": agent2}
      +
      +    with patch("gateway.status.remove_pid_file"), \
      +         patch("gateway.status.write_runtime_status"):
      +        await runner.stop()
      +
      +    finalize_calls = [
      +        c for c in mock_invoke_hook.call_args_list
      +        if c[0][0] == "on_session_finalize"
      +    ]
      +    session_ids = {c[1]["session_id"] for c in finalize_calls}
      +    assert session_ids == {"sess-a", "sess-b"}
      +
      +
      [redacted:email]
      +@patch("hermes_cli.plugins.invoke_hook", side_effect=Exception("boom"))
      +async def test_hook_error_does_not_break_reset(mock_invoke_hook):
      +    """Plugin hook errors must not prevent /new from completing."""
      +    runner = _make_runner()
      +
      +    result = await runner._handle_reset_command(_make_event("/new"))
      +
      +    # Should still return a success message despite hook errors
      +    assert "Session reset" in result or "New session" in result

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #5594 feat(plugins): add session:end and session:reset plugin hooks · done elsewhere → #6129
  • #5967 feat(cli): add on_session_finalize and on_session_reset plugin hooks · merged via another change → #6129
Closed issues · 1
  • Closed#5592Plugin hooks for session boundary events (session:end, session:reset)
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
gateway
Merged PRs
#6129
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
8 Apr 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.