123 lines
3.6 KiB
Python
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
|