Skip to content

Commit fccb45f

Browse files
fix(realtime): coalesce response.create across parallel tool calls
When the Realtime model emits multiple function_call items in a single response, each completing tool task previously fired its own RealtimeModelSendToolOutput(start_response=True). The two response.create events race the API and the second one is rejected with conversation_already_has_active_response, so the model never speaks for the rest of the turn. Track tool calls per response_id and drive a single response.create from the last completing call (or directly from turn_ended if all tools finish before the response is done). Refs #1168
1 parent bdd228b commit fccb45f

5 files changed

Lines changed: 300 additions & 5 deletions

File tree

src/agents/realtime/model_events.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class RealtimeModelToolCallEvent:
2828
id: str | None = None
2929
previous_item_id: str | None = None
3030

31+
response_id: str | None = None
32+
"""The ID of the model response that produced this tool call."""
33+
3134
type: Literal["function_call"] = "function_call"
3235

3336

@@ -143,6 +146,9 @@ class RealtimeModelTurnStartedEvent:
143146
class RealtimeModelTurnEndedEvent:
144147
"""Triggered when the model finishes generating a response for a turn."""
145148

149+
response_id: str | None = None
150+
"""The ID of the model response that just finished, if available."""
151+
146152
type: Literal["turn_ended"] = "turn_ended"
147153

148154

src/agents/realtime/openai_realtime.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,9 @@ async def _handle_audio_delta(self, parsed: ResponseAudioDeltaEvent) -> None:
926926
)
927927
)
928928

