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

View File

@@ -5,6 +5,7 @@ import logging
from collections.abc import Awaitable, Callable
from contextlib import aclosing
from voice_assistant.agent.adapter import StreamingAgentAdapter
from voice_assistant.application.events import (
AgentReply,
AgentTextDelta,
@@ -245,7 +246,7 @@ class VoiceSession:
await self._respond(turn_id, event.text)
async def _respond(self, turn_id: int, text: str) -> None:
if callable(getattr(self._pipeline.agent, "stream", None)):
if isinstance(self._pipeline.agent, StreamingAgentAdapter):
async with aclosing(self._pipeline.stream_turn_response(
session_id=self.session_id, turn_id=turn_id, text=text,
)) as response_events:
@@ -263,8 +264,11 @@ class VoiceSession:
session_id=self.session_id, turn_id=turn_id, text=text,
)
await self._emit(AgentReply(self.session_id, turn_id, result.response_text))
async for audio in self._pipeline.synthesize_response(result.response_text):
await self._emit(SpeechChunk(self.session_id, turn_id, audio))
async with aclosing(
self._pipeline.synthesize_response(result.response_text)
) as audio_stream:
async for audio in audio_stream:
await self._emit(SpeechChunk(self.session_id, turn_id, audio))
self._pipeline.on_tts_complete(turn_id)
async def _finalize_turn(self, turn_id: int, stt: STTAdapter) -> None:
@@ -404,9 +408,14 @@ class VoiceSession:
self._pipeline.close_session()
except Exception:
logger.exception("Failed to close voice session. session_id=%s", self.session_id)
close_agent = getattr(self._pipeline.agent, "aclose", None)
if callable(close_agent):
for name, provider in (
("Agent", self._pipeline.agent),
("TTS", self._pipeline.tts),
):
try:
await close_agent()
await provider.aclose()
except Exception:
logger.exception("Failed to close Agent provider. session_id=%s", self.session_id)
logger.exception(
"Failed to close %s provider. session_id=%s",
name, self.session_id,
)

View File

@@ -28,6 +28,7 @@ from voice_assistant.domain.session import SessionState
from voice_assistant.providers import (
create_agent_adapter, create_stt_adapter, create_tts_adapter,
)
from voice_assistant.tts.adapter import TTSAdapter
from voice_assistant.turn_manager import TurnManager
from voice_assistant.voice_pipeline import VoicePipeline
@@ -96,6 +97,7 @@ async def create_voice_session(websocket: WebSocket, session_id: str | None) ->
"""Compose providers at the channel boundary, injecting application ports."""
state = SessionState(session_id=session_id) if session_id is not None else SessionState()
agent = create_agent_adapter(config=app_config, catalog=provider_catalog)
tts: TTSAdapter | None = None
try:
tts = create_tts_adapter(config=app_config, catalog=provider_catalog)
pipeline = VoicePipeline(
@@ -104,12 +106,15 @@ async def create_voice_session(websocket: WebSocket, session_id: str | None) ->
except BaseException:
# A partially composed application has not yet acquired ownership.
with anyio.CancelScope(shield=True):
close_agent = getattr(agent, "aclose", None)
if callable(close_agent):
for name, provider in (("Agent", agent), ("TTS", tts)):
if provider is None:
continue
try:
await close_agent()
await provider.aclose()
except Exception:
logger.exception("Failed to close Agent after session composition failure.")
logger.exception(
"Failed to close %s after session composition failure.", name,
)
raise
return VoiceSession(
pipeline=pipeline,

View File

@@ -1,12 +1,24 @@
"""Speech synthesis streaming and session cleanup contract."""
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
class TTSAdapter(ABC):
@abstractmethod
async def synthesize(
self,
text: str,
) -> AsyncIterator[bytes]:
"""Convert text into audio chunks."""
def synthesize(self, text: str) -> AsyncGenerator[bytes, None]:
"""Return an audio generator consumed directly with async for.
The caller closes it on early exit. Implementations must release
stream-owned resources in finally blocks and propagate cancellation
and provider errors. The caller does not await synthesize itself.
"""
raise NotImplementedError
async def aclose(self) -> None:
"""Release retained resources after synthesis has stopped.
Override when resources are retained between synthesis calls.
Repeated calls must be safe and cleanup errors must propagate.
Adapters owning resources only inside synthesize inherit this no-op.
"""

View File

@@ -1,4 +1,4 @@
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
from .adapter import TTSAdapter
@@ -10,7 +10,7 @@ class MockTTSAdapter(TTSAdapter):
async def synthesize(
self,
text: str,
) -> AsyncIterator[bytes]:
) -> AsyncGenerator[bytes, None]:
self.calls.append(text)
yield b"\x01\x02\x03\x04"

View File

@@ -1,4 +1,4 @@
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator
import httpx
@@ -27,7 +27,7 @@ class VoxCPMTTSAdapter(TTSAdapter):
async def synthesize(
self,
text: str,
) -> AsyncIterator[bytes]:
) -> AsyncGenerator[bytes, None]:
if not text:
raise ValueError("Text cannot be empty.")

View File

@@ -45,7 +45,7 @@ from contextlib import aclosing
from voice_assistant.agent.text_chunker import TextChunker
from voice_assistant.turn_manager import TurnState
from voice_assistant.agent.adapter import AgentAdapter
from voice_assistant.agent.adapter import AgentAdapter, StreamingAgentAdapter
from voice_assistant.domain.session import SessionState
from voice_assistant.turn_manager import Turn, TurnManager
from voice_assistant.tts.adapter import TTSAdapter
@@ -181,10 +181,9 @@ class VoicePipeline:
The TTS adapter remains responsible for provider-specific
synthesis and audio streaming behavior.
"""
async for audio_chunk in self.tts.synthesize(
response_text,
):
yield audio_chunk
async with aclosing(self.tts.synthesize(response_text)) as audio_stream:
async for audio_chunk in audio_stream:
yield audio_chunk
async def stream_turn_response(
@@ -211,9 +210,9 @@ class VoicePipeline:
f"Cannot stream response while turn is {turn.state.value}."
)
stream_method = getattr(self.agent, "stream", None)
agent = self.agent
if not callable(stream_method):
if not isinstance(agent, StreamingAgentAdapter):
raise RuntimeError(
"Configured Agent does not support streaming."
)
@@ -230,7 +229,7 @@ class VoicePipeline:
parts: list[str] = []
async with aclosing(
stream_method(
agent.stream(
session_id=session_id,
turn_id=turn_id,
text=text,