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
@@ -0,0 +1,1142 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import inspect
6
+ import json
7
+ import os
8
+ from collections.abc import AsyncGenerator, Awaitable, Mapping
9
+ from dataclasses import dataclass
10
+ from typing import Any, Callable, Optional, Union
11
+
12
+ from openai.types.responses.response_usage import InputTokensDetails, OutputTokensDetails
13
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
14
+ from typing_extensions import Literal, NotRequired, TypeAlias, TypedDict, TypeGuard
15
+
16
+ from agents import _debug
17
+ from agents.exceptions import ModelBehaviorError, UserError
18
+ from agents.logger import logger
19
+ from agents.models import _openai_shared
20
+ from agents.run_context import RunContextWrapper
21
+ from agents.strict_schema import ensure_strict_json_schema
22
+ from agents.tool import FunctionTool, ToolErrorFunction, default_tool_error_function
23
+ from agents.tool_context import ToolContext
24
+ from agents.tracing import SpanError, custom_span
25
+ from agents.usage import Usage as AgentsUsage
26
+ from agents.util import _error_tracing
27
+ from agents.util._types import MaybeAwaitable
28
+
29
+ from .codex import Codex
30
+ from .codex_options import CodexOptions, coerce_codex_options
31
+ from .events import (
32
+ ItemCompletedEvent,
33
+ ItemStartedEvent,
34
+ ItemUpdatedEvent,
35
+ ThreadErrorEvent,
36
+ ThreadEvent,
37
+ TurnCompletedEvent,
38
+ TurnFailedEvent,
39
+ Usage,
40
+ coerce_thread_event,
41
+ )
42
+ from .items import (
43
+ CommandExecutionItem,
44
+ McpToolCallItem,
45
+ ReasoningItem,
46
+ ThreadItem,
47
+ is_agent_message_item,
48
+ )
49
+ from .payloads import _DictLike
50
+ from .thread import Input, Thread, UserInput
51
+ from .thread_options import SandboxMode, ThreadOptions, coerce_thread_options
52
+ from .turn_options import TurnOptions, coerce_turn_options
53
+
54
+ JSON_PRIMITIVE_TYPES = {"string", "number", "integer", "boolean"}
55
+ SPAN_TRIM_KEYS = (
56
+ "arguments",
57
+ "command",
58
+ "output",
59
+ "result",
60
+ "error",
61
+ "text",
62
+ "changes",
63
+ "items",
64
+ )
65
+
66
+
67
+ class CodexToolInputItem(BaseModel):
68
+ type: Literal["text", "local_image"]
69
+ text: str | None = None
70
+ path: str | None = None
71
+
72
+ model_config = ConfigDict(extra="forbid")
73
+
74
+ @model_validator(mode="after")
75
+ def validate_item(self) -> CodexToolInputItem:
76
+ text_value = (self.text or "").strip()
77
+ path_value = (self.path or "").strip()
78
+
79
+ if self.type == "text":
80
+ if not text_value:
81
+ raise ValueError('Text inputs must include a non-empty "text" field.')
82
+ if path_value:
83
+ raise ValueError('"path" is not allowed when type is "text".')
84
+ self.text = text_value
85
+ self.path = None
86
+ return self
87
+
88
+ if not path_value:
89
+ raise ValueError('Local image inputs must include a non-empty "path" field.')
90
+ if text_value:
91
+ raise ValueError('"text" is not allowed when type is "local_image".')
92
+ self.path = path_value
93
+ self.text = None
94
+ return self
95
+
96
+
97
+ class CodexToolParameters(BaseModel):
98
+ inputs: list[CodexToolInputItem] = Field(
99
+ ...,
100
+ min_length=1,
101
+ description=(
102
+ "Structured inputs appended to the Codex task. Provide at least one input item."
103
+ ),
104
+ )
105
+
106
+ model_config = ConfigDict(extra="forbid")
107
+
108
+
109
+ class OutputSchemaPrimitive(TypedDict, total=False):
110
+ type: Literal["string", "number", "integer", "boolean"]
111
+ description: NotRequired[str]
112
+ enum: NotRequired[list[str]]
113
+
114
+
115
+ class OutputSchemaArray(TypedDict, total=False):
116
+ type: Literal["array"]
117
+ description: NotRequired[str]
118
+ items: OutputSchemaPrimitive
119
+
120
+
121
+ OutputSchemaField: TypeAlias = Union[OutputSchemaPrimitive, OutputSchemaArray]
122
+
123
+
124
+ class OutputSchemaPropertyDescriptor(TypedDict, total=False):
125
+ name: str
126
+ description: NotRequired[str]
127
+ schema: OutputSchemaField
128
+
129
+
130
+ class OutputSchemaDescriptor(TypedDict, total=False):
131
+ title: NotRequired[str]
132
+ description: NotRequired[str]
133
+ properties: list[OutputSchemaPropertyDescriptor]
134
+ required: NotRequired[list[str]]
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class CodexToolResult:
139
+ thread_id: str | None
140
+ response: str
141
+ usage: Usage | None
142
+
143
+ def as_dict(self) -> dict[str, Any]:
144
+ return {
145
+ "thread_id": self.thread_id,
146
+ "response": self.response,
147
+ "usage": self.usage.as_dict() if isinstance(self.usage, Usage) else self.usage,
148
+ }
149
+
150
+ def __str__(self) -> str:
151
+ return json.dumps(self.as_dict())
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class CodexToolStreamEvent(_DictLike):
156
+ event: ThreadEvent
157
+ thread: Thread
158
+ tool_call: Any
159
+
160
+
161
+ @dataclass
162
+ class CodexToolOptions:
163
+ name: str | None = None
164
+ description: str | None = None
165
+ parameters: type[BaseModel] | None = None
166
+ output_schema: OutputSchemaDescriptor | Mapping[str, Any] | None = None
167
+ codex: Codex | None = None
168
+ codex_options: CodexOptions | Mapping[str, Any] | None = None
169
+ default_thread_options: ThreadOptions | Mapping[str, Any] | None = None
170
+ thread_id: str | None = None
171
+ sandbox_mode: SandboxMode | None = None
172
+ working_directory: str | None = None
173
+ skip_git_repo_check: bool | None = None
174
+ default_turn_options: TurnOptions | Mapping[str, Any] | None = None
175
+ span_data_max_chars: int | None = 8192
176
+ persist_session: bool = False
177
+ on_stream: Callable[[CodexToolStreamEvent], MaybeAwaitable[None]] | None = None
178
+ is_enabled: bool | Callable[[RunContextWrapper[Any], Any], MaybeAwaitable[bool]] = True
179
+ failure_error_function: ToolErrorFunction | None = default_tool_error_function
180
+
181
+
182
+ CodexToolCallArguments: TypeAlias = dict[str, Optional[list[UserInput]]]
183
+
184
+
185
+ class _UnsetType:
186
+ pass
187
+
188
+
189
+ _UNSET = _UnsetType()
190
+
191
+
192
+ def codex_tool(
193
+ options: CodexToolOptions | Mapping[str, Any] | None = None,
194
+ *,
195
+ name: str | None = None,
196
+ description: str | None = None,
197
+ parameters: type[BaseModel] | None = None,
198
+ output_schema: OutputSchemaDescriptor | Mapping[str, Any] | None = None,
199
+ codex: Codex | None = None,
200
+ codex_options: CodexOptions | Mapping[str, Any] | None = None,
201
+ default_thread_options: ThreadOptions | Mapping[str, Any] | None = None,
202
+ thread_id: str | None = None,
203
+ sandbox_mode: SandboxMode | None = None,
204
+ working_directory: str | None = None,
205
+ skip_git_repo_check: bool | None = None,
206
+ default_turn_options: TurnOptions | Mapping[str, Any] | None = None,
207
+ span_data_max_chars: int | None | _UnsetType = _UNSET,
208
+ persist_session: bool | None = None,
209
+ on_stream: Callable[[CodexToolStreamEvent], MaybeAwaitable[None]] | None = None,
210
+ is_enabled: bool | Callable[[RunContextWrapper[Any], Any], MaybeAwaitable[bool]] | None = None,
211
+ failure_error_function: ToolErrorFunction | None | _UnsetType = _UNSET,
212
+ ) -> FunctionTool:
213
+ resolved_options = _coerce_tool_options(options)
214
+ if name is not None:
215
+ resolved_options.name = name
216
+ if description is not None:
217
+ resolved_options.description = description
218
+ if parameters is not None:
219
+ resolved_options.parameters = parameters
220
+ if output_schema is not None:
221
+ resolved_options.output_schema = output_schema
222
+ if codex is not None:
223
+ resolved_options.codex = codex
224
+ if codex_options is not None:
225
+ resolved_options.codex_options = codex_options
226
+ if default_thread_options is not None:
227
+ resolved_options.default_thread_options = default_thread_options
228
+ if thread_id is not None:
229
+ resolved_options.thread_id = thread_id
230
+ if sandbox_mode is not None:
231
+ resolved_options.sandbox_mode = sandbox_mode
232
+ if working_directory is not None:
233
+ resolved_options.working_directory = working_directory
234
+ if skip_git_repo_check is not None:
235
+ resolved_options.skip_git_repo_check = skip_git_repo_check
236
+ if default_turn_options is not None:
237
+ resolved_options.default_turn_options = default_turn_options
238
+ if not isinstance(span_data_max_chars, _UnsetType):
239
+ resolved_options.span_data_max_chars = span_data_max_chars
240
+ if persist_session is not None:
241
+ resolved_options.persist_session = persist_session
242
+ if on_stream is not None:
243
+ resolved_options.on_stream = on_stream
244
+ if is_enabled is not None:
245
+ resolved_options.is_enabled = is_enabled
246
+ if not isinstance(failure_error_function, _UnsetType):
247
+ resolved_options.failure_error_function = failure_error_function
248
+
249
+ resolved_options.codex_options = coerce_codex_options(resolved_options.codex_options)
250
+ resolved_options.default_thread_options = coerce_thread_options(
251
+ resolved_options.default_thread_options
252
+ )
253
+ resolved_options.default_turn_options = coerce_turn_options(
254
+ resolved_options.default_turn_options
255
+ )
256
+ name = resolved_options.name or "codex"
257
+ description = resolved_options.description or (
258
+ "Executes an agentic Codex task against the current workspace."
259
+ )
260
+ parameters_model = resolved_options.parameters or CodexToolParameters
261
+
262
+ params_schema = ensure_strict_json_schema(parameters_model.model_json_schema())
263
+ resolved_codex_options = _resolve_codex_options(resolved_options.codex_options)
264
+ resolve_codex = _create_codex_resolver(resolved_options.codex, resolved_codex_options)
265
+
266
+ validated_output_schema = _resolve_output_schema(resolved_options.output_schema)
267
+ resolved_thread_options = _resolve_thread_options(
268
+ resolved_options.default_thread_options,
269
+ resolved_options.sandbox_mode,
270
+ resolved_options.working_directory,
271
+ resolved_options.skip_git_repo_check,
272
+ )
273
+
274
+ persisted_thread: Thread | None = None
275
+
276
+ async def _on_invoke_tool(ctx: ToolContext[Any], input_json: str) -> Any:
277
+ nonlocal persisted_thread
278
+ try:
279
+ parsed = _parse_tool_input(parameters_model, input_json)
280
+ args = _normalize_parameters(parsed)
281
+
282
+ codex = await resolve_codex()
283
+ if resolved_options.persist_session:
284
+ # Reuse a single Codex thread across tool calls.
285
+ thread = _get_or_create_persisted_thread(
286
+ codex,
287
+ resolved_options.thread_id,
288
+ resolved_thread_options,
289
+ persisted_thread,
290
+ )
291
+ if persisted_thread is None:
292
+ persisted_thread = thread
293
+ else:
294
+ thread = _get_thread(codex, resolved_options.thread_id, resolved_thread_options)
295
+
296
+ turn_options = _build_turn_options(
297
+ resolved_options.default_turn_options, validated_output_schema
298
+ )
299
+ codex_input = _build_codex_input(args)
300
+
301
+ # Always stream and aggregate locally to enable on_stream callbacks.
302
+ stream_result = await thread.run_streamed(codex_input, turn_options)
303
+ response, usage = await _consume_events(
304
+ stream_result.events,
305
+ args,
306
+ ctx,
307
+ thread,
308
+ resolved_options.on_stream,
309
+ resolved_options.span_data_max_chars,
310
+ )
311
+
312
+ if usage is not None:
313
+ ctx.usage.add(_to_agent_usage(usage))
314
+
315
+ return CodexToolResult(thread_id=thread.id, response=response, usage=usage)
316
+ except Exception as exc: # noqa: BLE001
317
+ if resolved_options.failure_error_function is None:
318
+ raise
319
+
320
+ result = resolved_options.failure_error_function(ctx, exc)
321
+ if inspect.isawaitable(result):
322
+ result = await result
323
+
324
+ _error_tracing.attach_error_to_current_span(
325
+ SpanError(
326
+ message="Error running Codex tool (non-fatal)",
327
+ data={"tool_name": name, "error": str(exc)},
328
+ )
329
+ )
330
+ if _debug.DONT_LOG_TOOL_DATA:
331
+ logger.debug("Codex tool failed")
332
+ else:
333
+ logger.error("Codex tool failed: %s", exc, exc_info=exc)
334
+ return result
335
+
336
+ return FunctionTool(
337
+ name=name,
338
+ description=description,
339
+ params_json_schema=params_schema,
340
+ on_invoke_tool=_on_invoke_tool,
341
+ strict_json_schema=True,
342
+ is_enabled=resolved_options.is_enabled,
343
+ )
344
+
345
+
346
+ def _coerce_tool_options(
347
+ options: CodexToolOptions | Mapping[str, Any] | None,
348
+ ) -> CodexToolOptions:
349
+ if options is None:
350
+ return CodexToolOptions()
351
+ if isinstance(options, CodexToolOptions):
352
+ resolved = options
353
+ else:
354
+ if not isinstance(options, Mapping):
355
+ raise UserError("Codex tool options must be a CodexToolOptions or a mapping.")
356
+
357
+ allowed = {field.name for field in dataclasses.fields(CodexToolOptions)}
358
+ unknown = set(options.keys()) - allowed
359
+ if unknown:
360
+ raise UserError(f"Unknown Codex tool option(s): {sorted(unknown)}")
361
+
362
+ resolved = CodexToolOptions(**dict(options))
363
+ # Normalize nested option dictionaries to their dataclass equivalents.
364
+ resolved.codex_options = coerce_codex_options(resolved.codex_options)
365
+ resolved.default_thread_options = coerce_thread_options(resolved.default_thread_options)
366
+ resolved.default_turn_options = coerce_turn_options(resolved.default_turn_options)
367
+ return resolved
368
+
369
+
370
+ def _parse_tool_input(parameters_model: type[BaseModel], input_json: str) -> BaseModel:
371
+ try:
372
+ json_data = json.loads(input_json) if input_json else {}
373
+ except Exception as exc: # noqa: BLE001
374
+ if _debug.DONT_LOG_TOOL_DATA:
375
+ logger.debug("Invalid JSON input for codex tool")
376
+ else:
377
+ logger.debug("Invalid JSON input for codex tool: %s", input_json)
378
+ raise ModelBehaviorError(f"Invalid JSON input for codex tool: {input_json}") from exc
379
+
380
+ try:
381
+ return parameters_model.model_validate(json_data)
382
+ except ValidationError as exc:
383
+ raise ModelBehaviorError(f"Invalid JSON input for codex tool: {exc}") from exc
384
+
385
+
386
+ def _normalize_parameters(params: BaseModel) -> CodexToolCallArguments:
387
+ inputs_value = getattr(params, "inputs", None)
388
+ if inputs_value is None:
389
+ raise UserError("Codex tool parameters must include an inputs field.")
390
+
391
+ inputs = [{"type": item.type, "text": item.text, "path": item.path} for item in inputs_value]
392
+
393
+ normalized_inputs: list[UserInput] = []
394
+ for item in inputs:
395
+ if item["type"] == "text":
396
+ normalized_inputs.append({"type": "text", "text": item["text"] or ""})
397
+ else:
398
+ normalized_inputs.append({"type": "local_image", "path": item["path"] or ""})
399
+
400
+ return {"inputs": normalized_inputs if normalized_inputs else None}
401
+
402
+
403
+ def _build_codex_input(args: CodexToolCallArguments) -> Input:
404
+ if args.get("inputs"):
405
+ return args["inputs"] # type: ignore[return-value]
406
+ return ""
407
+
408
+
409
+ def _resolve_codex_options(
410
+ options: CodexOptions | Mapping[str, Any] | None,
411
+ ) -> CodexOptions | None:
412
+ options = coerce_codex_options(options)
413
+ if options and options.api_key:
414
+ return options
415
+
416
+ api_key = _resolve_default_codex_api_key(options)
417
+ if not api_key:
418
+ return options
419
+
420
+ if options is None:
421
+ return CodexOptions(api_key=api_key)
422
+
423
+ return CodexOptions(
424
+ codex_path_override=options.codex_path_override,
425
+ base_url=options.base_url,
426
+ api_key=api_key,
427
+ env=options.env,
428
+ )
429
+
430
+
431
+ def _resolve_default_codex_api_key(options: CodexOptions | None) -> str | None:
432
+ if options and options.api_key:
433
+ return options.api_key
434
+
435
+ env_override = options.env if options else None
436
+ if env_override:
437
+ env_codex = env_override.get("CODEX_API_KEY")
438
+ if env_codex:
439
+ return env_codex
440
+ env_openai = env_override.get("OPENAI_API_KEY")
441
+ if env_openai:
442
+ return env_openai
443
+
444
+ env_codex = os.environ.get("CODEX_API_KEY")
445
+ if env_codex:
446
+ return env_codex
447
+
448
+ env_openai = os.environ.get("OPENAI_API_KEY")
449
+ if env_openai:
450
+ return env_openai
451
+
452
+ return _openai_shared.get_default_openai_key()
453
+
454
+
455
+ def _create_codex_resolver(
456
+ provided: Codex | None, options: CodexOptions | None
457
+ ) -> Callable[[], Awaitable[Codex]]:
458
+ if provided is not None:
459
+
460
+ async def _return_provided() -> Codex:
461
+ return provided
462
+
463
+ return _return_provided
464
+
465
+ codex_instance: Codex | None = None
466
+
467
+ async def _get_or_create() -> Codex:
468
+ nonlocal codex_instance
469
+ if codex_instance is None:
470
+ codex_instance = Codex(options)
471
+ return codex_instance
472
+
473
+ return _get_or_create
474
+
475
+
476
+ def _resolve_thread_options(
477
+ defaults: ThreadOptions | Mapping[str, Any] | None,
478
+ sandbox_mode: SandboxMode | None,
479
+ working_directory: str | None,
480
+ skip_git_repo_check: bool | None,
481
+ ) -> ThreadOptions | None:
482
+ defaults = coerce_thread_options(defaults)
483
+ if not defaults and not sandbox_mode and not working_directory and skip_git_repo_check is None:
484
+ return None
485
+
486
+ return ThreadOptions(
487
+ **{
488
+ **(defaults.__dict__ if defaults else {}),
489
+ **({"sandbox_mode": sandbox_mode} if sandbox_mode else {}),
490
+ **({"working_directory": working_directory} if working_directory else {}),
491
+ **(
492
+ {"skip_git_repo_check": skip_git_repo_check}
493
+ if skip_git_repo_check is not None
494
+ else {}
495
+ ),
496
+ }
497
+ )
498
+
499
+
500
+ def _build_turn_options(
501
+ defaults: TurnOptions | Mapping[str, Any] | None,
502
+ output_schema: dict[str, Any] | None,
503
+ ) -> TurnOptions:
504
+ defaults = coerce_turn_options(defaults)
505
+ if defaults is None and output_schema is None:
506
+ return TurnOptions()
507
+
508
+ if defaults is None:
509
+ return TurnOptions(output_schema=output_schema, signal=None, idle_timeout_seconds=None)
510
+
511
+ merged_output_schema = output_schema if output_schema is not None else defaults.output_schema
512
+ return TurnOptions(
513
+ output_schema=merged_output_schema,
514
+ signal=defaults.signal,
515
+ idle_timeout_seconds=defaults.idle_timeout_seconds,
516
+ )
517
+
518
+
519
+ def _resolve_output_schema(
520
+ option: OutputSchemaDescriptor | Mapping[str, Any] | None,
521
+ ) -> dict[str, Any] | None:
522
+ if option is None:
523
+ return None
524
+
525
+ if isinstance(option, Mapping) and _looks_like_descriptor(option):
526
+ # Descriptor input is converted to a strict JSON schema for Codex.
527
+ descriptor = _validate_descriptor(option)
528
+ return _build_codex_output_schema(descriptor)
529
+
530
+ if isinstance(option, Mapping):
531
+ schema = dict(option)
532
+ if "type" in schema and schema.get("type") != "object":
533
+ raise UserError('Codex output schema must be a JSON object schema with type "object".')
534
+ return ensure_strict_json_schema(schema)
535
+
536
+ raise UserError("Codex output schema must be a JSON schema or descriptor.")
537
+
538
+
539
+ def _looks_like_descriptor(option: Mapping[str, Any]) -> bool:
540
+ properties = option.get("properties")
541
+ if not isinstance(properties, list):
542
+ return False
543
+ return all(isinstance(item, Mapping) and "name" in item for item in properties)
544
+
545
+
546
+ def _validate_descriptor(option: Mapping[str, Any]) -> OutputSchemaDescriptor:
547
+ properties = option.get("properties")
548
+ if not isinstance(properties, list) or not properties:
549
+ raise UserError("Codex output schema descriptor must include properties.")
550
+
551
+ seen: set[str] = set()
552
+ for prop in properties:
553
+ name = prop.get("name") if isinstance(prop, Mapping) else None
554
+ if not isinstance(name, str) or not name.strip():
555
+ raise UserError("Codex output schema properties must include non-empty names.")
556
+ if name in seen:
557
+ raise UserError(f'Duplicate property name "{name}" in output_schema.')
558
+ seen.add(name)
559
+
560
+ schema = prop.get("schema")
561
+ if not _is_valid_field(schema):
562
+ raise UserError(f'Invalid schema for output property "{name}".')
563
+
564
+ required = option.get("required")
565
+ if required is not None:
566
+ if not isinstance(required, list) or not all(isinstance(item, str) for item in required):
567
+ raise UserError("output_schema.required must be a list of strings.")
568
+ for name in required:
569
+ if name not in seen:
570
+ raise UserError(f'Required property "{name}" must also be defined in "properties".')
571
+
572
+ return option # type: ignore[return-value]
573
+
574
+
575
+ def _is_valid_field(field: Any) -> bool:
576
+ if not isinstance(field, Mapping):
577
+ return False
578
+ field_type = field.get("type")
579
+ if field_type in JSON_PRIMITIVE_TYPES:
580
+ enum = field.get("enum")
581
+ if enum is not None and (
582
+ not isinstance(enum, list) or not all(isinstance(item, str) for item in enum)
583
+ ):
584
+ return False
585
+ return True
586
+ if field_type == "array":
587
+ items = field.get("items")
588
+ return _is_valid_field(items)
589
+ return False
590
+
591
+
592
+ def _build_codex_output_schema(descriptor: OutputSchemaDescriptor) -> dict[str, Any]:
593
+ # Compose the strict object schema required by Codex structured outputs.
594
+ properties: dict[str, Any] = {}
595
+ for prop in descriptor["properties"]:
596
+ prop_schema = _build_codex_output_schema_field(prop["schema"])
597
+ if prop.get("description"):
598
+ prop_schema["description"] = prop["description"]
599
+ properties[prop["name"]] = prop_schema
600
+
601
+ required = list(descriptor.get("required", []))
602
+
603
+ schema: dict[str, Any] = {
604
+ "type": "object",
605
+ "additionalProperties": False,
606
+ "properties": properties,
607
+ "required": required,
608
+ }
609
+
610
+ if "title" in descriptor and descriptor["title"]:
611
+ schema["title"] = descriptor["title"]
612
+ if "description" in descriptor and descriptor["description"]:
613
+ schema["description"] = descriptor["description"]
614
+
615
+ return schema
616
+
617
+
618
+ def _build_codex_output_schema_field(field: OutputSchemaField) -> dict[str, Any]:
619
+ if field["type"] == "array":
620
+ schema: dict[str, Any] = {
621
+ "type": "array",
622
+ "items": _build_codex_output_schema_field(field["items"]),
623
+ }
624
+ if "description" in field and field["description"]:
625
+ schema["description"] = field["description"]
626
+ return schema
627
+ result: dict[str, Any] = {"type": field["type"]}
628
+ if "description" in field and field["description"]:
629
+ result["description"] = field["description"]
630
+ if "enum" in field:
631
+ result["enum"] = field["enum"]
632
+ return result
633
+
634
+
635
+ def _get_thread(codex: Codex, thread_id: str | None, defaults: ThreadOptions | None) -> Thread:
636
+ if thread_id:
637
+ return codex.resume_thread(thread_id, defaults)
638
+ return codex.start_thread(defaults)
639
+
640
+
641
+ def _get_or_create_persisted_thread(
642
+ codex: Codex,
643
+ thread_id: str | None,
644
+ thread_options: ThreadOptions | None,
645
+ existing_thread: Thread | None,
646
+ ) -> Thread:
647
+ if existing_thread is not None:
648
+ if thread_id:
649
+ existing_id = existing_thread.id
650
+ if existing_id and existing_id != thread_id:
651
+ raise UserError(
652
+ "Codex tool is configured with persist_session=true "
653
+ + "and already has an active thread."
654
+ )
655
+ return existing_thread
656
+
657
+ return _get_thread(codex, thread_id, thread_options)
658
+
659
+
660
+ def _to_agent_usage(usage: Usage) -> AgentsUsage:
661
+ return AgentsUsage(
662
+ requests=1,
663
+ input_tokens=usage.input_tokens,
664
+ output_tokens=usage.output_tokens,
665
+ total_tokens=usage.input_tokens + usage.output_tokens,
666
+ input_tokens_details=InputTokensDetails(cached_tokens=usage.cached_input_tokens),
667
+ output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
668
+ )
669
+
670
+
671
+ async def _consume_events(
672
+ events: AsyncGenerator[ThreadEvent | Mapping[str, Any], None],
673
+ args: CodexToolCallArguments,
674
+ ctx: ToolContext[Any],
675
+ thread: Thread,
676
+ on_stream: Callable[[CodexToolStreamEvent], MaybeAwaitable[None]] | None,
677
+ span_data_max_chars: int | None,
678
+ ) -> tuple[str, Usage | None]:
679
+ # Track spans keyed by item id for command/mcp/reasoning events.
680
+ active_spans: dict[str, Any] = {}
681
+ final_response = ""
682
+ usage: Usage | None = None
683
+
684
+ event_queue: asyncio.Queue[CodexToolStreamEvent | None] | None = None
685
+ dispatch_task: asyncio.Task[None] | None = None
686
+
687
+ if on_stream is not None:
688
+ # Buffer events so user callbacks cannot block the Codex stream loop.
689
+ event_queue = asyncio.Queue()
690
+
691
+ async def _run_handler(payload: CodexToolStreamEvent) -> None:
692
+ # Dispatch user callbacks asynchronously to avoid blocking the stream.
693
+ try:
694
+ maybe_result = on_stream(payload)
695
+ if inspect.isawaitable(maybe_result):
696
+ await maybe_result
697
+ except Exception:
698
+ logger.exception("Error while handling Codex on_stream event.")
699
+
700
+ async def _dispatch() -> None:
701
+ assert event_queue is not None
702
+ while True:
703
+ payload = await event_queue.get()
704
+ is_sentinel = payload is None
705
+ try:
706
+ if payload is not None:
707
+ await _run_handler(payload)
708
+ finally:
709
+ event_queue.task_done()
710
+ if is_sentinel:
711
+ break
712
+
713
+ dispatch_task = asyncio.create_task(_dispatch())
714
+
715
+ try:
716
+ async for raw_event in events:
717
+ event = coerce_thread_event(raw_event)
718
+ if event_queue is not None:
719
+ await event_queue.put(
720
+ CodexToolStreamEvent(
721
+ event=event,
722
+ thread=thread,
723
+ tool_call=ctx.tool_call,
724
+ )
725
+ )
726
+
727
+ if isinstance(event, ItemStartedEvent):
728
+ _handle_item_started(event.item, active_spans, span_data_max_chars)
729
+ elif isinstance(event, ItemUpdatedEvent):
730
+ _handle_item_updated(event.item, active_spans, span_data_max_chars)
731
+ elif isinstance(event, ItemCompletedEvent):
732
+ _handle_item_completed(event.item, active_spans, span_data_max_chars)
733
+ if is_agent_message_item(event.item):
734
+ final_response = event.item.text
735
+ elif isinstance(event, TurnCompletedEvent):
736
+ usage = event.usage
737
+ elif isinstance(event, TurnFailedEvent):
738
+ error = event.error.message
739
+ raise UserError(f"Codex turn failed{(': ' + error) if error else ''}")
740
+ elif isinstance(event, ThreadErrorEvent):
741
+ raise UserError(f"Codex stream error: {event.message}")
742
+ finally:
743
+ if event_queue is not None:
744
+ await event_queue.put(None)
745
+ await event_queue.join()
746
+ if dispatch_task is not None:
747
+ await dispatch_task
748
+
749
+ # Ensure any open spans are closed even on failure.
750
+ for span in active_spans.values():
751
+ span.finish()
752
+ active_spans.clear()
753
+
754
+ if not final_response:
755
+ final_response = _build_default_response(args)
756
+
757
+ return final_response, usage
758
+
759
+
760
+ def _handle_item_started(
761
+ item: ThreadItem, spans: dict[str, Any], span_data_max_chars: int | None
762
+ ) -> None:
763
+ item_id = getattr(item, "id", None)
764
+ if not item_id:
765
+ return
766
+
767
+ if _is_command_execution_item(item):
768
+ output = item.aggregated_output
769
+ updates = {
770
+ "command": item.command,
771
+ "status": item.status,
772
+ "exit_code": item.exit_code,
773
+ }
774
+ if output not in (None, ""):
775
+ updates["output"] = _truncate_span_value(output, span_data_max_chars)
776
+ data = _merge_span_data(
777
+ {},
778
+ updates,
779
+ span_data_max_chars,
780
+ )
781
+ span = custom_span(
782
+ name="Codex command execution",
783
+ data=data,
784
+ )
785
+ span.start()
786
+ spans[item_id] = span
787
+ return
788
+
789
+ if _is_mcp_tool_call_item(item):
790
+ data = _merge_span_data(
791
+ {},
792
+ {
793
+ "server": item.server,
794
+ "tool": item.tool,
795
+ "status": item.status,
796
+ "arguments": _truncate_span_value(
797
+ _maybe_as_dict(item.arguments), span_data_max_chars
798
+ ),
799
+ },
800
+ span_data_max_chars,
801
+ )
802
+ span = custom_span(
803
+ name="Codex MCP tool call",
804
+ data=data,
805
+ )
806
+ span.start()
807
+ spans[item_id] = span
808
+ return
809
+
810
+ if _is_reasoning_item(item):
811
+ data = _merge_span_data(
812
+ {},
813
+ {"text": _truncate_span_value(item.text, span_data_max_chars)},
814
+ span_data_max_chars,
815
+ )
816
+ span = custom_span(
817
+ name="Codex reasoning",
818
+ data=data,
819
+ )
820
+ span.start()
821
+ spans[item_id] = span
822
+
823
+
824
+ def _handle_item_updated(
825
+ item: ThreadItem, spans: dict[str, Any], span_data_max_chars: int | None
826
+ ) -> None:
827
+ item_id = getattr(item, "id", None)
828
+ if not item_id:
829
+ return
830
+ span = spans.get(item_id)
831
+ if span is None:
832
+ return
833
+
834
+ if _is_command_execution_item(item):
835
+ _update_command_span(span, item, span_data_max_chars)
836
+ elif _is_mcp_tool_call_item(item):
837
+ _update_mcp_tool_span(span, item, span_data_max_chars)
838
+ elif _is_reasoning_item(item):
839
+ _update_reasoning_span(span, item, span_data_max_chars)
840
+
841
+
842
+ def _handle_item_completed(
843
+ item: ThreadItem, spans: dict[str, Any], span_data_max_chars: int | None
844
+ ) -> None:
845
+ item_id = getattr(item, "id", None)
846
+ if not item_id:
847
+ return
848
+ span = spans.get(item_id)
849
+ if span is None:
850
+ return
851
+
852
+ if _is_command_execution_item(item):
853
+ _update_command_span(span, item, span_data_max_chars)
854
+ if item.status == "failed":
855
+ error_data: dict[str, Any] = {
856
+ "exit_code": item.exit_code,
857
+ }
858
+ output = item.aggregated_output
859
+ if output not in (None, ""):
860
+ error_data["output"] = _truncate_span_value(output, span_data_max_chars)
861
+ span.set_error(
862
+ SpanError(
863
+ message="Codex command execution failed.",
864
+ data=error_data,
865
+ )
866
+ )
867
+ elif _is_mcp_tool_call_item(item):
868
+ _update_mcp_tool_span(span, item, span_data_max_chars)
869
+ error = item.error
870
+ if item.status == "failed" and error is not None and error.message:
871
+ span.set_error(SpanError(message=error.message, data={}))
872
+ elif _is_reasoning_item(item):
873
+ _update_reasoning_span(span, item, span_data_max_chars)
874
+
875
+ span.finish()
876
+ spans.pop(item_id, None)
877
+
878
+
879
+ def _truncate_span_string(value: str, max_chars: int | None) -> str:
880
+ if max_chars is None:
881
+ return value
882
+ if max_chars <= 0:
883
+ return ""
884
+ if len(value) <= max_chars:
885
+ return value
886
+
887
+ suffix = f"... [truncated, {len(value)} chars]"
888
+ max_prefix = max_chars - len(suffix)
889
+ if max_prefix <= 0:
890
+ return value[:max_chars]
891
+ return value[:max_prefix] + suffix
892
+
893
+
894
+ def _json_char_size(value: Any) -> int:
895
+ try:
896
+ return len(json.dumps(value, ensure_ascii=True, separators=(",", ":"), default=str))
897
+ except Exception:
898
+ return len(str(value))
899
+
900
+
901
+ def _drop_empty_string_fields(data: dict[str, Any]) -> dict[str, Any]:
902
+ return {key: value for key, value in data.items() if value != ""}
903
+
904
+
905
+ def _stringify_span_value(value: Any) -> str:
906
+ if value is None:
907
+ return ""
908
+ if isinstance(value, str):
909
+ return value
910
+ try:
911
+ return json.dumps(value, ensure_ascii=True, separators=(",", ":"), default=str)
912
+ except Exception:
913
+ return str(value)
914
+
915
+
916
+ def _maybe_as_dict(value: Any) -> Any:
917
+ if isinstance(value, _DictLike):
918
+ return value.as_dict()
919
+ if isinstance(value, list):
920
+ return [_maybe_as_dict(item) for item in value]
921
+ if isinstance(value, dict):
922
+ return {key: _maybe_as_dict(item) for key, item in value.items()}
923
+ return value
924
+
925
+
926
+ def _truncate_span_value(value: Any, max_chars: int | None) -> Any:
927
+ if max_chars is None:
928
+ return value
929
+ if value is None or isinstance(value, (bool, int, float)):
930
+ return value
931
+ if isinstance(value, str):
932
+ return _truncate_span_string(value, max_chars)
933
+
934
+ try:
935
+ encoded = json.dumps(value, ensure_ascii=True, separators=(",", ":"), default=str)
936
+ except Exception:
937
+ encoded = str(value)
938
+
939
+ if len(encoded) <= max_chars:
940
+ return value
941
+
942
+ return {
943
+ "preview": _truncate_span_string(encoded, max_chars),
944
+ "truncated": True,
945
+ "original_length": len(encoded),
946
+ }
947
+
948
+
949
+ def _enforce_span_data_budget(data: dict[str, Any], max_chars: int | None) -> dict[str, Any]:
950
+ # Trim span payloads to fit the overall JSON size budget while preserving keys.
951
+ if max_chars is None:
952
+ return _drop_empty_string_fields(data)
953
+ if max_chars <= 0:
954
+ return {}
955
+
956
+ trimmed = _drop_empty_string_fields(dict(data))
957
+ if _json_char_size(trimmed) <= max_chars:
958
+ return trimmed
959
+
960
+ trim_keys = SPAN_TRIM_KEYS
961
+ kept_keys = [key for key in trim_keys if key in trimmed]
962
+ if not kept_keys:
963
+ return trimmed
964
+
965
+ base = dict(trimmed)
966
+ for key in kept_keys:
967
+ base[key] = ""
968
+ base_size = _json_char_size(base)
969
+
970
+ while base_size > max_chars and kept_keys:
971
+ # Drop lowest-priority keys only if the empty base cannot fit.
972
+ drop_key = kept_keys.pop()
973
+ base.pop(drop_key, None)
974
+ trimmed.pop(drop_key, None)
975
+ base_size = _json_char_size(base)
976
+
977
+ if base_size > max_chars:
978
+ return _drop_empty_string_fields(base)
979
+
980
+ values = {
981
+ key: _stringify_span_value(trimmed[key])
982
+ for key in kept_keys
983
+ if trimmed.get(key) not in ("", None)
984
+ }
985
+ for key, value in list(values.items()):
986
+ if value == "":
987
+ values.pop(key, None)
988
+ trimmed[key] = ""
989
+ kept_keys = [key for key in kept_keys if key in values or key in trimmed]
990
+
991
+ if not kept_keys:
992
+ return _drop_empty_string_fields(base)
993
+
994
+ base_size = _json_char_size(base)
995
+ available = max_chars - base_size
996
+ if available <= 0:
997
+ return _drop_empty_string_fields(base)
998
+
999
+ ordered_keys = [key for key in trim_keys if key in values]
1000
+ min_budget = 1
1001
+ budgets = {key: 0 for key in values}
1002
+ if available >= len(values):
1003
+ for key in values:
1004
+ budgets[key] = min_budget
1005
+ remaining = available - len(values)
1006
+ else:
1007
+ for key in ordered_keys[:available]:
1008
+ budgets[key] = min_budget
1009
+ remaining = 0
1010
+
1011
+ if "arguments" in values and remaining > 0:
1012
+ # Keep arguments intact when they already fit within the budget.
1013
+ needed = len(values["arguments"]) - budgets["arguments"]
1014
+ if needed > 0:
1015
+ grant = min(needed, remaining)
1016
+ budgets["arguments"] += grant
1017
+ remaining -= grant
1018
+
1019
+ if remaining > 0:
1020
+ weights = {key: max(len(values[key]) - budgets[key], 0) for key in values}
1021
+ weight_total = sum(weights.values())
1022
+ if weight_total > 0:
1023
+ for key, weight in weights.items():
1024
+ if weight == 0:
1025
+ continue
1026
+ budgets[key] += int(remaining * (weight / weight_total))
1027
+ for key in list(budgets.keys()):
1028
+ budgets[key] = min(budgets[key], len(values[key]))
1029
+ allocated = sum(budgets.values())
1030
+ leftover = available - allocated
1031
+ if leftover > 0:
1032
+ ordered = sorted(values.keys(), key=lambda k: weights.get(k, 0), reverse=True)
1033
+ idx = 0
1034
+ while leftover > 0:
1035
+ expandable = [key for key in ordered if budgets[key] < len(values[key])]
1036
+ if not expandable:
1037
+ break
1038
+ key = expandable[idx % len(expandable)]
1039
+ budgets[key] += 1
1040
+ leftover -= 1
1041
+ idx += 1
1042
+
1043
+ for key in kept_keys:
1044
+ if key in values:
1045
+ trimmed[key] = _truncate_span_string(values[key], budgets.get(key, 0))
1046
+ else:
1047
+ trimmed[key] = ""
1048
+
1049
+ size = _json_char_size(trimmed)
1050
+ while size > max_chars and kept_keys:
1051
+ key = max(kept_keys, key=lambda k: len(str(trimmed.get(k, ""))))
1052
+ current = str(trimmed.get(key, ""))
1053
+ if len(current) > 0:
1054
+ trimmed[key] = _truncate_span_string(values.get(key, ""), len(current) - 1)
1055
+ else:
1056
+ kept_keys.remove(key)
1057
+ size = _json_char_size(trimmed)
1058
+
1059
+ if _json_char_size(trimmed) <= max_chars:
1060
+ return _drop_empty_string_fields(trimmed)
1061
+ return _drop_empty_string_fields(base)
1062
+
1063
+
1064
+ def _merge_span_data(
1065
+ current: dict[str, Any],
1066
+ updates: dict[str, Any],
1067
+ max_chars: int | None,
1068
+ ) -> dict[str, Any]:
1069
+ merged = {**current, **updates}
1070
+ return _enforce_span_data_budget(merged, max_chars)
1071
+
1072
+
1073
+ def _apply_span_updates(
1074
+ span: Any,
1075
+ updates: dict[str, Any],
1076
+ max_chars: int | None,
1077
+ ) -> None:
1078
+ # Update span data in place to keep references stable for tracing processors.
1079
+ current = span.span_data.data
1080
+ trimmed = _merge_span_data(current, updates, max_chars)
1081
+ current.clear()
1082
+ current.update(trimmed)
1083
+
1084
+
1085
+ def _update_command_span(
1086
+ span: Any, item: CommandExecutionItem, span_data_max_chars: int | None
1087
+ ) -> None:
1088
+ updates: dict[str, Any] = {
1089
+ "command": item.command,
1090
+ "status": item.status,
1091
+ "exit_code": item.exit_code,
1092
+ }
1093
+ output = item.aggregated_output
1094
+ if output not in (None, ""):
1095
+ updates["output"] = _truncate_span_value(output, span_data_max_chars)
1096
+ _apply_span_updates(
1097
+ span,
1098
+ updates,
1099
+ span_data_max_chars,
1100
+ )
1101
+
1102
+
1103
+ def _update_mcp_tool_span(
1104
+ span: Any, item: McpToolCallItem, span_data_max_chars: int | None
1105
+ ) -> None:
1106
+ _apply_span_updates(
1107
+ span,
1108
+ {
1109
+ "server": item.server,
1110
+ "tool": item.tool,
1111
+ "status": item.status,
1112
+ "arguments": _truncate_span_value(_maybe_as_dict(item.arguments), span_data_max_chars),
1113
+ "result": _truncate_span_value(_maybe_as_dict(item.result), span_data_max_chars),
1114
+ "error": _truncate_span_value(_maybe_as_dict(item.error), span_data_max_chars),
1115
+ },
1116
+ span_data_max_chars,
1117
+ )
1118
+
1119
+
1120
+ def _update_reasoning_span(span: Any, item: ReasoningItem, span_data_max_chars: int | None) -> None:
1121
+ _apply_span_updates(
1122
+ span,
1123
+ {"text": _truncate_span_value(item.text, span_data_max_chars)},
1124
+ span_data_max_chars,
1125
+ )
1126
+
1127
+
1128
+ def _build_default_response(args: CodexToolCallArguments) -> str:
1129
+ input_summary = "with inputs." if args.get("inputs") else "with no inputs."
1130
+ return f"Codex task completed {input_summary}"
1131
+
1132
+
1133
+ def _is_command_execution_item(item: ThreadItem) -> TypeGuard[CommandExecutionItem]:
1134
+ return isinstance(item, CommandExecutionItem)
1135
+
1136
+
1137
+ def _is_mcp_tool_call_item(item: ThreadItem) -> TypeGuard[McpToolCallItem]:
1138
+ return isinstance(item, McpToolCallItem)
1139
+
1140
+
1141
+ def _is_reasoning_item(item: ThreadItem) -> TypeGuard[ReasoningItem]:
1142
+ return isinstance(item, ReasoningItem)