coder-eval 0.8.2__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 (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,2055 @@
1
+ """Codex agent implementation using the official OpenAI Codex SDK."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import time
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ from coder_eval.agent import Agent, AgentState
16
+ from coder_eval.agents._logging import PrefixedAdapter, log_raw_sdk_event
17
+ from coder_eval.agents.registry import AgentRegistry
18
+ from coder_eval.agents.watchdog import ThreadedWatchdog
19
+ from coder_eval.config import settings
20
+ from coder_eval.errors import (
21
+ AgentCrashError,
22
+ TurnTimeoutError,
23
+ truncate_crash_message,
24
+ )
25
+ from coder_eval.models import (
26
+ AgentKind,
27
+ ApiRoute,
28
+ AssistantMessage,
29
+ CodexAgentConfig,
30
+ CommandTelemetry,
31
+ ContentBlock,
32
+ DirectRoute,
33
+ TokenUsage,
34
+ TranscriptMessage,
35
+ TurnRecord,
36
+ )
37
+ from coder_eval.pricing import calculate_cost
38
+ from coder_eval.streaming.callbacks import CompositeStreamCallback, StreamCallback
39
+ from coder_eval.streaming.collector import EventCollector
40
+ from coder_eval.streaming.events import (
41
+ AgentEndEvent,
42
+ AgentEndStatus,
43
+ AgentStartEvent,
44
+ TextChunkEvent,
45
+ ToolEndEvent,
46
+ ToolEndStatus,
47
+ ToolStartEvent,
48
+ TurnEndEvent,
49
+ TurnEndStatus,
50
+ TurnStartEvent,
51
+ )
52
+ from coder_eval.utils import expand_env_vars
53
+
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ # Tool name mapping: Claude Code SDK names → Codex SDK names
58
+ _CLAUDE_TO_CODEX_TOOL_MAP: dict[str, str] = {
59
+ "Bash": "shell",
60
+ "Write": "apply_patch",
61
+ "Edit": "apply_patch",
62
+ "Read": "shell",
63
+ "Grep": "shell",
64
+ "Glob": "shell",
65
+ }
66
+
67
+ # Permission mode → sandbox mode mapping
68
+ _PERMISSION_MODE_TO_SANDBOX: dict[str, str] = {
69
+ "bypassPermissions": "full-access",
70
+ "acceptEdits": "workspace-write",
71
+ "default": "workspace-write",
72
+ "plan": "read-only",
73
+ }
74
+
75
+ # Approval mode — the SAME for every permission mode (no per-mode mapping).
76
+ #
77
+ # The Codex SDK exposes exactly two approval modes:
78
+ # - auto_review → AskForApproval.on_request + a SERVER-SIDE ApprovalsReviewer.
79
+ # The reviewer (an extra app-server/gateway decision) adjudicates each
80
+ # apply_patch/shell escalation. Under gateway load it can spuriously return
81
+ # "declined" — files silently not written. Claude has no analog: its
82
+ # Write/Edit permissions are decided CLIENT-SIDE with no model/reviewer in
83
+ # the loop, so it never hits this failure mode.
84
+ # - deny_all → AskForApproval.never + NO reviewer. Despite the name, this is
85
+ # the "run autonomously, never prompt, no reviewer" mode: in-sandbox
86
+ # operations (apply_patch within cwd, shell) execute directly; only
87
+ # escalations BEYOND the sandbox are refused.
88
+ #
89
+ # An eval harness never wants a reviewer that can flake, so EVERY permission mode
90
+ # uses deny_all. The trust boundary is the sandbox (_PERMISSION_MODE_TO_SANDBOX),
91
+ # which DOES vary by mode: `plan` stays read-only, `bypassPermissions` is
92
+ # full-access (intended for isolated Docker runs).
93
+ _CODEX_APPROVAL_MODE = "deny_all"
94
+
95
+ # Provider id registered in thread config when CODEX_BASE_URL routes to a
96
+ # custom endpoint.
97
+ _CUSTOM_PROVIDER_ID = "custom"
98
+
99
+ # Codex apply_patch (Write/Edit) statuses that mean the patch did not apply.
100
+ # PatchApplyStatus enum values are inProgress/completed/failed/declined — never
101
+ # the literal "error" the code used to compare against, so a failed patch was
102
+ # silently recorded as a successful Write. We use this only to classify the
103
+ # Write TELEMETRY honestly (failed → error). We do NOT fail or retry the turn on
104
+ # it: "declined" can only come from an approval reviewer, and every permission
105
+ # mode uses deny_all (no reviewer; see _CODEX_APPROVAL_MODE), so
106
+ # in-sandbox apply_patch is applied directly and "declined" should not occur;
107
+ # "failed" (diff context mismatch) is self-healed by the model within the turn,
108
+ # and grading checks the actual files regardless.
109
+ _FILE_CHANGE_FAILURE_STATUSES = frozenset({"failed", "declined"})
110
+
111
+ # Codex thread-item types that carry transcript CONTENT or session metadata
112
+ # rather than a tool call. Everything else streamed as item/started+item/completed
113
+ # is treated as a tool call (see _run_turn_with_streaming), so new Codex tool
114
+ # kinds are captured automatically instead of being silently dropped — the old
115
+ # code hard-coded only commandExecution/fileChange.
116
+ # - reasoning / agentMessage -> assistant transcript blocks (handled inline)
117
+ # - userMessage -> prompt echo (skipped)
118
+ # - contextCompaction / entered|exitedReviewMode / hookPrompt / plan ->
119
+ # session lifecycle + planning items, not agent tool calls.
120
+ _CONTENT_ITEM_TYPES = frozenset(
121
+ {
122
+ "reasoning",
123
+ "agentMessage",
124
+ "userMessage",
125
+ "contextCompaction",
126
+ "enteredReviewMode",
127
+ "exitedReviewMode",
128
+ "hookPrompt",
129
+ "plan",
130
+ }
131
+ )
132
+
133
+ # Friendly tool-name labels for known Codex tool item types. Unknown tool types
134
+ # fall back to the raw item type (so they still surface, just un-prettied).
135
+ _TOOL_ITEM_NAMES: dict[str, str] = {
136
+ "commandExecution": "Bash",
137
+ "fileChange": "Write",
138
+ "collabAgentToolCall": "Agent",
139
+ "mcpToolCall": "Mcp",
140
+ "dynamicToolCall": "Tool",
141
+ "webSearch": "WebSearch",
142
+ "imageGeneration": "ImageGeneration",
143
+ "imageView": "ImageView",
144
+ }
145
+
146
+ # collabAgentToolCall.tool value that spawns a NEW sub-agent (vs "wait"/messaging
147
+ # operations that act on an already-spawned agent). Only spawns register a new
148
+ # child thread to recover. Lowercased to match _status_value, which normalizes
149
+ # the SDK's "spawnAgent" enum value to lowercase.
150
+ _COLLAB_SPAWN_TOOL = "spawnagent"
151
+
152
+ # Friendly tool-name labels for the raw ResponseItem function-call names found in
153
+ # a sub-agent's on-disk rollout (see _recover_subagent_tool_calls). The child
154
+ # thread persists `function_call`/`local_shell_call` ResponseItems unconditionally
155
+ # even though its `commandExecution` events are dropped under Limited persistence,
156
+ # so the rollout is the only place the sub-agent's inner tool calls survive.
157
+ _ROLLOUT_FN_NAMES: dict[str, str] = {
158
+ "exec_command": "Bash",
159
+ "shell": "Bash",
160
+ "local_shell": "Bash",
161
+ "apply_patch": "Write",
162
+ "spawn_agent": "Agent",
163
+ "wait_agent": "Agent",
164
+ }
165
+
166
+ # Rollout ResponseItem payload types that are sub-agent tool CALLS and their
167
+ # matching OUTPUT type (keyed by `call_id`). Anything not listed is skipped.
168
+ _ROLLOUT_TOOL_CALL_TYPES = frozenset({"function_call", "local_shell_call", "custom_tool_call"})
169
+ _ROLLOUT_TOOL_OUTPUT_TYPES = frozenset({"function_call_output", "custom_tool_call_output"})
170
+
171
+ # Sentinel returned by ``next(stream_iter, _STREAM_DONE)`` at stream end. We pass
172
+ # a default rather than catching StopIteration because a StopIteration raised
173
+ # inside ``asyncio.to_thread`` is converted by asyncio into a TypeError
174
+ # ("StopIteration interacts badly with generators…") that escapes ``except
175
+ # StopIteration`` — masking the real turn-failure reason on the stream-end path.
176
+ _STREAM_DONE = object()
177
+
178
+
179
+ def _ms_to_dt(ms: int | None) -> datetime:
180
+ """Convert a Codex Unix-millisecond timestamp to a datetime (now() if absent)."""
181
+ if ms is None:
182
+ return datetime.now()
183
+ return datetime.fromtimestamp(ms / 1000)
184
+
185
+
186
+ def _status_value(status: Any) -> str:
187
+ """Normalize a Codex status (enum or str) to its lowercase string value."""
188
+ value = getattr(status, "value", status)
189
+ return str(value).lower() if value is not None else ""
190
+
191
+
192
+ def _fresh_input_tokens(raw_input: int, cached: int) -> int:
193
+ """The fresh (uncached) prompt slice = tokens written to cache this call.
194
+
195
+ Single definition of the OpenAI cache-write convention, shared by the
196
+ per-message (`_flush_message`) and per-turn (`_token_usage_from_sdk`) paths so
197
+ they can't drift if the billing model ever changes.
198
+ """
199
+ return max(raw_input - cached, 0)
200
+
201
+
202
+ def _message_uncached_input(m: AssistantMessage) -> int:
203
+ """A captured generation's fresh (uncached) input.
204
+
205
+ Single definition of the per-message convention, shared by the cost and the
206
+ fold-up paths. Codex children carry 0 ``cache_creation`` (no cache-write fee),
207
+ but fold both defensively so nothing is dropped if that ever changes.
208
+ """
209
+ return m.input_tokens + m.cache_creation_tokens
210
+
211
+
212
+ # Wire protocol for the custom model provider. The pinned codex binary only
213
+ # supports the Responses API (it rejects `wire_api = "chat"` as "no longer
214
+ # supported"), so this is a fixed constant, not an operator knob.
215
+ _CODEX_WIRE_API = "responses"
216
+
217
+
218
+ def _get_item_root(notification: Any) -> Any:
219
+ """Extract the typed item root from a Codex SDK notification.
220
+
221
+ Handles nested getattr safely: notification.payload.item.root
222
+ """
223
+ payload = getattr(notification, "payload", None)
224
+ if payload is None:
225
+ return None
226
+ item = getattr(payload, "item", None)
227
+ if item is None:
228
+ return None
229
+ return getattr(item, "root", None)
230
+
231
+
232
+ class _CodexTurnState:
233
+ """Per-turn mutable scratch state for one ``CodexAgent.communicate`` call.
234
+
235
+ Holds the stream-pump locals and the assistant-transcript reconstruction
236
+ buffers, with one method per notification kind (``on_*``) plus ``dispatch``
237
+ (returns True on ``turn/completed`` to break the pump), ``_record_block`` /
238
+ ``_flush_message`` (the per-generation message cutter) and ``finalize`` (the
239
+ terminal ``AgentEndEvent`` + partial-record build). A plain module-private
240
+ class (NOT Pydantic, NOT exported) with a back-reference to the agent for its
241
+ existing helpers (``_tool_name`` / ``_telemetry_for_item`` / … ).
242
+
243
+ ``commands`` and ``messages`` are the SAME list objects ``communicate`` owns,
244
+ held by identity (no copy) so a mid-turn crash keeps the partial transcript.
245
+ The finalize inputs (``sdk_token_usage`` / ``result_turn`` / ``result_text``)
246
+ are COMMITTED by ``communicate`` only after the pump returns cleanly — they
247
+ default to None/None/"" so a crashed turn finalizes from the captured messages
248
+ (the live pump scratch ``turn_result`` / ``latest_token_usage`` /
249
+ ``agent_message_chunks`` is intentionally NOT what finalize reads).
250
+ """
251
+
252
+ def __init__(
253
+ self,
254
+ agent: "CodexAgent",
255
+ *,
256
+ emit: CompositeStreamCallback,
257
+ task_id: str,
258
+ turn_id: str,
259
+ collector: EventCollector,
260
+ commands: list[CommandTelemetry],
261
+ messages: list[TranscriptMessage],
262
+ user_input: str,
263
+ iteration: int,
264
+ turn_start_time: float,
265
+ ) -> None:
266
+ self._agent = agent
267
+ self.emit = emit
268
+ self.task_id = task_id
269
+ self.turn_id = turn_id
270
+ self.collector = collector
271
+ self.commands = commands
272
+ self.messages = messages
273
+ self.user_input = user_input
274
+ self.iteration = iteration
275
+ self.turn_start_time = turn_start_time
276
+ self.timeout_hit = False
277
+ self.finalized = False
278
+
279
+ # Live pump scratch (set during streaming).
280
+ self.turn_result: Any = None
281
+ self.latest_token_usage: Any = None
282
+ self.agent_message_chunks: list[str] = []
283
+ # Sequence per executable item, assigned at item/started and reused at
284
+ # item/completed via this id->seq map.
285
+ self.next_sequence = 0
286
+ self.seq_by_id: dict[str, int] = {}
287
+ # Spawned sub-agent child thread id -> spawning Agent call tool_use_id.
288
+ self.collab_spawn_by_thread: dict[str, str] = {}
289
+ # (child_thread_id, spawning Agent tool_use_id, spawned model) per spawn.
290
+ self.spawned_children: list[tuple[str, str, str | None]] = []
291
+ # child thread id -> returned message (fallback when the rollout is absent).
292
+ self.collab_results: dict[str, str] = {}
293
+
294
+ # Assistant-transcript reconstruction buffers (one AssistantMessage per gen).
295
+ self.open_blocks: list[ContentBlock] = []
296
+ self.open_start_ms: int | None = None
297
+ self.open_end_ms: int | None = None
298
+ self.start_ms_by_id: dict[str, int] = {}
299
+ self.blocks_by_id: dict[str, ContentBlock] = {}
300
+ # Tools that emitted item/started but not item/completed; whatever remains
301
+ # at turn end is an orphan, force-closed unresolved.
302
+ self.open_tools: dict[str, CommandTelemetry] = {}
303
+ # Text-less reasoning blocks, resolved at flush once reasoning tokens known.
304
+ self.reasoning_placeholders: list[ContentBlock] = []
305
+ self.gen_index = 0
306
+
307
+ # Finalize inputs, COMMITTED by communicate after a clean pump return.
308
+ # Defaults are the crash values (no terminal usage; format from messages).
309
+ self.sdk_token_usage: Any = None
310
+ self.result_turn: Any = None
311
+ self.result_text: str = ""
312
+
313
+ def _record_block(self, block: ContentBlock, item_id: str, completed_ms: int | None) -> None:
314
+ self.open_blocks.append(block)
315
+ start_ms = self.start_ms_by_id.get(item_id)
316
+ if start_ms is not None and (self.open_start_ms is None or start_ms < self.open_start_ms):
317
+ self.open_start_ms = start_ms
318
+ if completed_ms is not None and (self.open_end_ms is None or completed_ms > self.open_end_ms):
319
+ self.open_end_ms = completed_ms
320
+
321
+ def _flush_message(self, last: Any) -> None:
322
+ """Cut the open buffer into AssistantMessage(s) for one generation.
323
+
324
+ ``last`` is the SDK ``TokenUsageBreakdown`` for the generation that
325
+ produced these blocks (or None for a safety flush with no usage). Emits
326
+ ONE sub-message per block kind (thinking vs tool/text), all sharing this
327
+ generation's ``message_id``; the first carries the gen's input/cache, the
328
+ rest carry 0 so per-message_id sums don't double-count.
329
+ """
330
+ if not self.open_blocks:
331
+ self.reasoning_placeholders = []
332
+ return
333
+ # Per-generation tokens from the matching tokenUsage `last` delta.
334
+ cached = (getattr(last, "cached_input_tokens", 0) or 0) if last else 0
335
+ raw_input = (getattr(last, "input_tokens", 0) or 0) if last else 0
336
+ total_output = (getattr(last, "output_tokens", 0) or 0) if last else 0
337
+ # The fresh (uncached) prompt slice is plain input — OpenAI bills no
338
+ # separate cache-write fee, so Codex carries 0 cache_creation.
339
+ gen_input = _fresh_input_tokens(raw_input, cached)
340
+ gen_cache_write = 0
341
+ reasoning_tok = (getattr(last, "reasoning_output_tokens", 0) or 0) if last else 0
342
+ # Resolve text-less reasoning blocks: show a policy placeholder when
343
+ # reasoning was billed, else drop the block.
344
+ if self.reasoning_placeholders:
345
+ if reasoning_tok > 0:
346
+ for blk in self.reasoning_placeholders:
347
+ blk.thinking = "_Reasoning hidden by OpenAI policy_"
348
+ else:
349
+ for blk in self.reasoning_placeholders:
350
+ if blk in self.open_blocks:
351
+ self.open_blocks.remove(blk)
352
+ self.reasoning_placeholders = []
353
+ if not self.open_blocks:
354
+ self.open_start_ms = self.open_end_ms = None
355
+ return
356
+
357
+ thinking_blocks = [b for b in self.open_blocks if b.block_type == "thinking"]
358
+ action_blocks = [b for b in self.open_blocks if b.block_type != "thinking"]
359
+ started = _ms_to_dt(self.open_start_ms)
360
+ completed = _ms_to_dt(self.open_end_ms if self.open_end_ms is not None else self.open_start_ms)
361
+ gen_ms = max((completed - started).total_seconds() * 1000.0, 0.0)
362
+ message_id = f"{self.turn_id}-msg-{self.gen_index}"
363
+
364
+ # Output split: reasoning portion to the thinking row, the remainder to
365
+ # the action row. With only one kind present, that kind gets all output.
366
+ think_out = reasoning_tok if action_blocks else total_output
367
+ action_out = max(total_output - reasoning_tok, 0) if thinking_blocks else total_output
368
+
369
+ # Sub-message specs in generation order (thinking first). The FIRST carries
370
+ # the gen's input/cache + gen-time; the rest carry 0 (no double-count).
371
+ specs: list[tuple[list[ContentBlock], int, int]] = []
372
+ if thinking_blocks:
373
+ specs.append((thinking_blocks, think_out, reasoning_tok))
374
+ if action_blocks:
375
+ specs.append((action_blocks, action_out, 0))
376
+
377
+ for idx, (blocks, out_tok, reas_tok) in enumerate(specs):
378
+ for i, blk in enumerate(blocks):
379
+ blk.sequence = i
380
+ first = idx == 0
381
+ self.messages.append(
382
+ AssistantMessage(
383
+ started_at=started,
384
+ completed_at=completed,
385
+ generation_duration_ms=gen_ms if first else 0.0,
386
+ content_blocks=blocks,
387
+ tool_use_ids=[b.tool_use_id for b in blocks if b.block_type == "tool_use" and b.tool_use_id],
388
+ input_tokens=gen_input if first else 0,
389
+ output_tokens=out_tok,
390
+ cache_creation_tokens=gen_cache_write if first else 0,
391
+ cache_read_tokens=cached if first else 0,
392
+ reasoning_tokens=reas_tok,
393
+ model=self._agent._effective_model(),
394
+ message_id=message_id,
395
+ )
396
+ )
397
+ self.gen_index += 1
398
+ self.open_blocks = []
399
+ self.open_start_ms = None
400
+ self.open_end_ms = None
401
+
402
+ def dispatch(self, notification: Any) -> bool:
403
+ """Route a notification to its handler. Returns True on ``turn/completed``
404
+ (a valid TurnCompletedNotification) so the pump loop breaks."""
405
+ root = _get_item_root(notification)
406
+ method = notification.method
407
+ log_raw_sdk_event(
408
+ self._agent._log,
409
+ repr_target=notification,
410
+ attr_target=root,
411
+ method=method,
412
+ root_type=getattr(root, "type", None),
413
+ )
414
+ if method == "item/started":
415
+ self.on_item_started(notification)
416
+ elif method == "item/completed":
417
+ self.on_item_completed(notification)
418
+ elif method == "item/agentMessage/delta":
419
+ self.on_agent_message_delta(notification)
420
+ elif method == "thread/tokenUsage/updated":
421
+ self.on_token_usage_updated(notification)
422
+ elif method == "turn/completed":
423
+ return self.on_turn_completed(notification)
424
+ return False
425
+
426
+ def on_item_started(self, notification: Any) -> None:
427
+ """Emit ToolStartEvent + record the tool_use block for every tool-like item."""
428
+ root = _get_item_root(notification)
429
+ if root is None:
430
+ return
431
+ # Record the start time for every item kind so flushed messages get real timing.
432
+ item_id = getattr(root, "id", None)
433
+ started_at_ms = getattr(notification.payload, "started_at_ms", None)
434
+ if item_id is not None and started_at_ms is not None:
435
+ self.start_ms_by_id[item_id] = started_at_ms
436
+ root_type = getattr(root, "type", None)
437
+ # Any item that isn't transcript content is a tool call (generic capture).
438
+ if root_type is not None and root_type not in _CONTENT_ITEM_TYPES:
439
+ tool_id = item_id or f"{root_type}_{self.next_sequence}"
440
+ self.seq_by_id[tool_id] = self.next_sequence
441
+ start_tel = CommandTelemetry(
442
+ tool_name=self._agent._tool_name(root_type),
443
+ tool_id=tool_id,
444
+ timestamp=datetime.now(),
445
+ parameters=self._agent._tool_parameters(root, root_type),
446
+ sequence_number=self.next_sequence,
447
+ )
448
+ self.open_tools[tool_id] = start_tel
449
+ self.emit.on_event(ToolStartEvent(task_id=self.task_id, turn_id=self.turn_id, tool=start_tel))
450
+ self.next_sequence += 1
451
+ # Record the tool_use block now; is_error patched at item/completed,
452
+ # even after the message is flushed (held by reference in blocks_by_id).
453
+ block = ContentBlock(block_type="tool_use", sequence=0, tool_use_id=tool_id)
454
+ self.blocks_by_id[tool_id] = block
455
+ self._record_block(block, tool_id, None)
456
+
457
+ def on_item_completed(self, notification: Any) -> None:
458
+ """Emit ToolEndEvent + capture telemetry / sub-agents / transcript blocks."""
459
+ root = _get_item_root(notification)
460
+ if root is None:
461
+ return
462
+ completed_ms = getattr(notification.payload, "completed_at_ms", None)
463
+ root_type = getattr(root, "type", None)
464
+ if root_type is not None and root_type not in _CONTENT_ITEM_TYPES:
465
+ tool_id = getattr(root, "id", None) or f"{root_type}_{self.next_sequence}"
466
+ seq = self.seq_by_id.get(tool_id, self.next_sequence)
467
+ # This tool is now resolved — drop it from the orphan set.
468
+ self.open_tools.pop(tool_id, None)
469
+
470
+ telemetry, is_error = self._agent._telemetry_for_item(root, root_type, tool_id, seq)
471
+ if telemetry:
472
+ self.commands.append(telemetry)
473
+ self.emit.on_event(
474
+ ToolEndEvent(
475
+ task_id=self.task_id,
476
+ turn_id=self.turn_id,
477
+ tool=telemetry
478
+ or CommandTelemetry(
479
+ tool_name=self._agent._tool_name(root_type),
480
+ tool_id=tool_id,
481
+ timestamp=datetime.now(),
482
+ sequence_number=seq,
483
+ ),
484
+ status=ToolEndStatus.ERROR if is_error else ToolEndStatus.OK,
485
+ )
486
+ )
487
+ # Patch the block recorded at item/started (held by reference, so this
488
+ # lands even post-flush) + extend the still-open message's end time.
489
+ if tool_id in self.blocks_by_id:
490
+ self.blocks_by_id[tool_id].is_error = is_error
491
+ if (
492
+ self.open_blocks
493
+ and completed_ms is not None
494
+ and (self.open_end_ms is None or completed_ms > self.open_end_ms)
495
+ ):
496
+ self.open_end_ms = completed_ms
497
+
498
+ # Codex's native multi-agent calls land here as collabAgentToolCall items.
499
+ if root_type == "collabAgentToolCall":
500
+ self._agent._handle_collab_completion(
501
+ root,
502
+ tool_id,
503
+ self.collab_spawn_by_thread,
504
+ self.spawned_children,
505
+ self.collab_results,
506
+ )
507
+
508
+ elif root_type == "reasoning":
509
+ # OpenAI never returns raw CoT, so a text-less item becomes a
510
+ # placeholder block, resolved with its token count at flush.
511
+ reasoning_id = getattr(root, "id", f"reasoning_{self.next_sequence}")
512
+ parts = getattr(root, "content", None) or getattr(root, "summary", None) or []
513
+ text = "\n".join(p for p in parts if p)
514
+ block = ContentBlock(block_type="thinking", sequence=0, thinking=text or None)
515
+ self._record_block(block, reasoning_id, completed_ms)
516
+ if not text:
517
+ self.reasoning_placeholders.append(block)
518
+
519
+ elif root_type == "agentMessage":
520
+ # Full assistant text — append a text block. The message is cut at the
521
+ # following tokenUsage event (the generation boundary), not here.
522
+ message_item_id = getattr(root, "id", f"msg_{self.next_sequence}")
523
+ text = getattr(root, "text", "") or ""
524
+ if text:
525
+ self._record_block(
526
+ ContentBlock(block_type="text", sequence=0, text=text),
527
+ message_item_id,
528
+ completed_ms,
529
+ )
530
+
531
+ def on_agent_message_delta(self, notification: Any) -> None:
532
+ """Emit TextChunkEvent for streaming assistant text."""
533
+ if notification.payload:
534
+ delta = getattr(notification.payload, "delta", None)
535
+ if delta:
536
+ self.agent_message_chunks.append(delta)
537
+ self.emit.on_event(TextChunkEvent(task_id=self.task_id, turn_id=self.turn_id, text=delta))
538
+
539
+ def on_token_usage_updated(self, notification: Any) -> None:
540
+ """One per generation → cut a message. Carries `total` (cumulative turn
541
+ figure) and `last` (this generation's delta)."""
542
+ if notification.payload:
543
+ self.latest_token_usage = getattr(notification.payload, "token_usage", None)
544
+ self._flush_message(getattr(self.latest_token_usage, "last", None))
545
+
546
+ def on_turn_completed(self, notification: Any) -> bool:
547
+ """Capture the final Turn. Returns True (break the pump) iff the payload is
548
+ a valid TurnCompletedNotification."""
549
+ from openai_codex.generated.v2_all import TurnCompletedNotification
550
+
551
+ if isinstance(notification.payload, TurnCompletedNotification):
552
+ self.turn_result = notification.payload.turn
553
+ return True
554
+ return False
555
+
556
+ def close_open_tools(self) -> None:
557
+ """Force-close any tool that started but never completed (an orphan) as
558
+ ``unresolved``, so its transcript block keeps a real tool name + count."""
559
+ for start_tel in sorted(self.open_tools.values(), key=lambda t: getattr(t, "sequence_number", 0)):
560
+ start_tel.result_status = "unknown"
561
+ self.emit.on_event(
562
+ ToolEndEvent(
563
+ task_id=self.task_id,
564
+ turn_id=self.turn_id,
565
+ tool=start_tel,
566
+ status=ToolEndStatus.UNRESOLVED,
567
+ )
568
+ )
569
+ self.open_tools.clear()
570
+
571
+ def finalize(self, status: AgentEndStatus, *, crashed: bool = False, crash_reason: str | None = None) -> None:
572
+ """Emit the terminal TurnEnd + AgentEnd and, on a crash, build the partial
573
+ TurnRecord. Idempotent. Reads the COMMITTED finalize inputs (None/"" on a
574
+ crash) so a crashed turn under-reports nothing it didn't actually commit."""
575
+ if self.finalized:
576
+ return
577
+ self.finalized = True
578
+
579
+ # Prefer the SDK total; on crash/timeout it stays None, so fall back to the
580
+ # per-generation tokens already captured on the messages.
581
+ token_usage = self._agent._token_usage_from_sdk(self.sdk_token_usage) or self._agent._token_usage_from_messages(
582
+ self.messages
583
+ )
584
+ # Codex bills sub-agents on separate threads, so fold the recovered child
585
+ # generations into the turn total — matching Claude's bubbled-up totals.
586
+ token_usage = self._agent._fold_subagent_tokens(token_usage, self.messages)
587
+
588
+ self.emit.on_event(
589
+ TurnEndEvent(
590
+ task_id=self.task_id,
591
+ turn_id=self.turn_id,
592
+ status=TurnEndStatus(status.value),
593
+ tokens=token_usage,
594
+ )
595
+ )
596
+
597
+ model_used = getattr(self.result_turn, "model", None) or self._agent.config.model
598
+ usage = token_usage or TokenUsage()
599
+ # Real assistant text arrives as agentMessage deltas; fall back to the raw
600
+ # Turn dump only when nothing streamed.
601
+ agent_output = self.result_text or (
602
+ self._agent._format_turn_result(self.result_turn) if self.result_turn is not None else ""
603
+ )
604
+
605
+ self.emit.on_event(
606
+ AgentEndEvent(
607
+ task_id=self.task_id,
608
+ status=status,
609
+ usage=usage,
610
+ iteration=self.iteration,
611
+ user_input=self.user_input,
612
+ agent_output=agent_output,
613
+ model_used=model_used,
614
+ assistant_turn_count=1,
615
+ messages=self.messages,
616
+ num_turns=1,
617
+ crashed=crashed,
618
+ crash_reason=crash_reason,
619
+ duration_seconds=time.monotonic() - self.turn_start_time,
620
+ )
621
+ )
622
+
623
+ if crashed:
624
+ self._agent._capture_partial_turn(self.collector)
625
+
626
+
627
+ @AgentRegistry.register(AgentKind.CODEX, CodexAgentConfig)
628
+ class CodexAgent(Agent[CodexAgentConfig]):
629
+ """Implementation of the Agent interface for OpenAI Codex using the Codex SDK."""
630
+
631
+ def __init__(
632
+ self,
633
+ config: CodexAgentConfig,
634
+ route: ApiRoute | None = None,
635
+ *,
636
+ instance_name: str = "codex",
637
+ ):
638
+ """Initialize the Codex agent.
639
+
640
+ Args:
641
+ config: Agent configuration
642
+ route: API routing configuration (unused for Codex, kept for interface compatibility)
643
+ instance_name: Short label used to prefix this instance's log records
644
+ """
645
+ self.config = config
646
+ self.route = route or DirectRoute()
647
+ self.codex_client: Any = None
648
+ self.thread: Any = None
649
+ self.working_directory: Path | None = None
650
+ self._env_path_prepend: list[str] = []
651
+ # _state / _iteration / _iteration_was_incremented / pending_turn lifecycle
652
+ # bookkeeping lives on the Agent base class (shared defaults + helpers).
653
+ self._log = PrefixedAdapter(logger, {"prefix": instance_name})
654
+ # Live handle to the in-flight turn, set by _run_turn_with_streaming and
655
+ # cleared in its finally. kill()/kill_sync() use it to interrupt a stuck
656
+ # turn — the watchdog's task.cancel() alone can't preempt a blocking SDK
657
+ # call (it lands only at an await point, which we now create via to_thread).
658
+ self._active_turn_handle: Any = None
659
+
660
+ async def start(
661
+ self,
662
+ working_directory: str,
663
+ *,
664
+ env_path_prepend: list[str] | None = None,
665
+ plugin_tools_dir: str | None = None,
666
+ ) -> None:
667
+ """Initialize and start the Codex agent.
668
+
669
+ Args:
670
+ working_directory: Path to the working directory
671
+ env_path_prepend: Absolute directories to prepend to PATH for the
672
+ Codex app-server (typically the resolved
673
+ ``SandboxConfig.mock_path_dirs``), so mock CLIs shadow the
674
+ real ones — same semantics as the Claude agent.
675
+ plugin_tools_dir: Optional plugin tools directory (for skills setup)
676
+ """
677
+ self.working_directory = Path(working_directory)
678
+ self._env_path_prepend = list(env_path_prepend or [])
679
+ self._state = AgentState.WORKING
680
+
681
+ try:
682
+ from openai_codex import Codex, CodexConfig
683
+
684
+ # Build CodexConfig with environment variables for custom API configuration
685
+ env_override = self._build_codex_env()
686
+ config = CodexConfig(env=env_override) if env_override else None
687
+
688
+ # Initialize the Codex client (context manager compatible). Close any
689
+ # prior client first: start() is driven through execute_with_retry, so
690
+ # a retried start would otherwise orphan the previous app-server
691
+ # subprocess + reader threads (reaped only at final cleanup).
692
+ self._close_client()
693
+ self.codex_client = Codex(config=config)
694
+ self._log.debug("Codex client initialized")
695
+
696
+ # Authenticate with the API key when one is configured. Without this
697
+ # the app-server falls back to an existing ChatGPT login, so headless
698
+ # API-key runs (CI) would otherwise fail to authenticate.
699
+ api_key = os.getenv("CODEX_API_KEY")
700
+ if api_key:
701
+ try:
702
+ await self._run_async(self.codex_client.login_api_key, api_key)
703
+ except Exception as exc:
704
+ self._log.warning(
705
+ "CodexAgent: login_api_key failed — agent will fall back to env-based auth: %s", exc
706
+ )
707
+
708
+ # Set up skills from plugin_tools_dir or plugins config
709
+ self._setup_skills(plugin_tools_dir)
710
+
711
+ # Log permission and tool configuration
712
+ self._log_config_enforcement()
713
+
714
+ except ImportError as e:
715
+ raise RuntimeError("Codex SDK not installed. Install with: pip install 'coder-eval[codex]'") from e
716
+ except Exception as e:
717
+ raise RuntimeError(f"Failed to initialize Codex client: {e}") from e
718
+
719
+ async def communicate(
720
+ self,
721
+ user_input: str,
722
+ *,
723
+ stream_callback: StreamCallback | None = None,
724
+ timeout: float | None = None,
725
+ max_turns: int | None = None,
726
+ ) -> TurnRecord:
727
+ """Send a message to Codex and receive its response.
728
+
729
+ Args:
730
+ user_input: The message/prompt to send
731
+ stream_callback: Optional callback for real-time event streaming
732
+ timeout: Hard wall-clock deadline in seconds
733
+ max_turns: Hard cap on inner-loop turns (unused for Codex single-turn)
734
+
735
+ Returns:
736
+ TurnRecord containing the complete interaction
737
+
738
+ Raises:
739
+ RuntimeError: If agent is not started
740
+ TurnTimeoutError: Timeout elapsed
741
+ AgentCrashError: SDK/CLI failed mid-turn
742
+ """
743
+ if not self.working_directory or not self.codex_client:
744
+ raise RuntimeError("Agent not started. Call start() first.")
745
+
746
+ assert self.config.type is not None, "CodexAgent requires AgentConfig.type to be set before communicate()"
747
+
748
+ # Reset the pending slot + bump the iteration counter (shared lifecycle).
749
+ self._begin_turn()
750
+
751
+ turn_start_time = time.monotonic()
752
+
753
+ # Event emission: the agent is the SOLE emitter; events fan out to an
754
+ # internal EventCollector (which assembles the TurnRecord — the single,
755
+ # agent-agnostic capture path) and the caller's stream_callback.
756
+ task_id = str(self.config.type) # str() so a plugin subclass with a non-enum kind also works
757
+ collector = EventCollector()
758
+ emit = CompositeStreamCallback([c for c in (collector, stream_callback) if c is not None])
759
+
760
+ # Codex has no per-API-call boundary: one thread.turn() == one turn_id.
761
+ turn_id = f"codex-{self._iteration}"
762
+ # All per-turn scratch lives on the state so the stream-pump branches are
763
+ # methods; the same commands/messages lists flow through the pump and
764
+ # finalize. timeout_hit is set by the watchdog callback (atomic bool).
765
+ state = _CodexTurnState(
766
+ self,
767
+ emit=emit,
768
+ task_id=task_id,
769
+ turn_id=turn_id,
770
+ collector=collector,
771
+ commands=[],
772
+ messages=[],
773
+ user_input=user_input,
774
+ iteration=self._iteration,
775
+ turn_start_time=turn_start_time,
776
+ )
777
+
778
+ try:
779
+ emit.on_event(
780
+ AgentStartEvent(
781
+ task_id=task_id,
782
+ prompt=user_input,
783
+ iteration=self._iteration,
784
+ model=self._effective_model(),
785
+ )
786
+ )
787
+
788
+ if self.thread is None:
789
+ thread_kwargs = self._build_thread_options()
790
+ # Add working directory
791
+ if self.working_directory:
792
+ thread_kwargs["cwd"] = str(self.working_directory)
793
+ self.thread = await self._run_async(self.codex_client.thread_start, **thread_kwargs)
794
+
795
+ def _on_turn_timeout() -> None:
796
+ state.timeout_hit = True
797
+
798
+ with ThreadedWatchdog(
799
+ timeout_seconds=timeout,
800
+ on_timeout=_on_turn_timeout,
801
+ asyncio_task_to_cancel=asyncio.current_task(),
802
+ label=f"Turn timeout ({timeout:g}s)" if timeout else "turn_timeout",
803
+ ):
804
+ self._log.debug("Starting Codex turn...")
805
+ emit.on_event(TurnStartEvent(task_id=task_id, turn_id=turn_id, model=self._effective_model()))
806
+
807
+ try:
808
+ # Commit the pump's results onto the state only on a CLEAN
809
+ # return; a crash skips this, so finalize reads the crash
810
+ # defaults (None/"") and falls back to the captured messages.
811
+ state.result_turn, state.sdk_token_usage, state.result_text = await self._run_turn_with_streaming(
812
+ state
813
+ )
814
+ except asyncio.CancelledError:
815
+ if state.timeout_hit:
816
+ self._finalize_and_raise_timeout(state.finalize, timeout or 0)
817
+ raise
818
+ except Exception as e:
819
+ if state.timeout_hit:
820
+ self._finalize_and_raise_timeout(state.finalize, timeout or 0, cause=e)
821
+ self._finalize_and_raise_crash(
822
+ state.finalize, truncate_crash_message(f"Codex turn failed: {e!s}"), cause=e
823
+ )
824
+
825
+ if state.timeout_hit:
826
+ # Watchdog fired but the pump finished before the cancel landed.
827
+ # Route through the shared kernel so this path sets _state=ERROR
828
+ # like every other timeout/crash path (it previously did not — a
829
+ # latent inconsistency now fixed).
830
+ assert timeout is not None
831
+ self._finalize_and_raise_timeout(state.finalize, timeout)
832
+ except (AgentCrashError, TurnTimeoutError):
833
+ # Already funneled through finalize by the inner handlers.
834
+ raise
835
+ except asyncio.CancelledError:
836
+ # Non-timeout cancel (external cancellation, or a cancel during
837
+ # thread_start before the watchdog block). The timeout path already
838
+ # finalized above; otherwise close the AgentStart so the event tree
839
+ # stays balanced and the pending-turn contract holds. finalize is
840
+ # idempotent, so the timeout case is a no-op here.
841
+ if not state.finalized:
842
+ self._state = AgentState.ERROR
843
+ state.finalize(AgentEndStatus.CRASHED, crashed=True, crash_reason="turn cancelled")
844
+ raise
845
+ except Exception as e:
846
+ # Catches failures OUTSIDE the inner turn block — notably thread_start
847
+ # and _format_turn_result. Without this, such errors escape as a bare
848
+ # exception: the orchestrator never drains pending_turn and _iteration
849
+ # stays incremented, violating the pending-turn contract.
850
+ self._finalize_and_raise_crash(state.finalize, truncate_crash_message(f"Codex turn failed: {e!s}"), cause=e)
851
+
852
+ self._state = AgentState.WORKING
853
+ self._end_turn_ok()
854
+
855
+ # The TurnRecord is the EventCollector's reduction of the emitted events.
856
+ state.finalize(AgentEndStatus.COMPLETED, crashed=False, crash_reason=None)
857
+ return collector.build_turn_record()
858
+
859
+ async def stop(self) -> None:
860
+ """Stop the agent and tear down the Codex SDK session.
861
+
862
+ ``Codex(config=...)`` eagerly spawns an app-server subprocess plus reader
863
+ threads; ``close()`` reaps them. Skipping it leaks a subprocess + threads
864
+ per task across a batch run, so close before nulling the reference.
865
+ """
866
+ self._close_client()
867
+ self.thread = None
868
+ self._active_turn_handle = None
869
+ self._mark_stopped()
870
+
871
+ async def kill(self) -> None:
872
+ """Force-terminate the agent: interrupt any in-flight turn, then tear down."""
873
+ self._interrupt_active_turn()
874
+ await self.stop()
875
+
876
+ def kill_sync(self) -> None:
877
+ """Synchronous abort for the watchdog thread (cannot await coroutines).
878
+
879
+ Best-effort: interrupt the in-flight turn so the blocked stream iteration
880
+ unblocks, then close the client. Safe to call at any time and idempotent.
881
+ """
882
+ self._interrupt_active_turn()
883
+ self._close_client()
884
+
885
+ def _interrupt_active_turn(self) -> None:
886
+ """Interrupt the in-flight Codex turn, if any (best-effort, idempotent)."""
887
+ handle = self._active_turn_handle
888
+ if handle is None:
889
+ return
890
+ with contextlib.suppress(Exception):
891
+ handle.interrupt()
892
+
893
+ def _close_client(self) -> None:
894
+ """Close the Codex SDK client (best-effort), reaping its subprocess/threads."""
895
+ client = self.codex_client
896
+ self.codex_client = None
897
+ if client is None:
898
+ return
899
+ with contextlib.suppress(Exception):
900
+ client.close()
901
+
902
+ def get_environment_info(self) -> dict[str, Any]:
903
+ """Record the resolved Codex routing so runs are auditable/comparable.
904
+
905
+ Only emits when a custom endpoint is configured (CODEX_BASE_URL). On a
906
+ custom endpoint the model is an operator-chosen alias (a deployment name
907
+ on Azure), so two operators' ``gpt-5-codex`` deployments are otherwise
908
+ indistinguishable in run artifacts. The host (not the full URL) is
909
+ recorded to avoid leaking any embedded credentials; the API key is never
910
+ recorded.
911
+ """
912
+ base_url = self._resolve_base_url()
913
+ if not base_url:
914
+ return {}
915
+ return {
916
+ "codex_base_url_host": urlparse(base_url).hostname or "",
917
+ "codex_wire_api": _CODEX_WIRE_API,
918
+ "codex_api_version": self._resolve_api_version() or "",
919
+ "codex_model_is_deployment": True,
920
+ }
921
+
922
+ def _setup_skills(self, plugin_tools_dir: str | None) -> None:
923
+ """Set up .agents/skills directory from plugins or plugin_tools_dir.
924
+
925
+ Codex auto-discovers skills in .agents/skills/ directories scanned from
926
+ the working directory up through parent directories to repo root.
927
+
928
+ Skills are collected from two sources:
929
+ 1. config.plugins - task-defined plugins with type='local' and path pointing to skills
930
+ 2. plugin_tools_dir parameter - runtime plugin directory
931
+
932
+ Supports SKILL.md files following the Agent Skills open standard.
933
+ Creates .agents/skills/ directory and symlinks/copies skill directories.
934
+ """
935
+ if not self.working_directory:
936
+ return
937
+
938
+ skills_sources: list[Path] = []
939
+
940
+ # Collect skills directories from config.plugins
941
+ if self.config.plugins:
942
+ for plugin in self.config.plugins:
943
+ if isinstance(plugin, dict) and plugin.get("type") == "local":
944
+ path_str = plugin.get("path")
945
+ if path_str:
946
+ # Expand environment variables in path
947
+ expanded_path = expand_env_vars(path_str)
948
+ plugin_path = Path(expanded_path)
949
+ if plugin_path.exists() and plugin_path.is_dir():
950
+ skills_sources.append(plugin_path)
951
+ self._log.debug(f"Found skills from plugin: {plugin_path}")
952
+ else:
953
+ # Loud: an unresolved env var (e.g. unset
954
+ # $SKILLS_REPO_PATH) or missing dir silently drops
955
+ # the plugin's skills, so the agent runs blind.
956
+ hint = "env var likely unset" if "$" in expanded_path else "path does not exist"
957
+ self._log.warning(
958
+ f"Plugin skills path did not resolve: {path_str!r} "
959
+ + f"→ {expanded_path!r} ({hint}); no skills linked from it"
960
+ )
961
+
962
+ # Also check plugin_tools_dir parameter
963
+ if plugin_tools_dir:
964
+ plugin_path = Path(plugin_tools_dir)
965
+ if plugin_path.exists() and plugin_path.is_dir():
966
+ skills_sources.append(plugin_path)
967
+ self._log.debug(f"Found skills from plugin_tools_dir: {plugin_path}")
968
+
969
+ if not skills_sources:
970
+ return
971
+
972
+ # Create .agents/skills directory (Codex auto-discovery location)
973
+ agents_skills_dir = self.working_directory / ".agents" / "skills"
974
+ try:
975
+ agents_skills_dir.mkdir(parents=True, exist_ok=True)
976
+
977
+ # Symlink or copy skills from all sources. A source may either
978
+ # contain skill dirs directly (<source>/<skill>/SKILL.md) or be a
979
+ # Claude plugin-marketplace root whose skills live one level deeper
980
+ # (<source>/skills/<skill>/SKILL.md). Scan both layouts.
981
+ for skills_source in skills_sources:
982
+ scan_dirs = [skills_source]
983
+ nested = skills_source / "skills"
984
+ if nested.is_dir():
985
+ scan_dirs.append(nested)
986
+
987
+ for scan_dir in scan_dirs:
988
+ for skill_dir in scan_dir.iterdir():
989
+ if not (skill_dir.is_dir() and (skill_dir / "SKILL.md").exists()):
990
+ continue
991
+ target = agents_skills_dir / skill_dir.name
992
+ if target.exists():
993
+ # Skip if already exists (first source wins)
994
+ continue
995
+
996
+ try:
997
+ # Try to create a symlink for efficiency
998
+ target.symlink_to(skill_dir)
999
+ self._log.debug(f"Linked skill: {skill_dir.name}")
1000
+ except (OSError, NotImplementedError):
1001
+ # Fall back to copying if symlink fails (Windows compatibility)
1002
+ shutil.copytree(skill_dir, target, dirs_exist_ok=True)
1003
+ self._log.debug(f"Copied skill: {skill_dir.name}")
1004
+
1005
+ linked = list(agents_skills_dir.iterdir())
1006
+ if linked:
1007
+ self._log.debug(f"Linked {len(linked)} skill(s) into {agents_skills_dir}")
1008
+ else:
1009
+ # Sources existed but no SKILL.md was found under them or their
1010
+ # skills/ subdir — codex will run without any skill context.
1011
+ self._log.warning(
1012
+ f"0 skills linked into {agents_skills_dir} despite "
1013
+ + f"{len(skills_sources)} plugin source(s): "
1014
+ + f"{[str(s) for s in skills_sources]}; "
1015
+ + "check the plugin path points at a skills repo root"
1016
+ )
1017
+
1018
+ except Exception as e:
1019
+ self._log.warning(f"Failed to set up skills: {e}")
1020
+
1021
+ @staticmethod
1022
+ def _resolve_base_url() -> str | None:
1023
+ """Custom Codex endpoint base URL from CODEX_BASE_URL, or None."""
1024
+ return os.getenv("CODEX_BASE_URL") or None
1025
+
1026
+ @staticmethod
1027
+ def _resolve_api_version() -> str | None:
1028
+ """Azure OpenAI ``api-version`` from CODEX_API_VERSION, or None.
1029
+
1030
+ Azure's Responses endpoint requires an ``api-version`` query parameter on
1031
+ every request; when set it is injected as the custom provider's
1032
+ ``query_params``. Plain OpenAI / gateway endpoints leave this unset.
1033
+ """
1034
+ return os.getenv("CODEX_API_VERSION") or None
1035
+
1036
+ def _effective_model(self) -> str | None:
1037
+ """Resolve the model: task/CLI ``agent.model`` wins, else CODEX_MODEL.
1038
+
1039
+ Mirrors the Claude agent's ``config_model or route_model`` precedence
1040
+ (where the route fallback is BEDROCK_MODEL); here the fallback is the
1041
+ settings-backed CODEX_MODEL.
1042
+ """
1043
+ return self.config.model or settings.codex_model
1044
+
1045
+ def _build_codex_env(self) -> dict[str, str] | None:
1046
+ """Build the environment passed to the Codex app-server.
1047
+
1048
+ Carries the API key (``CODEX_API_KEY``, read by the codex binary when a
1049
+ model provider's ``env_key`` points at it) and, when the sandbox
1050
+ resolved ``mock_path_dirs``, a PATH with those directories prepended so
1051
+ mock CLIs shadow the real ones for the agent's shell commands. The base
1052
+ URL is NOT an env var the binary honors — it is applied through the
1053
+ model provider config in ``_build_thread_options`` instead.
1054
+
1055
+ The SDK merges this dict over ``os.environ`` for the app-server process
1056
+ (and normalizes the PATH key case-insensitively), so a full PATH value
1057
+ here safely replaces the inherited one.
1058
+ """
1059
+ env: dict[str, str] = {}
1060
+ api_key = os.getenv("CODEX_API_KEY")
1061
+ if api_key:
1062
+ env["CODEX_API_KEY"] = api_key
1063
+ self._log.debug("CODEX_API_KEY configured")
1064
+ if self._env_path_prepend:
1065
+ path_key = next((k for k in os.environ if k.upper() == "PATH"), "PATH")
1066
+ env[path_key] = os.pathsep.join([*self._env_path_prepend, os.environ.get(path_key, "")])
1067
+ self._log.debug(f"PATH prepend: {os.pathsep.join(self._env_path_prepend)}")
1068
+ return env if env else None
1069
+
1070
+ def _build_thread_options(self) -> dict[str, Any]:
1071
+ """Build thread_start options from agent config.
1072
+
1073
+ Returns a dict with sandbox, approval_mode, and config parameters
1074
+ for thread_start() based on permission_mode, allowed_tools, and disallowed_tools.
1075
+ """
1076
+ from openai_codex.api import ApprovalMode, Sandbox # pyright: ignore[reportPrivateImportUsage]
1077
+
1078
+ options: dict[str, Any] = {}
1079
+
1080
+ # Pin the model when one is resolved; otherwise Codex picks its default
1081
+ # and two runs can silently differ.
1082
+ effective_model = self._effective_model()
1083
+ if effective_model:
1084
+ options["model"] = effective_model
1085
+ self._log.debug(f"Codex model pinned to {effective_model}")
1086
+
1087
+ # Map permission_mode to sandbox and approval_mode
1088
+ permission_mode = self.config.permission_mode.value
1089
+ sandbox_mode_str = _PERMISSION_MODE_TO_SANDBOX.get(permission_mode, "workspace-write")
1090
+ approval_mode_str = _CODEX_APPROVAL_MODE
1091
+
1092
+ # Codex's read-only / workspace-write sandboxes are enforced by a Landlock
1093
+ # helper that can't initialize inside the eval's docker container — its
1094
+ # writes/execs then fail silently and the agent produces no artifacts
1095
+ # (FAILURE with score 0, no loud error). When the harness already provides
1096
+ # container isolation (docker driver, signalled by CODER_EVAL_IN_CONTAINER),
1097
+ # the in-process sandbox is both redundant and broken, so fall back to
1098
+ # full-access: the container IS the trust boundary. No-op on the host
1099
+ # (tempdir/host runs), where Landlock works and the marker is unset.
1100
+ if sandbox_mode_str != "full-access" and os.getenv("CODER_EVAL_IN_CONTAINER"):
1101
+ self._log.warning(
1102
+ f"Codex sandbox '{sandbox_mode_str}' can't initialize inside the eval container "
1103
+ + "(Landlock unavailable); using 'full-access' — the container provides isolation."
1104
+ )
1105
+ sandbox_mode_str = "full-access"
1106
+
1107
+ # Convert to Codex SDK enum values
1108
+ # Sandbox enum values use hyphens, ApprovalMode uses underscores
1109
+ options["sandbox"] = Sandbox(sandbox_mode_str)
1110
+ options["approval_mode"] = ApprovalMode(approval_mode_str)
1111
+
1112
+ # For logging, use the enum names (which use underscores)
1113
+ sandbox_name = options["sandbox"].name
1114
+ approval_name = options["approval_mode"].name
1115
+
1116
+ self._log.debug(f"Permission mode {permission_mode} → sandbox={sandbox_name}, approval_mode={approval_name}")
1117
+
1118
+ # Build config dict for tool enforcement
1119
+ tool_config: dict[str, Any] = {}
1120
+
1121
+ # Codex's workspace-write sandbox disables network by default, so any
1122
+ # tool the agent needs to install (npm/pip/etc.) fails with
1123
+ # "fetch failed". Always open network in workspace-write — every task
1124
+ # we exercise needs the UiPath CLI / package installs. Read-only and
1125
+ # danger-full-access keep their built-in network defaults.
1126
+ if sandbox_mode_str == "workspace-write":
1127
+ tool_config["sandbox_workspace_write"] = {"network_access": True}
1128
+
1129
+ if self.config.allowed_tools:
1130
+ enabled_tools = [_CLAUDE_TO_CODEX_TOOL_MAP.get(tool, tool) for tool in self.config.allowed_tools]
1131
+ tool_config["enabled_tools"] = enabled_tools
1132
+ normalized = ", ".join(enabled_tools)
1133
+ self._log.debug(f"Allowed tools (normalized): {normalized}")
1134
+
1135
+ if self.config.disallowed_tools:
1136
+ disabled_tools = [_CLAUDE_TO_CODEX_TOOL_MAP.get(tool, tool) for tool in self.config.disallowed_tools]
1137
+ tool_config["disabled_tools"] = disabled_tools
1138
+ normalized = ", ".join(disabled_tools)
1139
+ self._log.warning(
1140
+ f"disallowed_tools ({normalized}) is passed to Codex but NOT enforced by the SDK; "
1141
+ + "do not rely on it as a security boundary."
1142
+ )
1143
+
1144
+ # Route through a custom endpoint (e.g. an OpenAI-/responses-compatible
1145
+ # gateway, or Azure OpenAI) when CODEX_BASE_URL is set. The codex binary
1146
+ # has no base-URL env var — a model provider must be defined in config and
1147
+ # selected, with env_key naming the env var that holds the key
1148
+ # (CODEX_API_KEY). For Azure, CODEX_API_VERSION adds the required
1149
+ # ``api-version`` query param and CODEX_MODEL is the deployment name.
1150
+ base_url = self._resolve_base_url()
1151
+ if base_url:
1152
+ options["model_provider"] = _CUSTOM_PROVIDER_ID
1153
+ if not effective_model:
1154
+ self._log.warning(
1155
+ "CODEX_BASE_URL is set but no model resolved (agent.model / CODEX_MODEL) "
1156
+ + "— the provider may reject the request."
1157
+ )
1158
+ provider: dict[str, Any] = {
1159
+ "name": "Custom",
1160
+ "base_url": base_url,
1161
+ "env_key": "CODEX_API_KEY",
1162
+ # The pinned codex binary only supports the Responses wire API
1163
+ # (it rejects `wire_api = "chat"` as "no longer supported"), so
1164
+ # this is fixed rather than configurable.
1165
+ "wire_api": _CODEX_WIRE_API,
1166
+ }
1167
+ api_version = self._resolve_api_version()
1168
+ if api_version:
1169
+ # Azure requires ?api-version=… on every request; the codex binary
1170
+ # appends these to the provider's request URL.
1171
+ provider["query_params"] = {"api-version": api_version}
1172
+ tool_config["model_providers"] = {_CUSTOM_PROVIDER_ID: provider}
1173
+ self._log.debug(
1174
+ f"Codex routed via custom provider (host={urlparse(base_url).hostname or '(unknown)'}, "
1175
+ + f"api_version={'set' if api_version else 'unset'})"
1176
+ )
1177
+
1178
+ if tool_config:
1179
+ options["config"] = tool_config
1180
+
1181
+ return options
1182
+
1183
+ def _log_config_enforcement(self) -> None:
1184
+ """Log configuration settings."""
1185
+ if self.config.allowed_tools:
1186
+ self._log.debug(f"Allowed tools: {', '.join(self.config.allowed_tools)}")
1187
+
1188
+ if self.config.disallowed_tools:
1189
+ self._log.debug(f"Disallowed tools: {', '.join(self.config.disallowed_tools)}")
1190
+
1191
+ self._log.debug(f"Permission mode: {self.config.permission_mode.value}")
1192
+ if self.config.permission_mode.value == "bypassPermissions":
1193
+ self._log.warning(
1194
+ "[SECURITY] bypassPermissions grants unrestricted sandbox access (full-access). "
1195
+ + "Only use in fully isolated environments with untrusted code execution disabled."
1196
+ )
1197
+
1198
+ def _format_turn_result(self, turn_result: Any) -> str:
1199
+ """Format a Codex Turn to a readable string — fallback when no text streamed.
1200
+
1201
+ The Turn payload has no ``final_response`` field; assistant text arrives as
1202
+ agentMessage deltas during streaming. This only fires when streaming produced
1203
+ nothing, dumping the raw Turn for debugging.
1204
+ """
1205
+ try:
1206
+ result_dict = turn_result.model_dump() if hasattr(turn_result, "model_dump") else vars(turn_result)
1207
+ return json.dumps(result_dict, indent=2, default=str)
1208
+ except Exception as e:
1209
+ self._log.warning(f"Failed to format turn result: {e}")
1210
+ return str(turn_result)
1211
+
1212
+ async def _run_turn_with_streaming(self, state: _CodexTurnState) -> tuple[Any, Any, str]:
1213
+ """Drive ``turn.stream()`` through the per-turn state, emitting the standard
1214
+ event protocol; returns ``(turn_result, latest_token_usage, agent_text)``.
1215
+
1216
+ The enclosing ``communicate()`` owns the TurnStart/TurnEnd/AgentEnd
1217
+ boundaries; this drives the inner notification pump. ``state`` accumulates
1218
+ commands, the assistant transcript, sub-agent spawns and per-generation
1219
+ tokens — all mutated in place so a mid-turn crash keeps the partial.
1220
+ """
1221
+ # Create the turn handle (starts the turn but doesn't block) + event stream.
1222
+ turn_handle = await self._run_async(self.thread.turn, state.user_input)
1223
+ self._active_turn_handle = turn_handle
1224
+ stream = await self._run_async(turn_handle.stream)
1225
+
1226
+ stream_iter = iter(stream)
1227
+ try:
1228
+ while True:
1229
+ # Offload the blocking SDK iteration to a worker thread so the event
1230
+ # loop stays free (parallel agents don't serialize) and the
1231
+ # watchdog's task.cancel() can actually land at this await point.
1232
+ notification: Any = await asyncio.to_thread(next, stream_iter, _STREAM_DONE)
1233
+ if notification is _STREAM_DONE:
1234
+ break
1235
+ if state.dispatch(notification): # True on a valid turn/completed
1236
+ break
1237
+ finally:
1238
+ self._active_turn_handle = None
1239
+ # Close any orphan tool (item/started without item/completed), flush any
1240
+ # trailing blocks not closed by a tokenUsage event (e.g. a crash
1241
+ # mid-generation), then close the stream. Runs on every exit path.
1242
+ state.close_open_tools()
1243
+ state._flush_message(None)
1244
+ with contextlib.suppress(Exception):
1245
+ await self._run_async(stream.close)
1246
+
1247
+ if state.turn_result is None:
1248
+ raise RuntimeError("Turn did not complete (no turn/completed notification received)")
1249
+
1250
+ # Belt-and-suspenders: if streaming surfaced no assistant transcript,
1251
+ # rebuild it from the terminal Turn's ordered item list.
1252
+ if not state.messages:
1253
+ state.messages.extend(self._messages_from_items(getattr(state.turn_result, "items", None), state.turn_id))
1254
+
1255
+ # Recover each spawned sub-agent's INNER tool calls from its on-disk rollout
1256
+ # and nest them under the spawning Agent call. The parent stream never
1257
+ # carries the child's commands (Limited persistence drops them), but its
1258
+ # rollout always persists the raw function_call/local_shell_call items.
1259
+ if state.spawned_children:
1260
+ await self._recover_subagent_tool_calls(
1261
+ state.spawned_children,
1262
+ state.collab_results,
1263
+ state.messages,
1264
+ state.commands,
1265
+ state.emit,
1266
+ state.task_id,
1267
+ state.turn_id,
1268
+ )
1269
+
1270
+ return state.turn_result, state.latest_token_usage, "".join(state.agent_message_chunks)
1271
+
1272
+ def _messages_from_items(self, items: Any, turn_id: str) -> list[AssistantMessage]:
1273
+ """Rebuild the assistant transcript from a Turn's ``items`` list (fallback).
1274
+
1275
+ Same item→block mapping as the streaming path, but Turn items carry no
1276
+ per-item timestamps, so timing falls back to now()/0.0. Used only when the
1277
+ stream produced no messages.
1278
+ """
1279
+ if not items:
1280
+ return []
1281
+
1282
+ rebuilt: list[AssistantMessage] = []
1283
+ open_blocks: list[ContentBlock] = []
1284
+
1285
+ def _flush() -> None:
1286
+ nonlocal open_blocks
1287
+ if not open_blocks:
1288
+ return
1289
+ for i, blk in enumerate(open_blocks):
1290
+ blk.sequence = i
1291
+ now = datetime.now()
1292
+ rebuilt.append(
1293
+ AssistantMessage(
1294
+ started_at=now,
1295
+ completed_at=now,
1296
+ generation_duration_ms=0.0,
1297
+ content_blocks=open_blocks,
1298
+ tool_use_ids=[b.tool_use_id for b in open_blocks if b.block_type == "tool_use" and b.tool_use_id],
1299
+ model=self._effective_model(),
1300
+ message_id=f"{turn_id}-msg-{len(rebuilt)}",
1301
+ )
1302
+ )
1303
+ open_blocks = []
1304
+
1305
+ for item in items:
1306
+ root = getattr(item, "root", item)
1307
+ root_type = getattr(root, "type", None)
1308
+ item_id = getattr(root, "id", "")
1309
+ if root_type is not None and root_type not in _CONTENT_ITEM_TYPES:
1310
+ # Any tool-like item (generic, not just command/fileChange) → a
1311
+ # tool_use block, mirroring the streaming path's broad capture.
1312
+ status = _status_value(getattr(root, "status", "completed"))
1313
+ exit_code = getattr(root, "exit_code", None)
1314
+ is_error = (
1315
+ status in _FILE_CHANGE_FAILURE_STATUSES
1316
+ or (exit_code is not None and exit_code != 0)
1317
+ or bool(getattr(root, "error", None))
1318
+ or getattr(root, "success", None) is False
1319
+ )
1320
+ open_blocks.append(
1321
+ ContentBlock(block_type="tool_use", sequence=0, tool_use_id=item_id, is_error=is_error)
1322
+ )
1323
+ elif root_type == "reasoning":
1324
+ parts = getattr(root, "content", None) or getattr(root, "summary", None) or []
1325
+ text = "\n".join(p for p in parts if p)
1326
+ if text:
1327
+ open_blocks.append(ContentBlock(block_type="thinking", sequence=0, thinking=text))
1328
+ elif root_type == "agentMessage":
1329
+ text = getattr(root, "text", "") or ""
1330
+ if text:
1331
+ open_blocks.append(ContentBlock(block_type="text", sequence=0, text=text))
1332
+ _flush()
1333
+
1334
+ _flush()
1335
+ return rebuilt
1336
+
1337
+ @staticmethod
1338
+ def _tool_name(root_type: str | None) -> str:
1339
+ """Friendly tool label for a Codex item type (falls back to the raw type)."""
1340
+ return _TOOL_ITEM_NAMES.get(root_type or "", root_type or "Tool")
1341
+
1342
+ def _tool_parameters(self, root: Any, root_type: str | None) -> dict[str, Any]:
1343
+ """Best-effort ToolStartEvent parameters for any Codex tool item.
1344
+
1345
+ Per-kind for the items we understand; an empty dict for unknown tool
1346
+ kinds (still emitted, just without parameters).
1347
+ """
1348
+ if root_type == "commandExecution":
1349
+ return {"command": getattr(root, "command", "")}
1350
+ if root_type == "fileChange":
1351
+ changes = getattr(root, "changes", None) or []
1352
+ path = str(changes[0].path) if changes and hasattr(changes[0], "path") else "?"
1353
+ return {"path": path}
1354
+ if root_type == "collabAgentToolCall":
1355
+ params: dict[str, Any] = {"operation": _status_value(getattr(root, "tool", ""))}
1356
+ if model := getattr(root, "model", None):
1357
+ params["model"] = model
1358
+ if prompt := getattr(root, "prompt", None):
1359
+ params["prompt"] = prompt
1360
+ return params
1361
+ if root_type in ("mcpToolCall", "dynamicToolCall"):
1362
+ params = {"tool": getattr(root, "tool", "")}
1363
+ if server := (getattr(root, "server", None) or getattr(root, "namespace", None)):
1364
+ params["server"] = server
1365
+ args = getattr(root, "arguments", None)
1366
+ if args is not None:
1367
+ params["arguments"] = args
1368
+ return params
1369
+ if root_type == "webSearch":
1370
+ return {"query": getattr(root, "query", "")}
1371
+ if root_type == "imageView":
1372
+ return {"path": str(getattr(root, "path", ""))}
1373
+ if root_type == "imageGeneration":
1374
+ return {"prompt": getattr(root, "revised_prompt", None) or ""}
1375
+ return {}
1376
+
1377
+ def _telemetry_for_item(
1378
+ self, root: Any, root_type: str | None, tool_id: str, seq: int
1379
+ ) -> tuple[CommandTelemetry | None, bool]:
1380
+ """Build (telemetry, is_error) for a completed tool item.
1381
+
1382
+ commandExecution/fileChange keep their dedicated rich extractors; every
1383
+ other tool kind routes through the generic builder so it still produces
1384
+ countable telemetry.
1385
+ """
1386
+ if root_type == "commandExecution":
1387
+ exit_code = getattr(root, "exit_code", None)
1388
+ return self._extract_command_telemetry(root, seq), exit_code != 0
1389
+ if root_type == "fileChange":
1390
+ changes = getattr(root, "changes", []) or []
1391
+ status_str = _status_value(getattr(root, "status", "completed"))
1392
+ failed = status_str in _FILE_CHANGE_FAILURE_STATUSES
1393
+ return self._extract_file_change_telemetry(tool_id, changes, status_str, seq), failed
1394
+ return self._extract_generic_telemetry(root, root_type, tool_id, seq)
1395
+
1396
+ def _extract_generic_telemetry(
1397
+ self, root: Any, root_type: str | None, tool_id: str, seq: int
1398
+ ) -> tuple[CommandTelemetry | None, bool]:
1399
+ """CommandTelemetry for any tool item without a dedicated extractor.
1400
+
1401
+ Reads status / duration / error generically so MCP calls, web searches,
1402
+ collab-agent spawns and future tool kinds all render and count uniformly.
1403
+ """
1404
+ try:
1405
+ status_str = _status_value(getattr(root, "status", "") or "")
1406
+ err = getattr(root, "error", None)
1407
+ success = getattr(root, "success", None)
1408
+ is_error = bool(err) or success is False or status_str in _FILE_CHANGE_FAILURE_STATUSES
1409
+ duration_ms = getattr(root, "duration_ms", None)
1410
+ return (
1411
+ CommandTelemetry(
1412
+ tool_name=self._tool_name(root_type),
1413
+ tool_id=tool_id,
1414
+ timestamp=datetime.now(),
1415
+ duration_ms=float(duration_ms) if duration_ms is not None else None,
1416
+ parameters=self._tool_parameters(root, root_type),
1417
+ result_status="error" if is_error else ("success" if status_str else "unknown"),
1418
+ result_summary=self._summarize_tool_item(root, root_type),
1419
+ error_message=str(err) if err else None,
1420
+ sequence_number=seq,
1421
+ ),
1422
+ is_error,
1423
+ )
1424
+ except Exception as e:
1425
+ self._log.debug(f"Failed to extract generic tool telemetry ({root_type}): {e}")
1426
+ return None, False
1427
+
1428
+ @staticmethod
1429
+ def _summarize_tool_item(root: Any, root_type: str | None) -> str:
1430
+ """One-line human summary of a generic tool item for telemetry."""
1431
+ if root_type == "collabAgentToolCall":
1432
+ op = _status_value(getattr(root, "tool", ""))
1433
+ states = getattr(root, "agents_states", None) or {}
1434
+ messages = [s.message for s in states.values() if getattr(s, "message", None)]
1435
+ return f"collab {op}: {'; '.join(messages)[:200]}" if messages else f"collab {op}"
1436
+ if root_type == "webSearch":
1437
+ return f"query: {getattr(root, 'query', '')}"
1438
+ if root_type in ("mcpToolCall", "dynamicToolCall"):
1439
+ return f"{getattr(root, 'server', '') or getattr(root, 'namespace', '')}:{getattr(root, 'tool', '')}".strip(
1440
+ ":"
1441
+ )
1442
+ return _status_value(getattr(root, "status", "")) or (root_type or "")
1443
+
1444
+ def _handle_collab_completion(
1445
+ self,
1446
+ root: Any,
1447
+ tool_id: str,
1448
+ spawn_by_thread: dict[str, str],
1449
+ spawned_children: list[tuple[str, str, str | None]],
1450
+ collab_results: dict[str, str],
1451
+ ) -> None:
1452
+ """Process a completed Codex collab-agent call (spawn / wait / message).
1453
+
1454
+ Two responsibilities:
1455
+
1456
+ 1. SPAWN (``tool == 'spawnAgent'``): remember which Agent call owns each
1457
+ spawned child thread (so the child's result can nest under it) and the
1458
+ spawned model. Follow-up ``wait``/messaging calls reuse the same thread
1459
+ and are NOT new sub-agents.
1460
+
1461
+ Codex emits NO per-sub-agent token breakdown in the parent stream —
1462
+ every ``thread/tokenUsage/updated`` reports only the PARENT thread's
1463
+ cumulative usage. The child's real per-generation tokens are recovered
1464
+ AFTER the turn from its on-disk rollout and reconstructed as nested
1465
+ ``parent_tool_use_id`` messages (see ``_recover_subagent_tool_calls``);
1466
+ ``_finalize`` then folds those messages into the turn total.
1467
+
1468
+ 2. RESULT: any collab completion may carry the child's returned message in
1469
+ ``agents_states[thread].message``. We stash it in ``collab_results`` as
1470
+ a FALLBACK — used only if the child's rollout can't be found later. When
1471
+ the rollout IS found, ``_recover_subagent_tool_calls`` rebuilds the
1472
+ sub-agent's full generation sequence (tool calls + final text) with real
1473
+ per-generation tokens, so the returned message is just the last of those.
1474
+ """
1475
+ tool = _status_value(getattr(root, "tool", ""))
1476
+ receivers = getattr(root, "receiver_thread_ids", None) or []
1477
+ if tool == _COLLAB_SPAWN_TOOL:
1478
+ model = getattr(root, "model", None) or self._effective_model() or None
1479
+ for thread_id in receivers:
1480
+ spawn_by_thread[thread_id] = tool_id
1481
+ spawned_children.append((str(thread_id), tool_id, model))
1482
+
1483
+ states = getattr(root, "agents_states", None) or {}
1484
+ for thread_id, state in states.items():
1485
+ message = getattr(state, "message", None)
1486
+ if message and thread_id in spawn_by_thread:
1487
+ collab_results[str(thread_id)] = str(message)
1488
+
1489
+ async def _recover_subagent_tool_calls(
1490
+ self,
1491
+ spawned_children: list[tuple[str, str, str | None]],
1492
+ collab_results: dict[str, str],
1493
+ messages: list[TranscriptMessage],
1494
+ commands: list[CommandTelemetry],
1495
+ emit: StreamCallback,
1496
+ task_id: str,
1497
+ turn_id: str,
1498
+ ) -> None:
1499
+ """Recover each spawned sub-agent's INNER tool calls AND token usage.
1500
+
1501
+ Codex runs every sub-agent on its own child thread whose events never
1502
+ reach the parent stream, and that child thread persists with *Limited*
1503
+ rollout policy — which drops ``commandExecution`` events. So neither the
1504
+ live stream nor ``thread.read`` surfaces the sub-agent's shell commands,
1505
+ and ``thread/tokenUsage/updated`` only ever reports the PARENT thread, so
1506
+ per-child tokens never appear in the live stream.
1507
+
1508
+ But the child rollout ALWAYS persists the raw ``function_call`` /
1509
+ ``local_shell_call`` / ``custom_tool_call`` (+ ``*_output``) ResponseItems
1510
+ (``should_persist_response_item`` keeps them regardless of mode) AND a
1511
+ ``token_count`` event with the child thread's cumulative usage. So we
1512
+ locate the child rollout by thread id and:
1513
+
1514
+ - per inner call, emit one ``CommandTelemetry`` (so the tool row resolves)
1515
+ plus one nested ``AssistantMessage`` parented to the spawning Agent call
1516
+ (so the evalboard renders it as an expandable child), carrying that
1517
+ generation's real per-generation tokens. ``_finalize`` folds these
1518
+ ``parent_tool_use_id`` messages into the turn total so the run cost
1519
+ includes the sub-agent, exactly as Claude's total already includes its
1520
+ bubbled-up sub-agent messages.
1521
+
1522
+ Best-effort: any failure (missing file, parse error) is swallowed so a
1523
+ recovery hiccup never fails the turn.
1524
+ """
1525
+ home = self._codex_home()
1526
+ for thread_id, parent_tool_id, model in spawned_children:
1527
+ try:
1528
+ path = await self._await_rollout_file(home, thread_id)
1529
+ if path is None:
1530
+ # No rollout to mine: nest just the returned message (if any) so
1531
+ # the sub-agent's answer still shows, tokenless.
1532
+ self._log.debug("CodexAgent: no rollout found for sub-agent thread %s", thread_id)
1533
+ result = collab_results.get(thread_id)
1534
+ if result:
1535
+ messages.append(
1536
+ self._subagent_text_message(result, parent_tool_id, model, turn_id, len(messages))
1537
+ )
1538
+ continue
1539
+ gens = self._parse_rollout_generations(path)
1540
+ # Rebuild the sub-agent's generations in order — each a nested
1541
+ # message parented to the spawn, carrying its real per-generation
1542
+ # tokens (fresh slice is plain input, cache_creation=0 — Codex has
1543
+ # no separate cache-write fee) and its blocks.
1544
+ for gi, gen in enumerate(gens):
1545
+ blocks, tools = self._subagent_generation_blocks(gen, thread_id)
1546
+ if not blocks:
1547
+ continue
1548
+ for tel in tools:
1549
+ commands.append(tel)
1550
+ emit.on_event(ToolStartEvent(task_id=task_id, turn_id=turn_id, tool=tel))
1551
+ emit.on_event(
1552
+ ToolEndEvent(
1553
+ task_id=task_id,
1554
+ turn_id=turn_id,
1555
+ tool=tel,
1556
+ status=ToolEndStatus.ERROR if tel.result_status == "error" else ToolEndStatus.OK,
1557
+ )
1558
+ )
1559
+ messages.append(self._subagent_generation_message(blocks, gen, parent_tool_id, model, turn_id, gi))
1560
+ except Exception as exc:
1561
+ # Best-effort: a recovery hiccup must never fail the turn.
1562
+ self._log.debug("CodexAgent: sub-agent recovery failed for %s: %s", thread_id, exc)
1563
+
1564
+ @staticmethod
1565
+ def _codex_home() -> Path:
1566
+ """Codex data directory (rollouts live under ``<home>/sessions``)."""
1567
+ return Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
1568
+
1569
+ async def _await_rollout_file(self, home: Path, thread_id: str, *, attempts: int = 20) -> Path | None:
1570
+ """Locate a thread's rollout file, polling briefly for the async flush.
1571
+
1572
+ The child turn has finished by the time its ``wait`` returns, but the
1573
+ rollout recorder flushes on a background task, so the file can lag the
1574
+ parent ``turn/completed`` by a beat. Poll up to ~2s before giving up.
1575
+
1576
+ If ``<home>/sessions`` doesn't exist at all, the binary isn't writing
1577
+ rollouts there — bail immediately rather than polling for a flush that
1578
+ can never land (also keeps unit tests with a stub home fast).
1579
+ """
1580
+ if not (home / "sessions").is_dir():
1581
+ return None
1582
+ for _ in range(attempts):
1583
+ path = self._find_rollout_file(home, thread_id)
1584
+ if path is not None:
1585
+ return path
1586
+ await asyncio.sleep(0.1)
1587
+ return None
1588
+
1589
+ @staticmethod
1590
+ def _find_rollout_file(home: Path, thread_id: str) -> Path | None:
1591
+ """Find ``<home>/sessions/**/rollout-<ts>-<thread_id>.jsonl`` (the id is
1592
+ embedded verbatim in the filename, so a suffix glob is exact)."""
1593
+ sessions = home / "sessions"
1594
+ if not sessions.is_dir():
1595
+ return None
1596
+ matches = sorted(sessions.glob(f"**/rollout-*-{thread_id}.jsonl"))
1597
+ return matches[-1] if matches else None
1598
+
1599
+ @classmethod
1600
+ def _parse_rollout_generations(cls, path: Path) -> list[dict[str, Any]]:
1601
+ """Reconstruct a sub-agent's GENERATIONS from its rollout JSONL.
1602
+
1603
+ A ``token_count`` event marks each generation boundary (same as the
1604
+ parent stream's ``thread/tokenUsage/updated``). We walk the ordered
1605
+ ``response_item`` lines, accumulating tool calls / assistant text into the
1606
+ current generation, and close it on each ``token_count`` with that
1607
+ generation's ``last_token_usage``. Tool CALLS are paired with their OUTPUT
1608
+ (``*_output``, possibly emitted in a later generation) by ``call_id``.
1609
+
1610
+ Returns ordered generation dicts: ``{"tokens": (input, cached, output,
1611
+ reasoning) | None, "items": [ordered specs], "tools": [tool-call dicts]}``.
1612
+ Trailing items with no closing ``token_count`` flush as a final
1613
+ token-less generation. Partial/corrupt lines are skipped.
1614
+ """
1615
+ objs: list[dict[str, Any]] = []
1616
+ for raw in path.read_text(encoding="utf-8").splitlines():
1617
+ raw = raw.strip()
1618
+ if not raw:
1619
+ continue
1620
+ try:
1621
+ objs.append(json.loads(raw))
1622
+ except json.JSONDecodeError:
1623
+ continue
1624
+
1625
+ # Pass 1: tool OUTPUTs by call_id (a call's result can land a generation later).
1626
+ outputs: dict[str, tuple[str, bool]] = {}
1627
+ for obj in objs:
1628
+ if obj.get("type") == "response_item":
1629
+ p = obj.get("payload") or {}
1630
+ if p.get("type") in _ROLLOUT_TOOL_OUTPUT_TYPES:
1631
+ outputs[str(p.get("call_id") or "")] = cls._subagent_output(p)
1632
+
1633
+ # Pass 2: walk into generations.
1634
+ gens: list[dict[str, Any]] = []
1635
+ cur: list[dict[str, Any]] = []
1636
+ n_calls = 0
1637
+
1638
+ def close(tokens: tuple[int, int, int, int] | None) -> None:
1639
+ nonlocal cur
1640
+ if not cur and tokens is None:
1641
+ return
1642
+ gens.append({"tokens": tokens, "items": cur, "tools": [it["call"] for it in cur if it["kind"] == "tool"]})
1643
+ cur = []
1644
+
1645
+ for obj in objs:
1646
+ t = obj.get("type")
1647
+ p = obj.get("payload") or {}
1648
+ if t == "response_item":
1649
+ pt = p.get("type")
1650
+ if pt in _ROLLOUT_TOOL_CALL_TYPES:
1651
+ call_id = str(p.get("call_id") or p.get("id") or f"call_{n_calls}")
1652
+ n_calls += 1
1653
+ summary, is_error = outputs.get(call_id, ("", False))
1654
+ cur.append(
1655
+ {
1656
+ "kind": "tool",
1657
+ "call": {
1658
+ "call_id": call_id,
1659
+ "tool_name": cls._subagent_tool_name(p),
1660
+ "parameters": cls._subagent_parameters(p),
1661
+ "result_summary": summary,
1662
+ "is_error": is_error,
1663
+ },
1664
+ }
1665
+ )
1666
+ elif pt == "message" and p.get("role") == "assistant":
1667
+ text = cls._message_text(p.get("content"))
1668
+ if text:
1669
+ cur.append({"kind": "text", "text": text})
1670
+ elif t == "event_msg" and p.get("type") == "token_count":
1671
+ last = (p.get("info") or {}).get("last_token_usage") or {}
1672
+ close(
1673
+ (
1674
+ int(last.get("input_tokens", 0) or 0),
1675
+ int(last.get("cached_input_tokens", 0) or 0),
1676
+ int(last.get("output_tokens", 0) or 0),
1677
+ int(last.get("reasoning_output_tokens", 0) or 0),
1678
+ )
1679
+ )
1680
+ close(None)
1681
+ return gens
1682
+
1683
+ @staticmethod
1684
+ def _message_text(content: Any) -> str:
1685
+ """Join the text of a rollout ``message`` ResponseItem's content parts."""
1686
+ if isinstance(content, str):
1687
+ return content
1688
+ if not isinstance(content, list):
1689
+ return ""
1690
+ parts = [c.get("text", "") for c in content if isinstance(c, dict) and isinstance(c.get("text"), str)]
1691
+ return "".join(parts)
1692
+
1693
+ @staticmethod
1694
+ def _subagent_tool_name(payload: dict[str, Any]) -> str:
1695
+ """Friendly tool name for a rollout tool-call ResponseItem."""
1696
+ name = payload.get("name")
1697
+ if isinstance(name, str) and name:
1698
+ return _ROLLOUT_FN_NAMES.get(name, name)
1699
+ if payload.get("type") == "local_shell_call":
1700
+ return "Bash"
1701
+ return "Tool"
1702
+
1703
+ @staticmethod
1704
+ def _subagent_parameters(payload: dict[str, Any]) -> dict[str, Any]:
1705
+ """Best-effort parameters for a rollout tool-call ResponseItem.
1706
+
1707
+ ``function_call.arguments`` is a JSON string; ``local_shell_call`` carries
1708
+ an ``action``. Shell-style calls are normalized to ``{"command": ...}`` so
1709
+ the transcript renders the command line; everything else is passed through.
1710
+ """
1711
+ args = payload.get("arguments")
1712
+ if isinstance(args, str) and args:
1713
+ with contextlib.suppress(json.JSONDecodeError):
1714
+ parsed = json.loads(args)
1715
+ if isinstance(parsed, dict):
1716
+ if "cmd" in parsed and "command" not in parsed:
1717
+ parsed["command"] = parsed["cmd"]
1718
+ return parsed
1719
+ return {"arguments": args}
1720
+ action = payload.get("action")
1721
+ if isinstance(action, dict):
1722
+ command = action.get("command")
1723
+ if isinstance(command, list):
1724
+ return {"command": " ".join(str(c) for c in command)}
1725
+ if command is not None:
1726
+ return {"command": str(command)}
1727
+ return dict(action)
1728
+ if isinstance(payload.get("input"), (str, dict)):
1729
+ return {"input": payload["input"]}
1730
+ return {}
1731
+
1732
+ @staticmethod
1733
+ def _subagent_output(payload: dict[str, Any]) -> tuple[str, bool]:
1734
+ """(result_summary, is_error) for a rollout tool-output ResponseItem."""
1735
+ output = payload.get("output")
1736
+ is_error = False
1737
+ if isinstance(output, dict):
1738
+ if output.get("success") is False:
1739
+ is_error = True
1740
+ text = output.get("content") or output.get("output") or json.dumps(output)
1741
+ else:
1742
+ text = "" if output is None else str(output)
1743
+ return str(text), is_error
1744
+
1745
+ def _subagent_generation_blocks(
1746
+ self, gen: dict[str, Any], thread_id: str
1747
+ ) -> tuple[list[ContentBlock], list[CommandTelemetry]]:
1748
+ """Content blocks + tool telemetry for one recovered sub-agent generation.
1749
+
1750
+ Blocks are emitted in rollout order (tool calls, assistant text). Each
1751
+ tool call gets a ``tool_use`` block whose id (``sub:<thread>:<call_id>``)
1752
+ matches a ``CommandTelemetry`` so the evalboard tool row resolves. Inner
1753
+ tool ids are thread-prefixed to stay unique across the parent's own tools.
1754
+ """
1755
+ blocks: list[ContentBlock] = []
1756
+ telemetries: list[CommandTelemetry] = []
1757
+ for seq, item in enumerate(gen["items"]):
1758
+ if item["kind"] == "tool":
1759
+ call = item["call"]
1760
+ tool_id = f"sub:{thread_id}:{call['call_id']}"
1761
+ blocks.append(
1762
+ ContentBlock(block_type="tool_use", sequence=seq, tool_use_id=tool_id, is_error=call["is_error"])
1763
+ )
1764
+ telemetries.append(
1765
+ CommandTelemetry(
1766
+ tool_name=call["tool_name"],
1767
+ tool_id=tool_id,
1768
+ timestamp=datetime.now(),
1769
+ parameters=call["parameters"],
1770
+ result_status="error" if call["is_error"] else "success",
1771
+ result_summary=call["result_summary"],
1772
+ )
1773
+ )
1774
+ elif item["kind"] == "text":
1775
+ blocks.append(ContentBlock(block_type="text", sequence=seq, text=item["text"]))
1776
+ return blocks, telemetries
1777
+
1778
+ def _subagent_generation_message(
1779
+ self,
1780
+ blocks: list[ContentBlock],
1781
+ gen: dict[str, Any],
1782
+ parent_tool_use_id: str,
1783
+ model: str | None,
1784
+ turn_id: str,
1785
+ index: int,
1786
+ ) -> AssistantMessage:
1787
+ """A nested sub-agent generation as an AssistantMessage with real tokens.
1788
+
1789
+ Parented to the spawning Agent call so it nests in the transcript. Tokens
1790
+ come from the child's per-generation ``token_count``: the fresh slice
1791
+ (input - cached) is plain ``input`` and ``cache_creation`` is 0 — Codex
1792
+ has no separate cache-write fee.
1793
+ """
1794
+ raw_input, cached, output, reasoning = gen["tokens"] or (0, 0, 0, 0)
1795
+ fresh = _fresh_input_tokens(raw_input, cached)
1796
+ now = datetime.now()
1797
+ return AssistantMessage(
1798
+ started_at=now,
1799
+ completed_at=now,
1800
+ generation_duration_ms=0.0,
1801
+ content_blocks=blocks,
1802
+ tool_use_ids=[b.tool_use_id for b in blocks if b.block_type == "tool_use" and b.tool_use_id],
1803
+ input_tokens=fresh,
1804
+ output_tokens=output,
1805
+ cache_creation_tokens=0,
1806
+ cache_read_tokens=cached,
1807
+ reasoning_tokens=reasoning,
1808
+ model=model or self._effective_model(),
1809
+ message_id=f"{turn_id}-subagent-{index}",
1810
+ parent_tool_use_id=parent_tool_use_id,
1811
+ )
1812
+
1813
+ def _subagent_text_message(
1814
+ self, text: str, parent_tool_use_id: str, model: str | None, turn_id: str, index: int
1815
+ ) -> AssistantMessage:
1816
+ """Fallback nested message: just the sub-agent's returned text, tokenless.
1817
+
1818
+ Used only when the child's rollout can't be found, so the answer still
1819
+ shows under the Agent call even without per-generation detail. ``model``
1820
+ is the spawned sub-agent's model (not the parent's), matching the
1821
+ rollout-found path."""
1822
+ now = datetime.now()
1823
+ return AssistantMessage(
1824
+ started_at=now,
1825
+ completed_at=now,
1826
+ generation_duration_ms=0.0,
1827
+ content_blocks=[ContentBlock(block_type="text", sequence=0, text=text)],
1828
+ tool_use_ids=[],
1829
+ input_tokens=0,
1830
+ output_tokens=0,
1831
+ cache_creation_tokens=0,
1832
+ cache_read_tokens=0,
1833
+ model=model or self._effective_model(),
1834
+ message_id=f"{turn_id}-subagent-{index}",
1835
+ parent_tool_use_id=parent_tool_use_id,
1836
+ )
1837
+
1838
+ def _extract_command_telemetry(self, command_item: Any, sequence: int) -> CommandTelemetry | None:
1839
+ """Extract CommandTelemetry from a CommandExecutionThreadItem.
1840
+
1841
+ Maps Codex command execution details to the CommandTelemetry format used by Claude Code.
1842
+ """
1843
+
1844
+ try:
1845
+ # Extract basic info
1846
+ command = getattr(command_item, "command", "")
1847
+ command_id = getattr(command_item, "id", f"cmd_{sequence}")
1848
+ duration_ms = getattr(command_item, "duration_ms", None)
1849
+ exit_code = getattr(command_item, "exit_code", None)
1850
+ output = getattr(command_item, "aggregated_output", None)
1851
+
1852
+ # Determine result status from exit code
1853
+ result_status = "success" if exit_code == 0 else "error" if exit_code is not None else "unknown"
1854
+
1855
+ # Build result summary with output if available
1856
+ summary_parts = [f"Exit code: {exit_code}" if exit_code is not None else "Command executed"]
1857
+ if output and len(output.strip()) > 0:
1858
+ summary_parts.append(f"Output: {output[:100]}")
1859
+ result_summary = " | ".join(summary_parts)
1860
+
1861
+ # Try to parse output as JSON
1862
+ result_data = None
1863
+ if output:
1864
+ with contextlib.suppress(json.JSONDecodeError, TypeError):
1865
+ result_data = json.loads(output)
1866
+
1867
+ # Build parameters from command string
1868
+ parameters = {"command": command}
1869
+
1870
+ return CommandTelemetry(
1871
+ tool_name="Bash",
1872
+ tool_id=command_id,
1873
+ timestamp=datetime.now(),
1874
+ duration_ms=float(duration_ms) if duration_ms is not None else None,
1875
+ parameters=parameters,
1876
+ result_status=result_status,
1877
+ result_summary=result_summary,
1878
+ error_message=None if exit_code == 0 else output or f"Exit code {exit_code}",
1879
+ result_data=result_data,
1880
+ sequence_number=sequence,
1881
+ )
1882
+ except Exception as e:
1883
+ self._log.debug(f"Failed to extract command telemetry: {e}")
1884
+ return None
1885
+
1886
+ def _extract_file_change_telemetry(
1887
+ self, change_id: str, changes: Any, status: Any, sequence: int
1888
+ ) -> CommandTelemetry | None:
1889
+ """Build CommandTelemetry for a Codex fileChange item.
1890
+
1891
+ Recorded as a ``Write`` tool call so cross-agent criteria that count or
1892
+ match file edits (``command_executed``, ``commands_efficiency``) see the
1893
+ same signal they get from Claude's Write/Edit tool calls. A failed/declined
1894
+ apply_patch is recorded as an ``error`` (the old ``status != "error"``
1895
+ test never matched the real PatchApplyStatus values, so failed patches
1896
+ were scored as successful writes).
1897
+ """
1898
+ try:
1899
+ paths = [str(c.path) for c in changes if hasattr(c, "path")] if changes else []
1900
+ status_str = _status_value(status)
1901
+ failed = status_str in _FILE_CHANGE_FAILURE_STATUSES
1902
+ return CommandTelemetry(
1903
+ tool_name="Write",
1904
+ tool_id=change_id,
1905
+ timestamp=datetime.now(),
1906
+ duration_ms=None,
1907
+ parameters={"paths": paths},
1908
+ result_status="error" if failed else "success",
1909
+ result_summary=(
1910
+ f"{len(paths)} file(s) changed"
1911
+ if not failed
1912
+ else f"apply_patch {status_str}: {len(paths)} file(s) not written"
1913
+ ),
1914
+ error_message=f"apply_patch {status_str}" if failed else None,
1915
+ result_data=None,
1916
+ sequence_number=sequence,
1917
+ )
1918
+ except Exception as e:
1919
+ self._log.debug(f"Failed to extract file-change telemetry: {e}")
1920
+ return None
1921
+
1922
+ def _token_usage_from_sdk(self, sdk_token_usage: Any) -> TokenUsage | None:
1923
+ """Convert the Codex SDK's ThreadTokenUsage to our TokenUsage.
1924
+
1925
+ Single conversion site for both the TurnEndEvent and the AgentEndEvent,
1926
+ so cached-input tokens can't be captured in one path but dropped in the
1927
+ other. The Codex SDK does not surface cost, so we derive it from the
1928
+ pricing table keyed on the effective model (None if the model is unpriced).
1929
+
1930
+ Cache-bucket convention (Codex/OpenAI): the SDK's ``input_tokens`` is the
1931
+ FULL prompt count, *inclusive* of the cached prefix. The fresh slice
1932
+ (``input_tokens - cached``) is the uncached input (OpenAI bills no separate
1933
+ cache-write fee), so:
1934
+
1935
+ uncached_input_tokens = input - cached
1936
+ cache_creation_input_tokens = 0 (no separate cache-write bucket)
1937
+ cache_read_input_tokens = cached
1938
+ input_tokens (derived) = uncached + cache_read == the full prompt
1939
+
1940
+ Cost bills the uncached slice at the input rate — identical to the old
1941
+ "fresh as cache-write" pricing since OpenAI's cache-write rate == input
1942
+ rate, just labeled honestly.
1943
+ """
1944
+ if not sdk_token_usage:
1945
+ return None
1946
+ total = getattr(sdk_token_usage, "total", None)
1947
+ if not total:
1948
+ return None
1949
+ input_tokens = getattr(total, "input_tokens", 0) or 0
1950
+ output_tokens = getattr(total, "output_tokens", 0) or 0
1951
+ cached_input = getattr(total, "cached_input_tokens", 0) or 0
1952
+ # Fresh (uncached) prompt slice = full prompt minus the cached prefix.
1953
+ uncached = _fresh_input_tokens(input_tokens, cached_input)
1954
+ cost = calculate_cost(
1955
+ self._effective_model() or "",
1956
+ uncached_input_tokens=uncached,
1957
+ output_tokens=output_tokens,
1958
+ cache_read_tokens=cached_input,
1959
+ )
1960
+ return TokenUsage(
1961
+ uncached_input_tokens=uncached,
1962
+ output_tokens=output_tokens,
1963
+ cache_read_input_tokens=cached_input,
1964
+ total_cost_usd=cost,
1965
+ )
1966
+
1967
+ def _fold_subagent_tokens(self, parent: TokenUsage | None, messages: list[TranscriptMessage]) -> TokenUsage | None:
1968
+ """Add recovered sub-agent (child-thread) tokens to the parent turn total.
1969
+
1970
+ Codex bills children on separate threads, so the parent's streamed total
1971
+ (``_token_usage_from_sdk`` / parent-only ``_token_usage_from_messages``)
1972
+ omits them. The child generations were reconstructed as
1973
+ ``parent_tool_use_id``-tagged ``AssistantMessage``s carrying their real
1974
+ per-generation tokens (fresh slice in ``input_tokens``, ``cache_read`` for
1975
+ the cached prefix, no ``cache_creation`` — Codex has no cache-write fee).
1976
+ Sum those here as ``uncached_input``, priced per child model, to make the
1977
+ turn total all-inclusive — the same end state Claude reaches naturally,
1978
+ where sub-agent messages bubble into the parent stream. A no-op when no
1979
+ child generations were recovered.
1980
+ """
1981
+ children = [
1982
+ m
1983
+ for m in messages
1984
+ if isinstance(m, AssistantMessage)
1985
+ and m.parent_tool_use_id is not None
1986
+ and (m.input_tokens or m.output_tokens or m.cache_creation_tokens or m.cache_read_tokens)
1987
+ ]
1988
+ if not children:
1989
+ return parent
1990
+ base = parent or TokenUsage()
1991
+
1992
+ # Price each child generation on its own model (sub-agents may run a
1993
+ # different model than the parent), then sum.
1994
+ child_cost = 0.0
1995
+ for m in children:
1996
+ child_cost += (
1997
+ calculate_cost(
1998
+ m.model or self._effective_model() or "",
1999
+ uncached_input_tokens=_message_uncached_input(m),
2000
+ output_tokens=m.output_tokens,
2001
+ cache_read_tokens=m.cache_read_tokens,
2002
+ )
2003
+ or 0.0
2004
+ )
2005
+
2006
+ base_cost = base.total_cost_usd
2007
+ return TokenUsage(
2008
+ uncached_input_tokens=base.uncached_input_tokens + sum(_message_uncached_input(m) for m in children),
2009
+ output_tokens=base.output_tokens + sum(m.output_tokens for m in children),
2010
+ cache_creation_input_tokens=base.cache_creation_input_tokens,
2011
+ cache_read_input_tokens=base.cache_read_input_tokens + sum(m.cache_read_tokens for m in children),
2012
+ total_cost_usd=(base_cost or 0.0) + child_cost if (base_cost is not None or child_cost) else None,
2013
+ )
2014
+
2015
+ def _token_usage_from_messages(self, messages: list[TranscriptMessage]) -> TokenUsage | None:
2016
+ """Sum per-generation tokens off the captured assistant messages.
2017
+
2018
+ Crash/timeout fallback for ``_finalize``: when the stream raises before it
2019
+ returns the SDK ``total`` (so ``_token_usage_from_sdk`` has nothing), the
2020
+ per-generation tokens were already recorded on the flushed
2021
+ ``AssistantMessage``s (fresh slice in ``input_tokens``, cached prefix in
2022
+ ``cache_read``). Summing them recovers the tokens/cost a crashed turn
2023
+ actually spent. Returns None when nothing was captured, matching
2024
+ ``_token_usage_from_sdk``'s empty contract.
2025
+ """
2026
+ # PARENT-thread messages only — sub-agent (separate-thread) tokens are
2027
+ # added via _fold_subagent_tokens, not summed here (would double-count).
2028
+ assistant = [m for m in messages if isinstance(m, AssistantMessage) and m.parent_tool_use_id is None]
2029
+ if not assistant:
2030
+ return None
2031
+ uncached = sum(_message_uncached_input(m) for m in assistant)
2032
+ output = sum(m.output_tokens for m in assistant)
2033
+ cache_read = sum(m.cache_read_tokens for m in assistant)
2034
+ if not (uncached or output or cache_read):
2035
+ return None
2036
+ cost = calculate_cost(
2037
+ self._effective_model() or "",
2038
+ uncached_input_tokens=uncached,
2039
+ output_tokens=output,
2040
+ cache_read_tokens=cache_read,
2041
+ )
2042
+ return TokenUsage(
2043
+ uncached_input_tokens=uncached,
2044
+ output_tokens=output,
2045
+ cache_read_input_tokens=cache_read,
2046
+ total_cost_usd=cost,
2047
+ )
2048
+
2049
+ @staticmethod
2050
+ async def _run_async(func: Any, *args: Any, **kwargs: Any) -> Any:
2051
+ """Run a potentially blocking or async function."""
2052
+ result = func(*args, **kwargs)
2053
+ if asyncio.iscoroutine(result):
2054
+ return await result
2055
+ return result