refactor: separa composição OCI e inicialização do DeepAgents
This commit is contained in:
@@ -6,6 +6,14 @@ from contextlib import aclosing
|
||||
|
||||
|
||||
class AgentAdapter(ABC):
|
||||
async def initialize(self) -> None:
|
||||
"""Prepare session-owned resources without blocking the event loop.
|
||||
|
||||
Resource-free adapters inherit this no-op. Implementations retain
|
||||
ownership of work still running after cancellation or timeout, and
|
||||
aclose() must clean partially initialized resources as well.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def process(
|
||||
self,
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
Deep Agents implementation of the AgentVoice Agent port.
|
||||
|
||||
Responsibilities:
|
||||
- OCI Generative AI inference through LangChain.
|
||||
- Translate a prepared DeepAgents graph to the Agent streaming contract.
|
||||
- Contextual memory per session.
|
||||
- Text streaming compatible with VoicePipeline.
|
||||
- Asynchronous resource cleanup.
|
||||
- Explicit closure of consumed event streams.
|
||||
- Model/client ownership belongs to the composition that supplies the graph.
|
||||
|
||||
Does not manage:
|
||||
- WebSocket connections.
|
||||
@@ -21,27 +22,22 @@ from collections.abc import AsyncGenerator, Iterator
|
||||
from typing import Any
|
||||
from contextlib import aclosing
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_oci import ChatOCIGenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """
|
||||
Você é o AgentVoice, um assistente pessoal por voz. Que responde pelo nome de Roberta.
|
||||
|
||||
Responda em português brasileiro, de forma natural,
|
||||
objetiva e adequada para reprodução por voz.
|
||||
|
||||
Mantenha o contexto das mensagens anteriores.
|
||||
Interprete correções e complementos do usuário
|
||||
considerando o histórico da conversa.
|
||||
|
||||
Não invente informações atuais ou resultados
|
||||
de ferramentas que não foram executadas.
|
||||
"""
|
||||
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]:
|
||||
@@ -62,64 +58,11 @@ def _text_fragments(content: str | list[str | dict[str, Any]]) -> Iterator[str]:
|
||||
|
||||
|
||||
class DeepAgentsAdapter(StreamingAgentAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
region: str,
|
||||
compartment_id: str,
|
||||
model_id: str,
|
||||
temperature: float,
|
||||
max_completion_tokens: int,
|
||||
timeout_seconds: float,
|
||||
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
||||
*,
|
||||
auth_type: str = "INSTANCE_PRINCIPAL",
|
||||
auth_profile: str = "DEFAULT",
|
||||
auth_file_location: str = "~/.oci/config",
|
||||
) -> None:
|
||||
if not region:
|
||||
raise ValueError("OCI region is required.")
|
||||
|
||||
if not compartment_id:
|
||||
raise ValueError("OCI compartment_id is required.")
|
||||
|
||||
if not model_id:
|
||||
raise ValueError("OCI model_id is required.")
|
||||
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise ValueError("timeout_seconds must be finite and positive.")
|
||||
|
||||
self._stream_event_timeout = float(timeout_seconds)
|
||||
|
||||
self._model = ChatOCIGenAI(
|
||||
model_id=model_id,
|
||||
compartment_id=compartment_id,
|
||||
service_endpoint=(
|
||||
f"https://inference.generativeai.{region}.oci.oraclecloud.com"
|
||||
),
|
||||
auth_type=auth_type,
|
||||
auth_profile=auth_profile,
|
||||
auth_file_location=auth_file_location,
|
||||
is_stream=True,
|
||||
model_kwargs={
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_completion_tokens,
|
||||
},
|
||||
def __init__(self, graph: CompiledStateGraph, timeout_seconds: float) -> None:
|
||||
self._stream_event_timeout = positive_timeout(
|
||||
timeout_seconds, "timeout_seconds"
|
||||
)
|
||||
|
||||
self._checkpointer = MemorySaver()
|
||||
|
||||
self._graph = create_deep_agent(
|
||||
model=self._model,
|
||||
tools=[],
|
||||
system_prompt=system_prompt,
|
||||
checkpointer=self._checkpointer,
|
||||
)
|
||||
|
||||
self._graph = graph
|
||||
self._closed = False
|
||||
|
||||
@staticmethod
|
||||
@@ -176,5 +119,4 @@ class DeepAgentsAdapter(StreamingAgentAdapter):
|
||||
if self._closed:
|
||||
return
|
||||
|
||||
await self._model.aclose()
|
||||
self._closed = True
|
||||
|
||||
178
src/voice_assistant/agent/oci_deep_agent.py
Normal file
178
src/voice_assistant/agent/oci_deep_agent.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Compose OCI authentication, model and graph behind the Agent contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import aclosing
|
||||
from functools import partial
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
from langchain_oci import ChatOCIGenAI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
from voice_assistant.agent.adapter import StreamingAgentAdapter
|
||||
from voice_assistant.agent.deep_agent import DeepAgentsAdapter, positive_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_SYSTEM_PROMPT = """
|
||||
Você é o AgentVoice, um assistente pessoal por voz. Que responde pelo nome de Roberta.
|
||||
|
||||
Responda em português brasileiro, de forma natural,
|
||||
objetiva e adequada para reprodução por voz.
|
||||
|
||||
Mantenha o contexto das mensagens anteriores.
|
||||
Interprete correções e complementos do usuário
|
||||
considerando o histórico da conversa.
|
||||
|
||||
Não invente informações atuais ou resultados
|
||||
de ferramentas que não foram executadas.
|
||||
"""
|
||||
|
||||
|
||||
class OCIDeepAgentsAdapter(StreamingAgentAdapter):
|
||||
"""Session-scoped composition; construction itself performs no OCI I/O."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
region: str,
|
||||
compartment_id: str,
|
||||
model_id: str,
|
||||
temperature: float,
|
||||
max_completion_tokens: int,
|
||||
timeout_seconds: float,
|
||||
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
||||
*,
|
||||
auth_type: str = "INSTANCE_PRINCIPAL",
|
||||
auth_profile: str = "DEFAULT",
|
||||
auth_file_location: str = "~/.oci/config",
|
||||
initialization_timeout_seconds: float = 5.0,
|
||||
cleanup_timeout_seconds: float = 1.0,
|
||||
) -> None:
|
||||
for name, value in (
|
||||
("region", region),
|
||||
("compartment_id", compartment_id),
|
||||
("model_id", model_id),
|
||||
):
|
||||
if not value:
|
||||
raise ValueError(f"OCI {name} is required.")
|
||||
self._event_timeout = positive_timeout(timeout_seconds, "timeout_seconds")
|
||||
self._initialization_timeout = positive_timeout(
|
||||
initialization_timeout_seconds, "initialization_timeout_seconds"
|
||||
)
|
||||
self._cleanup_timeout = positive_timeout(
|
||||
cleanup_timeout_seconds, "cleanup_timeout_seconds"
|
||||
)
|
||||
self._create_model = partial(
|
||||
ChatOCIGenAI,
|
||||
model_id=model_id,
|
||||
compartment_id=compartment_id,
|
||||
service_endpoint=f"https://inference.generativeai.{region}.oci.oraclecloud.com",
|
||||
auth_type=auth_type,
|
||||
auth_profile=auth_profile,
|
||||
auth_file_location=auth_file_location,
|
||||
is_stream=True,
|
||||
model_kwargs={
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_completion_tokens,
|
||||
},
|
||||
)
|
||||
self._system_prompt = system_prompt
|
||||
self._model: ChatOCIGenAI | None = None
|
||||
self._delegate: DeepAgentsAdapter | None = None
|
||||
self._initialization: asyncio.Task[None] | None = None
|
||||
self._closing = False
|
||||
self._closed = False
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._closing:
|
||||
raise RuntimeError("OCI Deep Agent is closing or closed.")
|
||||
if self._delegate is not None:
|
||||
return
|
||||
if self._initialization is None:
|
||||
self._initialization = asyncio.create_task(self._prepare())
|
||||
self._initialization.add_done_callback(self._observe_initialization)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(self._initialization),
|
||||
self._initialization_timeout,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
self._closing = True
|
||||
raise TimeoutError(
|
||||
f"OCI Agent initialization exceeded {self._initialization_timeout:g} seconds."
|
||||
) from exc
|
||||
except BaseException:
|
||||
self._closing = True
|
||||
raise
|
||||
if self._closing:
|
||||
raise RuntimeError("OCI Deep Agent closed during initialization.")
|
||||
|
||||
async def _prepare(self) -> None:
|
||||
# Shielded ownership: an abandoned caller never cancels this task.
|
||||
self._model = await asyncio.to_thread(self._create_model)
|
||||
try:
|
||||
if self._closing:
|
||||
return
|
||||
graph = create_deep_agent(
|
||||
model=self._model,
|
||||
tools=[], # Additional tools only; DeepAgents native tools remain enabled.
|
||||
system_prompt=self._system_prompt,
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
self._delegate = DeepAgentsAdapter(
|
||||
graph, timeout_seconds=self._event_timeout
|
||||
)
|
||||
finally:
|
||||
if self._closing or self._delegate is None:
|
||||
await self._dispose_model()
|
||||
|
||||
def _observe_initialization(self, task: asyncio.Task[None]) -> None:
|
||||
if task.cancelled():
|
||||
return
|
||||
error = task.exception()
|
||||
if error is not None and self._closing:
|
||||
logger.error(
|
||||
"Abandoned OCI Agent initialization failed.",
|
||||
exc_info=(type(error), error, error.__traceback__),
|
||||
)
|
||||
|
||||
async def _dispose_model(self) -> None:
|
||||
if self._model is None:
|
||||
return
|
||||
model = self._model
|
||||
try:
|
||||
await model.aclose()
|
||||
finally:
|
||||
# ChatOCIGenAI.aclose closes only its asynchronous HTTP client.
|
||||
model.client.base_client.session.close()
|
||||
self._model = None
|
||||
|
||||
async def stream(
|
||||
self, session_id: str, turn_id: int, text: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
await self.initialize()
|
||||
if self._delegate is None:
|
||||
raise RuntimeError("OCI Deep Agent is not initialized.")
|
||||
async with aclosing(self._delegate.stream(session_id, turn_id, text)) as deltas:
|
||||
async for delta in deltas:
|
||||
yield delta
|
||||
|
||||
async def aclose(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closing = True
|
||||
if self._initialization is not None:
|
||||
done, _ = await asyncio.wait(
|
||||
{self._initialization}, timeout=self._cleanup_timeout
|
||||
)
|
||||
if not done:
|
||||
raise TimeoutError(
|
||||
"OCI initialization is still running; cleanup ownership retained."
|
||||
)
|
||||
if self._delegate is not None:
|
||||
await self._delegate.aclose()
|
||||
self._delegate = None
|
||||
await self._dispose_model()
|
||||
self._closed = True
|
||||
@@ -106,6 +106,7 @@ async def create_voice_session(
|
||||
vad = None
|
||||
try:
|
||||
agent = create_agent_adapter(config=app_config, catalog=provider_catalog)
|
||||
await agent.initialize()
|
||||
tts = create_tts_adapter(config=app_config, catalog=provider_catalog)
|
||||
if server_vad:
|
||||
vad = create_vad_adapter(config=app_config, catalog=provider_catalog)
|
||||
|
||||
Reference in New Issue
Block a user