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
agents/run.py CHANGED
@@ -1,15 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import copy
4
+ import contextlib
5
5
  import inspect
6
+ import os
7
+ import warnings
6
8
  from dataclasses import dataclass, field
7
- from typing import Any, Generic, cast
9
+ from typing import Any, Callable, Generic, cast, get_args, get_origin
8
10
 
9
- from openai.types.responses import ResponseCompletedEvent
11
+ from openai.types.responses import (
12
+ ResponseCompletedEvent,
13
+ ResponseOutputItemDoneEvent,
14
+ )
10
15
  from openai.types.responses.response_prompt_param import (
11
16
  ResponsePromptParam,
12
17
  )
18
+ from openai.types.responses.response_reasoning_item import ResponseReasoningItem
13
19
  from typing_extensions import NotRequired, TypedDict, Unpack
14
20
 
15
21
  from ._run_impl import (
@@ -40,22 +46,40 @@ from .guardrail import (
40
46
  OutputGuardrail,
41
47
  OutputGuardrailResult,
42
48
  )
43
- from .handoffs import Handoff, HandoffInputFilter, handoff
44
- from .items import ItemHelpers, ModelResponse, RunItem, TResponseInputItem
45
- 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
46
63
  from .logger import logger
47
- from .memory import Session
64
+ from .memory import Session, SessionInputCallback, is_openai_responses_compaction_aware_session
48
65
  from .model_settings import ModelSettings
49
66
  from .models.interface import Model, ModelProvider
50
67
  from .models.multi_provider import MultiProvider
51
68
  from .result import RunResult, RunResultStreaming
52
- from .run_context import RunContextWrapper, TContext
53
- from .stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent
54
- from .tool import Tool
55
- 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
56
79
  from .tracing.span_data import AgentSpanData
57
80
  from .usage import Usage
58
81
  from .util import _coro, _error_tracing
82
+ from .util._types import MaybeAwaitable
59
83
 
60
84
  DEFAULT_MAX_TURNS = 10
61
85
 
@@ -81,6 +105,83 @@ 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
+
114
+ @dataclass
115
+ class ModelInputData:
116
+ """Container for the data that will be sent to the model."""
117
+
118
+ input: list[TResponseInputItem]
119
+ instructions: str | None
120
+
121
+
122
+ @dataclass
123
+ class CallModelData(Generic[TContext]):
124
+ """Data passed to `RunConfig.call_model_input_filter` prior to model call."""
125
+
126
+ model_data: ModelInputData
127
+ agent: Agent[TContext]
128
+ context: TContext | None
129
+
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
+
181
+ # Type alias for the optional input filter callback
182
+ CallModelInputFilter = Callable[[CallModelData[Any]], MaybeAwaitable[ModelInputData]]
183
+
184
+
84
185
  @dataclass
85
186
  class RunConfig:
86
187
  """Configures settings for the entire agent run."""
@@ -104,6 +205,19 @@ class RunConfig:
104
205
  agent. See the documentation in `Handoff.input_filter` for more details.
105
206
  """
106
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
+
107
221
  input_guardrails: list[InputGuardrail[Any]] | None = None
108
222
  """A list of input guardrails to run on the initial run input."""
109
223
 
@@ -114,7 +228,12 @@ class RunConfig:
114
228
  """Whether tracing is disabled for the agent run. If disabled, we will not trace the agent run.
115
229
  """
116
230
 
117
- 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
+ )
118
237
  """Whether we include potentially sensitive data (for example: inputs/outputs of tool calls or
119
238
  LLM generations) in traces. If False, we'll still create spans for these events, but the
120
239
  sensitive data will not be included.
@@ -139,6 +258,23 @@ class RunConfig:
139
258
  An optional dictionary of additional metadata to include with the trace.
140
259
  """
141
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
+
268
+ call_model_input_filter: CallModelInputFilter | None = None
269
+ """
270
+ Optional callback that is invoked immediately before calling the model. It receives the current
271
+ agent, context and the model input (instructions and input items), and must return a possibly
272
+ modified `ModelInputData` to use for the model call.
273
+
274
+ This allows you to edit the input sent to the model e.g. to stay within a token limit.
275
+ For example, you can use this to add a system prompt to the input.
276
+ """
277
+
142
278
 
143
279
  class RunOptions(TypedDict, Generic[TContext]):
144
280
  """Arguments for ``AgentRunner`` methods."""
@@ -158,6 +294,12 @@ class RunOptions(TypedDict, Generic[TContext]):
158
294
  previous_response_id: NotRequired[str | None]
159
295
  """The ID of the previous response, if any."""
160
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
+
161
303
  session: NotRequired[Session | None]
162
304
  """The session for the run."""
163
305
 
@@ -174,34 +316,58 @@ class Runner:
174
316
  hooks: RunHooks[TContext] | None = None,
175
317
  run_config: RunConfig | None = None,
176
318
  previous_response_id: str | None = None,
319
+ auto_previous_response_id: bool = False,
320
+ conversation_id: str | None = None,
177
321
  session: Session | None = None,
178
322
  ) -> RunResult:
179
- """Run a workflow starting at the given agent. The agent will run in a loop until a final
180
- output is generated. The loop runs like so:
181
- 1. The agent is invoked with the given input.
182
- 2. If there is a final output (i.e. the agent produces something of type
183
- `agent.output_type`, the loop terminates.
184
- 3. If there's a handoff, we run the loop again, with the new agent.
185
- 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
+
186
334
  In two cases, the agent may raise an exception:
187
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
188
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
189
- 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
+
190
343
  Args:
191
344
  starting_agent: The starting agent to run.
192
- input: The initial input to the agent. You can pass a single string for a user message,
193
- 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.
194
347
  context: The context to run the agent with.
195
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
196
- 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).
197
350
  hooks: An object that receives callbacks on various lifecycle events.
198
351
  run_config: Global settings for the entire agent run.
199
- previous_response_id: The ID of the previous response, if using OpenAI models via the
200
- 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
+
201
365
  Returns:
202
- A run result containing all the inputs, guardrail results and the output of the last
203
- 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.
204
369
  """
370
+
205
371
  runner = DEFAULT_AGENT_RUNNER
206
372
  return await runner.run(
207
373
  starting_agent,
@@ -211,6 +377,8 @@ class Runner:
211
377
  hooks=hooks,
212
378
  run_config=run_config,
213
379
  previous_response_id=previous_response_id,
380
+ auto_previous_response_id=auto_previous_response_id,
381
+ conversation_id=conversation_id,
214
382
  session=session,
215
383
  )
216
384
 
@@ -225,37 +393,56 @@ class Runner:
225
393
  hooks: RunHooks[TContext] | None = None,
226
394
  run_config: RunConfig | None = None,
227
395
  previous_response_id: str | None = None,
396
+ auto_previous_response_id: bool = False,
397
+ conversation_id: str | None = None,
228
398
  session: Session | None = None,
229
399
  ) -> RunResult:
230
- """Run a workflow synchronously, starting at the given agent. Note that this just wraps the
231
- `run` method, so it will not work if there's already an event loop (e.g. inside an async
232
- function, or in a Jupyter notebook or async context like FastAPI). For those cases, use
233
- the `run` method instead.
234
- The agent will run in a loop until a final output is generated. The loop runs like so:
235
- 1. The agent is invoked with the given input.
236
- 2. If there is a final output (i.e. the agent produces something of type
237
- `agent.output_type`, the loop terminates.
238
- 3. If there's a handoff, we run the loop again, with the new agent.
239
- 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
+
240
416
  In two cases, the agent may raise an exception:
241
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
242
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
243
- 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
+
244
425
  Args:
245
426
  starting_agent: The starting agent to run.
246
- input: The initial input to the agent. You can pass a single string for a user message,
247
- 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.
248
429
  context: The context to run the agent with.
249
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
250
- 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).
251
432
  hooks: An object that receives callbacks on various lifecycle events.
252
433
  run_config: Global settings for the entire agent run.
253
- previous_response_id: The ID of the previous response, if using OpenAI models via the
254
- 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
+
255
440
  Returns:
256
- A run result containing all the inputs, guardrail results and the output of the last
257
- 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.
258
444
  """
445
+
259
446
  runner = DEFAULT_AGENT_RUNNER
260
447
  return runner.run_sync(
261
448
  starting_agent,
@@ -265,7 +452,9 @@ class Runner:
265
452
  hooks=hooks,
266
453
  run_config=run_config,
267
454
  previous_response_id=previous_response_id,
455
+ conversation_id=conversation_id,
268
456
  session=session,
457
+ auto_previous_response_id=auto_previous_response_id,
269
458
  )
270
459
 
271
460
  @classmethod
@@ -278,34 +467,53 @@ class Runner:
278
467
  hooks: RunHooks[TContext] | None = None,
279
468
  run_config: RunConfig | None = None,
280
469
  previous_response_id: str | None = None,
470
+ auto_previous_response_id: bool = False,
471
+ conversation_id: str | None = None,
281
472
  session: Session | None = None,
282
473
  ) -> RunResultStreaming:
283
- """Run a workflow starting at the given agent in streaming mode. The returned result object
284
- 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
+
285
480
  The agent will run in a loop until a final output is generated. The loop runs like so:
286
- 1. The agent is invoked with the given input.
287
- 2. If there is a final output (i.e. the agent produces something of type
288
- `agent.output_type`, the loop terminates.
289
- 3. If there's a handoff, we run the loop again, with the new agent.
290
- 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
+
291
488
  In two cases, the agent may raise an exception:
292
- 1. If the max_turns is exceeded, a MaxTurnsExceeded exception is raised.
293
- 2. If a guardrail tripwire is triggered, a GuardrailTripwireTriggered exception is raised.
294
- 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
+
295
497
  Args:
296
498
  starting_agent: The starting agent to run.
297
- input: The initial input to the agent. You can pass a single string for a user message,
298
- 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.
299
501
  context: The context to run the agent with.
300
- max_turns: The maximum number of turns to run the agent for. A turn is defined as one
301
- 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).
302
504
  hooks: An object that receives callbacks on various lifecycle events.
