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