"""SessionStore — protocol + in-memory implementation (D-019). Protocol is DB-migration-ready (A-003): swap InMemorySessionStore for a Redis/PG-backed implementation without touching the API layer. Sessions are agent-scoped: switching agents starts a new session ID (avoids persona bleed, A-007). History windowing happens here (last N messages), controlling token growth per session. """ import asyncio from collections import OrderedDict from dataclasses import dataclass, field from typing import Protocol from ..llm.types import Message DEFAULT_WINDOW = 20 DEFAULT_MAX_SESSIONS = 500 @dataclass class AgentSession: session_id: str agent: str learner_id: str = "seed-learner-1" messages: list[Message] = field(default_factory=list) class SessionStore(Protocol): def get(self, session_id: str) -> AgentSession | None: ... def create( self, session_id: str, agent: str, learner_id: str = "seed-learner-1" ) -> AgentSession: ... def append(self, session_id: str, message: Message) -> None: ... def history_window( self, session_id: str, max_messages: int = DEFAULT_WINDOW ) -> list[Message]: ... def delete(self, session_id: str) -> None: ... class InMemorySessionStore: """asyncio.Lock-guarded dict with 20-message windows and 500-cap LRU eviction.""" def __init__( self, window: int = DEFAULT_WINDOW, max_sessions: int = DEFAULT_MAX_SESSIONS, ) -> None: self._sessions: OrderedDict[str, AgentSession] = OrderedDict() self._lock = asyncio.Lock() self._window = window self._max_sessions = max_sessions async def get(self, session_id: str) -> AgentSession | None: async with self._lock: session = self._sessions.get(session_id) if session is not None: self._sessions.move_to_end(session_id) # LRU touch return session async def create( self, session_id: str, agent: str, learner_id: str = "seed-learner-1" ) -> AgentSession: async with self._lock: session = AgentSession(session_id=session_id, agent=agent, learner_id=learner_id) self._sessions[session_id] = session self._evict_locked() return session async def append(self, session_id: str, message: Message) -> None: async with self._lock: session = self._sessions.get(session_id) if session is None: raise KeyError(f"unknown session {session_id!r}") session.messages.append(message) # Bound stored history too (window bounds replay, not storage): # keep at most 2x window so retries/recent context survive. if len(session.messages) > self._window * 2: del session.messages[: len(session.messages) - self._window * 2] self._sessions.move_to_end(session_id) async def history_window( self, session_id: str, max_messages: int = DEFAULT_WINDOW ) -> list[Message]: async with self._lock: session = self._sessions.get(session_id) if session is None: raise KeyError(f"unknown session {session_id!r}") return list(session.messages[-max_messages:]) async def delete(self, session_id: str) -> None: async with self._lock: self._sessions.pop(session_id, None) def _evict_locked(self) -> None: while len(self._sessions) > self._max_sessions: self._sessions.popitem(last=False) # evict least-recently-used