6666from .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
0 commit comments