88a1dab810
---ci--- phase: 7 milestone: v0.2 status: complete requirements: covered: [REQ-2-001, REQ-2-002, REQ-2-003, REQ-2-004, REQ-2-005, REQ-2-006, REQ-2-007, REQ-2-008, REQ-2-009, REQ-2-010, REQ-2-011, REQ-2-012] partial: [] ---/ci--- Milestone v0.2 (ai-tutor-architecture) merged to main. Escalation record (audit remediation, durable): P1 executor delegation failed twice (empty subagent results, zero files created); auto-resolved at full autonomy to inline execution with identical plan fidelity (commit 3271373, reflog-only after phase branch squash-delete).
99 lines
3.5 KiB
Python
99 lines
3.5 KiB
Python
"""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
|