From bacc65b1430a6905f8f9ee3c97e8c1920a5f10ea Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Wed, 22 Oct 2025 06:23:17 +0900 Subject: [PATCH 1/2] fix: #1907 guardrails w/ turn_detection.interrupt_response: true (#1968) --- src/agents/realtime/model_inputs.py | 3 ++ src/agents/realtime/openai_realtime.py | 56 ++++++++++++++------------ src/agents/realtime/session.py | 2 +- tests/realtime/test_openai_realtime.py | 54 +++++++++++++++++++++++++ 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/src/agents/realtime/model_inputs.py b/src/agents/realtime/model_inputs.py index 9d7ab143d..411177b7a 100644 --- a/src/agents/realtime/model_inputs.py +++ b/src/agents/realtime/model_inputs.py @@ -95,6 +95,9 @@ class RealtimeModelSendToolOutput: class RealtimeModelSendInterrupt: """Send an interrupt to the model.""" + force_response_cancel: bool = False + """Force sending a response.cancel event even if automatic cancellation is enabled.""" + @dataclass class RealtimeModelSendSessionUpdate: diff --git a/src/agents/realtime/openai_realtime.py b/src/agents/realtime/openai_realtime.py index 04a227ac8..873062c1d 100644 --- a/src/agents/realtime/openai_realtime.py +++ b/src/agents/realtime/openai_realtime.py @@ -395,6 +395,7 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: current_item_id = playback_state.get("current_item_id") current_item_content_index = playback_state.get("current_item_content_index") elapsed_ms = playback_state.get("elapsed_ms") + if current_item_id is None or elapsed_ms is None: logger.debug( "Skipping interrupt. " @@ -402,29 +403,28 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: f"elapsed ms: {elapsed_ms}, " f"content index: {current_item_content_index}" ) - return - - current_item_content_index = current_item_content_index or 0 - if elapsed_ms > 0: - await self._emit_event( - RealtimeModelAudioInterruptedEvent( - item_id=current_item_id, - content_index=current_item_content_index, - ) - ) - converted = _ConversionHelper.convert_interrupt( - current_item_id, - current_item_content_index, - int(elapsed_ms), - ) - await self._send_raw_message(converted) else: - logger.debug( - "Didn't interrupt bc elapsed ms is < 0. " - f"Item id: {current_item_id}, " - f"elapsed ms: {elapsed_ms}, " - f"content index: {current_item_content_index}" - ) + current_item_content_index = current_item_content_index or 0 + if elapsed_ms > 0: + await self._emit_event( + RealtimeModelAudioInterruptedEvent( + item_id=current_item_id, + content_index=current_item_content_index, + ) + ) + converted = _ConversionHelper.convert_interrupt( + current_item_id, + current_item_content_index, + int(elapsed_ms), + ) + await self._send_raw_message(converted) + else: + logger.debug( + "Didn't interrupt bc elapsed ms is < 0. " + f"Item id: {current_item_id}, " + f"elapsed ms: {elapsed_ms}, " + f"content index: {current_item_content_index}" + ) session = self._created_session automatic_response_cancellation_enabled = ( @@ -434,12 +434,16 @@ async def _send_interrupt(self, event: RealtimeModelSendInterrupt) -> None: and session.audio.input.turn_detection is not None and session.audio.input.turn_detection.interrupt_response is True ) - if not automatic_response_cancellation_enabled: + should_cancel_response = event.force_response_cancel or ( + not automatic_response_cancellation_enabled + ) + if should_cancel_response: await self._cancel_response() - self._audio_state_tracker.on_interrupted() - if self._playback_tracker: - self._playback_tracker.on_interrupted() + if current_item_id is not None and elapsed_ms is not None: + self._audio_state_tracker.on_interrupted() + if self._playback_tracker: + self._playback_tracker.on_interrupted() async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> None: """Send a session update to the model.""" diff --git a/src/agents/realtime/session.py b/src/agents/realtime/session.py index 42dcf531a..e10b48e53 100644 --- a/src/agents/realtime/session.py +++ b/src/agents/realtime/session.py @@ -704,7 +704,7 @@ async def _run_output_guardrails(self, text: str, response_id: str) -> bool: ) # Interrupt the model - await self._model.send_event(RealtimeModelSendInterrupt()) + await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True)) # Send guardrail triggered message guardrail_names = [result.guardrail.get_name() for result in triggered_results] diff --git a/tests/realtime/test_openai_realtime.py b/tests/realtime/test_openai_realtime.py index 29b6fbd9a..2b9683456 100644 --- a/tests/realtime/test_openai_realtime.py +++ b/tests/realtime/test_openai_realtime.py @@ -1,4 +1,5 @@ import json +from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, Mock, patch @@ -509,6 +510,59 @@ async def test_send_event_dispatch(self, model, monkeypatch): # session update -> 1 assert send_raw.await_count == 8 + @pytest.mark.asyncio + async def test_interrupt_force_cancel_overrides_auto_cancellation(self, model, monkeypatch): + """Interrupt should send response.cancel even when auto cancel is enabled.""" + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) + model._ongoing_response = True + model._created_session = SimpleNamespace( + audio=SimpleNamespace( + input=SimpleNamespace( + turn_detection=SimpleNamespace(interrupt_response=True) + ) + ) + ) + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt(RealtimeModelSendInterrupt(force_response_cancel=True)) + + assert send_raw.await_count == 2 + payload_types = {call.args[0].type for call in send_raw.call_args_list} + assert payload_types == {"conversation.item.truncate", "response.cancel"} + assert model._ongoing_response is False + assert model._audio_state_tracker.get_last_audio_item() is None + + @pytest.mark.asyncio + async def test_interrupt_respects_auto_cancellation_when_not_forced(self, model, monkeypatch): + """Interrupt should avoid sending response.cancel when relying on automatic cancellation.""" + model._audio_state_tracker.set_audio_format("pcm16") + model._audio_state_tracker.on_audio_delta("item_1", 0, b"\x00" * 4800) + model._ongoing_response = True + model._created_session = SimpleNamespace( + audio=SimpleNamespace( + input=SimpleNamespace( + turn_detection=SimpleNamespace(interrupt_response=True) + ) + ) + ) + + send_raw = AsyncMock() + emit_event = AsyncMock() + monkeypatch.setattr(model, "_send_raw_message", send_raw) + monkeypatch.setattr(model, "_emit_event", emit_event) + + await model._send_interrupt(RealtimeModelSendInterrupt()) + + assert send_raw.await_count == 1 + assert send_raw.call_args_list[0].args[0].type == "conversation.item.truncate" + assert all(call.args[0].type != "response.cancel" for call in send_raw.call_args_list) + assert model._ongoing_response is True + def test_add_remove_listener_and_tools_conversion(self, model): listener = AsyncMock() model.add_listener(listener) From 04eec503970894d2e5e404c4d87b5fdff68fc8f7 Mon Sep 17 00:00:00 2001 From: Wen-Tien Chang Date: Wed, 22 Oct 2025 05:26:17 +0800 Subject: [PATCH 2/2] Fix #1846 Litellm: fails with function name for tool_choice parameter w/ streaming enabled (#1971) --- src/agents/extensions/models/litellm_model.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/agents/extensions/models/litellm_model.py b/src/agents/extensions/models/litellm_model.py index 2fc10ae43..6271a1600 100644 --- a/src/agents/extensions/models/litellm_model.py +++ b/src/agents/extensions/models/litellm_model.py @@ -44,6 +44,7 @@ from ...models.chatcmpl_stream_handler import ChatCmplStreamHandler from ...models.fake_id import FAKE_RESPONSES_ID from ...models.interface import Model, ModelTracing +from ...models.openai_responses import Converter as OpenAIResponsesConverter from ...tool import Tool from ...tracing import generation_span from ...tracing.span_data import GenerationSpanData @@ -367,15 +368,19 @@ async def _fetch_response( if isinstance(ret, litellm.types.utils.ModelResponse): return ret + responses_tool_choice = OpenAIResponsesConverter.convert_tool_choice( + model_settings.tool_choice + ) + if responses_tool_choice is None or responses_tool_choice is omit: + responses_tool_choice = "auto" + response = Response( id=FAKE_RESPONSES_ID, created_at=time.time(), model=self.model, object="response", output=[], - tool_choice=cast(Literal["auto", "required", "none"], tool_choice) - if tool_choice is not omit - else "auto", + tool_choice=responses_tool_choice, # type: ignore[arg-type] top_p=model_settings.top_p, temperature=model_settings.temperature, tools=[],