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.
- coder_eval/.gitattributes +2 -0
- coder_eval/__init__.py +3 -0
- coder_eval/agent.py +302 -0
- coder_eval/agents/__init__.py +41 -0
- coder_eval/agents/_logging.py +89 -0
- coder_eval/agents/antigravity_agent.py +811 -0
- coder_eval/agents/claude_code_agent.py +1701 -0
- coder_eval/agents/codex_agent.py +2055 -0
- coder_eval/agents/noop_agent.py +100 -0
- coder_eval/agents/registry.py +163 -0
- coder_eval/agents/watchdog.py +116 -0
- coder_eval/analysis.py +88 -0
- coder_eval/cli/__init__.py +87 -0
- coder_eval/cli/aggregate_command.py +153 -0
- coder_eval/cli/console.py +7 -0
- coder_eval/cli/evaluate_command.py +164 -0
- coder_eval/cli/plan_command.py +152 -0
- coder_eval/cli/report_command.py +121 -0
- coder_eval/cli/run_command.py +686 -0
- coder_eval/cli/run_helpers.py +137 -0
- coder_eval/cli/run_task_internal_command.py +210 -0
- coder_eval/cli/utils.py +37 -0
- coder_eval/config.py +212 -0
- coder_eval/criteria/__init__.py +163 -0
- coder_eval/criteria/_classification_aggregate.py +106 -0
- coder_eval/criteria/agent_judge.py +425 -0
- coder_eval/criteria/base.py +248 -0
- coder_eval/criteria/classification_match.py +107 -0
- coder_eval/criteria/command_executed.py +181 -0
- coder_eval/criteria/commands_efficiency.py +73 -0
- coder_eval/criteria/file_check.py +119 -0
- coder_eval/criteria/file_contains.py +85 -0
- coder_eval/criteria/file_exists.py +45 -0
- coder_eval/criteria/file_matches_regex.py +86 -0
- coder_eval/criteria/json_check.py +184 -0
- coder_eval/criteria/llm_judge.py +260 -0
- coder_eval/criteria/reference_comparison.py +130 -0
- coder_eval/criteria/run_command.py +220 -0
- coder_eval/criteria/skill_triggered.py +111 -0
- coder_eval/criteria/uipath_eval.py +223 -0
- coder_eval/errors/__init__.py +26 -0
- coder_eval/errors/agent.py +26 -0
- coder_eval/errors/budget.py +28 -0
- coder_eval/errors/categories.py +205 -0
- coder_eval/errors/categorization.py +240 -0
- coder_eval/errors/executor.py +124 -0
- coder_eval/errors/judge.py +12 -0
- coder_eval/errors/retry.py +211 -0
- coder_eval/errors/timeout.py +63 -0
- coder_eval/evaluation/__init__.py +10 -0
- coder_eval/evaluation/checker.py +260 -0
- coder_eval/evaluation/judge_anthropic.py +55 -0
- coder_eval/evaluation/judge_bedrock.py +114 -0
- coder_eval/evaluation/judge_context.py +497 -0
- coder_eval/evaluation/judge_models.py +53 -0
- coder_eval/evaluation/judge_persistence.py +291 -0
- coder_eval/evaluation/judge_usage.py +50 -0
- coder_eval/evaluation/sub_agent.py +231 -0
- coder_eval/evaluation/summaries.py +48 -0
- coder_eval/evaluation/verdict_tool.py +213 -0
- coder_eval/formatting.py +191 -0
- coder_eval/isolation/__init__.py +15 -0
- coder_eval/isolation/docker_runner.py +1253 -0
- coder_eval/logging_config.py +399 -0
- coder_eval/models/__init__.py +337 -0
- coder_eval/models/agent_config.py +373 -0
- coder_eval/models/container_paths.py +26 -0
- coder_eval/models/criteria.py +942 -0
- coder_eval/models/enums.py +121 -0
- coder_eval/models/experiment.py +314 -0
- coder_eval/models/judge.py +90 -0
- coder_eval/models/judge_defaults.py +11 -0
- coder_eval/models/limits.py +89 -0
- coder_eval/models/merge_strategy.py +132 -0
- coder_eval/models/mutations.py +95 -0
- coder_eval/models/results.py +787 -0
- coder_eval/models/routing.py +160 -0
- coder_eval/models/sandbox.py +371 -0
- coder_eval/models/tasks.py +631 -0
- coder_eval/models/telemetry.py +454 -0
- coder_eval/models/templates.py +108 -0
- coder_eval/orchestration/__init__.py +13 -0
- coder_eval/orchestration/batch.py +690 -0
- coder_eval/orchestration/config.py +111 -0
- coder_eval/orchestration/config_merge.py +431 -0
- coder_eval/orchestration/evaluation.py +94 -0
- coder_eval/orchestration/experiment.py +837 -0
- coder_eval/orchestration/overrides.py +206 -0
- coder_eval/orchestration/task_loader.py +437 -0
- coder_eval/orchestrator.py +2078 -0
- coder_eval/path_utils.py +79 -0
- coder_eval/plugins.py +81 -0
- coder_eval/pricing.py +169 -0
- coder_eval/py.typed +0 -0
- coder_eval/reports.py +1066 -0
- coder_eval/reports_experiment.py +761 -0
- coder_eval/reports_html.py +1659 -0
- coder_eval/reports_stats.py +250 -0
- coder_eval/resources/__init__.py +137 -0
- coder_eval/resources/default_experiment.yaml +85 -0
- coder_eval/resources/default_ignore_patterns.yaml +60 -0
- coder_eval/resources/tags.yaml +55 -0
- coder_eval/sandbox.py +1127 -0
- coder_eval/scoring/__init__.py +11 -0
- coder_eval/scoring/ast_similarity.py +38 -0
- coder_eval/scoring/complexity.py +115 -0
- coder_eval/scoring/quality.py +154 -0
- coder_eval/scoring/signature_similarity.py +57 -0
- coder_eval/scoring/similarity.py +103 -0
- coder_eval/scoring/token_similarity.py +44 -0
- coder_eval/simulation/__init__.py +27 -0
- coder_eval/simulation/termination.py +88 -0
- coder_eval/simulation/user_simulator.py +324 -0
- coder_eval/streaming/__init__.py +44 -0
- coder_eval/streaming/callbacks.py +57 -0
- coder_eval/streaming/collector.py +193 -0
- coder_eval/streaming/events.py +198 -0
- coder_eval/streaming/renderers.py +248 -0
- coder_eval/streaming/wire.py +115 -0
- coder_eval/telemetry.py +407 -0
- coder_eval/utils.py +517 -0
- coder_eval-0.8.2.dist-info/METADATA +242 -0
- coder_eval-0.8.2.dist-info/RECORD +127 -0
- coder_eval-0.8.2.dist-info/WHEEL +4 -0
- coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
- coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
- 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
|