refactor(memory): keep session summaries structured

This commit is contained in:
chengyongru
2026-08-19 18:40:20 +08:00
committed by chengyongru
parent fa0605abd0
commit b162019271
10 changed files with 172 additions and 118 deletions
+19 -30
View File
@@ -4,11 +4,12 @@ from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
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
if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator
@@ -25,7 +26,7 @@ class AutoCompact:
self.consolidator = consolidator
self._ttl = session_ttl_minutes
self._archiving: set[str] = set()
self._summaries: dict[str, tuple[str, datetime]] = {}
self._summaries: dict[str, SessionSummary] = {}
def _is_expired(self, ts: datetime | str | None,
now: datetime | None = None) -> bool:
@@ -49,10 +50,6 @@ class AutoCompact:
session = self.sessions.get_or_create(key)
return session.last_consolidated < len(session.messages)
@staticmethod
def _format_summary(text: str, last_active: datetime) -> str:
return f"Previous conversation summary (last active {last_active.isoformat()}):\n{text}"
@classmethod
def _is_internal_session(cls, key: str) -> bool:
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
@@ -94,18 +91,22 @@ class AutoCompact:
)
if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key)
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
self._summaries[key] = (
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
stored = SessionSummary.from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
if stored is not None:
self._summaries[key] = stored
except Exception:
logger.exception("Auto-compact: failed for {}", key)
finally:
self._archiving.discard(key)
def prepare_session(self, session: Session, key: str) -> tuple[Session, str | None]:
def prepare_session(
self,
session: Session,
key: str,
) -> tuple[Session, SessionSummary | None]:
if self._is_internal_session(key):
self._archiving.discard(key)
self._summaries.pop(key, None)
@@ -116,23 +117,11 @@ class AutoCompact:
# Hot path: summary from in-memory dict (process hasn't restarted).
entry = self._summaries.pop(key, None)
if entry:
return session, self._format_summary(entry[0], entry[1])
return session, entry
# Cold path: summary persisted in session metadata (process restarted).
# Persisted metadata may outlive schema changes; a malformed summary must
# not abort turn preparation.
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
summary_meta = cast(dict[str, object], meta)
text = summary_meta.get("text")
if isinstance(text, str) and text:
raw_last_active = summary_meta.get("last_active")
try:
last_active = (
datetime.fromisoformat(raw_last_active)
if isinstance(raw_last_active, str)
else session.updated_at
)
except ValueError:
last_active = session.updated_at
return session, self._format_summary(text, last_active)
return session, None
return session, SessionSummary.from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
+32 -13
View File
@@ -3,6 +3,7 @@
import base64
import mimetypes
import platform
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence, cast
@@ -25,6 +26,10 @@ from nanobot.runtime_context import (
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.security.workspace_access import WorkspaceScopeResolver
from nanobot.session.keys import last_channel_from_metadata
from nanobot.session.manager import Session
from nanobot.session.summary import SessionSummary
from nanobot.utils.helpers import (
detect_image_mime,
load_bundled_template,
@@ -49,6 +54,27 @@ async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolReg
return await image_generation_tools.handle_runtime_control(state, msg, tools)
@dataclass(frozen=True, slots=True)
class PersistedPromptContextResolver:
"""Restore prompt routing context when no inbound message is available."""
workspace_scopes: WorkspaceScopeResolver
unified_session: bool = False
def __call__(self, session: Session) -> tuple[str | None, Path]:
channel = session.key.split(":", 1)[0] if ":" in session.key else None
if self.unified_session:
route = last_channel_from_metadata(session.metadata)
if route is not None:
channel = route[0]
scope = self.workspace_scopes.for_turn(
channel=channel,
message_metadata=None,
session_metadata=session.metadata,
)
return channel, scope.project_path
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
@@ -58,7 +84,6 @@ 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
@@ -71,7 +96,7 @@ class ContextBuilder:
*,
active_skill_names: Sequence[str] | None = None,
channel: str | None = None,
session_summary: str | None = None,
session_summary: SessionSummary | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
@@ -132,31 +157,25 @@ class ContextBuilder:
parts.append("# Recent History\n\n" + history_text)
if session_summary:
parts.append(f"[Archived Context Summary]\n\n{session_summary}")
parts.append(f"[Archived Context Summary]\n\n{session_summary.for_prompt()}")
return "\n\n---\n\n".join(parts)
@classmethod
@staticmethod
def _without_duplicate_session_summary(
cls,
entries: list[dict[str, Any]],
*,
session_key: str | None,
session_summary: str | None,
session_summary: SessionSummary | 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
and entry.get("content") == session_summary.text
):
return [*entries[:index], *entries[index + 1:]]
return entries
@@ -246,7 +265,7 @@ class ContextBuilder:
media: list[str] | None = None,
channel: str | None = None,
current_role: str = "user",
session_summary: str | None = None,
session_summary: SessionSummary | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None,
include_memory: bool = True,
+7 -21
View File
@@ -24,7 +24,7 @@ from nanobot.agent import context as agent_context
from nanobot.agent import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.automation_turns import publish_next_deferred_turn
from nanobot.agent.context import ContextBuilder
from nanobot.agent.context import ContextBuilder, PersistedPromptContextResolver
from nanobot.agent.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
from nanobot.agent.memory import Consolidator
@@ -76,7 +76,6 @@ from nanobot.session.goal_state import (
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.keys import (
UNIFIED_SESSION_KEY,
last_channel_from_metadata,
remember_last_channel,
)
from nanobot.session.manager import (
@@ -89,6 +88,7 @@ from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata,
)
from nanobot.session.summary import SessionSummary
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.cancellation import task_is_cancelling
from nanobot.utils.document import reference_non_image_attachments
@@ -155,7 +155,7 @@ class TurnContext:
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
pending_queue: asyncio.Queue[InboundMessage] | None = None
pending_summary: str | None = None
pending_summary: SessionSummary | None = None
ephemeral: bool = False
run_extra_hooks_for_ephemeral: bool = False
@@ -446,7 +446,10 @@ class AgentLoop:
sessions=self.sessions,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
resolve_prompt_context=self._idle_consolidation_prompt_context,
resolve_prompt_context=PersistedPromptContextResolver(
workspace_scopes=self.workspace_scopes,
unified_session=unified_session,
),
consolidation_ratio=consolidation_ratio,
unified_session=unified_session,
)
@@ -923,23 +926,6 @@ class AgentLoop:
return
remember_last_channel(session.metadata, msg.channel, msg.chat_id)
def _idle_consolidation_prompt_context(
self,
session: Session,
) -> tuple[str | None, Path]:
"""Resolve the same persisted route and workspace used by a normal turn."""
channel = session.key.split(":", 1)[0] if ":" in session.key else None
if self._unified_session:
route = last_channel_from_metadata(session.metadata)
if route is not None:
channel = route[0]
scope = self.workspace_scopes.for_turn(
channel=channel,
message_metadata=None,
session_metadata=session.metadata,
)
return channel, scope.project_path
@staticmethod
def _replay_token_budget(runtime: LLMRuntime) -> int:
"""Derive a token budget for session history replay from the context window."""
+8 -23
View File
@@ -27,6 +27,7 @@ from nanobot.session.manager import (
SessionManager,
replay_max_messages_for_context,
)
from nanobot.session.summary import SessionSummary
from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
@@ -954,14 +955,9 @@ 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
# Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary")
summary = (
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
summary = SessionSummary.from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
)
probe_messages = self._build_messages(
history=history,
@@ -1060,20 +1056,6 @@ class Consolidator:
)
return summary
@staticmethod
def _session_summary_for_prompt(session: Session) -> str | None:
"""Rebuild the summary text used by normal turns after an idle archive."""
meta = session.metadata.get("_last_summary")
if not isinstance(meta, dict):
return None
summary_meta = cast(dict[str, Any], meta)
text = summary_meta.get("text")
if not isinstance(text, str) or not text:
return None
last_active = summary_meta.get("last_active")
timestamp = last_active if isinstance(last_active, str) else session.updated_at.isoformat()
return f"Previous conversation summary (last active {timestamp}):\n{text}"
async def _archive_idle_tail(
self,
session: Session,
@@ -1121,7 +1103,10 @@ class Consolidator:
history=history,
current_message=prompt,
channel=channel,
session_summary=self._session_summary_for_prompt(session),
session_summary=SessionSummary.from_metadata(
session.metadata,
fallback_last_active=session.updated_at,
),
workspace=workspace,
session_key=session.key,
unified_session=self.unified_session,