Skip to content

Commit aab64c5

Browse files
committed
feat: add dedicated event for input audio buffer timeouts
1 parent 857c70e commit aab64c5

File tree

7 files changed

+109
-0
lines changed

7 files changed

+109
-0
lines changed

src/agents/realtime/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
RealtimeModelErrorEvent,
6060
RealtimeModelEvent,
6161
RealtimeModelExceptionEvent,
62+
RealtimeModelInputAudioBufferTimeoutEvent,
6263
RealtimeModelInputAudioTranscriptionCompletedEvent,
6364
RealtimeModelItemDeletedEvent,
6465
RealtimeModelItemUpdatedEvent,
@@ -152,6 +153,7 @@
152153
"RealtimeModelErrorEvent",
153154
"RealtimeModelEvent",
154155
"RealtimeModelExceptionEvent",
156+
"RealtimeModelInputAudioBufferTimeoutEvent",
155157
"RealtimeModelInputAudioTranscriptionCompletedEvent",
156158
"RealtimeModelItemDeletedEvent",
157159
"RealtimeModelItemUpdatedEvent",

src/agents/realtime/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ class RealtimeSessionModelSettings(TypedDict):
9797
speed: NotRequired[float]
9898
"""The speed of the model's responses."""
9999

100+
idle_timeout_ms: NotRequired[int]
101+
"""The idle timeout before the session is closed, in milliseconds."""
102+
100103
input_audio_format: NotRequired[RealtimeAudioFormat]
101104
"""The format for input audio streams."""
102105

src/agents/realtime/model_events.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ class RealtimeModelAudioDoneEvent:
7575
type: Literal["audio_done"] = "audio_done"
7676

7777

78+
@dataclass
79+
class RealtimeModelInputAudioBufferTimeoutEvent:
80+
"""Triggered when the input audio buffer times out due to inactivity."""
81+
82+
event_id: str
83+
audio_start_ms: int
84+
audio_end_ms: int
85+
item_id: str
86+
87+
type: Literal["input_audio_buffer.timeout_triggered"] = "input_audio_buffer.timeout_triggered"
88+
89+
7890
@dataclass
7991
class RealtimeModelInputAudioTranscriptionCompletedEvent:
8092
"""Input audio transcription completed."""
@@ -174,6 +186,7 @@ class RealtimeModelRawServerEvent:
174186
RealtimeModelAudioEvent,
175187
RealtimeModelAudioInterruptedEvent,
176188
RealtimeModelAudioDoneEvent,
189+
RealtimeModelInputAudioBufferTimeoutEvent,
177190
RealtimeModelInputAudioTranscriptionCompletedEvent,
178191
RealtimeModelTranscriptDeltaEvent,
179192
RealtimeModelItemUpdatedEvent,

src/agents/realtime/openai_realtime.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@
8383
RealtimeModelErrorEvent,
8484
RealtimeModelEvent,
8585
RealtimeModelExceptionEvent,
86+
RealtimeModelInputAudioBufferTimeoutEvent,
8687
RealtimeModelInputAudioTranscriptionCompletedEvent,
8788
RealtimeModelItemDeletedEvent,
8889
RealtimeModelItemUpdatedEvent,
@@ -459,6 +460,16 @@ async def _cancel_response(self) -> None:
459460

