diff --git a/cli.py b/cli.py index b4358a163c..01ea17ff29 100644 --- a/cli.py +++ b/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 @@ class HermesCLI: 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 @@ class HermesCLI: 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 @@ class HermesCLI: ) except Exception: pass + self._notify_session_boundary("on_session_reset") if not silent: print("(^_^)v New session started!") diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 23a655aa30..7323bbd011 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -61,6 +61,8 @@ VALID_HOOKS: Set[str] = { "post_api_request", "on_session_start", "on_session_end", + "on_session_finalize", + "on_session_reset", } ENTRY_POINTS_GROUP = "hermes_agent.plugins" diff --git a/tests/test_session_boundary_hooks.py b/tests/test_session_boundary_hooks.py new file mode 100644 index 0000000000..19de4cd97a --- /dev/null +++ b/tests/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 == []