From a26052b7b23995b055eb2df9d87b55f23f97fa78 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Thu, 14 May 2026 12:28:31 -0400 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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 zcmeFZWq1_H_cmPBJ(`hBCKH1s1W0gS+}+)GvBllp-Q8JWad&qXcXx*nAg<%n-BtCT zn!xV!|NegbHL# zJm(?E19tDYtFa4-dx`M+u(yg^~s;}fqi73Sv<5>5=&-@><6TNKsuGBfuz8mOFGK{ z$z)j|*(`@S;Fs-}9Ohz9zqpv2dHllnJ|_PZ!YE_#<6Dk~1rQObK~z6QgkzqgMLMMS z|BNUAj#NkkTb`o@;b{Zp>-_ifeEtNDKcAoHSMV!E$jyDa+JCJG?^DA$p2x4?cjO-Z zcUOS_xq4)RHyDr^_Qs!LLV+NBYXJ#_bNq}M1^KTs!Iqz~g7EVe$p5vqqEN^Uf}ah9 zUl9xv^i%lRke_FQ{VDzgeg%J;4bJi>@jG)R1afTt^L!fw=@9>2!Tu-PP}tA2{5`+! zvHUC@FA;x72;9d@YxY0c3i|+mU3h(YO?d6~kdwRTZJFC*x}U9<+vdDIc@O4_;Gf@! z{w<&B@N@s~AMba{$WP%q@Jkoizq$OZ0>U*$@{14j#|K*E_6yf4u4Q%*$4~jCe_RXg z@Z*}91sa+OdYTTB0y>-s!nHcZui4*0vlHR>jeY$opZ%Ku2}S^y4&D*2HXPhk;um&AD6jfnD6g_++nxf^0Qa{S6qfO z*C5631aFzQ6y|CUIMtE6<>% zcso6TU#?B!ZTIG<&3y~@hqv-CoAa4{VF~}rH|QH~dlLOtCk3`?(3`w3dGBWXZ4S3Q zZsrBuQ2h3UTa;Xzl4~oth6aEZa~R!DMN}D8{VCN^EmYl~u8wL#x&|E80I7-Uz){Vg z!c+WAEmRw%E=V0z54N>oUl-N)ZyUm%=Qlu&;MV{(M2-FXMsVC1H3ew`(iAmA&HYDw z-vYHlEn(XXek~#02DOD>JHNDn6hCf5d)qI~8mwFl_{`wsqVJEA{OCph|b zmZ$!c&ZvuDx}x9V-aq`?+WI=}qh}ko9aGTg%pftb^Zbwu-G}%h^)40=A3UA`re^2r{3| z1DVa{um!N63o?_5$5mX>1Cc#`y6xHWkv7*aS8Xev{ycA5Q?82&su|yg$v4 zc-|y`&REEu=$A?UoL}iFY%<6cxNq7|neKnyba>NrC_%2wg3`@}9~WM}xlrZ>@LTAw z2j4FB*JKIomomOx2Ex-zp|-1_{;NRNLMyC>E#I$%A8(sAaI_BEinrE!wgH5v`F;bm z;W`k$-w18G8QS)5*yhULYzrLmmfrYNHo?*VP4iyh&*8Z|&5v^5l-n!0eZ+eww~u%k zc?o&xxbU7@2DMrIQ+UmIpXJsvw-0$Q{u17oxoO_7yiI;x&0AwR2yYF(&25#H{+8jo zu^fK6?XnhX!ArRT_PoAZ{IV6aY!}e{5UH9;vb=3{CFYWKXMZQKX4z#$Mz3DQs$m28Ag33_$ZD& zxDCkiW0f4g-?9U8$we(LKZ?mk$sF0_qGgVBq@Tzq7X!)Q-8d%7MN8cJa8s1K=LqW; z&iaM5eqpd&8`|60Fc01R1 z=N@q+%q=?i=6S)F^Q-dyyf!x{3Pr+kBnUqr?e_%y=vO)l<%4w8zrG?L9Ow57M+F5? ztlxuhbP$IM!BH&i3qoEDqzVB}Cx8w1+ln1dsY#(p%>0uTk4T#UhyL@U4$xtOB0 zA5FCT>Df7gX#dmSb7avGkOUWw6@KA?KTucH4W!5aBY&cvsJCDGqQ0mPq zgl`9;A!q>X2Y?I&`L!Q}2KkQ%`mf~q{ZT*v*#U4S_Z-jT^4~T6;642yhrh8er26=e zd79td_h)JN`P?)7?%e!7{%d#%xbQdpDitpmzluwLG#JV|AQ6tBMykB`gw}!rN=|`7M{aVowbfnQwJ>l53?oWhP z)vt|QOUwJUmv3`X8rSyHKhbnaKT<1>N`Uau@oOw_ZRePpqw0$O5m63CN$$AG9bbk0 z*eCT+_}2JZxi+ZVm-bdz*h_Y`{OfJ zgs6We`hRDjZm`_B*8A5!*P=VYhC9I4^A(U^>mM%2^ZuO2=3+%|-MR2NuH?7s#BcNe zn+fB8*DN>!75pfae=X!#lv{N^yV3oe+3>XoZXt751NdqIuWuySp%Tz`d{j60kH8Ml zkIkX4>O7E;?-yjzJUAS7B~$w z%nly496Y}nlw=tS$2#F4oGXWS;rn<8eudtmLwE{ah7)iBAyl}EKjLG!Dt4mV=otD7 zO$03tgK}*Ltr5Y3#h^VbftF(h*eQwtEf#~;nt)cI^C%OQ!o#4Y-Oxuicwc?6pe0a4 zoQm@ZfkF>KFFqIg2v=}HAx!uSx55hAhSC{gA4no8PjA!COpo4y|4L=uU`$; zo=%`{ItUQQA;46_K);qCE3S@*;BDB5XF<?&0}xXnnu?yVF>DCy z&xQl;O=cSOnQ52-FiJhh`yK5^uh|z;iJqpv!;|)~v+M)3S0$E7_tOFN3H8!%^bbI3 z_t^}Tga{giuCO1p6`;{OYyw?KeSoR%!;JklQ$ssA=mOdu+H^a8N2<}0bSf=?47d^= zfWAY`-ys?Fv^JD+5sGGeXaQCe`uaAt07`9$hCv-a&{Ciiv7m29&@RwHzB+jskYy|O zo=hcGXckk@pSUlsfs@b#ToYFY+oeXO*!`ymXcoN8re@Lu`o8B)S~^Vm!y)8v<9H(;n1=NXg#(8bT$oaRCi`&jhTww zp)2VP`U6^G8lcbvECO`r3Ntb%9mlFNGwk!TO|&ODO)`m*4ku^H584|fk(OkEY$-j; zO2VjJ2KDcS)<7NS(YLe>btp5HR-^}uVBhFvdIM^;3cUFTn7<4lbCj3LG7?BTQ5$IE z575>ObX903)WjR$jhXZm4Ttg{r}LrIujyS{98h9Q(A-*hBnzQM$#b%Y?g7jl#w<{Z zo@kcv1Fyid@mTZ)+UGTltYP2@elR<|M*n0_;kXiRjDN$kq2KNFFdaw>LOmJGH)pU| z){@>Q>q%jHl2!psKc5A#MQAj>jl+eDrp$DkC( z*E2%MU!mKVmN`}K&>q0+~?PMWYN7k?kXc9Y#D4?P~ps|yoM3Hn2*$i6r zoeZJRXfWFcR&W9ygF6U$RfBOy^d7BaI|z|pDN~6{38Q%#MkYL07^teFdM4%-SKvc{ z3aZe-L`|QNCqzSc(dE!1Uxk;#Hh9lX_KvotbIB-Wi_)Jwp*PqvTuOCA_=*=|AHIvd zsH~7*xP#h&_9C_Aa8LI@GVg^hRw-hfxYxNL^)`r?L z)C-<41@8l!J_sj*rTq@0`#LF4BES-@V)66?nQ16)PuBoq{)gkAVNSlJb^bpsgiQ3tR20JKx$5vb(-37b18vImM(5r?piryfXFi!kKunTvDw>SbkP^?e}toRw+ z9UY{b6&E?gu7X#Zj$1?CAzT*6p(&`MunFtX6f%Uophs~>>;eyP0zZRxh(u$-Dzri! z!J~x;O@+O}d>n-b;6KpSx zuhA9oC_4NepA;Xl9%P`hlI)_}*f5-h3*Z>sLfEHTC9KEi@g~%m{X-0B2c9jQ#xrmt zekjZnLb09IVha6@CD90allB3Wa*q{8!FVx_#b4N8Xa-uviqmG)&d#7nc;;#J5v{|g z@KMm5O6VzT58kT|N0xa4z;V9Jpr7%dWE;bjs3Uh?(f&#YAiw6kt z&_-64kIh5Ru^X_)O_T>au@Bnl1RDbWAK^i04r@+Z(n9nlDFox71v`)9#Fb(Pv8%X8 zjK@J>`R)KF>IviU4Em0);|YRVvIT1gmYk-C z0pH|DSJ4z-RKhQPwIckJ+SaHBx+W|{e#$FtcFX2Ua0a(jd zv;|7HqL1`8TMg(dTv&oPvoh!r8ws}fJIwJW&H5W^ zb`S>$4TYP+9I>V_6Sbht=`^^*%*sLi&eLV!Ne6+o>)U3p?9Lu5Ox;u z##h=8yu)ok3?JD+zz3(cY=z@Gs7UI33emnOla;t^d7 z$gmjTzG`?kdIRYDFX|$tXnm%mf6(fv2)hSJq72yXPvE%=Qxo8w<8%yQ)Eg`g5PDy9 z5A|p1fX1rQ5~QrshBSxk|E4yu!qDDqHQrkqoZ zw2c2bt5F+#4dyo*=;i=0dSBk6ef5vE}qQnF)(nB^d^! zauCpQS@6eu0OOCNn9d+}r7>wpGGR)jC0at{QpyH0gR~?nVwXS5x0Q=l@D@pWs33yeBVFx7VQuIq8zM3&If-L1$Zoh)}mwS9MF$=v`RQ5dR4z` zrfI%trfTCfozyn6vemFT%IaTi6@x znd~m~lfF=Ro^~>~%{;uAx zJER$)DkLO;buLeLDP84mQcd42ufy9;S}$Kwb}C1eZ{U?)Lm$;*P3dkWL8{}s=4t1? z>I`sH%UPclo!K?xQ~Hkdw&~$%&g7V+eThqxN~X|Ml<_$INrsTU(catD-TU5GSZ=7a zCgo@<4G_C%KIuvrP8-LY_F0|<=Y+b#oMG9a??Ud|t_NF!w_C?ppIY4JB;#U3h+($D zVh9KrsoSm=RU5>Q!ee2$_*F0n8OVzo!zlSA?ogFbiK>%g4RO4xt@??2pSqlSn5v=p zP+-DBz?lz`nm&|cq(a{Q?(fd3_Q6@>(_g35O}g}5_5ID)N}tm|YS$Cac65!h}SGtBhQG7>?gN{4Z4`XBf;H5ONQ0UTPj~>frI&L#_WsU8JQ5# zCp0|xOVD}iQmZwnvAMM|+Hfpja6n^SL)GsXvzp|gTuSaOH&yDBwzLS{M$fTqd_^pz zHmX{QIl>Ti7wtc~R9zGOC;bimcU>KAjOMZ`Of=wha>>`)o8+G9T4^7hRW!Y1N`=JQ z-`;&*|9(XT7sKU3WcIe2t;!YLmJwR5vcLPsp4+ zWugbhWW_Ek+_1=&LWN_$#7vKh$#Xq)O-OiX%kW*H+imr%?E>4G>Kb?JmZ=n0R4M8s z?(Ob=p5{KAa*qrGlyIM(Vx#bA;jFM!2o$TSPHM{O&lv8Q41u!(o0x_L*fk#20$~?B zrOfc@-4~p3j%GQY%)#kGGar~#Z@!5$tQ=4Q?%b9H- z>*(OLx`{i~GYYQ^7;WvJXLGbQ-@SrO3a=_!s!-d4H0Eqnlf0)Qx`vO4$d2q3-6Kk$ zXO?ZOCBf7zV2|3)UMrs^1Bg|QCWY8%)JW*SMv*N_9TG|-*&tL)_)9fMzrpy@lpGjm zSz?}SROt(=s^UBJkup?X;j7>o?4Ir#?NqX2(&LjW#k=0-c_BPn`0(hn{BJjZib?2| zaOHcIlnd!`nUga@v&ZIa$m#ByM#6;9#*_cK9ZmKNIRH=R2O1#A`zs*-VWYH$_rFR zy-)Yhu+eM^inQJdYGs*g8mQl>-hlg%#$fkgR>J-w`K0r%VL7!j;**wrZTvpxdB?{S z9`1g2>D@oylfPF;+?yPkxb8VHSSfX7zzmpq z^0@wSJo5Y?TFu{vrGc-4dIf1MH%!$Ht@TVhT&t+}iG##@;tkbN^;U5u4fn?7oKL|? z(-KO3-1WBVtITIdUibeLmY9*4lUN{eYC_!CJPEbGcKqHoEzZ8qb4>mNIki))sS!0I z$3{3pW`^Vo{}OR5@2aS(1)j#Xi%X3Wqqaso$g4#BW-}OytJbo5!WVI%YLcq2s+j6L znyW1LymNQ=MEi)x=IQM|C?z7BexdP_`AA?bQvpLK{aI~_Hch|N*vC}b=+lqa9#jny z!_g_}lT*$#rOZj%{Jnj=?Nj*so^O`Ezw{+Nas0P{gk1@pzO?zWI-y6xs~^tvAuhf2 zKx&HCXbzY%Lk@&}w#Ecc4#|pW8d*4cUcTi8|BgEv7njc!5glp^SA}I-I|htXw^H@g zbkjD_%3=zj#i@$RGtqI+;c*GlD#32InTuO*d`jG7TJ|>~f*VSJ?eJz!c z`u%ds-t6A)sXm9Volqd4dyp!4agg1Jg2F;8M0Sk2k#BOrd$FYp9*Q=F&kWIqeG97? zf&!cBpJqyLxBAA}FX;|rz_$Sj>PK6&*w z_T}QIGhYsV-So|tVRf$aF7~~W$FfVhqUOtiT?}vZzZq@>H_3A=n&m$l`=U^Xm`&lk z1M6C}HfNry;dg_#nHCvd1WYw(HT!T+`i5wg9B-6+uY0Amo0g&_d{v!`9CN&^=eT6?US3&{lhAew5Nf!X)im-A#>2<;FA63HnHW<{9RGu zzDT(Iqj1(j&pKZ#SNYVK1nVcwmp9+$q&2q>@qQx{yeA!NvSjz~?1=7y;j5v5e!0;T z*d=&+==+EvQE8EK_}@XXfe(Y$M%;-CiJBf(-ZCzrzGja)T^*|JY zd8SII6uY!onjx1^X2B}h7SfGf!Hr;!*htw))xt2%GEKH>Ext%@NJqQ}+@n1K@}J&q zj)&<55{vx!oHEJvTC)3)y<*C$FI~Uf{`xfWYW6Ij4VNJI+!-1A9F;d-h&8-6drkLD zTJs6>+rYiS(Rn^b7K$Wco}g+$cS4jrCnGcRd$39gVfUz z<4*FOfmwz?7ZHbCLMrdoN%hEg+)(I_N-4dS?lhl(RhPx~co((HHGRqMLLQ&5gXD6* z&svk5{Ubf;57#c`A!+O?oIWUFM*N=%hGbLr6kkWxcUHn%Dnpx5-|>T`1mw3Y3oKy{ zGqo|!49I6HYO9p@x9F&-Vj*pURtG%}o04a9#PECok6*fxdGL})=(X^cF zclS|WdEjn6gcWs#jFK;S>Uu8v3b8Fhd!Z5iB!7|{!g@#nG#XYTshnSmaX)m%xmUTj zIa_8gNRCS?oPN^VoMtN_j-b>ZUjx2XNgSEB#i^smv^KollRvFM#za?~>aEEgG~Hqj zOf}j9uIcU>iU)7VyE(d2WRPu#B{isN==HGnp`EM`0*{%W8q)(b`gKO5xxcB5DoI}K zEa0n3Uy5G*o=qTSls_fCcbk{W1%#TanL@fURyrnaA+2B?s+v+z87AkIPP-pF`#aa$ z4`uC1Yn?PG)#;o-PSJhdyXmdJ*ZtD#`}owEIX~n8FoN5Re;a)=UrwG2!6kwk2Nw)!7xFGR&1$u_2-;!_G;GsPG}bc`T|P8d zD&s9qZX#K@&u)=4Ww$ibJIy`9cZCcQ#|Ubc&sWITOj@dRQGD_MK>SE5;C|*1?dP&b zWpBtRmfSHt#qonqCrO^j%m+V~ee0JLl^)VKt0R zi}IGG!>l!i1`wTc(_6vQ#nVMujj+m2pG(_3XT4v15%L{rvRs~g_nF-%ayn-9&-|V_ zBz-|z<@8ye8e{-*I?JaQ`*!lj)s)s*MP26te-$sF&v!>$`bt=FxJ<${?Q zT57s#iWv8Wb;?)1;1(DeY3B2mJwYdej#?Ml>W7`TO$?l?y{>+uIUL~Bwih2NSCld| zAAU+_D^(<Up?zsF>xdwEFHJk&5m^JXWav;2lunyu(UPn^kz*j z^?TFq(ChgI7FmnyUWwSTTj^r-zM(KO?~m6Xm7fwnWvSvwl7-B^jvl>$v%^QFZE_>dg`XE zr{20`g>v34W{gP;_;xpCdUk1#iA@lPtMbX~9q%$;I1kcZhJ%(l)^v+(+NysmhH9!C zn}u$P%9HO(o_4mgmbAbRfmJM;pr^s(LcAf10$ut_>K7WOxu9-~C(&}G3~*EdRDrrvsu=Nw4o_^U_KUY|Hrpm zc_FoPj7Tf`tzsfeo9p}`_rrR14YtKwJm-Pk>~o4oOpB}wtV_)60`_PYYDxyI3JQ;q zBdbQGgm$n_53Fi&SoR0iuwJ#63;to*Z0e{Vr)#ZEP(Q{~X_kDK{K0miy+kEn^gi%J zcy_ywd3O6|D>szJvdufgt#XIfTZcZ7O-N@UK?NHyvTjTnYqQ)Qt2w($ERteZMC6q5B9%{_xWfwj$5jZ(mA(SE_M@32)6OUNNn4y%(skMUr!U-7J108hcJvl9WOK53a z5vz#DXZH3ZgT<(T7p5hFO#-7$*qCGJU_KF~4=oxV8!m@T42cQJw)KQh)Q>Fh%uUSs z0;`()n6?;`^pDl;(0S#u)I+*RqSzdYWS6I}Yq+zn^ON(CyOg(&FTlId9q*jr9OAs- z+~KHVpPv0AJ1ASoyqlho);g_iW@meE&olRT*B0ljtVS8|mwnGQUAcfWgmAQugen{4 zt3tH?g!#H9zxA2*gSoirs>u|j*p^0k@@$Kk7h(<_7P32dqIFo%T+4{Sx#kOiig|@n-@MPEIjOP=;YAxp|+5A)?0x!%ok0HF~hhNW;`~1HPvkT zhjiUDL)x#@pc|C)(su7N*AZx&@s3ii?w(hkMxN8Ity zznESibEVN< z6l{GKyd-2!=($j1Sl6&UVV5EvMZ61p5ZuSI%amw*XzXa(X!>N#Z#b%Mg+#fAG)&qc z50OtuLwviulRb9Vey7%HbZ&4yhaSA`Z0>65zUZuBf05ZIqegnSwCU;VGInI1${FIk z?|$Vh>-e0LkVUde+QXgeJbRRqutph-hQl|y>S(O`zP_rdeqiySOzZ5Rc(AMAtT7>% z!^(%h3ELYghsA}*hCQ{7u&ONKfyt(^#u7%w@ZC^M|5lYn+X4FNEFY1N%RS_i(lp;c zo)OMF_M3K(bDn#Yr7|u(lwh5lT7J>+14Z0gVrQ#bK9AaLSdTlePPDX6y?xxtBMJOet-qIv#gjB9p~(Waz}X=h^4!@BE$DI(9*Ro+93@>TZhGjtqR#=9UL?>Fv(coa1VUa zINd|-I88tCHd;dqC|aqGH{7$+li+jv>iX`$noob%1!q-fQRiG|l5-!F=|N7pY$dZ{ zmXsNhc_o8nzRXE;3~)7fyPakrw!+pDL8k)$F~u4N=ojeb>b7cMtMdp;SP6PY zSu7urQhl3!*T5g0b{BTdcJKFW@mWg4bAwGjx(=ipLQ&A z)pE~py?3^BhB}ryj=QYhSCWr3VwK@bSF#waX`y|l>k#nXpfgo8_X_M0)H`@+$hVLc zA){;?gVzN2v}tXVg3nu+9`Z?tSRm z;k3F=xLbM>-S=F_om(8A?LF;r_6_#u_Lug)_U!C&nL^f{?4Aynvy|KD`RpFxKH%!_ zigG{l-0?k--;l!W5b7taQg_fE*Vfbhr8})7`tybqQzJ_+(5F(t@j)#u_W~~j7O-p$ zj0xOr&TDRJDsP-#)6KKoUCmv> z-OD}2ExJ9gQAC#_Z&fnSHa^~5?oW)%O+)q7Y+|OO9u4E^2m3KGwPLb{^%~=uL zRnVw*sVb{lsHW-NQUH+!x*Xylvc*+?&0He1oNdN)sBzzSAh&7QRvns?zEJ%?9mC zol{pkV4mTYvAikVJSy;($!%1bS{d^g`xqw~R0eFgqL;O&)g{$y#1xbbY@E%+LqvLw zY$A_gE&qgMlm`0Vc=P%yN^7NtQe)o^Z-{q?rV?4*e zZ^d{ePbtqx&kgTbZ)KlRb}Rcy99fLc;REQiAOVpqRJ|P}O!rj(Fd)T{Y5ZWyGJY`r zZOU(G77%5)ZLq>DWVS9+cVAmV6EFTiZQ&bfJvN^%r(IY{@>=Og`^hS)tkhm9q8#>> zlS7maN?TuwH$ny!>)Qcx%NyW%|5xG@+5eAN&BVKUWc!^)Wvtom*q1`dx4cR zQATVyy2Tm+@8BK&O{gxqRX&xhRJWI6CVK>Dp_*YwX&|+8OGv z>Kye^^(?#=B@6G^M0$rdLWh87vVdHKdS)qolvJ7wJ-bbcqrr5rG@lr#PaZ5a^eyq~ zd^X=P?-rlSciMN$*TXm0`^>x7tM;DtnIyIEk*|+@pM;aQijfRrU0A%LL64whH-vGj z7TQ#?in^-dFU?WSRKt0#PCG~+rwi7V*IV>PU17~}{Y=ez<(jI2CKk9fec?O%df=b! zKy~OnQjL8C{(^(OQYMg3*u+f0ORC0J(F9+loLBjz#L82o@6sh@ppxR7E{XDJd8yP( z&L$6JQCUwfOJ$^NC0jY6R8$7Tm)&~wBWuI9E2D7=#xy_P3ExP*iSyK>wYA{8NmK1m zaf&!z+gRN}y;b{6onNz5cUJXXv}vlS@(YhxSM>xjzvL0lsJb(axLoj&BWMsVfxgOA znIncP=?c~qWj1o1tp^T4CFPA&QEDeWg)ecj(gV7hO;P?NACwHqC0muc&{~z~LM0#V z0N<+H08^tRg!zpCrqoV)0vHti#C2jdv7ac27U6(!UmT%+go_CaG+or8>T~KbVn5Y# zU5GFPJr<+IyZAmUrrxB=OI50G;yH2`J;l#}-34qMItsoBXD~tsqY5eye8H+iPLM_L zZLT$WPTS(g9Nm2_j2fc7K#OdNKd9%`x-b5vU-H{&~l&0jfY8E-C z%zzb=7}8 z(R4w^_gG$J7Xr~_Rso-6)A3)LzQ8A{g2Uj8{4sojHDTj{LHYu|YoAADaXI04;4Bmp zcafu{p=dxQ=^^q(Xd$@dJoGrUbq%(h9F>1#_0VUSXKkgc&_{L}zNci;YixspNo86S zIG2}!5wH_DQ@4b^bSV8S9AqEqC^SzjEe>OS@LPNrb%pnPf#2{LZ5CHz0&IZcnBrx? zpx(*~P@B*VDc(h5XLUc|qHRFcR1v^u?5MgzKGEY^JvpZA){FzrSwVaqm>AcHgm0io zsXXwdr%1uL8ghBIlJ)EpSxl?L3fmsil?4e=vYOn}o|m1{By|I3B?;6jXwfxqv`||t zqS$aATv;&zJ8LPhJ~p8gQXD^kvG9XjMs0NUNvcv`TbHGfDq>e*8`H?2gxkV(X%w)h z)MNlk#&hv;C5JZBErve52@Kf^=rNmy+n~nEGS&($0nXG3VA5&vUi66El1o#AdJvtY zRA)uNk^~96l-J5W;SOC)T);gWM0|2Du?@cMs)pAJFMMa26@8P|p-Z?UJwS$|otk#! zvs_PT0gMNXLfAao0r*0rutoil9-*gHZ-8&QN9+sC$ak7HcsQ`%<_HFS%ePZpAuM)} z6t0M4r4Td=U#9QyG<=ZECp(xVo>tx|{ZvJO)4EEGVR3W^8VBDix8T;wALNqCK<6uE zL65JnB0eW@eZENH%);(U58=zPEDfa#P;;CLOo{EN5m=!I;uG?Yq=;$YuP(E1=x>%G zH^BzsH()xB118-S;XYa?zs4a#8)YZtJOrlN6R6)}V3gd)Gl0brBR(TfNu)^No839F zENGgbp1@*>ibld5u8y<}9~QQd>cFs%qaIp;eSw+vC)!DcfR~{WDzZQ5Xo#~bOyi{p zTvP}I?%F2031zd*bc^zgB?8L~v1GcA6$Pfp092d#z@~m-M<`Mni9Q_St%3`RFGvzW zC`xq-)ARB=0d;@-ECR)$F z0^4LYu%pV$?~n&AmmAUJ=#fwX+Ovj+&}($EDdT+5sO(Y=Y!L?m<4|Ek*(>}J%~95)j=%|D24nA%x-e}*3uwO3BV?KIw=f!6 zlu%*5(3{i}9$>A~19YPZa2)dCJ1RA+M7s%NfF+YDya(2ED^)Dbq=z(5=_2}89fZaM zvY0Gh!cV*r!ZX1u{Vom>%6ebHSGFC#n~dNVz)k9hW(YCp72AsEz-VeBHlhyNNwz)w2O?7*2Fq)ZoT2?ynFz;&vQ1_9H!faWTl4`tm0Y)Ui4$4x*tP#VjR`r^UB zJ-!Z1Of4QyIs(7+t6GKJmnCYjB|i(E~8Gk6fn-K;`+cCLSkPSJ?qgfTo8vqoW??E z%T&yeL@a_%_(tlZh4>^s4rkU1b>NF>FX0Cw;I(c8XM6!*m9A(h{vEb!@JEOcJb)hp z7b-t68ZQB#u^4a#O8}QS2XInNh`i9kxsJf=Y6Cp7L-;vF12MONjdT z3-1T^-6Y^64hIh2Kfof~2z#rD-cT{ATKL` z&I7-{8ALIFF99!}2UyB;p}e;M^^63oYJlj86Tl~|gjzBSSe|qi3K3^LAzrW~v{)$I zcM85H{|2!{2hd_90Y13|Ea_e7IOH`(TTlhO2BM2b!nuXO3LFP~|8?L&+n}Y;c1@t{ zJPN%RaOHv_%BKOut=Pdbgh5MZLcF96q9yELF<$^i^FZ{$8)iVs(95SF^&Cd30=>Th zVjq6M$oK&a*6VPU7VdZoIpu)SO`tFG0gDYm|CNQ~k`Nga2VC{$@T&~1-4lK_p)3uc zMpgWgPqiU#rZlucF(^rG;PRW`tP$oLI%q2%CzJ`iVgCPDUO}|N2l&4T+oA0qf&DuJ zk*xb6#(F12?QVtbE_M`RUDw0WcJL6};LI)1or7@odARl;xaT$;$Ajh^hq03lCH??> zS>lf;<^K^w2J6n_fOrq7Kr{G%y(onE|GFpu+e9dj2evPvjWQul_6@|AKZa600sbJ^ z0Ep7K08jtRkC0}=2tE(dIJ_i#Axd`zL_)8HlJOYc^$?xRBaAn}SBjNTejcH`8-9DB zoI8Obf59KGdmU@JT<=TW@81jpd~ z0eJ6mIKK5>0=?bW(4Gph2v?! zKkW znpS6j!uRIlaMS}>p}bYv00;JWsL3KITWwYnjt@gE$AA{phFFdMFfLEA-Vk|l2fiq^ z1ZHjwjK!P4)h!0s+kkT&2{Wd3P`BTpoD-qk6QP77pya*$^&Z8#LCcKxx8x9b_ZoOl zeW=v}NKb$g&4qUj1-)DdZQl*@RzeKMLfG@RTMSyc0M7OST|3L-pgxDedht8ffSxXg zURnV9dR161~zgtJUirfmk|H|kkwBAdwE=Jx&=m!xP zuW4QMQoN2!qA+NMGiZ~*0BbcM-S9b~Jx(P9*kg5h^=P!z(~I24w^ftq0i^@msKUY` zPb(TO{!o`Eo0UjaAHaS!@J_Ug)uzi(C76L-0EYHiK-?MlATUf1KzvmKaD0!DVzf8v zqN*k)(H@??Xq&33n4cabNf>4&=$Ud^$%7O17L+LWBRN7RVEfe|t?4tBMW{p@`YNlc zXqvFSN+Q*0PGjH&OWkn1>b9!7Tt?ZZO+twfrL;#ZfGwU(>`=#u3T;IraJFE@)8)}@ zh%i7k1o*9`#o0m+)lFS%!sHjKsoHv~Lb8$kV8`Lhx4`-+?;$-~*d>-l#k@8aqAIRE zBaf2?izQX>=y1n-bXgMt*tI;VEeux&h(W;e$SbVWbRne_qnIp~781xDb`W<`Riw*F zZ=tcOjF3n9M&=9ZfbqZ?2xEiA)o8imzI-CsJn0mdeh0w^ds#d3#-MsZSGq@`QmWgyI-7~ssxx36CP>LZKLiQp3v2K{8ZYTU-l#^aUSe4p z1oQfaG>8_*Yqe)-St5!xHFxn2*B`W)%A$QiTPyR}C{=f{sq%`>5?cqHqpjpnbX>Ju zJgWQI2bfuTe)0g(#}O@nPJDU6;Pm^Fn>ipVUm63+Ldy z+k9{FBXK71I;%m8Wg@K*rI~|I!+1Ri)}4!E2F&wDq%`^@v{H+>HpFOdfT*ZR5NXm6#nV_AcSZ3`mJf;IVTg)( zL|Xs~$U=*Nsb3th_*$?fFJPr{9+`|g3-CXUAlhJzz~g{t3R%ESOOguUWLT%^Oj@%7 z8V_r${3gE&xX@{S%>8Buu{$L&=%v9COm4uQxd1Ditk zu~ZZakrW9K6`BL~OodLtd{W0Q<0iBh)e6V(26UA^1v{Y-6~qxwqFW&Xs3}B3E<~4A za0kql3*nCFwo-vaqgm?qC#fC8?%kV5ER4~LQXRHtD&S5 z)eDJe25=@uLCN^7M}Lxe^6i}D?2zF%-7x|&>PL3kOfA|HTv-9Q--nURfCp+rlR zbXX;M0Fi;W*dDnhRpGm0e?^vO3)RGpXbUh=&j{aO#m`H73Jm<=Jfery$aJbj^@OeJ z-eB=YAuGh$)FM~NGd5P7fzPtM^fMD7BI^>HMLtR~C`x#S+L0h?gc-dXVik^|F>IZ$ zJFGjcVEbWZ{uM+%uO}tFw-ks5#?63jHIeRt=+%QtTe=wLAJObIL=ZY)Z7&L&a0)t# zRzf`60O6XrPmB^gxVO+#94;JyNI12MiV8Z2tU@_;YgUD(;U0n!*`*8Q1Vr{?BFQ(2 zK#M8Wyve=^Qo666_q?ZtH_Nl%J=M9vmF1f4D(pzK|Koh(^0+H{gXAdkJFN={wm15O zPYEePadjnKpfRthvbi5{(pv{UG2b&CHO(~l4lH7R9*_`_X!vMa8}LPYQQb||1)owb z$_M2+$_3wEZ*yO&yM=Rn&SZPZoFy4WQfs8#PraEIly)L{PTH&VUFiqXN@bME>E!wo zT09eCjtaAI)hkVRt)gkBZ()8Kc+%prYzy0xCnw@sc=7O3;nQs6tTyYQ-~m>1V4UfU z@tVn{gSA@xjsYjkGs?BlxgEZOPIA_DEYGQ$T`#M4M)kBxDd&@EvNkm;wQky&jFMS9 zv)*JL&nlVI45B~MJ(tmT(I?!Zk*X8gdHO4wLfT6Kf0&<|BaKsoriMg@j|lA?`ZDZ* zEiGt$;Cf3_;BdSFuIz5ZS=|BkRG|hI*)w^t*XtVRJmm7YI=FT@8#!w_TRUFl+|4p( zx6j&=@hHQVIXW{uD>XaW(b3)%Vog$VHrqei-+I)vmwGzH{p}ZStIDe#;%wD@&7FX2 z`WE`3`eqhi@OYap#AB;xD{kpzzGW_8sUB!E4l|ZB{%N?ZT>;TDQ()d-$2Zi|-&4ki zypKHVy|ulsJ%4!CxgNnkfL!iq=V+3Xo>@3c&ME1LfG<%CoKNlR;G1GJ{5z}=dkJrS zaWv>?53rspb!~NPtioMYQ#F$2h5CY_nPpyZ=a6{diM$TF7WmDq53FZiW&B|1X^b^* zHdzeQG{tp)Y457;Dn9QYKI)t8OYwB~)sxmsJ*92lj;^JS>h?Z4tFv}xZpiGN9bv!d z*z7FloN5ow+Lu{1XG_++oNTvDzM;Kf5y}e_G@}KALIktAn_3jB=$yv#L63v`g-9W( zwiDL&fx`lyn`fBnoAQ`cre)?m=9Z=f0e$rG`hvoFWe2eBqU94ljWk|XLrfJ`PWrxj z^zP!$q4u%a^)j2}9Cu803GR7rok!&wXs?u2HtS|)cE%a|8*dx5h&&<@xT!E3--NGK z-OyaI8E~-$%?{%(i_Tig_Rtm|ECf97Ee&~r%<9F>FRtMs& zqRBZqQQi-c^}*ofhAJDR6P^*C((W8bS$nMgfn$klmiw@~ok#Ti<9eL)DC<&IzRWwB zCv&d3_eec~Z@r#a!QK_7%am8FEmq@xf~>lw8)$xG3AD@(P7JOTwAkF;e8e)=6mDE^ zoE}&@aGxQ@^a7YO5xQX2VTgn|Np6r7`1TS>sslrI6S2rWeDl5W@O62kE794~{lZtr zH_A6an&%zosp9Dm{O7q@diy?m&m7{k`Sy^#B!f(#58%tl9Jwm3hUP;=;|QUu>Ja=> z&kE+U=0tM`OM8=O>}{-LYHFHl+-7QI8f{1ksBf5S>>qGiJ5u!xH-HtI{>)7qg3TJq zu7PF$Ls>$a%crHg@^x=p&m>PBUq31J|0C%tz?;ar_IR{O+SFYsr9yFvI}}~q-C?oC z-Q9I@cXwFaOBE;;pzfZ=Gd}<6_dj`R>15{4TtBZI_9*ucyUEd#`^d(E{^q28HPhLe zYZ*qr;$nq|{BzJ^&WBZdn6ySPBVL#h5TR3;O};|$N%2Q9&8e4WqdG|WM{!%VUDaR7 z%Iva2#Z#wQies`)@+I;!#D9EeDG(it(eM;{z#N$)9$@Zp&&0df8g{Z{JY(Zs`4@Iy zezf!t;=!l0m-umPD9ziZI0_s~9HF!qI~1GX9Fwp8$AnJ(*w{AmhoCq zK=ooFFeWz$&4smWcPSBd#tgu8?n_Rfo{)p^&v-t0T5*x!@f)&Kg?khA&9EQ{Q6J#Uo%1BV2(EJvr91yxbyt~j$JOxNnJjAo7NxOtNW-cd#*l9Vl zfW0CO=ErdF_#NVMaSuOMFoLde3Lpb7#O0t#ckuHOA6y^`;XFTOi}6@QDXPhZWNT>$ zHk>-AjKsWHm`Wmee)qqjX#9}2cA%uSb^Wkdl zI_y;fM1#nk={aKKI{tG(r@vhgoSy^$Kh+766iwu zi_>?&w5BpkvCa5?el{rUYq<58Gk!tn32OJT4RKL$uDBtr6brD?5O3BCryy2VE9k_Y z;%>1SD2wBzY@iSPggx*`aU(aAe=N-*r?8W`nes!(Xeh-5VA#k7gY-ADNjw2M)(jyE zuS3096Y>lq=2wIODHq=k`}wwVXW;<%1b>BROEUT6XzL|_YnOO+N6B9&6`C-}B#rNT!T&K)BXBv)xI z_MUsrPbE{ZZsI4$J9Hy16AtqoL=2k-W1%nj&^Q71?ke~QUnL$}Ck`@45#E#+9mSrK zX38_U3tX~nJSbb|$x6gYe5o`Ct3oDoaiT9cD2&EGAU&CrsGQh`1Pd*sw^Rf=3)JB$ z#C6ogwuo3vd2wzW#HZv|sLSaPV>pT2MZ>|5rZ=!821zIAp6GY99XpeshBTvo^9KGO z>Wwg&AB&CVA91sg6|fhpXa2)xqKs`a-jlq-nfTt)HEb+6Yz#!aAik3Z-Uca1I;oTf zh-dIY*e8kO^k^1n=yfoHRFJz-wKPjSjt8KE{T$-JDReT|S-3zg!(NGtxyi^+K%Mo% zAHg5nASLlUvlFvnow-`rjekKm!tA30OhhIA&Buc#y$xv3+oR#cQQ@S}mgs|QhMf}* zG4i3XR*xtf8q!)f!OyS=M znWzi4R6N5ClTIuC5_D{?l!c$bLU}jw53-n8kHiV*SZ90$wvhfC7z1HzF4s#!E2f1;1>5iPTNGgCE5` zpj60NVJ<`pT7pkc8Mu)Qg88>l@?y_Y?FfT;Kkh+oXP*i*GLTpXTJ{gX#ke6ZCOU)D zLmkAvKO;?XZ-^?Y(4R2U9Kp|m`$r~n300yBh+VNrYcv63eU%WsdI+)4y)gclN=D%h zL`p~^Qrc%3PK=jb1HO_5NteAqM#73@6R>D&?$8&+be~URoI>;C7BknLVM$ryg4zu=D!ic}%ABnkWYig$Of$v3F@P*PF z`!OP&T+F@`hM_T}EA;dcfO-r8{KX-)0e;Xdsd^2e`t0$v)&^{+i&1=0gM{Oo+iBO3#o^)EfRRCnMI$By5*m z6mH`CWm|#U6)UbLjuYF&$x>$|0PTnO!9CF>93g#Cb;IPWmnBXWr8e1em}tD*xtHMM zm@btmACf8cFNCdRQ(1sCQuvJyMnmx-vT49?TA}^{$k`omMS|P>@ zyv=(G;J;051~ko`Y9S8h7a=iJ4RM2i0;5x~d=++^e`YTr?Q&jP$!_I?s8CrF>T3Ti z#N+2FZ^;!_jQ6Mzs*3BzUKf28G8ms+=qJQenL9d)`OHsHT|zyC7s5ca7CFK<#=>zx z1o-|)lJc(jlq*L2;&RMKGy#L48C8kj6*t+>;cl`rB$1yi+{R8~=g`;S%#bDyMmm!p zu^ieVWMgY&v0M#v7pLTBv1OL$QVpr421zg2zvx+tyRu-5*^z*q*KcC`IXFcEWk43v z8EjAVlPXCZ#Vq9dz$&>H+63HQw6bYLfY`;l5NS;wz#8*cI2oEOQkbHu1EWk0{*Yy#oiYGQe|Q5g}&S<@O${o ze zbEo4LWIg>2`IpGXG2tH}oaB+qVt_akU5K>fI-?Tc+G!Aj=!mw4==@V`t2mkO$G?Pi zV}JV>ZY42Y7bEVoZLwdb)QXqFVY)w8PDabxqLlRjdk>wWc#drn-ZK`V1V5}Yvg7Q( zgkn4#zri_kCNW+Xk3~sVVTWjxVlW?M1>eKL;QL5|4;3;+cl0BE2g?vxx;=ja+SLg@ z&oWfdlYXi;LMmM#aM%ItJim(d=k@~AYz)$a3qd6O7j;nh&K?t6Bc4)wZo6;~#(
  • ~-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/21] =?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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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)