mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(memory): avoid duplicate summary injection
This commit is contained in:
@@ -58,6 +58,7 @@ class ContextBuilder:
|
|||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
_RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
|
_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):
|
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
|
||||||
self.workspace = workspace
|
self.workspace = workspace
|
||||||
@@ -115,17 +116,51 @@ class ContextBuilder:
|
|||||||
)
|
)
|
||||||
if entries:
|
if entries:
|
||||||
capped = entries[-self._MAX_RECENT_HISTORY:]
|
capped = entries[-self._MAX_RECENT_HISTORY:]
|
||||||
history_text = "\n".join(
|
capped = self._without_duplicate_session_summary(
|
||||||
f"- [{e['timestamp']}] {e['content']}" for e in capped
|
capped,
|
||||||
|
session_key=session_key,
|
||||||
|
session_summary=session_summary,
|
||||||
)
|
)
|
||||||
history_text = truncate_text_to_tokens(history_text, self._MAX_HISTORY_TOKENS)
|
if capped:
|
||||||
parts.append("# Recent History\n\n" + history_text)
|
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:
|
if session_summary:
|
||||||
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
|
||||||
|
|
||||||
return "\n\n---\n\n".join(parts)
|
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:
|
def _get_identity(self, channel: str | None = None, workspace: Path | None = None) -> str:
|
||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
|
|||||||
@@ -724,6 +724,10 @@ class TestAutoCompactIntegration:
|
|||||||
async def test_full_lifecycle(self, tmp_path):
|
async def test_full_lifecycle(self, tmp_path):
|
||||||
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")
|
||||||
|
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
|
# Phase 1: User has a conversation longer than the retained recent suffix
|
||||||
session.add_message("user", "I'm learning English, teach me past tense")
|
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
|
# Phase 3: User returns with a new message
|
||||||
loop.provider.chat_with_retry = AsyncMock(
|
loop.provider.chat_with_retry = AsyncMock(
|
||||||
return_value=LLMResponse(
|
return_value=LLMResponse(
|
||||||
content="User is learning English past tense. Example: 'I walked to the store yesterday.'",
|
content=overview,
|
||||||
tool_calls=[],
|
tool_calls=[],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -759,6 +763,9 @@ class TestAutoCompactIntegration:
|
|||||||
|
|
||||||
# Phase 4: Verify
|
# Phase 4: Verify
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
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(
|
assert any(
|
||||||
"past tense is used" in str(m.get("content", "")).lower()
|
"past tense is used" in str(m.get("content", "")).lower()
|
||||||
@@ -773,6 +780,7 @@ class TestAutoCompactIntegration:
|
|||||||
assert not any(
|
assert not any(
|
||||||
"[Resumed Session]" in str(m.get("content", "")) for m in session_after.messages
|
"[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
|
# Runtime context end marker should NOT be persisted
|
||||||
assert not any(
|
assert not any(
|
||||||
"[/Runtime Context]" in str(m.get("content", "")) for m in session_after.messages
|
"[/Runtime Context]" in str(m.get("content", "")) for m in session_after.messages
|
||||||
|
|||||||
@@ -112,6 +112,35 @@ def test_recent_history_injection_is_session_scoped(tmp_path) -> None:
|
|||||||
assert "legacy entry without session" not in prompt
|
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:
|
def test_recent_history_injection_unified_excludes_cron_internals(tmp_path) -> None:
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|||||||
Reference in New Issue
Block a user