refactor(memory): unify structured consolidation flow

This commit is contained in:
chengyongru
2026-08-19 18:40:20 +08:00
committed by chengyongru
parent d4de0e4e3d
commit 16be6b0f36
14 changed files with 257 additions and 340 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine
from loguru import logger
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
from nanobot.session.summary import SessionSummary
from nanobot.session.summary import SessionSummary, session_summary_from_metadata
if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator
@@ -91,7 +91,7 @@ class AutoCompact:
)
if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key)
stored = SessionSummary.from_metadata(
stored = session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
@@ -117,7 +117,7 @@ class AutoCompact:
# Cold path: summary persisted in session metadata (process restarted).
# Persisted metadata may outlive schema changes; a malformed summary must
# not abort turn preparation.
return session, SessionSummary.from_metadata(
return session, session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
+6 -2
View File
@@ -157,7 +157,11 @@ class ContextBuilder:
parts.append("# Recent History\n\n" + history_text)
if session_summary:
parts.append(f"[Archived Context Summary]\n\n{session_summary.for_prompt()}")
parts.append(
"[Archived Context Summary]\n\n"
f"Previous conversation summary (last active {session_summary['last_active']}):\n"
f"{session_summary['text']}"
)
return "\n\n---\n\n".join(parts)
@@ -175,7 +179,7 @@ class ContextBuilder:
entry = entries[index]
if (
entry.get("session_key") == session_key
and entry.get("content") == session_summary.text
and entry.get("content") == session_summary["text"]
):
return [*entries[:index], *entries[index + 1:]]
return entries
+33 -54
View File
@@ -27,7 +27,7 @@ from nanobot.session.manager import (
SessionManager,
replay_max_messages_for_context,
)
from nanobot.session.summary import SessionSummary
from nanobot.session.summary import session_summary_from_metadata
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
@@ -38,7 +38,6 @@ from nanobot.utils.helpers import (
recent_message_start_index,
strip_think,
truncate_text,
truncate_text_to_tokens,
)
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.workspace_prompts import (
@@ -928,10 +927,10 @@ class Consolidator:
len(chunk),
replay_max_messages,
)
summary = await self.archive(
chunk,
summary = await self.archive_session(
session,
archive_end=end_idx,
runtime=runtime,
session_key=session.key,
)
session.last_consolidated = end_idx
session.provider_state = None
@@ -955,7 +954,7 @@ class Consolidator:
"""Estimate prompt size from the full replayable session history."""
history = self._full_replay_history(session)
channel = session.key.split(":", 1)[0] if ":" in session.key else None
summary = SessionSummary.from_metadata(
summary = session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
@@ -982,52 +981,24 @@ class Consolidator:
- self._SAFETY_BUFFER
)
def _truncate_to_token_budget(self, text: str, *, runtime: LLMRuntime) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget(runtime)
if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
return truncate_text_to_tokens(text, budget)
async def archive(
self,
messages: list[dict[str, Any]],
*,
runtime: LLMRuntime,
session_key: str | None = None,
summary_messages: list[dict[str, Any]] | None = None,
request_messages: list[dict[str, Any]] | None = None,
request_tools: list[dict[str, Any]] | None = None,
session_key: str,
request_messages: list[dict[str, Any]],
request_tools: list[dict[str, Any]],
) -> str | None:
"""Summarize messages and append the result to history.jsonl.
``summary_messages`` adds context but is excluded from raw fallback.
``request_messages`` preserves a prebuilt model-facing prefix instead
of flattening the messages; tools are included but disabled.
"""
"""Execute a prepared consolidation request and persist its result."""
if not messages:
return None
prebuilt_request = request_messages is not None
if request_messages is None:
formatted = MemoryStore._format_messages(
public_history_messages(
summary_messages if summary_messages is not None else messages
)
)
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
request_messages = [
{
"role": "system",
"content": render_template("agent/consolidator_archive.md", strip=True),
},
{"role": "user", "content": formatted},
]
try:
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=request_messages,
tools=request_tools if prebuilt_request else None,
tool_choice="none" if prebuilt_request else None,
tools=request_tools,
tool_choice="none",
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
@@ -1056,23 +1027,31 @@ class Consolidator:
)
return summary
async def _archive_idle_tail(
async def archive_session(
self,
session: Session,
messages: list[dict[str, Any]],
*,
archive_end: int,
runtime: LLMRuntime,
) -> str | None:
"""Archive an idle tail by extending the ordinary model-facing messages."""
"""Archive a session prefix by appending a consolidation instruction."""
messages = list(session.messages[session.last_consolidated:archive_end])
if not messages:
return None
budget = self._input_token_budget(runtime)
if budget <= 0:
logger.debug(
"Idle consolidation has no safe input budget for {}; raw-dumping",
"Consolidation has no safe input budget for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
return None
history = session.get_history(
prefix = Session(
key=session.key,
messages=list(session.messages[:archive_end]),
last_consolidated=session.last_consolidated,
)
history = prefix.get_history(
max_messages=replay_max_messages_for_context(runtime.context_window_tokens),
max_tokens=budget,
)
@@ -1085,7 +1064,7 @@ class Consolidator:
or history[-len(archive_history):] != archive_history
):
logger.debug(
"Idle consolidation cannot replay the full tail for {}; raw-dumping",
"Consolidation cannot replay the full chunk for {}; raw-dumping",
session.key,
)
self.store.raw_archive(messages, session_key=session.key)
@@ -1103,7 +1082,7 @@ class Consolidator:
history=history,
current_message=prompt,
channel=channel,
session_summary=SessionSummary.from_metadata(
session_summary=session_summary_from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
),
@@ -1120,7 +1099,7 @@ class Consolidator:
)
if estimated > budget:
logger.debug(
"Idle consolidation prefix exceeds budget for {}; raw-dumping: {}/{} via {}",
"Consolidation prefix exceeds budget for {}; raw-dumping: {}/{} via {}",
session.key,
estimated,
budget,
@@ -1215,13 +1194,13 @@ class Consolidator:
source,
len(chunk),
)
summary = await self.archive(
chunk,
summary = await self.archive_session(
session,
archive_end=end_idx,
runtime=runtime,
session_key=session.key,
)
# Advance the cursor either way: on success the chunk was
# summarized; on failure archive() already raw-archived it as
# summarized; on failure archive_session() raw-archived it as
# a breadcrumb. Re-archiving the same chunk on the next call
# would just emit duplicate [RAW] entries.
if summary:
@@ -1278,9 +1257,9 @@ class Consolidator:
last_active = session.updated_at
archive_end = archive_start + len(messages_to_archive)
summary = await self._archive_idle_tail(
summary = await self.archive_session(
session,
messages_to_archive,
archive_end=archive_end,
runtime=runtime,
)