"""Auto compact: proactive compression of idle sessions to reduce token cost and latency.""" from __future__ import annotations from collections.abc import Collection from datetime import datetime 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, session_summary_from_metadata if TYPE_CHECKING: from nanobot.agent.memory import Consolidator from nanobot.utils.llm_runtime import LLMRuntime class AutoCompact: _RECENT_SUFFIX_MESSAGES = MIN_COMPACTED_REPLAY_MESSAGES _INTERNAL_SESSION_PREFIXES = ("dream:",) def __init__(self, sessions: SessionManager, consolidator: Consolidator, session_ttl_minutes: int = 0): self.sessions = sessions self.consolidator = consolidator self._ttl = session_ttl_minutes self._archiving: set[str] = set() self._summaries: dict[str, SessionSummary] = {} def _is_expired(self, ts: datetime | str | None, now: datetime | None = None) -> bool: if self._ttl <= 0 or not ts: return False try: if isinstance(ts, str): ts = datetime.fromisoformat(ts) current = now or datetime.now() if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None: idle_seconds = current.timestamp() - ts.timestamp() else: idle_seconds = (current - ts).total_seconds() except (OSError, OverflowError, TypeError, ValueError): # list_sessions() forwards raw persisted metadata; an unusable value # must not escape the idle scan and stop the agent loop. return False return idle_seconds >= self._ttl * 60 def _has_unarchived_messages(self, key: str) -> bool: session = self.sessions.get_or_create(key) return session.last_archived < len(session.messages) @classmethod def _is_internal_session(cls, key: str) -> bool: return key.startswith(cls._INTERNAL_SESSION_PREFIXES) def check_expired( self, schedule_background: Callable[[Coroutine[Any, Any, None]], None], resolve_runtime: Callable[[Session], LLMRuntime], active_session_keys: Collection[str] = (), ) -> None: """Schedule archival for idle sessions, skipping those with in-flight agent tasks.""" now = datetime.now() for info in self.sessions.list_sessions(): key = info.get("key", "") if not key or self._is_internal_session(key) or key in self._archiving: continue if key in active_session_keys: continue updated_at = info.get("updated_at") 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) except (KeyError, ValueError): # Invalid session selections remain recoverable through /model. continue self._archiving.add(key) schedule_background(self._archive(key, runtime=runtime)) async def _archive(self, key: str, *, runtime: LLMRuntime) -> None: if self._is_internal_session(key): self._archiving.discard(key) return try: summary = await self.consolidator.compact_idle_session( key, runtime=runtime, max_suffix=self._RECENT_SUFFIX_MESSAGES, ) if summary and summary != "(nothing)": session = self.sessions.get_or_create(key) stored = session_summary_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, SessionSummary | None]: if self._is_internal_session(key): self._archiving.discard(key) self._summaries.pop(key, None) return session, None if key in self._archiving or self._is_expired(session.updated_at): logger.info("Auto-compact: reloading session {} (archiving={})", key, key in self._archiving) session = self.sessions.get_or_create(key) # Hot path: summary from in-memory dict (process hasn't restarted). entry = self._summaries.pop(key, None) if entry: 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. return session, session_summary_from_metadata( session.metadata, fallback_last_active=session.updated_at, )