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.

Files changed (40) hide show
  1. agents/_debug.py +15 -4
  2. agents/_run_impl.py +34 -37
  3. agents/agent.py +18 -2
  4. agents/extensions/handoff_filters.py +2 -0
  5. agents/extensions/memory/__init__.py +42 -15
  6. agents/extensions/memory/encrypt_session.py +185 -0
  7. agents/extensions/models/litellm_model.py +62 -10
  8. agents/function_schema.py +45 -3
  9. agents/memory/__init__.py +2 -0
  10. agents/memory/openai_conversations_session.py +0 -3
  11. agents/memory/util.py +20 -0
  12. agents/models/chatcmpl_converter.py +74 -15
  13. agents/models/chatcmpl_helpers.py +6 -0
  14. agents/models/chatcmpl_stream_handler.py +29 -1
  15. agents/models/openai_chatcompletions.py +26 -4
  16. agents/models/openai_responses.py +30 -4
  17. agents/realtime/__init__.py +2 -0
  18. agents/realtime/_util.py +1 -1
  19. agents/realtime/agent.py +7 -0
  20. agents/realtime/audio_formats.py +29 -0
  21. agents/realtime/config.py +32 -4
  22. agents/realtime/items.py +17 -1
  23. agents/realtime/model_events.py +2 -0
  24. agents/realtime/model_inputs.py +15 -1
  25. agents/realtime/openai_realtime.py +421 -130
  26. agents/realtime/session.py +167 -14
  27. agents/result.py +47 -20
  28. agents/run.py +191 -106
  29. agents/tool.py +1 -1
  30. agents/tracing/processor_interface.py +84 -11
  31. agents/tracing/spans.py +88 -0
  32. agents/tracing/traces.py +99 -16
  33. agents/util/_json.py +19 -1
  34. agents/util/_transforms.py +12 -2
  35. agents/voice/input.py +5 -4
  36. agents/voice/models/openai_stt.py +15 -8
  37. {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/METADATA +4 -2
  38. {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/RECORD +40 -37
  39. {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/WHEEL +0 -0
  40. {openai_agents-0.2.11.dist-info → openai_agents-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -35,7 +35,16 @@ from .events import (
35
35
  RealtimeToolStart,
36
36
  )
37
37
  from .handoffs import realtime_handoff
38
- from .items import AssistantAudio, InputAudio, InputText, RealtimeItem
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
- await self._put_event(
229
- RealtimeHistoryUpdated(info=self._event_info, history=self._history)
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 not preserved and incoming_item.role == "assistant":
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
- new_history[existing_index] = event
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 run config model settings as base
623
- run_config_settings = self._run_config.get("model_settings", {})
624
- updated_settings: RealtimeSessionModelSettings = run_config_settings.copy()
625
- # Apply starting settings (from model config) next
626
- if starting_settings:
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
- while True:
189
- self._check_errors()
190
- if self._stored_exception:
191
- logger.debug("Breaking due to stored exception")
192
- self.is_complete = True
193
- break
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
- if self.is_complete and self._event_queue.empty():
196
- break
196
+ if self.is_complete and self._event_queue.empty():
197
+ break
197
198
 
198
- try:
199
- item = await self._event_queue.get()
200
- except asyncio.CancelledError:
201
- break
199
+ try:
200
+ item = await self._event_queue.get()
201
+ except asyncio.CancelledError:
202
+ break
202
203
 
203
- if isinstance(item, QueueCompleteSentinel):
204
- self._event_queue.task_done()
205
- # Check for errors, in case the queue was completed due to an exception
206
- self._check_errors()
207
- break
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
- yield item
210
- self._event_queue.task_done()
211
+ # Check for errors, in case the queue was completed
212
+ # due to an exception
213
+ self._check_errors()
214
+ break
211
215
 
212
- self._cleanup_tasks()
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