Files
nextcraft/apps/ai-service/ai_service/agents/session.py
T
CIAgent 88a1dab810 docs(milestone): complete v0.2-ai-tutor-architecture
---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).
2026-09-11 17:34:50 +00:00

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