Source code for litestar_mcp.agent.runtime

"""Guarded agent runtime managing multi-turn tool calling, delegation, and telemetry."""

import inspect
import logging
import math
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4

import anyio
import msgspec

from litestar_mcp.agent.guards import BudgetExceededError, TurnBudget
from litestar_mcp.agent.models import GoogleGenAIClient, ModelClient, ModelDelta
from litestar_mcp.agent.sessions import ANONYMOUS_OWNER, MemorySessionStore, SessionStore
from litestar_mcp.agent.spec import Agent, AgentGroup, AgentMessage
from litestar_mcp.agent.streaming import AgentStreamFrame
from litestar_mcp.core.observability import SpanManager
from litestar_mcp.core.tools import Tool, ToolCall, ToolResult, execute_tool_calls

if TYPE_CHECKING:
    from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream

    from litestar_mcp.core.context import ToolContext

CompactionHook = Callable[[list[AgentMessage]], Awaitable[list[AgentMessage]] | list[AgentMessage]]

__all__ = ("AgentRuntime", "CompactionHook", "TurnRequest", "TurnResponse")

_logger = logging.getLogger(__name__)


@dataclass(slots=True)
class TurnRequest:
    """Input parameters for one agent turn execution."""

    user_message: "str" = ""
    session_id: "str | None" = None
    turn_id: "str | None" = None
    dynamic_context: "str | None" = None
    context: "ToolContext | None" = None


@dataclass(slots=True)
class TurnResponse:
    """Aggregated result of a completed agent turn."""

    session_id: "str"
    turn_id: "str"
    output: "str"
    agent_name: "str"
    messages: "list[AgentMessage]" = field(default_factory=list)
    token_usage: "dict[str, int]" = field(default_factory=dict)

    @property
    def content(self) -> "str":
        """Alias for output text produced by the turn."""
        return self.output


async def _next_before(
    iterator: "AsyncIterator[ModelDelta]",
    deadline: "float",
) -> "ModelDelta | None":
    """Await one model delta under the turn deadline without spanning a yield."""
    with anyio.fail_after(max(deadline - anyio.current_time(), 0.0)):
        return await anext(iterator, None)


def _process_delta(
    delta: "ModelDelta",
    active_agent: "Agent",
    turn_id: "str",
    turn_text: "list[str]",
    tool_calls: "list[dict[str, Any]]",
    token_usage: "dict[str, int]",
) -> "AgentStreamFrame | None":
    """Process an incremental model delta and update turn state."""
    if delta.event_type == "delta" and delta.text:
        turn_text.append(delta.text)
        return AgentStreamFrame(
            turn_id=turn_id,
            event_type="delta",
            text=delta.text,
            agent_name=active_agent.name,
        )
    if delta.event_type == "thought" and delta.text:
        return AgentStreamFrame(
            turn_id=turn_id,
            event_type="thought",
            text=delta.text,
            agent_name=active_agent.name,
        )
    if delta.event_type == "tool_call" and delta.tool_name:
        call_info = {
            "name": delta.tool_name,
            "call_id": delta.call_id or "",
            "arguments": delta.arguments or {},
            "provider_metadata": delta.provider_metadata,
        }
        tool_calls.append(call_info)
        return AgentStreamFrame(
            turn_id=turn_id,
            event_type="tool_call",
            tool_name=delta.tool_name,
            call_id=delta.call_id,
            agent_name=active_agent.name,
            payload=delta.arguments or {},
        )
    if delta.event_type == "usage":
        if delta.prompt_tokens is not None:
            token_usage["prompt_tokens"] = token_usage.get("prompt_tokens", 0) + delta.prompt_tokens
        if delta.completion_tokens is not None:
            token_usage["completion_tokens"] = token_usage.get("completion_tokens", 0) + delta.completion_tokens
        if delta.thought_tokens is not None:
            token_usage["thought_tokens"] = token_usage.get("thought_tokens", 0) + delta.thought_tokens
    return None


