{"repo":"NousResearch/hermes-agent","public_id":"22394","display_label":"Memory session commit missing context engine notifications","summary":"These issues and PRs address a bug where committing a memory session fails to notify the context engine or context compressor at session end. Multiple fixes target the same missing callback across related components.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22764},"resolution":{"state":"resolved","by":"pr-22764"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T19:28:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":22394,"pr_number":22764,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":22394,"title":"commit_memory_session() missing context_compressor.on_session_end() call","url":null,"state":"closed"},{"kind":"pr","number":22431,"title":"fix(run_agent): notify context engine on commit_memory_session (#22394)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e90aa7f28","number":0},{"kind":"pr","value":"#22764","number":22764}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22433,"title":"fix(agent): notify context_compressor on commit_memory_session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22394","number":22394},{"kind":"pr","value":"#22764","number":22764}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22599,"title":"fix: commit context engine sessions on rotation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22764","number":22764},{"kind":"pr","value":"#22431","number":22431},{"kind":"pr","value":"#22433","number":22433}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":22738,"title":"fix(memory): call context_compressor.on_session_end in commit_memory_session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#22764","number":22764},{"kind":"pr","value":"#22394","number":22394}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":22764,"title":"fix(agent): notify context engine on commit_memory_session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_commit_memory_session_context_engine.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -5067,12 +5067,25 @@ def commit_memory_session(self, messages: list = None) -> None:\n         Called when session_id rotates (e.g. /new, context compression);\n         providers keep their state and continue running under the old\n         session_id — they just flush pending extraction now.\"\"\"\n-        if not self._memory_manager:\n-            return\n-        try:\n-            self._memory_manager.on_session_end(messages or [])\n-        except Exception:\n-            pass\n+        if self._memory_manager:\n+            try:\n+                self._memory_manager.on_session_end(messages or [])\n+            except Exception:\n+                pass\n+        # Notify context engine of session end too — same lifecycle moment as\n+        # the memory manager's on_session_end. Without this, engines that\n+        # accumulate per-session state (DAGs, summaries) leak that state from\n+        # the rotated-out session into whatever comes next under the same\n+        # compressor instance. Mirrors the call in shutdown_memory_provider().\n+        # See issue #22394.\n+        if hasattr(self, \"context_compressor\") and self.context_compressor:\n+            try:\n+                self.context_compressor.on_session_end(\n+                    self.session_id or \"\",\n+                    messages or [],\n+                )\n+            except Exception:\n+                pass\n \n     def _sync_external_memory_for_turn(\n         self,"]},{"file":"tests/run_agent/test_commit_memory_session_context_engine.py","hunks":["@@ -0,0 +1,102 @@\n+\"\"\"Regression tests for AIAgent.commit_memory_session.\n+\n+Issue #22394: commit_memory_session was calling MemoryManager.on_session_end\n+but never ContextEngine.on_session_end. Context engines that accumulate\n+per-session state (LCM-style DAGs, summary stores) leaked that state from a\n+rotated-out session into whatever continued under the same compressor\n+instance.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+\n+\n+def _make_minimal_agent(memory_manager, context_compressor, session_id=\"abc\"):\n+    \"\"\"Build an object with just enough surface for commit_memory_session to run.\n+\n+    AIAgent.__init__ is too heavy for a focused unit test — bind the method\n+    to a SimpleNamespace-style object that has the attributes the method\n+    actually touches.\n+    \"\"\"\n+    from run_agent import AIAgent\n+\n+    obj = SimpleNamespace(\n+        _memory_manager=memory_manager,\n+        context_compressor=context_compressor,\n+        session_id=session_id,\n+    )\n+    obj.commit_memory_session = AIAgent.commit_memory_session.__get__(obj)\n+    return obj\n+\n+\n+def test_commit_memory_session_notifies_context_engine():\n+    \"\"\"Both the memory manager AND the context engine receive on_session_end.\"\"\"\n+    mm = MagicMock()\n+    ctx = MagicMock()\n+    agent = _make_minimal_agent(mm, ctx, session_id=\"sess-42\")\n+\n+    msgs = [{\"role\": \"user\", \"content\": \"hi\"}, {\"role\": \"assistant\", \"content\": \"yo\"}]\n+    agent.commit_memory_session(msgs)\n+\n+    mm.on_session_end.assert_called_once_with(msgs)\n+    ctx.on_session_end.assert_called_once_with(\"sess-42\", msgs)\n+\n+\n+def test_commit_memory_session_with_no_messages_passes_empty_list():\n+    \"\"\"Empty/None messages must still fire both hooks with an empty list.\"\"\"\n+    mm = MagicMock()\n+    ctx = MagicMock()\n+    agent = _make_minimal_agent(mm, ctx, session_id=\"sess-7\")\n+\n+    agent.commit_memory_session(None)\n+\n+    mm.on_session_end.assert_called_once_with([])\n+    ctx.on_session_end.assert_called_once_with(\"sess-7\", [])\n+\n+\n+def test_commit_memory_session_no_memory_manager_still_notifies_context_engine():\n+    \"\"\"If only the context engine is configured, it still gets the hook.\"\"\"\n+    ctx = MagicMock()\n+    agent = _make_minimal_agent(None, ctx, session_id=\"sess-9\")\n+\n+    agent.commit_memory_session([{\"role\": \"user\", \"content\": \"x\"}])\n+\n+    ctx.on_session_end.assert_called_once_with(\"sess-9\", [{\"role\": \"user\", \"content\": \"x\"}])\n+\n+\n+def test_commit_memory_session_no_context_engine_still_notifies_memory_manager():\n+    \"\"\"If only the memory manager is configured, it still gets the hook.\"\"\"\n+    mm = MagicMock()\n+    agent = _make_minimal_agent(mm, None, session_id=\"sess-3\")\n+\n+    agent.commit_memory_session([{\"role\": \"user\", \"content\": \"x\"}])\n+\n+    mm.on_session_end.assert_called_once_with([{\"role\": \"user\", \"content\": \"x\"}])\n+\n+\n+def test_commit_memory_session_tolerates_memory_manager_failure():\n+    \"\"\"A raising memory manager must not block the context engine notification.\"\"\"\n+    mm = MagicMock()\n+    mm.on_session_end.side_effect = RuntimeError(\"boom\")\n+    ctx = MagicMock()\n+    agent = _make_minimal_agent(mm, ctx, session_id=\"sess-X\")\n+\n+    # Must not raise\n+    agent.commit_memory_session([{\"role\": \"user\", \"content\": \"x\"}])\n+\n+    ctx.on_session_end.assert_called_once_with(\"sess-X\", [{\"role\": \"user\", \"content\": \"x\"}])\n+\n+\n+def test_commit_memory_session_tolerates_context_engine_failure():\n+    \"\"\"A raising context engine must not surface the exception.\"\"\"\n+    mm = MagicMock()\n+    ctx = MagicMock()\n+    ctx.on_session_end.side_effect = RuntimeError(\"boom\")\n+    agent = _make_minimal_agent(mm, ctx, session_id=\"sess-Y\")\n+\n+    # Must not raise\n+    agent.commit_memory_session([{\"role\": \"user\", \"content\": \"x\"}])\n+\n+    mm.on_session_end.assert_called_once()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}