# core/models.py """ Pure data models — no database logic, no side effects. These are simple datacontainers. All persistence is handled by SessionManager. """ from dataclasses import dataclass from typing import Dict, List, Any, Optional, TYPE_CHECKING from src.tool_approval_scopes import ( CHAT_SESSION_APPROVAL_CONTEXT_MARKER, ) from src.message_metadata import sanitize_projected_message_metadata from src.tool_approval_provenance import has_chat_session_approval_grant if TYPE_CHECKING: from .session_manager import SessionManager # Module-level session manager singleton (single source of truth) _SESSION_MANAGER_INSTANCE: Optional["SessionManager"] = None def set_session_manager_instance(manager: "SessionManager"): """Set the global SessionManager singleton.""" global _SESSION_MANAGER_INSTANCE _SESSION_MANAGER_INSTANCE = manager def get_session_manager_instance() -> Optional["SessionManager"]: """Get the global SessionManager singleton.""" return _SESSION_MANAGER_INSTANCE # Keep legacy name for backward compatibility set_session_manager = set_session_manager_instance get_session_manager = get_session_manager_instance def _history_grants_chat_session_approval( history: List["ChatMessage"], session_id: str, ) -> bool: """Compatibility shim: durable history is never an authority source. Keep the old private symbol for downstream imports, but deliberately return false. The live projection checks the separate server-owned grant table. """ return False @dataclass class ChatMessage: """A single chat message.""" role: str content: str metadata: Optional[Dict[str, Any]] = None def to_dict(self) -> Dict[str, Any]: """Convert to dict for API responses.""" result = {"role": self.role, "content": self.content} if self.metadata: result["metadata"] = self.metadata return result def get(self, key: str, default=None): """Dict-like access for compatibility.""" return getattr(self, key, default) @dataclass class Session: """A chat session — pure data container. ``.history`` is the authoritative mutable message list. Callers may read, append, pop, or reassign it directly — these changes take effect immediately. ``_history`` remains a compatibility alias that always resolves to the authoritative ``history`` list. Each session gets its own unique history list at construction time (the dataclass default is never shared between instances). """ id: str name: str endpoint_url: str model: str rag: bool = False archived: bool = False headers: Optional[Dict[str, str]] = None history: List[ChatMessage] = None owner: Optional[str] = None model_endpoint_id: Optional[str] = None endpoint_provenance: Optional[str] = None is_important: bool = False message_count: int = 0 def __post_init__(self): if self.headers is None: self.headers = {} # Ensure each session gets its OWN list (not the shared dataclass default) if self.history is None: self.history = [] @property def _history(self) -> List[ChatMessage]: """Compatibility alias for callers that still reference ``_history``.""" return self.history @_history.setter def _history(self, messages: List[ChatMessage]): self.history = messages def add_message(self, message: ChatMessage): """ Add a message to this session. Appends to the authoritative history list and increments message_count. Delegates to SessionManager for persistence if available. """ self.history.append(message) self.message_count = len(self.history) # Delegate to session manager for persistence if _SESSION_MANAGER_INSTANCE: _SESSION_MANAGER_INSTANCE._persist_message(self.id, message) def get_context_messages(self) -> List[Dict[str, Any]]: """Get messages in format for LLM API. Slash-command / setup replies are persisted to history so they render in the transcript, but they are UI chatter (e.g. ``/setup ...`` and its status lines) the user never meant as conversation. They carry ``metadata.source == "slash"``; exclude them here so they never reach the model. Display/history-load paths use the raw ``history`` and are unaffected. """ messages = [] for msg in self.history: raw_metadata = getattr(msg, "metadata", None) if isinstance(raw_metadata, dict) and raw_metadata.get("source") == "slash": continue projected = msg.to_dict() metadata = projected.get("metadata") if not isinstance(metadata, dict): # Old or malformed durable rows must not make context # projection fail, and non-mapping metadata has no trusted # fields that belong in the model context. projected.pop("metadata", None) messages.append(projected) continue metadata = sanitize_projected_message_metadata(metadata) if metadata: projected["metadata"] = metadata else: projected.pop("metadata", None) messages.append(projected) if not has_chat_session_approval_grant(self.id, self.owner): return messages # Keep the grant close to the latest user request so route-neutral # compaction/trimming preserves it. Copy the metadata instead of # mutating the durable transcript object. for index in range(len(messages) - 1, -1, -1): if messages[index].get("role") != "user": continue message = dict(messages[index]) metadata = dict(message.get("metadata") or {}) metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True message["metadata"] = metadata messages[index] = message break return messages def get(self, key: str, default=None): """Dict-like access for compatibility.""" return getattr(self, key, default) def __getitem__(self, key: str): """Allow session['field'] syntax.""" return getattr(self, key)