fix(session): cap messages at persistence boundary
Bind SessionManager saves to the existing raw archive path so SDK imports and other bypass saves cannot persist more than the file cap without archiving unconsolidated overflow. Add an SDK regression test that exercises the real ingest path. Refs #4787
This commit is contained in:
@@ -369,6 +369,7 @@ class AgentLoop:
|
||||
|
||||
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
|
||||
self.sessions = session_manager or SessionManager(workspace)
|
||||
self.sessions.set_file_cap_archiver(self.context.memory.raw_archive)
|
||||
self.tools = ToolRegistry()
|
||||
# One file-read/write tracker per logical session. The tool registry is
|
||||
# shared by this loop, so tools resolve the active state via contextvars.
|
||||
|
||||
@@ -12,7 +12,7 @@ from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from loguru import logger
|
||||
@@ -427,6 +427,7 @@ class SessionManager:
|
||||
# Preserve identity for sessions held by active callers without retaining idle ones.
|
||||
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
|
||||
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
|
||||
self._file_cap_archiver: Callable[..., None] | None = None
|
||||
|
||||
def _remember(self, session: Session) -> None:
|
||||
"""Keep recent sessions strongly cached without duplicating live objects."""
|
||||
@@ -448,6 +449,10 @@ class SessionManager:
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def set_file_cap_archiver(self, archiver: Callable[..., None]) -> None:
|
||||
"""Archive unconsolidated overflow whenever a session is persisted."""
|
||||
self._file_cap_archiver = archiver
|
||||
|
||||
@staticmethod
|
||||
def safe_key(key: str) -> str:
|
||||
"""Public helper used by HTTP handlers to map an arbitrary key to a stable filename stem."""
|
||||
@@ -669,6 +674,14 @@ class SessionManager:
|
||||
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
|
||||
the most recent writes.
|
||||
"""
|
||||
if self._file_cap_archiver is not None:
|
||||
session.enforce_file_cap(
|
||||
on_archive=lambda messages: self._file_cap_archiver(
|
||||
messages,
|
||||
session_key=session.key,
|
||||
)
|
||||
)
|
||||
|
||||
path = self._get_session_path(session.key)
|
||||
tmp_path = path.with_suffix(".jsonl.tmp")
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from nanobot.runtime_context import (
|
||||
RuntimeContextBlock,
|
||||
append_runtime_context,
|
||||
)
|
||||
from nanobot.session.manager import FILE_MAX_MESSAGES
|
||||
from nanobot.utils.llm_runtime import runtime_from_provider_snapshot
|
||||
|
||||
|
||||
@@ -1205,6 +1206,27 @@ async def test_sessions_ingest_imports_transcript_without_running_model(tmp_path
|
||||
assert reloaded.messages == snapshot.messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_ingest_archives_overflow_at_persistence_boundary(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
bot = Nanobot.from_config(config_path, workspace=tmp_path)
|
||||
|
||||
snapshot = await bot.sessions.ingest(
|
||||
"sdk:overflow",
|
||||
[
|
||||
{"role": "user", "content": f"message-{index}"}
|
||||
for index in range(FILE_MAX_MESSAGES + 1)
|
||||
],
|
||||
)
|
||||
|
||||
assert len(snapshot.messages) == FILE_MAX_MESSAGES
|
||||
assert snapshot.messages[0]["content"] == "message-1"
|
||||
history = bot.memory.read_history(session_key="sdk:overflow")
|
||||
assert len(history) == 1
|
||||
assert "[RAW] 1 messages" in history[0]["content"]
|
||||
assert "message-0" in history[0]["content"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sessions_ingest_validates_message_shape(tmp_path):
|
||||
config_path = _write_config(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user