def _check_agent_transfer(
    target: "Agent | AgentGroup",
    tool_calls: "list[dict[str, Any]]",
    turn_id: "str",
) -> "tuple[Agent | None, AgentStreamFrame | None]":
    """Check if any tool calls triggered agent transfer within an AgentGroup."""
    if isinstance(target, AgentGroup):
        for call in tool_calls:
            tc = ToolCall(
                name=call.get("name", ""),
                call_id=call.get("call_id", ""),
                arguments=call.get("arguments", {}),
            )
            new_agent = target.resolve_transfer(tc)
            if new_agent is not None:
                frame = AgentStreamFrame(
                    turn_id=turn_id,
                    event_type="agent_transfer",
                    agent_name=new_agent.name,
                    payload={"reason": tc.arguments.get("reason", "")},
                )
                return new_agent, frame
    return None, None


[docs] class AgentRuntime: """Runs agent turns with budgets, owner-scoped sessions, and streamed frames."""
[docs] def __init__( self, target: "Agent | AgentGroup", *, default_model: "ModelClient | None" = None, budget: "TurnBudget | None" = None, sessions: "SessionStore | None" = None, span_manager: "SpanManager | None" = None, compaction_hook: "CompactionHook | None" = None, stream_buffer: "int" = 64, ) -> "None": """Initialize runtime with target agent or group and execution policies.""" self.target = target self.default_model = default_model self.budget = budget or TurnBudget() self.sessions = sessions or MemorySessionStore() self.span_manager = span_manager or SpanManager() self.compaction_hook = compaction_hook self.stream_buffer = stream_buffer self._model_cache: dict[str, ModelClient] = {}
def _resolve_model(self, agent: "Agent") -> "ModelClient": """Resolve active model client for an agent.""" if isinstance(agent.model, ModelClient): return agent.model if isinstance(agent.model, str): client = self._model_cache.get(agent.model) if client is None: client = GoogleGenAIClient(model=agent.model) self._model_cache[agent.model] = client return client if self.default_model is not None: return self.default_model default_client = self._model_cache.get("") if default_client is None: default_client = GoogleGenAIClient() self._model_cache[""] = default_client return default_client async def _resolve_session(self, turn: "TurnRequest") -> "tuple[str, str, str]": """Resolve store owner, session id, and turn id.""" owner = turn.context.owner_key() if turn.context is not None else None if owner is not None: store_owner = owner sid = turn.session_id or uuid4().hex else: store_owner = ANONYMOUS_OWNER if turn.session_id: existing = await self.sessions.load(ANONYMOUS_OWNER, turn.session_id) sid = turn.session_id if existing is not None else uuid4().hex else: sid = uuid4().hex tid = turn.turn_id or uuid4().hex return store_owner, sid, tid
[docs] async def get_history( self, context: "ToolContext | None", session_id: "str", ) -> "list[AgentMessage] | None": """Return conversation history scoped to connection owner.""" owner = context.owner_key() if context is not None else None store_owner = owner if owner is not None else ANONYMOUS_OWNER return await self.sessions.load(store_owner, session_id)
async def _apply_compaction(self, messages: "list[AgentMessage]") -> "list[AgentMessage]": """Run history compaction hook if configured.""" if self.compaction_hook is None: return messages result = self.compaction_hook(messages) if inspect.isawaitable(result): return await result return result async def _execute_tools( self, tool_calls: "list[dict[str, Any]]", tools_map: "dict[str, Tool]", context: "Any", turn_id: "str", active_agent: "Agent", ) -> "tuple[list[dict[str, Any]], list[AgentStreamFrame]]": """Execute tool calls in parallel and build corresponding stream frames.""" calls = [ ToolCall( name=c["name"], call_id=c.get("call_id") or "", arguments=c.get("arguments") or {}, provider_metadata=c.get("provider_metadata") or {}, ) for c in tool_calls ] tool_spans = [ self.span_manager.start_tool_span( tool_name=call.name, call_id=call.call_id, ) for call in calls ] try: raw_results = await execute_tool_calls( calls, tools_map, context=context, ) tool_results = [ { "call_id": res.call_id, "name": res.name, "content": res.content, "is_error": res.is_error, } for res in raw_results ] finally: for s in tool_spans: self.span_manager.end_span(s) frames = [ AgentStreamFrame( turn_id=turn_id, event_type="tool_result", tool_name=res.name, call_id=res.call_id, agent_name=active_agent.name, payload={ "call_id": res.call_id, "name": res.name, "content": res.content, "is_error": res.is_error, }, ) for res in raw_results ] return tool_results, frames
[docs] async def produce( self, turn: "TurnRequest", send: "MemoryObjectSendStream[AgentStreamFrame]", ) -> "TurnResponse": """Execute turn loop as a producer coroutine emitting frames to the send stream.""" store_owner, sid, tid = await self._resolve_session(turn) seq_counter = 0 async def emit(frame: "AgentStreamFrame") -> "None": nonlocal seq_counter seq_counter += 1 stamped = msgspec.structs.replace(frame, seq=seq_counter) await send.send(stamped) active_agent = self.target.coordinator if isinstance(self.target, AgentGroup) else self.target group_name = self.target.coordinator.name if isinstance(self.target, AgentGroup) else None span = self.span_manager.start_agent_span( agent_name=active_agent.name, agent_group=group_name, session_id=sid, turn_id=tid, ) try: with self.span_manager.use(span): await emit( AgentStreamFrame( event_type="session", turn_id=tid, agent_name=active_agent.name, payload={"session_id": sid, "turn_id": tid}, ) ) deadline = anyio.current_time() + self.budget.timeout_seconds async with self.sessions.lock(store_owner, sid): history = await self.sessions.load(store_owner, sid) messages: list[AgentMessage] = list(history) if history is not None else [] if turn.user_message: messages.append(AgentMessage(role="user", content=turn.user_message)) token_usage: dict[str, int] = {"prompt_tokens": 0, "completion_tokens": 0} accumulated_text: list[str] = [] active_agent = await self._execute_turn_loop( active_agent=active_agent, messages=messages, turn=turn, tid=tid, deadline=deadline, emit=emit, accumulated_text=accumulated_text, token_usage=token_usage, ) await self.sessions.save(store_owner, sid, messages) output_text = "".join(accumulated_text) await emit( AgentStreamFrame( event_type="complete", turn_id=tid, agent_name=active_agent.name, payload={ "output": output_text, "token_usage": token_usage, }, ) ) self.span_manager.set_usage( span, token_usage.get("prompt_tokens", 0), token_usage.get("completion_tokens", 0), ) self.span_manager.end_span(span) return TurnResponse( session_id=sid, turn_id=tid, output=output_text, agent_name=active_agent.name, messages=messages, token_usage=token_usage, ) except TimeoutError as exc: budget_err = BudgetExceededError(f"Turn timed out after {self.budget.timeout_seconds}s") self.span_manager.end_span(span, error=budget_err) seq_counter += 1 err_frame = AgentStreamFrame( event_type="error", turn_id=tid, seq=seq_counter, agent_name=active_agent.name, payload={"error": str(budget_err)}, ) with suppress(anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError): send.send_nowait(err_frame) raise budget_err from exc except Exception as exc: self.span_manager.end_span(span, error=exc) seq_counter += 1 err_frame = AgentStreamFrame( event_type="error", turn_id=tid, seq=seq_counter, agent_name=active_agent.name, payload={"error": f"{type(exc).__name__}: {exc}"}, ) with suppress(anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError): send.send_nowait(err_frame) raise
[docs] @asynccontextmanager async def open_stream( self, turn: "TurnRequest", ) -> "AsyncIterator[MemoryObjectReceiveStream[AgentStreamFrame]]": """Open a background task group streaming frames to an in-memory receive stream.""" send, receive = anyio.create_memory_object_stream[AgentStreamFrame](self.stream_buffer) async with anyio.create_task_group() as tg: async def _runner() -> None: async with send: try: await self.produce(turn, send) except Exception: _logger.debug("Exception in stream producer", exc_info=True) tg.start_soon(_runner) try: async with receive: yield receive finally: tg.cancel_scope.cancel()
[docs] async def stream( self, turn: "TurnRequest", ) -> "AsyncIterator[AgentStreamFrame]": """Yield stream frames sequentially from open_stream.""" async with self.open_stream(turn) as stream_ctx: async for frame in stream_ctx: yield frame
[docs] async def run_turn(self, turn: "TurnRequest") -> "TurnResponse": """Run turn to completion and return TurnResponse.""" send, receive = anyio.create_memory_object_stream[AgentStreamFrame](math.inf) async with send, receive: return await self.produce(turn, send)
async def _execute_turn_loop( self, active_agent: Agent, messages: list[AgentMessage], turn: "TurnRequest", tid: str, deadline: float, emit: Callable[[AgentStreamFrame], Awaitable[None]], accumulated_text: list[str], token_usage: dict[str, int], ) -> Agent: """Run the multi-turn model calling and tool execution loop.""" completed_naturally = False total_tool_calls = 0 for _ in range(self.budget.max_turns): messages[:] = await self._apply_compaction(messages) model_client = self._resolve_model(active_agent) active_tools = ( self.target.tools_for(active_agent) if isinstance(self.target, AgentGroup) else active_agent.tool_set ) tools_map: dict[str, Tool] = {t.name: t for t in active_tools} instructions = active_agent.combined_instructions if turn.dynamic_context: instructions = f"{turn.dynamic_context}\n\n{instructions}" turn_text: list[str] = [] tool_calls: list[dict[str, Any]] = [] stream_iter = model_client.stream_turn( system_instruction=instructions, messages=messages, tools=list(tools_map.values()), ) await self._drain_stream(stream_iter, deadline, active_agent, tid, turn_text, tool_calls, token_usage, emit) complete_turn_text = "".join(turn_text) if complete_turn_text: accumulated_text.append(complete_turn_text) if not tool_calls: messages.append( AgentMessage( role="assistant", content=complete_turn_text, agent_name=active_agent.name, ) ) completed_naturally = True break total_tool_calls += len(tool_calls) if total_tool_calls > self.budget.max_tool_calls: msg = ( f"Tool call budget exceeded " f"({total_tool_calls} > {self.budget.max_tool_calls}, " f"max_tool_calls={self.budget.max_tool_calls})" ) raise BudgetExceededError(msg) call_objects = [ ToolCall( name=c["name"], call_id=c.get("call_id") or "", arguments=c.get("arguments") or {}, provider_metadata=c.get("provider_metadata") or {}, ) for c in tool_calls ] messages.append( AgentMessage( role="assistant", content=complete_turn_text, tool_calls=call_objects, agent_name=active_agent.name, ) ) with anyio.fail_after(max(deadline - anyio.current_time(), 0.0)): tool_results, result_frames = await self._execute_tools( tool_calls, tools_map, turn.context, tid, active_agent ) for f in result_frames: await emit(f) result_objects = [ ToolResult( call_id=r.get("call_id") or "", name=r.get("name") or "", content=r.get("content"), is_error=bool(r.get("is_error", False)), ) for r in tool_results ] messages.append( AgentMessage( role="tool", content="", tool_results=result_objects, agent_name=active_agent.name, ) ) new_agent, transfer_frame = _check_agent_transfer(self.target, tool_calls, tid) if new_agent is not None: active_agent = new_agent if transfer_frame is not None: await emit(transfer_frame) if not completed_naturally: msg = f"Turn budget exceeded (max_turns={self.budget.max_turns})" raise BudgetExceededError(msg) return active_agent @staticmethod async def _drain_stream( stream_iter: AsyncIterator[ModelDelta], deadline: float, active_agent: Agent, tid: str, turn_text: list[str], tool_calls: list[dict[str, Any]], token_usage: dict[str, int], emit: Callable[[AgentStreamFrame], Awaitable[None]], ) -> None: """Drain model delta stream until completion or deadline.""" try: while True: delta = await _next_before(stream_iter, deadline) if delta is None: break frame = _process_delta(delta, active_agent, tid, turn_text, tool_calls, token_usage) if frame is not None: await emit(frame) finally: if hasattr(stream_iter, "aclose"): aclose_fn = cast("Any", stream_iter).aclose res = aclose_fn() if inspect.isawaitable(res): await res