{"repo":"NousResearch/hermes-agent","public_id":"15218","display_label":"Skip memory sync on interrupted turns","summary":"These items address a fix to prevent external memory provider synchronization from running when a turn has been interrupted. All three members refer to the same specific change.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15395,"merged_at":"2026-04-24T22:30:19+00:00"},"resolution":{"state":"resolved","by":"pr-15395"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T22:30:19+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":15218,"pr_number":15395,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T22:30:19+00:00"}],"members":[{"kind":"issue","number":15218,"title":"External memory sync should skip interrupted turns","url":null,"state":"closed"},{"kind":"pr","number":15249,"title":"fix(memory): skip external-provider sync on interrupted turns (#15218)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15218","number":15218}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":15395,"title":"fix(memory): skip external-provider sync on interrupted turns (#15218)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_memory_sync_interrupted.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -4162,6 +4162,49 @@ def commit_memory_session(self, messages: list = None) -> None:\n         except Exception:\n             pass\n \n+    def _sync_external_memory_for_turn(\n+        self,\n+        *,\n+        original_user_message: Any,\n+        final_response: Any,\n+        interrupted: bool,\n+    ) -> None:\n+        \"\"\"Mirror a completed turn into external memory providers.\n+\n+        Called at the end of ``run_conversation`` with the cleaned user\n+        message (``original_user_message``) and the finalised assistant\n+        response.  The external memory backend gets both ``sync_all`` (to\n+        persist the exchange) and ``queue_prefetch_all`` (to start\n+        warming context for the next turn) in one shot.\n+\n+        Uses ``original_user_message`` rather than ``user_message``\n+        because the latter may carry injected skill content that bloats\n+        or breaks provider queries.\n+\n+        Interrupted turns are skipped entirely (#15218).  A partial\n+        assistant output, an aborted tool chain, or a mid-stream reset\n+        is not durable conversational truth — mirroring it into an\n+        external memory backend pollutes future recall with state the\n+        user never saw completed.  The prefetch is gated on the same\n+        flag: the user's next message is almost certainly a retry of\n+        the same intent, and a prefetch keyed on the interrupted turn\n+        would fire against stale context.\n+\n+        Normal completed turns still sync as before.  The whole body is\n+        wrapped in ``try/except Exception`` because external memory\n+        providers are strictly best-effort — a misconfigured or offline\n+        backend must not block the user from seeing their response.\n+        \"\"\"\n+        if interrupted:\n+            return\n+        if not (self._memory_manager and final_response and original_user_message):\n+            return\n+        try:\n+            self._memory_manager.sync_all(original_user_message, final_response)\n+            self._memory_manager.queue_prefetch_all(original_user_message)\n+        except Exception:\n+            pass\n+\n     def release_clients(self) -> None:\n         \"\"\"Release LLM client resources WITHOUT tearing down session tool state.\n ","@@ -12524,14 +12567,11 @@ def _stop_spinner():\n             self._iters_since_skill = 0\n \n         # External memory provider: sync the completed turn + queue next prefetch.\n-        # Use original_user_message (clean input) — user_message may contain\n-        # injected skill content that bloats / breaks provider queries.\n-        if self._memory_manager and final_response and original_user_message:\n-            try:\n-                self._memory_manager.sync_all(original_user_message, final_response)\n-                self._memory_manager.queue_prefetch_all(original_user_message)\n-            except Exception:\n-                pass\n+        self._sync_external_memory_for_turn(\n+            original_user_message=original_user_message,\n+            final_response=final_response,\n+            interrupted=interrupted,\n+        )\n \n         # Background memory/skill review — runs AFTER the response is delivered\n         # so it never competes with the user's task for model attention."]},{"file":"tests/run_agent/test_memory_sync_interrupted.py","hunks":["@@ -0,0 +1,189 @@\n+\"\"\"Regression guard for #15218 — external memory sync must skip interrupted turns.\n+\n+Before this fix, ``run_conversation`` called\n+``memory_manager.sync_all(original_user_message, final_response)`` at the\n+end of every turn where both args were present.  That gate didn't check\n+the ``interrupted`` flag, so an external memory backend received partial\n+assistant output, aborted tool chains, or mid-stream resets as durable\n+conversational truth.  Downstream recall then treated that not-yet-real\n+state as if the user had seen it complete.\n+\n+The fix is ``AIAgent._sync_external_memory_for_turn`` — a small helper\n+that replaces the inline block and returns early when ``interrupted``\n+is True (regardless of whether ``final_response`` and\n+``original_user_message`` happen to be populated).\n+\n+These tests exercise the helper directly on a bare ``AIAgent`` built\n+via ``__new__`` so the full ``run_conversation`` machinery isn't needed\n+— the method is pure logic and three state arguments.\n+\"\"\"\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+\n+def _bare_agent():\n+    \"\"\"Build an ``AIAgent`` with only the attributes\n+    ``_sync_external_memory_for_turn`` touches — matches the bare-agent\n+    pattern used across ``tests/run_agent/test_interrupt_propagation.py``.\n+    \"\"\"\n+    from run_agent import AIAgent\n+\n+    agent = AIAgent.__new__(AIAgent)\n+    agent._memory_manager = MagicMock()\n+    return agent\n+\n+\n+class TestSyncExternalMemoryForTurn:\n+    # --- Interrupt guard (the #15218 fix) -------------------------------\n+\n+    def test_interrupted_turn_does_not_sync(self):\n+        \"\"\"The whole point of #15218: even with a final_response and a\n+        user message, an interrupted turn must NOT reach the memory\n+        backend.\"\"\"\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"What time is it?\",\n+            final_response=\"It is 3pm.\",  # looks complete — but partial\n+            interrupted=True,\n+        )\n+        agent._memory_manager.sync_all.assert_not_called()\n+        agent._memory_manager.queue_prefetch_all.assert_not_called()\n+\n+    def test_interrupted_turn_skips_even_when_response_is_full(self):\n+        \"\"\"A long, seemingly-complete assistant response is still\n+        partial if ``interrupted`` is True — an interrupt may have\n+        landed between the streamed reply and the next tool call.  The\n+        memory backend has no way to distinguish on its own, so we must\n+        gate at the source.\"\"\"\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"Plan a trip to Lisbon\",\n+            final_response=\"Here's a detailed 7-day itinerary: [...]\",\n+            interrupted=True,\n+        )\n+        agent._memory_manager.sync_all.assert_not_called()\n+\n+    # --- Normal completed turn still syncs ------------------------------\n+\n+    def test_completed_turn_syncs_and_queues_prefetch(self):\n+        \"\"\"Regression guard for the positive path: a normal completed\n+        turn must still trigger both ``sync_all`` AND\n+        ``queue_prefetch_all`` — otherwise the external memory backend\n+        never learns about anything and every user complains.\n+        \"\"\"\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"What's the weather in Paris?\",\n+            final_response=\"It's sunny and 22°C.\",\n+            interrupted=False,\n+        )\n+        agent._memory_manager.sync_all.assert_called_once_with(\n+            \"What's the weather in Paris?\", \"It's sunny and 22°C.\",\n+        )\n+        agent._memory_manager.queue_prefetch_all.assert_called_once_with(\n+            \"What's the weather in Paris?\",\n+        )\n+\n+    # --- Edge cases (pre-existing behaviour preserved) ------------------\n+\n+    def test_no_final_response_skips(self):\n+        \"\"\"If the model produced no final_response (e.g. tool-only turn\n+        that never resolved), we must not fabricate an empty sync.\"\"\"\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"Hello\",\n+            final_response=None,\n+            interrupted=False,\n+        )\n+        agent._memory_manager.sync_all.assert_not_called()\n+\n+    def test_no_original_user_message_skips(self):\n+        \"\"\"No user-origin message means this wasn't a user turn (e.g.\n+        a system-initiated refresh).  Don't sync an assistant-only\n+        exchange as if a user said something.\"\"\"\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=None,\n+            final_response=\"Proactive notification text\",\n+            interrupted=False,\n+        )\n+        agent._memory_manager.sync_all.assert_not_called()\n+\n+    def test_no_memory_manager_is_a_no_op(self):\n+        \"\"\"Sessions without an external memory manager must not crash\n+        or try to call .sync_all on None.\"\"\"\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent.__new__(AIAgent)\n+        agent._memory_manager = None\n+\n+        # Must not raise.\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"hi\",\n+            final_response=\"hey\",\n+            interrupted=False,\n+        )\n+\n+    # --- Exception safety ----------------------------------------------\n+\n+    def test_sync_exception_is_swallowed(self):\n+        \"\"\"External memory providers are best-effort; a misconfigured\n+        or offline backend must not block the user from seeing their\n+        response by propagating the exception up.\"\"\"\n+        agent = _bare_agent()\n+        agent._memory_manager.sync_all.side_effect = RuntimeError(\n+            \"backend unreachable\"\n+        )\n+\n+        # Must not raise.\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"hi\",\n+            final_response=\"hey\",\n+            interrupted=False,\n+        )\n+        # sync_all was attempted.\n+        agent._memory_manager.sync_all.assert_called_once()\n+\n+    def test_prefetch_exception_is_swallowed(self):\n+        \"\"\"Same best-effort contract applies to the prefetch step — a\n+        failure in queue_prefetch_all must not bubble out.\"\"\"\n+        agent = _bare_agent()\n+        agent._memory_manager.queue_prefetch_all.side_effect = RuntimeError(\n+            \"prefetch worker dead\"\n+        )\n+\n+        # Must not raise.\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=\"hi\",\n+            final_response=\"hey\",\n+            interrupted=False,\n+        )\n+        # sync_all still happened before the prefetch blew up.\n+        agent._memory_manager.sync_all.assert_called_once()\n+\n+    # --- The specific matrix the reporter asked about ------------------\n+\n+    @pytest.mark.parametrize(\"interrupted,final,user,expect_sync\", [\n+        (False, \"resp\", \"user\",  True),   # normal completed → sync\n+        (True,  \"resp\", \"user\",  False),  # interrupted → skip (the fix)\n+        (False, None,   \"user\",  False),  # no response → skip\n+        (False, \"resp\", None,    False),  # no user msg → skip\n+        (True,  None,   \"user\",  False),  # interrupted + no response → skip\n+        (True,  \"resp\", None,    False),  # interrupted + no user → skip\n+        (False, None,   None,    False),  # nothing → skip\n+        (True,  None,   None,    False),  # interrupted + nothing → skip\n+    ])\n+    def test_sync_matrix(self, interrupted, final, user, expect_sync):\n+        agent = _bare_agent()\n+        agent._sync_external_memory_for_turn(\n+            original_user_message=user,\n+            final_response=final,\n+            interrupted=interrupted,\n+        )\n+        if expect_sync:\n+            agent._memory_manager.sync_all.assert_called_once()\n+            agent._memory_manager.queue_prefetch_all.assert_called_once()\n+        else:\n+            agent._memory_manager.sync_all.assert_not_called()\n+            agent._memory_manager.queue_prefetch_all.assert_not_called()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}