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.
This commit is contained in:
chengyongru 2026-05-07 16:17:27 +08:00 committed by Xubin Ren
parent 56eee06736
commit e5a1416a37
4 changed files with 27 additions and 13 deletions

View File

@ -89,6 +89,7 @@ class AutoCompact:
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
self._summaries[key] = (summary, last_active) self._summaries[key] = (summary, last_active)
session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()} session.metadata["_last_summary"] = {"text": summary, "last_active": last_active.isoformat()}
session.metadata.pop("_last_summary_used", None)
session.messages = kept_msgs session.messages = kept_msgs
session.last_consolidated = 0 session.last_consolidated = 0
session.updated_at = datetime.now() session.updated_at = datetime.now()
@ -111,13 +112,15 @@ class AutoCompact:
logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving) logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving)
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
# Hot path: summary from in-memory dict (process hasn't restarted). # 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) entry = self._summaries.pop(key, None)
if entry: if entry:
session.metadata.pop("_last_summary", None) session.metadata["_last_summary_used"] = True
return session, self._format_summary(entry[0], entry[1]) return session, self._format_summary(entry[0], entry[1])
if "_last_summary" in session.metadata: # Cold path: summary persisted in session metadata (process restarted).
meta = session.metadata.pop("_last_summary") # Keep it in metadata so it survives restarts; only inject once per
self.sessions.save(session) # 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, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
return session, None return session, None

View File

@ -585,6 +585,7 @@ class Consolidator:
"text": summary, "text": summary,
"last_active": session.updated_at.isoformat(), "last_active": session.updated_at.isoformat(),
} }
session.metadata.pop("_last_summary_used", None)
self.sessions.save(session) self.sessions.save(session)
def estimate_session_prompt_tokens( def estimate_session_prompt_tokens(

View File

@ -1021,13 +1021,15 @@ class TestSummaryPersistence:
assert summary is not None assert summary is not None
assert "User said hello." in summary assert "User said hello." in summary
assert "Inactive for" in summary assert "Inactive for" in summary
# Metadata should be cleaned up after consumption # Metadata persists so the summary survives restarts; _last_summary_used
assert "_last_summary" not in reloaded.metadata # 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() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_metadata_cleanup_no_leak(self, tmp_path): 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) loop = _make_loop(tmp_path, session_ttl_minutes=15)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
_add_turns(session, 6, prefix="hello") _add_turns(session, 6, prefix="hello")
@ -1050,10 +1052,13 @@ class TestSummaryPersistence:
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary is not None 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") _, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary2 is None 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() await loop.close_mcp()
@pytest.mark.asyncio @pytest.mark.asyncio
@ -1081,6 +1086,8 @@ class TestSummaryPersistence:
# In-memory path is taken (no restart) # In-memory path is taken (no restart)
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary is not None assert summary is not None
# Metadata should also be cleaned up # _last_summary stays in metadata for restart survival;
assert "_last_summary" not in reloaded.metadata # _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() await loop.close_mcp()

View File

@ -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") reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert pending is not None assert pending is not None
assert "User discussed project status." in pending 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 @pytest.mark.asyncio