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.
- agents/__init__.py +105 -4
- agents/_debug.py +15 -4
- agents/_run_impl.py +1203 -96
- agents/agent.py +164 -19
- agents/apply_diff.py +329 -0
- agents/editor.py +47 -0
- agents/exceptions.py +35 -0
- agents/extensions/experimental/__init__.py +6 -0
- agents/extensions/experimental/codex/__init__.py +92 -0
- agents/extensions/experimental/codex/codex.py +89 -0
- agents/extensions/experimental/codex/codex_options.py +35 -0
- agents/extensions/experimental/codex/codex_tool.py +1142 -0
- agents/extensions/experimental/codex/events.py +162 -0
- agents/extensions/experimental/codex/exec.py +263 -0
- agents/extensions/experimental/codex/items.py +245 -0
- agents/extensions/experimental/codex/output_schema_file.py +50 -0
- agents/extensions/experimental/codex/payloads.py +31 -0
- agents/extensions/experimental/codex/thread.py +214 -0
- agents/extensions/experimental/codex/thread_options.py +54 -0
- agents/extensions/experimental/codex/turn_options.py +36 -0
- agents/extensions/handoff_filters.py +13 -1
- agents/extensions/memory/__init__.py +120 -0
- agents/extensions/memory/advanced_sqlite_session.py +1285 -0
- agents/extensions/memory/async_sqlite_session.py +239 -0
- agents/extensions/memory/dapr_session.py +423 -0
- agents/extensions/memory/encrypt_session.py +185 -0
- agents/extensions/memory/redis_session.py +261 -0
- agents/extensions/memory/sqlalchemy_session.py +334 -0
- agents/extensions/models/litellm_model.py +449 -36
- agents/extensions/models/litellm_provider.py +3 -1
- agents/function_schema.py +47 -5
- agents/guardrail.py +16 -2
- agents/{handoffs.py → handoffs/__init__.py} +89 -47
- agents/handoffs/history.py +268 -0
- agents/items.py +237 -11
- agents/lifecycle.py +75 -14
- agents/mcp/server.py +280 -37
- agents/mcp/util.py +24 -3
- agents/memory/__init__.py +22 -2
- agents/memory/openai_conversations_session.py +91 -0
- agents/memory/openai_responses_compaction_session.py +249 -0
- agents/memory/session.py +19 -261
- agents/memory/sqlite_session.py +275 -0
- agents/memory/util.py +20 -0
- agents/model_settings.py +14 -3
- agents/models/__init__.py +13 -0
- agents/models/chatcmpl_converter.py +303 -50
- agents/models/chatcmpl_helpers.py +63 -0
- agents/models/chatcmpl_stream_handler.py +290 -68
- agents/models/default_models.py +58 -0
- agents/models/interface.py +4 -0
- agents/models/openai_chatcompletions.py +103 -49
- agents/models/openai_provider.py +10 -4
- agents/models/openai_responses.py +162 -46
- agents/realtime/__init__.py +4 -0
- agents/realtime/_util.py +14 -3
- agents/realtime/agent.py +7 -0
- agents/realtime/audio_formats.py +53 -0
- agents/realtime/config.py +78 -10
- agents/realtime/events.py +18 -0
- agents/realtime/handoffs.py +2 -2
- agents/realtime/items.py +17 -1
- agents/realtime/model.py +13 -0
- agents/realtime/model_events.py +12 -0
- agents/realtime/model_inputs.py +18 -1
- agents/realtime/openai_realtime.py +696 -150
- agents/realtime/session.py +243 -23
- agents/repl.py +7 -3
- agents/result.py +197 -38
- agents/run.py +949 -168
- agents/run_context.py +13 -2
- agents/stream_events.py +1 -0
- agents/strict_schema.py +14 -0
- agents/tool.py +413 -15
- agents/tool_context.py +22 -1
- agents/tool_guardrails.py +279 -0
- agents/tracing/__init__.py +2 -0
- agents/tracing/config.py +9 -0
- agents/tracing/create.py +4 -0
- agents/tracing/processor_interface.py +84 -11
- agents/tracing/processors.py +65 -54
- agents/tracing/provider.py +64 -7
- agents/tracing/spans.py +105 -0
- agents/tracing/traces.py +116 -16
- agents/usage.py +134 -12
- agents/util/_json.py +19 -1
- agents/util/_transforms.py +12 -2
- agents/voice/input.py +5 -4
- agents/voice/models/openai_stt.py +17 -9
- agents/voice/pipeline.py +2 -0
- agents/voice/pipeline_config.py +4 -0
- {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/METADATA +44 -19
- openai_agents-0.6.8.dist-info/RECORD +134 -0
- {openai_agents-0.2.8.dist-info → openai_agents-0.6.8.dist-info}/WHEEL +1 -1
- openai_agents-0.2.8.dist-info/RECORD +0 -103
- {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)
|