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
- 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
Open PRs0
Fixes on record
-
#6129 feat(plugins): add on_session_finalize and on_session_reset hooks (CLI + gateway)
files:
cli.py,gateway/run.py,hermes_cli/plugins.py,tests/cli/test_session_boundary_hooks.py,tests/gateway/test_session_boundary_hooks.pyShow 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
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.00means 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 LabAt 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.