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 collections.abc import Collection
from datetime import datetime 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 loguru import logger
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
from nanobot.session.summary import SessionSummary
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator from nanobot.agent.memory import Consolidator
@@ -25,7 +26,7 @@ class AutoCompact:
self.consolidator = consolidator self.consolidator = consolidator
self._ttl = session_ttl_minutes self._ttl = session_ttl_minutes
self._archiving: set[str] = set() 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, def _is_expired(self, ts: datetime | str | None,
now: datetime | None = None) -> bool: now: datetime | None = None) -> bool:
@@ -49,10 +50,6 @@ class AutoCompact:
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
return session.last_consolidated < len(session.messages) 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 @classmethod
def _is_internal_session(cls, key: str) -> bool: def _is_internal_session(cls, key: str) -> bool:
return key.startswith(cls._INTERNAL_SESSION_PREFIXES) return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
@@ -94,18 +91,22 @@ class AutoCompact:
) )
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
meta = session.metadata.get("_last_summary") stored = SessionSummary.from_metadata(
if isinstance(meta, dict): session.metadata,
self._summaries[key] = ( fallback_last_active=session.updated_at,
cast(str, meta["text"]), )
datetime.fromisoformat(cast(str, meta["last_active"])), if stored is not None:
) self._summaries[key] = stored
except Exception: except Exception:
logger.exception("Auto-compact: failed for {}", key) logger.exception("Auto-compact: failed for {}", key)
finally: finally:
self._archiving.discard(key) 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): if self._is_internal_session(key):
self._archiving.discard(key) self._archiving.discard(key)
self._summaries.pop(key, None) self._summaries.pop(key, None)
@@ -116,23 +117,11 @@ class AutoCompact:
# Hot path: summary from in-memory dict (process hasn't restarted). # Hot path: summary from in-memory dict (process hasn't restarted).
entry = self._summaries.pop(key, None) entry = self._summaries.pop(key, None)
if entry: if entry:
return session, self._format_summary(entry[0], entry[1]) return session, entry
# Cold path: summary persisted in session metadata (process restarted). # Cold path: summary persisted in session metadata (process restarted).
# Persisted metadata may outlive schema changes; a malformed summary must # Persisted metadata may outlive schema changes; a malformed summary must
# not abort turn preparation. # not abort turn preparation.
meta = session.metadata.get("_last_summary") return session, SessionSummary.from_metadata(
if isinstance(meta, dict): session.metadata,
summary_meta = cast(dict[str, object], meta) fallback_last_active=session.updated_at,
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
+32 -13
View File
@@ -3,6 +3,7 @@
import base64 import base64
import mimetypes import mimetypes
import platform import platform
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, Sequence, cast from typing import Any, Mapping, Sequence, cast
@@ -25,6 +26,10 @@ from nanobot.runtime_context import (
RuntimeContextBlock, RuntimeContextBlock,
append_runtime_context, 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 ( from nanobot.utils.helpers import (
detect_image_mime, detect_image_mime,
load_bundled_template, 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) 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: class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """Builds the context (system prompt + messages) for the agent."""
@@ -58,7 +84,6 @@ 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
@@ -71,7 +96,7 @@ class ContextBuilder:
*, *,
active_skill_names: Sequence[str] | None = None, active_skill_names: Sequence[str] | None = None,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: SessionSummary | None = None,
workspace: Path | None = None, workspace: Path | None = None,
include_memory: bool = True, include_memory: bool = True,
include_memory_recent_history: bool = True, include_memory_recent_history: bool = True,
@@ -132,31 +157,25 @@ class ContextBuilder:
parts.append("# Recent History\n\n" + history_text) 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.for_prompt()}")
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
@classmethod @staticmethod
def _without_duplicate_session_summary( def _without_duplicate_session_summary(
cls,
entries: list[dict[str, Any]], entries: list[dict[str, Any]],
*, *,
session_key: str | None, session_key: str | None,
session_summary: str | None, session_summary: SessionSummary | None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Drop the history entry already represented by the session summary.""" """Drop the history entry already represented by the session summary."""
if not session_summary: if not session_summary:
return entries 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): for index in range(len(entries) - 1, -1, -1):
entry = entries[index] entry = entries[index]
if ( if (
entry.get("session_key") == session_key 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[:index], *entries[index + 1:]]
return entries return entries
@@ -246,7 +265,7 @@ class ContextBuilder:
media: list[str] | None = None, media: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
current_role: str = "user", current_role: str = "user",
session_summary: str | None = None, session_summary: SessionSummary | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None, runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None, workspace: Path | None = None,
include_memory: bool = True, 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 import model_presets as preset_helpers
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.autocompact import AutoCompact
from nanobot.agent.automation_turns import publish_next_deferred_turn 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.cron_turns import CronTurnCoordinator
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
from nanobot.agent.memory import Consolidator 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.history_visibility import HIDDEN_HISTORY_META
from nanobot.session.keys import ( from nanobot.session.keys import (
UNIFIED_SESSION_KEY, UNIFIED_SESSION_KEY,
last_channel_from_metadata,
remember_last_channel, remember_last_channel,
) )
from nanobot.session.manager import ( from nanobot.session.manager import (
@@ -89,6 +88,7 @@ from nanobot.session.model_selection import (
SESSION_MODEL_PRESET_METADATA_KEY, SESSION_MODEL_PRESET_METADATA_KEY,
model_preset_from_metadata, model_preset_from_metadata,
) )
from nanobot.session.summary import SessionSummary
from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator from nanobot.triggers.local_turns import LocalTriggerTurnCoordinator
from nanobot.utils.cancellation import task_is_cancelling from nanobot.utils.cancellation import task_is_cancelling
from nanobot.utils.document import reference_non_image_attachments from nanobot.utils.document import reference_non_image_attachments
@@ -155,7 +155,7 @@ class TurnContext:
on_retry_wait: Callable[[str], Awaitable[None]] | None = None on_retry_wait: Callable[[str], Awaitable[None]] | None = None
pending_queue: asyncio.Queue[InboundMessage] | None = None pending_queue: asyncio.Queue[InboundMessage] | None = None
pending_summary: str | None = None pending_summary: SessionSummary | None = None
ephemeral: bool = False ephemeral: bool = False
run_extra_hooks_for_ephemeral: bool = False run_extra_hooks_for_ephemeral: bool = False
@@ -446,7 +446,10 @@ class AgentLoop:
sessions=self.sessions, sessions=self.sessions,
build_messages=self.context.build_messages, build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions, 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, consolidation_ratio=consolidation_ratio,
unified_session=unified_session, unified_session=unified_session,
) )
@@ -923,23 +926,6 @@ class AgentLoop:
return return
remember_last_channel(session.metadata, msg.channel, msg.chat_id) 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 @staticmethod
def _replay_token_budget(runtime: LLMRuntime) -> int: def _replay_token_budget(runtime: LLMRuntime) -> int:
"""Derive a token budget for session history replay from the context window.""" """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, SessionManager,
replay_max_messages_for_context, replay_max_messages_for_context,
) )
from nanobot.session.summary import SessionSummary
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs, content_with_media_breadcrumbs,
@@ -954,14 +955,9 @@ class Consolidator:
"""Estimate prompt size from the full replayable session history.""" """Estimate prompt size from the full replayable session history."""
history = self._full_replay_history(session) history = self._full_replay_history(session)
channel = session.key.split(":", 1)[0] if ":" in session.key else None channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it. summary = SessionSummary.from_metadata(
meta = session.metadata.get("_last_summary") session.metadata,
summary = ( fallback_last_active=session.updated_at,
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
) )
probe_messages = self._build_messages( probe_messages = self._build_messages(
history=history, history=history,
@@ -1060,20 +1056,6 @@ class Consolidator:
) )
return summary 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( async def _archive_idle_tail(
self, self,
session: Session, session: Session,
@@ -1121,7 +1103,10 @@ class Consolidator:
history=history, history=history,
current_message=prompt, current_message=prompt,
channel=channel, 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, workspace=workspace,
session_key=session.key, session_key=session.key,
unified_session=self.unified_session, unified_session=self.unified_session,
+47
View File
@@ -0,0 +1,47 @@
"""Structured session-summary values used while building model context."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import cast
@dataclass(frozen=True, slots=True)
class SessionSummary:
"""A consolidated session checkpoint before presentation formatting."""
text: str
last_active: datetime
def for_prompt(self) -> str:
return (
f"Previous conversation summary (last active {self.last_active.isoformat()}):\n"
f"{self.text}"
)
@classmethod
def from_metadata(
cls,
metadata: Mapping[str, object] | None,
*,
fallback_last_active: datetime,
) -> SessionSummary | None:
raw: object = metadata.get("_last_summary") if metadata is not None else None
if not isinstance(raw, Mapping):
return None
summary_data = cast(Mapping[str, object], raw)
text = summary_data.get("text")
if not isinstance(text, str) or not text:
return None
raw_last_active = summary_data.get("last_active")
try:
last_active = (
datetime.fromisoformat(raw_last_active)
if isinstance(raw_last_active, str)
else fallback_last_active
)
except ValueError:
last_active = fallback_last_active
return cls(text=text, last_active=last_active)
+7 -7
View File
@@ -421,7 +421,7 @@ class TestAutoCompact:
entry = loop.auto_compact._summaries.get("cli:test") entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None assert entry is not None
assert entry[0] == "User said hello." assert entry.text == "User said hello."
session_after = loop.sessions.get_or_create("cli:test") session_after = loop.sessions.get_or_create("cli:test")
assert len(session_after.messages) == 12 assert len(session_after.messages) == 12
assert len(session_after.get_history(max_messages=12)) == ( assert len(session_after.get_history(max_messages=12)) == (
@@ -909,7 +909,7 @@ class TestProactiveAutoCompact:
assert len(archived_messages) == 10 assert len(archived_messages) == 10
entry = loop.auto_compact._summaries.get("cli:test") entry = loop.auto_compact._summaries.get("cli:test")
assert entry is not None assert entry is not None
assert entry[0] == "User chatted about old things." assert entry.text == "User chatted about old things."
await loop.aclose() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -1227,8 +1227,8 @@ class TestSummaryPersistence:
_, summary = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary is not None assert summary is not None
assert "User said hello." in summary assert summary.text == "User said hello."
assert "Previous conversation summary" in summary assert "Previous conversation summary" in summary.for_prompt()
# _last_summary persists in metadata for restart survival. # _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
await loop.aclose() await loop.aclose()
@@ -1256,7 +1256,7 @@ class TestSummaryPersistence:
assert summary is not None assert summary is not None
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary2 is not None assert summary2 is not None
assert "Summary." in summary2 assert summary2.text == "Summary."
# _last_summary persists in metadata for restart survival. # _last_summary persists in metadata for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
await loop.aclose() await loop.aclose()
@@ -1306,7 +1306,7 @@ class TestSummaryPersistence:
loop.sessions.get_or_create("cli:test"), "cli:test" loop.sessions.get_or_create("cli:test"), "cli:test"
) )
assert summary1 is not None assert summary1 is not None
assert "First summary." in summary1 assert summary1.text == "First summary."
assert "cli:test" not in loop.auto_compact._summaries # popped by hot path assert "cli:test" not in loop.auto_compact._summaries # popped by hot path
# Add new messages and archive again (simulating a later turn) # Add new messages and archive again (simulating a later turn)
@@ -1326,7 +1326,7 @@ class TestSummaryPersistence:
reloaded = loop.sessions.get_or_create("cli:test") reloaded = loop.sessions.get_or_create("cli:test")
_, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test") _, summary2 = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert summary2 is not None assert summary2 is not None
assert "Second summary." in summary2 assert summary2.text == "Second summary."
await loop.aclose() await loop.aclose()
@pytest.mark.asyncio @pytest.mark.asyncio
+24 -19
View File
@@ -7,6 +7,7 @@ import pytest
from nanobot.agent.autocompact import AutoCompact from nanobot.agent.autocompact import AutoCompact
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
from nanobot.session.summary import SessionSummary
def _runtime(_session: Session | None = None): def _runtime(_session: Session | None = None):
@@ -176,29 +177,29 @@ class TestIsExpired:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _format_summary # SessionSummary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestFormatSummary: class TestSessionSummary:
"""Test AutoCompact._format_summary static method.""" """Test prompt rendering for the structured summary value."""
def test_contains_isoformat_timestamp(self): def test_contains_isoformat_timestamp(self):
"""Output should contain last_active as isoformat.""" """Output should contain last_active as isoformat."""
last_active = datetime(2026, 5, 13, 14, 30, 0) last_active = datetime(2026, 5, 13, 14, 30, 0)
result = AutoCompact._format_summary("Some text", last_active) result = SessionSummary("Some text", last_active).for_prompt()
assert "2026-05-13T14:30:00" in result assert "2026-05-13T14:30:00" in result
def test_contains_summary_text(self): def test_contains_summary_text(self):
"""Output should contain the provided text verbatim.""" """Output should contain the provided text verbatim."""
last_active = datetime(2026, 1, 1) last_active = datetime(2026, 1, 1)
result = AutoCompact._format_summary("User discussed Python.", last_active) result = SessionSummary("User discussed Python.", last_active).for_prompt()
assert "User discussed Python." in result assert "User discussed Python." in result
def test_output_starts_with_label(self): def test_output_starts_with_label(self):
"""Output should start with the standard prefix.""" """Output should start with the standard prefix."""
last_active = datetime(2026, 1, 1) last_active = datetime(2026, 1, 1)
result = AutoCompact._format_summary("text", last_active) result = SessionSummary("text", last_active).for_prompt()
assert result.startswith("Previous conversation summary (last active ") assert result.startswith("Previous conversation summary (last active ")
@@ -498,7 +499,7 @@ class TestArchiveDelegates:
entry = ac._summaries.get("cli:test") entry = ac._summaries.get("cli:test")
assert entry is not None assert entry is not None
assert entry[0] == "Hello." assert entry.text == "Hello."
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_no_summary_when_compact_returns_empty(self): async def test_no_summary_when_compact_returns_empty(self):
@@ -577,21 +578,21 @@ class TestPrepareSession:
ac = _make_autocompact() ac = _make_autocompact()
session = _make_session() session = _make_session()
last_active = datetime(2026, 5, 13, 14, 0, 0) last_active = datetime(2026, 5, 13, 14, 0, 0)
ac._summaries["cli:test"] = ("Hot summary.", last_active) ac._summaries["cli:test"] = SessionSummary("Hot summary.", last_active)
result_session, summary = ac.prepare_session(session, "cli:test") result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session assert result_session is session
assert summary is not None assert summary is not None
assert "Hot summary." in summary assert summary.text == "Hot summary."
assert "Previous conversation summary" in summary assert "Previous conversation summary" in summary.for_prompt()
def test_hot_path_pops_summary_one_shot(self): def test_hot_path_pops_summary_one_shot(self):
"""Hot path should pop the summary (one-shot; second call returns None).""" """Hot path should pop the summary (one-shot; second call returns None)."""
ac = _make_autocompact() ac = _make_autocompact()
session = _make_session() session = _make_session()
last_active = datetime(2026, 1, 1) last_active = datetime(2026, 1, 1)
ac._summaries["cli:test"] = ("One-shot.", last_active) ac._summaries["cli:test"] = SessionSummary("One-shot.", last_active)
_, summary1 = ac.prepare_session(session, "cli:test") _, summary1 = ac.prepare_session(session, "cli:test")
assert summary1 is not None assert summary1 is not None
@@ -614,7 +615,7 @@ class TestPrepareSession:
assert result_session is session assert result_session is session
assert summary is not None assert summary is not None
assert "Cold summary." in summary assert summary.text == "Cold summary."
def test_cold_path_tolerates_malformed_last_active(self): def test_cold_path_tolerates_malformed_last_active(self):
"""A malformed persisted last_active must not raise on the turn path. """A malformed persisted last_active must not raise on the turn path.
@@ -637,8 +638,8 @@ class TestPrepareSession:
assert result_session is session assert result_session is session
assert summary is not None assert summary is not None
assert "Cold summary." in summary assert summary.text == "Cold summary."
assert fallback.isoformat() in summary assert summary.last_active == fallback
def test_cold_path_tolerates_missing_last_active(self): def test_cold_path_tolerates_missing_last_active(self):
"""A _last_summary dict without last_active must not raise.""" """A _last_summary dict without last_active must not raise."""
@@ -653,8 +654,8 @@ class TestPrepareSession:
assert result_session is session assert result_session is session
assert summary is not None assert summary is not None
assert "Cold summary." in summary assert summary.text == "Cold summary."
assert fallback.isoformat() in summary assert summary.last_active == fallback
def test_cold_path_missing_text_returns_none(self): def test_cold_path_missing_text_returns_none(self):
"""A _last_summary without a non-empty string text yields no summary.""" """A _last_summary without a non-empty string text yields no summary."""
@@ -685,7 +686,10 @@ class TestPrepareSession:
ac.sessions = mock_sm ac.sessions = mock_sm
key = "dream:20260602-155256" key = "dream:20260602-155256"
ac._archiving.add(key) ac._archiving.add(key)
ac._summaries[key] = ("Hot summary.", datetime(2026, 6, 2, 15, 52, 56)) ac._summaries[key] = SessionSummary(
"Hot summary.",
datetime(2026, 6, 2, 15, 52, 56),
)
session = _make_session( session = _make_session(
key=key, key=key,
updated_at=datetime.now() - timedelta(minutes=20), updated_at=datetime.now() - timedelta(minutes=20),
@@ -725,8 +729,9 @@ class TestPrepareSession:
}, },
}) })
last_active = datetime(2026, 5, 13, 14, 0, 0) last_active = datetime(2026, 5, 13, 14, 0, 0)
ac._summaries["cli:test"] = ("Hot summary.", last_active) ac._summaries["cli:test"] = SessionSummary("Hot summary.", last_active)
_, summary = ac.prepare_session(session, "cli:test") _, summary = ac.prepare_session(session, "cli:test")
assert "Hot summary." in summary assert summary is not None
assert summary.text == "Hot summary."
# After hot path pops, cold path would kick in on next call # After hot path pops, cold path would kick in on next call
+14 -2
View File
@@ -1,11 +1,13 @@
"""Tests for ContextBuilder — system prompt and message assembly.""" """Tests for ContextBuilder — system prompt and message assembly."""
from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest import pytest
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.runtime_context import RuntimeContextBlock from nanobot.runtime_context import RuntimeContextBlock
from nanobot.session.summary import SessionSummary
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@@ -330,14 +332,24 @@ class TestBuildSystemPrompt:
def test_includes_session_summary(self, tmp_path): def test_includes_session_summary(self, tmp_path):
builder = _builder(tmp_path) builder = _builder(tmp_path)
result = builder.build_system_prompt(session_summary="Previous chat about Python.") result = builder.build_system_prompt(
session_summary=SessionSummary(
text="Previous chat about Python.",
last_active=datetime(2026, 8, 19, 10, 0),
)
)
assert "Previous chat about Python." in result assert "Previous chat about Python." in result
assert "[Archived Context Summary]" in result assert "[Archived Context Summary]" in result
def test_sections_separated_by_separator(self, tmp_path): def test_sections_separated_by_separator(self, tmp_path):
(tmp_path / "AGENTS.md").write_text("Rules.", encoding="utf-8") (tmp_path / "AGENTS.md").write_text("Rules.", encoding="utf-8")
builder = _builder(tmp_path) builder = _builder(tmp_path)
result = builder.build_system_prompt(session_summary="Summary.") result = builder.build_system_prompt(
session_summary=SessionSummary(
text="Summary.",
last_active=datetime(2026, 8, 19, 10, 0),
)
)
assert "\n\n---\n\n" in result assert "\n\n---\n\n" in result
def test_no_bootstrap_no_summary(self, tmp_path): def test_no_bootstrap_no_summary(self, tmp_path):
+5 -1
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.runtime_context import RuntimeContextBlock from nanobot.runtime_context import RuntimeContextBlock
from nanobot.session.summary import SessionSummary
class _FakeDatetime(real_datetime): class _FakeDatetime(real_datetime):
@@ -120,7 +121,10 @@ def test_session_summary_replaces_matching_recent_history_entry(tmp_path) -> Non
builder.memory.append_history("another session event", session_key=session_key) builder.memory.append_history("another session event", session_key=session_key)
summary_cursor = builder.memory.append_history(overview, 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}" summary = SessionSummary(
text=overview,
last_active=real_datetime(2026, 8, 19, 10, 0),
)
prompt = builder.build_system_prompt( prompt = builder.build_system_prompt(
session_key=session_key, session_key=session_key,
@@ -7,6 +7,7 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
from nanobot.session.manager import replay_max_messages_for_context from nanobot.session.manager import replay_max_messages_for_context
from nanobot.session.summary import SessionSummary
def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop: def _make_loop(tmp_path, *, estimated_tokens: int, context_window_tokens: int) -> AgentLoop:
@@ -202,7 +203,7 @@ async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path,
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test") reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert pending is not None assert pending is not None
assert "User discussed project status." in pending assert pending.text == "User discussed project status."
# _last_summary persists for restart survival. # _last_summary persists for restart survival.
assert "_last_summary" in reloaded.metadata assert "_last_summary" in reloaded.metadata
@@ -212,7 +213,13 @@ async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> Non
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200) loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
session = loop.sessions.get_or_create("cli:test") session = loop.sessions.get_or_create("cli:test")
loop.auto_compact.prepare_session = MagicMock( loop.auto_compact.prepare_session = MagicMock(
return_value=(session, "Previous conversation summary: earlier context") return_value=(
session,
SessionSummary(
text="earlier context",
last_active=session.updated_at,
),
)
) # type: ignore[method-assign] ) # type: ignore[method-assign]
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign] loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign] loop.schedule_background = lambda coro: coro.close() # type: ignore[method-assign]