fix(memory): flush Hindsight's buffered turns when a session ends - #89801
Open
AP3X-Dev wants to merge 1 commit into
Open
fix(memory): flush Hindsight's buffered turns when a session ends#89801AP3X-Dev wants to merge 1 commit into
AP3X-Dev wants to merge 1 commit into
Conversation
With retain_every_n_turns > 1 the Hindsight provider accumulates turns in RAM and only persists at every Nth-turn boundary. Anything buffered past that boundary lives nowhere else, so a session that ends without hitting it loses those turns outright. Hindsight is the only bundled memory provider that batches turns, and it was the only one not implementing on_session_end -- the base-class hook that exists for exactly this. Sessions ending on a path that does not rotate session_id (desktop session.close, the WS orphan reaper, gateway expiry) therefore never reached on_session_switch, the sole flush point. shutdown() had the same gap: it drained the writer queue but never enqueued the buffer, so the gateway and dashboard -- which die on SIGTERM without running atexit -- dropped it every restart. Extract the flush that on_session_switch already performed into _flush_buffered_turns() and call it from on_session_end() and shutdown(). The helper clears the buffer, which is what makes it safe to call from several paths: MemoryManager.commit_session_boundary_async delivers on_session_end strictly before on_session_switch, and without the clear the switch would re-ship the same turns and double-ingest them. In shutdown() the flush runs BEFORE _shutting_down is set -- the helper refuses to enqueue once that flag is up, so the reverse order would discard the buffer it is meant to save. The new call sites skip the flush when the last turn landed on a retain boundary, mirroring sync_turn's own buffering gate. That gate is the only signal valid in both modes: _last_retained_turn_count is advanced only on append-capable APIs, so on legacy it stays at 0 and cannot answer the question. on_session_switch keeps its previous whole-buffer behavior, so this refactor does not change that path -- tightening its watermark is open separately as NousResearch#41911. Fixes NousResearch#88944
Collaborator
|
I implemented locally a change that adds two keywords: "auto_retain" and "retain_at_session_end". The rationale is that I have configured the Hindsight plugin to only send session transcripts to Hindsight at the end of a session to not pollute the memory with intermediate decisions. While not identical to your PR, my Hermes session made me aware of this PR as related. Would it be useful to add those two config options to your PR? |
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?
Stops the Hindsight memory provider silently dropping buffered turns when a session ends.
The causal chain. With
retain_every_n_turns > 1,sync_turn()accumulates turns in_session_turnsand only dispatches a retain at every Nth-turn boundary. Anything buffered past that boundary exists nowhere but RAM. Two gaps meant it was never drained:on_session_end.MemoryProvider.on_session_end(agent/memory_provider.py:251) is the base-class hook for exactly this, and the other bundled providers implement it (holographic,honcho,openviking,supermemory). Hindsight — the only one that batches — did not. Its sole flush point wason_session_switch, which fires only whensession_idrotates. Sessions that end without rotating (desktopsession.close, the WS orphan reaper, gateway session expiry) never reached it.shutdown()never flushed. It set_shutting_down, then drained the writer queue — but the buffer was never enqueued, so only already-dispatched retains survived. The gateway and dashboard are killed by SIGTERM and do not runatexit, so every restart dropped the buffer.The fix extracts the flush
on_session_switchalready performed into_flush_buffered_turns()and calls it from a newon_session_end()and fromshutdown().Why the helper clears the buffer.
MemoryManager.commit_session_boundary_async(agent/memory_manager.py:927) deliberately deliverson_session_endstrictly beforeon_session_switch, and its docstring names "double-ingest of the old turn buffer" as the hazard it is guarding against. Clearing on flush is what makes the hook safe to call from several paths: the switch then finds an empty buffer and no-ops.Ordering in
shutdown()is load-bearing. The helper refuses to enqueue once_shutting_downis set (the writer is draining/gone), so the flush runs before that flag goes up. Reversed, it would silently discard the very buffer it exists to save.Why the new call sites use a watermark and the old one doesn't.
sync_turnskips a retain when the turn count hasn't reached a boundary, soon_session_end/shutdownmust not re-ship turns already persisted. The guard mirrorssync_turn's own gate (_turn_counter % retain_every_n_turns) rather than_last_retained_turn_count, because that watermark is advanced only on append-capable APIs (sync_turn, guarded byif update_mode == "append") — on legacy/overwrite it stays at0forever and cannot answer the question. On append the watermark is checked as well, since it is the more precise signal there.on_session_switchpassesrespect_watermark=Falseand so keeps its previous whole-buffer behavior byte-for-byte. Tightening that path is #41911, which is already open — this PR deliberately does not touch it.Related Issue
Fixes #88944
Scope note: the linked issue reports three defects. This PR fixes defect #3 (the provider-level flush) only, which is self-contained and testable. Defects #1 and #2 are process-lifecycle changes in
gateway/run.pyand the serve teardown, and they touch a file with several other open PRs — they belong in their own change.Type of Change
Changes Made
plugins/memory/hindsight/__init__.pyon_session_switchinto_flush_buffered_turns(reason, respect_watermark), which now also clears the buffer.on_session_end()implementing the base-class hook.shutdown()flushes before setting_shutting_down.tests/plugins/memory/test_hindsight_provider.py— addedTestSessionEndBufferFlush(4 tests).How to Test
4 passed on this branch. With
plugins/memory/hindsight/__init__.pyreverted tomain, two fail:test_on_session_end_flushes_buffered_turns— buffers 2 turns withretain_every_n_turns=3, callson_session_end, asserts both turns land under the originaldocument_id. Onmainno retain is dispatched at all.test_shutdown_flushes_buffered_turns— one buffered turn withretain_every_n_turns=5, thenshutdown(). Onmainthe turn dies with the process.test_session_end_then_switch_does_not_double_ingest— asserts exactly onearetain_batchacross the manager'send → switchordering.test_no_flush_when_last_turn_hit_a_retain_boundary— turn count landing exactly on the boundary must not flush again.The last two pass both with and without the fix by design — they are regression guards for the duplicate-ingest failure mode, not proof of the fix. I'm calling that out rather than implying all four bite.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — left unchecked deliberately. I have not run the whole suite green on this Windows box; it carries pre-existing failures unrelated to this change. What I did run:tests/plugins/memory/→ 343 passed, 7 failed, 7 skipped. The identical 7 failures occur on cleanmainwith my changes stashed (339 passed, 7 failed — the +4 is this PR's new tests). Same failure set, no regressions.tests/agent/test_memory_session_switch.py→ 4 passedDocumentation & Housekeeping
docs/, docstrings) — the new helper and hook carry the ordering and watermark rationale inline; no user-facing docs describe this pathcli-config.yaml.exampleif I added/changed config keys — N/A, no config keysCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/Acheck-windows-footguns.pyreports the same 9 pre-existing findings in these two files onmainas on this branch; this change adds none.Screenshots / Logs
Behavior on
main— the turns are buffered and then never persisted:With this change the same teardown emits: