openai-agents 0.2.6__py3-none-any.whl → 0.6.8__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.
Files changed (96) hide show
  1. agents/__init__.py +105 -4
  2. agents/_debug.py +15 -4
  3. agents/_run_impl.py +1203 -96
  4. agents/agent.py +294 -21
  5. agents/apply_diff.py +329 -0
  6. agents/editor.py +47 -0
  7. agents/exceptions.py +35 -0
  8. agents/extensions/experimental/__init__.py +6 -0
  9. agents/extensions/experimental/codex/__init__.py +92 -0
  10. agents/extensions/experimental/codex/codex.py +89 -0
  11. agents/extensions/experimental/codex/codex_options.py +35 -0
  12. agents/extensions/experimental/codex/codex_tool.py +1142 -0
  13. agents/extensions/experimental/codex/events.py +162 -0
  14. agents/extensions/experimental/codex/exec.py +263 -0
  15. agents/extensions/experimental/codex/items.py +245 -0
  16. agents/extensions/experimental/codex/output_schema_file.py +50 -0
  17. agents/extensions/experimental/codex/payloads.py +31 -0
  18. agents/extensions/experimental/codex/thread.py +214 -0
  19. agents/extensions/experimental/codex/thread_options.py +54 -0
  20. agents/extensions/experimental/codex/turn_options.py +36 -0
  21. agents/extensions/handoff_filters.py +13 -1
  22. agents/extensions/memory/__init__.py +120 -0
  23. agents/extensions/memory/advanced_sqlite_session.py +1285 -0
  24. agents/extensions/memory/async_sqlite_session.py +239 -0
  25. agents/extensions/memory/dapr_session.py +423 -0
  26. agents/extensions/memory/encrypt_session.py +185 -0
  27. agents/extensions/memory/redis_session.py +261 -0
  28. agents/extensions/memory/sqlalchemy_session.py +334 -0
  29. agents/extensions/models/litellm_model.py +449 -36
  30. agents/extensions/models/litellm_provider.py +3 -1
  31. agents/function_schema.py +47 -5
  32. agents/guardrail.py +16 -2
  33. agents/{handoffs.py → handoffs/__init__.py} +89 -47
  34. agents/handoffs/history.py +268 -0
  35. agents/items.py +238 -13
  36. agents/lifecycle.py +75 -14
  37. agents/mcp/server.py +280 -37
  38. agents/mcp/util.py +24 -3
  39. agents/memory/__init__.py +22 -2
  40. agents/memory/openai_conversations_session.py +91 -0
  41. agents/memory/openai_responses_compaction_session.py +249 -0
  42. agents/memory/session.py +19 -261
  43. agents/memory/sqlite_session.py +275 -0
  44. agents/memory/util.py +20 -0
  45. agents/model_settings.py +18 -3
  46. agents/models/__init__.py +13 -0
  47. agents/models/chatcmpl_converter.py +303 -50
  48. agents/models/chatcmpl_helpers.py +63 -0
  49. agents/models/chatcmpl_stream_handler.py +290 -68
  50. agents/models/default_models.py +58 -0
  51. agents/models/interface.py +4 -0
  52. agents/models/openai_chatcompletions.py +103 -48
  53. agents/models/openai_provider.py +10 -4
  54. agents/models/openai_responses.py +167 -46
  55. agents/realtime/__init__.py +4 -0
  56. agents/realtime/_util.py +14 -3
  57. agents/realtime/agent.py +7 -0
  58. agents/realtime/audio_formats.py +53 -0
  59. agents/realtime/config.py +78 -10
  60. agents/realtime/events.py +18 -0
  61. agents/realtime/handoffs.py +2 -2
  62. agents/realtime/items.py +17 -1
  63. agents/realtime/model.py +13 -0
  64. agents/realtime/model_events.py +12 -0
  65. agents/realtime/model_inputs.py +18 -1
  66. agents/realtime/openai_realtime.py +700 -151
  67. agents/realtime/session.py +309 -32
  68. agents/repl.py +7 -3
  69. agents/result.py +197 -38
  70. agents/run.py +1053 -178
  71. agents/run_context.py +13 -2
  72. agents/stream_events.py +1 -0
  73. agents/strict_schema.py +14 -0
  74. agents/tool.py +413 -15
  75. agents/tool_context.py +22 -1
  76. agents/tool_guardrails.py +279 -0
  77. agents/tracing/__init__.py +2 -0
  78. agents/tracing/config.py +9 -0
  79. agents/tracing/create.py +4 -0
  80. agents/tracing/processor_interface.py +84 -11
  81. agents/tracing/processors.py +65 -54
  82. agents/tracing/provider.py +64 -7
  83. agents/tracing/spans.py +105 -0
  84. agents/tracing/traces.py +116 -16
  85. agents/usage.py +134 -12
  86. agents/util/_json.py +19 -1
  87. agents/util/_transforms.py +12 -2
  88. agents/voice/input.py +5 -4
  89. agents/voice/models/openai_stt.py +17 -9
  90. agents/voice/pipeline.py +2 -0
  91. agents/voice/pipeline_config.py +4 -0
  92. {openai_agents-0.2.6.dist-info → openai_agents-0.6.8.dist-info}/METADATA +44 -19
  93. openai_agents-0.6.8.dist-info/RECORD +134 -0
  94. {openai_agents-0.2.6.dist-info → openai_agents-0.6.8.dist-info}/WHEEL +1 -1
  95. openai_agents-0.2.6.dist-info/RECORD +0 -103
  96. {openai_agents-0.2.6.dist-info → openai_agents-0.6.8.dist-info}/licenses/LICENSE +0 -0
@@ -8,8 +8,9 @@ from typing import Any, cast
8
8
  from typing_extensions import assert_never
9
9
 
10
10
  from ..agent import Agent
11
- from ..exceptions import ModelBehaviorError, UserError
11
+ from ..exceptions import UserError
12
12
  from ..handoffs import Handoff
13
+ from ..logger import logger
13
14
  from ..run_context import RunContextWrapper, TContext
14
15
  from ..tool import FunctionTool
15
16
  from ..tool_context import ToolContext
@@ -27,13 +28,23 @@ from .events import (
27
28
  RealtimeHandoffEvent,
28
29
  RealtimeHistoryAdded,
29
30
  RealtimeHistoryUpdated,
31
+ RealtimeInputAudioTimeoutTriggered,
30
32
  RealtimeRawModelEvent,
31
33
  RealtimeSessionEvent,
32
34
  RealtimeToolEnd,
33
35
  RealtimeToolStart,
34
36
  )
35
37
  from .handoffs import realtime_handoff
36
- from .items import InputAudio, InputText, RealtimeItem
38
+ from .items import (
39
+ AssistantAudio,
40
+ AssistantMessageItem,
41
+ AssistantText,
42
+ InputAudio,
43
+ InputImage,
44
+ InputText,
45
+ RealtimeItem,
46
+ UserMessageItem,
47
+ )
37
48
  from .model import RealtimeModel, RealtimeModelConfig, RealtimeModelListener
38
49
  from .model_events import (
39
50
  RealtimeModelEvent,
@@ -93,12 +104,18 @@ class RealtimeSession(RealtimeModelListener):
93
104
  self._history: list[RealtimeItem] = []
94
105
  self._model_config = model_config or {}
95
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
+ }
96
113
  self._event_queue: asyncio.Queue[RealtimeSessionEvent] = asyncio.Queue()
97
114
  self._closed = False
98
- self._stored_exception: Exception | None = None
115
+ self._stored_exception: BaseException | None = None
99
116
 
100
117
  # Guardrails state tracking
101
- self._interrupted_by_guardrail = False
118
+ self._interrupted_response_ids: set[str] = set()
102
119
  self._item_transcripts: dict[str, str] = {} # item_id -> accumulated transcript
103
120
  self._item_guardrail_run_counts: dict[str, int] = {} # item_id -> run count
104
121
  self._debounce_text_length = self._run_config.get("guardrails_settings", {}).get(
@@ -106,6 +123,8 @@ class RealtimeSession(RealtimeModelListener):
106
123
  )
107
124
 
108
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))
109
128
 
110
129
  @property
111
130
  def model(self) -> RealtimeModel:
@@ -199,7 +218,11 @@ class RealtimeSession(RealtimeModelListener):
199
218
  if event.type == "error":
200
219
  await self._put_event(RealtimeError(info=self._event_info, error=event.error))
201
220
  elif event.type == "function_call":
202
- await self._handle_tool_call(event)
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)
203
226
  elif event.type == "audio":
204
227
  await self._put_event(
205
228
  RealtimeAudio(
@@ -222,9 +245,22 @@ class RealtimeSession(RealtimeModelListener):
222
245
  )
223
246
  )
224
247
  elif event.type == "input_audio_transcription_completed":
248
+ prev_len = len(self._history)
225
249
  self._history = RealtimeSession._get_new_history(self._history, event)
250
+ # If a new user item was appended (no existing item),
251
+ # emit history_added for incremental UIs.
252
+ if len(self._history) > prev_len and len(self._history) > 0:
253
+ new_item = self._history[-1]
254
+ await self._put_event(RealtimeHistoryAdded(info=self._event_info, item=new_item))
255
+ else:
256
+ await self._put_event(
257
+ RealtimeHistoryUpdated(info=self._event_info, history=self._history)
258
+ )
259
+ elif event.type == "input_audio_timeout_triggered":
226
260
  await self._put_event(
227
- RealtimeHistoryUpdated(info=self._event_info, history=self._history)
261
+ RealtimeInputAudioTimeoutTriggered(
262
+ info=self._event_info,
263
+ )
228
264
  )
229
265
  elif event.type == "transcript_delta":
230
266
  # Accumulate transcript text for guardrail debouncing per item_id
@@ -234,6 +270,13 @@ class RealtimeSession(RealtimeModelListener):
234
270
  self._item_guardrail_run_counts[item_id] = 0
235
271
 
236
272
  self._item_transcripts[item_id] += event.delta
273
+ self._history = self._get_new_history(
274
+ self._history,
275
+ AssistantMessageItem(
276
+ item_id=item_id,
277
+ content=[AssistantAudio(transcript=self._item_transcripts[item_id])],
278
+ ),
279
+ )
237
280
 
238
281
  # Check if we should run guardrails based on debounce threshold
239
282
  current_length = len(self._item_transcripts[item_id])
@@ -242,10 +285,62 @@ class RealtimeSession(RealtimeModelListener):
242
285
 
243
286
  if current_length >= next_run_threshold:
244
287
  self._item_guardrail_run_counts[item_id] += 1
245
- self._enqueue_guardrail_task(self._item_transcripts[item_id])
288
+ # Pass response_id so we can ensure only a single interrupt per response
289
+ self._enqueue_guardrail_task(self._item_transcripts[item_id], event.response_id)
246
290
  elif event.type == "item_updated":
247
291
  is_new = not any(item.item_id == event.item.item_id for item in self._history)
248
- self._history = self._get_new_history(self._history, event.item)
292
+
293
+ # Preserve previously known transcripts when updating existing items.
294
+ # This prevents transcripts from disappearing when an item is later
295
+ # retrieved without transcript fields populated.
296
+ incoming_item = event.item
297
+ existing_item = next(
298
+ (i for i in self._history if i.item_id == incoming_item.item_id), None
299
+ )
300
+
301
+ if (
302
+ existing_item is not None
303
+ and existing_item.type == "message"
304
+ and incoming_item.type == "message"
305
+ ):
306
+ try:
307
+ # Merge transcripts for matching content indices
308
+ existing_content = existing_item.content
309
+ new_content = []
310
+ for idx, entry in enumerate(incoming_item.content):
311
+ # Only attempt to preserve for audio-like content
312
+ if entry.type in ("audio", "input_audio"):
313
+ # Use tuple form for Python 3.9 compatibility
314
+ assert isinstance(entry, (InputAudio, AssistantAudio))
315
+ # Determine if transcript is missing/empty on the incoming entry
316
+ entry_transcript = entry.transcript
317
+ if not entry_transcript:
318
+ preserved: str | None = None
319
+ # First prefer any transcript from the existing history item
320
+ if idx < len(existing_content):
321
+ this_content = existing_content[idx]
322
+ if isinstance(this_content, AssistantAudio) or isinstance(
323
+ this_content, InputAudio
324
+ ):
325
+ preserved = this_content.transcript
326
+
327
+ # If still missing and this is an assistant item, fall back to
328
+ # accumulated transcript deltas tracked during the turn.
329
+ if incoming_item.role == "assistant":
330
+ preserved = self._item_transcripts.get(incoming_item.item_id)
331
+
332
+ if preserved:
333
+ entry = entry.model_copy(update={"transcript": preserved})
334
+
335
+ new_content.append(entry)
336
+
337
+ if new_content:
338
+ incoming_item = incoming_item.model_copy(update={"content": new_content})
339
+ except Exception:
340
+ logger.error("Error merging transcripts", exc_info=True)
341
+ pass
342
+
343
+ self._history = self._get_new_history(self._history, incoming_item)
249
344
  if is_new:
250
345
  new_item = next(
251
346
  item for item in self._history if item.item_id == event.item.item_id
@@ -274,7 +369,6 @@ class RealtimeSession(RealtimeModelListener):
274
369
  # Clear guardrail state for next turn
275
370
  self._item_transcripts.clear()
276
371
  self._item_guardrail_run_counts.clear()
277
- self._interrupted_by_guardrail = False
278
372
 
279
373
  await self._put_event(
280
374
  RealtimeAgentEndEvent(
@@ -296,11 +390,17 @@ class RealtimeSession(RealtimeModelListener):
296
390
  """Put an event into the queue."""
297
391
  await self._event_queue.put(event)
298
392
 
299
- async def _handle_tool_call(self, event: RealtimeModelToolCallEvent) -> None:
393
+ async def _handle_tool_call(
394
+ self,
395
+ event: RealtimeModelToolCallEvent,
396
+ *,
397
+ agent_snapshot: RealtimeAgent | None = None,
398
+ ) -> None:
300
399
  """Handle a tool call event."""
400
+ agent = agent_snapshot or self._current_agent
301
401
  tools, handoffs = await asyncio.gather(
302
- self._current_agent.get_all_tools(self._context_wrapper),
303
- self._get_handoffs(self._current_agent, self._context_wrapper),
402
+ agent.get_all_tools(self._context_wrapper),
403
+ self._get_handoffs(agent, self._context_wrapper),
304
404
  )
305
405
  function_map = {tool.name: tool for tool in tools if isinstance(tool, FunctionTool)}
306
406
  handoff_map = {handoff.tool_name: handoff for handoff in handoffs}
@@ -310,7 +410,8 @@ class RealtimeSession(RealtimeModelListener):
310
410
  RealtimeToolStart(
311
411
  info=self._event_info,
312
412
  tool=function_map[event.name],
313
- agent=self._current_agent,
413
+ agent=agent,
414
+ arguments=event.arguments,
314
415
  )
315
416
  )
316
417
 
@@ -320,6 +421,7 @@ class RealtimeSession(RealtimeModelListener):
320
421
  usage=self._context_wrapper.usage,
321
422
  tool_name=event.name,
322
423
  tool_call_id=event.call_id,
424
+ tool_arguments=event.arguments,
323
425
  )
324
426
  result = await func_tool.on_invoke_tool(tool_context, event.arguments)
325
427
 
@@ -334,7 +436,8 @@ class RealtimeSession(RealtimeModelListener):
334
436
  info=self._event_info,
335
437
  tool=func_tool,
336
438
  output=result,
337
- agent=self._current_agent,
439
+ agent=agent,
440
+ arguments=event.arguments,
338
441
  )
339
442
  )
340
443
  elif event.name in handoff_map:
@@ -344,6 +447,7 @@ class RealtimeSession(RealtimeModelListener):
344
447
  usage=self._context_wrapper.usage,
345
448
  tool_name=event.name,
346
449
  tool_call_id=event.call_id,
450
+ tool_arguments=event.arguments,
347
451
  )
348
452
 
349
453
  # Execute the handoff to get the new agent
@@ -354,7 +458,7 @@ class RealtimeSession(RealtimeModelListener):
354
458
  )
355
459
 
356
460
  # Store previous agent for event
357
- previous_agent = self._current_agent
461
+ previous_agent = agent
358
462
 
359
463
  # Update current agent
360
464
  self._current_agent = result
@@ -389,7 +493,12 @@ class RealtimeSession(RealtimeModelListener):
389
493
  )
390
494
  )
391
495
  else:
392
- raise ModelBehaviorError(f"Tool {event.name} not found")
496
+ await self._put_event(
497
+ RealtimeError(
498
+ info=self._event_info,
499
+ error={"message": f"Tool {event.name} not found"},
500
+ )
501
+ )
393
502
 
394
503
  @classmethod
395
504
  def _get_new_history(
@@ -397,9 +506,9 @@ class RealtimeSession(RealtimeModelListener):
397
506
  old_history: list[RealtimeItem],
398
507
  event: RealtimeModelInputAudioTranscriptionCompletedEvent | RealtimeItem,
399
508
  ) -> list[RealtimeItem]:
400
- # Merge transcript into placeholder input_audio message.
401
509
  if isinstance(event, RealtimeModelInputAudioTranscriptionCompletedEvent):
402
510
  new_history: list[RealtimeItem] = []
511
+ existing_item_found = False
403
512
  for item in old_history:
404
513
  if item.item_id == event.item_id and item.type == "message" and item.role == "user":
405
514
  content: list[InputText | InputAudio] = []
@@ -412,11 +521,18 @@ class RealtimeSession(RealtimeModelListener):
412
521
  new_history.append(
413
522
  item.model_copy(update={"content": content, "status": "completed"})
414
523
  )
524
+ existing_item_found = True
415
525
  else:
416
526
  new_history.append(item)
527
+
528
+ if existing_item_found is False:
529
+ new_history.append(
530
+ UserMessageItem(
531
+ item_id=event.item_id, content=[InputText(text=event.transcript)]
532
+ )
533
+ )
417
534
  return new_history
418
535
 
419
- # Otherwise it's just a new item
420
536
  # TODO (rm) Add support for audio storage config
421
537
 
422
538
  # If the item already exists, update it
@@ -425,8 +541,122 @@ class RealtimeSession(RealtimeModelListener):
425
541
  )
426
542
  if existing_index is not None:
427
543
  new_history = old_history.copy()
428
- new_history[existing_index] = event
544
+ if event.type == "message" and event.content is not None and len(event.content) > 0:
545
+ existing_item = old_history[existing_index]
546
+ if existing_item.type == "message":
547
+ # Merge content preserving existing transcript/text when incoming entry is empty
548
+ if event.role == "assistant" and existing_item.role == "assistant":
549
+ assistant_existing_content = existing_item.content
550
+ assistant_incoming = event.content
551
+ assistant_new_content: list[AssistantText | AssistantAudio] = []
552
+ for idx, ac in enumerate(assistant_incoming):
553
+ if idx >= len(assistant_existing_content):
554
+ assistant_new_content.append(ac)
555
+ continue
556
+ assistant_current = assistant_existing_content[idx]
557
+ if ac.type == "audio":
558
+ if ac.transcript is None:
559
+ assistant_new_content.append(assistant_current)
560
+ else:
561
+ assistant_new_content.append(ac)
562
+ else: # text
563
+ cur_text = (
564
+ assistant_current.text
565
+ if isinstance(assistant_current, AssistantText)
566
+ else None
567
+ )
568
+ if cur_text is not None and ac.text is None:
569
+ assistant_new_content.append(assistant_current)
570
+ else:
571
+ assistant_new_content.append(ac)
572
+ updated_assistant = event.model_copy(
573
+ update={"content": assistant_new_content}
574
+ )
575
+ new_history[existing_index] = updated_assistant
576
+ elif event.role == "user" and existing_item.role == "user":
577
+ user_existing_content = existing_item.content
578
+ user_incoming = event.content
579
+
580
+ # Start from incoming content (prefer latest fields)
581
+ user_new_content: list[InputText | InputAudio | InputImage] = list(
582
+ user_incoming
583
+ )
584
+
585
+ # Merge by type with special handling for images and transcripts
586
+ def _image_url_str(val: object) -> str | None:
587
+ if isinstance(val, InputImage):
588
+ return val.image_url or None
589
+ return None
590
+
591
+ # 1) Preserve any existing images that are missing from the incoming payload
592
+ incoming_image_urls: set[str] = set()
593
+ for part in user_incoming:
594
+ if isinstance(part, InputImage):
595
+ u = _image_url_str(part)
596
+ if u:
597
+ incoming_image_urls.add(u)
598
+
599
+ missing_images: list[InputImage] = []
600
+ for part in user_existing_content:
601
+ if isinstance(part, InputImage):
602
+ u = _image_url_str(part)
603
+ if u and u not in incoming_image_urls:
604
+ missing_images.append(part)
605
+
606
+ # Insert missing images at the beginning to keep them visible and stable
607
+ if missing_images:
608
+ user_new_content = missing_images + user_new_content
609
+
610
+ # 2) For text/audio entries, preserve existing when incoming entry is empty
611
+ merged: list[InputText | InputAudio | InputImage] = []
612
+ for idx, uc in enumerate(user_new_content):
613
+ if uc.type == "input_audio":
614
+ # Attempt to preserve transcript if empty
615
+ transcript = getattr(uc, "transcript", None)
616
+ if transcript is None and idx < len(user_existing_content):
617
+ prev = user_existing_content[idx]
618
+ if isinstance(prev, InputAudio) and prev.transcript is not None:
619
+ uc = uc.model_copy(update={"transcript": prev.transcript})
620
+ merged.append(uc)
621
+ elif uc.type == "input_text":
622
+ text = getattr(uc, "text", None)
623
+ if (text is None or text == "") and idx < len(
624
+ user_existing_content
625
+ ):
626
+ prev = user_existing_content[idx]
627
+ if isinstance(prev, InputText) and prev.text:
628
+ uc = uc.model_copy(update={"text": prev.text})
629
+ merged.append(uc)
630
+ else:
631
+ merged.append(uc)
632
+
633
+ updated_user = event.model_copy(update={"content": merged})
634
+ new_history[existing_index] = updated_user
635
+ elif event.role == "system" and existing_item.role == "system":
636
+ system_existing_content = existing_item.content
637
+ system_incoming = event.content
638
+ # Prefer existing non-empty text when incoming is empty
639
+ system_new_content: list[InputText] = []
640
+ for idx, sc in enumerate(system_incoming):
641
+ if idx >= len(system_existing_content):
642
+ system_new_content.append(sc)
643
+ continue
644
+ system_current = system_existing_content[idx]
645
+ cur_text = system_current.text
646
+ if cur_text is not None and sc.text is None:
647
+ system_new_content.append(system_current)
648
+ else:
649
+ system_new_content.append(sc)
650
+ updated_system = event.model_copy(update={"content": system_new_content})
651
+ new_history[existing_index] = updated_system
652
+ else:
653
+ # Role changed or mismatched; just replace
654
+ new_history[existing_index] = event
655
+ else:
656
+ # If the existing item is not a message, just replace it.
657
+ new_history[existing_index] = event
429
658
  return new_history
659
+
430
660
  # Otherwise, insert it after the previous_item_id if that is set
431
661
  elif event.previous_item_id:
432
662
  # Insert the new item after the previous item
@@ -442,7 +672,7 @@ class RealtimeSession(RealtimeModelListener):
442
672
  # Otherwise, add it to the end
443
673
  return old_history + [event]
444
674
 
445
- async def _run_output_guardrails(self, text: str) -> bool:
675
+ async def _run_output_guardrails(self, text: str, response_id: str) -> bool:
446
676
  """Run output guardrails on the given text. Returns True if any guardrail was triggered."""
447
677
  combined_guardrails = self._current_agent.output_guardrails + self._run_config.get(
448
678
  "output_guardrails", []
@@ -455,7 +685,8 @@ class RealtimeSession(RealtimeModelListener):
455
685
  output_guardrails.append(guardrail)
456
686
  seen_ids.add(guardrail_id)
457
687
 
458
- if not output_guardrails or self._interrupted_by_guardrail:
688
+ # If we've already interrupted this response, skip
689
+ if not output_guardrails or response_id in self._interrupted_response_ids:
459
690
  return False
460
691
 
461
692
  triggered_results = []
@@ -475,8 +706,12 @@ class RealtimeSession(RealtimeModelListener):
475
706
  continue
476
707
 
477
708
  if triggered_results:
478
- # Mark as interrupted to prevent multiple interrupts
479
- self._interrupted_by_guardrail = True
709
+ # Double-check: bail if already interrupted for this response
710
+ if response_id in self._interrupted_response_ids:
711
+ return False
712
+
713
+ # Mark as interrupted immediately (before any awaits) to minimize race window
714
+ self._interrupted_response_ids.add(response_id)
480
715
 
481
716
  # Emit guardrail tripped event
482
717
  await self._put_event(
@@ -488,7 +723,7 @@ class RealtimeSession(RealtimeModelListener):
488
723
  )
489
724
 
490
725
  # Interrupt the model
491
- await self._model.send_event(RealtimeModelSendInterrupt())
726
+ await self._model.send_event(RealtimeModelSendInterrupt(force_response_cancel=True))
492
727
 
493
728
  # Send guardrail triggered message
494
729
  guardrail_names = [result.guardrail.get_name() for result in triggered_results]
@@ -502,10 +737,10 @@ class RealtimeSession(RealtimeModelListener):
502
737
 
503
738
  return False
504
739
 
505
- def _enqueue_guardrail_task(self, text: str) -> None:
740
+ def _enqueue_guardrail_task(self, text: str, response_id: str) -> None:
506
741
  # Runs the guardrails in a separate task to avoid blocking the main loop
507
742
 
508
- task = asyncio.create_task(self._run_output_guardrails(text))
743
+ task = asyncio.create_task(self._run_output_guardrails(text, response_id))
509
744
  self._guardrail_tasks.add(task)
510
745
 
511
746
  # Add callback to remove completed tasks and handle exceptions
@@ -536,10 +771,49 @@ class RealtimeSession(RealtimeModelListener):
536
771
  task.cancel()
537
772
  self._guardrail_tasks.clear()
538
773
 
774
+ def _enqueue_tool_call_task(
775
+ self, event: RealtimeModelToolCallEvent, agent_snapshot: RealtimeAgent
776
+ ) -> None:
777
+ """Run tool calls in the background to avoid blocking realtime transport."""
778
+ task = asyncio.create_task(self._handle_tool_call(event, agent_snapshot=agent_snapshot))
779
+ self._tool_call_tasks.add(task)
780
+ task.add_done_callback(self._on_tool_call_task_done)
781
+
782
+ def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
783
+ self._tool_call_tasks.discard(task)
784
+
785
+ if task.cancelled():
786
+ return
787
+
788
+ exception = task.exception()
789
+ if exception is None:
790
+ return
791
+
792
+ logger.exception("Realtime tool call task failed", exc_info=exception)
793
+
794
+ if self._stored_exception is None:
795
+ self._stored_exception = exception
796
+
797
+ asyncio.create_task(
798
+ self._put_event(
799
+ RealtimeError(
800
+ info=self._event_info,
801
+ error={"message": f"Tool call task failed: {exception}"},
802
+ )
803
+ )
804
+ )
805
+
806
+ def _cleanup_tool_call_tasks(self) -> None:
807
+ for task in self._tool_call_tasks:
808
+ if not task.done():
809
+ task.cancel()
810
+ self._tool_call_tasks.clear()
811
+
539
812
  async def _cleanup(self) -> None:
540
813
  """Clean up all resources and mark session as closed."""
541
814
  # Cancel and cleanup guardrail tasks
542
815
  self._cleanup_guardrail_tasks()
816
+ self._cleanup_tool_call_tasks()
543
817
 
544
818
  # Remove ourselves as a listener
545
819
  self._model.remove_listener(self)
@@ -555,12 +829,11 @@ class RealtimeSession(RealtimeModelListener):
555
829
  starting_settings: RealtimeSessionModelSettings | None,
556
830
  agent: RealtimeAgent,
557
831
  ) -> RealtimeSessionModelSettings:
558
- # Start with run config model settings as base
559
- run_config_settings = self._run_config.get("model_settings", {})
560
- updated_settings: RealtimeSessionModelSettings = run_config_settings.copy()
561
- # Apply starting settings (from model config) next
562
- if starting_settings:
563
- updated_settings.update(starting_settings)
832
+ # Start with the merged base settings from run and model configuration.
833
+ updated_settings = self._base_model_settings.copy()
834
+
835
+ if agent.prompt is not None:
836
+ updated_settings["prompt"] = agent.prompt
564
837
 
565
838
  instructions, tools, handoffs = await asyncio.gather(
566
839
  agent.get_system_prompt(self._context_wrapper),
@@ -571,6 +844,10 @@ class RealtimeSession(RealtimeModelListener):
571
844
  updated_settings["tools"] = tools or []
572
845
  updated_settings["handoffs"] = handoffs or []
573
846
 
847
+ # Apply starting settings (from model config) next
848
+ if starting_settings:
849
+ updated_settings.update(starting_settings)
850
+
574
851
  disable_tracing = self._run_config.get("tracing_disabled", False)
575
852
  if disable_tracing:
576
853
  updated_settings["tracing"] = None
agents/repl.py CHANGED
@@ -8,10 +8,13 @@ from .agent import Agent
8
8
  from .items import TResponseInputItem
9
9
  from .result import RunResultBase
10
10
  from .run import Runner
11
+ from .run_context import TContext
11
12
  from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, RunItemStreamEvent
12
13
 
13
14
 
14
- async def run_demo_loop(agent: Agent[Any], *, stream: bool = True) -> None:
15
+ async def run_demo_loop(
16
+ agent: Agent[Any], *, stream: bool = True, context: TContext | None = None
17
+ ) -> None:
15
18
  """Run a simple REPL loop with the given agent.
16
19
 
17
20
  This utility allows quick manual testing and debugging of an agent from the
@@ -21,6 +24,7 @@ async def run_demo_loop(agent: Agent[Any], *, stream: bool = True) -> None:
21
24
  Args:
22
25
  agent: The starting agent to run.
23
26
  stream: Whether to stream the agent output.
27
+ context: Additional context information to pass to the runner.
24
28
  """
25
29
 
26
30
  current_agent = agent
@@ -40,7 +44,7 @@ async def run_demo_loop(agent: Agent[Any], *, stream: bool = True) -> None:
40
44
 
41
45
  result: RunResultBase
42
46
  if stream:
43
- result = Runner.run_streamed(current_agent, input=input_items)
47
+ result = Runner.run_streamed(current_agent, input=input_items, context=context)
44
48
  async for event in result.stream_events():
45
49
  if isinstance(event, RawResponsesStreamEvent):
46
50
  if isinstance(event.data, ResponseTextDeltaEvent):
@@ -54,7 +58,7 @@ async def run_demo_loop(agent: Agent[Any], *, stream: bool = True) -> None:
54
58
  print(f"\n[Agent updated: {event.new_agent.name}]", flush=True)
55
59
  print()
56
60
  else:
57
- result = await Runner.run(current_agent, input_items)
61
+ result = await Runner.run(current_agent, input_items, context=context)
58
62
  if result.final_output is not None:
59
63
  print(result.final_output)
60
64