openai-agents 0.2.8__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 +164 -19
  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 +237 -11
  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 +14 -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 -49
  53. agents/models/openai_provider.py +10 -4
  54. agents/models/openai_responses.py +162 -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 +696 -150
  67. agents/realtime/session.py +243 -23
  68. agents/repl.py +7 -3
  69. agents/result.py +197 -38
  70. agents/run.py +949 -168
  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.8.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.8.dist-info → openai_agents-0.6.8.dist-info}/WHEEL +1 -1
  95. openai_agents-0.2.8.dist-info/RECORD +0 -103
  96. {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/licenses/LICENSE +0 -0
agents/run.py CHANGED
@@ -1,14 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import contextlib
4
5
  import inspect
6
+ import os
7
+ import warnings
5
8
  from dataclasses import dataclass, field
6
- from typing import Any, Callable, Generic, cast
9
+ from typing import Any, Callable, Generic, cast, get_args, get_origin
7
10
 
8
- from openai.types.responses import ResponseCompletedEvent
11
+ from openai.types.responses import (
12
+ ResponseCompletedEvent,
13
+ ResponseOutputItemDoneEvent,
14
+ )
9
15
  from openai.types.responses.response_prompt_param import (
10
16
  ResponsePromptParam,
11
17
  )
18
+ from openai.types.responses.response_reasoning_item import ResponseReasoningItem
12
19
  from typing_extensions import NotRequired, TypedDict, Unpack
13
20
 
14
21
  from ._run_impl import (
@@ -39,19 +46,36 @@ from .guardrail import (
39
46
  OutputGuardrail,
40
47
  OutputGuardrailResult,
41
48
  )
42
- from .handoffs import Handoff, HandoffInputFilter, handoff
43
- from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem
44
- from .lifecycle import RunHooks
49
+ from .handoffs import Handoff, HandoffHistoryMapper, HandoffInputFilter, handoff
50
+ from .items import (
51
+ HandoffCallItem,
52
+ HandoffOutputItem,
53
+ ItemHelpers,
54
+ ModelResponse,
55
+ ReasoningItem,
56
+ RunItem,
57
+ ToolCallItem,
58
+ ToolCallItemTypes,
59
+ ToolCallOutputItem,
60
+ TResponseInputItem,
61
+ )
62
+ from .lifecycle import AgentHooksBase, RunHooks, RunHooksBase
45
63
  from .logger import logger
46
- from .memory import Session
64
+ from .memory import Session, SessionInputCallback, is_openai_responses_compaction_aware_session
47
65
  from .model_settings import ModelSettings
48
66
  from .models.interface import Model, ModelProvider
49
67
  from .models.multi_provider import MultiProvider
50
68
  from .result import RunResult, RunResultStreaming
51
- from .run_context import RunContextWrapper, TContext
52
- from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent
53
- from .tool import Tool
54
- from .tracing import Span, SpanError, agent_span, get_current_trace, trace
69
+ from .run_context import AgentHookContext, RunContextWrapper, TContext
70
+ from .stream_events import (
71
+ AgentUpdatedStreamEvent,
72
+ RawResponsesStreamEvent,
73
+ RunItemStreamEvent,
74
+ StreamEvent,
75
+ )
76
+ from .tool import Tool, dispose_resolved_computers
77
+ from .tool_guardrails import ToolInputGuardrailResult, ToolOutputGuardrailResult
78
+ from .tracing import Span, SpanError, TracingConfig, agent_span, get_current_trace, trace
55
79
  from .tracing.span_data import AgentSpanData
56
80
  from .usage import Usage
57
81
  from .util import _coro, _error_tracing
@@ -81,6 +105,12 @@ def get_default_agent_runner() -> AgentRunner:
81
105
  return DEFAULT_AGENT_RUNNER
82
106
 
83
107
 
108
+ def _default_trace_include_sensitive_data() -> bool:
109
+ """Returns the default value for trace_include_sensitive_data based on environment variable."""
110
+ val = os.getenv("OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA", "true")
111
+ return val.strip().lower() in ("1", "true", "yes", "on")
112
+
113
+
84
114
  @dataclass
85
115
  class ModelInputData:
86
116
  """Container for the data that will be sent to the model."""
@@ -98,6 +128,56 @@ class CallModelData(Generic[TContext]):
98
128
  context: TContext | None
99
129
 
100
130
 
131
+ @dataclass
132
+ class _ServerConversationTracker:
133
+ """Tracks server-side conversation state for either conversation_id or
134
+ previous_response_id modes.
135
+
136
+ Note: When auto_previous_response_id=True is used, response chaining is enabled
137
+ automatically for the first turn, even when there's no actual previous response ID yet.
138
+ """
139
+
140
+ conversation_id: str | None = None
141
+ previous_response_id: str | None = None
142
+ auto_previous_response_id: bool = False
143
+ sent_items: set[int] = field(default_factory=set)
144
+ server_items: set[int] = field(default_factory=set)
145
+
146
+ def track_server_items(self, model_response: ModelResponse) -> None:
147
+ for output_item in model_response.output:
148
+ self.server_items.add(id(output_item))
149
+
150
+ # Update previous_response_id when using previous_response_id mode or auto mode
151
+ if (
152
+ self.conversation_id is None
153
+ and (self.previous_response_id is not None or self.auto_previous_response_id)
154
+ and model_response.response_id is not None
155
+ ):
156
+ self.previous_response_id = model_response.response_id
157
+
158
+ def prepare_input(
159
+ self,
160
+ original_input: str | list[TResponseInputItem],
161
+ generated_items: list[RunItem],
162
+ ) -> list[TResponseInputItem]:
163
+ input_items: list[TResponseInputItem] = []
164
+
165
+ # On first call (when there are no generated items yet), include the original input
166
+ if not generated_items:
167
+ input_items.extend(ItemHelpers.input_to_new_input_list(original_input))
168
+
169
+ # Process generated_items, skip items already sent or from server
170
+ for item in generated_items:
171
+ raw_item_id = id(item.raw_item)
172
+
173
+ if raw_item_id in self.sent_items or raw_item_id in self.server_items:
174
+ continue
175
+ input_items.append(item.to_input_item())
176
+ self.sent_items.add(raw_item_id)
177
+
178
+ return input_items
179
+
180
+
101
181
  # Type alias for the optional input filter callback
102
182
  CallModelInputFilter = Callable[[CallModelData[Any]], MaybeAwaitable[ModelInputData]]
103
183
 
@@ -125,6 +205,19 @@ class RunConfig:
125
205
  agent. See the documentation in `Handoff.input_filter` for more details.
126
206
  """
127
207
 
208
+ nest_handoff_history: bool = True
209
+ """Wrap prior run history in a single assistant message before handing off when no custom
210
+ input filter is set. Set to False to preserve the raw transcript behavior from previous
211
+ releases.
212
+ """
213
+
214
+ handoff_history_mapper: HandoffHistoryMapper | None = None
215
+ """Optional function that receives the normalized transcript (history + handoff items) and
216
+ returns the input history that should be passed to the next agent. When left as `None`, the
217
+ runner collapses the transcript into a single assistant message. This function only runs when
218
+ `nest_handoff_history` is True.
219
+ """
220
+
128
221
  input_guardrails: list[InputGuardrail[Any]] | None = None
129
222
  """A list of input guardrails to run on the initial run input."""
130
223
 
@@ -135,7 +228,12 @@ class RunConfig:
135
228
  """Whether tracing is disabled for the agent run. If disabled, we will not trace the agent run.
136
229
  """
137
230
 
138
- trace_include_sensitive_data: bool = True
231
+ tracing: TracingConfig | None = None
232
+ """Tracing configuration for this run."""
233
+
234
+ trace_include_sensitive_data: bool = field(
235
+ default_factory=_default_trace_include_sensitive_data
236
+ )
139
237
  """Whether we include potentially sensitive data (for example: inputs/outputs of tool calls or
140
238
  LLM generations) in traces. If False, we'll still create spans for these events, but the
141
239
  sensitive data will not be included.
@@ -160,6 +258,13 @@ class RunConfig:
160
258
  An optional dictionary of additional metadata to include with the trace.
161
259
  """
162
260
 
261
+ session_input_callback: SessionInputCallback | None = None
262
+ """Defines how to handle session history when new input is provided.
263
+ - `None` (default): The new input is appended to the session history.
264
+ - `SessionInputCallback`: A custom function that receives the history and new input, and
265
+ returns the desired combined list of items.
266
+ """
267
+
163
268
  call_model_input_filter: CallModelInputFilter | None = None
164
269
  """
165
270
  Optional callback that is invoked immediately before calling the model. It receives the current
@@ -189,6 +294,12 @@ class RunOptions(TypedDict, Generic[TContext]):
189
294
  previous_response_id: NotRequired[str | None]
190
295
  """The ID of the previous response, if any."""
191
296
 
297
+ auto_previous_response_id: NotRequired[bool]
298
+ """Enable automatic response chaining for the first turn."""
299
+
300
+ conversation_id: NotRequired[str | None]
301
+ """The ID of the stored conversation, if any."""
302
+
192
303
  session: NotRequired[Session | None]
193
304
  """The session for the run."""
194
305
 
@@ -205,34 +316,58 @@ class Runner:
205
316
  hooks: RunHooks[TContext] | None = None,
206
317
  run_config: RunConfig | None = None,
207
318
  previous_response_id: str | None = None,
319
+ auto_previous_response_id: bool = False,
320
+ conversation_id: str | None = None,
208
321
  session: Session | None = None,
209
322
  ) -> RunResult:
210
- """Run a workflow starting at the given agent. The agent will run in a loop until a final
211
- output is generated. The loop runs like so:
212
- 1. The agent is invoked with the given input.
213
- 2. If there is a final output (i.e. the agent produces something of type
214
- `agent.output_type`, the loop terminates.
215
- 3. If there's a handoff, we run the loop again, with the new agent.
216
- 4. Else, we run tool calls (if any), and re-run the loop.
323
+ """
324
+ Run a workflow starting at the given agent.
325
+
326
+ The agent will run in a loop until a final output is generated. The loop runs like so:
327
+
328
+ 1. The agent is invoked with the given input.
329
+ 2. If there is a final output (i.e. the agent produces something of type
330
+ `agent.output_type`), the loop terminates.
331
+ 3. If there's a handoff, we run the loop again, with the new agent.
332
+ 4. Else, we run tool calls (if any), and re-run the loop.
333
+
217
334
  In two cases, the agent may raise an exception:
218
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
219
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
220
- Note that only the first agent's input guardrails are run.
335
+
336
+ 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
337
+ 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered
338
+ exception is raised.
339
+
340
+ Note:
341
+ Only the first agent's input guardrails are run.
342
+
221
343
  Args:
222
344
  starting_agent: The starting agent to run.
223
- input: The initial input to the agent. You can pass a single string for a user message,
224
- or a list of input items.
345
+ input: The initial input to the agent. You can pass a single string for a
346
+ user message, or a list of input items.
225
347
  context: The context to run the agent with.
226
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
227
- AI invocation (including any tool calls that might occur).
348
+ max_turns: The maximum number of turns to run the agent for. A turn is
349
+ defined as one AI invocation (including any tool calls that might occur).
228
350
  hooks: An object that receives callbacks on various lifecycle events.
229
351
  run_config: Global settings for the entire agent run.
230
- previous_response_id: The ID of the previous response, if using OpenAI models via the
231
- Responses API, this allows you to skip passing in input from the previous turn.
352
+ previous_response_id: The ID of the previous response. If using OpenAI
353
+ models via the Responses API, this allows you to skip passing in input
354
+ from the previous turn.
355
+ conversation_id: The conversation ID
356
+ (https://platform.openai.com/docs/guides/conversation-state?api-mode=responses).
357
+ If provided, the conversation will be used to read and write items.
358
+ Every agent will have access to the conversation history so far,
359
+ and its output items will be written to the conversation.
360
+ We recommend only using this if you are exclusively using OpenAI models;
361
+ other model providers don't write to the Conversation object,
362
+ so you'll end up having partial conversations stored.
363
+ session: A session for automatic conversation history management.
364
+
232
365
  Returns:
233
- A run result containing all the inputs, guardrail results and the output of the last
234
- agent. Agents may perform handoffs, so we don't know the specific type of the output.
366
+ A run result containing all the inputs, guardrail results and the output of
367
+ the last agent. Agents may perform handoffs, so we don't know the specific
368
+ type of the output.
235
369
  """
370
+
236
371
  runner = DEFAULT_AGENT_RUNNER
237
372
  return await runner.run(
238
373
  starting_agent,
@@ -242,6 +377,8 @@ class Runner:
242
377
  hooks=hooks,
243
378
  run_config=run_config,
244
379
  previous_response_id=previous_response_id,
380
+ auto_previous_response_id=auto_previous_response_id,
381
+ conversation_id=conversation_id,
245
382
  session=session,
246
383
  )
247
384
 
@@ -256,37 +393,56 @@ class Runner:
256
393
  hooks: RunHooks[TContext] | None = None,
257
394
  run_config: RunConfig | None = None,
258
395
  previous_response_id: str | None = None,
396
+ auto_previous_response_id: bool = False,
397
+ conversation_id: str | None = None,
259
398
  session: Session | None = None,
260
399
  ) -> RunResult:
261
- """Run a workflow synchronously, starting at the given agent. Note that this just wraps the
262
- `run` method, so it will not work if there's already an event loop (e.g. inside an async
263
- function, or in a Jupyter notebook or async context like FastAPI). For those cases, use
264
- the `run` method instead.
265
- The agent will run in a loop until a final output is generated. The loop runs like so:
266
- 1. The agent is invoked with the given input.
267
- 2. If there is a final output (i.e. the agent produces something of type
268
- `agent.output_type`, the loop terminates.
269
- 3. If there's a handoff, we run the loop again, with the new agent.
270
- 4. Else, we run tool calls (if any), and re-run the loop.
400
+ """
401
+ Run a workflow synchronously, starting at the given agent.
402
+
403
+ Note:
404
+ This just wraps the `run` method, so it will not work if there's already an
405
+ event loop (e.g. inside an async function, or in a Jupyter notebook or async
406
+ context like FastAPI). For those cases, use the `run` method instead.
407
+
408
+ The agent will run in a loop until a final output is generated. The loop runs:
409
+
410
+ 1. The agent is invoked with the given input.
411
+ 2. If there is a final output (i.e. the agent produces something of type
412
+ `agent.output_type`), the loop terminates.
413
+ 3. If there's a handoff, we run the loop again, with the new agent.
414
+ 4. Else, we run tool calls (if any), and re-run the loop.
415
+
271
416
  In two cases, the agent may raise an exception:
272
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
273
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
274
- Note that only the first agent's input guardrails are run.
417
+
418
+ 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
419
+ 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered
420
+ exception is raised.
421
+
422
+ Note:
423
+ Only the first agent's input guardrails are run.
424
+
275
425
  Args:
276
426
  starting_agent: The starting agent to run.
277
- input: The initial input to the agent. You can pass a single string for a user message,
278
- or a list of input items.
427
+ input: The initial input to the agent. You can pass a single string for a
428
+ user message, or a list of input items.
279
429
  context: The context to run the agent with.
280
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
281
- AI invocation (including any tool calls that might occur).
430
+ max_turns: The maximum number of turns to run the agent for. A turn is
431
+ defined as one AI invocation (including any tool calls that might occur).
282
432
  hooks: An object that receives callbacks on various lifecycle events.
283
433
  run_config: Global settings for the entire agent run.
284
- previous_response_id: The ID of the previous response, if using OpenAI models via the
285
- Responses API, this allows you to skip passing in input from the previous turn.
434
+ previous_response_id: The ID of the previous response, if using OpenAI
435
+ models via the Responses API, this allows you to skip passing in input
436
+ from the previous turn.
437
+ conversation_id: The ID of the stored conversation, if any.
438
+ session: A session for automatic conversation history management.
439
+
286
440
  Returns:
287
- A run result containing all the inputs, guardrail results and the output of the last
288
- agent. Agents may perform handoffs, so we don't know the specific type of the output.
441
+ A run result containing all the inputs, guardrail results and the output of
442
+ the last agent. Agents may perform handoffs, so we don't know the specific
443
+ type of the output.
289
444
  """
445
+
290
446
  runner = DEFAULT_AGENT_RUNNER
291
447
  return runner.run_sync(
292
448
  starting_agent,
@@ -296,7 +452,9 @@ class Runner:
296
452
  hooks=hooks,
297
453
  run_config=run_config,
298
454
  previous_response_id=previous_response_id,
455
+ conversation_id=conversation_id,
299
456
  session=session,
457
+ auto_previous_response_id=auto_previous_response_id,
300
458
  )
301
459
 
302
460
  @classmethod
@@ -309,34 +467,53 @@ class Runner:
309
467
  hooks: RunHooks[TContext] | None = None,
310
468
  run_config: RunConfig | None = None,
311
469
  previous_response_id: str | None = None,
470
+ auto_previous_response_id: bool = False,
471
+ conversation_id: str | None = None,
312
472
  session: Session | None = None,
313
473
  ) -> RunResultStreaming:
314
- """Run a workflow starting at the given agent in streaming mode. The returned result object
315
- contains a method you can use to stream semantic events as they are generated.
474
+ """
475
+ Run a workflow starting at the given agent in streaming mode.
476
+
477
+ The returned result object contains a method you can use to stream semantic
478
+ events as they are generated.
479
+
316
480
  The agent will run in a loop until a final output is generated. The loop runs like so:
317
- 1. The agent is invoked with the given input.
318
- 2. If there is a final output (i.e. the agent produces something of type
319
- `agent.output_type`, the loop terminates.
320
- 3. If there's a handoff, we run the loop again, with the new agent.
321
- 4. Else, we run tool calls (if any), and re-run the loop.
481
+
482
+ 1. The agent is invoked with the given input.
483
+ 2. If there is a final output (i.e. the agent produces something of type
484
+ `agent.output_type`), the loop terminates.
485
+ 3. If there's a handoff, we run the loop again, with the new agent.
486
+ 4. Else, we run tool calls (if any), and re-run the loop.
487
+
322
488
  In two cases, the agent may raise an exception:
323
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
324
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
325
- Note that only the first agent's input guardrails are run.
489
+
490
+ 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
491
+ 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered
492
+ exception is raised.
493
+
494
+ Note:
495
+ Only the first agent's input guardrails are run.
496
+
326
497
  Args:
327
498
  starting_agent: The starting agent to run.
328
- input: The initial input to the agent. You can pass a single string for a user message,
329
- or a list of input items.
499
+ input: The initial input to the agent. You can pass a single string for a
500
+ user message, or a list of input items.
330
501
  context: The context to run the agent with.
331
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
332
- AI invocation (including any tool calls that might occur).
502
+ max_turns: The maximum number of turns to run the agent for. A turn is
503
+ defined as one AI invocation (including any tool calls that might occur).
333
504
  hooks: An object that receives callbacks on various lifecycle events.
334
505
  run_config: Global settings for the entire agent run.
335
- previous_response_id: The ID of the previous response, if using OpenAI models via the
336
- Responses API, this allows you to skip passing in input from the previous turn.
506
+ previous_response_id: The ID of the previous response, if using OpenAI
507
+ models via the Responses API, this allows you to skip passing in input
508
+ from the previous turn.
509
+ conversation_id: The ID of the stored conversation, if any.
510
+ session: A session for automatic conversation history management.
511
+
337
512
  Returns:
338
- A result object that contains data about the run, as well as a method to stream events.
513
+ A result object that contains data about the run, as well as a method to
514
+ stream events.
339
515
  """
516
+
340
517
  runner = DEFAULT_AGENT_RUNNER
341
518
  return runner.run_streamed(
342
519
  starting_agent,
@@ -346,6 +523,8 @@ class Runner:
346
523
  hooks=hooks,
347
524
  run_config=run_config,
348
525
  previous_response_id=previous_response_id,
526
+ auto_previous_response_id=auto_previous_response_id,
527
+ conversation_id=conversation_id,
349
528
  session=session,
350
529
  )
351
530
 
@@ -364,17 +543,35 @@ class AgentRunner:
364
543
  ) -> RunResult:
365
544
  context = kwargs.get("context")
366
545
  max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
367
- hooks = kwargs.get("hooks")
546
+ hooks = cast(RunHooks[TContext], self._validate_run_hooks(kwargs.get("hooks")))
368
547
  run_config = kwargs.get("run_config")
369
548
  previous_response_id = kwargs.get("previous_response_id")
549
+ auto_previous_response_id = kwargs.get("auto_previous_response_id", False)
550
+ conversation_id = kwargs.get("conversation_id")
370
551
  session = kwargs.get("session")
371
- if hooks is None:
372
- hooks = RunHooks[Any]()
552
+
373
553
  if run_config is None:
374
554
  run_config = RunConfig()
375
555
 
376
- # Prepare input with session if enabled
377
- prepared_input = await self._prepare_input_with_session(input, session)
556
+ # Check whether to enable OpenAI server-managed conversation
557
+ if (
558
+ conversation_id is not None
559
+ or previous_response_id is not None
560
+ or auto_previous_response_id
561
+ ):
562
+ server_conversation_tracker = _ServerConversationTracker(
563
+ conversation_id=conversation_id,
564
+ previous_response_id=previous_response_id,
565
+ auto_previous_response_id=auto_previous_response_id,
566
+ )
567
+ else:
568
+ server_conversation_tracker = None
569
+
570
+ # Keep original user input separate from session-prepared input
571
+ original_user_input = input
572
+ prepared_input = await self._prepare_input_with_session(
573
+ input, session, run_config.session_input_callback
574
+ )
378
575
 
379
576
  tool_use_tracker = AgentToolUseTracker()
380
577
 
@@ -383,11 +580,13 @@ class AgentRunner:
383
580
  trace_id=run_config.trace_id,
384
581
  group_id=run_config.group_id,
385
582
  metadata=run_config.trace_metadata,
583
+ tracing=run_config.tracing,
386
584
  disabled=run_config.tracing_disabled,
387
585
  ):
388
586
  current_turn = 0
389
587
  original_input: str | list[TResponseInputItem] = _copy_str_or_list(prepared_input)
390
- generated_items: list[RunItem] = []
588
+ generated_items: list[RunItem] = [] # For model input (may be filtered on handoffs)
589
+ session_items: list[RunItem] = [] # For observability (always unfiltered)
391
590
  model_responses: list[ModelResponse] = []
392
591
 
393
592
  context_wrapper: RunContextWrapper[TContext] = RunContextWrapper(
@@ -395,14 +594,22 @@ class AgentRunner:
395
594
  )
396
595
 
397
596
  input_guardrail_results: list[InputGuardrailResult] = []
597
+ tool_input_guardrail_results: list[ToolInputGuardrailResult] = []
598
+ tool_output_guardrail_results: list[ToolOutputGuardrailResult] = []
398
599
 
399
600
  current_span: Span[AgentSpanData] | None = None
400
601
  current_agent = starting_agent
401
602
  should_run_agent_start_hooks = True
402
603
 
604
+ # save only the new user input to the session, not the combined history
605
+ await self._save_result_to_session(session, original_user_input, [])
606
+
403
607
  try:
404
608
  while True:
405
609
  all_tools = await AgentRunner._get_all_tools(current_agent, context_wrapper)
610
+ await RunImpl.initialize_computer_tools(
611
+ tools=all_tools, context_wrapper=context_wrapper
612
+ )
406
613
 
407
614
  # Start an agent span if we don't have one. This span is ended if the current
408
615
  # agent changes, or if the agent loop ends.
@@ -440,11 +647,31 @@ class AgentRunner:
440
647
  )
441
648
 
442
649
  if current_turn == 1:
650
+ # Separate guardrails based on execution mode.
651
+ all_input_guardrails = starting_agent.input_guardrails + (
652
+ run_config.input_guardrails or []
653
+ )
654
+ sequential_guardrails = [
655
+ g for g in all_input_guardrails if not g.run_in_parallel
656
+ ]
657
+ parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
658
+
659
+ # Run blocking guardrails first, before agent starts.
660
+ # (will raise exception if tripwire triggered).
661
+ sequential_results = []
662
+ if sequential_guardrails:
663
+ sequential_results = await self._run_input_guardrails(
664
+ starting_agent,
665
+ sequential_guardrails,
666
+ _copy_str_or_list(prepared_input),
667
+ context_wrapper,
668
+ )
669
+
670
+ # Run parallel guardrails + agent together.
443
671
  input_guardrail_results, turn_result = await asyncio.gather(
444
672
  self._run_input_guardrails(
445
673
  starting_agent,
446
- starting_agent.input_guardrails
447
- + (run_config.input_guardrails or []),
674
+ parallel_guardrails,
448
675
  _copy_str_or_list(prepared_input),
449
676
  context_wrapper,
450
677
  ),
@@ -458,9 +685,12 @@ class AgentRunner:
458
685
  run_config=run_config,
459
686
  should_run_agent_start_hooks=should_run_agent_start_hooks,
460
687
  tool_use_tracker=tool_use_tracker,
461
- previous_response_id=previous_response_id,
688
+ server_conversation_tracker=server_conversation_tracker,
462
689
  ),
463
690
  )
691
+
692
+ # Combine sequential and parallel results.
693
+ input_guardrail_results = sequential_results + input_guardrail_results
464
694
  else:
465
695
  turn_result = await self._run_single_turn(
466
696
  agent=current_agent,
@@ -472,51 +702,111 @@ class AgentRunner:
472
702
  run_config=run_config,
473
703
  should_run_agent_start_hooks=should_run_agent_start_hooks,
474
704
  tool_use_tracker=tool_use_tracker,
475
- previous_response_id=previous_response_id,
705
+ server_conversation_tracker=server_conversation_tracker,
476
706
  )
477
707
  should_run_agent_start_hooks = False
478
708
 
479
709
  model_responses.append(turn_result.model_response)
480
710
  original_input = turn_result.original_input
481
- generated_items = turn_result.generated_items
711
+ # For model input, use new_step_items (filtered on handoffs)
712
+ generated_items = turn_result.pre_step_items + turn_result.new_step_items
713
+ # Accumulate unfiltered items for observability
714
+ session_items_for_turn = (
715
+ turn_result.session_step_items
716
+ if turn_result.session_step_items is not None
717
+ else turn_result.new_step_items
718
+ )
719
+ session_items.extend(session_items_for_turn)
482
720
 
483
- if isinstance(turn_result.next_step, NextStepFinalOutput):
484
- output_guardrail_results = await self._run_output_guardrails(
485
- current_agent.output_guardrails + (run_config.output_guardrails or []),
486
- current_agent,
487
- turn_result.next_step.output,
488
- context_wrapper,
489
- )
490
- result = RunResult(
491
- input=original_input,
492
- new_items=generated_items,
493
- raw_responses=model_responses,
494
- final_output=turn_result.next_step.output,
495
- _last_agent=current_agent,
496
- input_guardrail_results=input_guardrail_results,
497
- output_guardrail_results=output_guardrail_results,
498
- context_wrapper=context_wrapper,
499
- )
721
+ if server_conversation_tracker is not None:
722
+ server_conversation_tracker.track_server_items(turn_result.model_response)
500
723
 
501
- # Save the conversation to session if enabled
502
- await self._save_result_to_session(session, input, result)
724
+ # Collect tool guardrail results from this turn
725
+ tool_input_guardrail_results.extend(turn_result.tool_input_guardrail_results)
726
+ tool_output_guardrail_results.extend(turn_result.tool_output_guardrail_results)
503
727
 
504
- return result
505
- elif isinstance(turn_result.next_step, NextStepHandoff):
506
- current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
507
- current_span.finish(reset_current=True)
508
- current_span = None
509
- should_run_agent_start_hooks = True
510
- elif isinstance(turn_result.next_step, NextStepRunAgain):
511
- pass
512
- else:
513
- raise AgentsException(
514
- f"Unknown next step type: {type(turn_result.next_step)}"
515
- )
728
+ try:
729
+ if isinstance(turn_result.next_step, NextStepFinalOutput):
730
+ output_guardrail_results = await self._run_output_guardrails(
731
+ current_agent.output_guardrails
732
+ + (run_config.output_guardrails or []),
733
+ current_agent,
734
+ turn_result.next_step.output,
735
+ context_wrapper,
736
+ )
737
+ result = RunResult(
738
+ input=original_input,
739
+ new_items=session_items, # Use unfiltered items for observability
740
+ raw_responses=model_responses,
741
+ final_output=turn_result.next_step.output,
742
+ _last_agent=current_agent,
743
+ input_guardrail_results=input_guardrail_results,
744
+ output_guardrail_results=output_guardrail_results,
745
+ tool_input_guardrail_results=tool_input_guardrail_results,
746
+ tool_output_guardrail_results=tool_output_guardrail_results,
747
+ context_wrapper=context_wrapper,
748
+ )
749
+ if not any(
750
+ guardrail_result.output.tripwire_triggered
751
+ for guardrail_result in input_guardrail_results
752
+ ):
753
+ await self._save_result_to_session(
754
+ session,
755
+ [],
756
+ turn_result.session_step_items
757
+ if turn_result.session_step_items is not None
758
+ else turn_result.new_step_items,
759
+ turn_result.model_response.response_id,
760
+ )
761
+
762
+ return result
763
+ elif isinstance(turn_result.next_step, NextStepHandoff):
764
+ # Save the conversation to session if enabled (before handoff)
765
+ if session is not None:
766
+ if not any(
767
+ guardrail_result.output.tripwire_triggered
768
+ for guardrail_result in input_guardrail_results
769
+ ):
770
+ await self._save_result_to_session(
771
+ session,
772
+ [],
773
+ turn_result.session_step_items
774
+ if turn_result.session_step_items is not None
775
+ else turn_result.new_step_items,
776
+ turn_result.model_response.response_id,
777
+ )
778
+ current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
779
+ current_span.finish(reset_current=True)
780
+ current_span = None
781
+ should_run_agent_start_hooks = True
782
+ elif isinstance(turn_result.next_step, NextStepRunAgain):
783
+ if not any(
784
+ guardrail_result.output.tripwire_triggered
785
+ for guardrail_result in input_guardrail_results
786
+ ):
787
+ await self._save_result_to_session(
788
+ session,
789
+ [],
790
+ turn_result.session_step_items
791
+ if turn_result.session_step_items is not None
792
+ else turn_result.new_step_items,
793
+ turn_result.model_response.response_id,
794
+ )
795
+ else:
796
+ raise AgentsException(
797
+ f"Unknown next step type: {type(turn_result.next_step)}"
798
+ )
799
+ finally:
800
+ # RunImpl.execute_tools_and_side_effects returns a SingleStepResult that
801
+ # stores direct references to the `pre_step_items` and `new_step_items`
802
+ # lists it manages internally. Clear them here so the next turn does not
803
+ # hold on to items from previous turns and to avoid leaking agent refs.
804
+ turn_result.pre_step_items.clear()
805
+ turn_result.new_step_items.clear()
516
806
  except AgentsException as exc:
517
807
  exc.run_data = RunErrorDetails(
518
808
  input=original_input,
519
- new_items=generated_items,
809
+ new_items=session_items, # Use unfiltered items for observability
520
810
  raw_responses=model_responses,
521
811
  last_agent=current_agent,
522
812
  context_wrapper=context_wrapper,
@@ -525,6 +815,10 @@ class AgentRunner:
525
815
  )
526
816
  raise
527
817
  finally:
818
+ try:
819
+ await dispose_resolved_computers(run_context=context_wrapper)
820
+ except Exception as error:
821
+ logger.warning("Failed to dispose computers after run: %s", error)
528
822
  if current_span:
529
823
  current_span.finish(reset_current=True)
530
824
 
@@ -539,9 +833,44 @@ class AgentRunner:
539
833
  hooks = kwargs.get("hooks")
540
834
  run_config = kwargs.get("run_config")
541
835
  previous_response_id = kwargs.get("previous_response_id")
836
+ auto_previous_response_id = kwargs.get("auto_previous_response_id", False)
837
+ conversation_id = kwargs.get("conversation_id")
542
838
  session = kwargs.get("session")
543
839
 
544
- return asyncio.get_event_loop().run_until_complete(
840
+ # Python 3.14 stopped implicitly wiring up a default event loop
841
+ # when synchronous code touches asyncio APIs for the first time.
842
+ # Several of our synchronous entry points (for example the Redis/SQLAlchemy session helpers)
843
+ # construct asyncio primitives like asyncio.Lock during __init__,
844
+ # which binds them to whatever loop happens to be the thread's default at that moment.
845
+ # To keep those locks usable we must ensure that run_sync reuses that same default loop
846
+ # instead of hopping over to a brand-new asyncio.run() loop.
847
+ try:
848
+ already_running_loop = asyncio.get_running_loop()
849
+ except RuntimeError:
850
+ already_running_loop = None
851
+
852
+ if already_running_loop is not None:
853
+ # This method is only expected to run when no loop is already active.
854
+ # (Each thread has its own default loop; concurrent sync runs should happen on
855
+ # different threads. In a single thread use the async API to interleave work.)
856
+ raise RuntimeError(
857
+ "AgentRunner.run_sync() cannot be called when an event loop is already running."
858
+ )
859
+
860
+ policy = asyncio.get_event_loop_policy()
861
+ with warnings.catch_warnings():
862
+ warnings.simplefilter("ignore", DeprecationWarning)
863
+ try:
864
+ default_loop = policy.get_event_loop()
865
+ except RuntimeError:
866
+ default_loop = policy.new_event_loop()
867
+ policy.set_event_loop(default_loop)
868
+
869
+ # We intentionally leave the default loop open even if we had to create one above. Session
870
+ # instances and other helpers stash loop-bound primitives between calls and expect to find
871
+ # the same default loop every time run_sync is invoked on this thread.
872
+ # Schedule the async run on the default loop so that we can manage cancellation explicitly.
873
+ task = default_loop.create_task(
545
874
  self.run(
546
875
  starting_agent,
547
876
  input,
@@ -551,9 +880,29 @@ class AgentRunner:
551
880
  hooks=hooks,
552
881
  run_config=run_config,
553
882
  previous_response_id=previous_response_id,
883
+ auto_previous_response_id=auto_previous_response_id,
884
+ conversation_id=conversation_id,
554
885
  )
555
886
  )
556
887
 
888
+ try:
889
+ # Drive the coroutine to completion, harvesting the final RunResult.
890
+ return default_loop.run_until_complete(task)
891
+ except BaseException:
892
+ # If the sync caller aborts (KeyboardInterrupt, etc.), make sure the scheduled task
893
+ # does not linger on the shared loop by cancelling it and waiting for completion.
894
+ if not task.done():
895
+ task.cancel()
896
+ with contextlib.suppress(asyncio.CancelledError):
897
+ default_loop.run_until_complete(task)
898
+ raise
899
+ finally:
900
+ if not default_loop.is_closed():
901
+ # The loop stays open for subsequent runs, but we still need to flush any pending
902
+ # async generators so their cleanup code executes promptly.
903
+ with contextlib.suppress(RuntimeError):
904
+ default_loop.run_until_complete(default_loop.shutdown_asyncgens())
905
+
557
906
  def run_streamed(
558
907
  self,
559
908
  starting_agent: Agent[TContext],
@@ -562,13 +911,13 @@ class AgentRunner:
562
911
  ) -> RunResultStreaming:
563
912
  context = kwargs.get("context")
564
913
  max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
565
- hooks = kwargs.get("hooks")
914
+ hooks = cast(RunHooks[TContext], self._validate_run_hooks(kwargs.get("hooks")))
566
915
  run_config = kwargs.get("run_config")
567
916
  previous_response_id = kwargs.get("previous_response_id")
917
+ auto_previous_response_id = kwargs.get("auto_previous_response_id", False)
918
+ conversation_id = kwargs.get("conversation_id")
568
919
  session = kwargs.get("session")
569
920
 
570
- if hooks is None:
571
- hooks = RunHooks[Any]()
572
921
  if run_config is None:
573
922
  run_config = RunConfig()
574
923
 
@@ -583,6 +932,7 @@ class AgentRunner:
583
932
  trace_id=run_config.trace_id,
584
933
  group_id=run_config.group_id,
585
934
  metadata=run_config.trace_metadata,
935
+ tracing=run_config.tracing,
586
936
  disabled=run_config.tracing_disabled,
587
937
  )
588
938
  )
@@ -603,6 +953,8 @@ class AgentRunner:
603
953
  max_turns=max_turns,
604
954
  input_guardrail_results=[],
605
955
  output_guardrail_results=[],
956
+ tool_input_guardrail_results=[],
957
+ tool_output_guardrail_results=[],
606
958
  _current_agent_output_schema=output_schema,
607
959
  trace=new_trace,
608
960
  context_wrapper=context_wrapper,
@@ -619,11 +971,30 @@ class AgentRunner:
619
971
  context_wrapper=context_wrapper,
620
972
  run_config=run_config,
621
973
  previous_response_id=previous_response_id,
974
+ auto_previous_response_id=auto_previous_response_id,
975
+ conversation_id=conversation_id,
622
976
  session=session,
623
977
  )
624
978
  )
625
979
  return streamed_result
626
980
 
981
+ @staticmethod
982
+ def _validate_run_hooks(
983
+ hooks: RunHooksBase[Any, Agent[Any]] | AgentHooksBase[Any, Agent[Any]] | Any | None,
984
+ ) -> RunHooks[Any]:
985
+ if hooks is None:
986
+ return RunHooks[Any]()
987
+ input_hook_type = type(hooks).__name__
988
+ if isinstance(hooks, AgentHooksBase):
989
+ raise TypeError(
990
+ "Run hooks must be instances of RunHooks. "
991
+ f"Received agent-scoped hooks ({input_hook_type}). "
992
+ "Attach AgentHooks to an Agent via Agent(..., hooks=...)."
993
+ )
994
+ if not isinstance(hooks, RunHooksBase):
995
+ raise TypeError(f"Run hooks must be instances of RunHooks. Received {input_hook_type}.")
996
+ return hooks
997
+
627
998
  @classmethod
628
999
  async def _maybe_filter_model_input(
629
1000
  cls,
@@ -689,6 +1060,11 @@ class AgentRunner:
689
1060
  for done in asyncio.as_completed(guardrail_tasks):
690
1061
  result = await done
691
1062
  if result.output.tripwire_triggered:
1063
+ # Cancel all remaining guardrail tasks if a tripwire is triggered.
1064
+ for t in guardrail_tasks:
1065
+ t.cancel()
1066
+ # Wait for cancellations to propagate by awaiting the cancelled tasks.
1067
+ await asyncio.gather(*guardrail_tasks, return_exceptions=True)
692
1068
  _error_tracing.attach_error_to_span(
693
1069
  parent_span,
694
1070
  SpanError(
@@ -699,6 +1075,9 @@ class AgentRunner:
699
1075
  },
700
1076
  ),
701
1077
  )
1078
+ queue.put_nowait(result)
1079
+ guardrail_results.append(result)
1080
+ break
702
1081
  queue.put_nowait(result)
703
1082
  guardrail_results.append(result)
704
1083
  except Exception:
@@ -706,7 +1085,9 @@ class AgentRunner:
706
1085
  t.cancel()
707
1086
  raise
708
1087
 
709
- streamed_result.input_guardrail_results = guardrail_results
1088
+ streamed_result.input_guardrail_results = (
1089
+ streamed_result.input_guardrail_results + guardrail_results
1090
+ )
710
1091
 
711
1092
  @classmethod
712
1093
  async def _start_streaming(
@@ -719,6 +1100,8 @@ class AgentRunner:
719
1100
  context_wrapper: RunContextWrapper[TContext],
720
1101
  run_config: RunConfig,
721
1102
  previous_response_id: str | None,
1103
+ auto_previous_response_id: bool,
1104
+ conversation_id: str | None,
722
1105
  session: Session | None,
723
1106
  ):
724
1107
  if streamed_result.trace:
@@ -730,20 +1113,47 @@ class AgentRunner:
730
1113
  should_run_agent_start_hooks = True
731
1114
  tool_use_tracker = AgentToolUseTracker()
732
1115
 
1116
+ # Check whether to enable OpenAI server-managed conversation
1117
+ if (
1118
+ conversation_id is not None
1119
+ or previous_response_id is not None
1120
+ or auto_previous_response_id
1121
+ ):
1122
+ server_conversation_tracker = _ServerConversationTracker(
1123
+ conversation_id=conversation_id,
1124
+ previous_response_id=previous_response_id,
1125
+ auto_previous_response_id=auto_previous_response_id,
1126
+ )
1127
+ else:
1128
+ server_conversation_tracker = None
1129
+
733
1130
  streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
734
1131
 
735
1132
  try:
736
1133
  # Prepare input with session if enabled
737
- prepared_input = await AgentRunner._prepare_input_with_session(starting_input, session)
1134
+ prepared_input = await AgentRunner._prepare_input_with_session(
1135
+ starting_input, session, run_config.session_input_callback
1136
+ )
738
1137
 
739
1138
  # Update the streamed result with the prepared input
740
1139
  streamed_result.input = prepared_input
741
1140
 
1141
+ await AgentRunner._save_result_to_session(session, starting_input, [])
1142
+
742
1143
  while True:
1144
+ # Check for soft cancel before starting new turn
1145
+ if streamed_result._cancel_mode == "after_turn":
1146
+ streamed_result.is_complete = True
1147
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1148
+ break
1149
+
743
1150
  if streamed_result.is_complete:
744
1151
  break
745
1152
 
746
1153
  all_tools = await cls._get_all_tools(current_agent, context_wrapper)
1154
+ await RunImpl.initialize_computer_tools(
1155
+ tools=all_tools, context_wrapper=context_wrapper
1156
+ )
747
1157
 
748
1158
  # Start an agent span if we don't have one. This span is ended if the current
749
1159
  # agent changes, or if the agent loop ends.
@@ -780,11 +1190,36 @@ class AgentRunner:
780
1190
  break
781
1191
 
782
1192
  if current_turn == 1:
783
- # Run the input guardrails in the background and put the results on the queue
1193
+ # Separate guardrails based on execution mode.
1194
+ all_input_guardrails = starting_agent.input_guardrails + (
1195
+ run_config.input_guardrails or []
1196
+ )
1197
+ sequential_guardrails = [
1198
+ g for g in all_input_guardrails if not g.run_in_parallel
1199
+ ]
1200
+ parallel_guardrails = [g for g in all_input_guardrails if g.run_in_parallel]
1201
+
1202
+ # Run sequential guardrails first.
1203
+ if sequential_guardrails:
1204
+ await cls._run_input_guardrails_with_queue(
1205
+ starting_agent,
1206
+ sequential_guardrails,
1207
+ ItemHelpers.input_to_new_input_list(prepared_input),
1208
+ context_wrapper,
1209
+ streamed_result,
1210
+ current_span,
1211
+ )
1212
+ # Check if any blocking guardrail triggered and raise before starting agent.
1213
+ for result in streamed_result.input_guardrail_results:
1214
+ if result.output.tripwire_triggered:
1215
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1216
+ raise InputGuardrailTripwireTriggered(result)
1217
+
1218
+ # Run parallel guardrails in background.
784
1219
  streamed_result._input_guardrails_task = asyncio.create_task(
785
1220
  cls._run_input_guardrails_with_queue(
786
1221
  starting_agent,
787
- starting_agent.input_guardrails + (run_config.input_guardrails or []),
1222
+ parallel_guardrails,
788
1223
  ItemHelpers.input_to_new_input_list(prepared_input),
789
1224
  context_wrapper,
790
1225
  streamed_result,
@@ -801,7 +1236,7 @@ class AgentRunner:
801
1236
  should_run_agent_start_hooks,
802
1237
  tool_use_tracker,
803
1238
  all_tools,
804
- previous_response_id,
1239
+ server_conversation_tracker,
805
1240
  )
806
1241
  should_run_agent_start_hooks = False
807
1242
 
@@ -809,9 +1244,40 @@ class AgentRunner:
809
1244
  turn_result.model_response
810
1245
  ]
811
1246
  streamed_result.input = turn_result.original_input
812
- streamed_result.new_items = turn_result.generated_items
1247
+ # Keep filtered items for building model input on the next turn.
1248
+ streamed_result._model_input_items = (
1249
+ turn_result.pre_step_items + turn_result.new_step_items
1250
+ )
1251
+ # Accumulate unfiltered items for observability
1252
+ session_items_for_turn = (
1253
+ turn_result.session_step_items
1254
+ if turn_result.session_step_items is not None
1255
+ else turn_result.new_step_items
1256
+ )
1257
+ streamed_result.new_items.extend(session_items_for_turn)
1258
+
1259
+ if server_conversation_tracker is not None:
1260
+ server_conversation_tracker.track_server_items(turn_result.model_response)
813
1261
 
814
1262
  if isinstance(turn_result.next_step, NextStepHandoff):
1263
+ # Save the conversation to session if enabled (before handoff)
1264
+ # Streaming needs to save for graceful cancellation support
1265
+ if session is not None:
1266
+ should_skip_session_save = (
1267
+ await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1268
+ streamed_result
1269
+ )
1270
+ )
1271
+ if should_skip_session_save is False:
1272
+ await AgentRunner._save_result_to_session(
1273
+ session,
1274
+ [],
1275
+ turn_result.session_step_items
1276
+ if turn_result.session_step_items is not None
1277
+ else turn_result.new_step_items,
1278
+ turn_result.model_response.response_id,
1279
+ )
1280
+
815
1281
  current_agent = turn_result.next_step.new_agent
816
1282
  current_span.finish(reset_current=True)
817
1283
  current_span = None
@@ -819,6 +1285,12 @@ class AgentRunner:
819
1285
  streamed_result._event_queue.put_nowait(
820
1286
  AgentUpdatedStreamEvent(new_agent=current_agent)
821
1287
  )
1288
+
1289
+ # Check for soft cancel after handoff
1290
+ if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap]
1291
+ streamed_result.is_complete = True
1292
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1293
+ break
822
1294
  elif isinstance(turn_result.next_step, NextStepFinalOutput):
823
1295
  streamed_result._output_guardrails_task = asyncio.create_task(
824
1296
  cls._run_output_guardrails(
@@ -841,24 +1313,45 @@ class AgentRunner:
841
1313
  streamed_result.is_complete = True
842
1314
 
843
1315
  # Save the conversation to session if enabled
844
- # Create a temporary RunResult for session saving
845
- temp_result = RunResult(
846
- input=streamed_result.input,
847
- new_items=streamed_result.new_items,
848
- raw_responses=streamed_result.raw_responses,
849
- final_output=streamed_result.final_output,
850
- _last_agent=current_agent,
851
- input_guardrail_results=streamed_result.input_guardrail_results,
852
- output_guardrail_results=streamed_result.output_guardrail_results,
853
- context_wrapper=context_wrapper,
854
- )
855
- await AgentRunner._save_result_to_session(
856
- session, starting_input, temp_result
857
- )
1316
+ if session is not None:
1317
+ should_skip_session_save = (
1318
+ await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1319
+ streamed_result
1320
+ )
1321
+ )
1322
+ if should_skip_session_save is False:
1323
+ await AgentRunner._save_result_to_session(
1324
+ session,
1325
+ [],
1326
+ turn_result.session_step_items
1327
+ if turn_result.session_step_items is not None
1328
+ else turn_result.new_step_items,
1329
+ turn_result.model_response.response_id,
1330
+ )
858
1331
 
859
1332
  streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
860
1333
  elif isinstance(turn_result.next_step, NextStepRunAgain):
861
- pass
1334
+ if session is not None:
1335
+ should_skip_session_save = (
1336
+ await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1337
+ streamed_result
1338
+ )
1339
+ )
1340
+ if should_skip_session_save is False:
1341
+ await AgentRunner._save_result_to_session(
1342
+ session,
1343
+ [],
1344
+ turn_result.session_step_items
1345
+ if turn_result.session_step_items is not None
1346
+ else turn_result.new_step_items,
1347
+ turn_result.model_response.response_id,
1348
+ )
1349
+
1350
+ # Check for soft cancel after turn completion
1351
+ if streamed_result._cancel_mode == "after_turn": # type: ignore[comparison-overlap]
1352
+ streamed_result.is_complete = True
1353
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1354
+ break
862
1355
  except AgentsException as exc:
863
1356
  streamed_result.is_complete = True
864
1357
  streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
@@ -887,11 +1380,32 @@ class AgentRunner:
887
1380
 
888
1381
  streamed_result.is_complete = True
889
1382
  finally:
1383
+ if streamed_result._input_guardrails_task:
1384
+ try:
1385
+ await AgentRunner._input_guardrail_tripwire_triggered_for_stream(
1386
+ streamed_result
1387
+ )
1388
+ except Exception as e:
1389
+ logger.debug(
1390
+ f"Error in streamed_result finalize for agent {current_agent.name} - {e}"
1391
+ )
1392
+ try:
1393
+ await dispose_resolved_computers(run_context=context_wrapper)
1394
+ except Exception as error:
1395
+ logger.warning("Failed to dispose computers after streamed run: %s", error)
890
1396
  if current_span:
891
1397
  current_span.finish(reset_current=True)
892
1398
  if streamed_result.trace:
893
1399
  streamed_result.trace.finish(reset_current=True)
894
1400
 
1401
+ # Ensure QueueCompleteSentinel is always put in the queue when the stream ends,
1402
+ # even if an exception occurs before the inner try/except block (e.g., in
1403
+ # _save_result_to_session at the beginning). Without this, stream_events()
1404
+ # would hang forever waiting for more items.
1405
+ if not streamed_result.is_complete:
1406
+ streamed_result.is_complete = True
1407
+ streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
1408
+
895
1409
  @classmethod
896
1410
  async def _run_single_turn_streamed(
897
1411
  cls,
@@ -903,13 +1417,21 @@ class AgentRunner:
903
1417
  should_run_agent_start_hooks: bool,
904
1418
  tool_use_tracker: AgentToolUseTracker,
905
1419
  all_tools: list[Tool],
906
- previous_response_id: str | None,
1420
+ server_conversation_tracker: _ServerConversationTracker | None = None,
907
1421
  ) -> SingleStepResult:
1422
+ emitted_tool_call_ids: set[str] = set()
1423
+ emitted_reasoning_item_ids: set[str] = set()
1424
+
908
1425
  if should_run_agent_start_hooks:
1426
+ agent_hook_context = AgentHookContext(
1427
+ context=context_wrapper.context,
1428
+ usage=context_wrapper.usage,
1429
+ turn_input=ItemHelpers.input_to_new_input_list(streamed_result.input),
1430
+ )
909
1431
  await asyncio.gather(
910
- hooks.on_agent_start(context_wrapper, agent),
1432
+ hooks.on_agent_start(agent_hook_context, agent),
911
1433
  (
912
- agent.hooks.on_start(context_wrapper, agent)
1434
+ agent.hooks.on_start(agent_hook_context, agent)
913
1435
  if agent.hooks
914
1436
  else _coro.noop_coroutine()
915
1437
  ),
@@ -932,9 +1454,15 @@ class AgentRunner:
932
1454
 
933
1455
  final_response: ModelResponse | None = None
934
1456
 
935
- input = ItemHelpers.input_to_new_input_list(streamed_result.input)
936
- input.extend([item.to_input_item() for item in streamed_result.new_items])
1457
+ if server_conversation_tracker is not None:
1458
+ input = server_conversation_tracker.prepare_input(
1459
+ streamed_result.input, streamed_result._model_input_items
1460
+ )
1461
+ else:
1462
+ input = ItemHelpers.input_to_new_input_list(streamed_result.input)
1463
+ input.extend([item.to_input_item() for item in streamed_result._model_input_items])
937
1464
 
1465
+ # THIS IS THE RESOLVED CONFLICT BLOCK
938
1466
  filtered = await cls._maybe_filter_model_input(
939
1467
  agent=agent,
940
1468
  run_config=run_config,
@@ -943,6 +1471,28 @@ class AgentRunner:
943
1471
  system_instructions=system_prompt,
944
1472
  )
945
1473
 
1474
+ # Call hook just before the model is invoked, with the correct system_prompt.
1475
+ await asyncio.gather(
1476
+ hooks.on_llm_start(context_wrapper, agent, filtered.instructions, filtered.input),
1477
+ (
1478
+ agent.hooks.on_llm_start(
1479
+ context_wrapper, agent, filtered.instructions, filtered.input
1480
+ )
1481
+ if agent.hooks
1482
+ else _coro.noop_coroutine()
1483
+ ),
1484
+ )
1485
+
1486
+ previous_response_id = (
1487
+ server_conversation_tracker.previous_response_id
1488
+ if server_conversation_tracker
1489
+ and server_conversation_tracker.previous_response_id is not None
1490
+ else None
1491
+ )
1492
+ conversation_id = (
1493
+ server_conversation_tracker.conversation_id if server_conversation_tracker else None
1494
+ )
1495
+
946
1496
  # 1. Stream the output events
947
1497
  async for event in model.stream_response(
948
1498
  filtered.instructions,
@@ -955,8 +1505,12 @@ class AgentRunner:
955
1505
  run_config.tracing_disabled, run_config.trace_include_sensitive_data
956
1506
  ),
957
1507
  previous_response_id=previous_response_id,
1508
+ conversation_id=conversation_id,
958
1509
  prompt=prompt_config,
959
1510
  ):
1511
+ # Emit the raw event ASAP
1512
+ streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event))
1513
+
960
1514
  if isinstance(event, ResponseCompletedEvent):
961
1515
  usage = (
962
1516
  Usage(
@@ -977,16 +1531,56 @@ class AgentRunner:
977
1531
  )
978
1532
  context_wrapper.usage.add(usage)
979
1533
 
980
- streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event))
1534
+ if isinstance(event, ResponseOutputItemDoneEvent):
1535
+ output_item = event.item
1536
+
1537
+ if isinstance(output_item, _TOOL_CALL_TYPES):
1538
+ call_id: str | None = getattr(
1539
+ output_item, "call_id", getattr(output_item, "id", None)
1540
+ )
1541
+
1542
+ if call_id and call_id not in emitted_tool_call_ids:
1543
+ emitted_tool_call_ids.add(call_id)
1544
+
1545
+ tool_item = ToolCallItem(
1546
+ raw_item=cast(ToolCallItemTypes, output_item),
1547
+ agent=agent,
1548
+ )
1549
+ streamed_result._event_queue.put_nowait(
1550
+ RunItemStreamEvent(item=tool_item, name="tool_called")
1551
+ )
1552
+
1553
+ elif isinstance(output_item, ResponseReasoningItem):
1554
+ reasoning_id: str | None = getattr(output_item, "id", None)
1555
+
1556
+ if reasoning_id and reasoning_id not in emitted_reasoning_item_ids:
1557
+ emitted_reasoning_item_ids.add(reasoning_id)
1558
+
1559
+ reasoning_item = ReasoningItem(raw_item=output_item, agent=agent)
1560
+ streamed_result._event_queue.put_nowait(
1561
+ RunItemStreamEvent(item=reasoning_item, name="reasoning_item_created")
1562
+ )
1563
+
1564
+ # Call hook just after the model response is finalized.
1565
+ if final_response is not None:
1566
+ await asyncio.gather(
1567
+ (
1568
+ agent.hooks.on_llm_end(context_wrapper, agent, final_response)
1569
+ if agent.hooks
1570
+ else _coro.noop_coroutine()
1571
+ ),
1572
+ hooks.on_llm_end(context_wrapper, agent, final_response),
1573
+ )
981
1574
 
982
1575
  # 2. At this point, the streaming is complete for this turn of the agent loop.
983
1576
  if not final_response:
984
1577
  raise ModelBehaviorError("Model did not produce a final response!")
985
1578
 
986
1579
  # 3. Now, we can process the turn as we do in the non-streaming case
987
- return await cls._get_single_step_result_from_streamed_response(
1580
+ single_step_result = await cls._get_single_step_result_from_response(
988
1581
  agent=agent,
989
- streamed_result=streamed_result,
1582
+ original_input=streamed_result.input,
1583
+ pre_step_items=streamed_result._model_input_items,
990
1584
  new_response=final_response,
991
1585
  output_schema=output_schema,
992
1586
  all_tools=all_tools,
@@ -995,8 +1589,59 @@ class AgentRunner:
995
1589
  context_wrapper=context_wrapper,
996
1590
  run_config=run_config,
997
1591
  tool_use_tracker=tool_use_tracker,
1592
+ event_queue=streamed_result._event_queue,
998
1593
  )
999
1594
 
1595
+ import dataclasses as _dc
1596
+
1597
+ # Stream session items (unfiltered) when available for observability.
1598
+ streaming_items = (
1599
+ single_step_result.session_step_items
1600
+ if single_step_result.session_step_items is not None
1601
+ else single_step_result.new_step_items
1602
+ )
1603
+
1604
+ # Filter out items that have already been sent to avoid duplicates.
1605
+ items_to_stream = streaming_items
1606
+
1607
+ if emitted_tool_call_ids:
1608
+ # Filter out tool call items that were already emitted during streaming
1609
+ items_to_stream = [
1610
+ item
1611
+ for item in items_to_stream
1612
+ if not (
1613
+ isinstance(item, ToolCallItem)
1614
+ and (
1615
+ call_id := getattr(
1616
+ item.raw_item, "call_id", getattr(item.raw_item, "id", None)
1617
+ )
1618
+ )
1619
+ and call_id in emitted_tool_call_ids
1620
+ )
1621
+ ]
1622
+
1623
+ if emitted_reasoning_item_ids:
1624
+ # Filter out reasoning items that were already emitted during streaming
1625
+ items_to_stream = [
1626
+ item
1627
+ for item in items_to_stream
1628
+ if not (
1629
+ isinstance(item, ReasoningItem)
1630
+ and (reasoning_id := getattr(item.raw_item, "id", None))
1631
+ and reasoning_id in emitted_reasoning_item_ids
1632
+ )
1633
+ ]
1634
+
1635
+ # Filter out HandoffCallItem to avoid duplicates (already sent earlier)
1636
+ items_to_stream = [
1637
+ item for item in items_to_stream if not isinstance(item, HandoffCallItem)
1638
+ ]
1639
+
1640
+ # Create filtered result and send to queue
1641
+ filtered_result = _dc.replace(single_step_result, new_step_items=items_to_stream)
1642
+ RunImpl.stream_step_result_to_queue(filtered_result, streamed_result._event_queue)
1643
+ return single_step_result
1644
+
1000
1645
  @classmethod
1001
1646
  async def _run_single_turn(
1002
1647
  cls,
@@ -1010,14 +1655,19 @@ class AgentRunner:
1010
1655
  run_config: RunConfig,
1011
1656
  should_run_agent_start_hooks: bool,
1012
1657
  tool_use_tracker: AgentToolUseTracker,
1013
- previous_response_id: str | None,
1658
+ server_conversation_tracker: _ServerConversationTracker | None = None,
1014
1659
  ) -> SingleStepResult:
1015
1660
  # Ensure we run the hooks before anything else
1016
1661
  if should_run_agent_start_hooks:
1662
+ agent_hook_context = AgentHookContext(
1663
+ context=context_wrapper.context,
1664
+ usage=context_wrapper.usage,
1665
+ turn_input=ItemHelpers.input_to_new_input_list(original_input),
1666
+ )
1017
1667
  await asyncio.gather(
1018
- hooks.on_agent_start(context_wrapper, agent),
1668
+ hooks.on_agent_start(agent_hook_context, agent),
1019
1669
  (
1020
- agent.hooks.on_start(context_wrapper, agent)
1670
+ agent.hooks.on_start(agent_hook_context, agent)
1021
1671
  if agent.hooks
1022
1672
  else _coro.noop_coroutine()
1023
1673
  ),
@@ -1030,8 +1680,11 @@ class AgentRunner:
1030
1680
 
1031
1681
  output_schema = cls._get_output_schema(agent)
1032
1682
  handoffs = await cls._get_handoffs(agent, context_wrapper)
1033
- input = ItemHelpers.input_to_new_input_list(original_input)
1034
- input.extend([generated_item.to_input_item() for generated_item in generated_items])
1683
+ if server_conversation_tracker is not None:
1684
+ input = server_conversation_tracker.prepare_input(original_input, generated_items)
1685
+ else:
1686
+ input = ItemHelpers.input_to_new_input_list(original_input)
1687
+ input.extend([generated_item.to_input_item() for generated_item in generated_items])
1035
1688
 
1036
1689
  new_response = await cls._get_new_response(
1037
1690
  agent,
@@ -1040,10 +1693,11 @@ class AgentRunner:
1040
1693
  output_schema,
1041
1694
  all_tools,
1042
1695
  handoffs,
1696
+ hooks,
1043
1697
  context_wrapper,
1044
1698
  run_config,
1045
1699
  tool_use_tracker,
1046
- previous_response_id,
1700
+ server_conversation_tracker,
1047
1701
  prompt_config,
1048
1702
  )
1049
1703
 
@@ -1076,6 +1730,7 @@ class AgentRunner:
1076
1730
  context_wrapper: RunContextWrapper[TContext],
1077
1731
  run_config: RunConfig,
1078
1732
  tool_use_tracker: AgentToolUseTracker,
1733
+ event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] | None = None,
1079
1734
  ) -> SingleStepResult:
1080
1735
  processed_response = RunImpl.process_model_response(
1081
1736
  agent=agent,
@@ -1087,6 +1742,14 @@ class AgentRunner:
1087
1742
 
1088
1743
  tool_use_tracker.add_tool_use(agent, processed_response.tools_used)
1089
1744
 
1745
+ # Send handoff items immediately for streaming, but avoid duplicates
1746
+ if event_queue is not None and processed_response.new_items:
1747
+ handoff_items = [
1748
+ item for item in processed_response.new_items if isinstance(item, HandoffCallItem)
1749
+ ]
1750
+ if handoff_items:
1751
+ RunImpl.stream_step_items_to_queue(cast(list[RunItem], handoff_items), event_queue)
1752
+
1090
1753
  return await RunImpl.execute_tools_and_side_effects(
1091
1754
  agent=agent,
1092
1755
  original_input=original_input,
@@ -1115,7 +1778,7 @@ class AgentRunner:
1115
1778
  tool_use_tracker: AgentToolUseTracker,
1116
1779
  ) -> SingleStepResult:
1117
1780
  original_input = streamed_result.input
1118
- pre_step_items = streamed_result.new_items
1781
+ pre_step_items = streamed_result._model_input_items
1119
1782
  event_queue = streamed_result._event_queue
1120
1783
 
1121
1784
  processed_response = RunImpl.process_model_response(
@@ -1140,10 +1803,15 @@ class AgentRunner:
1140
1803
  context_wrapper=context_wrapper,
1141
1804
  run_config=run_config,
1142
1805
  )
1806
+ # Use session_step_items (unfiltered) if available for streaming observability,
1807
+ # otherwise fall back to new_step_items.
1808
+ streaming_items = (
1809
+ single_step_result.session_step_items
1810
+ if single_step_result.session_step_items is not None
1811
+ else single_step_result.new_step_items
1812
+ )
1143
1813
  new_step_items = [
1144
- item
1145
- for item in single_step_result.new_step_items
1146
- if item not in new_items_processed_response
1814
+ item for item in streaming_items if item not in new_items_processed_response
1147
1815
  ]
1148
1816
  RunImpl.stream_step_items_to_queue(new_step_items, event_queue)
1149
1817
 
@@ -1175,6 +1843,8 @@ class AgentRunner:
1175
1843
  # Cancel all guardrail tasks if a tripwire is triggered.
1176
1844
  for t in guardrail_tasks:
1177
1845
  t.cancel()
1846
+ # Wait for cancellations to propagate by awaiting the cancelled tasks.
1847
+ await asyncio.gather(*guardrail_tasks, return_exceptions=True)
1178
1848
  _error_tracing.attach_error_to_current_span(
1179
1849
  SpanError(
1180
1850
  message="Guardrail tripwire triggered",
@@ -1234,10 +1904,11 @@ class AgentRunner:
1234
1904
  output_schema: AgentOutputSchemaBase | None,
1235
1905
  all_tools: list[Tool],
1236
1906
  handoffs: list[Handoff],
1907
+ hooks: RunHooks[TContext],
1237
1908
  context_wrapper: RunContextWrapper[TContext],
1238
1909
  run_config: RunConfig,
1239
1910
  tool_use_tracker: AgentToolUseTracker,
1240
- previous_response_id: str | None,
1911
+ server_conversation_tracker: _ServerConversationTracker | None,
1241
1912
  prompt_config: ResponsePromptParam | None,
1242
1913
  ) -> ModelResponse:
1243
1914
  # Allow user to modify model input right before the call, if configured
@@ -1253,6 +1924,31 @@ class AgentRunner:
1253
1924
  model_settings = agent.model_settings.resolve(run_config.model_settings)
1254
1925
  model_settings = RunImpl.maybe_reset_tool_choice(agent, tool_use_tracker, model_settings)
1255
1926
 
1927
+ # If we have run hooks, or if the agent has hooks, we need to call them before the LLM call
1928
+ await asyncio.gather(
1929
+ hooks.on_llm_start(context_wrapper, agent, filtered.instructions, filtered.input),
1930
+ (
1931
+ agent.hooks.on_llm_start(
1932
+ context_wrapper,
1933
+ agent,
1934
+ filtered.instructions, # Use filtered instructions
1935
+ filtered.input, # Use filtered input
1936
+ )
1937
+ if agent.hooks
1938
+ else _coro.noop_coroutine()
1939
+ ),
1940
+ )
1941
+
1942
+ previous_response_id = (
1943
+ server_conversation_tracker.previous_response_id
1944
+ if server_conversation_tracker
1945
+ and server_conversation_tracker.previous_response_id is not None
1946
+ else None
1947
+ )
1948
+ conversation_id = (
1949
+ server_conversation_tracker.conversation_id if server_conversation_tracker else None
1950
+ )
1951
+
1256
1952
  new_response = await model.get_response(
1257
1953
  system_instructions=filtered.instructions,
1258
1954
  input=filtered.input,
@@ -1264,11 +1960,22 @@ class AgentRunner:
1264
1960
  run_config.tracing_disabled, run_config.trace_include_sensitive_data
1265
1961
  ),
1266
1962
  previous_response_id=previous_response_id,
1963
+ conversation_id=conversation_id,
1267
1964
  prompt=prompt_config,
1268
1965
  )
1269
1966
 
1270
1967
  context_wrapper.usage.add(new_response.usage)
1271
1968
 
1969
+ # If we have run hooks, or if the agent has hooks, we need to call them after the LLM call
1970
+ await asyncio.gather(
1971
+ (
1972
+ agent.hooks.on_llm_end(context_wrapper, agent, new_response)
1973
+ if agent.hooks
1974
+ else _coro.noop_coroutine()
1975
+ ),
1976
+ hooks.on_llm_end(context_wrapper, agent, new_response),
1977
+ )
1978
+
1272
1979
  return new_response
1273
1980
 
1274
1981
  @classmethod
@@ -1326,19 +2033,20 @@ class AgentRunner:
1326
2033
  cls,
1327
2034
  input: str | list[TResponseInputItem],
1328
2035
  session: Session | None,
2036
+ session_input_callback: SessionInputCallback | None,
1329
2037
  ) -> str | list[TResponseInputItem]:
1330
2038
  """Prepare input by combining it with session history if enabled."""
1331
2039
  if session is None:
1332
2040
  return input
1333
2041
 
1334
- # Validate that we don't have both a session and a list input, as this creates
1335
- # ambiguity about whether the list should append to or replace existing session history
1336
- if isinstance(input, list):
2042
+ # If the user doesn't specify an input callback and pass a list as input
2043
+ if isinstance(input, list) and not session_input_callback:
1337
2044
  raise UserError(
1338
- "Cannot provide both a session and a list of input items. "
1339
- "When using session memory, provide only a string input to append to the "
1340
- "conversation, or use session=None and provide a list to manually manage "
1341
- "conversation history."
2045
+ "When using session memory, list inputs require a "
2046
+ "`RunConfig.session_input_callback` to define how they should be merged "
2047
+ "with the conversation history. If you don't want to use a callback, "
2048
+ "provide your input as a string instead, or disable session memory "
2049
+ "(session=None) and pass a list to manage the history manually."
1342
2050
  )
1343
2051
 
1344
2052
  # Get previous conversation history
@@ -1347,19 +2055,32 @@ class AgentRunner:
1347
2055
  # Convert input to list format
1348
2056
  new_input_list = ItemHelpers.input_to_new_input_list(input)
1349
2057
 
1350
- # Combine history with new input
1351
- combined_input = history + new_input_list
1352
-
1353
- return combined_input
2058
+ if session_input_callback is None:
2059
+ return history + new_input_list
2060
+ elif callable(session_input_callback):
2061
+ res = session_input_callback(history, new_input_list)
2062
+ if inspect.isawaitable(res):
2063
+ return await res
2064
+ return res
2065
+ else:
2066
+ raise UserError(
2067
+ f"Invalid `session_input_callback` value: {session_input_callback}. "
2068
+ "Choose between `None` or a custom callable function."
2069
+ )
1354
2070
 
1355
2071
  @classmethod
1356
2072
  async def _save_result_to_session(
1357
2073
  cls,
1358
2074
  session: Session | None,
1359
2075
  original_input: str | list[TResponseInputItem],
1360
- result: RunResult,
2076
+ new_items: list[RunItem],
2077
+ response_id: str | None = None,
1361
2078
  ) -> None:
1362
- """Save the conversation turn to session."""
2079
+ """
2080
+ Save the conversation turn to session.
2081
+ It does not account for any filtering or modification performed by
2082
+ `RunConfig.session_input_callback`.
2083
+ """
1363
2084
  if session is None:
1364
2085
  return
1365
2086
 
@@ -1367,16 +2088,76 @@ class AgentRunner:
1367
2088
  input_list = ItemHelpers.input_to_new_input_list(original_input)
1368
2089
 
1369
2090
  # Convert new items to input format
1370
- new_items_as_input = [item.to_input_item() for item in result.new_items]
2091
+ new_items_as_input = [item.to_input_item() for item in new_items]
1371
2092
 
1372
2093
  # Save all items from this turn
1373
2094
  items_to_save = input_list + new_items_as_input
1374
2095
  await session.add_items(items_to_save)
1375
2096
 
2097
+ # Run compaction if session supports it and we have a response_id
2098
+ if response_id and is_openai_responses_compaction_aware_session(session):
2099
+ has_local_tool_outputs = any(
2100
+ isinstance(item, (ToolCallOutputItem, HandoffOutputItem)) for item in new_items
2101
+ )
2102
+ if has_local_tool_outputs:
2103
+ defer_compaction = getattr(session, "_defer_compaction", None)
2104
+ if callable(defer_compaction):
2105
+ result = defer_compaction(response_id)
2106
+ if inspect.isawaitable(result):
2107
+ await result
2108
+ logger.debug(
2109
+ "skip: deferring compaction for response %s due to local tool outputs",
2110
+ response_id,
2111
+ )
2112
+ return
2113
+ deferred_response_id = None
2114
+ get_deferred = getattr(session, "_get_deferred_compaction_response_id", None)
2115
+ if callable(get_deferred):
2116
+ deferred_response_id = get_deferred()
2117
+ force_compaction = deferred_response_id is not None
2118
+ if force_compaction:
2119
+ logger.debug(
2120
+ "compact: forcing for response %s after deferred %s",
2121
+ response_id,
2122
+ deferred_response_id,
2123
+ )
2124
+ await session.run_compaction({"response_id": response_id, "force": force_compaction})
2125
+
2126
+ @staticmethod
2127
+ async def _input_guardrail_tripwire_triggered_for_stream(
2128
+ streamed_result: RunResultStreaming,
2129
+ ) -> bool:
2130
+ """Return True if any input guardrail triggered during a streamed run."""
2131
+
2132
+ task = streamed_result._input_guardrails_task
2133
+ if task is None:
2134
+ return False
2135
+
2136
+ if not task.done():
2137
+ await task
2138
+
2139
+ return any(
2140
+ guardrail_result.output.tripwire_triggered
2141
+ for guardrail_result in streamed_result.input_guardrail_results
2142
+ )
2143
+
1376
2144
 
1377
2145
  DEFAULT_AGENT_RUNNER = AgentRunner()
1378
2146
 
1379
2147
 
2148
+ def _get_tool_call_types() -> tuple[type, ...]:
2149
+ normalized_types: list[type] = []
2150
+ for type_hint in get_args(ToolCallItemTypes):
2151
+ origin = get_origin(type_hint)
2152
+ candidate = origin or type_hint
2153
+ if isinstance(candidate, type):
2154
+ normalized_types.append(candidate)
2155
+ return tuple(normalized_types)
2156
+
2157
+
2158
+ _TOOL_CALL_TYPES: tuple[type, ...] = _get_tool_call_types()
2159
+
2160
+
1380
2161
  def _copy_str_or_list(input: str | list[TResponseInputItem]) -> str | list[TResponseInputItem]:
1381
2162
  if isinstance(input, str):
1382
2163
  return input