fix(memory): skip external-provider sync on interrupted turns (#15218)#15249
Closed
briandevans wants to merge 1 commit into
Closed
fix(memory): skip external-provider sync on interrupted turns (#15218)#15249briandevans wants to merge 1 commit into
briandevans wants to merge 1 commit into
Conversation
…search#15218) ``run_conversation`` was calling ``memory_manager.sync_all( original_user_message, final_response)`` at the end of every turn where both args were present. That gate didn't consider the ``interrupted`` local 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 the not-yet-real state as if the user had seen it complete, poisoning the trust boundary between "what the user took away from the turn" and "what Hermes was in the middle of producing when the interrupt hit". Extracted the inline sync block into a new private method ``AIAgent._sync_external_memory_for_turn(original_user_message, final_response, interrupted)`` so the interrupt guard is a single visible check at the top of the method instead of hidden in a boolean-and at the call site. That also gives tests a clean seam to assert on — the pre-fix layout buried the logic inside the 3,000-line ``run_conversation`` function where no focused test could reach it. The new method encodes three independent skip conditions: 1. ``interrupted`` → skip entirely (the NousResearch#15218 fix). Applies even when ``final_response`` and ``original_user_message`` happen to be populated — an interrupt may have landed between a streamed reply and the next tool call, so the strings on disk are not actually the turn the user took away. 2. No memory manager / no final_response / no user message → preserve existing skip behaviour (nothing new for providerless sessions, system-initiated refreshes, tool-only turns that never resolved, etc.). 3. Sync_all / queue_prefetch_all exceptions → swallow. External memory providers are strictly best-effort; a misconfigured or offline backend must never block the user from seeing their response. The prefetch side-effect is gated on the same interrupt 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. ### Tests (16 new, all passing on py3.11 venv) ``tests/run_agent/test_memory_sync_interrupted.py`` exercises the helper directly on a bare ``AIAgent`` (``__new__`` pattern that the interrupt-propagation tests already use). Coverage: - Interrupted turn with full-looking response → no sync (the fix) - Interrupted turn with long assistant output → no sync (the interrupt could have landed mid-stream; strings-on-disk lie) - Normal completed turn → sync_all + queue_prefetch_all both called with the right args (regression guard for the positive path) - No final_response / no user_message / no memory manager → existing pre-fix skip paths still apply - sync_all raises → exception swallowed, prefetch still attempted - queue_prefetch_all raises → exception swallowed after sync succeeded - 8-case parametrised matrix across (interrupted × final_response × original_user_message) asserts sync fires iff interrupted=False AND both strings are non-empty Closes NousResearch#15218 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes #15218 by preventing external memory providers from persisting interrupted (partial/aborted) turns as durable conversation history.
Changes:
- Extracts the external-memory sync logic from
run_conversationintoAIAgent._sync_external_memory_for_turn(...). - Gates both
sync_all(...)andqueue_prefetch_all(...)oninterrupted=Falsewhile preserving existing skip conditions and best-effort exception swallowing. - Adds a focused regression test suite covering interrupted vs completed turns, skip paths, and exception-safety.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
run_agent.py |
Adds _sync_external_memory_for_turn and routes per-turn external memory sync through it, skipping interrupted turns. |
tests/run_agent/test_memory_sync_interrupted.py |
Adds regression tests ensuring interrupted turns do not sync/prefetch while completed turns still do, including exception-safety. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Contributor
|
Salvaged via #15395 — your commit was cherry-picked onto current main with authorship preserved in git log. Thanks @briandevans! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes `#15218`. `run_conversation` was calling `memory_manager.sync_all(original_user_message, final_response)` at the end of every turn where both args were present, without considering the `interrupted` local flag. Result: an external memory backend received partial assistant output, aborted tool chains, or mid-stream resets as durable conversational truth — downstream recall then treated the not-yet-real state as if the user had seen it complete, poisoning the trust boundary between "what the user took away from the turn" and "what Hermes was in the middle of producing when the interrupt hit".
Reporter's minimal-fix suggestion was to gate on `not was_interrupted`; I implemented that with a small refactor that also makes the logic testable in isolation.
Fix
Extracted the inline sync block in `run_conversation` into a new private method on `AIAgent`:
```python
def _sync_external_memory_for_turn(
self,
*,
original_user_message: Any,
final_response: Any,
interrupted: bool,
) -> None:
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
```
Why a helper instead of a one-line guard at the call site?
Skip conditions, encoded explicitly
Related Issue
Fixes #15218
Type of Change
Test plan
Test coverage detail
`TestSyncExternalMemoryForTurn` (16 tests):
Not in scope