fix(memory): archive short idle sessions for Dream
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.memory import Consolidator
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class AutoCompact:
|
||||
_RECENT_SUFFIX_MESSAGES = 8
|
||||
_RECENT_SUFFIX_MESSAGES = MIN_COMPACTED_REPLAY_MESSAGES
|
||||
_INTERNAL_SESSION_PREFIXES = ("dream:",)
|
||||
|
||||
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
||||
@@ -45,25 +45,9 @@ class AutoCompact:
|
||||
return False
|
||||
return idle_seconds >= self._ttl * 60
|
||||
|
||||
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||
def _has_unarchived_messages(self, key: str) -> bool:
|
||||
session = self.sessions.get_or_create(key)
|
||||
tail = list(session.messages[session.last_consolidated:])
|
||||
if not tail:
|
||||
return False
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=tail,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(
|
||||
self._RECENT_SUFFIX_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||
return bool(messages_to_remove)
|
||||
return session.last_consolidated < len(session.messages)
|
||||
|
||||
@staticmethod
|
||||
def _format_summary(text: str, last_active: datetime) -> str:
|
||||
@@ -88,7 +72,7 @@ class AutoCompact:
|
||||
if key in active_session_keys:
|
||||
continue
|
||||
updated_at = info.get("updated_at")
|
||||
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||
if self._is_expired(updated_at, now) and self._has_unarchived_messages(key):
|
||||
session = self.sessions.get_or_create(key)
|
||||
try:
|
||||
runtime = resolve_runtime(session)
|
||||
|
||||
+36
-36
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.runtime_context import public_history_messages
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
||||
from nanobot.utils.gitstore import GitStore
|
||||
from nanobot.utils.helpers import (
|
||||
content_with_media_breadcrumbs,
|
||||
@@ -858,14 +858,13 @@ class Consolidator:
|
||||
return last_boundary
|
||||
|
||||
@staticmethod
|
||||
def _full_unconsolidated_history(
|
||||
def _full_replay_history(
|
||||
session: Session,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||
if unconsolidated_count <= 0:
|
||||
"""Return all messages that can reach the next model prompt."""
|
||||
if not session.messages:
|
||||
return []
|
||||
return session.get_history(max_messages=unconsolidated_count)
|
||||
return session.get_history(max_messages=len(session.messages))
|
||||
|
||||
@staticmethod
|
||||
def _replay_overflow_boundary(
|
||||
@@ -948,8 +947,8 @@ class Consolidator:
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
) -> tuple[int, str]:
|
||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||
history = self._full_unconsolidated_history(session)
|
||||
"""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")
|
||||
@@ -1160,42 +1159,37 @@ class Consolidator:
|
||||
session_key: str,
|
||||
*,
|
||||
runtime: LLMRuntime,
|
||||
max_suffix: int = 8,
|
||||
max_suffix: int = MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
) -> str | None:
|
||||
"""Archive an idle prefix and hide it from replay without deleting it."""
|
||||
"""Archive the full idle tail while keeping recent messages replayable.
|
||||
|
||||
``max_suffix`` remains accepted for SDK compatibility. Replay retention
|
||||
is now derived independently from archive progress using the project-wide
|
||||
compacted-session window.
|
||||
"""
|
||||
if max_suffix != MIN_COMPACTED_REPLAY_MESSAGES:
|
||||
logger.debug(
|
||||
"Idle-session compact for {} uses the fixed replay window ({}, requested {})",
|
||||
session_key,
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
max_suffix,
|
||||
)
|
||||
lock = self.get_lock(session_key)
|
||||
async with lock:
|
||||
self.sessions.invalidate(session_key)
|
||||
session = self.sessions.get_or_create(session_key)
|
||||
|
||||
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||
if not messages_to_summarize:
|
||||
self.sessions.save(session)
|
||||
return ""
|
||||
|
||||
probe = Session(
|
||||
key=session.key,
|
||||
messages=messages_to_summarize.copy(),
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
metadata={},
|
||||
last_consolidated=0,
|
||||
)
|
||||
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||
visible_suffix = probe.messages
|
||||
messages_to_remove = result.dropped
|
||||
|
||||
if not messages_to_remove:
|
||||
self.sessions.save(session)
|
||||
archive_start = session.last_consolidated
|
||||
messages_to_archive = list(session.messages[archive_start:])
|
||||
if not messages_to_archive:
|
||||
return ""
|
||||
|
||||
last_active = session.updated_at
|
||||
# The visible suffix informs the summary but stays out of raw fallback.
|
||||
archive_end = archive_start + len(messages_to_archive)
|
||||
summary = await self.archive(
|
||||
messages_to_remove,
|
||||
messages_to_archive,
|
||||
runtime=runtime,
|
||||
session_key=session_key,
|
||||
summary_messages=messages_to_summarize,
|
||||
)
|
||||
|
||||
if summary and summary != "(nothing)":
|
||||
@@ -1204,16 +1198,22 @@ class Consolidator:
|
||||
"last_active": last_active.isoformat(),
|
||||
}
|
||||
|
||||
# Preserve history and advance only the replay boundary.
|
||||
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||
# A turn can append while the provider call is in flight. Advance only
|
||||
# through the captured batch so new messages remain eligible next time.
|
||||
session.last_consolidated = archive_end
|
||||
session.provider_state = None
|
||||
self.sessions.save(session)
|
||||
|
||||
visible = session.get_history(
|
||||
max_messages=MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
|
||||
session_key,
|
||||
len(messages_to_remove),
|
||||
len(visible_suffix),
|
||||
len(messages_to_archive),
|
||||
len(visible),
|
||||
len(session.messages),
|
||||
bool(summary),
|
||||
)
|
||||
|
||||
@@ -36,6 +36,7 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
||||
FILE_MAX_MESSAGES = 2000
|
||||
SESSION_CACHE_MAX_SIZE = 128
|
||||
MIN_REPLAY_MAX_MESSAGES = 120
|
||||
MIN_COMPACTED_REPLAY_MESSAGES = 8
|
||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||
@@ -191,19 +192,37 @@ class Session:
|
||||
extend_to_user: bool = False,
|
||||
include_runtime_context: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
"""Return recent replayable messages for LLM input.
|
||||
|
||||
History is sliced by message count first (``max_messages``), then by
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
replay_start = self.last_consolidated
|
||||
if replay_start:
|
||||
# ``last_consolidated`` is archive progress, not a replay boundary.
|
||||
# Keep a small raw suffix for continuity, extending back to the user
|
||||
# that started an assistant/tool sequence when necessary.
|
||||
recent_start = recent_message_start_index(
|
||||
self.messages,
|
||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
||||
extend_to_user=True,
|
||||
)
|
||||
replay_start = min(replay_start, recent_start)
|
||||
|
||||
replayable = self.messages[replay_start:]
|
||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
||||
start_idx = recent_message_start_index(
|
||||
unconsolidated,
|
||||
max_messages,
|
||||
extend_to_user=extend_to_user,
|
||||
)
|
||||
sliced = unconsolidated[start_idx:]
|
||||
unarchived_count = len(self.messages) - self.last_consolidated
|
||||
if replay_start < self.last_consolidated and unarchived_count < max_messages:
|
||||
# The archived replay suffix can exceed the nominal count when one
|
||||
# tool-heavy turn spans the boundary. Preserve that complete turn.
|
||||
start_idx = 0
|
||||
else:
|
||||
start_idx = recent_message_start_index(
|
||||
replayable,
|
||||
max_messages,
|
||||
extend_to_user=extend_to_user,
|
||||
)
|
||||
sliced = replayable[start_idx:]
|
||||
|
||||
# Avoid starting mid-turn when possible, except for proactive
|
||||
# assistant deliveries that the user may be replying to.
|
||||
|
||||
Reference in New Issue
Block a user