Files
2026-07-13 13:22:34 +08:00

243 lines
7.6 KiB
Python

import json
import os
import signal
import tempfile
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from mlflow.assistant.types import Message
SESSION_DIR = Path(tempfile.gettempdir()) / "mlflow-assistant-sessions"
@dataclass
class Session:
"""Session state for assistant conversations."""
context: dict[str, Any] = field(default_factory=dict)
messages: list[Message] = field(default_factory=list)
pending_message: Message | None = None
provider_session_id: str | None = None
working_dir: Path | None = None # Working directory for the session (e.g. project path)
# tool_call_id -> "allow" | "deny": a decision awaiting the next stream so a
# turn paused at a permission prompt can resume. Set by the resume endpoint,
# consumed (and cleared) by the stream.
pending_tool_decisions: dict[str, Literal["allow", "deny"]] = field(default_factory=dict)
def add_message(self, role: str, content: str) -> None:
"""Add a message to the session history.
Args:
role: Role of the message sender (user, assistant, system)
content: Text content of the message
"""
self.messages.append(Message(role=role, content=content))
def set_pending_message(self, role: str, content: str) -> None:
"""Set the pending message to be processed.
Args:
role: Role of the message sender
content: Text content of the message
"""
self.pending_message = Message(role=role, content=content)
def clear_pending_message(self) -> Message | None:
"""Clear and return the pending message.
Returns:
The pending message, or None if no message was pending
"""
msg = self.pending_message
self.pending_message = None
return msg
def update_context(self, context: dict[str, Any]) -> None:
"""Update session context.
Args:
context: Context data to merge into session context
"""
self.context.update(context)
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization.
Returns:
Dictionary representation of session
"""
return {
"context": self.context,
"messages": [msg.model_dump() for msg in self.messages],
"pending_message": self.pending_message.model_dump() if self.pending_message else None,
"provider_session_id": self.provider_session_id,
"working_dir": self.working_dir.as_posix() if self.working_dir else None,
"pending_tool_decisions": self.pending_tool_decisions,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Session":
"""Load from dictionary.
Args:
data: Dictionary representation of session
Returns:
Session instance
"""
messages = [Message.model_validate(m) for m in data.get("messages", [])]
pending = data.get("pending_message")
pending_msg = Message.model_validate(pending) if pending else None
return cls(
context=data.get("context", {}),
messages=messages,
pending_message=pending_msg,
provider_session_id=data.get("provider_session_id"),
working_dir=Path(data.get("working_dir")) if data.get("working_dir") else None,
pending_tool_decisions=data.get("pending_tool_decisions") or {},
)
class SessionManager:
"""Manages session storage and retrieval.
Provides static methods for session operations, keeping
Session as a simple data container.
"""
@staticmethod
def validate_session_id(session_id: str) -> None:
"""Validate that session_id is a valid UUID to prevent path traversal.
Args:
session_id: Session ID to validate
Raises:
ValueError: If session ID is not a valid UUID
"""
try:
uuid.UUID(session_id)
except (ValueError, TypeError) as e:
raise ValueError("Invalid session ID format") from e
@staticmethod
def get_session_file(session_id: str) -> Path:
"""Get the file path for a session.
Args:
session_id: Session ID
Returns:
Path to session file
Raises:
ValueError: If session ID is invalid
"""
SessionManager.validate_session_id(session_id)
return SESSION_DIR / f"{session_id}.json"
@staticmethod
def save(session_id: str, session: Session) -> None:
"""Save session to disk atomically.
Args:
session_id: Session ID
session: Session to save
Raises:
ValueError: If session ID is invalid
"""
SessionManager.validate_session_id(session_id)
SESSION_DIR.mkdir(parents=True, exist_ok=True)
session_file = SessionManager.get_session_file(session_id)
# Write to temp file, then rename (atomic on POSIX)
fd, temp_path = tempfile.mkstemp(dir=SESSION_DIR, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(session.to_dict(), f)
os.replace(temp_path, session_file)
except Exception:
os.unlink(temp_path)
raise
@staticmethod
def load(session_id: str) -> Session | None:
"""Load session from disk. Returns a Session instance, or None if not found"""
try:
session_file = SessionManager.get_session_file(session_id)
except ValueError:
return None
if not session_file.exists():
return None
data = json.loads(session_file.read_text())
return Session.from_dict(data)
@staticmethod
def create(context: dict[str, Any] | None = None, working_dir: Path | None = None) -> Session:
"""Create a new session.
Args:
context: Initial context data, or None
working_dir: Working directory for the session
Returns:
New Session instance
"""
return Session(context=context or {}, working_dir=working_dir)
def get_process_file(session_id: str) -> Path:
"""Get the file path for storing process PID."""
SessionManager.validate_session_id(session_id)
return SESSION_DIR / f"{session_id}.process.json"
def save_process_pid(session_id: str, pid: int) -> None:
"""Save process PID to file for cancellation support."""
SESSION_DIR.mkdir(parents=True, exist_ok=True)
process_file = get_process_file(session_id)
process_file.write_text(json.dumps({"pid": pid}))
def get_process_pid(session_id: str) -> int | None:
try:
process_file = get_process_file(session_id)
except ValueError:
return None
if not process_file.exists():
return None
data = json.loads(process_file.read_text())
return data.get("pid")
def clear_process_pid(session_id: str) -> None:
try:
process_file = get_process_file(session_id)
except ValueError:
return
if process_file.exists():
process_file.unlink()
def terminate_session_process(session_id: str) -> bool:
"""Terminate the process associated with a session.
Args:
session_id: Session ID
Returns:
True if process was terminated, False otherwise
"""
if pid := get_process_pid(session_id):
try:
os.kill(pid, signal.SIGTERM)
clear_process_pid(session_id)
return True
except (ProcessLookupError, PermissionError):
clear_process_pid(session_id)
return False