303
505
  run_config: Global settings for the entire agent run.
304
- previous_response_id: The ID of the previous response, if using OpenAI models via the
305
- 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
+
306
512
  Returns:
307
- 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.
308
515
  """
516
+
309
517
  runner = DEFAULT_AGENT_RUNNER
310
518
  return runner.run_streamed(
311
519
  starting_agent,
@@ -315,6 +523,8 @@ class Runner:
315
523
  hooks=hooks,
316
524
  run_config=run_config,
317
525
  previous_response_id=previous_response_id,
526
+ auto_previous_response_id=auto_previous_response_id,
527
+ conversation_id=conversation_id,
318
528
  session=session,
319
529
  )
320
530
 
@@ -333,17 +543,35 @@ class AgentRunner:
333
543
  ) -> RunResult:
334
544
  context = kwargs.get("context")
335
545
  max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
336
- hooks = kwargs.get("hooks")
546
+ hooks = cast(RunHooks[TContext], self._validate_run_hooks(kwargs.get("hooks")))
337
547
  run_config = kwargs.get("run_config")
338
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")
339
551
  session = kwargs.get("session")
340
- if hooks is None:
341
- hooks = RunHooks[Any]()
552
+
342
553
  if run_config is None:
343
554
  run_config = RunConfig()
344
555
 
345
- # Prepare input with session if enabled
346
- 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
+ )
347
575
 
348
576
  tool_use_tracker = AgentToolUseTracker()
349
577
 
@@ -352,11 +580,13 @@ class AgentRunner:
352
580
  trace_id=run_config.trace_id,
353
581
  group_id=run_config.group_id,
354
582
  metadata=run_config.trace_metadata,
583
+ tracing=run_config.tracing,
355
584
  disabled=run_config.tracing_disabled,
356
585
  ):
357
586
  current_turn = 0
358
- original_input: str | list[TResponseInputItem] = copy.deepcopy(prepared_input)
359
- generated_items: list[RunItem] = []
587
+ original_input: str | list[TResponseInputItem] = _copy_str_or_list(prepared_input)
588
+ generated_items: list[RunItem] = [] # For model input (may be filtered on handoffs)
589
+ session_items: list[RunItem] = [] # For observability (always unfiltered)
360
590
  model_responses: list[ModelResponse] = []
361
591
 
362
592
  context_wrapper: RunContextWrapper[TContext] = RunContextWrapper(
@@ -364,14 +594,22 @@ class AgentRunner:
364
594
  )
365
595
 
366
596
  input_guardrail_results: list[InputGuardrailResult] = []
597
+ tool_input_guardrail_results: list[ToolInputGuardrailResult] = []
598
+ tool_output_guardrail_results: list[ToolOutputGuardrailResult] = []
367
599
 
368
600
  current_span: Span[AgentSpanData] | None = None
369
601
  current_agent = starting_agent
370
602
  should_run_agent_start_hooks = True
371
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
+
372
607
  try:
373
608
  while True:
374
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
+ )
375
613
 
376
614
  # Start an agent span if we don't have one. This span is ended if the current
377
615
  # agent changes, or if the agent loop ends.
@@ -409,12 +647,32 @@ class AgentRunner:
409
647
  )
410
648
 
411
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.
412
671
  input_guardrail_results, turn_result = await asyncio.gather(
413
672
  self._run_input_guardrails(
414
673
  starting_agent,
415
- starting_agent.input_guardrails
416
- + (run_config.input_guardrails or []),
417
- copy.deepcopy(prepared_input),
674
+ parallel_guardrails,
675
+ _copy_str_or_list(prepared_input),
418
676
  context_wrapper,
419
677
  ),
420
678
  self._run_single_turn(
@@ -427,9 +685,12 @@ class AgentRunner:
427
685
  run_config=run_config,
428
686
  should_run_agent_start_hooks=should_run_agent_start_hooks,
429
687
  tool_use_tracker=tool_use_tracker,
430
- previous_response_id=previous_response_id,
688
+ server_conversation_tracker=server_conversation_tracker,
431
689
  ),
432
690
  )
691
+
692
+ # Combine sequential and parallel results.
693
+ input_guardrail_results = sequential_results + input_guardrail_results
433
694
  else:
434
695
  turn_result = await self._run_single_turn(
435
696
  agent=current_agent,
@@ -441,51 +702,111 @@ class AgentRunner:
441
702
  run_config=run_config,
442
703
  should_run_agent_start_hooks=should_run_agent_start_hooks,
443
704
  tool_use_tracker=tool_use_tracker,
444
- previous_response_id=previous_response_id,
705
+ server_conversation_tracker=server_conversation_tracker,
445
706
  )
446
707
  should_run_agent_start_hooks = False
447
708
 
448
709
  model_responses.append(turn_result.model_response)
449
710
  original_input = turn_result.original_input
450
- 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)
451
720
 
452
- if isinstance(turn_result.next_step, NextStepFinalOutput):
453
- output_guardrail_results = await self._run_output_guardrails(
454
- current_agent.output_guardrails + (run_config.output_guardrails or []),
455
- current_agent,
456
- turn_result.next_step.output,
457
- context_wrapper,
458
- )
459
- result = RunResult(
460
- input=original_input,
461
- new_items=generated_items,
462
- raw_responses=model_responses,
463
- final_output=turn_result.next_step.output,
464
- _last_agent=current_agent,
465
- input_guardrail_results=input_guardrail_results,
466
- output_guardrail_results=output_guardrail_results,
467
- context_wrapper=context_wrapper,
468
- )
721
+ if server_conversation_tracker is not None:
722
+ server_conversation_tracker.track_server_items(turn_result.model_response)
469
723
 
470
- # Save the conversation to session if enabled
471
- 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)
472
727
 
473
- return result
474
- elif isinstance(turn_result.next_step, NextStepHandoff):
475
- current_agent = cast(Agent[TContext], turn_result.next_step.new_agent)
476
- current_span.finish(reset_current=True)
477
- current_span = None
478
- should_run_agent_start_hooks = True
479
- elif isinstance(turn_result.next_step, NextStepRunAgain):
480
- pass
481
- else:
482
- raise AgentsException(
483
- f"Unknown next step type: {type(turn_result.next_step)}"
484
- )
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()
485
806
  except AgentsException as exc:
486
807
  exc.run_data = RunErrorDetails(
487
808
  input=original_input,
488
- new_items=generated_items,
809
+ new_items=session_items, # Use unfiltered items for observability
489
810
  raw_responses=model_responses,
490
811
  last_agent=current_agent,
491
812
  context_wrapper=context_wrapper,
@@ -494,6 +815,10 @@ class AgentRunner:
494
815
  )
495
816
  raise
496
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)
497
822
  if current_span:
498
823
  current_span.finish(reset_current=True)
499
824
 
@@ -508,9 +833,44 @@ class AgentRunner:
508
833
  hooks = kwargs.get("hooks")
509
834
  run_config = kwargs.get("run_config")
510
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")
511
838
  session = kwargs.get("session")
512
839
 
513
- 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(
514
874
  self.run(
515
875
  starting_agent,
516
876
  input,
@@ -520,9 +880,29 @@ class AgentRunner:
520
880
  hooks=hooks,
521
881
  run_config=run_config,
522
882
  previous_response_id=previous_response_id,
883
+ auto_previous_response_id=auto_previous_response_id,
884
+ conversation_id=conversation_id,
523
885
  )
524
886
  )
525
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
+
526
906
  def run_streamed(
527
907
  self,
528
908
  starting_agent: Agent[TContext],
@@ -531,13 +911,13 @@ class AgentRunner:
531
911
  ) -> RunResultStreaming:
532
912
  context = kwargs.get("context")
533
913
  max_turns = kwargs.get("max_turns", DEFAULT_MAX_TURNS)
534
- hooks = kwargs.get("hooks")
914
+ hooks = cast(RunHooks[TContext], self._validate_run_hooks(kwargs.get("hooks")))
535
915
  run_config = kwargs.get("run_config")
536
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")
537
919
  session = kwargs.get("session")
538
920
 
539
- if hooks is None:
540
- hooks = RunHooks[Any]()
541
921
  if run_config is None:
542
922
  run_config = RunConfig()
543
923
 
@@ -552,6 +932,7 @@ class AgentRunner:
552
932
  trace_id=run_config.trace_id,
553
933
  group_id=run_config.group_id,
554
934
  metadata=run_config.trace_metadata,
935
+ tracing=run_config.tracing,
555
936
  disabled=run_config.tracing_disabled,
556
937
  )
557
938
  )
@@ -562,7 +943,7 @@ class AgentRunner:
562
943
  )
563
944
 
564
945
  streamed_result = RunResultStreaming(
565
- input=copy.deepcopy(input),
946
+ input=_copy_str_or_list(input),
566
947
  new_items=[],
567
948
  current_agent=starting_agent,
568
949
  raw_responses=[],
@@ -572,6 +953,8 @@ class AgentRunner:
572
953
  max_turns=max_turns,
573
954
  input_guardrail_results=[],
574
955
  output_guardrail_results=[],
956
+ tool_input_guardrail_results=[],
957
+ tool_output_guardrail_results=[],
575
958
  _current_agent_output_schema=output_schema,
576
959
  trace=new_trace,
577
960
  context_wrapper=context_wrapper,
@@ -588,11 +971,71 @@ class AgentRunner:
588
971
  context_wrapper=context_wrapper,
589
972
  run_config=run_config,
590
973
  previous_response_id=previous_response_id,
974
+ auto_previous_response_id=auto_previous_response_id,
975
+ conversation_id=conversation_id,
591
976
  session=session,
592
977
  )
593
978
  )
594
979
  return streamed_result
595
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
+
998
+ @classmethod
999
+ async def _maybe_filter_model_input(
1000
+ cls,
1001
+ *,
1002
+ agent: Agent[TContext],
1003
+ run_config: RunConfig,
1004
+ context_wrapper: RunContextWrapper[TContext],
1005
+ input_items: list[TResponseInputItem],
1006
+ system_instructions: str | None,
1007
+ ) -> ModelInputData:
1008
+ """Apply optional call_model_input_filter to modify model input.
1009
+
1010
+ Returns a `ModelInputData` that will be sent to the model.
1011
+ """
1012
+ effective_instructions = system_instructions
1013
+ effective_input: list[TResponseInputItem] = input_items
1014
+
1015
+ if run_config.call_model_input_filter is None:
1016
+ return ModelInputData(input=effective_input, instructions=effective_instructions)
1017
+
1018
+ try:
1019
+ model_input = ModelInputData(
1020
+ input=effective_input.copy(),
1021
+ instructions=effective_instructions,
1022
+ )
1023
+ filter_payload: CallModelData[TContext] = CallModelData(
1024
+ model_data=model_input,
1025
+ agent=agent,
1026
+ context=context_wrapper.context,
1027
+ )
1028
+ maybe_updated = run_config.call_model_input_filter(filter_payload)
1029
+ updated = await maybe_updated if inspect.isawaitable(maybe_updated) else maybe_updated
1030
+ if not isinstance(updated, ModelInputData):
1031
+ raise UserError("call_model_input_filter must return a ModelInputData instance")
1032
+ return updated
1033
+ except Exception as e:
1034
+ _error_tracing.attach_error_to_current_span(
1035
+ SpanError(message="Error in call_model_input_filter", data={"error": str(e)})
1036
+ )
1037
+ raise
1038
+
596
1039
  @classmethod
597
1040
  async def _run_input_guardrails_with_queue(
598
1041
  cls,
@@ -617,6 +1060,11 @@ class AgentRunner:
617
1060
  for done in asyncio.as_completed(guardrail_tasks):
618
1061
  result = await done
619
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)
620
1068
  _error_tracing.attach_error_to_span(
621
1069
  parent_span,
622
1070
  SpanError(
@@ -627,6 +1075,9 @@ class AgentRunner:
627
1075
  },
628
1076
  ),
629
1077
  )
1078
+ queue.put_nowait(result)
1079
+ guardrail_results.append(result)
1080
+ break
630
1081
  queue.put_nowait(result)
631
1082
  guardrail_results.append(result)
632
1083
  except Exception:
@@ -634,7 +1085,9 @@ class AgentRunner:
634
1085
  t.cancel()
635
1086
  raise
636
1087
 
637
- streamed_result.input_guardrail_results = guardrail_results
1088
+ streamed_result.input_guardrail_results = (
1089
+ streamed_result.input_guardrail_results + guardrail_results
1090
+ )
638
1091
 
639
1092
  @classmethod
640
1093
  async def _start_streaming(
@@ -647,6 +1100,8 @@ class AgentRunner:
647
1100
  context_wrapper: RunContextWrapper[TContext],
648
1101
  run_config: RunConfig,
649
1102
  previous_response_id: str | None,
1103
+ auto_previous_response_id: bool,
1104
+ conversation_id: str | None,
650
1105
  session: Session | None,
651
1106
  ):
652
1107
  if streamed_result.trace:
@@ -658,20 +1113,47 @@ class AgentRunner:
658
1113
  should_run_agent_start_hooks = True
659
1114
  tool_use_tracker = AgentToolUseTracker()
660
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
+
661
1130
  streamed_result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=current_agent))
662
1131
 
663
1132
  try:
664
1133
  # Prepare input with session if enabled
665
- 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
+ )
666
1137
 
667
1138
  # Update the streamed result with the prepared input
668
1139
  streamed_result.input = prepared_input
669
1140
 
1141
+ await AgentRunner._save_result_to_session(session, starting_input, [])
1142
+
670
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
+
671
1150
  if streamed_result.is_complete:
672
1151
  break
673
1152
 
674
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
+ )
675
1157
 
676
1158
  # Start an agent span if we don't have one. This span is ended if the current
677
1159
  # agent changes, or if the agent loop ends.
@@ -708,12 +1190,37 @@ class AgentRunner:
708
1190
  break
709
1191
 
710
1192
  if current_turn == 1:
711
- # 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.
712
1219
  streamed_result._input_guardrails_task = asyncio.create_task(
713
1220
  cls._run_input_guardrails_with_queue(
714
1221
  starting_agent,
715
- starting_agent.input_guardrails + (run_config.input_guardrails or []),
716
- copy.deepcopy(ItemHelpers.input_to_new_input_list(prepared_input)),
1222
+ parallel_guardrails,
1223
+ ItemHelpers.input_to_new_input_list(prepared_input),
717
1224
  context_wrapper,
718
1225
  streamed_result,
719
1226
  current_span,
@@ -729,7 +1236,7 @@ class AgentRunner:
729
1236
  should_run_agent_start_hooks,
730
1237
  tool_use_tracker,
731
1238
  all_tools,
732
- previous_response_id,
1239
+ server_conversation_tracker,
733
1240
  )
734
1241
  should_run_agent_start_hooks = False
735
1242
 
@@ -737,9 +1244,40 @@ class AgentRunner:
737
1244
  turn_result.model_response
738
1245
  ]
739
1246
  streamed_result.input = turn_result.original_input
740
- 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)
741
1261
 
742
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
+
743
1281
  current_agent = turn_result.next_step.new_agent
744
1282
  current_span.finish(reset_current=True)
745
1283
  current_span = None
@@ -747,6 +1285,12 @@ class AgentRunner:
747
1285
  streamed_result._event_queue.put_nowait(
748
1286
  AgentUpdatedStreamEvent(new_agent=current_agent)
749
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
750
1294
  elif isinstance(turn_result.next_step, NextStepFinalOutput):
751
1295
  streamed_result._output_guardrails_task = asyncio.create_task(
752
1296
  cls._run_output_guardrails(
@@ -769,24 +1313,45 @@ class AgentRunner:
769
1313
  streamed_result.is_complete = True
770
1314
 
771
1315
  # Save the conversation to session if enabled
772
- # Create a temporary RunResult for session saving
773
- temp_result = RunResult(
774
- input=streamed_result.input,
775
- new_items=streamed_result.new_items,
776
- raw_responses=streamed_result.raw_responses,
777
- final_output=streamed_result.final_output,
778
- _last_agent=current_agent,
779
- input_guardrail_results=streamed_result.input_guardrail_results,
780
- output_guardrail_results=streamed_result.output_guardrail_results,
781
- context_wrapper=context_wrapper,
782
- )
783
- await AgentRunner._save_result_to_session(
784
- session, starting_input, temp_result
785
- )
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
+ )
786
1331
 
787
1332
  streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
788
1333
  elif isinstance(turn_result.next_step, NextStepRunAgain):
789
- 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
790
1355
  except AgentsException as exc:
791
1356
  streamed_result.is_complete = True
792
1357
  streamed_result._event_queue.put_nowait(QueueCompleteSentinel())
@@ -815,11 +1380,32 @@ class AgentRunner:
815
1380
 
816
1381
  streamed_result.is_complete = True
817
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)
818
1396
  if current_span:
819
1397
  current_span.finish(reset_current=True)
820
1398
  if streamed_result.trace:
821
1399
  streamed_result.trace.finish(reset_current=True)
822
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
+
823
1409
  @classmethod
824
1410
  async def _run_single_turn_streamed(
825
1411
  cls,
@@ -831,13 +1417,21 @@ class AgentRunner:
831
1417
  should_run_agent_start_hooks: bool,
832
1418
  tool_use_tracker: AgentToolUseTracker,
833
1419
  all_tools: list[Tool],
834
- previous_response_id: str | None,
1420
+ server_conversation_tracker: _ServerConversationTracker | None = None,
835
1421
  ) -> SingleStepResult:
1422
+ emitted_tool_call_ids: set[str] = set()
1423
+ emitted_reasoning_item_ids: set[str] = set()
1424
+
836
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
+ )
837
1431
  await asyncio.gather(
838
- hooks.on_agent_start(context_wrapper, agent),
1432
+ hooks.on_agent_start(agent_hook_context, agent),
839
1433
  (
840
- agent.hooks.on_start(context_wrapper, agent)
1434
+ agent.hooks.on_start(agent_hook_context, agent)
841
1435
  if agent.hooks
842
1436
  else _coro.noop_coroutine()
843
1437
  ),
@@ -860,13 +1454,49 @@ class AgentRunner:
860
1454
 
861
1455
  final_response: ModelResponse | None = None
862
1456
 
863
- input = ItemHelpers.input_to_new_input_list(streamed_result.input)
864
- 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])
1464
+
1465
+ # THIS IS THE RESOLVED CONFLICT BLOCK
1466
+ filtered = await cls._maybe_filter_model_input(
1467
+ agent=agent,
1468
+ run_config=run_config,
1469
+ context_wrapper=context_wrapper,
1470
+ input_items=input,
1471
+ system_instructions=system_prompt,
1472
+ )
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
+ )
865
1495
 
866
1496
  # 1. Stream the output events
867
1497
  async for event in model.stream_response(
868
- system_prompt,
869
- input,
1498
+ filtered.instructions,
1499
+ filtered.input,
870
1500
  model_settings,
871
1501
  all_tools,
872
1502
  output_schema,
@@ -875,8 +1505,12 @@ class AgentRunner:
875
1505
  run_config.tracing_disabled, run_config.trace_include_sensitive_data
876
1506
  ),
877
1507
  previous_response_id=previous_response_id,
1508
+ conversation_id=conversation_id,
878
1509
  prompt=prompt_config,
879
1510
  ):
1511
+ # Emit the raw event ASAP
1512
+ streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event))
1513
+
880
1514
  if isinstance(event, ResponseCompletedEvent):
881
1515
  usage = (
882
1516
  Usage(
@@ -897,16 +1531,56 @@ class AgentRunner:
897
1531
  )
898
1532
  context_wrapper.usage.add(usage)
899
1533
 
900
- 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
+ )
901
1574
 
902
1575
  # 2. At this point, the streaming is complete for this turn of the agent loop.
903
1576
  if not final_response:
904
1577
  raise ModelBehaviorError("Model did not produce a final response!")
905
1578
 
906
1579
  # 3. Now, we can process the turn as we do in the non-streaming case
907
- return await cls._get_single_step_result_from_streamed_response(
1580
+ single_step_result = await cls._get_single_step_result_from_response(
908
1581
  agent=agent,
909
- streamed_result=streamed_result,
1582
+ original_input=streamed_result.input,
1583
+ pre_step_items=streamed_result._model_input_items,
910
1584
  new_response=final_response,
911
1585
  output_schema=output_schema,
912
1586
  all_tools=all_tools,
@@ -915,8 +1589,59 @@ class AgentRunner:
915
1589
  context_wrapper=context_wrapper,
916
1590
  run_config=run_config,
917
1591
  tool_use_tracker=tool_use_tracker,
1592
+ event_queue=streamed_result._event_queue,
918
1593
  )
919
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
+
920
1645
  @classmethod
921
1646
  async def _run_single_turn(
922
1647
  cls,
@@ -930,14 +1655,19 @@ class AgentRunner:
930
1655
  run_config: RunConfig,
931
1656
  should_run_agent_start_hooks: bool,
932
1657
  tool_use_tracker: AgentToolUseTracker,
933
- previous_response_id: str | None,
1658
+ server_conversation_tracker: _ServerConversationTracker | None = None,
934
1659
  ) -> SingleStepResult:
935
1660
  # Ensure we run the hooks before anything else
936
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
+ )
937
1667
  await asyncio.gather(
938
- hooks.on_agent_start(context_wrapper, agent),
1668
+ hooks.on_agent_start(agent_hook_context, agent),
939
1669
  (
940
- agent.hooks.on_start(context_wrapper, agent)
1670
+ agent.hooks.on_start(agent_hook_context, agent)
941
1671
  if agent.hooks
942
1672
  else _coro.noop_coroutine()
943
1673
  ),
@@ -950,8 +1680,11 @@ class AgentRunner:
950
1680
 
951
1681
  output_schema = cls._get_output_schema(agent)
952
1682
  handoffs = await cls._get_handoffs(agent, context_wrapper)
953
- input = ItemHelpers.input_to_new_input_list(original_input)
954
- 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])
955
1688
 
956
1689
  new_response = await cls._get_new_response(
957
1690
  agent,
@@ -960,10 +1693,11 @@ class AgentRunner:
960
1693
  output_schema,
961
1694
  all_tools,
962
1695
  handoffs,
1696
+ hooks,
963
1697
  context_wrapper,
964
1698
  run_config,
965
1699
  tool_use_tracker,
966
- previous_response_id,
1700
+ server_conversation_tracker,
967
1701
  prompt_config,
968
1702
  )
969
1703
 
@@ -996,6 +1730,7 @@ class AgentRunner:
996
1730
  context_wrapper: RunContextWrapper[TContext],
997
1731
  run_config: RunConfig,
998
1732
  tool_use_tracker: AgentToolUseTracker,
1733
+ event_queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] | None = None,
999
1734
  ) -> SingleStepResult:
1000
1735
  processed_response = RunImpl.process_model_response(
1001
1736
  agent=agent,
@@ -1007,6 +1742,14 @@ class AgentRunner:
1007
1742
 
1008
1743
  tool_use_tracker.add_tool_use(agent, processed_response.tools_used)
1009
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
+
1010
1753
  return await RunImpl.execute_tools_and_side_effects(
1011
1754
  agent=agent,
1012
1755
  original_input=original_input,
@@ -1034,9 +1777,8 @@ class AgentRunner:
1034
1777
  run_config: RunConfig,
1035
1778
  tool_use_tracker: AgentToolUseTracker,
1036
1779
  ) -> SingleStepResult:
1037
-
1038
1780
  original_input = streamed_result.input
1039
- pre_step_items = streamed_result.new_items
1781
+ pre_step_items = streamed_result._model_input_items
1040
1782
  event_queue = streamed_result._event_queue
1041
1783
 
1042
1784
  processed_response = RunImpl.process_model_response(
@@ -1061,10 +1803,15 @@ class AgentRunner:
1061
1803
  context_wrapper=context_wrapper,
1062
1804
  run_config=run_config,
1063
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
+ )
1064
1813
  new_step_items = [
1065
- item
1066
- for item in single_step_result.new_step_items
1067
- if item not in new_items_processed_response
1814
+ item for item in streaming_items if item not in new_items_processed_response
1068
1815
  ]
1069
1816
  RunImpl.stream_step_items_to_queue(new_step_items, event_queue)
1070
1817
 
@@ -1096,6 +1843,8 @@ class AgentRunner:
1096
1843
  # Cancel all guardrail tasks if a tripwire is triggered.
1097
1844
  for t in guardrail_tasks:
1098
1845
  t.cancel()
1846
+ # Wait for cancellations to propagate by awaiting the cancelled tasks.
1847
+ await asyncio.gather(*guardrail_tasks, return_exceptions=True)
1099
1848
  _error_tracing.attach_error_to_current_span(
1100
1849
  SpanError(
1101
1850
  message="Guardrail tripwire triggered",
@@ -1155,19 +1904,54 @@ class AgentRunner:
1155
1904
  output_schema: AgentOutputSchemaBase | None,
1156
1905
  all_tools: list[Tool],
1157
1906
  handoffs: list[Handoff],
1907
+ hooks: RunHooks[TContext],
1158
1908
  context_wrapper: RunContextWrapper[TContext],
1159
1909
  run_config: RunConfig,
1160
1910
  tool_use_tracker: AgentToolUseTracker,
1161
- previous_response_id: str | None,
1911
+ server_conversation_tracker: _ServerConversationTracker | None,
1162
1912
  prompt_config: ResponsePromptParam | None,
1163
1913
  ) -> ModelResponse:
1914
+ # Allow user to modify model input right before the call, if configured
1915
+ filtered = await cls._maybe_filter_model_input(
1916
+ agent=agent,
1917
+ run_config=run_config,
1918
+ context_wrapper=context_wrapper,
1919
+ input_items=input,
1920
+ system_instructions=system_prompt,
1921
+ )
1922
+
1164
1923
  model = cls._get_model(agent, run_config)
1165
1924
  model_settings = agent.model_settings.resolve(run_config.model_settings)
1166
1925
  model_settings = RunImpl.maybe_reset_tool_choice(agent, tool_use_tracker, model_settings)
1167
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
+
1168
1952
  new_response = await model.get_response(
1169
- system_instructions=system_prompt,
1170
- input=input,
1953
+ system_instructions=filtered.instructions,
1954
+ input=filtered.input,
1171
1955
  model_settings=model_settings,
1172
1956
  tools=all_tools,
1173
1957
  output_schema=output_schema,
@@ -1176,11 +1960,22 @@ class AgentRunner:
1176
1960
  run_config.tracing_disabled, run_config.trace_include_sensitive_data
1177
1961
  ),
1178
1962
  previous_response_id=previous_response_id,
1963
+ conversation_id=conversation_id,
1179
1964
  prompt=prompt_config,
1180
1965
  )
1181
1966
 
1182
1967
  context_wrapper.usage.add(new_response.usage)
1183
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
+
1184
1979
  return new_response
1185
1980
 
1186
1981
  @classmethod
@@ -1238,19 +2033,20 @@ class AgentRunner:
1238
2033
  cls,
1239
2034
  input: str | list[TResponseInputItem],
1240
2035
  session: Session | None,
2036
+ session_input_callback: SessionInputCallback | None,
1241
2037
  ) -> str | list[TResponseInputItem]:
1242
2038
  """Prepare input by combining it with session history if enabled."""
1243
2039
  if session is None:
1244
2040
  return input
1245
2041
 
1246
- # Validate that we don't have both a session and a list input, as this creates
1247
- # ambiguity about whether the list should append to or replace existing session history
1248
- 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:
1249
2044
  raise UserError(
1250
- "Cannot provide both a session and a list of input items. "
1251
- "When using session memory, provide only a string input to append to the "
1252
- "conversation, or use session=None and provide a list to manually manage "
1253
- "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."
1254
2050
  )
1255
2051
 
1256
2052
  # Get previous conversation history
@@ -1259,19 +2055,32 @@ class AgentRunner:
1259
2055
  # Convert input to list format
1260
2056
  new_input_list = ItemHelpers.input_to_new_input_list(input)
1261
2057
 
1262
- # Combine history with new input
1263
- combined_input = history + new_input_list
1264
-
1265
- 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
+ )
1266
2070
 
1267
2071
  @classmethod
1268
2072
  async def _save_result_to_session(
1269
2073
  cls,
1270
2074
  session: Session | None,
1271
2075
  original_input: str | list[TResponseInputItem],
1272
- result: RunResult,
2076
+ new_items: list[RunItem],
2077
+ response_id: str | None = None,
1273
2078
  ) -> None:
1274
- """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
+ """
1275
2084
  if session is None:
1276
2085
  return
1277
2086
 
@@ -1279,11 +2088,77 @@ class AgentRunner:
1279
2088
  input_list = ItemHelpers.input_to_new_input_list(original_input)
1280
2089
 
1281
2090
  # Convert new items to input format
1282
- 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]
1283
2092
 
1284
2093
  # Save all items from this turn
1285
2094
  items_to_save = input_list + new_items_as_input
1286
2095
  await session.add_items(items_to_save)
1287
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
+
1288
2144
 
1289
2145
  DEFAULT_AGENT_RUNNER = AgentRunner()
2146
+
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
+
2161
+ def _copy_str_or_list(input: str | list[TResponseInputItem]) -> str | list[TResponseInputItem]:
2162
+ if isinstance(input, str):
2163
+ return input
2164
+ return input.copy()