From e5a1416a37b423de95b0fa279e9473110a678112 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 7 May 2026 16:17:27 +0800 Subject: [PATCH] fix(agent): persist _last_summary across restarts with used sentinel The previous implementation popped _last_summary from session.metadata after injecting it into the prompt, then saved the session. This caused the summary to be permanently lost after a process restart, making the AI forget archived context and appear to ignore memory or reference non-existent previous messages. Replace the destructive pop with a _last_summary_used sentinel: - _last_summary stays in metadata for restart survival - _last_summary_used prevents duplicate injection within the same turn - Clear the sentinel whenever a new summary is generated Updates tests to match the new persistence behavior. --- nanobot/agent/autocompact.py | 13 +++++++----- nanobot/agent/memory.py | 1 + tests/agent/test_auto_compact.py | 21 ++++++++++++------- tests/agent/test_loop_consolidation_tokens.py | 5 ++++- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/nanobot/agent/autocompact.py b/nanobot/agent/autocompact.py index eabd86155..fa4af0efb 100644 --- a/nanobot/agent/autocompact.py +++ b/nanobot/agent/autocompact.py @@ -89,6 +89,7 @@ class AutoCompact: if summary and summary != "(nothing)": self._summaries[key] = (summary, last_active) session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()} + session.metadata.pop("_last_summary_used", None) session.messages = kept_msgs session.last_consolidated = 0 session.updated_at = datetime.now() @@ -111,13 +112,15 @@ class AutoCompact: logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving) session = self.sessions.get_or_create(key) # Hot path: summary from in-memory dict (process hasn't restarted). - # Also clean metadata copy so stale _last_summary never leaks to disk. entry = self._summaries.pop(key, None) if entry: - session.metadata.pop("_last_summary", None) + session.metadata["_last_summary_used"] = True return session, self._format_summary(entry[0], entry[1]) - if "_last_summary" in session.metadata: - meta = session.metadata.pop("_last_summary") - self.sessions.save(session) + # Cold path: summary persisted in session metadata (process restarted). + # Keep it in metadata so it survives restarts; only inject once per + # turn via the _last_summary_used sentinel. + meta = session.metadata.get("_last_summary") + if meta and not session.metadata.get("_last_summary_used"): + session.metadata["_last_summary_used"] = True return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"])) return session, None diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 8eaf06daf..aaec64e7e 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -585,6 +585,7 @@ class Consolidator: "text": summary, "last_active": session.updated_at.isoformat(), } + session.metadata.pop("_last_summary_used", None) self.sessions.save(session) def estimate_session_prompt_tokens( diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index ecef55044..963da2f36 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -1021,13 +1021,15 @@ class TestSummaryPersistence: assert summary is not None assert "User said hello." in summary assert "Inactive for" in summary - # Metadata should be cleaned up after consumption - assert "_last_summary" not in reloaded.metadata + # Metadata persists so the summary survives restarts; _last_summary_used + # sentinel prevents duplicate injection within the same turn. + assert "_last_summary" in reloaded.metadata + assert reloaded.metadata.get("_last_summary_used") is True await loop.close_mcp() @pytest.mark.asyncio async def test_metadata_cleanup_no_leak(self, tmp_path): - """_last_summary should be removed from metadata after being consumed.""" + """_last_summary persists in metadata for restart survival; _last_summary_used sentinel prevents duplicate injection.""" loop = _make_loop(tmp_path, session_ttl_minutes=15) session = loop.sessions.get_or_create("cli:test") _add_turns(session, 6, prefix="hello") @@ -1050,10 +1052,13 @@ class TestSummaryPersistence: _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") assert summary is not None - # Second call: no summary (already consumed) + # Second call: no summary (already consumed this turn) _, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test") assert summary2 is None - assert "_last_summary" not in reloaded.metadata + # _last_summary stays in metadata for restart survival; + # _last_summary_used sentinel prevents duplicate injection. + assert "_last_summary" in reloaded.metadata + assert reloaded.metadata.get("_last_summary_used") is True await loop.close_mcp() @pytest.mark.asyncio @@ -1081,6 +1086,8 @@ class TestSummaryPersistence: # In-memory path is taken (no restart) _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") assert summary is not None - # Metadata should also be cleaned up - assert "_last_summary" not in reloaded.metadata + # _last_summary stays in metadata for restart survival; + # _last_summary_used sentinel prevents duplicate injection. + assert "_last_summary" in reloaded.metadata + assert reloaded.metadata.get("_last_summary_used") is True await loop.close_mcp() diff --git a/tests/agent/test_loop_consolidation_tokens.py b/tests/agent/test_loop_consolidation_tokens.py index aeb67d8b3..ddbafd23d 100644 --- a/tests/agent/test_loop_consolidation_tokens.py +++ b/tests/agent/test_loop_consolidation_tokens.py @@ -190,7 +190,10 @@ async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path, reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test") assert pending is not None assert "User discussed project status." in pending - assert "_last_summary" not in reloaded.metadata + # _last_summary persists for restart survival; _last_summary_used prevents + # duplicate injection within the same turn. + assert "_last_summary" in reloaded.metadata + assert reloaded.metadata.get("_last_summary_used") is True @pytest.mark.asyncio