Nothing open on GitHub. Fix merged in #15395 on 24 Apr 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #15395. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #15395. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#15395 fix(memory): skip external-provider sync on interrupted turns (#15218)
files:
run_agent.py,tests/run_agent/test_memory_sync_interrupted.pyShow the diff
-
run_agent.py@@ -4162,6 +4162,49 @@ def commit_memory_session(self, messages: list = None) -> None: except Exception: pass + def _sync_external_memory_for_turn( + self, + *, + original_user_message: Any, + final_response: Any, + interrupted: bool, + ) -> None: + """Mirror a completed turn into external memory providers. + + Called at the end of ``run_conversation`` with the cleaned user + message (``original_user_message``) and the finalised assistant + response. The external memory backend gets both ``sync_all`` (to + persist the exchange) and ``queue_prefetch_all`` (to start + warming context for the next turn) in one shot. + + Uses ``original_user_message`` rather than ``user_message`` + because the latter may carry injected skill content that bloats + or breaks provider queries. + + Interrupted turns are skipped entirely (#15218). A partial + assistant output, an aborted tool chain, or a mid-stream reset + is not durable conversational truth — mirroring it into an + external memory backend pollutes future recall with state the + user never saw completed. The prefetch is gated on the same + flag: the user's next message is almost certainly a retry of + the same intent, and a prefetch keyed on the interrupted turn + would fire against stale context. + + Normal completed turns still sync as before. The whole body is + wrapped in ``try/except Exception`` because external memory + providers are strictly best-effort — a misconfigured or offline + backend must not block the user from seeing their response. + """ + if interrupted: + return + if not (self._memory_manager and final_response and original_user_message): + return + try: + self._memory_manager.sync_all(original_user_message, final_response) + self._memory_manager.queue_prefetch_all(original_user_message) + except Exception: + pass + def release_clients(self) -> None: """Release LLM client resources WITHOUT tearing down session tool state. @@ -12524,14 +12567,11 @@ def _stop_spinner(): self._iters_since_skill = 0 # External memory provider: sync the completed turn + queue next prefetch. - # Use original_user_message (clean input) — user_message may contain - # injected skill content that bloats / breaks provider queries. - if self._memory_manager and final_response and original_user_message: - try: - self._memory_manager.sync_all(original_user_message, final_response) - self._memory_manager.queue_prefetch_all(original_user_message) - except Exception: - pass + self._sync_external_memory_for_turn( + original_user_message=original_user_message, + final_response=final_response, + interrupted=interrupted, + ) # Background memory/skill review — runs AFTER the response is delivered # so it never competes with the user's task for model attention. -
tests/run_agent/test_memory_sync_interrupted.py@@ -0,0 +1,189 @@ +"""Regression guard for #15218 — external memory sync must skip interrupted turns. + +Before this fix, ``run_conversation`` called +``memory_manager.sync_all(original_user_message, final_response)`` at the +end of every turn where both args were present. That gate didn't check +the ``interrupted`` flag, so an external memory backend received partial +assistant output, aborted tool chains, or mid-stream resets as durable +conversational truth. Downstream recall then treated that not-yet-real +state as if the user had seen it complete. + +The fix is ``AIAgent._sync_external_memory_for_turn`` — a small helper +that replaces the inline block and returns early when ``interrupted`` +is True (regardless of whether ``final_response`` and +``original_user_message`` happen to be populated). + +These tests exercise the helper directly on a bare ``AIAgent`` built +via ``__new__`` so the full ``run_conversation`` machinery isn't needed +— the method is pure logic and three state arguments. +""" +from unittest.mock import MagicMock + +import pytest + + +def _bare_agent(): + """Build an ``AIAgent`` with only the attributes + ``_sync_external_memory_for_turn`` touches — matches the bare-agent + pattern used across ``tests/run_agent/test_interrupt_propagation.py``. + """ + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + agent._memory_manager = MagicMock() + return agent + + +class TestSyncExternalMemoryForTurn: + # --- Interrupt guard (the #15218 fix) ------------------------------- + + def test_interrupted_turn_does_not_sync(self): + """The whole point of #15218: even with a final_response and a + user message, an interrupted turn must NOT reach the memory + backend.""" + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message="What time is it?", + final_response="It is 3pm.", # looks complete — but partial + interrupted=True, + ) + agent._memory_manager.sync_all.assert_not_called() + agent._memory_manager.queue_prefetch_all.assert_not_called() + + def test_interrupted_turn_skips_even_when_response_is_full(self): + """A long, seemingly-complete assistant response is still + partial if ``interrupted`` is True — an interrupt may have + landed between the streamed reply and the next tool call. The + memory backend has no way to distinguish on its own, so we must + gate at the source.""" + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message="Plan a trip to Lisbon", + final_response="Here's a detailed 7-day itinerary: [...]", + interrupted=True, + ) + agent._memory_manager.sync_all.assert_not_called() + + # --- Normal completed turn still syncs ------------------------------ + + def test_completed_turn_syncs_and_queues_prefetch(self): + """Regression guard for the positive path: a normal completed + turn must still trigger both ``sync_all`` AND + ``queue_prefetch_all`` — otherwise the external memory backend + never learns about anything and every user complains. + """ + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message="What's the weather in Paris?", + final_response="It's sunny and 22°C.", + interrupted=False, + ) + agent._memory_manager.sync_all.assert_called_once_with( + "What's the weather in Paris?", "It's sunny and 22°C.", + ) + agent._memory_manager.queue_prefetch_all.assert_called_once_with( + "What's the weather in Paris?", + ) + + # --- Edge cases (pre-existing behaviour preserved) ------------------ + + def test_no_final_response_skips(self): + """If the model produced no final_response (e.g. tool-only turn + that never resolved), we must not fabricate an empty sync.""" + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message="Hello", + final_response=None, + interrupted=False, + ) + agent._memory_manager.sync_all.assert_not_called() + + def test_no_original_user_message_skips(self): + """No user-origin message means this wasn't a user turn (e.g. + a system-initiated refresh). Don't sync an assistant-only + exchange as if a user said something.""" + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message=None, + final_response="Proactive notification text", + interrupted=False, + ) + agent._memory_manager.sync_all.assert_not_called() + + def test_no_memory_manager_is_a_no_op(self): + """Sessions without an external memory manager must not crash + or try to call .sync_all on None.""" + from run_agent import AIAgent + + agent = AIAgent.__new__(AIAgent) + agent._memory_manager = None + + # Must not raise. + agent._sync_external_memory_for_turn( + original_user_message="hi", + final_response="hey", + interrupted=False, + ) + + # --- Exception safety ---------------------------------------------- + + def test_sync_exception_is_swallowed(self): + """External memory providers are best-effort; a misconfigured + or offline backend must not block the user from seeing their + response by propagating the exception up.""" + agent = _bare_agent() + agent._memory_manager.sync_all.side_effect = RuntimeError( + "backend unreachable" + ) + + # Must not raise. + agent._sync_external_memory_for_turn( + original_user_message="hi", + final_response="hey", + interrupted=False, + ) + # sync_all was attempted. + agent._memory_manager.sync_all.assert_called_once() + + def test_prefetch_exception_is_swallowed(self): + """Same best-effort contract applies to the prefetch step — a + failure in queue_prefetch_all must not bubble out.""" + agent = _bare_agent() + agent._memory_manager.queue_prefetch_all.side_effect = RuntimeError( + "prefetch worker dead" + ) + + # Must not raise. + agent._sync_external_memory_for_turn( + original_user_message="hi", + final_response="hey", + interrupted=False, + ) + # sync_all still happened before the prefetch blew up. + agent._memory_manager.sync_all.assert_called_once() + + # --- The specific matrix the reporter asked about ------------------ + + @pytest.mark.parametrize("interrupted,final,user,expect_sync", [ + (False, "resp", "user", True), # normal completed → sync + (True, "resp", "user", False), # interrupted → skip (the fix) + (False, None, "user", False), # no response → skip + (False, "resp", None, False), # no user msg → skip + (True, None, "user", False), # interrupted + no response → skip + (True, "resp", None, False), # interrupted + no user → skip + (False, None, None, False), # nothing → skip + (True, None, None, False), # interrupted + nothing → skip + ]) + def test_sync_matrix(self, interrupted, final, user, expect_sync): + agent = _bare_agent() + agent._sync_external_memory_for_turn( + original_user_message=user, + final_response=final, + interrupted=interrupted, + ) + if expect_sync: + agent._memory_manager.sync_all.assert_called_once() + agent._memory_manager.queue_prefetch_all.assert_called_once() + else: + agent._memory_manager.sync_all.assert_not_called() + agent._memory_manager.queue_prefetch_all.assert_not_called()
-
1 PR closed without merging
From the close comments: partial fix 1
-
#15249 fix(memory): skip external-provider sync on interrupted turns (#15218) · partial fix →
#15218
Closed issues · 1
- Closed#15218External memory sync should skip interrupted turns
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.67: 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
- Merged PRs
- #15395
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.