""" Deep Agents implementation of the AgentVoice Agent port. Responsibilities: - Translate a prepared DeepAgents graph to the Agent streaming contract. - Contextual memory per session. - Text streaming compatible with VoicePipeline. - Explicit closure of consumed event streams. - Model/client ownership belongs to the composition that supplies the graph. Does not manage: - WebSocket connections. - STT or TTS. - Semantic turn lifecycle. """ from __future__ import annotations import asyncio import math from collections.abc import AsyncGenerator, Iterator from typing import Any from contextlib import aclosing from langchain_core.messages import HumanMessage from langchain_core.runnables import RunnableConfig from langgraph.graph.state import CompiledStateGraph from voice_assistant.agent.adapter import StreamingAgentAdapter def positive_timeout(value: float, name: str) -> float: if ( isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0 ): raise ValueError(f"{name} must be finite and positive.") return float(value) def _text_fragments(content: str | list[str | dict[str, Any]]) -> Iterator[str]: """Extract text without exposing reasoning, tools or other content blocks.""" if isinstance(content, str): if content: yield content return for block in content: if isinstance(block, str): if block: yield block elif isinstance(block, dict) and block.get("type") == "text": text = block.get("text") if isinstance(text, str) and text: yield text class DeepAgentsAdapter(StreamingAgentAdapter): def __init__(self, graph: CompiledStateGraph, timeout_seconds: float) -> None: self._stream_event_timeout = positive_timeout( timeout_seconds, "timeout_seconds" ) self._graph = graph self._closed = False @staticmethod def _session_config(session_id: str) -> RunnableConfig: if not session_id: raise ValueError("session_id is required.") return { "configurable": { "thread_id": session_id, } } async def stream( self, session_id: str, turn_id: int, text: str, ) -> AsyncGenerator[str, None]: if self._closed: raise RuntimeError("Deep Agent is closed.") if not text.strip(): raise ValueError("Agent input text cannot be empty.") config = self._session_config(session_id) events = self._graph.astream( {"messages": [HumanMessage(content=text)]}, config=config, stream_mode="messages", ) async with aclosing(events): while True: try: async with asyncio.timeout(self._stream_event_timeout): message, metadata = await anext(events) except StopAsyncIteration: break except TimeoutError as exc: raise TimeoutError( "Deep Agent timed out waiting for the next stream event." ) from exc if metadata.get("langgraph_node") != "model": continue if getattr(message, "tool_call_chunks", None): continue for fragment in _text_fragments(message.content): yield fragment async def aclose(self) -> None: if self._closed: return self._closed = True