929-
async def _handle_output_item(self, item: ConversationItem) -> None:
929+
async def _handle_output_item(
930+
self, item: ConversationItem, response_id: str | None = None
931+
) -> None:
930932
"""Handle response output item events (function calls and messages)."""
931933
if item.type == "function_call" and item.status == "completed":
932934
tool_call = RealtimeToolCallItem(
@@ -948,6 +950,7 @@ async def _handle_output_item(self, item: ConversationItem) -> None:
948950
name=item.name or "",
949951
arguments=item.arguments or "",
950952
id=item.id or "",
953+
response_id=response_id,
951954
)
952955
)
953956
elif item.type == "message":
@@ -1149,7 +1152,9 @@ async def _handle_ws_event(self, event: dict[str, Any]):
11491152
await self._emit_event(RealtimeModelTurnStartedEvent())
11501153
elif parsed.type == "response.done":
11511154
await self._mark_response_done()
1152-
await self._emit_event(RealtimeModelTurnEndedEvent())
1155+
await self._emit_event(
1156+
RealtimeModelTurnEndedEvent(response_id=getattr(parsed.response, "id", None))
1157+
)
11531158
elif parsed.type == "session.created":
11541159
await self._send_tracing_config(self._tracing_config)
11551160
self._update_created_session(parsed.session)
@@ -1209,7 +1214,9 @@ async def _handle_ws_event(self, event: dict[str, Any]):
12091214
parsed.type == "response.output_item.added"
12101215
or parsed.type == "response.output_item.done"
12111216
):
1212-
await self._handle_output_item(parsed.item)
1217+
await self._handle_output_item(
1218+
parsed.item, response_id=getattr(parsed, "response_id", None)
1219+
)
12131220
elif parsed.type == "input_audio_buffer.timeout_triggered":
12141221
await self._emit_event(
12151222
RealtimeModelInputAudioTimeoutTriggeredEvent(

src/agents/realtime/session.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
from .model_inputs import (
6767
RealtimeModelSendAudio,
6868
RealtimeModelSendInterrupt,
69+
RealtimeModelSendRawMessage,
6970
RealtimeModelSendSessionUpdate,
7071
RealtimeModelSendToolOutput,
7172
RealtimeModelSendUserInput,
@@ -176,6 +177,14 @@ def __init__(
176177
self._tool_call_tasks: set[asyncio.Task[Any]] = set()
177178
self._async_tool_calls: bool = bool(self._run_config.get("async_tool_calls", True))
178179

180+
# Coalesce response.create across parallel tool calls emitted in the same model response.
181+
# Each entry tracks call_ids still pending and whether the model has signalled the end
182+
# of the turn (response.done). Only the last completing tool call for a finished turn
183+
# requests a new response so the OpenAI Realtime API never sees overlapping
184+
# response.create events that would error with conversation_already_has_active_response.
185+
self._tool_calls_by_response: dict[str, set[str]] = {}
186+
self._turn_ended_responses: set[str] = set()
187+
179188
@property
180189
def model(self) -> RealtimeModel:
181190
"""Access the underlying model for adding listeners or other direct interaction."""
@@ -278,6 +287,7 @@ async def on_event(self, event: RealtimeModelEvent) -> None:
278287
await self._put_event(RealtimeError(info=self._event_info, error=event.error))
279288
elif event.type == "function_call":
280289
agent_snapshot = self._current_agent
290+
self._register_pending_tool_call(event)
281291
if self._async_tool_calls:
282292
self._enqueue_tool_call_task(event, agent_snapshot)
283293
else:
@@ -429,6 +439,8 @@ async def on_event(self, event: RealtimeModelEvent) -> None:
429439
self._item_transcripts.clear()
430440
self._item_guardrail_run_counts.clear()
431441

442+
await self._mark_turn_ended(event.response_id)
443+
432444
await self._put_event(
433445
RealtimeAgentEndEvent(
434446
agent=self._current_agent,
@@ -556,11 +568,12 @@ async def _send_tool_rejection(
556568
tool=tool,
557569
call_id=event.call_id,
558570
)
571+
start_response = self._consume_pending_tool_call(event)
559572
await self._model.send_event(
560573
RealtimeModelSendToolOutput(
561574
tool_call=event,
562575
output=rejection_message,
563-
start_response=True,
576+
start_response=start_response,
564577
)
565578
)
566579

@@ -700,11 +713,12 @@ async def _handle_tool_call(
700713
arguments=event.arguments,
701714
)
702715

716+
start_response = self._consume_pending_tool_call(event)
703717
await self._model.send_event(
704718
RealtimeModelSendToolOutput(
705719
tool_call=event,
706720
output=_serialize_tool_output(result),
707-
start_response=True,
721+
start_response=start_response,
708722
)
709723
)
710724

@@ -763,6 +777,7 @@ async def _handle_tool_call(
763777

764778
# Then send tool output to complete the handoff (this triggers a new response)
765779
transfer_message = handoff.get_transfer_message(result)
780+
self._consume_pending_tool_call(event)
766781
await self._model.send_event(
767782
RealtimeModelSendToolOutput(
768783
tool_call=event,
@@ -772,6 +787,7 @@ async def _handle_tool_call(
772787
)
773788
else:
774789
error_message = f"Tool {event.name} not found"
790+
self._consume_pending_tool_call(event)
775791
await self._model.send_event(
776792
RealtimeModelSendToolOutput(
777793
tool_call=event,
@@ -1065,6 +1081,71 @@ def _enqueue_tool_call_task(
10651081
self._tool_call_tasks.add(task)
10661082
task.add_done_callback(self._on_tool_call_task_done)
10671083

1084+
def _register_pending_tool_call(self, event: RealtimeModelToolCallEvent) -> None:
1085+
"""Record a tool call so we can coalesce response.create per model response."""
1086+
response_id = event.response_id
1087+
if not response_id:
1088+
return
1089+
pending = self._tool_calls_by_response.setdefault(response_id, set())
1090+
pending.add(event.call_id)
1091+
1092+
def _consume_pending_tool_call(self, event: RealtimeModelToolCallEvent) -> bool:
1093+
"""Mark a tool call as complete and return whether it should trigger response.create.
1094+
1095+
Returns True if this is the last pending tool call for a turn that has already ended,
1096+
meaning the caller should request a new response. Otherwise returns False so the
1097+
Realtime API never receives a response.create while another response is still active
1098+
or while other tool outputs for the same turn are still in flight.
1099+
"""
1100+
response_id = event.response_id
1101+
if not response_id:
1102+
# Without a response id we can't coalesce, so fall back to the historical behavior
1103+
# of always requesting a response after every tool output.
1104+
return True
1105+
1106+
pending = self._tool_calls_by_response.get(response_id)
1107+
if pending is None:
1108+
# The function_call event arrived without going through on_event, or the bookkeeping
1109+
# has already been cleared. Be conservative and request a response.
1110+
return True
1111+
1112+
pending.discard(event.call_id)
1113+
if pending:
1114+
# Other tool outputs for the same turn are still in flight.
1115+
return False
1116+
1117+
if response_id not in self._turn_ended_responses:
1118+
# The model hasn't finished emitting this turn yet, so more tool calls may still
1119+
# arrive. Wait for turn_ended to drive response.create.
1120+
return False
1121+
1122+
self._tool_calls_by_response.pop(response_id, None)
1123+
self._turn_ended_responses.discard(response_id)
1124+
return True
1125+
1126+
async def _mark_turn_ended(self, response_id: str | None) -> None:
1127+
"""Record that the model finished a turn and trigger response.create if needed."""
1128+
if not response_id:
1129+
return
1130+
pending = self._tool_calls_by_response.get(response_id)
1131+
if pending is None:
1132+
# No tool calls were tracked for this response, nothing to coalesce.
1133+
return
1134+
if pending:
1135+
# Tool outputs are still in flight; the last one to finish will request the response.
1136+
self._turn_ended_responses.add(response_id)
1137+
return
1138+
1139+
# All tool outputs have already been sent with start_response=False, so we have to
1140+
# request a response ourselves now that the turn is complete.
1141+
self._tool_calls_by_response.pop(response_id, None)
1142+
self._turn_ended_responses.discard(response_id)
1143+
await self._model.send_event(
1144+
RealtimeModelSendRawMessage(
1145+
message={"type": "response.create"},
1146+
)
1147+
)
1148+
10681149
def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
10691150
self._tool_call_tasks.discard(task)
10701151

@@ -1117,6 +1198,8 @@ async def _cleanup(self) -> None:
11171198

11181199
# Clear pending approval tracking
11191200
self._pending_tool_calls.clear()
1201+
self._tool_calls_by_response.clear()
1202+
self._turn_ended_responses.clear()
11201203

11211204
# Mark as closed
11221205
self._closed = True

tests/realtime/test_openai_realtime.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,6 +1692,9 @@ async def test_handle_tool_call_event_success(self, model):
16921692
assert tool_call_emitted.name == "get_weather"
16931693
assert tool_call_emitted.arguments == '{"location": "San Francisco"}'
16941694
assert tool_call_emitted.call_id == "call_123"
1695+
# response_id must be propagated so RealtimeSession can coalesce parallel tool
1696+
# calls per model response and avoid overlapping response.create events.
1697+
assert tool_call_emitted.response_id == "resp_789"
16951698

16961699
@pytest.mark.asyncio
16971700
async def test_audio_timing_calculation_accuracy(self, model):

0 commit comments

Comments
 (0)