Files
smart-support/backend/app/session_manager.py
Yaojia Wang 33488fd634 feat: complete phase 1 -- core framework with chat loop, agents, and React UI
Backend:
- FastAPI WebSocket /ws endpoint with streaming via LangGraph astream
- LangGraph Supervisor connecting 3 mock agents (order_lookup, order_actions, fallback)
- YAML Agent Registry with Pydantic validation and immutable configs
- PostgresSaver checkpoint persistence via langgraph-checkpoint-postgres
- Session TTL with 30-min sliding window and interrupt extension
- LLM provider abstraction (Anthropic/OpenAI/Google)
- Token usage + cost tracking callback handler
- Input validation: message size cap, thread_id format, content length
- Security: no hardcoded defaults, startup API key validation, no input reflection

Frontend:
- React 19 + TypeScript + Vite chat UI
- WebSocket hook with reconnect + exponential backoff
- Streaming token display with agent attribution
- Interrupt approval/reject UI for write operations
- Collapsible tool call viewer

Testing:
- 87 unit tests, 87% coverage (exceeds 80% requirement)
- Ruff lint + format clean

Infrastructure:
- Docker Compose (PostgreSQL 16 + backend)
- pyproject.toml with full dependency management
2026-03-30 00:54:21 +02:00

79 lines
2.6 KiB
Python

"""Session TTL management with sliding window and interrupt extension."""
from __future__ import annotations
import time
from dataclasses import dataclass
@dataclass(frozen=True)
class SessionState:
thread_id: str
last_activity: float
has_pending_interrupt: bool
class SessionManager:
"""Manages session TTL with sliding window and interrupt extensions.
- Each message resets the TTL (sliding window).
- A pending interrupt suspends expiration until resolved.
"""
def __init__(self, session_ttl_seconds: int = 1800) -> None:
self._session_ttl = session_ttl_seconds
self._sessions: dict[str, SessionState] = {}
def touch(self, thread_id: str) -> SessionState:
"""Update last activity for a session (resets sliding window)."""
existing = self._sessions.get(thread_id)
new_state = SessionState(
thread_id=thread_id,
last_activity=time.time(),
has_pending_interrupt=existing.has_pending_interrupt if existing else False,
)
self._sessions = {**self._sessions, thread_id: new_state}
return new_state
def is_expired(self, thread_id: str) -> bool:
"""Check if a session has expired."""
state = self._sessions.get(thread_id)
if state is None:
return True
if state.has_pending_interrupt:
return False
elapsed = time.time() - state.last_activity
return elapsed > self._session_ttl
def extend_for_interrupt(self, thread_id: str) -> SessionState:
"""Mark session as having a pending interrupt (suspends TTL)."""
existing = self._sessions.get(thread_id)
if existing is None:
return self.touch(thread_id)
new_state = SessionState(
thread_id=thread_id,
last_activity=existing.last_activity,
has_pending_interrupt=True,
)
self._sessions = {**self._sessions, thread_id: new_state}
return new_state
def resolve_interrupt(self, thread_id: str) -> SessionState:
"""Remove interrupt extension and reset activity timer."""
new_state = SessionState(
thread_id=thread_id,
last_activity=time.time(),
has_pending_interrupt=False,
)
self._sessions = {**self._sessions, thread_id: new_state}
return new_state
def get_state(self, thread_id: str) -> SessionState | None:
return self._sessions.get(thread_id)
def remove(self, thread_id: str) -> None:
self._sessions = {k: v for k, v in self._sessions.items() if k != thread_id}