Files
voiceagent/src/voice_assistant/agent/deep_agent.py

123 lines
3.6 KiB
Python

"""
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