refactor: formaliza contratos de streaming e ciclo de vida dos provedores

This commit is contained in:
alex alves
2026-11-03 16:15:14 -03:00
parent dc45dbdb6d
commit 4d7ff46a36
14 changed files with 337 additions and 57 deletions

View File

@@ -1,7 +1,8 @@
from .adapter import AgentAdapter
from .adapter import AgentAdapter, StreamingAgentAdapter
from .mock import MockAgentAdapter
__all__ = [
"AgentAdapter",
"StreamingAgentAdapter",
"MockAgentAdapter",
]

View File

@@ -1,4 +1,8 @@
"""Agent processing, optional streaming capability, and session cleanup."""
from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator
from contextlib import aclosing
class AgentAdapter(ABC):
@@ -9,6 +13,45 @@ class AgentAdapter(ABC):
turn_id: int,
text: str,
) -> str:
"""Process the final text of a turn and return the response."""
"""Return a complete response, propagating cancellation and errors."""
raise NotImplementedError
async def aclose(self) -> None:
"""Release session-owned resources after execution has stopped.
Override when the adapter retains connections or other resources.
Repeated calls must be safe; cleanup errors must propagate so the
application can report them and continue cleaning other providers.
Resource-free adapters inherit this no-op implementation.
"""
class StreamingAgentAdapter(AgentAdapter):
@abstractmethod
def stream(
self,
session_id: str,
turn_id: int,
text: str,
) -> AsyncGenerator[str, None]:
"""Yield ordered text deltas, preserving spaces between fragments.
The consumer owns the generator and closes it on early exit.
Implementations must release stream-owned resources in finally
blocks and propagate cancellation and provider errors.
"""
raise NotImplementedError
async def process(
self,
session_id: str,
turn_id: int,
text: str,
) -> str:
"""Offer complete-response processing for streaming implementations."""
async with aclosing(self.stream(session_id, turn_id, text)) as deltas:
parts = [delta async for delta in deltas]
response = "".join(parts).strip()
if not response:
raise RuntimeError("Agent returned an empty streaming response.")
return response

View File

@@ -16,7 +16,7 @@ Does not manage:
from __future__ import annotations
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from contextlib import aclosing
from deepagents import create_deep_agent
@@ -24,7 +24,7 @@ from langchain_core.messages import HumanMessage
from langchain_oci import ChatOCIGenAI
from langgraph.checkpoint.memory import MemorySaver
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.agent.adapter import StreamingAgentAdapter
DEFAULT_SYSTEM_PROMPT = """
Você é o AgentVoice, um assistente pessoal por voz. Que responde pelo nome de Roberta.
@@ -41,7 +41,7 @@ de ferramentas que não foram executadas.
"""
class DeepAgentsAdapter(AgentAdapter):
class DeepAgentsAdapter(StreamingAgentAdapter):
def __init__(
self,
region: str,
@@ -133,7 +133,7 @@ class DeepAgentsAdapter(AgentAdapter):
session_id: str,
turn_id: int,
text: str,
) -> AsyncIterator[str]:
) -> AsyncGenerator[str, None]:
if self._closed:
raise RuntimeError("Deep Agent is closed.")
@@ -169,5 +169,5 @@ class DeepAgentsAdapter(AgentAdapter):
if self._closed:
return
self._closed = True
await self.model.aclose()
self._closed = True

View File

@@ -7,16 +7,17 @@ chat inference using the GenericChatRequest format.
from __future__ import annotations
import asyncio
import json
from typing import Any
import logging
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from contextlib import aclosing
import oci
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.agent.adapter import StreamingAgentAdapter
from voice_assistant.agent.oci_stream import stream_sse_events
from voice_assistant.agent.oci_connection import open_oci_stream
@@ -91,7 +92,7 @@ def extract_text_delta(raw_data: str) -> str | None:
logger = logging.getLogger(__name__)
class OCIAgentAdapter(AgentAdapter):
class OCIAgentAdapter(StreamingAgentAdapter):
"""
OCI Generative AI streaming Agent.
@@ -135,6 +136,7 @@ class OCIAgentAdapter(AgentAdapter):
self.temperature = temperature
self.max_completion_tokens = max_completion_tokens
self.timeout_seconds = timeout_seconds
self._closed = False
signer = (
oci.auth.signers
@@ -184,7 +186,7 @@ class OCIAgentAdapter(AgentAdapter):
session_id: str,
turn_id: int,
text: str,
) -> AsyncIterator[str]:
) -> AsyncGenerator[str, None]:
"""
Stream OCI Generative AI text deltas.
@@ -192,6 +194,9 @@ class OCIAgentAdapter(AgentAdapter):
event loop. SSE events are consumed through the
asynchronous bridge.
"""
if self._closed:
raise RuntimeError("OCI Agent is closed.")
if not text.strip():
raise ValueError("Agent input text cannot be empty.")
@@ -255,3 +260,11 @@ class OCIAgentAdapter(AgentAdapter):
)
return response_text
async def aclose(self) -> None:
"""Release the OCI HTTP session after turn execution has stopped."""
if self._closed:
return
await asyncio.to_thread(self.client.base_client.session.close)
self._closed = True