460461
async def _handle_ws_event(self, event: dict[str, Any]):
461462
await self._emit_event(RealtimeModelRawServerEvent(data=event))
463+
if isinstance(event, dict) and event.get("type") == "input_audio_buffer.timeout_triggered":
464+
await self._emit_event(
465+
RealtimeModelInputAudioBufferTimeoutEvent(
466+
event_id=event["event_id"],
467+
audio_start_ms=event["audio_start_ms"],
468+
audio_end_ms=event["audio_end_ms"],
469+
item_id=event["item_id"],
470+
)
471+
)
472+
return
462473
try:
463474
if "previous_item_id" in event and event["previous_item_id"] is None:
464475
event["previous_item_id"] = "" # TODO (rm) remove
@@ -580,6 +591,7 @@ def _get_session_config(
580591
),
581592
voice=model_settings.get("voice", DEFAULT_MODEL_SETTINGS.get("voice")),
582593
speed=model_settings.get("speed", None),
594+
idle_timeout_ms=model_settings.get("idle_timeout_ms", None),
583595
modalities=model_settings.get("modalities", DEFAULT_MODEL_SETTINGS.get("modalities")),
584596
input_audio_format=model_settings.get(
585597
"input_audio_format",

src/agents/realtime/session.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,8 @@ async def on_event(self, event: RealtimeModelEvent) -> None:
337337
elif event.type == "exception":
338338
# Store the exception to be raised in __aiter__
339339
self._stored_exception = event.exception
340+
elif event.type == "input_audio_buffer.timeout_triggered":
341+
pass
340342
elif event.type == "other":
341343
pass
342344
elif event.type == "raw_server_event":

tests/realtime/test_openai_realtime.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from agents.realtime.model_events import (
99
RealtimeModelAudioEvent,
1010
RealtimeModelErrorEvent,
11+
RealtimeModelInputAudioBufferTimeoutEvent,
1112
RealtimeModelToolCallEvent,
1213
)
1314
from agents.realtime.openai_realtime import OpenAIRealtimeWebSocketModel
@@ -30,6 +31,15 @@ def mock_websocket(self):
3031
return mock_ws
3132

3233

34+
class TestSessionConfig(TestOpenAIRealtimeWebSocketModel):
35+
"""Test session configuration helpers."""
36+
37+
def test_get_session_config_includes_idle_timeout_ms(self, model):
38+
"""Test that _get_session_config includes idle_timeout_ms when provided."""
39+
session_obj = model._get_session_config({"idle_timeout_ms": 1234})
40+
assert session_obj.idle_timeout_ms == 1234
41+
42+
3343
class TestConnectionLifecycle(TestOpenAIRealtimeWebSocketModel):
3444
"""Test connection establishment, configuration, and error handling."""
3545

@@ -219,6 +229,32 @@ async def test_handle_unknown_event_type_ignored(self, model):
219229
# unknown, it should be ignored
220230
pass
221231

232+
@pytest.mark.asyncio
233+
async def test_handle_idle_timeout_event_emits_timeout_event(self, model):
234+
"""Test that input audio buffer timeouts are passed through."""
235+
mock_listener = AsyncMock()
236+
model.add_listener(mock_listener)
237+
238+
timeout_event = {
239+
"type": "input_audio_buffer.timeout_triggered",
240+
"event_id": "evt_123",
241+
"audio_start_ms": 100,
242+
"audio_end_ms": 200,
243+
"item_id": "item_1",
244+
}
245+
246+
await model._handle_ws_event(timeout_event)
247+
248+
assert mock_listener.on_event.call_count == 2
249+
raw_event = mock_listener.on_event.call_args_list[0][0][0]
250+
assert raw_event.type == "raw_server_event"
251+
timeout = mock_listener.on_event.call_args_list[1][0][0]
252+
assert isinstance(timeout, RealtimeModelInputAudioBufferTimeoutEvent)
253+
assert timeout.event_id == timeout_event["event_id"]
254+
assert timeout.audio_start_ms == timeout_event["audio_start_ms"]
255+
assert timeout.audio_end_ms == timeout_event["audio_end_ms"]
256+
assert timeout.item_id == timeout_event["item_id"]
257+
222258
@pytest.mark.asyncio
223259
async def test_handle_audio_delta_event_success(self, model):
224260
"""Test successful handling of audio delta events."""

tests/realtime/test_session.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
RealtimeModelAudioInterruptedEvent,
3838
RealtimeModelConnectionStatusEvent,
3939
RealtimeModelErrorEvent,
40+
RealtimeModelInputAudioBufferTimeoutEvent,
4041
RealtimeModelInputAudioTranscriptionCompletedEvent,
4142
RealtimeModelItemDeletedEvent,
4243
RealtimeModelItemUpdatedEvent,
@@ -367,6 +368,25 @@ async def test_ignored_events_only_generate_raw_events(self, mock_model, mock_ag
367368
event = await session._event_queue.get()
368369
assert isinstance(event, RealtimeRawModelEvent)
369370

371+
@pytest.mark.asyncio
372+
async def test_idle_timeout_event_forwarded(self, mock_model, mock_agent):
373+
"""Test that idle timeout events are forwarded as raw model events."""
374+
session = RealtimeSession(mock_model, mock_agent, None)
375+
376+
timeout_event = RealtimeModelInputAudioBufferTimeoutEvent(
377+
event_id="evt_1",
378+
audio_start_ms=0,
379+
audio_end_ms=500,
380+
item_id="item_1",
381+
)
382+
383+
await session.on_event(timeout_event)
384+
385+
assert session._event_queue.qsize() == 1
386+
event = await session._event_queue.get()
387+
assert isinstance(event, RealtimeRawModelEvent)
388+
assert event.data == timeout_event
389+
370390
@pytest.mark.asyncio
371391
async def test_function_call_event_triggers_tool_handling(self, mock_model, mock_agent):
372392
"""Test that function_call events trigger tool call handling"""
@@ -1360,6 +1380,27 @@ async def test_session_gets_model_settings_from_agent_during_connection(self):
13601380

13611381
await session.__aexit__(None, None, None)
13621382

1383+
@pytest.mark.asyncio
1384+
async def test_idle_timeout_ms_passed_to_model(self):
1385+
"""Test that idle_timeout_ms is forwarded to the model connect settings."""
1386+
mock_model = Mock(spec=RealtimeModel)
1387+
mock_model.connect = AsyncMock()
1388+
mock_model.add_listener = Mock()
1389+
1390+
agent = Mock(spec=RealtimeAgent)
1391+
agent.get_system_prompt = AsyncMock(return_value="")
1392+
agent.get_all_tools = AsyncMock(return_value=[])
1393+
agent.handoffs = []
1394+
1395+
run_config: RealtimeRunConfig = {"model_settings": {"idle_timeout_ms": 1000}}
1396+
session = RealtimeSession(mock_model, agent, None, run_config=run_config)
1397+
1398+
await session.__aenter__()
1399+
connect_config = mock_model.connect.call_args[0][0]
1400+
assert connect_config["initial_model_settings"]["idle_timeout_ms"] == 1000
1401+
1402+
await session.__aexit__(None, None, None)
1403+
13631404
@pytest.mark.asyncio
13641405
async def test_model_config_overrides_model_settings_not_agent(self):
13651406
"""Test that initial_model_settings from model_config override model settings

0 commit comments

Comments
 (0)