openai-agents 0.2.11__py3-none-any.whl → 0.3.1__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/_debug.py +15 -4
- agents/_run_impl.py +34 -37
- agents/agent.py +18 -2
- agents/extensions/handoff_filters.py +2 -0
- agents/extensions/memory/__init__.py +42 -15
- agents/extensions/memory/encrypt_session.py +185 -0
- agents/extensions/models/litellm_model.py +62 -10
- agents/function_schema.py +45 -3
- agents/memory/__init__.py +2 -0
- agents/memory/openai_conversations_session.py +0 -3
- agents/memory/util.py +20 -0
- agents/models/chatcmpl_converter.py +74 -15
- agents/models/chatcmpl_helpers.py +6 -0
- agents/models/chatcmpl_stream_handler.py +29 -1
- agents/models/openai_chatcompletions.py +26 -4
- agents/models/openai_responses.py +30 -4
- agents/realtime/__init__.py +2 -0
- agents/realtime/_util.py +1 -1
- agents/realtime/agent.py +7 -0
- agents/realtime/audio_formats.py +29 -0
- agents/realtime/config.py +32 -4
- agents/realtime/items.py +17 -1
- agents/realtime/model_events.py +2 -0
- agents/realtime/model_inputs.py +15 -1
- agents/realtime/openai_realtime.py +421 -130
- agents/realtime/session.py +167 -14
- agents/result.py +47 -20
- agents/run.py +191 -106
- agents/tool.py +1 -1
- agents/tracing/processor_interface.py +84 -11
- agents/tracing/spans.py +88 -0
- agents/tracing/traces.py +99 -16
- agents/util/_json.py +19 -1
- agents/util/_transforms.py +12 -2
- agents/voice/input.py +5 -4
- agents/voice/models/openai_stt.py +15 -8
- {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/METADATA +4 -2
- {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/RECORD +40 -37
- {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/WHEEL +0 -0
- {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/licenses/LICENSE +0 -0
agents/realtime/session.py
CHANGED
|
@@ -35,7 +35,16 @@ from .events import (
|
|
|
35
35
|
RealtimeToolStart,
|
|
36
36
|
)
|
|
37
37
|
from .handoffs import realtime_handoff
|
|
38
|
-
from .items import
|
|
38
|
+
from .items import (
|
|
39
|
+
AssistantAudio,
|
|
40
|
+
AssistantMessageItem,
|
|
41
|
+
AssistantText,
|
|
42
|
+
InputAudio,
|
|
43
|
+
InputImage,
|
|
44
|
+
InputText,
|
|
45
|
+
RealtimeItem,
|
|
46
|
+
UserMessageItem,
|
|
47
|
+
)
|
|
39
48
|
from .model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener
|
|
40
49
|
from .model_events import (
|
|
41
50
|
RealtimeModelEvent,
|
|
@@ -95,6 +104,12 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
95
104
|
self._history: list[RealtimeItem] = []
|
|
96
105
|
self._model_config = model_config or {}
|
|
97
106
|
self._run_config = run_config or {}
|
|
107
|
+
initial_model_settings = self._model_config.get("initial_model_settings")
|
|
108
|
+
run_config_settings = self._run_config.get("model_settings")
|
|
109
|
+
self._base_model_settings: RealtimeSessionModelSettings = {
|
|
110
|
+
**(run_config_settings or {}),
|
|
111
|
+
**(initial_model_settings or {}),
|
|
112
|
+
}
|
|
98
113
|
self._event_queue: asyncio.Queue[RealtimeSessionEvent] = asyncio.Queue()
|
|
99
114
|
self._closed = False
|
|
100
115
|
self._stored_exception: Exception | None = None
|
|
@@ -224,10 +239,17 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
224
239
|
)
|
|
225
240
|
)
|
|
226
241
|
elif event.type == "input_audio_transcription_completed":
|
|
242
|
+
prev_len = len(self._history)
|
|
227
243
|
self._history = RealtimeSession._get_new_history(self._history, event)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
244
|
+
# If a new user item was appended (no existing item),
|
|
245
|
+
# emit history_added for incremental UIs.
|
|
246
|
+
if len(self._history) > prev_len and len(self._history) > 0:
|
|
247
|
+
new_item = self._history[-1]
|
|
248
|
+
await self._put_event(RealtimeHistoryAdded(info=self._event_info, item=new_item))
|
|
249
|
+
else:
|
|
250
|
+
await self._put_event(
|
|
251
|
+
RealtimeHistoryUpdated(info=self._event_info, history=self._history)
|
|
252
|
+
)
|
|
231
253
|
elif event.type == "input_audio_timeout_triggered":
|
|
232
254
|
await self._put_event(
|
|
233
255
|
RealtimeInputAudioTimeoutTriggered(
|
|
@@ -242,6 +264,13 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
242
264
|
self._item_guardrail_run_counts[item_id] = 0
|
|
243
265
|
|
|
244
266
|
self._item_transcripts[item_id] += event.delta
|
|
267
|
+
self._history = self._get_new_history(
|
|
268
|
+
self._history,
|
|
269
|
+
AssistantMessageItem(
|
|
270
|
+
item_id=item_id,
|
|
271
|
+
content=[AssistantAudio(transcript=self._item_transcripts[item_id])],
|
|
272
|
+
),
|
|
273
|
+
)
|
|
245
274
|
|
|
246
275
|
# Check if we should run guardrails based on debounce threshold
|
|
247
276
|
current_length = len(self._item_transcripts[item_id])
|
|
@@ -291,7 +320,7 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
291
320
|
|
|
292
321
|
# If still missing and this is an assistant item, fall back to
|
|
293
322
|
# accumulated transcript deltas tracked during the turn.
|
|
294
|
-
if
|
|
323
|
+
if incoming_item.role == "assistant":
|
|
295
324
|
preserved = self._item_transcripts.get(incoming_item.item_id)
|
|
296
325
|
|
|
297
326
|
if preserved:
|
|
@@ -456,9 +485,9 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
456
485
|
old_history: list[RealtimeItem],
|
|
457
486
|
event: RealtimeModelInputAudioTranscriptionCompletedEvent | RealtimeItem,
|
|
458
487
|
) -> list[RealtimeItem]:
|
|
459
|
-
# Merge transcript into placeholder input_audio message.
|
|
460
488
|
if isinstance(event, RealtimeModelInputAudioTranscriptionCompletedEvent):
|
|
461
489
|
new_history: list[RealtimeItem] = []
|
|
490
|
+
existing_item_found = False
|
|
462
491
|
for item in old_history:
|
|
463
492
|
if item.item_id == event.item_id and item.type == "message" and item.role == "user":
|
|
464
493
|
content: list[InputText | InputAudio] = []
|
|
@@ -471,11 +500,18 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
471
500
|
new_history.append(
|
|
472
501
|
item.model_copy(update={"content": content, "status": "completed"})
|
|
473
502
|
)
|
|
503
|
+
existing_item_found = True
|
|
474
504
|
else:
|
|
475
505
|
new_history.append(item)
|
|
506
|
+
|
|
507
|
+
if existing_item_found is False:
|
|
508
|
+
new_history.append(
|
|
509
|
+
UserMessageItem(
|
|
510
|
+
item_id=event.item_id, content=[InputText(text=event.transcript)]
|
|
511
|
+
)
|
|
512
|
+
)
|
|
476
513
|
return new_history
|
|
477
514
|
|
|
478
|
-
# Otherwise it's just a new item
|
|
479
515
|
# TODO (rm) Add support for audio storage config
|
|
480
516
|
|
|
481
517
|
# If the item already exists, update it
|
|
@@ -484,8 +520,122 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
484
520
|
)
|
|
485
521
|
if existing_index is not None:
|
|
486
522
|
new_history = old_history.copy()
|
|
487
|
-
|
|
523
|
+
if event.type == "message" and event.content is not None and len(event.content) > 0:
|
|
524
|
+
existing_item = old_history[existing_index]
|
|
525
|
+
if existing_item.type == "message":
|
|
526
|
+
# Merge content preserving existing transcript/text when incoming entry is empty
|
|
527
|
+
if event.role == "assistant" and existing_item.role == "assistant":
|
|
528
|
+
assistant_existing_content = existing_item.content
|
|
529
|
+
assistant_incoming = event.content
|
|
530
|
+
assistant_new_content: list[AssistantText | AssistantAudio] = []
|
|
531
|
+
for idx, ac in enumerate(assistant_incoming):
|
|
532
|
+
if idx >= len(assistant_existing_content):
|
|
533
|
+
assistant_new_content.append(ac)
|
|
534
|
+
continue
|
|
535
|
+
assistant_current = assistant_existing_content[idx]
|
|
536
|
+
if ac.type == "audio":
|
|
537
|
+
if ac.transcript is None:
|
|
538
|
+
assistant_new_content.append(assistant_current)
|
|
539
|
+
else:
|
|
540
|
+
assistant_new_content.append(ac)
|
|
541
|
+
else: # text
|
|
542
|
+
cur_text = (
|
|
543
|
+
assistant_current.text
|
|
544
|
+
if isinstance(assistant_current, AssistantText)
|
|
545
|
+
else None
|
|
546
|
+
)
|
|
547
|
+
if cur_text is not None and ac.text is None:
|
|
548
|
+
assistant_new_content.append(assistant_current)
|
|
549
|
+
else:
|
|
550
|
+
assistant_new_content.append(ac)
|
|
551
|
+
updated_assistant = event.model_copy(
|
|
552
|
+
update={"content": assistant_new_content}
|
|
553
|
+
)
|
|
554
|
+
new_history[existing_index] = updated_assistant
|
|
555
|
+
elif event.role == "user" and existing_item.role == "user":
|
|
556
|
+
user_existing_content = existing_item.content
|
|
557
|
+
user_incoming = event.content
|
|
558
|
+
|
|
559
|
+
# Start from incoming content (prefer latest fields)
|
|
560
|
+
user_new_content: list[InputText | InputAudio | InputImage] = list(
|
|
561
|
+
user_incoming
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# Merge by type with special handling for images and transcripts
|
|
565
|
+
def _image_url_str(val: object) -> str | None:
|
|
566
|
+
if isinstance(val, InputImage):
|
|
567
|
+
return val.image_url or None
|
|
568
|
+
return None
|
|
569
|
+
|
|
570
|
+
# 1) Preserve any existing images that are missing from the incoming payload
|
|
571
|
+
incoming_image_urls: set[str] = set()
|
|
572
|
+
for part in user_incoming:
|
|
573
|
+
if isinstance(part, InputImage):
|
|
574
|
+
u = _image_url_str(part)
|
|
575
|
+
if u:
|
|
576
|
+
incoming_image_urls.add(u)
|
|
577
|
+
|
|
578
|
+
missing_images: list[InputImage] = []
|
|
579
|
+
for part in user_existing_content:
|
|
580
|
+
if isinstance(part, InputImage):
|
|
581
|
+
u = _image_url_str(part)
|
|
582
|
+
if u and u not in incoming_image_urls:
|
|
583
|
+
missing_images.append(part)
|
|
584
|
+
|
|
585
|
+
# Insert missing images at the beginning to keep them visible and stable
|
|
586
|
+
if missing_images:
|
|
587
|
+
user_new_content = missing_images + user_new_content
|
|
588
|
+
|
|
589
|
+
# 2) For text/audio entries, preserve existing when incoming entry is empty
|
|
590
|
+
merged: list[InputText | InputAudio | InputImage] = []
|
|
591
|
+
for idx, uc in enumerate(user_new_content):
|
|
592
|
+
if uc.type == "input_audio":
|
|
593
|
+
# Attempt to preserve transcript if empty
|
|
594
|
+
transcript = getattr(uc, "transcript", None)
|
|
595
|
+
if transcript is None and idx < len(user_existing_content):
|
|
596
|
+
prev = user_existing_content[idx]
|
|
597
|
+
if isinstance(prev, InputAudio) and prev.transcript is not None:
|
|
598
|
+
uc = uc.model_copy(update={"transcript": prev.transcript})
|
|
599
|
+
merged.append(uc)
|
|
600
|
+
elif uc.type == "input_text":
|
|
601
|
+
text = getattr(uc, "text", None)
|
|
602
|
+
if (text is None or text == "") and idx < len(
|
|
603
|
+
user_existing_content
|
|
604
|
+
):
|
|
605
|
+
prev = user_existing_content[idx]
|
|
606
|
+
if isinstance(prev, InputText) and prev.text:
|
|
607
|
+
uc = uc.model_copy(update={"text": prev.text})
|
|
608
|
+
merged.append(uc)
|
|
609
|
+
else:
|
|
610
|
+
merged.append(uc)
|
|
611
|
+
|
|
612
|
+
updated_user = event.model_copy(update={"content": merged})
|
|
613
|
+
new_history[existing_index] = updated_user
|
|
614
|
+
elif event.role == "system" and existing_item.role == "system":
|
|
615
|
+
system_existing_content = existing_item.content
|
|
616
|
+
system_incoming = event.content
|
|
617
|
+
# Prefer existing non-empty text when incoming is empty
|
|
618
|
+
system_new_content: list[InputText] = []
|
|
619
|
+
for idx, sc in enumerate(system_incoming):
|
|
620
|
+
if idx >= len(system_existing_content):
|
|
621
|
+
system_new_content.append(sc)
|
|
622
|
+
continue
|
|
623
|
+
system_current = system_existing_content[idx]
|
|
624
|
+
cur_text = system_current.text
|
|
625
|
+
if cur_text is not None and sc.text is None:
|
|
626
|
+
system_new_content.append(system_current)
|
|
627
|
+
else:
|
|
628
|
+
system_new_content.append(sc)
|
|
629
|
+
updated_system = event.model_copy(update={"content": system_new_content})
|
|
630
|
+
new_history[existing_index] = updated_system
|
|
631
|
+
else:
|
|
632
|
+
# Role changed or mismatched; just replace
|
|
633
|
+
new_history[existing_index] = event
|
|
634
|
+
else:
|
|
635
|
+
# If the existing item is not a message, just replace it.
|
|
636
|
+
new_history[existing_index] = event
|
|
488
637
|
return new_history
|
|
638
|
+
|
|
489
639
|
# Otherwise, insert it after the previous_item_id if that is set
|
|
490
640
|
elif event.previous_item_id:
|
|
491
641
|
# Insert the new item after the previous item
|
|
@@ -619,12 +769,11 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
619
769
|
starting_settings: RealtimeSessionModelSettings | None,
|
|
620
770
|
agent: RealtimeAgent,
|
|
621
771
|
) -> RealtimeSessionModelSettings:
|
|
622
|
-
# Start with
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
updated_settings.update(starting_settings)
|
|
772
|
+
# Start with the merged base settings from run and model configuration.
|
|
773
|
+
updated_settings = self._base_model_settings.copy()
|
|
774
|
+
|
|
775
|
+
if agent.prompt is not None:
|
|
776
|
+
updated_settings["prompt"] = agent.prompt
|
|
628
777
|
|
|
629
778
|
instructions, tools, handoffs = await asyncio.gather(
|
|
630
779
|
agent.get_system_prompt(self._context_wrapper),
|
|
@@ -635,6 +784,10 @@ class RealtimeSession(RealtimeModelListener):
|
|
|
635
784
|
updated_settings["tools"] = tools or []
|
|
636
785
|
updated_settings["handoffs"] = handoffs or []
|
|
637
786
|
|
|
787
|
+
# Apply starting settings (from model config) next
|
|
788
|
+
if starting_settings:
|
|
789
|
+
updated_settings.update(starting_settings)
|
|
790
|
+
|
|
638
791
|
disable_tracing = self._run_config.get("tracing_disabled", False)
|
|
639
792
|
if disable_tracing:
|
|
640
793
|
updated_settings["tracing"] = None
|
agents/result.py
CHANGED
|
@@ -185,31 +185,42 @@ class RunResultStreaming(RunResultBase):
|
|
|
185
185
|
- A MaxTurnsExceeded exception if the agent exceeds the max_turns limit.
|
|
186
186
|
- A GuardrailTripwireTriggered exception if a guardrail is tripped.
|
|
187
187
|
"""
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
188
|
+
try:
|
|
189
|
+
while True:
|
|
190
|
+
self._check_errors()
|
|
191
|
+
if self._stored_exception:
|
|
192
|
+
logger.debug("Breaking due to stored exception")
|
|
193
|
+
self.is_complete = True
|
|
194
|
+
break
|
|
194
195
|
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
if self.is_complete and self._event_queue.empty():
|
|
197
|
+
break
|
|
197
198
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
199
|
+
try:
|
|
200
|
+
item = await self._event_queue.get()
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
break
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
if isinstance(item, QueueCompleteSentinel):
|
|
205
|
+
# Await input guardrails if they are still running, so late
|
|
206
|
+
# exceptions are captured.
|
|
207
|
+
await self._await_task_safely(self._input_guardrails_task)
|
|
208
|
+
|
|
209
|
+
self._event_queue.task_done()
|
|
208
210
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
# Check for errors, in case the queue was completed
|
|
212
|
+
# due to an exception
|
|
213
|
+
self._check_errors()
|
|
214
|
+
break
|
|
211
215
|
|
|
212
|
-
|
|
216
|
+
yield item
|
|
217
|
+
self._event_queue.task_done()
|
|
218
|
+
finally:
|
|
219
|
+
# Ensure main execution completes before cleanup to avoid race conditions
|
|
220
|
+
# with session operations
|
|
221
|
+
await self._await_task_safely(self._run_impl_task)
|
|
222
|
+
# Safely terminate all background tasks after main execution has finished
|
|
223
|
+
self._cleanup_tasks()
|
|
213
224
|
|
|
214
225
|
if self._stored_exception:
|
|
215
226
|
raise self._stored_exception
|
|
@@ -274,3 +285,19 @@ class RunResultStreaming(RunResultBase):
|
|
|
274
285
|
|
|
275
286
|
def __str__(self) -> str:
|
|
276
287
|
return pretty_print_run_result_streaming(self)
|
|
288
|
+
|
|
289
|
+
async def _await_task_safely(self, task: asyncio.Task[Any] | None) -> None:
|
|
290
|
+
"""Await a task if present, ignoring cancellation and storing exceptions elsewhere.
|
|
291
|
+
|
|
292
|
+
This ensures we do not lose late guardrail exceptions while not surfacing
|
|
293
|
+
CancelledError to callers of stream_events.
|
|
294
|
+
"""
|
|
295
|
+
if task and not task.done():
|
|
296
|
+
try:
|
|
297
|
+
await task
|
|
298
|
+
except asyncio.CancelledError:
|
|
299
|
+
# Task was cancelled (e.g., due to result.cancel()). Nothing to do here.
|
|
300
|
+
pass
|
|
301
|
+
except Exception:
|
|
302
|
+
# The exception will be surfaced via _check_errors() if needed.
|
|
303
|
+
pass
|