From a26052b7b23995b055eb2df9d87b55f23f97fa78 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 14 May 2026 12:28:31 -0400 Subject: [PATCH 01/47] Server VAD config for RealtimeTarget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/__init__.py | 2 + pyrit/prompt_target/common/realtime_common.py | 38 ++++++++++ .../openai/openai_realtime_target.py | 24 +++++++ .../target/test_realtime_target.py | 72 ++++++++++++++++++- 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 pyrit/prompt_target/common/realtime_common.py diff --git a/pyrit/prompt_target/__init__.py b/pyrit/prompt_target/__init__.py index 82f897c156..f902996218 100644 --- a/pyrit/prompt_target/__init__.py +++ b/pyrit/prompt_target/__init__.py @@ -19,6 +19,7 @@ ) from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget from pyrit.prompt_target.common.prompt_target import PromptTarget +from pyrit.prompt_target.common.realtime_common import ServerVadConfig from pyrit.prompt_target.common.target_capabilities import ( CapabilityHandlingPolicy, CapabilityName, @@ -101,6 +102,7 @@ def __getattr__(name: str) -> object: "PromptShieldTarget", "PromptTarget", "RealtimeTarget", + "ServerVadConfig", "TargetCapabilities", "TargetConfiguration", "TargetRequirements", diff --git a/pyrit/prompt_target/common/realtime_common.py b/pyrit/prompt_target/common/realtime_common.py new file mode 100644 index 0000000000..fffa03ed4d --- /dev/null +++ b/pyrit/prompt_target/common/realtime_common.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Shared types for realtime audio prompt targets.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ServerVadConfig: + """ + Server-side voice activity detection (VAD) tuning for realtime audio targets. + + Attributes: + threshold: VAD activation threshold (0.0 to 1.0). Defaults to 0.4. + prefix_padding_ms: Milliseconds of pre-roll audio retained before detected speech. + Defaults to 200. + silence_duration_ms: Milliseconds of silence required to detect end-of-turn. + Defaults to 1500. + """ + + threshold: float = 0.4 + prefix_padding_ms: int = 200 + silence_duration_ms: int = 1500 + + def __post_init__(self) -> None: + """ + Validate VAD tuning values. + + Raises: + ValueError: If any field is outside its valid range. + """ + if not 0.0 <= self.threshold <= 1.0: + raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") + if self.prefix_padding_ms < 0: + raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") + if self.silence_duration_ms < 0: + raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 2674150b1c..e646f61234 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -22,6 +22,7 @@ data_serializer_factory, ) from pyrit.prompt_target.common.prompt_target import PromptTarget +from pyrit.prompt_target.common.realtime_common import ServerVadConfig from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration from pyrit.prompt_target.common.utils import limit_requests_per_minute @@ -98,6 +99,7 @@ def __init__( existing_convo: Optional[dict[str, Any]] = None, custom_configuration: Optional[TargetConfiguration] = None, custom_capabilities: Optional[TargetCapabilities] = None, + server_vad: bool | ServerVadConfig = False, **kwargs: Any, ) -> None: """ @@ -123,6 +125,11 @@ def __init__( this target instance. Defaults to None. custom_capabilities (TargetCapabilities, Optional): **Deprecated.** Use ``custom_configuration`` instead. Will be removed in v0.14.0. + server_vad (bool | ServerVadConfig): Server-side voice activity detection (VAD). + ``False`` (default) keeps the existing atomic send/receive behavior. + ``True`` enables VAD with default tuning. + Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming/interruption plumbing + arrives in subsequent changes; this currently only affects the emitted session config. **kwargs: Additional keyword arguments passed to the parent OpenAITarget class. httpx_client_kwargs (dict, Optional): Additional kwargs to be passed to the ``httpx.AsyncClient()`` constructor. For example, to specify a 3 minute timeout: ``httpx_client_kwargs={"timeout": 180}`` @@ -133,6 +140,13 @@ def __init__( self._existing_conversation = existing_convo if existing_convo is not None else {} self._realtime_client: Optional[AsyncOpenAI] = None + if isinstance(server_vad, ServerVadConfig): + self._server_vad: Optional[ServerVadConfig] = server_vad + elif server_vad: + self._server_vad = ServerVadConfig() + else: + self._server_vad = None + def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" self.endpoint_environment_variable = "OPENAI_REALTIME_ENDPOINT" @@ -293,6 +307,16 @@ def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, An }, } + if self._server_vad is not None: + session_config["audio"]["input"]["turn_detection"] = { # type: ignore[ty:invalid-assignment] + "type": "server_vad", + "threshold": self._server_vad.threshold, + "prefix_padding_ms": self._server_vad.prefix_padding_ms, + "silence_duration_ms": self._server_vad.silence_duration_ms, + "create_response": True, + "interrupt_response": True, + } + if self.voice: session_config["audio"]["output"]["voice"] = self.voice # type: ignore[ty:invalid-assignment] diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index d0aa9cc5e2..74af108045 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -7,7 +7,7 @@ from pyrit.exceptions.exception_classes import ServerErrorException from pyrit.models import Message, MessagePiece -from pyrit.prompt_target import RealtimeTarget +from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTargetResult # Env vars that may leak from .env files loaded by other tests in parallel workers. @@ -430,3 +430,73 @@ async def test_receive_events_skips_stale_response_done(target): # Should have processed through to the real response.done with actual audio assert result.audio_bytes == b"dummyaudio" assert result.transcripts == ["hello"] + + +# --------------------------------------------------------------------------- +# Chunk 1 — ServerVadConfig + session config +# --------------------------------------------------------------------------- + + +def test_session_config_omits_turn_detection_when_vad_disabled(target): + """Default construction must not emit a turn_detection block; pins atomic flow.""" + config = target._set_system_prompt_and_config_vars(system_prompt="test prompt") + + assert "turn_detection" not in config["audio"]["input"] + assert config["instructions"] == "test prompt" + + +@patch.dict("os.environ", _CLEAN_UNDERLYING_MODEL_ENV) +def test_session_config_emits_server_vad_block_with_defaults(sqlite_instance): + """server_vad=True must emit defaults.""" + vad_target = RealtimeTarget( + api_key="test_key", + endpoint="wss://test_url", + model_name="test", + server_vad=True, + ) + + config = vad_target._set_system_prompt_and_config_vars(system_prompt="test prompt") + + turn_detection = config["audio"]["input"]["turn_detection"] + assert turn_detection == { + "type": "server_vad", + "threshold": 0.4, + "prefix_padding_ms": 200, + "silence_duration_ms": 1500, + "create_response": True, + "interrupt_response": True, + } + + +@patch.dict("os.environ", _CLEAN_UNDERLYING_MODEL_ENV) +def test_session_config_honors_custom_vad_tuning(sqlite_instance): + """Passing a ServerVadConfig must flow through to the emitted turn_detection block.""" + vad_target = RealtimeTarget( + api_key="test_key", + endpoint="wss://test_url", + model_name="test", + server_vad=ServerVadConfig(threshold=0.7, prefix_padding_ms=350, silence_duration_ms=800), + ) + + turn_detection = vad_target._set_system_prompt_and_config_vars(system_prompt="x")["audio"]["input"][ + "turn_detection" + ] + + assert turn_detection["threshold"] == 0.7 + assert turn_detection["prefix_padding_ms"] == 350 + assert turn_detection["silence_duration_ms"] == 800 + + +@pytest.mark.parametrize( + "kwargs", + [ + {"threshold": -0.1}, + {"threshold": 1.5}, + {"prefix_padding_ms": -1}, + {"silence_duration_ms": -1}, + ], +) +def test_server_vad_config_rejects_invalid_values(kwargs): + """ServerVadConfig must reject out-of-range tuning values at construction.""" + with pytest.raises(ValueError): + ServerVadConfig(**kwargs) From dea833dc5ee6c7b7c65452bb439087cd615401f1 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 14 May 2026 13:11:00 -0400 Subject: [PATCH 02/47] Stream PCM chunks via input_audio_buffer.append Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/openai_realtime_target.py | 37 ++++++++ .../target/test_realtime_target.py | 85 +++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index e646f61234..b628c5ef11 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -503,6 +503,43 @@ async def send_response_create(self, conversation_id: str) -> None: connection = self._get_connection(conversation_id=conversation_id) await connection.response.create() + async def _stream_pcm_async( + self, + *, + connection: Any, + pcm_bytes: bytes, + commit: bool, + chunk_ms: int = 100, + sample_rate: int = 24000, + ) -> None: + """ + Stream raw PCM16 audio to the Realtime API as ``input_audio_buffer.append`` chunks. + + Operates on raw PCM bytes (not WAV) so this helper can back both the + WAV-file path and future per-frame streaming consumers (e.g. browser audio + forwarded by a GUI backend). Caller decides whether to manually commit; + server VAD commits automatically when enabled. + + Args: + connection: Active Realtime API connection from ``self.connect()``. + pcm_bytes (bytes): Raw PCM16 mono audio. Empty buffers are accepted + and result in zero appends. + commit (bool): When True, sends ``input_audio_buffer.commit`` after the + final chunk. Pass False when server VAD is committing automatically. + chunk_ms (int): Milliseconds of audio per chunk. Defaults to 100. + sample_rate (int): PCM sample rate in Hz. Defaults to 24000. + """ + bytes_per_sample = 2 # PCM16 + chunk_size = (chunk_ms * sample_rate * bytes_per_sample) // 1000 + + for offset in range(0, len(pcm_bytes), chunk_size): + chunk = pcm_bytes[offset : offset + chunk_size] + audio_b64 = base64.b64encode(chunk).decode("ascii") + await connection.input_audio_buffer.append(audio=audio_b64) + + if commit: + await connection.input_audio_buffer.commit() + async def receive_events(self, conversation_id: str) -> RealtimeTargetResult: """ Continuously receive events from the OpenAI Realtime API connection. diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 74af108045..2381ab35e4 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import base64 from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -500,3 +501,87 @@ def test_server_vad_config_rejects_invalid_values(kwargs): """ServerVadConfig must reject out-of-range tuning values at construction.""" with pytest.raises(ValueError): ServerVadConfig(**kwargs) + + +# --------------------------------------------------------------------------- +# Chunk 2 — _stream_pcm_async helper +# --------------------------------------------------------------------------- + + +def _make_mock_connection(): + """Return an AsyncMock connection with input_audio_buffer wired up.""" + connection = AsyncMock() + connection.input_audio_buffer.append = AsyncMock() + connection.input_audio_buffer.commit = AsyncMock() + return connection + + +async def test_stream_pcm_even_split_no_commit(target): + """A buffer that divides evenly into chunks emits N appends and no commit when commit=False.""" + connection = _make_mock_connection() + # 100ms @ 24kHz @ 2 bytes/sample = 4800 bytes per chunk. 9600 bytes = 2 chunks. + pcm = b"\x00" * 9600 + + await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) + + assert connection.input_audio_buffer.append.call_count == 2 + connection.input_audio_buffer.commit.assert_not_called() + + +async def test_stream_pcm_partial_final_chunk(target): + """A buffer not a clean multiple of chunk size sends the final partial chunk as-is.""" + connection = _make_mock_connection() + # 5000 bytes => one full 4800-byte chunk + one 200-byte tail. + pcm = b"\x01" * 5000 + + await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) + + assert connection.input_audio_buffer.append.call_count == 2 + # Inspect the second call's chunk size by base64-decoding its audio kwarg. + second_call_audio_b64 = connection.input_audio_buffer.append.call_args_list[1].kwargs["audio"] + assert len(base64.b64decode(second_call_audio_b64)) == 200 + + +async def test_stream_pcm_empty_buffer(target): + """An empty buffer yields zero appends. commit=False produces no commit either.""" + connection = _make_mock_connection() + + await target._stream_pcm_async(connection=connection, pcm_bytes=b"", commit=False) + + connection.input_audio_buffer.append.assert_not_called() + connection.input_audio_buffer.commit.assert_not_called() + + +async def test_stream_pcm_commits_when_asked(target): + """commit=True triggers exactly one input_audio_buffer.commit after all appends.""" + connection = _make_mock_connection() + pcm = b"\x02" * 4800 + + await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=True) + + assert connection.input_audio_buffer.append.call_count == 1 + connection.input_audio_buffer.commit.assert_awaited_once_with() + + +async def test_stream_pcm_empty_buffer_still_commits_when_asked(target): + """commit=True with an empty buffer should still fire commit (e.g. to flush an existing buffer).""" + connection = _make_mock_connection() + + await target._stream_pcm_async(connection=connection, pcm_bytes=b"", commit=True) + + connection.input_audio_buffer.append.assert_not_called() + connection.input_audio_buffer.commit.assert_awaited_once_with() + + +async def test_stream_pcm_appends_base64_encoded_chunks(target): + """Each append's audio kwarg must be the base64 encoding of the corresponding PCM chunk.""" + connection = _make_mock_connection() + # Build a recognizable buffer: 4800 bytes of 0xAA then 4800 bytes of 0xBB. + pcm = (b"\xaa" * 4800) + (b"\xbb" * 4800) + + await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) + + first_audio = connection.input_audio_buffer.append.call_args_list[0].kwargs["audio"] + second_audio = connection.input_audio_buffer.append.call_args_list[1].kwargs["audio"] + assert base64.b64decode(first_audio) == b"\xaa" * 4800 + assert base64.b64decode(second_audio) == b"\xbb" * 4800 From 22c0b54ab4903bffc16fb5626dee9fd8ceebf0c4 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 14 May 2026 16:07:29 -0400 Subject: [PATCH 03/47] Turn state and response cancel for RealtimeTarget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/__init__.py | 2 +- pyrit/prompt_target/common/realtime_audio.py | 90 +++++++++++++++++++ pyrit/prompt_target/common/realtime_common.py | 38 -------- .../openai/openai_realtime_target.py | 63 +++++++------ .../target/test_realtime_audio.py | 18 ++++ .../target/test_realtime_target.py | 53 ++++++++++- 6 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 pyrit/prompt_target/common/realtime_audio.py delete mode 100644 pyrit/prompt_target/common/realtime_common.py create mode 100644 tests/unit/prompt_target/target/test_realtime_audio.py diff --git a/pyrit/prompt_target/__init__.py b/pyrit/prompt_target/__init__.py index f902996218..114bfa4893 100644 --- a/pyrit/prompt_target/__init__.py +++ b/pyrit/prompt_target/__init__.py @@ -19,7 +19,7 @@ ) from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget from pyrit.prompt_target.common.prompt_target import PromptTarget -from pyrit.prompt_target.common.realtime_common import ServerVadConfig +from pyrit.prompt_target.common.realtime_audio import ServerVadConfig from pyrit.prompt_target.common.target_capabilities import ( CapabilityHandlingPolicy, CapabilityName, diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py new file mode 100644 index 0000000000..974078ac42 --- /dev/null +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Shared types for realtime audio prompt targets.""" + +import asyncio +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class ServerVadConfig: + """ + Server-side voice activity detection (VAD) tuning for realtime audio targets. + + Attributes: + threshold: VAD activation threshold (0.0 to 1.0). Defaults to 0.4. + prefix_padding_ms: Milliseconds of pre-roll audio retained before detected speech. + Defaults to 200. + silence_duration_ms: Milliseconds of silence required to detect end-of-turn. + Defaults to 1500. + """ + + threshold: float = 0.4 + prefix_padding_ms: int = 200 + silence_duration_ms: int = 1500 + + def __post_init__(self) -> None: + """ + Validate VAD tuning values. + + Raises: + ValueError: If any field is outside its valid range. + """ + if not 0.0 <= self.threshold <= 1.0: + raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") + if self.prefix_padding_ms < 0: + raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") + if self.silence_duration_ms < 0: + raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") + + +@dataclass +class RealtimeTargetResult: + """ + Result of a Realtime API turn, containing the audio and transcripts actually delivered. + + Attributes: + audio_bytes: Raw PCM16 audio returned by the assistant. May be partial if the + turn was interrupted. + transcripts: Transcript deltas captured during the turn. + """ + + audio_bytes: bytes = b"" + transcripts: list[str] = field(default_factory=list) + + def flatten_transcripts(self) -> str: + """Return all transcript deltas concatenated into a single string.""" + return "".join(self.transcripts) + + +@dataclass +class _RealtimeTurnState: + """ + Mutable per-turn state assembled by the dispatcher and read by the cancel path. + + The dispatcher routes incoming events into this object during a turn; the + completion future is resolved by the dispatcher with a ``RealtimeTargetResult`` + snapshotted from these fields once the turn ends normally or via interruption. + + Attributes: + completion: Future resolved with the assembled result when the turn ends. + is_responding: True between ``response.created`` and ``response.done`` for + the active response. + delivered_audio: Assistant audio bytes accumulated from ``response.audio.delta``. + Uses ``bytearray`` so deltas append in place rather than reallocating. + delivered_transcripts: Transcript deltas accumulated from ``response.audio_transcript.delta``. + current_item_id: Item id of the assistant response currently being streamed. + None until ``response.output_item.added`` fires. + last_response_id: Response id of the in-flight response. None until + ``response.created`` fires. + interrupted: Set True when the cancel/truncate path runs. + """ + + completion: asyncio.Future[RealtimeTargetResult] + is_responding: bool = False + delivered_audio: bytearray = field(default_factory=bytearray) + delivered_transcripts: list[str] = field(default_factory=list) + current_item_id: str | None = None + last_response_id: str | None = None + interrupted: bool = False diff --git a/pyrit/prompt_target/common/realtime_common.py b/pyrit/prompt_target/common/realtime_common.py deleted file mode 100644 index fffa03ed4d..0000000000 --- a/pyrit/prompt_target/common/realtime_common.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Shared types for realtime audio prompt targets.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True) -class ServerVadConfig: - """ - Server-side voice activity detection (VAD) tuning for realtime audio targets. - - Attributes: - threshold: VAD activation threshold (0.0 to 1.0). Defaults to 0.4. - prefix_padding_ms: Milliseconds of pre-roll audio retained before detected speech. - Defaults to 200. - silence_duration_ms: Milliseconds of silence required to detect end-of-turn. - Defaults to 1500. - """ - - threshold: float = 0.4 - prefix_padding_ms: int = 200 - silence_duration_ms: int = 1500 - - def __post_init__(self) -> None: - """ - Validate VAD tuning values. - - Raises: - ValueError: If any field is outside its valid range. - """ - if not 0.0 <= self.threshold <= 1.0: - raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") - if self.prefix_padding_ms < 0: - raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") - if self.silence_duration_ms < 0: - raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index b628c5ef11..08acb39c73 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -6,7 +6,6 @@ import logging import re import wave -from dataclasses import dataclass, field from typing import Any, Literal, Optional from openai import AsyncOpenAI @@ -22,7 +21,11 @@ data_serializer_factory, ) from pyrit.prompt_target.common.prompt_target import PromptTarget -from pyrit.prompt_target.common.realtime_common import ServerVadConfig +from pyrit.prompt_target.common.realtime_audio import ( + RealtimeTargetResult, + ServerVadConfig, + _RealtimeTurnState, +) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration from pyrit.prompt_target.common.utils import limit_requests_per_minute @@ -36,29 +39,6 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] -@dataclass -class RealtimeTargetResult: - """ - Represents the result of a Realtime API request, containing audio data and transcripts. - - Attributes: - audio_bytes: Raw audio data returned by the API - transcripts: List of text transcripts generated from the audio - """ - - audio_bytes: bytes = field(default_factory=lambda: b"") - transcripts: list[str] = field(default_factory=list) - - def flatten_transcripts(self) -> str: - """ - Flattens the list of transcripts into a single string. - - Returns: - A single string containing all transcripts concatenated together. - """ - return "".join(self.transcripts) - - class RealtimeTarget(OpenAITarget, PromptTarget): """ A prompt target for Azure OpenAI Realtime API. @@ -540,6 +520,39 @@ async def _stream_pcm_async( if commit: await connection.input_audio_buffer.commit() + async def _cancel_in_flight_response( + self, + *, + connection: Any, + state: _RealtimeTurnState, + ) -> None: + """ + Cancel the in-flight response and truncate the assistant item to delivered bytes. + + Sends ``response.cancel`` and ``conversation.item.truncate`` so the server stops + generating and prunes its conversation history to only what was actually delivered. + Marks ``state.interrupted = True`` even if either wire call fails so callers can + tell the turn was cut short. Resolving the completion future is the dispatcher's + responsibility, not this method's. + + Args: + connection: Active Realtime API connection from ``self.connect()``. + state (_RealtimeTurnState): The turn whose response should be cancelled. + """ + try: + if state.last_response_id is not None: + await connection.response.cancel(response_id=state.last_response_id) + if state.current_item_id is not None: + await connection.conversation.item.truncate( + item_id=state.current_item_id, + content_index=0, + # PCM16 @ 24 kHz: 48 bytes per millisecond. + audio_end_ms=len(state.delivered_audio) // 48, + ) + except Exception as e: + logger.warning(f"Cancel/truncate failed for response {state.last_response_id}: {e}") + state.interrupted = True + async def receive_events(self, conversation_id: str) -> RealtimeTargetResult: """ Continuously receive events from the OpenAI Realtime API connection. diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py new file mode 100644 index 0000000000..df31cc2fe5 --- /dev/null +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +import asyncio + +from pyrit.prompt_target.common.realtime_audio import _RealtimeTurnState + + +async def test_realtime_turn_state_defaults(): + """Newly constructed turn state must be empty: no audio, no transcripts, not responding, not interrupted.""" + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + assert state.is_responding is False + assert state.interrupted is False + assert bytes(state.delivered_audio) == b"" + assert state.delivered_transcripts == [] + assert state.current_item_id is None + assert state.last_response_id is None diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 2381ab35e4..f9e0e6d126 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +import asyncio import base64 from unittest.mock import AsyncMock, MagicMock, patch @@ -9,7 +10,7 @@ from pyrit.exceptions.exception_classes import ServerErrorException from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig -from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTargetResult +from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, _RealtimeTurnState # Env vars that may leak from .env files loaded by other tests in parallel workers. _CLEAN_UNDERLYING_MODEL_ENV = { @@ -585,3 +586,53 @@ async def test_stream_pcm_appends_base64_encoded_chunks(target): second_audio = connection.input_audio_buffer.append.call_args_list[1].kwargs["audio"] assert base64.b64decode(first_audio) == b"\xaa" * 4800 assert base64.b64decode(second_audio) == b"\xbb" * 4800 + + +def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = "item_xyz") -> _RealtimeTurnState: + """Build a turn state with the named ids preset; completion future is unused by cancel tests.""" + return _RealtimeTurnState( + completion=asyncio.get_event_loop().create_future(), + is_responding=True, + last_response_id=response_id, + current_item_id=item_id, + ) + + +async def test_cancel_calls_response_cancel_with_state_response_id(target): + """_cancel_in_flight_response must forward state.last_response_id to response.cancel.""" + connection = AsyncMock() + state = _turn_state(response_id="resp_42") + state.delivered_audio.extend(b"\x00" * 4800) + + await target._cancel_in_flight_response(connection=connection, state=state) + + connection.response.cancel.assert_awaited_once_with(response_id="resp_42") + + +async def test_cancel_truncates_to_delivered_audio_ms(target): + """Truncate must be called with audio_end_ms computed from delivered_audio length.""" + connection = AsyncMock() + state = _turn_state(item_id="item_99") + # 4800 delivered bytes / 48 bytes-per-ms = 100ms + state.delivered_audio.extend(b"\x00" * 4800) + + await target._cancel_in_flight_response(connection=connection, state=state) + + connection.conversation.item.truncate.assert_awaited_once_with( + item_id="item_99", + content_index=0, + audio_end_ms=100, + ) + assert state.interrupted is True + + +async def test_cancel_marks_interrupted_even_when_wire_call_raises(target, caplog): + """A failed cancel must log a warning and still flip state.interrupted.""" + connection = AsyncMock() + connection.response.cancel.side_effect = RuntimeError("boom") + state = _turn_state() + + await target._cancel_in_flight_response(connection=connection, state=state) + + assert state.interrupted is True + assert any("Cancel/truncate failed" in record.message for record in caplog.records) From 276c06c9c9ec476ea8ad05edfa00d34dd882a9f0 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 15 May 2026 17:27:41 -0400 Subject: [PATCH 04/47] Event dispatcher for RealtimeTarget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 119 ++++++++++++++ .../openai/openai_realtime_target.py | 138 ++++++++++++---- .../target/test_realtime_audio.py | 133 +++++++++++++++- .../target/test_realtime_target.py | 147 ++++++++++++++++-- 4 files changed, 494 insertions(+), 43 deletions(-) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 974078ac42..4e148764ea 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -4,7 +4,13 @@ """Shared types for realtime audio prompt targets.""" import asyncio +import contextlib +import logging +from abc import ABC, abstractmethod from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) @dataclass(frozen=True) @@ -88,3 +94,116 @@ class _RealtimeTurnState: current_item_id: str | None = None last_response_id: str | None = None interrupted: bool = False + + +class _RealtimeEventDispatcher(ABC): + """ + Owns a realtime connection's event stream and routes events to the active turn. + + One long-lived async task per websocket connection. The dispatcher is the only + code that consumes the connection's async iterator; turn-aware senders register + a ``_RealtimeTurnState`` and ``await state.completion`` while the dispatcher + mutates the state in response to incoming events. + + Provider-specific event names and cancel wire calls are isolated to the + abstract methods so each realtime provider (OpenAI, Gemini Live, etc.) supplies + only its routing and cancel logic. + """ + + def __init__(self, *, connection: Any) -> None: + """ + Args: + connection: An open realtime connection exposing an async iterator + of server events. The dispatcher owns reading from it. + """ + self._connection = connection + self._current_turn: _RealtimeTurnState | None = None + self._task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start the background dispatch task. Idempotent.""" + if self._task is None: + self._task = asyncio.create_task(self._dispatch_loop()) + + async def stop(self) -> None: + """Cancel the background dispatch task and release the reference.""" + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._task + self._task = None + + def register_turn(self, state: _RealtimeTurnState) -> None: + """ + Bind a new turn as the active turn. + + Args: + state (_RealtimeTurnState): The turn whose completion future will be + resolved when this turn ends. + + Raises: + RuntimeError: If another turn is already active on this dispatcher. + """ + if self._current_turn is not None and not self._current_turn.completion.done(): + raise RuntimeError("Another turn is already active on this dispatcher") + self._current_turn = state + + async def _dispatch_loop(self) -> None: + """ + Consume events from the connection and route each to the active turn. + + Raises: + asyncio.CancelledError: Propagated when ``stop()`` cancels the task. + """ + try: + async for event in self._connection: + turn = self._current_turn + if turn is None or turn.completion.done(): + continue + try: + await self._route_event(event=event, state=turn) + except Exception as e: + logger.exception(f"Realtime event router raised: {e}") + if not turn.completion.done(): + turn.completion.set_exception(e) + except asyncio.CancelledError: + raise + except Exception as e: + logger.exception(f"Realtime dispatch loop crashed: {e}") + turn = self._current_turn + if turn is not None and not turn.completion.done(): + turn.completion.set_exception(e) + + @abstractmethod + async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + """ + Update ``state`` based on a single provider-specific event. + + Concrete implementations: + - Set ``state.is_responding`` / ``state.last_response_id`` / ``state.current_item_id`` + as the relevant lifecycle events arrive. + - Append delivered audio and transcript deltas to ``state``. + - On normal completion, resolve ``state.completion`` with a + ``RealtimeTargetResult`` snapshotted from ``state``. + - On server-side speech-started while ``state.is_responding``, call + ``self._cancel(state=state)`` then resolve ``state.completion`` with + ``interrupted=True`` and the partial audio. + - On error events, resolve ``state.completion`` via ``set_exception``. + + Args: + event: A single provider-specific event from the connection iterator. + state (_RealtimeTurnState): The currently-active turn. + """ + + @abstractmethod + async def _cancel(self, *, state: _RealtimeTurnState) -> None: + """ + Send provider-specific cancel and truncate events for the in-flight response. + + Must set ``state.interrupted = True`` even on wire-call failure so callers + can tell the turn was cut short. Must not resolve ``state.completion``; + that is the dispatcher's responsibility. + + Args: + state (_RealtimeTurnState): The turn whose response should be cancelled. + """ diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 08acb39c73..edd7df6aa7 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -24,6 +24,7 @@ from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, ServerVadConfig, + _RealtimeEventDispatcher, _RealtimeTurnState, ) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities @@ -520,39 +521,6 @@ async def _stream_pcm_async( if commit: await connection.input_audio_buffer.commit() - async def _cancel_in_flight_response( - self, - *, - connection: Any, - state: _RealtimeTurnState, - ) -> None: - """ - Cancel the in-flight response and truncate the assistant item to delivered bytes. - - Sends ``response.cancel`` and ``conversation.item.truncate`` so the server stops - generating and prunes its conversation history to only what was actually delivered. - Marks ``state.interrupted = True`` even if either wire call fails so callers can - tell the turn was cut short. Resolving the completion future is the dispatcher's - responsibility, not this method's. - - Args: - connection: Active Realtime API connection from ``self.connect()``. - state (_RealtimeTurnState): The turn whose response should be cancelled. - """ - try: - if state.last_response_id is not None: - await connection.response.cancel(response_id=state.last_response_id) - if state.current_item_id is not None: - await connection.conversation.item.truncate( - item_id=state.current_item_id, - content_index=0, - # PCM16 @ 24 kHz: 48 bytes per millisecond. - audio_end_ms=len(state.delivered_audio) // 48, - ) - except Exception as e: - logger.warning(f"Cancel/truncate failed for response {state.last_response_id}: {e}") - state.interrupted = True - async def receive_events(self, conversation_id: str) -> RealtimeTargetResult: """ Continuously receive events from the OpenAI Realtime API connection. @@ -883,3 +851,107 @@ async def _construct_message_from_response(self, response: Any, request: Any) -> This implementation exists to satisfy the abstract base class requirement. """ raise NotImplementedError("RealtimeTarget uses receive_events for message construction") + + +class _OpenAIRealtimeDispatcher(_RealtimeEventDispatcher): + """ + Concrete ``_RealtimeEventDispatcher`` for the OpenAI Realtime API. + + Routes OpenAI server events into the active ``_RealtimeTurnState`` and issues + ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. + """ + + async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + """Update state and resolve completion based on OpenAI Realtime events.""" + if state.completion.done(): + return + + event_type = getattr(event, "type", "") + + if event_type == "response.created": + state.is_responding = True + response = getattr(event, "response", None) + if response is not None: + state.last_response_id = getattr(response, "id", None) + return + + if event_type in ("response.output_item.added", "response.output_item.created"): + item = getattr(event, "item", None) + if item is not None: + state.current_item_id = getattr(item, "id", None) + return + + if event_type in ("response.audio.delta", "response.output_audio.delta"): + delta = getattr(event, "delta", "") + if delta: + state.delivered_audio.extend(base64.b64decode(delta)) + return + + if event_type in ("response.audio_transcript.delta", "response.output_audio_transcript.delta"): + delta = getattr(event, "delta", "") + if delta: + state.delivered_transcripts.append(delta) + return + + if event_type == "response.done": + response = getattr(event, "response", None) + done_response_id = getattr(response, "id", None) if response is not None else None + if state.last_response_id is not None and done_response_id != state.last_response_id: + # Stale event from a cancelled response; drop without resolving. + return + state.is_responding = False + state.completion.set_result( + RealtimeTargetResult( + audio_bytes=bytes(state.delivered_audio), + transcripts=list(state.delivered_transcripts), + ) + ) + return + + if event_type == "input_audio_buffer.speech_started" and state.is_responding: + await self._cancel(state=state) + state.is_responding = False + state.completion.set_result( + RealtimeTargetResult( + audio_bytes=bytes(state.delivered_audio), + transcripts=list(state.delivered_transcripts), + ) + ) + return + + if event_type == "error": + error = getattr(event, "error", None) + message = getattr(error, "message", "unknown") if error is not None else "unknown" + state.completion.set_exception(RuntimeError(f"Realtime API error: {message}")) + return + + async def _cancel(self, *, state: _RealtimeTurnState) -> None: + """ + Send ``response.cancel`` + ``conversation.item.truncate`` for the in-flight response. + + Marks ``state.interrupted = True`` even when either wire call fails. + Does not resolve ``state.completion``; the caller (``_route_event``) does that. + + Args: + state (_RealtimeTurnState): The turn whose response should be cancelled. + """ + if state.last_response_id is not None: + try: + await self._connection.response.cancel(response_id=state.last_response_id) + except Exception as e: + logger.debug(f"response.cancel raised for {state.last_response_id} (likely cancelled server-side): {e}") + if state.current_item_id is not None: + # PCM16 @ 24 kHz: 48 bytes per millisecond. + audio_end_ms = len(state.delivered_audio) // 48 + try: + await self._connection.conversation.item.truncate( + item_id=state.current_item_id, + content_index=0, + audio_end_ms=audio_end_ms, + ) + except Exception as e: + logger.warning( + f"conversation.item.truncate failed for item {state.current_item_id} " + f"(audio_end_ms={audio_end_ms}): {e}" + ) + state.interrupted = True diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index df31cc2fe5..80a0cd9734 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -2,8 +2,16 @@ # Licensed under the MIT license. import asyncio +from typing import Any +from unittest.mock import AsyncMock -from pyrit.prompt_target.common.realtime_audio import _RealtimeTurnState +import pytest + +from pyrit.prompt_target.common.realtime_audio import ( + RealtimeTargetResult, + _RealtimeEventDispatcher, + _RealtimeTurnState, +) async def test_realtime_turn_state_defaults(): @@ -16,3 +24,126 @@ async def test_realtime_turn_state_defaults(): assert state.delivered_transcripts == [] assert state.current_item_id is None assert state.last_response_id is None + + +class _RecordingDispatcher(_RealtimeEventDispatcher): + """Minimal concrete dispatcher for testing the generic base class behavior.""" + + def __init__(self, *, connection: Any) -> None: + super().__init__(connection=connection) + self.routed_events: list[Any] = [] + self.cancel_calls: int = 0 + + async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + self.routed_events.append(event) + # End the turn on a sentinel event so tests can drain the loop. + if getattr(event, "_finish", False): + state.completion.set_result(RealtimeTargetResult()) + + async def _cancel(self, *, state: _RealtimeTurnState) -> None: + self.cancel_calls += 1 + state.interrupted = True + + +class _ScriptedConnection: + """Async-iterable connection that yields a fixed event list once registered.""" + + def __init__(self, events: list[Any]) -> None: + self._events = events + + async def __aiter__(self): + for event in self._events: + yield event + + +def _sentinel_event(*, finish: bool = False) -> AsyncMock: + event = AsyncMock() + event._finish = finish + return event + + +async def test_dispatcher_start_is_idempotent(): + """Calling start twice must not spawn two tasks.""" + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) + await dispatcher.start() + first_task = dispatcher._task + await dispatcher.start() + assert dispatcher._task is first_task + await dispatcher.stop() + + +async def test_dispatcher_stop_releases_task(): + """stop must cancel the task and clear the reference.""" + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) + await dispatcher.start() + await dispatcher.stop() + assert dispatcher._task is None + + +async def test_dispatcher_register_turn_rejects_concurrent_active_turn(): + """Registering a turn while another is active and unresolved must raise.""" + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) + first = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + second = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + dispatcher.register_turn(first) + with pytest.raises(RuntimeError, match="already active"): + dispatcher.register_turn(second) + + +async def test_dispatcher_register_turn_allows_replacement_after_completion(): + """Once the active turn's future is done, register_turn may bind a new turn.""" + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) + first = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + first.completion.set_result(RealtimeTargetResult()) + second = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + dispatcher.register_turn(first) + dispatcher.register_turn(second) + assert dispatcher._current_turn is second + + +async def test_dispatcher_loop_routes_events_to_active_turn(): + """The dispatch loop must forward events from the connection to _route_event.""" + finish = _sentinel_event(finish=True) + other = _sentinel_event() + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([other, finish])) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + dispatcher.register_turn(state) + + await dispatcher.start() + await asyncio.wait_for(state.completion, timeout=1.0) + await dispatcher.stop() + + assert dispatcher.routed_events == [other, finish] + + +async def test_dispatcher_loop_skips_events_when_no_active_turn(): + """Events arriving with no current turn (or a completed one) are dropped quietly.""" + finish = _sentinel_event(finish=True) + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([_sentinel_event(), finish])) + + # No register_turn called. + await dispatcher.start() + await asyncio.sleep(0.05) + await dispatcher.stop() + + assert dispatcher.routed_events == [] + + +async def test_dispatcher_loop_sets_exception_on_router_failure(): + """A router exception must propagate to the active turn's completion future.""" + + class _ExplodingDispatcher(_RecordingDispatcher): + async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + raise ValueError("router boom") + + event = _sentinel_event() + dispatcher = _ExplodingDispatcher(connection=_ScriptedConnection([event])) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + dispatcher.register_turn(state) + + await dispatcher.start() + with pytest.raises(ValueError, match="router boom"): + await asyncio.wait_for(state.completion, timeout=1.0) + await dispatcher.stop() diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index f9e0e6d126..2bd17f6626 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -11,6 +11,7 @@ from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, _RealtimeTurnState +from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher # Env vars that may leak from .env files loaded by other tests in parallel workers. _CLEAN_UNDERLYING_MODEL_ENV = { @@ -598,25 +599,32 @@ def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = " ) -async def test_cancel_calls_response_cancel_with_state_response_id(target): - """_cancel_in_flight_response must forward state.last_response_id to response.cancel.""" +def _make_dispatcher(connection): + """Build an _OpenAIRealtimeDispatcher around the given mock connection.""" + return _OpenAIRealtimeDispatcher(connection=connection) + + +async def test_cancel_calls_response_cancel_with_state_response_id(): + """_cancel must forward state.last_response_id to response.cancel.""" connection = AsyncMock() + dispatcher = _make_dispatcher(connection) state = _turn_state(response_id="resp_42") state.delivered_audio.extend(b"\x00" * 4800) - await target._cancel_in_flight_response(connection=connection, state=state) + await dispatcher._cancel(state=state) connection.response.cancel.assert_awaited_once_with(response_id="resp_42") -async def test_cancel_truncates_to_delivered_audio_ms(target): +async def test_cancel_truncates_to_delivered_audio_ms(): """Truncate must be called with audio_end_ms computed from delivered_audio length.""" connection = AsyncMock() + dispatcher = _make_dispatcher(connection) state = _turn_state(item_id="item_99") # 4800 delivered bytes / 48 bytes-per-ms = 100ms state.delivered_audio.extend(b"\x00" * 4800) - await target._cancel_in_flight_response(connection=connection, state=state) + await dispatcher._cancel(state=state) connection.conversation.item.truncate.assert_awaited_once_with( item_id="item_99", @@ -626,13 +634,134 @@ async def test_cancel_truncates_to_delivered_audio_ms(target): assert state.interrupted is True -async def test_cancel_marks_interrupted_even_when_wire_call_raises(target, caplog): - """A failed cancel must log a warning and still flip state.interrupted.""" +async def test_cancel_marks_interrupted_even_when_response_cancel_raises(caplog): + """A failed response.cancel must log at debug (likely server-side cancelled) and still flip state.interrupted.""" connection = AsyncMock() connection.response.cancel.side_effect = RuntimeError("boom") + dispatcher = _make_dispatcher(connection) + state = _turn_state() + + with caplog.at_level("DEBUG"): + await dispatcher._cancel(state=state) + + assert state.interrupted is True + # Truncate must still have been attempted despite the cancel failure. + connection.conversation.item.truncate.assert_awaited_once() + assert any("response.cancel raised" in record.message and record.levelname == "DEBUG" for record in caplog.records) + + +async def test_cancel_marks_interrupted_when_truncate_raises(caplog): + """A failed conversation.item.truncate must log a warning and still flip state.interrupted.""" + connection = AsyncMock() + connection.conversation.item.truncate.side_effect = RuntimeError("boom") + dispatcher = _make_dispatcher(connection) state = _turn_state() - await target._cancel_in_flight_response(connection=connection, state=state) + await dispatcher._cancel(state=state) + + assert state.interrupted is True + assert any( + "conversation.item.truncate failed" in record.message and record.levelname == "WARNING" + for record in caplog.records + ) + + +def _scripted_event(event_type, **fields): + """Build a MagicMock event with the named type plus any extra attribute paths.""" + event = MagicMock() + event.type = event_type + for path, value in fields.items(): + # Allow dotted attribute paths like "response.id" by walking nested MagicMocks. + parts = path.split(".") + target_attr = event + for part in parts[:-1]: + target_attr = getattr(target_attr, part) + setattr(target_attr, parts[-1], value) + return event + + +async def test_route_event_happy_path_resolves_completion_with_assembled_result(): + """response.created -> output_item.added -> audio.delta -> transcript.delta -> response.done.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) + await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) + await dispatcher._route_event( + event=_scripted_event("response.audio.delta", delta=base64.b64encode(b"\xaa" * 4800).decode("ascii")), + state=state, + ) + await dispatcher._route_event(event=_scripted_event("response.audio_transcript.delta", delta="hello "), state=state) + await dispatcher._route_event(event=_scripted_event("response.audio_transcript.delta", delta="world"), state=state) + await dispatcher._route_event(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) + + assert state.completion.done() + result = state.completion.result() + assert result.audio_bytes == b"\xaa" * 4800 + assert result.transcripts == ["hello ", "world"] + assert state.interrupted is False + +async def test_route_event_speech_started_while_responding_cancels_and_resolves_interrupted(): + """speech_started during a response triggers cancel and resolves with interrupted=True.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) + await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) + await dispatcher._route_event( + event=_scripted_event("response.audio.delta", delta=base64.b64encode(b"\xbb" * 2400).decode("ascii")), + state=state, + ) + await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) + + connection.response.cancel.assert_awaited_once_with(response_id="r1") + connection.conversation.item.truncate.assert_awaited_once_with( + item_id="i1", + content_index=0, + audio_end_ms=50, # 2400 / 48 + ) + result = state.completion.result() + assert result.audio_bytes == b"\xbb" * 2400 assert state.interrupted is True - assert any("Cancel/truncate failed" in record.message for record in caplog.records) + + +async def test_route_event_stale_response_done_after_cancel_is_dropped(): + """A response.done with a stale response_id must not re-resolve a completed future.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + # Pretend a turn just resolved as interrupted on response_id r1. + state.last_response_id = "r1" + state.completion.set_result(RealtimeTargetResult()) + + # Late response.done for r1 arrives; router must not raise InvalidStateError. + await dispatcher._route_event(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) + + +async def test_route_event_error_resolves_with_exception(): + """error events resolve the completion future via set_exception.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + await dispatcher._route_event(event=_scripted_event("error", **{"error.message": "rate limited"}), state=state) + + with pytest.raises(RuntimeError, match="rate limited"): + state.completion.result() + + +async def test_route_event_speech_started_without_responding_is_noop(): + """speech_started before a response is in flight does not call cancel or resolve.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) + + connection.response.cancel.assert_not_awaited() + connection.conversation.item.truncate.assert_not_awaited() + assert not state.completion.done() + assert state.interrupted is False From 66bc828a481568e263dcd3d5825f0013181caa3a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Mon, 18 May 2026 14:32:39 -0400 Subject: [PATCH 05/47] Persist interrupted flag on RealtimeTargetResult and message metadata Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 5 ++ .../openai/openai_realtime_target.py | 4 ++ .../target/test_realtime_audio.py | 14 +++++ .../target/test_realtime_target.py | 52 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 4e148764ea..93a12b6ef6 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -54,10 +54,15 @@ class RealtimeTargetResult: audio_bytes: Raw PCM16 audio returned by the assistant. May be partial if the turn was interrupted. transcripts: Transcript deltas captured during the turn. + interrupted: True if the turn was cut short by server VAD detecting new user + speech during the assistant's response. Always False on the atomic + ``send_audio_async`` / ``send_text_async`` paths; populated in the + streaming-session path when a barge-in is detected. """ audio_bytes: bytes = b"" transcripts: list[str] = field(default_factory=list) + interrupted: bool = False def flatten_transcripts(self) -> str: """Return all transcript deltas concatenated into a single string.""" diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index edd7df6aa7..bbf9aa7d59 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -401,6 +401,10 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me request=request, response_text_pieces=[output_audio_path], response_type="audio_path" ).message_pieces[0] + if result.interrupted: + text_response_piece.prompt_metadata["interrupted"] = True + audio_response_piece.prompt_metadata["interrupted"] = True + response_entry = Message(message_pieces=[text_response_piece, audio_response_piece]) return [response_entry] diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index 80a0cd9734..c2377591b1 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -26,6 +26,20 @@ async def test_realtime_turn_state_defaults(): assert state.last_response_id is None +def test_realtime_target_result_interrupted_defaults_false(): + """RealtimeTargetResult must default interrupted=False so atomic callers see no change.""" + result = RealtimeTargetResult() + assert result.interrupted is False + assert result.audio_bytes == b"" + assert result.transcripts == [] + + +def test_realtime_target_result_carries_interrupted_when_set(): + """The interrupted flag round-trips through construction.""" + result = RealtimeTargetResult(audio_bytes=b"partial", transcripts=["hi"], interrupted=True) + assert result.interrupted is True + + class _RecordingDispatcher(_RealtimeEventDispatcher): """Minimal concrete dispatcher for testing the generic base class behavior.""" diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 2bd17f6626..79187b3703 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -73,6 +73,58 @@ async def test_send_prompt_async(target): await target.cleanup_target() +async def test_send_prompt_async_propagates_interrupted_to_metadata(target): + """When a turn result carries interrupted=True, both response pieces' metadata must reflect it.""" + target.connect = AsyncMock(return_value=AsyncMock()) + target.send_config = AsyncMock() + interrupted_result = RealtimeTargetResult(audio_bytes=b"partial", transcripts=["hi"], interrupted=True) + target.send_text_async = AsyncMock(return_value=("partial.wav", interrupted_result)) + + message_piece = MessagePiece( + original_value="Hello", + original_value_data_type="text", + converted_value="Hello", + converted_value_data_type="text", + role="user", + conversation_id="test_conv", + ) + message = Message(message_pieces=[message_piece]) + + response = await target.send_prompt_async(message=message) + + text_piece, audio_piece = response[0].message_pieces + assert text_piece.prompt_metadata.get("interrupted") is True + assert audio_piece.prompt_metadata.get("interrupted") is True + + await target.cleanup_target() + + +async def test_send_prompt_async_omits_interrupted_metadata_when_not_set(target): + """A non-interrupted result must not write an interrupted key to MessagePiece metadata.""" + target.connect = AsyncMock(return_value=AsyncMock()) + target.send_config = AsyncMock() + normal_result = RealtimeTargetResult(audio_bytes=b"full", transcripts=["hi"]) + target.send_text_async = AsyncMock(return_value=("full.wav", normal_result)) + + message_piece = MessagePiece( + original_value="Hello", + original_value_data_type="text", + converted_value="Hello", + converted_value_data_type="text", + role="user", + conversation_id="test_conv", + ) + message = Message(message_pieces=[message_piece]) + + response = await target.send_prompt_async(message=message) + + text_piece, audio_piece = response[0].message_pieces + assert "interrupted" not in text_piece.prompt_metadata + assert "interrupted" not in audio_piece.prompt_metadata + + await target.cleanup_target() + + async def test_get_system_prompt_from_conversation_with_system_message(target): """Test that system prompt is extracted from conversation history when present.""" From 69b7a1c254b73d0df4e0401eedc8aee1018e5352 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Mon, 18 May 2026 15:13:43 -0400 Subject: [PATCH 06/47] Add user audio committed callback to realtime dispatcher Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 91 +++++++++++++++---- .../openai/openai_realtime_target.py | 25 ++++- .../target/test_realtime_audio.py | 59 ++++++++++-- .../target/test_realtime_target.py | 39 ++++++++ 4 files changed, 185 insertions(+), 29 deletions(-) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 93a12b6ef6..45e418b701 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -7,6 +7,7 @@ import contextlib import logging from abc import ABC, abstractmethod +from collections.abc import Callable, Coroutine from dataclasses import dataclass, field from typing import Any @@ -101,6 +102,22 @@ class _RealtimeTurnState: interrupted: bool = False +@dataclass(frozen=True) +class _CommittedEvent: + """ + Event-shaped payload passed to ``on_user_audio_committed`` callbacks. + + Attributes: + item_id: Server-assigned id of the conversation item that was committed. + Used to delete the raw item before replaying converted audio. + audio_start_ms: Optional audio start timestamp from the underlying server + event, when reported by the provider. May be useful for analytics. + """ + + item_id: str + audio_start_ms: int | None = None + + class _RealtimeEventDispatcher(ABC): """ Owns a realtime connection's event stream and routes events to the active turn. @@ -115,15 +132,26 @@ class _RealtimeEventDispatcher(ABC): only its routing and cancel logic. """ - def __init__(self, *, connection: Any) -> None: + def __init__( + self, + *, + connection: Any, + on_user_audio_committed: Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None = None, + ) -> None: """ Args: connection: An open realtime connection exposing an async iterator of server events. The dispatcher owns reading from it. + on_user_audio_committed: Optional callback fired when the server + commits a user audio buffer (e.g. server VAD finalizing a turn). + Invoked as a background task so converter work in the callback + does not block the dispatch loop. Default None disables it. """ self._connection = connection + self._on_user_audio_committed = on_user_audio_committed self._current_turn: _RealtimeTurnState | None = None self._task: asyncio.Task[None] | None = None + self._callback_tasks: set[asyncio.Task[None]] = set() async def start(self) -> None: """Start the background dispatch task. Idempotent.""" @@ -131,12 +159,21 @@ async def start(self) -> None: self._task = asyncio.create_task(self._dispatch_loop()) async def stop(self) -> None: - """Cancel the background dispatch task and release the reference.""" + """ + Cancel the background dispatch task and release the reference. + + In-flight callback tasks are awaited (with exception suppression) so + their resources release cleanly before the connection is torn down. + """ if self._task is not None: self._task.cancel() with contextlib.suppress(asyncio.CancelledError, Exception): await self._task self._task = None + if self._callback_tasks: + pending = list(self._callback_tasks) + self._callback_tasks.clear() + await asyncio.gather(*pending, return_exceptions=True) def register_turn(self, state: _RealtimeTurnState) -> None: """ @@ -157,19 +194,24 @@ async def _dispatch_loop(self) -> None: """ Consume events from the connection and route each to the active turn. + The router is called for every event with the current turn (which may + be None during the gap between turns). Concrete routers are expected to + handle ``state is None`` for input-side events that need no turn state + and return early on output-side events when no turn is registered. + Raises: asyncio.CancelledError: Propagated when ``stop()`` cancels the task. """ try: async for event in self._connection: turn = self._current_turn - if turn is None or turn.completion.done(): - continue + if turn is not None and turn.completion.done(): + turn = None try: await self._route_event(event=event, state=turn) except Exception as e: logger.exception(f"Realtime event router raised: {e}") - if not turn.completion.done(): + if turn is not None and not turn.completion.done(): turn.completion.set_exception(e) except asyncio.CancelledError: raise @@ -179,25 +221,40 @@ async def _dispatch_loop(self) -> None: if turn is not None and not turn.completion.done(): turn.completion.set_exception(e) + def _fire_committed_callback(self, event: _CommittedEvent) -> None: + """ + Schedule the ``on_user_audio_committed`` callback as a background task. + + Tracks the resulting task so ``stop()`` can wait for it to finish. + """ + if self._on_user_audio_committed is None: + return + task = asyncio.create_task(self._on_user_audio_committed(event)) + self._callback_tasks.add(task) + task.add_done_callback(self._callback_tasks.discard) + @abstractmethod - async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: """ - Update ``state`` based on a single provider-specific event. + Route a single provider-specific event. Concrete implementations: - - Set ``state.is_responding`` / ``state.last_response_id`` / ``state.current_item_id`` - as the relevant lifecycle events arrive. - - Append delivered audio and transcript deltas to ``state``. - - On normal completion, resolve ``state.completion`` with a - ``RealtimeTargetResult`` snapshotted from ``state``. - - On server-side speech-started while ``state.is_responding``, call - ``self._cancel(state=state)`` then resolve ``state.completion`` with - ``interrupted=True`` and the partial audio. - - On error events, resolve ``state.completion`` via ``set_exception``. + - When the event is output-side (response lifecycle, audio/transcript + deltas, etc.) and ``state`` is non-None, mutate ``state`` and resolve + ``state.completion`` at end-of-turn or on interruption. + - When ``state`` is None (no active turn) or + ``state.completion.done()``, output-side events should be dropped. + - When the event is input-side (e.g. ``input_audio_buffer.committed``), + fire any subscribed callback via ``self._fire_committed_callback(...)``. + These callbacks may run regardless of ``state``. + - On error events, resolve ``state.completion`` via ``set_exception`` + when a turn is active. Args: event: A single provider-specific event from the connection iterator. - state (_RealtimeTurnState): The currently-active turn. + state (_RealtimeTurnState | None): The currently-active turn, or None + if no turn is registered (e.g. between turns in a streaming + session). """ @abstractmethod diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index bbf9aa7d59..899bdd9fe8 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -24,6 +24,7 @@ from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, ServerVadConfig, + _CommittedEvent, _RealtimeEventDispatcher, _RealtimeTurnState, ) @@ -865,12 +866,27 @@ class _OpenAIRealtimeDispatcher(_RealtimeEventDispatcher): ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. """ - async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: - """Update state and resolve completion based on OpenAI Realtime events.""" - if state.completion.done(): + async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: + """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" + event_type = getattr(event, "type", "") + + # Input-side events fire callbacks regardless of whether a turn is registered. + if event_type == "input_audio_buffer.committed": + item_id = getattr(event, "item_id", None) + if item_id is None: + return + self._fire_committed_callback( + _CommittedEvent( + item_id=item_id, + audio_start_ms=getattr(event, "audio_start_ms", None), + ) + ) + # Fall through: also include the bookkeeping below (none currently uses committed). return - event_type = getattr(event, "type", "") + # Remaining events are output-side and mutate per-turn state; drop if no turn. + if state is None or state.completion.done(): + return if event_type == "response.created": state.is_responding = True @@ -919,6 +935,7 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: RealtimeTargetResult( audio_bytes=bytes(state.delivered_audio), transcripts=list(state.delivered_transcripts), + interrupted=True, ) ) return diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index c2377591b1..2ef64b2115 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -3,12 +3,13 @@ import asyncio from typing import Any -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock import pytest from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, + _CommittedEvent, _RealtimeEventDispatcher, _RealtimeTurnState, ) @@ -48,10 +49,10 @@ def __init__(self, *, connection: Any) -> None: self.routed_events: list[Any] = [] self.cancel_calls: int = 0 - async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: self.routed_events.append(event) # End the turn on a sentinel event so tests can drain the loop. - if getattr(event, "_finish", False): + if state is not None and getattr(event, "_finish", False): state.completion.set_result(RealtimeTargetResult()) async def _cancel(self, *, state: _RealtimeTurnState) -> None: @@ -132,24 +133,26 @@ async def test_dispatcher_loop_routes_events_to_active_turn(): assert dispatcher.routed_events == [other, finish] -async def test_dispatcher_loop_skips_events_when_no_active_turn(): - """Events arriving with no current turn (or a completed one) are dropped quietly.""" +async def test_dispatcher_loop_routes_events_with_no_turn_as_state_none(): + """When no turn is registered, events still reach _route_event so input callbacks can fire; state is None.""" finish = _sentinel_event(finish=True) - dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([_sentinel_event(), finish])) + other = _sentinel_event() + dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([other, finish])) # No register_turn called. await dispatcher.start() await asyncio.sleep(0.05) await dispatcher.stop() - assert dispatcher.routed_events == [] + # Both events were routed but no turn was completed (state was None, sentinel branch skipped). + assert dispatcher.routed_events == [other, finish] async def test_dispatcher_loop_sets_exception_on_router_failure(): """A router exception must propagate to the active turn's completion future.""" class _ExplodingDispatcher(_RecordingDispatcher): - async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: + async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: raise ValueError("router boom") event = _sentinel_event() @@ -161,3 +164,43 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState) -> None: with pytest.raises(ValueError, match="router boom"): await asyncio.wait_for(state.completion, timeout=1.0) await dispatcher.stop() + + +async def test_dispatcher_fires_committed_callback_as_background_task(): + """The on_user_audio_committed callback must be invoked and awaited via background tasks.""" + + received: list[Any] = [] + blocked = asyncio.Event() + release = asyncio.Event() + + async def slow_callback(event): + received.append(event) + blocked.set() + # Block until the test releases us; this proves the dispatch loop did not wait. + await release.wait() + + class _CallbackDispatcher(_RealtimeEventDispatcher): + async def _route_event(self, *, event, state): + # Synthesize a committed callback fire on every event for the test. + self._fire_committed_callback(event) + + async def _cancel(self, *, state): # pragma: no cover - not exercised here + return + + fake_event_1 = MagicMock(spec=_CommittedEvent) + fake_event_2 = MagicMock(spec=_CommittedEvent) + dispatcher = _CallbackDispatcher( + connection=_ScriptedConnection([fake_event_1, fake_event_2]), + on_user_audio_committed=slow_callback, + ) + + await dispatcher.start() + # Both events should reach the slow callback even though the first is "blocked" awaiting release. + await asyncio.wait_for(blocked.wait(), timeout=1.0) + # Give the loop a tick to process the second event despite the first callback still running. + await asyncio.sleep(0.05) + release.set() + await dispatcher.stop() + + # Both events fired the callback; the loop did not serialize behind the slow first call. + assert len(received) == 2 diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 79187b3703..d036a65ae7 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -3,6 +3,7 @@ import asyncio import base64 +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -777,6 +778,7 @@ async def test_route_event_speech_started_while_responding_cancels_and_resolves_ ) result = state.completion.result() assert result.audio_bytes == b"\xbb" * 2400 + assert result.interrupted is True assert state.interrupted is True @@ -817,3 +819,40 @@ async def test_route_event_speech_started_without_responding_is_noop(): connection.conversation.item.truncate.assert_not_awaited() assert not state.completion.done() assert state.interrupted is False + + +async def test_route_event_committed_event_fires_user_audio_callback(): + """input_audio_buffer.committed must fire the registered on_user_audio_committed callback.""" + connection = AsyncMock() + received: list[Any] = [] + + async def on_committed(event): + received.append(event) + + dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) + + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.committed", item_id="raw_item_42", audio_start_ms=1234), + state=None, + ) + # Background callback task may not have run yet; yield until it does. + for _ in range(20): + if received: + break + await asyncio.sleep(0.01) + + assert len(received) == 1 + assert received[0].item_id == "raw_item_42" + assert received[0].audio_start_ms == 1234 + + +async def test_route_event_committed_event_without_callback_is_noop(): + """A committed event with no callback configured must be ignored quietly.""" + connection = AsyncMock() + dispatcher = _OpenAIRealtimeDispatcher(connection=connection) # no callback + + # Must not raise. + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.committed", item_id="raw_item_99"), + state=None, + ) From d624e6a17d35f2c5c4e51b5f0a65e82afda7a60c Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 19 May 2026 12:52:59 -0400 Subject: [PATCH 07/47] Add wire primitive methods to RealtimeTarget for streaming attacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/openai_realtime_target.py | 72 +++++++++++++++++++ .../target/test_realtime_target.py | 53 ++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 899bdd9fe8..7b00837065 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -489,6 +489,78 @@ async def send_response_create(self, conversation_id: str) -> None: connection = self._get_connection(conversation_id=conversation_id) await connection.response.create() + async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """ + Append a single PCM16 mono @ 24 kHz audio chunk to the server's input buffer. + + Used by streaming-style callers (e.g. ``BargeInAttack``) that source chunks + from an iterator and want to control commit timing externally. Server VAD, + when enabled on the session, decides when to commit and fire response logic. + Empty buffers are accepted as no-ops. + + Args: + connection: Active Realtime API connection from ``self.connect()``. + pcm_bytes: Raw PCM16 mono audio for this chunk. + """ + if not pcm_bytes: + return + audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") + await connection.input_audio_buffer.append(audio=audio_b64) + + async def insert_user_audio_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """ + Insert a user message containing the given PCM16 mono @ 24 kHz audio into the conversation. + + Use for the convert-on-commit dance — after deleting the server's raw user item, + the attack inserts the converted audio via this method before manually triggering + ``response.create``. + + Args: + connection: Active Realtime API connection. + pcm_bytes: Converted PCM16 mono audio. + """ + audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") + await connection.conversation.item.create( + item={ + "type": "message", + "role": "user", + "content": [{"type": "input_audio", "audio": audio_b64}], + } + ) + + async def insert_user_text_async(self, *, connection: Any, text: str) -> None: + """ + Insert a user message containing the given text into the conversation. + + Lets streaming attacks mix text turns into an otherwise audio-driven session. + The caller is responsible for triggering ``response.create`` after insertion. + + Args: + connection: Active Realtime API connection. + text: User-side text content. + """ + await connection.conversation.item.create( + item={ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}], + } + ) + + async def delete_conversation_item_async(self, *, connection: Any, item_id: str) -> None: + """ + Delete a conversation item by id (e.g. the server's raw user audio item). + + Used during convert-on-commit to remove the raw audio item before replacing + it with a converted one. Errors are propagated; callers that want best-effort + deletion should wrap with ``contextlib.suppress``. + + Args: + connection: Active Realtime API connection. + item_id: Server-assigned item id to delete. + """ + await connection.conversation.item.delete(item_id=item_id) + async def _stream_pcm_async( self, *, diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index d036a65ae7..f21ab7ec79 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -642,6 +642,59 @@ async def test_stream_pcm_appends_base64_encoded_chunks(target): assert base64.b64decode(second_audio) == b"\xbb" * 4800 +# ---- Wire primitives for streaming attacks --------------------------------------------------- + + +async def test_push_audio_chunk_async_base64_encodes_and_appends(target): + connection = _make_mock_connection() + pcm = b"\x33" * 480 + + await target.push_audio_chunk_async(connection=connection, pcm_bytes=pcm) + + connection.input_audio_buffer.append.assert_awaited_once() + audio_b64 = connection.input_audio_buffer.append.call_args.kwargs["audio"] + assert base64.b64decode(audio_b64) == pcm + + +async def test_push_audio_chunk_async_empty_is_noop(target): + connection = _make_mock_connection() + await target.push_audio_chunk_async(connection=connection, pcm_bytes=b"") + connection.input_audio_buffer.append.assert_not_called() + + +async def test_insert_user_audio_async_creates_input_audio_item(target): + connection = AsyncMock() + pcm = b"\x44" * 480 + + await target.insert_user_audio_async(connection=connection, pcm_bytes=pcm) + + connection.conversation.item.create.assert_awaited_once() + item = connection.conversation.item.create.call_args.kwargs["item"] + assert item["type"] == "message" + assert item["role"] == "user" + assert item["content"][0]["type"] == "input_audio" + assert base64.b64decode(item["content"][0]["audio"]) == pcm + + +async def test_insert_user_text_async_creates_input_text_item(target): + connection = AsyncMock() + + await target.insert_user_text_async(connection=connection, text="hello model") + + connection.conversation.item.create.assert_awaited_once() + item = connection.conversation.item.create.call_args.kwargs["item"] + assert item["role"] == "user" + assert item["content"][0] == {"type": "input_text", "text": "hello model"} + + +async def test_delete_conversation_item_async_forwards_item_id(target): + connection = AsyncMock() + + await target.delete_conversation_item_async(connection=connection, item_id="raw_item_99") + + connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_item_99") + + def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = "item_xyz") -> _RealtimeTurnState: """Build a turn state with the named ids preset; completion future is unused by cancel tests.""" return _RealtimeTurnState( From 1aaf10b43010d4670aa968b28efc749f0c61ecaa Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 19 May 2026 14:04:45 -0400 Subject: [PATCH 08/47] Add streaming barge-in attack with subscription and turn-future target API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/__init__.py | 3 + pyrit/executor/attack/streaming/__init__.py | 11 + pyrit/executor/attack/streaming/barge_in.py | 209 +++++++++++++++++ pyrit/prompt_target/common/realtime_audio.py | 13 ++ .../common/target_capabilities.py | 8 + .../openai/openai_realtime_target.py | 94 ++++++++ .../executor/attack/streaming/__init__.py | 2 + .../attack/streaming/test_barge_in.py | 220 ++++++++++++++++++ .../target/test_realtime_audio.py | 28 +++ .../target/test_realtime_target.py | 108 ++++++++- 10 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 pyrit/executor/attack/streaming/__init__.py create mode 100644 pyrit/executor/attack/streaming/barge_in.py create mode 100644 tests/unit/executor/attack/streaming/__init__.py create mode 100644 tests/unit/executor/attack/streaming/test_barge_in.py diff --git a/pyrit/executor/attack/__init__.py b/pyrit/executor/attack/__init__.py index 1dfb17b6c5..7160e29ece 100644 --- a/pyrit/executor/attack/__init__.py +++ b/pyrit/executor/attack/__init__.py @@ -52,6 +52,7 @@ SingleTurnAttackStrategy, SkeletonKeyAttack, ) +from pyrit.executor.attack.streaming import BargeInAttack, BargeInAttackContext __all__ = [ "AttackStrategy", @@ -93,6 +94,8 @@ "ConversationState", "AttackExecutor", "AttackExecutorResult", + "BargeInAttack", + "BargeInAttackContext", "PrependedConversationConfig", "generate_simulated_conversation_async", ] diff --git a/pyrit/executor/attack/streaming/__init__.py b/pyrit/executor/attack/streaming/__init__.py new file mode 100644 index 0000000000..b743ea7961 --- /dev/null +++ b/pyrit/executor/attack/streaming/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Streaming attack strategies (barge-in over realtime audio targets).""" + +from pyrit.executor.attack.streaming.barge_in import BargeInAttack, BargeInAttackContext + +__all__ = [ + "BargeInAttack", + "BargeInAttackContext", +] diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py new file mode 100644 index 0000000000..729a296350 --- /dev/null +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +""" +Streaming barge-in attack over realtime audio targets. + +Pushes user audio chunks into a continuous Realtime API session, lets server VAD +detect turn boundaries, runs configured audio converters against the buffered raw +audio for each detected turn, swaps the server's raw user item for the converted +audio, manually fires ``response.create``, and observes server-side interruption +when new user audio arrives while the assistant is still speaking. Per-turn +``Message`` pairs are written to ``CentralMemory``; interrupted turns carry +``prompt_metadata["interrupted"] = True`` on both assistant pieces. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults +from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT +from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy +from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier +from pyrit.models import ( + AttackOutcome, + AttackResult, +) +from pyrit.prompt_target.common.target_capabilities import CapabilityName +from pyrit.prompt_target.common.target_requirements import TargetRequirements + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from pyrit.prompt_target import PromptTarget + from pyrit.prompt_target.common.realtime_audio import ( + RealtimeTargetResult, + _CommittedEvent, + _RealtimeEventDispatcher, + ) + from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget + +logger = logging.getLogger(__name__) + + +@dataclass +class BargeInAttackContext(AttackContext[AttackParamsT]): + """ + Context for a streaming barge-in attack. + + Beyond the standard ``AttackContext`` fields, callers supply: + + Attributes: + conversation_id: Identifier shared by all turns persisted from this session. + audio_chunks: Async iterator yielding raw PCM16 mono @ 24 kHz chunks. Drives + the cadence of input; the attack pushes each chunk as it arrives. When + the iterator exhausts, the attack waits briefly for any in-flight turn + to resolve, then tears down. + system_prompt: System prompt to apply to the realtime session. + """ + + conversation_id: str = field(default_factory=lambda: str(uuid.uuid4())) + audio_chunks: AsyncIterator[bytes] | None = None + system_prompt: str = "You are a helpful AI assistant" + + +class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): + """ + Streaming attack that drives a Realtime API session with server VAD + barge-in. + + The attack pushes user audio chunks through the target, lets server VAD detect + turn boundaries, manually fires ``response.create`` after each commit, and + observes assistant turns (including interrupted ones) via per-turn futures + returned by the target's ``request_response_async``. + """ + + TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements( + required=frozenset({CapabilityName.STREAMING_BARGE_IN}), + ) + + _POST_STREAM_SETTLE_SECONDS = 1.0 + + @apply_defaults + def __init__( + self, + *, + objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] + params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] + ) -> None: + """ + Initialize the streaming barge-in attack. + + Args: + objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` + in its capabilities (validated by ``TARGET_REQUIREMENTS``); the + server-VAD configuration check happens lazily when the streaming + session config is sent. + params_type: Attack parameter dataclass type. Defaults to + ``AttackParameters``. + """ + super().__init__( + objective_target=objective_target, + context_type=BargeInAttackContext, + params_type=params_type, + logger=logger, + ) + + def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: + """ + Validate the context before executing. + + Args: + context: The streaming attack context. + + Raises: + ValueError: If the context is missing required fields. + """ + if not context.objective or context.objective.isspace(): + raise ValueError("Attack objective must be provided and non-empty in the context") + if context.audio_chunks is None: + raise ValueError("BargeInAttackContext.audio_chunks must be set to an async iterator of PCM bytes") + + async def _setup_async(self, *, context: BargeInAttackContext[Any]) -> None: + """ + Set up the attack: nothing beyond ensuring a conversation id is present. + """ + if not context.conversation_id: + context.conversation_id = str(uuid.uuid4()) + + async def _teardown_async(self, *, context: BargeInAttackContext[Any]) -> None: + """No-op teardown — connection / dispatcher are closed inside ``_perform_async``.""" + return + + async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackResult: + """ + Run the streaming session: connect, subscribe, push chunks, await final turn, tear down. + + Args: + context: Streaming attack context with ``audio_chunks`` source. + + Returns: + An ``AttackResult`` capturing the last assistant turn (if any) and the + number of completed turns. + """ + target = cast("RealtimeTarget", self._objective_target) + assert context.audio_chunks is not None # validated upstream + + connection = await target.connect(conversation_id=context.conversation_id) + last_result: RealtimeTargetResult | None = None + executed_turns = 0 + + async def on_committed(event: _CommittedEvent) -> None: + """On each user turn commit, manually fire response.create and record the result.""" + nonlocal last_result, executed_turns + try: + turn_future = await target.request_response_async( + connection=connection, dispatcher=dispatcher + ) + last_result = await turn_future + executed_turns += 1 + except Exception: + logger.exception("BargeInAttack turn failed while awaiting response.") + + dispatcher: _RealtimeEventDispatcher = await target.subscribe_events_async( + connection=connection, + on_user_audio_committed=on_committed, + ) + + try: + await target.send_streaming_session_config_async( + connection=connection, system_prompt=context.system_prompt + ) + + async for chunk in context.audio_chunks: + await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) + + # Give server VAD time to commit the buffer and the dispatcher to drain. + await asyncio.sleep(self._POST_STREAM_SETTLE_SECONDS) + finally: + await dispatcher.stop() + try: + await connection.close() + except Exception as e: + logger.warning(f"Error closing streaming connection: {e}") + + outcome = AttackOutcome.UNDETERMINED + outcome_reason: str | None + if executed_turns == 0: + outcome_reason = "No assistant turns completed (server VAD did not commit any user audio)" + else: + outcome_reason = f"{executed_turns} assistant turn(s) completed; no scorer configured" + + return AttackResult( + conversation_id=context.conversation_id, + objective=context.objective, + atomic_attack_identifier=build_atomic_attack_identifier( + attack_identifier=self.get_identifier() + ), + last_response=None, + last_score=None, + related_conversations=context.related_conversations, + outcome=outcome, + outcome_reason=outcome_reason, + executed_turns=executed_turns, + labels=context.memory_labels, + ) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 45e418b701..7fa964a845 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -152,6 +152,18 @@ def __init__( self._current_turn: _RealtimeTurnState | None = None self._task: asyncio.Task[None] | None = None self._callback_tasks: set[asyncio.Task[None]] = set() + self._failure: BaseException | None = None + + @property + def failure(self) -> BaseException | None: + """ + The exception that killed the dispatch loop, or None if it is still healthy. + + Set when the outer event iterator raises. Callers (e.g. ``BargeInAttack``) + poll this between operations to detect a dead connection without needing a + callback. Once set, ``stop()`` should be called and the attack torn down. + """ + return self._failure async def start(self) -> None: """Start the background dispatch task. Idempotent.""" @@ -217,6 +229,7 @@ async def _dispatch_loop(self) -> None: raise except Exception as e: logger.exception(f"Realtime dispatch loop crashed: {e}") + self._failure = e turn = self._current_turn if turn is not None and not turn.completion.done(): turn.completion.set_exception(e) diff --git a/pyrit/prompt_target/common/target_capabilities.py b/pyrit/prompt_target/common/target_capabilities.py index 6ae9ed69e2..b578d6eefd 100644 --- a/pyrit/prompt_target/common/target_capabilities.py +++ b/pyrit/prompt_target/common/target_capabilities.py @@ -24,6 +24,7 @@ class CapabilityName(str, Enum): JSON_OUTPUT = "supports_json_output" EDITABLE_HISTORY = "supports_editable_history" SYSTEM_PROMPT = "supports_system_prompt" + STREAMING_BARGE_IN = "supports_streaming_barge_in" class UnsupportedCapabilityBehavior(str, Enum): @@ -138,6 +139,13 @@ class attribute. Users can override individual capabilities per instance # Whether the target natively supports system prompts. supports_system_prompt: bool = False + # Whether the target supports the streaming barge-in API: pushing user audio chunks + # via ``push_audio_chunk_async``, subscribing to user-audio-committed events via + # ``subscribe_events_async``, swapping committed items via + # ``delete_conversation_item_async`` + ``insert_user_audio_async``, and triggering + # responses via ``request_response_async``. Required by ``BargeInAttack``. + supports_streaming_barge_in: bool = False + # The input modalities supported by the target (e.g., "text", "image"). input_modalities: frozenset[frozenset[PromptDataType]] = frozenset({frozenset(["text"])}) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 7b00837065..344bb89a58 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -6,6 +6,7 @@ import logging import re import wave +from collections.abc import Callable, Coroutine from typing import Any, Literal, Optional from openai import AsyncOpenAI @@ -58,6 +59,7 @@ class RealtimeTarget(OpenAITarget, PromptTarget): supports_editable_history=True, supports_multi_message_pieces=True, supports_system_prompt=True, + supports_streaming_barge_in=True, input_modalities=frozenset( { frozenset(["text"]), @@ -561,6 +563,98 @@ async def delete_conversation_item_async(self, *, connection: Any, item_id: str) """ await connection.conversation.item.delete(item_id=item_id) + async def subscribe_events_async( + self, + *, + connection: Any, + on_user_audio_committed: ( + Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None + ) = None, + ) -> _RealtimeEventDispatcher: + """ + Start consuming events from the connection and route them via the OpenAI dispatcher. + + Streaming-style callers (``BargeInAttack``) use this to receive normalized + events (``user_audio_committed``). The returned dispatcher exposes + ``stop()`` to tear down the background task and drain in-flight callback + tasks, and a ``failure`` property that callers can poll between operations + to detect a dead dispatch loop (e.g. websocket closed). Callers should + call ``stop()`` before closing the connection. + + Args: + connection: Active Realtime API connection from ``self.connect()``. + on_user_audio_committed: Async callback fired when server VAD finalizes + a user audio buffer. Called as a background task. + + Returns: + The started dispatcher. Pass it to ``request_response_async`` for turn + futures, poll ``failure`` for dispatch-loop errors, and call ``stop()`` + to tear it down. + """ + dispatcher = _OpenAIRealtimeDispatcher( + connection=connection, + on_user_audio_committed=on_user_audio_committed, + ) + await dispatcher.start() + return dispatcher + + async def request_response_async( + self, + *, + connection: Any, + dispatcher: _RealtimeEventDispatcher, + ) -> asyncio.Future[RealtimeTargetResult]: + """ + Trigger ``response.create`` and return a future that resolves when the turn ends. + + Constructs a fresh ``_RealtimeTurnState``, binds it to the dispatcher as the + active turn, then sends ``response.create``. The dispatcher resolves the + returned future via ``response.done`` (with ``interrupted=False``) or via + the barge-in cancel path (with ``interrupted=True``). + + Args: + connection: Active Realtime API connection. + dispatcher: Subscription handle previously returned by + ``subscribe_events_async``. Must not have another turn pending. + + Returns: + Future resolved with the assembled ``RealtimeTargetResult`` when this + turn ends (normally or via barge-in). + + Raises: + RuntimeError: If another turn is already pending on the dispatcher. + """ + state = _RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) + dispatcher.register_turn(state) + await connection.response.create() + return state.completion + + async def send_streaming_session_config_async(self, *, connection: Any, system_prompt: str) -> None: + """ + Configure the realtime session for streaming use: server VAD with manual response creation. + + Emits the same session config as the atomic path except ``turn_detection.create_response`` + is forced to False so the streaming attack can swap the raw user audio item for converted + audio before triggering ``response.create``. + + Args: + connection: Active Realtime API connection. + system_prompt: System prompt for the realtime session. + + Raises: + ValueError: If the target was constructed without server VAD. + """ + if self._server_vad is None: + raise ValueError( + "send_streaming_session_config_async requires server VAD; " + "construct RealtimeTarget(server_vad=True) or pass a ServerVadConfig." + ) + config = self._set_system_prompt_and_config_vars(system_prompt=system_prompt) + turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") + if turn_detection is not None: + turn_detection["create_response"] = False + await connection.session.update(session=config) + async def _stream_pcm_async( self, *, diff --git a/tests/unit/executor/attack/streaming/__init__.py b/tests/unit/executor/attack/streaming/__init__.py new file mode 100644 index 0000000000..9a0454564d --- /dev/null +++ b/tests/unit/executor/attack/streaming/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py new file mode 100644 index 0000000000..260b851681 --- /dev/null +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -0,0 +1,220 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for ``BargeInAttack`` (R4a — streaming session plumbing only).""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, patch + +import pytest + +from pyrit.executor.attack import BargeInAttack, BargeInAttackContext +from pyrit.executor.attack.core import AttackParameters +from pyrit.models import AttackOutcome +from pyrit.prompt_target import RealtimeTarget +from pyrit.prompt_target.common.realtime_audio import ( + RealtimeTargetResult, + _CommittedEvent, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +_CLEAN_ENV = {"OPENAI_REALTIME_UNDERLYING_MODEL": ""} + + +@pytest.fixture +@patch.dict("os.environ", _CLEAN_ENV) +def vad_target(sqlite_instance): + return RealtimeTarget( + api_key="test_key", endpoint="wss://test_url", model_name="test", server_vad=True + ) + + +async def _aiter(chunks: list[bytes]) -> AsyncIterator[bytes]: + for c in chunks: + yield c + + +def _attack_context(*, audio_chunks: AsyncIterator[bytes], objective: str = "obj") -> BargeInAttackContext[Any]: + return BargeInAttackContext( + params=AttackParameters(objective=objective), + audio_chunks=audio_chunks, + ) + + +def _mock_connection() -> AsyncMock: + connection = AsyncMock() + connection.input_audio_buffer.append = AsyncMock() + connection.conversation.item.create = AsyncMock() + connection.conversation.item.delete = AsyncMock() + connection.response.create = AsyncMock() + connection.session.update = AsyncMock() + connection.close = AsyncMock() + return connection + + +# ---- Construction validation ----------------------------------------------------------------- + + +@patch.dict("os.environ", _CLEAN_ENV) +def test_constructor_rejects_target_without_streaming_capability(sqlite_instance): + """A target whose capabilities lack STREAMING_BARGE_IN must be rejected at construction.""" + from pyrit.prompt_target import OpenAIChatTarget + + no_streaming = OpenAIChatTarget(api_key="k", endpoint="https://x", model_name="m") + with pytest.raises(Exception, match="streaming_barge_in"): + BargeInAttack(objective_target=no_streaming) + + +def test_constructor_succeeds_with_vad_target(vad_target): + """A RealtimeTarget declares STREAMING_BARGE_IN — construction succeeds.""" + attack = BargeInAttack(objective_target=vad_target) + assert attack.get_objective_target() is vad_target + + +def test_constructor_succeeds_even_without_server_vad_enabled(sqlite_instance): + """Capability check passes; server VAD is a runtime config concern surfaced when used.""" + with patch.dict("os.environ", _CLEAN_ENV): + no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") + # Construction succeeds — capability is about the target type, not server_vad config. + attack = BargeInAttack(objective_target=no_vad) + assert attack.get_objective_target() is no_vad + + +# ---- Context validation ---------------------------------------------------------------------- + + +async def test_validate_context_requires_objective(vad_target): + attack = BargeInAttack(objective_target=vad_target) + ctx = BargeInAttackContext( + params=AttackParameters(objective=""), + audio_chunks=_aiter([b"\x00" * 96]), + ) + with pytest.raises(ValueError, match="objective"): + attack._validate_context(context=ctx) + + +async def test_validate_context_requires_audio_chunks(vad_target): + attack = BargeInAttack(objective_target=vad_target) + ctx = BargeInAttackContext( + params=AttackParameters(objective="o"), + audio_chunks=None, + ) + with pytest.raises(ValueError, match="audio_chunks"): + attack._validate_context(context=ctx) + + +# ---- Streaming loop end-to-end --------------------------------------------------------------- + + +async def test_perform_async_streams_chunks_and_tears_down(vad_target): + """Happy path: connect, send config, subscribe, push chunks, stop, close — no commits.""" + attack = BargeInAttack(objective_target=vad_target) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + dispatcher = AsyncMock() + dispatcher.stop = AsyncMock() + vad_target.subscribe_events_async = AsyncMock(return_value=dispatcher) + + chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] + ctx = _attack_context(audio_chunks=_aiter(chunks)) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + result = await attack._perform_async(context=ctx) + + vad_target.connect.assert_awaited_once_with(conversation_id=ctx.conversation_id) + vad_target.send_streaming_session_config_async.assert_awaited_once() + vad_target.subscribe_events_async.assert_awaited_once() + assert vad_target.push_audio_chunk_async.await_count == len(chunks) + pushed = [call.kwargs["pcm_bytes"] for call in vad_target.push_audio_chunk_async.await_args_list] + assert pushed == chunks + dispatcher.stop.assert_awaited_once() + connection.close.assert_awaited_once() + assert result.executed_turns == 0 + assert result.outcome == AttackOutcome.UNDETERMINED + + +async def test_perform_async_fires_request_response_on_commit(vad_target): + """A commit event must drive request_response_async and increment the turn counter.""" + attack = BargeInAttack(objective_target=vad_target) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + + # Capture the registered on_user_audio_committed so we can drive it. + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + + expected = RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]) + expected_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + expected_future.set_result(expected) + vad_target.request_response_async = AsyncMock(return_value=expected_future) + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield b"\x00" * 480 + # Drive a fake commit mid-stream. + await captured["on_committed"](_CommittedEvent(item_id="raw_1")) + + ctx = _attack_context(audio_chunks=chunks_then_commit()) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + result = await attack._perform_async(context=ctx) + + vad_target.request_response_async.assert_awaited_once() + assert result.executed_turns == 1 + assert "1 assistant turn" in (result.outcome_reason or "") + + +async def test_perform_async_stops_dispatcher_even_on_exception(vad_target): + """If the chunk loop raises, dispatcher.stop() and connection.close() still run.""" + attack = BargeInAttack(objective_target=vad_target) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) + dispatcher = AsyncMock() + vad_target.subscribe_events_async = AsyncMock(return_value=dispatcher) + + ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) + + with pytest.raises(RuntimeError, match="push exploded"): + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + await attack._perform_async(context=ctx) + + dispatcher.stop.assert_awaited_once() + connection.close.assert_awaited_once() + + +# ---- send_streaming_session_config_async (target-side helper added in R4a) ------------------- + + +async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): + """The streaming session config must flip create_response to False on turn_detection.""" + connection = _mock_connection() + await vad_target.send_streaming_session_config_async( + connection=connection, system_prompt="hi" + ) + connection.session.update.assert_awaited_once() + config = connection.session.update.call_args.kwargs["session"] + assert config["audio"]["input"]["turn_detection"]["create_response"] is False + + +@patch.dict("os.environ", _CLEAN_ENV) +async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): + """Without server VAD, sending streaming session config must raise.""" + no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") + connection = _mock_connection() + with pytest.raises(ValueError, match="server VAD"): + await no_vad.send_streaming_session_config_async(connection=connection, system_prompt="hi") diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index 2ef64b2115..9b34528589 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -204,3 +204,31 @@ async def _cancel(self, *, state): # pragma: no cover - not exercised here # Both events fired the callback; the loop did not serialize behind the slow first call. assert len(received) == 2 + + +async def test_dispatcher_records_failure_on_iterator_crash(): + """When the connection iterator raises, the dispatcher's failure property captures the exception.""" + + class _NoopDispatcher(_RealtimeEventDispatcher): + async def _route_event(self, *, event, state): # pragma: no cover - never called + return + + async def _cancel(self, *, state): # pragma: no cover + return + + class _ExplodingConnection: + def __aiter__(self): + return self + + async def __anext__(self): + raise RuntimeError("iterator died") + + dispatcher = _NoopDispatcher(connection=_ExplodingConnection()) + await dispatcher.start() + for _ in range(50): + if dispatcher.failure is not None: + break + await asyncio.sleep(0.01) + await dispatcher.stop() + + assert isinstance(dispatcher.failure, RuntimeError) and str(dispatcher.failure) == "iterator died" diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index f21ab7ec79..8efb27a6ba 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -11,7 +11,11 @@ from pyrit.exceptions.exception_classes import ServerErrorException from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig -from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, _RealtimeTurnState +from pyrit.prompt_target.common.realtime_audio import ( + RealtimeTargetResult, + _CommittedEvent, + _RealtimeTurnState, +) from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher # Env vars that may leak from .env files loaded by other tests in parallel workers. @@ -909,3 +913,105 @@ async def test_route_event_committed_event_without_callback_is_noop(): event=_scripted_event("input_audio_buffer.committed", item_id="raw_item_99"), state=None, ) + + +# Placeholder for R2 tests + + +# ---- subscribe_events_async + request_response_async (R2) ------------------------------------ + + +async def test_subscribe_events_async_returns_started_dispatcher(target): + """Subscription handle must be a started dispatcher; closing tears the task down.""" + events = [_scripted_event("input_audio_buffer.committed", item_id="i_1")] + + async def event_iter(): + for e in events: + yield e + # Keep the iterator alive briefly so the dispatch task can run. + await asyncio.sleep(0.01) + + connection = MagicMock() + connection.__aiter__ = lambda self_: event_iter() + + received: list[_CommittedEvent] = [] + + async def on_committed(event): + received.append(event) + + dispatcher = await target.subscribe_events_async( + connection=connection, on_user_audio_committed=on_committed + ) + try: + # Yield until the dispatch loop processes the scripted event. + for _ in range(20): + if received: + break + await asyncio.sleep(0.01) + assert len(received) == 1 and received[0].item_id == "i_1" + finally: + await dispatcher.stop() + + +async def test_subscribe_events_async_records_loop_failure_on_dispatcher(target): + """A dispatcher loop crash must be reachable via the dispatcher's ``failure`` property.""" + + async def boom_iter(): + raise RuntimeError("loop kaboom") + yield # pragma: no cover # makes it a generator + + connection = MagicMock() + connection.__aiter__ = lambda self_: boom_iter() + + dispatcher = await target.subscribe_events_async(connection=connection) + try: + for _ in range(50): + if dispatcher.failure is not None: + break + await asyncio.sleep(0.01) + assert isinstance(dispatcher.failure, RuntimeError) + finally: + await dispatcher.stop() + + +async def test_request_response_async_registers_turn_and_sends_response_create(target): + """request_response_async must register a fresh turn and call response.create.""" + connection = AsyncMock() + dispatcher = MagicMock() + dispatcher.register_turn = MagicMock() + + future = await target.request_response_async(connection=connection, dispatcher=dispatcher) + + dispatcher.register_turn.assert_called_once() + registered_state = dispatcher.register_turn.call_args.args[0] + assert isinstance(registered_state, _RealtimeTurnState) + assert registered_state.completion is future + connection.response.create.assert_awaited_once_with() + + +async def test_request_response_async_future_resolves_with_dispatcher_result(target): + """The future returned by request_response_async resolves when the turn ends.""" + connection = AsyncMock() + dispatcher = MagicMock() + expected_result = RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"]) + + def _register(state): + state.completion.set_result(expected_result) + + dispatcher.register_turn = MagicMock(side_effect=_register) + + future = await target.request_response_async(connection=connection, dispatcher=dispatcher) + result = await future + assert result is expected_result + + +async def test_request_response_async_propagates_register_turn_failure(target): + """If another turn is already pending, register_turn raises and request_response_async surfaces it.""" + connection = AsyncMock() + dispatcher = MagicMock() + dispatcher.register_turn = MagicMock(side_effect=RuntimeError("turn already pending")) + + with pytest.raises(RuntimeError, match="turn already pending"): + await target.request_response_async(connection=connection, dispatcher=dispatcher) + + connection.response.create.assert_not_called() From d1edfd5a44fe86392e3f66ff16d9c662ecf19444 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 19 May 2026 14:35:22 -0400 Subject: [PATCH 09/47] Add convert-on-commit to streaming barge-in attack via PromptNormalizer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 65 ++++- pyrit/prompt_normalizer/prompt_normalizer.py | 77 ++++++ .../attack/streaming/test_barge_in.py | 233 +++++++++++++++++- .../test_prompt_normalizer.py | 98 ++++++++ 4 files changed, 462 insertions(+), 11 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 729a296350..4264d29748 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -19,9 +19,10 @@ import logging import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Optional, cast from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults +from pyrit.executor.attack.core.attack_config import AttackConverterConfig from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier @@ -29,6 +30,7 @@ AttackOutcome, AttackResult, ) +from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -45,6 +47,8 @@ logger = logging.getLogger(__name__) +_REALTIME_SAMPLE_RATE_HZ = 24000 + @dataclass class BargeInAttackContext(AttackContext[AttackParamsT]): @@ -88,6 +92,8 @@ def __init__( self, *, objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] + attack_converter_config: Optional[AttackConverterConfig] = None, + prompt_normalizer: Optional[PromptNormalizer] = None, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ @@ -98,6 +104,13 @@ def __init__( in its capabilities (validated by ``TARGET_REQUIREMENTS``); the server-VAD configuration check happens lazily when the streaming session config is sent. + attack_converter_config: Converter configurations applied to each + committed user turn via ``PromptNormalizer.convert_audio_async``. + ``request_converters`` runs on the raw user audio post-commit; + ``response_converters`` is currently unused (streaming responses + are surfaced raw to the caller). Defaults to no converters. + prompt_normalizer: Optional normalizer override. Defaults to a fresh + ``PromptNormalizer`` instance. params_type: Attack parameter dataclass type. Defaults to ``AttackParameters``. """ @@ -107,6 +120,10 @@ def __init__( params_type=params_type, logger=logger, ) + attack_converter_config = attack_converter_config or AttackConverterConfig() + self._request_converters = attack_converter_config.request_converters + self._response_converters = attack_converter_config.response_converters + self._prompt_normalizer = prompt_normalizer or PromptNormalizer() def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ @@ -149,20 +166,50 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR assert context.audio_chunks is not None # validated upstream connection = await target.connect(conversation_id=context.conversation_id) + raw_buffer = bytearray() + turn_lock = asyncio.Lock() last_result: RealtimeTargetResult | None = None executed_turns = 0 async def on_committed(event: _CommittedEvent) -> None: - """On each user turn commit, manually fire response.create and record the result.""" + """Convert-on-commit dance: snapshot raw audio → run converters → swap → request response.""" nonlocal last_result, executed_turns try: - turn_future = await target.request_response_async( - connection=connection, dispatcher=dispatcher - ) - last_result = await turn_future - executed_turns += 1 + async with turn_lock: + snapshot = bytes(raw_buffer) + raw_buffer.clear() + + try: + converted_pcm, _identifiers = await self._prompt_normalizer.convert_audio_async( + pcm_bytes=snapshot, + sample_rate=_REALTIME_SAMPLE_RATE_HZ, + converter_configurations=self._request_converters, + ) + except Exception: + logger.exception("Audio converters failed; dropping turn.") + return + + using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot + # Without converters, let the server's already-committed raw item drive the + # response. With converters, replace the raw item before triggering response. + if using_converted_audio: + try: + await target.delete_conversation_item_async( + connection=connection, item_id=event.item_id + ) + except Exception as e: + logger.warning(f"conversation.item.delete failed for {event.item_id}: {e}") + await target.insert_user_audio_async( + connection=connection, pcm_bytes=converted_pcm + ) + + turn_future = await target.request_response_async( + connection=connection, dispatcher=dispatcher + ) + last_result = await turn_future + executed_turns += 1 except Exception: - logger.exception("BargeInAttack turn failed while awaiting response.") + logger.exception("BargeInAttack turn failed in convert-on-commit handler.") dispatcher: _RealtimeEventDispatcher = await target.subscribe_events_async( connection=connection, @@ -175,6 +222,8 @@ async def on_committed(event: _CommittedEvent) -> None: ) async for chunk in context.audio_chunks: + if chunk: + raw_buffer.extend(chunk) await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) # Give server VAD time to commit the buffer and the dispatcher to drain. diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 528782dee6..5335510995 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -4,7 +4,10 @@ import asyncio import copy import logging +import os +import tempfile import traceback +import wave from typing import Any, Optional from uuid import uuid4 @@ -296,6 +299,80 @@ async def convert_values( piece.converted_value = converted_text piece.converted_value_data_type = converted_text_data_type + async def convert_audio_async( + self, + *, + pcm_bytes: bytes, + sample_rate: int, + converter_configurations: list[PromptConverterConfiguration], + ) -> tuple[bytes, list[ComponentIdentifier]]: + """ + Apply audio converter configurations to raw PCM and return converted PCM with identifiers that ran. + + For streaming attacks that hold raw PCM mid-turn rather than a ``Message``. Respects + ``prompt_data_types_to_apply``; ``indexes_to_apply`` is ignored. + + Args: + pcm_bytes (bytes): Raw PCM16 mono audio. + sample_rate (int): Sample rate in Hz. + converter_configurations (list[PromptConverterConfiguration]): Same shape used by ``convert_values``. + + Returns: + tuple[bytes, list[ComponentIdentifier]]: ``(converted_pcm, identifiers_that_ran)``. + + Raises: + ValueError: If converter output is not mono PCM16 at ``sample_rate``. + """ + if not converter_configurations or not pcm_bytes: + return pcm_bytes, [] + + identifiers: list[ComponentIdentifier] = [] + + with tempfile.TemporaryDirectory() as tmpdir: + current_path = os.path.join(tmpdir, "streaming_input.wav") + with wave.open(current_path, "wb") as wav_out: + wav_out.setnchannels(1) + wav_out.setsampwidth(2) + wav_out.setframerate(sample_rate) + wav_out.writeframes(pcm_bytes) + + for config in converter_configurations: + if config.prompt_data_types_to_apply and "audio_path" not in config.prompt_data_types_to_apply: + continue + + for converter in config.converters: + outer_context = get_execution_context() + with execution_context( + component_role=ComponentRole.CONVERTER, + attack_strategy_name=outer_context.attack_strategy_name if outer_context else None, + attack_identifier=outer_context.attack_identifier if outer_context else None, + component_identifier=converter.get_identifier(), + objective_target_conversation_id=( + outer_context.objective_target_conversation_id if outer_context else None + ), + ): + result = await converter.convert_tokens_async( + prompt=current_path, + input_type="audio_path", + start_token=self._start_token, + end_token=self._end_token, + ) + current_path = result.output_text + identifiers.append(converter.get_identifier()) + + with wave.open(current_path, "rb") as wav_in: + if ( + wav_in.getnchannels() != 1 + or wav_in.getsampwidth() != 2 + or wav_in.getframerate() != sample_rate + ): + raise ValueError( + "Converter output incompatible with streaming target: " + f"expected mono PCM16 @ {sample_rate} Hz, got channels={wav_in.getnchannels()} " + f"sampwidth={wav_in.getsampwidth()} rate={wav_in.getframerate()}." + ) + return wav_in.readframes(wav_in.getnframes()), identifiers + async def _calc_hash(self, request: Message) -> None: """Add a request to the memory.""" tasks = [asyncio.create_task(piece.set_sha256_values_async()) for piece in request.message_pieces] diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 260b851681..59e023b249 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -1,19 +1,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Unit tests for ``BargeInAttack`` (R4a — streaming session plumbing only).""" +"""Unit tests for ``BargeInAttack`` and supporting helpers.""" from __future__ import annotations import asyncio +import os +import tempfile +import wave from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pyrit.executor.attack import BargeInAttack, BargeInAttackContext -from pyrit.executor.attack.core import AttackParameters +from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters from pyrit.models import AttackOutcome +from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer from pyrit.prompt_target import RealtimeTarget from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, @@ -218,3 +222,226 @@ async def test_send_streaming_session_config_async_requires_server_vad(sqlite_in connection = _mock_connection() with pytest.raises(ValueError, match="server VAD"): await no_vad.send_streaming_session_config_async(connection=connection, system_prompt="hi") + + +# Placeholder for R4b tests + + +# ---- Convert-on-commit dance (R4b) ---------------------------------------------------------- + + +def _make_audio_converter(transformer): + """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" + converter = MagicMock() + converter.get_identifier = MagicMock(return_value=MagicMock()) + + async def _convert(*, prompt, input_type, start_token=None, end_token=None): + assert input_type == "audio_path" + with wave.open(prompt, "rb") as wf_in: + sample_rate = wf_in.getframerate() + pcm = wf_in.readframes(wf_in.getnframes()) + new_pcm = transformer(pcm) + out_dir = tempfile.mkdtemp() + out_path = os.path.join(out_dir, "out.wav") + with wave.open(out_path, "wb") as wf_out: + wf_out.setnchannels(1) + wf_out.setsampwidth(2) + wf_out.setframerate(sample_rate) + wf_out.writeframes(new_pcm) + result = MagicMock() + result.output_text = out_path + return result + + converter.convert_tokens_async = AsyncMock(side_effect=_convert) + return converter + + +def _converter_config(converters: list[Any]) -> AttackConverterConfig: + """Wrap a list of converters into an AttackConverterConfig.""" + return AttackConverterConfig( + request_converters=PromptConverterConfiguration.from_converters(converters=converters), + ) + + +async def test_perform_async_swaps_raw_item_when_converters_change_audio(vad_target): + """When converters change the audio, the attack must delete the raw item + insert converted.""" + bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + vad_target.delete_conversation_item_async = AsyncMock() + vad_target.insert_user_audio_async = AsyncMock() + + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + + result_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + result_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"])) + vad_target.request_response_async = AsyncMock(return_value=result_future) + + raw_chunk = b"\x05" * 96 # PCM16 sample-aligned + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield raw_chunk + await captured["on_committed"](_CommittedEvent(item_id="raw_99")) + + ctx = BargeInAttackContext( + params=AttackParameters(objective="obj"), + audio_chunks=chunks_then_commit(), + ) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + result = await attack._perform_async(context=ctx) + + vad_target.delete_conversation_item_async.assert_awaited_once_with( + connection=connection, item_id="raw_99" + ) + vad_target.insert_user_audio_async.assert_awaited_once() + inserted_pcm = vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] + assert inserted_pcm == bytes((b + 1) & 0xFF for b in raw_chunk) + vad_target.request_response_async.assert_awaited_once() + assert result.executed_turns == 1 + + +async def test_perform_async_skips_swap_when_no_converters(vad_target): + """Empty converter list: don't delete raw, don't insert converted, just request response.""" + attack = BargeInAttack(objective_target=vad_target) # no converter config + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + vad_target.delete_conversation_item_async = AsyncMock() + vad_target.insert_user_audio_async = AsyncMock() + + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + result_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + result_future.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) + vad_target.request_response_async = AsyncMock(return_value=result_future) + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield b"\x00" * 96 + await captured["on_committed"](_CommittedEvent(item_id="raw_42")) + + ctx = BargeInAttackContext( + params=AttackParameters(objective="obj"), + audio_chunks=chunks_then_commit(), + ) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + result = await attack._perform_async(context=ctx) + + vad_target.delete_conversation_item_async.assert_not_called() + vad_target.insert_user_audio_async.assert_not_called() + vad_target.request_response_async.assert_awaited_once() + assert result.executed_turns == 1 + + +async def test_perform_async_clears_raw_buffer_between_commits(vad_target): + """A commit must snapshot+reset the raw buffer so the next turn doesn't see prior audio.""" + bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + vad_target.delete_conversation_item_async = AsyncMock() + vad_target.insert_user_audio_async = AsyncMock() + + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + + def _future_with(result: RealtimeTargetResult) -> asyncio.Future[RealtimeTargetResult]: + fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + fut.set_result(result) + return fut + + vad_target.request_response_async = AsyncMock( + side_effect=lambda **_: _future_with(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) + ) + + async def chunks_then_two_commits() -> AsyncIterator[bytes]: + yield b"\x01" * 96 + await captured["on_committed"](_CommittedEvent(item_id="raw_1")) + yield b"\x02" * 96 + await captured["on_committed"](_CommittedEvent(item_id="raw_2")) + + ctx = BargeInAttackContext( + params=AttackParameters(objective="obj"), + audio_chunks=chunks_then_two_commits(), + ) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + await attack._perform_async(context=ctx) + + insert_calls = vad_target.insert_user_audio_async.await_args_list + assert len(insert_calls) == 2 + assert insert_calls[0].kwargs["pcm_bytes"] == bytes((b + 1) & 0xFF for b in (b"\x01" * 96)) + assert insert_calls[1].kwargs["pcm_bytes"] == bytes((b + 1) & 0xFF for b in (b"\x02" * 96)) + + +async def test_perform_async_uses_injected_normalizer(vad_target): + """The attack must delegate audio conversion to its injected PromptNormalizer.""" + fake_normalizer = MagicMock(spec=PromptNormalizer) + fake_normalizer.convert_audio_async = AsyncMock(return_value=(b"\xff" * 96, [])) + attack = BargeInAttack( + objective_target=vad_target, + attack_converter_config=_converter_config([_make_audio_converter(lambda pcm: pcm)]), + prompt_normalizer=fake_normalizer, + ) + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + vad_target.delete_conversation_item_async = AsyncMock() + vad_target.insert_user_audio_async = AsyncMock() + + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + fut.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) + vad_target.request_response_async = AsyncMock(return_value=fut) + + raw = b"\x05" * 96 + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield raw + await captured["on_committed"](_CommittedEvent(item_id="raw_z")) + + ctx = BargeInAttackContext( + params=AttackParameters(objective="obj"), + audio_chunks=chunks_then_commit(), + ) + + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + await attack._perform_async(context=ctx) + + fake_normalizer.convert_audio_async.assert_awaited_once() + kwargs = fake_normalizer.convert_audio_async.call_args.kwargs + assert kwargs["pcm_bytes"] == raw + assert kwargs["sample_rate"] == 24000 + # Converted audio (returned by mock) should reach insert_user_audio_async. + vad_target.insert_user_audio_async.assert_awaited_once() + assert vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] == b"\xff" * 96 diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index 07231243d3..937b55949f 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -3,6 +3,7 @@ import os import tempfile +import wave from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -629,3 +630,100 @@ async def test_add_prepended_conversation_to_memory(mock_memory_instance): assert result[0].message_pieces[0].conversation_id == conv_id assert result[0].message_pieces[0].attack_identifier == attack_id mock_memory_instance.add_message_to_memory.assert_called_once() + + +# Placeholder for convert_audio_async tests + + +def _make_audio_converter(transformer, *, output_sample_rate=24000): + """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" + converter = MagicMock() + converter.get_identifier = MagicMock(return_value=MagicMock()) + + async def _convert(*, prompt, input_type, start_token=None, end_token=None): + assert input_type == "audio_path" + with wave.open(prompt, "rb") as wf_in: + pcm = wf_in.readframes(wf_in.getnframes()) + new_pcm = transformer(pcm) + out_dir = tempfile.mkdtemp() + out_path = os.path.join(out_dir, "out.wav") + with wave.open(out_path, "wb") as wf_out: + wf_out.setnchannels(1) + wf_out.setsampwidth(2) + wf_out.setframerate(output_sample_rate) + wf_out.writeframes(new_pcm) + result = MagicMock() + result.output_text = out_path + return result + + converter.convert_tokens_async = AsyncMock(side_effect=_convert) + return converter + + +async def test_convert_audio_async_no_configurations_returns_input(sqlite_instance): + normalizer = PromptNormalizer() + pcm = b"\xaa" * 1024 + out, ids = await normalizer.convert_audio_async( + pcm_bytes=pcm, sample_rate=24000, converter_configurations=[] + ) + assert out == pcm + assert ids == [] + + +async def test_convert_audio_async_empty_pcm_returns_input(sqlite_instance): + normalizer = PromptNormalizer() + bump = _make_audio_converter(lambda pcm: pcm) + out, ids = await normalizer.convert_audio_async( + pcm_bytes=b"", + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump]), + ) + assert out == b"" + assert ids == [] + + +async def test_convert_audio_async_chains_converters_and_returns_identifiers(sqlite_instance): + normalizer = PromptNormalizer() + bump_a = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + bump_b = _make_audio_converter(lambda pcm: bytes((b + 2) & 0xFF for b in pcm)) + + out, ids = await normalizer.convert_audio_async( + pcm_bytes=b"\x00\x10\x20\x30", + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump_a, bump_b]), + ) + + assert out == b"\x03\x13\x23\x33" + assert len(ids) == 2 # one identifier per converter that ran + + +async def test_convert_audio_async_respects_data_type_filter(sqlite_instance): + """A configuration with prompt_data_types_to_apply not including audio_path must be skipped.""" + normalizer = PromptNormalizer() + skipped = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) + applied = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + + configs = [ + PromptConverterConfiguration(converters=[skipped], prompt_data_types_to_apply=["text"]), + PromptConverterConfiguration(converters=[applied], prompt_data_types_to_apply=["audio_path"]), + ] + out, ids = await normalizer.convert_audio_async( + pcm_bytes=b"\x00\x10", sample_rate=24000, converter_configurations=configs + ) + + # Only the audio_path-applicable converter ran (+1 not +9). + assert out == b"\x01\x11" + assert len(ids) == 1 + + +async def test_convert_audio_async_rejects_mismatched_sample_rate(sqlite_instance): + """Converter output at a different sample rate must raise ValueError.""" + normalizer = PromptNormalizer() + bad = _make_audio_converter(lambda pcm: pcm, output_sample_rate=16000) + with pytest.raises(ValueError, match="incompatible"): + await normalizer.convert_audio_async( + pcm_bytes=b"\x00" * 1024, + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bad]), + ) + From 23225adf0a5391924ec9425add51a0ad300dbd74 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 19 May 2026 14:43:42 -0400 Subject: [PATCH 10/47] Persist streaming barge-in turns to CentralMemory Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 92 ++++++++- .../attack/streaming/test_barge_in.py | 184 +++++++++++++++++- .../test_prompt_normalizer.py | 10 +- 3 files changed, 274 insertions(+), 12 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 4264d29748..38e4e14acf 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -29,6 +29,9 @@ from pyrit.models import ( AttackOutcome, AttackResult, + Message, + MessagePiece, + construct_response_from_request, ) from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target.common.target_capabilities import CapabilityName @@ -37,6 +40,7 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator + from pyrit.identifiers import ComponentIdentifier from pyrit.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, @@ -168,19 +172,19 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR connection = await target.connect(conversation_id=context.conversation_id) raw_buffer = bytearray() turn_lock = asyncio.Lock() - last_result: RealtimeTargetResult | None = None + last_assistant_message: Message | None = None executed_turns = 0 async def on_committed(event: _CommittedEvent) -> None: - """Convert-on-commit dance: snapshot raw audio → run converters → swap → request response.""" - nonlocal last_result, executed_turns + """Convert-on-commit dance: snapshot raw audio → run converters → swap → request response → persist.""" + nonlocal last_assistant_message, executed_turns try: async with turn_lock: snapshot = bytes(raw_buffer) raw_buffer.clear() try: - converted_pcm, _identifiers = await self._prompt_normalizer.convert_audio_async( + converted_pcm, applied_identifiers = await self._prompt_normalizer.convert_audio_async( pcm_bytes=snapshot, sample_rate=_REALTIME_SAMPLE_RATE_HZ, converter_configurations=self._request_converters, @@ -190,8 +194,6 @@ async def on_committed(event: _CommittedEvent) -> None: return using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot - # Without converters, let the server's already-committed raw item drive the - # response. With converters, replace the raw item before triggering response. if using_converted_audio: try: await target.delete_conversation_item_async( @@ -206,7 +208,17 @@ async def on_committed(event: _CommittedEvent) -> None: turn_future = await target.request_response_async( connection=connection, dispatcher=dispatcher ) - last_result = await turn_future + turn_result = await turn_future + + user_audio_pcm = converted_pcm if using_converted_audio else snapshot + assistant_message = await self._persist_turn_async( + target=target, + conversation_id=context.conversation_id, + user_audio_pcm=user_audio_pcm, + applied_converter_identifiers=applied_identifiers, + turn_result=turn_result, + ) + last_assistant_message = assistant_message executed_turns += 1 except Exception: logger.exception("BargeInAttack turn failed in convert-on-commit handler.") @@ -248,7 +260,7 @@ async def on_committed(event: _CommittedEvent) -> None: atomic_attack_identifier=build_atomic_attack_identifier( attack_identifier=self.get_identifier() ), - last_response=None, + last_response=last_assistant_message.message_pieces[0] if last_assistant_message else None, last_score=None, related_conversations=context.related_conversations, outcome=outcome, @@ -256,3 +268,67 @@ async def on_committed(event: _CommittedEvent) -> None: executed_turns=executed_turns, labels=context.memory_labels, ) + + async def _persist_turn_async( + self, + *, + target: RealtimeTarget, + conversation_id: str, + user_audio_pcm: bytes, + applied_converter_identifiers: list[ComponentIdentifier], + turn_result: RealtimeTargetResult, + ) -> Message: + """ + Persist the user+assistant ``Message`` pair for one completed turn to ``CentralMemory``. + + Saves user audio (whichever PCM the model actually heard — converted or raw) + and the assistant response audio to disk, builds a one-piece user ``Message`` + and a two-piece assistant ``Message`` (text transcript + audio_path), stamps + ``converter_identifiers`` on the user piece, and sets + ``prompt_metadata["interrupted"] = True`` on both assistant pieces when the + turn was cut short by server-side barge-in. + + Returns: + The assistant ``Message`` so callers can surface it as ``last_response``. + """ + user_audio_path = await target.save_audio( + user_audio_pcm, + num_channels=1, + sample_width=2, + sample_rate=_REALTIME_SAMPLE_RATE_HZ, + ) + user_piece = MessagePiece( + role="user", + original_value=user_audio_path, + original_value_data_type="audio_path", + converted_value=user_audio_path, + converted_value_data_type="audio_path", + conversation_id=conversation_id, + ) + user_piece.converter_identifiers.extend(applied_converter_identifiers) + user_message = Message(message_pieces=[user_piece]) + + response_audio_path = await target.save_audio( + turn_result.audio_bytes, + num_channels=1, + sample_width=2, + sample_rate=_REALTIME_SAMPLE_RATE_HZ, + ) + text_piece = construct_response_from_request( + request=user_piece, + response_text_pieces=[turn_result.flatten_transcripts()], + response_type="text", + ).message_pieces[0] + audio_piece = construct_response_from_request( + request=user_piece, + response_text_pieces=[response_audio_path], + response_type="audio_path", + ).message_pieces[0] + if turn_result.interrupted: + text_piece.prompt_metadata["interrupted"] = True + audio_piece.prompt_metadata["interrupted"] = True + assistant_message = Message(message_pieces=[text_piece, audio_piece]) + + target._memory.add_message_to_memory(request=user_message) + target._memory.add_message_to_memory(request=assistant_message) + return assistant_message diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 59e023b249..1f011c3bff 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -16,6 +16,7 @@ from pyrit.executor.attack import BargeInAttack, BargeInAttackContext from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters +from pyrit.identifiers import ComponentIdentifier from pyrit.models import AttackOutcome from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer from pyrit.prompt_target import RealtimeTarget @@ -230,10 +231,12 @@ async def test_send_streaming_session_config_async_requires_server_vad(sqlite_in # ---- Convert-on-commit dance (R4b) ---------------------------------------------------------- -def _make_audio_converter(transformer): +def _make_audio_converter(transformer, *, identifier_name: str = "MockAudioConverter"): """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" converter = MagicMock() - converter.get_identifier = MagicMock(return_value=MagicMock()) + converter.get_identifier = MagicMock( + return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), + ) async def _convert(*, prompt, input_type, start_token=None, end_token=None): assert input_type == "audio_path" @@ -445,3 +448,180 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: # Converted audio (returned by mock) should reach insert_user_audio_async. vad_target.insert_user_audio_async.assert_awaited_once() assert vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] == b"\xff" * 96 + + +# Placeholder for R4c tests + + +# ---- Per-turn persistence to CentralMemory (R4c) -------------------------------------------- + + +async def _drive_one_audio_turn( + attack, + vad_target, + *, + raw_chunk: bytes, + item_id: str, + turn_result: RealtimeTargetResult, +): + """Helper that runs a single audio-driven turn end-to-end against a mocked target.""" + connection = _mock_connection() + vad_target.connect = AsyncMock(return_value=connection) + vad_target.send_streaming_session_config_async = AsyncMock() + vad_target.push_audio_chunk_async = AsyncMock() + vad_target.delete_conversation_item_async = AsyncMock() + vad_target.insert_user_audio_async = AsyncMock() + + captured: dict[str, Any] = {} + + async def fake_subscribe(*, connection, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() + fut.set_result(turn_result) + vad_target.request_response_async = AsyncMock(return_value=fut) + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield raw_chunk + await captured["on_committed"](_CommittedEvent(item_id=item_id)) + + ctx = BargeInAttackContext( + params=AttackParameters(objective="obj"), + audio_chunks=chunks_then_commit(), + ) + with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + return await attack._perform_async(context=ctx) + + +async def test_persists_user_and_assistant_messages_per_turn(vad_target): + """A successful turn writes 1 user piece + 2 assistant pieces sharing the conversation id.""" + attack = BargeInAttack(objective_target=vad_target) + add_calls: list[Any] = [] + vad_target._memory = MagicMock() + vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + result = await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_1", + turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]), + ) + + assert len(add_calls) == 2 + user_msg, assistant_msg = add_calls + assert len(user_msg.message_pieces) == 1 + assert user_msg.message_pieces[0].converted_value_data_type == "audio_path" + assert user_msg.message_pieces[0].conversation_id == result.conversation_id + assert len(assistant_msg.message_pieces) == 2 + piece_types = sorted(p.converted_value_data_type for p in assistant_msg.message_pieces) + assert piece_types == ["audio_path", "text"] + text_piece = next(p for p in assistant_msg.message_pieces if p.converted_value_data_type == "text") + assert text_piece.converted_value == "hello" + + +async def test_persists_interrupted_metadata_on_assistant_pieces(vad_target): + """Interrupted turns mark both assistant pieces with prompt_metadata['interrupted'] = True.""" + attack = BargeInAttack(objective_target=vad_target) + add_calls: list[Any] = [] + vad_target._memory = MagicMock() + vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_int", + turn_result=RealtimeTargetResult( + audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True + ), + ) + + assistant_msg = add_calls[1] + for piece in assistant_msg.message_pieces: + assert piece.prompt_metadata.get("interrupted") is True + + +async def test_persists_converter_identifiers_on_user_piece(vad_target): + """Converter identifiers reported by convert_audio_async must land on the user piece.""" + bump = _make_audio_converter( + lambda pcm: bytes((b + 1) & 0xFF for b in pcm), + identifier_name="BumpConverter", + ) + attack = BargeInAttack( + objective_target=vad_target, + attack_converter_config=AttackConverterConfig( + request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), + ), + ) + add_calls: list[Any] = [] + vad_target._memory = MagicMock() + vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x05" * 96, + item_id="raw_c", + turn_result=RealtimeTargetResult(audio_bytes=b"", transcripts=[]), + ) + + user_msg = add_calls[0] + identifiers = user_msg.message_pieces[0].converter_identifiers + assert len(identifiers) == 1 + assert identifiers[0].class_name == "BumpConverter" + + +async def test_persists_converted_audio_when_converters_changed_bytes(vad_target): + """The user piece's audio_path must point at the converted PCM, not the raw snapshot.""" + bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + attack = BargeInAttack( + objective_target=vad_target, + attack_converter_config=AttackConverterConfig( + request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), + ), + ) + saved_calls: list[bytes] = [] + + async def fake_save_audio(audio_bytes, **_): + saved_calls.append(audio_bytes) + return f"/tmp/audio_{len(saved_calls)}.wav" + + vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + vad_target._memory = MagicMock() + vad_target._memory.add_message_to_memory = MagicMock() + + raw = b"\x05" * 96 + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=raw, + item_id="raw_x", + turn_result=RealtimeTargetResult(audio_bytes=b"\xff" * 96, transcripts=[]), + ) + + # save_audio called twice per turn: first for user audio (must be CONVERTED), then assistant audio. + assert len(saved_calls) == 2 + assert saved_calls[0] == bytes((b + 1) & 0xFF for b in raw) + assert saved_calls[1] == b"\xff" * 96 + + +async def test_attack_result_last_response_is_final_assistant_text_piece(vad_target): + """AttackResult.last_response must point at the last assistant message's first piece (text).""" + attack = BargeInAttack(objective_target=vad_target) + vad_target._memory = MagicMock() + vad_target._memory.add_message_to_memory = MagicMock() + + result = await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_lr", + turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["final answer"]), + ) + + assert result.last_response is not None + assert result.last_response.converted_value_data_type == "text" + assert result.last_response.converted_value == "final answer" diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index 937b55949f..12ecbcfbd5 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -635,10 +635,16 @@ async def test_add_prepended_conversation_to_memory(mock_memory_instance): # Placeholder for convert_audio_async tests -def _make_audio_converter(transformer, *, output_sample_rate=24000): +from pyrit.identifiers import ComponentIdentifier +from pyrit.prompt_normalizer import PromptConverterConfiguration + + +def _make_audio_converter(transformer, *, output_sample_rate=24000, identifier_name="MockAudioConverter"): """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" converter = MagicMock() - converter.get_identifier = MagicMock(return_value=MagicMock()) + converter.get_identifier = MagicMock( + return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), + ) async def _convert(*, prompt, input_type, start_token=None, end_token=None): assert input_type == "audio_path" From 836b2a917b4385084021fb2c532123d1f17ced89 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 20 May 2026 12:53:20 -0400 Subject: [PATCH 11/47] Add barge-in demo notebook, fix dispatcher deadlock and turn-await teardown Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- assets/photosynthesis_question.wav | Bin 0 -> 189042 bytes .../executor/attack/barge_in_attack.ipynb | 426 ++++++++++++++++++ doc/code/executor/attack/barge_in_attack.py | 205 +++++++++ doc/myst.yml | 1 + pyrit/executor/attack/streaming/barge_in.py | 63 ++- pyrit/prompt_normalizer/prompt_normalizer.py | 6 +- pyrit/prompt_target/common/realtime_audio.py | 7 +- .../openai/openai_realtime_target.py | 4 +- .../attack/streaming/test_barge_in.py | 46 +- .../test_prompt_normalizer.py | 10 +- .../target/test_realtime_target.py | 4 +- 11 files changed, 706 insertions(+), 66 deletions(-) create mode 100644 assets/photosynthesis_question.wav create mode 100644 doc/code/executor/attack/barge_in_attack.ipynb create mode 100644 doc/code/executor/attack/barge_in_attack.py diff --git a/assets/photosynthesis_question.wav b/assets/photosynthesis_question.wav new file mode 100644 index 0000000000000000000000000000000000000000..44f9e54141cfd7d169aae3bd3fd6125b6ca6c356 GIT binary patch literal 189042 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
  • ~-mD&1~{`(PdD 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%0A&>W5rGl4^1lv8AlQX6>#8VS_;z-y?3ul|DX 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 OkbndvAOQ*dLEr@y8{J$0 literal 0 HcmV?d00001 diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb new file mode 100644 index 0000000000..ec297550df --- /dev/null +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -0,0 +1,426 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a8b0c445", + "metadata": {}, + "source": [ + "# Barge-In Attack (Streaming Audio)\n", + "\n", + "`BargeInAttack` streams user audio to a `RealtimeTarget` and uses server-side voice-activity\n", + "detection (VAD) to detect turn boundaries. When the user speaks while the assistant is still\n", + "responding, server VAD cancels the in-flight response (barge-in). Interrupted turns are\n", + "persisted with `prompt_metadata[\"interrupted\"] = True`.\n", + "\n", + "Audio converters are applied per turn after VAD commits. The raw audio drives interruption\n", + "timing while the model responds to the converted version.\n", + "\n", + "> **Note:** Memory must be initialized via `initialize_pyrit_async`. See the\n", + "> [Memory Configuration Guide](../../memory/0_memory.md)." + ] + }, + { + "cell_type": "markdown", + "id": "d7d76fcf", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "`BargeInAttack` requires a `RealtimeTarget` with `server_vad=True` (or a `ServerVadConfig`\n", + "for custom tuning)." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "7102f541", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-20T16:25:04.524032Z", + "iopub.status.busy": "2026-05-20T16:25:04.524032Z", + "iopub.status.idle": "2026-05-20T16:25:09.960652Z", + "shell.execute_reply": "2026-05-20T16:25:09.959638Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found default environment files: ['./.pyrit/.env']\n", + "Loaded environment file: ./.pyrit/.env\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "No new upgrade operations detected.\n" + ] + } + ], + "source": [ + "import asyncio\n", + "import wave\n", + "from pathlib import Path\n", + "\n", + "from pyrit.executor.attack import (\n", + " AttackConverterConfig,\n", + " BargeInAttack,\n", + " BargeInAttackContext,\n", + " ConsoleAttackResultPrinter,\n", + ")\n", + "from pyrit.executor.attack.core import AttackParameters\n", + "from pyrit.memory import CentralMemory\n", + "from pyrit.prompt_converter import AudioFrequencyConverter\n", + "from pyrit.prompt_normalizer import PromptConverterConfiguration\n", + "from pyrit.prompt_target import RealtimeTarget\n", + "from pyrit.setup import IN_MEMORY, initialize_pyrit_async\n", + "\n", + "await initialize_pyrit_async(memory_db_type=IN_MEMORY) # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "88baceb7", + "metadata": {}, + "source": [ + "## Shared setup\n", + "\n", + "Both sections use a pre-recorded 24 kHz mono PCM16 question about photosynthesis. The\n", + "format matches what the OpenAI Realtime API expects. Any async generator yielding 24 kHz\n", + "PCM16 bytes works as a chunk source (live mic, TTS, etc.)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "9048dac1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-20T16:25:09.963652Z", + "iopub.status.busy": "2026-05-20T16:25:09.962652Z", + "iopub.status.idle": "2026-05-20T16:25:09.985006Z", + "shell.execute_reply": "2026-05-20T16:25:09.983979Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loaded question: 3.94s @ 24 kHz\n" + ] + } + ], + "source": [ + "CHUNK_MS = 100\n", + "CHUNK_SIZE = CHUNK_MS * 48 # PCM16 @ 24 kHz mono = 48 bytes per millisecond.\n", + "SILENCE_CHUNK = b\"\\x00\" * CHUNK_SIZE\n", + "audio_path = Path(\"../../../../assets/photosynthesis_question.wav\").resolve()\n", + "\n", + "\n", + "def _load_pcm(path: Path) -> bytes:\n", + " \"\"\"Read a WAV at 24 kHz / mono / PCM16 into raw PCM bytes.\"\"\"\n", + " with wave.open(str(path), \"rb\") as wav:\n", + " assert wav.getframerate() == 24000 and wav.getnchannels() == 1 and wav.getsampwidth() == 2\n", + " return wav.readframes(wav.getnframes())\n", + "\n", + "\n", + "async def _yield_chunks(pcm: bytes, real_time: bool = True):\n", + " \"\"\"Yield PCM in 100ms slices, optionally pacing at real-time.\"\"\"\n", + " for offset in range(0, len(pcm), CHUNK_SIZE):\n", + " yield pcm[offset : offset + CHUNK_SIZE]\n", + " if real_time:\n", + " await asyncio.sleep(CHUNK_MS / 1000)\n", + "\n", + "\n", + "question_pcm_24k = _load_pcm(audio_path)\n", + "print(f\"Loaded question: {len(question_pcm_24k) / 48 / 1000:.2f}s @ 24 kHz\")\n", + "\n", + "converters = PromptConverterConfiguration.from_converters(converters=[AudioFrequencyConverter(shift_value=200)])" + ] + }, + { + "cell_type": "markdown", + "id": "ff57f5e8", + "metadata": {}, + "source": [ + "## Section 1: Single-turn streaming with a converter\n", + "\n", + "Streams one user utterance, applies a frequency-shift converter after VAD commits the turn,\n", + "and gets the model's response. Exercises the full pipeline (chunk push, convert-on-commit,\n", + "item swap, response trigger, memory persistence) without barge-in." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "38326992", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-20T16:25:09.987001Z", + "iopub.status.busy": "2026-05-20T16:25:09.987001Z", + "iopub.status.idle": "2026-05-20T16:25:33.406429Z", + "shell.execute_reply": "2026-05-20T16:25:33.404612Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "executed_turns: 1\n", + "\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294332341158.mp3\u001b[0m\n", + "\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy, which they store as sugars. It mainly takes place in the chloroplasts of leaf cells. Here's how it works:\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 1. Light absorption: Chlorophyll, the green pigment, captures sunlight. This energy excites electrons within the chlorophyll.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 2. Water splitting: The plant takes in water (H₂O) from the roots and transfers it to the leaves. The light energy splits the water molecules into oxygen, protons, and electrons. The oxygen is\u001b[0m\n", + "\u001b[33m released as a byproduct.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 3. Conversion of energy: The excited electrons move through a chain of proteins, creating ATP and NADPH, which are energy carriers.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 4. Carbon fixation: Using that stored energy, the plant takes in carbon dioxide (CO₂) from the air. Through the Calvin cycle, it combines the CO₂ with the energy carriers to form glucose.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m This glucose feeds the plant and can be stored as starch. In essence, photosynthesis fuels plant growth and provides oxygen for us.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294332344158.mp3\u001b[0m\n", + "\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" + ] + } + ], + "source": [ + "async def single_turn_source():\n", + " async for chunk in _yield_chunks(question_pcm_24k):\n", + " yield chunk\n", + " # Trailing silence helps server VAD recognize end-of-turn.\n", + " for _ in range(25): # 2.5s trailing silence, above the 1.5s VAD threshold\n", + " yield SILENCE_CHUNK\n", + " await asyncio.sleep(CHUNK_MS / 1000)\n", + "\n", + "\n", + "target = RealtimeTarget(server_vad=True)\n", + "attack = BargeInAttack(\n", + " objective_target=target,\n", + " attack_converter_config=AttackConverterConfig(request_converters=converters),\n", + ")\n", + "\n", + "context = BargeInAttackContext(\n", + " params=AttackParameters(objective=\"Observe a single converted user turn end-to-end\"),\n", + " audio_chunks=single_turn_source(),\n", + ")\n", + "\n", + "result = await attack.execute_with_context_async(context=context) # type: ignore\n", + "print(f\"executed_turns: {result.executed_turns}\")\n", + "await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore\n", + "await target.cleanup_target() # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "0240a7c0", + "metadata": {}, + "source": [ + "## Section 2: Barge-in (interrupting the assistant mid-response)\n", + "\n", + "Plays the question twice with timing arranged so turn 2's speech arrives during turn 1's\n", + "response. Server VAD detects the new speech, cancels turn 1's response, and resolves it\n", + "with `interrupted=True`." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5d82347f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-20T16:25:33.409442Z", + "iopub.status.busy": "2026-05-20T16:25:33.408453Z", + "iopub.status.idle": "2026-05-20T16:26:07.641830Z", + "shell.execute_reply": "2026-05-20T16:26:07.640790Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "executed_turns: 2\n", + "\n", + "Persisted pieces (4 messages):\n", + " user audio_path: 1779294342848770.mp3\n", + " assistant text [INTERRUPTED]: Sure! Photosynthesis is the process plants use to convert light energy into chem...\n", + " assistant audio_path [INTERRUPTED]: 1779294342850774.mp3\n", + " user audio_path: 1779294366566679.mp3\n", + " assistant text: Absolutely! Let’s break it down step by step.\n", + "\n", + "1. **Where it happens**: Photosyn...\n", + " assistant audio_path: 1779294366569687.mp3\n", + "\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294342848770.mp3\u001b[0m\n", + "\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy they can use as\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294342850774.mp3\u001b[0m\n", + "\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[34m🔹 Turn 2 - USER\u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294366566679.mp3\u001b[0m\n", + "\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", + "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[33m Absolutely! Let’s break it down step by step.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 1. **Where it happens**: Photosynthesis takes place in chloroplasts, which are specialized structures inside plant cells. These contain chlorophyll, the green pigment that captures light energy from\u001b[0m\n", + "\u001b[33m the sun.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 2. **The raw materials**: Plants use carbon dioxide from the air (taken in through tiny pores called stomata) and water from the soil (absorbed through their roots).\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 3. **The light-dependent reactions**: Inside the chloroplasts, chlorophyll absorbs sunlight, which excites electrons. This energy splits water molecules into oxygen, protons, and electrons. Oxygen\u001b[0m\n", + "\u001b[33m is released as a byproduct (that’s the oxygen we breathe!). The electrons and protons help generate energy-rich molecules called ATP and NADPH.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 4. **The Calvin cycle (light-independent reactions)**: Using the ATP and NADPH, plants convert carbon dioxide into glucose through a series of enzyme-driven steps. Glucose is a simple sugar that\u001b[0m\n", + "\u001b[33m plants use to build more complex carbohydrates like starch and cellulose, fueling growth and development.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 5. **Energy storage and use**: The glucose can be used immediately for energy, or it can be stored as starch. This stored energy supports the plant’s metabolism, growth, and reproduction.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m In short, plants take in sunlight, water, and carbon dioxide, and through photosynthesis they produce oxygen and energy-rich sugars that sustain both themselves and, ultimately, life on Earth.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294366569687.mp3\u001b[0m\n", + "\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" + ] + } + ], + "source": [ + "TURN1_RESPONSE_WAIT_S = 0.2 # how long to let the model start speaking before barging in\n", + "\n", + "\n", + "async def barge_in_source():\n", + " # Turn 1: speak the question, then 1.5s of silence so VAD commits.\n", + " async for chunk in _yield_chunks(question_pcm_24k):\n", + " yield chunk\n", + " for _ in range(25): # 2.5s trailing silence\n", + " yield SILENCE_CHUNK\n", + " await asyncio.sleep(CHUNK_MS / 1000)\n", + "\n", + " # Let the model get partway into its response before we interrupt.\n", + " for _ in range(int(TURN1_RESPONSE_WAIT_S * 10)):\n", + " yield SILENCE_CHUNK\n", + " await asyncio.sleep(CHUNK_MS / 1000)\n", + "\n", + " # Turn 2: speak the question again. VAD's speech_started fires while turn 1's response\n", + " # is still streaming → server cancels + truncates turn 1.\n", + " async for chunk in _yield_chunks(question_pcm_24k):\n", + " yield chunk\n", + " for _ in range(25): # 2.5s trailing silence\n", + " yield SILENCE_CHUNK\n", + " await asyncio.sleep(CHUNK_MS / 1000)\n", + "\n", + "\n", + "target2 = RealtimeTarget(server_vad=True)\n", + "attack2 = BargeInAttack(\n", + " objective_target=target2,\n", + " attack_converter_config=AttackConverterConfig(request_converters=converters),\n", + ")\n", + "\n", + "barge_in_context = BargeInAttackContext(\n", + " params=AttackParameters(objective=\"Demonstrate barge-in by interrupting a benign answer\"),\n", + " audio_chunks=barge_in_source(),\n", + ")\n", + "\n", + "barge_in_result = await attack2.execute_with_context_async(context=barge_in_context) # type: ignore\n", + "print(f\"executed_turns: {barge_in_result.executed_turns}\")\n", + "\n", + "# Inspect memory to verify the barge-in landed in metadata.\n", + "memory = CentralMemory.get_memory_instance()\n", + "turns = memory.get_conversation(conversation_id=barge_in_result.conversation_id)\n", + "print(f\"\\nPersisted pieces ({len(turns)} messages):\")\n", + "for message in turns:\n", + " for piece in message.message_pieces:\n", + " interrupted = piece.prompt_metadata.get(\"interrupted\")\n", + " marker = \" [INTERRUPTED]\" if interrupted else \"\"\n", + " val = piece.converted_value\n", + " if piece.converted_value_data_type == \"audio_path\":\n", + " val = Path(val).name\n", + " value_preview = (val[:80] + \"...\") if len(val) > 80 else val\n", + " print(f\" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}\")\n", + "\n", + "await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore\n", + "await target2.cleanup_target() # type: ignore" + ] + }, + { + "cell_type": "markdown", + "id": "1cb9559a", + "metadata": {}, + "source": [ + "### Reading the barge-in output\n", + "\n", + "If barge-in fired successfully:\n", + "- `executed_turns: 2` (two VAD-detected user turns)\n", + "- First assistant turn shows `[INTERRUPTED]` with a truncated transcript\n", + "- Second assistant turn completes normally\n", + "\n", + "If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio\n", + "arrives earlier in turn 1's response window." + ] + }, + { + "cell_type": "markdown", + "id": "5cdf9e24", + "metadata": {}, + "source": [ + "## Alternate chunk sources\n", + "\n", + "The chunk source is the main strategy hook:\n", + "\n", + "- **Pre-recorded WAV** (this notebook): most common starting point\n", + "- **TTS converter**: generate audio from text prompts dynamically\n", + "- **Live microphone**: use `sounddevice` or similar; yield what the mic produces\n", + "\n", + "For adaptive attacks (e.g., score-driven strategies), subclass `BargeInAttack` and override\n", + "`_perform_async` to interleave turn observation with chunk generation." + ] + } + ], + "metadata": { + "jupytext": { + "cell_metadata_filter": "-all" + }, + "kernelspec": { + "display_name": "pyrit (Python 3.13.12)", + "language": "python", + "name": "pyrit" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py new file mode 100644 index 0000000000..8df628e553 --- /dev/null +++ b/doc/code/executor/attack/barge_in_attack.py @@ -0,0 +1,205 @@ +# --- +# jupyter: +# jupytext: +# cell_metadata_filter: -all +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# jupytext_version: 1.19.1 +# --- + +# %% [markdown] +# # Barge-In Attack (Streaming Audio) +# +# `BargeInAttack` streams user audio to a `RealtimeTarget` and uses server-side voice-activity +# detection (VAD) to detect turn boundaries. When the user speaks while the assistant is still +# responding, server VAD cancels the in-flight response (barge-in). Interrupted turns are +# persisted with `prompt_metadata["interrupted"] = True`. +# +# Audio converters are applied per turn after VAD commits. The raw audio drives interruption +# timing while the model responds to the converted version. +# +# > **Note:** Memory must be initialized via `initialize_pyrit_async`. See the +# > [Memory Configuration Guide](../../memory/0_memory.md). + +# %% [markdown] +# ## Setup +# +# `BargeInAttack` requires a `RealtimeTarget` with `server_vad=True` (or a `ServerVadConfig` +# for custom tuning). + +# %% +import asyncio +import wave +from pathlib import Path + +from pyrit.executor.attack import ( + AttackConverterConfig, + BargeInAttack, + BargeInAttackContext, + ConsoleAttackResultPrinter, +) +from pyrit.executor.attack.core import AttackParameters +from pyrit.memory import CentralMemory +from pyrit.prompt_converter import AudioFrequencyConverter +from pyrit.prompt_normalizer import PromptConverterConfiguration +from pyrit.prompt_target import RealtimeTarget +from pyrit.setup import IN_MEMORY, initialize_pyrit_async + +await initialize_pyrit_async(memory_db_type=IN_MEMORY) # type: ignore + +# %% [markdown] +# ## Shared setup +# +# Both sections use a pre-recorded 24 kHz mono PCM16 question about photosynthesis. The +# format matches what the OpenAI Realtime API expects. Any async generator yielding 24 kHz +# PCM16 bytes works as a chunk source (live mic, TTS, etc.). + +# %% +CHUNK_MS = 100 +CHUNK_SIZE = CHUNK_MS * 48 # PCM16 @ 24 kHz mono = 48 bytes per millisecond. +SILENCE_CHUNK = b"\x00" * CHUNK_SIZE +audio_path = Path("../../../../assets/photosynthesis_question.wav").resolve() + + +def _load_pcm(path: Path) -> bytes: + """Read a WAV at 24 kHz / mono / PCM16 into raw PCM bytes.""" + with wave.open(str(path), "rb") as wav: + assert wav.getframerate() == 24000 and wav.getnchannels() == 1 and wav.getsampwidth() == 2 + return wav.readframes(wav.getnframes()) + + +async def _yield_chunks(pcm: bytes, real_time: bool = True): + """Yield PCM in 100ms slices, optionally pacing at real-time.""" + for offset in range(0, len(pcm), CHUNK_SIZE): + yield pcm[offset : offset + CHUNK_SIZE] + if real_time: + await asyncio.sleep(CHUNK_MS / 1000) + + +question_pcm_24k = _load_pcm(audio_path) +print(f"Loaded question: {len(question_pcm_24k) / 48 / 1000:.2f}s @ 24 kHz") + +converters = PromptConverterConfiguration.from_converters(converters=[AudioFrequencyConverter(shift_value=200)]) + + +# %% [markdown] +# ## Section 1: Single-turn streaming with a converter +# +# Streams one user utterance, applies a frequency-shift converter after VAD commits the turn, +# and gets the model's response. Exercises the full pipeline (chunk push, convert-on-commit, +# item swap, response trigger, memory persistence) without barge-in. + +# %% +async def single_turn_source(): + async for chunk in _yield_chunks(question_pcm_24k): + yield chunk + # Trailing silence helps server VAD recognize end-of-turn. + for _ in range(25): # 2.5s trailing silence, above the 1.5s VAD threshold + yield SILENCE_CHUNK + await asyncio.sleep(CHUNK_MS / 1000) + + +target = RealtimeTarget(server_vad=True) +attack = BargeInAttack( + objective_target=target, + attack_converter_config=AttackConverterConfig(request_converters=converters), +) + +context = BargeInAttackContext( + params=AttackParameters(objective="Observe a single converted user turn end-to-end"), + audio_chunks=single_turn_source(), +) + +result = await attack.execute_with_context_async(context=context) # type: ignore +print(f"executed_turns: {result.executed_turns}") +await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore +await target.cleanup_target() # type: ignore + +# %% [markdown] +# ## Section 2: Barge-in (interrupting the assistant mid-response) +# +# Plays the question twice with timing arranged so turn 2's speech arrives during turn 1's +# response. Server VAD detects the new speech, cancels turn 1's response, and resolves it +# with `interrupted=True`. + +# %% +TURN1_RESPONSE_WAIT_S = 0.2 # how long to let the model start speaking before barging in + + +async def barge_in_source(): + # Turn 1: speak the question, then 1.5s of silence so VAD commits. + async for chunk in _yield_chunks(question_pcm_24k): + yield chunk + for _ in range(25): # 2.5s trailing silence + yield SILENCE_CHUNK + await asyncio.sleep(CHUNK_MS / 1000) + + # Let the model get partway into its response before we interrupt. + for _ in range(int(TURN1_RESPONSE_WAIT_S * 10)): + yield SILENCE_CHUNK + await asyncio.sleep(CHUNK_MS / 1000) + + # Turn 2: speak the question again. VAD's speech_started fires while turn 1's response + # is still streaming → server cancels + truncates turn 1. + async for chunk in _yield_chunks(question_pcm_24k): + yield chunk + for _ in range(25): # 2.5s trailing silence + yield SILENCE_CHUNK + await asyncio.sleep(CHUNK_MS / 1000) + + +target2 = RealtimeTarget(server_vad=True) +attack2 = BargeInAttack( + objective_target=target2, + attack_converter_config=AttackConverterConfig(request_converters=converters), +) + +barge_in_context = BargeInAttackContext( + params=AttackParameters(objective="Demonstrate barge-in by interrupting a benign answer"), + audio_chunks=barge_in_source(), +) + +barge_in_result = await attack2.execute_with_context_async(context=barge_in_context) # type: ignore +print(f"executed_turns: {barge_in_result.executed_turns}") + +# Inspect memory to verify the barge-in landed in metadata. +memory = CentralMemory.get_memory_instance() +turns = memory.get_conversation(conversation_id=barge_in_result.conversation_id) +print(f"\nPersisted pieces ({len(turns)} messages):") +for message in turns: + for piece in message.message_pieces: + interrupted = piece.prompt_metadata.get("interrupted") + marker = " [INTERRUPTED]" if interrupted else "" + val = piece.converted_value + if piece.converted_value_data_type == "audio_path": + val = Path(val).name + value_preview = (val[:80] + "...") if len(val) > 80 else val + print(f" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}") + +await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore +await target2.cleanup_target() # type: ignore + +# %% [markdown] +# ### Reading the barge-in output +# +# If barge-in fired successfully: +# - `executed_turns: 2` (two VAD-detected user turns) +# - First assistant turn shows `[INTERRUPTED]` with a truncated transcript +# - Second assistant turn completes normally +# +# If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio +# arrives earlier in turn 1's response window. + +# %% [markdown] +# ## Alternate chunk sources +# +# The chunk source is the main strategy hook: +# +# - **Pre-recorded WAV** (this notebook): most common starting point +# - **TTS converter**: generate audio from text prompts dynamically +# - **Live microphone**: use `sounddevice` or similar; yield what the mic produces +# +# For adaptive attacks (e.g., score-driven strategies), subclass `BargeInAttack` and override +# `_perform_async` to interleave turn observation with chunk generation. diff --git a/doc/myst.yml b/doc/myst.yml index f703d6c8cd..1232c73587 100644 --- a/doc/myst.yml +++ b/doc/myst.yml @@ -87,6 +87,7 @@ project: - file: code/executor/attack/role_play_attack.ipynb - file: code/executor/attack/skeleton_key_attack.ipynb - file: code/executor/attack/tap_attack.ipynb + - file: code/executor/attack/barge_in_attack.ipynb - file: code/executor/attack/violent_durian_attack.ipynb - file: code/executor/workflow/0_workflow.md children: diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 38e4e14acf..d320dd7d80 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -89,7 +89,10 @@ class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): required=frozenset({CapabilityName.STREAMING_BARGE_IN}), ) - _POST_STREAM_SETTLE_SECONDS = 1.0 + #: Maximum time to wait after the chunk source exhausts for any in-flight VAD-committed + #: turn to finish (commit → convert → response.create → response.done → persist). Acts as + #: a safety cap; the attack returns as soon as the last turn actually completes. + _MAX_POST_STREAM_WAIT_SECONDS = 30.0 @apply_defaults def __init__( @@ -174,10 +177,14 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR turn_lock = asyncio.Lock() last_assistant_message: Message | None = None executed_turns = 0 + turn_tasks: list[asyncio.Task[None]] = [] async def on_committed(event: _CommittedEvent) -> None: """Convert-on-commit dance: snapshot raw audio → run converters → swap → request response → persist.""" nonlocal last_assistant_message, executed_turns + current_task = asyncio.current_task() + if current_task is not None: + turn_tasks.append(current_task) try: async with turn_lock: snapshot = bytes(raw_buffer) @@ -196,18 +203,12 @@ async def on_committed(event: _CommittedEvent) -> None: using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot if using_converted_audio: try: - await target.delete_conversation_item_async( - connection=connection, item_id=event.item_id - ) + await target.delete_conversation_item_async(connection=connection, item_id=event.item_id) except Exception as e: logger.warning(f"conversation.item.delete failed for {event.item_id}: {e}") - await target.insert_user_audio_async( - connection=connection, pcm_bytes=converted_pcm - ) + await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) - turn_future = await target.request_response_async( - connection=connection, dispatcher=dispatcher - ) + turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) turn_result = await turn_future user_audio_pcm = converted_pcm if using_converted_audio else snapshot @@ -229,17 +230,18 @@ async def on_committed(event: _CommittedEvent) -> None: ) try: - await target.send_streaming_session_config_async( - connection=connection, system_prompt=context.system_prompt - ) + await target.send_streaming_session_config_async(connection=connection, system_prompt=context.system_prompt) async for chunk in context.audio_chunks: if chunk: raw_buffer.extend(chunk) await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) - # Give server VAD time to commit the buffer and the dispatcher to drain. - await asyncio.sleep(self._POST_STREAM_SETTLE_SECONDS) + # Wait for any in-flight committed-turn tasks to finish (convert + response + + # persistence), capped by a safety timeout. The chunk source must end with enough + # trailing silence for server VAD's silence threshold to fire commit — otherwise + # the last turn never enters the convert pipeline and there is nothing to wait on. + await self._wait_for_pending_turns_async(turn_tasks) finally: await dispatcher.stop() try: @@ -257,9 +259,7 @@ async def on_committed(event: _CommittedEvent) -> None: return AttackResult( conversation_id=context.conversation_id, objective=context.objective, - atomic_attack_identifier=build_atomic_attack_identifier( - attack_identifier=self.get_identifier() - ), + atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()), last_response=last_assistant_message.message_pieces[0] if last_assistant_message else None, last_score=None, related_conversations=context.related_conversations, @@ -269,6 +269,33 @@ async def on_committed(event: _CommittedEvent) -> None: labels=context.memory_labels, ) + async def _wait_for_pending_turns_async(self, turn_tasks: list[asyncio.Task[None]]) -> None: + """ + Wait for any in-flight VAD-committed turn tasks to finish, with a safety timeout. + + Returns as soon as all known turn tasks complete (or the cap elapses, whichever + comes first). The timeout is a safety net for stuck turns; the common case is to + return immediately once the last turn's persistence finishes. + + Args: + turn_tasks: Task handles for every ``on_committed`` invocation launched so far. + Tasks added after this method starts are not waited on; the dispatcher + callback machinery makes this race vanishingly unlikely in practice. + """ + if not turn_tasks: + return + try: + await asyncio.wait_for( + asyncio.gather(*turn_tasks, return_exceptions=True), + timeout=self._MAX_POST_STREAM_WAIT_SECONDS, + ) + except asyncio.TimeoutError: + logger.warning( + f"Timed out after {self._MAX_POST_STREAM_WAIT_SECONDS}s waiting for in-flight turn tasks to " + "finish; teardown will cancel them. Increase _MAX_POST_STREAM_WAIT_SECONDS if responses " + "regularly take longer." + ) + async def _persist_turn_async( self, *, diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 5335510995..8544e0f477 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -361,11 +361,7 @@ async def convert_audio_async( identifiers.append(converter.get_identifier()) with wave.open(current_path, "rb") as wav_in: - if ( - wav_in.getnchannels() != 1 - or wav_in.getsampwidth() != 2 - or wav_in.getframerate() != sample_rate - ): + if wav_in.getnchannels() != 1 or wav_in.getsampwidth() != 2 or wav_in.getframerate() != sample_rate: raise ValueError( "Converter output incompatible with streaming target: " f"expected mono PCM16 @ {sample_rate} Hz, got channels={wav_in.getnchannels()} " diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 7fa964a845..218e5e4552 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -174,8 +174,9 @@ async def stop(self) -> None: """ Cancel the background dispatch task and release the reference. - In-flight callback tasks are awaited (with exception suppression) so - their resources release cleanly before the connection is torn down. + In-flight callback tasks are cancelled and awaited (with exception + suppression) so they don't deadlock waiting on the turn future that the + now-dead dispatch loop would have resolved. """ if self._task is not None: self._task.cancel() @@ -185,6 +186,8 @@ async def stop(self) -> None: if self._callback_tasks: pending = list(self._callback_tasks) self._callback_tasks.clear() + for task in pending: + task.cancel() await asyncio.gather(*pending, return_exceptions=True) def register_turn(self, state: _RealtimeTurnState) -> None: diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 344bb89a58..122c1da7ac 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -567,9 +567,7 @@ async def subscribe_events_async( self, *, connection: Any, - on_user_audio_committed: ( - Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None - ) = None, + on_user_audio_committed: (Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None) = None, ) -> _RealtimeEventDispatcher: """ Start consuming events from the connection and route them via the OpenAI dispatcher. diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 1f011c3bff..545ae8249f 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -34,9 +34,7 @@ @pytest.fixture @patch.dict("os.environ", _CLEAN_ENV) def vad_target(sqlite_instance): - return RealtimeTarget( - api_key="test_key", endpoint="wss://test_url", model_name="test", server_vad=True - ) + return RealtimeTarget(api_key="test_key", endpoint="wss://test_url", model_name="test", server_vad=True) async def _aiter(chunks: list[bytes]) -> AsyncIterator[bytes]: @@ -130,7 +128,7 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] ctx = _attack_context(audio_chunks=_aiter(chunks)) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) vad_target.connect.assert_awaited_once_with(conversation_id=ctx.conversation_id) @@ -170,11 +168,11 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield b"\x00" * 480 # Drive a fake commit mid-stream. - await captured["on_committed"](_CommittedEvent(item_id="raw_1")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_1"))) ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) vad_target.request_response_async.assert_awaited_once() @@ -195,7 +193,7 @@ async def test_perform_async_stops_dispatcher_even_on_exception(vad_target): ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) with pytest.raises(RuntimeError, match="push exploded"): - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): await attack._perform_async(context=ctx) dispatcher.stop.assert_awaited_once() @@ -208,9 +206,7 @@ async def test_perform_async_stops_dispatcher_even_on_exception(vad_target): async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): """The streaming session config must flip create_response to False on turn_detection.""" connection = _mock_connection() - await vad_target.send_streaming_session_config_async( - connection=connection, system_prompt="hi" - ) + await vad_target.send_streaming_session_config_async(connection=connection, system_prompt="hi") connection.session.update.assert_awaited_once() config = connection.session.update.call_args.kwargs["session"] assert config["audio"]["input"]["turn_detection"]["create_response"] is False @@ -293,19 +289,17 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw_chunk - await captured["on_committed"](_CommittedEvent(item_id="raw_99")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_99"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), audio_chunks=chunks_then_commit(), ) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) - vad_target.delete_conversation_item_async.assert_awaited_once_with( - connection=connection, item_id="raw_99" - ) + vad_target.delete_conversation_item_async.assert_awaited_once_with(connection=connection, item_id="raw_99") vad_target.insert_user_audio_async.assert_awaited_once() inserted_pcm = vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] assert inserted_pcm == bytes((b + 1) & 0xFF for b in raw_chunk) @@ -336,14 +330,14 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield b"\x00" * 96 - await captured["on_committed"](_CommittedEvent(item_id="raw_42")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_42"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), audio_chunks=chunks_then_commit(), ) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) vad_target.delete_conversation_item_async.assert_not_called() @@ -382,16 +376,16 @@ def _future_with(result: RealtimeTargetResult) -> asyncio.Future[RealtimeTargetR async def chunks_then_two_commits() -> AsyncIterator[bytes]: yield b"\x01" * 96 - await captured["on_committed"](_CommittedEvent(item_id="raw_1")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_1"))) yield b"\x02" * 96 - await captured["on_committed"](_CommittedEvent(item_id="raw_2")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_2"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), audio_chunks=chunks_then_two_commits(), ) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): await attack._perform_async(context=ctx) insert_calls = vad_target.insert_user_audio_async.await_args_list @@ -431,14 +425,14 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw - await captured["on_committed"](_CommittedEvent(item_id="raw_z")) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_z"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), audio_chunks=chunks_then_commit(), ) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): await attack._perform_async(context=ctx) fake_normalizer.convert_audio_async.assert_awaited_once() @@ -485,13 +479,13 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw_chunk - await captured["on_committed"](_CommittedEvent(item_id=item_id)) + await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id=item_id))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), audio_chunks=chunks_then_commit(), ) - with patch.object(attack, "_POST_STREAM_SETTLE_SECONDS", 0): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): return await attack._perform_async(context=ctx) @@ -534,9 +528,7 @@ async def test_persists_interrupted_metadata_on_assistant_pieces(vad_target): vad_target, raw_chunk=b"\x00" * 96, item_id="raw_int", - turn_result=RealtimeTargetResult( - audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True - ), + turn_result=RealtimeTargetResult(audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True), ) assistant_msg = add_calls[1] diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index 12ecbcfbd5..ce0befa515 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -17,6 +17,7 @@ execution_context, get_execution_context, ) +from pyrit.identifiers import ComponentIdentifier from pyrit.memory import CentralMemory from pyrit.models import ( Message, @@ -635,10 +636,6 @@ async def test_add_prepended_conversation_to_memory(mock_memory_instance): # Placeholder for convert_audio_async tests -from pyrit.identifiers import ComponentIdentifier -from pyrit.prompt_normalizer import PromptConverterConfiguration - - def _make_audio_converter(transformer, *, output_sample_rate=24000, identifier_name="MockAudioConverter"): """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" converter = MagicMock() @@ -669,9 +666,7 @@ async def _convert(*, prompt, input_type, start_token=None, end_token=None): async def test_convert_audio_async_no_configurations_returns_input(sqlite_instance): normalizer = PromptNormalizer() pcm = b"\xaa" * 1024 - out, ids = await normalizer.convert_audio_async( - pcm_bytes=pcm, sample_rate=24000, converter_configurations=[] - ) + out, ids = await normalizer.convert_audio_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=[]) assert out == pcm assert ids == [] @@ -732,4 +727,3 @@ async def test_convert_audio_async_rejects_mismatched_sample_rate(sqlite_instanc sample_rate=24000, converter_configurations=PromptConverterConfiguration.from_converters(converters=[bad]), ) - diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 8efb27a6ba..19c2e1c5a5 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -939,9 +939,7 @@ async def event_iter(): async def on_committed(event): received.append(event) - dispatcher = await target.subscribe_events_async( - connection=connection, on_user_audio_committed=on_committed - ) + dispatcher = await target.subscribe_events_async(connection=connection, on_user_audio_committed=on_committed) try: # Yield until the dispatch loop processes the scripted event. for _ in range(20): From 32dd43e09971d5f22ec4622be245864bc214def0 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 20 May 2026 13:13:30 -0400 Subject: [PATCH 12/47] =?UTF-8?q?Fix=20review=20findings:=20insert-before-?= =?UTF-8?q?delete,=20CentralMemory,=20connect=5Fasync=20rename,=20Optional?= =?UTF-8?q?=E2=86=92union?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../executor/attack/barge_in_attack.ipynb | 71 +++------- doc/code/executor/attack/barge_in_attack.py | 1 + pyrit/executor/attack/streaming/barge_in.py | 16 ++- .../openai/openai_realtime_target.py | 4 +- .../attack/streaming/test_barge_in.py | 126 ++++++++++-------- .../target/test_realtime_target.py | 2 +- 6 files changed, 100 insertions(+), 120 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index ec297550df..77e27b1361 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "a8b0c445", + "id": "0", "metadata": {}, "source": [ "# Barge-In Attack (Streaming Audio)\n", @@ -21,7 +21,7 @@ }, { "cell_type": "markdown", - "id": "d7d76fcf", + "id": "1", "metadata": {}, "source": [ "## Setup\n", @@ -32,16 +32,9 @@ }, { "cell_type": "code", - "execution_count": 1, - "id": "7102f541", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-20T16:25:04.524032Z", - "iopub.status.busy": "2026-05-20T16:25:04.524032Z", - "iopub.status.idle": "2026-05-20T16:25:09.960652Z", - "shell.execute_reply": "2026-05-20T16:25:09.959638Z" - } - }, + "execution_count": null, + "id": "2", + "metadata": {}, "outputs": [ { "name": "stdout", @@ -82,7 +75,7 @@ }, { "cell_type": "markdown", - "id": "88baceb7", + "id": "3", "metadata": {}, "source": [ "## Shared setup\n", @@ -94,16 +87,9 @@ }, { "cell_type": "code", - "execution_count": 2, - "id": "9048dac1", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-20T16:25:09.963652Z", - "iopub.status.busy": "2026-05-20T16:25:09.962652Z", - "iopub.status.idle": "2026-05-20T16:25:09.985006Z", - "shell.execute_reply": "2026-05-20T16:25:09.983979Z" - } - }, + "execution_count": null, + "id": "4", + "metadata": {}, "outputs": [ { "name": "stdout", @@ -143,7 +129,7 @@ }, { "cell_type": "markdown", - "id": "ff57f5e8", + "id": "5", "metadata": {}, "source": [ "## Section 1: Single-turn streaming with a converter\n", @@ -155,16 +141,9 @@ }, { "cell_type": "code", - "execution_count": 3, - "id": "38326992", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-20T16:25:09.987001Z", - "iopub.status.busy": "2026-05-20T16:25:09.987001Z", - "iopub.status.idle": "2026-05-20T16:25:33.406429Z", - "shell.execute_reply": "2026-05-20T16:25:33.404612Z" - } - }, + "execution_count": null, + "id": "6", + "metadata": {}, "outputs": [ { "name": "stdout", @@ -227,7 +206,7 @@ }, { "cell_type": "markdown", - "id": "0240a7c0", + "id": "7", "metadata": {}, "source": [ "## Section 2: Barge-in (interrupting the assistant mid-response)\n", @@ -239,16 +218,9 @@ }, { "cell_type": "code", - "execution_count": 4, - "id": "5d82347f", - "metadata": { - "execution": { - "iopub.execute_input": "2026-05-20T16:25:33.409442Z", - "iopub.status.busy": "2026-05-20T16:25:33.408453Z", - "iopub.status.idle": "2026-05-20T16:26:07.641830Z", - "shell.execute_reply": "2026-05-20T16:26:07.640790Z" - } - }, + "execution_count": null, + "id": "8", + "metadata": {}, "outputs": [ { "name": "stdout", @@ -367,7 +339,7 @@ }, { "cell_type": "markdown", - "id": "1cb9559a", + "id": "9", "metadata": {}, "source": [ "### Reading the barge-in output\n", @@ -383,7 +355,7 @@ }, { "cell_type": "markdown", - "id": "5cdf9e24", + "id": "10", "metadata": {}, "source": [ "## Alternate chunk sources\n", @@ -403,11 +375,6 @@ "jupytext": { "cell_metadata_filter": "-all" }, - "kernelspec": { - "display_name": "pyrit (Python 3.13.12)", - "language": "python", - "name": "pyrit" - }, "language_info": { "codemirror_mode": { "name": "ipython", diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py index 8df628e553..30df5bc570 100644 --- a/doc/code/executor/attack/barge_in_attack.py +++ b/doc/code/executor/attack/barge_in_attack.py @@ -91,6 +91,7 @@ async def _yield_chunks(pcm: bytes, real_time: bool = True): # and gets the model's response. Exercises the full pipeline (chunk push, convert-on-commit, # item swap, response trigger, memory persistence) without barge-in. + # %% async def single_turn_source(): async for chunk in _yield_chunks(question_pcm_24k): diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index d320dd7d80..b624a25201 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -19,13 +19,14 @@ import logging import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, ClassVar, Optional, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults from pyrit.executor.attack.core.attack_config import AttackConverterConfig from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier +from pyrit.memory import CentralMemory from pyrit.models import ( AttackOutcome, AttackResult, @@ -99,8 +100,8 @@ def __init__( self, *, objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] - attack_converter_config: Optional[AttackConverterConfig] = None, - prompt_normalizer: Optional[PromptNormalizer] = None, + attack_converter_config: AttackConverterConfig | None = None, + prompt_normalizer: PromptNormalizer | None = None, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ @@ -172,7 +173,7 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR target = cast("RealtimeTarget", self._objective_target) assert context.audio_chunks is not None # validated upstream - connection = await target.connect(conversation_id=context.conversation_id) + connection = await target.connect_async(conversation_id=context.conversation_id) raw_buffer = bytearray() turn_lock = asyncio.Lock() last_assistant_message: Message | None = None @@ -202,11 +203,11 @@ async def on_committed(event: _CommittedEvent) -> None: using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot if using_converted_audio: + await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) try: await target.delete_conversation_item_async(connection=connection, item_id=event.item_id) except Exception as e: logger.warning(f"conversation.item.delete failed for {event.item_id}: {e}") - await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) turn_result = await turn_future @@ -356,6 +357,7 @@ async def _persist_turn_async( audio_piece.prompt_metadata["interrupted"] = True assistant_message = Message(message_pieces=[text_piece, audio_piece]) - target._memory.add_message_to_memory(request=user_message) - target._memory.add_message_to_memory(request=assistant_message) + memory = CentralMemory.get_memory_instance() + memory.add_message_to_memory(request=user_message) + memory.add_message_to_memory(request=assistant_message) return assistant_message diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 122c1da7ac..530c35f76f 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -242,7 +242,7 @@ def _get_openai_client(self) -> AsyncOpenAI: return self._realtime_client - async def connect(self, conversation_id: str) -> Any: + async def connect_async(self, conversation_id: str) -> Any: """ Connect to Realtime API using AsyncOpenAI client and return the realtime connection. @@ -370,7 +370,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me message = normalized_conversation[-1] conversation_id = message.message_pieces[0].conversation_id if conversation_id not in self._existing_conversation: - connection = await self.connect(conversation_id=conversation_id) + connection = await self.connect_async(conversation_id=conversation_id) self._existing_conversation[conversation_id] = connection # Only send config when creating a new connection diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 545ae8249f..2f902e12a1 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -118,7 +118,7 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): """Happy path: connect, send config, subscribe, push chunks, stop, close — no commits.""" attack = BargeInAttack(objective_target=vad_target) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() dispatcher = AsyncMock() @@ -131,7 +131,7 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) - vad_target.connect.assert_awaited_once_with(conversation_id=ctx.conversation_id) + vad_target.connect_async.assert_awaited_once_with(conversation_id=ctx.conversation_id) vad_target.send_streaming_session_config_async.assert_awaited_once() vad_target.subscribe_events_async.assert_awaited_once() assert vad_target.push_audio_chunk_async.await_count == len(chunks) @@ -147,7 +147,7 @@ async def test_perform_async_fires_request_response_on_commit(vad_target): """A commit event must drive request_response_async and increment the turn counter.""" attack = BargeInAttack(objective_target=vad_target) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() @@ -184,7 +184,7 @@ async def test_perform_async_stops_dispatcher_even_on_exception(vad_target): """If the chunk loop raises, dispatcher.stop() and connection.close() still run.""" attack = BargeInAttack(objective_target=vad_target) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) dispatcher = AsyncMock() @@ -267,7 +267,7 @@ async def test_perform_async_swaps_raw_item_when_converters_change_audio(vad_tar bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() vad_target.delete_conversation_item_async = AsyncMock() @@ -311,7 +311,7 @@ async def test_perform_async_skips_swap_when_no_converters(vad_target): """Empty converter list: don't delete raw, don't insert converted, just request response.""" attack = BargeInAttack(objective_target=vad_target) # no converter config connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() vad_target.delete_conversation_item_async = AsyncMock() @@ -351,7 +351,7 @@ async def test_perform_async_clears_raw_buffer_between_commits(vad_target): bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() vad_target.delete_conversation_item_async = AsyncMock() @@ -404,7 +404,7 @@ async def test_perform_async_uses_injected_normalizer(vad_target): prompt_normalizer=fake_normalizer, ) connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() vad_target.delete_conversation_item_async = AsyncMock() @@ -460,7 +460,7 @@ async def _drive_one_audio_turn( ): """Helper that runs a single audio-driven turn end-to-end against a mocked target.""" connection = _mock_connection() - vad_target.connect = AsyncMock(return_value=connection) + vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() vad_target.delete_conversation_item_async = AsyncMock() @@ -493,16 +493,18 @@ async def test_persists_user_and_assistant_messages_per_turn(vad_target): """A successful turn writes 1 user piece + 2 assistant pieces sharing the conversation id.""" attack = BargeInAttack(objective_target=vad_target) add_calls: list[Any] = [] - vad_target._memory = MagicMock() - vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - result = await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_1", - turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]), - ) + mock_memory = MagicMock() + mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: + mock_cm.get_memory_instance.return_value = mock_memory + result = await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_1", + turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]), + ) assert len(add_calls) == 2 user_msg, assistant_msg = add_calls @@ -520,16 +522,18 @@ async def test_persists_interrupted_metadata_on_assistant_pieces(vad_target): """Interrupted turns mark both assistant pieces with prompt_metadata['interrupted'] = True.""" attack = BargeInAttack(objective_target=vad_target) add_calls: list[Any] = [] - vad_target._memory = MagicMock() - vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_int", - turn_result=RealtimeTargetResult(audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True), - ) + mock_memory = MagicMock() + mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: + mock_cm.get_memory_instance.return_value = mock_memory + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_int", + turn_result=RealtimeTargetResult(audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True), + ) assistant_msg = add_calls[1] for piece in assistant_msg.message_pieces: @@ -549,16 +553,18 @@ async def test_persists_converter_identifiers_on_user_piece(vad_target): ), ) add_calls: list[Any] = [] - vad_target._memory = MagicMock() - vad_target._memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x05" * 96, - item_id="raw_c", - turn_result=RealtimeTargetResult(audio_bytes=b"", transcripts=[]), - ) + mock_memory = MagicMock() + mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) + + with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: + mock_cm.get_memory_instance.return_value = mock_memory + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x05" * 96, + item_id="raw_c", + turn_result=RealtimeTargetResult(audio_bytes=b"", transcripts=[]), + ) user_msg = add_calls[0] identifiers = user_msg.message_pieces[0].converter_identifiers @@ -582,17 +588,19 @@ async def fake_save_audio(audio_bytes, **_): return f"/tmp/audio_{len(saved_calls)}.wav" vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) - vad_target._memory = MagicMock() - vad_target._memory.add_message_to_memory = MagicMock() + mock_memory = MagicMock() + mock_memory.add_message_to_memory = MagicMock() raw = b"\x05" * 96 - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=raw, - item_id="raw_x", - turn_result=RealtimeTargetResult(audio_bytes=b"\xff" * 96, transcripts=[]), - ) + with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: + mock_cm.get_memory_instance.return_value = mock_memory + await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=raw, + item_id="raw_x", + turn_result=RealtimeTargetResult(audio_bytes=b"\xff" * 96, transcripts=[]), + ) # save_audio called twice per turn: first for user audio (must be CONVERTED), then assistant audio. assert len(saved_calls) == 2 @@ -603,16 +611,18 @@ async def fake_save_audio(audio_bytes, **_): async def test_attack_result_last_response_is_final_assistant_text_piece(vad_target): """AttackResult.last_response must point at the last assistant message's first piece (text).""" attack = BargeInAttack(objective_target=vad_target) - vad_target._memory = MagicMock() - vad_target._memory.add_message_to_memory = MagicMock() - - result = await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_lr", - turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["final answer"]), - ) + mock_memory = MagicMock() + mock_memory.add_message_to_memory = MagicMock() + + with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: + mock_cm.get_memory_instance.return_value = mock_memory + result = await _drive_one_audio_turn( + attack, + vad_target, + raw_chunk=b"\x00" * 96, + item_id="raw_lr", + turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["final answer"]), + ) assert result.last_response is not None assert result.last_response.converted_value_data_type == "text" diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 19c2e1c5a5..e63f10646d 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -37,7 +37,7 @@ async def test_connect_success(target): mock_client.realtime.connect.return_value.__aenter__ = AsyncMock(return_value=mock_connection) with patch.object(target, "_get_openai_client", return_value=mock_client): - connection = await target.connect(conversation_id="test_conv") + connection = await target.connect_async(conversation_id="test_conv") assert connection == mock_connection mock_client.realtime.connect.assert_called_once_with(model="test") await target.cleanup_target() From 892beb5ec2affa6f7a1d4386720dfaaa82734995 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 20 May 2026 14:17:07 -0400 Subject: [PATCH 13/47] Remove redundant response.cancel (server auto-cancels on speech detection) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/openai_realtime_target.py | 12 +++---- .../target/test_realtime_target.py | 34 +++++++++---------- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 530c35f76f..5e3f47d6fc 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -1112,19 +1112,17 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> async def _cancel(self, *, state: _RealtimeTurnState) -> None: """ - Send ``response.cancel`` + ``conversation.item.truncate`` for the in-flight response. + Truncate the in-flight response's conversation item to what was actually delivered. - Marks ``state.interrupted = True`` even when either wire call fails. + The server auto-cancels the response when it detects new speech, so we only need to + trim the conversation history to match the audio we received. + + Marks ``state.interrupted = True`` even when the truncate call fails. Does not resolve ``state.completion``; the caller (``_route_event``) does that. Args: state (_RealtimeTurnState): The turn whose response should be cancelled. """ - if state.last_response_id is not None: - try: - await self._connection.response.cancel(response_id=state.last_response_id) - except Exception as e: - logger.debug(f"response.cancel raised for {state.last_response_id} (likely cancelled server-side): {e}") if state.current_item_id is not None: # PCM16 @ 24 kHz: 48 bytes per millisecond. audio_end_ms = len(state.delivered_audio) // 48 diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index e63f10646d..2854ba6c5e 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -45,7 +45,7 @@ async def test_connect_success(target): async def test_send_prompt_async(target): # Mock the necessary methods - target.connect = AsyncMock(return_value=AsyncMock()) + target.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"file", transcripts=["hello"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -80,7 +80,7 @@ async def test_send_prompt_async(target): async def test_send_prompt_async_propagates_interrupted_to_metadata(target): """When a turn result carries interrupted=True, both response pieces' metadata must reflect it.""" - target.connect = AsyncMock(return_value=AsyncMock()) + target.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() interrupted_result = RealtimeTargetResult(audio_bytes=b"partial", transcripts=["hi"], interrupted=True) target.send_text_async = AsyncMock(return_value=("partial.wav", interrupted_result)) @@ -106,7 +106,7 @@ async def test_send_prompt_async_propagates_interrupted_to_metadata(target): async def test_send_prompt_async_omits_interrupted_metadata_when_not_set(target): """A non-interrupted result must not write an interrupted key to MessagePiece metadata.""" - target.connect = AsyncMock(return_value=AsyncMock()) + target.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() normal_result = RealtimeTargetResult(audio_bytes=b"full", transcripts=["hi"]) target.send_text_async = AsyncMock(return_value=("full.wav", normal_result)) @@ -183,7 +183,7 @@ async def test_get_system_prompt_empty_conversation(target): async def test_multiple_websockets_created_for_multiple_conversations(target): # Mock the necessary methods - target.connect = AsyncMock(return_value=AsyncMock()) + target.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"event1", transcripts=["event2"]) target.send_text_async = AsyncMock(return_value=("output_audio_path", result)) @@ -406,7 +406,7 @@ async def test_multi_turn_reuses_connection(target): This ensures that the server-side conversation context is preserved. """ mock_connection = AsyncMock() - target.connect = AsyncMock(return_value=mock_connection) + target.connect_async = AsyncMock(return_value=mock_connection) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"audio", transcripts=["response"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -436,7 +436,7 @@ async def test_multi_turn_reuses_connection(target): await target.send_prompt_async(message=Message(message_pieces=[message_piece_2])) # Connection should only be created once for the conversation - target.connect.assert_called_once_with(conversation_id=conversation_id) + target.connect_async.assert_called_once_with(conversation_id=conversation_id) target.send_config.assert_called_once() # Both turns should use the same connection @@ -714,8 +714,8 @@ def _make_dispatcher(connection): return _OpenAIRealtimeDispatcher(connection=connection) -async def test_cancel_calls_response_cancel_with_state_response_id(): - """_cancel must forward state.last_response_id to response.cancel.""" +async def test_cancel_does_not_send_response_cancel(): + """_cancel must NOT send response.cancel (server auto-cancels on speech detection).""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) state = _turn_state(response_id="resp_42") @@ -723,7 +723,7 @@ async def test_cancel_calls_response_cancel_with_state_response_id(): await dispatcher._cancel(state=state) - connection.response.cancel.assert_awaited_once_with(response_id="resp_42") + connection.response.cancel.assert_not_awaited() async def test_cancel_truncates_to_delivered_audio_ms(): @@ -744,20 +744,18 @@ async def test_cancel_truncates_to_delivered_audio_ms(): assert state.interrupted is True -async def test_cancel_marks_interrupted_even_when_response_cancel_raises(caplog): - """A failed response.cancel must log at debug (likely server-side cancelled) and still flip state.interrupted.""" +async def test_cancel_only_truncates_no_response_cancel(caplog): + """_cancel must only truncate, not send response.cancel (server handles cancellation).""" connection = AsyncMock() - connection.response.cancel.side_effect = RuntimeError("boom") dispatcher = _make_dispatcher(connection) - state = _turn_state() + state = _turn_state(item_id="item_1") + state.delivered_audio.extend(b"\x00" * 4800) - with caplog.at_level("DEBUG"): - await dispatcher._cancel(state=state) + await dispatcher._cancel(state=state) assert state.interrupted is True - # Truncate must still have been attempted despite the cancel failure. connection.conversation.item.truncate.assert_awaited_once() - assert any("response.cancel raised" in record.message and record.levelname == "DEBUG" for record in caplog.records) + connection.response.cancel.assert_not_awaited() async def test_cancel_marks_interrupted_when_truncate_raises(caplog): @@ -827,7 +825,7 @@ async def test_route_event_speech_started_while_responding_cancels_and_resolves_ ) await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) - connection.response.cancel.assert_awaited_once_with(response_id="r1") + connection.response.cancel.assert_not_awaited() connection.conversation.item.truncate.assert_awaited_once_with( item_id="i1", content_index=0, From 7e122e3ea467b24b4a4cf0c986fa6f50927ae31f Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 20 May 2026 15:26:41 -0400 Subject: [PATCH 14/47] Trim verbose docstrings to match codebase conventions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 54 +++-------------- pyrit/prompt_target/common/realtime_audio.py | 64 ++------------------ 2 files changed, 13 insertions(+), 105 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index b624a25201..12c83d3a34 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -1,17 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -""" -Streaming barge-in attack over realtime audio targets. - -Pushes user audio chunks into a continuous Realtime API session, lets server VAD -detect turn boundaries, runs configured audio converters against the buffered raw -audio for each detected turn, swaps the server's raw user item for the converted -audio, manually fires ``response.create``, and observes server-side interruption -when new user audio arrives while the assistant is still speaking. Per-turn -``Message`` pairs are written to ``CentralMemory``; interrupted turns carry -``prompt_metadata["interrupted"] = True`` on both assistant pieces. -""" +"""Streaming barge-in attack over realtime audio targets.""" from __future__ import annotations @@ -57,19 +47,7 @@ @dataclass class BargeInAttackContext(AttackContext[AttackParamsT]): - """ - Context for a streaming barge-in attack. - - Beyond the standard ``AttackContext`` fields, callers supply: - - Attributes: - conversation_id: Identifier shared by all turns persisted from this session. - audio_chunks: Async iterator yielding raw PCM16 mono @ 24 kHz chunks. Drives - the cadence of input; the attack pushes each chunk as it arrives. When - the iterator exhausts, the attack waits briefly for any in-flight turn - to resolve, then tears down. - system_prompt: System prompt to apply to the realtime session. - """ + """Context for a streaming barge-in attack with audio chunk source and session config.""" conversation_id: str = field(default_factory=lambda: str(uuid.uuid4())) audio_chunks: AsyncIterator[bytes] | None = None @@ -108,19 +86,10 @@ def __init__( Initialize the streaming barge-in attack. Args: - objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` - in its capabilities (validated by ``TARGET_REQUIREMENTS``); the - server-VAD configuration check happens lazily when the streaming - session config is sent. - attack_converter_config: Converter configurations applied to each - committed user turn via ``PromptNormalizer.convert_audio_async``. - ``request_converters`` runs on the raw user audio post-commit; - ``response_converters`` is currently unused (streaming responses - are surfaced raw to the caller). Defaults to no converters. - prompt_normalizer: Optional normalizer override. Defaults to a fresh - ``PromptNormalizer`` instance. - params_type: Attack parameter dataclass type. Defaults to - ``AttackParameters``. + objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` capability. + attack_converter_config: Converters applied to each committed user turn. + prompt_normalizer: Optional normalizer override. + params_type: Attack parameter dataclass type. """ super().__init__( objective_target=objective_target, @@ -307,17 +276,10 @@ async def _persist_turn_async( turn_result: RealtimeTargetResult, ) -> Message: """ - Persist the user+assistant ``Message`` pair for one completed turn to ``CentralMemory``. - - Saves user audio (whichever PCM the model actually heard — converted or raw) - and the assistant response audio to disk, builds a one-piece user ``Message`` - and a two-piece assistant ``Message`` (text transcript + audio_path), stamps - ``converter_identifiers`` on the user piece, and sets - ``prompt_metadata["interrupted"] = True`` on both assistant pieces when the - turn was cut short by server-side barge-in. + Persist the user+assistant Message pair for one completed turn to CentralMemory. Returns: - The assistant ``Message`` so callers can surface it as ``last_response``. + The assistant Message so callers can surface it as ``last_response``. """ user_audio_path = await target.save_audio( user_audio_pcm, diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 218e5e4552..a2f76060e2 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -16,16 +16,7 @@ @dataclass(frozen=True) class ServerVadConfig: - """ - Server-side voice activity detection (VAD) tuning for realtime audio targets. - - Attributes: - threshold: VAD activation threshold (0.0 to 1.0). Defaults to 0.4. - prefix_padding_ms: Milliseconds of pre-roll audio retained before detected speech. - Defaults to 200. - silence_duration_ms: Milliseconds of silence required to detect end-of-turn. - Defaults to 1500. - """ + """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" threshold: float = 0.4 prefix_padding_ms: int = 200 @@ -48,18 +39,7 @@ def __post_init__(self) -> None: @dataclass class RealtimeTargetResult: - """ - Result of a Realtime API turn, containing the audio and transcripts actually delivered. - - Attributes: - audio_bytes: Raw PCM16 audio returned by the assistant. May be partial if the - turn was interrupted. - transcripts: Transcript deltas captured during the turn. - interrupted: True if the turn was cut short by server VAD detecting new user - speech during the assistant's response. Always False on the atomic - ``send_audio_async`` / ``send_text_async`` paths; populated in the - streaming-session path when a barge-in is detected. - """ + """Result of a Realtime API turn: delivered audio, transcripts, and interruption status.""" audio_bytes: bytes = b"" transcripts: list[str] = field(default_factory=list) @@ -72,26 +52,7 @@ def flatten_transcripts(self) -> str: @dataclass class _RealtimeTurnState: - """ - Mutable per-turn state assembled by the dispatcher and read by the cancel path. - - The dispatcher routes incoming events into this object during a turn; the - completion future is resolved by the dispatcher with a ``RealtimeTargetResult`` - snapshotted from these fields once the turn ends normally or via interruption. - - Attributes: - completion: Future resolved with the assembled result when the turn ends. - is_responding: True between ``response.created`` and ``response.done`` for - the active response. - delivered_audio: Assistant audio bytes accumulated from ``response.audio.delta``. - Uses ``bytearray`` so deltas append in place rather than reallocating. - delivered_transcripts: Transcript deltas accumulated from ``response.audio_transcript.delta``. - current_item_id: Item id of the assistant response currently being streamed. - None until ``response.output_item.added`` fires. - last_response_id: Response id of the in-flight response. None until - ``response.created`` fires. - interrupted: Set True when the cancel/truncate path runs. - """ + """Mutable per-turn state assembled by the dispatcher from incoming events.""" completion: asyncio.Future[RealtimeTargetResult] is_responding: bool = False @@ -104,15 +65,7 @@ class _RealtimeTurnState: @dataclass(frozen=True) class _CommittedEvent: - """ - Event-shaped payload passed to ``on_user_audio_committed`` callbacks. - - Attributes: - item_id: Server-assigned id of the conversation item that was committed. - Used to delete the raw item before replaying converted audio. - audio_start_ms: Optional audio start timestamp from the underlying server - event, when reported by the provider. May be useful for analytics. - """ + """Payload passed to ``on_user_audio_committed`` callbacks when server VAD commits.""" item_id: str audio_start_ms: int | None = None @@ -122,14 +75,7 @@ class _RealtimeEventDispatcher(ABC): """ Owns a realtime connection's event stream and routes events to the active turn. - One long-lived async task per websocket connection. The dispatcher is the only - code that consumes the connection's async iterator; turn-aware senders register - a ``_RealtimeTurnState`` and ``await state.completion`` while the dispatcher - mutates the state in response to incoming events. - - Provider-specific event names and cancel wire calls are isolated to the - abstract methods so each realtime provider (OpenAI, Gemini Live, etc.) supplies - only its routing and cancel logic. + Provider-specific event routing and cancel logic are isolated to the abstract methods. """ def __init__( From b1abe9c8ff66bbe4db89d7b0f9a9d88b702cc11c Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 20 May 2026 17:43:28 -0400 Subject: [PATCH 15/47] Enable supports_streaming_barge_in in permissive probe configuration --- pyrit/prompt_target/common/discover_target_capabilities.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyrit/prompt_target/common/discover_target_capabilities.py b/pyrit/prompt_target/common/discover_target_capabilities.py index 859d07d428..b7ba4a5fe5 100644 --- a/pyrit/prompt_target/common/discover_target_capabilities.py +++ b/pyrit/prompt_target/common/discover_target_capabilities.py @@ -149,6 +149,7 @@ def _permissive_configuration( supports_json_output=True, supports_editable_history=True, supports_system_prompt=True, + supports_streaming_barge_in=True, input_modalities=merged_modalities, ) # Rebuild a fresh configuration from the instance's native capabilities so From 2cdfe14348af2486037a7e0d83a170650f365bbc Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 12:36:03 -0400 Subject: [PATCH 16/47] Replace assert with explicit ValueError in BargeInAttack Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 12c83d3a34..b2a5972ac4 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -138,9 +138,13 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR Returns: An ``AttackResult`` capturing the last assistant turn (if any) and the number of completed turns. + + Raises: + ValueError: If ``context.audio_chunks`` is ``None``. """ target = cast("RealtimeTarget", self._objective_target) - assert context.audio_chunks is not None # validated upstream + if context.audio_chunks is None: + raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") connection = await target.connect_async(conversation_id=context.conversation_id) raw_buffer = bytearray() From aa9feeec99b1084c0e971fc9825137b371ed2359 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 13:15:35 -0400 Subject: [PATCH 17/47] Extract audio conversion into a target-owned AudioStreamNormalizer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 7 +- pyrit/prompt_normalizer/__init__.py | 6 +- .../audio_stream_normalizer.py | 107 +++++++++++++++++ pyrit/prompt_normalizer/prompt_normalizer.py | 73 ------------ .../openai/openai_realtime_target.py | 14 ++- .../attack/streaming/test_barge_in.py | 17 ++- .../test_audio_stream_normalizer.py | 110 ++++++++++++++++++ .../test_prompt_normalizer.py | 98 ---------------- 8 files changed, 244 insertions(+), 188 deletions(-) create mode 100644 pyrit/prompt_normalizer/audio_stream_normalizer.py create mode 100644 tests/unit/prompt_normalizer/test_audio_stream_normalizer.py diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index b2a5972ac4..607f4c2686 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -24,7 +24,6 @@ MessagePiece, construct_response_from_request, ) -from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -79,7 +78,6 @@ def __init__( *, objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] attack_converter_config: AttackConverterConfig | None = None, - prompt_normalizer: PromptNormalizer | None = None, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ @@ -87,8 +85,8 @@ def __init__( Args: objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` capability. + Audio normalization is delegated to ``objective_target.audio_normalizer``. attack_converter_config: Converters applied to each committed user turn. - prompt_normalizer: Optional normalizer override. params_type: Attack parameter dataclass type. """ super().__init__( @@ -100,7 +98,6 @@ def __init__( attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters - self._prompt_normalizer = prompt_normalizer or PromptNormalizer() def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ @@ -165,7 +162,7 @@ async def on_committed(event: _CommittedEvent) -> None: raw_buffer.clear() try: - converted_pcm, applied_identifiers = await self._prompt_normalizer.convert_audio_async( + converted_pcm, applied_identifiers = await target.audio_normalizer.normalize_async( pcm_bytes=snapshot, sample_rate=_REALTIME_SAMPLE_RATE_HZ, converter_configurations=self._request_converters, diff --git a/pyrit/prompt_normalizer/__init__.py b/pyrit/prompt_normalizer/__init__.py index fa030605f7..dd1179b8b4 100644 --- a/pyrit/prompt_normalizer/__init__.py +++ b/pyrit/prompt_normalizer/__init__.py @@ -8,12 +8,14 @@ including converter configurations and request handling. """ +from pyrit.prompt_normalizer.audio_stream_normalizer import AudioStreamNormalizer from pyrit.prompt_normalizer.normalizer_request import NormalizerRequest from pyrit.prompt_normalizer.prompt_converter_configuration import PromptConverterConfiguration from pyrit.prompt_normalizer.prompt_normalizer import PromptNormalizer __all__ = [ - "PromptNormalizer", - "PromptConverterConfiguration", + "AudioStreamNormalizer", "NormalizerRequest", + "PromptConverterConfiguration", + "PromptNormalizer", ] diff --git a/pyrit/prompt_normalizer/audio_stream_normalizer.py b/pyrit/prompt_normalizer/audio_stream_normalizer.py new file mode 100644 index 0000000000..b8de3ae1f0 --- /dev/null +++ b/pyrit/prompt_normalizer/audio_stream_normalizer.py @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Normalizer for streaming audio: raw PCM in, converter-transformed PCM out.""" + +from __future__ import annotations + +import os +import tempfile +import wave +from typing import TYPE_CHECKING + +from pyrit.exceptions import ( + ComponentRole, + execution_context, + get_execution_context, +) + +if TYPE_CHECKING: + from pyrit.identifiers import ComponentIdentifier + from pyrit.prompt_normalizer.prompt_converter_configuration import ( + PromptConverterConfiguration, + ) + + +class AudioStreamNormalizer: + """ + Normalizer that adapts raw PCM audio for streaming targets. + + Streaming attacks hold mid-turn PCM rather than a ``Message``; this class bridges + raw PCM to PyRIT's file-based converter ecosystem by writing the audio to a + temporary WAV, running converters via ``convert_tokens_async`` with + ``input_type="audio_path"``, and reading the resulting PCM back. Subclass to + customize bridging behavior (alternate format adaptation, parallelism, etc.). + """ + + def __init__(self, *, start_token: str = "⟪", end_token: str = "⟫") -> None: + """Initialize with optional token delimiters passed through to converters.""" + self._start_token = start_token + self._end_token = end_token + + async def normalize_async( + self, + *, + pcm_bytes: bytes, + sample_rate: int, + converter_configurations: list[PromptConverterConfiguration], + ) -> tuple[bytes, list[ComponentIdentifier]]: + """ + Run ``converter_configurations`` against ``pcm_bytes`` via a temp WAV bridge. + + Args: + pcm_bytes: Raw PCM16 mono audio. + sample_rate: Sample rate in Hz. + converter_configurations: Same shape consumed by ``PromptNormalizer.convert_values``. + + Returns: + ``(converted_pcm, identifiers_that_ran)``. + + Raises: + ValueError: If converter output is not mono PCM16 at ``sample_rate``. + """ + if not converter_configurations or not pcm_bytes: + return pcm_bytes, [] + + identifiers: list[ComponentIdentifier] = [] + + with tempfile.TemporaryDirectory() as tmpdir: + current_path = os.path.join(tmpdir, "streaming_input.wav") + with wave.open(current_path, "wb") as wav_out: + wav_out.setnchannels(1) + wav_out.setsampwidth(2) + wav_out.setframerate(sample_rate) + wav_out.writeframes(pcm_bytes) + + for config in converter_configurations: + if config.prompt_data_types_to_apply and "audio_path" not in config.prompt_data_types_to_apply: + continue + + for converter in config.converters: + outer_context = get_execution_context() + with execution_context( + component_role=ComponentRole.CONVERTER, + attack_strategy_name=outer_context.attack_strategy_name if outer_context else None, + attack_identifier=outer_context.attack_identifier if outer_context else None, + component_identifier=converter.get_identifier(), + objective_target_conversation_id=( + outer_context.objective_target_conversation_id if outer_context else None + ), + ): + result = await converter.convert_tokens_async( + prompt=current_path, + input_type="audio_path", + start_token=self._start_token, + end_token=self._end_token, + ) + current_path = result.output_text + identifiers.append(converter.get_identifier()) + + with wave.open(current_path, "rb") as wav_in: + if wav_in.getnchannels() != 1 or wav_in.getsampwidth() != 2 or wav_in.getframerate() != sample_rate: + raise ValueError( + "Converter output incompatible with streaming target: " + f"expected mono PCM16 @ {sample_rate} Hz, got channels={wav_in.getnchannels()} " + f"sampwidth={wav_in.getsampwidth()} rate={wav_in.getframerate()}." + ) + return wav_in.readframes(wav_in.getnframes()), identifiers diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 8544e0f477..528782dee6 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -4,10 +4,7 @@ import asyncio import copy import logging -import os -import tempfile import traceback -import wave from typing import Any, Optional from uuid import uuid4 @@ -299,76 +296,6 @@ async def convert_values( piece.converted_value = converted_text piece.converted_value_data_type = converted_text_data_type - async def convert_audio_async( - self, - *, - pcm_bytes: bytes, - sample_rate: int, - converter_configurations: list[PromptConverterConfiguration], - ) -> tuple[bytes, list[ComponentIdentifier]]: - """ - Apply audio converter configurations to raw PCM and return converted PCM with identifiers that ran. - - For streaming attacks that hold raw PCM mid-turn rather than a ``Message``. Respects - ``prompt_data_types_to_apply``; ``indexes_to_apply`` is ignored. - - Args: - pcm_bytes (bytes): Raw PCM16 mono audio. - sample_rate (int): Sample rate in Hz. - converter_configurations (list[PromptConverterConfiguration]): Same shape used by ``convert_values``. - - Returns: - tuple[bytes, list[ComponentIdentifier]]: ``(converted_pcm, identifiers_that_ran)``. - - Raises: - ValueError: If converter output is not mono PCM16 at ``sample_rate``. - """ - if not converter_configurations or not pcm_bytes: - return pcm_bytes, [] - - identifiers: list[ComponentIdentifier] = [] - - with tempfile.TemporaryDirectory() as tmpdir: - current_path = os.path.join(tmpdir, "streaming_input.wav") - with wave.open(current_path, "wb") as wav_out: - wav_out.setnchannels(1) - wav_out.setsampwidth(2) - wav_out.setframerate(sample_rate) - wav_out.writeframes(pcm_bytes) - - for config in converter_configurations: - if config.prompt_data_types_to_apply and "audio_path" not in config.prompt_data_types_to_apply: - continue - - for converter in config.converters: - outer_context = get_execution_context() - with execution_context( - component_role=ComponentRole.CONVERTER, - attack_strategy_name=outer_context.attack_strategy_name if outer_context else None, - attack_identifier=outer_context.attack_identifier if outer_context else None, - component_identifier=converter.get_identifier(), - objective_target_conversation_id=( - outer_context.objective_target_conversation_id if outer_context else None - ), - ): - result = await converter.convert_tokens_async( - prompt=current_path, - input_type="audio_path", - start_token=self._start_token, - end_token=self._end_token, - ) - current_path = result.output_text - identifiers.append(converter.get_identifier()) - - with wave.open(current_path, "rb") as wav_in: - if wav_in.getnchannels() != 1 or wav_in.getsampwidth() != 2 or wav_in.getframerate() != sample_rate: - raise ValueError( - "Converter output incompatible with streaming target: " - f"expected mono PCM16 @ {sample_rate} Hz, got channels={wav_in.getnchannels()} " - f"sampwidth={wav_in.getsampwidth()} rate={wav_in.getframerate()}." - ) - return wav_in.readframes(wav_in.getnframes()), identifiers - async def _calc_hash(self, request: Message) -> None: """Add a request to the memory.""" tasks = [asyncio.create_task(piece.set_sha256_values_async()) for piece in request.message_pieces] diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 844e856444..fe9bd6fe3b 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -7,7 +7,7 @@ import re import wave from collections.abc import Callable, Coroutine -from typing import Any, Literal, Optional +from typing import TYPE_CHECKING, Any, Literal, Optional from openai import AsyncOpenAI @@ -34,6 +34,9 @@ from pyrit.prompt_target.common.utils import limit_requests_per_minute from pyrit.prompt_target.openai.openai_target import OpenAITarget +if TYPE_CHECKING: + from pyrit.prompt_normalizer import AudioStreamNormalizer + logger = logging.getLogger(__name__) # Voices supported by the OpenAI Realtime API. @@ -83,6 +86,7 @@ def __init__( existing_convo: Optional[dict[str, Any]] = None, custom_configuration: Optional[TargetConfiguration] = None, server_vad: bool | ServerVadConfig = False, + audio_normalizer: Optional["AudioStreamNormalizer"] = None, **kwargs: Any, ) -> None: """ @@ -111,6 +115,10 @@ def __init__( ``True`` enables VAD with default tuning. Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming/interruption plumbing arrives in subsequent changes; this currently only affects the emitted session config. + audio_normalizer (AudioStreamNormalizer, Optional): Normalizer applied to raw PCM + mid-turn before it is sent back into the conversation. Defaults to a stock + ``AudioStreamNormalizer`` that bridges PCM to PyRIT's file-based converter + pipeline. Override to plug in custom format adaptation. **kwargs: Additional keyword arguments passed to the parent OpenAITarget class. httpx_client_kwargs (dict, Optional): Additional kwargs to be passed to the ``httpx.AsyncClient()`` constructor. For example, to specify a 3 minute timeout: ``httpx_client_kwargs={"timeout": 180}`` @@ -128,6 +136,10 @@ def __init__( else: self._server_vad = None + from pyrit.prompt_normalizer import AudioStreamNormalizer + + self.audio_normalizer: AudioStreamNormalizer = audio_normalizer or AudioStreamNormalizer() + def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" self.endpoint_environment_variable = "OPENAI_REALTIME_ENDPOINT" diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 2f902e12a1..569e80a0ae 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -18,7 +18,7 @@ from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters from pyrit.identifiers import ComponentIdentifier from pyrit.models import AttackOutcome -from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer +from pyrit.prompt_normalizer import AudioStreamNormalizer, PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, @@ -394,14 +394,14 @@ async def chunks_then_two_commits() -> AsyncIterator[bytes]: assert insert_calls[1].kwargs["pcm_bytes"] == bytes((b + 1) & 0xFF for b in (b"\x02" * 96)) -async def test_perform_async_uses_injected_normalizer(vad_target): - """The attack must delegate audio conversion to its injected PromptNormalizer.""" - fake_normalizer = MagicMock(spec=PromptNormalizer) - fake_normalizer.convert_audio_async = AsyncMock(return_value=(b"\xff" * 96, [])) +async def test_perform_async_uses_target_audio_normalizer(vad_target): + """The attack must delegate audio conversion to the target's audio_normalizer.""" + fake_normalizer = MagicMock(spec=AudioStreamNormalizer) + fake_normalizer.normalize_async = AsyncMock(return_value=(b"\xff" * 96, [])) + vad_target.audio_normalizer = fake_normalizer attack = BargeInAttack( objective_target=vad_target, attack_converter_config=_converter_config([_make_audio_converter(lambda pcm: pcm)]), - prompt_normalizer=fake_normalizer, ) connection = _mock_connection() vad_target.connect_async = AsyncMock(return_value=connection) @@ -435,11 +435,10 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): await attack._perform_async(context=ctx) - fake_normalizer.convert_audio_async.assert_awaited_once() - kwargs = fake_normalizer.convert_audio_async.call_args.kwargs + fake_normalizer.normalize_async.assert_awaited_once() + kwargs = fake_normalizer.normalize_async.call_args.kwargs assert kwargs["pcm_bytes"] == raw assert kwargs["sample_rate"] == 24000 - # Converted audio (returned by mock) should reach insert_user_audio_async. vad_target.insert_user_audio_async.assert_awaited_once() assert vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] == b"\xff" * 96 diff --git a/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py b/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py new file mode 100644 index 0000000000..1979bbfe26 --- /dev/null +++ b/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for ``AudioStreamNormalizer``.""" + +import os +import tempfile +import wave +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from pyrit.identifiers import ComponentIdentifier +from pyrit.prompt_normalizer import AudioStreamNormalizer +from pyrit.prompt_normalizer.prompt_converter_configuration import ( + PromptConverterConfiguration, +) + + +def _make_audio_converter(transformer, *, output_sample_rate=24000, identifier_name="MockAudioConverter"): + """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" + converter = MagicMock() + converter.get_identifier = MagicMock( + return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), + ) + + async def _convert(*, prompt, input_type, start_token=None, end_token=None): + assert input_type == "audio_path" + with wave.open(prompt, "rb") as wf_in: + pcm = wf_in.readframes(wf_in.getnframes()) + new_pcm = transformer(pcm) + out_dir = tempfile.mkdtemp() + out_path = os.path.join(out_dir, "out.wav") + with wave.open(out_path, "wb") as wf_out: + wf_out.setnchannels(1) + wf_out.setsampwidth(2) + wf_out.setframerate(output_sample_rate) + wf_out.writeframes(new_pcm) + result = MagicMock() + result.output_text = out_path + return result + + converter.convert_tokens_async = AsyncMock(side_effect=_convert) + return converter + + +async def test_normalize_async_no_configurations_returns_input(): + normalizer = AudioStreamNormalizer() + pcm = b"\xaa" * 1024 + out, ids = await normalizer.normalize_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=[]) + assert out == pcm + assert ids == [] + + +async def test_normalize_async_empty_pcm_returns_input(): + normalizer = AudioStreamNormalizer() + bump = _make_audio_converter(lambda pcm: pcm) + out, ids = await normalizer.normalize_async( + pcm_bytes=b"", + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump]), + ) + assert out == b"" + assert ids == [] + + +async def test_normalize_async_chains_converters_and_returns_identifiers(): + normalizer = AudioStreamNormalizer() + bump_a = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + bump_b = _make_audio_converter(lambda pcm: bytes((b + 2) & 0xFF for b in pcm)) + + out, ids = await normalizer.normalize_async( + pcm_bytes=b"\x00\x10\x20\x30", + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump_a, bump_b]), + ) + + assert out == b"\x03\x13\x23\x33" + assert len(ids) == 2 # one identifier per converter that ran + + +async def test_normalize_async_respects_data_type_filter(): + """A configuration with prompt_data_types_to_apply not including audio_path must be skipped.""" + normalizer = AudioStreamNormalizer() + skipped = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) + applied = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) + + configs = [ + PromptConverterConfiguration(converters=[skipped], prompt_data_types_to_apply=["text"]), + PromptConverterConfiguration(converters=[applied], prompt_data_types_to_apply=["audio_path"]), + ] + out, ids = await normalizer.normalize_async( + pcm_bytes=b"\x00\x10", sample_rate=24000, converter_configurations=configs + ) + + # Only the audio_path-applicable converter ran (+1 not +9). + assert out == b"\x01\x11" + assert len(ids) == 1 + + +async def test_normalize_async_rejects_mismatched_sample_rate(): + """Converter output at a different sample rate must raise ValueError.""" + normalizer = AudioStreamNormalizer() + bad = _make_audio_converter(lambda pcm: pcm, output_sample_rate=16000) + with pytest.raises(ValueError, match="incompatible"): + await normalizer.normalize_async( + pcm_bytes=b"\x00" * 1024, + sample_rate=24000, + converter_configurations=PromptConverterConfiguration.from_converters(converters=[bad]), + ) diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index ce0befa515..07231243d3 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -3,7 +3,6 @@ import os import tempfile -import wave from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -17,7 +16,6 @@ execution_context, get_execution_context, ) -from pyrit.identifiers import ComponentIdentifier from pyrit.memory import CentralMemory from pyrit.models import ( Message, @@ -631,99 +629,3 @@ async def test_add_prepended_conversation_to_memory(mock_memory_instance): assert result[0].message_pieces[0].conversation_id == conv_id assert result[0].message_pieces[0].attack_identifier == attack_id mock_memory_instance.add_message_to_memory.assert_called_once() - - -# Placeholder for convert_audio_async tests - - -def _make_audio_converter(transformer, *, output_sample_rate=24000, identifier_name="MockAudioConverter"): - """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" - converter = MagicMock() - converter.get_identifier = MagicMock( - return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), - ) - - async def _convert(*, prompt, input_type, start_token=None, end_token=None): - assert input_type == "audio_path" - with wave.open(prompt, "rb") as wf_in: - pcm = wf_in.readframes(wf_in.getnframes()) - new_pcm = transformer(pcm) - out_dir = tempfile.mkdtemp() - out_path = os.path.join(out_dir, "out.wav") - with wave.open(out_path, "wb") as wf_out: - wf_out.setnchannels(1) - wf_out.setsampwidth(2) - wf_out.setframerate(output_sample_rate) - wf_out.writeframes(new_pcm) - result = MagicMock() - result.output_text = out_path - return result - - converter.convert_tokens_async = AsyncMock(side_effect=_convert) - return converter - - -async def test_convert_audio_async_no_configurations_returns_input(sqlite_instance): - normalizer = PromptNormalizer() - pcm = b"\xaa" * 1024 - out, ids = await normalizer.convert_audio_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=[]) - assert out == pcm - assert ids == [] - - -async def test_convert_audio_async_empty_pcm_returns_input(sqlite_instance): - normalizer = PromptNormalizer() - bump = _make_audio_converter(lambda pcm: pcm) - out, ids = await normalizer.convert_audio_async( - pcm_bytes=b"", - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump]), - ) - assert out == b"" - assert ids == [] - - -async def test_convert_audio_async_chains_converters_and_returns_identifiers(sqlite_instance): - normalizer = PromptNormalizer() - bump_a = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - bump_b = _make_audio_converter(lambda pcm: bytes((b + 2) & 0xFF for b in pcm)) - - out, ids = await normalizer.convert_audio_async( - pcm_bytes=b"\x00\x10\x20\x30", - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump_a, bump_b]), - ) - - assert out == b"\x03\x13\x23\x33" - assert len(ids) == 2 # one identifier per converter that ran - - -async def test_convert_audio_async_respects_data_type_filter(sqlite_instance): - """A configuration with prompt_data_types_to_apply not including audio_path must be skipped.""" - normalizer = PromptNormalizer() - skipped = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) - applied = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - - configs = [ - PromptConverterConfiguration(converters=[skipped], prompt_data_types_to_apply=["text"]), - PromptConverterConfiguration(converters=[applied], prompt_data_types_to_apply=["audio_path"]), - ] - out, ids = await normalizer.convert_audio_async( - pcm_bytes=b"\x00\x10", sample_rate=24000, converter_configurations=configs - ) - - # Only the audio_path-applicable converter ran (+1 not +9). - assert out == b"\x01\x11" - assert len(ids) == 1 - - -async def test_convert_audio_async_rejects_mismatched_sample_rate(sqlite_instance): - """Converter output at a different sample rate must raise ValueError.""" - normalizer = PromptNormalizer() - bad = _make_audio_converter(lambda pcm: pcm, output_sample_rate=16000) - with pytest.raises(ValueError, match="incompatible"): - await normalizer.convert_audio_async( - pcm_bytes=b"\x00" * 1024, - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bad]), - ) From 32347fde882615dfd69a5c8cc882afb12aa89041 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 13:38:58 -0400 Subject: [PATCH 18/47] Decompose _perform_async into named per-turn helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 183 ++++++++++++++------ 1 file changed, 130 insertions(+), 53 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 607f4c2686..c2d508b60a 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -53,6 +53,17 @@ class BargeInAttackContext(AttackContext[AttackParamsT]): system_prompt: str = "You are a helpful AI assistant" +@dataclass +class _BargeInRunState: + """Mutable per-session state accumulated as turns commit.""" + + raw_buffer: bytearray = field(default_factory=bytearray) + turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + last_assistant_message: Message | None = None + executed_turns: int = 0 + turn_tasks: list[asyncio.Task[None]] = field(default_factory=list) + + class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): """ Streaming attack that drives a Realtime API session with server VAD + barge-in. @@ -144,54 +155,21 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") connection = await target.connect_async(conversation_id=context.conversation_id) - raw_buffer = bytearray() - turn_lock = asyncio.Lock() - last_assistant_message: Message | None = None - executed_turns = 0 - turn_tasks: list[asyncio.Task[None]] = [] + state = _BargeInRunState() async def on_committed(event: _CommittedEvent) -> None: - """Convert-on-commit dance: snapshot raw audio → run converters → swap → request response → persist.""" - nonlocal last_assistant_message, executed_turns current_task = asyncio.current_task() if current_task is not None: - turn_tasks.append(current_task) + state.turn_tasks.append(current_task) try: - async with turn_lock: - snapshot = bytes(raw_buffer) - raw_buffer.clear() - - try: - converted_pcm, applied_identifiers = await target.audio_normalizer.normalize_async( - pcm_bytes=snapshot, - sample_rate=_REALTIME_SAMPLE_RATE_HZ, - converter_configurations=self._request_converters, - ) - except Exception: - logger.exception("Audio converters failed; dropping turn.") - return - - using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot - if using_converted_audio: - await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) - try: - await target.delete_conversation_item_async(connection=connection, item_id=event.item_id) - except Exception as e: - logger.warning(f"conversation.item.delete failed for {event.item_id}: {e}") - - turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) - turn_result = await turn_future - - user_audio_pcm = converted_pcm if using_converted_audio else snapshot - assistant_message = await self._persist_turn_async( - target=target, - conversation_id=context.conversation_id, - user_audio_pcm=user_audio_pcm, - applied_converter_identifiers=applied_identifiers, - turn_result=turn_result, - ) - last_assistant_message = assistant_message - executed_turns += 1 + await self._handle_committed_turn_async( + state=state, + event=event, + target=target, + connection=connection, + dispatcher=dispatcher, + conversation_id=context.conversation_id, + ) except Exception: logger.exception("BargeInAttack turn failed in convert-on-commit handler.") @@ -205,14 +183,14 @@ async def on_committed(event: _CommittedEvent) -> None: async for chunk in context.audio_chunks: if chunk: - raw_buffer.extend(chunk) + state.raw_buffer.extend(chunk) await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) # Wait for any in-flight committed-turn tasks to finish (convert + response + # persistence), capped by a safety timeout. The chunk source must end with enough # trailing silence for server VAD's silence threshold to fire commit — otherwise # the last turn never enters the convert pipeline and there is nothing to wait on. - await self._wait_for_pending_turns_async(turn_tasks) + await self._wait_for_pending_turns_async(state.turn_tasks) finally: await dispatcher.stop() try: @@ -220,23 +198,122 @@ async def on_committed(event: _CommittedEvent) -> None: except Exception as e: logger.warning(f"Error closing streaming connection: {e}") - outcome = AttackOutcome.UNDETERMINED - outcome_reason: str | None - if executed_turns == 0: - outcome_reason = "No assistant turns completed (server VAD did not commit any user audio)" + return self._build_result(state=state, context=context) + + async def _handle_committed_turn_async( + self, + *, + state: _BargeInRunState, + event: _CommittedEvent, + target: RealtimeTarget, + connection: Any, + dispatcher: _RealtimeEventDispatcher, + conversation_id: str, + ) -> None: + """Run the convert-on-commit dance for one VAD-committed user audio turn.""" + async with state.turn_lock: + snapshot = self._snapshot_user_audio(state) + + try: + converted_pcm, applied_identifiers = await target.audio_normalizer.normalize_async( + pcm_bytes=snapshot, + sample_rate=_REALTIME_SAMPLE_RATE_HZ, + converter_configurations=self._request_converters, + ) + except Exception: + logger.exception("Audio converters failed; dropping turn.") + return + + using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot + if using_converted_audio: + await self._swap_user_audio_async( + target=target, + connection=connection, + converted_pcm=converted_pcm, + original_item_id=event.item_id, + ) + + turn_result = await self._drive_response_async(target=target, connection=connection, dispatcher=dispatcher) + + user_audio_pcm = converted_pcm if using_converted_audio else snapshot + state.last_assistant_message = await self._persist_turn_async( + target=target, + conversation_id=conversation_id, + user_audio_pcm=user_audio_pcm, + applied_converter_identifiers=applied_identifiers, + turn_result=turn_result, + ) + state.executed_turns += 1 + + def _snapshot_user_audio(self, state: _BargeInRunState) -> bytes: + """ + Snapshot the accumulated user PCM and clear the buffer for the next turn. + + Returns: + Snapshot of buffered PCM bytes prior to clearing. + """ + snapshot = bytes(state.raw_buffer) + state.raw_buffer.clear() + return snapshot + + async def _swap_user_audio_async( + self, + *, + target: RealtimeTarget, + connection: Any, + converted_pcm: bytes, + original_item_id: str, + ) -> None: + """Replace the server's originally-committed item with the converted audio.""" + await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) + try: + await target.delete_conversation_item_async(connection=connection, item_id=original_item_id) + except Exception as e: + logger.warning(f"conversation.item.delete failed for {original_item_id}: {e}") + + async def _drive_response_async( + self, + *, + target: RealtimeTarget, + connection: Any, + dispatcher: _RealtimeEventDispatcher, + ) -> RealtimeTargetResult: + """ + Trigger ``response.create`` and await the resulting turn future. + + Returns: + The completed ``RealtimeTargetResult`` for the assistant turn. + """ + turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) + return await turn_future + + def _build_result( + self, + *, + state: _BargeInRunState, + context: BargeInAttackContext[Any], + ) -> AttackResult: + """ + Assemble the final ``AttackResult`` from accumulated run state. + + Returns: + ``AttackResult`` with the last assistant message, executed turn count, and outcome reason. + """ + if state.executed_turns == 0: + outcome_reason: str | None = "No assistant turns completed (server VAD did not commit any user audio)" else: - outcome_reason = f"{executed_turns} assistant turn(s) completed; no scorer configured" + outcome_reason = f"{state.executed_turns} assistant turn(s) completed; no scorer configured" return AttackResult( conversation_id=context.conversation_id, objective=context.objective, atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()), - last_response=last_assistant_message.message_pieces[0] if last_assistant_message else None, + last_response=(state.last_assistant_message.message_pieces[0] if state.last_assistant_message else None), last_score=None, related_conversations=context.related_conversations, - outcome=outcome, + outcome=AttackOutcome.UNDETERMINED, outcome_reason=outcome_reason, - executed_turns=executed_turns, + executed_turns=state.executed_turns, labels=context.memory_labels, ) From 50aec9e10b70d98e04e7cdc3b27f2820866e39bd Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 13:49:12 -0400 Subject: [PATCH 19/47] Rename 'utterance' to 'statement' in barge-in notebook Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/executor/attack/barge_in_attack.ipynb | 13 +++++++++---- doc/code/executor/attack/barge_in_attack.py | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index 77e27b1361..a891a47f85 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -89,7 +89,9 @@ "cell_type": "code", "execution_count": null, "id": "4", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 2 + }, "outputs": [ { "name": "stdout", @@ -130,11 +132,13 @@ { "cell_type": "markdown", "id": "5", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 2 + }, "source": [ "## Section 1: Single-turn streaming with a converter\n", "\n", - "Streams one user utterance, applies a frequency-shift converter after VAD commits the turn,\n", + "Streams one user statement, applies a frequency-shift converter after VAD commits the turn,\n", "and gets the model's response. Exercises the full pipeline (chunk push, convert-on-commit,\n", "item swap, response trigger, memory persistence) without barge-in." ] @@ -373,7 +377,8 @@ ], "metadata": { "jupytext": { - "cell_metadata_filter": "-all" + "cell_metadata_filter": "-all", + "main_language": "python" }, "language_info": { "codemirror_mode": { diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py index 30df5bc570..d96899e910 100644 --- a/doc/code/executor/attack/barge_in_attack.py +++ b/doc/code/executor/attack/barge_in_attack.py @@ -6,7 +6,7 @@ # extension: .py # format_name: percent # format_version: '1.3' -# jupytext_version: 1.19.1 +# jupytext_version: 1.18.1 # --- # %% [markdown] @@ -87,7 +87,7 @@ async def _yield_chunks(pcm: bytes, real_time: bool = True): # %% [markdown] # ## Section 1: Single-turn streaming with a converter # -# Streams one user utterance, applies a frequency-shift converter after VAD commits the turn, +# Streams one user statement, applies a frequency-shift converter after VAD commits the turn, # and gets the model's response. Exercises the full pipeline (chunk push, convert-on-commit, # item swap, response trigger, memory persistence) without barge-in. From 6f53913a25bfdd1f04e9b4c5cdc041848fd7091a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 14:22:45 -0400 Subject: [PATCH 20/47] Promote realtime streaming types to public and add swap_user_audio primitive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 34 +++-------- pyrit/prompt_target/common/realtime_audio.py | 24 ++++---- .../openai/openai_realtime_target.py | 55 +++++++++++++----- .../attack/streaming/test_barge_in.py | 16 +++--- .../target/test_realtime_audio.py | 36 ++++++------ .../target/test_realtime_target.py | 56 +++++++++++++++---- 6 files changed, 132 insertions(+), 89 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index c2d508b60a..f4d5147f5f 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -33,9 +33,9 @@ from pyrit.identifiers import ComponentIdentifier from pyrit.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, + RealtimeEventDispatcher, RealtimeTargetResult, - _CommittedEvent, - _RealtimeEventDispatcher, ) from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget @@ -157,7 +157,7 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR connection = await target.connect_async(conversation_id=context.conversation_id) state = _BargeInRunState() - async def on_committed(event: _CommittedEvent) -> None: + async def on_committed(event: CommittedEvent) -> None: current_task = asyncio.current_task() if current_task is not None: state.turn_tasks.append(current_task) @@ -173,7 +173,7 @@ async def on_committed(event: _CommittedEvent) -> None: except Exception: logger.exception("BargeInAttack turn failed in convert-on-commit handler.") - dispatcher: _RealtimeEventDispatcher = await target.subscribe_events_async( + dispatcher: RealtimeEventDispatcher = await target.subscribe_events_async( connection=connection, on_user_audio_committed=on_committed, ) @@ -204,10 +204,10 @@ async def _handle_committed_turn_async( self, *, state: _BargeInRunState, - event: _CommittedEvent, + event: CommittedEvent, target: RealtimeTarget, connection: Any, - dispatcher: _RealtimeEventDispatcher, + dispatcher: RealtimeEventDispatcher, conversation_id: str, ) -> None: """Run the convert-on-commit dance for one VAD-committed user audio turn.""" @@ -226,11 +226,10 @@ async def _handle_committed_turn_async( using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot if using_converted_audio: - await self._swap_user_audio_async( - target=target, + await target.swap_user_audio_async( connection=connection, + committed_event=event, converted_pcm=converted_pcm, - original_item_id=event.item_id, ) turn_result = await self._drive_response_async(target=target, connection=connection, dispatcher=dispatcher) @@ -256,27 +255,12 @@ def _snapshot_user_audio(self, state: _BargeInRunState) -> bytes: state.raw_buffer.clear() return snapshot - async def _swap_user_audio_async( - self, - *, - target: RealtimeTarget, - connection: Any, - converted_pcm: bytes, - original_item_id: str, - ) -> None: - """Replace the server's originally-committed item with the converted audio.""" - await target.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) - try: - await target.delete_conversation_item_async(connection=connection, item_id=original_item_id) - except Exception as e: - logger.warning(f"conversation.item.delete failed for {original_item_id}: {e}") - async def _drive_response_async( self, *, target: RealtimeTarget, connection: Any, - dispatcher: _RealtimeEventDispatcher, + dispatcher: RealtimeEventDispatcher, ) -> RealtimeTargetResult: """ Trigger ``response.create`` and await the resulting turn future. diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index a2f76060e2..fb2d989d25 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -51,7 +51,7 @@ def flatten_transcripts(self) -> str: @dataclass -class _RealtimeTurnState: +class RealtimeTurnState: """Mutable per-turn state assembled by the dispatcher from incoming events.""" completion: asyncio.Future[RealtimeTargetResult] @@ -64,14 +64,14 @@ class _RealtimeTurnState: @dataclass(frozen=True) -class _CommittedEvent: +class CommittedEvent: """Payload passed to ``on_user_audio_committed`` callbacks when server VAD commits.""" item_id: str audio_start_ms: int | None = None -class _RealtimeEventDispatcher(ABC): +class RealtimeEventDispatcher(ABC): """ Owns a realtime connection's event stream and routes events to the active turn. @@ -82,7 +82,7 @@ def __init__( self, *, connection: Any, - on_user_audio_committed: Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None = None, + on_user_audio_committed: Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None = None, ) -> None: """ Args: @@ -95,7 +95,7 @@ def __init__( """ self._connection = connection self._on_user_audio_committed = on_user_audio_committed - self._current_turn: _RealtimeTurnState | None = None + self._current_turn: RealtimeTurnState | None = None self._task: asyncio.Task[None] | None = None self._callback_tasks: set[asyncio.Task[None]] = set() self._failure: BaseException | None = None @@ -136,12 +136,12 @@ async def stop(self) -> None: task.cancel() await asyncio.gather(*pending, return_exceptions=True) - def register_turn(self, state: _RealtimeTurnState) -> None: + def register_turn(self, state: RealtimeTurnState) -> None: """ Bind a new turn as the active turn. Args: - state (_RealtimeTurnState): The turn whose completion future will be + state (RealtimeTurnState): The turn whose completion future will be resolved when this turn ends. Raises: @@ -183,7 +183,7 @@ async def _dispatch_loop(self) -> None: if turn is not None and not turn.completion.done(): turn.completion.set_exception(e) - def _fire_committed_callback(self, event: _CommittedEvent) -> None: + def _fire_committed_callback(self, event: CommittedEvent) -> None: """ Schedule the ``on_user_audio_committed`` callback as a background task. @@ -196,7 +196,7 @@ def _fire_committed_callback(self, event: _CommittedEvent) -> None: task.add_done_callback(self._callback_tasks.discard) @abstractmethod - async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: + async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: """ Route a single provider-specific event. @@ -214,13 +214,13 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> Args: event: A single provider-specific event from the connection iterator. - state (_RealtimeTurnState | None): The currently-active turn, or None + state (RealtimeTurnState | None): The currently-active turn, or None if no turn is registered (e.g. between turns in a streaming session). """ @abstractmethod - async def _cancel(self, *, state: _RealtimeTurnState) -> None: + async def _cancel(self, *, state: RealtimeTurnState) -> None: """ Send provider-specific cancel and truncate events for the in-flight response. @@ -229,5 +229,5 @@ async def _cancel(self, *, state: _RealtimeTurnState) -> None: that is the dispatcher's responsibility. Args: - state (_RealtimeTurnState): The turn whose response should be cancelled. + state (RealtimeTurnState): The turn whose response should be cancelled. """ diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index fe9bd6fe3b..0f9471bb69 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -23,11 +23,11 @@ ) from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, + RealtimeEventDispatcher, RealtimeTargetResult, + RealtimeTurnState, ServerVadConfig, - _CommittedEvent, - _RealtimeEventDispatcher, - _RealtimeTurnState, ) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration @@ -572,12 +572,37 @@ async def delete_conversation_item_async(self, *, connection: Any, item_id: str) """ await connection.conversation.item.delete(item_id=item_id) + async def swap_user_audio_async( + self, + *, + connection: Any, + committed_event: CommittedEvent, + converted_pcm: bytes, + ) -> None: + """ + Replace the server's just-committed user audio with converted PCM. + + Inserts ``converted_pcm`` as a new user item and best-effort deletes the original + item identified by ``committed_event``. Hides OpenAI's item-id concept from + callers so streaming attacks can stay provider-agnostic. + + Args: + connection: Active Realtime API connection. + committed_event: Payload received in the on-committed callback. + converted_pcm: PCM16 mono @ 24 kHz audio to insert in place of the original. + """ + await self.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) + try: + await self.delete_conversation_item_async(connection=connection, item_id=committed_event.item_id) + except Exception as e: + logger.warning(f"conversation.item.delete failed for {committed_event.item_id}: {e}") + async def subscribe_events_async( self, *, connection: Any, - on_user_audio_committed: (Callable[[_CommittedEvent], Coroutine[Any, Any, None]] | None) = None, - ) -> _RealtimeEventDispatcher: + on_user_audio_committed: (Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None) = None, + ) -> RealtimeEventDispatcher: """ Start consuming events from the connection and route them via the OpenAI dispatcher. @@ -609,12 +634,12 @@ async def request_response_async( self, *, connection: Any, - dispatcher: _RealtimeEventDispatcher, + dispatcher: RealtimeEventDispatcher, ) -> asyncio.Future[RealtimeTargetResult]: """ Trigger ``response.create`` and return a future that resolves when the turn ends. - Constructs a fresh ``_RealtimeTurnState``, binds it to the dispatcher as the + Constructs a fresh ``RealtimeTurnState``, binds it to the dispatcher as the active turn, then sends ``response.create``. The dispatcher resolves the returned future via ``response.done`` (with ``interrupted=False``) or via the barge-in cancel path (with ``interrupted=True``). @@ -631,7 +656,7 @@ async def request_response_async( Raises: RuntimeError: If another turn is already pending on the dispatcher. """ - state = _RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) dispatcher.register_turn(state) await connection.response.create() return state.completion @@ -1031,15 +1056,15 @@ async def _construct_message_from_response(self, response: Any, request: Any) -> raise NotImplementedError("RealtimeTarget uses receive_events for message construction") -class _OpenAIRealtimeDispatcher(_RealtimeEventDispatcher): +class _OpenAIRealtimeDispatcher(RealtimeEventDispatcher): """ - Concrete ``_RealtimeEventDispatcher`` for the OpenAI Realtime API. + Concrete ``RealtimeEventDispatcher`` for the OpenAI Realtime API. - Routes OpenAI server events into the active ``_RealtimeTurnState`` and issues + Routes OpenAI server events into the active ``RealtimeTurnState`` and issues ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. """ - async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: + async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" event_type = getattr(event, "type", "") @@ -1049,7 +1074,7 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> if item_id is None: return self._fire_committed_callback( - _CommittedEvent( + CommittedEvent( item_id=item_id, audio_start_ms=getattr(event, "audio_start_ms", None), ) @@ -1119,7 +1144,7 @@ async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> state.completion.set_exception(RuntimeError(f"Realtime API error: {message}")) return - async def _cancel(self, *, state: _RealtimeTurnState) -> None: + async def _cancel(self, *, state: RealtimeTurnState) -> None: """ Truncate the in-flight response's conversation item to what was actually delivered. @@ -1130,7 +1155,7 @@ async def _cancel(self, *, state: _RealtimeTurnState) -> None: Does not resolve ``state.completion``; the caller (``_route_event``) does that. Args: - state (_RealtimeTurnState): The turn whose response should be cancelled. + state (RealtimeTurnState): The turn whose response should be cancelled. """ if state.current_item_id is not None: # PCM16 @ 24 kHz: 48 bytes per millisecond. diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 569e80a0ae..b8f8cb81b7 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -21,8 +21,8 @@ from pyrit.prompt_normalizer import AudioStreamNormalizer, PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, RealtimeTargetResult, - _CommittedEvent, ) if TYPE_CHECKING: @@ -168,7 +168,7 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield b"\x00" * 480 # Drive a fake commit mid-stream. - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_1"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_1"))) ctx = _attack_context(audio_chunks=chunks_then_commit()) @@ -289,7 +289,7 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw_chunk - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_99"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_99"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), @@ -330,7 +330,7 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield b"\x00" * 96 - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_42"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_42"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), @@ -376,9 +376,9 @@ def _future_with(result: RealtimeTargetResult) -> asyncio.Future[RealtimeTargetR async def chunks_then_two_commits() -> AsyncIterator[bytes]: yield b"\x01" * 96 - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_1"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_1"))) yield b"\x02" * 96 - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_2"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_2"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), @@ -425,7 +425,7 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id="raw_z"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_z"))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), @@ -478,7 +478,7 @@ async def fake_subscribe(*, connection, on_user_audio_committed): async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw_chunk - await asyncio.create_task(captured["on_committed"](_CommittedEvent(item_id=item_id))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id=item_id))) ctx = BargeInAttackContext( params=AttackParameters(objective="obj"), diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index 9b34528589..005814a5e4 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -8,16 +8,16 @@ import pytest from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, + RealtimeEventDispatcher, RealtimeTargetResult, - _CommittedEvent, - _RealtimeEventDispatcher, - _RealtimeTurnState, + RealtimeTurnState, ) async def test_realtime_turn_state_defaults(): """Newly constructed turn state must be empty: no audio, no transcripts, not responding, not interrupted.""" - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) assert state.is_responding is False assert state.interrupted is False @@ -41,7 +41,7 @@ def test_realtime_target_result_carries_interrupted_when_set(): assert result.interrupted is True -class _RecordingDispatcher(_RealtimeEventDispatcher): +class _RecordingDispatcher(RealtimeEventDispatcher): """Minimal concrete dispatcher for testing the generic base class behavior.""" def __init__(self, *, connection: Any) -> None: @@ -49,13 +49,13 @@ def __init__(self, *, connection: Any) -> None: self.routed_events: list[Any] = [] self.cancel_calls: int = 0 - async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: + async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: self.routed_events.append(event) # End the turn on a sentinel event so tests can drain the loop. if state is not None and getattr(event, "_finish", False): state.completion.set_result(RealtimeTargetResult()) - async def _cancel(self, *, state: _RealtimeTurnState) -> None: + async def _cancel(self, *, state: RealtimeTurnState) -> None: self.cancel_calls += 1 state.interrupted = True @@ -98,8 +98,8 @@ async def test_dispatcher_stop_releases_task(): async def test_dispatcher_register_turn_rejects_concurrent_active_turn(): """Registering a turn while another is active and unresolved must raise.""" dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) - first = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) - second = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + first = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + second = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(first) with pytest.raises(RuntimeError, match="already active"): @@ -109,9 +109,9 @@ async def test_dispatcher_register_turn_rejects_concurrent_active_turn(): async def test_dispatcher_register_turn_allows_replacement_after_completion(): """Once the active turn's future is done, register_turn may bind a new turn.""" dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) - first = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + first = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) first.completion.set_result(RealtimeTargetResult()) - second = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + second = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(first) dispatcher.register_turn(second) @@ -123,7 +123,7 @@ async def test_dispatcher_loop_routes_events_to_active_turn(): finish = _sentinel_event(finish=True) other = _sentinel_event() dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([other, finish])) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(state) await dispatcher.start() @@ -152,12 +152,12 @@ async def test_dispatcher_loop_sets_exception_on_router_failure(): """A router exception must propagate to the active turn's completion future.""" class _ExplodingDispatcher(_RecordingDispatcher): - async def _route_event(self, *, event: Any, state: _RealtimeTurnState | None) -> None: + async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: raise ValueError("router boom") event = _sentinel_event() dispatcher = _ExplodingDispatcher(connection=_ScriptedConnection([event])) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(state) await dispatcher.start() @@ -179,7 +179,7 @@ async def slow_callback(event): # Block until the test releases us; this proves the dispatch loop did not wait. await release.wait() - class _CallbackDispatcher(_RealtimeEventDispatcher): + class _CallbackDispatcher(RealtimeEventDispatcher): async def _route_event(self, *, event, state): # Synthesize a committed callback fire on every event for the test. self._fire_committed_callback(event) @@ -187,8 +187,8 @@ async def _route_event(self, *, event, state): async def _cancel(self, *, state): # pragma: no cover - not exercised here return - fake_event_1 = MagicMock(spec=_CommittedEvent) - fake_event_2 = MagicMock(spec=_CommittedEvent) + fake_event_1 = MagicMock(spec=CommittedEvent) + fake_event_2 = MagicMock(spec=CommittedEvent) dispatcher = _CallbackDispatcher( connection=_ScriptedConnection([fake_event_1, fake_event_2]), on_user_audio_committed=slow_callback, @@ -209,7 +209,7 @@ async def _cancel(self, *, state): # pragma: no cover - not exercised here async def test_dispatcher_records_failure_on_iterator_crash(): """When the connection iterator raises, the dispatcher's failure property captures the exception.""" - class _NoopDispatcher(_RealtimeEventDispatcher): + class _NoopDispatcher(RealtimeEventDispatcher): async def _route_event(self, *, event, state): # pragma: no cover - never called return diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 2854ba6c5e..a1d4e88b10 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -12,9 +12,9 @@ from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, RealtimeTargetResult, - _CommittedEvent, - _RealtimeTurnState, + RealtimeTurnState, ) from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher @@ -699,9 +699,43 @@ async def test_delete_conversation_item_async_forwards_item_id(target): connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_item_99") -def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = "item_xyz") -> _RealtimeTurnState: +async def test_swap_user_audio_async_inserts_converted_then_deletes_original(target): + """``swap_user_audio_async`` must insert the converted PCM then delete the original item.""" + connection = AsyncMock() + event = CommittedEvent(item_id="raw_swap_1") + + await target.swap_user_audio_async( + connection=connection, + committed_event=event, + converted_pcm=b"\xab" * 96, + ) + + # Insert came first (item.create), then delete. + connection.conversation.item.create.assert_awaited_once() + connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_1") + + +async def test_swap_user_audio_async_logs_and_swallows_delete_failure(target, caplog): + """Best-effort delete: if ``delete`` raises, ``swap`` logs a warning and returns normally.""" + connection = AsyncMock() + connection.conversation.item.delete.side_effect = RuntimeError("delete blew up") + event = CommittedEvent(item_id="raw_swap_fail") + + with caplog.at_level("WARNING"): + await target.swap_user_audio_async( + connection=connection, + committed_event=event, + converted_pcm=b"\x01" * 96, + ) + + connection.conversation.item.create.assert_awaited_once() + connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_fail") + assert any("delete failed for raw_swap_fail" in record.message for record in caplog.records) + + +def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = "item_xyz") -> RealtimeTurnState: """Build a turn state with the named ids preset; completion future is unused by cancel tests.""" - return _RealtimeTurnState( + return RealtimeTurnState( completion=asyncio.get_event_loop().create_future(), is_responding=True, last_response_id=response_id, @@ -792,7 +826,7 @@ async def test_route_event_happy_path_resolves_completion_with_assembled_result( """response.created -> output_item.added -> audio.delta -> transcript.delta -> response.done.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) @@ -815,7 +849,7 @@ async def test_route_event_speech_started_while_responding_cancels_and_resolves_ """speech_started during a response triggers cancel and resolves with interrupted=True.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) @@ -841,7 +875,7 @@ async def test_route_event_stale_response_done_after_cancel_is_dropped(): """A response.done with a stale response_id must not re-resolve a completed future.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) # Pretend a turn just resolved as interrupted on response_id r1. state.last_response_id = "r1" state.completion.set_result(RealtimeTargetResult()) @@ -854,7 +888,7 @@ async def test_route_event_error_resolves_with_exception(): """error events resolve the completion future via set_exception.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) await dispatcher._route_event(event=_scripted_event("error", **{"error.message": "rate limited"}), state=state) @@ -866,7 +900,7 @@ async def test_route_event_speech_started_without_responding_is_noop(): """speech_started before a response is in flight does not call cancel or resolve.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) - state = _RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) @@ -932,7 +966,7 @@ async def event_iter(): connection = MagicMock() connection.__aiter__ = lambda self_: event_iter() - received: list[_CommittedEvent] = [] + received: list[CommittedEvent] = [] async def on_committed(event): received.append(event) @@ -980,7 +1014,7 @@ async def test_request_response_async_registers_turn_and_sends_response_create(t dispatcher.register_turn.assert_called_once() registered_state = dispatcher.register_turn.call_args.args[0] - assert isinstance(registered_state, _RealtimeTurnState) + assert isinstance(registered_state, RealtimeTurnState) assert registered_state.completion is future connection.response.create.assert_awaited_once_with() From 50561457ae2f361d1a19bb435727f18f953b2583 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 22 May 2026 14:42:05 -0400 Subject: [PATCH 21/47] Fix self-review polish: ordering test, filtered-config short-circuit, inline drive_response Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 19 ++----------------- .../audio_stream_normalizer.py | 17 ++++++++++++----- .../test_audio_stream_normalizer.py | 16 ++++++++++++++++ .../target/test_realtime_target.py | 12 ++++++++++-- 4 files changed, 40 insertions(+), 24 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index f4d5147f5f..141289ec39 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -232,7 +232,8 @@ async def _handle_committed_turn_async( converted_pcm=converted_pcm, ) - turn_result = await self._drive_response_async(target=target, connection=connection, dispatcher=dispatcher) + turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) + turn_result = await turn_future user_audio_pcm = converted_pcm if using_converted_audio else snapshot state.last_assistant_message = await self._persist_turn_async( @@ -255,22 +256,6 @@ def _snapshot_user_audio(self, state: _BargeInRunState) -> bytes: state.raw_buffer.clear() return snapshot - async def _drive_response_async( - self, - *, - target: RealtimeTarget, - connection: Any, - dispatcher: RealtimeEventDispatcher, - ) -> RealtimeTargetResult: - """ - Trigger ``response.create`` and await the resulting turn future. - - Returns: - The completed ``RealtimeTargetResult`` for the assistant turn. - """ - turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) - return await turn_future - def _build_result( self, *, diff --git a/pyrit/prompt_normalizer/audio_stream_normalizer.py b/pyrit/prompt_normalizer/audio_stream_normalizer.py index b8de3ae1f0..350de64780 100644 --- a/pyrit/prompt_normalizer/audio_stream_normalizer.py +++ b/pyrit/prompt_normalizer/audio_stream_normalizer.py @@ -60,7 +60,17 @@ async def normalize_async( Raises: ValueError: If converter output is not mono PCM16 at ``sample_rate``. """ - if not converter_configurations or not pcm_bytes: + if not pcm_bytes: + return pcm_bytes, [] + + # Drop configs that don't target audio_path so we never enter the WAV bridge when + # nothing applicable will run (e.g. text-only converters configured on a streaming attack). + applicable_configs = [ + config + for config in converter_configurations + if not config.prompt_data_types_to_apply or "audio_path" in config.prompt_data_types_to_apply + ] + if not applicable_configs: return pcm_bytes, [] identifiers: list[ComponentIdentifier] = [] @@ -73,10 +83,7 @@ async def normalize_async( wav_out.setframerate(sample_rate) wav_out.writeframes(pcm_bytes) - for config in converter_configurations: - if config.prompt_data_types_to_apply and "audio_path" not in config.prompt_data_types_to_apply: - continue - + for config in applicable_configs: for converter in config.converters: outer_context = get_execution_context() with execution_context( diff --git a/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py b/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py index 1979bbfe26..f48dff68c7 100644 --- a/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py +++ b/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py @@ -98,6 +98,22 @@ async def test_normalize_async_respects_data_type_filter(): assert len(ids) == 1 +async def test_normalize_async_short_circuits_when_all_configs_filtered_out(): + """When every config is text-only, skip the WAV round-trip entirely.""" + normalizer = AudioStreamNormalizer() + text_only = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) + + configs = [ + PromptConverterConfiguration(converters=[text_only], prompt_data_types_to_apply=["text"]), + ] + pcm = b"\x00\x10\x20\x30" + out, ids = await normalizer.normalize_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=configs) + + assert out == pcm # bytes unchanged + assert ids == [] + text_only.convert_tokens_async.assert_not_awaited() + + async def test_normalize_async_rejects_mismatched_sample_rate(): """Converter output at a different sample rate must raise ValueError.""" normalizer = AudioStreamNormalizer() diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index a1d4e88b10..c810587a8e 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -4,7 +4,7 @@ import asyncio import base64 from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest @@ -710,9 +710,13 @@ async def test_swap_user_audio_async_inserts_converted_then_deletes_original(tar converted_pcm=b"\xab" * 96, ) - # Insert came first (item.create), then delete. connection.conversation.item.create.assert_awaited_once() connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_1") + # Insert must precede delete: any future refactor that swaps the order or runs them + # concurrently would corrupt the streaming session — pin the ordering here. + create_index = connection.method_calls.index(call.conversation.item.create(item=ANY)) + delete_index = connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_1")) + assert create_index < delete_index async def test_swap_user_audio_async_logs_and_swallows_delete_failure(target, caplog): @@ -730,6 +734,10 @@ async def test_swap_user_audio_async_logs_and_swallows_delete_failure(target, ca connection.conversation.item.create.assert_awaited_once() connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_fail") + # Even on delete failure, insert must have happened first. + create_index = connection.method_calls.index(call.conversation.item.create(item=ANY)) + delete_index = connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_fail")) + assert create_index < delete_index assert any("delete failed for raw_swap_fail" in record.message for record in caplog.records) From fb28586887dfbf3d3357b678eaf25495950b2311 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 26 May 2026 18:02:59 -0400 Subject: [PATCH 22/47] Route BargeInAttack through PromptNormalizer.send_prompt_async Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 213 +++--- pyrit/prompt_normalizer/__init__.py | 2 - .../audio_stream_normalizer.py | 114 ---- .../openai/openai_realtime_target.py | 198 ++++-- .../attack/streaming/test_barge_in.py | 614 +++++------------- .../test_audio_stream_normalizer.py | 126 ---- .../target/test_realtime_target.py | 354 +++++++++- 7 files changed, 759 insertions(+), 862 deletions(-) delete mode 100644 pyrit/prompt_normalizer/audio_stream_normalizer.py delete mode 100644 tests/unit/prompt_normalizer/test_audio_stream_normalizer.py diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 141289ec39..920edaeabf 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -16,33 +16,28 @@ from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy from pyrit.identifiers.atomic_attack_identifier import build_atomic_attack_identifier -from pyrit.memory import CentralMemory from pyrit.models import ( AttackOutcome, AttackResult, Message, MessagePiece, - construct_response_from_request, ) +from pyrit.prompt_normalizer import PromptNormalizer from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements +from pyrit.prompt_target.openai.openai_realtime_target import _REALTIME_COMMITTED_ITEM_ID_KEY if TYPE_CHECKING: from collections.abc import AsyncIterator - from pyrit.identifiers import ComponentIdentifier from pyrit.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( CommittedEvent, - RealtimeEventDispatcher, - RealtimeTargetResult, ) from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget logger = logging.getLogger(__name__) -_REALTIME_SAMPLE_RATE_HZ = 24000 - @dataclass class BargeInAttackContext(AttackContext[AttackParamsT]): @@ -55,12 +50,9 @@ class BargeInAttackContext(AttackContext[AttackParamsT]): @dataclass class _BargeInRunState: - """Mutable per-session state accumulated as turns commit.""" + """Mutable per-session state shared between ``_perform_async`` and ``on_committed``.""" raw_buffer: bytearray = field(default_factory=bytearray) - turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - last_assistant_message: Message | None = None - executed_turns: int = 0 turn_tasks: list[asyncio.Task[None]] = field(default_factory=list) @@ -89,6 +81,7 @@ def __init__( *, objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] attack_converter_config: AttackConverterConfig | None = None, + prompt_normalizer: PromptNormalizer | None = None, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ @@ -96,8 +89,9 @@ def __init__( Args: objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` capability. - Audio normalization is delegated to ``objective_target.audio_normalizer``. attack_converter_config: Converters applied to each committed user turn. + prompt_normalizer: Normalizer used to apply converters and persist messages. + Defaults to a fresh ``PromptNormalizer``. params_type: Attack parameter dataclass type. """ super().__init__( @@ -109,6 +103,7 @@ def __init__( attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters + self._prompt_normalizer = prompt_normalizer or PromptNormalizer() def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ @@ -156,25 +151,29 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR connection = await target.connect_async(conversation_id=context.conversation_id) state = _BargeInRunState() + last_response: Message | None = None + executed_turns = 0 async def on_committed(event: CommittedEvent) -> None: + nonlocal last_response, executed_turns current_task = asyncio.current_task() if current_task is not None: state.turn_tasks.append(current_task) try: - await self._handle_committed_turn_async( - state=state, + response = await self._handle_committed_turn_async( event=event, + context=context, + state=state, target=target, - connection=connection, - dispatcher=dispatcher, - conversation_id=context.conversation_id, ) + last_response = response + executed_turns += 1 except Exception: logger.exception("BargeInAttack turn failed in convert-on-commit handler.") - dispatcher: RealtimeEventDispatcher = await target.subscribe_events_async( + await target.subscribe_events_async( connection=connection, + conversation_id=context.conversation_id, on_user_audio_committed=on_committed, ) @@ -186,103 +185,101 @@ async def on_committed(event: CommittedEvent) -> None: state.raw_buffer.extend(chunk) await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) - # Wait for any in-flight committed-turn tasks to finish (convert + response + - # persistence), capped by a safety timeout. The chunk source must end with enough - # trailing silence for server VAD's silence threshold to fire commit — otherwise - # the last turn never enters the convert pipeline and there is nothing to wait on. + # Wait for any in-flight committed-turn tasks to finish, capped by a safety timeout. + # The chunk source must end with enough trailing silence for server VAD's silence + # threshold to fire commit — otherwise the last turn never enters the pipeline. await self._wait_for_pending_turns_async(state.turn_tasks) finally: - await dispatcher.stop() - try: - await connection.close() - except Exception as e: - logger.warning(f"Error closing streaming connection: {e}") + await target.cleanup_conversation(context.conversation_id) - return self._build_result(state=state, context=context) + return self._build_result( + last_response=last_response, + executed_turns=executed_turns, + context=context, + ) async def _handle_committed_turn_async( self, *, - state: _BargeInRunState, event: CommittedEvent, + context: BargeInAttackContext[Any], + state: _BargeInRunState, target: RealtimeTarget, - connection: Any, - dispatcher: RealtimeEventDispatcher, - conversation_id: str, - ) -> None: - """Run the convert-on-commit dance for one VAD-committed user audio turn.""" - async with state.turn_lock: - snapshot = self._snapshot_user_audio(state) - - try: - converted_pcm, applied_identifiers = await target.audio_normalizer.normalize_async( - pcm_bytes=snapshot, - sample_rate=_REALTIME_SAMPLE_RATE_HZ, - converter_configurations=self._request_converters, - ) - except Exception: - logger.exception("Audio converters failed; dropping turn.") - return - - using_converted_audio = bool(self._request_converters) and converted_pcm != snapshot - if using_converted_audio: - await target.swap_user_audio_async( - connection=connection, - committed_event=event, - converted_pcm=converted_pcm, - ) - - turn_future = await target.request_response_async(connection=connection, dispatcher=dispatcher) - turn_result = await turn_future - - user_audio_pcm = converted_pcm if using_converted_audio else snapshot - state.last_assistant_message = await self._persist_turn_async( - target=target, - conversation_id=conversation_id, - user_audio_pcm=user_audio_pcm, - applied_converter_identifiers=applied_identifiers, - turn_result=turn_result, - ) - state.executed_turns += 1 - - def _snapshot_user_audio(self, state: _BargeInRunState) -> bytes: + ) -> Message: """ - Snapshot the accumulated user PCM and clear the buffer for the next turn. + Run one convert-and-respond turn for a VAD-committed user audio buffer. + + Snapshots the locally-accumulated raw PCM, persists it as a durable WAV, + wraps it in a Message with the server's committed item id stashed in + ``prompt_metadata`` so the target's streaming branch can swap raw audio + for converter-transformed audio, then drives ``send_prompt_async``. Returns: - Snapshot of buffered PCM bytes prior to clearing. + The assistant Message returned by ``send_prompt_async`` for this turn. """ + # Snapshot the locally-accumulated raw PCM and reset for the next turn. snapshot = bytes(state.raw_buffer) state.raw_buffer.clear() - return snapshot + + # PromptNormalizer.send_prompt_async needs an audio_path-shaped Message, + # so persist the snapshot to a durable WAV before wrapping. + snapshot_path = await target.save_audio( + snapshot, + num_channels=1, + sample_width=2, + sample_rate=target.SAMPLE_RATE_HZ, + ) + # Stash the server-assigned item id so the target's streaming branch + # can swap the raw buffer for converter-transformed audio. + piece = MessagePiece( + role="user", + original_value=snapshot_path, + original_value_data_type="audio_path", + converted_value=snapshot_path, + converted_value_data_type="audio_path", + conversation_id=context.conversation_id, + prompt_metadata={_REALTIME_COMMITTED_ITEM_ID_KEY: event.item_id}, + ) + message = Message(message_pieces=[piece]) + + return await self._prompt_normalizer.send_prompt_async( + message=message, + target=target, + request_converter_configurations=self._request_converters, + response_converter_configurations=self._response_converters, + conversation_id=context.conversation_id, + attack_identifier=self.get_identifier(), + ) def _build_result( self, *, - state: _BargeInRunState, + last_response: Message | None, + executed_turns: int, context: BargeInAttackContext[Any], ) -> AttackResult: """ - Assemble the final ``AttackResult`` from accumulated run state. + Assemble the final ``AttackResult`` from accumulated turn outcomes. Returns: - ``AttackResult`` with the last assistant message, executed turn count, and outcome reason. + ``AttackResult`` with the last assistant message, executed turn count, + and outcome reason. """ - if state.executed_turns == 0: + if executed_turns == 0: outcome_reason: str | None = "No assistant turns completed (server VAD did not commit any user audio)" else: - outcome_reason = f"{state.executed_turns} assistant turn(s) completed; no scorer configured" + outcome_reason = f"{executed_turns} assistant turn(s) completed; no scorer configured" return AttackResult( conversation_id=context.conversation_id, objective=context.objective, atomic_attack_identifier=build_atomic_attack_identifier(attack_identifier=self.get_identifier()), - last_response=(state.last_assistant_message.message_pieces[0] if state.last_assistant_message else None), + last_response=(last_response.message_pieces[0] if last_response else None), last_score=None, related_conversations=context.related_conversations, outcome=AttackOutcome.UNDETERMINED, outcome_reason=outcome_reason, - executed_turns=state.executed_turns, + executed_turns=executed_turns, labels=context.memory_labels, ) @@ -312,61 +309,3 @@ async def _wait_for_pending_turns_async(self, turn_tasks: list[asyncio.Task[None "finish; teardown will cancel them. Increase _MAX_POST_STREAM_WAIT_SECONDS if responses " "regularly take longer." ) - - async def _persist_turn_async( - self, - *, - target: RealtimeTarget, - conversation_id: str, - user_audio_pcm: bytes, - applied_converter_identifiers: list[ComponentIdentifier], - turn_result: RealtimeTargetResult, - ) -> Message: - """ - Persist the user+assistant Message pair for one completed turn to CentralMemory. - - Returns: - The assistant Message so callers can surface it as ``last_response``. - """ - user_audio_path = await target.save_audio( - user_audio_pcm, - num_channels=1, - sample_width=2, - sample_rate=_REALTIME_SAMPLE_RATE_HZ, - ) - user_piece = MessagePiece( - role="user", - original_value=user_audio_path, - original_value_data_type="audio_path", - converted_value=user_audio_path, - converted_value_data_type="audio_path", - conversation_id=conversation_id, - ) - user_piece.converter_identifiers.extend(applied_converter_identifiers) - user_message = Message(message_pieces=[user_piece]) - - response_audio_path = await target.save_audio( - turn_result.audio_bytes, - num_channels=1, - sample_width=2, - sample_rate=_REALTIME_SAMPLE_RATE_HZ, - ) - text_piece = construct_response_from_request( - request=user_piece, - response_text_pieces=[turn_result.flatten_transcripts()], - response_type="text", - ).message_pieces[0] - audio_piece = construct_response_from_request( - request=user_piece, - response_text_pieces=[response_audio_path], - response_type="audio_path", - ).message_pieces[0] - if turn_result.interrupted: - text_piece.prompt_metadata["interrupted"] = True - audio_piece.prompt_metadata["interrupted"] = True - assistant_message = Message(message_pieces=[text_piece, audio_piece]) - - memory = CentralMemory.get_memory_instance() - memory.add_message_to_memory(request=user_message) - memory.add_message_to_memory(request=assistant_message) - return assistant_message diff --git a/pyrit/prompt_normalizer/__init__.py b/pyrit/prompt_normalizer/__init__.py index dd1179b8b4..04980a08d7 100644 --- a/pyrit/prompt_normalizer/__init__.py +++ b/pyrit/prompt_normalizer/__init__.py @@ -8,13 +8,11 @@ including converter configurations and request handling. """ -from pyrit.prompt_normalizer.audio_stream_normalizer import AudioStreamNormalizer from pyrit.prompt_normalizer.normalizer_request import NormalizerRequest from pyrit.prompt_normalizer.prompt_converter_configuration import PromptConverterConfiguration from pyrit.prompt_normalizer.prompt_normalizer import PromptNormalizer __all__ = [ - "AudioStreamNormalizer", "NormalizerRequest", "PromptConverterConfiguration", "PromptNormalizer", diff --git a/pyrit/prompt_normalizer/audio_stream_normalizer.py b/pyrit/prompt_normalizer/audio_stream_normalizer.py deleted file mode 100644 index 350de64780..0000000000 --- a/pyrit/prompt_normalizer/audio_stream_normalizer.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Normalizer for streaming audio: raw PCM in, converter-transformed PCM out.""" - -from __future__ import annotations - -import os -import tempfile -import wave -from typing import TYPE_CHECKING - -from pyrit.exceptions import ( - ComponentRole, - execution_context, - get_execution_context, -) - -if TYPE_CHECKING: - from pyrit.identifiers import ComponentIdentifier - from pyrit.prompt_normalizer.prompt_converter_configuration import ( - PromptConverterConfiguration, - ) - - -class AudioStreamNormalizer: - """ - Normalizer that adapts raw PCM audio for streaming targets. - - Streaming attacks hold mid-turn PCM rather than a ``Message``; this class bridges - raw PCM to PyRIT's file-based converter ecosystem by writing the audio to a - temporary WAV, running converters via ``convert_tokens_async`` with - ``input_type="audio_path"``, and reading the resulting PCM back. Subclass to - customize bridging behavior (alternate format adaptation, parallelism, etc.). - """ - - def __init__(self, *, start_token: str = "⟪", end_token: str = "⟫") -> None: - """Initialize with optional token delimiters passed through to converters.""" - self._start_token = start_token - self._end_token = end_token - - async def normalize_async( - self, - *, - pcm_bytes: bytes, - sample_rate: int, - converter_configurations: list[PromptConverterConfiguration], - ) -> tuple[bytes, list[ComponentIdentifier]]: - """ - Run ``converter_configurations`` against ``pcm_bytes`` via a temp WAV bridge. - - Args: - pcm_bytes: Raw PCM16 mono audio. - sample_rate: Sample rate in Hz. - converter_configurations: Same shape consumed by ``PromptNormalizer.convert_values``. - - Returns: - ``(converted_pcm, identifiers_that_ran)``. - - Raises: - ValueError: If converter output is not mono PCM16 at ``sample_rate``. - """ - if not pcm_bytes: - return pcm_bytes, [] - - # Drop configs that don't target audio_path so we never enter the WAV bridge when - # nothing applicable will run (e.g. text-only converters configured on a streaming attack). - applicable_configs = [ - config - for config in converter_configurations - if not config.prompt_data_types_to_apply or "audio_path" in config.prompt_data_types_to_apply - ] - if not applicable_configs: - return pcm_bytes, [] - - identifiers: list[ComponentIdentifier] = [] - - with tempfile.TemporaryDirectory() as tmpdir: - current_path = os.path.join(tmpdir, "streaming_input.wav") - with wave.open(current_path, "wb") as wav_out: - wav_out.setnchannels(1) - wav_out.setsampwidth(2) - wav_out.setframerate(sample_rate) - wav_out.writeframes(pcm_bytes) - - for config in applicable_configs: - for converter in config.converters: - outer_context = get_execution_context() - with execution_context( - component_role=ComponentRole.CONVERTER, - attack_strategy_name=outer_context.attack_strategy_name if outer_context else None, - attack_identifier=outer_context.attack_identifier if outer_context else None, - component_identifier=converter.get_identifier(), - objective_target_conversation_id=( - outer_context.objective_target_conversation_id if outer_context else None - ), - ): - result = await converter.convert_tokens_async( - prompt=current_path, - input_type="audio_path", - start_token=self._start_token, - end_token=self._end_token, - ) - current_path = result.output_text - identifiers.append(converter.get_identifier()) - - with wave.open(current_path, "rb") as wav_in: - if wav_in.getnchannels() != 1 or wav_in.getsampwidth() != 2 or wav_in.getframerate() != sample_rate: - raise ValueError( - "Converter output incompatible with streaming target: " - f"expected mono PCM16 @ {sample_rate} Hz, got channels={wav_in.getnchannels()} " - f"sampwidth={wav_in.getsampwidth()} rate={wav_in.getframerate()}." - ) - return wav_in.readframes(wav_in.getnframes()), identifiers diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 0f9471bb69..69a08b6ef1 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -7,7 +7,8 @@ import re import wave from collections.abc import Callable, Coroutine -from typing import TYPE_CHECKING, Any, Literal, Optional +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional from openai import AsyncOpenAI @@ -34,9 +35,6 @@ from pyrit.prompt_target.common.utils import limit_requests_per_minute from pyrit.prompt_target.openai.openai_target import OpenAITarget -if TYPE_CHECKING: - from pyrit.prompt_normalizer import AudioStreamNormalizer - logger = logging.getLogger(__name__) # Voices supported by the OpenAI Realtime API. @@ -45,6 +43,29 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] +#: Key under which the streaming attack stashes the server-side item id of the +#: most recently committed user audio buffer. Read by +#: :meth:`RealtimeTarget._send_streaming_turn_async` to identify which item to +#: delete when swapping in converter-transformed audio. +_REALTIME_COMMITTED_ITEM_ID_KEY = "_realtime_committed_item_id" + + +@dataclass +class _StreamingConversationState: + """ + Per-conversation streaming-mode bookkeeping for :class:`RealtimeTarget`. + + Presence in :attr:`RealtimeTarget._streaming_state` is the signal that a + conversation should take the streaming swap-and-respond path inside + :meth:`RealtimeTarget._send_prompt_to_target_async` rather than the atomic + send_audio / send_text path. The lock serializes per-turn work so back-to-back + VAD commits cannot race on the dispatcher's single active-turn slot. + """ + + dispatcher: RealtimeEventDispatcher + turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock) + + class RealtimeTarget(OpenAITarget, PromptTarget): """ A prompt target for Azure OpenAI Realtime API. @@ -79,6 +100,11 @@ class RealtimeTarget(OpenAITarget, PromptTarget): ) ) + #: Sample rate (Hz) for all PCM16 audio exchanged with the Realtime API. + #: The Realtime API negotiates 24 kHz; callers (streaming attacks, audio + #: helpers, normalizers) should read this rather than hard-coding 24000. + SAMPLE_RATE_HZ: ClassVar[int] = 24000 + def __init__( self, *, @@ -86,7 +112,6 @@ def __init__( existing_convo: Optional[dict[str, Any]] = None, custom_configuration: Optional[TargetConfiguration] = None, server_vad: bool | ServerVadConfig = False, - audio_normalizer: Optional["AudioStreamNormalizer"] = None, **kwargs: Any, ) -> None: """ @@ -115,10 +140,6 @@ def __init__( ``True`` enables VAD with default tuning. Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming/interruption plumbing arrives in subsequent changes; this currently only affects the emitted session config. - audio_normalizer (AudioStreamNormalizer, Optional): Normalizer applied to raw PCM - mid-turn before it is sent back into the conversation. Defaults to a stock - ``AudioStreamNormalizer`` that bridges PCM to PyRIT's file-based converter - pipeline. Override to plug in custom format adaptation. **kwargs: Additional keyword arguments passed to the parent OpenAITarget class. httpx_client_kwargs (dict, Optional): Additional kwargs to be passed to the ``httpx.AsyncClient()`` constructor. For example, to specify a 3 minute timeout: ``httpx_client_kwargs={"timeout": 180}`` @@ -136,9 +157,11 @@ def __init__( else: self._server_vad = None - from pyrit.prompt_normalizer import AudioStreamNormalizer - - self.audio_normalizer: AudioStreamNormalizer = audio_normalizer or AudioStreamNormalizer() + # Streaming-mode bookkeeping. Entries are added by ``subscribe_events_async`` and + # consumed by the streaming branch of ``_send_prompt_to_target_async``. The + # presence of a conversation_id key signals "this conversation is in streaming + # mode" so the target can route requests to the swap-and-respond path. + self._streaming_state: dict[str, _StreamingConversationState] = {} def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" @@ -288,13 +311,13 @@ def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, An }, "format": { "type": "audio/pcm", - "rate": 24000, + "rate": self.SAMPLE_RATE_HZ, }, }, "output": { "format": { "type": "audio/pcm", - "rate": 24000, + "rate": self.SAMPLE_RATE_HZ, } }, }, @@ -365,6 +388,10 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me """ Asynchronously send a message to the OpenAI realtime target. + Routes to the streaming swap-and-respond path when streaming state is + registered for the conversation; otherwise dispatches to the atomic + send_audio / send_text path. + Args: normalized_conversation (list[Message]): The full conversation (history + current message) after running the normalization @@ -378,32 +405,85 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me """ message = normalized_conversation[-1] conversation_id = message.message_pieces[0].conversation_id - if conversation_id not in self._existing_conversation: - connection = await self.connect_async(conversation_id=conversation_id) - self._existing_conversation[conversation_id] = connection - - # Only send config when creating a new connection - await self.send_config(conversation_id=conversation_id, conversation=normalized_conversation) - # Give the server a moment to process the session update - await asyncio.sleep(0.5) - request = message.message_pieces[0] - response_type = request.converted_value_data_type - # Order of messages sent varies based on the data format of the prompt - if response_type == "audio_path": - output_audio_path, result = await self.send_audio_async( - filename=request.converted_value, - conversation_id=conversation_id, - ) + streaming = self._streaming_state.get(conversation_id) + if streaming is not None: + # Streaming swap-and-respond path. The lock serializes per-turn work so + # back-to-back VAD commits cannot race on the dispatcher's single turn slot. + async with streaming.turn_lock: + if request.converted_value_data_type != "audio_path": + raise ValueError( + f"Streaming realtime requests must carry audio_path, got {request.converted_value_data_type!r}." + ) - elif response_type == "text": - output_audio_path, result = await self.send_text_async( - text=request.converted_value, - conversation_id=conversation_id, - ) + connection = self._existing_conversation[conversation_id] + + with wave.open(request.converted_value, "rb") as wav_in: + if ( + wav_in.getnchannels() != 1 + or wav_in.getsampwidth() != 2 + or wav_in.getframerate() != self.SAMPLE_RATE_HZ + ): + raise ValueError( + f"Streaming audio must be mono PCM16 at {self.SAMPLE_RATE_HZ} Hz, got " + f"channels={wav_in.getnchannels()} sampwidth={wav_in.getsampwidth()} " + f"rate={wav_in.getframerate()}." + ) + pcm_bytes = wav_in.readframes(wav_in.getnframes()) + + # Only swap when converters ran. Otherwise the server's raw committed + # buffer is what we want and a swap would be wasted work. + if request.converter_identifiers: + item_id = request.prompt_metadata.get(_REALTIME_COMMITTED_ITEM_ID_KEY) + if not item_id: + raise ValueError( + "Streaming request with converters requires the server's committed " + f"item id in piece.prompt_metadata[{_REALTIME_COMMITTED_ITEM_ID_KEY!r}]." + ) + await self.swap_user_audio_async( + connection=connection, + committed_event=CommittedEvent(item_id=str(item_id)), + converted_pcm=pcm_bytes, + ) + + turn_future = await self.request_response_async( + connection=connection, + dispatcher=streaming.dispatcher, + ) + result: RealtimeTargetResult = await turn_future + output_audio_path = await self.save_audio( + result.audio_bytes, + num_channels=1, + sample_width=2, + sample_rate=self.SAMPLE_RATE_HZ, + ) else: - raise ValueError(f"Unsupported response type: {response_type}") + if conversation_id not in self._existing_conversation: + connection = await self.connect_async(conversation_id=conversation_id) + self._existing_conversation[conversation_id] = connection + + # Only send config when creating a new connection + await self.send_config(conversation_id=conversation_id, conversation=normalized_conversation) + # Give the server a moment to process the session update + await asyncio.sleep(0.5) + + response_type = request.converted_value_data_type + + # Order of messages sent varies based on the data format of the prompt + if response_type == "audio_path": + output_audio_path, result = await self.send_audio_async( + filename=request.converted_value, + conversation_id=conversation_id, + ) + + elif response_type == "text": + output_audio_path, result = await self.send_text_async( + text=request.converted_value, + conversation_id=conversation_id, + ) + else: + raise ValueError(f"Unsupported response type: {response_type}") text_response_piece = construct_response_from_request( request=request, response_text_pieces=[result.flatten_transcripts()], response_type="text" @@ -456,7 +536,18 @@ async def save_audio( async def cleanup_target(self) -> None: """ Disconnects from the Realtime API connections. + + Stops any active streaming dispatchers before closing their underlying + websocket connections so the dispatch loops do not race with connection + shutdown. Safe to call multiple times. """ + for cid, streaming in list(self._streaming_state.items()): + try: + await streaming.dispatcher.stop() + except Exception as e: + logger.warning(f"Error stopping dispatcher for {cid}: {e}") + self._streaming_state = {} + for conversation_id, connection in list(self._existing_conversation.items()): if connection: try: @@ -477,10 +568,19 @@ async def cleanup_conversation(self, conversation_id: str) -> None: """ Disconnects from the Realtime API for a specific conversation. + Stops any active streaming dispatcher for the conversation before closing + the underlying connection. Safe to call when no streaming state exists. + Args: conversation_id (str): The conversation ID to disconnect from. - """ + streaming = self._streaming_state.pop(conversation_id, None) + if streaming is not None: + try: + await streaming.dispatcher.stop() + except Exception as e: + logger.warning(f"Error stopping dispatcher for {conversation_id}: {e}") + connection = self._existing_conversation.get(conversation_id) if connection: try: @@ -601,32 +701,42 @@ async def subscribe_events_async( self, *, connection: Any, + conversation_id: str, on_user_audio_committed: (Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None) = None, ) -> RealtimeEventDispatcher: """ Start consuming events from the connection and route them via the OpenAI dispatcher. - Streaming-style callers (``BargeInAttack``) use this to receive normalized - events (``user_audio_committed``). The returned dispatcher exposes - ``stop()`` to tear down the background task and drain in-flight callback - tasks, and a ``failure`` property that callers can poll between operations - to detect a dead dispatch loop (e.g. websocket closed). Callers should - call ``stop()`` before closing the connection. + Also registers per-conversation streaming state so requests routed through + ``send_prompt_async`` for ``conversation_id`` take the streaming swap-and-respond + path inside ``_send_prompt_to_target_async`` instead of the atomic send_audio / + send_text path. + + The returned dispatcher exposes ``stop()`` to tear down the background task and + drain in-flight callback tasks, and a ``failure`` property that callers can poll + between operations to detect a dead dispatch loop (e.g. websocket closed). Args: connection: Active Realtime API connection from ``self.connect()``. + conversation_id: Conversation id for the realtime session. Used as the key + under which streaming state is registered. on_user_audio_committed: Async callback fired when server VAD finalizes a user audio buffer. Called as a background task. Returns: The started dispatcher. Pass it to ``request_response_async`` for turn futures, poll ``failure`` for dispatch-loop errors, and call ``stop()`` - to tear it down. + (or ``cleanup_conversation``) to tear it down. """ dispatcher = _OpenAIRealtimeDispatcher( connection=connection, on_user_audio_committed=on_user_audio_committed, ) + self._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) + # Register the connection under the same key so cleanup_conversation / + # cleanup_target can find and close it without callers reaching into + # private state. + self._existing_conversation[conversation_id] = connection await dispatcher.start() return dispatcher diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index b8f8cb81b7..b7cab54ae4 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -6,9 +6,6 @@ from __future__ import annotations import asyncio -import os -import tempfile -import wave from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch @@ -16,14 +13,11 @@ from pyrit.executor.attack import BargeInAttack, BargeInAttackContext from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters -from pyrit.identifiers import ComponentIdentifier -from pyrit.models import AttackOutcome -from pyrit.prompt_normalizer import AudioStreamNormalizer, PromptConverterConfiguration +from pyrit.models import AttackOutcome, Message, MessagePiece +from pyrit.prompt_normalizer import PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget -from pyrit.prompt_target.common.realtime_audio import ( - CommittedEvent, - RealtimeTargetResult, -) +from pyrit.prompt_target.common.realtime_audio import CommittedEvent +from pyrit.prompt_target.openai.openai_realtime_target import _REALTIME_COMMITTED_ITEM_ID_KEY if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -114,15 +108,58 @@ async def test_validate_context_requires_audio_chunks(vad_target): # ---- Streaming loop end-to-end --------------------------------------------------------------- -async def test_perform_async_streams_chunks_and_tears_down(vad_target): - """Happy path: connect, send config, subscribe, push chunks, stop, close — no commits.""" - attack = BargeInAttack(objective_target=vad_target) +def _setup_streaming_target(vad_target, *, future_response: Message | None = None) -> AsyncMock: + """ + Mock the streaming-mode surface on ``vad_target`` and return the connection mock. + + Stubs ``connect_async``, ``send_streaming_session_config_async``, ``push_audio_chunk_async``, + ``subscribe_events_async``, ``save_audio``, and ``cleanup_conversation`` so a callback can + be invoked mid-stream without exercising the real target machinery. + """ connection = _mock_connection() vad_target.connect_async = AsyncMock(return_value=connection) vad_target.send_streaming_session_config_async = AsyncMock() vad_target.push_audio_chunk_async = AsyncMock() + vad_target.save_audio = AsyncMock(return_value="/tmp/snapshot.wav") + vad_target.cleanup_conversation = AsyncMock() + return connection + + +def _capture_committed_callback(vad_target, captured: dict[str, Any]) -> None: + """Wire ``subscribe_events_async`` to capture the registered ``on_user_audio_committed``.""" + + async def fake_subscribe(*, connection, conversation_id, on_user_audio_committed): + captured["on_committed"] = on_user_audio_committed + return AsyncMock() + + vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + + +def _stub_send_prompt(attack: BargeInAttack, return_value: Message | None = None) -> AsyncMock: + """Replace the attack's prompt_normalizer.send_prompt_async with an AsyncMock and return it.""" + if return_value is None: + return_value = Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value="ok", + original_value_data_type="text", + converted_value="ok", + converted_value_data_type="text", + conversation_id="any", + ) + ] + ) + send_mock = AsyncMock(return_value=return_value) + attack._prompt_normalizer.send_prompt_async = send_mock + return send_mock + + +async def test_perform_async_streams_chunks_and_tears_down(vad_target): + """Happy path: connect, send config, subscribe, push chunks, then cleanup_conversation — no commits.""" + attack = BargeInAttack(objective_target=vad_target) + connection = _setup_streaming_target(vad_target) dispatcher = AsyncMock() - dispatcher.stop = AsyncMock() vad_target.subscribe_events_async = AsyncMock(return_value=dispatcher) chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] @@ -137,492 +174,197 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): assert vad_target.push_audio_chunk_async.await_count == len(chunks) pushed = [call.kwargs["pcm_bytes"] for call in vad_target.push_audio_chunk_async.await_args_list] assert pushed == chunks - dispatcher.stop.assert_awaited_once() - connection.close.assert_awaited_once() + vad_target.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) assert result.executed_turns == 0 assert result.outcome == AttackOutcome.UNDETERMINED -async def test_perform_async_fires_request_response_on_commit(vad_target): - """A commit event must drive request_response_async and increment the turn counter.""" - attack = BargeInAttack(objective_target=vad_target) - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - - # Capture the registered on_user_audio_committed so we can drive it. +async def test_perform_async_calls_send_prompt_async_on_commit(vad_target): + """A commit must invoke prompt_normalizer.send_prompt_async with an audio_path Message.""" + bump = MagicMock() + bump.get_identifier = MagicMock(return_value=MagicMock()) + converter_config = AttackConverterConfig( + request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), + ) + attack = BargeInAttack(objective_target=vad_target, attack_converter_config=converter_config) + send_mock = _stub_send_prompt(attack) + _setup_streaming_target(vad_target) captured: dict[str, Any] = {} - - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - - expected = RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]) - expected_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - expected_future.set_result(expected) - vad_target.request_response_async = AsyncMock(return_value=expected_future) + _capture_committed_callback(vad_target, captured) async def chunks_then_commit() -> AsyncIterator[bytes]: - yield b"\x00" * 480 - # Drive a fake commit mid-stream. - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_1"))) + yield b"\x05" * 480 + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="item_42"))) ctx = _attack_context(audio_chunks=chunks_then_commit()) with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): result = await attack._perform_async(context=ctx) - vad_target.request_response_async.assert_awaited_once() + send_mock.assert_awaited_once() + kwargs = send_mock.call_args.kwargs + sent_message = kwargs["message"] + assert sent_message.message_pieces[0].converted_value_data_type == "audio_path" + assert sent_message.message_pieces[0].conversation_id == ctx.conversation_id + assert sent_message.message_pieces[0].prompt_metadata[_REALTIME_COMMITTED_ITEM_ID_KEY] == "item_42" + assert kwargs["target"] is vad_target + assert kwargs["request_converter_configurations"] == attack._request_converters + assert kwargs["conversation_id"] == ctx.conversation_id assert result.executed_turns == 1 - assert "1 assistant turn" in (result.outcome_reason or "") -async def test_perform_async_stops_dispatcher_even_on_exception(vad_target): - """If the chunk loop raises, dispatcher.stop() and connection.close() still run.""" +async def test_perform_async_message_carries_snapshot_audio_path(vad_target): + """The audio_path on the user piece must point at the persisted snapshot WAV.""" attack = BargeInAttack(objective_target=vad_target) - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) - dispatcher = AsyncMock() - vad_target.subscribe_events_async = AsyncMock(return_value=dispatcher) - - ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) - - with pytest.raises(RuntimeError, match="push exploded"): - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): - await attack._perform_async(context=ctx) - - dispatcher.stop.assert_awaited_once() - connection.close.assert_awaited_once() - - -# ---- send_streaming_session_config_async (target-side helper added in R4a) ------------------- - - -async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): - """The streaming session config must flip create_response to False on turn_detection.""" - connection = _mock_connection() - await vad_target.send_streaming_session_config_async(connection=connection, system_prompt="hi") - connection.session.update.assert_awaited_once() - config = connection.session.update.call_args.kwargs["session"] - assert config["audio"]["input"]["turn_detection"]["create_response"] is False - - -@patch.dict("os.environ", _CLEAN_ENV) -async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): - """Without server VAD, sending streaming session config must raise.""" - no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") - connection = _mock_connection() - with pytest.raises(ValueError, match="server VAD"): - await no_vad.send_streaming_session_config_async(connection=connection, system_prompt="hi") - - -# Placeholder for R4b tests - - -# ---- Convert-on-commit dance (R4b) ---------------------------------------------------------- - - -def _make_audio_converter(transformer, *, identifier_name: str = "MockAudioConverter"): - """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" - converter = MagicMock() - converter.get_identifier = MagicMock( - return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), - ) - - async def _convert(*, prompt, input_type, start_token=None, end_token=None): - assert input_type == "audio_path" - with wave.open(prompt, "rb") as wf_in: - sample_rate = wf_in.getframerate() - pcm = wf_in.readframes(wf_in.getnframes()) - new_pcm = transformer(pcm) - out_dir = tempfile.mkdtemp() - out_path = os.path.join(out_dir, "out.wav") - with wave.open(out_path, "wb") as wf_out: - wf_out.setnchannels(1) - wf_out.setsampwidth(2) - wf_out.setframerate(sample_rate) - wf_out.writeframes(new_pcm) - result = MagicMock() - result.output_text = out_path - return result - - converter.convert_tokens_async = AsyncMock(side_effect=_convert) - return converter - - -def _converter_config(converters: list[Any]) -> AttackConverterConfig: - """Wrap a list of converters into an AttackConverterConfig.""" - return AttackConverterConfig( - request_converters=PromptConverterConfiguration.from_converters(converters=converters), - ) - - -async def test_perform_async_swaps_raw_item_when_converters_change_audio(vad_target): - """When converters change the audio, the attack must delete the raw item + insert converted.""" - bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.delete_conversation_item_async = AsyncMock() - vad_target.insert_user_audio_async = AsyncMock() - + send_mock = _stub_send_prompt(attack) + connection = _setup_streaming_target(vad_target) + vad_target.save_audio = AsyncMock(return_value="/tmp/persisted_snapshot.wav") captured: dict[str, Any] = {} + _capture_committed_callback(vad_target, captured) - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - - result_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - result_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"])) - vad_target.request_response_async = AsyncMock(return_value=result_future) - - raw_chunk = b"\x05" * 96 # PCM16 sample-aligned + raw_chunk = b"\x07" * 96 async def chunks_then_commit() -> AsyncIterator[bytes]: yield raw_chunk - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_99"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i"))) - ctx = BargeInAttackContext( - params=AttackParameters(objective="obj"), - audio_chunks=chunks_then_commit(), - ) + ctx = _attack_context(audio_chunks=chunks_then_commit()) with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): - result = await attack._perform_async(context=ctx) - - vad_target.delete_conversation_item_async.assert_awaited_once_with(connection=connection, item_id="raw_99") - vad_target.insert_user_audio_async.assert_awaited_once() - inserted_pcm = vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] - assert inserted_pcm == bytes((b + 1) & 0xFF for b in raw_chunk) - vad_target.request_response_async.assert_awaited_once() - assert result.executed_turns == 1 - - -async def test_perform_async_skips_swap_when_no_converters(vad_target): - """Empty converter list: don't delete raw, don't insert converted, just request response.""" - attack = BargeInAttack(objective_target=vad_target) # no converter config - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.delete_conversation_item_async = AsyncMock() - vad_target.insert_user_audio_async = AsyncMock() - - captured: dict[str, Any] = {} - - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - result_future: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - result_future.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) - vad_target.request_response_async = AsyncMock(return_value=result_future) - - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield b"\x00" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_42"))) + await attack._perform_async(context=ctx) - ctx = BargeInAttackContext( - params=AttackParameters(objective="obj"), - audio_chunks=chunks_then_commit(), + # save_audio called with the snapshot PCM; the resulting path lands on the message piece. + save_kwargs_or_args = vad_target.save_audio.call_args + saved_pcm = ( + save_kwargs_or_args.args[0] if save_kwargs_or_args.args else save_kwargs_or_args.kwargs.get("audio_bytes") ) - - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): - result = await attack._perform_async(context=ctx) - - vad_target.delete_conversation_item_async.assert_not_called() - vad_target.insert_user_audio_async.assert_not_called() - vad_target.request_response_async.assert_awaited_once() - assert result.executed_turns == 1 + assert saved_pcm == raw_chunk + piece = send_mock.call_args.kwargs["message"].message_pieces[0] + assert piece.original_value == "/tmp/persisted_snapshot.wav" + assert piece.converted_value == "/tmp/persisted_snapshot.wav" async def test_perform_async_clears_raw_buffer_between_commits(vad_target): - """A commit must snapshot+reset the raw buffer so the next turn doesn't see prior audio.""" - bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - attack = BargeInAttack(objective_target=vad_target, attack_converter_config=_converter_config([bump])) - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.delete_conversation_item_async = AsyncMock() - vad_target.insert_user_audio_async = AsyncMock() - - captured: dict[str, Any] = {} - - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + """Each commit gets fresh PCM: the snapshot saved for turn 2 has no carryover from turn 1.""" + attack = BargeInAttack(objective_target=vad_target) + _stub_send_prompt(attack) + _setup_streaming_target(vad_target) + saved_pcm: list[bytes] = [] - def _future_with(result: RealtimeTargetResult) -> asyncio.Future[RealtimeTargetResult]: - fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - fut.set_result(result) - return fut + async def fake_save_audio(audio_bytes, **_): + saved_pcm.append(audio_bytes) + return f"/tmp/snap_{len(saved_pcm)}.wav" - vad_target.request_response_async = AsyncMock( - side_effect=lambda **_: _future_with(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) - ) + vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + captured: dict[str, Any] = {} + _capture_committed_callback(vad_target, captured) - async def chunks_then_two_commits() -> AsyncIterator[bytes]: + async def chunks_two_commits() -> AsyncIterator[bytes]: yield b"\x01" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_1"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i1"))) yield b"\x02" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_2"))) + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i2"))) - ctx = BargeInAttackContext( - params=AttackParameters(objective="obj"), - audio_chunks=chunks_then_two_commits(), - ) + ctx = _attack_context(audio_chunks=chunks_two_commits()) with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): await attack._perform_async(context=ctx) - insert_calls = vad_target.insert_user_audio_async.await_args_list - assert len(insert_calls) == 2 - assert insert_calls[0].kwargs["pcm_bytes"] == bytes((b + 1) & 0xFF for b in (b"\x01" * 96)) - assert insert_calls[1].kwargs["pcm_bytes"] == bytes((b + 1) & 0xFF for b in (b"\x02" * 96)) - + assert saved_pcm == [b"\x01" * 96, b"\x02" * 96] -async def test_perform_async_uses_target_audio_normalizer(vad_target): - """The attack must delegate audio conversion to the target's audio_normalizer.""" - fake_normalizer = MagicMock(spec=AudioStreamNormalizer) - fake_normalizer.normalize_async = AsyncMock(return_value=(b"\xff" * 96, [])) - vad_target.audio_normalizer = fake_normalizer - attack = BargeInAttack( - objective_target=vad_target, - attack_converter_config=_converter_config([_make_audio_converter(lambda pcm: pcm)]), - ) - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.delete_conversation_item_async = AsyncMock() - vad_target.insert_user_audio_async = AsyncMock() +async def test_perform_async_tracks_last_response_and_turn_count(vad_target): + """AttackResult.last_response is the last Message from send_prompt_async; count matches commits.""" + attack = BargeInAttack(objective_target=vad_target) + responses_in_order = [ + Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value=text, + original_value_data_type="text", + converted_value=text, + converted_value_data_type="text", + conversation_id="x", + ) + ] + ) + for text in ("first", "second", "final") + ] + send_mock = AsyncMock(side_effect=responses_in_order) + attack._prompt_normalizer.send_prompt_async = send_mock + _setup_streaming_target(vad_target) captured: dict[str, Any] = {} + _capture_committed_callback(vad_target, captured) - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - fut.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=[])) - vad_target.request_response_async = AsyncMock(return_value=fut) + async def chunks_three_commits() -> AsyncIterator[bytes]: + for i in range(3): + yield bytes([i + 1]) * 96 + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id=f"i{i}"))) - raw = b"\x05" * 96 - - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield raw - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="raw_z"))) - - ctx = BargeInAttackContext( - params=AttackParameters(objective="obj"), - audio_chunks=chunks_then_commit(), - ) + ctx = _attack_context(audio_chunks=chunks_three_commits()) with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): - await attack._perform_async(context=ctx) + result = await attack._perform_async(context=ctx) - fake_normalizer.normalize_async.assert_awaited_once() - kwargs = fake_normalizer.normalize_async.call_args.kwargs - assert kwargs["pcm_bytes"] == raw - assert kwargs["sample_rate"] == 24000 - vad_target.insert_user_audio_async.assert_awaited_once() - assert vad_target.insert_user_audio_async.call_args.kwargs["pcm_bytes"] == b"\xff" * 96 + assert result.executed_turns == 3 + assert result.last_response is not None + assert result.last_response.converted_value == "final" -# Placeholder for R4c tests +async def test_perform_async_cleans_up_even_on_exception(vad_target): + """If the chunk loop raises, cleanup_conversation still fires.""" + attack = BargeInAttack(objective_target=vad_target) + _setup_streaming_target(vad_target) + vad_target.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) + vad_target.subscribe_events_async = AsyncMock(return_value=AsyncMock()) + ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) -# ---- Per-turn persistence to CentralMemory (R4c) -------------------------------------------- + with pytest.raises(RuntimeError, match="push exploded"): + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + await attack._perform_async(context=ctx) + vad_target.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) -async def _drive_one_audio_turn( - attack, - vad_target, - *, - raw_chunk: bytes, - item_id: str, - turn_result: RealtimeTargetResult, -): - """Helper that runs a single audio-driven turn end-to-end against a mocked target.""" - connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.delete_conversation_item_async = AsyncMock() - vad_target.insert_user_audio_async = AsyncMock() +async def test_perform_async_swallows_callback_exception(vad_target): + """If send_prompt_async raises mid-turn, the session keeps going (no executed turn).""" + attack = BargeInAttack(objective_target=vad_target) + attack._prompt_normalizer.send_prompt_async = AsyncMock(side_effect=RuntimeError("converter blew up")) + _setup_streaming_target(vad_target) captured: dict[str, Any] = {} - - async def fake_subscribe(*, connection, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - fut: asyncio.Future[RealtimeTargetResult] = asyncio.get_event_loop().create_future() - fut.set_result(turn_result) - vad_target.request_response_async = AsyncMock(return_value=fut) + _capture_committed_callback(vad_target, captured) async def chunks_then_commit() -> AsyncIterator[bytes]: - yield raw_chunk - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id=item_id))) - - ctx = BargeInAttackContext( - params=AttackParameters(objective="obj"), - audio_chunks=chunks_then_commit(), - ) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): - return await attack._perform_async(context=ctx) - - -async def test_persists_user_and_assistant_messages_per_turn(vad_target): - """A successful turn writes 1 user piece + 2 assistant pieces sharing the conversation id.""" - attack = BargeInAttack(objective_target=vad_target) - add_calls: list[Any] = [] - mock_memory = MagicMock() - mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: - mock_cm.get_memory_instance.return_value = mock_memory - result = await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_1", - turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"]), - ) - - assert len(add_calls) == 2 - user_msg, assistant_msg = add_calls - assert len(user_msg.message_pieces) == 1 - assert user_msg.message_pieces[0].converted_value_data_type == "audio_path" - assert user_msg.message_pieces[0].conversation_id == result.conversation_id - assert len(assistant_msg.message_pieces) == 2 - piece_types = sorted(p.converted_value_data_type for p in assistant_msg.message_pieces) - assert piece_types == ["audio_path", "text"] - text_piece = next(p for p in assistant_msg.message_pieces if p.converted_value_data_type == "text") - assert text_piece.converted_value == "hello" - - -async def test_persists_interrupted_metadata_on_assistant_pieces(vad_target): - """Interrupted turns mark both assistant pieces with prompt_metadata['interrupted'] = True.""" - attack = BargeInAttack(objective_target=vad_target) - add_calls: list[Any] = [] - mock_memory = MagicMock() - mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: - mock_cm.get_memory_instance.return_value = mock_memory - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_int", - turn_result=RealtimeTargetResult(audio_bytes=b"\xbb" * 96, transcripts=["partial"], interrupted=True), - ) - - assistant_msg = add_calls[1] - for piece in assistant_msg.message_pieces: - assert piece.prompt_metadata.get("interrupted") is True - - -async def test_persists_converter_identifiers_on_user_piece(vad_target): - """Converter identifiers reported by convert_audio_async must land on the user piece.""" - bump = _make_audio_converter( - lambda pcm: bytes((b + 1) & 0xFF for b in pcm), - identifier_name="BumpConverter", - ) - attack = BargeInAttack( - objective_target=vad_target, - attack_converter_config=AttackConverterConfig( - request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), - ), - ) - add_calls: list[Any] = [] - mock_memory = MagicMock() - mock_memory.add_message_to_memory = MagicMock(side_effect=lambda **kw: add_calls.append(kw["request"])) - - with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: - mock_cm.get_memory_instance.return_value = mock_memory - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x05" * 96, - item_id="raw_c", - turn_result=RealtimeTargetResult(audio_bytes=b"", transcripts=[]), - ) + yield b"\x00" * 96 + await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i"))) - user_msg = add_calls[0] - identifiers = user_msg.message_pieces[0].converter_identifiers - assert len(identifiers) == 1 - assert identifiers[0].class_name == "BumpConverter" + ctx = _attack_context(audio_chunks=chunks_then_commit()) + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + result = await attack._perform_async(context=ctx) -async def test_persists_converted_audio_when_converters_changed_bytes(vad_target): - """The user piece's audio_path must point at the converted PCM, not the raw snapshot.""" - bump = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - attack = BargeInAttack( - objective_target=vad_target, - attack_converter_config=AttackConverterConfig( - request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), - ), - ) - saved_calls: list[bytes] = [] + # The callback caught the exception; no turn counted as successful. + assert result.executed_turns == 0 - async def fake_save_audio(audio_bytes, **_): - saved_calls.append(audio_bytes) - return f"/tmp/audio_{len(saved_calls)}.wav" - vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) - mock_memory = MagicMock() - mock_memory.add_message_to_memory = MagicMock() - - raw = b"\x05" * 96 - with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: - mock_cm.get_memory_instance.return_value = mock_memory - await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=raw, - item_id="raw_x", - turn_result=RealtimeTargetResult(audio_bytes=b"\xff" * 96, transcripts=[]), - ) +# ---- send_streaming_session_config_async (target-side helper added in R4a) ------------------- - # save_audio called twice per turn: first for user audio (must be CONVERTED), then assistant audio. - assert len(saved_calls) == 2 - assert saved_calls[0] == bytes((b + 1) & 0xFF for b in raw) - assert saved_calls[1] == b"\xff" * 96 +async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): + """The streaming session config must flip create_response to False on turn_detection.""" + connection = _mock_connection() + await vad_target.send_streaming_session_config_async(connection=connection, system_prompt="hi") + connection.session.update.assert_awaited_once() + config = connection.session.update.call_args.kwargs["session"] + assert config["audio"]["input"]["turn_detection"]["create_response"] is False -async def test_attack_result_last_response_is_final_assistant_text_piece(vad_target): - """AttackResult.last_response must point at the last assistant message's first piece (text).""" - attack = BargeInAttack(objective_target=vad_target) - mock_memory = MagicMock() - mock_memory.add_message_to_memory = MagicMock() - - with patch("pyrit.executor.attack.streaming.barge_in.CentralMemory") as mock_cm: - mock_cm.get_memory_instance.return_value = mock_memory - result = await _drive_one_audio_turn( - attack, - vad_target, - raw_chunk=b"\x00" * 96, - item_id="raw_lr", - turn_result=RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["final answer"]), - ) - assert result.last_response is not None - assert result.last_response.converted_value_data_type == "text" - assert result.last_response.converted_value == "final answer" +@patch.dict("os.environ", _CLEAN_ENV) +async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): + """Without server VAD, sending streaming session config must raise.""" + no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") + connection = _mock_connection() + with pytest.raises(ValueError, match="server VAD"): + await no_vad.send_streaming_session_config_async(connection=connection, system_prompt="hi") diff --git a/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py b/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py deleted file mode 100644 index f48dff68c7..0000000000 --- a/tests/unit/prompt_normalizer/test_audio_stream_normalizer.py +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Unit tests for ``AudioStreamNormalizer``.""" - -import os -import tempfile -import wave -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from pyrit.identifiers import ComponentIdentifier -from pyrit.prompt_normalizer import AudioStreamNormalizer -from pyrit.prompt_normalizer.prompt_converter_configuration import ( - PromptConverterConfiguration, -) - - -def _make_audio_converter(transformer, *, output_sample_rate=24000, identifier_name="MockAudioConverter"): - """Mock audio converter whose convert_tokens_async runs transformer(pcm) and emits a new WAV path.""" - converter = MagicMock() - converter.get_identifier = MagicMock( - return_value=ComponentIdentifier(class_name=identifier_name, class_module="tests.unit.mocks"), - ) - - async def _convert(*, prompt, input_type, start_token=None, end_token=None): - assert input_type == "audio_path" - with wave.open(prompt, "rb") as wf_in: - pcm = wf_in.readframes(wf_in.getnframes()) - new_pcm = transformer(pcm) - out_dir = tempfile.mkdtemp() - out_path = os.path.join(out_dir, "out.wav") - with wave.open(out_path, "wb") as wf_out: - wf_out.setnchannels(1) - wf_out.setsampwidth(2) - wf_out.setframerate(output_sample_rate) - wf_out.writeframes(new_pcm) - result = MagicMock() - result.output_text = out_path - return result - - converter.convert_tokens_async = AsyncMock(side_effect=_convert) - return converter - - -async def test_normalize_async_no_configurations_returns_input(): - normalizer = AudioStreamNormalizer() - pcm = b"\xaa" * 1024 - out, ids = await normalizer.normalize_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=[]) - assert out == pcm - assert ids == [] - - -async def test_normalize_async_empty_pcm_returns_input(): - normalizer = AudioStreamNormalizer() - bump = _make_audio_converter(lambda pcm: pcm) - out, ids = await normalizer.normalize_async( - pcm_bytes=b"", - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump]), - ) - assert out == b"" - assert ids == [] - - -async def test_normalize_async_chains_converters_and_returns_identifiers(): - normalizer = AudioStreamNormalizer() - bump_a = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - bump_b = _make_audio_converter(lambda pcm: bytes((b + 2) & 0xFF for b in pcm)) - - out, ids = await normalizer.normalize_async( - pcm_bytes=b"\x00\x10\x20\x30", - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bump_a, bump_b]), - ) - - assert out == b"\x03\x13\x23\x33" - assert len(ids) == 2 # one identifier per converter that ran - - -async def test_normalize_async_respects_data_type_filter(): - """A configuration with prompt_data_types_to_apply not including audio_path must be skipped.""" - normalizer = AudioStreamNormalizer() - skipped = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) - applied = _make_audio_converter(lambda pcm: bytes((b + 1) & 0xFF for b in pcm)) - - configs = [ - PromptConverterConfiguration(converters=[skipped], prompt_data_types_to_apply=["text"]), - PromptConverterConfiguration(converters=[applied], prompt_data_types_to_apply=["audio_path"]), - ] - out, ids = await normalizer.normalize_async( - pcm_bytes=b"\x00\x10", sample_rate=24000, converter_configurations=configs - ) - - # Only the audio_path-applicable converter ran (+1 not +9). - assert out == b"\x01\x11" - assert len(ids) == 1 - - -async def test_normalize_async_short_circuits_when_all_configs_filtered_out(): - """When every config is text-only, skip the WAV round-trip entirely.""" - normalizer = AudioStreamNormalizer() - text_only = _make_audio_converter(lambda pcm: bytes((b + 9) & 0xFF for b in pcm)) - - configs = [ - PromptConverterConfiguration(converters=[text_only], prompt_data_types_to_apply=["text"]), - ] - pcm = b"\x00\x10\x20\x30" - out, ids = await normalizer.normalize_async(pcm_bytes=pcm, sample_rate=24000, converter_configurations=configs) - - assert out == pcm # bytes unchanged - assert ids == [] - text_only.convert_tokens_async.assert_not_awaited() - - -async def test_normalize_async_rejects_mismatched_sample_rate(): - """Converter output at a different sample rate must raise ValueError.""" - normalizer = AudioStreamNormalizer() - bad = _make_audio_converter(lambda pcm: pcm, output_sample_rate=16000) - with pytest.raises(ValueError, match="incompatible"): - await normalizer.normalize_async( - pcm_bytes=b"\x00" * 1024, - sample_rate=24000, - converter_configurations=PromptConverterConfiguration.from_converters(converters=[bad]), - ) diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index c810587a8e..21db8fc693 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -3,12 +3,14 @@ import asyncio import base64 +import wave from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest from pyrit.exceptions.exception_classes import ServerErrorException +from pyrit.identifiers import ComponentIdentifier from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.common.realtime_audio import ( @@ -16,7 +18,11 @@ RealtimeTargetResult, RealtimeTurnState, ) -from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher +from pyrit.prompt_target.openai.openai_realtime_target import ( + _REALTIME_COMMITTED_ITEM_ID_KEY, + _OpenAIRealtimeDispatcher, + _StreamingConversationState, +) # Env vars that may leak from .env files loaded by other tests in parallel workers. _CLEAN_UNDERLYING_MODEL_ENV = { @@ -979,7 +985,9 @@ async def event_iter(): async def on_committed(event): received.append(event) - dispatcher = await target.subscribe_events_async(connection=connection, on_user_audio_committed=on_committed) + dispatcher = await target.subscribe_events_async( + connection=connection, conversation_id="test_conv", on_user_audio_committed=on_committed + ) try: # Yield until the dispatch loop processes the scripted event. for _ in range(20): @@ -1001,7 +1009,7 @@ async def boom_iter(): connection = MagicMock() connection.__aiter__ = lambda self_: boom_iter() - dispatcher = await target.subscribe_events_async(connection=connection) + dispatcher = await target.subscribe_events_async(connection=connection, conversation_id="test_conv") try: for _ in range(50): if dispatcher.failure is not None: @@ -1053,3 +1061,343 @@ async def test_request_response_async_propagates_register_turn_failure(target): await target.request_response_async(connection=connection, dispatcher=dispatcher) connection.response.create.assert_not_called() + + +# ---- streaming-mode state lifecycle --------------------------------------------- + + +def test_sample_rate_hz_class_constant(): + """SAMPLE_RATE_HZ is the single source of truth for the realtime PCM sample rate.""" + assert RealtimeTarget.SAMPLE_RATE_HZ == 24000 + + +async def test_subscribe_events_async_registers_streaming_state(target): + """Subscription must register per-conversation streaming state keyed by conversation_id.""" + + async def event_iter(): + await asyncio.sleep(0) + return + yield # pragma: no cover + + connection = MagicMock() + connection.__aiter__ = lambda self_: event_iter() + + assert "conv-A" not in target._streaming_state + + dispatcher = await target.subscribe_events_async(connection=connection, conversation_id="conv-A") + try: + assert "conv-A" in target._streaming_state + assert target._streaming_state["conv-A"].dispatcher is dispatcher + # The lock is created lazily-but-default; just verify it's an asyncio.Lock. + assert isinstance(target._streaming_state["conv-A"].turn_lock, asyncio.Lock) + finally: + await dispatcher.stop() + + +async def test_cleanup_conversation_clears_streaming_state(target): + """cleanup_conversation must stop the dispatcher and pop the streaming state entry.""" + dispatcher = AsyncMock() + dispatcher.stop = AsyncMock() + target._streaming_state["conv-B"] = _StreamingConversationState(dispatcher=dispatcher) + target._existing_conversation["conv-B"] = AsyncMock() + + await target.cleanup_conversation("conv-B") + + dispatcher.stop.assert_awaited_once() + assert "conv-B" not in target._streaming_state + assert "conv-B" not in target._existing_conversation + + +async def test_cleanup_conversation_is_safe_without_streaming_state(target): + """cleanup_conversation must not fail when no streaming state was registered.""" + target._existing_conversation["conv-C"] = AsyncMock() + + # Should not raise even though _streaming_state has no entry for conv-C. + await target.cleanup_conversation("conv-C") + assert "conv-C" not in target._existing_conversation + + +async def test_cleanup_target_clears_all_streaming_state(target): + """cleanup_target must stop every active dispatcher before closing connections.""" + dispatcher_a = AsyncMock() + dispatcher_a.stop = AsyncMock() + dispatcher_b = AsyncMock() + dispatcher_b.stop = AsyncMock() + target._streaming_state["conv-X"] = _StreamingConversationState(dispatcher=dispatcher_a) + target._streaming_state["conv-Y"] = _StreamingConversationState(dispatcher=dispatcher_b) + target._existing_conversation["conv-X"] = AsyncMock() + target._existing_conversation["conv-Y"] = AsyncMock() + + await target.cleanup_target() + + dispatcher_a.stop.assert_awaited_once() + dispatcher_b.stop.assert_awaited_once() + assert target._streaming_state == {} + assert target._existing_conversation == {} + + +async def test_cleanup_target_swallows_dispatcher_stop_errors(target): + """A failing dispatcher.stop() must not prevent cleanup_target from proceeding.""" + bad_dispatcher = AsyncMock() + bad_dispatcher.stop = AsyncMock(side_effect=RuntimeError("already stopped")) + target._streaming_state["conv-Z"] = _StreamingConversationState(dispatcher=bad_dispatcher) + connection = AsyncMock() + target._existing_conversation["conv-Z"] = connection + + await target.cleanup_target() # must not raise + + bad_dispatcher.stop.assert_awaited_once() + connection.close.assert_awaited_once() + assert target._streaming_state == {} + assert target._existing_conversation == {} + + +# ---- _send_streaming_turn_async / send_prompt routing ----------------------- + + +def _write_wav( + path: Any, + *, + rate: int = 24000, + channels: int = 1, + sampwidth: int = 2, + pcm: bytes = b"\x00" * 96, +) -> str: + """Write a small WAV file at ``path`` and return the path as a string.""" + with wave.open(str(path), "wb") as w: + w.setnchannels(channels) + w.setsampwidth(sampwidth) + w.setframerate(rate) + w.writeframes(pcm) + return str(path) + + +def _make_streaming_request( + *, + conversation_id: str, + wav_path: str, + converter_identifiers: list | None = None, + committed_item_id: str | None = None, +) -> Message: + """Construct a streaming-mode request Message matching the attack's contract.""" + metadata: dict[str, Any] = {} + if committed_item_id is not None: + metadata[_REALTIME_COMMITTED_ITEM_ID_KEY] = committed_item_id + piece = MessagePiece( + role="user", + original_value=wav_path, + original_value_data_type="audio_path", + converted_value=wav_path, + converted_value_data_type="audio_path", + conversation_id=conversation_id, + prompt_metadata=metadata or None, + converter_identifiers=converter_identifiers or [], + ) + return Message(message_pieces=[piece]) + + +def _register_streaming(target, conversation_id: str) -> tuple[MagicMock, AsyncMock]: + """Register streaming state for a conversation; return (dispatcher, connection).""" + dispatcher = MagicMock() + connection = AsyncMock() + target._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) + target._existing_conversation[conversation_id] = connection + return dispatcher, connection + + +async def test_send_prompt_routes_streaming_when_state_registered(target, tmp_path): + """When streaming state is registered, the streaming branch runs (no atomic send).""" + _register_streaming(target, "conv-R") + wav_path = _write_wav(tmp_path / "in.wav") + message = _make_streaming_request(conversation_id="conv-R", wav_path=wav_path) + + target.swap_user_audio_async = AsyncMock() + target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + completed_future: asyncio.Future = asyncio.get_running_loop().create_future() + completed_future.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=["ok"])) + target.request_response_async = AsyncMock(return_value=completed_future) + target.send_audio_async = AsyncMock() + target.send_text_async = AsyncMock() + + responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) + + target.request_response_async.assert_awaited_once() + target.send_audio_async.assert_not_called() + target.send_text_async.assert_not_called() + assert len(responses) == 1 + + +async def test_send_prompt_uses_atomic_path_when_no_streaming_state(target, tmp_path): + """Without streaming state, the atomic send_audio_async path runs as before.""" + wav_path = _write_wav(tmp_path / "in.wav") + piece = MessagePiece( + role="user", + original_value=wav_path, + original_value_data_type="audio_path", + converted_value=wav_path, + converted_value_data_type="audio_path", + conversation_id="conv-A", + ) + message = Message(message_pieces=[piece]) + + target.swap_user_audio_async = AsyncMock() + target.request_response_async = AsyncMock() + target.connect_async = AsyncMock(return_value=AsyncMock()) + target.send_config = AsyncMock() + target.send_audio_async = AsyncMock( + return_value=("/tmp/out.wav", RealtimeTargetResult(audio_bytes=b"", transcripts=["hi"])), + ) + + await target._send_prompt_to_target_async(normalized_conversation=[message]) + + target.send_audio_async.assert_awaited_once() + target.swap_user_audio_async.assert_not_called() + target.request_response_async.assert_not_called() + + +async def test_streaming_send_swaps_when_converters_ran(target, tmp_path): + """When converter_identifiers is non-empty, the swap call fires with the converted PCM.""" + _, connection = _register_streaming(target, "conv-S") + converted_pcm = b"\x11\x22" * 48 + wav_path = _write_wav(tmp_path / "converted.wav", pcm=converted_pcm) + + converter_id = ComponentIdentifier(class_name="FakeConverter", class_module="tests.fake") + message = _make_streaming_request( + conversation_id="conv-S", + wav_path=wav_path, + converter_identifiers=[converter_id], + committed_item_id="item_xyz", + ) + + target.swap_user_audio_async = AsyncMock() + target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + completed_future: asyncio.Future = asyncio.get_running_loop().create_future() + completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"])) + target.request_response_async = AsyncMock(return_value=completed_future) + + responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) + + target.swap_user_audio_async.assert_awaited_once() + swap_kwargs = target.swap_user_audio_async.call_args.kwargs + assert swap_kwargs["connection"] is connection + assert swap_kwargs["committed_event"].item_id == "item_xyz" + assert swap_kwargs["converted_pcm"] == converted_pcm + assert responses[0].message_pieces[0].converted_value == "hello" + assert responses[0].message_pieces[1].converted_value == "/tmp/resp.wav" + + +async def test_streaming_send_skips_swap_when_no_converters(target, tmp_path): + """With no converters, the server's raw committed buffer is used: no swap.""" + _register_streaming(target, "conv-N") + wav_path = _write_wav(tmp_path / "raw.wav") + message = _make_streaming_request( + conversation_id="conv-N", + wav_path=wav_path, + converter_identifiers=[], + ) + + target.swap_user_audio_async = AsyncMock() + target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + completed_future: asyncio.Future = asyncio.get_running_loop().create_future() + completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"])) + target.request_response_async = AsyncMock(return_value=completed_future) + + await target._send_prompt_to_target_async(normalized_conversation=[message]) + + target.swap_user_audio_async.assert_not_called() + target.request_response_async.assert_awaited_once() + + +async def test_streaming_send_requires_item_id_when_converters_ran(target, tmp_path): + """A streaming request with converters but no committed item id is a contract violation.""" + _register_streaming(target, "conv-X") + wav_path = _write_wav(tmp_path / "in.wav") + converter_id = ComponentIdentifier(class_name="FakeConverter", class_module="tests.fake") + message = _make_streaming_request( + conversation_id="conv-X", + wav_path=wav_path, + converter_identifiers=[converter_id], + committed_item_id=None, + ) + + with pytest.raises(ValueError, match="committed item id"): + await target._send_prompt_to_target_async(normalized_conversation=[message]) + + +async def test_streaming_send_rejects_wrong_audio_format(target, tmp_path): + """Converters must preserve mono PCM16 @ SAMPLE_RATE_HZ; mismatches raise.""" + _register_streaming(target, "conv-F") + wav_path = _write_wav(tmp_path / "bad.wav", rate=16000) # wrong rate + message = _make_streaming_request(conversation_id="conv-F", wav_path=wav_path) + + with pytest.raises(ValueError, match="mono PCM16"): + await target._send_prompt_to_target_async(normalized_conversation=[message]) + + +async def test_send_prompt_propagates_interrupted_metadata_for_streaming(target, tmp_path): + """When the realtime turn future resolves with interrupted=True, both response pieces gain the flag.""" + _register_streaming(target, "conv-I") + wav_path = _write_wav(tmp_path / "in.wav") + message = _make_streaming_request(conversation_id="conv-I", wav_path=wav_path) + + target.save_audio = AsyncMock(return_value="/tmp/partial.wav") + completed_future: asyncio.Future = asyncio.get_running_loop().create_future() + completed_future.set_result( + RealtimeTargetResult(audio_bytes=b"\xaa" * 32, transcripts=["partial"], interrupted=True), + ) + target.request_response_async = AsyncMock(return_value=completed_future) + + responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) + + assert len(responses) == 1 + text_piece, audio_piece = responses[0].message_pieces + assert text_piece.prompt_metadata.get("interrupted") is True + assert audio_piece.prompt_metadata.get("interrupted") is True + + +async def test_streaming_send_rejects_non_audio_piece(target, tmp_path): + """A text-typed piece routed to the streaming branch must surface a clear error.""" + _register_streaming(target, "conv-T") + piece = MessagePiece( + role="user", + original_value="hello", + original_value_data_type="text", + converted_value="hello", + converted_value_data_type="text", + conversation_id="conv-T", + ) + message = Message(message_pieces=[piece]) + + with pytest.raises(ValueError, match="audio_path"): + await target._send_prompt_to_target_async(normalized_conversation=[message]) + + +async def test_streaming_send_serializes_via_turn_lock(target, tmp_path): + """Two concurrent turns on the same conversation must run sequentially under the lock.""" + _register_streaming(target, "conv-L") + wav_path = _write_wav(tmp_path / "in.wav") + message = _make_streaming_request(conversation_id="conv-L", wav_path=wav_path) + + target.save_audio = AsyncMock(return_value="/tmp/r.wav") + active = 0 + max_concurrent = 0 + + async def fake_request_response(*, connection, dispatcher): + nonlocal active, max_concurrent + active += 1 + max_concurrent = max(max_concurrent, active) + # Yield control so a second turn would interleave if the lock weren't held. + await asyncio.sleep(0.01) + active -= 1 + fut: asyncio.Future = asyncio.get_running_loop().create_future() + fut.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 32, transcripts=["ok"])) + return fut + + target.request_response_async = AsyncMock(side_effect=fake_request_response) + + await asyncio.gather( + target._send_prompt_to_target_async(normalized_conversation=[message]), + target._send_prompt_to_target_async(normalized_conversation=[message]), + ) + + assert max_concurrent == 1 From 87c6b93acd7d6a3ccc9d70664a192f31d5afc833 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 26 May 2026 18:04:04 -0400 Subject: [PATCH 23/47] Replace BargeInAttackContext.system_prompt with prepended_conversation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/executor/attack/barge_in_attack.py | 6 +++-- pyrit/executor/attack/streaming/barge_in.py | 7 +++--- .../openai/openai_realtime_target.py | 9 +++++-- .../attack/streaming/test_barge_in.py | 24 +++++++++++++++++-- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py index d96899e910..b3dff07b60 100644 --- a/doc/code/executor/attack/barge_in_attack.py +++ b/doc/code/executor/attack/barge_in_attack.py @@ -202,5 +202,7 @@ async def barge_in_source(): # - **TTS converter**: generate audio from text prompts dynamically # - **Live microphone**: use `sounddevice` or similar; yield what the mic produces # -# For adaptive attacks (e.g., score-driven strategies), subclass `BargeInAttack` and override -# `_perform_async` to interleave turn observation with chunk generation. +# For feedback-driven attacks — for example, scoring each assistant turn and choosing +# to barge in with follow-up audio only when the response shows incomplete refusal — +# subclass `BargeInAttack` and override `_perform_async` to interleave turn observation +# with chunk generation. diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 920edaeabf..27cc4d6234 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -41,11 +41,10 @@ @dataclass class BargeInAttackContext(AttackContext[AttackParamsT]): - """Context for a streaming barge-in attack with audio chunk source and session config.""" + """Context for a streaming barge-in attack with an audio chunk source.""" conversation_id: str = field(default_factory=lambda: str(uuid.uuid4())) audio_chunks: AsyncIterator[bytes] | None = None - system_prompt: str = "You are a helpful AI assistant" @dataclass @@ -178,7 +177,9 @@ async def on_committed(event: CommittedEvent) -> None: ) try: - await target.send_streaming_session_config_async(connection=connection, system_prompt=context.system_prompt) + await target.send_streaming_session_config_async( + connection=connection, conversation=context.prepended_conversation + ) async for chunk in context.audio_chunks: if chunk: diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 69a08b6ef1..71b7ea208c 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -771,7 +771,9 @@ async def request_response_async( await connection.response.create() return state.completion - async def send_streaming_session_config_async(self, *, connection: Any, system_prompt: str) -> None: + async def send_streaming_session_config_async( + self, *, connection: Any, conversation: list[Message] | None = None + ) -> None: """ Configure the realtime session for streaming use: server VAD with manual response creation. @@ -781,7 +783,9 @@ async def send_streaming_session_config_async(self, *, connection: Any, system_p Args: connection: Active Realtime API connection. - system_prompt: System prompt for the realtime session. + conversation: Optional conversation history; if its first message is a system + message, its text becomes the session's instructions. Defaults to None, + in which case the default system prompt is used. Raises: ValueError: If the target was constructed without server VAD. @@ -791,6 +795,7 @@ async def send_streaming_session_config_async(self, *, connection: Any, system_p "send_streaming_session_config_async requires server VAD; " "construct RealtimeTarget(server_vad=True) or pass a ServerVadConfig." ) + system_prompt = self._get_system_prompt_from_conversation(conversation=conversation or []) config = self._set_system_prompt_and_config_vars(system_prompt=system_prompt) turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") if turn_detection is not None: diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index b7cab54ae4..00a6b26fb2 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -355,7 +355,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): """The streaming session config must flip create_response to False on turn_detection.""" connection = _mock_connection() - await vad_target.send_streaming_session_config_async(connection=connection, system_prompt="hi") + await vad_target.send_streaming_session_config_async(connection=connection) connection.session.update.assert_awaited_once() config = connection.session.update.call_args.kwargs["session"] assert config["audio"]["input"]["turn_detection"]["create_response"] is False @@ -367,4 +367,24 @@ async def test_send_streaming_session_config_async_requires_server_vad(sqlite_in no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") connection = _mock_connection() with pytest.raises(ValueError, match="server VAD"): - await no_vad.send_streaming_session_config_async(connection=connection, system_prompt="hi") + await no_vad.send_streaming_session_config_async(connection=connection) + + +async def test_send_streaming_session_config_async_uses_system_message_from_conversation(vad_target): + """If the prepended conversation begins with a system message, it becomes session instructions.""" + connection = _mock_connection() + system_msg = Message( + message_pieces=[ + MessagePiece( + role="system", + original_value="You are a strict assistant.", + original_value_data_type="text", + converted_value="You are a strict assistant.", + converted_value_data_type="text", + conversation_id="x", + ) + ] + ) + await vad_target.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) + config = connection.session.update.call_args.kwargs["session"] + assert config["instructions"] == "You are a strict assistant." From 69dfe87637b202e35ea6354afbb9d192f512d69a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 26 May 2026 18:06:51 -0400 Subject: [PATCH 24/47] Bridge audio_start_ms from speech_started to committed event Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 76 +++++++ pyrit/prompt_target/common/realtime_audio.py | 4 + .../openai/openai_realtime_target.py | 20 +- .../attack/streaming/test_barge_in.py | 197 ++++++++++++++++++ .../target/test_realtime_target.py | 82 ++++++++ 5 files changed, 378 insertions(+), 1 deletion(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 27cc4d6234..31e3c5beb3 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -53,6 +53,57 @@ class _BargeInRunState: raw_buffer: bytearray = field(default_factory=bytearray) turn_tasks: list[asyncio.Task[None]] = field(default_factory=list) + # Session-time (in ms) at which the current buffer started accumulating. Used to + # convert the server's session-relative ``audio_start_ms`` into a buffer-relative + # offset for trimming. 0 at session start; advances by ``audio_end_ms`` of each + # commit, but since the server omits ``audio_end_ms`` we approximate it as + # ``audio_start_ms + buffer_speech_duration``. In practice we just track the most + # recent commit's reported start so the next turn's trim is relative to it. + buffer_start_session_ms: int = 0 + + +def _trim_snapshot_to_speech( + *, + raw_buffer: bytes, + sample_rate_hz: int, + audio_start_ms: int | None, + prefix_padding_ms: int, + sample_width_bytes: int = 2, + channels: int = 1, +) -> bytes: + """ + Trim leading pre-speech silence from a raw mic snapshot. + + Server VAD reports where speech began via ``audio_start_ms``. The local + accumulator captures every chunk pushed since the last commit — including + seconds of pre-speech silence — so without a trim the converted audio that + gets swapped into the server's committed item would be much longer than + what the server actually committed, causing the model to hear leading silence. + + Args: + raw_buffer: PCM16 mono audio for the current buffer (all bytes pushed since the last commit). + sample_rate_hz: PCM sample rate in Hz. + audio_start_ms: Server's ``audio_start_ms`` offset, or None when unknown. + prefix_padding_ms: Bytes to keep before ``audio_start_ms`` so we don't chop the speech onset + (typically matches server VAD's ``prefix_padding_ms``). + sample_width_bytes: Bytes per sample (2 for PCM16). + channels: Audio channels (1 for mono). + + Returns: + The trimmed buffer; returns ``raw_buffer`` unchanged when ``audio_start_ms`` + is None or 0, or when the computed trim would leave nothing. + """ + if not audio_start_ms or audio_start_ms <= 0: + return raw_buffer + bytes_per_ms = sample_rate_hz * sample_width_bytes * channels // 1000 + start_ms = max(0, audio_start_ms - prefix_padding_ms) + start_byte = start_ms * bytes_per_ms + # Align to sample frame boundary so the trimmed buffer doesn't start mid-sample. + frame_bytes = sample_width_bytes * channels + start_byte -= start_byte % frame_bytes + if start_byte >= len(raw_buffer): + return raw_buffer + return raw_buffer[start_byte:] class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): @@ -222,6 +273,31 @@ async def _handle_committed_turn_async( snapshot = bytes(state.raw_buffer) state.raw_buffer.clear() + # Convert the server's session-relative audio_start_ms into a buffer-relative + # offset, then trim leading pre-speech silence. Without this, the converted + # audio that gets swapped into the server's committed item is several seconds + # longer than what server VAD actually committed, and the model hears the + # leading silence (often dominant) when converters are active. + bytes_per_ms = target.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono + original_buffer_duration_ms = len(snapshot) // bytes_per_ms if bytes_per_ms else 0 + + buffer_relative_audio_start_ms: int | None = None + if event.audio_start_ms is not None: + buffer_relative_audio_start_ms = event.audio_start_ms - state.buffer_start_session_ms + + server_vad = target.server_vad_config + prefix_padding_ms = server_vad.prefix_padding_ms if server_vad is not None else 0 + snapshot = _trim_snapshot_to_speech( + raw_buffer=snapshot, + sample_rate_hz=target.SAMPLE_RATE_HZ, + audio_start_ms=buffer_relative_audio_start_ms, + prefix_padding_ms=prefix_padding_ms, + ) + + # Advance session-time bookkeeping for the next turn. Uses the ORIGINAL (pre-trim) + # buffer duration since the server saw every byte we pushed. + state.buffer_start_session_ms += original_buffer_duration_ms + # PromptNormalizer.send_prompt_async needs an audio_path-shaped Message, # so persist the snapshot to a durable WAV before wrapping. snapshot_path = await target.save_audio( diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index fb2d989d25..615596904e 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -99,6 +99,10 @@ def __init__( self._task: asyncio.Task[None] | None = None self._callback_tasks: set[asyncio.Task[None]] = set() self._failure: BaseException | None = None + # Server VAD reports audio_start_ms on speech_started but omits it from + # input_audio_buffer.committed. Concrete subclasses capture it here when + # speech_started fires and read it back on commit. + self._pending_speech_start_ms: int | None = None @property def failure(self) -> BaseException | None: diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 71b7ea208c..0a18536f3a 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -163,6 +163,11 @@ def __init__( # mode" so the target can route requests to the swap-and-respond path. self._streaming_state: dict[str, _StreamingConversationState] = {} + @property + def server_vad_config(self) -> ServerVadConfig | None: + """Server VAD configuration in effect for this target, or None if server VAD is disabled.""" + return self._server_vad + def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" self.endpoint_environment_variable = "OPENAI_REALTIME_ENDPOINT" @@ -1183,15 +1188,28 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" event_type = getattr(event, "type", "") + # Capture audio_start_ms from speech_started for the next committed event. + # The server reports it reliably here but omits it from the commit event itself. + # Do not return — the downstream state-aware branch still needs to fire the + # barge-in cancel when speech starts mid-response. + if event_type == "input_audio_buffer.speech_started": + speech_start = getattr(event, "audio_start_ms", None) + if speech_start is not None: + self._pending_speech_start_ms = speech_start + # Input-side events fire callbacks regardless of whether a turn is registered. if event_type == "input_audio_buffer.committed": item_id = getattr(event, "item_id", None) if item_id is None: return + audio_start_ms = getattr(event, "audio_start_ms", None) + if audio_start_ms is None: + audio_start_ms = self._pending_speech_start_ms + self._pending_speech_start_ms = None self._fire_committed_callback( CommittedEvent( item_id=item_id, - audio_start_ms=getattr(event, "audio_start_ms", None), + audio_start_ms=audio_start_ms, ) ) # Fall through: also include the bookkeeping below (none currently uses committed). diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 00a6b26fb2..1a8c7bbd21 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -13,6 +13,7 @@ from pyrit.executor.attack import BargeInAttack, BargeInAttackContext from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters +from pyrit.executor.attack.streaming.barge_in import _trim_snapshot_to_speech from pyrit.models import AttackOutcome, Message, MessagePiece from pyrit.prompt_normalizer import PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget @@ -388,3 +389,199 @@ async def test_send_streaming_session_config_async_uses_system_message_from_conv await vad_target.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) config = connection.session.update.call_args.kwargs["session"] assert config["instructions"] == "You are a strict assistant." + + +# ---- _trim_snapshot_to_speech (pre-speech silence trim) ------------------------------------- + + +def test_trim_drops_leading_silence_using_audio_start_ms(): + """When audio_start_ms is set, everything before (audio_start_ms - prefix_padding_ms) is trimmed.""" + # 24 kHz mono PCM16 → 48 bytes per ms. 1000 ms of silence + 100 ms of "speech". + silence = b"\x00" * (1000 * 48) + speech = b"\x11" * (100 * 48) + buffer = silence + speech + + trimmed = _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=1000, # speech starts at 1000 ms + prefix_padding_ms=200, # keep 200 ms before speech + ) + + # Expect: dropped 800 ms (1000 - 200) of silence; kept 200 ms silence + 100 ms speech. + assert len(trimmed) == (200 + 100) * 48 + assert trimmed[-len(speech) :] == speech + + +def test_trim_passes_through_when_audio_start_ms_missing(): + """If the server didn't report audio_start_ms, no trim happens.""" + buffer = b"\xff" * 480 + assert ( + _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=None, + prefix_padding_ms=300, + ) + is buffer + ) + + +def test_trim_passes_through_when_audio_start_ms_zero(): + """audio_start_ms == 0 means speech started immediately; no trim.""" + buffer = b"\xff" * 480 + assert ( + _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=0, + prefix_padding_ms=300, + ) + is buffer + ) + + +def test_trim_clamps_when_audio_start_ms_less_than_prefix_padding(): + """audio_start_ms - prefix_padding_ms shouldn't go negative.""" + buffer = b"\xab" * (500 * 48) + trimmed = _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=100, + prefix_padding_ms=300, + ) + # max(0, 100 - 300) = 0 → no bytes dropped. + assert trimmed == buffer + + +def test_trim_aligns_to_sample_boundary(): + """Trim must land on a sample-frame boundary (2 bytes for PCM16 mono) so playback isn't garbled.""" + # Sample rate 8000 Hz → 16 bytes/ms; audio_start_ms=3, prefix=0 → start_byte=48 (aligned). + buffer = bytes(range(256)) * 4 # arbitrary bytes + trimmed = _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=8000, + audio_start_ms=3, + prefix_padding_ms=0, + sample_width_bytes=2, + channels=1, + ) + # 48 bytes is already a frame boundary (48 % 2 == 0). + assert len(trimmed) == len(buffer) - 48 + # Sanity: the trim point is sample-aligned. + assert (len(buffer) - len(trimmed)) % 2 == 0 + + +def test_trim_passes_through_when_computed_start_exceeds_buffer(): + """Safety: if audio_start_ms points past the buffer, return the buffer unchanged.""" + buffer = b"\x00" * 480 # 10 ms at 24 kHz + trimmed = _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=10_000, + prefix_padding_ms=0, + ) + assert trimmed is buffer + + +async def test_perform_async_trims_first_turn_using_audio_start_ms(vad_target): + """Turn 1: buffer_start_session_ms=0, so audio_start_ms is already buffer-relative.""" + from pyrit.prompt_target.common.realtime_audio import ServerVadConfig + + # Pin prefix_padding_ms to a known value so the expected byte count is unambiguous. + vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) + + attack = BargeInAttack(objective_target=vad_target) + send_mock = _stub_send_prompt(attack) + _setup_streaming_target(vad_target) + saved_pcm: list[bytes] = [] + + async def fake_save_audio(audio_bytes, **_): + saved_pcm.append(audio_bytes) + return "/tmp/snap.wav" + + vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + captured: dict[str, Any] = {} + _capture_committed_callback(vad_target, captured) + + # 1000 ms of leading silence + 100 ms speech-like payload at 24 kHz mono PCM16 → 48 bytes/ms. + silence = b"\x00" * (1000 * 48) + speech = b"\x11" * (100 * 48) + + async def chunks_then_commit() -> AsyncIterator[bytes]: + yield silence + speech + # Server says speech started at 1000 ms (session-relative); with prefix_padding_ms=300, drop 700 ms. + await asyncio.create_task( + captured["on_committed"](CommittedEvent(item_id="i", audio_start_ms=1000)), + ) + + ctx = _attack_context(audio_chunks=chunks_then_commit()) + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + await attack._perform_async(context=ctx) + + # Expect save_audio to receive the trimmed snapshot: + # max(0, 1000 - 300) = 700 ms dropped; remaining = 300 ms silence + 100 ms speech = 400 ms. + assert len(saved_pcm) == 1 + assert len(saved_pcm[0]) == 400 * 48 + assert saved_pcm[0].endswith(speech) + send_mock.assert_awaited_once() + + +async def test_perform_async_trims_second_turn_with_session_relative_offset(vad_target): + """Turn 2: audio_start_ms is session-relative; the attack converts it to buffer-relative. + + Without the conversion, a session-relative audio_start_ms larger than the local buffer + would skip the trim (passthrough on out-of-range), letting silence reach the model. + """ + from pyrit.prompt_target.common.realtime_audio import ServerVadConfig + + vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) + + attack = BargeInAttack(objective_target=vad_target) + _stub_send_prompt(attack) + _setup_streaming_target(vad_target) + saved_pcm: list[bytes] = [] + + async def fake_save_audio(audio_bytes, **_): + saved_pcm.append(audio_bytes) + return "/tmp/snap.wav" + + vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + captured: dict[str, Any] = {} + _capture_committed_callback(vad_target, captured) + + silence_500 = b"\x00" * (500 * 48) # 500 ms silence + speech_short = b"\x11" * (100 * 48) # 100 ms speech-like + silence_2000 = b"\x00" * (2000 * 48) # 2000 ms silence (between turns) + speech_long = b"\x22" * (300 * 48) # 300 ms speech-like (turn 2) + + async def two_turns() -> AsyncIterator[bytes]: + # Turn 1: 500 ms silence + 100 ms speech; total local buffer = 600 ms. + yield silence_500 + speech_short + # Server VAD fires commit at session_ms ≈ 600 with audio_start_ms = 500 (session-relative). + await asyncio.create_task( + captured["on_committed"](CommittedEvent(item_id="i1", audio_start_ms=500)), + ) + # Turn 2: 2000 ms silence (since turn 1's commit) + 300 ms speech. + # session_ms_at_speech_start ≈ 600 + 2000 = 2600. + yield silence_2000 + speech_long + await asyncio.create_task( + captured["on_committed"](CommittedEvent(item_id="i2", audio_start_ms=2600)), + ) + + ctx = _attack_context(audio_chunks=two_turns()) + with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + await attack._perform_async(context=ctx) + + assert len(saved_pcm) == 2 + + # Turn 1: buffer_relative_start = 500 - 0 = 500; trim = max(0, 500 - 300) = 200 ms; + # remaining = 300 ms pre-speech-padding + 100 ms speech = 400 ms. + assert len(saved_pcm[0]) == 400 * 48 + assert saved_pcm[0].endswith(speech_short) + + # Turn 2: buffer_start_session_ms advanced by 600 ms (turn 1's full buffer duration). + # buffer_relative_start = 2600 - 600 = 2000; trim = max(0, 2000 - 300) = 1700 ms; + # remaining = 300 ms pre-speech-padding + 300 ms speech = 600 ms. + assert len(saved_pcm[1]) == 600 * 48 + assert saved_pcm[1].endswith(speech_long) diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 21db8fc693..2bc54c4ea4 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -961,6 +961,73 @@ async def test_route_event_committed_event_without_callback_is_noop(): ) +async def test_route_event_speech_started_audio_start_propagates_to_commit(): + """speech_started's audio_start_ms is captured and attached to the next CommittedEvent. + + The OpenAI Realtime server omits audio_start_ms from the input_audio_buffer.committed + event but reports it on speech_started. The dispatcher bridges the two so callbacks + receive the value reliably. + """ + received: list[CommittedEvent] = [] + + async def on_committed(event: CommittedEvent) -> None: + received.append(event) + + connection = AsyncMock() + dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) + + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.speech_started", audio_start_ms=8536), + state=None, + ) + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.committed", item_id="raw_99", audio_start_ms=None), + state=None, + ) + for _ in range(20): + if received: + break + await asyncio.sleep(0.01) + + assert len(received) == 1 + assert received[0].item_id == "raw_99" + assert received[0].audio_start_ms == 8536 + + +async def test_route_event_pending_speech_start_resets_after_commit(): + """After commit fires, the dispatcher clears its captured speech_start so a later + commit (e.g. for a turn whose speech_started never fired) doesn't see stale data.""" + received: list[CommittedEvent] = [] + + async def on_committed(event: CommittedEvent) -> None: + received.append(event) + + connection = AsyncMock() + dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) + + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.speech_started", audio_start_ms=500), + state=None, + ) + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.committed", item_id="i1", audio_start_ms=None), + state=None, + ) + # Second commit without a prior speech_started: must NOT reuse the 500 captured above. + await dispatcher._route_event( + event=_scripted_event("input_audio_buffer.committed", item_id="i2", audio_start_ms=None), + state=None, + ) + for _ in range(20): + if len(received) >= 2: + break + await asyncio.sleep(0.01) + + assert len(received) == 2 + assert received[0].audio_start_ms == 500 + assert received[1].audio_start_ms is None + + # Placeholder for R2 tests @@ -1071,6 +1138,21 @@ def test_sample_rate_hz_class_constant(): assert RealtimeTarget.SAMPLE_RATE_HZ == 24000 +def test_server_vad_config_returns_config_when_enabled(target): + """server_vad_config exposes the underlying ServerVadConfig when server VAD is enabled.""" + target._server_vad = ServerVadConfig(prefix_padding_ms=250, silence_duration_ms=400) + cfg = target.server_vad_config + assert cfg is not None + assert cfg.prefix_padding_ms == 250 + assert cfg.silence_duration_ms == 400 + + +def test_server_vad_config_returns_none_when_disabled(target): + """server_vad_config is None when server VAD is disabled.""" + target._server_vad = None + assert target.server_vad_config is None + + async def test_subscribe_events_async_registers_streaming_state(target): """Subscription must register per-conversation streaming state keyed by conversation_id.""" From 7950de52c7936db30aca789a379f0d47568a45e8 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 27 May 2026 11:53:27 -0400 Subject: [PATCH 25/47] Persist BargeInAttack prepended_conversation via ConversationManager Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 32 +++++++- .../attack/streaming/test_barge_in.py | 82 +++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 31e3c5beb3..fe0f41f38c 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, cast from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults +from pyrit.executor.attack.component.conversation_manager import ConversationManager from pyrit.executor.attack.core.attack_config import AttackConverterConfig from pyrit.executor.attack.core.attack_parameters import AttackParameters, AttackParamsT from pyrit.executor.attack.core.attack_strategy import AttackContext, AttackStrategy @@ -41,7 +42,16 @@ @dataclass class BargeInAttackContext(AttackContext[AttackParamsT]): - """Context for a streaming barge-in attack with an audio chunk source.""" + """ + Context for a streaming barge-in attack with an audio chunk source. + + ``prepended_conversation`` (inherited from ``AttackContext``) is persisted to memory + on setup, but only the leading system message is propagated to the live realtime + session as session instructions. User / assistant turns from the prepended history + are not (yet) pushed through ``conversation.item.create``, so the model conditions + only on the system prompt plus live audio chunks. See follow-up issue for full + realtime-session injection. + """ conversation_id: str = field(default_factory=lambda: str(uuid.uuid4())) audio_chunks: AsyncIterator[bytes] | None = None @@ -154,6 +164,10 @@ def __init__( self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters self._prompt_normalizer = prompt_normalizer or PromptNormalizer() + self._conversation_manager = ConversationManager( + attack_identifier=self.get_identifier(), + prompt_normalizer=self._prompt_normalizer, + ) def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ @@ -172,10 +186,24 @@ def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: async def _setup_async(self, *, context: BargeInAttackContext[Any]) -> None: """ - Set up the attack: nothing beyond ensuring a conversation id is present. + Set up the attack: ensure a conversation id and initialize prepended conversation. + + Merges memory labels and persists ``context.prepended_conversation`` to memory via + ``ConversationManager`` so streaming attacks share the same memory contract as + non-streaming attacks. Note: prepended messages are recorded in memory but are NOT + pushed into the live realtime session beyond the system prompt — the model only + conditions on the system message and live audio chunks. Pushing prepended user / + assistant turns into the websocket session via ``conversation.item.create`` is + tracked as a follow-up. """ if not context.conversation_id: context.conversation_id = str(uuid.uuid4()) + await self._conversation_manager.initialize_context_async( + context=context, + target=self._objective_target, + conversation_id=context.conversation_id, + request_converters=self._request_converters, + ) async def _teardown_async(self, *, context: BargeInAttackContext[Any]) -> None: """No-op teardown — connection / dispatcher are closed inside ``_perform_async``.""" diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 1a8c7bbd21..01e4167f4c 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -106,6 +106,88 @@ async def test_validate_context_requires_audio_chunks(vad_target): attack._validate_context(context=ctx) +# ---- _setup_async + prepended_conversation persistence --------------------------------------- + + +async def test_setup_async_persists_prepended_conversation_to_memory(vad_target): + """Prepended_conversation messages must be written to memory on setup like other attacks do.""" + attack = BargeInAttack(objective_target=vad_target) + sys_msg = Message( + message_pieces=[ + MessagePiece( + role="system", + original_value="You are a strict assistant.", + original_value_data_type="text", + converted_value="You are a strict assistant.", + converted_value_data_type="text", + conversation_id="ignored-by-setup", + ) + ] + ) + user_msg = Message( + message_pieces=[ + MessagePiece( + role="user", + original_value="prior user turn", + original_value_data_type="text", + converted_value="prior user turn", + converted_value_data_type="text", + conversation_id="ignored-by-setup", + ) + ] + ) + assistant_msg = Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value="prior assistant turn", + original_value_data_type="text", + converted_value="prior assistant turn", + converted_value_data_type="text", + conversation_id="ignored-by-setup", + ) + ] + ) + + ctx = BargeInAttackContext( + params=AttackParameters( + objective="o", + prepended_conversation=[sys_msg, user_msg, assistant_msg], + ), + audio_chunks=_aiter([b"\x00" * 96]), + ) + + add_calls: list[Any] = [] + with patch.object(attack._conversation_manager._memory, "add_message_to_memory") as mock_add: + mock_add.side_effect = lambda **kw: add_calls.append(kw["request"]) + await attack._setup_async(context=ctx) + + # All three prepended messages should have been written to memory under the + # attack's conversation_id; assistant role becomes simulated_assistant on storage. + assert len(add_calls) == 3 + storage_roles = [m.message_pieces[0].get_role_for_storage() for m in add_calls] + assert storage_roles == ["system", "user", "simulated_assistant"] + # All three messages share the context's conversation_id post-setup. + for m in add_calls: + assert m.message_pieces[0].conversation_id == ctx.conversation_id + + +async def test_setup_async_no_op_when_prepended_conversation_empty(vad_target): + """Empty prepended_conversation: no memory writes, no crash.""" + attack = BargeInAttack(objective_target=vad_target) + ctx = BargeInAttackContext( + params=AttackParameters(objective="o"), # no prepended_conversation + audio_chunks=_aiter([b"\x00" * 96]), + ) + + add_calls: list[Any] = [] + with patch.object(attack._conversation_manager._memory, "add_message_to_memory") as mock_add: + mock_add.side_effect = lambda **kw: add_calls.append(kw["request"]) + await attack._setup_async(context=ctx) + + assert add_calls == [] + + # ---- Streaming loop end-to-end --------------------------------------------------------------- From d71077275f90ab787f931af5630b1d72c21e52a0 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 27 May 2026 11:56:16 -0400 Subject: [PATCH 26/47] Regenerate barge_in_attack notebook to pick up source changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/code/executor/attack/barge_in_attack.ipynb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index a891a47f85..f9a92ba9dc 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -370,8 +370,10 @@ "- **TTS converter**: generate audio from text prompts dynamically\n", "- **Live microphone**: use `sounddevice` or similar; yield what the mic produces\n", "\n", - "For adaptive attacks (e.g., score-driven strategies), subclass `BargeInAttack` and override\n", - "`_perform_async` to interleave turn observation with chunk generation." + "For feedback-driven attacks — for example, scoring each assistant turn and choosing\n", + "to barge in with follow-up audio only when the response shows incomplete refusal —\n", + "subclass `BargeInAttack` and override `_perform_async` to interleave turn observation\n", + "with chunk generation." ] } ], From 3b9bfad44904d39dd809fd680d0ffa82080f269b Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 27 May 2026 12:25:33 -0400 Subject: [PATCH 27/47] Expose BargeInAttack post-stream wait as configurable constructor arg Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 22 +++++++++----- .../attack/streaming/test_barge_in.py | 30 +++++++++++++------ 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index fe0f41f38c..02109d749e 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -130,10 +130,11 @@ class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): required=frozenset({CapabilityName.STREAMING_BARGE_IN}), ) - #: Maximum time to wait after the chunk source exhausts for any in-flight VAD-committed - #: turn to finish (commit → convert → response.create → response.done → persist). Acts as - #: a safety cap; the attack returns as soon as the last turn actually completes. - _MAX_POST_STREAM_WAIT_SECONDS = 30.0 + #: Default maximum time to wait after the chunk source exhausts for any in-flight + #: VAD-committed turn to finish (commit → convert → response.create → response.done + #: → persist). Acts as a safety cap; the attack returns as soon as the last turn + #: actually completes. Overridable per-instance via ``max_post_stream_wait_seconds``. + DEFAULT_MAX_POST_STREAM_WAIT_SECONDS: ClassVar[float] = 60.0 @apply_defaults def __init__( @@ -142,6 +143,7 @@ def __init__( objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] attack_converter_config: AttackConverterConfig | None = None, prompt_normalizer: PromptNormalizer | None = None, + max_post_stream_wait_seconds: float = DEFAULT_MAX_POST_STREAM_WAIT_SECONDS, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ @@ -152,6 +154,9 @@ def __init__( attack_converter_config: Converters applied to each committed user turn. prompt_normalizer: Normalizer used to apply converters and persist messages. Defaults to a fresh ``PromptNormalizer``. + max_post_stream_wait_seconds: Safety cap on the wait between the chunk source + exhausting and the last in-flight turn finishing. Defaults to 60 seconds. + Bump if a long realtime response is being cancelled at teardown. params_type: Attack parameter dataclass type. """ super().__init__( @@ -168,6 +173,7 @@ def __init__( attack_identifier=self.get_identifier(), prompt_normalizer=self._prompt_normalizer, ) + self._max_post_stream_wait_seconds = max_post_stream_wait_seconds def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ @@ -406,11 +412,11 @@ async def _wait_for_pending_turns_async(self, turn_tasks: list[asyncio.Task[None try: await asyncio.wait_for( asyncio.gather(*turn_tasks, return_exceptions=True), - timeout=self._MAX_POST_STREAM_WAIT_SECONDS, + timeout=self._max_post_stream_wait_seconds, ) except asyncio.TimeoutError: logger.warning( - f"Timed out after {self._MAX_POST_STREAM_WAIT_SECONDS}s waiting for in-flight turn tasks to " - "finish; teardown will cancel them. Increase _MAX_POST_STREAM_WAIT_SECONDS if responses " - "regularly take longer." + f"Timed out after {self._max_post_stream_wait_seconds}s waiting for in-flight turn tasks to " + "finish; teardown will cancel them. Raise max_post_stream_wait_seconds on the attack " + "constructor if responses regularly take longer." ) diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 01e4167f4c..99d05c10e7 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -83,6 +83,18 @@ def test_constructor_succeeds_even_without_server_vad_enabled(sqlite_instance): assert attack.get_objective_target() is no_vad +def test_constructor_default_max_post_stream_wait_seconds(vad_target): + """When not passed, max_post_stream_wait_seconds takes the class default.""" + attack = BargeInAttack(objective_target=vad_target) + assert attack._max_post_stream_wait_seconds == BargeInAttack.DEFAULT_MAX_POST_STREAM_WAIT_SECONDS + + +def test_constructor_accepts_custom_max_post_stream_wait_seconds(vad_target): + """max_post_stream_wait_seconds is configurable per-instance.""" + attack = BargeInAttack(objective_target=vad_target, max_post_stream_wait_seconds=120.0) + assert attack._max_post_stream_wait_seconds == 120.0 + + # ---- Context validation ---------------------------------------------------------------------- @@ -248,7 +260,7 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] ctx = _attack_context(audio_chunks=_aiter(chunks)) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): result = await attack._perform_async(context=ctx) vad_target.connect_async.assert_awaited_once_with(conversation_id=ctx.conversation_id) @@ -281,7 +293,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): result = await attack._perform_async(context=ctx) send_mock.assert_awaited_once() @@ -313,7 +325,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) # save_audio called with the snapshot PCM; the resulting path lands on the message piece. @@ -350,7 +362,7 @@ async def chunks_two_commits() -> AsyncIterator[bytes]: ctx = _attack_context(audio_chunks=chunks_two_commits()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) assert saved_pcm == [b"\x01" * 96, b"\x02" * 96] @@ -387,7 +399,7 @@ async def chunks_three_commits() -> AsyncIterator[bytes]: ctx = _attack_context(audio_chunks=chunks_three_commits()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): result = await attack._perform_async(context=ctx) assert result.executed_turns == 3 @@ -405,7 +417,7 @@ async def test_perform_async_cleans_up_even_on_exception(vad_target): ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) with pytest.raises(RuntimeError, match="push exploded"): - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) vad_target.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) @@ -425,7 +437,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): result = await attack._perform_async(context=ctx) # The callback caught the exception; no turn counted as successful. @@ -598,7 +610,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: ) ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) # Expect save_audio to receive the trimmed snapshot: @@ -652,7 +664,7 @@ async def two_turns() -> AsyncIterator[bytes]: ) ctx = _attack_context(audio_chunks=two_turns()) - with patch.object(attack, "_MAX_POST_STREAM_WAIT_SECONDS", 0): + with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) assert len(saved_pcm) == 2 From 47c4d6d9d07ee20d822679c02f81aee5219d9f9a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 27 May 2026 16:37:15 -0400 Subject: [PATCH 28/47] Tighten BargeInAttack input validation and target typing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 29 +++++--- pyrit/prompt_target/common/realtime_audio.py | 69 ++++++++++++++++++- .../openai/openai_realtime_target.py | 12 +--- .../attack/streaming/test_barge_in.py | 42 ++++++++++- .../target/test_realtime_target.py | 4 +- 5 files changed, 133 insertions(+), 23 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 02109d749e..af4a7deb3b 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -24,9 +24,12 @@ MessagePiece, ) from pyrit.prompt_normalizer import PromptNormalizer +from pyrit.prompt_target.common.realtime_audio import ( + REALTIME_COMMITTED_ITEM_ID_KEY, + StreamingBargeInTarget, +) from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements -from pyrit.prompt_target.openai.openai_realtime_target import _REALTIME_COMMITTED_ITEM_ID_KEY if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -35,7 +38,6 @@ from pyrit.prompt_target.common.realtime_audio import ( CommittedEvent, ) - from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget logger = logging.getLogger(__name__) @@ -102,9 +104,20 @@ def _trim_snapshot_to_speech( Returns: The trimmed buffer; returns ``raw_buffer`` unchanged when ``audio_start_ms`` is None or 0, or when the computed trim would leave nothing. + + Raises: + ValueError: If ``audio_start_ms`` is negative, or if ``sample_rate_hz``, + ``sample_width_bytes``, or ``channels`` is not positive. """ - if not audio_start_ms or audio_start_ms <= 0: + if sample_rate_hz <= 0 or sample_width_bytes <= 0 or channels <= 0: + raise ValueError( + f"sample_rate_hz, sample_width_bytes, and channels must all be positive; " + f"got sample_rate_hz={sample_rate_hz}, sample_width_bytes={sample_width_bytes}, channels={channels}" + ) + if audio_start_ms is None or audio_start_ms == 0: return raw_buffer + if audio_start_ms < 0: + raise ValueError(f"audio_start_ms must be >= 0, got {audio_start_ms}") bytes_per_ms = sample_rate_hz * sample_width_bytes * channels // 1000 start_ms = max(0, audio_start_ms - prefix_padding_ms) start_byte = start_ms * bytes_per_ms @@ -150,7 +163,7 @@ def __init__( Initialize the streaming barge-in attack. Args: - objective_target: Target to attack. Must declare ``STREAMING_BARGE_IN`` capability. + objective_target: Target to attack. Must support ``STREAMING_BARGE_IN`` capability. attack_converter_config: Converters applied to each committed user turn. prompt_normalizer: Normalizer used to apply converters and persist messages. Defaults to a fresh ``PromptNormalizer``. @@ -229,7 +242,7 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR Raises: ValueError: If ``context.audio_chunks`` is ``None``. """ - target = cast("RealtimeTarget", self._objective_target) + target = cast("StreamingBargeInTarget", self._objective_target) if context.audio_chunks is None: raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") @@ -290,7 +303,7 @@ async def _handle_committed_turn_async( event: CommittedEvent, context: BargeInAttackContext[Any], state: _BargeInRunState, - target: RealtimeTarget, + target: StreamingBargeInTarget, ) -> Message: """ Run one convert-and-respond turn for a VAD-committed user audio buffer. @@ -349,13 +362,13 @@ async def _handle_committed_turn_async( converted_value=snapshot_path, converted_value_data_type="audio_path", conversation_id=context.conversation_id, - prompt_metadata={_REALTIME_COMMITTED_ITEM_ID_KEY: event.item_id}, + prompt_metadata={REALTIME_COMMITTED_ITEM_ID_KEY: event.item_id}, ) message = Message(message_pieces=[piece]) return await self._prompt_normalizer.send_prompt_async( message=message, - target=target, + target=self._objective_target, request_converter_configurations=self._request_converters, response_converter_configurations=self._response_converters, conversation_id=context.conversation_id, diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 615596904e..e99d1061bb 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,11 +9,19 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any +from typing import Any, Protocol, runtime_checkable logger = logging.getLogger(__name__) +#: Key under which streaming attacks stash the server-assigned item id of the most +#: recently committed user audio buffer (in ``MessagePiece.prompt_metadata``). Realtime +#: targets read this key in their streaming branch to identify which committed item +#: to delete when swapping in converter-transformed audio. Exposed as a public +#: constant so attacks can reference it without reaching into target internals. +REALTIME_COMMITTED_ITEM_ID_KEY = "_realtime_committed_item_id" + + @dataclass(frozen=True) class ServerVadConfig: """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" @@ -235,3 +243,62 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: Args: state (RealtimeTurnState): The turn whose response should be cancelled. """ + + +@runtime_checkable +class StreamingBargeInTarget(Protocol): + """ + Provider-agnostic surface a streaming barge-in attack requires of its target. + + Captures the methods and attributes ``BargeInAttack`` reads from its objective + target so the attack can be typed against this Protocol rather than a concrete + provider class (e.g. ``RealtimeTarget``). A second realtime provider could + implement this Protocol without subclassing ``RealtimeTarget``. + """ + + #: PCM sample rate in Hz negotiated by the provider's realtime protocol. + SAMPLE_RATE_HZ: int + + @property + def server_vad_config(self) -> "ServerVadConfig | None": + """Server VAD configuration in effect, or None if server VAD is disabled.""" + ... + + async def connect_async(self, conversation_id: str) -> Any: + """Open the realtime connection for ``conversation_id`` and return the connection handle.""" + ... + + async def subscribe_events_async( + self, + *, + connection: Any, + conversation_id: str, + on_user_audio_committed: Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None = None, + ) -> "RealtimeEventDispatcher": + """Spawn a background reader that routes server events and returns the dispatcher.""" + ... + + async def send_streaming_session_config_async( + self, *, connection: Any, conversation: list[Any] | None = None + ) -> None: + """Send the initial ``session.update`` over the wire (system prompt, VAD config, etc.).""" + ... + + async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """Push a PCM16 audio chunk into the server's input buffer.""" + ... + + async def save_audio( + self, + audio_bytes: bytes, + num_channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + output_filename: str | None = None, + ) -> str: + """Persist a PCM buffer to disk and return the file path.""" + ... + + async def cleanup_conversation(self, conversation_id: str) -> None: + """Tear down any per-conversation state held by the target.""" + ... diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 0a18536f3a..b88dceb11e 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -24,6 +24,7 @@ ) from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( + REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent, RealtimeEventDispatcher, RealtimeTargetResult, @@ -43,13 +44,6 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] -#: Key under which the streaming attack stashes the server-side item id of the -#: most recently committed user audio buffer. Read by -#: :meth:`RealtimeTarget._send_streaming_turn_async` to identify which item to -#: delete when swapping in converter-transformed audio. -_REALTIME_COMMITTED_ITEM_ID_KEY = "_realtime_committed_item_id" - - @dataclass class _StreamingConversationState: """ @@ -440,11 +434,11 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me # Only swap when converters ran. Otherwise the server's raw committed # buffer is what we want and a swap would be wasted work. if request.converter_identifiers: - item_id = request.prompt_metadata.get(_REALTIME_COMMITTED_ITEM_ID_KEY) + item_id = request.prompt_metadata.get(REALTIME_COMMITTED_ITEM_ID_KEY) if not item_id: raise ValueError( "Streaming request with converters requires the server's committed " - f"item id in piece.prompt_metadata[{_REALTIME_COMMITTED_ITEM_ID_KEY!r}]." + f"item id in piece.prompt_metadata[{REALTIME_COMMITTED_ITEM_ID_KEY!r}]." ) await self.swap_user_audio_async( connection=connection, diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 99d05c10e7..825ed952c9 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -17,8 +17,7 @@ from pyrit.models import AttackOutcome, Message, MessagePiece from pyrit.prompt_normalizer import PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget -from pyrit.prompt_target.common.realtime_audio import CommittedEvent -from pyrit.prompt_target.openai.openai_realtime_target import _REALTIME_COMMITTED_ITEM_ID_KEY +from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -95,6 +94,13 @@ def test_constructor_accepts_custom_max_post_stream_wait_seconds(vad_target): assert attack._max_post_stream_wait_seconds == 120.0 +def test_realtime_target_satisfies_streaming_barge_in_protocol(vad_target): + """RealtimeTarget structurally implements StreamingBargeInTarget so the cast is safe.""" + from pyrit.prompt_target.common.realtime_audio import StreamingBargeInTarget + + assert isinstance(vad_target, StreamingBargeInTarget) + + # ---- Context validation ---------------------------------------------------------------------- @@ -301,7 +307,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: sent_message = kwargs["message"] assert sent_message.message_pieces[0].converted_value_data_type == "audio_path" assert sent_message.message_pieces[0].conversation_id == ctx.conversation_id - assert sent_message.message_pieces[0].prompt_metadata[_REALTIME_COMMITTED_ITEM_ID_KEY] == "item_42" + assert sent_message.message_pieces[0].prompt_metadata[REALTIME_COMMITTED_ITEM_ID_KEY] == "item_42" assert kwargs["target"] is vad_target assert kwargs["request_converter_configurations"] == attack._request_converters assert kwargs["conversation_id"] == ctx.conversation_id @@ -535,6 +541,36 @@ def test_trim_passes_through_when_audio_start_ms_zero(): ) +def test_trim_raises_on_negative_audio_start_ms(): + """A negative audio_start_ms is a server contract violation, not 'unknown'.""" + buffer = b"\xff" * 480 + with pytest.raises(ValueError, match="audio_start_ms must be >= 0"): + _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=24000, + audio_start_ms=-100, + prefix_padding_ms=300, + ) + + +@pytest.mark.parametrize( + "sample_rate_hz, sample_width_bytes, channels", + [(0, 2, 1), (24000, 0, 1), (24000, 2, 0), (-100, 2, 1), (24000, -1, 1)], +) +def test_trim_raises_on_nonpositive_format_args(sample_rate_hz, sample_width_bytes, channels): + """Non-positive sample rate, width, or channel count signals a misconfiguration; raise.""" + buffer = b"\xff" * 480 + with pytest.raises(ValueError, match="must all be positive"): + _trim_snapshot_to_speech( + raw_buffer=buffer, + sample_rate_hz=sample_rate_hz, + audio_start_ms=100, + prefix_padding_ms=0, + sample_width_bytes=sample_width_bytes, + channels=channels, + ) + + def test_trim_clamps_when_audio_start_ms_less_than_prefix_padding(): """audio_start_ms - prefix_padding_ms shouldn't go negative.""" buffer = b"\xab" * (500 * 48) diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 2bc54c4ea4..fa01aa3995 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -14,12 +14,12 @@ from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.common.realtime_audio import ( + REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent, RealtimeTargetResult, RealtimeTurnState, ) from pyrit.prompt_target.openai.openai_realtime_target import ( - _REALTIME_COMMITTED_ITEM_ID_KEY, _OpenAIRealtimeDispatcher, _StreamingConversationState, ) @@ -1264,7 +1264,7 @@ def _make_streaming_request( """Construct a streaming-mode request Message matching the attack's contract.""" metadata: dict[str, Any] = {} if committed_item_id is not None: - metadata[_REALTIME_COMMITTED_ITEM_ID_KEY] = committed_item_id + metadata[REALTIME_COMMITTED_ITEM_ID_KEY] = committed_item_id piece = MessagePiece( role="user", original_value=wav_path, From 410c59c357f233fe9ee8076ada3243b14891d524 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 27 May 2026 17:45:22 -0400 Subject: [PATCH 29/47] Push BargeInAttack streaming surface onto PromptTarget and refine validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 25 +++---- pyrit/prompt_target/common/prompt_target.py | 70 ++++++++++++++++++- pyrit/prompt_target/common/realtime_audio.py | 63 +---------------- .../attack/streaming/test_barge_in.py | 25 ------- 4 files changed, 81 insertions(+), 102 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index af4a7deb3b..976e00fd97 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -9,7 +9,7 @@ import logging import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults from pyrit.executor.attack.component.conversation_manager import ConversationManager @@ -24,10 +24,7 @@ MessagePiece, ) from pyrit.prompt_normalizer import PromptNormalizer -from pyrit.prompt_target.common.realtime_audio import ( - REALTIME_COMMITTED_ITEM_ID_KEY, - StreamingBargeInTarget, -) +from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -106,15 +103,14 @@ def _trim_snapshot_to_speech( is None or 0, or when the computed trim would leave nothing. Raises: - ValueError: If ``audio_start_ms`` is negative, or if ``sample_rate_hz``, - ``sample_width_bytes``, or ``channels`` is not positive. + ValueError: If ``audio_start_ms`` is negative. """ - if sample_rate_hz <= 0 or sample_width_bytes <= 0 or channels <= 0: - raise ValueError( - f"sample_rate_hz, sample_width_bytes, and channels must all be positive; " - f"got sample_rate_hz={sample_rate_hz}, sample_width_bytes={sample_width_bytes}, channels={channels}" + if audio_start_ms is None: + logger.warning( + "audio_start_ms missing on commit; returning full buffer (converter audio may include leading silence)." ) - if audio_start_ms is None or audio_start_ms == 0: + return raw_buffer + if audio_start_ms == 0: return raw_buffer if audio_start_ms < 0: raise ValueError(f"audio_start_ms must be >= 0, got {audio_start_ms}") @@ -242,10 +238,10 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR Raises: ValueError: If ``context.audio_chunks`` is ``None``. """ - target = cast("StreamingBargeInTarget", self._objective_target) if context.audio_chunks is None: raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") + target = self._objective_target connection = await target.connect_async(conversation_id=context.conversation_id) state = _BargeInRunState() last_response: Message | None = None @@ -261,7 +257,6 @@ async def on_committed(event: CommittedEvent) -> None: event=event, context=context, state=state, - target=target, ) last_response = response executed_turns += 1 @@ -303,7 +298,6 @@ async def _handle_committed_turn_async( event: CommittedEvent, context: BargeInAttackContext[Any], state: _BargeInRunState, - target: StreamingBargeInTarget, ) -> Message: """ Run one convert-and-respond turn for a VAD-committed user audio buffer. @@ -325,6 +319,7 @@ async def _handle_committed_turn_async( # audio that gets swapped into the server's committed item is several seconds # longer than what server VAD actually committed, and the model hears the # leading silence (often dominant) when converters are active. + target = self._objective_target bytes_per_ms = target.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono original_buffer_duration_ms = len(snapshot) // bytes_per_ms if bytes_per_ms else 0 diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index b1ee5caaa2..23c847da6f 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -3,7 +3,7 @@ import abc import logging -from typing import Any, Union, final +from typing import TYPE_CHECKING, Any, ClassVar, Union, final from pyrit.common.deprecation import print_deprecation_message from pyrit.identifiers import ComponentIdentifier, Identifiable @@ -13,6 +13,15 @@ from pyrit.prompt_target.common.target_capabilities import CapabilityName, TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + + from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, + RealtimeEventDispatcher, + ServerVadConfig, + ) + logger = logging.getLogger(__name__) @@ -43,6 +52,11 @@ class PromptTarget(Identifiable): # constructor parameter, which takes precedence over the class-level value. _DEFAULT_CONFIGURATION: TargetConfiguration = TargetConfiguration(capabilities=TargetCapabilities()) + # Streaming-audio sample rate negotiated by a target's realtime protocol. + # Targets that declare ``STREAMING_BARGE_IN`` must override this. The default + # of ``0`` is a sentinel — any non-streaming target reading it indicates misuse. + SAMPLE_RATE_HZ: ClassVar[int] = 0 + def __init__( self, verbose: bool = False, @@ -481,3 +495,57 @@ def _get_json_response_config(self, *, message_piece: MessagePiece) -> _JsonResp raise ValueError(f"This target {target_name} does not support JSON response format.") return config + + # ------------------------------------------------------------------------------------------ + # Streaming-audio surface. + # + # The following methods/properties are the contract between streaming-audio attacks + # (e.g. ``BargeInAttack``) and targets that declare the ``STREAMING_BARGE_IN`` capability. + # The capability flag is the sole gate: when an attack accepts a ``PromptTarget`` whose + # capabilities include ``STREAMING_BARGE_IN``, these overrides are guaranteed to be + # present. Non-streaming targets inherit the default ``NotImplementedError`` bodies. + # ------------------------------------------------------------------------------------------ + + @property + def server_vad_config(self) -> "ServerVadConfig | None": + """Server VAD configuration in effect, or ``None`` if server VAD is disabled.""" + return None + + async def connect_async(self, conversation_id: str) -> Any: + """Open the streaming connection for ``conversation_id`` and return the connection handle.""" + raise NotImplementedError(f"{type(self).__name__} does not support streaming connections.") + + async def subscribe_events_async( + self, + *, + connection: Any, + conversation_id: str, + on_user_audio_committed: "Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None" = None, + ) -> "RealtimeEventDispatcher": + """Spawn a background reader that routes server events and returns the dispatcher.""" + raise NotImplementedError(f"{type(self).__name__} does not support streaming event subscription.") + + async def send_streaming_session_config_async( + self, *, connection: Any, conversation: list[Message] | None = None + ) -> None: + """Send the initial streaming session configuration over the wire.""" + raise NotImplementedError(f"{type(self).__name__} does not support streaming session config.") + + async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """Push a PCM16 audio chunk into the server's input buffer.""" + raise NotImplementedError(f"{type(self).__name__} does not support streaming audio input.") + + async def save_audio( + self, + audio_bytes: bytes, + num_channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + output_filename: str | None = None, + ) -> str: + """Persist a PCM buffer to disk and return the file path.""" + raise NotImplementedError(f"{type(self).__name__} does not support audio persistence.") + + async def cleanup_conversation(self, conversation_id: str) -> None: + """Tear down any per-conversation state held by the target.""" + raise NotImplementedError(f"{type(self).__name__} does not support per-conversation cleanup.") diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index e99d1061bb..0267c7807e 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any, Protocol, runtime_checkable +from typing import Any logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ #: targets read this key in their streaming branch to identify which committed item #: to delete when swapping in converter-transformed audio. Exposed as a public #: constant so attacks can reference it without reaching into target internals. -REALTIME_COMMITTED_ITEM_ID_KEY = "_realtime_committed_item_id" +REALTIME_COMMITTED_ITEM_ID_KEY = "realtime_committed_item_id" @dataclass(frozen=True) @@ -243,62 +243,3 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: Args: state (RealtimeTurnState): The turn whose response should be cancelled. """ - - -@runtime_checkable -class StreamingBargeInTarget(Protocol): - """ - Provider-agnostic surface a streaming barge-in attack requires of its target. - - Captures the methods and attributes ``BargeInAttack`` reads from its objective - target so the attack can be typed against this Protocol rather than a concrete - provider class (e.g. ``RealtimeTarget``). A second realtime provider could - implement this Protocol without subclassing ``RealtimeTarget``. - """ - - #: PCM sample rate in Hz negotiated by the provider's realtime protocol. - SAMPLE_RATE_HZ: int - - @property - def server_vad_config(self) -> "ServerVadConfig | None": - """Server VAD configuration in effect, or None if server VAD is disabled.""" - ... - - async def connect_async(self, conversation_id: str) -> Any: - """Open the realtime connection for ``conversation_id`` and return the connection handle.""" - ... - - async def subscribe_events_async( - self, - *, - connection: Any, - conversation_id: str, - on_user_audio_committed: Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None = None, - ) -> "RealtimeEventDispatcher": - """Spawn a background reader that routes server events and returns the dispatcher.""" - ... - - async def send_streaming_session_config_async( - self, *, connection: Any, conversation: list[Any] | None = None - ) -> None: - """Send the initial ``session.update`` over the wire (system prompt, VAD config, etc.).""" - ... - - async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """Push a PCM16 audio chunk into the server's input buffer.""" - ... - - async def save_audio( - self, - audio_bytes: bytes, - num_channels: int = 1, - sample_width: int = 2, - sample_rate: int = 16000, - output_filename: str | None = None, - ) -> str: - """Persist a PCM buffer to disk and return the file path.""" - ... - - async def cleanup_conversation(self, conversation_id: str) -> None: - """Tear down any per-conversation state held by the target.""" - ... diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 825ed952c9..00c332740c 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -94,13 +94,6 @@ def test_constructor_accepts_custom_max_post_stream_wait_seconds(vad_target): assert attack._max_post_stream_wait_seconds == 120.0 -def test_realtime_target_satisfies_streaming_barge_in_protocol(vad_target): - """RealtimeTarget structurally implements StreamingBargeInTarget so the cast is safe.""" - from pyrit.prompt_target.common.realtime_audio import StreamingBargeInTarget - - assert isinstance(vad_target, StreamingBargeInTarget) - - # ---- Context validation ---------------------------------------------------------------------- @@ -553,24 +546,6 @@ def test_trim_raises_on_negative_audio_start_ms(): ) -@pytest.mark.parametrize( - "sample_rate_hz, sample_width_bytes, channels", - [(0, 2, 1), (24000, 0, 1), (24000, 2, 0), (-100, 2, 1), (24000, -1, 1)], -) -def test_trim_raises_on_nonpositive_format_args(sample_rate_hz, sample_width_bytes, channels): - """Non-positive sample rate, width, or channel count signals a misconfiguration; raise.""" - buffer = b"\xff" * 480 - with pytest.raises(ValueError, match="must all be positive"): - _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=sample_rate_hz, - audio_start_ms=100, - prefix_padding_ms=0, - sample_width_bytes=sample_width_bytes, - channels=channels, - ) - - def test_trim_clamps_when_audio_start_ms_less_than_prefix_padding(): """audio_start_ms - prefix_padding_ms shouldn't go negative.""" buffer = b"\xab" * (500 * 48) From 8a46c3988f04b657c737634edad60005682e633a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 28 May 2026 12:24:44 -0400 Subject: [PATCH 30/47] Refactor BargeInAttack streaming surface to composition and split committed-turn handler Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 113 +++-- pyrit/prompt_target/common/prompt_target.py | 73 +--- pyrit/prompt_target/common/realtime_audio.py | 69 +++- .../openai/openai_realtime_target.py | 385 +++++++++--------- .../attack/streaming/test_barge_in.py | 196 +++++++-- .../target/test_realtime_target.py | 59 +-- 6 files changed, 564 insertions(+), 331 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 976e00fd97..4ed98955a5 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -167,6 +167,10 @@ def __init__( exhausting and the last in-flight turn finishing. Defaults to 60 seconds. Bump if a long realtime response is being cancelled at teardown. params_type: Attack parameter dataclass type. + + Raises: + ValueError: If ``objective_target`` declares ``STREAMING_BARGE_IN`` but did not + wire its ``streaming`` attribute to a ``StreamingHandle``. """ super().__init__( objective_target=objective_target, @@ -174,6 +178,12 @@ def __init__( params_type=params_type, logger=logger, ) + if objective_target.streaming is None: + raise ValueError( + f"{type(objective_target).__name__} declares STREAMING_BARGE_IN capability but did not " + f"wire `self.streaming` to a StreamingHandle. This is a target-implementation bug." + ) + self._streaming = objective_target.streaming attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters @@ -241,8 +251,7 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR if context.audio_chunks is None: raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") - target = self._objective_target - connection = await target.connect_async(conversation_id=context.conversation_id) + connection = await self._streaming.connect_async(conversation_id=context.conversation_id) state = _BargeInRunState() last_response: Message | None = None executed_turns = 0 @@ -263,28 +272,28 @@ async def on_committed(event: CommittedEvent) -> None: except Exception: logger.exception("BargeInAttack turn failed in convert-on-commit handler.") - await target.subscribe_events_async( + await self._streaming.subscribe_events_async( connection=connection, conversation_id=context.conversation_id, on_user_audio_committed=on_committed, ) try: - await target.send_streaming_session_config_async( + await self._streaming.send_streaming_session_config_async( connection=connection, conversation=context.prepended_conversation ) async for chunk in context.audio_chunks: if chunk: state.raw_buffer.extend(chunk) - await target.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) + await self._streaming.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) # Wait for any in-flight committed-turn tasks to finish, capped by a safety timeout. # The chunk source must end with enough trailing silence for server VAD's silence # threshold to fire commit — otherwise the last turn never enters the pipeline. await self._wait_for_pending_turns_async(state.turn_tasks) finally: - await target.cleanup_conversation(context.conversation_id) + await self._streaming.cleanup_conversation(context.conversation_id) return self._build_result( last_response=last_response, @@ -310,63 +319,107 @@ async def _handle_committed_turn_async( Returns: The assistant Message returned by ``send_prompt_async`` for this turn. """ - # Snapshot the locally-accumulated raw PCM and reset for the next turn. - snapshot = bytes(state.raw_buffer) + snapshot, original_buffer_duration_ms = self._snapshot_and_trim(event=event, state=state) + # Centralize state mutations so the helpers stay pure and testable. state.raw_buffer.clear() + state.buffer_start_session_ms += original_buffer_duration_ms + + message = await self._build_message_for_turn( + snapshot=snapshot, + item_id=event.item_id, + conversation_id=context.conversation_id, + ) + return await self._send_via_normalizer(message=message, conversation_id=context.conversation_id) + + def _snapshot_and_trim( + self, + *, + event: CommittedEvent, + state: _BargeInRunState, + ) -> tuple[bytes, int]: + """ + Return a trimmed PCM snapshot for the current buffer plus its original (pre-trim) duration. + + Converts the server's session-relative ``audio_start_ms`` into a buffer-relative offset + and trims leading pre-speech silence. Without this, the converted audio that gets + swapped into the server's committed item would be several seconds longer than what + server VAD actually committed, and the model would hear the leading silence (often + dominant) when converters are active. + + The original duration (pre-trim) is returned so the caller can advance session-time + bookkeeping — the server saw every byte we pushed, not just the trimmed snapshot. + + Returns: + ``(snapshot, original_buffer_duration_ms)``. The caller is responsible for + clearing ``state.raw_buffer`` and advancing ``state.buffer_start_session_ms``. + """ + snapshot = bytes(state.raw_buffer) - # Convert the server's session-relative audio_start_ms into a buffer-relative - # offset, then trim leading pre-speech silence. Without this, the converted - # audio that gets swapped into the server's committed item is several seconds - # longer than what server VAD actually committed, and the model hears the - # leading silence (often dominant) when converters are active. - target = self._objective_target - bytes_per_ms = target.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono + bytes_per_ms = self._streaming.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono original_buffer_duration_ms = len(snapshot) // bytes_per_ms if bytes_per_ms else 0 buffer_relative_audio_start_ms: int | None = None if event.audio_start_ms is not None: buffer_relative_audio_start_ms = event.audio_start_ms - state.buffer_start_session_ms - server_vad = target.server_vad_config + server_vad = self._streaming.server_vad_config prefix_padding_ms = server_vad.prefix_padding_ms if server_vad is not None else 0 snapshot = _trim_snapshot_to_speech( raw_buffer=snapshot, - sample_rate_hz=target.SAMPLE_RATE_HZ, + sample_rate_hz=self._streaming.SAMPLE_RATE_HZ, audio_start_ms=buffer_relative_audio_start_ms, prefix_padding_ms=prefix_padding_ms, ) + return snapshot, original_buffer_duration_ms - # Advance session-time bookkeeping for the next turn. Uses the ORIGINAL (pre-trim) - # buffer duration since the server saw every byte we pushed. - state.buffer_start_session_ms += original_buffer_duration_ms + async def _build_message_for_turn( + self, + *, + snapshot: bytes, + item_id: str, + conversation_id: str, + ) -> Message: + """ + Persist the snapshot to disk and wrap it in an audio_path-shaped Message. + + ``send_prompt_async`` requires a file-backed Message, so the caller persists + the PCM bytes to a durable WAV first. The server's committed ``item_id`` is + stashed in ``prompt_metadata`` so the target's streaming branch can identify + which committed item to swap for converter-transformed audio. - # PromptNormalizer.send_prompt_async needs an audio_path-shaped Message, - # so persist the snapshot to a durable WAV before wrapping. - snapshot_path = await target.save_audio( + Returns: + The constructed Message containing one ``audio_path`` MessagePiece. + """ + snapshot_path = await self._streaming.save_audio( snapshot, num_channels=1, sample_width=2, - sample_rate=target.SAMPLE_RATE_HZ, + sample_rate=self._streaming.SAMPLE_RATE_HZ, ) - # Stash the server-assigned item id so the target's streaming branch - # can swap the raw buffer for converter-transformed audio. piece = MessagePiece( role="user", original_value=snapshot_path, original_value_data_type="audio_path", converted_value=snapshot_path, converted_value_data_type="audio_path", - conversation_id=context.conversation_id, - prompt_metadata={REALTIME_COMMITTED_ITEM_ID_KEY: event.item_id}, + conversation_id=conversation_id, + prompt_metadata={REALTIME_COMMITTED_ITEM_ID_KEY: item_id}, ) - message = Message(message_pieces=[piece]) + return Message(message_pieces=[piece]) + + async def _send_via_normalizer(self, *, message: Message, conversation_id: str) -> Message: + """ + Send a built turn-Message through the normalizer with this attack's converters. + Returns: + The assistant Message returned by ``PromptNormalizer.send_prompt_async``. + """ return await self._prompt_normalizer.send_prompt_async( message=message, target=self._objective_target, request_converter_configurations=self._request_converters, response_converter_configurations=self._response_converters, - conversation_id=context.conversation_id, + conversation_id=conversation_id, attack_identifier=self.get_identifier(), ) diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index 23c847da6f..c476b22842 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +from __future__ import annotations + import abc import logging -from typing import TYPE_CHECKING, Any, ClassVar, Union, final +from typing import TYPE_CHECKING, Any, Union, final from pyrit.common.deprecation import print_deprecation_message from pyrit.identifiers import ComponentIdentifier, Identifiable @@ -14,13 +16,7 @@ from pyrit.prompt_target.common.target_configuration import TargetConfiguration if TYPE_CHECKING: - from collections.abc import Callable, Coroutine - - from pyrit.prompt_target.common.realtime_audio import ( - CommittedEvent, - RealtimeEventDispatcher, - ServerVadConfig, - ) + from pyrit.prompt_target.common.realtime_audio import StreamingHandle logger = logging.getLogger(__name__) @@ -52,10 +48,9 @@ class PromptTarget(Identifiable): # constructor parameter, which takes precedence over the class-level value. _DEFAULT_CONFIGURATION: TargetConfiguration = TargetConfiguration(capabilities=TargetCapabilities()) - # Streaming-audio sample rate negotiated by a target's realtime protocol. - # Targets that declare ``STREAMING_BARGE_IN`` must override this. The default - # of ``0`` is a sentinel — any non-streaming target reading it indicates misuse. - SAMPLE_RATE_HZ: ClassVar[int] = 0 + #: Provider-specific streaming handle, set by targets that declare ``STREAMING_BARGE_IN``. + #: Non-streaming targets leave this as ``None``. + streaming: StreamingHandle | None = None def __init__( self, @@ -495,57 +490,3 @@ def _get_json_response_config(self, *, message_piece: MessagePiece) -> _JsonResp raise ValueError(f"This target {target_name} does not support JSON response format.") return config - - # ------------------------------------------------------------------------------------------ - # Streaming-audio surface. - # - # The following methods/properties are the contract between streaming-audio attacks - # (e.g. ``BargeInAttack``) and targets that declare the ``STREAMING_BARGE_IN`` capability. - # The capability flag is the sole gate: when an attack accepts a ``PromptTarget`` whose - # capabilities include ``STREAMING_BARGE_IN``, these overrides are guaranteed to be - # present. Non-streaming targets inherit the default ``NotImplementedError`` bodies. - # ------------------------------------------------------------------------------------------ - - @property - def server_vad_config(self) -> "ServerVadConfig | None": - """Server VAD configuration in effect, or ``None`` if server VAD is disabled.""" - return None - - async def connect_async(self, conversation_id: str) -> Any: - """Open the streaming connection for ``conversation_id`` and return the connection handle.""" - raise NotImplementedError(f"{type(self).__name__} does not support streaming connections.") - - async def subscribe_events_async( - self, - *, - connection: Any, - conversation_id: str, - on_user_audio_committed: "Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None" = None, - ) -> "RealtimeEventDispatcher": - """Spawn a background reader that routes server events and returns the dispatcher.""" - raise NotImplementedError(f"{type(self).__name__} does not support streaming event subscription.") - - async def send_streaming_session_config_async( - self, *, connection: Any, conversation: list[Message] | None = None - ) -> None: - """Send the initial streaming session configuration over the wire.""" - raise NotImplementedError(f"{type(self).__name__} does not support streaming session config.") - - async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """Push a PCM16 audio chunk into the server's input buffer.""" - raise NotImplementedError(f"{type(self).__name__} does not support streaming audio input.") - - async def save_audio( - self, - audio_bytes: bytes, - num_channels: int = 1, - sample_width: int = 2, - sample_rate: int = 16000, - output_filename: str | None = None, - ) -> str: - """Persist a PCM buffer to disk and return the file path.""" - raise NotImplementedError(f"{type(self).__name__} does not support audio persistence.") - - async def cleanup_conversation(self, conversation_id: str) -> None: - """Tear down any per-conversation state held by the target.""" - raise NotImplementedError(f"{type(self).__name__} does not support per-conversation cleanup.") diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 0267c7807e..05608c6d67 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any +from typing import Any, ClassVar logger = logging.getLogger(__name__) @@ -107,9 +107,10 @@ def __init__( self._task: asyncio.Task[None] | None = None self._callback_tasks: set[asyncio.Task[None]] = set() self._failure: BaseException | None = None - # Server VAD reports audio_start_ms on speech_started but omits it from - # input_audio_buffer.committed. Concrete subclasses capture it here when - # speech_started fires and read it back on commit. + # Optional bridge slot for providers whose protocol reports audio_start_ms on + # ``speech_started`` but omits it from ``input_audio_buffer.committed``. Such + # subclasses capture it here when speech_started fires and read it back on commit. + # Providers that report audio_start_ms directly on commit can ignore this slot. self._pending_speech_start_ms: int | None = None @property @@ -243,3 +244,63 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: Args: state (RealtimeTurnState): The turn whose response should be cancelled. """ + + +class StreamingHandle(ABC): + """ + Provider-agnostic streaming surface owned by targets that declare ``STREAMING_BARGE_IN``. + + A streaming attack (e.g. ``BargeInAttack``) interacts with the target's realtime + transport via ``target.streaming``, not via methods on the target itself. This keeps + the streaming surface out of ``PromptTarget`` while giving the attack a single typed + contract to program against. Concrete realtime providers (OpenAI, Azure, etc.) provide + a concrete ``StreamingHandle`` subclass and assign it to ``self.streaming`` in their + target's ``__init__``. + """ + + #: PCM sample rate in Hz negotiated by the provider's realtime protocol. + SAMPLE_RATE_HZ: ClassVar[int] + + @property + @abstractmethod + def server_vad_config(self) -> "ServerVadConfig | None": + """Server VAD configuration in effect, or ``None`` if server VAD is disabled.""" + + @abstractmethod + async def connect_async(self, conversation_id: str) -> Any: + """Open the streaming connection for ``conversation_id`` and return the connection handle.""" + + @abstractmethod + async def subscribe_events_async( + self, + *, + connection: Any, + conversation_id: str, + on_user_audio_committed: Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None = None, + ) -> "RealtimeEventDispatcher": + """Spawn a background reader that routes server events and returns the dispatcher.""" + + @abstractmethod + async def send_streaming_session_config_async( + self, *, connection: Any, conversation: "list[Any] | None" = None + ) -> None: + """Send the initial streaming session configuration over the wire.""" + + @abstractmethod + async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """Push a PCM16 audio chunk into the server's input buffer.""" + + @abstractmethod + async def save_audio( + self, + audio_bytes: bytes, + num_channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + output_filename: str | None = None, + ) -> str: + """Persist a PCM buffer to disk and return the file path.""" + + @abstractmethod + async def cleanup_conversation(self, conversation_id: str) -> None: + """Tear down any per-conversation state held by the target.""" diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index b88dceb11e..dbb1785e7e 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -30,6 +30,7 @@ RealtimeTargetResult, RealtimeTurnState, ServerVadConfig, + StreamingHandle, ) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration @@ -60,6 +61,192 @@ class _StreamingConversationState: turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock) +class _RealtimeStreamingHandle(StreamingHandle): + """ + OpenAI Realtime API implementation of :class:`StreamingHandle`. + + Owns the websocket-level streaming surface (connect, subscribe, push audio, + config, cleanup) and the audio persistence helper. Holds a back-reference to + its owning :class:`RealtimeTarget` so it can read per-target state + (server VAD config, OpenAI client, conversation registries). + """ + + SAMPLE_RATE_HZ: ClassVar[int] = 24000 + + def __init__(self, target: "RealtimeTarget") -> None: + self._target = target + + @property + def server_vad_config(self) -> ServerVadConfig | None: + return self._target._server_vad + + async def connect_async(self, conversation_id: str) -> Any: + """ + Connect to Realtime API using AsyncOpenAI client and return the realtime connection. + + Returns: + The Realtime API connection. + """ + logger.info(f"Connecting to Realtime API: {self._target._endpoint}") + + client = self._target._get_openai_client() + connection = await client.realtime.connect(model=self._target._model_name).__aenter__() + + logger.info("Successfully connected to AzureOpenAI Realtime API") + return connection + + async def subscribe_events_async( + self, + *, + connection: Any, + conversation_id: str, + on_user_audio_committed: (Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None) = None, + ) -> RealtimeEventDispatcher: + """ + Start consuming events from the connection and route them via the OpenAI dispatcher. + + Also registers per-conversation streaming state so requests routed through + ``send_prompt_async`` for ``conversation_id`` take the streaming swap-and-respond + path inside ``_send_prompt_to_target_async`` instead of the atomic send_audio / + send_text path. + + The returned dispatcher exposes ``stop()`` to tear down the background task and + drain in-flight callback tasks, and a ``failure`` property that callers can poll + between operations to detect a dead dispatch loop (e.g. websocket closed). + + Args: + connection: Active Realtime API connection from ``connect_async``. + conversation_id: Conversation id for the realtime session. Used as the key + under which streaming state is registered. + on_user_audio_committed: Async callback fired when server VAD finalizes + a user audio buffer. Called as a background task. + + Returns: + The started dispatcher. Pass it to ``request_response_async`` for turn + futures, poll ``failure`` for dispatch-loop errors, and call ``stop()`` + (or ``cleanup_conversation``) to tear it down. + """ + dispatcher = _OpenAIRealtimeDispatcher( + connection=connection, + on_user_audio_committed=on_user_audio_committed, + ) + self._target._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) + # Register the connection under the same key so cleanup_conversation / + # cleanup_target can find and close it without callers reaching into + # private state. + self._target._existing_conversation[conversation_id] = connection + await dispatcher.start() + return dispatcher + + async def send_streaming_session_config_async( + self, *, connection: Any, conversation: list[Message] | None = None + ) -> None: + """ + Configure the realtime session for streaming use: server VAD with manual response creation. + + Emits the same session config as the atomic path except ``turn_detection.create_response`` + is forced to False so the streaming attack can swap the raw user audio item for converted + audio before triggering ``response.create``. + + Args: + connection: Active Realtime API connection. + conversation: Optional conversation history; if its first message is a system + message, its text becomes the session's instructions. Defaults to None, + in which case the default system prompt is used. + + Raises: + ValueError: If the target was constructed without server VAD. + """ + if self._target._server_vad is None: + raise ValueError( + "send_streaming_session_config_async requires server VAD; " + "construct RealtimeTarget(server_vad=True) or pass a ServerVadConfig." + ) + system_prompt = self._target._get_system_prompt_from_conversation(conversation=conversation or []) + config = self._target._set_system_prompt_and_config_vars(system_prompt=system_prompt) + turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") + if turn_detection is not None: + turn_detection["create_response"] = False + await connection.session.update(session=config) + + async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: + """ + Append a single PCM16 mono @ 24 kHz audio chunk to the server's input buffer. + + Used by streaming-style callers (e.g. ``BargeInAttack``) that source chunks + from an iterator and want to control commit timing externally. Server VAD, + when enabled on the session, decides when to commit and fire response logic. + Empty buffers are accepted as no-ops. + + Args: + connection: Active Realtime API connection from ``connect_async``. + pcm_bytes: Raw PCM16 mono audio for this chunk. + """ + if not pcm_bytes: + return + audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") + await connection.input_audio_buffer.append(audio=audio_b64) + + async def save_audio( + self, + audio_bytes: bytes, + num_channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + output_filename: Optional[str] = None, + ) -> str: + """ + Save audio bytes to a WAV file. + + Args: + audio_bytes (bytes): Audio bytes to save. + num_channels (int): Number of audio channels. Defaults to 1 for the PCM16 format + sample_width (int): Sample width in bytes. Defaults to 2 for the PCM16 format + sample_rate (int): Sample rate in Hz. Defaults to 16000 Hz for the PCM16 format + output_filename (str): Output filename. If None, a UUID filename will be used. + + Returns: + str: The path to the saved audio file. + """ + data = data_serializer_factory(category="prompt-memory-entries", data_type="audio_path") + + await data.save_formatted_audio( + data=audio_bytes, + output_filename=output_filename, + num_channels=num_channels, + sample_width=sample_width, + sample_rate=sample_rate, + ) + + return data.value + + async def cleanup_conversation(self, conversation_id: str) -> None: + """ + Disconnects from the Realtime API for a specific conversation. + + Stops any active streaming dispatcher for the conversation before closing + the underlying connection. Safe to call when no streaming state exists. + + Args: + conversation_id (str): The conversation ID to disconnect from. + """ + streaming = self._target._streaming_state.pop(conversation_id, None) + if streaming is not None: + try: + await streaming.dispatcher.stop() + except Exception as e: + logger.warning(f"Error stopping dispatcher for {conversation_id}: {e}") + + connection = self._target._existing_conversation.get(conversation_id) + if connection: + try: + await connection.close() + logger.info(f"Disconnected from {self._target._endpoint} with conversation ID: {conversation_id}") + except Exception as e: + logger.warning(f"Error closing connection for {conversation_id}: {e}") + del self._target._existing_conversation[conversation_id] + + class RealtimeTarget(OpenAITarget, PromptTarget): """ A prompt target for Azure OpenAI Realtime API. @@ -94,10 +281,9 @@ class RealtimeTarget(OpenAITarget, PromptTarget): ) ) - #: Sample rate (Hz) for all PCM16 audio exchanged with the Realtime API. - #: The Realtime API negotiates 24 kHz; callers (streaming attacks, audio - #: helpers, normalizers) should read this rather than hard-coding 24000. - SAMPLE_RATE_HZ: ClassVar[int] = 24000 + #: Narrower override of ``PromptTarget.streaming``. ``RealtimeTarget`` always sets + #: this in ``__init__``, so it is guaranteed non-None for downstream callers. + streaming: "_RealtimeStreamingHandle" def __init__( self, @@ -157,10 +343,9 @@ def __init__( # mode" so the target can route requests to the swap-and-respond path. self._streaming_state: dict[str, _StreamingConversationState] = {} - @property - def server_vad_config(self) -> ServerVadConfig | None: - """Server VAD configuration in effect for this target, or None if server VAD is disabled.""" - return self._server_vad + # Composition: streaming surface lives on a dedicated handle so the attack can + # type against the provider-agnostic ``StreamingHandle`` ABC. + self.streaming = _RealtimeStreamingHandle(target=self) def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" @@ -273,21 +458,6 @@ def _get_openai_client(self) -> AsyncOpenAI: return self._realtime_client - async def connect_async(self, conversation_id: str) -> Any: - """ - Connect to Realtime API using AsyncOpenAI client and return the realtime connection. - - Returns: - The Realtime API connection. - """ - logger.info(f"Connecting to Realtime API: {self._endpoint}") - - client = self._get_openai_client() - connection = await client.realtime.connect(model=self._model_name).__aenter__() - - logger.info("Successfully connected to AzureOpenAI Realtime API") - return connection - def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, Any]: """ Create session configuration for OpenAI client. @@ -310,13 +480,13 @@ def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, An }, "format": { "type": "audio/pcm", - "rate": self.SAMPLE_RATE_HZ, + "rate": self.streaming.SAMPLE_RATE_HZ, }, }, "output": { "format": { "type": "audio/pcm", - "rate": self.SAMPLE_RATE_HZ, + "rate": self.streaming.SAMPLE_RATE_HZ, } }, }, @@ -422,10 +592,10 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me if ( wav_in.getnchannels() != 1 or wav_in.getsampwidth() != 2 - or wav_in.getframerate() != self.SAMPLE_RATE_HZ + or wav_in.getframerate() != self.streaming.SAMPLE_RATE_HZ ): raise ValueError( - f"Streaming audio must be mono PCM16 at {self.SAMPLE_RATE_HZ} Hz, got " + f"Streaming audio must be mono PCM16 at {self.streaming.SAMPLE_RATE_HZ} Hz, got " f"channels={wav_in.getnchannels()} sampwidth={wav_in.getsampwidth()} " f"rate={wav_in.getframerate()}." ) @@ -451,15 +621,15 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me dispatcher=streaming.dispatcher, ) result: RealtimeTargetResult = await turn_future - output_audio_path = await self.save_audio( + output_audio_path = await self.streaming.save_audio( result.audio_bytes, num_channels=1, sample_width=2, - sample_rate=self.SAMPLE_RATE_HZ, + sample_rate=self.streaming.SAMPLE_RATE_HZ, ) else: if conversation_id not in self._existing_conversation: - connection = await self.connect_async(conversation_id=conversation_id) + connection = await self.streaming.connect_async(conversation_id=conversation_id) self._existing_conversation[conversation_id] = connection # Only send config when creating a new connection @@ -499,39 +669,6 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me response_entry = Message(message_pieces=[text_response_piece, audio_response_piece]) return [response_entry] - async def save_audio( - self, - audio_bytes: bytes, - num_channels: int = 1, - sample_width: int = 2, - sample_rate: int = 16000, - output_filename: Optional[str] = None, - ) -> str: - """ - Save audio bytes to a WAV file. - - Args: - audio_bytes (bytes): Audio bytes to save. - num_channels (int): Number of audio channels. Defaults to 1 for the PCM16 format - sample_width (int): Sample width in bytes. Defaults to 2 for the PCM16 format - sample_rate (int): Sample rate in Hz. Defaults to 16000 Hz for the PCM16 format - output_filename (str): Output filename. If None, a UUID filename will be used. - - Returns: - str: The path to the saved audio file. - """ - data = data_serializer_factory(category="prompt-memory-entries", data_type="audio_path") - - await data.save_formatted_audio( - data=audio_bytes, - output_filename=output_filename, - num_channels=num_channels, - sample_width=sample_width, - sample_rate=sample_rate, - ) - - return data.value - async def cleanup_target(self) -> None: """ Disconnects from the Realtime API connections. @@ -563,32 +700,6 @@ async def cleanup_target(self) -> None: logger.warning(f"Error closing realtime client: {e}") self._realtime_client = None - async def cleanup_conversation(self, conversation_id: str) -> None: - """ - Disconnects from the Realtime API for a specific conversation. - - Stops any active streaming dispatcher for the conversation before closing - the underlying connection. Safe to call when no streaming state exists. - - Args: - conversation_id (str): The conversation ID to disconnect from. - """ - streaming = self._streaming_state.pop(conversation_id, None) - if streaming is not None: - try: - await streaming.dispatcher.stop() - except Exception as e: - logger.warning(f"Error stopping dispatcher for {conversation_id}: {e}") - - connection = self._existing_conversation.get(conversation_id) - if connection: - try: - await connection.close() - logger.info(f"Disconnected from {self._endpoint} with conversation ID: {conversation_id}") - except Exception as e: - logger.warning(f"Error closing connection for {conversation_id}: {e}") - del self._existing_conversation[conversation_id] - async def send_response_create(self, conversation_id: str) -> None: """ Send response.create using OpenAI client. @@ -599,24 +710,6 @@ async def send_response_create(self, conversation_id: str) -> None: connection = self._get_connection(conversation_id=conversation_id) await connection.response.create() - async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """ - Append a single PCM16 mono @ 24 kHz audio chunk to the server's input buffer. - - Used by streaming-style callers (e.g. ``BargeInAttack``) that source chunks - from an iterator and want to control commit timing externally. Server VAD, - when enabled on the session, decides when to commit and fire response logic. - Empty buffers are accepted as no-ops. - - Args: - connection: Active Realtime API connection from ``self.connect()``. - pcm_bytes: Raw PCM16 mono audio for this chunk. - """ - if not pcm_bytes: - return - audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") - await connection.input_audio_buffer.append(audio=audio_b64) - async def insert_user_audio_async(self, *, connection: Any, pcm_bytes: bytes) -> None: """ Insert a user message containing the given PCM16 mono @ 24 kHz audio into the conversation. @@ -696,49 +789,6 @@ async def swap_user_audio_async( except Exception as e: logger.warning(f"conversation.item.delete failed for {committed_event.item_id}: {e}") - async def subscribe_events_async( - self, - *, - connection: Any, - conversation_id: str, - on_user_audio_committed: (Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None) = None, - ) -> RealtimeEventDispatcher: - """ - Start consuming events from the connection and route them via the OpenAI dispatcher. - - Also registers per-conversation streaming state so requests routed through - ``send_prompt_async`` for ``conversation_id`` take the streaming swap-and-respond - path inside ``_send_prompt_to_target_async`` instead of the atomic send_audio / - send_text path. - - The returned dispatcher exposes ``stop()`` to tear down the background task and - drain in-flight callback tasks, and a ``failure`` property that callers can poll - between operations to detect a dead dispatch loop (e.g. websocket closed). - - Args: - connection: Active Realtime API connection from ``self.connect()``. - conversation_id: Conversation id for the realtime session. Used as the key - under which streaming state is registered. - on_user_audio_committed: Async callback fired when server VAD finalizes - a user audio buffer. Called as a background task. - - Returns: - The started dispatcher. Pass it to ``request_response_async`` for turn - futures, poll ``failure`` for dispatch-loop errors, and call ``stop()`` - (or ``cleanup_conversation``) to tear it down. - """ - dispatcher = _OpenAIRealtimeDispatcher( - connection=connection, - on_user_audio_committed=on_user_audio_committed, - ) - self._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) - # Register the connection under the same key so cleanup_conversation / - # cleanup_target can find and close it without callers reaching into - # private state. - self._existing_conversation[conversation_id] = connection - await dispatcher.start() - return dispatcher - async def request_response_async( self, *, @@ -770,37 +820,6 @@ async def request_response_async( await connection.response.create() return state.completion - async def send_streaming_session_config_async( - self, *, connection: Any, conversation: list[Message] | None = None - ) -> None: - """ - Configure the realtime session for streaming use: server VAD with manual response creation. - - Emits the same session config as the atomic path except ``turn_detection.create_response`` - is forced to False so the streaming attack can swap the raw user audio item for converted - audio before triggering ``response.create``. - - Args: - connection: Active Realtime API connection. - conversation: Optional conversation history; if its first message is a system - message, its text becomes the session's instructions. Defaults to None, - in which case the default system prompt is used. - - Raises: - ValueError: If the target was constructed without server VAD. - """ - if self._server_vad is None: - raise ValueError( - "send_streaming_session_config_async requires server VAD; " - "construct RealtimeTarget(server_vad=True) or pass a ServerVadConfig." - ) - system_prompt = self._get_system_prompt_from_conversation(conversation=conversation or []) - config = self._set_system_prompt_and_config_vars(system_prompt=system_prompt) - turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") - if turn_detection is not None: - turn_detection["create_response"] = False - await connection.session.update(session=config) - async def _stream_pcm_async( self, *, @@ -1097,7 +1116,7 @@ async def send_text_async( raise RuntimeError("No audio received from the server.") # Azure GA uses 24000 Hz sample rate - output_audio_path = await self.save_audio(audio_bytes=result.audio_bytes, sample_rate=24000) + output_audio_path = await self.streaming.save_audio(audio_bytes=result.audio_bytes, sample_rate=24000) return output_audio_path, result async def send_audio_async( @@ -1159,7 +1178,7 @@ async def send_audio_async( if not result.audio_bytes: raise RuntimeError("No audio received from the server.") - output_audio_path = await self.save_audio(result.audio_bytes, num_channels, sample_width, frame_rate) + output_audio_path = await self.streaming.save_audio(result.audio_bytes, num_channels, sample_width, frame_rate) return output_audio_path, result async def _construct_message_from_response(self, response: Any, request: Any) -> Message: diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 00c332740c..8a7b49b0e5 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -13,7 +13,7 @@ from pyrit.executor.attack import BargeInAttack, BargeInAttackContext from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters -from pyrit.executor.attack.streaming.barge_in import _trim_snapshot_to_speech +from pyrit.executor.attack.streaming.barge_in import _BargeInRunState, _trim_snapshot_to_speech from pyrit.models import AttackOutcome, Message, MessagePiece from pyrit.prompt_normalizer import PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget @@ -94,6 +94,20 @@ def test_constructor_accepts_custom_max_post_stream_wait_seconds(vad_target): assert attack._max_post_stream_wait_seconds == 120.0 +def test_constructor_caches_streaming_handle(vad_target): + """BargeInAttack stashes the target's streaming handle for direct access during _setup_async.""" + attack = BargeInAttack(objective_target=vad_target) + assert attack._streaming is vad_target.streaming + + +def test_constructor_rejects_target_without_streaming_handle(vad_target): + """If a target declares STREAMING_BARGE_IN but did not wire .streaming, construction fails.""" + # Simulate a malformed target: keeps the capability flag, drops the handle. + vad_target.streaming = None # type: ignore[assignment] + with pytest.raises(ValueError, match="declares STREAMING_BARGE_IN.*did not wire"): + BargeInAttack(objective_target=vad_target) + + # ---- Context validation ---------------------------------------------------------------------- @@ -211,11 +225,11 @@ def _setup_streaming_target(vad_target, *, future_response: Message | None = Non be invoked mid-stream without exercising the real target machinery. """ connection = _mock_connection() - vad_target.connect_async = AsyncMock(return_value=connection) - vad_target.send_streaming_session_config_async = AsyncMock() - vad_target.push_audio_chunk_async = AsyncMock() - vad_target.save_audio = AsyncMock(return_value="/tmp/snapshot.wav") - vad_target.cleanup_conversation = AsyncMock() + vad_target.streaming.connect_async = AsyncMock(return_value=connection) + vad_target.streaming.send_streaming_session_config_async = AsyncMock() + vad_target.streaming.push_audio_chunk_async = AsyncMock() + vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/snapshot.wav") + vad_target.streaming.cleanup_conversation = AsyncMock() return connection @@ -226,7 +240,7 @@ async def fake_subscribe(*, connection, conversation_id, on_user_audio_committed captured["on_committed"] = on_user_audio_committed return AsyncMock() - vad_target.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) + vad_target.streaming.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) def _stub_send_prompt(attack: BargeInAttack, return_value: Message | None = None) -> AsyncMock: @@ -254,7 +268,7 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): attack = BargeInAttack(objective_target=vad_target) connection = _setup_streaming_target(vad_target) dispatcher = AsyncMock() - vad_target.subscribe_events_async = AsyncMock(return_value=dispatcher) + vad_target.streaming.subscribe_events_async = AsyncMock(return_value=dispatcher) chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] ctx = _attack_context(audio_chunks=_aiter(chunks)) @@ -262,13 +276,13 @@ async def test_perform_async_streams_chunks_and_tears_down(vad_target): with patch.object(attack, "_max_post_stream_wait_seconds", 0): result = await attack._perform_async(context=ctx) - vad_target.connect_async.assert_awaited_once_with(conversation_id=ctx.conversation_id) - vad_target.send_streaming_session_config_async.assert_awaited_once() - vad_target.subscribe_events_async.assert_awaited_once() - assert vad_target.push_audio_chunk_async.await_count == len(chunks) - pushed = [call.kwargs["pcm_bytes"] for call in vad_target.push_audio_chunk_async.await_args_list] + vad_target.streaming.connect_async.assert_awaited_once_with(conversation_id=ctx.conversation_id) + vad_target.streaming.send_streaming_session_config_async.assert_awaited_once() + vad_target.streaming.subscribe_events_async.assert_awaited_once() + assert vad_target.streaming.push_audio_chunk_async.await_count == len(chunks) + pushed = [call.kwargs["pcm_bytes"] for call in vad_target.streaming.push_audio_chunk_async.await_args_list] assert pushed == chunks - vad_target.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) + vad_target.streaming.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) assert result.executed_turns == 0 assert result.outcome == AttackOutcome.UNDETERMINED @@ -312,7 +326,7 @@ async def test_perform_async_message_carries_snapshot_audio_path(vad_target): attack = BargeInAttack(objective_target=vad_target) send_mock = _stub_send_prompt(attack) connection = _setup_streaming_target(vad_target) - vad_target.save_audio = AsyncMock(return_value="/tmp/persisted_snapshot.wav") + vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted_snapshot.wav") captured: dict[str, Any] = {} _capture_committed_callback(vad_target, captured) @@ -328,7 +342,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: await attack._perform_async(context=ctx) # save_audio called with the snapshot PCM; the resulting path lands on the message piece. - save_kwargs_or_args = vad_target.save_audio.call_args + save_kwargs_or_args = vad_target.streaming.save_audio.call_args saved_pcm = ( save_kwargs_or_args.args[0] if save_kwargs_or_args.args else save_kwargs_or_args.kwargs.get("audio_bytes") ) @@ -349,7 +363,7 @@ async def fake_save_audio(audio_bytes, **_): saved_pcm.append(audio_bytes) return f"/tmp/snap_{len(saved_pcm)}.wav" - vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) captured: dict[str, Any] = {} _capture_committed_callback(vad_target, captured) @@ -410,8 +424,8 @@ async def test_perform_async_cleans_up_even_on_exception(vad_target): """If the chunk loop raises, cleanup_conversation still fires.""" attack = BargeInAttack(objective_target=vad_target) _setup_streaming_target(vad_target) - vad_target.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) - vad_target.subscribe_events_async = AsyncMock(return_value=AsyncMock()) + vad_target.streaming.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) + vad_target.streaming.subscribe_events_async = AsyncMock(return_value=AsyncMock()) ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) @@ -419,7 +433,7 @@ async def test_perform_async_cleans_up_even_on_exception(vad_target): with patch.object(attack, "_max_post_stream_wait_seconds", 0): await attack._perform_async(context=ctx) - vad_target.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) + vad_target.streaming.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) async def test_perform_async_swallows_callback_exception(vad_target): @@ -449,7 +463,7 @@ async def chunks_then_commit() -> AsyncIterator[bytes]: async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): """The streaming session config must flip create_response to False on turn_detection.""" connection = _mock_connection() - await vad_target.send_streaming_session_config_async(connection=connection) + await vad_target.streaming.send_streaming_session_config_async(connection=connection) connection.session.update.assert_awaited_once() config = connection.session.update.call_args.kwargs["session"] assert config["audio"]["input"]["turn_detection"]["create_response"] is False @@ -461,7 +475,7 @@ async def test_send_streaming_session_config_async_requires_server_vad(sqlite_in no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") connection = _mock_connection() with pytest.raises(ValueError, match="server VAD"): - await no_vad.send_streaming_session_config_async(connection=connection) + await no_vad.streaming.send_streaming_session_config_async(connection=connection) async def test_send_streaming_session_config_async_uses_system_message_from_conversation(vad_target): @@ -479,7 +493,7 @@ async def test_send_streaming_session_config_async_uses_system_message_from_conv ) ] ) - await vad_target.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) + await vad_target.streaming.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) config = connection.session.update.call_args.kwargs["session"] assert config["instructions"] == "You are a strict assistant." @@ -605,7 +619,7 @@ async def fake_save_audio(audio_bytes, **_): saved_pcm.append(audio_bytes) return "/tmp/snap.wav" - vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) captured: dict[str, Any] = {} _capture_committed_callback(vad_target, captured) @@ -651,7 +665,7 @@ async def fake_save_audio(audio_bytes, **_): saved_pcm.append(audio_bytes) return "/tmp/snap.wav" - vad_target.save_audio = AsyncMock(side_effect=fake_save_audio) + vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) captured: dict[str, Any] = {} _capture_committed_callback(vad_target, captured) @@ -690,3 +704,135 @@ async def two_turns() -> AsyncIterator[bytes]: # remaining = 300 ms pre-speech-padding + 300 ms speech = 600 ms. assert len(saved_pcm[1]) == 600 * 48 assert saved_pcm[1].endswith(speech_long) + + +# ---- _snapshot_and_trim (helper unit tests) -------------------------------------------------- + + +def test_snapshot_and_trim_returns_buffer_and_duration(vad_target): + """Helper returns the (trimmed snapshot, original-duration) pair without mutating state.""" + from pyrit.prompt_target.common.realtime_audio import ServerVadConfig + + vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) + attack = BargeInAttack(objective_target=vad_target) + + state = _BargeInRunState() + silence = b"\x00" * (1000 * 48) + speech = b"\x11" * (100 * 48) + state.raw_buffer.extend(silence + speech) + pre_call_buffer_len = len(state.raw_buffer) + + event = CommittedEvent(item_id="i", audio_start_ms=1000) + snapshot, duration_ms = attack._snapshot_and_trim(event=event, state=state) + + # Trimmed: drop max(0, 1000 - 300) = 700 ms; remaining = 300 ms pad + 100 ms speech. + assert len(snapshot) == 400 * 48 + assert snapshot.endswith(speech) + # Original duration spans the entire pre-trim buffer (1100 ms at 48 bytes/ms). + assert duration_ms == 1100 + # State is NOT mutated — caller is responsible for clearing the buffer and advancing offset. + assert len(state.raw_buffer) == pre_call_buffer_len + assert state.buffer_start_session_ms == 0 + + +def test_snapshot_and_trim_passes_through_when_audio_start_ms_none(vad_target): + """When the bridged audio_start_ms is None, the helper returns the buffer unchanged.""" + attack = BargeInAttack(objective_target=vad_target) + state = _BargeInRunState() + raw = b"\x42" * (300 * 48) + state.raw_buffer.extend(raw) + + event = CommittedEvent(item_id="i", audio_start_ms=None) + snapshot, duration_ms = attack._snapshot_and_trim(event=event, state=state) + + assert snapshot == raw + assert duration_ms == 300 + + +def test_snapshot_and_trim_uses_session_relative_offset(vad_target): + """The helper subtracts state.buffer_start_session_ms before passing to the trim function.""" + from pyrit.prompt_target.common.realtime_audio import ServerVadConfig + + vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) + attack = BargeInAttack(objective_target=vad_target) + + state = _BargeInRunState() + state.buffer_start_session_ms = 1000 # turn 2: 1000 ms of prior turns + silence = b"\x00" * (500 * 48) + speech = b"\x22" * (200 * 48) + state.raw_buffer.extend(silence + speech) + + # Server reports session-relative audio_start_ms = 1500 → buffer-relative = 500. + event = CommittedEvent(item_id="i", audio_start_ms=1500) + snapshot, _ = attack._snapshot_and_trim(event=event, state=state) + + # Trim = max(0, 500 - 300) = 200 ms; remaining = 300 ms pad + 200 ms speech. + assert len(snapshot) == 500 * 48 + assert snapshot.endswith(speech) + + +# ---- _build_message_for_turn (helper unit tests) --------------------------------------------- + + +async def test_build_message_for_turn_persists_and_wraps(vad_target): + """Builder calls save_audio and wraps the path in an audio_path-shaped Message.""" + attack = BargeInAttack(objective_target=vad_target) + vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted.wav") + + snapshot_bytes = b"\xaa" * 480 + message = await attack._build_message_for_turn( + snapshot=snapshot_bytes, + item_id="server_item_xyz", + conversation_id="conv-1", + ) + + # save_audio receives the snapshot bytes and the streaming-handle sample rate. + save_call = vad_target.streaming.save_audio.await_args + assert save_call.args[0] == snapshot_bytes + assert save_call.kwargs["sample_rate"] == vad_target.streaming.SAMPLE_RATE_HZ + + # Message shape: one audio_path piece pointing at the persisted file. + assert len(message.message_pieces) == 1 + piece = message.message_pieces[0] + assert piece.original_value == "/tmp/persisted.wav" + assert piece.converted_value == "/tmp/persisted.wav" + assert piece.original_value_data_type == "audio_path" + assert piece.converted_value_data_type == "audio_path" + assert piece.conversation_id == "conv-1" + + +async def test_build_message_for_turn_stashes_item_id_in_metadata(vad_target): + """The server's committed item_id is stashed under REALTIME_COMMITTED_ITEM_ID_KEY.""" + attack = BargeInAttack(objective_target=vad_target) + vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted.wav") + + message = await attack._build_message_for_turn( + snapshot=b"\x00" * 96, + item_id="srv_item_42", + conversation_id="conv-2", + ) + + assert message.message_pieces[0].prompt_metadata[REALTIME_COMMITTED_ITEM_ID_KEY] == "srv_item_42" + + +# ---- _send_via_normalizer (helper unit tests) ------------------------------------------------ + + +async def test_send_via_normalizer_forwards_to_prompt_normalizer(vad_target): + """Helper hands message + converters + identifiers to PromptNormalizer.send_prompt_async.""" + attack = BargeInAttack(objective_target=vad_target) + response = Message(message_pieces=[MessagePiece(role="assistant", original_value="ok")]) + attack._prompt_normalizer.send_prompt_async = AsyncMock(return_value=response) # type: ignore[method-assign] + + request = Message(message_pieces=[MessagePiece(role="user", original_value="/tmp/r.wav")]) + result = await attack._send_via_normalizer(message=request, conversation_id="conv-3") + + assert result is response + call = attack._prompt_normalizer.send_prompt_async.await_args + assert call.kwargs["message"] is request + # Forwards the underlying PromptTarget (not the streaming handle). + assert call.kwargs["target"] is vad_target + assert call.kwargs["conversation_id"] == "conv-3" + assert call.kwargs["request_converter_configurations"] is attack._request_converters + assert call.kwargs["response_converter_configurations"] is attack._response_converters + assert call.kwargs["attack_identifier"] == attack.get_identifier() diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index fa01aa3995..d9f2624864 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -18,9 +18,11 @@ CommittedEvent, RealtimeTargetResult, RealtimeTurnState, + StreamingHandle, ) from pyrit.prompt_target.openai.openai_realtime_target import ( _OpenAIRealtimeDispatcher, + _RealtimeStreamingHandle, _StreamingConversationState, ) @@ -43,7 +45,7 @@ async def test_connect_success(target): mock_client.realtime.connect.return_value.__aenter__ = AsyncMock(return_value=mock_connection) with patch.object(target, "_get_openai_client", return_value=mock_client): - connection = await target.connect_async(conversation_id="test_conv") + connection = await target.streaming.connect_async(conversation_id="test_conv") assert connection == mock_connection mock_client.realtime.connect.assert_called_once_with(model="test") await target.cleanup_target() @@ -51,7 +53,7 @@ async def test_connect_success(target): async def test_send_prompt_async(target): # Mock the necessary methods - target.connect_async = AsyncMock(return_value=AsyncMock()) + target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"file", transcripts=["hello"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -86,7 +88,7 @@ async def test_send_prompt_async(target): async def test_send_prompt_async_propagates_interrupted_to_metadata(target): """When a turn result carries interrupted=True, both response pieces' metadata must reflect it.""" - target.connect_async = AsyncMock(return_value=AsyncMock()) + target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() interrupted_result = RealtimeTargetResult(audio_bytes=b"partial", transcripts=["hi"], interrupted=True) target.send_text_async = AsyncMock(return_value=("partial.wav", interrupted_result)) @@ -112,7 +114,7 @@ async def test_send_prompt_async_propagates_interrupted_to_metadata(target): async def test_send_prompt_async_omits_interrupted_metadata_when_not_set(target): """A non-interrupted result must not write an interrupted key to MessagePiece metadata.""" - target.connect_async = AsyncMock(return_value=AsyncMock()) + target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() normal_result = RealtimeTargetResult(audio_bytes=b"full", transcripts=["hi"]) target.send_text_async = AsyncMock(return_value=("full.wav", normal_result)) @@ -189,7 +191,7 @@ async def test_get_system_prompt_empty_conversation(target): async def test_multiple_websockets_created_for_multiple_conversations(target): # Mock the necessary methods - target.connect_async = AsyncMock(return_value=AsyncMock()) + target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"event1", transcripts=["event2"]) target.send_text_async = AsyncMock(return_value=("output_audio_path", result)) @@ -412,7 +414,7 @@ async def test_multi_turn_reuses_connection(target): This ensures that the server-side conversation context is preserved. """ mock_connection = AsyncMock() - target.connect_async = AsyncMock(return_value=mock_connection) + target.streaming.connect_async = AsyncMock(return_value=mock_connection) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"audio", transcripts=["response"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -442,7 +444,7 @@ async def test_multi_turn_reuses_connection(target): await target.send_prompt_async(message=Message(message_pieces=[message_piece_2])) # Connection should only be created once for the conversation - target.connect_async.assert_called_once_with(conversation_id=conversation_id) + target.streaming.connect_async.assert_called_once_with(conversation_id=conversation_id) target.send_config.assert_called_once() # Both turns should use the same connection @@ -659,7 +661,7 @@ async def test_push_audio_chunk_async_base64_encodes_and_appends(target): connection = _make_mock_connection() pcm = b"\x33" * 480 - await target.push_audio_chunk_async(connection=connection, pcm_bytes=pcm) + await target.streaming.push_audio_chunk_async(connection=connection, pcm_bytes=pcm) connection.input_audio_buffer.append.assert_awaited_once() audio_b64 = connection.input_audio_buffer.append.call_args.kwargs["audio"] @@ -668,7 +670,7 @@ async def test_push_audio_chunk_async_base64_encodes_and_appends(target): async def test_push_audio_chunk_async_empty_is_noop(target): connection = _make_mock_connection() - await target.push_audio_chunk_async(connection=connection, pcm_bytes=b"") + await target.streaming.push_audio_chunk_async(connection=connection, pcm_bytes=b"") connection.input_audio_buffer.append.assert_not_called() @@ -1052,7 +1054,7 @@ async def event_iter(): async def on_committed(event): received.append(event) - dispatcher = await target.subscribe_events_async( + dispatcher = await target.streaming.subscribe_events_async( connection=connection, conversation_id="test_conv", on_user_audio_committed=on_committed ) try: @@ -1076,7 +1078,7 @@ async def boom_iter(): connection = MagicMock() connection.__aiter__ = lambda self_: boom_iter() - dispatcher = await target.subscribe_events_async(connection=connection, conversation_id="test_conv") + dispatcher = await target.streaming.subscribe_events_async(connection=connection, conversation_id="test_conv") try: for _ in range(50): if dispatcher.failure is not None: @@ -1135,13 +1137,24 @@ async def test_request_response_async_propagates_register_turn_failure(target): def test_sample_rate_hz_class_constant(): """SAMPLE_RATE_HZ is the single source of truth for the realtime PCM sample rate.""" - assert RealtimeTarget.SAMPLE_RATE_HZ == 24000 + assert _RealtimeStreamingHandle.SAMPLE_RATE_HZ == 24000 + + +def test_realtime_target_wires_streaming_handle(target): + """RealtimeTarget.__init__ instantiates and attaches its streaming handle.""" + assert isinstance(target.streaming, _RealtimeStreamingHandle) + assert isinstance(target.streaming, StreamingHandle) + + +def test_realtime_streaming_handle_has_no_abstract_methods(): + """_RealtimeStreamingHandle implements every method on the StreamingHandle ABC.""" + assert _RealtimeStreamingHandle.__abstractmethods__ == frozenset() def test_server_vad_config_returns_config_when_enabled(target): """server_vad_config exposes the underlying ServerVadConfig when server VAD is enabled.""" target._server_vad = ServerVadConfig(prefix_padding_ms=250, silence_duration_ms=400) - cfg = target.server_vad_config + cfg = target.streaming.server_vad_config assert cfg is not None assert cfg.prefix_padding_ms == 250 assert cfg.silence_duration_ms == 400 @@ -1150,7 +1163,7 @@ def test_server_vad_config_returns_config_when_enabled(target): def test_server_vad_config_returns_none_when_disabled(target): """server_vad_config is None when server VAD is disabled.""" target._server_vad = None - assert target.server_vad_config is None + assert target.streaming.server_vad_config is None async def test_subscribe_events_async_registers_streaming_state(target): @@ -1166,7 +1179,7 @@ async def event_iter(): assert "conv-A" not in target._streaming_state - dispatcher = await target.subscribe_events_async(connection=connection, conversation_id="conv-A") + dispatcher = await target.streaming.subscribe_events_async(connection=connection, conversation_id="conv-A") try: assert "conv-A" in target._streaming_state assert target._streaming_state["conv-A"].dispatcher is dispatcher @@ -1183,7 +1196,7 @@ async def test_cleanup_conversation_clears_streaming_state(target): target._streaming_state["conv-B"] = _StreamingConversationState(dispatcher=dispatcher) target._existing_conversation["conv-B"] = AsyncMock() - await target.cleanup_conversation("conv-B") + await target.streaming.cleanup_conversation("conv-B") dispatcher.stop.assert_awaited_once() assert "conv-B" not in target._streaming_state @@ -1195,7 +1208,7 @@ async def test_cleanup_conversation_is_safe_without_streaming_state(target): target._existing_conversation["conv-C"] = AsyncMock() # Should not raise even though _streaming_state has no entry for conv-C. - await target.cleanup_conversation("conv-C") + await target.streaming.cleanup_conversation("conv-C") assert "conv-C" not in target._existing_conversation @@ -1294,7 +1307,7 @@ async def test_send_prompt_routes_streaming_when_state_registered(target, tmp_pa message = _make_streaming_request(conversation_id="conv-R", wav_path=wav_path) target.swap_user_audio_async = AsyncMock() - target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") completed_future: asyncio.Future = asyncio.get_running_loop().create_future() completed_future.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=["ok"])) target.request_response_async = AsyncMock(return_value=completed_future) @@ -1324,7 +1337,7 @@ async def test_send_prompt_uses_atomic_path_when_no_streaming_state(target, tmp_ target.swap_user_audio_async = AsyncMock() target.request_response_async = AsyncMock() - target.connect_async = AsyncMock(return_value=AsyncMock()) + target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() target.send_audio_async = AsyncMock( return_value=("/tmp/out.wav", RealtimeTargetResult(audio_bytes=b"", transcripts=["hi"])), @@ -1352,7 +1365,7 @@ async def test_streaming_send_swaps_when_converters_ran(target, tmp_path): ) target.swap_user_audio_async = AsyncMock() - target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") completed_future: asyncio.Future = asyncio.get_running_loop().create_future() completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"])) target.request_response_async = AsyncMock(return_value=completed_future) @@ -1379,7 +1392,7 @@ async def test_streaming_send_skips_swap_when_no_converters(target, tmp_path): ) target.swap_user_audio_async = AsyncMock() - target.save_audio = AsyncMock(return_value="/tmp/resp.wav") + target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") completed_future: asyncio.Future = asyncio.get_running_loop().create_future() completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"])) target.request_response_async = AsyncMock(return_value=completed_future) @@ -1422,7 +1435,7 @@ async def test_send_prompt_propagates_interrupted_metadata_for_streaming(target, wav_path = _write_wav(tmp_path / "in.wav") message = _make_streaming_request(conversation_id="conv-I", wav_path=wav_path) - target.save_audio = AsyncMock(return_value="/tmp/partial.wav") + target.streaming.save_audio = AsyncMock(return_value="/tmp/partial.wav") completed_future: asyncio.Future = asyncio.get_running_loop().create_future() completed_future.set_result( RealtimeTargetResult(audio_bytes=b"\xaa" * 32, transcripts=["partial"], interrupted=True), @@ -1460,7 +1473,7 @@ async def test_streaming_send_serializes_via_turn_lock(target, tmp_path): wav_path = _write_wav(tmp_path / "in.wav") message = _make_streaming_request(conversation_id="conv-L", wav_path=wav_path) - target.save_audio = AsyncMock(return_value="/tmp/r.wav") + target.streaming.save_audio = AsyncMock(return_value="/tmp/r.wav") active = 0 max_concurrent = 0 From db539f79361e87de21a56424d5f2678e2211556d Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 28 May 2026 12:25:04 -0400 Subject: [PATCH 31/47] Regenerate barge_in_attack notebook to pick up source changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../executor/attack/barge_in_attack.ipynb | 93 +++++++++++-------- 1 file changed, 55 insertions(+), 38 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index f9a92ba9dc..7aad2c7b5e 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -158,27 +158,34 @@ "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294332341158.mp3\u001b[0m\n", + "\u001b[36m Original:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779982976939175.mp3\u001b[0m\n", + "\n", + "\u001b[36m Converted:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779982976976521.wav\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy, which they store as sugars. It mainly takes place in the chloroplasts of leaf cells. Here's how it works:\u001b[0m\n", - "\u001b[33m \u001b[0m\n", - "\u001b[33m 1. Light absorption: Chlorophyll, the green pigment, captures sunlight. This energy excites electrons within the chlorophyll.\u001b[0m\n", - "\u001b[33m \u001b[0m\n", - "\u001b[33m 2. Water splitting: The plant takes in water (H₂O) from the roots and transfers it to the leaves. The light energy splits the water molecules into oxygen, protons, and electrons. The oxygen is\u001b[0m\n", - "\u001b[33m released as a byproduct.\u001b[0m\n", - "\u001b[33m \u001b[0m\n", - "\u001b[33m 3. Conversion of energy: The excited electrons move through a chain of proteins, creating ATP and NADPH, which are energy carriers.\u001b[0m\n", - "\u001b[33m \u001b[0m\n", - "\u001b[33m 4. Carbon fixation: Using that stored energy, the plant takes in carbon dioxide (CO₂) from the air. Through the Calvin cycle, it combines the CO₂ with the energy carriers to form glucose.\u001b[0m\n", + "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy. In their leaves, cells contain chloroplasts, which house the pigment chlorophyll. Chlorophyll absorbs\u001b[0m\n", + "\u001b[33m sunlight, primarily in the blue and red wavelengths, and uses that energy to drive reactions.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m This glucose feeds the plant and can be stored as starch. In essence, photosynthesis fuels plant growth and provides oxygen for us.\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294332344158.mp3\u001b[0m\n", + "\u001b[33m In the presence of sunlight, plants take in carbon dioxide from the air through tiny pores called stomata, and water from the soil via their roots. Inside the chloroplasts, these inputs undergo a\u001b[0m\n", + "\u001b[33m series of chemical reactions known as the light-dependent and light-independent (or Calvin) cycles. The light-dependent reactions generate energy carriers (ATP and NADPH) and split water\u001b[0m\n", + "\u001b[33m molecules, releasing oxygen as a byproduct. The Calvin cycle then uses those energy carriers to convert carbon dioxide into glucose, a carbohydrate that the plant can use for energy and growth. In\u001b[0m\n", + "\u001b[33m essence, photosynthesis produces food for the plant and releases oxygen into the atmosphere, which is essential for life on Earth.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983011877612.mp3\u001b[0m\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "./AppData/Local/Temp/ipykernel_35248/3556598072.py:23: DeprecationWarning: print_conversation_async is deprecated and will be removed in 2.0. Use write_async instead.\n", + " await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore\n" + ] } ], "source": [ @@ -233,54 +240,65 @@ "executed_turns: 2\n", "\n", "Persisted pieces (4 messages):\n", - " user audio_path: 1779294342848770.mp3\n", - " assistant text [INTERRUPTED]: Sure! Photosynthesis is the process plants use to convert light energy into chem...\n", - " assistant audio_path [INTERRUPTED]: 1779294342850774.mp3\n", - " user audio_path: 1779294366566679.mp3\n", - " assistant text: Absolutely! Let’s break it down step by step.\n", - "\n", - "1. **Where it happens**: Photosyn...\n", - " assistant audio_path: 1779294366569687.mp3\n", + " user audio_path: 1779983020688882.wav\n", + " assistant text [INTERRUPTED]: Sure! Plants use photosynthesis to convert light energy into chemical energy. In...\n", + " assistant audio_path [INTERRUPTED]: 1779983022483318.mp3\n", + " user audio_path: 1779983027936167.wav\n", + " assistant text: Absolutely. Photosynthesis is the process where plants, algae, and some bacteria...\n", + " assistant audio_path: 1779983042984248.mp3\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294342848770.mp3\u001b[0m\n", + "\u001b[36m Original:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983020666036.mp3\u001b[0m\n", + "\n", + "\u001b[36m Converted:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983020688882.wav\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy they can use as\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294342850774.mp3\u001b[0m\n", + "\u001b[33m Sure! Plants use photosynthesis to convert light energy into chemical energy. Inside their leaves are cells with chloroplasts\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983022483318.mp3\u001b[0m\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 2 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[34m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294366566679.mp3\u001b[0m\n", + "\u001b[36m Original:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983027912417.mp3\u001b[0m\n", + "\n", + "\u001b[36m Converted:\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983027936167.wav\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Absolutely! Let’s break it down step by step.\u001b[0m\n", + "\u001b[33m Absolutely. Photosynthesis is the process where plants, algae, and some bacteria transform light energy into chemical energy stored as sugars. Here’s how it works in plants:\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 1. **Where it happens**: Photosynthesis takes place in chloroplasts, which are specialized structures inside plant cells. These contain chlorophyll, the green pigment that captures light energy from\u001b[0m\n", - "\u001b[33m the sun.\u001b[0m\n", + "\u001b[33m 1. Light absorption: Chlorophyll, the green pigment in chloroplasts, captures sunlight, especially in the red and blue wavelengths.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 2. **The raw materials**: Plants use carbon dioxide from the air (taken in through tiny pores called stomata) and water from the soil (absorbed through their roots).\u001b[0m\n", + "\u001b[33m 2. Water splitting: The absorbed light energy is used to split water molecules (H₂O) taken up from the roots into oxygen, protons, and electrons. Oxygen is released into the air through the leaves.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 3. **The light-dependent reactions**: Inside the chloroplasts, chlorophyll absorbs sunlight, which excites electrons. This energy splits water molecules into oxygen, protons, and electrons. Oxygen\u001b[0m\n", - "\u001b[33m is released as a byproduct (that’s the oxygen we breathe!). The electrons and protons help generate energy-rich molecules called ATP and NADPH.\u001b[0m\n", + "\u001b[33m 3. Energy carriers: The electrons and protons move through a series of reactions called the light-dependent reactions. They generate two key energy carriers: ATP and NADPH.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 4. **The Calvin cycle (light-independent reactions)**: Using the ATP and NADPH, plants convert carbon dioxide into glucose through a series of enzyme-driven steps. Glucose is a simple sugar that\u001b[0m\n", - "\u001b[33m plants use to build more complex carbohydrates like starch and cellulose, fueling growth and development.\u001b[0m\n", + "\u001b[33m 4. Carbon fixation: In a cycle of reactions called the Calvin cycle, the plant uses ATP and NADPH to convert carbon dioxide (CO₂) from the air into glucose, a sugar that stores energy.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 5. **Energy storage and use**: The glucose can be used immediately for energy, or it can be stored as starch. This stored energy supports the plant’s metabolism, growth, and reproduction.\u001b[0m\n", + "\u001b[33m 5. Energy storage: Glucose can be used immediately for energy, converted into other carbohydrates like starch for storage, or used to build other plant structures.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m In short, plants take in sunlight, water, and carbon dioxide, and through photosynthesis they produce oxygen and energy-rich sugars that sustain both themselves and, ultimately, life on Earth.\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779294366569687.mp3\u001b[0m\n", + "\u001b[33m This process keeps plants alive and provides oxygen and food for much of life on Earth. Is there any part you’d like me to dive into more?\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983042984248.mp3\u001b[0m\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "./AppData/Local/Temp/ipykernel_35248/3722491799.py:54: DeprecationWarning: print_conversation_async is deprecated and will be removed in 2.0. Use write_async instead.\n", + " await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore\n" + ] } ], "source": [ @@ -379,8 +397,7 @@ ], "metadata": { "jupytext": { - "cell_metadata_filter": "-all", - "main_language": "python" + "cell_metadata_filter": "-all" }, "language_info": { "codemirror_mode": { From b677cc52f5f32690ae5e659b858ee7d1d7423538 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Fri, 29 May 2026 15:53:59 -0400 Subject: [PATCH 32/47] Replace PromptTarget.streaming attribute with SupportsStreamingBargeIn Protocol Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 16 +++++++++------- pyrit/prompt_target/common/prompt_target.py | 9 +-------- pyrit/prompt_target/common/realtime_audio.py | 19 ++++++++++++++++++- .../attack/streaming/test_barge_in.py | 8 ++++---- 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 4ed98955a5..48e39eb95a 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -24,7 +24,7 @@ MessagePiece, ) from pyrit.prompt_normalizer import PromptNormalizer -from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY +from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY, SupportsStreamingBargeIn from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -169,8 +169,9 @@ def __init__( params_type: Attack parameter dataclass type. Raises: - ValueError: If ``objective_target`` declares ``STREAMING_BARGE_IN`` but did not - wire its ``streaming`` attribute to a ``StreamingHandle``. + TypeError: If ``objective_target`` does not satisfy ``SupportsStreamingBargeIn`` + (i.e. it declared ``STREAMING_BARGE_IN`` but did not wire a ``streaming`` + attribute pointing at a ``StreamingHandle``). """ super().__init__( objective_target=objective_target, @@ -178,10 +179,11 @@ def __init__( params_type=params_type, logger=logger, ) - if objective_target.streaming is None: - raise ValueError( - f"{type(objective_target).__name__} declares STREAMING_BARGE_IN capability but did not " - f"wire `self.streaming` to a StreamingHandle. This is a target-implementation bug." + if not isinstance(objective_target, SupportsStreamingBargeIn): + raise TypeError( + f"{type(objective_target).__name__} does not satisfy SupportsStreamingBargeIn " + f"(missing `streaming` attribute). Targets that declare STREAMING_BARGE_IN must " + f"set `self.streaming` to a StreamingHandle instance in `__init__`." ) self._streaming = objective_target.streaming attack_converter_config = attack_converter_config or AttackConverterConfig() diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index c476b22842..3d5763dd45 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -5,7 +5,7 @@ import abc import logging -from typing import TYPE_CHECKING, Any, Union, final +from typing import Any, Union, final from pyrit.common.deprecation import print_deprecation_message from pyrit.identifiers import ComponentIdentifier, Identifiable @@ -15,9 +15,6 @@ from pyrit.prompt_target.common.target_capabilities import CapabilityName, TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration -if TYPE_CHECKING: - from pyrit.prompt_target.common.realtime_audio import StreamingHandle - logger = logging.getLogger(__name__) @@ -48,10 +45,6 @@ class PromptTarget(Identifiable): # constructor parameter, which takes precedence over the class-level value. _DEFAULT_CONFIGURATION: TargetConfiguration = TargetConfiguration(capabilities=TargetCapabilities()) - #: Provider-specific streaming handle, set by targets that declare ``STREAMING_BARGE_IN``. - #: Non-streaming targets leave this as ``None``. - streaming: StreamingHandle | None = None - def __init__( self, verbose: bool = False, diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 05608c6d67..0eb302bff4 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any, ClassVar +from typing import Any, ClassVar, Protocol, runtime_checkable logger = logging.getLogger(__name__) @@ -304,3 +304,20 @@ async def save_audio( @abstractmethod async def cleanup_conversation(self, conversation_id: str) -> None: """Tear down any per-conversation state held by the target.""" + + +@runtime_checkable +class SupportsStreamingBargeIn(Protocol): + """ + Structural marker for targets that expose a streaming barge-in surface. + + Used by ``BargeInAttack`` to validate at construction time that its objective + target wires a ``StreamingHandle`` on ``self.streaming``. Decoupled from + ``PromptTarget`` so the base class stays free of streaming-specific attributes. + + Note: ``runtime_checkable`` Protocol ``isinstance`` checks attribute *presence*, + not value — a target that explicitly sets ``streaming = None`` would still pass + the check and fail at first method call instead of construction time. + """ + + streaming: StreamingHandle diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 8a7b49b0e5..9eccb0fef7 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -101,10 +101,10 @@ def test_constructor_caches_streaming_handle(vad_target): def test_constructor_rejects_target_without_streaming_handle(vad_target): - """If a target declares STREAMING_BARGE_IN but did not wire .streaming, construction fails.""" - # Simulate a malformed target: keeps the capability flag, drops the handle. - vad_target.streaming = None # type: ignore[assignment] - with pytest.raises(ValueError, match="declares STREAMING_BARGE_IN.*did not wire"): + """A target that doesn't satisfy SupportsStreamingBargeIn (no streaming attr) fails fast.""" + # Simulate a malformed target: capability flag still set, but streaming attribute removed. + del vad_target.streaming + with pytest.raises(TypeError, match="does not satisfy SupportsStreamingBargeIn"): BargeInAttack(objective_target=vad_target) From 81a987777de6eed33cee484913e05cd993dceab5 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 12:55:29 -0400 Subject: [PATCH 33/47] Scaffold realtime streaming session contract Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_normalizer/prompt_normalizer.py | 106 ++++++++++ pyrit/prompt_target/__init__.py | 6 +- pyrit/prompt_target/common/realtime_audio.py | 27 +-- .../common/streaming/__init__.py | 16 ++ .../streaming/streaming_audio_target.py | 96 +++++++++ .../_openai_realtime_streaming_session.py | 63 ++++++ .../test_prompt_normalizer.py | 187 ++++++++++++++++++ 7 files changed, 477 insertions(+), 24 deletions(-) create mode 100644 pyrit/prompt_target/common/streaming/__init__.py create mode 100644 pyrit/prompt_target/common/streaming/streaming_audio_target.py create mode 100644 pyrit/prompt_target/openai/_openai_realtime_streaming_session.py diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 528782dee6..09f1881332 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -4,7 +4,11 @@ import asyncio import copy import logging +import os +import tempfile import traceback +import wave +from pathlib import Path from typing import Any, Optional from uuid import uuid4 @@ -19,6 +23,7 @@ from pyrit.memory import CentralMemory, MemoryInterface from pyrit.models import ( Message, + MessagePiece, construct_response_from_request, ) from pyrit.prompt_normalizer import NormalizerRequest, PromptConverterConfiguration @@ -296,6 +301,78 @@ async def convert_values( piece.converted_value = converted_text piece.converted_value_data_type = converted_text_data_type + async def convert_audio_async( + self, + *, + raw_pcm: bytes, + converter_configurations: list[PromptConverterConfiguration], + sample_rate_hz: int, + num_channels: int, + sample_width_bytes: int, + ) -> bytes: + """ + Apply converters to raw PCM audio and return the converted PCM. + + Wraps the input PCM in a temporary WAV file, builds a single-piece + ``audio_path`` ``Message``, runs ``convert_values``, then reads the + converted file back as raw PCM. The caller's PCM format is preserved + end-to-end; converters that change the format trigger a ``ValueError`` + on read-back. + + Args: + raw_pcm (bytes): Raw PCM audio samples (no WAV header). + converter_configurations (list[PromptConverterConfiguration]): + Converters to apply. If empty, ``raw_pcm`` is returned unchanged + and no temp file is written. + sample_rate_hz (int): Sample rate of the PCM in Hz. + num_channels (int): Channel count (1 for mono, 2 for stereo). + sample_width_bytes (int): Bytes per sample (2 for PCM16). + + Returns: + bytes: The converted raw PCM, matching the input format. + + Raises: + ValueError: If the converted audio has a different sample rate, + channel count, or sample width than the input. + """ + if not converter_configurations: + return raw_pcm + + input_path = _write_pcm_to_temp_wav( + raw_pcm=raw_pcm, + sample_rate_hz=sample_rate_hz, + num_channels=num_channels, + sample_width_bytes=sample_width_bytes, + ) + try: + piece = MessagePiece( + role="user", + original_value=input_path, + original_value_data_type="audio_path", + converted_value=input_path, + converted_value_data_type="audio_path", + ) + message = Message(message_pieces=[piece]) + await self.convert_values( + converter_configurations=converter_configurations, + message=message, + ) + actual_rate, actual_channels, actual_width, converted_pcm = _read_pcm_from_wav(piece.converted_value) + if (actual_rate, actual_channels, actual_width) != ( + sample_rate_hz, + num_channels, + sample_width_bytes, + ): + raise ValueError( + "Converted audio format mismatch: expected " + f"channels={num_channels} sampwidth={sample_width_bytes} " + f"rate={sample_rate_hz}, got channels={actual_channels} " + f"sampwidth={actual_width} rate={actual_rate}." + ) + return converted_pcm + finally: + Path(input_path).unlink(missing_ok=True) + async def _calc_hash(self, request: Message) -> None: """Add a request to the memory.""" tasks = [asyncio.create_task(piece.set_sha256_values_async()) for piece in request.message_pieces] @@ -344,3 +421,32 @@ async def add_prepended_conversation_to_memory( self.memory.add_message_to_memory(request=request) return prepended_conversation + + +def _write_pcm_to_temp_wav( + *, + raw_pcm: bytes, + sample_rate_hz: int, + num_channels: int, + sample_width_bytes: int, +) -> str: + """Return the path of a new temp WAV file containing the given PCM.""" + fd, path = tempfile.mkstemp(suffix=".wav") + os.close(fd) + with wave.open(path, "wb") as wav_out: + wav_out.setnchannels(num_channels) + wav_out.setsampwidth(sample_width_bytes) + wav_out.setframerate(sample_rate_hz) + wav_out.writeframes(raw_pcm) + return path + + +def _read_pcm_from_wav(wav_path: str) -> tuple[int, int, int, bytes]: + """Return (sample_rate_hz, num_channels, sample_width_bytes, pcm_bytes) from a WAV file.""" + with wave.open(wav_path, "rb") as wav_in: + return ( + wav_in.getframerate(), + wav_in.getnchannels(), + wav_in.getsampwidth(), + wav_in.readframes(wav_in.getnframes()), + ) diff --git a/pyrit/prompt_target/__init__.py b/pyrit/prompt_target/__init__.py index b0d42c9a76..aedea888f9 100644 --- a/pyrit/prompt_target/__init__.py +++ b/pyrit/prompt_target/__init__.py @@ -19,7 +19,10 @@ ) from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget from pyrit.prompt_target.common.prompt_target import PromptTarget -from pyrit.prompt_target.common.realtime_audio import ServerVadConfig +from pyrit.prompt_target.common.streaming import ( + ServerVadConfig, + StreamingAudioTarget, +) from pyrit.prompt_target.common.target_capabilities import ( CapabilityHandlingPolicy, CapabilityName, @@ -104,6 +107,7 @@ def __getattr__(name: str) -> object: "PromptTarget", "RealtimeTarget", "ServerVadConfig", + "StreamingAudioTarget", "RoundRobinTarget", "TargetCapabilities", "TargetConfiguration", diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 0eb302bff4..0f6f973247 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -11,6 +11,10 @@ from dataclasses import dataclass, field from typing import Any, ClassVar, Protocol, runtime_checkable +from pyrit.prompt_target.common.streaming.streaming_audio_target import ( + ServerVadConfig as ServerVadConfig, # noqa: TC001 +) + logger = logging.getLogger(__name__) @@ -22,29 +26,6 @@ REALTIME_COMMITTED_ITEM_ID_KEY = "realtime_committed_item_id" -@dataclass(frozen=True) -class ServerVadConfig: - """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" - - threshold: float = 0.4 - prefix_padding_ms: int = 200 - silence_duration_ms: int = 1500 - - def __post_init__(self) -> None: - """ - Validate VAD tuning values. - - Raises: - ValueError: If any field is outside its valid range. - """ - if not 0.0 <= self.threshold <= 1.0: - raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") - if self.prefix_padding_ms < 0: - raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") - if self.silence_duration_ms < 0: - raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") - - @dataclass class RealtimeTargetResult: """Result of a Realtime API turn: delivered audio, transcripts, and interruption status.""" diff --git a/pyrit/prompt_target/common/streaming/__init__.py b/pyrit/prompt_target/common/streaming/__init__.py new file mode 100644 index 0000000000..d414905227 --- /dev/null +++ b/pyrit/prompt_target/common/streaming/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Streaming capability ABCs and shared types for realtime prompt targets.""" + +from pyrit.prompt_target.common.streaming.streaming_audio_target import ( + STREAMING_INTERRUPTED_KEY, + ServerVadConfig, + StreamingAudioTarget, +) + +__all__ = [ + "STREAMING_INTERRUPTED_KEY", + "ServerVadConfig", + "StreamingAudioTarget", +] diff --git a/pyrit/prompt_target/common/streaming/streaming_audio_target.py b/pyrit/prompt_target/common/streaming/streaming_audio_target.py new file mode 100644 index 0000000000..13d77b06d1 --- /dev/null +++ b/pyrit/prompt_target/common/streaming/streaming_audio_target.py @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""StreamingAudioTarget capability ABC and shared types for realtime audio prompt targets.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from pyrit.models import Message + from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer + +#: Key set in ``MessagePiece.prompt_metadata`` by streaming targets to mark turns that +#: were interrupted by barge-in. Attacks consume this to count interrupted turns +#: without reaching into target internals. Value type is ``bool``. +STREAMING_INTERRUPTED_KEY = "interrupted" + + +@dataclass(frozen=True) +class ServerVadConfig: + """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" + + threshold: float = 0.4 + prefix_padding_ms: int = 200 + silence_duration_ms: int = 1500 + + def __post_init__(self) -> None: + """ + Validate VAD tuning values. + + Raises: + ValueError: If any field is outside its valid range. + """ + if not 0.0 <= self.threshold <= 1.0: + raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") + if self.prefix_padding_ms < 0: + raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") + if self.silence_duration_ms < 0: + raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") + + +class StreamingAudioTarget(ABC): + """Capability interface for realtime audio targets with server-VAD barge-in support.""" + + @abstractmethod + def send_streaming_prompt_async( + self, + *, + audio_chunks: AsyncIterator[bytes], + prompt_normalizer: PromptNormalizer, + conversation_id: str | None = None, + request_converter_configurations: list[PromptConverterConfiguration] | None = None, + response_converter_configurations: list[PromptConverterConfiguration] | None = None, + prepended_conversation: list[Message] | None = None, + vad: ServerVadConfig | None = None, + ) -> AsyncIterator[Message]: + """ + Stream user audio; yield one ``Message`` per server-VAD-committed turn. + + Implementations must: + + - Apply request converters to each committed audio buffer via + ``prompt_normalizer.convert_audio_async``. + - Apply response converters and persist each yielded ``Message`` via + ``CentralMemory``. + - Set ``MessagePiece.prompt_metadata[STREAMING_INTERRUPTED_KEY] = True`` on + turns that were interrupted by barge-in. + - Use ``prepended_conversation`` for session-level instructions (e.g. + system prompt). + + Args: + audio_chunks (AsyncIterator[bytes]): Raw PCM audio chunks from the caller. + prompt_normalizer (PromptNormalizer): Normalizer used to apply converters + and persist messages. + conversation_id (str | None): Conversation identifier; one is + auto-generated if not provided. + request_converter_configurations (list[PromptConverterConfiguration] | None): + Converters applied to each committed user turn. + response_converter_configurations (list[PromptConverterConfiguration] | None): + Converters applied to each assistant response. + prepended_conversation (list[Message] | None): Session-level conversation + context. In the current contract, only a leading system-prompt + ``Message`` is honored at the live session; prior turns are added to + memory but not replayed to the server. + vad (ServerVadConfig | None): Server-side VAD tuning. ``None`` uses + target defaults. + + Yields: + Message: One ``Message`` per VAD-committed user turn, persisted to + ``CentralMemory``. + """ diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py new file mode 100644 index 0000000000..43cfb0f17b --- /dev/null +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Private session lifecycle for OpenAI Realtime streaming conversations.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from pyrit.models import Message + from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer + from pyrit.prompt_target.common.streaming import ServerVadConfig + from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget + + +class _OpenAIRealtimeStreamingSession: + """ + Per-conversation lifecycle owner for one OpenAI Realtime streaming exchange. + + Internal to :mod:`pyrit.prompt_target.openai`. Constructed and consumed only by + :meth:`RealtimeTarget.send_streaming_prompt_async`; downstream code should depend on + the ``AsyncIterator[Message]`` contract, never on this class directly. + """ + + def __init__( + self, + *, + target: RealtimeTarget, + audio_chunks: AsyncIterator[bytes], + prompt_normalizer: PromptNormalizer, + conversation_id: str | None = None, + request_converter_configurations: list[PromptConverterConfiguration] | None = None, + response_converter_configurations: list[PromptConverterConfiguration] | None = None, + prepended_conversation: list[Message] | None = None, + vad: ServerVadConfig | None = None, + ) -> None: + self._target = target + self._audio_chunks = audio_chunks + self._prompt_normalizer = prompt_normalizer + self._conversation_id = conversation_id or str(uuid.uuid4()) + self._request_converter_configurations = request_converter_configurations or [] + self._response_converter_configurations = response_converter_configurations or [] + self._prepended_conversation = prepended_conversation or [] + self._vad = vad + + async def run_async(self) -> AsyncIterator[Message]: + """ + Drive the streaming conversation; yield one ``Message`` per VAD-committed user turn. + + Raises: + NotImplementedError: Always. Implementation lands in Phase 2 of the + streaming refactor; the body is scaffolded here so callers can depend + on the public contract. + + Yields: + Message: One ``Message`` per VAD-committed user turn (Phase 2). + """ + raise NotImplementedError("Streaming session implementation lands in Phase 2 of PR #1766") + yield # pragma: no cover - keeps this function an async generator diff --git a/tests/unit/prompt_normalizer/test_prompt_normalizer.py b/tests/unit/prompt_normalizer/test_prompt_normalizer.py index 07231243d3..010d7966bb 100644 --- a/tests/unit/prompt_normalizer/test_prompt_normalizer.py +++ b/tests/unit/prompt_normalizer/test_prompt_normalizer.py @@ -3,6 +3,8 @@ import os import tempfile +import wave +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 @@ -629,3 +631,188 @@ async def test_add_prepended_conversation_to_memory(mock_memory_instance): assert result[0].message_pieces[0].conversation_id == conv_id assert result[0].message_pieces[0].attack_identifier == attack_id mock_memory_instance.add_message_to_memory.assert_called_once() + + +_AUDIO_SAMPLE_RATE_HZ = 24000 +_AUDIO_NUM_CHANNELS = 1 +_AUDIO_SAMPLE_WIDTH_BYTES = 2 + + +def _write_test_wav(*, pcm: bytes, sample_rate_hz: int, num_channels: int, sample_width_bytes: int) -> str: + fd, path = tempfile.mkstemp(suffix=".wav") + os.close(fd) + with wave.open(path, "wb") as wav_out: + wav_out.setnchannels(num_channels) + wav_out.setsampwidth(sample_width_bytes) + wav_out.setframerate(sample_rate_hz) + wav_out.writeframes(pcm) + return path + + +@pytest.fixture +def sample_pcm() -> bytes: + return b"\x01\x02\x03\x04" * 1000 + + +@pytest.fixture +def dummy_audio_converter_config() -> PromptConverterConfiguration: + return PromptConverterConfiguration(converters=[MagicMock(spec=PromptConverter)]) + + +async def test_convert_audio_async_no_converters_returns_input_unchanged(mock_memory_instance, sample_pcm): + normalizer = PromptNormalizer() + with patch.object(normalizer, "convert_values", new_callable=AsyncMock) as mock_convert: + result = await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + assert result == sample_pcm + mock_convert.assert_not_called() + + +async def test_convert_audio_async_no_op_converter_round_trips_pcm( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + normalizer = PromptNormalizer() + with patch.object(normalizer, "convert_values", new_callable=AsyncMock): + result = await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + assert result == sample_pcm + + +async def test_convert_audio_async_returns_pcm_from_converted_value( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + transformed_pcm = b"\xfb\xfc\xfd\xfe" * 1000 + new_wav_path = _write_test_wav( + pcm=transformed_pcm, + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + + async def swap_converted_value(*, converter_configurations, message): + message.message_pieces[0].converted_value = new_wav_path + + normalizer = PromptNormalizer() + try: + with patch.object(normalizer, "convert_values", side_effect=swap_converted_value): + result = await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + assert result == transformed_pcm + finally: + Path(new_wav_path).unlink(missing_ok=True) + + +async def test_convert_audio_async_cleans_up_temp_file_on_success( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + captured_paths: list[str] = [] + + async def capture_input_path(*, converter_configurations, message): + captured_paths.append(message.message_pieces[0].converted_value) + + normalizer = PromptNormalizer() + with patch.object(normalizer, "convert_values", side_effect=capture_input_path): + await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + assert len(captured_paths) == 1 + assert not Path(captured_paths[0]).exists() + + +async def test_convert_audio_async_cleans_up_temp_file_on_converter_failure( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + captured_paths: list[str] = [] + + async def capture_then_raise(*, converter_configurations, message): + captured_paths.append(message.message_pieces[0].converted_value) + raise RuntimeError("converter blew up") + + normalizer = PromptNormalizer() + with patch.object(normalizer, "convert_values", side_effect=capture_then_raise): + with pytest.raises(RuntimeError, match="converter blew up"): + await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + assert len(captured_paths) == 1 + assert not Path(captured_paths[0]).exists() + + +async def test_convert_audio_async_raises_on_sample_rate_mismatch( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + wrong_rate_path = _write_test_wav( + pcm=b"\x00" * 100, + sample_rate_hz=16000, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + + async def swap_to_wrong_rate(*, converter_configurations, message): + message.message_pieces[0].converted_value = wrong_rate_path + + normalizer = PromptNormalizer() + try: + with patch.object(normalizer, "convert_values", side_effect=swap_to_wrong_rate): + with pytest.raises(ValueError, match="format mismatch"): + await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + finally: + Path(wrong_rate_path).unlink(missing_ok=True) + + +async def test_convert_audio_async_raises_on_channel_mismatch( + mock_memory_instance, sample_pcm, dummy_audio_converter_config +): + stereo_pcm = b"\x00\x01\x02\x03" * 100 + wrong_channels_path = _write_test_wav( + pcm=stereo_pcm, + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=2, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + + async def swap_to_stereo(*, converter_configurations, message): + message.message_pieces[0].converted_value = wrong_channels_path + + normalizer = PromptNormalizer() + try: + with patch.object(normalizer, "convert_values", side_effect=swap_to_stereo): + with pytest.raises(ValueError, match="format mismatch"): + await normalizer.convert_audio_async( + raw_pcm=sample_pcm, + converter_configurations=[dummy_audio_converter_config], + sample_rate_hz=_AUDIO_SAMPLE_RATE_HZ, + num_channels=_AUDIO_NUM_CHANNELS, + sample_width_bytes=_AUDIO_SAMPLE_WIDTH_BYTES, + ) + finally: + Path(wrong_channels_path).unlink(missing_ok=True) From 9117fea61c07d6617cd513ca727bba6e83d14723 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 14:05:44 -0400 Subject: [PATCH 34/47] Implement OpenAI realtime streaming session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_normalizer/prompt_normalizer.py | 13 + pyrit/prompt_target/common/realtime_audio.py | 40 ++ .../_openai_realtime_streaming_session.py | 287 ++++++++++- .../openai/openai_realtime_target.py | 33 +- .../test_openai_realtime_streaming_session.py | 445 ++++++++++++++++++ 5 files changed, 799 insertions(+), 19 deletions(-) create mode 100644 tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py diff --git a/pyrit/prompt_normalizer/prompt_normalizer.py b/pyrit/prompt_normalizer/prompt_normalizer.py index 09f1881332..261b46cee2 100644 --- a/pyrit/prompt_normalizer/prompt_normalizer.py +++ b/pyrit/prompt_normalizer/prompt_normalizer.py @@ -378,6 +378,19 @@ async def _calc_hash(self, request: Message) -> None: tasks = [asyncio.create_task(piece.set_sha256_values_async()) for piece in request.message_pieces] await asyncio.gather(*tasks) + async def hash_and_persist_message_async(self, *, message: Message) -> None: + """ + Hash and persist a Message to memory. + + Use when a target assembles a Message outside the ``send_prompt_async`` flow + (e.g. streaming sessions that yield per-turn Messages directly). + + Args: + message (Message): The message to hash and persist. + """ + await self._calc_hash(request=message) + self.memory.add_message_to_memory(request=message) + async def add_prepended_conversation_to_memory( self, conversation_id: str, diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 0f6f973247..8fb053e33e 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -130,6 +130,46 @@ async def stop(self) -> None: task.cancel() await asyncio.gather(*pending, return_exceptions=True) + async def drain_callbacks(self) -> None: + """ + Wait for in-flight on_user_audio_committed callback tasks to complete. + + Unlike :meth:`stop`, callbacks are not cancelled — they run to completion. + Use during graceful shutdown when the caller needs the final VAD-committed + turn to finish its convert-and-respond work before tearing down the + dispatcher. + """ + while self._callback_tasks: + pending = list(self._callback_tasks) + await asyncio.gather(*pending, return_exceptions=True) + + def add_failure_callback(self, callback: Callable[[BaseException], None]) -> None: + """ + Register a callback fired if the dispatch loop terminates abnormally. + + The callback is invoked exactly once with the exception that killed the + dispatch loop. Cancellation via :meth:`stop` does NOT trigger the callback. + Use to bridge dispatcher failures to a session-level consumer that would + otherwise block forever waiting on a turn future that will never resolve. + + Args: + callback: Sync callable receiving the dispatch-loop exception. + + Raises: + RuntimeError: If called before :meth:`start`. + """ + if self._task is None: + raise RuntimeError("add_failure_callback must be called after start()") + + def _on_done(task: asyncio.Task[None]) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + callback(exc) + + self._task.add_done_callback(_on_done) + def register_turn(self, state: RealtimeTurnState) -> None: """ Bind a new turn as the active turn. diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 43cfb0f17b..45f33c1daa 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -5,18 +5,48 @@ from __future__ import annotations +import asyncio +import contextlib +import logging import uuid -from typing import TYPE_CHECKING +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pyrit.models import Message, MessagePiece +from pyrit.prompt_target.common.streaming.streaming_audio_target import ( + STREAMING_INTERRUPTED_KEY, +) +from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher + +try: + from openai import BadRequestError as _OpenAIBadRequestError # noqa: TC002 +except ImportError: # pragma: no cover - openai is a hard dependency for this module + _OpenAIBadRequestError = Exception # type: ignore[misc, assignment] if TYPE_CHECKING: from collections.abc import AsyncIterator - from pyrit.models import Message from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer + from pyrit.prompt_target.common.realtime_audio import CommittedEvent from pyrit.prompt_target.common.streaming import ServerVadConfig from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _SentinelDone: + """Producer-side sentinel: all chunks drained and final turn callbacks have finished.""" + + +@dataclass(frozen=True) +class _SentinelError: + """Failure sentinel: bridges an exception raised in a background task to the consumer loop.""" + + exc: BaseException + + class _OpenAIRealtimeStreamingSession: """ Per-conversation lifecycle owner for one OpenAI Realtime streaming exchange. @@ -47,17 +77,256 @@ def __init__( self._prepended_conversation = prepended_conversation or [] self._vad = vad + # Tee raw user audio so we can persist it per VAD-committed turn; the dispatcher + # only surfaces ``CommittedEvent`` with an item id, not the bytes themselves. + self._pending_chunks = bytearray() + self._pending_chunks_lock = asyncio.Lock() + + # Serializes per-turn convert/swap/respond/persist work so two server-VAD + # commits firing back-to-back cannot interleave. + self._turn_lock = asyncio.Lock() + + # Set in ``_on_committed`` entry. Producer awaits this after issuing a + # forced final commit so the resulting callback can be observed before + # we signal end-of-stream and tear the dispatcher down. + self._commit_observed = asyncio.Event() + + # Populated in ``run_async``; held on ``self`` so callbacks can address them. + self._connection: Any = None + self._dispatcher: _OpenAIRealtimeDispatcher | None = None + self._queue: asyncio.Queue[Message | _SentinelDone | _SentinelError] | None = None + async def run_async(self) -> AsyncIterator[Message]: """ Drive the streaming conversation; yield one ``Message`` per VAD-committed user turn. + Yields: + Message: One assembled assistant ``Message`` per turn. The matching user + ``Message`` for each turn is persisted to memory but not yielded. + """ + target = self._target + streaming = target.streaming + + self._connection = await streaming.connect_async(conversation_id=self._conversation_id) + try: + await streaming.send_streaming_session_config_async( + connection=self._connection, + conversation=self._prepended_conversation, + vad=self._vad, + ) + await self._prompt_normalizer.add_prepended_conversation_to_memory( + conversation_id=self._conversation_id, + should_convert=False, + prepended_conversation=self._prepended_conversation, + ) + + self._queue = asyncio.Queue() + self._dispatcher = _OpenAIRealtimeDispatcher( + connection=self._connection, + on_user_audio_committed=self._on_committed, + ) + await self._dispatcher.start() + self._dispatcher.add_failure_callback(self._on_dispatcher_failure) + + producer = asyncio.create_task(self._drain_chunks_async()) + try: + while True: + item = await self._queue.get() + if isinstance(item, _SentinelDone): + break + if isinstance(item, _SentinelError): + raise item.exc + yield item + finally: + if not producer.done(): + producer.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await producer + try: + await self._dispatcher.stop() + except Exception as e: # noqa: BLE001 - cleanup, surface via log + logger.warning(f"dispatcher.stop() raised during session teardown: {e}") + finally: + try: + await self._connection.close() + except Exception as e: # noqa: BLE001 - cleanup, surface via log + logger.warning(f"connection.close() raised during session teardown: {e}") + + async def _drain_chunks_async(self) -> None: + """ + Forward caller chunks to the connection; on exhaustion, force commit and drain callbacks. + Raises: - NotImplementedError: Always. Implementation lands in Phase 2 of the - streaming refactor; the body is scaffolded here so callers can depend - on the public contract. + asyncio.CancelledError: Propagated when the consuming task is cancelled. + """ + assert self._connection is not None + assert self._dispatcher is not None + assert self._queue is not None - Yields: - Message: One ``Message`` per VAD-committed user turn (Phase 2). + connection = self._connection + streaming = self._target.streaming + try: + async for chunk in self._audio_chunks: + if not chunk: + continue + async with self._pending_chunks_lock: + self._pending_chunks.extend(chunk) + await streaming.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) + + # Snapshot commit-event count before forcing a final commit so we can + # detect whether the server accepted it (it produces a new committed + # event) without racing with any concurrent natural commit. + self._commit_observed.clear() + force_commit_accepted = False + try: + await connection.input_audio_buffer.commit() + force_commit_accepted = True + except _OpenAIBadRequestError as e: + # Empty buffer is a benign "nothing pending to commit" — happens whenever + # server VAD already auto-committed the final phrase. Anything else from + # this exception class still indicates a real API problem; log and continue. + logger.debug(f"Forced final commit rejected (likely empty buffer): {e}") + + if force_commit_accepted: + try: + await asyncio.wait_for(self._commit_observed.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning( + "Forced final commit was accepted but no committed event observed within 5s; " + "the final user turn may have been dropped by the server." + ) + + # Let any commit-triggered callbacks (the one we just forced plus any + # natural ones still mid-work) run to completion before signalling done. + await self._dispatcher.drain_callbacks() + await self._queue.put(_SentinelDone()) + except asyncio.CancelledError: + raise + except BaseException as e: # noqa: BLE001 - bridged to consumer via sentinel + await self._queue.put(_SentinelError(e)) + + def _on_dispatcher_failure(self, exc: BaseException) -> None: + """Dispatch-loop crash bridge: unblock the consumer with a failure sentinel.""" + if self._queue is None: + return + try: + self._queue.put_nowait(_SentinelError(exc)) + except Exception as e: # noqa: BLE001 - defensive; never let the bridge raise + logger.warning(f"Failed to bridge dispatcher failure into session queue: {e}") + + async def _on_committed(self, event: CommittedEvent) -> None: + """ + Dispatcher-side callback: snapshot raw audio now, then run the turn under the lock. + + Raises: + asyncio.CancelledError: Propagated when the dispatcher task is cancelled. + """ + assert self._queue is not None + # Snapshot the user audio buffered up to this commit BEFORE acquiring the + # turn lock. Anything pushed afterward belongs to the next turn; without + # this early snapshot, lock contention with a slow prior turn would let + # the next turn's audio leak into this turn's persisted file. + async with self._pending_chunks_lock: + raw_pcm = bytes(self._pending_chunks) + self._pending_chunks.clear() + # Signal the producer that a committed event was observed so a forced final + # commit can verify the server actually processed it. + self._commit_observed.set() + try: + async with self._turn_lock: + message = await self._handle_committed_turn_async(event=event, raw_pcm=raw_pcm) + await self._queue.put(message) + except asyncio.CancelledError: + raise + except BaseException as e: # noqa: BLE001 - bridged to consumer via sentinel + await self._queue.put(_SentinelError(e)) + + async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: bytes) -> Message: """ - raise NotImplementedError("Streaming session implementation lands in Phase 2 of PR #1766") - yield # pragma: no cover - keeps this function an async generator + Convert raw user audio, request a response, then assemble and persist both messages. + + Returns: + The assistant ``Message`` for this turn (the matching user ``Message`` is persisted only). + """ + assert self._connection is not None + assert self._dispatcher is not None + + target = self._target + streaming = target.streaming + sample_rate = streaming.SAMPLE_RATE_HZ + + if self._request_converter_configurations: + converted_pcm = await self._prompt_normalizer.convert_audio_async( + raw_pcm=raw_pcm, + converter_configurations=self._request_converter_configurations, + sample_rate_hz=sample_rate, + num_channels=1, + sample_width_bytes=2, + ) + await target.swap_user_audio_async( + connection=self._connection, + committed_event=event, + converted_pcm=converted_pcm, + ) + else: + converted_pcm = raw_pcm + + future = await target.request_response_async( + connection=self._connection, + dispatcher=self._dispatcher, + ) + result = await future + + raw_user_path = await streaming.save_audio(raw_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate) + if converted_pcm is raw_pcm: + converted_user_path = raw_user_path + else: + converted_user_path = await streaming.save_audio( + converted_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate + ) + assistant_audio_path = await streaming.save_audio( + result.audio_bytes, num_channels=1, sample_width=2, sample_rate=sample_rate + ) + + target_identifier = target.get_identifier() + user_piece = MessagePiece( + role="user", + original_value=raw_user_path, + original_value_data_type="audio_path", + converted_value=converted_user_path, + converted_value_data_type="audio_path", + conversation_id=self._conversation_id, + prompt_target_identifier=target_identifier, + ) + for cfg in self._request_converter_configurations: + user_piece.converter_identifiers.extend(converter.get_identifier() for converter in cfg.converters) + user_message = Message(message_pieces=[user_piece]) + + assistant_text_piece = MessagePiece( + role="assistant", + original_value=result.flatten_transcripts(), + original_value_data_type="text", + conversation_id=self._conversation_id, + prompt_target_identifier=target_identifier, + ) + assistant_audio_piece = MessagePiece( + role="assistant", + original_value=assistant_audio_path, + original_value_data_type="audio_path", + conversation_id=self._conversation_id, + prompt_target_identifier=target_identifier, + ) + if result.interrupted: + assistant_text_piece.prompt_metadata[STREAMING_INTERRUPTED_KEY] = True + assistant_audio_piece.prompt_metadata[STREAMING_INTERRUPTED_KEY] = True + assistant_message = Message(message_pieces=[assistant_text_piece, assistant_audio_piece]) + + if self._response_converter_configurations: + await self._prompt_normalizer.convert_values( + converter_configurations=self._response_converter_configurations, + message=assistant_message, + ) + + await self._prompt_normalizer.hash_and_persist_message_async(message=user_message) + await self._prompt_normalizer.hash_and_persist_message_async(message=assistant_message) + return assistant_message diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index dbb1785e7e..1bed4b1bb8 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -139,7 +139,11 @@ async def subscribe_events_async( return dispatcher async def send_streaming_session_config_async( - self, *, connection: Any, conversation: list[Message] | None = None + self, + *, + connection: Any, + conversation: list[Message] | None = None, + vad: ServerVadConfig | None = None, ) -> None: """ Configure the realtime session for streaming use: server VAD with manual response creation. @@ -153,17 +157,21 @@ async def send_streaming_session_config_async( conversation: Optional conversation history; if its first message is a system message, its text becomes the session's instructions. Defaults to None, in which case the default system prompt is used. + vad: Optional per-call VAD tuning. When provided, overrides the target's + constructor-set ``_server_vad``. When None, falls back to the target's + constructor value (existing behavior). Raises: - ValueError: If the target was constructed without server VAD. + ValueError: If neither ``vad`` nor the target's ``_server_vad`` is set. """ - if self._target._server_vad is None: + effective_vad = vad if vad is not None else self._target._server_vad + if effective_vad is None: raise ValueError( "send_streaming_session_config_async requires server VAD; " - "construct RealtimeTarget(server_vad=True) or pass a ServerVadConfig." + "pass vad=ServerVadConfig(...) or construct RealtimeTarget(server_vad=True)." ) system_prompt = self._target._get_system_prompt_from_conversation(conversation=conversation or []) - config = self._target._set_system_prompt_and_config_vars(system_prompt=system_prompt) + config = self._target._set_system_prompt_and_config_vars(system_prompt=system_prompt, server_vad=effective_vad) turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") if turn_detection is not None: turn_detection["create_response"] = False @@ -458,17 +466,22 @@ def _get_openai_client(self) -> AsyncOpenAI: return self._realtime_client - def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, Any]: + def _set_system_prompt_and_config_vars( + self, system_prompt: str, *, server_vad: ServerVadConfig | None = None + ) -> dict[str, Any]: """ Create session configuration for OpenAI client. Uses the Azure GA format with nested audio config. Args: system_prompt: The system prompt to use in the session configuration. + server_vad: Optional VAD override. When None, falls back to the target's + constructor-set ``self._server_vad``. Returns: dict: Session configuration dictionary. """ + effective_vad = server_vad if server_vad is not None else self._server_vad session_config = { "type": "realtime", "instructions": system_prompt, @@ -492,12 +505,12 @@ def _set_system_prompt_and_config_vars(self, system_prompt: str) -> dict[str, An }, } - if self._server_vad is not None: + if effective_vad is not None: session_config["audio"]["input"]["turn_detection"] = { # type: ignore[ty:invalid-assignment] "type": "server_vad", - "threshold": self._server_vad.threshold, - "prefix_padding_ms": self._server_vad.prefix_padding_ms, - "silence_duration_ms": self._server_vad.silence_duration_ms, + "threshold": effective_vad.threshold, + "prefix_padding_ms": effective_vad.prefix_padding_ms, + "silence_duration_ms": effective_vad.silence_duration_ms, "create_response": True, "interrupt_response": True, } diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py new file mode 100644 index 0000000000..009da48b7f --- /dev/null +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -0,0 +1,445 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Unit tests for the internal _OpenAIRealtimeStreamingSession lifecycle.""" + +import asyncio +import contextlib +import uuid +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from pyrit.models import Message +from pyrit.prompt_target.common.realtime_audio import CommittedEvent, RealtimeTargetResult +from pyrit.prompt_target.common.streaming import ServerVadConfig +from pyrit.prompt_target.common.streaming.streaming_audio_target import ( + STREAMING_INTERRUPTED_KEY, +) +from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( + _OpenAIRealtimeStreamingSession, +) + + +class _StubBadRequest(Exception): # noqa: N818 - stand-in for openai.BadRequestError shape + """Stand-in for openai.BadRequestError raised on empty-buffer forced commit.""" + + +# --------------------------------------------------------------------------- +# Harness helpers +# --------------------------------------------------------------------------- + + +def _paced_chunks(chunks: list[bytes], finish: asyncio.Event): + """Yield each chunk, then block on ``finish`` so the producer can be gated by the test.""" + + async def _gen(): + for chunk in chunks: + yield chunk + await finish.wait() + + return _gen() + + +def _build_target() -> MagicMock: + """Build a MagicMock target exposing the streaming + connection surface the session calls.""" + target = MagicMock(name="RealtimeTarget") + target.streaming = MagicMock(name="streaming") + target.streaming.SAMPLE_RATE_HZ = 24000 + + connection = AsyncMock(name="connection") + # AsyncMock auto-creates attributes as AsyncMock, but child attribute chains like + # ``input_audio_buffer.commit`` need explicit construction so ``commit`` is awaitable + # and we can attach a side_effect to make the forced final commit fail benignly. + connection.input_audio_buffer = MagicMock() + connection.input_audio_buffer.commit = AsyncMock(side_effect=_StubBadRequest("input_audio_buffer_commit_empty")) + + target.streaming.connect_async = AsyncMock(return_value=connection) + target.streaming.send_streaming_session_config_async = AsyncMock() + target.streaming.push_audio_chunk_async = AsyncMock() + target.streaming.save_audio = AsyncMock(side_effect=lambda pcm, **kw: f"/tmp/audio-{uuid.uuid4().hex[:8]}.wav") + target.swap_user_audio_async = AsyncMock() + target.get_identifier = MagicMock( + return_value={"__type__": "RealtimeTarget", "__module__": "test", "id": "test-id"} + ) + return target + + +def _make_request_response_async( + *, + audio_bytes: bytes = b"\xaa" * 96, + transcripts: tuple[str, ...] = ("hi",), + interrupted: bool = False, +) -> AsyncMock: + """AsyncMock for ``RealtimeTarget.request_response_async`` returning a resolved Future.""" + + async def _impl(*, connection: Any, dispatcher: Any) -> asyncio.Future: + future = asyncio.get_running_loop().create_future() + future.set_result( + RealtimeTargetResult( + audio_bytes=audio_bytes, + transcripts=list(transcripts), + interrupted=interrupted, + ) + ) + return future + + return AsyncMock(side_effect=_impl) + + +def _build_normalizer() -> MagicMock: + normalizer = MagicMock(name="PromptNormalizer") + normalizer.add_prepended_conversation_to_memory = AsyncMock() + # Identity: the session treats ``converted is raw_pcm`` as "no converters ran". + normalizer.convert_audio_async = AsyncMock(side_effect=lambda raw_pcm, **kw: raw_pcm) + normalizer.convert_values = AsyncMock() + normalizer.hash_and_persist_message_async = AsyncMock() + return normalizer + + +@contextlib.contextmanager +def _patched_dispatcher(): + """Patch the dispatcher factory + BadRequestError symbol inside the session module.""" + captured: dict[str, Any] = {} + + def _factory(*, connection, on_user_audio_committed): + captured["connection"] = connection + captured["on_user_audio_committed"] = on_user_audio_committed + d = MagicMock(name="dispatcher") + d.start = AsyncMock() + d.stop = AsyncMock() + d.drain_callbacks = AsyncMock() + d.add_failure_callback = MagicMock() + captured["dispatcher"] = d + return d + + with ( + patch( + "pyrit.prompt_target.openai._openai_realtime_streaming_session._OpenAIRealtimeDispatcher", + side_effect=_factory, + ), + patch( + "pyrit.prompt_target.openai._openai_realtime_streaming_session._OpenAIBadRequestError", + _StubBadRequest, + ), + ): + yield captured + + +async def _run_session_with_events( + session: _OpenAIRealtimeStreamingSession, + *, + finish: asyncio.Event, + events: list[CommittedEvent], +) -> list[Message]: + """Drive run_async to completion while firing the supplied committed events sequentially.""" + messages: list[Message] = [] + + async def _consume() -> None: + messages.extend([msg async for msg in session.run_async()]) + + async def _fire() -> None: + # Let the consumer task start and create the dispatcher / queue. + await asyncio.sleep(0) + for event in events: + await session._on_committed(event) + finish.set() + + await asyncio.gather(_consume(), _fire()) + return messages + + +# --------------------------------------------------------------------------- +# 1. Constructor smoke + conversation_id auto-generation +# --------------------------------------------------------------------------- + + +def test_init_autogenerates_conversation_id_when_omitted(): + """Constructor must populate a UUID conversation_id when caller does not supply one.""" + target = _build_target() + normalizer = _build_normalizer() + + async def _empty(): + if False: + yield b"" + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_empty(), + prompt_normalizer=normalizer, + ) + + # Valid UUID4 + parsed = uuid.UUID(session._conversation_id) + assert parsed.version == 4 + + explicit = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_empty(), + prompt_normalizer=normalizer, + conversation_id="conv-explicit", + ) + assert explicit._conversation_id == "conv-explicit" + + +# --------------------------------------------------------------------------- +# 2. Happy path: 2 VAD-committed turns -> 2 yielded Messages, both persisted +# --------------------------------------------------------------------------- + + +async def test_run_async_yields_one_message_per_committed_turn(): + """Two simulated server-VAD commits yield two assistant Messages and persist both user+assistant pairs.""" + target = _build_target() + target.request_response_async = _make_request_response_async(transcripts=("hello", " world")) + normalizer = _build_normalizer() + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100, b"\x02" * 100], finish), + prompt_normalizer=normalizer, + ) + + with _patched_dispatcher(): + messages = await _run_session_with_events( + session, + finish=finish, + events=[CommittedEvent(item_id="item-1"), CommittedEvent(item_id="item-2")], + ) + + assert len(messages) == 2 + for msg in messages: + # Each yielded Message is the assistant message with a text + audio piece. + assert len(msg.message_pieces) == 2 + roles = {piece.api_role for piece in msg.message_pieces} + assert roles == {"assistant"} + data_types = {piece.original_value_data_type for piece in msg.message_pieces} + assert data_types == {"text", "audio_path"} + + # 2 turns * (user + assistant) = 4 persistence calls. + assert normalizer.hash_and_persist_message_async.await_count == 4 + # request_response_async called once per turn. + assert target.request_response_async.await_count == 2 + + +# --------------------------------------------------------------------------- +# 3. Interrupted turn propagates the metadata key to both assistant pieces +# --------------------------------------------------------------------------- + + +async def test_run_async_marks_assistant_pieces_when_turn_interrupted(): + """When a turn is interrupted, STREAMING_INTERRUPTED_KEY must be set on text + audio pieces.""" + target = _build_target() + target.request_response_async = _make_request_response_async(interrupted=True) + normalizer = _build_normalizer() + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100], finish), + prompt_normalizer=normalizer, + ) + + with _patched_dispatcher(): + messages = await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-1")]) + + assert len(messages) == 1 + for piece in messages[0].message_pieces: + assert piece.prompt_metadata.get(STREAMING_INTERRUPTED_KEY) is True + + +# --------------------------------------------------------------------------- +# 4. Response converters run against the assembled assistant Message +# --------------------------------------------------------------------------- + + +async def test_run_async_applies_response_converters_to_assistant_message(): + """Response converter configurations must be applied to the assembled assistant Message.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + response_cfg = MagicMock(name="response_converter_cfg") + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100], finish), + prompt_normalizer=normalizer, + response_converter_configurations=[response_cfg], + ) + + with _patched_dispatcher(): + messages = await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-1")]) + + assert len(messages) == 1 + normalizer.convert_values.assert_awaited_once() + call_kwargs = normalizer.convert_values.await_args.kwargs + assert call_kwargs["converter_configurations"] == [response_cfg] + assert call_kwargs["message"] is messages[0] + + +# --------------------------------------------------------------------------- +# 5. Request converters trigger swap + populate user_piece.converter_identifiers +# --------------------------------------------------------------------------- + + +async def test_run_async_swaps_user_audio_and_records_identifiers_when_request_converters_present(): + """With request converters: convert_audio_async + swap_user_audio_async run, identifiers reach user piece.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + # Force convert_audio_async to return a NEW object so the session treats it as "converted". + normalizer.convert_audio_async = AsyncMock(side_effect=lambda raw_pcm, **kw: b"converted" + raw_pcm) + + fake_converter = MagicMock(name="converter") + fake_converter.get_identifier = MagicMock(return_value={"__type__": "FakeConverter"}) + request_cfg = MagicMock(name="request_converter_cfg") + request_cfg.converters = [fake_converter] + + persisted_user_messages: list[Message] = [] + + async def _capture(*, message: Message) -> None: + if message.message_pieces[0].api_role == "user": + persisted_user_messages.append(message) + + normalizer.hash_and_persist_message_async = AsyncMock(side_effect=_capture) + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100], finish), + prompt_normalizer=normalizer, + request_converter_configurations=[request_cfg], + ) + + with _patched_dispatcher(): + await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-A")]) + + normalizer.convert_audio_async.assert_awaited_once() + target.swap_user_audio_async.assert_awaited_once() + swap_kwargs = target.swap_user_audio_async.await_args.kwargs + assert swap_kwargs["committed_event"].item_id == "item-A" + + assert len(persisted_user_messages) == 1 + user_piece = persisted_user_messages[0].message_pieces[0] + assert user_piece.converter_identifiers == [{"__type__": "FakeConverter"}] + + +async def test_run_async_skips_swap_and_identifiers_when_no_request_converters(): + """Without request converters: no convert_audio_async, no swap_user_audio_async, empty identifiers.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + persisted_user_messages: list[Message] = [] + + async def _capture(*, message: Message) -> None: + if message.message_pieces[0].api_role == "user": + persisted_user_messages.append(message) + + normalizer.hash_and_persist_message_async = AsyncMock(side_effect=_capture) + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100], finish), + prompt_normalizer=normalizer, + ) + + with _patched_dispatcher(): + await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-B")]) + + normalizer.convert_audio_async.assert_not_called() + target.swap_user_audio_async.assert_not_called() + + assert len(persisted_user_messages) == 1 + assert persisted_user_messages[0].message_pieces[0].converter_identifiers == [] + + +# --------------------------------------------------------------------------- +# 6. Prepended conversation + VAD config reach the streaming handle and memory +# --------------------------------------------------------------------------- + + +async def test_run_async_persists_prepended_conversation_and_forwards_vad_config(): + """``prepended_conversation`` reaches normalizer.add_prepended_conversation_to_memory and session.update.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + prepended = [MagicMock(name="prepended_message")] + vad = ServerVadConfig() + + finish = asyncio.Event() + finish.set() # No chunks to drain; iterator exhausts immediately. + + async def _empty(): + if False: + yield b"" + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_empty(), + prompt_normalizer=normalizer, + prepended_conversation=prepended, + vad=vad, + conversation_id="conv-prep", + ) + + with _patched_dispatcher(): + # No committed events; iterator is empty so producer exits immediately. + async for _ in session.run_async(): + pytest.fail("no events were fired; session should yield nothing") + + target.streaming.send_streaming_session_config_async.assert_awaited_once() + config_kwargs = target.streaming.send_streaming_session_config_async.await_args.kwargs + assert config_kwargs["conversation"] == prepended + assert config_kwargs["vad"] is vad + + normalizer.add_prepended_conversation_to_memory.assert_awaited_once() + prep_kwargs = normalizer.add_prepended_conversation_to_memory.await_args.kwargs + assert prep_kwargs["conversation_id"] == "conv-prep" + assert prep_kwargs["should_convert"] is False + assert prep_kwargs["prepended_conversation"] == prepended + + +# --------------------------------------------------------------------------- +# 7. Dispatcher failure (no active turn) propagates via failure callback bridge +# --------------------------------------------------------------------------- + + +async def test_run_async_propagates_dispatcher_failure_via_failure_callback(): + """If the dispatch loop dies without an active turn, the failure callback unblocks the consumer.""" + target = _build_target() + normalizer = _build_normalizer() + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 100], finish), + prompt_normalizer=normalizer, + ) + + dispatcher_failure = RuntimeError("dispatch loop died") + + with _patched_dispatcher() as captured: + + async def _consume() -> None: + async for _ in session.run_async(): + pytest.fail("no message should be yielded before the failure surfaces") + + async def _fire_failure() -> None: + # Let run_async progress past dispatcher.start() and the add_failure_callback registration. + for _ in range(5): + await asyncio.sleep(0) + assert captured["dispatcher"].add_failure_callback.call_count == 1 + registered_cb = captured["dispatcher"].add_failure_callback.call_args.args[0] + # Simulate the dispatch loop dying: invoke the registered failure callback synchronously. + registered_cb(dispatcher_failure) + # Unblock the chunks iterator so the producer can exit cleanly after the consumer raises. + finish.set() + + with pytest.raises(RuntimeError, match="dispatch loop died"): + await asyncio.gather(_consume(), _fire_failure()) From c389217440dae2a146e0f68bcf3a3b1cdcf28997 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 14:39:26 -0400 Subject: [PATCH 35/47] Rewrite BargeInAttack on top of streaming session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/executor/attack/streaming/barge_in.py | 327 +-------- .../_openai_realtime_streaming_session.py | 109 ++- .../openai/openai_realtime_target.py | 73 +- .../attack/streaming/test_barge_in.py | 653 ++---------------- .../test_openai_realtime_streaming_session.py | 364 ++++++++++ 5 files changed, 639 insertions(+), 887 deletions(-) diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 48e39eb95a..fd26149fe2 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -5,7 +5,6 @@ from __future__ import annotations -import asyncio import logging import uuid from dataclasses import dataclass, field @@ -21,10 +20,9 @@ AttackOutcome, AttackResult, Message, - MessagePiece, ) from pyrit.prompt_normalizer import PromptNormalizer -from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY, SupportsStreamingBargeIn +from pyrit.prompt_target import RealtimeTarget from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements @@ -32,9 +30,6 @@ from collections.abc import AsyncIterator from pyrit.prompt_target import PromptTarget - from pyrit.prompt_target.common.realtime_audio import ( - CommittedEvent, - ) logger = logging.getLogger(__name__) @@ -56,75 +51,6 @@ class BargeInAttackContext(AttackContext[AttackParamsT]): audio_chunks: AsyncIterator[bytes] | None = None -@dataclass -class _BargeInRunState: - """Mutable per-session state shared between ``_perform_async`` and ``on_committed``.""" - - raw_buffer: bytearray = field(default_factory=bytearray) - turn_tasks: list[asyncio.Task[None]] = field(default_factory=list) - # Session-time (in ms) at which the current buffer started accumulating. Used to - # convert the server's session-relative ``audio_start_ms`` into a buffer-relative - # offset for trimming. 0 at session start; advances by ``audio_end_ms`` of each - # commit, but since the server omits ``audio_end_ms`` we approximate it as - # ``audio_start_ms + buffer_speech_duration``. In practice we just track the most - # recent commit's reported start so the next turn's trim is relative to it. - buffer_start_session_ms: int = 0 - - -def _trim_snapshot_to_speech( - *, - raw_buffer: bytes, - sample_rate_hz: int, - audio_start_ms: int | None, - prefix_padding_ms: int, - sample_width_bytes: int = 2, - channels: int = 1, -) -> bytes: - """ - Trim leading pre-speech silence from a raw mic snapshot. - - Server VAD reports where speech began via ``audio_start_ms``. The local - accumulator captures every chunk pushed since the last commit — including - seconds of pre-speech silence — so without a trim the converted audio that - gets swapped into the server's committed item would be much longer than - what the server actually committed, causing the model to hear leading silence. - - Args: - raw_buffer: PCM16 mono audio for the current buffer (all bytes pushed since the last commit). - sample_rate_hz: PCM sample rate in Hz. - audio_start_ms: Server's ``audio_start_ms`` offset, or None when unknown. - prefix_padding_ms: Bytes to keep before ``audio_start_ms`` so we don't chop the speech onset - (typically matches server VAD's ``prefix_padding_ms``). - sample_width_bytes: Bytes per sample (2 for PCM16). - channels: Audio channels (1 for mono). - - Returns: - The trimmed buffer; returns ``raw_buffer`` unchanged when ``audio_start_ms`` - is None or 0, or when the computed trim would leave nothing. - - Raises: - ValueError: If ``audio_start_ms`` is negative. - """ - if audio_start_ms is None: - logger.warning( - "audio_start_ms missing on commit; returning full buffer (converter audio may include leading silence)." - ) - return raw_buffer - if audio_start_ms == 0: - return raw_buffer - if audio_start_ms < 0: - raise ValueError(f"audio_start_ms must be >= 0, got {audio_start_ms}") - bytes_per_ms = sample_rate_hz * sample_width_bytes * channels // 1000 - start_ms = max(0, audio_start_ms - prefix_padding_ms) - start_byte = start_ms * bytes_per_ms - # Align to sample frame boundary so the trimmed buffer doesn't start mid-sample. - frame_bytes = sample_width_bytes * channels - start_byte -= start_byte % frame_bytes - if start_byte >= len(raw_buffer): - return raw_buffer - return raw_buffer[start_byte:] - - class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): """ Streaming attack that drives a Realtime API session with server VAD + barge-in. @@ -139,12 +65,6 @@ class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): required=frozenset({CapabilityName.STREAMING_BARGE_IN}), ) - #: Default maximum time to wait after the chunk source exhausts for any in-flight - #: VAD-committed turn to finish (commit → convert → response.create → response.done - #: → persist). Acts as a safety cap; the attack returns as soon as the last turn - #: actually completes. Overridable per-instance via ``max_post_stream_wait_seconds``. - DEFAULT_MAX_POST_STREAM_WAIT_SECONDS: ClassVar[float] = 60.0 - @apply_defaults def __init__( self, @@ -152,26 +72,21 @@ def __init__( objective_target: PromptTarget = REQUIRED_VALUE, # type: ignore[ty:invalid-parameter-default] attack_converter_config: AttackConverterConfig | None = None, prompt_normalizer: PromptNormalizer | None = None, - max_post_stream_wait_seconds: float = DEFAULT_MAX_POST_STREAM_WAIT_SECONDS, params_type: type[AttackParamsT] = AttackParameters, # type: ignore[ty:invalid-parameter-default] ) -> None: """ Initialize the streaming barge-in attack. Args: - objective_target: Target to attack. Must support ``STREAMING_BARGE_IN`` capability. + objective_target: Target to attack. Must be a ``RealtimeTarget`` (the only + target that today exposes ``open_streaming_session``). attack_converter_config: Converters applied to each committed user turn. prompt_normalizer: Normalizer used to apply converters and persist messages. Defaults to a fresh ``PromptNormalizer``. - max_post_stream_wait_seconds: Safety cap on the wait between the chunk source - exhausting and the last in-flight turn finishing. Defaults to 60 seconds. - Bump if a long realtime response is being cancelled at teardown. params_type: Attack parameter dataclass type. Raises: - TypeError: If ``objective_target`` does not satisfy ``SupportsStreamingBargeIn`` - (i.e. it declared ``STREAMING_BARGE_IN`` but did not wire a ``streaming`` - attribute pointing at a ``StreamingHandle``). + TypeError: If ``objective_target`` is not a ``RealtimeTarget``. """ super().__init__( objective_target=objective_target, @@ -179,13 +94,12 @@ def __init__( params_type=params_type, logger=logger, ) - if not isinstance(objective_target, SupportsStreamingBargeIn): + if not isinstance(objective_target, RealtimeTarget): raise TypeError( - f"{type(objective_target).__name__} does not satisfy SupportsStreamingBargeIn " - f"(missing `streaming` attribute). Targets that declare STREAMING_BARGE_IN must " - f"set `self.streaming` to a StreamingHandle instance in `__init__`." + f"{type(objective_target).__name__} is not a RealtimeTarget. BargeInAttack " + f"requires a target that exposes `open_streaming_session`." ) - self._streaming = objective_target.streaming + self._realtime_target: RealtimeTarget = objective_target attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters @@ -194,15 +108,11 @@ def __init__( attack_identifier=self.get_identifier(), prompt_normalizer=self._prompt_normalizer, ) - self._max_post_stream_wait_seconds = max_post_stream_wait_seconds def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: """ Validate the context before executing. - Args: - context: The streaming attack context. - Raises: ValueError: If the context is missing required fields. """ @@ -217,11 +127,13 @@ async def _setup_async(self, *, context: BargeInAttackContext[Any]) -> None: Merges memory labels and persists ``context.prepended_conversation`` to memory via ``ConversationManager`` so streaming attacks share the same memory contract as - non-streaming attacks. Note: prepended messages are recorded in memory but are NOT - pushed into the live realtime session beyond the system prompt — the model only - conditions on the system message and live audio chunks. Pushing prepended user / - assistant turns into the websocket session via ``conversation.item.create`` is - tracked as a follow-up. + non-streaming attacks. The session is opened with ``persist_prepended_conversation=False`` + in ``_perform_async`` so this is the single writer for prepended history. + + Prepended messages are recorded in memory but are NOT pushed into the live realtime + session beyond the system prompt — the model only conditions on the system message + and live audio chunks. Pushing prepended user / assistant turns into the websocket + session via ``conversation.item.create`` is tracked as a follow-up. """ if not context.conversation_id: context.conversation_id = str(uuid.uuid4()) @@ -233,15 +145,12 @@ async def _setup_async(self, *, context: BargeInAttackContext[Any]) -> None: ) async def _teardown_async(self, *, context: BargeInAttackContext[Any]) -> None: - """No-op teardown — connection / dispatcher are closed inside ``_perform_async``.""" + """No-op teardown — connection / dispatcher are closed inside the session's ``run_async``.""" return async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackResult: """ - Run the streaming session: connect, subscribe, push chunks, await final turn, tear down. - - Args: - context: Streaming attack context with ``audio_chunks`` source. + Drive the realtime streaming session and collect per-turn assistant messages. Returns: An ``AttackResult`` capturing the last assistant turn (if any) and the @@ -253,49 +162,22 @@ async def _perform_async(self, *, context: BargeInAttackContext[Any]) -> AttackR if context.audio_chunks is None: raise ValueError("BargeInAttackContext.audio_chunks must be set before executing the attack.") - connection = await self._streaming.connect_async(conversation_id=context.conversation_id) - state = _BargeInRunState() - last_response: Message | None = None - executed_turns = 0 - - async def on_committed(event: CommittedEvent) -> None: - nonlocal last_response, executed_turns - current_task = asyncio.current_task() - if current_task is not None: - state.turn_tasks.append(current_task) - try: - response = await self._handle_committed_turn_async( - event=event, - context=context, - state=state, - ) - last_response = response - executed_turns += 1 - except Exception: - logger.exception("BargeInAttack turn failed in convert-on-commit handler.") - - await self._streaming.subscribe_events_async( - connection=connection, + session = self._realtime_target.open_streaming_session( + audio_chunks=context.audio_chunks, + prompt_normalizer=self._prompt_normalizer, conversation_id=context.conversation_id, - on_user_audio_committed=on_committed, + request_converter_configurations=self._request_converters, + response_converter_configurations=self._response_converters, + prepended_conversation=context.prepended_conversation, + attack_identifier=self.get_identifier(), + persist_prepended_conversation=False, ) - try: - await self._streaming.send_streaming_session_config_async( - connection=connection, conversation=context.prepended_conversation - ) - - async for chunk in context.audio_chunks: - if chunk: - state.raw_buffer.extend(chunk) - await self._streaming.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) - - # Wait for any in-flight committed-turn tasks to finish, capped by a safety timeout. - # The chunk source must end with enough trailing silence for server VAD's silence - # threshold to fire commit — otherwise the last turn never enters the pipeline. - await self._wait_for_pending_turns_async(state.turn_tasks) - finally: - await self._streaming.cleanup_conversation(context.conversation_id) + last_response: Message | None = None + executed_turns = 0 + async for assistant_message in session.run_async(): + last_response = assistant_message + executed_turns += 1 return self._build_result( last_response=last_response, @@ -303,128 +185,6 @@ async def on_committed(event: CommittedEvent) -> None: context=context, ) - async def _handle_committed_turn_async( - self, - *, - event: CommittedEvent, - context: BargeInAttackContext[Any], - state: _BargeInRunState, - ) -> Message: - """ - Run one convert-and-respond turn for a VAD-committed user audio buffer. - - Snapshots the locally-accumulated raw PCM, persists it as a durable WAV, - wraps it in a Message with the server's committed item id stashed in - ``prompt_metadata`` so the target's streaming branch can swap raw audio - for converter-transformed audio, then drives ``send_prompt_async``. - - Returns: - The assistant Message returned by ``send_prompt_async`` for this turn. - """ - snapshot, original_buffer_duration_ms = self._snapshot_and_trim(event=event, state=state) - # Centralize state mutations so the helpers stay pure and testable. - state.raw_buffer.clear() - state.buffer_start_session_ms += original_buffer_duration_ms - - message = await self._build_message_for_turn( - snapshot=snapshot, - item_id=event.item_id, - conversation_id=context.conversation_id, - ) - return await self._send_via_normalizer(message=message, conversation_id=context.conversation_id) - - def _snapshot_and_trim( - self, - *, - event: CommittedEvent, - state: _BargeInRunState, - ) -> tuple[bytes, int]: - """ - Return a trimmed PCM snapshot for the current buffer plus its original (pre-trim) duration. - - Converts the server's session-relative ``audio_start_ms`` into a buffer-relative offset - and trims leading pre-speech silence. Without this, the converted audio that gets - swapped into the server's committed item would be several seconds longer than what - server VAD actually committed, and the model would hear the leading silence (often - dominant) when converters are active. - - The original duration (pre-trim) is returned so the caller can advance session-time - bookkeeping — the server saw every byte we pushed, not just the trimmed snapshot. - - Returns: - ``(snapshot, original_buffer_duration_ms)``. The caller is responsible for - clearing ``state.raw_buffer`` and advancing ``state.buffer_start_session_ms``. - """ - snapshot = bytes(state.raw_buffer) - - bytes_per_ms = self._streaming.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono - original_buffer_duration_ms = len(snapshot) // bytes_per_ms if bytes_per_ms else 0 - - buffer_relative_audio_start_ms: int | None = None - if event.audio_start_ms is not None: - buffer_relative_audio_start_ms = event.audio_start_ms - state.buffer_start_session_ms - - server_vad = self._streaming.server_vad_config - prefix_padding_ms = server_vad.prefix_padding_ms if server_vad is not None else 0 - snapshot = _trim_snapshot_to_speech( - raw_buffer=snapshot, - sample_rate_hz=self._streaming.SAMPLE_RATE_HZ, - audio_start_ms=buffer_relative_audio_start_ms, - prefix_padding_ms=prefix_padding_ms, - ) - return snapshot, original_buffer_duration_ms - - async def _build_message_for_turn( - self, - *, - snapshot: bytes, - item_id: str, - conversation_id: str, - ) -> Message: - """ - Persist the snapshot to disk and wrap it in an audio_path-shaped Message. - - ``send_prompt_async`` requires a file-backed Message, so the caller persists - the PCM bytes to a durable WAV first. The server's committed ``item_id`` is - stashed in ``prompt_metadata`` so the target's streaming branch can identify - which committed item to swap for converter-transformed audio. - - Returns: - The constructed Message containing one ``audio_path`` MessagePiece. - """ - snapshot_path = await self._streaming.save_audio( - snapshot, - num_channels=1, - sample_width=2, - sample_rate=self._streaming.SAMPLE_RATE_HZ, - ) - piece = MessagePiece( - role="user", - original_value=snapshot_path, - original_value_data_type="audio_path", - converted_value=snapshot_path, - converted_value_data_type="audio_path", - conversation_id=conversation_id, - prompt_metadata={REALTIME_COMMITTED_ITEM_ID_KEY: item_id}, - ) - return Message(message_pieces=[piece]) - - async def _send_via_normalizer(self, *, message: Message, conversation_id: str) -> Message: - """ - Send a built turn-Message through the normalizer with this attack's converters. - - Returns: - The assistant Message returned by ``PromptNormalizer.send_prompt_async``. - """ - return await self._prompt_normalizer.send_prompt_async( - message=message, - target=self._objective_target, - request_converter_configurations=self._request_converters, - response_converter_configurations=self._response_converters, - conversation_id=conversation_id, - attack_identifier=self.get_identifier(), - ) - def _build_result( self, *, @@ -456,30 +216,3 @@ def _build_result( executed_turns=executed_turns, labels=context.memory_labels, ) - - async def _wait_for_pending_turns_async(self, turn_tasks: list[asyncio.Task[None]]) -> None: - """ - Wait for any in-flight VAD-committed turn tasks to finish, with a safety timeout. - - Returns as soon as all known turn tasks complete (or the cap elapses, whichever - comes first). The timeout is a safety net for stuck turns; the common case is to - return immediately once the last turn's persistence finishes. - - Args: - turn_tasks: Task handles for every ``on_committed`` invocation launched so far. - Tasks added after this method starts are not waited on; the dispatcher - callback machinery makes this race vanishingly unlikely in practice. - """ - if not turn_tasks: - return - try: - await asyncio.wait_for( - asyncio.gather(*turn_tasks, return_exceptions=True), - timeout=self._max_post_stream_wait_seconds, - ) - except asyncio.TimeoutError: - logger.warning( - f"Timed out after {self._max_post_stream_wait_seconds}s waiting for in-flight turn tasks to " - "finish; teardown will cancel them. Raise max_post_stream_wait_seconds on the attack " - "constructor if responses regularly take longer." - ) diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 45f33c1daa..4d22cd82cc 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from collections.abc import AsyncIterator + from pyrit.identifiers import ComponentIdentifier from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer from pyrit.prompt_target.common.realtime_audio import CommittedEvent from pyrit.prompt_target.common.streaming import ServerVadConfig @@ -35,6 +36,51 @@ logger = logging.getLogger(__name__) +def _trim_snapshot_to_speech( + *, + raw_buffer: bytes, + sample_rate_hz: int, + audio_start_ms: int | None, + prefix_padding_ms: int, + sample_width_bytes: int = 2, + channels: int = 1, +) -> bytes: + """ + Trim leading pre-speech silence from a raw mic snapshot. + + Server VAD reports where speech began via ``audio_start_ms``. The session's + local accumulator captures every chunk pushed since the last commit — including + seconds of pre-speech silence — so without a trim the converted audio that + gets swapped into the server's committed item would be much longer than + what the server actually committed, causing the model to hear leading silence. + + Returns: + The trimmed PCM. Returns ``raw_buffer`` unchanged when ``audio_start_ms`` is + ``None`` or ``0``, or when the computed trim would leave nothing. + + Raises: + ValueError: If ``audio_start_ms`` is negative. + """ + if audio_start_ms is None: + logger.warning( + "audio_start_ms missing on commit; returning full buffer (converter audio may include leading silence)." + ) + return raw_buffer + if audio_start_ms == 0: + return raw_buffer + if audio_start_ms < 0: + raise ValueError(f"audio_start_ms must be >= 0, got {audio_start_ms}") + bytes_per_ms = sample_rate_hz * sample_width_bytes * channels // 1000 + start_ms = max(0, audio_start_ms - prefix_padding_ms) + start_byte = start_ms * bytes_per_ms + # Align to sample frame boundary so the trimmed buffer doesn't start mid-sample. + frame_bytes = sample_width_bytes * channels + start_byte -= start_byte % frame_bytes + if start_byte >= len(raw_buffer): + return raw_buffer + return raw_buffer[start_byte:] + + @dataclass(frozen=True) class _SentinelDone: """Producer-side sentinel: all chunks drained and final turn callbacks have finished.""" @@ -52,7 +98,7 @@ class _OpenAIRealtimeStreamingSession: Per-conversation lifecycle owner for one OpenAI Realtime streaming exchange. Internal to :mod:`pyrit.prompt_target.openai`. Constructed and consumed only by - :meth:`RealtimeTarget.send_streaming_prompt_async`; downstream code should depend on + :meth:`RealtimeTarget.open_streaming_session`; downstream code should depend on the ``AsyncIterator[Message]`` contract, never on this class directly. """ @@ -67,6 +113,8 @@ def __init__( response_converter_configurations: list[PromptConverterConfiguration] | None = None, prepended_conversation: list[Message] | None = None, vad: ServerVadConfig | None = None, + attack_identifier: ComponentIdentifier | None = None, + persist_prepended_conversation: bool = True, ) -> None: self._target = target self._audio_chunks = audio_chunks @@ -76,12 +124,20 @@ def __init__( self._response_converter_configurations = response_converter_configurations or [] self._prepended_conversation = prepended_conversation or [] self._vad = vad + self._attack_identifier = attack_identifier + self._persist_prepended_conversation = persist_prepended_conversation # Tee raw user audio so we can persist it per VAD-committed turn; the dispatcher # only surfaces ``CommittedEvent`` with an item id, not the bytes themselves. self._pending_chunks = bytearray() self._pending_chunks_lock = asyncio.Lock() + # Session-time (ms) at which the current buffer started accumulating. Used to + # convert the server's session-relative ``audio_start_ms`` into a buffer-relative + # offset for trimming. Advanced under ``_pending_chunks_lock`` so back-to-back + # commits cannot interleave with the snapshot/trim. + self._buffer_start_session_ms: int = 0 + # Serializes per-turn convert/swap/respond/persist work so two server-VAD # commits firing back-to-back cannot interleave. self._turn_lock = asyncio.Lock() @@ -114,11 +170,12 @@ async def run_async(self) -> AsyncIterator[Message]: conversation=self._prepended_conversation, vad=self._vad, ) - await self._prompt_normalizer.add_prepended_conversation_to_memory( - conversation_id=self._conversation_id, - should_convert=False, - prepended_conversation=self._prepended_conversation, - ) + if self._persist_prepended_conversation: + await self._prompt_normalizer.add_prepended_conversation_to_memory( + conversation_id=self._conversation_id, + should_convert=False, + prepended_conversation=self._prepended_conversation, + ) self._queue = asyncio.Queue() self._dispatcher = _OpenAIRealtimeDispatcher( @@ -216,25 +273,50 @@ def _on_dispatcher_failure(self, exc: BaseException) -> None: async def _on_committed(self, event: CommittedEvent) -> None: """ - Dispatcher-side callback: snapshot raw audio now, then run the turn under the lock. + Dispatcher-side callback: snapshot raw audio + trim now, then run the turn under the lock. + + Snapshot, trim, and ``_buffer_start_session_ms`` advance all happen under + ``_pending_chunks_lock`` so back-to-back commits (the dispatcher schedules + callbacks as background tasks) cannot interleave and corrupt the trim or + the offset bookkeeping. The slow convert/swap/respond work then runs + outside this lock, gated by ``_turn_lock``. Raises: asyncio.CancelledError: Propagated when the dispatcher task is cancelled. """ assert self._queue is not None - # Snapshot the user audio buffered up to this commit BEFORE acquiring the - # turn lock. Anything pushed afterward belongs to the next turn; without - # this early snapshot, lock contention with a slow prior turn would let - # the next turn's audio leak into this turn's persisted file. + streaming = self._target.streaming + sample_rate = streaming.SAMPLE_RATE_HZ + async with self._pending_chunks_lock: raw_pcm = bytes(self._pending_chunks) self._pending_chunks.clear() + + bytes_per_ms = sample_rate * 2 // 1000 # PCM16 mono + buffer_duration_ms = len(raw_pcm) // bytes_per_ms if bytes_per_ms else 0 + + buffer_relative_audio_start_ms: int | None = None + if event.audio_start_ms is not None: + buffer_relative_audio_start_ms = event.audio_start_ms - self._buffer_start_session_ms + + # ``self._vad is None`` means "use target default", not "no VAD". + effective_vad = self._vad if self._vad is not None else streaming.server_vad_config + prefix_padding_ms = effective_vad.prefix_padding_ms if effective_vad is not None else 0 + + trimmed_pcm = _trim_snapshot_to_speech( + raw_buffer=raw_pcm, + sample_rate_hz=sample_rate, + audio_start_ms=buffer_relative_audio_start_ms, + prefix_padding_ms=prefix_padding_ms, + ) + self._buffer_start_session_ms += buffer_duration_ms + # Signal the producer that a committed event was observed so a forced final # commit can verify the server actually processed it. self._commit_observed.set() try: async with self._turn_lock: - message = await self._handle_committed_turn_async(event=event, raw_pcm=raw_pcm) + message = await self._handle_committed_turn_async(event=event, raw_pcm=trimmed_pcm) await self._queue.put(message) except asyncio.CancelledError: raise @@ -297,6 +379,7 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: converted_value_data_type="audio_path", conversation_id=self._conversation_id, prompt_target_identifier=target_identifier, + attack_identifier=self._attack_identifier, ) for cfg in self._request_converter_configurations: user_piece.converter_identifiers.extend(converter.get_identifier() for converter in cfg.converters) @@ -308,6 +391,7 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: original_value_data_type="text", conversation_id=self._conversation_id, prompt_target_identifier=target_identifier, + attack_identifier=self._attack_identifier, ) assistant_audio_piece = MessagePiece( role="assistant", @@ -315,6 +399,7 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: original_value_data_type="audio_path", conversation_id=self._conversation_id, prompt_target_identifier=target_identifier, + attack_identifier=self._attack_identifier, ) if result.interrupted: assistant_text_piece.prompt_metadata[STREAMING_INTERRUPTED_KEY] = True diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 1bed4b1bb8..6fd005db7f 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -8,7 +8,7 @@ import wave from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any, ClassVar, Literal, Optional +from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional from openai import AsyncOpenAI @@ -37,6 +37,14 @@ from pyrit.prompt_target.common.utils import limit_requests_per_minute from pyrit.prompt_target.openai.openai_target import OpenAITarget +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer + from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( + _OpenAIRealtimeStreamingSession, + ) + logger = logging.getLogger(__name__) # Voices supported by the OpenAI Realtime API. @@ -355,6 +363,69 @@ def __init__( # type against the provider-agnostic ``StreamingHandle`` ABC. self.streaming = _RealtimeStreamingHandle(target=self) + def open_streaming_session( + self, + *, + audio_chunks: "AsyncIterator[bytes]", + prompt_normalizer: "PromptNormalizer", + conversation_id: str | None = None, + request_converter_configurations: "list[PromptConverterConfiguration] | None" = None, + response_converter_configurations: "list[PromptConverterConfiguration] | None" = None, + prepended_conversation: list[Message] | None = None, + vad: ServerVadConfig | None = None, + attack_identifier: "ComponentIdentifier | None" = None, + persist_prepended_conversation: bool = True, + ) -> "_OpenAIRealtimeStreamingSession": + """ + Open a new server-VAD streaming session bound to this target. + + Returns: + A fresh :class:`_OpenAIRealtimeStreamingSession`. Drive it by iterating + ``await session.run_async()``; one assistant ``Message`` is yielded per + VAD-committed turn, and the matching user message is persisted to memory + (but not yielded). The session owns its websocket connection + dispatcher + for the duration of ``run_async``. + + Args: + audio_chunks: Async iterator yielding PCM16 mono bytes at the target's + ``streaming.SAMPLE_RATE_HZ`` rate. + prompt_normalizer: Normalizer used to apply converters and persist messages. + conversation_id: Conversation id for this session. Auto-generated when omitted. + request_converter_configurations: Converters applied to each committed user turn + before swap-and-respond. + response_converter_configurations: Converters applied to each assistant turn + before persistence. + prepended_conversation: Optional conversation history. The leading system + message becomes session instructions. + vad: Optional per-call VAD tuning. When ``None``, falls back to the target's + constructor-set ``server_vad``. + attack_identifier: Stamped on every persisted user / assistant piece for + attribution. Pass the caller's identifier so live messages share the + provenance contract of prepended messages. + persist_prepended_conversation: When ``True`` (default), the session writes + ``prepended_conversation`` to memory itself. Pass ``False`` when the + caller already persisted the prepended conversation (e.g. via + ``ConversationManager.initialize_context_async``) to avoid double-writes. + """ + # Local import: the session module imports ``_OpenAIRealtimeDispatcher`` from + # this module, so a module-level import here would be circular. + from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( + _OpenAIRealtimeStreamingSession, + ) + + return _OpenAIRealtimeStreamingSession( + target=self, + audio_chunks=audio_chunks, + prompt_normalizer=prompt_normalizer, + conversation_id=conversation_id, + request_converter_configurations=request_converter_configurations, + response_converter_configurations=response_converter_configurations, + prepended_conversation=prepended_conversation, + vad=vad, + attack_identifier=attack_identifier, + persist_prepended_conversation=persist_prepended_conversation, + ) + def _set_openai_env_configuration_vars(self) -> None: self.model_name_environment_variable = "OPENAI_REALTIME_MODEL" self.endpoint_environment_variable = "OPENAI_REALTIME_ENDPOINT" diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 9eccb0fef7..52e45d381a 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -1,23 +1,19 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -"""Unit tests for ``BargeInAttack`` and supporting helpers.""" +"""Unit tests for ``BargeInAttack``.""" from __future__ import annotations -import asyncio from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from pyrit.executor.attack import BargeInAttack, BargeInAttackContext -from pyrit.executor.attack.core import AttackConverterConfig, AttackParameters -from pyrit.executor.attack.streaming.barge_in import _BargeInRunState, _trim_snapshot_to_speech +from pyrit.executor.attack.core import AttackParameters from pyrit.models import AttackOutcome, Message, MessagePiece -from pyrit.prompt_normalizer import PromptConverterConfiguration from pyrit.prompt_target import RealtimeTarget -from pyrit.prompt_target.common.realtime_audio import REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent if TYPE_CHECKING: from collections.abc import AsyncIterator @@ -82,32 +78,6 @@ def test_constructor_succeeds_even_without_server_vad_enabled(sqlite_instance): assert attack.get_objective_target() is no_vad -def test_constructor_default_max_post_stream_wait_seconds(vad_target): - """When not passed, max_post_stream_wait_seconds takes the class default.""" - attack = BargeInAttack(objective_target=vad_target) - assert attack._max_post_stream_wait_seconds == BargeInAttack.DEFAULT_MAX_POST_STREAM_WAIT_SECONDS - - -def test_constructor_accepts_custom_max_post_stream_wait_seconds(vad_target): - """max_post_stream_wait_seconds is configurable per-instance.""" - attack = BargeInAttack(objective_target=vad_target, max_post_stream_wait_seconds=120.0) - assert attack._max_post_stream_wait_seconds == 120.0 - - -def test_constructor_caches_streaming_handle(vad_target): - """BargeInAttack stashes the target's streaming handle for direct access during _setup_async.""" - attack = BargeInAttack(objective_target=vad_target) - assert attack._streaming is vad_target.streaming - - -def test_constructor_rejects_target_without_streaming_handle(vad_target): - """A target that doesn't satisfy SupportsStreamingBargeIn (no streaming attr) fails fast.""" - # Simulate a malformed target: capability flag still set, but streaming attribute removed. - del vad_target.streaming - with pytest.raises(TypeError, match="does not satisfy SupportsStreamingBargeIn"): - BargeInAttack(objective_target=vad_target) - - # ---- Context validation ---------------------------------------------------------------------- @@ -213,248 +183,117 @@ async def test_setup_async_no_op_when_prepended_conversation_empty(vad_target): assert add_calls == [] -# ---- Streaming loop end-to-end --------------------------------------------------------------- - - -def _setup_streaming_target(vad_target, *, future_response: Message | None = None) -> AsyncMock: - """ - Mock the streaming-mode surface on ``vad_target`` and return the connection mock. - - Stubs ``connect_async``, ``send_streaming_session_config_async``, ``push_audio_chunk_async``, - ``subscribe_events_async``, ``save_audio``, and ``cleanup_conversation`` so a callback can - be invoked mid-stream without exercising the real target machinery. - """ - connection = _mock_connection() - vad_target.streaming.connect_async = AsyncMock(return_value=connection) - vad_target.streaming.send_streaming_session_config_async = AsyncMock() - vad_target.streaming.push_audio_chunk_async = AsyncMock() - vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/snapshot.wav") - vad_target.streaming.cleanup_conversation = AsyncMock() - return connection - - -def _capture_committed_callback(vad_target, captured: dict[str, Any]) -> None: - """Wire ``subscribe_events_async`` to capture the registered ``on_user_audio_committed``.""" +# ---- _perform_async: session factory passthrough ---------------------------------------------- - async def fake_subscribe(*, connection, conversation_id, on_user_audio_committed): - captured["on_committed"] = on_user_audio_committed - return AsyncMock() - vad_target.streaming.subscribe_events_async = AsyncMock(side_effect=fake_subscribe) - - -def _stub_send_prompt(attack: BargeInAttack, return_value: Message | None = None) -> AsyncMock: - """Replace the attack's prompt_normalizer.send_prompt_async with an AsyncMock and return it.""" - if return_value is None: - return_value = Message( - message_pieces=[ - MessagePiece( - role="assistant", - original_value="ok", - original_value_data_type="text", - converted_value="ok", - converted_value_data_type="text", - conversation_id="any", - ) - ] - ) - send_mock = AsyncMock(return_value=return_value) - attack._prompt_normalizer.send_prompt_async = send_mock - return send_mock - - -async def test_perform_async_streams_chunks_and_tears_down(vad_target): - """Happy path: connect, send config, subscribe, push chunks, then cleanup_conversation — no commits.""" - attack = BargeInAttack(objective_target=vad_target) - connection = _setup_streaming_target(vad_target) - dispatcher = AsyncMock() - vad_target.streaming.subscribe_events_async = AsyncMock(return_value=dispatcher) - - chunks = [b"\x11" * 480, b"\x22" * 480, b"\x33" * 240] - ctx = _attack_context(audio_chunks=_aiter(chunks)) - - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - result = await attack._perform_async(context=ctx) - - vad_target.streaming.connect_async.assert_awaited_once_with(conversation_id=ctx.conversation_id) - vad_target.streaming.send_streaming_session_config_async.assert_awaited_once() - vad_target.streaming.subscribe_events_async.assert_awaited_once() - assert vad_target.streaming.push_audio_chunk_async.await_count == len(chunks) - pushed = [call.kwargs["pcm_bytes"] for call in vad_target.streaming.push_audio_chunk_async.await_args_list] - assert pushed == chunks - vad_target.streaming.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) - assert result.executed_turns == 0 - assert result.outcome == AttackOutcome.UNDETERMINED - - -async def test_perform_async_calls_send_prompt_async_on_commit(vad_target): - """A commit must invoke prompt_normalizer.send_prompt_async with an audio_path Message.""" - bump = MagicMock() - bump.get_identifier = MagicMock(return_value=MagicMock()) - converter_config = AttackConverterConfig( - request_converters=PromptConverterConfiguration.from_converters(converters=[bump]), +def _assistant_message(text: str = "ok") -> Message: + return Message( + message_pieces=[ + MessagePiece( + role="assistant", + original_value=text, + original_value_data_type="text", + converted_value=text, + converted_value_data_type="text", + conversation_id="any", + ) + ] ) - attack = BargeInAttack(objective_target=vad_target, attack_converter_config=converter_config) - send_mock = _stub_send_prompt(attack) - _setup_streaming_target(vad_target) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield b"\x05" * 480 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="item_42"))) - ctx = _attack_context(audio_chunks=chunks_then_commit()) +def _fake_session(messages: list[Message] | None = None, raise_exc: Exception | None = None) -> MagicMock: + """Return a mock session whose ``run_async()`` yields ``messages`` or raises ``raise_exc``.""" - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - result = await attack._perform_async(context=ctx) + async def _gen(): + for m in messages or []: + yield m + if raise_exc is not None: + raise raise_exc - send_mock.assert_awaited_once() - kwargs = send_mock.call_args.kwargs - sent_message = kwargs["message"] - assert sent_message.message_pieces[0].converted_value_data_type == "audio_path" - assert sent_message.message_pieces[0].conversation_id == ctx.conversation_id - assert sent_message.message_pieces[0].prompt_metadata[REALTIME_COMMITTED_ITEM_ID_KEY] == "item_42" - assert kwargs["target"] is vad_target - assert kwargs["request_converter_configurations"] == attack._request_converters - assert kwargs["conversation_id"] == ctx.conversation_id - assert result.executed_turns == 1 + session = MagicMock() + session.run_async = MagicMock(return_value=_gen()) + return session -async def test_perform_async_message_carries_snapshot_audio_path(vad_target): - """The audio_path on the user piece must point at the persisted snapshot WAV.""" +async def test_perform_async_opens_session_with_expected_kwargs(vad_target): + """``_perform_async`` constructs the session via the target factory with the right kwargs.""" attack = BargeInAttack(objective_target=vad_target) - send_mock = _stub_send_prompt(attack) - connection = _setup_streaming_target(vad_target) - vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted_snapshot.wav") - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - - raw_chunk = b"\x07" * 96 + fake = _fake_session(messages=[_assistant_message()]) - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield raw_chunk - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i"))) + chunks = _aiter([b"\x00" * 96]) + ctx = _attack_context(audio_chunks=chunks) - ctx = _attack_context(audio_chunks=chunks_then_commit()) - - with patch.object(attack, "_max_post_stream_wait_seconds", 0): + with patch.object(RealtimeTarget, "open_streaming_session", return_value=fake) as factory: await attack._perform_async(context=ctx) - # save_audio called with the snapshot PCM; the resulting path lands on the message piece. - save_kwargs_or_args = vad_target.streaming.save_audio.call_args - saved_pcm = ( - save_kwargs_or_args.args[0] if save_kwargs_or_args.args else save_kwargs_or_args.kwargs.get("audio_bytes") - ) - assert saved_pcm == raw_chunk - piece = send_mock.call_args.kwargs["message"].message_pieces[0] - assert piece.original_value == "/tmp/persisted_snapshot.wav" - assert piece.converted_value == "/tmp/persisted_snapshot.wav" + factory.assert_called_once() + kwargs = factory.call_args.kwargs + assert kwargs["audio_chunks"] is chunks + assert kwargs["prompt_normalizer"] is attack._prompt_normalizer + assert kwargs["conversation_id"] == ctx.conversation_id + assert kwargs["request_converter_configurations"] == attack._request_converters + assert kwargs["response_converter_configurations"] == attack._response_converters + assert kwargs["prepended_conversation"] == ctx.prepended_conversation + assert kwargs["attack_identifier"] == attack.get_identifier() + assert kwargs["persist_prepended_conversation"] is False -async def test_perform_async_clears_raw_buffer_between_commits(vad_target): - """Each commit gets fresh PCM: the snapshot saved for turn 2 has no carryover from turn 1.""" +async def test_perform_async_aggregates_assistant_turns(vad_target): + """Multiple yielded Messages bump executed_turns and last_response tracks the final one.""" attack = BargeInAttack(objective_target=vad_target) - _stub_send_prompt(attack) - _setup_streaming_target(vad_target) - saved_pcm: list[bytes] = [] + messages = [_assistant_message("first"), _assistant_message("second")] + fake = _fake_session(messages=messages) - async def fake_save_audio(audio_bytes, **_): - saved_pcm.append(audio_bytes) - return f"/tmp/snap_{len(saved_pcm)}.wav" + ctx = _attack_context(audio_chunks=_aiter([b"\x00"])) - vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - - async def chunks_two_commits() -> AsyncIterator[bytes]: - yield b"\x01" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i1"))) - yield b"\x02" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i2"))) + with patch.object(RealtimeTarget, "open_streaming_session", return_value=fake): + result = await attack._perform_async(context=ctx) - ctx = _attack_context(audio_chunks=chunks_two_commits()) + assert result.executed_turns == 2 + assert result.last_response is not None + assert result.last_response.converted_value == "second" + assert result.outcome == AttackOutcome.UNDETERMINED - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - await attack._perform_async(context=ctx) - assert saved_pcm == [b"\x01" * 96, b"\x02" * 96] +async def test_perform_async_zero_turns_returns_undetermined(vad_target): + """If the session yields no Messages, executed_turns is 0 and outcome_reason explains it.""" + attack = BargeInAttack(objective_target=vad_target) + fake = _fake_session(messages=[]) + ctx = _attack_context(audio_chunks=_aiter([b"\x00"])) -async def test_perform_async_tracks_last_response_and_turn_count(vad_target): - """AttackResult.last_response is the last Message from send_prompt_async; count matches commits.""" - attack = BargeInAttack(objective_target=vad_target) - responses_in_order = [ - Message( - message_pieces=[ - MessagePiece( - role="assistant", - original_value=text, - original_value_data_type="text", - converted_value=text, - converted_value_data_type="text", - conversation_id="x", - ) - ] - ) - for text in ("first", "second", "final") - ] - send_mock = AsyncMock(side_effect=responses_in_order) - attack._prompt_normalizer.send_prompt_async = send_mock - _setup_streaming_target(vad_target) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - - async def chunks_three_commits() -> AsyncIterator[bytes]: - for i in range(3): - yield bytes([i + 1]) * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id=f"i{i}"))) - - ctx = _attack_context(audio_chunks=chunks_three_commits()) - - with patch.object(attack, "_max_post_stream_wait_seconds", 0): + with patch.object(RealtimeTarget, "open_streaming_session", return_value=fake): result = await attack._perform_async(context=ctx) - assert result.executed_turns == 3 - assert result.last_response is not None - assert result.last_response.converted_value == "final" + assert result.executed_turns == 0 + assert result.outcome == AttackOutcome.UNDETERMINED + assert result.last_response is None + assert "No assistant turns completed" in (result.outcome_reason or "") -async def test_perform_async_cleans_up_even_on_exception(vad_target): - """If the chunk loop raises, cleanup_conversation still fires.""" +async def test_perform_async_propagates_session_exception(vad_target): + """Exceptions raised inside ``session.run_async`` propagate to the caller.""" attack = BargeInAttack(objective_target=vad_target) - _setup_streaming_target(vad_target) - vad_target.streaming.push_audio_chunk_async = AsyncMock(side_effect=RuntimeError("push exploded")) - vad_target.streaming.subscribe_events_async = AsyncMock(return_value=AsyncMock()) + fake = _fake_session(raise_exc=RuntimeError("dispatcher blew up")) - ctx = _attack_context(audio_chunks=_aiter([b"\x00" * 96])) + ctx = _attack_context(audio_chunks=_aiter([b"\x00"])) - with pytest.raises(RuntimeError, match="push exploded"): - with patch.object(attack, "_max_post_stream_wait_seconds", 0): + with patch.object(RealtimeTarget, "open_streaming_session", return_value=fake): + with pytest.raises(RuntimeError, match="dispatcher blew up"): await attack._perform_async(context=ctx) - vad_target.streaming.cleanup_conversation.assert_awaited_once_with(ctx.conversation_id) - -async def test_perform_async_swallows_callback_exception(vad_target): - """If send_prompt_async raises mid-turn, the session keeps going (no executed turn).""" +async def test_perform_async_rejects_missing_audio_chunks(vad_target): + """``audio_chunks=None`` raises ValueError before any session is opened.""" attack = BargeInAttack(objective_target=vad_target) - attack._prompt_normalizer.send_prompt_async = AsyncMock(side_effect=RuntimeError("converter blew up")) - _setup_streaming_target(vad_target) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) + ctx = BargeInAttackContext(params=AttackParameters(objective="obj")) + assert ctx.audio_chunks is None - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield b"\x00" * 96 - await asyncio.create_task(captured["on_committed"](CommittedEvent(item_id="i"))) - - ctx = _attack_context(audio_chunks=chunks_then_commit()) + with patch.object(RealtimeTarget, "open_streaming_session") as factory: + with pytest.raises(ValueError, match="audio_chunks"): + await attack._perform_async(context=ctx) - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - result = await attack._perform_async(context=ctx) - - # The callback caught the exception; no turn counted as successful. - assert result.executed_turns == 0 + factory.assert_not_called() # ---- send_streaming_session_config_async (target-side helper added in R4a) ------------------- @@ -496,343 +335,3 @@ async def test_send_streaming_session_config_async_uses_system_message_from_conv await vad_target.streaming.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) config = connection.session.update.call_args.kwargs["session"] assert config["instructions"] == "You are a strict assistant." - - -# ---- _trim_snapshot_to_speech (pre-speech silence trim) ------------------------------------- - - -def test_trim_drops_leading_silence_using_audio_start_ms(): - """When audio_start_ms is set, everything before (audio_start_ms - prefix_padding_ms) is trimmed.""" - # 24 kHz mono PCM16 → 48 bytes per ms. 1000 ms of silence + 100 ms of "speech". - silence = b"\x00" * (1000 * 48) - speech = b"\x11" * (100 * 48) - buffer = silence + speech - - trimmed = _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=1000, # speech starts at 1000 ms - prefix_padding_ms=200, # keep 200 ms before speech - ) - - # Expect: dropped 800 ms (1000 - 200) of silence; kept 200 ms silence + 100 ms speech. - assert len(trimmed) == (200 + 100) * 48 - assert trimmed[-len(speech) :] == speech - - -def test_trim_passes_through_when_audio_start_ms_missing(): - """If the server didn't report audio_start_ms, no trim happens.""" - buffer = b"\xff" * 480 - assert ( - _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=None, - prefix_padding_ms=300, - ) - is buffer - ) - - -def test_trim_passes_through_when_audio_start_ms_zero(): - """audio_start_ms == 0 means speech started immediately; no trim.""" - buffer = b"\xff" * 480 - assert ( - _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=0, - prefix_padding_ms=300, - ) - is buffer - ) - - -def test_trim_raises_on_negative_audio_start_ms(): - """A negative audio_start_ms is a server contract violation, not 'unknown'.""" - buffer = b"\xff" * 480 - with pytest.raises(ValueError, match="audio_start_ms must be >= 0"): - _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=-100, - prefix_padding_ms=300, - ) - - -def test_trim_clamps_when_audio_start_ms_less_than_prefix_padding(): - """audio_start_ms - prefix_padding_ms shouldn't go negative.""" - buffer = b"\xab" * (500 * 48) - trimmed = _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=100, - prefix_padding_ms=300, - ) - # max(0, 100 - 300) = 0 → no bytes dropped. - assert trimmed == buffer - - -def test_trim_aligns_to_sample_boundary(): - """Trim must land on a sample-frame boundary (2 bytes for PCM16 mono) so playback isn't garbled.""" - # Sample rate 8000 Hz → 16 bytes/ms; audio_start_ms=3, prefix=0 → start_byte=48 (aligned). - buffer = bytes(range(256)) * 4 # arbitrary bytes - trimmed = _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=8000, - audio_start_ms=3, - prefix_padding_ms=0, - sample_width_bytes=2, - channels=1, - ) - # 48 bytes is already a frame boundary (48 % 2 == 0). - assert len(trimmed) == len(buffer) - 48 - # Sanity: the trim point is sample-aligned. - assert (len(buffer) - len(trimmed)) % 2 == 0 - - -def test_trim_passes_through_when_computed_start_exceeds_buffer(): - """Safety: if audio_start_ms points past the buffer, return the buffer unchanged.""" - buffer = b"\x00" * 480 # 10 ms at 24 kHz - trimmed = _trim_snapshot_to_speech( - raw_buffer=buffer, - sample_rate_hz=24000, - audio_start_ms=10_000, - prefix_padding_ms=0, - ) - assert trimmed is buffer - - -async def test_perform_async_trims_first_turn_using_audio_start_ms(vad_target): - """Turn 1: buffer_start_session_ms=0, so audio_start_ms is already buffer-relative.""" - from pyrit.prompt_target.common.realtime_audio import ServerVadConfig - - # Pin prefix_padding_ms to a known value so the expected byte count is unambiguous. - vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) - - attack = BargeInAttack(objective_target=vad_target) - send_mock = _stub_send_prompt(attack) - _setup_streaming_target(vad_target) - saved_pcm: list[bytes] = [] - - async def fake_save_audio(audio_bytes, **_): - saved_pcm.append(audio_bytes) - return "/tmp/snap.wav" - - vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - - # 1000 ms of leading silence + 100 ms speech-like payload at 24 kHz mono PCM16 → 48 bytes/ms. - silence = b"\x00" * (1000 * 48) - speech = b"\x11" * (100 * 48) - - async def chunks_then_commit() -> AsyncIterator[bytes]: - yield silence + speech - # Server says speech started at 1000 ms (session-relative); with prefix_padding_ms=300, drop 700 ms. - await asyncio.create_task( - captured["on_committed"](CommittedEvent(item_id="i", audio_start_ms=1000)), - ) - - ctx = _attack_context(audio_chunks=chunks_then_commit()) - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - await attack._perform_async(context=ctx) - - # Expect save_audio to receive the trimmed snapshot: - # max(0, 1000 - 300) = 700 ms dropped; remaining = 300 ms silence + 100 ms speech = 400 ms. - assert len(saved_pcm) == 1 - assert len(saved_pcm[0]) == 400 * 48 - assert saved_pcm[0].endswith(speech) - send_mock.assert_awaited_once() - - -async def test_perform_async_trims_second_turn_with_session_relative_offset(vad_target): - """Turn 2: audio_start_ms is session-relative; the attack converts it to buffer-relative. - - Without the conversion, a session-relative audio_start_ms larger than the local buffer - would skip the trim (passthrough on out-of-range), letting silence reach the model. - """ - from pyrit.prompt_target.common.realtime_audio import ServerVadConfig - - vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) - - attack = BargeInAttack(objective_target=vad_target) - _stub_send_prompt(attack) - _setup_streaming_target(vad_target) - saved_pcm: list[bytes] = [] - - async def fake_save_audio(audio_bytes, **_): - saved_pcm.append(audio_bytes) - return "/tmp/snap.wav" - - vad_target.streaming.save_audio = AsyncMock(side_effect=fake_save_audio) - captured: dict[str, Any] = {} - _capture_committed_callback(vad_target, captured) - - silence_500 = b"\x00" * (500 * 48) # 500 ms silence - speech_short = b"\x11" * (100 * 48) # 100 ms speech-like - silence_2000 = b"\x00" * (2000 * 48) # 2000 ms silence (between turns) - speech_long = b"\x22" * (300 * 48) # 300 ms speech-like (turn 2) - - async def two_turns() -> AsyncIterator[bytes]: - # Turn 1: 500 ms silence + 100 ms speech; total local buffer = 600 ms. - yield silence_500 + speech_short - # Server VAD fires commit at session_ms ≈ 600 with audio_start_ms = 500 (session-relative). - await asyncio.create_task( - captured["on_committed"](CommittedEvent(item_id="i1", audio_start_ms=500)), - ) - # Turn 2: 2000 ms silence (since turn 1's commit) + 300 ms speech. - # session_ms_at_speech_start ≈ 600 + 2000 = 2600. - yield silence_2000 + speech_long - await asyncio.create_task( - captured["on_committed"](CommittedEvent(item_id="i2", audio_start_ms=2600)), - ) - - ctx = _attack_context(audio_chunks=two_turns()) - with patch.object(attack, "_max_post_stream_wait_seconds", 0): - await attack._perform_async(context=ctx) - - assert len(saved_pcm) == 2 - - # Turn 1: buffer_relative_start = 500 - 0 = 500; trim = max(0, 500 - 300) = 200 ms; - # remaining = 300 ms pre-speech-padding + 100 ms speech = 400 ms. - assert len(saved_pcm[0]) == 400 * 48 - assert saved_pcm[0].endswith(speech_short) - - # Turn 2: buffer_start_session_ms advanced by 600 ms (turn 1's full buffer duration). - # buffer_relative_start = 2600 - 600 = 2000; trim = max(0, 2000 - 300) = 1700 ms; - # remaining = 300 ms pre-speech-padding + 300 ms speech = 600 ms. - assert len(saved_pcm[1]) == 600 * 48 - assert saved_pcm[1].endswith(speech_long) - - -# ---- _snapshot_and_trim (helper unit tests) -------------------------------------------------- - - -def test_snapshot_and_trim_returns_buffer_and_duration(vad_target): - """Helper returns the (trimmed snapshot, original-duration) pair without mutating state.""" - from pyrit.prompt_target.common.realtime_audio import ServerVadConfig - - vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) - attack = BargeInAttack(objective_target=vad_target) - - state = _BargeInRunState() - silence = b"\x00" * (1000 * 48) - speech = b"\x11" * (100 * 48) - state.raw_buffer.extend(silence + speech) - pre_call_buffer_len = len(state.raw_buffer) - - event = CommittedEvent(item_id="i", audio_start_ms=1000) - snapshot, duration_ms = attack._snapshot_and_trim(event=event, state=state) - - # Trimmed: drop max(0, 1000 - 300) = 700 ms; remaining = 300 ms pad + 100 ms speech. - assert len(snapshot) == 400 * 48 - assert snapshot.endswith(speech) - # Original duration spans the entire pre-trim buffer (1100 ms at 48 bytes/ms). - assert duration_ms == 1100 - # State is NOT mutated — caller is responsible for clearing the buffer and advancing offset. - assert len(state.raw_buffer) == pre_call_buffer_len - assert state.buffer_start_session_ms == 0 - - -def test_snapshot_and_trim_passes_through_when_audio_start_ms_none(vad_target): - """When the bridged audio_start_ms is None, the helper returns the buffer unchanged.""" - attack = BargeInAttack(objective_target=vad_target) - state = _BargeInRunState() - raw = b"\x42" * (300 * 48) - state.raw_buffer.extend(raw) - - event = CommittedEvent(item_id="i", audio_start_ms=None) - snapshot, duration_ms = attack._snapshot_and_trim(event=event, state=state) - - assert snapshot == raw - assert duration_ms == 300 - - -def test_snapshot_and_trim_uses_session_relative_offset(vad_target): - """The helper subtracts state.buffer_start_session_ms before passing to the trim function.""" - from pyrit.prompt_target.common.realtime_audio import ServerVadConfig - - vad_target._server_vad = ServerVadConfig(prefix_padding_ms=300, silence_duration_ms=500) - attack = BargeInAttack(objective_target=vad_target) - - state = _BargeInRunState() - state.buffer_start_session_ms = 1000 # turn 2: 1000 ms of prior turns - silence = b"\x00" * (500 * 48) - speech = b"\x22" * (200 * 48) - state.raw_buffer.extend(silence + speech) - - # Server reports session-relative audio_start_ms = 1500 → buffer-relative = 500. - event = CommittedEvent(item_id="i", audio_start_ms=1500) - snapshot, _ = attack._snapshot_and_trim(event=event, state=state) - - # Trim = max(0, 500 - 300) = 200 ms; remaining = 300 ms pad + 200 ms speech. - assert len(snapshot) == 500 * 48 - assert snapshot.endswith(speech) - - -# ---- _build_message_for_turn (helper unit tests) --------------------------------------------- - - -async def test_build_message_for_turn_persists_and_wraps(vad_target): - """Builder calls save_audio and wraps the path in an audio_path-shaped Message.""" - attack = BargeInAttack(objective_target=vad_target) - vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted.wav") - - snapshot_bytes = b"\xaa" * 480 - message = await attack._build_message_for_turn( - snapshot=snapshot_bytes, - item_id="server_item_xyz", - conversation_id="conv-1", - ) - - # save_audio receives the snapshot bytes and the streaming-handle sample rate. - save_call = vad_target.streaming.save_audio.await_args - assert save_call.args[0] == snapshot_bytes - assert save_call.kwargs["sample_rate"] == vad_target.streaming.SAMPLE_RATE_HZ - - # Message shape: one audio_path piece pointing at the persisted file. - assert len(message.message_pieces) == 1 - piece = message.message_pieces[0] - assert piece.original_value == "/tmp/persisted.wav" - assert piece.converted_value == "/tmp/persisted.wav" - assert piece.original_value_data_type == "audio_path" - assert piece.converted_value_data_type == "audio_path" - assert piece.conversation_id == "conv-1" - - -async def test_build_message_for_turn_stashes_item_id_in_metadata(vad_target): - """The server's committed item_id is stashed under REALTIME_COMMITTED_ITEM_ID_KEY.""" - attack = BargeInAttack(objective_target=vad_target) - vad_target.streaming.save_audio = AsyncMock(return_value="/tmp/persisted.wav") - - message = await attack._build_message_for_turn( - snapshot=b"\x00" * 96, - item_id="srv_item_42", - conversation_id="conv-2", - ) - - assert message.message_pieces[0].prompt_metadata[REALTIME_COMMITTED_ITEM_ID_KEY] == "srv_item_42" - - -# ---- _send_via_normalizer (helper unit tests) ------------------------------------------------ - - -async def test_send_via_normalizer_forwards_to_prompt_normalizer(vad_target): - """Helper hands message + converters + identifiers to PromptNormalizer.send_prompt_async.""" - attack = BargeInAttack(objective_target=vad_target) - response = Message(message_pieces=[MessagePiece(role="assistant", original_value="ok")]) - attack._prompt_normalizer.send_prompt_async = AsyncMock(return_value=response) # type: ignore[method-assign] - - request = Message(message_pieces=[MessagePiece(role="user", original_value="/tmp/r.wav")]) - result = await attack._send_via_normalizer(message=request, conversation_id="conv-3") - - assert result is response - call = attack._prompt_normalizer.send_prompt_async.await_args - assert call.kwargs["message"] is request - # Forwards the underlying PromptTarget (not the streaming handle). - assert call.kwargs["target"] is vad_target - assert call.kwargs["conversation_id"] == "conv-3" - assert call.kwargs["request_converter_configurations"] is attack._request_converters - assert call.kwargs["response_converter_configurations"] is attack._response_converters - assert call.kwargs["attack_identifier"] == attack.get_identifier() diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 009da48b7f..25389206db 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -443,3 +443,367 @@ async def _fire_failure() -> None: with pytest.raises(RuntimeError, match="dispatch loop died"): await asyncio.gather(_consume(), _fire_failure()) + + +# --------------------------------------------------------------------------- +# 8. Trim: pre-speech silence is stripped using audio_start_ms before persistence +# --------------------------------------------------------------------------- + + +async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audio(): + """``audio_start_ms`` past prefix_padding trims the snapshot before save_audio is called.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + # 600ms buffer @ 24kHz mono PCM16 = 600 * 48 = 28800 bytes. We push it as one chunk + # so the trim computation is easy to reason about. + bytes_per_ms = 48 + buffer_ms = 600 + chunk = b"\xaa" * (buffer_ms * bytes_per_ms) + finish = asyncio.Event() + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([chunk], finish), + prompt_normalizer=normalizer, + vad=ServerVadConfig(prefix_padding_ms=100), + ) + + with _patched_dispatcher(): + await _run_session_with_events( + session, + finish=finish, + events=[CommittedEvent(item_id="item-1", audio_start_ms=500)], + ) + + # start_ms = max(0, 500 - 100) = 400 → start_byte = 400 * 48 = 19200 + # trimmed length = 28800 - 19200 = 9600 bytes (200ms) + raw_save_call = target.streaming.save_audio.await_args_list[0] + saved_user_pcm = raw_save_call.args[0] if raw_save_call.args else raw_save_call.kwargs.get("pcm") + assert len(saved_user_pcm) == 9600 + + +async def test_on_committed_skips_trim_when_audio_start_ms_missing(): + """When ``audio_start_ms`` is None, the full buffer is persisted (no trim).""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + bytes_per_ms = 48 + buffer_ms = 200 + chunk = b"\xbb" * (buffer_ms * bytes_per_ms) + finish = asyncio.Event() + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([chunk], finish), + prompt_normalizer=normalizer, + vad=ServerVadConfig(prefix_padding_ms=100), + ) + + with _patched_dispatcher(): + await _run_session_with_events( + session, + finish=finish, + events=[CommittedEvent(item_id="item-1", audio_start_ms=None)], + ) + + raw_save_call = target.streaming.save_audio.await_args_list[0] + saved_user_pcm = raw_save_call.args[0] if raw_save_call.args else raw_save_call.kwargs.get("pcm") + assert len(saved_user_pcm) == buffer_ms * bytes_per_ms + + +async def test_buffer_start_session_ms_advances_across_commits(): + """Second commit's server-relative ``audio_start_ms`` is mapped through ``_buffer_start_session_ms``.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + bytes_per_ms = 48 + # Turn 1 buffer: 600ms, audio_start_ms=500 (server-relative) → trims 400ms = 19200 bytes. + # After turn 1, _buffer_start_session_ms advances to 600. + # Turn 2 buffer: 400ms, audio_start_ms=800 (server-relative) → buffer-relative = 200. + # start_ms = max(0, 200 - 100) = 100 → 100*48 = 4800 bytes trimmed. + # trimmed length = 19200 - 4800 = 14400 bytes (300ms). + chunk1 = b"\xaa" * (600 * bytes_per_ms) + chunk2 = b"\xbb" * (400 * bytes_per_ms) + + # Per-chunk gating so the second chunk lands AFTER commit #1 fires; otherwise the + # producer would drain both chunks into the buffer before any commit and turn 1 + # would see 1000ms of audio (advancing _buffer_start_session_ms past turn 2's + # event.audio_start_ms). + gate2 = asyncio.Event() + finish = asyncio.Event() + + async def _gated_chunks(): + yield chunk1 + await gate2.wait() + yield chunk2 + await finish.wait() + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_gated_chunks(), + prompt_normalizer=normalizer, + vad=ServerVadConfig(prefix_padding_ms=100), + ) + + async def _consume() -> None: + async for _msg in session.run_async(): + pass + + async def _fire() -> None: + await asyncio.sleep(0) + await session._on_committed(CommittedEvent(item_id="t1", audio_start_ms=500)) + gate2.set() + # Give the producer time to drain chunk2 into _pending_chunks. + for _ in range(20): + await asyncio.sleep(0) + await session._on_committed(CommittedEvent(item_id="t2", audio_start_ms=800)) + finish.set() + + with _patched_dispatcher(): + await asyncio.gather(_consume(), _fire()) + + # save_audio call ordering per turn: raw_user, assistant. We requested no request converters + # so converted_user_path == raw_user_path and only one user save_audio fires per turn. + # Across two turns: [user_t1, assistant_t1, user_t2, assistant_t2]. + calls = target.streaming.save_audio.await_args_list + assert len(calls) == 4 + assert len(calls[0].args[0]) == 9600 + assert len(calls[2].args[0]) == 14400 + + +# --------------------------------------------------------------------------- +# 9. attack_identifier is stamped on persisted user + assistant pieces +# --------------------------------------------------------------------------- + + +async def test_attack_identifier_stamped_on_persisted_pieces_when_set(): + """When ``attack_identifier`` is provided, every persisted piece carries it.""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + persisted_messages: list[Message] = [] + + async def _capture(*, message: Message) -> None: + persisted_messages.append(message) + + normalizer.hash_and_persist_message_async = AsyncMock(side_effect=_capture) + + attack_id = {"__type__": "BargeInAttack", "__module__": "test", "id": "attack-42"} + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 96], finish), + prompt_normalizer=normalizer, + attack_identifier=attack_id, + ) + + with _patched_dispatcher(): + await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="i")]) + + # Expect one user message + one assistant message (two pieces) — three pieces total. + all_pieces = [piece for msg in persisted_messages for piece in msg.message_pieces] + assert len(all_pieces) == 3 + for piece in all_pieces: + assert piece.attack_identifier == attack_id + + +async def test_attack_identifier_absent_when_not_provided(): + """Without ``attack_identifier``, persisted pieces have None attribution (back-compat).""" + target = _build_target() + target.request_response_async = _make_request_response_async() + normalizer = _build_normalizer() + + persisted_messages: list[Message] = [] + + async def _capture(*, message: Message) -> None: + persisted_messages.append(message) + + normalizer.hash_and_persist_message_async = AsyncMock(side_effect=_capture) + + finish = asyncio.Event() + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x01" * 96], finish), + prompt_normalizer=normalizer, + ) + + with _patched_dispatcher(): + await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="i")]) + + all_pieces = [piece for msg in persisted_messages for piece in msg.message_pieces] + assert len(all_pieces) == 3 + for piece in all_pieces: + assert piece.attack_identifier is None + + +# --------------------------------------------------------------------------- +# 10. persist_prepended_conversation=False skips the prepended-memory write +# --------------------------------------------------------------------------- + + +async def test_persist_prepended_conversation_false_skips_memory_add(): + """``persist_prepended_conversation=False`` skips ``add_prepended_conversation_to_memory``.""" + target = _build_target() + normalizer = _build_normalizer() + + finish = asyncio.Event() + finish.set() + + async def _empty(): + if False: + yield b"" + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_empty(), + prompt_normalizer=normalizer, + prepended_conversation=[MagicMock(name="prepended_message")], + persist_prepended_conversation=False, + ) + + with _patched_dispatcher(): + async for _ in session.run_async(): + pytest.fail("no events were fired; session should yield nothing") + + # send_streaming_session_config still gets the prepended conversation (system msg → instructions). + target.streaming.send_streaming_session_config_async.assert_awaited_once() + # But the memory write is skipped — the caller (e.g., the attack) has already persisted it. + normalizer.add_prepended_conversation_to_memory.assert_not_called() + + +# --------------------------------------------------------------------------- +# 11. Factory passthrough: RealtimeTarget.open_streaming_session forwards every kwarg +# --------------------------------------------------------------------------- + + +_CLEAN_ENV = {"OPENAI_REALTIME_UNDERLYING_MODEL": ""} + + +@patch.dict("os.environ", _CLEAN_ENV) +def test_open_streaming_session_forwards_kwargs_to_session_constructor(sqlite_instance): + """``RealtimeTarget.open_streaming_session`` is a thin pass-through to the session ctor.""" + from pyrit.prompt_target import RealtimeTarget + + target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test", server_vad=True) + normalizer = _build_normalizer() + + async def _empty(): + if False: + yield b"" + + chunks = _empty() + prepended = [MagicMock(name="prepended_message")] + req_cfgs = [MagicMock(name="req_cfg")] + resp_cfgs = [MagicMock(name="resp_cfg")] + vad = ServerVadConfig(prefix_padding_ms=42) + attack_id = {"__type__": "BargeInAttack", "id": "x"} + + captured: dict[str, Any] = {} + + def _fake_session_ctor(**kwargs): + captured.update(kwargs) + return MagicMock(name="session") + + with patch( + "pyrit.prompt_target.openai._openai_realtime_streaming_session._OpenAIRealtimeStreamingSession", + side_effect=_fake_session_ctor, + ): + target.open_streaming_session( + audio_chunks=chunks, + prompt_normalizer=normalizer, + conversation_id="conv-X", + request_converter_configurations=req_cfgs, + response_converter_configurations=resp_cfgs, + prepended_conversation=prepended, + vad=vad, + attack_identifier=attack_id, + persist_prepended_conversation=False, + ) + + assert captured["target"] is target + assert captured["audio_chunks"] is chunks + assert captured["prompt_normalizer"] is normalizer + assert captured["conversation_id"] == "conv-X" + assert captured["request_converter_configurations"] is req_cfgs + assert captured["response_converter_configurations"] is resp_cfgs + assert captured["prepended_conversation"] is prepended + assert captured["vad"] is vad + assert captured["attack_identifier"] is attack_id + assert captured["persist_prepended_conversation"] is False + + +# --------------------------------------------------------------------------- +# 12. Direct unit tests for the _trim_snapshot_to_speech helper +# --------------------------------------------------------------------------- + + +def test_trim_returns_full_buffer_when_audio_start_ms_none(): + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + buf = b"\xaa" * (100 * 48) + out = _trim_snapshot_to_speech(raw_buffer=buf, sample_rate_hz=24000, audio_start_ms=None, prefix_padding_ms=300) + assert out is buf + + +def test_trim_returns_full_buffer_when_audio_start_ms_zero(): + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + buf = b"\xaa" * (100 * 48) + out = _trim_snapshot_to_speech(raw_buffer=buf, sample_rate_hz=24000, audio_start_ms=0, prefix_padding_ms=300) + assert out is buf + + +def test_trim_raises_on_negative_audio_start_ms(): + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + with pytest.raises(ValueError, match="must be >= 0"): + _trim_snapshot_to_speech(raw_buffer=b"\x00" * 100, sample_rate_hz=24000, audio_start_ms=-1, prefix_padding_ms=0) + + +def test_trim_keeps_prefix_padding_window(): + """``audio_start_ms - prefix_padding_ms`` is the trim point; padding before speech is kept.""" + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + # 1000ms buffer; speech starts at 700ms with 200ms padding → trim at 500ms = 24000 bytes. + buf = b"\xaa" * (1000 * 48) + out = _trim_snapshot_to_speech(raw_buffer=buf, sample_rate_hz=24000, audio_start_ms=700, prefix_padding_ms=200) + assert len(out) == 500 * 48 + + +def test_trim_returns_full_buffer_when_trim_would_exceed_length(): + """Defensive: when the computed trim is beyond the buffer, return the original buffer.""" + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + # 100ms buffer; speech "starts" at 5000ms with 0 padding → trim past end. + buf = b"\xaa" * (100 * 48) + out = _trim_snapshot_to_speech(raw_buffer=buf, sample_rate_hz=24000, audio_start_ms=5000, prefix_padding_ms=0) + assert out is buf + + +def test_trim_aligns_to_sample_frame_boundary(): + """Trim must align to ``sample_width_bytes * channels`` so PCM frames aren't split.""" + from pyrit.prompt_target.openai._openai_realtime_streaming_session import _trim_snapshot_to_speech + + # An odd start_byte (e.g., 1) must be rounded down to 0 for sample_width=2. + buf = b"\x01\x02\x03\x04\x05\x06\x07\x08" + # Construct an audio_start_ms that lands on byte 1 to trigger alignment: + # bytes_per_ms = 24000 * 2 // 1000 = 48 → no fine-grained way to land on byte 1. + # Instead pick rate=1000Hz so bytes_per_ms = 1000*2//1000 = 2; audio_start_ms=1 → start_byte=2. + # That's already aligned. Use sample_width=2 with a contrived offset by inspecting math: + # For rate=500Hz (so bytes_per_ms = 1), audio_start_ms=3 → start_byte=3 → must align to 2. + out = _trim_snapshot_to_speech( + raw_buffer=buf, + sample_rate_hz=500, + audio_start_ms=3, + prefix_padding_ms=0, + sample_width_bytes=2, + channels=1, + ) + # start_byte = 3 → aligned down to 2 → trimmed = buf[2:] = b"\x03..\x08" (6 bytes) + assert out == b"\x03\x04\x05\x06\x07\x08" From 4c5cc6d7ac43ef4f40b368f9937fc1fc9b594503 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 14:51:50 -0400 Subject: [PATCH 36/47] Drop dead realtime streaming subscription scaffolding Phase 3 moved all live streaming into _OpenAIRealtimeStreamingSession, so the old subscription/dispatcher/swap path on RealtimeTarget is unreachable. Remove the dead branch (and its tests) in one cut: _streaming_state registry, _StreamingConversationState, StreamingHandle.subscribe_events_async / cleanup_conversation, _RealtimeStreamingHandle's matching impls, the streaming branch in _send_prompt_to_target_async, the unused SupportsStreamingBargeIn Protocol, and the REALTIME_COMMITTED_ITEM_ID_KEY constant. Refresh stale docstrings and the supports_streaming_barge_in capability comment to point at open_streaming_session(). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 59 +-- .../common/target_capabilities.py | 10 +- .../openai/openai_realtime_target.py | 226 ++--------- .../target/test_realtime_target.py | 354 +----------------- 4 files changed, 58 insertions(+), 591 deletions(-) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 8fb053e33e..d96a422e90 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any, ClassVar, Protocol, runtime_checkable +from typing import Any, ClassVar from pyrit.prompt_target.common.streaming.streaming_audio_target import ( ServerVadConfig as ServerVadConfig, # noqa: TC001 @@ -18,14 +18,6 @@ logger = logging.getLogger(__name__) -#: Key under which streaming attacks stash the server-assigned item id of the most -#: recently committed user audio buffer (in ``MessagePiece.prompt_metadata``). Realtime -#: targets read this key in their streaming branch to identify which committed item -#: to delete when swapping in converter-transformed audio. Exposed as a public -#: constant so attacks can reference it without reaching into target internals. -REALTIME_COMMITTED_ITEM_ID_KEY = "realtime_committed_item_id" - - @dataclass class RealtimeTargetResult: """Result of a Realtime API turn: delivered audio, transcripts, and interruption status.""" @@ -269,14 +261,16 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: class StreamingHandle(ABC): """ - Provider-agnostic streaming surface owned by targets that declare ``STREAMING_BARGE_IN``. - - A streaming attack (e.g. ``BargeInAttack``) interacts with the target's realtime - transport via ``target.streaming``, not via methods on the target itself. This keeps - the streaming surface out of ``PromptTarget`` while giving the attack a single typed - contract to program against. Concrete realtime providers (OpenAI, Azure, etc.) provide - a concrete ``StreamingHandle`` subclass and assign it to ``self.streaming`` in their - target's ``__init__``. + Provider-agnostic websocket-level streaming surface for realtime targets. + + Owns the low-level transport primitives a streaming session needs: opening + the connection, sending the initial session config, pushing PCM chunks, and + persisting audio to disk. Streaming attacks (e.g. ``BargeInAttack``) drive + these through a session object (see ``RealtimeTarget.open_streaming_session``) + rather than calling this handle directly. Concrete realtime providers + (OpenAI, Azure, etc.) provide a concrete subclass and assign it to + ``self.streaming`` on their target so the session can read provider-agnostic + config without knowing the concrete target type. """ #: PCM sample rate in Hz negotiated by the provider's realtime protocol. @@ -291,16 +285,6 @@ def server_vad_config(self) -> "ServerVadConfig | None": async def connect_async(self, conversation_id: str) -> Any: """Open the streaming connection for ``conversation_id`` and return the connection handle.""" - @abstractmethod - async def subscribe_events_async( - self, - *, - connection: Any, - conversation_id: str, - on_user_audio_committed: Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None = None, - ) -> "RealtimeEventDispatcher": - """Spawn a background reader that routes server events and returns the dispatcher.""" - @abstractmethod async def send_streaming_session_config_async( self, *, connection: Any, conversation: "list[Any] | None" = None @@ -321,24 +305,3 @@ async def save_audio( output_filename: str | None = None, ) -> str: """Persist a PCM buffer to disk and return the file path.""" - - @abstractmethod - async def cleanup_conversation(self, conversation_id: str) -> None: - """Tear down any per-conversation state held by the target.""" - - -@runtime_checkable -class SupportsStreamingBargeIn(Protocol): - """ - Structural marker for targets that expose a streaming barge-in surface. - - Used by ``BargeInAttack`` to validate at construction time that its objective - target wires a ``StreamingHandle`` on ``self.streaming``. Decoupled from - ``PromptTarget`` so the base class stays free of streaming-specific attributes. - - Note: ``runtime_checkable`` Protocol ``isinstance`` checks attribute *presence*, - not value — a target that explicitly sets ``streaming = None`` would still pass - the check and fail at first method call instead of construction time. - """ - - streaming: StreamingHandle diff --git a/pyrit/prompt_target/common/target_capabilities.py b/pyrit/prompt_target/common/target_capabilities.py index b578d6eefd..c3fac20c0a 100644 --- a/pyrit/prompt_target/common/target_capabilities.py +++ b/pyrit/prompt_target/common/target_capabilities.py @@ -139,11 +139,11 @@ class attribute. Users can override individual capabilities per instance # Whether the target natively supports system prompts. supports_system_prompt: bool = False - # Whether the target supports the streaming barge-in API: pushing user audio chunks - # via ``push_audio_chunk_async``, subscribing to user-audio-committed events via - # ``subscribe_events_async``, swapping committed items via - # ``delete_conversation_item_async`` + ``insert_user_audio_async``, and triggering - # responses via ``request_response_async``. Required by ``BargeInAttack``. + # Whether the target supports the streaming barge-in API: opening a long-lived + # streaming session via ``open_streaming_session`` that pushes user audio chunks, + # delivers VAD-committed audio to the attack for converter work, swaps committed + # items in place, and drives manual ``response.create`` turns. Required by + # ``BargeInAttack``. supports_streaming_barge_in: bool = False # The input modalities supported by the target (e.g., "text", "image"). diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 6fd005db7f..0b9505f6ab 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -6,8 +6,6 @@ import logging import re import wave -from collections.abc import Callable, Coroutine -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional from openai import AsyncOpenAI @@ -24,7 +22,6 @@ ) from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( - REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent, RealtimeEventDispatcher, RealtimeTargetResult, @@ -53,30 +50,14 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] -@dataclass -class _StreamingConversationState: - """ - Per-conversation streaming-mode bookkeeping for :class:`RealtimeTarget`. - - Presence in :attr:`RealtimeTarget._streaming_state` is the signal that a - conversation should take the streaming swap-and-respond path inside - :meth:`RealtimeTarget._send_prompt_to_target_async` rather than the atomic - send_audio / send_text path. The lock serializes per-turn work so back-to-back - VAD commits cannot race on the dispatcher's single active-turn slot. - """ - - dispatcher: RealtimeEventDispatcher - turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock) - - class _RealtimeStreamingHandle(StreamingHandle): """ OpenAI Realtime API implementation of :class:`StreamingHandle`. - Owns the websocket-level streaming surface (connect, subscribe, push audio, - config, cleanup) and the audio persistence helper. Holds a back-reference to - its owning :class:`RealtimeTarget` so it can read per-target state - (server VAD config, OpenAI client, conversation registries). + Owns the websocket-level streaming surface (connect, push audio, config) and + the audio persistence helper. Holds a back-reference to its owning + :class:`RealtimeTarget` so it can read per-target state (server VAD config, + OpenAI client, conversation registries). """ SAMPLE_RATE_HZ: ClassVar[int] = 24000 @@ -103,49 +84,6 @@ async def connect_async(self, conversation_id: str) -> Any: logger.info("Successfully connected to AzureOpenAI Realtime API") return connection - async def subscribe_events_async( - self, - *, - connection: Any, - conversation_id: str, - on_user_audio_committed: (Callable[[CommittedEvent], Coroutine[Any, Any, None]] | None) = None, - ) -> RealtimeEventDispatcher: - """ - Start consuming events from the connection and route them via the OpenAI dispatcher. - - Also registers per-conversation streaming state so requests routed through - ``send_prompt_async`` for ``conversation_id`` take the streaming swap-and-respond - path inside ``_send_prompt_to_target_async`` instead of the atomic send_audio / - send_text path. - - The returned dispatcher exposes ``stop()`` to tear down the background task and - drain in-flight callback tasks, and a ``failure`` property that callers can poll - between operations to detect a dead dispatch loop (e.g. websocket closed). - - Args: - connection: Active Realtime API connection from ``connect_async``. - conversation_id: Conversation id for the realtime session. Used as the key - under which streaming state is registered. - on_user_audio_committed: Async callback fired when server VAD finalizes - a user audio buffer. Called as a background task. - - Returns: - The started dispatcher. Pass it to ``request_response_async`` for turn - futures, poll ``failure`` for dispatch-loop errors, and call ``stop()`` - (or ``cleanup_conversation``) to tear it down. - """ - dispatcher = _OpenAIRealtimeDispatcher( - connection=connection, - on_user_audio_committed=on_user_audio_committed, - ) - self._target._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) - # Register the connection under the same key so cleanup_conversation / - # cleanup_target can find and close it without callers reaching into - # private state. - self._target._existing_conversation[conversation_id] = connection - await dispatcher.start() - return dispatcher - async def send_streaming_session_config_async( self, *, @@ -236,32 +174,6 @@ async def save_audio( return data.value - async def cleanup_conversation(self, conversation_id: str) -> None: - """ - Disconnects from the Realtime API for a specific conversation. - - Stops any active streaming dispatcher for the conversation before closing - the underlying connection. Safe to call when no streaming state exists. - - Args: - conversation_id (str): The conversation ID to disconnect from. - """ - streaming = self._target._streaming_state.pop(conversation_id, None) - if streaming is not None: - try: - await streaming.dispatcher.stop() - except Exception as e: - logger.warning(f"Error stopping dispatcher for {conversation_id}: {e}") - - connection = self._target._existing_conversation.get(conversation_id) - if connection: - try: - await connection.close() - logger.info(f"Disconnected from {self._target._endpoint} with conversation ID: {conversation_id}") - except Exception as e: - logger.warning(f"Error closing connection for {conversation_id}: {e}") - del self._target._existing_conversation[conversation_id] - class RealtimeTarget(OpenAITarget, PromptTarget): """ @@ -334,8 +246,9 @@ def __init__( server_vad (bool | ServerVadConfig): Server-side voice activity detection (VAD). ``False`` (default) keeps the existing atomic send/receive behavior. ``True`` enables VAD with default tuning. - Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming/interruption plumbing - arrives in subsequent changes; this currently only affects the emitted session config. + Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming attacks + obtain a dedicated session via :meth:`open_streaming_session` and require + VAD to be enabled. **kwargs: Additional keyword arguments passed to the parent OpenAITarget class. httpx_client_kwargs (dict, Optional): Additional kwargs to be passed to the ``httpx.AsyncClient()`` constructor. For example, to specify a 3 minute timeout: ``httpx_client_kwargs={"timeout": 180}`` @@ -353,12 +266,6 @@ def __init__( else: self._server_vad = None - # Streaming-mode bookkeeping. Entries are added by ``subscribe_events_async`` and - # consumed by the streaming branch of ``_send_prompt_to_target_async``. The - # presence of a conversation_id key signals "this conversation is in streaming - # mode" so the target can route requests to the swap-and-respond path. - self._streaming_state: dict[str, _StreamingConversationState] = {} - # Composition: streaming surface lives on a dedicated handle so the attack can # type against the provider-agnostic ``StreamingHandle`` ABC. self.streaming = _RealtimeStreamingHandle(target=self) @@ -641,9 +548,9 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me """ Asynchronously send a message to the OpenAI realtime target. - Routes to the streaming swap-and-respond path when streaming state is - registered for the conversation; otherwise dispatches to the atomic - send_audio / send_text path. + Dispatches to the atomic send_audio / send_text path based on the + request's data type. Streaming attacks bypass this entry point and drive + the connection through :class:`_OpenAIRealtimeStreamingSession` instead. Args: normalized_conversation (list[Message]): The full conversation @@ -660,83 +567,31 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me conversation_id = message.message_pieces[0].conversation_id request = message.message_pieces[0] - streaming = self._streaming_state.get(conversation_id) - if streaming is not None: - # Streaming swap-and-respond path. The lock serializes per-turn work so - # back-to-back VAD commits cannot race on the dispatcher's single turn slot. - async with streaming.turn_lock: - if request.converted_value_data_type != "audio_path": - raise ValueError( - f"Streaming realtime requests must carry audio_path, got {request.converted_value_data_type!r}." - ) + if conversation_id not in self._existing_conversation: + connection = await self.streaming.connect_async(conversation_id=conversation_id) + self._existing_conversation[conversation_id] = connection - connection = self._existing_conversation[conversation_id] - - with wave.open(request.converted_value, "rb") as wav_in: - if ( - wav_in.getnchannels() != 1 - or wav_in.getsampwidth() != 2 - or wav_in.getframerate() != self.streaming.SAMPLE_RATE_HZ - ): - raise ValueError( - f"Streaming audio must be mono PCM16 at {self.streaming.SAMPLE_RATE_HZ} Hz, got " - f"channels={wav_in.getnchannels()} sampwidth={wav_in.getsampwidth()} " - f"rate={wav_in.getframerate()}." - ) - pcm_bytes = wav_in.readframes(wav_in.getnframes()) - - # Only swap when converters ran. Otherwise the server's raw committed - # buffer is what we want and a swap would be wasted work. - if request.converter_identifiers: - item_id = request.prompt_metadata.get(REALTIME_COMMITTED_ITEM_ID_KEY) - if not item_id: - raise ValueError( - "Streaming request with converters requires the server's committed " - f"item id in piece.prompt_metadata[{REALTIME_COMMITTED_ITEM_ID_KEY!r}]." - ) - await self.swap_user_audio_async( - connection=connection, - committed_event=CommittedEvent(item_id=str(item_id)), - converted_pcm=pcm_bytes, - ) + # Only send config when creating a new connection + await self.send_config(conversation_id=conversation_id, conversation=normalized_conversation) + # Give the server a moment to process the session update + await asyncio.sleep(0.5) - turn_future = await self.request_response_async( - connection=connection, - dispatcher=streaming.dispatcher, - ) - result: RealtimeTargetResult = await turn_future - output_audio_path = await self.streaming.save_audio( - result.audio_bytes, - num_channels=1, - sample_width=2, - sample_rate=self.streaming.SAMPLE_RATE_HZ, - ) - else: - if conversation_id not in self._existing_conversation: - connection = await self.streaming.connect_async(conversation_id=conversation_id) - self._existing_conversation[conversation_id] = connection - - # Only send config when creating a new connection - await self.send_config(conversation_id=conversation_id, conversation=normalized_conversation) - # Give the server a moment to process the session update - await asyncio.sleep(0.5) - - response_type = request.converted_value_data_type - - # Order of messages sent varies based on the data format of the prompt - if response_type == "audio_path": - output_audio_path, result = await self.send_audio_async( - filename=request.converted_value, - conversation_id=conversation_id, - ) + response_type = request.converted_value_data_type - elif response_type == "text": - output_audio_path, result = await self.send_text_async( - text=request.converted_value, - conversation_id=conversation_id, - ) - else: - raise ValueError(f"Unsupported response type: {response_type}") + # Order of messages sent varies based on the data format of the prompt + if response_type == "audio_path": + output_audio_path, result = await self.send_audio_async( + filename=request.converted_value, + conversation_id=conversation_id, + ) + + elif response_type == "text": + output_audio_path, result = await self.send_text_async( + text=request.converted_value, + conversation_id=conversation_id, + ) + else: + raise ValueError(f"Unsupported response type: {response_type}") text_response_piece = construct_response_from_request( request=request, response_text_pieces=[result.flatten_transcripts()], response_type="text" @@ -757,17 +612,10 @@ async def cleanup_target(self) -> None: """ Disconnects from the Realtime API connections. - Stops any active streaming dispatchers before closing their underlying - websocket connections so the dispatch loops do not race with connection - shutdown. Safe to call multiple times. + Closes every connection cached in ``_existing_conversation`` and the + shared ``AsyncOpenAI`` client, swallowing per-connection errors so a + single bad close does not block the rest. Safe to call multiple times. """ - for cid, streaming in list(self._streaming_state.items()): - try: - await streaming.dispatcher.stop() - except Exception as e: - logger.warning(f"Error stopping dispatcher for {cid}: {e}") - self._streaming_state = {} - for conversation_id, connection in list(self._existing_conversation.items()): if connection: try: @@ -889,8 +737,8 @@ async def request_response_async( Args: connection: Active Realtime API connection. - dispatcher: Subscription handle previously returned by - ``subscribe_events_async``. Must not have another turn pending. + dispatcher: The dispatcher driving this connection. Must not have + another turn pending. Returns: Future resolved with the assembled ``RealtimeTargetResult`` when this diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index d9f2624864..23e0ec4949 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -10,11 +10,9 @@ import pytest from pyrit.exceptions.exception_classes import ServerErrorException -from pyrit.identifiers import ComponentIdentifier from pyrit.models import Message, MessagePiece from pyrit.prompt_target import RealtimeTarget, ServerVadConfig from pyrit.prompt_target.common.realtime_audio import ( - REALTIME_COMMITTED_ITEM_ID_KEY, CommittedEvent, RealtimeTargetResult, RealtimeTurnState, @@ -23,7 +21,6 @@ from pyrit.prompt_target.openai.openai_realtime_target import ( _OpenAIRealtimeDispatcher, _RealtimeStreamingHandle, - _StreamingConversationState, ) # Env vars that may leak from .env files loaded by other tests in parallel workers. @@ -1033,60 +1030,7 @@ async def on_committed(event: CommittedEvent) -> None: # Placeholder for R2 tests -# ---- subscribe_events_async + request_response_async (R2) ------------------------------------ - - -async def test_subscribe_events_async_returns_started_dispatcher(target): - """Subscription handle must be a started dispatcher; closing tears the task down.""" - events = [_scripted_event("input_audio_buffer.committed", item_id="i_1")] - - async def event_iter(): - for e in events: - yield e - # Keep the iterator alive briefly so the dispatch task can run. - await asyncio.sleep(0.01) - - connection = MagicMock() - connection.__aiter__ = lambda self_: event_iter() - - received: list[CommittedEvent] = [] - - async def on_committed(event): - received.append(event) - - dispatcher = await target.streaming.subscribe_events_async( - connection=connection, conversation_id="test_conv", on_user_audio_committed=on_committed - ) - try: - # Yield until the dispatch loop processes the scripted event. - for _ in range(20): - if received: - break - await asyncio.sleep(0.01) - assert len(received) == 1 and received[0].item_id == "i_1" - finally: - await dispatcher.stop() - - -async def test_subscribe_events_async_records_loop_failure_on_dispatcher(target): - """A dispatcher loop crash must be reachable via the dispatcher's ``failure`` property.""" - - async def boom_iter(): - raise RuntimeError("loop kaboom") - yield # pragma: no cover # makes it a generator - - connection = MagicMock() - connection.__aiter__ = lambda self_: boom_iter() - - dispatcher = await target.streaming.subscribe_events_async(connection=connection, conversation_id="test_conv") - try: - for _ in range(50): - if dispatcher.failure is not None: - break - await asyncio.sleep(0.01) - assert isinstance(dispatcher.failure, RuntimeError) - finally: - await dispatcher.stop() +# ---- request_response_async (R2) ------------------------------------------------- async def test_request_response_async_registers_turn_and_sends_response_create(target): @@ -1132,7 +1076,7 @@ async def test_request_response_async_propagates_register_turn_failure(target): connection.response.create.assert_not_called() -# ---- streaming-mode state lifecycle --------------------------------------------- +# ---- streaming handle wiring & config ----------------------------------------- def test_sample_rate_hz_class_constant(): @@ -1166,88 +1110,7 @@ def test_server_vad_config_returns_none_when_disabled(target): assert target.streaming.server_vad_config is None -async def test_subscribe_events_async_registers_streaming_state(target): - """Subscription must register per-conversation streaming state keyed by conversation_id.""" - - async def event_iter(): - await asyncio.sleep(0) - return - yield # pragma: no cover - - connection = MagicMock() - connection.__aiter__ = lambda self_: event_iter() - - assert "conv-A" not in target._streaming_state - - dispatcher = await target.streaming.subscribe_events_async(connection=connection, conversation_id="conv-A") - try: - assert "conv-A" in target._streaming_state - assert target._streaming_state["conv-A"].dispatcher is dispatcher - # The lock is created lazily-but-default; just verify it's an asyncio.Lock. - assert isinstance(target._streaming_state["conv-A"].turn_lock, asyncio.Lock) - finally: - await dispatcher.stop() - - -async def test_cleanup_conversation_clears_streaming_state(target): - """cleanup_conversation must stop the dispatcher and pop the streaming state entry.""" - dispatcher = AsyncMock() - dispatcher.stop = AsyncMock() - target._streaming_state["conv-B"] = _StreamingConversationState(dispatcher=dispatcher) - target._existing_conversation["conv-B"] = AsyncMock() - - await target.streaming.cleanup_conversation("conv-B") - - dispatcher.stop.assert_awaited_once() - assert "conv-B" not in target._streaming_state - assert "conv-B" not in target._existing_conversation - - -async def test_cleanup_conversation_is_safe_without_streaming_state(target): - """cleanup_conversation must not fail when no streaming state was registered.""" - target._existing_conversation["conv-C"] = AsyncMock() - - # Should not raise even though _streaming_state has no entry for conv-C. - await target.streaming.cleanup_conversation("conv-C") - assert "conv-C" not in target._existing_conversation - - -async def test_cleanup_target_clears_all_streaming_state(target): - """cleanup_target must stop every active dispatcher before closing connections.""" - dispatcher_a = AsyncMock() - dispatcher_a.stop = AsyncMock() - dispatcher_b = AsyncMock() - dispatcher_b.stop = AsyncMock() - target._streaming_state["conv-X"] = _StreamingConversationState(dispatcher=dispatcher_a) - target._streaming_state["conv-Y"] = _StreamingConversationState(dispatcher=dispatcher_b) - target._existing_conversation["conv-X"] = AsyncMock() - target._existing_conversation["conv-Y"] = AsyncMock() - - await target.cleanup_target() - - dispatcher_a.stop.assert_awaited_once() - dispatcher_b.stop.assert_awaited_once() - assert target._streaming_state == {} - assert target._existing_conversation == {} - - -async def test_cleanup_target_swallows_dispatcher_stop_errors(target): - """A failing dispatcher.stop() must not prevent cleanup_target from proceeding.""" - bad_dispatcher = AsyncMock() - bad_dispatcher.stop = AsyncMock(side_effect=RuntimeError("already stopped")) - target._streaming_state["conv-Z"] = _StreamingConversationState(dispatcher=bad_dispatcher) - connection = AsyncMock() - target._existing_conversation["conv-Z"] = connection - - await target.cleanup_target() # must not raise - - bad_dispatcher.stop.assert_awaited_once() - connection.close.assert_awaited_once() - assert target._streaming_state == {} - assert target._existing_conversation == {} - - -# ---- _send_streaming_turn_async / send_prompt routing ----------------------- +# ---- send_prompt audio routing ------------------------------------------------- def _write_wav( @@ -1267,63 +1130,8 @@ def _write_wav( return str(path) -def _make_streaming_request( - *, - conversation_id: str, - wav_path: str, - converter_identifiers: list | None = None, - committed_item_id: str | None = None, -) -> Message: - """Construct a streaming-mode request Message matching the attack's contract.""" - metadata: dict[str, Any] = {} - if committed_item_id is not None: - metadata[REALTIME_COMMITTED_ITEM_ID_KEY] = committed_item_id - piece = MessagePiece( - role="user", - original_value=wav_path, - original_value_data_type="audio_path", - converted_value=wav_path, - converted_value_data_type="audio_path", - conversation_id=conversation_id, - prompt_metadata=metadata or None, - converter_identifiers=converter_identifiers or [], - ) - return Message(message_pieces=[piece]) - - -def _register_streaming(target, conversation_id: str) -> tuple[MagicMock, AsyncMock]: - """Register streaming state for a conversation; return (dispatcher, connection).""" - dispatcher = MagicMock() - connection = AsyncMock() - target._streaming_state[conversation_id] = _StreamingConversationState(dispatcher=dispatcher) - target._existing_conversation[conversation_id] = connection - return dispatcher, connection - - -async def test_send_prompt_routes_streaming_when_state_registered(target, tmp_path): - """When streaming state is registered, the streaming branch runs (no atomic send).""" - _register_streaming(target, "conv-R") - wav_path = _write_wav(tmp_path / "in.wav") - message = _make_streaming_request(conversation_id="conv-R", wav_path=wav_path) - - target.swap_user_audio_async = AsyncMock() - target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") - completed_future: asyncio.Future = asyncio.get_running_loop().create_future() - completed_future.set_result(RealtimeTargetResult(audio_bytes=b"", transcripts=["ok"])) - target.request_response_async = AsyncMock(return_value=completed_future) - target.send_audio_async = AsyncMock() - target.send_text_async = AsyncMock() - - responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) - - target.request_response_async.assert_awaited_once() - target.send_audio_async.assert_not_called() - target.send_text_async.assert_not_called() - assert len(responses) == 1 - - -async def test_send_prompt_uses_atomic_path_when_no_streaming_state(target, tmp_path): - """Without streaming state, the atomic send_audio_async path runs as before.""" +async def test_send_prompt_audio_path_calls_send_audio_async(target, tmp_path): + """An audio_path message is routed through the atomic send_audio_async path.""" wav_path = _write_wav(tmp_path / "in.wav") piece = MessagePiece( role="user", @@ -1335,8 +1143,6 @@ async def test_send_prompt_uses_atomic_path_when_no_streaming_state(target, tmp_ ) message = Message(message_pieces=[piece]) - target.swap_user_audio_async = AsyncMock() - target.request_response_async = AsyncMock() target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() target.send_audio_async = AsyncMock( @@ -1346,153 +1152,3 @@ async def test_send_prompt_uses_atomic_path_when_no_streaming_state(target, tmp_ await target._send_prompt_to_target_async(normalized_conversation=[message]) target.send_audio_async.assert_awaited_once() - target.swap_user_audio_async.assert_not_called() - target.request_response_async.assert_not_called() - - -async def test_streaming_send_swaps_when_converters_ran(target, tmp_path): - """When converter_identifiers is non-empty, the swap call fires with the converted PCM.""" - _, connection = _register_streaming(target, "conv-S") - converted_pcm = b"\x11\x22" * 48 - wav_path = _write_wav(tmp_path / "converted.wav", pcm=converted_pcm) - - converter_id = ComponentIdentifier(class_name="FakeConverter", class_module="tests.fake") - message = _make_streaming_request( - conversation_id="conv-S", - wav_path=wav_path, - converter_identifiers=[converter_id], - committed_item_id="item_xyz", - ) - - target.swap_user_audio_async = AsyncMock() - target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") - completed_future: asyncio.Future = asyncio.get_running_loop().create_future() - completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["hello"])) - target.request_response_async = AsyncMock(return_value=completed_future) - - responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) - - target.swap_user_audio_async.assert_awaited_once() - swap_kwargs = target.swap_user_audio_async.call_args.kwargs - assert swap_kwargs["connection"] is connection - assert swap_kwargs["committed_event"].item_id == "item_xyz" - assert swap_kwargs["converted_pcm"] == converted_pcm - assert responses[0].message_pieces[0].converted_value == "hello" - assert responses[0].message_pieces[1].converted_value == "/tmp/resp.wav" - - -async def test_streaming_send_skips_swap_when_no_converters(target, tmp_path): - """With no converters, the server's raw committed buffer is used: no swap.""" - _register_streaming(target, "conv-N") - wav_path = _write_wav(tmp_path / "raw.wav") - message = _make_streaming_request( - conversation_id="conv-N", - wav_path=wav_path, - converter_identifiers=[], - ) - - target.swap_user_audio_async = AsyncMock() - target.streaming.save_audio = AsyncMock(return_value="/tmp/resp.wav") - completed_future: asyncio.Future = asyncio.get_running_loop().create_future() - completed_future.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"])) - target.request_response_async = AsyncMock(return_value=completed_future) - - await target._send_prompt_to_target_async(normalized_conversation=[message]) - - target.swap_user_audio_async.assert_not_called() - target.request_response_async.assert_awaited_once() - - -async def test_streaming_send_requires_item_id_when_converters_ran(target, tmp_path): - """A streaming request with converters but no committed item id is a contract violation.""" - _register_streaming(target, "conv-X") - wav_path = _write_wav(tmp_path / "in.wav") - converter_id = ComponentIdentifier(class_name="FakeConverter", class_module="tests.fake") - message = _make_streaming_request( - conversation_id="conv-X", - wav_path=wav_path, - converter_identifiers=[converter_id], - committed_item_id=None, - ) - - with pytest.raises(ValueError, match="committed item id"): - await target._send_prompt_to_target_async(normalized_conversation=[message]) - - -async def test_streaming_send_rejects_wrong_audio_format(target, tmp_path): - """Converters must preserve mono PCM16 @ SAMPLE_RATE_HZ; mismatches raise.""" - _register_streaming(target, "conv-F") - wav_path = _write_wav(tmp_path / "bad.wav", rate=16000) # wrong rate - message = _make_streaming_request(conversation_id="conv-F", wav_path=wav_path) - - with pytest.raises(ValueError, match="mono PCM16"): - await target._send_prompt_to_target_async(normalized_conversation=[message]) - - -async def test_send_prompt_propagates_interrupted_metadata_for_streaming(target, tmp_path): - """When the realtime turn future resolves with interrupted=True, both response pieces gain the flag.""" - _register_streaming(target, "conv-I") - wav_path = _write_wav(tmp_path / "in.wav") - message = _make_streaming_request(conversation_id="conv-I", wav_path=wav_path) - - target.streaming.save_audio = AsyncMock(return_value="/tmp/partial.wav") - completed_future: asyncio.Future = asyncio.get_running_loop().create_future() - completed_future.set_result( - RealtimeTargetResult(audio_bytes=b"\xaa" * 32, transcripts=["partial"], interrupted=True), - ) - target.request_response_async = AsyncMock(return_value=completed_future) - - responses = await target._send_prompt_to_target_async(normalized_conversation=[message]) - - assert len(responses) == 1 - text_piece, audio_piece = responses[0].message_pieces - assert text_piece.prompt_metadata.get("interrupted") is True - assert audio_piece.prompt_metadata.get("interrupted") is True - - -async def test_streaming_send_rejects_non_audio_piece(target, tmp_path): - """A text-typed piece routed to the streaming branch must surface a clear error.""" - _register_streaming(target, "conv-T") - piece = MessagePiece( - role="user", - original_value="hello", - original_value_data_type="text", - converted_value="hello", - converted_value_data_type="text", - conversation_id="conv-T", - ) - message = Message(message_pieces=[piece]) - - with pytest.raises(ValueError, match="audio_path"): - await target._send_prompt_to_target_async(normalized_conversation=[message]) - - -async def test_streaming_send_serializes_via_turn_lock(target, tmp_path): - """Two concurrent turns on the same conversation must run sequentially under the lock.""" - _register_streaming(target, "conv-L") - wav_path = _write_wav(tmp_path / "in.wav") - message = _make_streaming_request(conversation_id="conv-L", wav_path=wav_path) - - target.streaming.save_audio = AsyncMock(return_value="/tmp/r.wav") - active = 0 - max_concurrent = 0 - - async def fake_request_response(*, connection, dispatcher): - nonlocal active, max_concurrent - active += 1 - max_concurrent = max(max_concurrent, active) - # Yield control so a second turn would interleave if the lock weren't held. - await asyncio.sleep(0.01) - active -= 1 - fut: asyncio.Future = asyncio.get_running_loop().create_future() - fut.set_result(RealtimeTargetResult(audio_bytes=b"\xaa" * 32, transcripts=["ok"])) - return fut - - target.request_response_async = AsyncMock(side_effect=fake_request_response) - - await asyncio.gather( - target._send_prompt_to_target_async(normalized_conversation=[message]), - target._send_prompt_to_target_async(normalized_conversation=[message]), - ) - - assert max_concurrent == 1 From f5af8035fbaa64f6ce435248707741c2a1ada312 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 15:03:09 -0400 Subject: [PATCH 37/47] Drop dead realtime methods _stream_pcm_async and insert_user_text_async Both methods have zero production callers after Phases 3 and 4. They were speculative API surface for streaming attacks that ended up using the session-based path instead. Tests targeting them are removed alongside. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/openai_realtime_target.py | 56 ------------ .../target/test_realtime_target.py | 89 +------------------ 2 files changed, 1 insertion(+), 144 deletions(-) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 0b9505f6ab..c97ddfdc06 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -663,25 +663,6 @@ async def insert_user_audio_async(self, *, connection: Any, pcm_bytes: bytes) -> } ) - async def insert_user_text_async(self, *, connection: Any, text: str) -> None: - """ - Insert a user message containing the given text into the conversation. - - Lets streaming attacks mix text turns into an otherwise audio-driven session. - The caller is responsible for triggering ``response.create`` after insertion. - - Args: - connection: Active Realtime API connection. - text: User-side text content. - """ - await connection.conversation.item.create( - item={ - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": text}], - } - ) - async def delete_conversation_item_async(self, *, connection: Any, item_id: str) -> None: """ Delete a conversation item by id (e.g. the server's raw user audio item). @@ -752,43 +733,6 @@ async def request_response_async( await connection.response.create() return state.completion - async def _stream_pcm_async( - self, - *, - connection: Any, - pcm_bytes: bytes, - commit: bool, - chunk_ms: int = 100, - sample_rate: int = 24000, - ) -> None: - """ - Stream raw PCM16 audio to the Realtime API as ``input_audio_buffer.append`` chunks. - - Operates on raw PCM bytes (not WAV) so this helper can back both the - WAV-file path and future per-frame streaming consumers (e.g. browser audio - forwarded by a GUI backend). Caller decides whether to manually commit; - server VAD commits automatically when enabled. - - Args: - connection: Active Realtime API connection from ``self.connect()``. - pcm_bytes (bytes): Raw PCM16 mono audio. Empty buffers are accepted - and result in zero appends. - commit (bool): When True, sends ``input_audio_buffer.commit`` after the - final chunk. Pass False when server VAD is committing automatically. - chunk_ms (int): Milliseconds of audio per chunk. Defaults to 100. - sample_rate (int): PCM sample rate in Hz. Defaults to 24000. - """ - bytes_per_sample = 2 # PCM16 - chunk_size = (chunk_ms * sample_rate * bytes_per_sample) // 1000 - - for offset in range(0, len(pcm_bytes), chunk_size): - chunk = pcm_bytes[offset : offset + chunk_size] - audio_b64 = base64.b64encode(chunk).decode("ascii") - await connection.input_audio_buffer.append(audio=audio_b64) - - if commit: - await connection.input_audio_buffer.commit() - async def receive_events(self, conversation_id: str) -> RealtimeTargetResult: """ Continuously receive events from the OpenAI Realtime API connection. diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 23e0ec4949..78d9990c3b 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -567,9 +567,7 @@ def test_server_vad_config_rejects_invalid_values(kwargs): ServerVadConfig(**kwargs) -# --------------------------------------------------------------------------- -# Chunk 2 — _stream_pcm_async helper -# --------------------------------------------------------------------------- +# ---- Wire primitives for streaming attacks --------------------------------------------------- def _make_mock_connection(): @@ -580,80 +578,6 @@ def _make_mock_connection(): return connection -async def test_stream_pcm_even_split_no_commit(target): - """A buffer that divides evenly into chunks emits N appends and no commit when commit=False.""" - connection = _make_mock_connection() - # 100ms @ 24kHz @ 2 bytes/sample = 4800 bytes per chunk. 9600 bytes = 2 chunks. - pcm = b"\x00" * 9600 - - await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) - - assert connection.input_audio_buffer.append.call_count == 2 - connection.input_audio_buffer.commit.assert_not_called() - - -async def test_stream_pcm_partial_final_chunk(target): - """A buffer not a clean multiple of chunk size sends the final partial chunk as-is.""" - connection = _make_mock_connection() - # 5000 bytes => one full 4800-byte chunk + one 200-byte tail. - pcm = b"\x01" * 5000 - - await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) - - assert connection.input_audio_buffer.append.call_count == 2 - # Inspect the second call's chunk size by base64-decoding its audio kwarg. - second_call_audio_b64 = connection.input_audio_buffer.append.call_args_list[1].kwargs["audio"] - assert len(base64.b64decode(second_call_audio_b64)) == 200 - - -async def test_stream_pcm_empty_buffer(target): - """An empty buffer yields zero appends. commit=False produces no commit either.""" - connection = _make_mock_connection() - - await target._stream_pcm_async(connection=connection, pcm_bytes=b"", commit=False) - - connection.input_audio_buffer.append.assert_not_called() - connection.input_audio_buffer.commit.assert_not_called() - - -async def test_stream_pcm_commits_when_asked(target): - """commit=True triggers exactly one input_audio_buffer.commit after all appends.""" - connection = _make_mock_connection() - pcm = b"\x02" * 4800 - - await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=True) - - assert connection.input_audio_buffer.append.call_count == 1 - connection.input_audio_buffer.commit.assert_awaited_once_with() - - -async def test_stream_pcm_empty_buffer_still_commits_when_asked(target): - """commit=True with an empty buffer should still fire commit (e.g. to flush an existing buffer).""" - connection = _make_mock_connection() - - await target._stream_pcm_async(connection=connection, pcm_bytes=b"", commit=True) - - connection.input_audio_buffer.append.assert_not_called() - connection.input_audio_buffer.commit.assert_awaited_once_with() - - -async def test_stream_pcm_appends_base64_encoded_chunks(target): - """Each append's audio kwarg must be the base64 encoding of the corresponding PCM chunk.""" - connection = _make_mock_connection() - # Build a recognizable buffer: 4800 bytes of 0xAA then 4800 bytes of 0xBB. - pcm = (b"\xaa" * 4800) + (b"\xbb" * 4800) - - await target._stream_pcm_async(connection=connection, pcm_bytes=pcm, commit=False) - - first_audio = connection.input_audio_buffer.append.call_args_list[0].kwargs["audio"] - second_audio = connection.input_audio_buffer.append.call_args_list[1].kwargs["audio"] - assert base64.b64decode(first_audio) == b"\xaa" * 4800 - assert base64.b64decode(second_audio) == b"\xbb" * 4800 - - -# ---- Wire primitives for streaming attacks --------------------------------------------------- - - async def test_push_audio_chunk_async_base64_encodes_and_appends(target): connection = _make_mock_connection() pcm = b"\x33" * 480 @@ -685,17 +609,6 @@ async def test_insert_user_audio_async_creates_input_audio_item(target): assert base64.b64decode(item["content"][0]["audio"]) == pcm -async def test_insert_user_text_async_creates_input_text_item(target): - connection = AsyncMock() - - await target.insert_user_text_async(connection=connection, text="hello model") - - connection.conversation.item.create.assert_awaited_once() - item = connection.conversation.item.create.call_args.kwargs["item"] - assert item["role"] == "user" - assert item["content"][0] == {"type": "input_text", "text": "hello model"} - - async def test_delete_conversation_item_async_forwards_item_id(target): connection = AsyncMock() From b56538af32a5f6311e61865034e4961c4385c4b7 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 15:14:37 -0400 Subject: [PATCH 38/47] Move streaming-only methods onto _OpenAIRealtimeStreamingSession Pulls send_streaming_session_config_async + push_audio_chunk_async off _RealtimeStreamingHandle and swap_user_audio_async + request_response_async (plus the insert/delete helpers) off RealtimeTarget, re-homing them as six private wire helpers on _OpenAIRealtimeStreamingSession. Drops the connection/dispatcher kwargs since the session already owns both as instance state. Captures _effective_vad in __init__ (per-call vad ?? target._server_vad) so the streaming session config and on-committed paths read from one source - prevents drift and stages the eventual removal of _server_vad from the target. Removes the corresponding abstract method declarations from the StreamingHandle ABC; the remaining ABC surface (connect_async, save_audio, SAMPLE_RATE_HZ, server_vad_config) will collapse in a follow-up commit. Test churn: relocated push_audio_chunk_async tests and the three send_streaming_session_config_async tests from test_barge_in.py and test_realtime_target.py to the session test file, where they now build a real RealtimeTarget + open_streaming_session and assert on the session's private wire helpers directly; refactored ~10 existing session tests to mock on the session instance after construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 10 - .../_openai_realtime_streaming_session.py | 132 +++++++-- .../openai/openai_realtime_target.py | 148 ---------- .../attack/streaming/test_barge_in.py | 41 --- .../test_openai_realtime_streaming_session.py | 276 ++++++++++++++++-- .../target/test_realtime_target.py | 140 +-------- 6 files changed, 356 insertions(+), 391 deletions(-) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index d96a422e90..9e649a180f 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -285,16 +285,6 @@ def server_vad_config(self) -> "ServerVadConfig | None": async def connect_async(self, conversation_id: str) -> Any: """Open the streaming connection for ``conversation_id`` and return the connection handle.""" - @abstractmethod - async def send_streaming_session_config_async( - self, *, connection: Any, conversation: "list[Any] | None" = None - ) -> None: - """Send the initial streaming session configuration over the wire.""" - - @abstractmethod - async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """Push a PCM16 audio chunk into the server's input buffer.""" - @abstractmethod async def save_audio( self, diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 4d22cd82cc..7ec3b0b85d 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import base64 import contextlib import logging import uuid @@ -13,6 +14,7 @@ from typing import TYPE_CHECKING, Any from pyrit.models import Message, MessagePiece +from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, RealtimeTurnState from pyrit.prompt_target.common.streaming.streaming_audio_target import ( STREAMING_INTERRUPTED_KEY, ) @@ -127,6 +129,11 @@ def __init__( self._attack_identifier = attack_identifier self._persist_prepended_conversation = persist_prepended_conversation + # Resolve VAD once at session construction so config send and commit-time trim + # both see the same value, even if the target's ``_server_vad`` is mutated + # later. ``self._vad is None`` means "use target default", not "no VAD". + self._effective_vad: ServerVadConfig | None = self._vad if self._vad is not None else self._target._server_vad + # Tee raw user audio so we can persist it per VAD-committed turn; the dispatcher # only surfaces ``CommittedEvent`` with an item id, not the bytes themselves. self._pending_chunks = bytearray() @@ -160,16 +167,11 @@ async def run_async(self) -> AsyncIterator[Message]: Message: One assembled assistant ``Message`` per turn. The matching user ``Message`` for each turn is persisted to memory but not yielded. """ - target = self._target - streaming = target.streaming + streaming = self._target.streaming self._connection = await streaming.connect_async(conversation_id=self._conversation_id) try: - await streaming.send_streaming_session_config_async( - connection=self._connection, - conversation=self._prepended_conversation, - vad=self._vad, - ) + await self._send_streaming_session_config_async() if self._persist_prepended_conversation: await self._prompt_normalizer.add_prepended_conversation_to_memory( conversation_id=self._conversation_id, @@ -221,14 +223,13 @@ async def _drain_chunks_async(self) -> None: assert self._queue is not None connection = self._connection - streaming = self._target.streaming try: async for chunk in self._audio_chunks: if not chunk: continue async with self._pending_chunks_lock: self._pending_chunks.extend(chunk) - await streaming.push_audio_chunk_async(connection=connection, pcm_bytes=chunk) + await self._push_audio_chunk_async(chunk) # Snapshot commit-event count before forcing a final commit so we can # detect whether the server accepted it (it produces a new committed @@ -299,9 +300,7 @@ async def _on_committed(self, event: CommittedEvent) -> None: if event.audio_start_ms is not None: buffer_relative_audio_start_ms = event.audio_start_ms - self._buffer_start_session_ms - # ``self._vad is None`` means "use target default", not "no VAD". - effective_vad = self._vad if self._vad is not None else streaming.server_vad_config - prefix_padding_ms = effective_vad.prefix_padding_ms if effective_vad is not None else 0 + prefix_padding_ms = self._effective_vad.prefix_padding_ms if self._effective_vad is not None else 0 trimmed_pcm = _trim_snapshot_to_speech( raw_buffer=raw_pcm, @@ -345,18 +344,11 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: num_channels=1, sample_width_bytes=2, ) - await target.swap_user_audio_async( - connection=self._connection, - committed_event=event, - converted_pcm=converted_pcm, - ) + await self._swap_user_audio_async(committed_event=event, converted_pcm=converted_pcm) else: converted_pcm = raw_pcm - future = await target.request_response_async( - connection=self._connection, - dispatcher=self._dispatcher, - ) + future = await self._request_response_async() result = await future raw_user_path = await streaming.save_audio(raw_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate) @@ -415,3 +407,101 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: await self._prompt_normalizer.hash_and_persist_message_async(message=user_message) await self._prompt_normalizer.hash_and_persist_message_async(message=assistant_message) return assistant_message + + # ---- Wire helpers ------------------------------------------------------- + # Private methods owning the session's websocket-level concerns: per-turn + # convert-swap, response triggering, and the streaming session config / + # audio chunk push that the producer uses. Kept here so ``RealtimeTarget`` + # stays atomic-only. + + async def _send_streaming_session_config_async(self) -> None: + """ + Configure the realtime session for streaming use: server VAD with manual response creation. + + Emits the same session config as the atomic path except ``turn_detection.create_response`` + is forced to False so the streaming attack can swap the raw user audio item for converted + audio before triggering ``response.create``. + + Raises: + ValueError: If neither the session's ``vad`` nor the target's ``_server_vad`` is set. + """ + assert self._connection is not None + if self._effective_vad is None: + raise ValueError( + "_send_streaming_session_config_async requires server VAD; " + "pass vad=ServerVadConfig(...) or construct RealtimeTarget(server_vad=True)." + ) + system_prompt = self._target._get_system_prompt_from_conversation(conversation=self._prepended_conversation) + config = self._target._set_system_prompt_and_config_vars( + system_prompt=system_prompt, server_vad=self._effective_vad + ) + turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") + if turn_detection is not None: + turn_detection["create_response"] = False + await self._connection.session.update(session=config) + + async def _push_audio_chunk_async(self, pcm_bytes: bytes) -> None: + """ + Append a single PCM16 mono @ 24 kHz audio chunk to the server's input buffer. + + Server VAD, when enabled on the session, decides when to commit and fire + response logic. Empty buffers are accepted as no-ops. + """ + if not pcm_bytes: + return + assert self._connection is not None + audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") + await self._connection.input_audio_buffer.append(audio=audio_b64) + + async def _insert_user_audio_async(self, pcm_bytes: bytes) -> None: + """Insert a user message containing PCM16 mono @ 24 kHz audio into the conversation.""" + assert self._connection is not None + audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") + await self._connection.conversation.item.create( + item={ + "type": "message", + "role": "user", + "content": [{"type": "input_audio", "audio": audio_b64}], + } + ) + + async def _delete_conversation_item_async(self, item_id: str) -> None: + """Delete a conversation item by id (e.g. the server's raw user audio item).""" + assert self._connection is not None + await self._connection.conversation.item.delete(item_id=item_id) + + async def _swap_user_audio_async(self, *, committed_event: CommittedEvent, converted_pcm: bytes) -> None: + """ + Replace the server's just-committed user audio with converted PCM. + + Inserts ``converted_pcm`` as a new user item then best-effort deletes the + original item identified by ``committed_event``. Insert precedes delete so + the converted audio is already in place if delete fails or races. + """ + await self._insert_user_audio_async(converted_pcm) + try: + await self._delete_conversation_item_async(committed_event.item_id) + except Exception as e: + logger.warning(f"conversation.item.delete failed for {committed_event.item_id}: {e}") + + async def _request_response_async(self) -> asyncio.Future[RealtimeTargetResult]: + """ + Trigger ``response.create`` and return a future that resolves when the turn ends. + + Constructs a fresh ``RealtimeTurnState``, binds it to the dispatcher as the + active turn, then sends ``response.create``. The dispatcher resolves the + returned future via ``response.done`` (with ``interrupted=False``) or via + the barge-in cancel path (with ``interrupted=True``). + + Returns: + A future resolving to the ``RealtimeTargetResult`` for this turn. + + Raises: + RuntimeError: If another turn is already pending on the dispatcher. + """ + assert self._connection is not None + assert self._dispatcher is not None + state = RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) + self._dispatcher.register_turn(state) + await self._connection.response.create() + return state.completion diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index c97ddfdc06..dde73abb20 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -84,63 +84,6 @@ async def connect_async(self, conversation_id: str) -> Any: logger.info("Successfully connected to AzureOpenAI Realtime API") return connection - async def send_streaming_session_config_async( - self, - *, - connection: Any, - conversation: list[Message] | None = None, - vad: ServerVadConfig | None = None, - ) -> None: - """ - Configure the realtime session for streaming use: server VAD with manual response creation. - - Emits the same session config as the atomic path except ``turn_detection.create_response`` - is forced to False so the streaming attack can swap the raw user audio item for converted - audio before triggering ``response.create``. - - Args: - connection: Active Realtime API connection. - conversation: Optional conversation history; if its first message is a system - message, its text becomes the session's instructions. Defaults to None, - in which case the default system prompt is used. - vad: Optional per-call VAD tuning. When provided, overrides the target's - constructor-set ``_server_vad``. When None, falls back to the target's - constructor value (existing behavior). - - Raises: - ValueError: If neither ``vad`` nor the target's ``_server_vad`` is set. - """ - effective_vad = vad if vad is not None else self._target._server_vad - if effective_vad is None: - raise ValueError( - "send_streaming_session_config_async requires server VAD; " - "pass vad=ServerVadConfig(...) or construct RealtimeTarget(server_vad=True)." - ) - system_prompt = self._target._get_system_prompt_from_conversation(conversation=conversation or []) - config = self._target._set_system_prompt_and_config_vars(system_prompt=system_prompt, server_vad=effective_vad) - turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") - if turn_detection is not None: - turn_detection["create_response"] = False - await connection.session.update(session=config) - - async def push_audio_chunk_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """ - Append a single PCM16 mono @ 24 kHz audio chunk to the server's input buffer. - - Used by streaming-style callers (e.g. ``BargeInAttack``) that source chunks - from an iterator and want to control commit timing externally. Server VAD, - when enabled on the session, decides when to commit and fire response logic. - Empty buffers are accepted as no-ops. - - Args: - connection: Active Realtime API connection from ``connect_async``. - pcm_bytes: Raw PCM16 mono audio for this chunk. - """ - if not pcm_bytes: - return - audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") - await connection.input_audio_buffer.append(audio=audio_b64) - async def save_audio( self, audio_bytes: bytes, @@ -642,97 +585,6 @@ async def send_response_create(self, conversation_id: str) -> None: connection = self._get_connection(conversation_id=conversation_id) await connection.response.create() - async def insert_user_audio_async(self, *, connection: Any, pcm_bytes: bytes) -> None: - """ - Insert a user message containing the given PCM16 mono @ 24 kHz audio into the conversation. - - Use for the convert-on-commit dance — after deleting the server's raw user item, - the attack inserts the converted audio via this method before manually triggering - ``response.create``. - - Args: - connection: Active Realtime API connection. - pcm_bytes: Converted PCM16 mono audio. - """ - audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") - await connection.conversation.item.create( - item={ - "type": "message", - "role": "user", - "content": [{"type": "input_audio", "audio": audio_b64}], - } - ) - - async def delete_conversation_item_async(self, *, connection: Any, item_id: str) -> None: - """ - Delete a conversation item by id (e.g. the server's raw user audio item). - - Used during convert-on-commit to remove the raw audio item before replacing - it with a converted one. Errors are propagated; callers that want best-effort - deletion should wrap with ``contextlib.suppress``. - - Args: - connection: Active Realtime API connection. - item_id: Server-assigned item id to delete. - """ - await connection.conversation.item.delete(item_id=item_id) - - async def swap_user_audio_async( - self, - *, - connection: Any, - committed_event: CommittedEvent, - converted_pcm: bytes, - ) -> None: - """ - Replace the server's just-committed user audio with converted PCM. - - Inserts ``converted_pcm`` as a new user item and best-effort deletes the original - item identified by ``committed_event``. Hides OpenAI's item-id concept from - callers so streaming attacks can stay provider-agnostic. - - Args: - connection: Active Realtime API connection. - committed_event: Payload received in the on-committed callback. - converted_pcm: PCM16 mono @ 24 kHz audio to insert in place of the original. - """ - await self.insert_user_audio_async(connection=connection, pcm_bytes=converted_pcm) - try: - await self.delete_conversation_item_async(connection=connection, item_id=committed_event.item_id) - except Exception as e: - logger.warning(f"conversation.item.delete failed for {committed_event.item_id}: {e}") - - async def request_response_async( - self, - *, - connection: Any, - dispatcher: RealtimeEventDispatcher, - ) -> asyncio.Future[RealtimeTargetResult]: - """ - Trigger ``response.create`` and return a future that resolves when the turn ends. - - Constructs a fresh ``RealtimeTurnState``, binds it to the dispatcher as the - active turn, then sends ``response.create``. The dispatcher resolves the - returned future via ``response.done`` (with ``interrupted=False``) or via - the barge-in cancel path (with ``interrupted=True``). - - Args: - connection: Active Realtime API connection. - dispatcher: The dispatcher driving this connection. Must not have - another turn pending. - - Returns: - Future resolved with the assembled ``RealtimeTargetResult`` when this - turn ends (normally or via barge-in). - - Raises: - RuntimeError: If another turn is already pending on the dispatcher. - """ - state = RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) - dispatcher.register_turn(state) - await connection.response.create() - return state.completion - async def receive_events(self, conversation_id: str) -> RealtimeTargetResult: """ Continuously receive events from the OpenAI Realtime API connection. diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 52e45d381a..a37d6101ab 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -294,44 +294,3 @@ async def test_perform_async_rejects_missing_audio_chunks(vad_target): await attack._perform_async(context=ctx) factory.assert_not_called() - - -# ---- send_streaming_session_config_async (target-side helper added in R4a) ------------------- - - -async def test_send_streaming_session_config_async_emits_create_response_false(vad_target): - """The streaming session config must flip create_response to False on turn_detection.""" - connection = _mock_connection() - await vad_target.streaming.send_streaming_session_config_async(connection=connection) - connection.session.update.assert_awaited_once() - config = connection.session.update.call_args.kwargs["session"] - assert config["audio"]["input"]["turn_detection"]["create_response"] is False - - -@patch.dict("os.environ", _CLEAN_ENV) -async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): - """Without server VAD, sending streaming session config must raise.""" - no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") - connection = _mock_connection() - with pytest.raises(ValueError, match="server VAD"): - await no_vad.streaming.send_streaming_session_config_async(connection=connection) - - -async def test_send_streaming_session_config_async_uses_system_message_from_conversation(vad_target): - """If the prepended conversation begins with a system message, it becomes session instructions.""" - connection = _mock_connection() - system_msg = Message( - message_pieces=[ - MessagePiece( - role="system", - original_value="You are a strict assistant.", - original_value_data_type="text", - converted_value="You are a strict assistant.", - converted_value_data_type="text", - conversation_id="x", - ) - ] - ) - await vad_target.streaming.send_streaming_session_config_async(connection=connection, conversation=[system_msg]) - config = connection.session.update.call_args.kwargs["session"] - assert config["instructions"] == "You are a strict assistant." diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 25389206db..a4d9b4fda0 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -4,14 +4,15 @@ """Unit tests for the internal _OpenAIRealtimeStreamingSession lifecycle.""" import asyncio +import base64 import contextlib import uuid from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, call, patch import pytest -from pyrit.models import Message +from pyrit.models import Message, MessagePiece from pyrit.prompt_target.common.realtime_audio import CommittedEvent, RealtimeTargetResult from pyrit.prompt_target.common.streaming import ServerVadConfig from pyrit.prompt_target.common.streaming.streaming_audio_target import ( @@ -47,6 +48,9 @@ def _build_target() -> MagicMock: target = MagicMock(name="RealtimeTarget") target.streaming = MagicMock(name="streaming") target.streaming.SAMPLE_RATE_HZ = 24000 + # MagicMock auto-creates attributes; pin _server_vad to None so the session's + # ``_effective_vad`` capture defaults correctly when no per-session vad is passed. + target._server_vad = None connection = AsyncMock(name="connection") # AsyncMock auto-creates attributes as AsyncMock, but child attribute chains like @@ -56,10 +60,7 @@ def _build_target() -> MagicMock: connection.input_audio_buffer.commit = AsyncMock(side_effect=_StubBadRequest("input_audio_buffer_commit_empty")) target.streaming.connect_async = AsyncMock(return_value=connection) - target.streaming.send_streaming_session_config_async = AsyncMock() - target.streaming.push_audio_chunk_async = AsyncMock() target.streaming.save_audio = AsyncMock(side_effect=lambda pcm, **kw: f"/tmp/audio-{uuid.uuid4().hex[:8]}.wav") - target.swap_user_audio_async = AsyncMock() target.get_identifier = MagicMock( return_value={"__type__": "RealtimeTarget", "__module__": "test", "id": "test-id"} ) @@ -72,9 +73,9 @@ def _make_request_response_async( transcripts: tuple[str, ...] = ("hi",), interrupted: bool = False, ) -> AsyncMock: - """AsyncMock for ``RealtimeTarget.request_response_async`` returning a resolved Future.""" + """AsyncMock for ``session._request_response_async`` returning a resolved Future.""" - async def _impl(*, connection: Any, dispatcher: Any) -> asyncio.Future: + async def _impl() -> asyncio.Future: future = asyncio.get_running_loop().create_future() future.set_result( RealtimeTargetResult( @@ -88,6 +89,21 @@ async def _impl(*, connection: Any, dispatcher: Any) -> asyncio.Future: return AsyncMock(side_effect=_impl) +def _mock_session_wire(session: _OpenAIRealtimeStreamingSession) -> None: + """ + Replace the session's websocket-facing private methods with AsyncMocks. + + Every test that drives ``run_async`` needs these stubbed so the orchestration + code under test doesn't try to speak to a real connection. Tests can override + any individual mock (e.g. ``session._request_response_async = _make_request_response_async(...)``) + after this call. + """ + session._send_streaming_session_config_async = AsyncMock() + session._push_audio_chunk_async = AsyncMock() + session._swap_user_audio_async = AsyncMock() + session._request_response_async = _make_request_response_async() + + def _build_normalizer() -> MagicMock: normalizer = MagicMock(name="PromptNormalizer") normalizer.add_prepended_conversation_to_memory = AsyncMock() @@ -191,7 +207,6 @@ async def _empty(): async def test_run_async_yields_one_message_per_committed_turn(): """Two simulated server-VAD commits yield two assistant Messages and persist both user+assistant pairs.""" target = _build_target() - target.request_response_async = _make_request_response_async(transcripts=("hello", " world")) normalizer = _build_normalizer() finish = asyncio.Event() @@ -200,6 +215,8 @@ async def test_run_async_yields_one_message_per_committed_turn(): audio_chunks=_paced_chunks([b"\x01" * 100, b"\x02" * 100], finish), prompt_normalizer=normalizer, ) + _mock_session_wire(session) + session._request_response_async = _make_request_response_async(transcripts=("hello", " world")) with _patched_dispatcher(): messages = await _run_session_with_events( @@ -219,8 +236,8 @@ async def test_run_async_yields_one_message_per_committed_turn(): # 2 turns * (user + assistant) = 4 persistence calls. assert normalizer.hash_and_persist_message_async.await_count == 4 - # request_response_async called once per turn. - assert target.request_response_async.await_count == 2 + # _request_response_async called once per turn. + assert session._request_response_async.await_count == 2 # --------------------------------------------------------------------------- @@ -231,7 +248,6 @@ async def test_run_async_yields_one_message_per_committed_turn(): async def test_run_async_marks_assistant_pieces_when_turn_interrupted(): """When a turn is interrupted, STREAMING_INTERRUPTED_KEY must be set on text + audio pieces.""" target = _build_target() - target.request_response_async = _make_request_response_async(interrupted=True) normalizer = _build_normalizer() finish = asyncio.Event() @@ -240,6 +256,8 @@ async def test_run_async_marks_assistant_pieces_when_turn_interrupted(): audio_chunks=_paced_chunks([b"\x01" * 100], finish), prompt_normalizer=normalizer, ) + _mock_session_wire(session) + session._request_response_async = _make_request_response_async(interrupted=True) with _patched_dispatcher(): messages = await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-1")]) @@ -257,7 +275,6 @@ async def test_run_async_marks_assistant_pieces_when_turn_interrupted(): async def test_run_async_applies_response_converters_to_assistant_message(): """Response converter configurations must be applied to the assembled assistant Message.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() response_cfg = MagicMock(name="response_converter_cfg") @@ -269,6 +286,7 @@ async def test_run_async_applies_response_converters_to_assistant_message(): prompt_normalizer=normalizer, response_converter_configurations=[response_cfg], ) + _mock_session_wire(session) with _patched_dispatcher(): messages = await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-1")]) @@ -286,9 +304,8 @@ async def test_run_async_applies_response_converters_to_assistant_message(): async def test_run_async_swaps_user_audio_and_records_identifiers_when_request_converters_present(): - """With request converters: convert_audio_async + swap_user_audio_async run, identifiers reach user piece.""" + """With request converters: convert_audio_async + _swap_user_audio_async run, identifiers reach user piece.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() # Force convert_audio_async to return a NEW object so the session treats it as "converted". normalizer.convert_audio_async = AsyncMock(side_effect=lambda raw_pcm, **kw: b"converted" + raw_pcm) @@ -313,13 +330,14 @@ async def _capture(*, message: Message) -> None: prompt_normalizer=normalizer, request_converter_configurations=[request_cfg], ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-A")]) normalizer.convert_audio_async.assert_awaited_once() - target.swap_user_audio_async.assert_awaited_once() - swap_kwargs = target.swap_user_audio_async.await_args.kwargs + session._swap_user_audio_async.assert_awaited_once() + swap_kwargs = session._swap_user_audio_async.await_args.kwargs assert swap_kwargs["committed_event"].item_id == "item-A" assert len(persisted_user_messages) == 1 @@ -328,9 +346,8 @@ async def _capture(*, message: Message) -> None: async def test_run_async_skips_swap_and_identifiers_when_no_request_converters(): - """Without request converters: no convert_audio_async, no swap_user_audio_async, empty identifiers.""" + """Without request converters: no convert_audio_async, no _swap_user_audio_async, empty identifiers.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() persisted_user_messages: list[Message] = [] @@ -347,12 +364,13 @@ async def _capture(*, message: Message) -> None: audio_chunks=_paced_chunks([b"\x01" * 100], finish), prompt_normalizer=normalizer, ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="item-B")]) normalizer.convert_audio_async.assert_not_called() - target.swap_user_audio_async.assert_not_called() + session._swap_user_audio_async.assert_not_called() assert len(persisted_user_messages) == 1 assert persisted_user_messages[0].message_pieces[0].converter_identifiers == [] @@ -364,9 +382,8 @@ async def _capture(*, message: Message) -> None: async def test_run_async_persists_prepended_conversation_and_forwards_vad_config(): - """``prepended_conversation`` reaches normalizer.add_prepended_conversation_to_memory and session.update.""" + """``prepended_conversation`` reaches normalizer.add_prepended_conversation_to_memory; vad reaches the session.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() prepended = [MagicMock(name="prepended_message")] @@ -387,16 +404,19 @@ async def _empty(): vad=vad, conversation_id="conv-prep", ) + _mock_session_wire(session) with _patched_dispatcher(): # No committed events; iterator is empty so producer exits immediately. async for _ in session.run_async(): pytest.fail("no events were fired; session should yield nothing") - target.streaming.send_streaming_session_config_async.assert_awaited_once() - config_kwargs = target.streaming.send_streaming_session_config_async.await_args.kwargs - assert config_kwargs["conversation"] == prepended - assert config_kwargs["vad"] is vad + # Session captured the per-call vad as the effective config. + assert session._effective_vad is vad + # The session retained the prepended conversation (its config builder reads from it). + assert session._prepended_conversation == prepended + # The streaming session config was emitted exactly once. + session._send_streaming_session_config_async.assert_awaited_once() normalizer.add_prepended_conversation_to_memory.assert_awaited_once() prep_kwargs = normalizer.add_prepended_conversation_to_memory.await_args.kwargs @@ -421,6 +441,7 @@ async def test_run_async_propagates_dispatcher_failure_via_failure_callback(): audio_chunks=_paced_chunks([b"\x01" * 100], finish), prompt_normalizer=normalizer, ) + _mock_session_wire(session) dispatcher_failure = RuntimeError("dispatch loop died") @@ -453,7 +474,6 @@ async def _fire_failure() -> None: async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audio(): """``audio_start_ms`` past prefix_padding trims the snapshot before save_audio is called.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() # 600ms buffer @ 24kHz mono PCM16 = 600 * 48 = 28800 bytes. We push it as one chunk @@ -469,6 +489,7 @@ async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audi prompt_normalizer=normalizer, vad=ServerVadConfig(prefix_padding_ms=100), ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events( @@ -487,7 +508,6 @@ async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audi async def test_on_committed_skips_trim_when_audio_start_ms_missing(): """When ``audio_start_ms`` is None, the full buffer is persisted (no trim).""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() bytes_per_ms = 48 @@ -501,6 +521,7 @@ async def test_on_committed_skips_trim_when_audio_start_ms_missing(): prompt_normalizer=normalizer, vad=ServerVadConfig(prefix_padding_ms=100), ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events( @@ -517,7 +538,6 @@ async def test_on_committed_skips_trim_when_audio_start_ms_missing(): async def test_buffer_start_session_ms_advances_across_commits(): """Second commit's server-relative ``audio_start_ms`` is mapped through ``_buffer_start_session_ms``.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() bytes_per_ms = 48 @@ -548,6 +568,7 @@ async def _gated_chunks(): prompt_normalizer=normalizer, vad=ServerVadConfig(prefix_padding_ms=100), ) + _mock_session_wire(session) async def _consume() -> None: async for _msg in session.run_async(): @@ -583,7 +604,6 @@ async def _fire() -> None: async def test_attack_identifier_stamped_on_persisted_pieces_when_set(): """When ``attack_identifier`` is provided, every persisted piece carries it.""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() persisted_messages: list[Message] = [] @@ -602,6 +622,7 @@ async def _capture(*, message: Message) -> None: prompt_normalizer=normalizer, attack_identifier=attack_id, ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="i")]) @@ -616,7 +637,6 @@ async def _capture(*, message: Message) -> None: async def test_attack_identifier_absent_when_not_provided(): """Without ``attack_identifier``, persisted pieces have None attribution (back-compat).""" target = _build_target() - target.request_response_async = _make_request_response_async() normalizer = _build_normalizer() persisted_messages: list[Message] = [] @@ -632,6 +652,7 @@ async def _capture(*, message: Message) -> None: audio_chunks=_paced_chunks([b"\x01" * 96], finish), prompt_normalizer=normalizer, ) + _mock_session_wire(session) with _patched_dispatcher(): await _run_session_with_events(session, finish=finish, events=[CommittedEvent(item_id="i")]) @@ -666,13 +687,14 @@ async def _empty(): prepended_conversation=[MagicMock(name="prepended_message")], persist_prepended_conversation=False, ) + _mock_session_wire(session) with _patched_dispatcher(): async for _ in session.run_async(): pytest.fail("no events were fired; session should yield nothing") - # send_streaming_session_config still gets the prepended conversation (system msg → instructions). - target.streaming.send_streaming_session_config_async.assert_awaited_once() + # _send_streaming_session_config still runs (it reads the prepended conversation for system msg). + session._send_streaming_session_config_async.assert_awaited_once() # But the memory write is skipped — the caller (e.g., the attack) has already persisted it. normalizer.add_prepended_conversation_to_memory.assert_not_called() @@ -807,3 +829,193 @@ def test_trim_aligns_to_sample_frame_boundary(): ) # start_byte = 3 → aligned down to 2 → trimmed = buf[2:] = b"\x03..\x08" (6 bytes) assert out == b"\x03\x04\x05\x06\x07\x08" + + +# --------------------------------------------------------------------------- +# 13. Wire-level tests for the session's websocket-facing private helpers +# --------------------------------------------------------------------------- + + +_CLEAN_ENV = {"OPENAI_REALTIME_UNDERLYING_MODEL": ""} + + +def _real_session_with_mock_connection( + sqlite_instance, + *, + server_vad: bool = True, + vad: ServerVadConfig | None = None, + prepended_conversation=None, +): + """Build a real ``_OpenAIRealtimeStreamingSession`` over a real ``RealtimeTarget`` with a mock connection. + + The session is wired to a real target (so the privates that delegate into + ``_set_system_prompt_and_config_vars`` work) but its connection is replaced + with an AsyncMock so wire calls are observable. Audio chunks iterator and + prompt normalizer are stubbed to whatever the caller wants — these tests + only exercise individual private helpers, not ``run_async``. + """ + from pyrit.prompt_target import RealtimeTarget + + with patch.dict("os.environ", _CLEAN_ENV): + target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test", server_vad=server_vad) + + async def _empty(): + if False: + yield b"" + + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_empty(), + prompt_normalizer=MagicMock(name="normalizer"), + prepended_conversation=prepended_conversation, + vad=vad, + ) + session._connection = AsyncMock(name="connection") + return session + + +# --- _push_audio_chunk_async ------------------------------------------------ + + +async def test_push_audio_chunk_async_base64_encodes_and_appends(sqlite_instance): + session = _real_session_with_mock_connection(sqlite_instance) + pcm = b"\x33" * 480 + + await session._push_audio_chunk_async(pcm) + + session._connection.input_audio_buffer.append.assert_awaited_once() + audio_b64 = session._connection.input_audio_buffer.append.call_args.kwargs["audio"] + assert base64.b64decode(audio_b64) == pcm + + +async def test_push_audio_chunk_async_empty_is_noop(sqlite_instance): + session = _real_session_with_mock_connection(sqlite_instance) + await session._push_audio_chunk_async(b"") + session._connection.input_audio_buffer.append.assert_not_called() + + +# --- _swap_user_audio_async ------------------------------------------------- + + +async def test_swap_user_audio_async_inserts_converted_then_deletes_original(sqlite_instance): + """``_swap_user_audio_async`` must insert the converted PCM then delete the original item.""" + session = _real_session_with_mock_connection(sqlite_instance) + event = CommittedEvent(item_id="raw_swap_1") + + await session._swap_user_audio_async(committed_event=event, converted_pcm=b"\xab" * 96) + + session._connection.conversation.item.create.assert_awaited_once() + session._connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_1") + # Insert must precede delete: any future refactor that swaps the order or runs them + # concurrently would corrupt the streaming session — pin the ordering here. + create_index = session._connection.method_calls.index(call.conversation.item.create(item=ANY)) + delete_index = session._connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_1")) + assert create_index < delete_index + + +async def test_swap_user_audio_async_logs_and_swallows_delete_failure(sqlite_instance, caplog): + """Best-effort delete: if ``delete`` raises, ``swap`` logs a warning and returns normally.""" + session = _real_session_with_mock_connection(sqlite_instance) + session._connection.conversation.item.delete.side_effect = RuntimeError("delete blew up") + event = CommittedEvent(item_id="raw_swap_fail") + + with caplog.at_level("WARNING"): + await session._swap_user_audio_async(committed_event=event, converted_pcm=b"\x01" * 96) + + session._connection.conversation.item.create.assert_awaited_once() + session._connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_fail") + # Even on delete failure, insert must have happened first. + create_index = session._connection.method_calls.index(call.conversation.item.create(item=ANY)) + delete_index = session._connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_fail")) + assert create_index < delete_index + assert any("delete failed for raw_swap_fail" in record.message for record in caplog.records) + + +# --- _request_response_async ------------------------------------------------ + + +async def test_request_response_async_registers_turn_and_sends_response_create(sqlite_instance): + """_request_response_async must register a fresh turn and call response.create.""" + session = _real_session_with_mock_connection(sqlite_instance) + from pyrit.prompt_target.common.realtime_audio import RealtimeTurnState + + dispatcher = MagicMock() + dispatcher.register_turn = MagicMock() + session._dispatcher = dispatcher + + future = await session._request_response_async() + + dispatcher.register_turn.assert_called_once() + registered_state = dispatcher.register_turn.call_args.args[0] + assert isinstance(registered_state, RealtimeTurnState) + assert registered_state.completion is future + session._connection.response.create.assert_awaited_once_with() + + +async def test_request_response_async_future_resolves_with_dispatcher_result(sqlite_instance): + """The future returned by _request_response_async resolves when the turn ends.""" + session = _real_session_with_mock_connection(sqlite_instance) + dispatcher = MagicMock() + expected_result = RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"]) + + def _register(state): + state.completion.set_result(expected_result) + + dispatcher.register_turn = MagicMock(side_effect=_register) + session._dispatcher = dispatcher + + future = await session._request_response_async() + result = await future + assert result is expected_result + + +async def test_request_response_async_propagates_register_turn_failure(sqlite_instance): + """If another turn is already pending, register_turn raises and request_response_async surfaces it.""" + session = _real_session_with_mock_connection(sqlite_instance) + dispatcher = MagicMock() + dispatcher.register_turn = MagicMock(side_effect=RuntimeError("turn already pending")) + session._dispatcher = dispatcher + + with pytest.raises(RuntimeError, match="turn already pending"): + await session._request_response_async() + + session._connection.response.create.assert_not_called() + + +# --- _send_streaming_session_config_async ----------------------------------- + + +async def test_send_streaming_session_config_async_emits_create_response_false(sqlite_instance): + """The streaming session config must flip create_response to False on turn_detection.""" + session = _real_session_with_mock_connection(sqlite_instance, server_vad=True) + await session._send_streaming_session_config_async() + session._connection.session.update.assert_awaited_once() + config = session._connection.session.update.call_args.kwargs["session"] + assert config["audio"]["input"]["turn_detection"]["create_response"] is False + + +async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): + """Without server VAD on target or per-session vad, sending streaming session config must raise.""" + session = _real_session_with_mock_connection(sqlite_instance, server_vad=False, vad=None) + with pytest.raises(ValueError, match="server VAD"): + await session._send_streaming_session_config_async() + + +async def test_send_streaming_session_config_async_uses_system_message_from_conversation(sqlite_instance): + """If the prepended conversation begins with a system message, it becomes session instructions.""" + system_msg = Message( + message_pieces=[ + MessagePiece( + role="system", + original_value="You are a strict assistant.", + original_value_data_type="text", + converted_value="You are a strict assistant.", + converted_value_data_type="text", + conversation_id="x", + ) + ] + ) + session = _real_session_with_mock_connection(sqlite_instance, server_vad=True, prepended_conversation=[system_msg]) + await session._send_streaming_session_config_async() + config = session._connection.session.update.call_args.kwargs["session"] + assert config["instructions"] == "You are a strict assistant." diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 78d9990c3b..afc4e20f1c 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -5,7 +5,7 @@ import base64 import wave from typing import Any -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -570,95 +570,6 @@ def test_server_vad_config_rejects_invalid_values(kwargs): # ---- Wire primitives for streaming attacks --------------------------------------------------- -def _make_mock_connection(): - """Return an AsyncMock connection with input_audio_buffer wired up.""" - connection = AsyncMock() - connection.input_audio_buffer.append = AsyncMock() - connection.input_audio_buffer.commit = AsyncMock() - return connection - - -async def test_push_audio_chunk_async_base64_encodes_and_appends(target): - connection = _make_mock_connection() - pcm = b"\x33" * 480 - - await target.streaming.push_audio_chunk_async(connection=connection, pcm_bytes=pcm) - - connection.input_audio_buffer.append.assert_awaited_once() - audio_b64 = connection.input_audio_buffer.append.call_args.kwargs["audio"] - assert base64.b64decode(audio_b64) == pcm - - -async def test_push_audio_chunk_async_empty_is_noop(target): - connection = _make_mock_connection() - await target.streaming.push_audio_chunk_async(connection=connection, pcm_bytes=b"") - connection.input_audio_buffer.append.assert_not_called() - - -async def test_insert_user_audio_async_creates_input_audio_item(target): - connection = AsyncMock() - pcm = b"\x44" * 480 - - await target.insert_user_audio_async(connection=connection, pcm_bytes=pcm) - - connection.conversation.item.create.assert_awaited_once() - item = connection.conversation.item.create.call_args.kwargs["item"] - assert item["type"] == "message" - assert item["role"] == "user" - assert item["content"][0]["type"] == "input_audio" - assert base64.b64decode(item["content"][0]["audio"]) == pcm - - -async def test_delete_conversation_item_async_forwards_item_id(target): - connection = AsyncMock() - - await target.delete_conversation_item_async(connection=connection, item_id="raw_item_99") - - connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_item_99") - - -async def test_swap_user_audio_async_inserts_converted_then_deletes_original(target): - """``swap_user_audio_async`` must insert the converted PCM then delete the original item.""" - connection = AsyncMock() - event = CommittedEvent(item_id="raw_swap_1") - - await target.swap_user_audio_async( - connection=connection, - committed_event=event, - converted_pcm=b"\xab" * 96, - ) - - connection.conversation.item.create.assert_awaited_once() - connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_1") - # Insert must precede delete: any future refactor that swaps the order or runs them - # concurrently would corrupt the streaming session — pin the ordering here. - create_index = connection.method_calls.index(call.conversation.item.create(item=ANY)) - delete_index = connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_1")) - assert create_index < delete_index - - -async def test_swap_user_audio_async_logs_and_swallows_delete_failure(target, caplog): - """Best-effort delete: if ``delete`` raises, ``swap`` logs a warning and returns normally.""" - connection = AsyncMock() - connection.conversation.item.delete.side_effect = RuntimeError("delete blew up") - event = CommittedEvent(item_id="raw_swap_fail") - - with caplog.at_level("WARNING"): - await target.swap_user_audio_async( - connection=connection, - committed_event=event, - converted_pcm=b"\x01" * 96, - ) - - connection.conversation.item.create.assert_awaited_once() - connection.conversation.item.delete.assert_awaited_once_with(item_id="raw_swap_fail") - # Even on delete failure, insert must have happened first. - create_index = connection.method_calls.index(call.conversation.item.create(item=ANY)) - delete_index = connection.method_calls.index(call.conversation.item.delete(item_id="raw_swap_fail")) - assert create_index < delete_index - assert any("delete failed for raw_swap_fail" in record.message for record in caplog.records) - - def _turn_state(*, response_id: str | None = "resp_abc", item_id: str | None = "item_xyz") -> RealtimeTurnState: """Build a turn state with the named ids preset; completion future is unused by cancel tests.""" return RealtimeTurnState( @@ -940,55 +851,6 @@ async def on_committed(event: CommittedEvent) -> None: assert received[1].audio_start_ms is None -# Placeholder for R2 tests - - -# ---- request_response_async (R2) ------------------------------------------------- - - -async def test_request_response_async_registers_turn_and_sends_response_create(target): - """request_response_async must register a fresh turn and call response.create.""" - connection = AsyncMock() - dispatcher = MagicMock() - dispatcher.register_turn = MagicMock() - - future = await target.request_response_async(connection=connection, dispatcher=dispatcher) - - dispatcher.register_turn.assert_called_once() - registered_state = dispatcher.register_turn.call_args.args[0] - assert isinstance(registered_state, RealtimeTurnState) - assert registered_state.completion is future - connection.response.create.assert_awaited_once_with() - - -async def test_request_response_async_future_resolves_with_dispatcher_result(target): - """The future returned by request_response_async resolves when the turn ends.""" - connection = AsyncMock() - dispatcher = MagicMock() - expected_result = RealtimeTargetResult(audio_bytes=b"\xaa" * 96, transcripts=["ok"]) - - def _register(state): - state.completion.set_result(expected_result) - - dispatcher.register_turn = MagicMock(side_effect=_register) - - future = await target.request_response_async(connection=connection, dispatcher=dispatcher) - result = await future - assert result is expected_result - - -async def test_request_response_async_propagates_register_turn_failure(target): - """If another turn is already pending, register_turn raises and request_response_async surfaces it.""" - connection = AsyncMock() - dispatcher = MagicMock() - dispatcher.register_turn = MagicMock(side_effect=RuntimeError("turn already pending")) - - with pytest.raises(RuntimeError, match="turn already pending"): - await target.request_response_async(connection=connection, dispatcher=dispatcher) - - connection.response.create.assert_not_called() - - # ---- streaming handle wiring & config ----------------------------------------- From 620b998dfa0ccbf964ba27a38cc9234dd73a07f3 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 15:57:35 -0400 Subject: [PATCH 39/47] Collapse StreamingHandle ABC; hoist members onto RealtimeTarget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The speculative provider-agnostic `StreamingHandle` ABC had one impl (`_RealtimeStreamingHandle`) and no structural consumers after phases 5a-5b moved every session-only method off it. This commit collapses the layer: - Delete `_RealtimeStreamingHandle` class (~65 LOC) and the `StreamingHandle` ABC (~50 LOC). - Hoist `SAMPLE_RATE_HZ` (ClassVar), `connect_async` (now private `_connect_async`), and `save_audio` (public) onto `RealtimeTarget`. - Drop `RealtimeTarget.streaming` attribute + the composition shim in `__init__`. - Rewire 5 atomic-path call sites in `openai_realtime_target.py` and 4 sites in `_openai_realtime_streaming_session.py` (also dropping the now-pointless `streaming = self._target.streaming` locals). - Update `open_streaming_session` docstring reference. - Test churn: drop 4 streaming-handle wiring tests; rewrite the `SAMPLE_RATE_HZ` assertion against `RealtimeTarget`; rewire `target.streaming.connect_async` mocks to `target._connect_async` and `target.streaming.save_audio` to `target.save_audio`. Net: -92 LOC. Pure structural refactor — no behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/common/realtime_audio.py | 40 +---- .../_openai_realtime_streaming_session.py | 16 +- .../openai/openai_realtime_target.py | 143 ++++++++---------- .../test_openai_realtime_streaming_session.py | 15 +- .../target/test_realtime_target.py | 48 ++---- 5 files changed, 85 insertions(+), 177 deletions(-) diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 9e649a180f..995a63479e 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from collections.abc import Callable, Coroutine from dataclasses import dataclass, field -from typing import Any, ClassVar +from typing import Any from pyrit.prompt_target.common.streaming.streaming_audio_target import ( ServerVadConfig as ServerVadConfig, # noqa: TC001 @@ -257,41 +257,3 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: Args: state (RealtimeTurnState): The turn whose response should be cancelled. """ - - -class StreamingHandle(ABC): - """ - Provider-agnostic websocket-level streaming surface for realtime targets. - - Owns the low-level transport primitives a streaming session needs: opening - the connection, sending the initial session config, pushing PCM chunks, and - persisting audio to disk. Streaming attacks (e.g. ``BargeInAttack``) drive - these through a session object (see ``RealtimeTarget.open_streaming_session``) - rather than calling this handle directly. Concrete realtime providers - (OpenAI, Azure, etc.) provide a concrete subclass and assign it to - ``self.streaming`` on their target so the session can read provider-agnostic - config without knowing the concrete target type. - """ - - #: PCM sample rate in Hz negotiated by the provider's realtime protocol. - SAMPLE_RATE_HZ: ClassVar[int] - - @property - @abstractmethod - def server_vad_config(self) -> "ServerVadConfig | None": - """Server VAD configuration in effect, or ``None`` if server VAD is disabled.""" - - @abstractmethod - async def connect_async(self, conversation_id: str) -> Any: - """Open the streaming connection for ``conversation_id`` and return the connection handle.""" - - @abstractmethod - async def save_audio( - self, - audio_bytes: bytes, - num_channels: int = 1, - sample_width: int = 2, - sample_rate: int = 16000, - output_filename: str | None = None, - ) -> str: - """Persist a PCM buffer to disk and return the file path.""" diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 7ec3b0b85d..7f40260b7b 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -167,9 +167,7 @@ async def run_async(self) -> AsyncIterator[Message]: Message: One assembled assistant ``Message`` per turn. The matching user ``Message`` for each turn is persisted to memory but not yielded. """ - streaming = self._target.streaming - - self._connection = await streaming.connect_async(conversation_id=self._conversation_id) + self._connection = await self._target._connect_async(conversation_id=self._conversation_id) try: await self._send_streaming_session_config_async() if self._persist_prepended_conversation: @@ -286,8 +284,7 @@ async def _on_committed(self, event: CommittedEvent) -> None: asyncio.CancelledError: Propagated when the dispatcher task is cancelled. """ assert self._queue is not None - streaming = self._target.streaming - sample_rate = streaming.SAMPLE_RATE_HZ + sample_rate = self._target.SAMPLE_RATE_HZ async with self._pending_chunks_lock: raw_pcm = bytes(self._pending_chunks) @@ -333,8 +330,7 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: assert self._dispatcher is not None target = self._target - streaming = target.streaming - sample_rate = streaming.SAMPLE_RATE_HZ + sample_rate = target.SAMPLE_RATE_HZ if self._request_converter_configurations: converted_pcm = await self._prompt_normalizer.convert_audio_async( @@ -351,14 +347,14 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: future = await self._request_response_async() result = await future - raw_user_path = await streaming.save_audio(raw_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate) + raw_user_path = await target.save_audio(raw_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate) if converted_pcm is raw_pcm: converted_user_path = raw_user_path else: - converted_user_path = await streaming.save_audio( + converted_user_path = await target.save_audio( converted_pcm, num_channels=1, sample_width=2, sample_rate=sample_rate ) - assistant_audio_path = await streaming.save_audio( + assistant_audio_path = await target.save_audio( result.audio_bytes, num_channels=1, sample_width=2, sample_rate=sample_rate ) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index dde73abb20..12ab4a458e 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -27,7 +27,6 @@ RealtimeTargetResult, RealtimeTurnState, ServerVadConfig, - StreamingHandle, ) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration @@ -50,74 +49,6 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] -class _RealtimeStreamingHandle(StreamingHandle): - """ - OpenAI Realtime API implementation of :class:`StreamingHandle`. - - Owns the websocket-level streaming surface (connect, push audio, config) and - the audio persistence helper. Holds a back-reference to its owning - :class:`RealtimeTarget` so it can read per-target state (server VAD config, - OpenAI client, conversation registries). - """ - - SAMPLE_RATE_HZ: ClassVar[int] = 24000 - - def __init__(self, target: "RealtimeTarget") -> None: - self._target = target - - @property - def server_vad_config(self) -> ServerVadConfig | None: - return self._target._server_vad - - async def connect_async(self, conversation_id: str) -> Any: - """ - Connect to Realtime API using AsyncOpenAI client and return the realtime connection. - - Returns: - The Realtime API connection. - """ - logger.info(f"Connecting to Realtime API: {self._target._endpoint}") - - client = self._target._get_openai_client() - connection = await client.realtime.connect(model=self._target._model_name).__aenter__() - - logger.info("Successfully connected to AzureOpenAI Realtime API") - return connection - - async def save_audio( - self, - audio_bytes: bytes, - num_channels: int = 1, - sample_width: int = 2, - sample_rate: int = 16000, - output_filename: Optional[str] = None, - ) -> str: - """ - Save audio bytes to a WAV file. - - Args: - audio_bytes (bytes): Audio bytes to save. - num_channels (int): Number of audio channels. Defaults to 1 for the PCM16 format - sample_width (int): Sample width in bytes. Defaults to 2 for the PCM16 format - sample_rate (int): Sample rate in Hz. Defaults to 16000 Hz for the PCM16 format - output_filename (str): Output filename. If None, a UUID filename will be used. - - Returns: - str: The path to the saved audio file. - """ - data = data_serializer_factory(category="prompt-memory-entries", data_type="audio_path") - - await data.save_formatted_audio( - data=audio_bytes, - output_filename=output_filename, - num_channels=num_channels, - sample_width=sample_width, - sample_rate=sample_rate, - ) - - return data.value - - class RealtimeTarget(OpenAITarget, PromptTarget): """ A prompt target for Azure OpenAI Realtime API. @@ -152,9 +83,9 @@ class RealtimeTarget(OpenAITarget, PromptTarget): ) ) - #: Narrower override of ``PromptTarget.streaming``. ``RealtimeTarget`` always sets - #: this in ``__init__``, so it is guaranteed non-None for downstream callers. - streaming: "_RealtimeStreamingHandle" + #: PCM sample rate in Hz negotiated by the OpenAI Realtime protocol. Single source + #: of truth for both atomic (send_text/send_audio) and streaming session paths. + SAMPLE_RATE_HZ: ClassVar[int] = 24000 def __init__( self, @@ -209,10 +140,6 @@ def __init__( else: self._server_vad = None - # Composition: streaming surface lives on a dedicated handle so the attack can - # type against the provider-agnostic ``StreamingHandle`` ABC. - self.streaming = _RealtimeStreamingHandle(target=self) - def open_streaming_session( self, *, @@ -238,7 +165,7 @@ def open_streaming_session( Args: audio_chunks: Async iterator yielding PCM16 mono bytes at the target's - ``streaming.SAMPLE_RATE_HZ`` rate. + ``SAMPLE_RATE_HZ`` rate. prompt_normalizer: Normalizer used to apply converters and persist messages. conversation_id: Conversation id for this session. Auto-generated when omitted. request_converter_configurations: Converters applied to each committed user turn @@ -414,13 +341,13 @@ def _set_system_prompt_and_config_vars( }, "format": { "type": "audio/pcm", - "rate": self.streaming.SAMPLE_RATE_HZ, + "rate": self.SAMPLE_RATE_HZ, }, }, "output": { "format": { "type": "audio/pcm", - "rate": self.streaming.SAMPLE_RATE_HZ, + "rate": self.SAMPLE_RATE_HZ, } }, }, @@ -511,7 +438,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me request = message.message_pieces[0] if conversation_id not in self._existing_conversation: - connection = await self.streaming.connect_async(conversation_id=conversation_id) + connection = await self._connect_async(conversation_id=conversation_id) self._existing_conversation[conversation_id] = connection # Only send config when creating a new connection @@ -575,6 +502,58 @@ async def cleanup_target(self) -> None: logger.warning(f"Error closing realtime client: {e}") self._realtime_client = None + async def _connect_async(self, *, conversation_id: str) -> Any: + """ + Open a fresh Realtime API websocket connection and return the connection handle. + + Args: + conversation_id: Conversation ID for logging/diagnostics; the connection + itself is not bound to a conversation server-side. + + Returns: + The Realtime API connection handle. + """ + logger.info(f"Connecting to Realtime API: {self._endpoint} (conversation_id={conversation_id})") + + client = self._get_openai_client() + connection = await client.realtime.connect(model=self._model_name).__aenter__() + + logger.info("Successfully connected to AzureOpenAI Realtime API") + return connection + + async def save_audio( + self, + audio_bytes: bytes, + num_channels: int = 1, + sample_width: int = 2, + sample_rate: int = 16000, + output_filename: Optional[str] = None, + ) -> str: + """ + Save audio bytes to a WAV file. + + Args: + audio_bytes (bytes): Audio bytes to save. + num_channels (int): Number of audio channels. Defaults to 1 for the PCM16 format + sample_width (int): Sample width in bytes. Defaults to 2 for the PCM16 format + sample_rate (int): Sample rate in Hz. Defaults to 16000 Hz for the PCM16 format + output_filename (str): Output filename. If None, a UUID filename will be used. + + Returns: + str: The path to the saved audio file. + """ + data = data_serializer_factory(category="prompt-memory-entries", data_type="audio_path") + + await data.save_formatted_audio( + data=audio_bytes, + output_filename=output_filename, + num_channels=num_channels, + sample_width=sample_width, + sample_rate=sample_rate, + ) + + return data.value + async def send_response_create(self, conversation_id: str) -> None: """ Send response.create using OpenAI client. @@ -844,7 +823,7 @@ async def send_text_async( raise RuntimeError("No audio received from the server.") # Azure GA uses 24000 Hz sample rate - output_audio_path = await self.streaming.save_audio(audio_bytes=result.audio_bytes, sample_rate=24000) + output_audio_path = await self.save_audio(audio_bytes=result.audio_bytes, sample_rate=24000) return output_audio_path, result async def send_audio_async( @@ -906,7 +885,7 @@ async def send_audio_async( if not result.audio_bytes: raise RuntimeError("No audio received from the server.") - output_audio_path = await self.streaming.save_audio(result.audio_bytes, num_channels, sample_width, frame_rate) + output_audio_path = await self.save_audio(result.audio_bytes, num_channels, sample_width, frame_rate) return output_audio_path, result async def _construct_message_from_response(self, response: Any, request: Any) -> Message: diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index a4d9b4fda0..015a0825a6 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -44,10 +44,9 @@ async def _gen(): def _build_target() -> MagicMock: - """Build a MagicMock target exposing the streaming + connection surface the session calls.""" + """Build a MagicMock target exposing the connection + audio surface the session calls.""" target = MagicMock(name="RealtimeTarget") - target.streaming = MagicMock(name="streaming") - target.streaming.SAMPLE_RATE_HZ = 24000 + target.SAMPLE_RATE_HZ = 24000 # MagicMock auto-creates attributes; pin _server_vad to None so the session's # ``_effective_vad`` capture defaults correctly when no per-session vad is passed. target._server_vad = None @@ -59,8 +58,8 @@ def _build_target() -> MagicMock: connection.input_audio_buffer = MagicMock() connection.input_audio_buffer.commit = AsyncMock(side_effect=_StubBadRequest("input_audio_buffer_commit_empty")) - target.streaming.connect_async = AsyncMock(return_value=connection) - target.streaming.save_audio = AsyncMock(side_effect=lambda pcm, **kw: f"/tmp/audio-{uuid.uuid4().hex[:8]}.wav") + target._connect_async = AsyncMock(return_value=connection) + target.save_audio = AsyncMock(side_effect=lambda pcm, **kw: f"/tmp/audio-{uuid.uuid4().hex[:8]}.wav") target.get_identifier = MagicMock( return_value={"__type__": "RealtimeTarget", "__module__": "test", "id": "test-id"} ) @@ -500,7 +499,7 @@ async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audi # start_ms = max(0, 500 - 100) = 400 → start_byte = 400 * 48 = 19200 # trimmed length = 28800 - 19200 = 9600 bytes (200ms) - raw_save_call = target.streaming.save_audio.await_args_list[0] + raw_save_call = target.save_audio.await_args_list[0] saved_user_pcm = raw_save_call.args[0] if raw_save_call.args else raw_save_call.kwargs.get("pcm") assert len(saved_user_pcm) == 9600 @@ -530,7 +529,7 @@ async def test_on_committed_skips_trim_when_audio_start_ms_missing(): events=[CommittedEvent(item_id="item-1", audio_start_ms=None)], ) - raw_save_call = target.streaming.save_audio.await_args_list[0] + raw_save_call = target.save_audio.await_args_list[0] saved_user_pcm = raw_save_call.args[0] if raw_save_call.args else raw_save_call.kwargs.get("pcm") assert len(saved_user_pcm) == buffer_ms * bytes_per_ms @@ -590,7 +589,7 @@ async def _fire() -> None: # save_audio call ordering per turn: raw_user, assistant. We requested no request converters # so converted_user_path == raw_user_path and only one user save_audio fires per turn. # Across two turns: [user_t1, assistant_t1, user_t2, assistant_t2]. - calls = target.streaming.save_audio.await_args_list + calls = target.save_audio.await_args_list assert len(calls) == 4 assert len(calls[0].args[0]) == 9600 assert len(calls[2].args[0]) == 14400 diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index afc4e20f1c..ba0e107caf 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -16,11 +16,9 @@ CommittedEvent, RealtimeTargetResult, RealtimeTurnState, - StreamingHandle, ) from pyrit.prompt_target.openai.openai_realtime_target import ( _OpenAIRealtimeDispatcher, - _RealtimeStreamingHandle, ) # Env vars that may leak from .env files loaded by other tests in parallel workers. @@ -42,7 +40,7 @@ async def test_connect_success(target): mock_client.realtime.connect.return_value.__aenter__ = AsyncMock(return_value=mock_connection) with patch.object(target, "_get_openai_client", return_value=mock_client): - connection = await target.streaming.connect_async(conversation_id="test_conv") + connection = await target._connect_async(conversation_id="test_conv") assert connection == mock_connection mock_client.realtime.connect.assert_called_once_with(model="test") await target.cleanup_target() @@ -50,7 +48,7 @@ async def test_connect_success(target): async def test_send_prompt_async(target): # Mock the necessary methods - target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) + target._connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"file", transcripts=["hello"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -85,7 +83,7 @@ async def test_send_prompt_async(target): async def test_send_prompt_async_propagates_interrupted_to_metadata(target): """When a turn result carries interrupted=True, both response pieces' metadata must reflect it.""" - target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) + target._connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() interrupted_result = RealtimeTargetResult(audio_bytes=b"partial", transcripts=["hi"], interrupted=True) target.send_text_async = AsyncMock(return_value=("partial.wav", interrupted_result)) @@ -111,7 +109,7 @@ async def test_send_prompt_async_propagates_interrupted_to_metadata(target): async def test_send_prompt_async_omits_interrupted_metadata_when_not_set(target): """A non-interrupted result must not write an interrupted key to MessagePiece metadata.""" - target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) + target._connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() normal_result = RealtimeTargetResult(audio_bytes=b"full", transcripts=["hi"]) target.send_text_async = AsyncMock(return_value=("full.wav", normal_result)) @@ -188,7 +186,7 @@ async def test_get_system_prompt_empty_conversation(target): async def test_multiple_websockets_created_for_multiple_conversations(target): # Mock the necessary methods - target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) + target._connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"event1", transcripts=["event2"]) target.send_text_async = AsyncMock(return_value=("output_audio_path", result)) @@ -411,7 +409,7 @@ async def test_multi_turn_reuses_connection(target): This ensures that the server-side conversation context is preserved. """ mock_connection = AsyncMock() - target.streaming.connect_async = AsyncMock(return_value=mock_connection) + target._connect_async = AsyncMock(return_value=mock_connection) target.send_config = AsyncMock() result = RealtimeTargetResult(audio_bytes=b"audio", transcripts=["response"]) target.send_text_async = AsyncMock(return_value=("output.wav", result)) @@ -441,7 +439,7 @@ async def test_multi_turn_reuses_connection(target): await target.send_prompt_async(message=Message(message_pieces=[message_piece_2])) # Connection should only be created once for the conversation - target.streaming.connect_async.assert_called_once_with(conversation_id=conversation_id) + target._connect_async.assert_called_once_with(conversation_id=conversation_id) target.send_config.assert_called_once() # Both turns should use the same connection @@ -851,38 +849,12 @@ async def on_committed(event: CommittedEvent) -> None: assert received[1].audio_start_ms is None -# ---- streaming handle wiring & config ----------------------------------------- +# ---- streaming wiring & config ------------------------------------------------ def test_sample_rate_hz_class_constant(): """SAMPLE_RATE_HZ is the single source of truth for the realtime PCM sample rate.""" - assert _RealtimeStreamingHandle.SAMPLE_RATE_HZ == 24000 - - -def test_realtime_target_wires_streaming_handle(target): - """RealtimeTarget.__init__ instantiates and attaches its streaming handle.""" - assert isinstance(target.streaming, _RealtimeStreamingHandle) - assert isinstance(target.streaming, StreamingHandle) - - -def test_realtime_streaming_handle_has_no_abstract_methods(): - """_RealtimeStreamingHandle implements every method on the StreamingHandle ABC.""" - assert _RealtimeStreamingHandle.__abstractmethods__ == frozenset() - - -def test_server_vad_config_returns_config_when_enabled(target): - """server_vad_config exposes the underlying ServerVadConfig when server VAD is enabled.""" - target._server_vad = ServerVadConfig(prefix_padding_ms=250, silence_duration_ms=400) - cfg = target.streaming.server_vad_config - assert cfg is not None - assert cfg.prefix_padding_ms == 250 - assert cfg.silence_duration_ms == 400 - - -def test_server_vad_config_returns_none_when_disabled(target): - """server_vad_config is None when server VAD is disabled.""" - target._server_vad = None - assert target.streaming.server_vad_config is None + assert RealtimeTarget.SAMPLE_RATE_HZ == 24000 # ---- send_prompt audio routing ------------------------------------------------- @@ -918,7 +890,7 @@ async def test_send_prompt_audio_path_calls_send_audio_async(target, tmp_path): ) message = Message(message_pieces=[piece]) - target.streaming.connect_async = AsyncMock(return_value=AsyncMock()) + target._connect_async = AsyncMock(return_value=AsyncMock()) target.send_config = AsyncMock() target.send_audio_async = AsyncMock( return_value=("/tmp/out.wav", RealtimeTargetResult(audio_bytes=b"", transcripts=["hi"])), From 3a3d5cc203513cd8f2b95cdfd2ff0d5882d92ca9 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 16:14:35 -0400 Subject: [PATCH 40/47] Make server_vad a streaming-only concept; drop from RealtimeTarget Server VAD only makes sense in streaming flow where chunks are continuously pushed and turn boundaries are server-detected. In the atomic path the caller controls turn boundaries explicitly, so a VAD config there was a footgun: `RealtimeTarget(server_vad=True).send_prompt_async(...)` silently emitted a turn_detection block. After this: - `RealtimeTarget` ctor no longer accepts `server_vad=`; the attribute is gone. - `_set_system_prompt_and_config_vars` keeps its `server_vad` kwarg (the session passes it explicitly) but drops the fallback. Atomic callers omit it and get no turn_detection block. - `open_streaming_session` (and the session ctor) rename `vad: ServerVadConfig | None = None` to `server_vad: bool | ServerVadConfig = True`. Default `True` matches the only non-degenerate streaming mode; pass a config for custom tuning, `False` to disable (sending streaming config then raises). Net -37 LOC. No external API changes (the ctor kwarg was added on this branch and never shipped on main). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_openai_realtime_streaming_session.py | 22 ++++++----- .../openai/openai_realtime_target.py | 37 ++++++------------- .../attack/streaming/test_barge_in.py | 11 +----- .../test_openai_realtime_streaming_session.py | 28 ++++++-------- .../target/test_realtime_target.py | 29 ++++----------- 5 files changed, 45 insertions(+), 82 deletions(-) diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 7f40260b7b..3bc81b4b16 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -15,6 +15,7 @@ from pyrit.models import Message, MessagePiece from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, RealtimeTurnState +from pyrit.prompt_target.common.streaming import ServerVadConfig from pyrit.prompt_target.common.streaming.streaming_audio_target import ( STREAMING_INTERRUPTED_KEY, ) @@ -31,7 +32,6 @@ from pyrit.identifiers import ComponentIdentifier from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer from pyrit.prompt_target.common.realtime_audio import CommittedEvent - from pyrit.prompt_target.common.streaming import ServerVadConfig from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget @@ -114,7 +114,7 @@ def __init__( request_converter_configurations: list[PromptConverterConfiguration] | None = None, response_converter_configurations: list[PromptConverterConfiguration] | None = None, prepended_conversation: list[Message] | None = None, - vad: ServerVadConfig | None = None, + server_vad: bool | ServerVadConfig = True, attack_identifier: ComponentIdentifier | None = None, persist_prepended_conversation: bool = True, ) -> None: @@ -125,14 +125,18 @@ def __init__( self._request_converter_configurations = request_converter_configurations or [] self._response_converter_configurations = response_converter_configurations or [] self._prepended_conversation = prepended_conversation or [] - self._vad = vad self._attack_identifier = attack_identifier self._persist_prepended_conversation = persist_prepended_conversation - # Resolve VAD once at session construction so config send and commit-time trim - # both see the same value, even if the target's ``_server_vad`` is mutated - # later. ``self._vad is None`` means "use target default", not "no VAD". - self._effective_vad: ServerVadConfig | None = self._vad if self._vad is not None else self._target._server_vad + # Normalize server_vad once at construction so config send and commit-time trim + # both see the same value. ``True`` uses default tuning; pass a ``ServerVadConfig`` + # for custom tuning, ``False`` to disable (sending streaming config then raises). + if isinstance(server_vad, ServerVadConfig): + self._effective_vad: ServerVadConfig | None = server_vad + elif server_vad: + self._effective_vad = ServerVadConfig() + else: + self._effective_vad = None # Tee raw user audio so we can persist it per VAD-committed turn; the dispatcher # only surfaces ``CommittedEvent`` with an item id, not the bytes themselves. @@ -419,13 +423,13 @@ async def _send_streaming_session_config_async(self) -> None: audio before triggering ``response.create``. Raises: - ValueError: If neither the session's ``vad`` nor the target's ``_server_vad`` is set. + ValueError: If server VAD is disabled for this session. """ assert self._connection is not None if self._effective_vad is None: raise ValueError( "_send_streaming_session_config_async requires server VAD; " - "pass vad=ServerVadConfig(...) or construct RealtimeTarget(server_vad=True)." + "pass server_vad=True or server_vad=ServerVadConfig(...) when opening the session." ) system_prompt = self._target._get_system_prompt_from_conversation(conversation=self._prepended_conversation) config = self._target._set_system_prompt_and_config_vars( diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 12ab4a458e..9edd510756 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -93,7 +93,6 @@ def __init__( voice: Optional[RealTimeVoice] = None, existing_convo: Optional[dict[str, Any]] = None, custom_configuration: Optional[TargetConfiguration] = None, - server_vad: bool | ServerVadConfig = False, **kwargs: Any, ) -> None: """ @@ -117,12 +116,6 @@ def __init__( existing_convo (dict[str, websockets.WebSocketClientProtocol], Optional): Existing conversations. custom_configuration (TargetConfiguration, Optional): Override the default configuration for this target instance. Defaults to None. - server_vad (bool | ServerVadConfig): Server-side voice activity detection (VAD). - ``False`` (default) keeps the existing atomic send/receive behavior. - ``True`` enables VAD with default tuning. - Pass a ``ServerVadConfig`` to enable with custom tuning. Streaming attacks - obtain a dedicated session via :meth:`open_streaming_session` and require - VAD to be enabled. **kwargs: Additional keyword arguments passed to the parent OpenAITarget class. httpx_client_kwargs (dict, Optional): Additional kwargs to be passed to the ``httpx.AsyncClient()`` constructor. For example, to specify a 3 minute timeout: ``httpx_client_kwargs={"timeout": 180}`` @@ -133,13 +126,6 @@ def __init__( self._existing_conversation = existing_convo if existing_convo is not None else {} self._realtime_client: Optional[AsyncOpenAI] = None - if isinstance(server_vad, ServerVadConfig): - self._server_vad: Optional[ServerVadConfig] = server_vad - elif server_vad: - self._server_vad = ServerVadConfig() - else: - self._server_vad = None - def open_streaming_session( self, *, @@ -149,7 +135,7 @@ def open_streaming_session( request_converter_configurations: "list[PromptConverterConfiguration] | None" = None, response_converter_configurations: "list[PromptConverterConfiguration] | None" = None, prepended_conversation: list[Message] | None = None, - vad: ServerVadConfig | None = None, + server_vad: bool | ServerVadConfig = True, attack_identifier: "ComponentIdentifier | None" = None, persist_prepended_conversation: bool = True, ) -> "_OpenAIRealtimeStreamingSession": @@ -174,8 +160,9 @@ def open_streaming_session( before persistence. prepended_conversation: Optional conversation history. The leading system message becomes session instructions. - vad: Optional per-call VAD tuning. When ``None``, falls back to the target's - constructor-set ``server_vad``. + server_vad: Server-side voice activity detection. ``True`` (default) enables + VAD with default tuning. Pass a ``ServerVadConfig`` for custom tuning, or + ``False`` to disable (sending streaming config will then raise). attack_identifier: Stamped on every persisted user / assistant piece for attribution. Pass the caller's identifier so live messages share the provenance contract of prepended messages. @@ -198,7 +185,7 @@ def open_streaming_session( request_converter_configurations=request_converter_configurations, response_converter_configurations=response_converter_configurations, prepended_conversation=prepended_conversation, - vad=vad, + server_vad=server_vad, attack_identifier=attack_identifier, persist_prepended_conversation=persist_prepended_conversation, ) @@ -323,13 +310,13 @@ def _set_system_prompt_and_config_vars( Args: system_prompt: The system prompt to use in the session configuration. - server_vad: Optional VAD override. When None, falls back to the target's - constructor-set ``self._server_vad``. + server_vad: When provided, emits a ``turn_detection`` block tuned by this + config. The atomic path always omits it (server VAD is a streaming-only + concept); the streaming session passes its resolved VAD here. Returns: dict: Session configuration dictionary. """ - effective_vad = server_vad if server_vad is not None else self._server_vad session_config = { "type": "realtime", "instructions": system_prompt, @@ -353,12 +340,12 @@ def _set_system_prompt_and_config_vars( }, } - if effective_vad is not None: + if server_vad is not None: session_config["audio"]["input"]["turn_detection"] = { # type: ignore[ty:invalid-assignment] "type": "server_vad", - "threshold": effective_vad.threshold, - "prefix_padding_ms": effective_vad.prefix_padding_ms, - "silence_duration_ms": effective_vad.silence_duration_ms, + "threshold": server_vad.threshold, + "prefix_padding_ms": server_vad.prefix_padding_ms, + "silence_duration_ms": server_vad.silence_duration_ms, "create_response": True, "interrupt_response": True, } diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index a37d6101ab..681aef94ba 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -24,7 +24,7 @@ @pytest.fixture @patch.dict("os.environ", _CLEAN_ENV) def vad_target(sqlite_instance): - return RealtimeTarget(api_key="test_key", endpoint="wss://test_url", model_name="test", server_vad=True) + return RealtimeTarget(api_key="test_key", endpoint="wss://test_url", model_name="test") async def _aiter(chunks: list[bytes]) -> AsyncIterator[bytes]: @@ -69,15 +69,6 @@ def test_constructor_succeeds_with_vad_target(vad_target): assert attack.get_objective_target() is vad_target -def test_constructor_succeeds_even_without_server_vad_enabled(sqlite_instance): - """Capability check passes; server VAD is a runtime config concern surfaced when used.""" - with patch.dict("os.environ", _CLEAN_ENV): - no_vad = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") - # Construction succeeds — capability is about the target type, not server_vad config. - attack = BargeInAttack(objective_target=no_vad) - assert attack.get_objective_target() is no_vad - - # ---- Context validation ---------------------------------------------------------------------- diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 015a0825a6..99913d82e2 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -47,9 +47,6 @@ def _build_target() -> MagicMock: """Build a MagicMock target exposing the connection + audio surface the session calls.""" target = MagicMock(name="RealtimeTarget") target.SAMPLE_RATE_HZ = 24000 - # MagicMock auto-creates attributes; pin _server_vad to None so the session's - # ``_effective_vad`` capture defaults correctly when no per-session vad is passed. - target._server_vad = None connection = AsyncMock(name="connection") # AsyncMock auto-creates attributes as AsyncMock, but child attribute chains like @@ -400,7 +397,7 @@ async def _empty(): audio_chunks=_empty(), prompt_normalizer=normalizer, prepended_conversation=prepended, - vad=vad, + server_vad=vad, conversation_id="conv-prep", ) _mock_session_wire(session) @@ -486,7 +483,7 @@ async def test_on_committed_trims_pre_speech_silence_before_persisting_user_audi target=target, audio_chunks=_paced_chunks([chunk], finish), prompt_normalizer=normalizer, - vad=ServerVadConfig(prefix_padding_ms=100), + server_vad=ServerVadConfig(prefix_padding_ms=100), ) _mock_session_wire(session) @@ -518,7 +515,7 @@ async def test_on_committed_skips_trim_when_audio_start_ms_missing(): target=target, audio_chunks=_paced_chunks([chunk], finish), prompt_normalizer=normalizer, - vad=ServerVadConfig(prefix_padding_ms=100), + server_vad=ServerVadConfig(prefix_padding_ms=100), ) _mock_session_wire(session) @@ -565,7 +562,7 @@ async def _gated_chunks(): target=target, audio_chunks=_gated_chunks(), prompt_normalizer=normalizer, - vad=ServerVadConfig(prefix_padding_ms=100), + server_vad=ServerVadConfig(prefix_padding_ms=100), ) _mock_session_wire(session) @@ -711,7 +708,7 @@ def test_open_streaming_session_forwards_kwargs_to_session_constructor(sqlite_in """``RealtimeTarget.open_streaming_session`` is a thin pass-through to the session ctor.""" from pyrit.prompt_target import RealtimeTarget - target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test", server_vad=True) + target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") normalizer = _build_normalizer() async def _empty(): @@ -742,7 +739,7 @@ def _fake_session_ctor(**kwargs): request_converter_configurations=req_cfgs, response_converter_configurations=resp_cfgs, prepended_conversation=prepended, - vad=vad, + server_vad=vad, attack_identifier=attack_id, persist_prepended_conversation=False, ) @@ -754,7 +751,7 @@ def _fake_session_ctor(**kwargs): assert captured["request_converter_configurations"] is req_cfgs assert captured["response_converter_configurations"] is resp_cfgs assert captured["prepended_conversation"] is prepended - assert captured["vad"] is vad + assert captured["server_vad"] is vad assert captured["attack_identifier"] is attack_id assert captured["persist_prepended_conversation"] is False @@ -841,8 +838,7 @@ def test_trim_aligns_to_sample_frame_boundary(): def _real_session_with_mock_connection( sqlite_instance, *, - server_vad: bool = True, - vad: ServerVadConfig | None = None, + server_vad: bool | ServerVadConfig = True, prepended_conversation=None, ): """Build a real ``_OpenAIRealtimeStreamingSession`` over a real ``RealtimeTarget`` with a mock connection. @@ -856,7 +852,7 @@ def _real_session_with_mock_connection( from pyrit.prompt_target import RealtimeTarget with patch.dict("os.environ", _CLEAN_ENV): - target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test", server_vad=server_vad) + target = RealtimeTarget(api_key="k", endpoint="wss://test_url", model_name="test") async def _empty(): if False: @@ -867,7 +863,7 @@ async def _empty(): audio_chunks=_empty(), prompt_normalizer=MagicMock(name="normalizer"), prepended_conversation=prepended_conversation, - vad=vad, + server_vad=server_vad, ) session._connection = AsyncMock(name="connection") return session @@ -994,8 +990,8 @@ async def test_send_streaming_session_config_async_emits_create_response_false(s async def test_send_streaming_session_config_async_requires_server_vad(sqlite_instance): - """Without server VAD on target or per-session vad, sending streaming session config must raise.""" - session = _real_session_with_mock_connection(sqlite_instance, server_vad=False, vad=None) + """Without server VAD on the session, sending streaming session config must raise.""" + session = _real_session_with_mock_connection(sqlite_instance, server_vad=False) with pytest.raises(ValueError, match="server VAD"): await session._send_streaming_session_config_async() diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index ba0e107caf..b38ea429ce 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -508,17 +508,9 @@ def test_session_config_omits_turn_detection_when_vad_disabled(target): assert config["instructions"] == "test prompt" -@patch.dict("os.environ", _CLEAN_UNDERLYING_MODEL_ENV) -def test_session_config_emits_server_vad_block_with_defaults(sqlite_instance): - """server_vad=True must emit defaults.""" - vad_target = RealtimeTarget( - api_key="test_key", - endpoint="wss://test_url", - model_name="test", - server_vad=True, - ) - - config = vad_target._set_system_prompt_and_config_vars(system_prompt="test prompt") +def test_session_config_emits_server_vad_block_with_defaults(target): + """Passing ``server_vad=ServerVadConfig()`` must emit the default tuning.""" + config = target._set_system_prompt_and_config_vars(system_prompt="test prompt", server_vad=ServerVadConfig()) turn_detection = config["audio"]["input"]["turn_detection"] assert turn_detection == { @@ -531,19 +523,12 @@ def test_session_config_emits_server_vad_block_with_defaults(sqlite_instance): } -@patch.dict("os.environ", _CLEAN_UNDERLYING_MODEL_ENV) -def test_session_config_honors_custom_vad_tuning(sqlite_instance): +def test_session_config_honors_custom_vad_tuning(target): """Passing a ServerVadConfig must flow through to the emitted turn_detection block.""" - vad_target = RealtimeTarget( - api_key="test_key", - endpoint="wss://test_url", - model_name="test", + turn_detection = target._set_system_prompt_and_config_vars( + system_prompt="x", server_vad=ServerVadConfig(threshold=0.7, prefix_padding_ms=350, silence_duration_ms=800), - ) - - turn_detection = vad_target._set_system_prompt_and_config_vars(system_prompt="x")["audio"]["input"][ - "turn_detection" - ] + )["audio"]["input"]["turn_detection"] assert turn_detection["threshold"] == 0.7 assert turn_detection["prefix_padding_ms"] == 350 From bff23a0d6fa1009a13b9f1ecf32fe0e020c02d09 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 16:20:30 -0400 Subject: [PATCH 41/47] Collapse common/streaming package into realtime_audio The common/streaming package was a speculative provider-agnostic split that never materialized: - StreamingAudioTarget ABC had zero implementors, zero isinstance checks, and zero callers of its abstractmethod send_streaming_prompt_async. The live contract is target.open_streaming_session(...) -> session, not the ABC. Its signature was also stale (kept the old vad= kwarg name after Phase 5d's rename to server_vad=). - ServerVadConfig and STREAMING_INTERRUPTED_KEY are realtime-audio- specific in practice. The artificial package split forced a re-export indirection in realtime_audio.py (`ServerVadConfig as ServerVadConfig`). Move the two live symbols into realtime_audio.py next to RealtimeTargetResult and friends, drop the dead ABC, delete the package, and remove the re-export kludge. 4 import sites updated; public `ServerVadConfig` export in `pyrit.prompt_target` preserved. Net: -90 LOC, -1 directory, -1 module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/__init__.py | 6 +- pyrit/prompt_target/common/realtime_audio.py | 32 ++++++- .../common/streaming/__init__.py | 16 ---- .../streaming/streaming_audio_target.py | 96 ------------------- .../_openai_realtime_streaming_session.py | 7 +- .../test_openai_realtime_streaming_session.py | 7 +- 6 files changed, 37 insertions(+), 127 deletions(-) delete mode 100644 pyrit/prompt_target/common/streaming/__init__.py delete mode 100644 pyrit/prompt_target/common/streaming/streaming_audio_target.py diff --git a/pyrit/prompt_target/__init__.py b/pyrit/prompt_target/__init__.py index aedea888f9..b0d42c9a76 100644 --- a/pyrit/prompt_target/__init__.py +++ b/pyrit/prompt_target/__init__.py @@ -19,10 +19,7 @@ ) from pyrit.prompt_target.common.prompt_chat_target import PromptChatTarget from pyrit.prompt_target.common.prompt_target import PromptTarget -from pyrit.prompt_target.common.streaming import ( - ServerVadConfig, - StreamingAudioTarget, -) +from pyrit.prompt_target.common.realtime_audio import ServerVadConfig from pyrit.prompt_target.common.target_capabilities import ( CapabilityHandlingPolicy, CapabilityName, @@ -107,7 +104,6 @@ def __getattr__(name: str) -> object: "PromptTarget", "RealtimeTarget", "ServerVadConfig", - "StreamingAudioTarget", "RoundRobinTarget", "TargetCapabilities", "TargetConfiguration", diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 995a63479e..9b1496d585 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -11,12 +11,36 @@ from dataclasses import dataclass, field from typing import Any -from pyrit.prompt_target.common.streaming.streaming_audio_target import ( - ServerVadConfig as ServerVadConfig, # noqa: TC001 -) - logger = logging.getLogger(__name__) +#: Key set in ``MessagePiece.prompt_metadata`` by streaming targets to mark turns that +#: were interrupted by barge-in. Attacks consume this to count interrupted turns +#: without reaching into target internals. Value type is ``bool``. +STREAMING_INTERRUPTED_KEY = "interrupted" + + +@dataclass(frozen=True) +class ServerVadConfig: + """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" + + threshold: float = 0.4 + prefix_padding_ms: int = 200 + silence_duration_ms: int = 1500 + + def __post_init__(self) -> None: + """ + Validate VAD tuning values. + + Raises: + ValueError: If any field is outside its valid range. + """ + if not 0.0 <= self.threshold <= 1.0: + raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") + if self.prefix_padding_ms < 0: + raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") + if self.silence_duration_ms < 0: + raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") + @dataclass class RealtimeTargetResult: diff --git a/pyrit/prompt_target/common/streaming/__init__.py b/pyrit/prompt_target/common/streaming/__init__.py deleted file mode 100644 index d414905227..0000000000 --- a/pyrit/prompt_target/common/streaming/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""Streaming capability ABCs and shared types for realtime prompt targets.""" - -from pyrit.prompt_target.common.streaming.streaming_audio_target import ( - STREAMING_INTERRUPTED_KEY, - ServerVadConfig, - StreamingAudioTarget, -) - -__all__ = [ - "STREAMING_INTERRUPTED_KEY", - "ServerVadConfig", - "StreamingAudioTarget", -] diff --git a/pyrit/prompt_target/common/streaming/streaming_audio_target.py b/pyrit/prompt_target/common/streaming/streaming_audio_target.py deleted file mode 100644 index 13d77b06d1..0000000000 --- a/pyrit/prompt_target/common/streaming/streaming_audio_target.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT license. - -"""StreamingAudioTarget capability ABC and shared types for realtime audio prompt targets.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from collections.abc import AsyncIterator - - from pyrit.models import Message - from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer - -#: Key set in ``MessagePiece.prompt_metadata`` by streaming targets to mark turns that -#: were interrupted by barge-in. Attacks consume this to count interrupted turns -#: without reaching into target internals. Value type is ``bool``. -STREAMING_INTERRUPTED_KEY = "interrupted" - - -@dataclass(frozen=True) -class ServerVadConfig: - """Server-side voice activity detection (VAD) tuning for realtime audio targets.""" - - threshold: float = 0.4 - prefix_padding_ms: int = 200 - silence_duration_ms: int = 1500 - - def __post_init__(self) -> None: - """ - Validate VAD tuning values. - - Raises: - ValueError: If any field is outside its valid range. - """ - if not 0.0 <= self.threshold <= 1.0: - raise ValueError(f"threshold must be in [0.0, 1.0], got {self.threshold}") - if self.prefix_padding_ms < 0: - raise ValueError(f"prefix_padding_ms must be non-negative, got {self.prefix_padding_ms}") - if self.silence_duration_ms < 0: - raise ValueError(f"silence_duration_ms must be non-negative, got {self.silence_duration_ms}") - - -class StreamingAudioTarget(ABC): - """Capability interface for realtime audio targets with server-VAD barge-in support.""" - - @abstractmethod - def send_streaming_prompt_async( - self, - *, - audio_chunks: AsyncIterator[bytes], - prompt_normalizer: PromptNormalizer, - conversation_id: str | None = None, - request_converter_configurations: list[PromptConverterConfiguration] | None = None, - response_converter_configurations: list[PromptConverterConfiguration] | None = None, - prepended_conversation: list[Message] | None = None, - vad: ServerVadConfig | None = None, - ) -> AsyncIterator[Message]: - """ - Stream user audio; yield one ``Message`` per server-VAD-committed turn. - - Implementations must: - - - Apply request converters to each committed audio buffer via - ``prompt_normalizer.convert_audio_async``. - - Apply response converters and persist each yielded ``Message`` via - ``CentralMemory``. - - Set ``MessagePiece.prompt_metadata[STREAMING_INTERRUPTED_KEY] = True`` on - turns that were interrupted by barge-in. - - Use ``prepended_conversation`` for session-level instructions (e.g. - system prompt). - - Args: - audio_chunks (AsyncIterator[bytes]): Raw PCM audio chunks from the caller. - prompt_normalizer (PromptNormalizer): Normalizer used to apply converters - and persist messages. - conversation_id (str | None): Conversation identifier; one is - auto-generated if not provided. - request_converter_configurations (list[PromptConverterConfiguration] | None): - Converters applied to each committed user turn. - response_converter_configurations (list[PromptConverterConfiguration] | None): - Converters applied to each assistant response. - prepended_conversation (list[Message] | None): Session-level conversation - context. In the current contract, only a leading system-prompt - ``Message`` is honored at the live session; prior turns are added to - memory but not replayed to the server. - vad (ServerVadConfig | None): Server-side VAD tuning. ``None`` uses - target defaults. - - Yields: - Message: One ``Message`` per VAD-committed user turn, persisted to - ``CentralMemory``. - """ diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 3bc81b4b16..f5b5326832 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -14,10 +14,11 @@ from typing import TYPE_CHECKING, Any from pyrit.models import Message, MessagePiece -from pyrit.prompt_target.common.realtime_audio import RealtimeTargetResult, RealtimeTurnState -from pyrit.prompt_target.common.streaming import ServerVadConfig -from pyrit.prompt_target.common.streaming.streaming_audio_target import ( +from pyrit.prompt_target.common.realtime_audio import ( STREAMING_INTERRUPTED_KEY, + RealtimeTargetResult, + RealtimeTurnState, + ServerVadConfig, ) from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 99913d82e2..12f07b7a12 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -13,10 +13,11 @@ import pytest from pyrit.models import Message, MessagePiece -from pyrit.prompt_target.common.realtime_audio import CommittedEvent, RealtimeTargetResult -from pyrit.prompt_target.common.streaming import ServerVadConfig -from pyrit.prompt_target.common.streaming.streaming_audio_target import ( +from pyrit.prompt_target.common.realtime_audio import ( STREAMING_INTERRUPTED_KEY, + CommittedEvent, + RealtimeTargetResult, + ServerVadConfig, ) from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( _OpenAIRealtimeStreamingSession, From b050ad9ff6d6f36a1fd633e0ddf8314243a90499 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 22:01:05 -0400 Subject: [PATCH 42/47] Polish realtime streaming: async suffixes, capability rename, docstring cleanup Rename the streaming capability to supports_streaming_audio / STREAMING_AUDIO (was barge-in-specific). Drop the redundant isinstance gate in BargeInAttack in favor of the capability requirement, using cast for the concrete dependency. Add async suffixes to realtime dispatcher/session methods and update callers. Replace forbidden reST roles with backticks and trim deferred-work docstring notes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../executor/attack/barge_in_attack.ipynb | 162 ++++++++++++------ doc/code/executor/attack/barge_in_attack.py | 8 +- pyrit/executor/attack/streaming/barge_in.py | 31 ++-- .../common/discover_target_capabilities.py | 2 +- pyrit/prompt_target/common/realtime_audio.py | 24 +-- .../common/target_capabilities.py | 6 +- .../_openai_realtime_streaming_session.py | 62 ++++--- .../openai/openai_realtime_target.py | 25 ++- .../attack/streaming/test_barge_in.py | 6 +- .../test_openai_realtime_streaming_session.py | 63 ++++++- .../target/test_realtime_audio.py | 48 +++--- .../target/test_realtime_target.py | 79 ++++++--- 12 files changed, 337 insertions(+), 179 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index 7aad2c7b5e..c7c9d09222 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -149,42 +149,72 @@ "id": "6", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Forced final commit was accepted but no committed event observed within 5s; the final user turn may have been dropped by the server.\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ "executed_turns: 1\n", "\n", + "\u001b[33m════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════\u001b[0m\n", + "\u001b[1m\u001b[33m ❓ ATTACK RESULT: UNDETERMINED ❓ \u001b[0m\n", + "\u001b[33m════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════\u001b[0m\n", + "\n", + "\u001b[1m\u001b[44m\u001b[37m Attack Summary \u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m 📋 Basic Information\u001b[0m\n", + "\u001b[36m • Objective: Observe a single converted user turn end-to-end\u001b[0m\n", + "\u001b[36m • Attack Type: BargeInAttack\u001b[0m\n", + "\u001b[36m • Conversation ID: 8db2d8a4-9d1a-4e8b-8858-2fc90581951a\u001b[0m\n", + "\n", + "\u001b[1m ⚡ Execution Metrics\u001b[0m\n", + "\u001b[32m • Turns Executed: 1\u001b[0m\n", + "\u001b[32m • Execution Time: 29.58s\u001b[0m\n", + "\n", + "\u001b[1m 🎯 Outcome\u001b[0m\n", + "\u001b[33m • Status: ❓ UNDETERMINED\u001b[0m\n", + "\u001b[37m • Reason: 1 assistant turn(s) completed; no scorer configured\u001b[0m\n", + "\n", + "\u001b[1m\u001b[44m\u001b[37m Conversation History with Objective Target \u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[36m Original:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779982976939175.mp3\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442479881292.mp3\u001b[0m\n", "\n", "\u001b[36m Converted:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779982976976521.wav\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442479883289.mp3\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Sure! Photosynthesis is the process plants use to convert light energy into chemical energy. In their leaves, cells contain chloroplasts, which house the pigment chlorophyll. Chlorophyll absorbs\u001b[0m\n", - "\u001b[33m sunlight, primarily in the blue and red wavelengths, and uses that energy to drive reactions.\u001b[0m\n", + "\u001b[33m Sure, I can explain that. Photosynthesis is the process by which green plants, algae, and some bacteria convert light energy into chemical energy stored in sugars. Let’s break it down:\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m In the presence of sunlight, plants take in carbon dioxide from the air through tiny pores called stomata, and water from the soil via their roots. Inside the chloroplasts, these inputs undergo a\u001b[0m\n", - "\u001b[33m series of chemical reactions known as the light-dependent and light-independent (or Calvin) cycles. The light-dependent reactions generate energy carriers (ATP and NADPH) and split water\u001b[0m\n", - "\u001b[33m molecules, releasing oxygen as a byproduct. The Calvin cycle then uses those energy carriers to convert carbon dioxide into glucose, a carbohydrate that the plant can use for energy and growth. In\u001b[0m\n", - "\u001b[33m essence, photosynthesis produces food for the plant and releases oxygen into the atmosphere, which is essential for life on Earth.\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983011877612.mp3\u001b[0m\n", + "\u001b[33m 1. Light absorption: Inside plant cells are chloroplasts, which contain chlorophyll, a pigment that absorbs light, primarily in the blue and red wavelengths.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 2. Water splitting: The absorbed light energy drives a reaction that splits water molecules (H₂O) into oxygen, protons, and electrons. The oxygen is released into the atmosphere.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 3. Energy carriers: The light-driven reactions also produce energy-rich molecules, ATP and NADPH, which act as energy carriers.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m 4. Carbon fixation: In the Calvin cycle, the plant uses ATP and NADPH to convert carbon dioxide (CO₂) from the air into glucose, a type of sugar.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m Overall, the simplified equation is: 6 CO₂ + 6 H₂O + light energy → C₆H₁₂O₆ (glucose) + 6 O₂.\u001b[0m\n", + "\u001b[33m \u001b[0m\n", + "\u001b[33m This process fuels plant growth and provides oxygen and food for other organisms.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442479885291.mp3\u001b[0m\n", "\n", - "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "./AppData/Local/Temp/ipykernel_35248/3556598072.py:23: DeprecationWarning: print_conversation_async is deprecated and will be removed in 2.0. Use write_async instead.\n", - " await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore\n" + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\n", + "\u001b[2m\u001b[37m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[2m\u001b[37m Report generated at: 2026-06-02 23:21:20 UTC \u001b[0m\n" ] } ], @@ -198,7 +228,7 @@ " await asyncio.sleep(CHUNK_MS / 1000)\n", "\n", "\n", - "target = RealtimeTarget(server_vad=True)\n", + "target = RealtimeTarget()\n", "attack = BargeInAttack(\n", " objective_target=target,\n", " attack_converter_config=AttackConverterConfig(request_converters=converters),\n", @@ -211,7 +241,7 @@ "\n", "result = await attack.execute_with_context_async(context=context) # type: ignore\n", "print(f\"executed_turns: {result.executed_turns}\")\n", - "await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore\n", + "await ConsoleAttackResultPrinter(width=200).write_async(result=result) # type: ignore\n", "await target.cleanup_target() # type: ignore" ] }, @@ -233,6 +263,13 @@ "id": "8", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "Forced final commit was accepted but no committed event observed within 5s; the final user turn may have been dropped by the server.\n" + ] + }, { "name": "stdout", "output_type": "stream", @@ -240,64 +277,85 @@ "executed_turns: 2\n", "\n", "Persisted pieces (4 messages):\n", - " user audio_path: 1779983020688882.wav\n", - " assistant text [INTERRUPTED]: Sure! Plants use photosynthesis to convert light energy into chemical energy. In...\n", - " assistant audio_path [INTERRUPTED]: 1779983022483318.mp3\n", - " user audio_path: 1779983027936167.wav\n", - " assistant text: Absolutely. Photosynthesis is the process where plants, algae, and some bacteria...\n", - " assistant audio_path: 1779983042984248.mp3\n", + " user audio_path: 1780442490778226.mp3\n", + " assistant text [INTERRUPTED]: Sure! In photosynthesis, plants use sunlight, carbon dioxide, and water to produ...\n", + " assistant audio_path [INTERRUPTED]: 1780442490781220.mp3\n", + " user audio_path: 1780442513320369.mp3\n", + " assistant text: Absolutely. Let me walk you through it step by step.\n", + "\n", + "1. Light absorption: Insid...\n", + " assistant audio_path: 1780442513322117.mp3\n", + "\n", + "\u001b[33m════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════\u001b[0m\n", + "\u001b[1m\u001b[33m ❓ ATTACK RESULT: UNDETERMINED ❓ \u001b[0m\n", + "\u001b[33m════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════\u001b[0m\n", + "\n", + "\u001b[1m\u001b[44m\u001b[37m Attack Summary \u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[1m 📋 Basic Information\u001b[0m\n", + "\u001b[36m • Objective: Demonstrate barge-in by interrupting a benign answer\u001b[0m\n", + "\u001b[36m • Attack Type: BargeInAttack\u001b[0m\n", + "\u001b[36m • Conversation ID: 31e147b8-57ee-4525-bbc6-6b728e6fb75d\u001b[0m\n", + "\n", + "\u001b[1m ⚡ Execution Metrics\u001b[0m\n", + "\u001b[32m • Turns Executed: 2\u001b[0m\n", + "\u001b[32m • Execution Time: 33.36s\u001b[0m\n", + "\n", + "\u001b[1m 🎯 Outcome\u001b[0m\n", + "\u001b[33m • Status: ❓ UNDETERMINED\u001b[0m\n", + "\u001b[37m • Reason: 2 assistant turn(s) completed; no scorer configured\u001b[0m\n", + "\n", + "\u001b[1m\u001b[44m\u001b[37m Conversation History with Objective Target \u001b[0m\n", + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 1 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[36m Original:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983020666036.mp3\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442490776202.mp3\u001b[0m\n", "\n", "\u001b[36m Converted:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983020688882.wav\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442490778226.mp3\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Sure! Plants use photosynthesis to convert light energy into chemical energy. Inside their leaves are cells with chloroplasts\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983022483318.mp3\u001b[0m\n", + "\u001b[33m Sure! In photosynthesis, plants use sunlight, carbon dioxide, and water to produce sugars for energy and oxygen as a byproduct.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442490781220.mp3\u001b[0m\n", "\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[34m🔹 Turn 2 - USER\u001b[0m\n", "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[36m Original:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983027912417.mp3\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442513317368.mp3\u001b[0m\n", "\n", "\u001b[36m Converted:\u001b[0m\n", - "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983027936167.wav\u001b[0m\n", + "\u001b[37m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442513320369.mp3\u001b[0m\n", "\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", "\u001b[1m\u001b[33m🔸 ASSISTANT\u001b[0m\n", "\u001b[33m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", - "\u001b[33m Absolutely. Photosynthesis is the process where plants, algae, and some bacteria transform light energy into chemical energy stored as sugars. Here’s how it works in plants:\u001b[0m\n", + "\u001b[33m Absolutely. Let me walk you through it step by step.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 1. Light absorption: Chlorophyll, the green pigment in chloroplasts, captures sunlight, especially in the red and blue wavelengths.\u001b[0m\n", + "\u001b[33m 1. Light absorption: Inside leaf cells, there are organelles called chloroplasts that contain green pigments known as chlorophyll. These pigments capture light energy, primarily from the blue and\u001b[0m\n", + "\u001b[33m red wavelengths of sunlight.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 2. Water splitting: The absorbed light energy is used to split water molecules (H₂O) taken up from the roots into oxygen, protons, and electrons. Oxygen is released into the air through the leaves.\u001b[0m\n", + "\u001b[33m 2. Water splitting (the light-dependent reactions): The absorbed light energy excites electrons in the chlorophyll, and this energy is used to split water molecules (H₂O) into oxygen, protons, and\u001b[0m\n", + "\u001b[33m electrons. The oxygen is released into the air, while the electrons and protons help generate energy-rich molecules (ATP and NADPH).\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 3. Energy carriers: The electrons and protons move through a series of reactions called the light-dependent reactions. They generate two key energy carriers: ATP and NADPH.\u001b[0m\n", + "\u001b[33m 3. Carbon fixation (the Calvin cycle): In a separate cycle that doesn’t require light directly, the plant uses the ATP and NADPH to convert carbon dioxide from the air into glucose, a sugar. This\u001b[0m\n", + "\u001b[33m process is catalyzed by enzymes, notably Rubisco, and involves a series of chemical reactions that produce glucose and regenerate the molecules needed to keep the cycle going.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 4. Carbon fixation: In a cycle of reactions called the Calvin cycle, the plant uses ATP and NADPH to convert carbon dioxide (CO₂) from the air into glucose, a sugar that stores energy.\u001b[0m\n", + "\u001b[33m 4. Utilization: The glucose produced can be used immediately for energy, stored as starch, or used to build other important molecules like cellulose. The oxygen released diffuses out of the plant\u001b[0m\n", + "\u001b[33m and into the atmosphere, which is crucial for life on Earth.\u001b[0m\n", "\u001b[33m \u001b[0m\n", - "\u001b[33m 5. Energy storage: Glucose can be used immediately for energy, converted into other carbohydrates like starch for storage, or used to build other plant structures.\u001b[0m\n", - "\u001b[33m \u001b[0m\n", - "\u001b[33m This process keeps plants alive and provides oxygen and food for much of life on Earth. Is there any part you’d like me to dive into more?\u001b[0m\n", - "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1779983042984248.mp3\u001b[0m\n", + "\u001b[33m Overall, photosynthesis is how plants convert solar energy into chemical energy, supporting not just their own growth but much of the life on our planet.\u001b[0m\n", + "\u001b[33m ./repos/PyRIT-internal/PyRIT/dbdata/prompt-memory-entries/audio/1780442513322117.mp3\u001b[0m\n", "\n", - "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "./AppData/Local/Temp/ipykernel_35248/3722491799.py:54: DeprecationWarning: print_conversation_async is deprecated and will be removed in 2.0. Use write_async instead.\n", - " await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore\n" + "\u001b[34m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\n", + "\u001b[2m\u001b[37m────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[0m\n", + "\u001b[2m\u001b[37m Report generated at: 2026-06-02 23:21:54 UTC \u001b[0m\n" ] } ], @@ -327,7 +385,7 @@ " await asyncio.sleep(CHUNK_MS / 1000)\n", "\n", "\n", - "target2 = RealtimeTarget(server_vad=True)\n", + "target2 = RealtimeTarget()\n", "attack2 = BargeInAttack(\n", " objective_target=target2,\n", " attack_converter_config=AttackConverterConfig(request_converters=converters),\n", @@ -355,7 +413,7 @@ " value_preview = (val[:80] + \"...\") if len(val) > 80 else val\n", " print(f\" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}\")\n", "\n", - "await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore\n", + "await ConsoleAttackResultPrinter(width=200).write_async(result=barge_in_result) # type: ignore\n", "await target2.cleanup_target() # type: ignore" ] }, diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py index b3dff07b60..9479e8fb42 100644 --- a/doc/code/executor/attack/barge_in_attack.py +++ b/doc/code/executor/attack/barge_in_attack.py @@ -102,7 +102,7 @@ async def single_turn_source(): await asyncio.sleep(CHUNK_MS / 1000) -target = RealtimeTarget(server_vad=True) +target = RealtimeTarget() attack = BargeInAttack( objective_target=target, attack_converter_config=AttackConverterConfig(request_converters=converters), @@ -115,7 +115,7 @@ async def single_turn_source(): result = await attack.execute_with_context_async(context=context) # type: ignore print(f"executed_turns: {result.executed_turns}") -await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=result) # type: ignore +await ConsoleAttackResultPrinter(width=200).write_async(result=result) # type: ignore await target.cleanup_target() # type: ignore # %% [markdown] @@ -151,7 +151,7 @@ async def barge_in_source(): await asyncio.sleep(CHUNK_MS / 1000) -target2 = RealtimeTarget(server_vad=True) +target2 = RealtimeTarget() attack2 = BargeInAttack( objective_target=target2, attack_converter_config=AttackConverterConfig(request_converters=converters), @@ -179,7 +179,7 @@ async def barge_in_source(): value_preview = (val[:80] + "...") if len(val) > 80 else val print(f" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}") -await ConsoleAttackResultPrinter(width=200).print_conversation_async(result=barge_in_result) # type: ignore +await ConsoleAttackResultPrinter(width=200).write_async(result=barge_in_result) # type: ignore await target2.cleanup_target() # type: ignore # %% [markdown] diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index fd26149fe2..ada41290db 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -8,7 +8,7 @@ import logging import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, cast from pyrit.common.apply_defaults import REQUIRED_VALUE, apply_defaults from pyrit.executor.attack.component.conversation_manager import ConversationManager @@ -22,14 +22,13 @@ Message, ) from pyrit.prompt_normalizer import PromptNormalizer -from pyrit.prompt_target import RealtimeTarget from pyrit.prompt_target.common.target_capabilities import CapabilityName from pyrit.prompt_target.common.target_requirements import TargetRequirements if TYPE_CHECKING: from collections.abc import AsyncIterator - from pyrit.prompt_target import PromptTarget + from pyrit.prompt_target import PromptTarget, RealtimeTarget logger = logging.getLogger(__name__) @@ -42,9 +41,8 @@ class BargeInAttackContext(AttackContext[AttackParamsT]): ``prepended_conversation`` (inherited from ``AttackContext``) is persisted to memory on setup, but only the leading system message is propagated to the live realtime session as session instructions. User / assistant turns from the prepended history - are not (yet) pushed through ``conversation.item.create``, so the model conditions - only on the system prompt plus live audio chunks. See follow-up issue for full - realtime-session injection. + are not pushed through ``conversation.item.create``, so the model conditions only on + the system prompt plus live audio chunks. """ conversation_id: str = field(default_factory=lambda: str(uuid.uuid4())) @@ -62,7 +60,7 @@ class BargeInAttack(AttackStrategy["BargeInAttackContext[Any]", AttackResult]): """ TARGET_REQUIREMENTS: ClassVar[TargetRequirements] = TargetRequirements( - required=frozenset({CapabilityName.STREAMING_BARGE_IN}), + required=frozenset({CapabilityName.STREAMING_AUDIO}), ) @apply_defaults @@ -78,15 +76,16 @@ def __init__( Initialize the streaming barge-in attack. Args: - objective_target: Target to attack. Must be a ``RealtimeTarget`` (the only - target that today exposes ``open_streaming_session``). + objective_target: Target to attack. Must declare the ``STREAMING_AUDIO`` + capability (today only ``RealtimeTarget`` does). attack_converter_config: Converters applied to each committed user turn. prompt_normalizer: Normalizer used to apply converters and persist messages. Defaults to a fresh ``PromptNormalizer``. params_type: Attack parameter dataclass type. Raises: - TypeError: If ``objective_target`` is not a ``RealtimeTarget``. + ValueError: If ``objective_target`` does not declare the ``STREAMING_AUDIO`` + capability. """ super().__init__( objective_target=objective_target, @@ -94,12 +93,9 @@ def __init__( params_type=params_type, logger=logger, ) - if not isinstance(objective_target, RealtimeTarget): - raise TypeError( - f"{type(objective_target).__name__} is not a RealtimeTarget. BargeInAttack " - f"requires a target that exposes `open_streaming_session`." - ) - self._realtime_target: RealtimeTarget = objective_target + # Capability validation (STREAMING_AUDIO) runs in super().__init__ via + # TARGET_REQUIREMENTS; the cast records the concrete dependency for the call site. + self._realtime_target = cast("RealtimeTarget", objective_target) attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters self._response_converters = attack_converter_config.response_converters @@ -132,8 +128,7 @@ async def _setup_async(self, *, context: BargeInAttackContext[Any]) -> None: Prepended messages are recorded in memory but are NOT pushed into the live realtime session beyond the system prompt — the model only conditions on the system message - and live audio chunks. Pushing prepended user / assistant turns into the websocket - session via ``conversation.item.create`` is tracked as a follow-up. + and live audio chunks. """ if not context.conversation_id: context.conversation_id = str(uuid.uuid4()) diff --git a/pyrit/prompt_target/common/discover_target_capabilities.py b/pyrit/prompt_target/common/discover_target_capabilities.py index f872e2b880..4e471e4388 100644 --- a/pyrit/prompt_target/common/discover_target_capabilities.py +++ b/pyrit/prompt_target/common/discover_target_capabilities.py @@ -149,7 +149,7 @@ def _permissive_configuration( supports_json_output=True, supports_editable_history=True, supports_system_prompt=True, - supports_streaming_barge_in=True, + supports_streaming_audio=True, input_modalities=merged_modalities, ) # Rebuild a fresh configuration from the instance's native capabilities so diff --git a/pyrit/prompt_target/common/realtime_audio.py b/pyrit/prompt_target/common/realtime_audio.py index 9b1496d585..bf4efc4948 100644 --- a/pyrit/prompt_target/common/realtime_audio.py +++ b/pyrit/prompt_target/common/realtime_audio.py @@ -121,12 +121,12 @@ def failure(self) -> BaseException | None: """ return self._failure - async def start(self) -> None: + async def start_async(self) -> None: """Start the background dispatch task. Idempotent.""" if self._task is None: - self._task = asyncio.create_task(self._dispatch_loop()) + self._task = asyncio.create_task(self._dispatch_loop_async()) - async def stop(self) -> None: + async def stop_async(self) -> None: """ Cancel the background dispatch task and release the reference. @@ -146,11 +146,11 @@ async def stop(self) -> None: task.cancel() await asyncio.gather(*pending, return_exceptions=True) - async def drain_callbacks(self) -> None: + async def drain_callbacks_async(self) -> None: """ Wait for in-flight on_user_audio_committed callback tasks to complete. - Unlike :meth:`stop`, callbacks are not cancelled — they run to completion. + Unlike ``stop_async``, callbacks are not cancelled — they run to completion. Use during graceful shutdown when the caller needs the final VAD-committed turn to finish its convert-and-respond work before tearing down the dispatcher. @@ -164,7 +164,7 @@ def add_failure_callback(self, callback: Callable[[BaseException], None]) -> Non Register a callback fired if the dispatch loop terminates abnormally. The callback is invoked exactly once with the exception that killed the - dispatch loop. Cancellation via :meth:`stop` does NOT trigger the callback. + dispatch loop. Cancellation via ``stop_async`` does NOT trigger the callback. Use to bridge dispatcher failures to a session-level consumer that would otherwise block forever waiting on a turn future that will never resolve. @@ -172,10 +172,10 @@ def add_failure_callback(self, callback: Callable[[BaseException], None]) -> Non callback: Sync callable receiving the dispatch-loop exception. Raises: - RuntimeError: If called before :meth:`start`. + RuntimeError: If called before ``start_async``. """ if self._task is None: - raise RuntimeError("add_failure_callback must be called after start()") + raise RuntimeError("add_failure_callback must be called after start_async()") def _on_done(task: asyncio.Task[None]) -> None: if task.cancelled(): @@ -201,7 +201,7 @@ def register_turn(self, state: RealtimeTurnState) -> None: raise RuntimeError("Another turn is already active on this dispatcher") self._current_turn = state - async def _dispatch_loop(self) -> None: + async def _dispatch_loop_async(self) -> None: """ Consume events from the connection and route each to the active turn. @@ -219,7 +219,7 @@ async def _dispatch_loop(self) -> None: if turn is not None and turn.completion.done(): turn = None try: - await self._route_event(event=event, state=turn) + await self._route_event_async(event=event, state=turn) except Exception as e: logger.exception(f"Realtime event router raised: {e}") if turn is not None and not turn.completion.done(): @@ -246,7 +246,7 @@ def _fire_committed_callback(self, event: CommittedEvent) -> None: task.add_done_callback(self._callback_tasks.discard) @abstractmethod - async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: + async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: """ Route a single provider-specific event. @@ -270,7 +270,7 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> """ @abstractmethod - async def _cancel(self, *, state: RealtimeTurnState) -> None: + async def _cancel_async(self, *, state: RealtimeTurnState) -> None: """ Send provider-specific cancel and truncate events for the in-flight response. diff --git a/pyrit/prompt_target/common/target_capabilities.py b/pyrit/prompt_target/common/target_capabilities.py index c3fac20c0a..7f1010745f 100644 --- a/pyrit/prompt_target/common/target_capabilities.py +++ b/pyrit/prompt_target/common/target_capabilities.py @@ -24,7 +24,7 @@ class CapabilityName(str, Enum): JSON_OUTPUT = "supports_json_output" EDITABLE_HISTORY = "supports_editable_history" SYSTEM_PROMPT = "supports_system_prompt" - STREAMING_BARGE_IN = "supports_streaming_barge_in" + STREAMING_AUDIO = "supports_streaming_audio" class UnsupportedCapabilityBehavior(str, Enum): @@ -139,12 +139,12 @@ class attribute. Users can override individual capabilities per instance # Whether the target natively supports system prompts. supports_system_prompt: bool = False - # Whether the target supports the streaming barge-in API: opening a long-lived + # Whether the target supports the streaming audio API: opening a long-lived # streaming session via ``open_streaming_session`` that pushes user audio chunks, # delivers VAD-committed audio to the attack for converter work, swaps committed # items in place, and drives manual ``response.create`` turns. Required by # ``BargeInAttack``. - supports_streaming_barge_in: bool = False + supports_streaming_audio: bool = False # The input modalities supported by the target (e.g., "text", "image"). input_modalities: frozenset[frozenset[PromptDataType]] = frozenset({frozenset(["text"])}) diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index f5b5326832..0b09a2e227 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -38,6 +38,10 @@ logger = logging.getLogger(__name__) +#: Minimum amount of buffered audio (ms) the Realtime server accepts on an explicit +#: ``input_audio_buffer.commit``. Forcing a commit below this raises "buffer too small". +_MIN_COMMIT_MS = 100 + def _trim_snapshot_to_speech( *, @@ -100,8 +104,8 @@ class _OpenAIRealtimeStreamingSession: """ Per-conversation lifecycle owner for one OpenAI Realtime streaming exchange. - Internal to :mod:`pyrit.prompt_target.openai`. Constructed and consumed only by - :meth:`RealtimeTarget.open_streaming_session`; downstream code should depend on + Internal to ``pyrit.prompt_target.openai``. Constructed and consumed only by + ``RealtimeTarget.open_streaming_session``; downstream code should depend on the ``AsyncIterator[Message]`` contract, never on this class directly. """ @@ -154,7 +158,7 @@ def __init__( # commits firing back-to-back cannot interleave. self._turn_lock = asyncio.Lock() - # Set in ``_on_committed`` entry. Producer awaits this after issuing a + # Set in ``_on_committed_async`` entry. Producer awaits this after issuing a # forced final commit so the resulting callback can be observed before # we signal end-of-stream and tear the dispatcher down. self._commit_observed = asyncio.Event() @@ -185,9 +189,9 @@ async def run_async(self) -> AsyncIterator[Message]: self._queue = asyncio.Queue() self._dispatcher = _OpenAIRealtimeDispatcher( connection=self._connection, - on_user_audio_committed=self._on_committed, + on_user_audio_committed=self._on_committed_async, ) - await self._dispatcher.start() + await self._dispatcher.start_async() self._dispatcher.add_failure_callback(self._on_dispatcher_failure) producer = asyncio.create_task(self._drain_chunks_async()) @@ -205,9 +209,9 @@ async def run_async(self) -> AsyncIterator[Message]: with contextlib.suppress(asyncio.CancelledError, Exception): await producer try: - await self._dispatcher.stop() + await self._dispatcher.stop_async() except Exception as e: # noqa: BLE001 - cleanup, surface via log - logger.warning(f"dispatcher.stop() raised during session teardown: {e}") + logger.warning(f"dispatcher.stop_async() raised during session teardown: {e}") finally: try: await self._connection.close() @@ -234,19 +238,31 @@ async def _drain_chunks_async(self) -> None: self._pending_chunks.extend(chunk) await self._push_audio_chunk_async(chunk) - # Snapshot commit-event count before forcing a final commit so we can - # detect whether the server accepted it (it produces a new committed - # event) without racing with any concurrent natural commit. + # Force a final commit only when enough uncommitted audio remains locally. + # When server VAD already committed the final phrase (e.g. the source ended + # with trailing silence), the buffer is empty and committing would be rejected + # — synchronously as a BadRequestError, or asynchronously as an + # ``input_audio_buffer_commit_empty`` error event that the dispatcher now treats + # as benign. Skipping sub-minimum buffers avoids both, plus the 5s observe wait. + bytes_per_ms = self._target.SAMPLE_RATE_HZ * 2 // 1000 # PCM16 mono + async with self._pending_chunks_lock: + pending_ms = len(self._pending_chunks) // bytes_per_ms if bytes_per_ms else 0 + self._commit_observed.clear() force_commit_accepted = False - try: - await connection.input_audio_buffer.commit() - force_commit_accepted = True - except _OpenAIBadRequestError as e: - # Empty buffer is a benign "nothing pending to commit" — happens whenever - # server VAD already auto-committed the final phrase. Anything else from - # this exception class still indicates a real API problem; log and continue. - logger.debug(f"Forced final commit rejected (likely empty buffer): {e}") + if pending_ms >= _MIN_COMMIT_MS: + try: + await connection.input_audio_buffer.commit() + force_commit_accepted = True + except _OpenAIBadRequestError as e: + # Server VAD may have committed between the size check and this call; + # the empty-buffer rejection is benign. Other BadRequestErrors still + # indicate a real API problem, so log and continue. + logger.debug(f"Forced final commit rejected (likely empty buffer): {e}") + else: + logger.debug( + f"Skipping forced final commit; {pending_ms}ms pending is below the {_MIN_COMMIT_MS}ms minimum." + ) if force_commit_accepted: try: @@ -259,7 +275,7 @@ async def _drain_chunks_async(self) -> None: # Let any commit-triggered callbacks (the one we just forced plus any # natural ones still mid-work) run to completion before signalling done. - await self._dispatcher.drain_callbacks() + await self._dispatcher.drain_callbacks_async() await self._queue.put(_SentinelDone()) except asyncio.CancelledError: raise @@ -275,7 +291,7 @@ def _on_dispatcher_failure(self, exc: BaseException) -> None: except Exception as e: # noqa: BLE001 - defensive; never let the bridge raise logger.warning(f"Failed to bridge dispatcher failure into session queue: {e}") - async def _on_committed(self, event: CommittedEvent) -> None: + async def _on_committed_async(self, event: CommittedEvent) -> None: """ Dispatcher-side callback: snapshot raw audio + trim now, then run the turn under the lock. @@ -300,7 +316,11 @@ async def _on_committed(self, event: CommittedEvent) -> None: buffer_relative_audio_start_ms: int | None = None if event.audio_start_ms is not None: - buffer_relative_audio_start_ms = event.audio_start_ms - self._buffer_start_session_ms + # The server's session clock and the locally-summed buffer offset can drift + # slightly out of alignment, so this subtraction may go marginally negative. + # Clamp to 0 ("trim nothing") rather than letting it reach the helper, which + # treats a negative offset as a caller bug and raises. + buffer_relative_audio_start_ms = max(0, event.audio_start_ms - self._buffer_start_session_ms) prefix_padding_ms = self._effective_vad.prefix_padding_ms if self._effective_vad is not None else 0 diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 9edd510756..5c95e862eb 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -66,7 +66,7 @@ class RealtimeTarget(OpenAITarget, PromptTarget): supports_editable_history=True, supports_multi_message_pieces=True, supports_system_prompt=True, - supports_streaming_barge_in=True, + supports_streaming_audio=True, input_modalities=frozenset( { frozenset(["text"]), @@ -143,7 +143,7 @@ def open_streaming_session( Open a new server-VAD streaming session bound to this target. Returns: - A fresh :class:`_OpenAIRealtimeStreamingSession`. Drive it by iterating + A fresh ``_OpenAIRealtimeStreamingSession``. Drive it by iterating ``await session.run_async()``; one assistant ``Message`` is yielded per VAD-committed turn, and the matching user message is persisted to memory (but not yielded). The session owns its websocket connection + dispatcher @@ -407,7 +407,7 @@ async def _send_prompt_to_target_async(self, *, normalized_conversation: list[Me Dispatches to the atomic send_audio / send_text path based on the request's data type. Streaming attacks bypass this entry point and drive - the connection through :class:`_OpenAIRealtimeStreamingSession` instead. + the connection through ``_OpenAIRealtimeStreamingSession`` instead. Args: normalized_conversation (list[Message]): The full conversation @@ -891,7 +891,11 @@ class _OpenAIRealtimeDispatcher(RealtimeEventDispatcher): ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. """ - async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: + #: Error code the server returns when an explicit commit finds an empty buffer + #: (server VAD already committed the final phrase). Benign for streaming sessions. + _COMMIT_EMPTY_ERROR_CODE: ClassVar[str] = "input_audio_buffer_commit_empty" + + async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" event_type = getattr(event, "type", "") @@ -919,7 +923,6 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> audio_start_ms=audio_start_ms, ) ) - # Fall through: also include the bookkeeping below (none currently uses committed). return # Remaining events are output-side and mutate per-turn state; drop if no turn. @@ -967,7 +970,7 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> return if event_type == "input_audio_buffer.speech_started" and state.is_responding: - await self._cancel(state=state) + await self._cancel_async(state=state) state.is_responding = False state.completion.set_result( RealtimeTargetResult( @@ -980,11 +983,17 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> if event_type == "error": error = getattr(event, "error", None) + code = getattr(error, "code", None) if error is not None else None message = getattr(error, "message", "unknown") if error is not None else "unknown" + if code == self._COMMIT_EMPTY_ERROR_CODE: + # A forced final commit raced an already-empty buffer (server VAD committed + # everything). Benign and unrelated to the active turn — never fail on it. + logger.debug(f"Ignoring benign empty input-buffer commit error: {message}") + return state.completion.set_exception(RuntimeError(f"Realtime API error: {message}")) return - async def _cancel(self, *, state: RealtimeTurnState) -> None: + async def _cancel_async(self, *, state: RealtimeTurnState) -> None: """ Truncate the in-flight response's conversation item to what was actually delivered. @@ -992,7 +1001,7 @@ async def _cancel(self, *, state: RealtimeTurnState) -> None: trim the conversation history to match the audio we received. Marks ``state.interrupted = True`` even when the truncate call fails. - Does not resolve ``state.completion``; the caller (``_route_event``) does that. + Does not resolve ``state.completion``; the caller (``_route_event_async``) does that. Args: state (RealtimeTurnState): The turn whose response should be cancelled. diff --git a/tests/unit/executor/attack/streaming/test_barge_in.py b/tests/unit/executor/attack/streaming/test_barge_in.py index 681aef94ba..4bbd7c0e9f 100644 --- a/tests/unit/executor/attack/streaming/test_barge_in.py +++ b/tests/unit/executor/attack/streaming/test_barge_in.py @@ -55,16 +55,16 @@ def _mock_connection() -> AsyncMock: @patch.dict("os.environ", _CLEAN_ENV) def test_constructor_rejects_target_without_streaming_capability(sqlite_instance): - """A target whose capabilities lack STREAMING_BARGE_IN must be rejected at construction.""" + """A target whose capabilities lack STREAMING_AUDIO must be rejected at construction.""" from pyrit.prompt_target import OpenAIChatTarget no_streaming = OpenAIChatTarget(api_key="k", endpoint="https://x", model_name="m") - with pytest.raises(Exception, match="streaming_barge_in"): + with pytest.raises(Exception, match="streaming_audio"): BargeInAttack(objective_target=no_streaming) def test_constructor_succeeds_with_vad_target(vad_target): - """A RealtimeTarget declares STREAMING_BARGE_IN — construction succeeds.""" + """A RealtimeTarget declares STREAMING_AUDIO — construction succeeds.""" attack = BargeInAttack(objective_target=vad_target) assert attack.get_objective_target() is vad_target diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 12f07b7a12..f5ba774077 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -120,9 +120,9 @@ def _factory(*, connection, on_user_audio_committed): captured["connection"] = connection captured["on_user_audio_committed"] = on_user_audio_committed d = MagicMock(name="dispatcher") - d.start = AsyncMock() - d.stop = AsyncMock() - d.drain_callbacks = AsyncMock() + d.start_async = AsyncMock() + d.stop_async = AsyncMock() + d.drain_callbacks_async = AsyncMock() d.add_failure_callback = MagicMock() captured["dispatcher"] = d return d @@ -156,7 +156,7 @@ async def _fire() -> None: # Let the consumer task start and create the dispatcher / queue. await asyncio.sleep(0) for event in events: - await session._on_committed(event) + await session._on_committed_async(event) finish.set() await asyncio.gather(_consume(), _fire()) @@ -449,7 +449,7 @@ async def _consume() -> None: pytest.fail("no message should be yielded before the failure surfaces") async def _fire_failure() -> None: - # Let run_async progress past dispatcher.start() and the add_failure_callback registration. + # Let run_async progress past dispatcher.start_async() and the add_failure_callback registration. for _ in range(5): await asyncio.sleep(0) assert captured["dispatcher"].add_failure_callback.call_count == 1 @@ -463,6 +463,55 @@ async def _fire_failure() -> None: await asyncio.gather(_consume(), _fire_failure()) +# --------------------------------------------------------------------------- +# 7b. Forced final commit is gated on the server's minimum buffer size +# --------------------------------------------------------------------------- + + +async def test_drain_skips_forced_commit_when_pending_below_minimum(): + """A tail buffer under the 100ms server minimum must not trigger a forced commit.""" + target = _build_target() + normalizer = _build_normalizer() + connection = target._connect_async.return_value + + finish = asyncio.Event() + # 96 bytes = 1ms at 24 kHz PCM16 mono, well below the 100ms minimum. + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x00" * 96], finish), + prompt_normalizer=normalizer, + ) + _mock_session_wire(session) + + with _patched_dispatcher(): + messages = await _run_session_with_events(session, finish=finish, events=[]) + + assert messages == [] + connection.input_audio_buffer.commit.assert_not_awaited() + + +async def test_drain_forces_commit_when_pending_meets_minimum(): + """At least 100ms of uncommitted audio must trigger the forced final commit.""" + target = _build_target() + normalizer = _build_normalizer() + connection = target._connect_async.return_value + + finish = asyncio.Event() + # 4800 bytes = exactly 100ms at 24 kHz PCM16 mono. + session = _OpenAIRealtimeStreamingSession( + target=target, + audio_chunks=_paced_chunks([b"\x00" * 4800], finish), + prompt_normalizer=normalizer, + ) + _mock_session_wire(session) + + with _patched_dispatcher(): + messages = await _run_session_with_events(session, finish=finish, events=[]) + + assert messages == [] + connection.input_audio_buffer.commit.assert_awaited_once() + + # --------------------------------------------------------------------------- # 8. Trim: pre-speech silence is stripped using audio_start_ms before persistence # --------------------------------------------------------------------------- @@ -573,12 +622,12 @@ async def _consume() -> None: async def _fire() -> None: await asyncio.sleep(0) - await session._on_committed(CommittedEvent(item_id="t1", audio_start_ms=500)) + await session._on_committed_async(CommittedEvent(item_id="t1", audio_start_ms=500)) gate2.set() # Give the producer time to drain chunk2 into _pending_chunks. for _ in range(20): await asyncio.sleep(0) - await session._on_committed(CommittedEvent(item_id="t2", audio_start_ms=800)) + await session._on_committed_async(CommittedEvent(item_id="t2", audio_start_ms=800)) finish.set() with _patched_dispatcher(): diff --git a/tests/unit/prompt_target/target/test_realtime_audio.py b/tests/unit/prompt_target/target/test_realtime_audio.py index 005814a5e4..a1248b03e9 100644 --- a/tests/unit/prompt_target/target/test_realtime_audio.py +++ b/tests/unit/prompt_target/target/test_realtime_audio.py @@ -49,13 +49,13 @@ def __init__(self, *, connection: Any) -> None: self.routed_events: list[Any] = [] self.cancel_calls: int = 0 - async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: + async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: self.routed_events.append(event) # End the turn on a sentinel event so tests can drain the loop. if state is not None and getattr(event, "_finish", False): state.completion.set_result(RealtimeTargetResult()) - async def _cancel(self, *, state: RealtimeTurnState) -> None: + async def _cancel_async(self, *, state: RealtimeTurnState) -> None: self.cancel_calls += 1 state.interrupted = True @@ -80,18 +80,18 @@ def _sentinel_event(*, finish: bool = False) -> AsyncMock: async def test_dispatcher_start_is_idempotent(): """Calling start twice must not spawn two tasks.""" dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) - await dispatcher.start() + await dispatcher.start_async() first_task = dispatcher._task - await dispatcher.start() + await dispatcher.start_async() assert dispatcher._task is first_task - await dispatcher.stop() + await dispatcher.stop_async() async def test_dispatcher_stop_releases_task(): """stop must cancel the task and clear the reference.""" dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([])) - await dispatcher.start() - await dispatcher.stop() + await dispatcher.start_async() + await dispatcher.stop_async() assert dispatcher._task is None @@ -119,30 +119,30 @@ async def test_dispatcher_register_turn_allows_replacement_after_completion(): async def test_dispatcher_loop_routes_events_to_active_turn(): - """The dispatch loop must forward events from the connection to _route_event.""" + """The dispatch loop must forward events from the connection to _route_event_async.""" finish = _sentinel_event(finish=True) other = _sentinel_event() dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([other, finish])) state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(state) - await dispatcher.start() + await dispatcher.start_async() await asyncio.wait_for(state.completion, timeout=1.0) - await dispatcher.stop() + await dispatcher.stop_async() assert dispatcher.routed_events == [other, finish] async def test_dispatcher_loop_routes_events_with_no_turn_as_state_none(): - """When no turn is registered, events still reach _route_event so input callbacks can fire; state is None.""" + """When no turn is registered, events still reach _route_event_async so input callbacks can fire; state is None.""" finish = _sentinel_event(finish=True) other = _sentinel_event() dispatcher = _RecordingDispatcher(connection=_ScriptedConnection([other, finish])) # No register_turn called. - await dispatcher.start() + await dispatcher.start_async() await asyncio.sleep(0.05) - await dispatcher.stop() + await dispatcher.stop_async() # Both events were routed but no turn was completed (state was None, sentinel branch skipped). assert dispatcher.routed_events == [other, finish] @@ -152,7 +152,7 @@ async def test_dispatcher_loop_sets_exception_on_router_failure(): """A router exception must propagate to the active turn's completion future.""" class _ExplodingDispatcher(_RecordingDispatcher): - async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> None: + async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: raise ValueError("router boom") event = _sentinel_event() @@ -160,10 +160,10 @@ async def _route_event(self, *, event: Any, state: RealtimeTurnState | None) -> state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) dispatcher.register_turn(state) - await dispatcher.start() + await dispatcher.start_async() with pytest.raises(ValueError, match="router boom"): await asyncio.wait_for(state.completion, timeout=1.0) - await dispatcher.stop() + await dispatcher.stop_async() async def test_dispatcher_fires_committed_callback_as_background_task(): @@ -180,11 +180,11 @@ async def slow_callback(event): await release.wait() class _CallbackDispatcher(RealtimeEventDispatcher): - async def _route_event(self, *, event, state): + async def _route_event_async(self, *, event, state): # Synthesize a committed callback fire on every event for the test. self._fire_committed_callback(event) - async def _cancel(self, *, state): # pragma: no cover - not exercised here + async def _cancel_async(self, *, state): # pragma: no cover - not exercised here return fake_event_1 = MagicMock(spec=CommittedEvent) @@ -194,13 +194,13 @@ async def _cancel(self, *, state): # pragma: no cover - not exercised here on_user_audio_committed=slow_callback, ) - await dispatcher.start() + await dispatcher.start_async() # Both events should reach the slow callback even though the first is "blocked" awaiting release. await asyncio.wait_for(blocked.wait(), timeout=1.0) # Give the loop a tick to process the second event despite the first callback still running. await asyncio.sleep(0.05) release.set() - await dispatcher.stop() + await dispatcher.stop_async() # Both events fired the callback; the loop did not serialize behind the slow first call. assert len(received) == 2 @@ -210,10 +210,10 @@ async def test_dispatcher_records_failure_on_iterator_crash(): """When the connection iterator raises, the dispatcher's failure property captures the exception.""" class _NoopDispatcher(RealtimeEventDispatcher): - async def _route_event(self, *, event, state): # pragma: no cover - never called + async def _route_event_async(self, *, event, state): # pragma: no cover - never called return - async def _cancel(self, *, state): # pragma: no cover + async def _cancel_async(self, *, state): # pragma: no cover return class _ExplodingConnection: @@ -224,11 +224,11 @@ async def __anext__(self): raise RuntimeError("iterator died") dispatcher = _NoopDispatcher(connection=_ExplodingConnection()) - await dispatcher.start() + await dispatcher.start_async() for _ in range(50): if dispatcher.failure is not None: break await asyncio.sleep(0.01) - await dispatcher.stop() + await dispatcher.stop_async() assert isinstance(dispatcher.failure, RuntimeError) and str(dispatcher.failure) == "iterator died" diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index b38ea429ce..7fbd140bce 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -569,13 +569,13 @@ def _make_dispatcher(connection): async def test_cancel_does_not_send_response_cancel(): - """_cancel must NOT send response.cancel (server auto-cancels on speech detection).""" + """_cancel_async must NOT send response.cancel (server auto-cancels on speech detection).""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) state = _turn_state(response_id="resp_42") state.delivered_audio.extend(b"\x00" * 4800) - await dispatcher._cancel(state=state) + await dispatcher._cancel_async(state=state) connection.response.cancel.assert_not_awaited() @@ -588,7 +588,7 @@ async def test_cancel_truncates_to_delivered_audio_ms(): # 4800 delivered bytes / 48 bytes-per-ms = 100ms state.delivered_audio.extend(b"\x00" * 4800) - await dispatcher._cancel(state=state) + await dispatcher._cancel_async(state=state) connection.conversation.item.truncate.assert_awaited_once_with( item_id="item_99", @@ -599,13 +599,13 @@ async def test_cancel_truncates_to_delivered_audio_ms(): async def test_cancel_only_truncates_no_response_cancel(caplog): - """_cancel must only truncate, not send response.cancel (server handles cancellation).""" + """_cancel_async must only truncate, not send response.cancel (server handles cancellation).""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) state = _turn_state(item_id="item_1") state.delivered_audio.extend(b"\x00" * 4800) - await dispatcher._cancel(state=state) + await dispatcher._cancel_async(state=state) assert state.interrupted is True connection.conversation.item.truncate.assert_awaited_once() @@ -619,7 +619,7 @@ async def test_cancel_marks_interrupted_when_truncate_raises(caplog): dispatcher = _make_dispatcher(connection) state = _turn_state() - await dispatcher._cancel(state=state) + await dispatcher._cancel_async(state=state) assert state.interrupted is True assert any( @@ -648,15 +648,21 @@ async def test_route_event_happy_path_resolves_completion_with_assembled_result( dispatcher = _make_dispatcher(connection) state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) - await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) - await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) - await dispatcher._route_event( + await dispatcher._route_event_async(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) + await dispatcher._route_event_async( + event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state + ) + await dispatcher._route_event_async( event=_scripted_event("response.audio.delta", delta=base64.b64encode(b"\xaa" * 4800).decode("ascii")), state=state, ) - await dispatcher._route_event(event=_scripted_event("response.audio_transcript.delta", delta="hello "), state=state) - await dispatcher._route_event(event=_scripted_event("response.audio_transcript.delta", delta="world"), state=state) - await dispatcher._route_event(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) + await dispatcher._route_event_async( + event=_scripted_event("response.audio_transcript.delta", delta="hello "), state=state + ) + await dispatcher._route_event_async( + event=_scripted_event("response.audio_transcript.delta", delta="world"), state=state + ) + await dispatcher._route_event_async(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) assert state.completion.done() result = state.completion.result() @@ -671,13 +677,15 @@ async def test_route_event_speech_started_while_responding_cancels_and_resolves_ dispatcher = _make_dispatcher(connection) state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) - await dispatcher._route_event(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) - await dispatcher._route_event(event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state) - await dispatcher._route_event( + await dispatcher._route_event_async(event=_scripted_event("response.created", **{"response.id": "r1"}), state=state) + await dispatcher._route_event_async( + event=_scripted_event("response.output_item.added", **{"item.id": "i1"}), state=state + ) + await dispatcher._route_event_async( event=_scripted_event("response.audio.delta", delta=base64.b64encode(b"\xbb" * 2400).decode("ascii")), state=state, ) - await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) + await dispatcher._route_event_async(event=_scripted_event("input_audio_buffer.speech_started"), state=state) connection.response.cancel.assert_not_awaited() connection.conversation.item.truncate.assert_awaited_once_with( @@ -701,7 +709,7 @@ async def test_route_event_stale_response_done_after_cancel_is_dropped(): state.completion.set_result(RealtimeTargetResult()) # Late response.done for r1 arrives; router must not raise InvalidStateError. - await dispatcher._route_event(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) + await dispatcher._route_event_async(event=_scripted_event("response.done", **{"response.id": "r1"}), state=state) async def test_route_event_error_resolves_with_exception(): @@ -710,19 +718,38 @@ async def test_route_event_error_resolves_with_exception(): dispatcher = _make_dispatcher(connection) state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) - await dispatcher._route_event(event=_scripted_event("error", **{"error.message": "rate limited"}), state=state) + await dispatcher._route_event_async( + event=_scripted_event("error", **{"error.message": "rate limited"}), state=state + ) with pytest.raises(RuntimeError, match="rate limited"): state.completion.result() +async def test_route_event_ignores_benign_empty_commit_error(): + """An input_audio_buffer_commit_empty error is benign and must not fail the active turn.""" + connection = AsyncMock() + dispatcher = _make_dispatcher(connection) + state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) + + await dispatcher._route_event_async( + event=_scripted_event( + "error", + **{"error.code": "input_audio_buffer_commit_empty", "error.message": "buffer too small"}, + ), + state=state, + ) + + assert not state.completion.done() + + async def test_route_event_speech_started_without_responding_is_noop(): """speech_started before a response is in flight does not call cancel or resolve.""" connection = AsyncMock() dispatcher = _make_dispatcher(connection) state = RealtimeTurnState(completion=asyncio.get_event_loop().create_future()) - await dispatcher._route_event(event=_scripted_event("input_audio_buffer.speech_started"), state=state) + await dispatcher._route_event_async(event=_scripted_event("input_audio_buffer.speech_started"), state=state) connection.response.cancel.assert_not_awaited() connection.conversation.item.truncate.assert_not_awaited() @@ -740,7 +767,7 @@ async def on_committed(event): dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.committed", item_id="raw_item_42", audio_start_ms=1234), state=None, ) @@ -761,7 +788,7 @@ async def test_route_event_committed_event_without_callback_is_noop(): dispatcher = _OpenAIRealtimeDispatcher(connection=connection) # no callback # Must not raise. - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.committed", item_id="raw_item_99"), state=None, ) @@ -782,11 +809,11 @@ async def on_committed(event: CommittedEvent) -> None: connection = AsyncMock() dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.speech_started", audio_start_ms=8536), state=None, ) - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.committed", item_id="raw_99", audio_start_ms=None), state=None, ) @@ -811,16 +838,16 @@ async def on_committed(event: CommittedEvent) -> None: connection = AsyncMock() dispatcher = _OpenAIRealtimeDispatcher(connection=connection, on_user_audio_committed=on_committed) - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.speech_started", audio_start_ms=500), state=None, ) - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.committed", item_id="i1", audio_start_ms=None), state=None, ) # Second commit without a prior speech_started: must NOT reuse the 500 captured above. - await dispatcher._route_event( + await dispatcher._route_event_async( event=_scripted_event("input_audio_buffer.committed", item_id="i2", audio_start_ms=None), state=None, ) From 2cd5431d6d3b211502b99847647663e9da1cd4f5 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Tue, 2 Jun 2026 22:58:50 -0400 Subject: [PATCH 43/47] Fix D420 docstring section order in open_streaming_session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prompt_target/openai/openai_realtime_target.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 85f054d851..acd81cc4bf 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -143,13 +143,6 @@ def open_streaming_session( """ Open a new server-VAD streaming session bound to this target. - Returns: - A fresh ``_OpenAIRealtimeStreamingSession``. Drive it by iterating - ``await session.run_async()``; one assistant ``Message`` is yielded per - VAD-committed turn, and the matching user message is persisted to memory - (but not yielded). The session owns its websocket connection + dispatcher - for the duration of ``run_async``. - Args: audio_chunks: Async iterator yielding PCM16 mono bytes at the target's ``SAMPLE_RATE_HZ`` rate. @@ -171,6 +164,13 @@ def open_streaming_session( ``prepended_conversation`` to memory itself. Pass ``False`` when the caller already persisted the prepended conversation (e.g. via ``ConversationManager.initialize_context_async``) to avoid double-writes. + + Returns: + A fresh ``_OpenAIRealtimeStreamingSession``. Drive it by iterating + ``await session.run_async()``; one assistant ``Message`` is yielded per + VAD-committed turn, and the matching user message is persisted to memory + (but not yielded). The session owns its websocket connection + dispatcher + for the duration of ``run_async``. """ # Local import: the session module imports ``_OpenAIRealtimeDispatcher`` from # this module, so a module-level import here would be circular. From 8a7bbd00bee092493d516ccca2d3cbdb11c5da71 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 3 Jun 2026 10:14:56 -0400 Subject: [PATCH 44/47] Test deprecated realtime aliases and cleanup error paths Lift diff coverage above the 90% gate by covering the deprecated non-_async aliases, cleanup_conversation_async, and the cleanup_target_async error-swallowing paths added during the main merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../target/test_realtime_target.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 8b476e057a..3c80800c9b 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -911,3 +911,73 @@ async def test_send_prompt_audio_path_calls_send_audio_async(target, tmp_path): await target._send_prompt_to_target_async(normalized_conversation=[message]) target.send_audio_async.assert_awaited_once() + + +@pytest.mark.parametrize( + "alias_name, async_name, args, kwargs, returns_value", + [ + ("send_config", "send_config_async", (), {"conversation_id": "conv"}, False), + ("cleanup_target", "cleanup_target_async", (), {}, False), + ("cleanup_conversation", "cleanup_conversation_async", (), {"conversation_id": "conv"}, False), + ("save_audio", "save_audio_async", (b"audio",), {}, True), + ("send_response_create", "send_response_create_async", (), {"conversation_id": "conv"}, False), + ("receive_events", "receive_events_async", (), {"conversation_id": "conv"}, True), + ], +) +async def test_deprecated_alias_delegates_to_async(target, alias_name, async_name, args, kwargs, returns_value): + mock_async = AsyncMock(return_value="sentinel") + setattr(target, async_name, mock_async) + + with patch("pyrit.prompt_target.openai.openai_realtime_target.print_deprecation_message") as mock_deprecation: + result = await getattr(target, alias_name)(*args, **kwargs) + + mock_deprecation.assert_called_once() + mock_async.assert_awaited_once() + assert result == "sentinel" if returns_value else result is None + + +async def test_cleanup_conversation_async_closes_and_removes(target): + mock_connection = AsyncMock() + target._existing_conversation["conv"] = mock_connection + + await target.cleanup_conversation_async(conversation_id="conv") + + mock_connection.close.assert_awaited_once() + assert "conv" not in target._existing_conversation + + +async def test_cleanup_conversation_async_swallows_close_error(target): + mock_connection = AsyncMock() + mock_connection.close.side_effect = RuntimeError("close failed") + target._existing_conversation["conv"] = mock_connection + + # The error is swallowed and the conversation is still removed. + await target.cleanup_conversation_async(conversation_id="conv") + + assert "conv" not in target._existing_conversation + + +async def test_cleanup_conversation_async_unknown_id_is_noop(target): + target._existing_conversation["conv"] = AsyncMock() + + await target.cleanup_conversation_async(conversation_id="missing") + + assert "conv" in target._existing_conversation + + +async def test_cleanup_target_async_swallows_connection_and_client_errors(target): + bad_connection = AsyncMock() + bad_connection.close.side_effect = RuntimeError("connection close failed") + target._existing_conversation["conv"] = bad_connection + + mock_client = AsyncMock() + mock_client.close.side_effect = RuntimeError("client close failed") + target._realtime_client = mock_client + + # Both close errors are swallowed; state is fully reset regardless. + await target.cleanup_target_async() + + bad_connection.close.assert_awaited_once() + mock_client.close.assert_awaited_once() + assert target._existing_conversation == {} + assert target._realtime_client is None From 6ec0cc7121e54f495959d4523bfecc5f0f88b299 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 3 Jun 2026 10:37:45 -0400 Subject: [PATCH 45/47] Address PR review nits: trim comment, docstring, doc reorder, async aliases - Drop redundant capability-validation comment above the RealtimeTarget cast - Make _validate_context Raises docstring describe the actual checks - Move the 'Reading the barge-in output' note above the barge-in cell and reword its intro for pre-execution reading - Use cleanup_target_async() in the barge-in doc (py + ipynb) - Remove dead 'from __future__ import annotations' in prompt_target base Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../executor/attack/barge_in_attack.ipynb | 38 +++++++++---------- doc/code/executor/attack/barge_in_attack.py | 26 ++++++------- pyrit/executor/attack/streaming/barge_in.py | 5 +-- pyrit/prompt_target/common/prompt_target.py | 2 - 4 files changed, 34 insertions(+), 37 deletions(-) diff --git a/doc/code/executor/attack/barge_in_attack.ipynb b/doc/code/executor/attack/barge_in_attack.ipynb index c7c9d09222..9ba59610d1 100644 --- a/doc/code/executor/attack/barge_in_attack.ipynb +++ b/doc/code/executor/attack/barge_in_attack.ipynb @@ -242,7 +242,7 @@ "result = await attack.execute_with_context_async(context=context) # type: ignore\n", "print(f\"executed_turns: {result.executed_turns}\")\n", "await ConsoleAttackResultPrinter(width=200).write_async(result=result) # type: ignore\n", - "await target.cleanup_target() # type: ignore" + "await target.cleanup_target_async() # type: ignore" ] }, { @@ -257,10 +257,26 @@ "with `interrupted=True`." ] }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "### Reading the barge-in output\n", + "\n", + "After running the next cell, if barge-in fired successfully:\n", + "- `executed_turns: 2` (two VAD-detected user turns)\n", + "- First assistant turn shows `[INTERRUPTED]` with a truncated transcript\n", + "- Second assistant turn completes normally\n", + "\n", + "If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio\n", + "arrives earlier in turn 1's response window." + ] + }, { "cell_type": "code", "execution_count": null, - "id": "8", + "id": "9", "metadata": {}, "outputs": [ { @@ -414,23 +430,7 @@ " print(f\" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}\")\n", "\n", "await ConsoleAttackResultPrinter(width=200).write_async(result=barge_in_result) # type: ignore\n", - "await target2.cleanup_target() # type: ignore" - ] - }, - { - "cell_type": "markdown", - "id": "9", - "metadata": {}, - "source": [ - "### Reading the barge-in output\n", - "\n", - "If barge-in fired successfully:\n", - "- `executed_turns: 2` (two VAD-detected user turns)\n", - "- First assistant turn shows `[INTERRUPTED]` with a truncated transcript\n", - "- Second assistant turn completes normally\n", - "\n", - "If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio\n", - "arrives earlier in turn 1's response window." + "await target2.cleanup_target_async() # type: ignore" ] }, { diff --git a/doc/code/executor/attack/barge_in_attack.py b/doc/code/executor/attack/barge_in_attack.py index 9479e8fb42..16111e2e40 100644 --- a/doc/code/executor/attack/barge_in_attack.py +++ b/doc/code/executor/attack/barge_in_attack.py @@ -116,7 +116,7 @@ async def single_turn_source(): result = await attack.execute_with_context_async(context=context) # type: ignore print(f"executed_turns: {result.executed_turns}") await ConsoleAttackResultPrinter(width=200).write_async(result=result) # type: ignore -await target.cleanup_target() # type: ignore +await target.cleanup_target_async() # type: ignore # %% [markdown] # ## Section 2: Barge-in (interrupting the assistant mid-response) @@ -125,6 +125,17 @@ async def single_turn_source(): # response. Server VAD detects the new speech, cancels turn 1's response, and resolves it # with `interrupted=True`. +# %% [markdown] +# ### Reading the barge-in output +# +# After running the next cell, if barge-in fired successfully: +# - `executed_turns: 2` (two VAD-detected user turns) +# - First assistant turn shows `[INTERRUPTED]` with a truncated transcript +# - Second assistant turn completes normally +# +# If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio +# arrives earlier in turn 1's response window. + # %% TURN1_RESPONSE_WAIT_S = 0.2 # how long to let the model start speaking before barging in @@ -180,18 +191,7 @@ async def barge_in_source(): print(f" {piece._role} {piece.converted_value_data_type}{marker}: {value_preview}") await ConsoleAttackResultPrinter(width=200).write_async(result=barge_in_result) # type: ignore -await target2.cleanup_target() # type: ignore - -# %% [markdown] -# ### Reading the barge-in output -# -# If barge-in fired successfully: -# - `executed_turns: 2` (two VAD-detected user turns) -# - First assistant turn shows `[INTERRUPTED]` with a truncated transcript -# - Second assistant turn completes normally -# -# If you don't see `[INTERRUPTED]`, decrease `TURN1_RESPONSE_WAIT_S` so turn 2's audio -# arrives earlier in turn 1's response window. +await target2.cleanup_target_async() # type: ignore # %% [markdown] # ## Alternate chunk sources diff --git a/pyrit/executor/attack/streaming/barge_in.py b/pyrit/executor/attack/streaming/barge_in.py index 29918a9e79..161e364f85 100644 --- a/pyrit/executor/attack/streaming/barge_in.py +++ b/pyrit/executor/attack/streaming/barge_in.py @@ -93,8 +93,6 @@ def __init__( params_type=params_type, logger=logger, ) - # Capability validation (STREAMING_AUDIO) runs in super().__init__ via - # TARGET_REQUIREMENTS; the cast records the concrete dependency for the call site. self._realtime_target = cast("RealtimeTarget", objective_target) attack_converter_config = attack_converter_config or AttackConverterConfig() self._request_converters = attack_converter_config.request_converters @@ -110,7 +108,8 @@ def _validate_context(self, *, context: BargeInAttackContext[Any]) -> None: Validate the context before executing. Raises: - ValueError: If the context is missing required fields. + ValueError: If the objective is missing/empty or ``audio_chunks`` is not set + to an async iterator of PCM bytes. """ if not context.objective or context.objective.isspace(): raise ValueError("Attack objective must be provided and non-empty in the context") diff --git a/pyrit/prompt_target/common/prompt_target.py b/pyrit/prompt_target/common/prompt_target.py index 0b9f1e5fa7..aa13658aac 100644 --- a/pyrit/prompt_target/common/prompt_target.py +++ b/pyrit/prompt_target/common/prompt_target.py @@ -1,8 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. -from __future__ import annotations - import abc import logging from typing import Any, Union, final From caafa62c6cc77e3db8b3660483d45304a3890efa Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 3 Jun 2026 11:01:29 -0400 Subject: [PATCH 46/47] Extract realtime dispatcher to its own module; replace lifecycle asserts - Move _OpenAIRealtimeDispatcher out of openai_realtime_target.py into a new _openai_realtime_dispatcher.py, mirroring the existing private session module. This removes the target<->session import cycle, so the session import is promoted from a function-local import to a normal top-level one. - Replace the scattered `assert self._connection/_dispatcher/_queue is not None` invariant checks in the streaming session with _require_connection/_require_ dispatcher/_require_queue helpers that raise RuntimeError (asserts are stripped under python -O and gave no diagnostic). - Update test patch targets for the moved dispatcher and the now top-level session import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/_openai_realtime_dispatcher.py | 157 ++++++++++++++++++ .../_openai_realtime_streaming_session.py | 72 +++++--- .../openai/openai_realtime_target.py | 155 +---------------- .../test_openai_realtime_streaming_session.py | 2 +- .../target/test_realtime_target.py | 2 +- 5 files changed, 208 insertions(+), 180 deletions(-) create mode 100644 pyrit/prompt_target/openai/_openai_realtime_dispatcher.py diff --git a/pyrit/prompt_target/openai/_openai_realtime_dispatcher.py b/pyrit/prompt_target/openai/_openai_realtime_dispatcher.py new file mode 100644 index 0000000000..92fe9324f1 --- /dev/null +++ b/pyrit/prompt_target/openai/_openai_realtime_dispatcher.py @@ -0,0 +1,157 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT license. + +"""Concrete OpenAI Realtime event dispatcher for streaming sessions.""" + +import base64 +import logging +from typing import Any, ClassVar + +from pyrit.prompt_target.common.realtime_audio import ( + CommittedEvent, + RealtimeEventDispatcher, + RealtimeTargetResult, + RealtimeTurnState, +) + +logger = logging.getLogger(__name__) + + +class _OpenAIRealtimeDispatcher(RealtimeEventDispatcher): + """ + Concrete ``RealtimeEventDispatcher`` for the OpenAI Realtime API. + + Routes OpenAI server events into the active ``RealtimeTurnState`` and issues + ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. + """ + + #: Error code the server returns when an explicit commit finds an empty buffer + #: (server VAD already committed the final phrase). Benign for streaming sessions. + _COMMIT_EMPTY_ERROR_CODE: ClassVar[str] = "input_audio_buffer_commit_empty" + + async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: + """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" + event_type = getattr(event, "type", "") + + # Capture audio_start_ms from speech_started for the next committed event. + # The server reports it reliably here but omits it from the commit event itself. + # Do not return — the downstream state-aware branch still needs to fire the + # barge-in cancel when speech starts mid-response. + if event_type == "input_audio_buffer.speech_started": + speech_start = getattr(event, "audio_start_ms", None) + if speech_start is not None: + self._pending_speech_start_ms = speech_start + + # Input-side events fire callbacks regardless of whether a turn is registered. + if event_type == "input_audio_buffer.committed": + item_id = getattr(event, "item_id", None) + if item_id is None: + return + audio_start_ms = getattr(event, "audio_start_ms", None) + if audio_start_ms is None: + audio_start_ms = self._pending_speech_start_ms + self._pending_speech_start_ms = None + self._fire_committed_callback( + CommittedEvent( + item_id=item_id, + audio_start_ms=audio_start_ms, + ) + ) + return + + # Remaining events are output-side and mutate per-turn state; drop if no turn. + if state is None or state.completion.done(): + return + + if event_type == "response.created": + state.is_responding = True + response = getattr(event, "response", None) + if response is not None: + state.last_response_id = getattr(response, "id", None) + return + + if event_type in ("response.output_item.added", "response.output_item.created"): + item = getattr(event, "item", None) + if item is not None: + state.current_item_id = getattr(item, "id", None) + return + + if event_type in ("response.audio.delta", "response.output_audio.delta"): + delta = getattr(event, "delta", "") + if delta: + state.delivered_audio.extend(base64.b64decode(delta)) + return + + if event_type in ("response.audio_transcript.delta", "response.output_audio_transcript.delta"): + delta = getattr(event, "delta", "") + if delta: + state.delivered_transcripts.append(delta) + return + + if event_type == "response.done": + response = getattr(event, "response", None) + done_response_id = getattr(response, "id", None) if response is not None else None + if state.last_response_id is not None and done_response_id != state.last_response_id: + # Stale event from a cancelled response; drop without resolving. + return + state.is_responding = False + state.completion.set_result( + RealtimeTargetResult( + audio_bytes=bytes(state.delivered_audio), + transcripts=list(state.delivered_transcripts), + ) + ) + return + + if event_type == "input_audio_buffer.speech_started" and state.is_responding: + await self._cancel_async(state=state) + state.is_responding = False + state.completion.set_result( + RealtimeTargetResult( + audio_bytes=bytes(state.delivered_audio), + transcripts=list(state.delivered_transcripts), + interrupted=True, + ) + ) + return + + if event_type == "error": + error = getattr(event, "error", None) + code = getattr(error, "code", None) if error is not None else None + message = getattr(error, "message", "unknown") if error is not None else "unknown" + if code == self._COMMIT_EMPTY_ERROR_CODE: + # A forced final commit raced an already-empty buffer (server VAD committed + # everything). Benign and unrelated to the active turn — never fail on it. + logger.debug(f"Ignoring benign empty input-buffer commit error: {message}") + return + state.completion.set_exception(RuntimeError(f"Realtime API error: {message}")) + return + + async def _cancel_async(self, *, state: RealtimeTurnState) -> None: + """ + Truncate the in-flight response's conversation item to what was actually delivered. + + The server auto-cancels the response when it detects new speech, so we only need to + trim the conversation history to match the audio we received. + + Marks ``state.interrupted = True`` even when the truncate call fails. + Does not resolve ``state.completion``; the caller (``_route_event_async``) does that. + + Args: + state (RealtimeTurnState): The turn whose response should be cancelled. + """ + if state.current_item_id is not None: + # PCM16 @ 24 kHz: 48 bytes per millisecond. + audio_end_ms = len(state.delivered_audio) // 48 + try: + await self._connection.conversation.item.truncate( + item_id=state.current_item_id, + content_index=0, + audio_end_ms=audio_end_ms, + ) + except Exception as e: + logger.warning( + f"conversation.item.truncate failed for item {state.current_item_id} " + f"(audio_end_ms={audio_end_ms}): {e}" + ) + state.interrupted = True diff --git a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py index 7e434ac2f1..3bda8528e3 100644 --- a/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py +++ b/pyrit/prompt_target/openai/_openai_realtime_streaming_session.py @@ -20,7 +20,7 @@ RealtimeTurnState, ServerVadConfig, ) -from pyrit.prompt_target.openai.openai_realtime_target import _OpenAIRealtimeDispatcher +from pyrit.prompt_target.openai._openai_realtime_dispatcher import _OpenAIRealtimeDispatcher try: from openai import BadRequestError as _OpenAIBadRequestError # noqa: TC002 @@ -33,6 +33,9 @@ from pyrit.models import ComponentIdentifier from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer from pyrit.prompt_target.common.realtime_audio import CommittedEvent + + # Keep this type-only: openai_realtime_target imports this module at runtime, so a + # runtime import here would reintroduce a circular dependency. from pyrit.prompt_target.openai.openai_realtime_target import RealtimeTarget @@ -42,6 +45,10 @@ #: ``input_audio_buffer.commit``. Forcing a commit below this raises "buffer too small". _MIN_COMMIT_MS = 100 +#: Raised by the ``_require_*`` accessors when a wire helper runs before ``run_async`` +#: has established the connection, dispatcher, and queue. +_SESSION_NOT_ACTIVE_MSG = "Streaming session is not active; its wire helpers must run within run_async()." + def _trim_snapshot_to_speech( *, @@ -168,6 +175,21 @@ def __init__( self._dispatcher: _OpenAIRealtimeDispatcher | None = None self._queue: asyncio.Queue[Message | _SentinelDone | _SentinelError] | None = None + def _require_connection(self) -> Any: + if self._connection is None: + raise RuntimeError(_SESSION_NOT_ACTIVE_MSG) + return self._connection + + def _require_dispatcher(self) -> _OpenAIRealtimeDispatcher: + if self._dispatcher is None: + raise RuntimeError(_SESSION_NOT_ACTIVE_MSG) + return self._dispatcher + + def _require_queue(self) -> asyncio.Queue[Message | _SentinelDone | _SentinelError]: + if self._queue is None: + raise RuntimeError(_SESSION_NOT_ACTIVE_MSG) + return self._queue + async def run_async(self) -> AsyncIterator[Message]: """ Drive the streaming conversation; yield one ``Message`` per VAD-committed user turn. @@ -225,11 +247,9 @@ async def _drain_chunks_async(self) -> None: Raises: asyncio.CancelledError: Propagated when the consuming task is cancelled. """ - assert self._connection is not None - assert self._dispatcher is not None - assert self._queue is not None - - connection = self._connection + connection = self._require_connection() + dispatcher = self._require_dispatcher() + queue = self._require_queue() try: async for chunk in self._audio_chunks: if not chunk: @@ -275,12 +295,12 @@ async def _drain_chunks_async(self) -> None: # Let any commit-triggered callbacks (the one we just forced plus any # natural ones still mid-work) run to completion before signalling done. - await self._dispatcher.drain_callbacks_async() - await self._queue.put(_SentinelDone()) + await dispatcher.drain_callbacks_async() + await queue.put(_SentinelDone()) except asyncio.CancelledError: raise except BaseException as e: # noqa: BLE001 - bridged to consumer via sentinel - await self._queue.put(_SentinelError(e)) + await queue.put(_SentinelError(e)) def _on_dispatcher_failure(self, exc: BaseException) -> None: """Dispatch-loop crash bridge: unblock the consumer with a failure sentinel.""" @@ -304,7 +324,7 @@ async def _on_committed_async(self, event: CommittedEvent) -> None: Raises: asyncio.CancelledError: Propagated when the dispatcher task is cancelled. """ - assert self._queue is not None + queue = self._require_queue() sample_rate = self._target.SAMPLE_RATE_HZ async with self._pending_chunks_lock: @@ -338,11 +358,11 @@ async def _on_committed_async(self, event: CommittedEvent) -> None: try: async with self._turn_lock: message = await self._handle_committed_turn_async(event=event, raw_pcm=trimmed_pcm) - await self._queue.put(message) + await queue.put(message) except asyncio.CancelledError: raise except BaseException as e: # noqa: BLE001 - bridged to consumer via sentinel - await self._queue.put(_SentinelError(e)) + await queue.put(_SentinelError(e)) async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: bytes) -> Message: """ @@ -351,8 +371,8 @@ async def _handle_committed_turn_async(self, *, event: CommittedEvent, raw_pcm: Returns: The assistant ``Message`` for this turn (the matching user ``Message`` is persisted only). """ - assert self._connection is not None - assert self._dispatcher is not None + self._require_connection() + self._require_dispatcher() target = self._target sample_rate = target.SAMPLE_RATE_HZ @@ -446,7 +466,7 @@ async def _send_streaming_session_config_async(self) -> None: Raises: ValueError: If server VAD is disabled for this session. """ - assert self._connection is not None + connection = self._require_connection() if self._effective_vad is None: raise ValueError( "_send_streaming_session_config_async requires server VAD; " @@ -459,7 +479,7 @@ async def _send_streaming_session_config_async(self) -> None: turn_detection = config.get("audio", {}).get("input", {}).get("turn_detection") if turn_detection is not None: turn_detection["create_response"] = False - await self._connection.session.update(session=config) + await connection.session.update(session=config) async def _push_audio_chunk_async(self, pcm_bytes: bytes) -> None: """ @@ -470,15 +490,15 @@ async def _push_audio_chunk_async(self, pcm_bytes: bytes) -> None: """ if not pcm_bytes: return - assert self._connection is not None + connection = self._require_connection() audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") - await self._connection.input_audio_buffer.append(audio=audio_b64) + await connection.input_audio_buffer.append(audio=audio_b64) async def _insert_user_audio_async(self, pcm_bytes: bytes) -> None: """Insert a user message containing PCM16 mono @ 24 kHz audio into the conversation.""" - assert self._connection is not None + connection = self._require_connection() audio_b64 = base64.b64encode(pcm_bytes).decode("ascii") - await self._connection.conversation.item.create( + await connection.conversation.item.create( item={ "type": "message", "role": "user", @@ -488,8 +508,8 @@ async def _insert_user_audio_async(self, pcm_bytes: bytes) -> None: async def _delete_conversation_item_async(self, item_id: str) -> None: """Delete a conversation item by id (e.g. the server's raw user audio item).""" - assert self._connection is not None - await self._connection.conversation.item.delete(item_id=item_id) + connection = self._require_connection() + await connection.conversation.item.delete(item_id=item_id) async def _swap_user_audio_async(self, *, committed_event: CommittedEvent, converted_pcm: bytes) -> None: """ @@ -520,9 +540,9 @@ async def _request_response_async(self) -> asyncio.Future[RealtimeTargetResult]: Raises: RuntimeError: If another turn is already pending on the dispatcher. """ - assert self._connection is not None - assert self._dispatcher is not None + connection = self._require_connection() + dispatcher = self._require_dispatcher() state = RealtimeTurnState(completion=asyncio.get_running_loop().create_future()) - self._dispatcher.register_turn(state) - await self._connection.response.create() + dispatcher.register_turn(state) + await connection.response.create() return state.completion diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index acd81cc4bf..74121f441f 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -23,24 +23,21 @@ ) from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( - CommittedEvent, - RealtimeEventDispatcher, RealtimeTargetResult, - RealtimeTurnState, ServerVadConfig, ) from pyrit.prompt_target.common.target_capabilities import TargetCapabilities from pyrit.prompt_target.common.target_configuration import TargetConfiguration from pyrit.prompt_target.common.utils import limit_requests_per_minute +from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( + _OpenAIRealtimeStreamingSession, +) from pyrit.prompt_target.openai.openai_target import OpenAITarget if TYPE_CHECKING: from collections.abc import AsyncIterator from pyrit.prompt_normalizer import PromptConverterConfiguration, PromptNormalizer - from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( - _OpenAIRealtimeStreamingSession, - ) logger = logging.getLogger(__name__) @@ -172,12 +169,6 @@ def open_streaming_session( (but not yielded). The session owns its websocket connection + dispatcher for the duration of ``run_async``. """ - # Local import: the session module imports ``_OpenAIRealtimeDispatcher`` from - # this module, so a module-level import here would be circular. - from pyrit.prompt_target.openai._openai_realtime_streaming_session import ( - _OpenAIRealtimeStreamingSession, - ) - return _OpenAIRealtimeStreamingSession( target=self, audio_chunks=audio_chunks, @@ -977,143 +968,3 @@ async def _construct_message_from_response_async(self, response: Any, request: A This implementation exists to satisfy the abstract base class requirement. """ raise NotImplementedError("RealtimeTarget uses receive_events for message construction") - - -class _OpenAIRealtimeDispatcher(RealtimeEventDispatcher): - """ - Concrete ``RealtimeEventDispatcher`` for the OpenAI Realtime API. - - Routes OpenAI server events into the active ``RealtimeTurnState`` and issues - ``response.cancel`` plus ``conversation.item.truncate`` when interrupted. - """ - - #: Error code the server returns when an explicit commit finds an empty buffer - #: (server VAD already committed the final phrase). Benign for streaming sessions. - _COMMIT_EMPTY_ERROR_CODE: ClassVar[str] = "input_audio_buffer_commit_empty" - - async def _route_event_async(self, *, event: Any, state: RealtimeTurnState | None) -> None: - """Route an OpenAI Realtime event to the active turn or to an input-side callback.""" - event_type = getattr(event, "type", "") - - # Capture audio_start_ms from speech_started for the next committed event. - # The server reports it reliably here but omits it from the commit event itself. - # Do not return — the downstream state-aware branch still needs to fire the - # barge-in cancel when speech starts mid-response. - if event_type == "input_audio_buffer.speech_started": - speech_start = getattr(event, "audio_start_ms", None) - if speech_start is not None: - self._pending_speech_start_ms = speech_start - - # Input-side events fire callbacks regardless of whether a turn is registered. - if event_type == "input_audio_buffer.committed": - item_id = getattr(event, "item_id", None) - if item_id is None: - return - audio_start_ms = getattr(event, "audio_start_ms", None) - if audio_start_ms is None: - audio_start_ms = self._pending_speech_start_ms - self._pending_speech_start_ms = None - self._fire_committed_callback( - CommittedEvent( - item_id=item_id, - audio_start_ms=audio_start_ms, - ) - ) - return - - # Remaining events are output-side and mutate per-turn state; drop if no turn. - if state is None or state.completion.done(): - return - - if event_type == "response.created": - state.is_responding = True - response = getattr(event, "response", None) - if response is not None: - state.last_response_id = getattr(response, "id", None) - return - - if event_type in ("response.output_item.added", "response.output_item.created"): - item = getattr(event, "item", None) - if item is not None: - state.current_item_id = getattr(item, "id", None) - return - - if event_type in ("response.audio.delta", "response.output_audio.delta"): - delta = getattr(event, "delta", "") - if delta: - state.delivered_audio.extend(base64.b64decode(delta)) - return - - if event_type in ("response.audio_transcript.delta", "response.output_audio_transcript.delta"): - delta = getattr(event, "delta", "") - if delta: - state.delivered_transcripts.append(delta) - return - - if event_type == "response.done": - response = getattr(event, "response", None) - done_response_id = getattr(response, "id", None) if response is not None else None - if state.last_response_id is not None and done_response_id != state.last_response_id: - # Stale event from a cancelled response; drop without resolving. - return - state.is_responding = False - state.completion.set_result( - RealtimeTargetResult( - audio_bytes=bytes(state.delivered_audio), - transcripts=list(state.delivered_transcripts), - ) - ) - return - - if event_type == "input_audio_buffer.speech_started" and state.is_responding: - await self._cancel_async(state=state) - state.is_responding = False - state.completion.set_result( - RealtimeTargetResult( - audio_bytes=bytes(state.delivered_audio), - transcripts=list(state.delivered_transcripts), - interrupted=True, - ) - ) - return - - if event_type == "error": - error = getattr(event, "error", None) - code = getattr(error, "code", None) if error is not None else None - message = getattr(error, "message", "unknown") if error is not None else "unknown" - if code == self._COMMIT_EMPTY_ERROR_CODE: - # A forced final commit raced an already-empty buffer (server VAD committed - # everything). Benign and unrelated to the active turn — never fail on it. - logger.debug(f"Ignoring benign empty input-buffer commit error: {message}") - return - state.completion.set_exception(RuntimeError(f"Realtime API error: {message}")) - return - - async def _cancel_async(self, *, state: RealtimeTurnState) -> None: - """ - Truncate the in-flight response's conversation item to what was actually delivered. - - The server auto-cancels the response when it detects new speech, so we only need to - trim the conversation history to match the audio we received. - - Marks ``state.interrupted = True`` even when the truncate call fails. - Does not resolve ``state.completion``; the caller (``_route_event_async``) does that. - - Args: - state (RealtimeTurnState): The turn whose response should be cancelled. - """ - if state.current_item_id is not None: - # PCM16 @ 24 kHz: 48 bytes per millisecond. - audio_end_ms = len(state.delivered_audio) // 48 - try: - await self._connection.conversation.item.truncate( - item_id=state.current_item_id, - content_index=0, - audio_end_ms=audio_end_ms, - ) - except Exception as e: - logger.warning( - f"conversation.item.truncate failed for item {state.current_item_id} " - f"(audio_end_ms={audio_end_ms}): {e}" - ) - state.interrupted = True diff --git a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py index 3edc8f9735..c765e7e4d6 100644 --- a/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py +++ b/tests/unit/prompt_target/target/test_openai_realtime_streaming_session.py @@ -779,7 +779,7 @@ def _fake_session_ctor(**kwargs): return MagicMock(name="session") with patch( - "pyrit.prompt_target.openai._openai_realtime_streaming_session._OpenAIRealtimeStreamingSession", + "pyrit.prompt_target.openai.openai_realtime_target._OpenAIRealtimeStreamingSession", side_effect=_fake_session_ctor, ): target.open_streaming_session( diff --git a/tests/unit/prompt_target/target/test_realtime_target.py b/tests/unit/prompt_target/target/test_realtime_target.py index 3c80800c9b..53fac1c5ef 100644 --- a/tests/unit/prompt_target/target/test_realtime_target.py +++ b/tests/unit/prompt_target/target/test_realtime_target.py @@ -17,7 +17,7 @@ RealtimeTargetResult, RealtimeTurnState, ) -from pyrit.prompt_target.openai.openai_realtime_target import ( +from pyrit.prompt_target.openai._openai_realtime_dispatcher import ( _OpenAIRealtimeDispatcher, ) From 27e61f51f5eac0c88493fe58a6b59ca905482e5c Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Wed, 3 Jun 2026 12:07:45 -0400 Subject: [PATCH 47/47] Use save_formatted_audio_async; drop redundant PromptTarget base - Revert save_formatted_audio -> save_formatted_audio_async (the plain alias is deprecated, removed_in 0.16.0); a merge from main had switched it. - Drop the redundant PromptTarget base from RealtimeTarget; OpenAITarget already subclasses PromptTarget, so the MRO is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pyrit/prompt_target/openai/openai_realtime_target.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyrit/prompt_target/openai/openai_realtime_target.py b/pyrit/prompt_target/openai/openai_realtime_target.py index 74121f441f..0197d5ba64 100644 --- a/pyrit/prompt_target/openai/openai_realtime_target.py +++ b/pyrit/prompt_target/openai/openai_realtime_target.py @@ -21,7 +21,6 @@ construct_response_from_request, data_serializer_factory, ) -from pyrit.prompt_target.common.prompt_target import PromptTarget from pyrit.prompt_target.common.realtime_audio import ( RealtimeTargetResult, ServerVadConfig, @@ -47,7 +46,7 @@ RealTimeVoice = Literal["alloy", "ash", "ballad", "coral", "echo", "sage", "shimmer", "verse", "marin", "cedar"] -class RealtimeTarget(OpenAITarget, PromptTarget): +class RealtimeTarget(OpenAITarget): """ A prompt target for Azure OpenAI Realtime API. @@ -568,7 +567,7 @@ async def save_audio_async( """ data = data_serializer_factory(category="prompt-memory-entries", data_type="audio_path") - await data.save_formatted_audio( + await data.save_formatted_audio_async( data=audio_bytes, output_filename=output_filename, num_channels=num_channels,