refactor: separa composição OCI e inicialização do DeepAgents

This commit is contained in:
alex alves
2026-11-05 16:39:01 -03:00
parent 85fc4c4d46
commit e319b4089c
10 changed files with 508 additions and 153 deletions

View File

@@ -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,

View File

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

View 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

View File

@@ -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)