- harness/es.py: append-only event store + проекции (образец — кооп-портал) - harness/levels.py: уровни 1с/1м/1ч/1д, контекст ∝ масштабу - harness/scheduler.py: уровневые потоки, идемпотентные тики, absorbed-стабы - harness/isolated_runner.py + _llm_worker.py: LLM-тик в сабпроцессе (глобальный asyncio-рантайм Strands не переживает 2+ create_harness) - cli.py: init/task/tasks/status/events + requeue (написан самим минутным агентом) - 9router: отдельный ключ sk-simbiot, модель auto (glm-5.3 первым)
1142 lines
45 KiB
Python
1142 lines
45 KiB
Python
"""Amazon Bedrock Nova Sonic provider for real-time streaming conversations.
|
|
|
|
Implements the BidiModel interface for Amazon's Nova Sonic, handling the
|
|
complex event sequencing and audio processing required by Nova Sonic's
|
|
InvokeModelWithBidirectionalStream protocol.
|
|
|
|
Nova Sonic specifics:
|
|
|
|
- Hierarchical event sequences: connectionStart → promptStart → content streaming
|
|
- Base64-encoded audio format with hex encoding
|
|
- Tool execution with content containers and identifier tracking
|
|
- 8-minute connection limits with proper cleanup sequences
|
|
- Barge-in detection through stopReason events
|
|
|
|
Note, BedrockNovaSonicModel is only supported for Python 3.12+
|
|
"""
|
|
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
if not TYPE_CHECKING and sys.version_info < (3, 12):
|
|
raise ImportError("BedrockNovaSonicModel is only supported for Python 3.12+")
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from collections.abc import AsyncGenerator
|
|
from dataclasses import dataclass
|
|
from typing import Any, cast
|
|
|
|
import boto3
|
|
from aws_sdk_bedrock_runtime.client import AsyncBedrockRuntimeClient, InvokeModelWithBidirectionalStreamOperationInput
|
|
from aws_sdk_bedrock_runtime.config import AsyncBedrockRuntimeConfig, HTTPAuthSchemeResolver, SigV4AuthScheme
|
|
from aws_sdk_bedrock_runtime.models import (
|
|
BidirectionalInputPayloadPart,
|
|
InvokeModelWithBidirectionalStreamInputChunk,
|
|
ModelTimeoutException,
|
|
ValidationException,
|
|
)
|
|
from boto3.session import Session
|
|
from smithy_aws_core.identity.static import StaticCredentialsResolver
|
|
from smithy_core.aio.eventstream import DuplexEventStream
|
|
from smithy_core.shapes import ShapeID
|
|
from smithy_http.aio.crt import AWSCRTHTTPClient, AWSCRTHTTPResponse
|
|
from typing_extensions import Unpack, override
|
|
|
|
from ....models._validation import validate_config_keys, validate_region
|
|
from ....types._events import ToolUseStreamEvent
|
|
from ....types.content import Messages, TextBlock
|
|
from ....types.tools import ToolResultBlock, ToolSpec, ToolUse
|
|
from .._async import stop_all
|
|
from ..types.content import BidiContentBlock, BidiContentDelta
|
|
from ..types.events import (
|
|
BidiAudioDeltaEvent,
|
|
BidiAudioStartEvent,
|
|
BidiAudioStopEvent,
|
|
BidiBargeInEvent,
|
|
BidiConnectionStartEvent,
|
|
BidiOutputEvent,
|
|
BidiResponseStartEvent,
|
|
BidiResponseStopEvent,
|
|
BidiTranscriptDeltaEvent,
|
|
BidiTranscriptStartEvent,
|
|
BidiTranscriptStopEvent,
|
|
BidiUsageEvent,
|
|
Role,
|
|
)
|
|
from ..types.media import AudioDelta
|
|
from .configs import (
|
|
AudioConfig,
|
|
AudioStreamConfig,
|
|
BedrockNovaSonicAudioConfig,
|
|
BedrockNovaSonicAudioStreamConfig,
|
|
ConnectionConfig,
|
|
ModelConfig,
|
|
ModelUpdateConfig,
|
|
_validate_audio_config,
|
|
_validate_model_config,
|
|
)
|
|
from .model import AudioCapable, BidiModel, ConnectionTimeoutError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
NOVA_TEXT_CONFIG = {"mediaType": "text/plain"}
|
|
NOVA_TOOL_CONFIG = {"mediaType": "application/json"}
|
|
|
|
_MAX_HISTORY_MESSAGE_BYTES = 50 * 1024 # 50KB per message
|
|
_MAX_HISTORY_TOTAL_BYTES = 200 * 1024 # 200KB total history
|
|
|
|
_STRANDS_USER_AGENT_EXTRA = "strands-agents"
|
|
|
|
# TODO: Remove the CRT lifecycle workaround when both upstream issues are fixed:
|
|
# https://github.com/aws/aws-sdk-python/issues/13
|
|
# https://github.com/awslabs/aws-crt-python/issues/762
|
|
_AWS_ERROR_HTTP_STREAM_HAS_COMPLETED = 2080
|
|
|
|
|
|
def _is_http_stream_completed_error(error: BaseException) -> bool:
|
|
"""Return whether a CRT write observed an already-completed HTTP stream."""
|
|
if getattr(error, "code", None) == _AWS_ERROR_HTTP_STREAM_HAS_COMPLETED:
|
|
return True
|
|
return isinstance(error, RuntimeError) and "AWS_ERROR_HTTP_STREAM_HAS_COMPLETED" in str(error)
|
|
|
|
|
|
class _BedrockAWSCRTHTTPClient(AWSCRTHTTPClient):
|
|
"""Observe CRT request writers so their terminal results are always consumed."""
|
|
|
|
async def _await_response(self, stream: Any) -> AWSCRTHTTPResponse:
|
|
writer_task = getattr(stream, "_writer", None)
|
|
if isinstance(writer_task, asyncio.Task):
|
|
writer_task.add_done_callback(self._observe_request_writer)
|
|
response = await super()._await_response(stream)
|
|
return _BedrockAWSCRTHTTPResponse(status=response.status, fields=response.fields, stream=stream)
|
|
|
|
def _observe_request_writer(self, task: asyncio.Task[Any]) -> None:
|
|
"""Consume a CRT request-writer result and report unexpected failures."""
|
|
if task.cancelled():
|
|
return
|
|
|
|
error = task.exception()
|
|
if error is None:
|
|
return
|
|
if _is_http_stream_completed_error(error):
|
|
logger.debug("error=<%s> | request writer stopped after HTTP/2 stream completion", error)
|
|
return
|
|
|
|
task.get_loop().call_exception_handler(
|
|
{
|
|
"message": "bedrock HTTP/2 request writer failed",
|
|
"exception": error,
|
|
"task": task,
|
|
}
|
|
)
|
|
|
|
|
|
class _BedrockAWSCRTHTTPResponse(AWSCRTHTTPResponse):
|
|
"""Keep CRT response reads alive until the stream resolves them."""
|
|
|
|
async def chunks(self) -> AsyncGenerator[bytes, None]:
|
|
while True:
|
|
read_task = asyncio.create_task(self._stream.get_next_response_chunk())
|
|
try:
|
|
await asyncio.wait({read_task})
|
|
except asyncio.CancelledError:
|
|
read_task.add_done_callback(self._observe_cancelled_read)
|
|
raise
|
|
|
|
chunk = read_task.result()
|
|
if not chunk:
|
|
return
|
|
yield chunk
|
|
|
|
def _observe_cancelled_read(self, task: asyncio.Task[bytes]) -> None:
|
|
if task.cancelled():
|
|
return
|
|
error = task.exception()
|
|
if error is not None:
|
|
task.get_loop().call_exception_handler(
|
|
{
|
|
"message": "bedrock HTTP/2 response reader failed after cancellation",
|
|
"exception": error,
|
|
"task": task,
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _Transcript:
|
|
"""Track one user or speculative assistant transcript."""
|
|
|
|
content_id: str
|
|
role: Role
|
|
text: str = ""
|
|
|
|
def append(self, delta: str) -> str:
|
|
"""Append a transcript block while preserving word boundaries."""
|
|
if self.text and delta and not self.text[-1].isspace() and not delta[0].isspace():
|
|
delta = f" {delta}"
|
|
self.text += delta
|
|
return delta
|
|
|
|
|
|
@dataclass
|
|
class _ResponseState:
|
|
"""Track transcript and response state for one Nova event stream.
|
|
|
|
Attributes:
|
|
generation_stage: Generation stage of the current text block.
|
|
transcript: Open user or speculative assistant transcript.
|
|
response_id: Identifier of the open response, including its user transcript.
|
|
tool_use: Whether the response requested a tool.
|
|
audio_started: Whether the response's audio stream is open.
|
|
"""
|
|
|
|
generation_stage: str | None = None
|
|
transcript: _Transcript | None = None
|
|
response_id: str | None = None
|
|
tool_use: bool = False
|
|
audio_started: bool = False
|
|
|
|
def reset(self) -> None:
|
|
"""Reset the response state."""
|
|
self.generation_stage = None
|
|
self.transcript = None
|
|
self.response_id = None
|
|
self.tool_use = False
|
|
self.audio_started = False
|
|
|
|
|
|
class BedrockNovaSonicModel(BidiModel, AudioCapable):
|
|
"""Amazon Bedrock Nova Sonic implementation for bidirectional streaming.
|
|
|
|
Combines model configuration and connection state in a single class.
|
|
Manages Nova Sonic's complex event sequencing, audio format conversion, and
|
|
tool execution patterns while providing the standard BidiModel interface.
|
|
|
|
Note, BedrockNovaSonicModel is only supported for Python 3.12+.
|
|
|
|
Attributes:
|
|
_stream: open bedrock stream to nova sonic.
|
|
"""
|
|
|
|
_stream: DuplexEventStream
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
boto_session: Session | None = None,
|
|
region: str | None = None,
|
|
audio: BedrockNovaSonicAudioConfig | None = None,
|
|
voice: str = "matthew",
|
|
**model_config: Unpack[ModelConfig],
|
|
) -> None:
|
|
"""Initialize Nova Sonic bidirectional model.
|
|
|
|
Args:
|
|
boto_session: Boto3 session used to resolve credentials and region.
|
|
region: AWS region. Cannot be combined with ``boto_session``.
|
|
audio: Audio configuration.
|
|
voice: Output voice identifier. Defaults to ``matthew``.
|
|
**model_config: Model configuration.
|
|
|
|
Raises:
|
|
ValueError: If any of the following conditions apply:
|
|
|
|
- Required model configuration fields are missing.
|
|
- ``model_id`` is not a non-empty string.
|
|
- Audio options or the resolved region are invalid.
|
|
- Both ``boto_session`` and ``region`` are provided.
|
|
"""
|
|
if boto_session is not None and region is not None:
|
|
raise ValueError("Cannot specify both 'boto_session' and 'region'")
|
|
|
|
_validate_model_config(model_config)
|
|
self._config = ModelConfig(**model_config)
|
|
self._config["params"] = dict(self._config.get("params") or {})
|
|
|
|
# Nova caps a connection at ~8 min; reconnect at 7 min, leaving headroom below the cap.
|
|
# It also reports cumulative usage totals.
|
|
self._config["connection"] = ConnectionConfig(**{"restart_after_s": 420, **self._config.get("connection", {})})
|
|
self.usage_is_cumulative = True
|
|
|
|
self._resolve_audio_config(audio)
|
|
self._voice = voice
|
|
|
|
self._session = boto_session or boto3.Session()
|
|
resolved_region = region if region is not None else self._session.region_name or "us-east-1"
|
|
self.region = validate_region(resolved_region)
|
|
|
|
# Track API-provided identifiers
|
|
self._connection_id: str | None = None
|
|
self._audio_content_name: str | None = None
|
|
self._current_completion_id: str | None = None
|
|
|
|
# Ensure certain events are sent in sequence when required
|
|
self._send_lock = asyncio.Lock()
|
|
|
|
logger.debug("model_id=<%s> | nova sonic model initialized", self._config["model_id"])
|
|
|
|
@override
|
|
def update_config(self, **model_config: Unpack[ModelUpdateConfig]) -> None: # type: ignore[override]
|
|
"""Update the model configuration with the provided arguments.
|
|
|
|
Args:
|
|
**model_config: Configuration overrides.
|
|
|
|
Raises:
|
|
ValueError: If any of the following conditions apply:
|
|
|
|
- The resulting configuration is missing required fields.
|
|
- ``model_id`` is not a non-empty string.
|
|
"""
|
|
_validate_model_config(self._config | model_config)
|
|
self._config.update(model_config)
|
|
|
|
@override
|
|
def get_config(self) -> ModelConfig:
|
|
"""Return the model configuration by reference."""
|
|
return self._config
|
|
|
|
@override
|
|
def get_audio_config(self) -> AudioConfig:
|
|
"""Get the resolved audio configuration."""
|
|
return self._audio_config
|
|
|
|
def _resolve_audio_config(self, config: BedrockNovaSonicAudioConfig | None) -> None:
|
|
"""Resolve and validate input and output audio settings."""
|
|
config = config or {}
|
|
validate_config_keys(config, BedrockNovaSonicAudioConfig)
|
|
|
|
input_config = config.get("input", {"sample_rate": 16000})
|
|
output_config = config.get("output", {"sample_rate": 16000})
|
|
for stream in (input_config, output_config):
|
|
validate_config_keys(stream, BedrockNovaSonicAudioStreamConfig)
|
|
sample_rate = stream["sample_rate"]
|
|
if sample_rate not in (8000, 16000, 24000):
|
|
raise ValueError(f"Unsupported sample rate: {sample_rate}. Expected 8000, 16000, or 24000.")
|
|
|
|
self._audio_config = AudioConfig(
|
|
input=AudioStreamConfig(sample_rate=input_config["sample_rate"], channels=1, format="pcm"),
|
|
output=AudioStreamConfig(sample_rate=output_config["sample_rate"], channels=1, format="pcm"),
|
|
)
|
|
_validate_audio_config(self._audio_config)
|
|
|
|
async def start(
|
|
self,
|
|
system_prompt: str | None = None,
|
|
tools: list[ToolSpec] | None = None,
|
|
messages: Messages | None = None,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
"""Establish bidirectional connection to Nova Sonic.
|
|
|
|
Args:
|
|
system_prompt: System instructions for the model.
|
|
tools: List of tools available to the model.
|
|
messages: Conversation history to initialize with.
|
|
**kwargs: Additional configuration options.
|
|
|
|
Raises:
|
|
RuntimeError: If user calls start again without first stopping.
|
|
"""
|
|
if self._connection_id:
|
|
raise RuntimeError("model already started | call stop before starting again")
|
|
|
|
logger.debug("nova connection starting")
|
|
|
|
self._connection_id = str(uuid.uuid4())
|
|
|
|
# Get credentials from boto3 session (full credential chain)
|
|
credentials = self._session.get_credentials()
|
|
|
|
if not credentials:
|
|
raise ValueError(
|
|
"no AWS credentials found. configure credentials via environment variables, "
|
|
"credential files, IAM roles, or SSO."
|
|
)
|
|
|
|
# Use static resolver with credentials configured as properties
|
|
resolver = StaticCredentialsResolver()
|
|
|
|
config = await AsyncBedrockRuntimeConfig.resolve(
|
|
endpoint_uri=f"https://bedrock-runtime.{self.region}.amazonaws.com",
|
|
region=self.region,
|
|
aws_credentials_identity_resolver=resolver,
|
|
auth_scheme_resolver=HTTPAuthSchemeResolver(),
|
|
auth_schemes={ShapeID("aws.auth#sigv4"): SigV4AuthScheme(service="bedrock")},
|
|
# Configure static credentials as properties
|
|
aws_access_key_id=credentials.access_key,
|
|
aws_secret_access_key=credentials.secret_key,
|
|
aws_session_token=credentials.token,
|
|
transport=_BedrockAWSCRTHTTPClient(),
|
|
user_agent_extra=_STRANDS_USER_AGENT_EXTRA,
|
|
)
|
|
|
|
self._client = AsyncBedrockRuntimeClient(config=config)
|
|
logger.debug("region=<%s> | nova sonic client initialized", self.region)
|
|
|
|
self._stream = await self._client.invoke_model_with_bidirectional_stream(
|
|
InvokeModelWithBidirectionalStreamOperationInput(model_id=self._config["model_id"])
|
|
)
|
|
logger.debug("region=<%s> | nova sonic bidirectional stream established", self.region)
|
|
|
|
init_events = self._build_initialization_events(system_prompt, tools, messages)
|
|
logger.debug("event_count=<%d> | sending nova sonic initialization events", len(init_events))
|
|
await self._send_nova_events(init_events)
|
|
|
|
logger.info("connection_id=<%s> | nova sonic connection established", self._connection_id)
|
|
|
|
def _build_initialization_events(
|
|
self, system_prompt: str | None, tools: list[ToolSpec] | None, messages: Messages | None
|
|
) -> list[str]:
|
|
"""Build the sequence of initialization events."""
|
|
tools = tools or []
|
|
events = [
|
|
self._get_connection_start_event(),
|
|
self._get_prompt_start_event(tools),
|
|
*self._get_system_prompt_events(system_prompt),
|
|
]
|
|
|
|
# Add conversation history if provided
|
|
if messages:
|
|
events.extend(self._get_message_history_events(messages))
|
|
logger.debug("message_count=<%d> | conversation history added to initialization", len(messages))
|
|
|
|
return events
|
|
|
|
def _log_event_type(self, nova_event: dict[str, Any]) -> None:
|
|
"""Log specific Nova Sonic event types for debugging."""
|
|
# Log the full event structure for detailed debugging
|
|
event_keys = list(nova_event.keys())
|
|
logger.debug("event_keys=<%s> | nova sonic event received", event_keys)
|
|
|
|
if "usageEvent" in nova_event:
|
|
usage = nova_event["usageEvent"]
|
|
logger.debug(
|
|
"input_tokens=<%s>, output_tokens=<%s>, usage_details=<%s> | nova usage event",
|
|
usage.get("totalInputTokens", 0),
|
|
usage.get("totalOutputTokens", 0),
|
|
json.dumps(usage, indent=2),
|
|
)
|
|
elif "textOutput" in nova_event:
|
|
text_content = nova_event["textOutput"].get("content", "")
|
|
logger.debug(
|
|
"text_length=<%d>, text_preview=<%s>, text_output_details=<%s> | nova text output",
|
|
len(text_content),
|
|
text_content[:100],
|
|
json.dumps(nova_event["textOutput"], indent=2)[:500],
|
|
)
|
|
elif "toolUse" in nova_event:
|
|
tool_use = nova_event["toolUse"]
|
|
logger.debug(
|
|
"tool_name=<%s>, tool_use_id=<%s>, tool_use_details=<%s> | nova tool use received",
|
|
tool_use["toolName"],
|
|
tool_use["toolUseId"],
|
|
json.dumps(tool_use, indent=2)[:500],
|
|
)
|
|
elif "audioOutput" in nova_event:
|
|
audio_content = nova_event["audioOutput"]["content"]
|
|
audio_bytes = base64.b64decode(audio_content)
|
|
logger.debug("audio_bytes=<%d> | nova audio output received", len(audio_bytes))
|
|
elif "completionStart" in nova_event:
|
|
completion_id = nova_event["completionStart"].get("completionId", "unknown")
|
|
logger.debug("completion_id=<%s> | nova completion started", completion_id)
|
|
elif "completionEnd" in nova_event:
|
|
completion_data = nova_event["completionEnd"]
|
|
logger.debug(
|
|
"completion_id=<%s>, stop_reason=<%s> | nova completion ended",
|
|
completion_data.get("completionId", "unknown"),
|
|
completion_data.get("stopReason", "unknown"),
|
|
)
|
|
elif "stopReason" in nova_event:
|
|
logger.debug("stop_reason=<%s> | nova stop reason event", nova_event["stopReason"])
|
|
else:
|
|
# Log any other event types
|
|
audio_metadata = self._get_audio_metadata_for_logging({"event": nova_event})
|
|
if audio_metadata:
|
|
logger.debug("audio_byte_count=<%d> | nova sonic event with audio", audio_metadata["audio_byte_count"])
|
|
else:
|
|
logger.debug("event_payload=<%s> | nova sonic event details", json.dumps(nova_event, indent=2)[:500])
|
|
|
|
async def receive(self) -> AsyncGenerator[BidiOutputEvent, None]:
|
|
"""Receive Nova Sonic events and convert to provider-agnostic format.
|
|
|
|
Raises:
|
|
RuntimeError: If start has not been called.
|
|
"""
|
|
if not self._connection_id:
|
|
raise RuntimeError("model not started | call start before receiving")
|
|
|
|
logger.debug("nova event stream starting")
|
|
yield BidiConnectionStartEvent(connection_id=self._connection_id, model=self._config["model_id"])
|
|
|
|
_, output = await self._stream.await_output()
|
|
response_state = _ResponseState()
|
|
while True:
|
|
try:
|
|
event_data = await output.receive()
|
|
|
|
except ValidationException as error:
|
|
if "InternalErrorCode=531" in error.message:
|
|
# nova also times out if user is silent for 175 seconds
|
|
raise ConnectionTimeoutError(error.message) from error
|
|
raise
|
|
|
|
except ModelTimeoutException as error:
|
|
raise ConnectionTimeoutError(error.message) from error
|
|
|
|
# Per the smithy EventReceiver contract, receive() returns None only at
|
|
# end-of-stream (e.g. the connection closed during reconnect). A closed receiver
|
|
# returns None without suspending, so continuing here busy-loops and starves the
|
|
# event loop; end the generator so the reader exits cleanly and the swap proceeds.
|
|
if event_data is None:
|
|
logger.debug("event stream closed by service | ending nova receive loop")
|
|
break
|
|
|
|
# Decode and parse the event
|
|
raw_bytes = event_data.value.bytes_.decode("utf-8")
|
|
logger.debug("raw_event_size=<%d> | received nova sonic event", len(raw_bytes))
|
|
|
|
nova_event = json.loads(raw_bytes)["event"]
|
|
self._log_event_type(nova_event)
|
|
|
|
for model_event in self._convert_nova_event(nova_event, response_state):
|
|
event_type = (
|
|
model_event.get("type", "unknown") if isinstance(model_event, dict) else type(model_event).__name__
|
|
)
|
|
logger.debug("converted_event_type=<%s> | yielding converted event", event_type)
|
|
yield model_event
|
|
|
|
async def send(self, content: BidiContentBlock | BidiContentDelta | ToolResultBlock) -> None:
|
|
"""Unified send method for all content types. Sends the given content to Nova Sonic.
|
|
|
|
Dispatches to appropriate internal handler based on content type.
|
|
|
|
Args:
|
|
content: A TextBlock, AudioDelta, or ToolResultBlock.
|
|
|
|
Raises:
|
|
ValueError: If content type not supported (e.g., image content).
|
|
"""
|
|
if not self._connection_id:
|
|
raise RuntimeError("model not started | call start before sending")
|
|
|
|
if isinstance(content, TextBlock):
|
|
text = content.text
|
|
text_preview = text[:100] if len(text) > 100 else text
|
|
logger.debug("text_length=<%d>, text_preview=<%s> | sending text content", len(text), text_preview)
|
|
await self._send_text_content(text)
|
|
elif isinstance(content, AudioDelta):
|
|
audio_bytes = content.source.get("bytes")
|
|
audio_size = len(audio_bytes) if audio_bytes else 0
|
|
logger.debug(
|
|
"audio_bytes=<%d>, format=<%s> | sending audio content",
|
|
audio_size,
|
|
content.format,
|
|
)
|
|
await self._send_audio_content(content)
|
|
elif isinstance(content, ToolResultBlock):
|
|
logger.debug(
|
|
"tool_use_id=<%s>, content_blocks=<%d> | sending tool result",
|
|
content.tool_use_id,
|
|
len(content.content),
|
|
)
|
|
await self._send_tool_result(content)
|
|
else:
|
|
logger.error("content_type=<%s> | unsupported content type", type(content))
|
|
raise ValueError(f"content_type={type(content)} | content not supported")
|
|
|
|
async def _start_audio_connection(self) -> None:
|
|
"""Internal: Start audio input connection (call once before sending audio chunks)."""
|
|
logger.debug("nova audio connection starting")
|
|
self._audio_content_name = str(uuid.uuid4())
|
|
|
|
# Build audio input configuration from config
|
|
audio_input_config = {
|
|
"mediaType": "audio/lpcm",
|
|
"sampleRateHertz": self._audio_config["input"]["sample_rate"],
|
|
"sampleSizeBits": 16,
|
|
"channelCount": self._audio_config["input"]["channels"],
|
|
"audioType": "SPEECH",
|
|
"encoding": "base64",
|
|
}
|
|
|
|
audio_content_start = json.dumps(
|
|
{
|
|
"event": {
|
|
"contentStart": {
|
|
"promptName": self._connection_id,
|
|
"contentName": self._audio_content_name,
|
|
"type": "AUDIO",
|
|
"interactive": True,
|
|
"role": "USER",
|
|
"audioInputConfiguration": audio_input_config,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
await self._send_nova_events([audio_content_start])
|
|
|
|
async def _send_audio_content(self, audio_input: AudioDelta) -> None:
|
|
"""Internal: Send audio using Nova Sonic protocol-specific format."""
|
|
# Start audio connection if not already active
|
|
if not self._audio_content_name:
|
|
await self._start_audio_connection()
|
|
|
|
audio_bytes = audio_input.source.get("bytes")
|
|
if audio_bytes is None:
|
|
raise ValueError("audio source must contain bytes for Nova Sonic")
|
|
audio = base64.b64encode(audio_bytes).decode("utf-8")
|
|
audio_event = json.dumps(
|
|
{
|
|
"event": {
|
|
"audioInput": {
|
|
"promptName": self._connection_id,
|
|
"contentName": self._audio_content_name,
|
|
"content": audio,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
await self._send_nova_events([audio_event])
|
|
|
|
async def _end_audio_input(self) -> None:
|
|
"""Internal: End current audio input connection to trigger Nova Sonic processing."""
|
|
if not self._audio_content_name:
|
|
return
|
|
|
|
logger.debug("nova audio connection ending")
|
|
|
|
audio_content_end = json.dumps(
|
|
{"event": {"contentEnd": {"promptName": self._connection_id, "contentName": self._audio_content_name}}}
|
|
)
|
|
|
|
await self._send_nova_events([audio_content_end])
|
|
self._audio_content_name = None
|
|
|
|
async def _send_text_content(self, text: str) -> None:
|
|
"""Internal: Send text content using Nova Sonic format."""
|
|
content_name = str(uuid.uuid4())
|
|
events = [
|
|
self._get_text_content_start_event(content_name),
|
|
self._get_text_input_event(content_name, text),
|
|
self._get_content_end_event(content_name),
|
|
]
|
|
await self._send_nova_events(events)
|
|
|
|
async def _send_tool_result(self, tool_result: ToolResultBlock) -> None:
|
|
"""Internal: Send tool result using Nova Sonic toolResult format."""
|
|
tool_use_id = tool_result.tool_use_id
|
|
|
|
logger.debug("tool_use_id=<%s> | sending nova tool result", tool_use_id)
|
|
|
|
# Validate content types and preserve structure
|
|
content = tool_result.content
|
|
|
|
# Validate all content types are supported
|
|
for block in content:
|
|
if "text" not in block and "json" not in block:
|
|
# Unsupported content type - raise error
|
|
raise ValueError(
|
|
f"tool_use_id=<{tool_use_id}>, content_types=<{list(block.keys())}> | "
|
|
f"Content type not supported by Nova Sonic"
|
|
)
|
|
|
|
# Optimize for single content item - unwrap the array
|
|
if len(content) == 1:
|
|
result_data = cast(dict[str, Any], content[0])
|
|
else:
|
|
# Multiple items - send as array
|
|
result_data = {"content": content}
|
|
|
|
content_name = str(uuid.uuid4())
|
|
events = [
|
|
self._get_tool_content_start_event(content_name, tool_use_id),
|
|
self._get_tool_result_event(content_name, result_data),
|
|
self._get_content_end_event(content_name),
|
|
]
|
|
await self._send_nova_events(events)
|
|
|
|
async def stop(self) -> None:
|
|
"""Close Nova Sonic connection with proper cleanup sequence."""
|
|
logger.debug("nova connection cleanup starting")
|
|
|
|
async def stop_events() -> None:
|
|
if not self._connection_id or not hasattr(self, "_stream"):
|
|
return
|
|
|
|
await self._end_audio_input()
|
|
cleanup_events = [self._get_prompt_end_event(), self._get_connection_end_event()]
|
|
await self._send_nova_events(cleanup_events)
|
|
|
|
async def stop_stream() -> None:
|
|
if not hasattr(self, "_stream"):
|
|
return
|
|
|
|
try:
|
|
await self._stream.close()
|
|
finally:
|
|
del self._stream
|
|
|
|
async def stop_client() -> None:
|
|
if not hasattr(self, "_client"):
|
|
return
|
|
|
|
try:
|
|
await self._client.close()
|
|
finally:
|
|
del self._client
|
|
|
|
async def stop_connection() -> None:
|
|
self._connection_id = None
|
|
|
|
await stop_all(stop_events, stop_stream, stop_client, stop_connection)
|
|
|
|
logger.debug("nova connection closed")
|
|
|
|
async def restart(
|
|
self,
|
|
system_prompt: str | None = None,
|
|
tools: list[ToolSpec] | None = None,
|
|
messages: Messages | None = None,
|
|
**restart_kwargs: Any,
|
|
) -> None:
|
|
"""Restart by closing the connection and starting a new one, replaying messages.
|
|
|
|
Args:
|
|
system_prompt: System instructions for the new connection.
|
|
tools: Tool specifications for the new connection.
|
|
messages: Conversation history to replay into the new connection.
|
|
**restart_kwargs: Reserved for provider-specific restart options.
|
|
"""
|
|
logger.debug("nova restart starting")
|
|
await self.stop()
|
|
await self.start(system_prompt, tools, messages, **restart_kwargs)
|
|
logger.debug("connection_id=<%s> | nova restart complete", self._connection_id)
|
|
|
|
def _convert_nova_event(
|
|
self,
|
|
nova_event: dict[str, Any],
|
|
response_state: _ResponseState,
|
|
) -> list[BidiOutputEvent]:
|
|
"""Convert Nova Sonic events to TypedEvent format."""
|
|
if "completionStart" in nova_event:
|
|
completion_data = nova_event["completionStart"]
|
|
self._current_completion_id = completion_data.get("completionId")
|
|
logger.debug("completion_id=<%s> | nova completion started", self._current_completion_id)
|
|
return []
|
|
|
|
if "completionEnd" in nova_event:
|
|
# completionEnd closes the whole prompt/session, so only clear local state.
|
|
self._current_completion_id = None
|
|
response_state.reset()
|
|
return []
|
|
|
|
if "contentStart" in nova_event:
|
|
content_start = nova_event["contentStart"]
|
|
content_type = content_start["type"]
|
|
role = content_start["role"].strip().lower()
|
|
|
|
events: list[BidiOutputEvent] = []
|
|
if role == "user" and response_state.tool_use:
|
|
# A tool-only response may have no audio END_TURN before the next user content.
|
|
events.extend(self._complete_response(response_state))
|
|
|
|
generation_stage = None
|
|
if content_type == "TEXT":
|
|
generation_stage = json.loads(content_start.get("additionalModelFields", "{}")).get("generationStage")
|
|
response_state.generation_stage = generation_stage
|
|
if role == "assistant" and generation_stage == "FINAL":
|
|
# FINAL text can continue after END_TURN; use speculative text and audio boundaries.
|
|
return []
|
|
|
|
transcript = response_state.transcript
|
|
if transcript is not None and transcript.role == "user" and role != "user":
|
|
# Nova uses PARTIAL_TURN for user text, so a role change closes the transcript.
|
|
events.append(BidiTranscriptStopEvent(transcript.text, transcript.role, transcript.content_id))
|
|
response_state.transcript = None
|
|
|
|
if role in ("user", "assistant", "tool") and response_state.response_id is None:
|
|
response_state.response_id = str(uuid.uuid4())
|
|
events.append(BidiResponseStartEvent(response_state.response_id))
|
|
|
|
if role == "user" or (role == "assistant" and generation_stage == "SPECULATIVE"):
|
|
if response_state.transcript is None:
|
|
transcript = _Transcript(content_start["contentId"], cast(Role, role))
|
|
response_state.transcript = transcript
|
|
events.append(BidiTranscriptStartEvent(transcript.role, transcript.content_id))
|
|
elif role == "assistant" and content_type == "AUDIO" and not response_state.audio_started:
|
|
response_state.audio_started = True
|
|
events.append(BidiAudioStartEvent())
|
|
return events
|
|
|
|
if "textOutput" in nova_event:
|
|
text_output = nova_event["textOutput"]
|
|
text_content = text_output["content"]
|
|
role = text_output["role"].strip().lower()
|
|
if role == "assistant" and response_state.generation_stage == "FINAL":
|
|
return []
|
|
|
|
transcript = cast(_Transcript, response_state.transcript)
|
|
return [
|
|
BidiTranscriptDeltaEvent(
|
|
delta=transcript.append(text_content),
|
|
role=transcript.role,
|
|
content_id=transcript.content_id,
|
|
)
|
|
]
|
|
|
|
if "audioOutput" in nova_event:
|
|
return [
|
|
BidiAudioDeltaEvent(
|
|
audio=nova_event["audioOutput"]["content"],
|
|
**self._audio_config["output"],
|
|
)
|
|
]
|
|
|
|
if "toolUse" in nova_event:
|
|
tool_use = nova_event["toolUse"]
|
|
tool_use_event: ToolUse = {
|
|
"toolUseId": tool_use["toolUseId"],
|
|
"name": tool_use["toolName"],
|
|
"input": json.loads(tool_use["content"]),
|
|
}
|
|
return [
|
|
ToolUseStreamEvent(
|
|
delta={
|
|
"toolUse": {
|
|
"toolUseId": tool_use_event["toolUseId"],
|
|
"name": tool_use_event["name"],
|
|
"input": json.dumps(tool_use_event["input"]),
|
|
}
|
|
},
|
|
current_tool_use=dict(tool_use_event),
|
|
)
|
|
]
|
|
|
|
if "contentEnd" in nova_event:
|
|
content_end = nova_event["contentEnd"]
|
|
content_type = content_end.get("type")
|
|
stop_reason = content_end.get("stopReason")
|
|
response_state.generation_stage = None
|
|
|
|
events = []
|
|
if content_type == "AUDIO":
|
|
if stop_reason == "END_TURN":
|
|
events.extend(self._complete_response(response_state))
|
|
return events
|
|
|
|
if stop_reason == "INTERRUPTED":
|
|
events.append(BidiBargeInEvent("user_speech"))
|
|
if response_state.response_id is not None:
|
|
events.extend(self._complete_response(response_state))
|
|
return events
|
|
|
|
if stop_reason == "TOOL_USE":
|
|
response_state.tool_use = True
|
|
|
|
return events
|
|
|
|
if "usageEvent" in nova_event:
|
|
usage_data = nova_event["usageEvent"]
|
|
total_input = usage_data.get("totalInputTokens", 0)
|
|
total_output = usage_data.get("totalOutputTokens", 0)
|
|
|
|
return [
|
|
BidiUsageEvent(
|
|
input_tokens=total_input,
|
|
output_tokens=total_output,
|
|
total_tokens=usage_data.get("totalTokens", total_input + total_output),
|
|
)
|
|
]
|
|
|
|
return []
|
|
|
|
def _complete_response(self, response_state: _ResponseState) -> list[BidiOutputEvent]:
|
|
"""Close audio, transcript, and response, then clear their state."""
|
|
events: list[BidiOutputEvent] = []
|
|
if response_state.audio_started:
|
|
events.append(BidiAudioStopEvent())
|
|
transcript = response_state.transcript
|
|
if transcript is not None:
|
|
events.append(BidiTranscriptStopEvent(transcript.text, transcript.role, transcript.content_id))
|
|
events.append(BidiResponseStopEvent(cast(str, response_state.response_id)))
|
|
response_state.reset()
|
|
return events
|
|
|
|
def _get_connection_start_event(self) -> str:
|
|
"""Generate Nova Sonic connection start event."""
|
|
session_start_event: dict[str, Any] = {"event": {"sessionStart": {**(self._config.get("params") or {})}}}
|
|
|
|
return json.dumps(session_start_event)
|
|
|
|
def _get_prompt_start_event(self, tools: list[ToolSpec]) -> str:
|
|
"""Generate Nova Sonic prompt start event with tool configuration."""
|
|
audio_output_config = {
|
|
"mediaType": "audio/lpcm",
|
|
"sampleRateHertz": self._audio_config["output"]["sample_rate"],
|
|
"sampleSizeBits": 16,
|
|
"channelCount": self._audio_config["output"]["channels"],
|
|
"voiceId": self._voice,
|
|
"encoding": "base64",
|
|
"audioType": "SPEECH",
|
|
}
|
|
|
|
prompt_start_event: dict[str, Any] = {
|
|
"event": {
|
|
"promptStart": {
|
|
"promptName": self._connection_id,
|
|
"textOutputConfiguration": NOVA_TEXT_CONFIG,
|
|
"audioOutputConfiguration": audio_output_config,
|
|
}
|
|
}
|
|
}
|
|
|
|
if tools:
|
|
tool_config = self._build_tool_configuration(tools)
|
|
prompt_start_event["event"]["promptStart"]["toolUseOutputConfiguration"] = NOVA_TOOL_CONFIG
|
|
prompt_start_event["event"]["promptStart"]["toolConfiguration"] = {"tools": tool_config}
|
|
|
|
return json.dumps(prompt_start_event)
|
|
|
|
def _build_tool_configuration(self, tools: list[ToolSpec]) -> list[dict[str, Any]]:
|
|
"""Build tool configuration from tool specs."""
|
|
tool_config: list[dict[str, Any]] = []
|
|
for tool in tools:
|
|
input_schema = (
|
|
{"json": json.dumps(tool["inputSchema"]["json"])}
|
|
if "json" in tool["inputSchema"]
|
|
else {"json": json.dumps(tool["inputSchema"])}
|
|
)
|
|
|
|
tool_config.append(
|
|
{"toolSpec": {"name": tool["name"], "description": tool["description"], "inputSchema": input_schema}}
|
|
)
|
|
return tool_config
|
|
|
|
def _get_system_prompt_events(self, system_prompt: str | None) -> list[str]:
|
|
"""Generate system prompt events."""
|
|
content_name = str(uuid.uuid4())
|
|
return [
|
|
self._get_text_content_start_event(content_name, "SYSTEM", interactive=False),
|
|
self._get_text_input_event(content_name, system_prompt or ""),
|
|
self._get_content_end_event(content_name),
|
|
]
|
|
|
|
def _get_message_history_events(self, messages: Messages) -> list[str]:
|
|
"""Generate conversation history events from agent messages.
|
|
|
|
Converts agent message history to Nova Sonic format following the
|
|
contentStart/textInput/contentEnd pattern for each message.
|
|
|
|
History messages are sent as non-interactive (interactive=False) so Nova Sonic
|
|
treats them as prior context rather than new inputs requiring a response.
|
|
|
|
Individual messages are truncated to 50KB and total history is capped
|
|
at 200KB. When the limit is reached, the oldest messages are dropped
|
|
to prioritize recent conversation context.
|
|
|
|
Args:
|
|
messages: List of conversation messages with role and content.
|
|
|
|
Returns:
|
|
List of JSON event strings for Nova Sonic.
|
|
"""
|
|
max_message_bytes = _MAX_HISTORY_MESSAGE_BYTES
|
|
max_total_bytes = _MAX_HISTORY_TOTAL_BYTES
|
|
|
|
# First pass: extract and truncate text from each message, walking backwards
|
|
# to prioritize recent messages when the total size limit is hit
|
|
prepared: list[tuple[str, str]] = [] # (role, text)
|
|
total_bytes = 0
|
|
|
|
for message in reversed(messages):
|
|
role = message["role"].upper()
|
|
content_blocks = message.get("content", [])
|
|
|
|
text_parts = []
|
|
for block in content_blocks:
|
|
if "text" in block:
|
|
text_parts.append(block["text"])
|
|
|
|
if not text_parts:
|
|
continue
|
|
|
|
combined_text = "\n".join(text_parts)
|
|
|
|
# Truncate individual message
|
|
encoded = combined_text.encode("utf-8")
|
|
if len(encoded) > max_message_bytes:
|
|
encoded = encoded[:max_message_bytes]
|
|
combined_text = encoded.decode("utf-8", errors="ignore")
|
|
encoded = combined_text.encode("utf-8")
|
|
|
|
msg_bytes = len(encoded)
|
|
|
|
if total_bytes + msg_bytes > max_total_bytes:
|
|
logger.debug(
|
|
"total_bytes=<%d>, msg_bytes=<%d>, max_total_bytes=<%d> | dropping older messages to fit limit",
|
|
total_bytes,
|
|
msg_bytes,
|
|
max_total_bytes,
|
|
)
|
|
break
|
|
|
|
total_bytes += msg_bytes
|
|
prepared.append((role, combined_text))
|
|
|
|
# Reverse back to chronological order
|
|
prepared.reverse()
|
|
|
|
# Ensure the first message is from the user role — drop leading assistant messages
|
|
while prepared and prepared[0][0] != "USER":
|
|
dropped_role, dropped_text = prepared.pop(0)
|
|
logger.debug(
|
|
"role=<%s>, text_preview=<%s> | dropping leading non-user message from history",
|
|
dropped_role,
|
|
dropped_text[:100],
|
|
)
|
|
|
|
logger.debug("prepared_count=<%d>, total_bytes=<%d> | final history after trimming", len(prepared), total_bytes)
|
|
|
|
# Second pass: build events
|
|
events: list[str] = []
|
|
for role, text in prepared:
|
|
content_name = str(uuid.uuid4())
|
|
events.extend(
|
|
[
|
|
self._get_text_content_start_event(content_name, role, interactive=False),
|
|
self._get_text_input_event(content_name, text),
|
|
self._get_content_end_event(content_name),
|
|
]
|
|
)
|
|
|
|
return events
|
|
|
|
def _get_text_content_start_event(self, content_name: str, role: str = "USER", interactive: bool = True) -> str:
|
|
"""Generate text content start event.
|
|
|
|
Args:
|
|
content_name: Unique identifier for this content block.
|
|
role: Message role (USER, ASSISTANT, SYSTEM).
|
|
interactive: Whether this is a live input (True) or history context (False).
|
|
"""
|
|
return json.dumps(
|
|
{
|
|
"event": {
|
|
"contentStart": {
|
|
"promptName": self._connection_id,
|
|
"contentName": content_name,
|
|
"type": "TEXT",
|
|
"role": role,
|
|
"interactive": interactive,
|
|
"textInputConfiguration": NOVA_TEXT_CONFIG,
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
def _get_tool_content_start_event(self, content_name: str, tool_use_id: str) -> str:
|
|
"""Generate tool content start event."""
|
|
return json.dumps(
|
|
{
|
|
"event": {
|
|
"contentStart": {
|
|
"promptName": self._connection_id,
|
|
"contentName": content_name,
|
|
"interactive": False,
|
|
"type": "TOOL",
|
|
"role": "TOOL",
|
|
"toolResultInputConfiguration": {
|
|
"toolUseId": tool_use_id,
|
|
"type": "TEXT",
|
|
"textInputConfiguration": NOVA_TEXT_CONFIG,
|
|
},
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
def _get_text_input_event(self, content_name: str, text: str) -> str:
|
|
"""Generate text input event."""
|
|
return json.dumps(
|
|
{"event": {"textInput": {"promptName": self._connection_id, "contentName": content_name, "content": text}}}
|
|
)
|
|
|
|
def _get_tool_result_event(self, content_name: str, result: dict[str, Any]) -> str:
|
|
"""Generate tool result event."""
|
|
return json.dumps(
|
|
{
|
|
"event": {
|
|
"toolResult": {
|
|
"promptName": self._connection_id,
|
|
"contentName": content_name,
|
|
"content": json.dumps(result),
|
|
}
|
|
}
|
|
}
|
|
)
|
|
|
|
def _get_content_end_event(self, content_name: str) -> str:
|
|
"""Generate content end event."""
|
|
return json.dumps({"event": {"contentEnd": {"promptName": self._connection_id, "contentName": content_name}}})
|
|
|
|
def _get_prompt_end_event(self) -> str:
|
|
"""Generate prompt end event."""
|
|
return json.dumps({"event": {"promptEnd": {"promptName": self._connection_id}}})
|
|
|
|
def _get_connection_end_event(self) -> str:
|
|
"""Generate connection end event."""
|
|
return json.dumps({"event": {"connectionEnd": {}}})
|
|
|
|
def _get_audio_metadata_for_logging(self, event_dict: dict[str, Any]) -> dict[str, Any]:
|
|
"""Extract audio metadata from event dict for logging.
|
|
|
|
Instead of logging large base64-encoded audio data, this extracts metadata
|
|
like byte count to verify audio presence without bloating logs.
|
|
|
|
Args:
|
|
event_dict: The event dictionary to process.
|
|
|
|
Returns:
|
|
A dict with audio metadata (byte_count) if audio is present, empty dict otherwise.
|
|
"""
|
|
metadata: dict[str, Any] = {}
|
|
|
|
if "event" in event_dict:
|
|
event_data = event_dict["event"]
|
|
|
|
# Handle contentStart events with audio
|
|
if "contentStart" in event_data and "content" in event_data["contentStart"]:
|
|
content = event_data["contentStart"]["content"]
|
|
if "audio" in content and "bytes" in content["audio"]:
|
|
metadata["audio_byte_count"] = len(content["audio"]["bytes"])
|
|
|
|
# Handle content events with audio
|
|
if "content" in event_data and "content" in event_data["content"]:
|
|
content = event_data["content"]["content"]
|
|
if "audio" in content and "bytes" in content["audio"]:
|
|
metadata["audio_byte_count"] = len(content["audio"]["bytes"])
|
|
|
|
return metadata
|
|
|
|
async def _send_nova_events(self, events: list[str]) -> None:
|
|
"""Send event JSON string to Nova Sonic stream.
|
|
|
|
A lock is used to send events in sequence when required (e.g., tool result start, content, and end).
|
|
|
|
Args:
|
|
events: Jsonified events.
|
|
"""
|
|
async with self._send_lock:
|
|
for event in events:
|
|
bytes_data = event.encode("utf-8")
|
|
chunk = InvokeModelWithBidirectionalStreamInputChunk(
|
|
value=BidirectionalInputPayloadPart(bytes_=bytes_data)
|
|
)
|
|
await self._stream.input_stream.send(chunk)
|