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,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
|