coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,1701 @@
1
+ """Claude Code agent implementation using the Claude Agent SDK."""
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import time
9
+ from collections.abc import Callable, Sequence
10
+ from contextlib import suppress
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from claude_agent_sdk import (
16
+ ClaudeAgentOptions,
17
+ ClaudeSDKClient,
18
+ Message,
19
+ ProcessError,
20
+ TaskNotificationMessage,
21
+ query,
22
+ )
23
+
24
+ # Private SDK import — the public `query()` API doesn't expose the subprocess
25
+ # handle, but we need it to SIGKILL on timeout (the SDK's anyio task groups
26
+ # swallow asyncio cancellation, so cooperative cancel doesn't preempt a stuck
27
+ # CLI). If this import breaks on an SDK upgrade, the threaded watchdog loses
28
+ # its kill target and timeouts will no longer be enforced at the agent layer.
29
+ from claude_agent_sdk._internal.transport.subprocess_cli import SubprocessCLITransport
30
+
31
+ from coder_eval.agent import Agent, AgentState
32
+ from coder_eval.agents._logging import PrefixedAdapter, log_raw_sdk_event
33
+ from coder_eval.agents.registry import AgentRegistry
34
+ from coder_eval.agents.watchdog import ThreadedWatchdog
35
+ from coder_eval.errors import (
36
+ TurnTimeoutError,
37
+ format_timeout_reason,
38
+ )
39
+ from coder_eval.formatting import format_messages, format_payload
40
+ from coder_eval.models import (
41
+ AgentKind,
42
+ ApiRoute,
43
+ BedrockRoute,
44
+ ClaudeCodeAgentConfig,
45
+ CommandTelemetry,
46
+ ContentBlock,
47
+ DirectRoute,
48
+ ResultSummary,
49
+ TokenUsage,
50
+ TranscriptMessage,
51
+ TurnRecord,
52
+ to_bedrock_inference_profile,
53
+ )
54
+ from coder_eval.models import (
55
+ AssistantMessage as AssistantMessageTelemetry,
56
+ )
57
+ from coder_eval.pricing import calculate_cost
58
+ from coder_eval.streaming.callbacks import CompositeStreamCallback, StreamCallback
59
+ from coder_eval.streaming.collector import EventCollector
60
+ from coder_eval.streaming.events import (
61
+ AgentEndEvent,
62
+ AgentEndStatus,
63
+ AgentStartEvent,
64
+ TextChunkEvent,
65
+ ToolEndEvent,
66
+ ToolEndStatus,
67
+ ToolStartEvent,
68
+ TurnEndEvent,
69
+ TurnEndStatus,
70
+ TurnStartEvent,
71
+ )
72
+ from coder_eval.utils import dump_dataclass, process_plugins
73
+
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+
78
+ # Type guards for SDK message types (using duck typing for robustness)
79
+ def _is_assistant_message(message: Any) -> bool:
80
+ """Check if message is an AssistantMessage using duck typing."""
81
+ return hasattr(message, "content") and hasattr(message, "model")
82
+
83
+
84
+ def _is_tool_use_block(block: Any) -> bool:
85
+ """Check if block is a ToolUseBlock using duck typing."""
86
+ return hasattr(block, "name") and hasattr(block, "id") and hasattr(block, "input")
87
+
88
+
89
+ def _is_thinking_block(block: Any) -> bool:
90
+ """Check if block is a ThinkingBlock (extended-thinking reasoning)."""
91
+ return hasattr(block, "thinking") and not hasattr(block, "text")
92
+
93
+
94
+ def _is_text_block(block: Any) -> bool:
95
+ """Check if block is a TextBlock (visible narration)."""
96
+ return hasattr(block, "text") and not hasattr(block, "thinking")
97
+
98
+
99
+ def _distribute_output_tokens(total: int, weights: list[int]) -> list[int]:
100
+ """Split a call's output_tokens across its block-emissions by content weight.
101
+
102
+ The Anthropic API reports output_tokens per API *call*, not per content
103
+ block, but the CLI surfaces one call as several per-block emissions. To make
104
+ each emission's recorded output sensible (rather than dumping the whole
105
+ call on the first block and zeroing the rest), we apportion the call total
106
+ across emissions by a content-length proxy (thinking/text length, or tool
107
+ name + serialized args length).
108
+
109
+ Uses the largest-remainder (Hamilton) method so the returned integers sum
110
+ EXACTLY to ``total`` — per-message output stays reconcilable with the
111
+ iteration aggregate. Falls back to an even split when all weights are zero.
112
+ """
113
+ n = len(weights)
114
+ if n == 0:
115
+ return []
116
+ if total <= 0:
117
+ return [0] * n
118
+ tw = sum(weights)
119
+ if tw <= 0:
120
+ base = total // n
121
+ out = [base] * n
122
+ for i in range(total - base * n):
123
+ out[i] += 1
124
+ return out
125
+ raw = [total * w / tw for w in weights]
126
+ floors = [int(r) for r in raw]
127
+ remainder = total - sum(floors)
128
+ # Hand the leftover (from flooring) to the largest fractional parts.
129
+ order = sorted(range(n), key=lambda i: (raw[i] - floors[i], weights[i]), reverse=True)
130
+ for i in range(remainder):
131
+ floors[order[i]] += 1
132
+ return floors
133
+
134
+
135
+ def _is_user_message(message: Any) -> bool:
136
+ """Check if message is a UserMessage (which may contain tool results) using duck typing."""
137
+ return hasattr(message, "content") and hasattr(message, "tool_use_result")
138
+
139
+
140
+ def _is_tool_result_block(block: Any) -> bool:
141
+ """Check if block is a ToolResultBlock using duck typing."""
142
+ return hasattr(block, "tool_use_id") and hasattr(block, "is_error")
143
+
144
+
145
+ def _tool_result_text(content: Any) -> str:
146
+ """Best-effort text of a ToolResultBlock's content (str, or list of text blocks)."""
147
+ if isinstance(content, str):
148
+ return content
149
+ if isinstance(content, list):
150
+ return "".join(b["text"] for b in content if isinstance(b, dict) and isinstance(b.get("text"), str))
151
+ return ""
152
+
153
+
154
+ def _is_task_notification(message: Any) -> bool:
155
+ """Check if message is a TaskNotificationMessage (sub-agent terminal event).
156
+
157
+ It is a SystemMessage carrying per-sub-agent ``usage`` (a TaskUsage), keyed
158
+ by the spawning ``tool_use_id``. It also has ``session_id`` + ``usage``, so
159
+ it would otherwise be misread as the final ResultMessage — hence the
160
+ explicit guard, checked before ``_is_sdk_result_message``. Identified by the
161
+ SDK type or ``subtype`` (both reliably present on the real message); we avoid
162
+ attribute-presence sniffing so it can't misfire on test mocks. The ``subtype``
163
+ fallback also lets duck-typed mocks (not real SDK instances) be recognized.
164
+ """
165
+ return isinstance(message, TaskNotificationMessage) or getattr(message, "subtype", None) == "task_notification"
166
+
167
+
168
+ def _is_sdk_result_message(message: Any) -> bool:
169
+ """Check if message is the SDK's final ResultMessage (with usage/cost data).
170
+
171
+ Distinct from ToolResultBlock which has tool_use_id, and from
172
+ TaskNotificationMessage which also carries session_id + usage (excluded).
173
+ """
174
+ return hasattr(message, "session_id") and hasattr(message, "usage") and not _is_task_notification(message)
175
+
176
+
177
+ _JSON_START_SEARCH_LIMIT = 200
178
+
179
+
180
+ class _ClaudeTurnState:
181
+ """Per-turn mutable scratch state for one ``ClaudeCodeAgent.communicate`` call.
182
+
183
+ Holds every cross-branch local the SDK-stream pump mutates and exposes one
184
+ method per message kind (``on_*``) plus ``dispatch`` (the type-dispatch
185
+ ladder, order-preserving) and ``finalize`` (terminal ``AgentEndEvent`` +
186
+ partial-record build). A plain module-private class (NOT Pydantic, NOT
187
+ exported) — it carries a back-reference to the agent so it can reuse the
188
+ agent's existing helpers (``_finalize_commands`` / ``_build_token_usage`` /
189
+ ``_format_messages`` / …). The two raw lists are kept DISTINCT and typed
190
+ separately: ``messages`` (raw SDK ``Message`` objects, fed to
191
+ ``_update_state_from_messages`` / ``_format_messages``) and ``sdk_messages``
192
+ (telemetry ``TranscriptMessage`` objects, carried on ``AgentEndEvent``).
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ agent: "ClaudeCodeAgent",
198
+ *,
199
+ emit: CompositeStreamCallback,
200
+ collector: EventCollector,
201
+ task_id: str,
202
+ user_input: str,
203
+ iteration: int,
204
+ max_turns: int | None,
205
+ log: PrefixedAdapter,
206
+ turn_start_time: float,
207
+ deadline: float | None,
208
+ ) -> None:
209
+ self._agent = agent
210
+ self.emit = emit
211
+ self.collector = collector
212
+ self.task_id = task_id
213
+ self.user_input = user_input
214
+ self.iteration = iteration
215
+ self.max_turns = max_turns
216
+ self.log = log
217
+ self.turn_start_time = turn_start_time
218
+ self.deadline = deadline
219
+ # Set True by the in-loop deadline break OR the watchdog callback.
220
+ self.timeout_hit = False
221
+ # Resolved by _build_claude_query, set on the state before any finalize
222
+ # path. Stays None if we crash before setup (finalize reads it for cost
223
+ # backfill).
224
+ self.effective_model: str | None = None
225
+
226
+ # Two distinct lists (do NOT merge): raw SDK objects vs. telemetry.
227
+ self.messages: list[Message] = []
228
+ self.sdk_messages: list[TranscriptMessage] = []
229
+
230
+ # Two-phase command tracking (tool_id -> {telemetry, command_start_time}).
231
+ self.pending_commands: dict[str, dict[str, Any]] = {}
232
+ self.processed_results: set[str] = set()
233
+ self.sequence_number = 0
234
+
235
+ self.last_assistant_message_index: int | None = None
236
+ self.last_event_monotonic: float = turn_start_time
237
+ self.last_event_wall: datetime = datetime.now()
238
+
239
+ # SDK ResultMessage capture.
240
+ self.sdk_result_usage: dict[str, Any] | None = None
241
+ self.sdk_result_model_usage: dict[str, Any] | None = None
242
+ self.sdk_result_cost: float | None = None
243
+ self.num_turns: int | None = None
244
+ self.sdk_result_summary: ResultSummary | None = None
245
+
246
+ self.sdk_model_used: str | None = None
247
+
248
+ # Per-emission output_tokens recovery from raw stream events.
249
+ self.pending_delta_output_tokens: int | None = None
250
+ self.current_stream_message_id: str | None = None
251
+ self.emissions_by_id: dict[str, list[AssistantMessageTelemetry]] = {}
252
+ self.emission_proxies_by_id: dict[str, list[int]] = {}
253
+
254
+ # Dedup of multi-emission API calls sharing a message_id.
255
+ self.seen_message_ids: set[str] = set()
256
+ self.last_message_had_id: bool = False
257
+
258
+ self.assistant_turn_count = 0
259
+
260
+ # Turn/tool bracketing (self-describing event tree).
261
+ self.current_turn_id: str | None = None
262
+ self.tool_turn_ids: dict[str, str] = {} # tool_id -> spawning turn_id
263
+ self.emitted_tool_ends: set[str] = set() # tool_ids already closed
264
+ self.finalized = False
265
+
266
+ def turn_tokens(self, turn_id: str) -> TokenUsage | None:
267
+ """Best-effort per-turn tokens, summed over that call's block emissions."""
268
+ records = self.emissions_by_id.get(turn_id)
269
+ if not records:
270
+ return None
271
+ total = TokenUsage()
272
+ for rec in records:
273
+ total = total + TokenUsage(
274
+ uncached_input_tokens=rec.input_tokens,
275
+ output_tokens=rec.output_tokens,
276
+ cache_creation_input_tokens=rec.cache_creation_tokens,
277
+ cache_read_input_tokens=rec.cache_read_tokens,
278
+ )
279
+ return total
280
+
281
+ def dispatch(self, message: Message) -> None:
282
+ """Record the raw message and route it to its per-kind handler.
283
+
284
+ Order is load-bearing: ``_is_sdk_result_message`` must be checked before
285
+ ``_is_user_message``; the TaskNotification guard before the result guard.
286
+ """
287
+ self.messages.append(message)
288
+ msg_type = type(message).__name__
289
+ log_raw_sdk_event(self.log, repr_target=message, type=msg_type)
290
+
291
+ if _is_assistant_message(message):
292
+ self.on_assistant_message(message)
293
+ elif _is_task_notification(message):
294
+ self.on_task_notification(message)
295
+ elif _is_sdk_result_message(message):
296
+ self.on_result_message(message)
297
+ elif isinstance(getattr(message, "event", None), dict):
298
+ self.on_stream_event(message)
299
+ elif _is_user_message(message):
300
+ self.on_user_message(message)
301
+
302
+ def on_assistant_message(self, message: Message) -> None:
303
+ """Capture ToolUseBlocks + build the AssistantMessage telemetry record."""
304
+ message_arrival_monotonic = time.monotonic()
305
+ message_arrival_wall = datetime.now()
306
+ generation_started_wall = self.last_event_wall
307
+ generation_duration_ms = (message_arrival_monotonic - self.last_event_monotonic) * 1000
308
+
309
+ current_turn_index = len(self.sdk_messages)
310
+ self.assistant_turn_count += 1
311
+ model_attr = getattr(message, "model", None)
312
+ if isinstance(model_attr, str):
313
+ self.sdk_model_used = model_attr
314
+
315
+ # Inner-turn boundary: one TurnStart per new message_id (one API call).
316
+ raw_mid = getattr(message, "message_id", None)
317
+ turn_id = raw_mid if isinstance(raw_mid, str) else f"turn-{self.assistant_turn_count}"
318
+ if turn_id != self.current_turn_id:
319
+ if self.current_turn_id is not None:
320
+ self.emit.on_event(
321
+ TurnEndEvent(
322
+ task_id=self.task_id,
323
+ turn_id=self.current_turn_id,
324
+ status=TurnEndStatus.COMPLETED,
325
+ tokens=self.turn_tokens(self.current_turn_id),
326
+ )
327
+ )
328
+ self.current_turn_id = turn_id
329
+ self.emit.on_event(TurnStartEvent(task_id=self.task_id, turn_id=turn_id, model=self.sdk_model_used))
330
+
331
+ content = getattr(message, "content", None)
332
+ turn_content_blocks: list[ContentBlock] = []
333
+ turn_tool_use_ids: list[str] = []
334
+ emission_content_chars = 0
335
+
336
+ if content and isinstance(content, list):
337
+ for block in content:
338
+ block_seq = len(turn_content_blocks)
339
+
340
+ if _is_tool_use_block(block):
341
+ tool_args = block.input if isinstance(block.input, dict) else {"raw": block.input}
342
+ emission_content_chars += len(str(getattr(block, "name", "") or "")) + len(
343
+ json.dumps(tool_args, default=str)
344
+ )
345
+ command_start_time = time.monotonic()
346
+
347
+ telemetry = CommandTelemetry(
348
+ tool_name=block.name,
349
+ tool_id=block.id,
350
+ timestamp=message_arrival_wall,
351
+ generation_completed_at=message_arrival_wall,
352
+ assistant_turn_index=current_turn_index,
353
+ parameters=block.input if isinstance(block.input, dict) else {"raw": block.input},
354
+ sequence_number=self.sequence_number,
355
+ result_status=None,
356
+ duration_ms=None,
357
+ )
358
+
359
+ self.pending_commands[block.id] = {
360
+ "telemetry": telemetry,
361
+ "command_start_time": command_start_time,
362
+ }
363
+ self.sequence_number += 1
364
+
365
+ turn_content_blocks.append(
366
+ ContentBlock(block_type="tool_use", sequence=block_seq, tool_use_id=block.id)
367
+ )
368
+ turn_tool_use_ids.append(block.id)
369
+
370
+ self.tool_turn_ids[block.id] = self.current_turn_id or ""
371
+ self.emit.on_event(
372
+ ToolStartEvent(task_id=self.task_id, turn_id=self.current_turn_id or "", tool=telemetry)
373
+ )
374
+ elif _is_thinking_block(block):
375
+ thinking_text = getattr(block, "thinking", None)
376
+ if thinking_text:
377
+ emission_content_chars += len(str(thinking_text))
378
+ turn_content_blocks.append(
379
+ ContentBlock(
380
+ block_type="thinking",
381
+ sequence=block_seq,
382
+ thinking=str(thinking_text) if thinking_text else None,
383
+ signature=getattr(block, "signature", None),
384
+ )
385
+ )
386
+ elif _is_text_block(block):
387
+ text_value = str(block.text)
388
+ emission_content_chars += len(text_value)
389
+ turn_content_blocks.append(ContentBlock(block_type="text", sequence=block_seq, text=text_value))
390
+ self.emit.on_event(
391
+ TextChunkEvent(task_id=self.task_id, turn_id=self.current_turn_id or "", text=text_value)
392
+ )
393
+
394
+ msg_usage = getattr(message, "usage", None) or {}
395
+ message_id = getattr(message, "message_id", None)
396
+ parent_tool_use_id = getattr(message, "parent_tool_use_id", None)
397
+ is_duplicate_emission = isinstance(message_id, str) and message_id in self.seen_message_ids
398
+ if isinstance(message_id, str):
399
+ self.seen_message_ids.add(message_id)
400
+ self.last_message_had_id = True
401
+ else:
402
+ self.last_message_had_id = False
403
+
404
+ if is_duplicate_emission:
405
+ in_tok = out_tok = cw_tok = cr_tok = rt_tok = 0
406
+ else:
407
+ in_tok = int(msg_usage.get("input_tokens", 0) or 0)
408
+ cw_tok = int(msg_usage.get("cache_creation_input_tokens", 0) or 0)
409
+ cr_tok = int(msg_usage.get("cache_read_input_tokens", 0) or 0)
410
+ rt_tok = int(msg_usage.get("reasoning_tokens", 0) or 0)
411
+ if self.pending_delta_output_tokens is not None:
412
+ out_tok = self.pending_delta_output_tokens
413
+ else:
414
+ out_tok = int(msg_usage.get("output_tokens", 0) or 0)
415
+ self.pending_delta_output_tokens = None
416
+
417
+ assistant_telemetry = AssistantMessageTelemetry(
418
+ started_at=generation_started_wall,
419
+ completed_at=message_arrival_wall,
420
+ generation_duration_ms=max(0.0, generation_duration_ms),
421
+ content_blocks=turn_content_blocks,
422
+ tool_use_ids=turn_tool_use_ids,
423
+ input_tokens=in_tok,
424
+ output_tokens=out_tok,
425
+ cache_creation_tokens=cw_tok,
426
+ cache_read_tokens=cr_tok,
427
+ reasoning_tokens=rt_tok,
428
+ stop_reason=(
429
+ getattr(message, "stop_reason", None)
430
+ if isinstance(getattr(message, "stop_reason", None), str)
431
+ else None
432
+ ),
433
+ model=self.sdk_model_used,
434
+ message_id=message_id if isinstance(message_id, str) else None,
435
+ parent_tool_use_id=(parent_tool_use_id if isinstance(parent_tool_use_id, str) else None),
436
+ )
437
+ self.sdk_messages.append(assistant_telemetry)
438
+ if isinstance(message_id, str):
439
+ self.emissions_by_id.setdefault(message_id, []).append(assistant_telemetry)
440
+ self.emission_proxies_by_id.setdefault(message_id, []).append(emission_content_chars)
441
+ self.last_assistant_message_index = len(self.sdk_messages) - 1
442
+
443
+ self.last_event_monotonic = message_arrival_monotonic
444
+ self.last_event_wall = message_arrival_wall
445
+
446
+ def on_task_notification(self, message: Message) -> None:
447
+ """TaskNotification carries lossy per-sub-agent usage; we capture it from
448
+ the Agent tool-result instead. This guard exists only to keep
449
+ ``_is_sdk_result_message`` from misreading it (session_id + usage)."""
450
+ pass
451
+
452
+ def on_result_message(self, message: Message) -> None:
453
+ """Capture the SDK ResultMessage usage/cost/session + the id-less backfill."""
454
+ self.sdk_result_usage = getattr(message, "usage", None)
455
+ self.sdk_result_model_usage = getattr(message, "model_usage", None)
456
+ self.sdk_result_cost = getattr(message, "total_cost_usd", None)
457
+ self.num_turns = getattr(message, "num_turns", None)
458
+ self.sdk_result_summary = self._agent._summarize_result(message)
459
+ # Only advance session_id on clean turns.
460
+ new_session_id = getattr(message, "session_id", None)
461
+ if self.sdk_result_summary is not None and self.sdk_result_summary.is_error:
462
+ self.log.debug(
463
+ "is_error ResultMessage; not advancing session_id (kept %s)",
464
+ self._agent._session_id,
465
+ )
466
+ else:
467
+ if new_session_id != self._agent._session_id:
468
+ self.log.debug("session_id changed: %s -> %s", self._agent._session_id, new_session_id)
469
+ self._agent._session_id = new_session_id
470
+
471
+ # Retro-populate the last AssistantMessage from ResultMessage.usage when
472
+ # per-message capture was not in effect for THAT message (no message_id).
473
+ if self.last_assistant_message_index is not None and self.sdk_result_usage and not self.last_message_had_id:
474
+ last_msg = self.sdk_messages[self.last_assistant_message_index]
475
+ if isinstance(last_msg, AssistantMessageTelemetry):
476
+ last_msg.input_tokens = int(self.sdk_result_usage.get("input_tokens", 0) or 0)
477
+ last_msg.output_tokens = int(self.sdk_result_usage.get("output_tokens", 0) or 0)
478
+ last_msg.cache_creation_tokens = int(self.sdk_result_usage.get("cache_creation_input_tokens", 0) or 0)
479
+ last_msg.cache_read_tokens = int(self.sdk_result_usage.get("cache_read_input_tokens", 0) or 0)
480
+ last_msg.reasoning_tokens = int(self.sdk_result_usage.get("reasoning_tokens", 0) or 0)
481
+
482
+ def on_stream_event(self, message: Message) -> None:
483
+ """Recover cumulative output_tokens from raw ``message_start`` /
484
+ ``message_delta`` stream events (handles both sub-cases internally)."""
485
+ evt: dict[str, Any] = getattr(message, "event", None) or {}
486
+ evt_type = evt.get("type")
487
+ if evt_type == "message_start":
488
+ mid = (evt.get("message") or {}).get("id")
489
+ self.current_stream_message_id = mid if isinstance(mid, str) else None
490
+ elif evt_type == "message_delta":
491
+ usage = evt.get("usage") or {}
492
+ ot = usage.get("output_tokens")
493
+ if isinstance(ot, int):
494
+ records: list[AssistantMessageTelemetry] | None = None
495
+ proxies: list[int] = []
496
+ if self.current_stream_message_id is not None:
497
+ records = self.emissions_by_id.get(self.current_stream_message_id)
498
+ proxies = self.emission_proxies_by_id.get(self.current_stream_message_id, [])
499
+ if records:
500
+ shares = _distribute_output_tokens(ot, proxies)
501
+ for record, share in zip(records, shares, strict=False):
502
+ record.output_tokens = share
503
+ else:
504
+ self.pending_delta_output_tokens = ot
505
+
506
+ def on_user_message(self, message: Message) -> None:
507
+ """Process tool results (and a sub-agent's terminal generation) from a
508
+ tool-result UserMessage. The sub-agent message is appended BEFORE the
509
+ tool-result loop — its position in ``sdk_messages`` is observable."""
510
+ self.last_event_monotonic = time.monotonic()
511
+ self.last_event_wall = datetime.now()
512
+
513
+ sub_msg = self._agent._synthesize_subagent_terminal_message(message, self.sdk_model_used)
514
+ if sub_msg is not None:
515
+ self.sdk_messages.append(sub_msg)
516
+
517
+ content = getattr(message, "content", None)
518
+ if content and isinstance(content, list):
519
+ for block in content:
520
+ if _is_tool_result_block(block):
521
+ tool_name = ""
522
+ if block.tool_use_id in self.pending_commands:
523
+ tool_name = self.pending_commands[block.tool_use_id]["telemetry"].tool_name
524
+ self._agent._resolve_pending_command(
525
+ block.tool_use_id,
526
+ getattr(block, "is_error", False) or False,
527
+ block.content,
528
+ self.pending_commands,
529
+ self.processed_results,
530
+ )
531
+ is_error_flag = getattr(block, "is_error", False) or False
532
+ resolved = self.pending_commands.get(block.tool_use_id, {}).get("telemetry")
533
+ tool_for_event = resolved or CommandTelemetry(
534
+ tool_name=tool_name or "unknown",
535
+ tool_id=block.tool_use_id,
536
+ timestamp=datetime.now(),
537
+ result_status="error" if is_error_flag else "success",
538
+ result_summary=format_payload(block.content),
539
+ )
540
+ status = self._agent._tool_end_status(is_error_flag, block.content)
541
+ self.emitted_tool_ends.add(block.tool_use_id)
542
+ self.emit.on_event(
543
+ ToolEndEvent(
544
+ task_id=self.task_id,
545
+ turn_id=self.tool_turn_ids.get(block.tool_use_id, self.current_turn_id or ""),
546
+ tool=tool_for_event,
547
+ status=status,
548
+ )
549
+ )
550
+
551
+ def finalize(self, status: AgentEndStatus, *, crashed: bool = False, crash_reason: str | None = None) -> None:
552
+ """Close orphaned tools + the open turn, emit the terminal AgentEndEvent,
553
+ and on a crash build the partial TurnRecord. Idempotent."""
554
+ if self.finalized:
555
+ return
556
+ self.finalized = True
557
+
558
+ commands = self._agent._finalize_commands(self.pending_commands, self.messages)
559
+ for cmd in commands:
560
+ if cmd.tool_id in self.emitted_tool_ends:
561
+ continue
562
+ self.emitted_tool_ends.add(cmd.tool_id)
563
+ self.emit.on_event(
564
+ ToolEndEvent(
565
+ task_id=self.task_id,
566
+ turn_id=self.tool_turn_ids.get(cmd.tool_id, self.current_turn_id or ""),
567
+ tool=cmd,
568
+ status=ToolEndStatus.UNRESOLVED,
569
+ )
570
+ )
571
+
572
+ max_turns_exhausted = not crashed and (
573
+ self._agent._is_max_turns_result(self.sdk_result_summary)
574
+ or (self.max_turns is not None and self.num_turns is not None and self.num_turns > self.max_turns)
575
+ )
576
+ if max_turns_exhausted and status == AgentEndStatus.COMPLETED:
577
+ status = AgentEndStatus.MAX_TURNS_EXHAUSTED
578
+ self.log.warning("Agent exhausted max_turns (%s); turn ended without completing", self.max_turns)
579
+
580
+ if self.current_turn_id is not None:
581
+ self.emit.on_event(
582
+ TurnEndEvent(
583
+ task_id=self.task_id,
584
+ turn_id=self.current_turn_id,
585
+ status=TurnEndStatus(status.value),
586
+ tokens=self.turn_tokens(self.current_turn_id),
587
+ )
588
+ )
589
+ self.current_turn_id = None
590
+
591
+ usage = (
592
+ self._agent._build_token_usage(
593
+ self.sdk_messages,
594
+ self.sdk_result_usage,
595
+ self.sdk_result_cost,
596
+ self.sdk_result_model_usage,
597
+ self.effective_model,
598
+ )
599
+ or TokenUsage()
600
+ )
601
+
602
+ try:
603
+ agent_output = self._agent._format_messages(self.messages)
604
+ except Exception as fmt_err:
605
+ logger.warning("Failed to format messages for AgentEndEvent; using placeholder", exc_info=True)
606
+ agent_output = f"<partial record: message formatting failed: {type(fmt_err).__name__}: {fmt_err}>"
607
+
608
+ self.emit.on_event(
609
+ AgentEndEvent(
610
+ task_id=self.task_id,
611
+ status=status,
612
+ usage=usage,
613
+ iteration=self.iteration,
614
+ user_input=self.user_input,
615
+ agent_output=agent_output,
616
+ model_used=self.sdk_model_used,
617
+ assistant_turn_count=self.assistant_turn_count,
618
+ messages=list(self.sdk_messages),
619
+ num_turns=self.num_turns,
620
+ max_turns_exhausted=max_turns_exhausted,
621
+ result_summary=self.sdk_result_summary,
622
+ crashed=crashed,
623
+ crash_reason=crash_reason,
624
+ duration_seconds=time.monotonic() - self.turn_start_time,
625
+ )
626
+ )
627
+
628
+ if crashed:
629
+ self._agent._capture_partial_turn(self.collector)
630
+
631
+
632
+ @AgentRegistry.register(AgentKind.CLAUDE_CODE, ClaudeCodeAgentConfig)
633
+ class ClaudeCodeAgent(Agent[ClaudeCodeAgentConfig]):
634
+ """Implementation of the Agent interface for Claude Code using the SDK."""
635
+
636
+ def __init__(
637
+ self,
638
+ config: ClaudeCodeAgentConfig,
639
+ route: ApiRoute | None = None,
640
+ *,
641
+ instance_name: str = "coder",
642
+ extra_mcp_servers: dict[str, Any] | None = None,
643
+ ):
644
+ """Initialize the Claude Code agent.
645
+
646
+ Args:
647
+ config: Agent configuration
648
+ route: API routing configuration. If None, uses DirectRoute.
649
+ instance_name: Short label used to prefix this instance's log
650
+ records (e.g. ``"coder"`` for the coding agent,
651
+ ``"simulator"`` for the tools-disabled user-simulator agent).
652
+ Lets you tell them apart in ``task.log`` when both run in
653
+ the same process.
654
+ extra_mcp_servers: Runtime-only in-process MCP servers (e.g. the
655
+ judge ``submit_verdict`` tool) merged into ``ClaudeAgentOptions.mcp_servers``.
656
+ NOT sourced from YAML — ``mcp_servers`` is in
657
+ ``_FRAMEWORK_OWNED_SDK_FIELDS`` and explicitly denied via
658
+ ``sdk_options`` for security. The judge criterion is the only
659
+ caller today.
660
+ """
661
+ self.config = config
662
+ self.route = route or DirectRoute()
663
+ self._extra_mcp_servers = extra_mcp_servers or {}
664
+ self.client: ClaudeSDKClient | None = None
665
+ self.working_directory: Path | None = None
666
+ # _state / _iteration / _iteration_was_incremented / pending_turn lifecycle
667
+ # bookkeeping lives on the Agent base class (shared defaults + helpers).
668
+ self._sdk_options_dump: dict[str, Any] | None = None
669
+ self._session_id: str | None = None
670
+ # Transport reference held only while a communicate() call is in flight,
671
+ # so kill() can reach into the CLI subprocess when the SDK swallows
672
+ # asyncio cancellation.
673
+ self._active_transport: SubprocessCLITransport | None = None
674
+ self._env_path_prepend: list[str] = []
675
+ self._plugin_tools_dir: str | None = None
676
+ self._log = PrefixedAdapter(logger, {"prefix": instance_name})
677
+ # Deduplicate "unhandled SDK message type" warnings per agent
678
+ # instance — _format_messages runs many times per task and these
679
+ # types are stable for the lifetime of a session.
680
+ self._warned_unknown_types: set[str] = set()
681
+
682
+ async def start(
683
+ self,
684
+ working_directory: str,
685
+ *,
686
+ env_path_prepend: list[str] | None = None,
687
+ plugin_tools_dir: str | None = None,
688
+ ) -> None:
689
+ """Initialize and start the Claude Code agent.
690
+
691
+ Args:
692
+ working_directory: Path to the working directory
693
+ env_path_prepend: Absolute directories to prepend to PATH for the SDK
694
+ subprocess (typically the resolved ``SandboxConfig.mock_path_dirs``).
695
+ plugin_tools_dir: Canonical ``node_modules/@uipath`` to export as
696
+ ``PLUGIN_TOOLS_DIR``. An external env-var pin still wins.
697
+ """
698
+ self.working_directory = Path(working_directory)
699
+ self._env_path_prepend = list(env_path_prepend or [])
700
+ self._plugin_tools_dir = plugin_tools_dir
701
+ self._state = AgentState.WORKING
702
+ # Note: Client is created per-communication to avoid transport issues
703
+
704
+ @staticmethod
705
+ def _build_sdk_env(
706
+ route: ApiRoute,
707
+ path_prepend: list[str] | None = None,
708
+ plugin_tools_dir: str | None = None,
709
+ ) -> tuple[dict[str, str], str | None]:
710
+ """Build SDK environment variables and resolve effective model for the given route.
711
+
712
+ Args:
713
+ route: API routing configuration.
714
+ path_prepend: Absolute directories to prepend (in order) to PATH so their
715
+ contents shadow same-named binaries in the parent PATH. Resolved by the
716
+ sandbox manager from ``SandboxConfig.mock_path_dirs``; the agent does no
717
+ filesystem inspection of its own.
718
+ plugin_tools_dir: Fallback canonical ``node_modules/@uipath`` to export as
719
+ ``PLUGIN_TOOLS_DIR`` when the process environment doesn't already
720
+ provide one. An external ``PLUGIN_TOOLS_DIR`` always wins.
721
+
722
+ Returns:
723
+ Tuple of (env_vars_dict, model_override_or_None).
724
+ """
725
+ base_env: dict[str, str] = {}
726
+ if path := os.environ.get("PATH"):
727
+ base_env["PATH"] = path
728
+
729
+ if path_prepend:
730
+ prefix = os.pathsep.join(path_prepend)
731
+ base_env["PATH"] = f"{prefix}{os.pathsep}{base_env.get('PATH', '')}"
732
+
733
+ # Pin UiPath CLI plugin discovery for the agent SDK subprocess. External
734
+ # env wins over sandbox-derived fallback so operators can override.
735
+ if tools_dir := os.environ.get("PLUGIN_TOOLS_DIR"):
736
+ base_env["PLUGIN_TOOLS_DIR"] = tools_dir
737
+ elif plugin_tools_dir:
738
+ base_env["PLUGIN_TOOLS_DIR"] = plugin_tools_dir
739
+
740
+ match route:
741
+ case BedrockRoute() as br:
742
+ env: dict[str, str] = {
743
+ "CLAUDE_CODE_USE_BEDROCK": "1",
744
+ "AWS_BEARER_TOKEN_BEDROCK": br.bearer_token,
745
+ "AWS_REGION": br.region,
746
+ }
747
+ if br.disable_attribution_header:
748
+ # FIXME(SDK#24168): Remove when SDK no longer injects reserved header
749
+ env["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "0"
750
+ if br.model:
751
+ env["ANTHROPIC_MODEL"] = br.model
752
+ if br.small_model:
753
+ env["ANTHROPIC_SMALL_FAST_MODEL"] = br.small_model
754
+ return {**base_env, **env}, br.model
755
+
756
+ case DirectRoute():
757
+ return base_env, None
758
+
759
+ raise AssertionError(f"Unhandled route type: {type(route).__name__}")
760
+
761
+ def _resolve_effective_model(
762
+ self, config_model: str | None, env: dict[str, str], route_model: str | None
763
+ ) -> str | None:
764
+ """Resolve the effective model and sync subprocess env on Bedrock.
765
+
766
+ Precedence: config_model (task YAML / --model / -D agent.model) wins
767
+ over the route default (BEDROCK_MODEL). On a Bedrock route, a bare alias
768
+ is auto-qualified with ``anthropic.`` and the region's inference-profile
769
+ prefix (``eu.``/``us.``/``apac.``) so the same value works across regions.
770
+ On Bedrock, the resolved value is always written to ``ANTHROPIC_MODEL``
771
+ so the subprocess sees the same model as ``ClaudeAgentOptions.model``.
772
+ """
773
+ if isinstance(self.route, BedrockRoute):
774
+ if config_model is not None:
775
+ config_model = to_bedrock_inference_profile(config_model, self.route.region)
776
+ effective = config_model or route_model
777
+ if effective:
778
+ env["ANTHROPIC_MODEL"] = effective
779
+ return effective
780
+ return config_model or route_model
781
+
782
+ async def communicate(
783
+ self,
784
+ user_input: str,
785
+ *,
786
+ stream_callback: StreamCallback | None = None,
787
+ timeout: float | None = None,
788
+ max_turns: int | None = None,
789
+ ) -> TurnRecord:
790
+ """Send a message to Claude and receive its response.
791
+
792
+ Args:
793
+ user_input: The message/prompt to send
794
+ stream_callback: Optional callback for real-time event streaming
795
+ timeout: Hard wall-clock deadline in seconds. When exceeded, a
796
+ watchdog task force-kills the CLI subprocess (the SDK's anyio
797
+ task groups suppress cooperative cancellation, so a graceful
798
+ asyncio.wait_for is not sufficient).
799
+ max_turns: Hard cap on inner-loop turns for this call. None defers
800
+ to the SDK default.
801
+
802
+ Returns:
803
+ TurnRecord containing the complete interaction
804
+
805
+ Raises:
806
+ RuntimeError: If agent is not started.
807
+ TurnTimeoutError: Watchdog/wall-clock fired; carries a partial TurnRecord.
808
+ AgentCrashError: SDK/CLI failed mid-turn; carries a partial TurnRecord.
809
+ """
810
+ if not self.working_directory:
811
+ raise RuntimeError("Agent not started. Call start() first.")
812
+
813
+ # AgentConfig.type is `str | None`, but the orchestrator, SubAgentRunner,
814
+ # and UserSimulator all set it before construction. Assert the invariant so
815
+ # streaming-event sites below can safely use `str(self.config.type)`.
816
+ assert self.config.type is not None, "ClaudeCodeAgent requires AgentConfig.type to be set before communicate()"
817
+
818
+ # Reset the pending slot + bump the iteration counter (shared lifecycle).
819
+ self._begin_turn()
820
+
821
+ turn_start_time = time.monotonic()
822
+ deadline = turn_start_time + timeout if timeout is not None else None
823
+
824
+ # Event emission: the agent is the SOLE emitter. Events fan out to an
825
+ # internal EventCollector (which assembles the TurnRecord — the single,
826
+ # agent-agnostic capture path) and the caller's stream_callback.
827
+ task_id = str(self.config.type) # str() so a plugin subclass with a non-enum kind also works
828
+ collector = EventCollector()
829
+ emit = CompositeStreamCallback([c for c in (collector, stream_callback) if c is not None])
830
+
831
+ # All per-turn scratch state lives on the state object so each stream
832
+ # branch is a method. Built BEFORE the try so the except/finally can
833
+ # finalize even when setup (_build_claude_query) crashes — `timeout_hit`
834
+ # is set by both the in-loop deadline break and the watchdog callback;
835
+ # Python bool assignment is atomic under the GIL, so no lock is needed.
836
+ state = _ClaudeTurnState(
837
+ self,
838
+ emit=emit,
839
+ collector=collector,
840
+ task_id=task_id,
841
+ user_input=user_input,
842
+ iteration=self._iteration,
843
+ max_turns=max_turns,
844
+ log=self._log,
845
+ turn_start_time=turn_start_time,
846
+ deadline=deadline,
847
+ )
848
+
849
+ # stderr capture STAYS a communicate local: it is wired into the SDK
850
+ # options during setup (a construction-order hazard if it lived on the
851
+ # state, which is built first), and only the error ladder reads its lines.
852
+ stderr_lines: list[str] = []
853
+
854
+ def capture_stderr(line: str) -> None:
855
+ stderr_lines.append(line)
856
+
857
+ try:
858
+ options, transport, effective_model = self._build_claude_query(
859
+ user_input, timeout, max_turns, capture_stderr
860
+ )
861
+ # Set on the state BEFORE the AgentStart emit and any finalize path
862
+ # (finalize reads it for cost backfill); stays None if setup crashed.
863
+ state.effective_model = effective_model
864
+ if transport is not None:
865
+ self._active_transport = transport
866
+
867
+ # Agent lifecycle opens here (the agent — not the orchestrator — owns it).
868
+ emit.on_event(
869
+ AgentStartEvent(
870
+ task_id=task_id,
871
+ prompt=user_input,
872
+ iteration=self._iteration,
873
+ model=effective_model,
874
+ )
875
+ )
876
+
877
+ # IMPORTANT: the transport is captured in the closure (not read from
878
+ # self._active_transport) so a stale watchdog from an earlier turn
879
+ # cannot kill a subsequent turn's subprocess.
880
+ watchdog_target = transport
881
+
882
+ def _on_turn_timeout() -> None:
883
+ state.timeout_hit = True
884
+ self._kill_transport(watchdog_target)
885
+
886
+ # Only forward the transport kwarg when we actually built one —
887
+ # otherwise keep the call shape identical to the no-timeout path so
888
+ # mocks with strict (prompt, options) signatures keep working.
889
+ query_kwargs: dict[str, Any] = {"prompt": user_input, "options": options}
890
+ if transport is not None:
891
+ query_kwargs["transport"] = transport
892
+ self._log.debug("Starting agent query stream...")
893
+ # OS-thread watchdog: fires at `timeout` seconds regardless of
894
+ # event-loop liveness. Immune to anyio cancel-scope suppression.
895
+ with ThreadedWatchdog(
896
+ timeout_seconds=timeout,
897
+ on_timeout=_on_turn_timeout,
898
+ asyncio_task_to_cancel=asyncio.current_task(),
899
+ label=f"Turn timeout ({timeout:g}s)" if timeout else "turn_timeout",
900
+ ):
901
+ async for message in query(**query_kwargs):
902
+ # Wall-clock guard at the TOP of the loop: it breaks BEFORE
903
+ # the message is dispatched (and appended), so the
904
+ # over-deadline message is DISCARDED — no append, no events.
905
+ # Do NOT relocate this to a post-loop check.
906
+ if deadline is not None and time.monotonic() > deadline:
907
+ state.timeout_hit = True
908
+ self._log.warning("Turn timeout reached mid-stream; breaking out of message loop")
909
+ break
910
+ state.dispatch(message)
911
+
912
+ self._log.debug("Agent query stream ended")
913
+
914
+ except asyncio.CancelledError:
915
+ # The threaded watchdog cancels the running task via
916
+ # loop.call_soon_threadsafe(task.cancel) when it fires. If that
917
+ # cancel landed *because* of the timeout, re-raise as
918
+ # TurnTimeoutError so the retry system sees a terminal timeout (not a
919
+ # transient cancel). External cancels propagate unchanged.
920
+ if self._timed_out(state.timeout_hit, deadline):
921
+ assert timeout is not None
922
+ self._finalize_and_raise_timeout(state.finalize, timeout)
923
+ raise
924
+ except ProcessError as e:
925
+ # When the watchdog SIGKILLs the subprocess, the SDK surfaces it as a
926
+ # ProcessError (exit code -9). Classify as a timeout so the retry
927
+ # system doesn't treat it as a transient AGENT_CRASH.
928
+ if self._timed_out(state.timeout_hit, deadline):
929
+ assert timeout is not None
930
+ self._finalize_and_raise_timeout(state.finalize, timeout, cause=e)
931
+ if not self._max_turns_short_circuit(state.sdk_result_summary, f"ProcessError(exit={e.exit_code})"):
932
+ stderr = self._build_stderr_message(e.stderr, stderr_lines)
933
+ error_info = self._format_error_summary(state.sdk_result_summary)
934
+ detail = error_info or stderr
935
+ message = f"CLI process failed (exit code {e.exit_code}): {detail}"
936
+ self._finalize_and_raise_crash(state.finalize, message, cause=e)
937
+ except Exception as e:
938
+ # Same race as above: the watchdog may have killed the subprocess and
939
+ # the SDK may have re-raised as a generic Exception. Check both the
940
+ # flag AND the wall-clock in case the flag flip races with our catch.
941
+ if self._timed_out(state.timeout_hit, deadline):
942
+ assert timeout is not None
943
+ self._finalize_and_raise_timeout(state.finalize, timeout, cause=e)
944
+ if not self._max_turns_short_circuit(state.sdk_result_summary, "Generic Exception"):
945
+ # The SDK wraps ProcessError as a generic Exception via the message stream.
946
+ # Read the captured ResultMessage summary (if any) for diagnostic context.
947
+ error_info = self._format_error_summary(state.sdk_result_summary)
948
+ cause_stderr = self._extract_cause_stderr(e)
949
+ stderr = self._build_stderr_message(cause_stderr, stderr_lines)
950
+ error_details = self._clean_error_message(str(e))
951
+ if error_info:
952
+ error_details += f"\nDetails: {error_info}"
953
+ elif stderr:
954
+ error_details += f"\nStderr output:\n{stderr}"
955
+ message = f"Communication with agent failed: {error_details}"
956
+ self._finalize_and_raise_crash(state.finalize, message, cause=e)
957
+ finally:
958
+ # Auto-finalize any path the except blocks didn't (happy path,
959
+ # max_turns short-circuit, in-loop timeout break). Idempotent
960
+ # (guarded by state.finalized) so the crash/timeout branches that
961
+ # already finalized are a no-op here. Exactly one AgentEndEvent + one
962
+ # EventCollector-built TurnRecord are produced on every exit path.
963
+ if not state.finalized:
964
+ if state.timeout_hit:
965
+ assert timeout is not None
966
+ state.finalize(AgentEndStatus.TIMEOUT, crashed=True, crash_reason=format_timeout_reason(timeout))
967
+ else:
968
+ state.finalize(AgentEndStatus.COMPLETED, crashed=False, crash_reason=None)
969
+ self._active_transport = None
970
+
971
+ # Only trust `timeout_hit` in the happy path: if the loop completed
972
+ # cleanly, a wall-clock drift during post-loop cleanup would falsely
973
+ # classify a successful turn as a timeout. The watchdog and in-loop guard
974
+ # are the authoritative signals. (pending_turn already set by the
975
+ # finalize(TIMEOUT) call in the finally above.)
976
+ if state.timeout_hit:
977
+ assert timeout is not None
978
+ raise TurnTimeoutError(timeout, iteration=self._iteration)
979
+
980
+ self._update_state_from_messages(state.messages)
981
+
982
+ # This turn completed successfully — the iteration increment stands.
983
+ self._end_turn_ok()
984
+
985
+ # The TurnRecord is the EventCollector's reduction of the events emitted
986
+ # above — single, agent-agnostic capture path (no parallel record build).
987
+ return collector.build_turn_record()
988
+
989
+ def _build_claude_query(
990
+ self,
991
+ user_input: str,
992
+ timeout: float | None,
993
+ max_turns: int | None,
994
+ stderr_callback: Callable[[str], None],
995
+ ) -> tuple[ClaudeAgentOptions, SubprocessCLITransport | None, str | None]:
996
+ """Build the SDK options (+ a timeout-only transport) for one turn.
997
+
998
+ Returns ``(options, transport, effective_model)``. ``transport`` is None
999
+ unless a ``timeout`` is set — it is pre-constructed only so the watchdog
1000
+ can hard-kill the subprocess (the SDK's default path creates it internally
1001
+ and never exposes it). ``effective_model`` is the resolved model id (may be
1002
+ None on a DirectRoute with no configured model). ``stderr_callback`` is
1003
+ wired into the options here but owned by ``communicate`` — it must exist
1004
+ before the options are built, and only the error ladder reads its lines.
1005
+ """
1006
+ assert self.working_directory is not None # guaranteed by communicate's guard above
1007
+
1008
+ # Process plugins: copy from config and replace env vars in paths.
1009
+ plugins = process_plugins(self.config.plugins or [], log=self._log) # type: ignore[arg-type]
1010
+
1011
+ # Build env overrides and resolve model for the configured API route.
1012
+ # Precedence: task/CLI agent.model > route default (e.g. BEDROCK_MODEL).
1013
+ env, route_model = self._build_sdk_env(
1014
+ self.route,
1015
+ path_prepend=self._env_path_prepend,
1016
+ plugin_tools_dir=self._plugin_tools_dir,
1017
+ )
1018
+ effective_model = self._resolve_effective_model(self.config.model, env, route_model)
1019
+
1020
+ disallowed_tools = list(self.config.disallowed_tools or [])
1021
+ # Do not allow ToolSearch. This is required to keep Bedrock backend in sync with the other backends.
1022
+ if "ToolSearch" not in disallowed_tools:
1023
+ disallowed_tools.append("ToolSearch")
1024
+
1025
+ # as_posix(), not str(): bash on Windows strips backslashes from unquoted
1026
+ # paths, so a redirect like `> D:\foo\bar` ends up writing to "Dfoobar".
1027
+ options = ClaudeAgentOptions(
1028
+ cwd=self.working_directory.as_posix(),
1029
+ permission_mode=self.config.permission_mode.value,
1030
+ allowed_tools=self.config.allowed_tools or [],
1031
+ disallowed_tools=disallowed_tools,
1032
+ model=effective_model,
1033
+ max_turns=max_turns,
1034
+ plugins=plugins, # type: ignore[arg-type]
1035
+ stderr=stderr_callback, # Capture stderr for better error messages
1036
+ env=env,
1037
+ # Subscribe to raw stream events so we can recover the *cumulative*
1038
+ # output_tokens for each emission from ``message_delta.usage`` events.
1039
+ # Claude Code CLI ships AssistantMessage.usage.output_tokens with only
1040
+ # a partial streaming snapshot (anthropics/claude-code#22686), so
1041
+ # summing per-message values undercounts by 10x+. Without this flag
1042
+ # StreamEvents are suppressed by the SDK.
1043
+ include_partial_messages=True,
1044
+ system_prompt=self.config.system_prompt,
1045
+ setting_sources=self.config.setting_sources if self.config.setting_sources is not None else ["project"],
1046
+ resume=self._session_id,
1047
+ settings=json.dumps(self.config.claude_settings)
1048
+ if isinstance(self.config.claude_settings, dict)
1049
+ else self.config.claude_settings,
1050
+ mcp_servers=self._extra_mcp_servers,
1051
+ **self.config.sdk_options,
1052
+ )
1053
+
1054
+ # Dump SDK options for later inspection (captures all 37+ fields including defaults).
1055
+ self._sdk_options_dump = dump_dataclass(options)
1056
+
1057
+ # When a timeout is set, pre-construct the transport so we retain a
1058
+ # reference to the subprocess for hard-kill; the SDK's default path
1059
+ # creates this internally and never exposes it. When no timeout is set we
1060
+ # leave it None so the SDK uses its own default (keeps the door open for
1061
+ # tests that mock query() without a real CLI).
1062
+ transport: SubprocessCLITransport | None = None
1063
+ if timeout is not None:
1064
+ transport = SubprocessCLITransport(prompt=user_input, options=options)
1065
+
1066
+ return options, transport, effective_model
1067
+
1068
+ async def stop(self) -> None:
1069
+ """Stop the agent and clean up resources."""
1070
+ self.client = None
1071
+ self._mark_stopped()
1072
+
1073
+ async def kill(self) -> None:
1074
+ """Force-terminate the in-flight Claude CLI subprocess, if any.
1075
+
1076
+ Async wrapper around ``kill_sync`` for callers that prefer async.
1077
+ The threaded watchdog inside communicate() uses ``_kill_transport``
1078
+ directly on a captured transport (not via ``self._active_transport``)
1079
+ to avoid a cross-turn race where a stale watchdog could kill a later
1080
+ turn's subprocess.
1081
+ """
1082
+ self.kill_sync()
1083
+
1084
+ def kill_sync(self) -> None:
1085
+ """Synchronously SIGKILL the in-flight Claude CLI subprocess, if any.
1086
+
1087
+ Safe to call from a non-asyncio thread (e.g. a ``threading.Timer``
1088
+ callback). Reads ``self._active_transport`` once; if a later turn
1089
+ has already cleared it, this is a no-op.
1090
+ """
1091
+ self._kill_transport(self._active_transport)
1092
+
1093
+ @staticmethod
1094
+ def _timed_out(timeout_hit: bool, deadline: float | None) -> bool:
1095
+ """Return True if the turn has exceeded its deadline by either path.
1096
+
1097
+ Checks both the watchdog flag AND the wall clock. The flag-only check
1098
+ races with the watchdog: if the handler was entered just before the
1099
+ watchdog flipped the flag, we'd misreport a timeout as a generic
1100
+ error. Checking wall-clock is the belt that catches that case.
1101
+ """
1102
+ if timeout_hit:
1103
+ return True
1104
+ return deadline is not None and time.monotonic() > deadline
1105
+
1106
+ @staticmethod
1107
+ def _kill_transport(transport: SubprocessCLITransport | None) -> None:
1108
+ """SIGKILL the subprocess behind `transport`, if any.
1109
+
1110
+ The SDK wraps the subprocess in anyio cancel scopes that suppress
1111
+ asyncio.CancelledError, so cooperative cancellation doesn't reliably
1112
+ stop a stuck CLI. Sending SIGKILL releases stdout/stdin, which
1113
+ unblocks the anyio readers so the async generator unwinds cleanly.
1114
+ """
1115
+ if transport is None:
1116
+ return
1117
+ # _process is set by transport.connect(); may be None if the call failed
1118
+ # before connect, or already cleared by the SDK's own cleanup.
1119
+ proc = getattr(transport, "_process", None)
1120
+ if proc is None or proc.returncode is not None:
1121
+ return
1122
+ logger.warning("Hard-killing Claude CLI subprocess (pid=%s)", getattr(proc, "pid", "?"))
1123
+ # OSError covers ProcessLookupError (already exited) and permission /
1124
+ # ESRCH races; any other exception would be a real bug worth raising.
1125
+ with suppress(OSError):
1126
+ proc.kill()
1127
+
1128
+ def _finalize_commands(
1129
+ self, pending_commands: dict[str, dict[str, Any]], messages: list[Message]
1130
+ ) -> list[CommandTelemetry]:
1131
+ """Convert pending commands to a finalized list, marking unresolved ones as unknown."""
1132
+ commands: list[CommandTelemetry] = []
1133
+ unknown_status_count = 0
1134
+
1135
+ for tool_id, cmd_data in pending_commands.items():
1136
+ cmd = cmd_data["telemetry"]
1137
+ if cmd.result_status is None:
1138
+ cmd.result_status = "unknown"
1139
+ unknown_status_count += 1
1140
+ self._log.warning(
1141
+ f"Command {cmd.tool_name}:{tool_id} completed without tool result. "
1142
+ + "Status set to 'unknown'. This may indicate agent interruption or SDK issue."
1143
+ )
1144
+ if cmd.duration_ms is None:
1145
+ cmd.duration_ms = 0.0
1146
+ commands.append(cmd)
1147
+
1148
+ if unknown_status_count > 0:
1149
+ msg_type_counts: dict[str, int] = {}
1150
+ for msg in messages:
1151
+ type_name = type(msg).__name__
1152
+ msg_type_counts[type_name] = msg_type_counts.get(type_name, 0) + 1
1153
+ type_summary = ", ".join(f"{k}={v}" for k, v in sorted(msg_type_counts.items()))
1154
+ self._log.warning(
1155
+ f"Turn completed with {unknown_status_count} command(s) in 'unknown' status. "
1156
+ + f"Messages received: [{type_summary}]. "
1157
+ + "This may indicate an SDK message type mismatch or agent interruption."
1158
+ )
1159
+
1160
+ return commands
1161
+
1162
+ @staticmethod
1163
+ def _aggregate_model_usage(model_usage: dict[str, Any] | None) -> TokenUsage | None:
1164
+ """Sum the SDK ResultMessage ``model_usage`` into a cumulative TokenUsage.
1165
+
1166
+ ``model_usage`` maps each model id to its cumulative billing for the
1167
+ session — ``{model: {inputTokens, outputTokens, cacheReadInputTokens,
1168
+ cacheCreationInputTokens, costUSD, ...}}`` (camelCase, unlike ``usage``).
1169
+ This is the SDK's authoritative cost breakdown: summed and priced it
1170
+ reconciles to ``total_cost_usd`` exactly, and it INCLUDES sub-agent
1171
+ consumption (notably cache-creation/input) that the assistant-message
1172
+ stream and the ``usage`` snapshot under-report. Returns None when absent
1173
+ or empty so the caller can fall back.
1174
+ """
1175
+ if not isinstance(model_usage, dict) or not model_usage:
1176
+ return None
1177
+ inp = out = cache_creation = cache_read = 0
1178
+ cost = 0.0
1179
+ any_cost = False
1180
+ for entry in model_usage.values():
1181
+ if not isinstance(entry, dict):
1182
+ continue
1183
+ inp += int(entry.get("inputTokens", 0) or 0)
1184
+ out += int(entry.get("outputTokens", 0) or 0)
1185
+ cache_creation += int(entry.get("cacheCreationInputTokens", 0) or 0)
1186
+ cache_read += int(entry.get("cacheReadInputTokens", 0) or 0)
1187
+ c = entry.get("costUSD")
1188
+ if c is not None:
1189
+ cost += float(c)
1190
+ any_cost = True
1191
+ return TokenUsage(
1192
+ uncached_input_tokens=inp,
1193
+ output_tokens=out,
1194
+ cache_creation_input_tokens=cache_creation,
1195
+ cache_read_input_tokens=cache_read,
1196
+ total_cost_usd=cost if any_cost else None,
1197
+ )
1198
+
1199
+ @staticmethod
1200
+ def _build_token_usage(
1201
+ messages: Sequence[TranscriptMessage],
1202
+ sdk_result_usage: dict[str, Any] | None,
1203
+ sdk_result_cost: float | None,
1204
+ sdk_result_model_usage: dict[str, Any] | None = None,
1205
+ model: str | None = None,
1206
+ ) -> TokenUsage | None:
1207
+ """Build the run's cumulative TokenUsage, or None if unavailable.
1208
+
1209
+ Source-of-truth order:
1210
+
1211
+ 1. ``ResultMessage.model_usage`` — the SDK's cumulative per-model billing.
1212
+ Summed + priced at list rates it equals ``total_cost_usd`` exactly,
1213
+ and it captures sub-agent token consumption (especially cache-creation
1214
+ and input) that the assistant-message stream and the ``usage`` snapshot
1215
+ do NOT — sub-agent emissions are only partially (sometimes never)
1216
+ bubbled into the recorded stream. This is authoritative; prefer it.
1217
+
1218
+ 2. Per-call telemetry stream (sum) — used when ``model_usage`` is absent
1219
+ (e.g. legacy/mock SDKs). Recorded usage is deduped by
1220
+ ``message_id``, so summing is exact when every token-bearing emission
1221
+ carries an id; this still beats the ``usage`` snapshot, which
1222
+ under-reports the cache-read cascade ~2-3x on multi-call runs.
1223
+
1224
+ 3. ``ResultMessage.usage`` snapshot — last resort.
1225
+
1226
+ ``total_cost_usd`` comes from ``model_usage.costUSD`` when present, else
1227
+ the ResultMessage ``total_cost_usd`` — the real billed total. When BOTH
1228
+ are absent (timeout / kill — there is no terminal ``ResultMessage``), it
1229
+ is backfilled from the priced token buckets via ``calculate_cost``,
1230
+ mirroring the Codex self-pricing path so killed turns still record a cost
1231
+ instead of ``—``. The tokens are already captured; this is pure pricing.
1232
+
1233
+ WHY THIS FIELD IS NECESSARY — AND WHY YOU CANNOT DERIVE IT FROM ``messages``
1234
+ ---------------------------------------------------------------------------
1235
+ There are two distinct token views, and they are NOT meant to reconcile:
1236
+
1237
+ * **Billing view** — ``model_usage`` (this method's output, carried as
1238
+ ``AgentEndEvent.usage`` → ``TurnRecord.token_usage``). The complete,
1239
+ cost-accurate per-model total for the turn. Cost/budget/reports read
1240
+ this.
1241
+ * **Attribution view** — the per-message ``messages`` transcript. Useful
1242
+ for per-generation / per-sub-agent display (group by
1243
+ ``parent_tool_use_id``), but NOT cost-complete.
1244
+
1245
+ Summing the per-generation ``AssistantMessage`` entries does NOT, on its
1246
+ own, equal ``model_usage`` — for three independent reasons (all confirmed
1247
+ empirically against live ``claude_subagent_test`` runs — see the dumps
1248
+ under ``tmp/agentusage-*`` and ``CODER_EVAL_RAW_SDK_LOG=1``):
1249
+
1250
+ 1. **Per-step input/output are lossy snapshots.** The streamed
1251
+ ``message_start``/``message_delta`` ``usage`` under-reports
1252
+ ``input_tokens`` (and ``output_tokens``) vs. the billed total; the
1253
+ docs say to "prefer the result message." Only ``output`` gets a
1254
+ ``message_delta`` correction — ``input`` never does.
1255
+ 2. **Sub-agent generations are not fully streamed.** A sub-agent's calls
1256
+ never emit ``message_start``/``message_delta`` into the parent stream;
1257
+ its terminal generation arrives only as the Agent tool result (we
1258
+ synthesize it — see ``_synthesize_subagent_terminal_message``), and
1259
+ its input is billed to ``model_usage`` without a corresponding parent
1260
+ message.
1261
+ 3. **A fixed ~512-token input is billed but never streamed.** Across runs
1262
+ ``model_usage.inputTokens`` exceeds the sum of ALL captured generations
1263
+ by a constant ~512 — and this gap is IDENTICAL with prompt caching
1264
+ disabled (``DISABLE_PROMPT_CACHING=1`` → cw=cr=0), so it is NOT a
1265
+ cache-bucketing artifact. ``message_start`` count == captured
1266
+ ``AssistantMessage`` count (we drop nothing); the 512 simply belongs to
1267
+ no SDK-emitted message. It is an upstream billing-vs-stream property,
1268
+ not a capture bug.
1269
+
1270
+ Cache (``cache_creation`` + ``cache_read``) reconciles from the generation
1271
+ messages to the token; only ``input``/``output`` carry the residual above.
1272
+ ``costUSD``/``total_cost_usd`` are themselves client-side estimates (the
1273
+ SDK computes them locally) — the authoritative figure is Anthropic's
1274
+ Usage/Cost API.
1275
+
1276
+ HOW THE STREAM RECONCILES (the residual is booked, not smeared). The full
1277
+ ``TurnRecord.messages`` stream DOES sum to ``token_usage`` exactly —
1278
+ because ``EventCollector`` appends one synthetic ``ReconciliationMessage``
1279
+ (``role="reconciliation"``) carrying the per-bucket residual (this method's
1280
+ ``model_usage`` total minus the sum of the real generations). The residual
1281
+ is the three sources above. Do NOT instead smear by-difference tokens onto
1282
+ the real ``AssistantMessage`` generations: that would fabricate
1283
+ per-generation numbers matching no real call and break per-sub-agent
1284
+ attribution. ``token_usage`` (billing) stays authoritative for
1285
+ cost/budget/reports; ``messages`` (one reconciliation entry included) is
1286
+ the attribution view that now sums to the same total.
1287
+ """
1288
+ from_models = ClaudeCodeAgent._aggregate_model_usage(sdk_result_model_usage)
1289
+ if from_models is not None:
1290
+ if from_models.total_cost_usd is None:
1291
+ from_models.total_cost_usd = sdk_result_cost
1292
+ return ClaudeCodeAgent._backfill_cost(from_models, model)
1293
+
1294
+ assistant_msgs = [m for m in messages if isinstance(m, AssistantMessageTelemetry)]
1295
+ token_bearing = [
1296
+ m
1297
+ for m in assistant_msgs
1298
+ if m.input_tokens or m.output_tokens or m.cache_creation_tokens or m.cache_read_tokens
1299
+ ]
1300
+ # Summing is exact only when every token-bearing emission has an id (so
1301
+ # the dedup in communicate() applied and no ResultMessage backfill was
1302
+ # mixed in). Otherwise defer to the ResultMessage summary.
1303
+ if token_bearing and all(m.message_id for m in token_bearing):
1304
+ return ClaudeCodeAgent._backfill_cost(
1305
+ TokenUsage(
1306
+ uncached_input_tokens=sum(m.input_tokens for m in assistant_msgs),
1307
+ output_tokens=sum(m.output_tokens for m in assistant_msgs),
1308
+ cache_creation_input_tokens=sum(m.cache_creation_tokens for m in assistant_msgs),
1309
+ cache_read_input_tokens=sum(m.cache_read_tokens for m in assistant_msgs),
1310
+ total_cost_usd=sdk_result_cost,
1311
+ ),
1312
+ model,
1313
+ )
1314
+ if not sdk_result_usage:
1315
+ return None
1316
+ return ClaudeCodeAgent._backfill_cost(
1317
+ TokenUsage(
1318
+ uncached_input_tokens=sdk_result_usage.get("input_tokens", 0),
1319
+ output_tokens=sdk_result_usage.get("output_tokens", 0),
1320
+ cache_creation_input_tokens=sdk_result_usage.get("cache_creation_input_tokens", 0) or 0,
1321
+ cache_read_input_tokens=sdk_result_usage.get("cache_read_input_tokens", 0) or 0,
1322
+ total_cost_usd=sdk_result_cost,
1323
+ ),
1324
+ model,
1325
+ )
1326
+
1327
+ @staticmethod
1328
+ def _backfill_cost(usage: TokenUsage, model: str | None) -> TokenUsage:
1329
+ """Price the token buckets when the SDK gave no cost (timeout / kill).
1330
+
1331
+ On a clean turn the SDK supplies ``costUSD`` / ``total_cost_usd``. When a
1332
+ turn is timed out or killed there is no terminal ``ResultMessage``, so the
1333
+ cost is absent even though the tokens are fully captured. Backfill it from
1334
+ the rate card — the same self-pricing the Codex agent always does — so the
1335
+ recorded top-line cost matches the evalboard simulator instead of ``—``.
1336
+ A no-op when the cost is already set or the model is unknown/unpriced.
1337
+ """
1338
+ if usage.total_cost_usd is not None or not model:
1339
+ return usage
1340
+ cost = calculate_cost(
1341
+ model,
1342
+ uncached_input_tokens=usage.uncached_input_tokens,
1343
+ output_tokens=usage.output_tokens,
1344
+ cache_creation_tokens=usage.cache_creation_input_tokens,
1345
+ cache_read_tokens=usage.cache_read_input_tokens,
1346
+ )
1347
+ if cost is not None:
1348
+ usage.total_cost_usd = cost
1349
+ else:
1350
+ # Model not in the rate card — the turn reverts to a null cost (the
1351
+ # pre-#386 symptom). Surface it so a stale pricing table is visible
1352
+ # rather than silently reproducing "Cost = —" for new models.
1353
+ logger.warning("No pricing for model %r; timeout/kill turn cost left unset", model)
1354
+ return usage
1355
+
1356
+ def get_sdk_options(self) -> dict[str, Any] | None:
1357
+ """Get the raw SDK options used for the last agent query.
1358
+
1359
+ Returns:
1360
+ Dictionary of SDK option field names to values, or None if communicate() hasn't been called.
1361
+ """
1362
+ return self._sdk_options_dump
1363
+
1364
+ @staticmethod
1365
+ def _try_parse_json_value(content: Any) -> dict[str, Any] | list[Any] | None:
1366
+ """Return the parsed JSON object or array from content, else None.
1367
+
1368
+ Strict telemetry-capture variant. ``coder_eval.formatting._extract_json``
1369
+ is the lenient display-path variant — keep behaviour aligned when you
1370
+ change one, but they are intentionally separate: the telemetry path
1371
+ feeds ``CommandTelemetry.result_data`` where false positives persist
1372
+ into ``task.json`` and downstream dashboards.
1373
+
1374
+ Accepts the two SDK-delivered shapes for ToolResultBlock.content: a plain
1375
+ string, or a list of content blocks (MCP tools use this, e.g.
1376
+ [{"type": "text", "text": "..."}]). Within the first 200 characters,
1377
+ looks for the first line whose first non-whitespace character is `{` or
1378
+ `[` and parses from there using raw_decode, so prefix noise (e.g. warning
1379
+ lines the `uip` CLI prints before the JSON body) and trailing garbage are
1380
+ tolerated. Requiring the brace to start a line avoids false positives
1381
+ from incidental `{` or `[` embedded inside text (e.g. the Read tool's
1382
+ line-numbered source where `items: list = []` would otherwise parse as
1383
+ an empty list). The 200-char cap further rules out braces buried deep in
1384
+ long text output. If the candidate fails to parse, returns None — no
1385
+ fragment fallback, which would surface misleading partial captures from
1386
+ truncated payloads. Bare empty containers (`{}` / `[]`) are rejected:
1387
+ a non-empty dict or list is evidence of real structured content.
1388
+ Primitives (strings, numbers, booleans, null) are rejected for the same
1389
+ reason — a bare primitive adds no information beyond result_summary.
1390
+ Non-JSON tool output is normal; parse failures are swallowed silently.
1391
+ """
1392
+ if isinstance(content, list):
1393
+ text_parts = [
1394
+ block["text"]
1395
+ for block in content
1396
+ if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str)
1397
+ ]
1398
+ if not text_parts:
1399
+ return None
1400
+ content = "".join(text_parts)
1401
+ if not isinstance(content, str):
1402
+ return None
1403
+ # Only look for the JSON start within the first 200 chars — enough to skip
1404
+ # a few prefix warning lines but not so lax that a brace buried in a long
1405
+ # text body gets mistaken for a structured payload.
1406
+ match = re.search(r"(?:^|\n)[^\S\n]*[{[]", content[:_JSON_START_SEARCH_LIMIT])
1407
+ if not match:
1408
+ return None
1409
+ try:
1410
+ parsed, _ = json.JSONDecoder().raw_decode(content, match.end() - 1)
1411
+ except ValueError:
1412
+ return None
1413
+ # Reject bare empty containers ({} / []): a non-empty dict or list is
1414
+ # evidence of real structured content, an empty one is indistinguishable
1415
+ # from an accidental match and adds nothing over result_summary.
1416
+ if isinstance(parsed, (dict, list)) and parsed:
1417
+ return parsed
1418
+ return None
1419
+
1420
+ _PERMISSION_PHRASES = ("permission", "not allowed", "requires approval", "denied", "blocked")
1421
+
1422
+ @classmethod
1423
+ def _tool_end_status(cls, is_error: bool, content: Any) -> ToolEndStatus:
1424
+ """Classify a tool result into a ToolEndStatus (promotes the old string-scan)."""
1425
+ if not is_error:
1426
+ return ToolEndStatus.OK
1427
+ text = str(content).lower() if content is not None else ""
1428
+ if any(phrase in text for phrase in cls._PERMISSION_PHRASES):
1429
+ return ToolEndStatus.PERMISSION_DENIED
1430
+ return ToolEndStatus.ERROR
1431
+
1432
+ @staticmethod
1433
+ def _synthesize_subagent_terminal_message(message: Any, model: str | None) -> AssistantMessageTelemetry | None:
1434
+ """Materialize a sub-agent's TERMINAL generation as an AssistantMessage.
1435
+
1436
+ A sub-agent that calls tools runs several generations. Its intermediate
1437
+ ones bubble into the parent stream as ``parent_tool_use_id``-tagged
1438
+ assistant messages, but its terminal generation is delivered as the Agent
1439
+ tool RESULT (``UserMessage.tool_use_result``), never as a streamed
1440
+ message. We synthesize it as one so the sub-agent's full lifecycle lives
1441
+ in the transcript and per-sub-agent usage is recoverable by grouping
1442
+ messages on ``parent_tool_use_id`` — no separate sidecar field needed.
1443
+
1444
+ ``tool_use_result.usage`` is the terminal call's usage breakdown (input /
1445
+ output / cache-creation / cache-read — complete, incl. the cache-read that
1446
+ ``TaskNotification.usage`` drops). It is terminal-only, not cumulative, so
1447
+ it does NOT overlap the bubbled intermediate generations (its output is
1448
+ the final reply's alone). Returns None for non-sub-agent tool results
1449
+ (regular Bash/Write/etc. carry ``tool_use_result`` but no ``agentId``).
1450
+
1451
+ The token total is unaffected: the normal path derives it from
1452
+ ``ResultMessage.model_usage`` (which ignores this transcript), so the
1453
+ synthetic message is purely additive for attribution/display.
1454
+ """
1455
+ tur = getattr(message, "tool_use_result", None)
1456
+ if not isinstance(tur, dict) or "agentId" not in tur:
1457
+ return None
1458
+ usage = tur.get("usage")
1459
+ if not isinstance(usage, dict):
1460
+ return None
1461
+
1462
+ # The spawning Agent tool_use_id (and the returned text) live on the
1463
+ # ToolResultBlock, not on tool_use_result itself.
1464
+ tool_use_id: str | None = None
1465
+ result_text = ""
1466
+ content = getattr(message, "content", None)
1467
+ if isinstance(content, list):
1468
+ for block in content:
1469
+ if _is_tool_result_block(block):
1470
+ tool_use_id = getattr(block, "tool_use_id", None)
1471
+ result_text = _tool_result_text(getattr(block, "content", None))
1472
+ break
1473
+ if not isinstance(tool_use_id, str) or not tool_use_id:
1474
+ return None
1475
+
1476
+ def _int(value: Any) -> int:
1477
+ try:
1478
+ return int(value or 0)
1479
+ except (TypeError, ValueError):
1480
+ return 0
1481
+
1482
+ now = datetime.now()
1483
+ return AssistantMessageTelemetry(
1484
+ started_at=now,
1485
+ completed_at=now,
1486
+ generation_duration_ms=0.0,
1487
+ content_blocks=([ContentBlock(block_type="text", sequence=0, text=result_text)] if result_text else []),
1488
+ tool_use_ids=[],
1489
+ input_tokens=_int(usage.get("input_tokens")),
1490
+ output_tokens=_int(usage.get("output_tokens")),
1491
+ cache_creation_tokens=_int(usage.get("cache_creation_input_tokens")),
1492
+ cache_read_tokens=_int(usage.get("cache_read_input_tokens")),
1493
+ reasoning_tokens=0,
1494
+ model=model,
1495
+ message_id=f"subagent-{tool_use_id}",
1496
+ parent_tool_use_id=tool_use_id,
1497
+ )
1498
+
1499
+ def _resolve_pending_command(
1500
+ self,
1501
+ tool_use_id: str,
1502
+ is_error: bool,
1503
+ content: Any,
1504
+ pending_commands: dict[str, dict[str, Any]],
1505
+ processed_results: set[str],
1506
+ ) -> None:
1507
+ """Match a tool result back to its pending command and update status/duration.
1508
+
1509
+ Args:
1510
+ tool_use_id: The tool use ID from the result
1511
+ is_error: Whether the tool execution resulted in an error
1512
+ content: The result content (string or structured)
1513
+ pending_commands: Map of tool_id -> {telemetry, command_start_time}
1514
+ processed_results: Set of already-processed tool IDs (for duplicate detection)
1515
+ """
1516
+ # Normalize content to string for storage
1517
+ content_str = str(content) if content is not None else ""
1518
+
1519
+ if tool_use_id in pending_commands:
1520
+ cmd_data = pending_commands[tool_use_id]
1521
+ cmd = cmd_data["telemetry"]
1522
+ command_start_time = cmd_data["command_start_time"]
1523
+
1524
+ # Calculate precise duration
1525
+ command_end_time = time.monotonic()
1526
+ duration_ms = (command_end_time - command_start_time) * 1000
1527
+
1528
+ # Update command with actual results
1529
+ cmd.result_status = "error" if is_error else "success"
1530
+ cmd.duration_ms = duration_ms
1531
+ cmd.result_summary = content_str if content_str else None
1532
+ cmd.result_data = ClaudeCodeAgent._try_parse_json_value(content)
1533
+
1534
+ # Wall-clock execution bounds. `execution_completed_at` is now;
1535
+ # `execution_started_at` is reconstructed by subtracting the
1536
+ # measured monotonic duration. This avoids storing a separate
1537
+ # wall-clock start (we don't have one without restructuring
1538
+ # pending_commands further) while still giving consumers two
1539
+ # explicit timestamps with the right delta.
1540
+ cmd.execution_completed_at = datetime.now()
1541
+ cmd.execution_started_at = cmd.execution_completed_at - timedelta(milliseconds=duration_ms)
1542
+
1543
+ if is_error:
1544
+ cmd.error_message = content_str
1545
+
1546
+ # Permission-blocked tool use is abnormal flow — warn so it
1547
+ # surfaces in runs that don't have DEBUG enabled.
1548
+ content_lower = content_str.lower()
1549
+ if any(
1550
+ phrase in content_lower
1551
+ for phrase in ("permission", "not allowed", "requires approval", "denied", "blocked")
1552
+ ):
1553
+ self._log.warning(
1554
+ f"Tool use blocked: {cmd.tool_name} (id={tool_use_id}) "
1555
+ + f"- permission denied. Error: {content_str[:200]}"
1556
+ )
1557
+
1558
+ if tool_use_id in processed_results:
1559
+ self._log.debug(f"Multiple results for tool_id={tool_use_id}. Last result wins.")
1560
+ processed_results.add(tool_use_id)
1561
+ else:
1562
+ self._log.warning(
1563
+ f"Tool result received for unknown tool_use_id={tool_use_id}. No matching ToolUseBlock found."
1564
+ )
1565
+
1566
+ @staticmethod
1567
+ def _build_stderr_message(sdk_stderr: str | None, stderr_lines: list[str]) -> str:
1568
+ """Combine SDK stderr with captured stderr lines, filtering out placeholder text.
1569
+
1570
+ The SDK often returns a hardcoded placeholder like "Check stderr output for details"
1571
+ instead of actual error content. The real error details are in stderr_lines captured
1572
+ via the stderr callback.
1573
+
1574
+ Args:
1575
+ sdk_stderr: The stderr string from ProcessError (may be a placeholder)
1576
+ stderr_lines: Lines captured via the stderr callback during execution
1577
+
1578
+ Returns:
1579
+ Combined stderr message with real content, or "No stderr captured"
1580
+ """
1581
+ parts = []
1582
+
1583
+ # Include SDK stderr only if it's not the hardcoded placeholder
1584
+ if sdk_stderr and "check stderr output" not in sdk_stderr.lower():
1585
+ parts.append(sdk_stderr)
1586
+
1587
+ # Always include captured stderr lines (these contain the real error details)
1588
+ if stderr_lines:
1589
+ parts.append("\n".join(stderr_lines[-20:]))
1590
+
1591
+ return "\n".join(parts) if parts else "No stderr captured"
1592
+
1593
+ @staticmethod
1594
+ def _extract_cause_stderr(error: Exception) -> str | None:
1595
+ """Walk the exception __cause__ chain looking for a ProcessError with stderr.
1596
+
1597
+ The SDK re-raises ProcessError as a generic Exception via the Query message stream.
1598
+ This method recovers the original stderr from the cause chain.
1599
+
1600
+ Args:
1601
+ error: The caught exception
1602
+
1603
+ Returns:
1604
+ stderr string from the original ProcessError, or None
1605
+ """
1606
+ cause = error.__cause__
1607
+ depth = 0
1608
+ while cause and depth < 5:
1609
+ if isinstance(cause, ProcessError):
1610
+ return cause.stderr
1611
+ cause = cause.__cause__
1612
+ depth += 1
1613
+ return None
1614
+
1615
+ @staticmethod
1616
+ def _clean_error_message(message: str) -> str:
1617
+ """Remove unhelpful SDK placeholder text from error messages.
1618
+
1619
+ Args:
1620
+ message: Raw error message string
1621
+
1622
+ Returns:
1623
+ Cleaned error message
1624
+ """
1625
+ # Remove the hardcoded placeholder that the SDK injects
1626
+ cleaned = message.replace("\nError output: Check stderr output for details", "")
1627
+ cleaned = cleaned.replace("Error output: Check stderr output for details", "")
1628
+ return cleaned.strip()
1629
+
1630
+ @staticmethod
1631
+ def _is_max_turns_result(summary: ResultSummary | None) -> bool:
1632
+ """True iff the captured ResultMessage indicates SDK-side max_turns exhaustion."""
1633
+ return summary is not None and summary.subtype == "error_max_turns"
1634
+
1635
+ def _max_turns_short_circuit(self, summary: ResultSummary | None, branch_label: str) -> bool:
1636
+ """Fall through error branches to the clean-completion path on error_max_turns."""
1637
+ if not self._is_max_turns_result(summary):
1638
+ return False
1639
+ self._log.debug("%s is error_max_turns; treating as clean turn", branch_label)
1640
+ return True
1641
+
1642
+ @staticmethod
1643
+ def _summarize_result(msg: Message) -> ResultSummary | None:
1644
+ """Build a ``ResultSummary`` from an SDK ResultMessage, or None.
1645
+
1646
+ Returns None only when ``msg`` lacks the SDK ResultMessage shape
1647
+ (``session_id`` + ``usage``). For real ResultMessages the SDK
1648
+ always provides ``subtype`` (it's a required dataclass field), so
1649
+ any missing/non-string value is treated as ``"unknown"`` rather
1650
+ than silently disabling the summary downstream.
1651
+ """
1652
+ if not _is_sdk_result_message(msg):
1653
+ return None
1654
+ subtype = getattr(msg, "subtype", None)
1655
+ stop_reason = getattr(msg, "stop_reason", None)
1656
+ result = getattr(msg, "result", None)
1657
+ return ResultSummary(
1658
+ is_error=bool(getattr(msg, "is_error", False)),
1659
+ subtype=subtype if isinstance(subtype, str) else "unknown",
1660
+ stop_reason=stop_reason if isinstance(stop_reason, str) else None,
1661
+ result=result if isinstance(result, str) else None,
1662
+ )
1663
+
1664
+ @staticmethod
1665
+ def _format_error_summary(summary: ResultSummary | None) -> str | None:
1666
+ """Format an errored ``ResultSummary`` for surfacing to the user.
1667
+
1668
+ Prefers free-form ``result`` text; falls back to the
1669
+ ``subtype``/``stop_reason`` classification when ``result`` is
1670
+ unset (which is the common shape on hard CLI crashes). Returns
1671
+ None when there is nothing useful to surface, so callers can
1672
+ decide whether to fall back to stderr.
1673
+ """
1674
+ if summary is None or not summary.is_error:
1675
+ return None
1676
+ if summary.result:
1677
+ return summary.result[:200]
1678
+ parts = [p for p in (summary.subtype, summary.stop_reason) if p]
1679
+ if parts:
1680
+ return f"Result[is_error=True]: {' / '.join(parts)}"
1681
+ return None
1682
+
1683
+ def _format_messages(self, messages: list[Message]) -> str:
1684
+ return format_messages(messages, warned_unknown_types=self._warned_unknown_types, log=self._log)
1685
+
1686
+ def _update_state_from_messages(self, messages: list[Message]) -> None:
1687
+ """Update agent state based on received messages.
1688
+
1689
+ Args:
1690
+ messages: List of messages from the agent (SDK objects)
1691
+ """
1692
+ # Check for explicit error messages (use getattr for safe access).
1693
+ # ResultMessage.is_error is intentionally NOT a state-change trigger —
1694
+ # the agent may recover from a tool error on a later turn.
1695
+ for msg in messages:
1696
+ if getattr(msg, "error", None):
1697
+ self._state = AgentState.ERROR
1698
+ return
1699
+
1700
+ # If no errors, agent is working normally
1701
+ self._state = AgentState.WORKING