openai-agents 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of openai-agents might be problematic. Click here for more details.
- agents/_run_impl.py +2 -0
- agents/extensions/memory/__init__.py +37 -1
- agents/extensions/memory/dapr_session.py +423 -0
- agents/extensions/memory/sqlalchemy_session.py +13 -0
- agents/extensions/models/litellm_model.py +54 -15
- agents/items.py +3 -0
- agents/lifecycle.py +4 -4
- agents/models/chatcmpl_converter.py +8 -4
- agents/models/chatcmpl_stream_handler.py +13 -7
- agents/realtime/config.py +3 -0
- agents/realtime/events.py +7 -0
- agents/realtime/model.py +7 -0
- agents/realtime/model_inputs.py +3 -0
- agents/realtime/openai_realtime.py +69 -33
- agents/realtime/session.py +62 -9
- agents/run.py +63 -1
- agents/stream_events.py +1 -0
- agents/tool.py +15 -1
- agents/usage.py +65 -0
- agents/voice/models/openai_stt.py +2 -1
- {openai_agents-0.4.0.dist-info → openai_agents-0.5.0.dist-info}/METADATA +14 -4
- {openai_agents-0.4.0.dist-info → openai_agents-0.5.0.dist-info}/RECORD +24 -23
- {openai_agents-0.4.0.dist-info → openai_agents-0.5.0.dist-info}/WHEEL +0 -0
- {openai_agents-0.4.0.dist-info → openai_agents-0.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -51,6 +51,8 @@ from ..model_settings import MCPToolChoice
|
|
|
51
51
|
from ..tool import FunctionTool, Tool
|
|
52
52
|
from .fake_id import FAKE_RESPONSES_ID
|
|
53
53
|
|
|
54
|
+
ResponseInputContentWithAudioParam = Union[ResponseInputContentParam, ResponseInputAudioParam]
|
|
55
|
+
|
|
54
56
|
|
|
55
57
|
class Converter:
|
|
56
58
|
@classmethod
|
|
@@ -136,7 +138,9 @@ class Converter:
|
|
|
136
138
|
)
|
|
137
139
|
if message.content:
|
|
138
140
|
message_item.content.append(
|
|
139
|
-
ResponseOutputText(
|
|
141
|
+
ResponseOutputText(
|
|
142
|
+
text=message.content, type="output_text", annotations=[], logprobs=[]
|
|
143
|
+
)
|
|
140
144
|
)
|
|
141
145
|
if message.refusal:
|
|
142
146
|
message_item.content.append(
|
|
@@ -246,7 +250,7 @@ class Converter:
|
|
|
246
250
|
|
|
247
251
|
@classmethod
|
|
248
252
|
def extract_text_content(
|
|
249
|
-
cls, content: str | Iterable[
|
|
253
|
+
cls, content: str | Iterable[ResponseInputContentWithAudioParam]
|
|
250
254
|
) -> str | list[ChatCompletionContentPartTextParam]:
|
|
251
255
|
all_content = cls.extract_all_content(content)
|
|
252
256
|
if isinstance(all_content, str):
|
|
@@ -259,7 +263,7 @@ class Converter:
|
|
|
259
263
|
|
|
260
264
|
@classmethod
|
|
261
265
|
def extract_all_content(
|
|
262
|
-
cls, content: str | Iterable[
|
|
266
|
+
cls, content: str | Iterable[ResponseInputContentWithAudioParam]
|
|
263
267
|
) -> str | list[ChatCompletionContentPartParam]:
|
|
264
268
|
if isinstance(content, str):
|
|
265
269
|
return content
|
|
@@ -535,7 +539,7 @@ class Converter:
|
|
|
535
539
|
elif func_output := cls.maybe_function_tool_call_output(item):
|
|
536
540
|
flush_assistant_message()
|
|
537
541
|
output_content = cast(
|
|
538
|
-
Union[str, Iterable[
|
|
542
|
+
Union[str, Iterable[ResponseInputContentWithAudioParam]], func_output["output"]
|
|
539
543
|
)
|
|
540
544
|
msg: ChatCompletionToolMessageParam = {
|
|
541
545
|
"role": "tool",
|
|
@@ -150,6 +150,12 @@ class ChatCmplStreamHandler:
|
|
|
150
150
|
)
|
|
151
151
|
|
|
152
152
|
if reasoning_content and state.reasoning_content_index_and_output:
|
|
153
|
+
# Ensure summary list has at least one element
|
|
154
|
+
if not state.reasoning_content_index_and_output[1].summary:
|
|
155
|
+
state.reasoning_content_index_and_output[1].summary = [
|
|
156
|
+
Summary(text="", type="summary_text")
|
|
157
|
+
]
|
|
158
|
+
|
|
153
159
|
yield ResponseReasoningSummaryTextDeltaEvent(
|
|
154
160
|
delta=reasoning_content,
|
|
155
161
|
item_id=FAKE_RESPONSES_ID,
|
|
@@ -201,7 +207,7 @@ class ChatCmplStreamHandler:
|
|
|
201
207
|
)
|
|
202
208
|
|
|
203
209
|
# Create a new summary with updated text
|
|
204
|
-
if state.reasoning_content_index_and_output[1].content
|
|
210
|
+
if not state.reasoning_content_index_and_output[1].content:
|
|
205
211
|
state.reasoning_content_index_and_output[1].content = [
|
|
206
212
|
Content(text="", type="reasoning_text")
|
|
207
213
|
]
|
|
@@ -225,6 +231,7 @@ class ChatCmplStreamHandler:
|
|
|
225
231
|
text="",
|
|
226
232
|
type="output_text",
|
|
227
233
|
annotations=[],
|
|
234
|
+
logprobs=[],
|
|
228
235
|
),
|
|
229
236
|
)
|
|
230
237
|
# Start a new assistant message stream
|
|
@@ -252,6 +259,7 @@ class ChatCmplStreamHandler:
|
|
|
252
259
|
text="",
|
|
253
260
|
type="output_text",
|
|
254
261
|
annotations=[],
|
|
262
|
+
logprobs=[],
|
|
255
263
|
),
|
|
256
264
|
type="response.content_part.added",
|
|
257
265
|
sequence_number=sequence_number.get_and_increment(),
|
|
@@ -303,12 +311,10 @@ class ChatCmplStreamHandler:
|
|
|
303
311
|
yield ResponseContentPartAddedEvent(
|
|
304
312
|
content_index=state.refusal_content_index_and_output[0],
|
|
305
313
|
item_id=FAKE_RESPONSES_ID,
|
|
306
|
-
output_index=state.reasoning_content_index_and_output
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
type="output_text",
|
|
311
|
-
annotations=[],
|
|
314
|
+
output_index=(1 if state.reasoning_content_index_and_output else 0),
|
|
315
|
+
part=ResponseOutputRefusal(
|
|
316
|
+
refusal="",
|
|
317
|
+
type="refusal",
|
|
312
318
|
),
|
|
313
319
|
type="response.content_part.added",
|
|
314
320
|
sequence_number=sequence_number.get_and_increment(),
|
agents/realtime/config.py
CHANGED
|
@@ -184,6 +184,9 @@ class RealtimeRunConfig(TypedDict):
|
|
|
184
184
|
tracing_disabled: NotRequired[bool]
|
|
185
185
|
"""Whether tracing is disabled for this run."""
|
|
186
186
|
|
|
187
|
+
async_tool_calls: NotRequired[bool]
|
|
188
|
+
"""Whether function tool calls should run asynchronously. Defaults to True."""
|
|
189
|
+
|
|
187
190
|
# TODO (rm) Add history audio storage config
|
|
188
191
|
|
|
189
192
|
|
agents/realtime/events.py
CHANGED
|
@@ -69,6 +69,10 @@ class RealtimeToolStart:
|
|
|
69
69
|
"""The agent that updated."""
|
|
70
70
|
|
|
71
71
|
tool: Tool
|
|
72
|
+
"""The tool being called."""
|
|
73
|
+
|
|
74
|
+
arguments: str
|
|
75
|
+
"""The arguments passed to the tool as a JSON string."""
|
|
72
76
|
|
|
73
77
|
info: RealtimeEventInfo
|
|
74
78
|
"""Common info for all events, such as the context."""
|
|
@@ -86,6 +90,9 @@ class RealtimeToolEnd:
|
|
|
86
90
|
tool: Tool
|
|
87
91
|
"""The tool that was called."""
|
|
88
92
|
|
|
93
|
+
arguments: str
|
|
94
|
+
"""The arguments passed to the tool as a JSON string."""
|
|
95
|
+
|
|
89
96
|
output: Any
|
|
90
97
|
"""The output of the tool call."""
|
|
91
98
|
|
agents/realtime/model.py
CHANGED
|
@@ -139,6 +139,13 @@ class RealtimeModelConfig(TypedDict):
|
|
|
139
139
|
is played to the user.
|
|
140
140
|
"""
|
|
141
141
|
|
|
142
|
+
call_id: NotRequired[str]
|
|
143
|
+
"""Attach to an existing realtime call instead of creating a new session.
|
|
144
|
+
|
|
145
|
+
When provided, the transport connects using the `call_id` query string parameter rather than a
|
|
146
|
+
model name. This is used for SIP-originated calls that are accepted via the Realtime Calls API.
|
|
147
|
+
"""
|
|
148
|
+
|
|
142
149
|
|
|
143
150
|
class RealtimeModel(abc.ABC):
|
|
144
151
|
"""Interface for connecting to a realtime model and sending/receiving events."""
|
agents/realtime/model_inputs.py
CHANGED
|
@@ -95,6 +95,9 @@ class RealtimeModelSendToolOutput:
|
|
|
95
95
|
class RealtimeModelSendInterrupt:
|
|
96
96
|
"""Send an interrupt to the model."""
|
|
97
97
|
|
|
98
|
+
force_response_cancel: bool = False
|
|
99
|
+
"""Force sending a response.cancel event even if automatic cancellation is enabled."""
|
|
100
|
+
|
|
98
101
|
|
|
99
102
|
@dataclass
|
|
100
103
|
class RealtimeModelSendSessionUpdate:
|
|
@@ -208,7 +208,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
208
208
|
|
|
209
209
|
self._playback_tracker = options.get("playback_tracker", None)
|
|
210
210
|
|
|
211
|
-
|
|
211
|
+
call_id = options.get("call_id")
|
|
212
|
+
model_name = model_settings.get("model_name")
|
|
213
|
+
if call_id and model_name:
|
|
214
|
+
error_message = (
|
|
215
|
+
"Cannot specify both `call_id` and `model_name` "
|
|
216
|
+
"when attaching to an existing realtime call."
|
|
217
|
+
)
|
|
218
|
+
raise UserError(error_message)
|
|
219
|
+
|
|
220
|
+
if model_name:
|
|
221
|
+
self.model = model_name
|
|
222
|
+
|
|
212
223
|
api_key = await get_api_key(options.get("api_key"))
|
|
213
224
|
|
|
214
225
|
if "tracing" in model_settings:
|
|
@@ -216,7 +227,10 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
216
227
|
else:
|
|
217
228
|
self._tracing_config = "auto"
|
|
218
229
|
|
|
219
|
-
|
|
230
|
+
if call_id:
|
|
231
|
+
url = options.get("url", f"wss://api.openai.com/v1/realtime?call_id={call_id}")
|
|
232
|
+
else:
|
|
233
|
+
url = options.get("url", f"wss://api.openai.com/v1/realtime?model={self.model}")
|
|
220
234
|
|
|
221
235
|
headers: dict[str, str] = {}
|
|
222
236
|
if options.get("headers") is not None:
|
|
@@ -266,7 +280,8 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
266
280
|
|
|
267
281
|
async def _emit_event(self, event: RealtimeModelEvent) -> None:
|
|
268
282
|
"""Emit an event to the listeners."""
|
|
269
|
-
|
|
283
|
+
# Copy list to avoid modification during iteration
|
|
284
|
+
for listener in list(self._listeners):
|
|
270
285
|
await listener.on_event(event)
|
|
271
286
|
|
|
272
287
|
async def _listen_for_messages(self):
|
|
@@ -394,6 +409,7 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
394
409
|
current_item_id = playback_state.get("current_item_id")
|
|
395
410
|
current_item_content_index = playback_state.get("current_item_content_index")
|
|
396
411
|
elapsed_ms = playback_state.get("elapsed_ms")
|
|
412
|
+
|
|
397
413
|
if current_item_id is None or elapsed_ms is None:
|
|
398
414
|
logger.debug(
|
|
399
415
|
"Skipping interrupt. "
|
|
@@ -401,29 +417,28 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
401
417
|
f"elapsed ms: {elapsed_ms}, "
|
|
402
418
|
f"content index: {current_item_content_index}"
|
|
403
419
|
)
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
current_item_content_index = current_item_content_index or 0
|
|
407
|
-
if elapsed_ms > 0:
|
|
408
|
-
await self._emit_event(
|
|
409
|
-
RealtimeModelAudioInterruptedEvent(
|
|
410
|
-
item_id=current_item_id,
|
|
411
|
-
content_index=current_item_content_index,
|
|
412
|
-
)
|
|
413
|
-
)
|
|
414
|
-
converted = _ConversionHelper.convert_interrupt(
|
|
415
|
-
current_item_id,
|
|
416
|
-
current_item_content_index,
|
|
417
|
-
int(elapsed_ms),
|
|
418
|
-
)
|
|
419
|
-
await self._send_raw_message(converted)
|
|
420
420
|
else:
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
421
|
+
current_item_content_index = current_item_content_index or 0
|
|
422
|
+
if elapsed_ms > 0:
|
|
423
|
+
await self._emit_event(
|
|
424
|
+
RealtimeModelAudioInterruptedEvent(
|
|
425
|
+
item_id=current_item_id,
|
|
426
|
+
content_index=current_item_content_index,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
converted = _ConversionHelper.convert_interrupt(
|
|
430
|
+
current_item_id,
|
|
431
|
+
current_item_content_index,
|
|
432
|
+
int(elapsed_ms),
|
|
433
|
+
)
|
|
434
|
+
await self._send_raw_message(converted)
|
|
435
|
+
else:
|
|
436
|
+
logger.debug(
|
|
437
|
+
"Didn't interrupt bc elapsed ms is < 0. "
|
|
438
|
+
f"Item id: {current_item_id}, "
|
|
439
|
+
f"elapsed ms: {elapsed_ms}, "
|
|
440
|
+
f"content index: {current_item_content_index}"
|
|
441
|
+
)
|
|
427
442
|
|
|
428
443
|
session = self._created_session
|
|
429
444
|
automatic_response_cancellation_enabled = (
|
|
@@ -431,14 +446,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
431
446
|
and session.audio is not None
|
|
432
447
|
and session.audio.input is not None
|
|
433
448
|
and session.audio.input.turn_detection is not None
|
|
434
|
-
and session.audio.input.turn_detection.interrupt_response is True
|
|
449
|
+
and session.audio.input.turn_detection.interrupt_response is True
|
|
435
450
|
)
|
|
436
|
-
|
|
451
|
+
should_cancel_response = event.force_response_cancel or (
|
|
452
|
+
not automatic_response_cancellation_enabled
|
|
453
|
+
)
|
|
454
|
+
if should_cancel_response:
|
|
437
455
|
await self._cancel_response()
|
|
438
456
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
self._playback_tracker
|
|
457
|
+
if current_item_id is not None and elapsed_ms is not None:
|
|
458
|
+
self._audio_state_tracker.on_interrupted()
|
|
459
|
+
if self._playback_tracker:
|
|
460
|
+
self._playback_tracker.on_interrupted()
|
|
442
461
|
|
|
443
462
|
async def _send_session_update(self, event: RealtimeModelSendSessionUpdate) -> None:
|
|
444
463
|
"""Send a session update to the model."""
|
|
@@ -516,6 +535,10 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
516
535
|
self._websocket = None
|
|
517
536
|
if self._websocket_task:
|
|
518
537
|
self._websocket_task.cancel()
|
|
538
|
+
try:
|
|
539
|
+
await self._websocket_task
|
|
540
|
+
except asyncio.CancelledError:
|
|
541
|
+
pass
|
|
519
542
|
self._websocket_task = None
|
|
520
543
|
|
|
521
544
|
async def _cancel_response(self) -> None:
|
|
@@ -616,12 +639,13 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
616
639
|
and session.audio is not None
|
|
617
640
|
and session.audio.input is not None
|
|
618
641
|
and session.audio.input.turn_detection is not None
|
|
619
|
-
and session.audio.input.turn_detection.interrupt_response is True
|
|
642
|
+
and session.audio.input.turn_detection.interrupt_response is True
|
|
620
643
|
)
|
|
621
644
|
if not automatic_response_cancellation_enabled:
|
|
622
645
|
await self._cancel_response()
|
|
623
|
-
# Avoid sending conversation.item.truncate here
|
|
624
|
-
#
|
|
646
|
+
# Avoid sending conversation.item.truncate here. When the session's
|
|
647
|
+
# turn_detection.interrupt_response is enabled (GA default), the server emits
|
|
648
|
+
# conversation.item.truncated after the VAD start and takes care of history updates.
|
|
625
649
|
elif parsed.type == "response.created":
|
|
626
650
|
self._ongoing_response = True
|
|
627
651
|
await self._emit_event(RealtimeModelTurnStartedEvent())
|
|
@@ -920,6 +944,18 @@ class OpenAIRealtimeWebSocketModel(RealtimeModel):
|
|
|
920
944
|
return converted_tools
|
|
921
945
|
|
|
922
946
|
|
|
947
|
+
class OpenAIRealtimeSIPModel(OpenAIRealtimeWebSocketModel):
|
|
948
|
+
"""Realtime model that attaches to SIP-originated calls using a call ID."""
|
|
949
|
+
|
|
950
|
+
async def connect(self, options: RealtimeModelConfig) -> None:
|
|
951
|
+
call_id = options.get("call_id")
|
|
952
|
+
if not call_id:
|
|
953
|
+
raise UserError("OpenAIRealtimeSIPModel requires `call_id` in the model configuration.")
|
|
954
|
+
|
|
955
|
+
sip_options = options.copy()
|
|
956
|
+
await super().connect(sip_options)
|
|
957
|
+
|
|
958
|
+
|
|
923
959
|
class _ConversionHelper:
|
|
924
960
|
@classmethod
|
|
925
961
|
def conversation_item_to_realtime_message_item(
|
agents/realtime/session.py
CHANGED
|
@@ -112,7 +112,7 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
112
112
|
}
|
|
113
113
|
self._event_queue: asyncio.Queue[RealtimeSessionEvent] = asyncio.Queue()
|
|
114
114
|
self._closed = False
|
|
115
|
-
self._stored_exception:
|
|
115
|
+
self._stored_exception: BaseException | None = None
|
|
116
116
|
|
|
117
117
|
# Guardrails state tracking
|
|
118
118
|
self._interrupted_response_ids: set[str] = set()
|
|
@@ -123,6 +123,8 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
123
123
|
)
|
|
124
124
|
|
|
125
125
|
self._guardrail_tasks: set[asyncio.Task[Any]] = set()
|
|
126
|
+
self._tool_call_tasks: set[asyncio.Task[Any]] = set()
|
|
127
|
+
self._async_tool_calls: bool = bool(self._run_config.get("async_tool_calls", True))
|
|
126
128
|
|
|
127
129
|
@property
|
|
128
130
|
def model(self) -> RealtimeModel:
|
|
@@ -216,7 +218,11 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
216
218
|
if event.type == "error":
|
|
217
219
|
await self._put_event(RealtimeError(info=self._event_info, error=event.error))
|
|
218
220
|
elif event.type == "function_call":
|
|
219
|
-
|
|
221
|
+
agent_snapshot = self._current_agent
|
|
222
|
+
if self._async_tool_calls:
|
|
223
|
+
self._enqueue_tool_call_task(event, agent_snapshot)
|
|
224
|
+
else:
|
|
225
|
+
await self._handle_tool_call(event, agent_snapshot=agent_snapshot)
|
|
220
226
|
elif event.type == "audio":
|
|
221
227
|
await self._put_event(
|
|
222
228
|
RealtimeAudio(
|
|
@@ -384,11 +390,17 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
384
390
|
"""Put an event into the queue."""
|
|
385
391
|
await self._event_queue.put(event)
|
|
386
392
|
|
|
387
|
-
async def _handle_tool_call(
|
|
393
|
+
async def _handle_tool_call(
|
|
394
|
+
self,
|
|
395
|
+
event: RealtimeModelToolCallEvent,
|
|
396
|
+
*,
|
|
397
|
+
agent_snapshot: RealtimeAgent | None = None,
|
|
398
|
+
) -> None:
|
|
388
399
|
"""Handle a tool call event."""
|
|
400
|
+
agent = agent_snapshot or self._current_agent
|
|
389
401
|
tools, handoffs = await asyncio.gather(
|
|
390
|
-
|
|
391
|
-
self._get_handoffs(
|
|
402
|
+
agent.get_all_tools(self._context_wrapper),
|
|
403
|
+
self._get_handoffs(agent, self._context_wrapper),
|
|
392
404
|
)
|
|
393
405
|
function_map = {tool.name: tool for tool in tools if isinstance(tool, FunctionTool)}
|
|
394
406
|
handoff_map = {handoff.tool_name: handoff for handoff in handoffs}
|
|
@@ -398,7 +410,8 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
398
410
|
RealtimeToolStart(
|
|
399
411
|
info=self._event_info,
|
|
400
412
|
tool=function_map[event.name],
|
|
401
|
-
agent=
|
|
413
|
+
agent=agent,
|
|
414
|
+
arguments=event.arguments,
|
|
402
415
|
)
|
|
403
416
|
)
|
|
404
417
|
|
|
@@ -423,7 +436,8 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
423
436
|
info=self._event_info,
|
|
424
437
|
tool=func_tool,
|
|
425
438
|
output=result,
|
|
426
|
-
agent=
|
|
439
|
+
agent=agent,
|
|
440
|
+
arguments=event.arguments,
|
|
427
441
|
)
|
|
428
442
|
)
|
|
429
443
|
elif event.name in handoff_map:
|
|
@@ -444,7 +458,7 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
444
458
|
)
|
|
445
459
|
|
|
446
460
|
# Store previous agent for event
|
|
447
|
-
previous_agent =
|
|
461
|
+
previous_agent = agent
|
|
448
462
|
|
|
449
463
|
# Update current agent
|
|
450
464
|
self._current_agent = result
|
|
@@ -704,7 +718,7 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
704
718
|
)
|
|
705
719
|
|
|
706
720
|
# Interrupt the model
|
|
707
|
-
await self._model.send_event(RealtimeModelSendInterrupt())
|
|
721
|
+
await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True))
|
|
708
722
|
|
|
709
723
|
# Send guardrail triggered message
|
|
710
724
|
guardrail_names = [result.guardrail.get_name() for result in triggered_results]
|
|
@@ -752,10 +766,49 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
752
766
|
task.cancel()
|
|
753
767
|
self._guardrail_tasks.clear()
|
|
754
768
|
|
|
769
|
+
def _enqueue_tool_call_task(
|
|
770
|
+
self, event: RealtimeModelToolCallEvent, agent_snapshot: RealtimeAgent
|
|
771
|
+
) -> None:
|
|
772
|
+
"""Run tool calls in the background to avoid blocking realtime transport."""
|
|
773
|
+
task = asyncio.create_task(self._handle_tool_call(event, agent_snapshot=agent_snapshot))
|
|
774
|
+
self._tool_call_tasks.add(task)
|
|
775
|
+
task.add_done_callback(self._on_tool_call_task_done)
|
|
776
|
+
|
|
777
|
+
def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
|
|
778
|
+
self._tool_call_tasks.discard(task)
|
|
779
|
+
|
|
780
|
+
if task.cancelled():
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
exception = task.exception()
|
|
784
|
+
if exception is None:
|
|
785
|
+
return
|
|
786
|
+
|
|
787
|
+
logger.exception("Realtime tool call task failed", exc_info=exception)
|
|
788
|
+
|
|
789
|
+
if self._stored_exception is None:
|
|
790
|
+
self._stored_exception = exception
|
|
791
|
+
|
|
792
|
+
asyncio.create_task(
|
|
793
|
+
self._put_event(
|
|
794
|
+
RealtimeError(
|
|
795
|
+
info=self._event_info,
|
|
796
|
+
error={"message": f"Tool call task failed: {exception}"},
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
def _cleanup_tool_call_tasks(self) -> None:
|
|
802
|
+
for task in self._tool_call_tasks:
|
|
803
|
+
if not task.done():
|
|
804
|
+
task.cancel()
|
|
805
|
+
self._tool_call_tasks.clear()
|
|
806
|
+
|
|
755
807
|
async def _cleanup(self) -> None:
|
|
756
808
|
"""Clean up all resources and mark session as closed."""
|
|
757
809
|
# Cancel and cleanup guardrail tasks
|
|
758
810
|
self._cleanup_guardrail_tasks()
|
|
811
|
+
self._cleanup_tool_call_tasks()
|
|
759
812
|
|
|
760
813
|
# Remove ourselves as a listener
|
|
761
814
|
self._model.remove_listener(self)
|
agents/run.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import contextlib
|
|
4
5
|
import inspect
|
|
5
6
|
import os
|
|
7
|
+
import warnings
|
|
6
8
|
from dataclasses import dataclass, field
|
|
7
9
|
from typing import Any, Callable, Generic, cast, get_args
|
|
8
10
|
|
|
@@ -720,7 +722,40 @@ class AgentRunner:
|
|
|
720
722
|
conversation_id = kwargs.get("conversation_id")
|
|
721
723
|
session = kwargs.get("session")
|
|
722
724
|
|
|
723
|
-
|
|
725
|
+
# Python 3.14 stopped implicitly wiring up a default event loop
|
|
726
|
+
# when synchronous code touches asyncio APIs for the first time.
|
|
727
|
+
# Several of our synchronous entry points (for example the Redis/SQLAlchemy session helpers)
|
|
728
|
+
# construct asyncio primitives like asyncio.Lock during __init__,
|
|
729
|
+
# which binds them to whatever loop happens to be the thread's default at that moment.
|
|
730
|
+
# To keep those locks usable we must ensure that run_sync reuses that same default loop
|
|
731
|
+
# instead of hopping over to a brand-new asyncio.run() loop.
|
|
732
|
+
try:
|
|
733
|
+
already_running_loop = asyncio.get_running_loop()
|
|
734
|
+
except RuntimeError:
|
|
735
|
+
already_running_loop = None
|
|
736
|
+
|
|
737
|
+
if already_running_loop is not None:
|
|
738
|
+
# This method is only expected to run when no loop is already active.
|
|
739
|
+
# (Each thread has its own default loop; concurrent sync runs should happen on
|
|
740
|
+
# different threads. In a single thread use the async API to interleave work.)
|
|
741
|
+
raise RuntimeError(
|
|
742
|
+
"AgentRunner.run_sync() cannot be called when an event loop is already running."
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
policy = asyncio.get_event_loop_policy()
|
|
746
|
+
with warnings.catch_warnings():
|
|
747
|
+
warnings.simplefilter("ignore", DeprecationWarning)
|
|
748
|
+
try:
|
|
749
|
+
default_loop = policy.get_event_loop()
|
|
750
|
+
except RuntimeError:
|
|
751
|
+
default_loop = policy.new_event_loop()
|
|
752
|
+
policy.set_event_loop(default_loop)
|
|
753
|
+
|
|
754
|
+
# We intentionally leave the default loop open even if we had to create one above. Session
|
|
755
|
+
# instances and other helpers stash loop-bound primitives between calls and expect to find
|
|
756
|
+
# the same default loop every time run_sync is invoked on this thread.
|
|
757
|
+
# Schedule the async run on the default loop so that we can manage cancellation explicitly.
|
|
758
|
+
task = default_loop.create_task(
|
|
724
759
|
self.run(
|
|
725
760
|
starting_agent,
|
|
726
761
|
input,
|
|
@@ -734,6 +769,24 @@ class AgentRunner:
|
|
|
734
769
|
)
|
|
735
770
|
)
|
|
736
771
|
|
|
772
|
+
try:
|
|
773
|
+
# Drive the coroutine to completion, harvesting the final RunResult.
|
|
774
|
+
return default_loop.run_until_complete(task)
|
|
775
|
+
except BaseException:
|
|
776
|
+
# If the sync caller aborts (KeyboardInterrupt, etc.), make sure the scheduled task
|
|
777
|
+
# does not linger on the shared loop by cancelling it and waiting for completion.
|
|
778
|
+
if not task.done():
|
|
779
|
+
task.cancel()
|
|
780
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
781
|
+
default_loop.run_until_complete(task)
|
|
782
|
+
raise
|
|
783
|
+
finally:
|
|
784
|
+
if not default_loop.is_closed():
|
|
785
|
+
# The loop stays open for subsequent runs, but we still need to flush any pending
|
|
786
|
+
# async generators so their cleanup code executes promptly.
|
|
787
|
+
with contextlib.suppress(RuntimeError):
|
|
788
|
+
default_loop.run_until_complete(default_loop.shutdown_asyncgens())
|
|
789
|
+
|
|
737
790
|
def run_streamed(
|
|
738
791
|
self,
|
|
739
792
|
starting_agent: Agent[TContext],
|
|
@@ -1138,6 +1191,15 @@ class AgentRunner:
|
|
|
1138
1191
|
|
|
1139
1192
|
streamed_result.is_complete = True
|
|
1140
1193
|
finally:
|
|
1194
|
+
if streamed_result._input_guardrails_task:
|
|
1195
|
+
try:
|
|
1196
|
+
await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
|
|
1197
|
+
streamed_result
|
|
1198
|
+
)
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
logger.debug(
|
|
1201
|
+
f"Error in streamed_result finalize for agent {current_agent.name} - {e}"
|
|
1202
|
+
)
|
|
1141
1203
|
if current_span:
|
|
1142
1204
|
current_span.finish(reset_current=True)
|
|
1143
1205
|
if streamed_result.trace:
|
agents/stream_events.py
CHANGED
agents/tool.py
CHANGED
|
@@ -15,7 +15,7 @@ from openai.types.responses.response_output_item import LocalShellCall, McpAppro
|
|
|
15
15
|
from openai.types.responses.tool_param import CodeInterpreter, ImageGeneration, Mcp
|
|
16
16
|
from openai.types.responses.web_search_tool import Filters as WebSearchToolFilters
|
|
17
17
|
from openai.types.responses.web_search_tool_param import UserLocation
|
|
18
|
-
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
18
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError, model_validator
|
|
19
19
|
from typing_extensions import Concatenate, NotRequired, ParamSpec, TypedDict
|
|
20
20
|
|
|
21
21
|
from . import _debug
|
|
@@ -75,6 +75,13 @@ class ToolOutputImage(BaseModel):
|
|
|
75
75
|
file_id: str | None = None
|
|
76
76
|
detail: Literal["low", "high", "auto"] | None = None
|
|
77
77
|
|
|
78
|
+
@model_validator(mode="after")
|
|
79
|
+
def check_at_least_one_required_field(self) -> ToolOutputImage:
|
|
80
|
+
"""Validate that at least one of image_url or file_id is provided."""
|
|
81
|
+
if self.image_url is None and self.file_id is None:
|
|
82
|
+
raise ValueError("At least one of image_url or file_id must be provided")
|
|
83
|
+
return self
|
|
84
|
+
|
|
78
85
|
|
|
79
86
|
class ToolOutputImageDict(TypedDict, total=False):
|
|
80
87
|
"""TypedDict variant for image tool outputs."""
|
|
@@ -98,6 +105,13 @@ class ToolOutputFileContent(BaseModel):
|
|
|
98
105
|
file_id: str | None = None
|
|
99
106
|
filename: str | None = None
|
|
100
107
|
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def check_at_least_one_required_field(self) -> ToolOutputFileContent:
|
|
110
|
+
"""Validate that at least one of file_data, file_url, or file_id is provided."""
|
|
111
|
+
if self.file_data is None and self.file_url is None and self.file_id is None:
|
|
112
|
+
raise ValueError("At least one of file_data, file_url, or file_id must be provided")
|
|
113
|
+
return self
|
|
114
|
+
|
|
101
115
|
|
|
102
116
|
class ToolOutputFileContentDict(TypedDict, total=False):
|
|
103
117
|
"""TypedDict variant for file content tool outputs."""
|