Source code for litestar_mcp.agent.controller

"""Litestar controller providing HTTP and SSE streaming endpoints for agents."""

from contextlib import suppress
from typing import TYPE_CHECKING, Any
from uuid import uuid4

import anyio
import msgspec
from litestar import Controller, get, post
from litestar.connection import Request  # noqa: TC002
from litestar.di import NamedDependency  # noqa: TC002
from litestar.exceptions import HTTPException, NotFoundException
from litestar.response import ServerSentEvent
from litestar.status_codes import HTTP_200_OK

from litestar_mcp.agent.guards import BudgetExceededError
from litestar_mcp.agent.runtime import AgentRuntime, TurnRequest
from litestar_mcp.agent.spec import AgentMessage  # noqa: TC001
from litestar_mcp.agent.streaming import AgentStreamFrame
from litestar_mcp.core._streaming import (
    DEFAULT_STREAM_CLEANUP_TIMEOUT,
    StreamCleanupMiddleware,
    prefetch_stream,
    start_stream,
)
from litestar_mcp.core.context import ToolContext

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from anyio.streams.memory import MemoryObjectSendStream
    from litestar.response.sse import ServerSentEventMessage

    from litestar_mcp.core._streaming import StreamOwner

__all__ = ("AgentChatController", "ChatRequest", "SessionHistory", "SessionNotFoundError", "TurnResult")


class SessionNotFoundError(NotFoundException):
    """Exception raised when a requested session is not found."""

    def __init__(self, session_id: str) -> None:
        """Initialize with session_id."""
        super().__init__(detail=f"Session {session_id!r} not found")


class ChatRequest(msgspec.Struct, kw_only=True, forbid_unknown_fields=True):
    """Body payload for agent chat streaming and turn creation."""

    message: "str"
    session_id: "str | None" = None
    dynamic_context: "str | None" = None


class TurnResult(msgspec.Struct, kw_only=True):
    """Aggregated turn result returned by POST /agent/turns."""

    session_id: "str"
    turn_id: "str"
    agent_name: "str"
    output: "str"
    messages: "list[AgentMessage]"
    token_usage: "dict[str, int]"


class SessionHistory(msgspec.Struct, kw_only=True):
    """Conversation history payload returned by GET /agent/sessions/{session_id}."""

    session_id: "str"
    messages: "list[AgentMessage]"


[docs] class AgentChatController(Controller): """SSE chat, synchronous turns, and owner-scoped history over an injected AgentRuntime.""" path = "/agent" ping_interval: "float" = 15.0 def _turn(self, request: "Request[Any, Any, Any]", data: "ChatRequest") -> "TurnRequest": """Build TurnRequest from connection request and payload data.""" return TurnRequest( user_message=data.message, session_id=data.session_id, dynamic_context=data.dynamic_context, context=ToolContext.from_connection(request), ) @post("/chat", middleware=[StreamCleanupMiddleware()], status_code=HTTP_200_OK) async def chat( self, request: "Request[Any, Any, Any]", data: "ChatRequest", runtime: "NamedDependency[AgentRuntime]", ) -> "ServerSentEvent": """Stream an agent turn over Server-Sent Events from a typed chat payload.""" turn_req = self._turn(request, data) encoders = request.route_handler.resolve_type_encoders() async def _producer(send: "MemoryObjectSendStream[AgentStreamFrame]") -> None: await runtime.produce(turn_req, send) owner: StreamOwner[AgentStreamFrame, None] = start_stream( request.scope, _producer, capacity=runtime.stream_buffer, cleanup_timeout=DEFAULT_STREAM_CLEANUP_TIMEOUT, ) try: first = await prefetch_stream(request.scope, request.receive, owner) except anyio.EndOfStream: first = None turn_id = first.turn_id if first is not None else uuid4().hex async def events() -> "AsyncIterator[ServerSentEventMessage]": try: if first is not None: yield first.to_sse_message(encoders) while True: frame: AgentStreamFrame | None = None with anyio.move_on_after(self.ping_interval) as idle: try: frame = await owner.receiver.receive() except anyio.EndOfStream: break if idle.cancelled_caught or frame is None: yield AgentStreamFrame(event_type="ping", turn_id=turn_id).to_sse_message(encoders) continue yield frame.to_sse_message(encoders) with suppress(Exception): await owner.result() finally: await owner.close() return ServerSentEvent(events()) @post("/turns", status_code=HTTP_200_OK) async def create_turn( self, request: "Request[Any, Any, Any]", data: "ChatRequest", runtime: "NamedDependency[AgentRuntime]", ) -> "TurnResult": """Execute an agent turn synchronously returning final TurnResult.""" turn_req = self._turn(request, data) try: res = await runtime.run_turn(turn_req) except BudgetExceededError as exc: raise HTTPException(status_code=504, detail=str(exc)) from exc return TurnResult( session_id=res.session_id, turn_id=res.turn_id, agent_name=res.agent_name, output=res.output, messages=res.messages, token_usage=res.token_usage, ) @get("/sessions/{session_id:str}") async def get_session( self, request: "Request[Any, Any, Any]", session_id: "str", runtime: "NamedDependency[AgentRuntime]", ) -> "SessionHistory": """Return conversation history for a session scoped to caller identity.""" tool_ctx = ToolContext.from_connection(request) messages = await runtime.get_history(tool_ctx, session_id) if messages is None: raise SessionNotFoundError(session_id) return SessionHistory( session_id=session_id, messages=messages, )