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