From 82e50e2c91192b2b6c00558b32098a668ba09135 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 19 Aug 2026 14:20:27 +0800 Subject: [PATCH] fix(memory): avoid duplicate summary injection --- nanobot/agent/context.py | 43 +++++++++++++++++++++--- tests/agent/test_auto_compact.py | 10 +++++- tests/agent/test_context_prompt_cache.py | 29 ++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index d659f2751..4142c9e66 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -58,6 +58,7 @@ class ContextBuilder: _MAX_RECENT_HISTORY = 50 _MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) _RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END + _SESSION_SUMMARY_HEADER_PREFIX = "Previous conversation summary (last active " def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): self.workspace = workspace @@ -115,17 +116,51 @@ class ContextBuilder: ) if entries: capped = entries[-self._MAX_RECENT_HISTORY:] - history_text = "\n".join( - f"- [{e['timestamp']}] {e['content']}" for e in capped + capped = self._without_duplicate_session_summary( + capped, + session_key=session_key, + session_summary=session_summary, ) - history_text = truncate_text_to_tokens(history_text, self._MAX_HISTORY_TOKENS) - parts.append("# Recent History\n\n" + history_text) + if capped: + history_text = "\n".join( + f"- [{e['timestamp']}] {e['content']}" for e in capped + ) + history_text = truncate_text_to_tokens( + history_text, + self._MAX_HISTORY_TOKENS, + ) + parts.append("# Recent History\n\n" + history_text) if session_summary: parts.append(f"[Archived Context Summary]\n\n{session_summary}") return "\n\n---\n\n".join(parts) + @classmethod + def _without_duplicate_session_summary( + cls, + entries: list[dict[str, Any]], + *, + session_key: str | None, + session_summary: str | None, + ) -> list[dict[str, Any]]: + """Drop the history entry already represented by the session summary.""" + if not session_summary: + return entries + summary_content = session_summary + if session_summary.startswith(cls._SESSION_SUMMARY_HEADER_PREFIX): + _header, separator, content = session_summary.partition("):\n") + if separator and content: + summary_content = content + for index in range(len(entries) - 1, -1, -1): + entry = entries[index] + if ( + entry.get("session_key") == session_key + and entry.get("content") == summary_content + ): + return [*entries[:index], *entries[index + 1:]] + return entries + def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str: """Get the core identity section.""" root = workspace or self.workspace diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index b576481f9..4fa292843 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -724,6 +724,10 @@ class TestAutoCompactIntegration: async def test_full_lifecycle(self, tmp_path): loop = _make_loop(tmp_path, session_ttl_minutes=15) session = loop.sessions.get_or_create("cli:test") + overview = ( + "IDLE_OVERVIEW_MARKER: User is learning English past tense. " + "Example: 'I walked to the store yesterday.'" + ) # Phase 1: User has a conversation longer than the retained recent suffix session.add_message("user", "I'm learning English, teach me past tense") @@ -745,7 +749,7 @@ class TestAutoCompactIntegration: # Phase 3: User returns with a new message loop.provider.chat_with_retry = AsyncMock( return_value=LLMResponse( - content="User is learning English past tense. Example: 'I walked to the store yesterday.'", + content=overview, tool_calls=[], ) ) @@ -759,6 +763,9 @@ class TestAutoCompactIntegration: # Phase 4: Verify session_after = loop.sessions.get_or_create("cli:test") + resumed_system_prompt = loop.provider.chat_with_retry.await_args_list[-1].kwargs[ + "messages" + ][0]["content"] assert any( "past tense is used" in str(m.get("content", "")).lower() @@ -773,6 +780,7 @@ class TestAutoCompactIntegration: assert not any( "[Resumed Session]" in str(m.get("content", "")) for m in session_after.messages ) + assert resumed_system_prompt.count(overview) == 1 # Runtime context end marker should NOT be persisted assert not any( "[/Runtime Context]" in str(m.get("content", "")) for m in session_after.messages diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index daae5ab88..0705f7f4e 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -112,6 +112,35 @@ def test_recent_history_injection_is_session_scoped(tmp_path) -> None: assert "legacy entry without session" not in prompt +def test_session_summary_replaces_matching_recent_history_entry(tmp_path) -> None: + workspace = _make_workspace(tmp_path) + builder = ContextBuilder(workspace) + session_key = "telegram:chat-1" + overview = "CURRENT_SESSION_OVERVIEW_MARKER" + + builder.memory.append_history("another session event", session_key=session_key) + summary_cursor = builder.memory.append_history(overview, session_key=session_key) + summary = f"Previous conversation summary (last active 2026-08-19T10:00:00):\n{overview}" + + prompt = builder.build_system_prompt( + session_key=session_key, + session_summary=summary, + ) + + assert "# Recent History" in prompt + assert "another session event" in prompt + assert "[Archived Context Summary]" in prompt + assert prompt.count(overview) == 1 + + builder.memory.set_last_dream_cursor(summary_cursor) + processed_prompt = builder.build_system_prompt( + session_key=session_key, + session_summary=summary, + ) + assert "# Recent History" not in processed_prompt + assert processed_prompt.count(overview) == 1 + + def test_recent_history_injection_unified_excludes_cron_internals(tmp_path) -> None: workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace)