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