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)":
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

View File

@ -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(

View File

@ -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()

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")
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