coder-eval 0.8.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. coder_eval/.gitattributes +2 -0
  2. coder_eval/__init__.py +3 -0
  3. coder_eval/agent.py +302 -0
  4. coder_eval/agents/__init__.py +41 -0
  5. coder_eval/agents/_logging.py +89 -0
  6. coder_eval/agents/antigravity_agent.py +811 -0
  7. coder_eval/agents/claude_code_agent.py +1701 -0
  8. coder_eval/agents/codex_agent.py +2055 -0
  9. coder_eval/agents/noop_agent.py +100 -0
  10. coder_eval/agents/registry.py +163 -0
  11. coder_eval/agents/watchdog.py +116 -0
  12. coder_eval/analysis.py +88 -0
  13. coder_eval/cli/__init__.py +87 -0
  14. coder_eval/cli/aggregate_command.py +153 -0
  15. coder_eval/cli/console.py +7 -0
  16. coder_eval/cli/evaluate_command.py +164 -0
  17. coder_eval/cli/plan_command.py +152 -0
  18. coder_eval/cli/report_command.py +121 -0
  19. coder_eval/cli/run_command.py +686 -0
  20. coder_eval/cli/run_helpers.py +137 -0
  21. coder_eval/cli/run_task_internal_command.py +210 -0
  22. coder_eval/cli/utils.py +37 -0
  23. coder_eval/config.py +212 -0
  24. coder_eval/criteria/__init__.py +163 -0
  25. coder_eval/criteria/_classification_aggregate.py +106 -0
  26. coder_eval/criteria/agent_judge.py +425 -0
  27. coder_eval/criteria/base.py +248 -0
  28. coder_eval/criteria/classification_match.py +107 -0
  29. coder_eval/criteria/command_executed.py +181 -0
  30. coder_eval/criteria/commands_efficiency.py +73 -0
  31. coder_eval/criteria/file_check.py +119 -0
  32. coder_eval/criteria/file_contains.py +85 -0
  33. coder_eval/criteria/file_exists.py +45 -0
  34. coder_eval/criteria/file_matches_regex.py +86 -0
  35. coder_eval/criteria/json_check.py +184 -0
  36. coder_eval/criteria/llm_judge.py +260 -0
  37. coder_eval/criteria/reference_comparison.py +130 -0
  38. coder_eval/criteria/run_command.py +220 -0
  39. coder_eval/criteria/skill_triggered.py +111 -0
  40. coder_eval/criteria/uipath_eval.py +223 -0
  41. coder_eval/errors/__init__.py +26 -0
  42. coder_eval/errors/agent.py +26 -0
  43. coder_eval/errors/budget.py +28 -0
  44. coder_eval/errors/categories.py +205 -0
  45. coder_eval/errors/categorization.py +240 -0
  46. coder_eval/errors/executor.py +124 -0
  47. coder_eval/errors/judge.py +12 -0
  48. coder_eval/errors/retry.py +211 -0
  49. coder_eval/errors/timeout.py +63 -0
  50. coder_eval/evaluation/__init__.py +10 -0
  51. coder_eval/evaluation/checker.py +260 -0
  52. coder_eval/evaluation/judge_anthropic.py +55 -0
  53. coder_eval/evaluation/judge_bedrock.py +114 -0
  54. coder_eval/evaluation/judge_context.py +497 -0
  55. coder_eval/evaluation/judge_models.py +53 -0
  56. coder_eval/evaluation/judge_persistence.py +291 -0
  57. coder_eval/evaluation/judge_usage.py +50 -0
  58. coder_eval/evaluation/sub_agent.py +231 -0
  59. coder_eval/evaluation/summaries.py +48 -0
  60. coder_eval/evaluation/verdict_tool.py +213 -0
  61. coder_eval/formatting.py +191 -0
  62. coder_eval/isolation/__init__.py +15 -0
  63. coder_eval/isolation/docker_runner.py +1253 -0
  64. coder_eval/logging_config.py +399 -0
  65. coder_eval/models/__init__.py +337 -0
  66. coder_eval/models/agent_config.py +373 -0
  67. coder_eval/models/container_paths.py +26 -0
  68. coder_eval/models/criteria.py +942 -0
  69. coder_eval/models/enums.py +121 -0
  70. coder_eval/models/experiment.py +314 -0
  71. coder_eval/models/judge.py +90 -0
  72. coder_eval/models/judge_defaults.py +11 -0
  73. coder_eval/models/limits.py +89 -0
  74. coder_eval/models/merge_strategy.py +132 -0
  75. coder_eval/models/mutations.py +95 -0
  76. coder_eval/models/results.py +787 -0
  77. coder_eval/models/routing.py +160 -0
  78. coder_eval/models/sandbox.py +371 -0
  79. coder_eval/models/tasks.py +631 -0
  80. coder_eval/models/telemetry.py +454 -0
  81. coder_eval/models/templates.py +108 -0
  82. coder_eval/orchestration/__init__.py +13 -0
  83. coder_eval/orchestration/batch.py +690 -0
  84. coder_eval/orchestration/config.py +111 -0
  85. coder_eval/orchestration/config_merge.py +431 -0
  86. coder_eval/orchestration/evaluation.py +94 -0
  87. coder_eval/orchestration/experiment.py +837 -0
  88. coder_eval/orchestration/overrides.py +206 -0
  89. coder_eval/orchestration/task_loader.py +437 -0
  90. coder_eval/orchestrator.py +2078 -0
  91. coder_eval/path_utils.py +79 -0
  92. coder_eval/plugins.py +81 -0
  93. coder_eval/pricing.py +169 -0
  94. coder_eval/py.typed +0 -0
  95. coder_eval/reports.py +1066 -0
  96. coder_eval/reports_experiment.py +761 -0
  97. coder_eval/reports_html.py +1659 -0
  98. coder_eval/reports_stats.py +250 -0
  99. coder_eval/resources/__init__.py +137 -0
  100. coder_eval/resources/default_experiment.yaml +85 -0
  101. coder_eval/resources/default_ignore_patterns.yaml +60 -0
  102. coder_eval/resources/tags.yaml +55 -0
  103. coder_eval/sandbox.py +1127 -0
  104. coder_eval/scoring/__init__.py +11 -0
  105. coder_eval/scoring/ast_similarity.py +38 -0
  106. coder_eval/scoring/complexity.py +115 -0
  107. coder_eval/scoring/quality.py +154 -0
  108. coder_eval/scoring/signature_similarity.py +57 -0
  109. coder_eval/scoring/similarity.py +103 -0
  110. coder_eval/scoring/token_similarity.py +44 -0
  111. coder_eval/simulation/__init__.py +27 -0
  112. coder_eval/simulation/termination.py +88 -0
  113. coder_eval/simulation/user_simulator.py +324 -0
  114. coder_eval/streaming/__init__.py +44 -0
  115. coder_eval/streaming/callbacks.py +57 -0
  116. coder_eval/streaming/collector.py +193 -0
  117. coder_eval/streaming/events.py +198 -0
  118. coder_eval/streaming/renderers.py +248 -0
  119. coder_eval/streaming/wire.py +115 -0
  120. coder_eval/telemetry.py +407 -0
  121. coder_eval/utils.py +517 -0
  122. coder_eval-0.8.2.dist-info/METADATA +242 -0
  123. coder_eval-0.8.2.dist-info/RECORD +127 -0
  124. coder_eval-0.8.2.dist-info/WHEEL +4 -0
  125. coder_eval-0.8.2.dist-info/entry_points.txt +5 -0
  126. coder_eval-0.8.2.dist-info/licenses/LICENSE +201 -0
  127. coder_eval-0.8.2.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,2078 @@
1
+ """Main orchestrator for coordinating task evaluation."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import re
6
+ import time
7
+ from collections.abc import Callable
8
+ from contextlib import suppress
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from inspect import isawaitable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .agent import Agent
16
+ from .agents.watchdog import ThreadedWatchdog
17
+ from .analysis import calculate_command_statistics
18
+ from .config import settings
19
+ from .criteria.commands_efficiency import compute_commands_efficiency
20
+ from .errors import (
21
+ AgentCrashError,
22
+ BudgetExceededError,
23
+ TaskTimeoutError,
24
+ TurnTimeoutError,
25
+ )
26
+ from .errors.executor import execute_with_retry
27
+ from .errors.retry import create_error_context
28
+ from .evaluation.checker import SuccessChecker, _short_failure_reason
29
+ from .models import (
30
+ ROUTE_NAMES,
31
+ AgentKind,
32
+ ApiRoute,
33
+ BedrockRoute,
34
+ ConfigLineageEntry,
35
+ CriterionResult,
36
+ DirectRoute,
37
+ EvaluationResult,
38
+ FinalStatus,
39
+ JudgeCriterionResult,
40
+ PostRunCommand,
41
+ PostRunResult,
42
+ PreRunCommand,
43
+ PreservationMode,
44
+ SimulationConfig,
45
+ SimulationTelemetry,
46
+ TaskConfigRecord,
47
+ TaskDefinition,
48
+ TokenUsage,
49
+ TurnRecord,
50
+ UserMessage,
51
+ resolve_route,
52
+ )
53
+ from .orchestration.evaluation import load_reference
54
+ from .path_utils import format_task_log_id, task_log_path
55
+ from .sandbox import Sandbox
56
+ from .simulation import DialogStopReason, SimulatorResult, UserSimulator, evaluate_stop
57
+ from .streaming.callbacks import StreamCallback, TaskScopedCallback, safe_emit
58
+ from .streaming.events import CriteriaCheckEvent, CriterionSummary
59
+ from .telemetry import Scalar, hash_identifier
60
+ from .utils import get_version_info, looks_like_version, runtime_uip_versions
61
+
62
+
63
+ # Get module logger
64
+ logger = logging.getLogger(__name__)
65
+
66
+
67
+ # Grace on outer wait_for so the agent's in-band watchdog (which preserves a partial)
68
+ # wins the race against the asyncio cancel path (which doesn't).
69
+ _WAIT_FOR_GRACE_SECONDS = 2.0
70
+
71
+
72
+ async def _pump_stream(
73
+ stream: asyncio.StreamReader | None,
74
+ log_fn: Callable[..., None],
75
+ label: str,
76
+ chunks: list[str],
77
+ ) -> None:
78
+ """Read ``stream`` line-by-line, log each non-empty line via ``log_fn``,
79
+ and accumulate the raw text into ``chunks`` for later capture.
80
+
81
+ Used to forward post_run subprocess output to the orchestrator log in
82
+ real time while still preserving it for ``PostRunResult``. If a single
83
+ line exceeds the StreamReader buffer (rare — only for binary-ish or
84
+ malformed output), it is drained as a chunk and logged as a partial.
85
+ """
86
+ if stream is None:
87
+ return
88
+ while True:
89
+ try:
90
+ raw = await stream.readline()
91
+ except asyncio.LimitOverrunError as e:
92
+ # Single line larger than the buffer; drain the buffered bytes so
93
+ # readline() can make progress on the next iteration.
94
+ raw = await stream.readexactly(e.consumed)
95
+ text = raw.decode(errors="replace")
96
+ chunks.append(text)
97
+ log_fn("[%s] (partial line, %d bytes)", label, len(raw))
98
+ continue
99
+ if not raw:
100
+ break
101
+ text = raw.decode(errors="replace")
102
+ chunks.append(text)
103
+ line = text.rstrip()
104
+ if line:
105
+ log_fn("[%s] %s", label, line)
106
+
107
+
108
+ # Structural tags emitted by ClaudeCodeAgent._format_messages. Other
109
+ # bracketed words (markdown footnotes, pylint codes, unknown SDK message types
110
+ # like [TaskStartedMessage]) are intentionally NOT matched — they pass through
111
+ # as content. Source of truth for the tag vocabulary is
112
+ # ``ClaudeCodeAgent._format_messages``; this regex is telemetry-only (utterance
113
+ # extraction for the per-task log), not a correctness-critical parser.
114
+ _UTTERANCE_TAG_RE = re.compile(r"^\[(ASSISTANT|RESULT - SUCCESS|RESULT - ERROR|TOOL USE)\](?: (.*))?$")
115
+
116
+
117
+ def _format_routing(route: ApiRoute) -> str:
118
+ """Format the route name for the ``API routing:`` log line.
119
+
120
+ For ``DirectRoute`` the resolved judge transport is appended so the choice
121
+ (anthropic / none) is visible on every run, not only in the persisted
122
+ ``environment_info`` record.
123
+ """
124
+ name = ROUTE_NAMES[type(route)]
125
+ if isinstance(route, DirectRoute):
126
+ return f"{name} (judge transport: {route.judge_transport or 'none'})"
127
+ return name
128
+
129
+
130
+ def _extract_utterance(raw: str) -> str:
131
+ """Collapse a ClaudeCodeAgent-formatted transcript to a clean utterance.
132
+
133
+ Input looks like:
134
+ [ASSISTANT] Sure, I'll do X.
135
+ [TOOL USE] Read
136
+ [ASSISTANT] Here is the answer...
137
+ [RESULT - SUCCESS] Here is the answer...
138
+
139
+ The SDK's ``ResultMessage`` duplicates the final assistant text, which
140
+ makes conversation.log read as if every message is repeated. Prefer the
141
+ ``[RESULT - ...]`` payload when it is non-empty (it is the canonical
142
+ final utterance); otherwise fall back to concatenated ``[ASSISTANT]``
143
+ blocks. ``[TOOL USE]`` lines are dropped. Input that does not look
144
+ tagged at all (plain user text like a pinned initial_prompt) is
145
+ returned unchanged.
146
+
147
+ Pre-tag content handling: any content appearing BEFORE the first
148
+ tagged line is collected into an implicit ``[ASSISTANT]`` block. It
149
+ survives in the output only on the ASSISTANT-fallback path (no
150
+ ``[RESULT - ...]`` in the transcript). When a ``[RESULT - SUCCESS]``
151
+ is present, it supersedes all ``[ASSISTANT]`` content — including
152
+ any pre-tag content — because the ResultMessage is the SDK's
153
+ canonical final utterance and ASSISTANT lines are chain-of-thought
154
+ that the RESULT already incorporates. ClaudeCodeAgent always begins
155
+ its output with a tag in practice, so this mostly matters for
156
+ defensive handling of upstream format drift.
157
+
158
+ Asymmetry note: ``[RESULT - SUCCESS]`` strips its label (it is the
159
+ canonical answer); ``[RESULT - ERROR]`` keeps a ``[RESULT - ERROR]``
160
+ prefix in the output so the error state remains visible in the log.
161
+ """
162
+ if not raw:
163
+ return ""
164
+ lines = raw.splitlines()
165
+ if not any(_UTTERANCE_TAG_RE.match(ln) for ln in lines):
166
+ return raw
167
+
168
+ assistant_parts: list[str] = []
169
+ result_parts: list[str] = []
170
+ # Pre-tag content becomes an implicit ASSISTANT block (not dropped).
171
+ current_tag: str = "ASSISTANT"
172
+ current_buf: list[str] = []
173
+
174
+ def _flush() -> None:
175
+ text = "\n".join(current_buf).strip()
176
+ if not text:
177
+ return
178
+ if current_tag == "ASSISTANT":
179
+ assistant_parts.append(text)
180
+ elif current_tag == "RESULT - SUCCESS":
181
+ result_parts.append(text)
182
+ elif current_tag == "RESULT - ERROR":
183
+ result_parts.append(f"[RESULT - ERROR] {text}")
184
+ # TOOL USE is dropped.
185
+
186
+ for ln in lines:
187
+ match = _UTTERANCE_TAG_RE.match(ln)
188
+ if match:
189
+ _flush()
190
+ current_tag = match.group(1)
191
+ current_buf = [match.group(2) or ""]
192
+ else:
193
+ current_buf.append(ln)
194
+ _flush()
195
+
196
+ if result_parts:
197
+ return "\n\n".join(result_parts)
198
+ if assistant_parts:
199
+ return "\n\n".join(assistant_parts)
200
+ return raw
201
+
202
+
203
+ def _extract_failure_reason(result: CriterionResult) -> str | None:
204
+ """Streaming-event wrapper around ``_short_failure_reason``.
205
+
206
+ Preserves the historical ``None``-for-no-content contract so
207
+ ``CriterionSummary.failure_reason`` stays ``None`` when there's nothing
208
+ to show. The actual reason text is produced by the shared helper so the
209
+ console FAILED log and the streamed event render identical strings.
210
+ """
211
+ if not result.error and not result.details:
212
+ return None
213
+ reason = _short_failure_reason(result)
214
+ return reason if reason != "no details" else None
215
+
216
+
217
+ def build_task_event(result: EvaluationResult, *, driver: str, variant_id: str) -> tuple[str, dict[str, Scalar]]:
218
+ """Build the (event_name, properties) for a finalized task's telemetry event.
219
+
220
+ Shared by the in-process path (``Orchestrator._finalize_result``) and the
221
+ docker path (``orchestration/batch.py``) so both drivers emit an identical
222
+ ``CoderEval.Task.End`` event. Carries only enums/counts/durations/config-derived
223
+ ids — no user content. None-safe.
224
+
225
+ Every task emits the SAME event name (``CoderEval.Task.End``); the outcome
226
+ lives in dimensions, never the name. ``Status`` carries the exact
227
+ ``FinalStatus`` and ``Category`` carries the canonical ``FinalStatus.category``
228
+ bucket (``succeeded`` / ``failed`` / ``error``) — the single source of truth
229
+ shared with reports. Slicing belongs in dimensions (the App Insights idiom),
230
+ so dashboards group by ``Status``/``Category`` rather than matching event
231
+ names, and the telemetry bucketing can never drift from ``category``.
232
+
233
+ The return type is the scalar event contract (``dict[str, Scalar]``) so a
234
+ non-scalar property is caught here by pyright, not just str()-coerced at
235
+ runtime by the telemetry layer. Token counts are intentionally NOT emitted —
236
+ this is usage telemetry, not eval analytics. Task/variant ids are emitted as
237
+ stable one-way hashes (``hash_identifier``) so an author-defined free-text id
238
+ that could encode sensitive data never reaches the telemetry store verbatim.
239
+ """
240
+ props: dict[str, Scalar] = {
241
+ "TaskId": hash_identifier(result.task_id),
242
+ "VariantId": hash_identifier(variant_id),
243
+ "Status": result.final_status.value,
244
+ "Category": result.final_status.category,
245
+ "DurationMs": int((result.duration_seconds or 0.0) * 1000),
246
+ "Score": float(result.weighted_score or 0.0),
247
+ "Iterations": result.iteration_count,
248
+ "AgentType": result.agent_type or "",
249
+ "Model": result.model_used or "",
250
+ "Driver": driver,
251
+ }
252
+ return "CoderEval.Task.End", props
253
+
254
+
255
+ @dataclass
256
+ class _SolicitedMessage:
257
+ """Result of asking the user simulator for one utterance (opener or in-loop).
258
+
259
+ ``message is None`` signals a simulator failure (the caller bumps
260
+ ``simulator_failures`` and reacts); otherwise the caller inspects
261
+ ``message.stop_requested`` and folds ``sim_in``/``sim_out`` into its running
262
+ token totals. Module-private — NOT a public model.
263
+ """
264
+
265
+ message: UserMessage | None
266
+ sim_in: int
267
+ sim_out: int
268
+
269
+
270
+ @dataclass
271
+ class _OpenerOutcome:
272
+ """Result of the pure-simulation opener acquisition (``initial_prompt is None``).
273
+
274
+ When ``short_circuit`` is True the opener already wrote simulation telemetry and the
275
+ dialog loop must ``return return_value`` immediately (simulator failure → ERROR, or an
276
+ opener carrying the stop token → STOP_TOKEN, both before any agent turn). Otherwise the
277
+ caller folds ``sim_in``/``sim_out``/``failures`` into its counters and binds
278
+ ``current_prompt``/``pending_user_turn``. Module-private — NOT a public model.
279
+ """
280
+
281
+ short_circuit: bool
282
+ return_value: bool = False
283
+ current_prompt: str | None = None
284
+ pending_user_turn: UserMessage | None = None
285
+ sim_in: int = 0
286
+ sim_out: int = 0
287
+ failures: int = 0
288
+
289
+
290
+ class Orchestrator:
291
+ """Coordinates the full evaluation loop for a task.
292
+
293
+ Manages the sandbox, agent, and evaluators to run a complete
294
+ task evaluation with multiple iterations.
295
+ """
296
+
297
+ def __init__(
298
+ self,
299
+ task: TaskDefinition,
300
+ run_dir: Path,
301
+ preservation_mode: PreservationMode = PreservationMode.MOVE_ON_WRITE,
302
+ task_file: Path | None = None,
303
+ stream_callback: StreamCallback | None = None,
304
+ sandbox: Sandbox | None = None,
305
+ *,
306
+ variant_id: str,
307
+ source_yaml: str = "",
308
+ config_lineage: dict[str, ConfigLineageEntry] | None = None,
309
+ replicate_index: int = 0,
310
+ workspace_dir: Path | None = None,
311
+ ):
312
+ """Initialize the orchestrator.
313
+
314
+ Args:
315
+ task: Task definition to evaluate
316
+ run_dir: Per-task directory within a run (e.g., runs/2025-10-09_15-30-45/default/hello_date/00/)
317
+ preservation_mode: How to persist the sandbox after completion
318
+ (NONE / MOVE_ON_WRITE / DIRECT_WRITE). The driver-derived
319
+ default is resolved upstream at the batch dispatch seam.
320
+ task_file: Path to task YAML file (for resolving reference file paths)
321
+ stream_callback: Optional callback for real-time event streaming
322
+ sandbox: Pre-built Sandbox to use directly; if None, creates one from task config and runs the agent
323
+ variant_id: Experiment variant identifier for this task
324
+ source_yaml: Raw YAML text from the task file
325
+ config_lineage: Config lineage dict (dotted-path -> ConfigLineageEntry)
326
+ replicate_index: Zero-indexed trial number (for simulation tasks with n_trials > 1).
327
+ Defaults to 0, which covers single-shot tasks and single-trial simulations.
328
+ workspace_dir: Docker WORKDIR alignment. When set, the agent runs
329
+ in-place at this absolute container path (the task image's WORKDIR) instead of
330
+ run_dir/artifacts/<task>, and the workspace is copied out to run_dir/artifacts/<task>
331
+ at cleanup. Resolved host-side by DockerRunner; None keeps standard behavior.
332
+ Takes precedence over preservation_mode when set.
333
+ """
334
+ self.task = task
335
+ self.run_dir = run_dir
336
+ self.preservation_mode = preservation_mode
337
+ self.workspace_dir = workspace_dir
338
+ self.task_file = task_file
339
+ self.stream_callback = stream_callback
340
+ self.sandbox = sandbox
341
+ self.variant_id = variant_id
342
+ self.source_yaml = source_yaml
343
+ self.config_lineage = config_lineage or {}
344
+ self.replicate_index = replicate_index
345
+
346
+ # Derived paths
347
+ self.report_path = self.run_dir / "task.json"
348
+ self.html_report_path = self.run_dir / "task.html"
349
+ # Clean user<->agent transcript for simulation runs. Written alongside
350
+ # task.log so a human can follow the conversation without the
351
+ # orchestrator noise in between.
352
+ self.conversation_log_path = self.run_dir / "conversation.log"
353
+ # Note: artifacts directory (run_dir/artifacts) is created on-demand during sandbox preservation
354
+
355
+ # Components (initialized in run())
356
+ self.agent: Agent[Any] | None = None
357
+ self.success_checker: SuccessChecker | None = None
358
+
359
+ # API routing (initialized in _setup)
360
+ self.route: ApiRoute | None = None
361
+
362
+ # Result tracking
363
+ self.result: EvaluationResult | None = None
364
+
365
+ # Reference solution cache (loaded on-demand)
366
+ self._reference_code: str | None = None
367
+
368
+ # One-shot flag: emit the "cost budget configured but no cost data" warning
369
+ # exactly once per task even if _check_run_limits fires every turn.
370
+ self._cost_budget_skipped_logged: bool = False
371
+
372
+ # One-shot flag: emit the expected_turns rollup warning exactly once per
373
+ # task run even though _check_expected_turns is called after every turn.
374
+ self._expected_turns_warning_emitted: bool = False
375
+
376
+ # Canonical id shared with run_dir layout, tqdm label, and streaming events.
377
+ self._log_task_id = format_task_log_id(variant_id, task.task_id, replicate_index)
378
+
379
+ @property
380
+ def _agent_name(self) -> str:
381
+ """Agent name for error-context telemetry.
382
+
383
+ The agent kind string for any resolved task (``"none"`` for no-op tasks);
384
+ falls back to ``"none"`` only on the evaluate-only path, where no agent
385
+ is attached. ``str(...)`` handles both built-in ``AgentKind`` members and
386
+ plugin-registered raw-string kinds.
387
+ """
388
+ if self.task.agent is not None and self.task.agent.type is not None:
389
+ return str(self.task.agent.type)
390
+ return AgentKind.NONE.value
391
+
392
+ async def run(self) -> EvaluationResult:
393
+ """Run the complete evaluation.
394
+
395
+ Returns:
396
+ Evaluation result with all details
397
+
398
+ Raises:
399
+ RuntimeError: If evaluation fails catastrophically
400
+ """
401
+ from .logging_config import task_log_handler
402
+
403
+ # Agent must be resolved before reaching the orchestrator. No-op (type: none)
404
+ # tasks are resolved to a NoneAgentConfig like any other agent, so there is
405
+ # no separate "no agent" branch here.
406
+ assert self.task.agent is not None, (
407
+ f"Task '{self.task.task_id}' has no agent config. Ensure experiment resolution ran before orchestration."
408
+ )
409
+ assert self.task.agent.type is not None, (
410
+ f"Task '{self.task.task_id}' has no agent.type. Ensure experiment resolution + CLI overrides "
411
+ "ran before orchestration."
412
+ )
413
+ agent_type = self.task.agent.type
414
+
415
+ start_time = time.time()
416
+ started_at = datetime.now()
417
+
418
+ # Initialize result
419
+ self.result = EvaluationResult(
420
+ task_id=self.task.task_id,
421
+ task_description=self.task.description,
422
+ variant_id=self.variant_id,
423
+ agent_type=agent_type,
424
+ started_at=started_at,
425
+ final_status=FinalStatus.FAILURE, # Will be updated
426
+ iteration_count=0,
427
+ environment_info=get_version_info(),
428
+ )
429
+
430
+ # Calculate task log path
431
+ task_log_file = task_log_path(self.run_dir)
432
+ task_log_file.parent.mkdir(parents=True, exist_ok=True) # noqa: CE002 — mkdir on local FS is nanoseconds
433
+
434
+ # Use context manager for automatic log handler management
435
+ with task_log_handler(task_log_file, task_id=self._log_task_id) as log_tail:
436
+ try:
437
+ # Setup components
438
+ await self._setup()
439
+
440
+ # Run pre-run commands inside the sandbox before the agent starts.
441
+ # A failing command with fail_on_error=True raises RuntimeError,
442
+ # which propagates to the outer except Exception below and lands
443
+ # the run as FinalStatus.ERROR; _run_post_run_commands and
444
+ # _cleanup still execute via the finally block.
445
+ await self._run_pre_run_commands()
446
+
447
+ # Enforce task-level timeout via an OS-thread watchdog that
448
+ # SIGKILLs the in-flight CLI subprocess AND cancels this
449
+ # task. The threaded approach is immune to anyio cancel
450
+ # scopes that were silently swallowing asyncio.wait_for
451
+ # cancellations during long rate-limited API calls.
452
+ task_timeout = self.task.run_limits.task_timeout if self.task.run_limits else None
453
+
454
+ def _kill_agent_subprocess_sync() -> None:
455
+ if self.agent is not None:
456
+ # kill_sync is a synchronous SIGKILL-by-PID, safe to
457
+ # call from a non-asyncio thread.
458
+ with suppress(Exception):
459
+ self.agent.kill_sync()
460
+
461
+ with ThreadedWatchdog(
462
+ timeout_seconds=task_timeout,
463
+ on_timeout=_kill_agent_subprocess_sync,
464
+ asyncio_task_to_cancel=asyncio.current_task(),
465
+ label=f"task_timeout ({self.task.task_id})",
466
+ ) as wd:
467
+ try:
468
+ success = await self._evaluation_loop()
469
+ except asyncio.CancelledError:
470
+ if wd.fired:
471
+ raise TaskTimeoutError(
472
+ task_timeout or 0,
473
+ task_id=self.task.task_id,
474
+ elapsed_seconds=time.time() - start_time,
475
+ ) from None
476
+ raise
477
+ # Belt-and-suspenders: if the loop returned normally but the
478
+ # watchdog fired during post-loop work or the inner coro
479
+ # swallowed the cancel, still classify as TIMEOUT.
480
+ if wd.fired and task_timeout is not None:
481
+ raise TaskTimeoutError(
482
+ task_timeout,
483
+ task_id=self.task.task_id,
484
+ elapsed_seconds=time.time() - start_time,
485
+ )
486
+
487
+ # Update final status
488
+ if success:
489
+ self.result.final_status = FinalStatus.SUCCESS
490
+ elif self.result.max_turns_exhausted:
491
+ self.result.final_status = FinalStatus.MAX_TURNS_EXHAUSTED
492
+ else:
493
+ self.result.final_status = FinalStatus.FAILURE
494
+
495
+ except asyncio.CancelledError:
496
+ # Re-raise cancellation to allow proper task cancellation
497
+ raise
498
+ except TaskTimeoutError as e:
499
+ # Task-level timeout gets a dedicated status (not generic ERROR)
500
+ self.result.final_status = FinalStatus.TIMEOUT
501
+ self.result.error_message = str(e)
502
+
503
+ self.result.error_details = create_error_context(
504
+ error=e,
505
+ task_id=self.task.task_id,
506
+ attempt=max(self.result.iteration_count, 1),
507
+ component="orchestrator.task_timeout",
508
+ agent_name=self._agent_name,
509
+ )
510
+
511
+ logger.error(f"Task timed out: {e}")
512
+ except BudgetExceededError as e:
513
+ # Map token-budget breaches and cost-budget breaches to distinct
514
+ # statuses so per-task records preserve the failure mode.
515
+ if e.budget_name == "usd":
516
+ self.result.final_status = FinalStatus.COST_BUDGET_EXCEEDED
517
+ component = "orchestrator.run_limits.cost"
518
+ else:
519
+ self.result.final_status = FinalStatus.TOKEN_BUDGET_EXCEEDED
520
+ component = "orchestrator.run_limits.tokens"
521
+ self.result.error_message = str(e)
522
+ self.result.error_details = create_error_context(
523
+ error=e,
524
+ task_id=self.task.task_id,
525
+ attempt=max(self.result.iteration_count, 1),
526
+ component=component,
527
+ agent_name=self._agent_name,
528
+ )
529
+ logger.warning(f"Run limit exceeded: {e}")
530
+ except Exception as e:
531
+ # Handle catastrophic errors
532
+ self.result.final_status = FinalStatus.ERROR
533
+ self.result.error_message = str(e)
534
+
535
+ # Determine which component failed (setup vs. iteration N)
536
+ if self.result.iteration_count == 0:
537
+ failed_component = "orchestrator.setup"
538
+ else:
539
+ failed_component = f"orchestrator.iteration_{self.result.iteration_count}"
540
+
541
+ # Capture detailed error context
542
+ self.result.error_details = create_error_context(
543
+ error=e,
544
+ task_id=self.task.task_id,
545
+ attempt=max(self.result.iteration_count, 1), # Actual iteration attempt (1-indexed)
546
+ component=failed_component,
547
+ agent_name=self._agent_name,
548
+ )
549
+
550
+ logger.error(f"Evaluation failed: {e}", exc_info=True)
551
+
552
+ finally:
553
+ # BEFORE post-run/cleanup: needs the live sandbox to resolve
554
+ # the agent-aligned `uip`, and post-task tool state on disk.
555
+ self._refresh_runtime_tool_versions()
556
+ await self._run_post_run_commands()
557
+ await self._cleanup()
558
+ # Capture the sanitised log tail AFTER teardown so any errors
559
+ # logged during post-run / cleanup also land in the report,
560
+ # but BEFORE _finalize_result so task.json includes the field.
561
+ # Allowlist non-success terminal statuses; SUCCESS and
562
+ # MAX_TURNS_EXHAUSTED skip the tail to keep task.json compact.
563
+ if self.result.final_status in {
564
+ FinalStatus.ERROR,
565
+ FinalStatus.TIMEOUT,
566
+ FinalStatus.FAILURE,
567
+ FinalStatus.TOKEN_BUDGET_EXCEEDED,
568
+ FinalStatus.COST_BUDGET_EXCEEDED,
569
+ }:
570
+ self.result.error_log_tail = log_tail.get_text() or None
571
+ self._finalize_result(start_time)
572
+
573
+ return self.result
574
+
575
+ def _finalize_result(self, start_time: float) -> None:
576
+ """Finalize the evaluation result: scores, telemetry, and persistence."""
577
+ if not self.result:
578
+ return
579
+
580
+ # Every resolved task carries an agent config (no-op tasks resolve to a
581
+ # NoneAgentConfig); a missing one is a resolution bug. The evaluate-only
582
+ # path doesn't reach here with task.agent unset.
583
+ if self.task.agent is None:
584
+ logger.error("Cannot finalize result: task.agent is None")
585
+ return
586
+
587
+ self.result.completed_at = datetime.now()
588
+ self.result.duration_seconds = time.time() - start_time
589
+
590
+ # Weighted score. This call site is wrapped because _finalize_result runs
591
+ # inside run()'s finally — an unguarded raise here would skip persistence and
592
+ # lose task.json. The other calculate_weighted_score calls (the simulation
593
+ # path) run inside run()'s try, whose broad `except Exception` already converts
594
+ # a raise into a populated ERROR result, so they intentionally stay unwrapped.
595
+ try:
596
+ self.result.calculate_weighted_score(self.task.success_criteria)
597
+ except ValueError as e:
598
+ logger.error("Weighted-score computation failed; marking row ERROR: %s", e, exc_info=True)
599
+ self.result.weighted_score = None
600
+ self.result.final_status = FinalStatus.ERROR
601
+ self.result.error_message = str(e)
602
+ self.result.error_details = create_error_context(
603
+ error=e,
604
+ task_id=self.task.task_id,
605
+ attempt=max(self.result.iteration_count, 1),
606
+ component="orchestrator.finalize.weighted_score",
607
+ agent_name=self._agent_name,
608
+ )
609
+
610
+ # Command statistics
611
+ if self.result.iterations:
612
+ self.result.command_stats = calculate_command_statistics(self.result.iterations)
613
+
614
+ # Resolve model_used (last turn with model wins, then agent config)
615
+ if self.result.iterations:
616
+ for turn in reversed(self.result.iterations):
617
+ if turn.model_used:
618
+ self.result.model_used = turn.model_used
619
+ break
620
+ if not self.result.model_used and self.task.agent is not None and self.task.agent.model:
621
+ self.result.model_used = self.task.agent.model
622
+
623
+ # Aggregate token usage
624
+ self._aggregate_token_usage()
625
+
626
+ # Record whether per-turn cost data was available when a cost budget was set.
627
+ # Lets users audit whether a configured max_usd budget was actually enforceable.
628
+ if self.task.run_limits is not None and self.task.run_limits.max_usd is not None:
629
+ any_cost_reported = any(
630
+ t.token_usage is not None and t.token_usage.total_cost_usd is not None for t in self.result.iterations
631
+ )
632
+ self.result.environment_info["cost_data_available"] = any_cost_reported
633
+
634
+ if self.result.iterations:
635
+ self.result.total_assistant_turns = sum(t.assistant_turn_count for t in self.result.iterations)
636
+
637
+ # command_stats counts crashed partials too — they're real executed work.
638
+ if self.result.iterations and self.result.command_stats:
639
+ actual_cmds = self.result.command_stats.total_commands
640
+ self.result.actual_commands = actual_cmds
641
+ if self.task.expected_commands is not None:
642
+ self.result.expected_commands = self.task.expected_commands
643
+ self.result.commands_efficiency = compute_commands_efficiency(actual_cmds, self.task.expected_commands)
644
+
645
+ # SDK options snapshot
646
+ if self.agent:
647
+ self.result.sdk_options = self.agent.get_sdk_options()
648
+
649
+ # Task config record (warnings=False: discriminated unions produce benign warnings)
650
+ self.result.task_config = TaskConfigRecord(
651
+ resolved=self.task.model_dump(warnings=False),
652
+ source_yaml=self.source_yaml,
653
+ source_file=str(self.task_file) if self.task_file else None,
654
+ lineage=self.config_lineage,
655
+ )
656
+
657
+ # Terminal per-task summary line. Emitted before report writes so a
658
+ # write failure cannot swallow the one-line outcome.
659
+ logger.info(
660
+ "Task finished: status=%s duration=%.1fs score=%.3f iterations=%d",
661
+ self.result.final_status.value,
662
+ self.result.duration_seconds or 0.0,
663
+ self.result.weighted_score or 0.0,
664
+ self.result.iteration_count,
665
+ )
666
+
667
+ # Usage telemetry (non-fatal; placed before persistence since track_event
668
+ # cannot raise). For the docker driver this in-process emit runs INSIDE the
669
+ # container where telemetry is off (the connection-string env vars aren't
670
+ # forwarded), so the host emits the event from batch.py instead — see
671
+ # build_task_event. Non-docker tasks finalize on the host and emit here.
672
+ from .telemetry import track_event
673
+
674
+ driver = self.task.sandbox.driver if self.task.sandbox else ""
675
+ name, props = build_task_event(self.result, driver=driver, variant_id=self.variant_id or "")
676
+ track_event(name, props)
677
+
678
+ # Persist
679
+ self.report_path.parent.mkdir(parents=True, exist_ok=True) # noqa: CE002 — mkdir on local FS is nanoseconds
680
+
681
+ # Spill any judge transcripts to sibling judge-<idx>.yaml files BEFORE
682
+ # we dump task.json, so transcript_path is set on each judge result.
683
+ # The inline `transcript` field stays in memory — HTML rendering below
684
+ # uses it directly. We strip it from the JSON dump via `exclude=...`.
685
+ from .evaluation.judge_persistence import spill_judge_transcripts
686
+
687
+ spill_judge_transcripts(self.result, self.report_path.parent)
688
+
689
+ # Atomic write: tmp file + os.replace. A SIGKILL mid-write (e.g. the
690
+ # docker-driver host-heartbeat watchdog firing) would otherwise leave
691
+ # a truncated task.json that the host parses as malformed-JSON rather
692
+ # than as "no result", conflating two distinct failure modes.
693
+ import os as _os
694
+
695
+ report_tmp = self.report_path.with_suffix(self.report_path.suffix + ".tmp")
696
+ report_tmp.write_text( # noqa: CE002 — small JSON write at end of run
697
+ self.result.model_dump_json(
698
+ indent=2,
699
+ # Strip inline transcripts: they live in sibling judge-<idx>.yaml
700
+ # next to task.json, referenced by transcript_path. Excluding
701
+ # `transcript` here avoids ~20-100 KB of bloat per judge result
702
+ # in the row record without losing any data.
703
+ exclude={"success_criteria_results": {"__all__": {"transcript"}}},
704
+ ),
705
+ encoding="utf-8",
706
+ )
707
+ _os.replace(report_tmp, self.report_path)
708
+
709
+ # Also emit an HTML trace/report alongside task.json. HTML failure must
710
+ # never mask the underlying run outcome — write_task_html logs and
711
+ # returns None on failure.
712
+ from .reports_html import write_task_html
713
+
714
+ write_task_html(self.result, self.html_report_path)
715
+
716
+ def _check_run_limits(self, *, iteration: int) -> None:
717
+ """Raise BudgetExceededError if any RunLimits budget is exceeded.
718
+
719
+ Called after each completed turn. Aggregates across self.result.iterations.
720
+ No-op when self.task.run_limits is None.
721
+ """
722
+ assert self.result is not None
723
+ limits = self.task.run_limits
724
+ if limits is None:
725
+ return
726
+
727
+ usages = [t.token_usage for t in self.result.iterations if t.token_usage is not None]
728
+ if not usages:
729
+ return
730
+
731
+ input_tokens = sum(u.uncached_input_tokens for u in usages)
732
+ if limits.count_cache_creation:
733
+ input_tokens += sum(u.cache_creation_input_tokens for u in usages)
734
+ if limits.count_cached_input:
735
+ input_tokens += sum(u.cache_read_input_tokens for u in usages)
736
+ output_tokens = sum(u.output_tokens for u in usages)
737
+ total_tokens = input_tokens + output_tokens
738
+
739
+ if limits.max_input_tokens is not None and input_tokens > limits.max_input_tokens:
740
+ raise BudgetExceededError(
741
+ "input_tokens",
742
+ actual=input_tokens,
743
+ limit=limits.max_input_tokens,
744
+ task_id=self.task.task_id,
745
+ iteration=iteration,
746
+ )
747
+ if limits.max_output_tokens is not None and output_tokens > limits.max_output_tokens:
748
+ raise BudgetExceededError(
749
+ "output_tokens",
750
+ actual=output_tokens,
751
+ limit=limits.max_output_tokens,
752
+ task_id=self.task.task_id,
753
+ iteration=iteration,
754
+ )
755
+ if limits.max_total_tokens is not None and total_tokens > limits.max_total_tokens:
756
+ raise BudgetExceededError(
757
+ "total_tokens",
758
+ actual=total_tokens,
759
+ limit=limits.max_total_tokens,
760
+ task_id=self.task.task_id,
761
+ iteration=iteration,
762
+ )
763
+
764
+ if limits.max_usd is not None:
765
+ costs = [u.total_cost_usd for u in usages if u.total_cost_usd is not None]
766
+ if not costs:
767
+ if not self._cost_budget_skipped_logged:
768
+ logger.warning(
769
+ "[%s] max_usd budget configured but no turn reported cost; skipping cost check",
770
+ self.task.task_id,
771
+ )
772
+ self._cost_budget_skipped_logged = True
773
+ return
774
+ total_cost = sum(costs)
775
+ if total_cost > limits.max_usd:
776
+ raise BudgetExceededError(
777
+ "usd",
778
+ actual=total_cost,
779
+ limit=limits.max_usd,
780
+ task_id=self.task.task_id,
781
+ iteration=iteration,
782
+ )
783
+
784
+ def _check_expected_turns(self, *, iteration: int) -> None:
785
+ """Emit a one-shot warning if visible turns exceed expected_turns.
786
+
787
+ Soft sibling of ``_check_run_limits.max_turns``: never aborts the run.
788
+ ``max_turns`` remains the hard cap (enforced inside the SDK). A
789
+ "turn" here is one timeline entry: each tool call plus the final
790
+ reply when present — the same metric evalboard renders. Cumulative
791
+ across iterations so simulation/dialog tasks compare against the
792
+ budget the user set.
793
+ """
794
+ if self.result is None:
795
+ return
796
+ limits = self.task.run_limits
797
+ if limits is None or limits.expected_turns is None:
798
+ return
799
+ if self._expected_turns_warning_emitted:
800
+ return
801
+ from .reports_stats import visible_turn_count
802
+
803
+ total = visible_turn_count(self.result)
804
+ if total > limits.expected_turns:
805
+ logger.warning(
806
+ "Visible turns (%d) exceeded expected_turns (%d) at iteration %d "
807
+ + "for task %s. Run continues — max_turns remains the hard cap.",
808
+ total,
809
+ limits.expected_turns,
810
+ iteration,
811
+ self.task.task_id,
812
+ )
813
+ self._expected_turns_warning_emitted = True
814
+
815
+ def _aggregate_token_usage(self) -> None:
816
+ """Aggregate token usage from turns, storing on self.result.
817
+
818
+ Per-turn ``TurnRecord.token_usage`` is filled by the agent SDK's
819
+ ResultMessage on DirectRoute / BedrockRoute (the bundled CLI parses
820
+ the response's ``usage`` field). Every iteration carries the right
821
+ per-turn value here, and we just sum them. Judge / sub-agent token
822
+ usage is captured on
823
+ the corresponding ``JudgeCriterionResult.token_usage`` and is
824
+ intentionally NOT included in this aggregate — it represents the
825
+ main agent's bill, not the eval-machinery overhead.
826
+ """
827
+ assert self.result is not None
828
+
829
+ # Include crashed=True partials: each API call is billed independently.
830
+ if self.result.iterations:
831
+ usages = [t.token_usage for t in self.result.iterations if t.token_usage is not None]
832
+ if usages:
833
+ costs = [u.total_cost_usd for u in usages if u.total_cost_usd is not None]
834
+ self.result.total_token_usage = TokenUsage(
835
+ uncached_input_tokens=sum(u.uncached_input_tokens for u in usages),
836
+ output_tokens=sum(u.output_tokens for u in usages),
837
+ cache_creation_input_tokens=sum(u.cache_creation_input_tokens for u in usages),
838
+ cache_read_input_tokens=sum(u.cache_read_input_tokens for u in usages),
839
+ total_cost_usd=sum(costs) if costs else None,
840
+ )
841
+
842
+ async def _setup(self) -> None:
843
+ """Set up all components for evaluation.
844
+
845
+ Raises:
846
+ RuntimeError: If setup fails
847
+ """
848
+ if self.sandbox is not None:
849
+ # evaluate-only mode: sandbox already set up, skip agent
850
+ assert self.result is not None
851
+ self.result.sandbox_path = str(self.sandbox.sandbox_dir)
852
+
853
+ self.route = resolve_route(settings)
854
+ logger.info("API routing: %s", _format_routing(self.route))
855
+ self.success_checker = SuccessChecker(self.sandbox, route=self.route)
856
+ self._record_route_environment_info()
857
+ return
858
+
859
+ # Validate API keys (agent guaranteed non-None after experiment resolution).
860
+ # validate_api_keys exempts the no-op agent (type: none) internally — it
861
+ # makes no API call, so it needs no agent keys.
862
+ assert self.task.agent is not None and self.task.agent.type is not None
863
+ settings.validate_api_keys(str(self.task.agent.type))
864
+
865
+ # Create sandbox with retry logic
866
+ task_dir = self.task_file.parent.resolve() if self.task_file else None
867
+ self.sandbox = Sandbox(self.task.sandbox, task_id=self.task.task_id, task_dir=task_dir)
868
+
869
+ # workspace_dir (docker WORKDIR alignment) wins: run the agent in-place at
870
+ # the image's own WORKDIR so its inputs/verifier paths line up, then copy
871
+ # the workspace out to run_dir/artifacts in _cleanup. Otherwise:
872
+ # DIRECT_WRITE runs the sandbox straight in run_dir/artifacts/<task_id>
873
+ # (no end-of-run copy/move); MOVE_ON_WRITE / NONE run in a tempdir —
874
+ # this keeps the run off run_dir on shared hosts, where parent-dir
875
+ # node_modules can contaminate Node tool resolution (MST-9795).
876
+ if self.workspace_dir is not None:
877
+ if self.preservation_mode == PreservationMode.DIRECT_WRITE:
878
+ logger.debug(
879
+ "workspace_dir=%s overrides DIRECT_WRITE; running in-place + capturing out.",
880
+ self.workspace_dir,
881
+ )
882
+ direct_target = self.workspace_dir
883
+ elif self.preservation_mode == PreservationMode.DIRECT_WRITE:
884
+ direct_target = self.run_dir / "artifacts" / self.task.task_id
885
+ else:
886
+ direct_target = None
887
+ # DIRECT_WRITE deliberately does NOT clear the target dir, so a reused
888
+ # --run-dir (or --resume) can leave a prior run's files alongside this
889
+ # run's outputs and silently perturb file-based criteria. Surface it.
890
+ # Skipped in workspace_dir mode: a WORKDIR (/root, /app) legitimately holds
891
+ # the image's baked inputs — not stale prior-run files — so the warning
892
+ # would fire on essentially every run and cry wolf.
893
+ if (
894
+ self.workspace_dir is None
895
+ and direct_target is not None
896
+ and direct_target.exists()
897
+ and any(direct_target.iterdir())
898
+ ):
899
+ logger.warning(
900
+ "DIRECT_WRITE target %s already exists and is non-empty; "
901
+ + "stale files from a prior run are not cleared and may affect criteria.",
902
+ direct_target,
903
+ )
904
+
905
+ async def _setup_sandbox() -> Any:
906
+ assert self.sandbox is not None
907
+ return await asyncio.to_thread(self.sandbox.setup, direct_target)
908
+
909
+ sandbox_dir = await execute_with_retry(
910
+ operation=_setup_sandbox,
911
+ operation_name="Sandbox setup",
912
+ context={"task_id": self.task.task_id, "component": "sandbox"},
913
+ )
914
+
915
+ # Assert result is initialized (set in run())
916
+ assert self.result is not None, "Result not initialized"
917
+ self.result.sandbox_path = str(sandbox_dir)
918
+
919
+ # Determine API routing from settings.api_backend enum
920
+ self.route = resolve_route(settings)
921
+ logger.info("API routing: %s", _format_routing(self.route))
922
+ self.success_checker = SuccessChecker(self.sandbox, route=self.route)
923
+
924
+ # Create and start the agent. For a no-op (type: none) task this dispatches
925
+ # to NoOpAgent, whose start/communicate/stop are no-ops — the orchestrator
926
+ # runs the normal lifecycle without any agentless branching, and the
927
+ # criteria are checked against the pre_run-prepared sandbox.
928
+ assert self.task.agent is not None
929
+ self.agent = await self._create_agent()
930
+
931
+ env_path_prepend = [str(p) for p in self.sandbox.resolved_mock_path_dirs]
932
+ plugin_tools_dir = self.sandbox.plugin_tools_dir
933
+
934
+ async def _start_agent() -> None:
935
+ assert self.agent is not None
936
+ await self.agent.start(
937
+ str(sandbox_dir),
938
+ env_path_prepend=env_path_prepend,
939
+ plugin_tools_dir=plugin_tools_dir,
940
+ )
941
+
942
+ await execute_with_retry(
943
+ operation=_start_agent,
944
+ operation_name="Agent start",
945
+ context={"task_id": self.task.task_id, "component": "agent", "agent_name": self._agent_name},
946
+ )
947
+
948
+ # Save agent config on result (copy to prevent mutation of shared reference)
949
+ self.result.agent_config = self.task.agent.model_copy(deep=True)
950
+
951
+ # Re-capture environment_info with sandbox path (for CLAUDE.md hash)
952
+ self.result.environment_info = get_version_info(
953
+ sandbox_path=Path(self.result.sandbox_path) if self.result.sandbox_path else None,
954
+ )
955
+
956
+ # Record API routing mode (shared between normal + evaluate-only paths)
957
+ self._record_route_environment_info()
958
+
959
+ # Add installed tool versions (from npm packages etc.)
960
+ if self.sandbox and self.sandbox.installed_tool_versions:
961
+ self.result.environment_info["installed_tools"] = self.sandbox.installed_tool_versions
962
+
963
+ def _sync_sandbox_command_path_with_agent(self) -> None:
964
+ """Align criteria command PATH with the PATH used for the last agent query.
965
+
966
+ Scope: called from the per-turn happy path in ``run_iteration`` /
967
+ ``run_simulation`` *after* a successful ``_communicate_with_retry``.
968
+ That means three pre-existing gaps remain (none introduced by this
969
+ change):
970
+
971
+ - **Agent crash / turn timeout** — the sync is skipped because the
972
+ method never returns; criteria fall back to ambient
973
+ ``os.environ['PATH']``. Acceptable: a crashed agent's SDK PATH may
974
+ itself be unreliable.
975
+ - **Evaluate-only mode** (``orchestrator.run_evaluation_only``) — no
976
+ agent turn runs, so no sync. Criteria use ambient PATH, same as
977
+ before this change.
978
+ - **Before the first turn** — same reason; first criterion check
979
+ always runs after at least one turn under the normal flow.
980
+
981
+ Sandbox-setup-time sync (using ``SandboxConfig.mock_path_dirs``) was
982
+ considered but rejected: the agent SDK's effective PATH is only
983
+ knowable after the SDK initializes, so a setup-time sync would
984
+ capture only the configured prepends, not the full agent env.
985
+
986
+ ``Agent.get_sdk_options()`` is declared synchronous on the ABC
987
+ (``dict[str, Any] | None``). ``AsyncMock``-based test fixtures
988
+ return a coroutine for *any* attribute access regardless of the
989
+ declared signature; ``isawaitable`` plus ``coroutine.close()``
990
+ prevents leaking ``RuntimeWarning: coroutine was never awaited``
991
+ from those fixtures into unrelated tests. That is a test-fixture
992
+ concern, not a production contract violation — logged at DEBUG.
993
+ Returning a non-dict-and-non-None *is* a production contract
994
+ violation and is logged at WARNING.
995
+ """
996
+ if self.agent is None or self.sandbox is None:
997
+ return
998
+ sdk_options = self.agent.get_sdk_options()
999
+ if sdk_options is None:
1000
+ return
1001
+ if isawaitable(sdk_options):
1002
+ close = getattr(sdk_options, "close", None)
1003
+ if callable(close):
1004
+ # ``coroutine.close()`` only documents ``RuntimeError``
1005
+ # (raised when invoked on a currently-running coroutine,
1006
+ # which cannot apply here). Narrow the suppress accordingly
1007
+ # so genuine unexpected exceptions still propagate.
1008
+ with suppress(RuntimeError):
1009
+ close()
1010
+ logger.debug(
1011
+ "Agent.get_sdk_options() returned an awaitable; skipping PATH sync."
1012
+ + " (Typical when tests stub the agent with AsyncMock.)"
1013
+ )
1014
+ return
1015
+ if not isinstance(sdk_options, dict):
1016
+ logger.warning(
1017
+ "Agent.get_sdk_options() returned non-dict %r; skipping PATH sync.",
1018
+ type(sdk_options).__name__,
1019
+ )
1020
+ return
1021
+ sdk_env = sdk_options.get("env")
1022
+ if not isinstance(sdk_env, dict):
1023
+ return
1024
+ path = sdk_env.get("PATH")
1025
+ if isinstance(path, str) and path:
1026
+ self.sandbox.set_command_base_path(path)
1027
+
1028
+ def _record_route_environment_info(self) -> None:
1029
+ """Persist resolved route + judge transport into ``result.environment_info``.
1030
+
1031
+ Called from both the normal and evaluate-only setup paths so audit
1032
+ tooling sees identical keys regardless of run mode (catches the
1033
+ evaluate-only short-circuit before the recording block).
1034
+ """
1035
+ assert self.result is not None
1036
+ assert self.route is not None
1037
+ self.result.environment_info["api_routing"] = ROUTE_NAMES[type(self.route)]
1038
+ if isinstance(self.route, BedrockRoute):
1039
+ self.result.environment_info["aws_region"] = self.route.region
1040
+ if self.route.model:
1041
+ self.result.environment_info["bedrock_model"] = self.route.model
1042
+ elif isinstance(self.route, DirectRoute):
1043
+ # Record which transport llm_judge will use under DirectRoute so the
1044
+ # choice is visible in run artifacts (and not just the startup log).
1045
+ self.result.environment_info["judge_transport"] = self.route.judge_transport or "none"
1046
+ # Agent-specific routing (e.g. Codex custom-endpoint / Azure). No-op for
1047
+ # the evaluate-only path (no agent) and for agents that add nothing.
1048
+ if self.agent is not None:
1049
+ self.result.environment_info.update(self.agent.get_environment_info())
1050
+
1051
+ def _refresh_runtime_tool_versions(self) -> None:
1052
+ """Re-capture uip shell + tool-plugin versions after the task ran.
1053
+
1054
+ ``get_version_info`` runs at setup time and therefore records the
1055
+ *pre-task* state. The UiPath CLI auto-installs/upgrades its
1056
+ ``@uipath/*-tool`` plugins on first use inside the task (under
1057
+ ``--driver docker`` containers start with image-baked or missing
1058
+ tools), so what the agent and criteria actually executed is only
1059
+ knowable once the task is done (coder_eval#366 follow-up). Resolve
1060
+ through the sandbox's agent-aligned PATH so we report the binary the
1061
+ task commands ran, not whichever ``uip`` this process sees first.
1062
+
1063
+ The two keys update independently: a run can end with a post-task
1064
+ ``cli_version`` but setup-time ``tool_plugins`` (e.g. the plugin dir
1065
+ was lost to a PATH change while the shell still resolves) — each key
1066
+ keeps its setup-time value only when its own post-task resolution
1067
+ came back empty.
1068
+
1069
+ Best-effort: skipped when the sandbox is gone; never raises.
1070
+ """
1071
+ if self.result is None or self.sandbox is None:
1072
+ return
1073
+ try:
1074
+ # Re-derive: auto-install may have (re)created the plugin dir mid-task.
1075
+ self.sandbox.refresh_plugin_tools_dir()
1076
+ versions = runtime_uip_versions(self.sandbox.plugin_tools_dir, self.sandbox.uip_search_path)
1077
+ # Keep the setup-time values when post-task resolution comes back
1078
+ # empty (e.g. `uip` gone from PATH) — they are the better estimate
1079
+ # of what the task ran than "unknown"/{}. Gate on looks_like_version
1080
+ # (not a "" / "unknown" denylist) so this sink shares the one
1081
+ # version-shape contract with _uip_version and the run-level join.
1082
+ if looks_like_version(versions.get("cli_version")):
1083
+ self.result.environment_info["cli_version"] = versions["cli_version"]
1084
+ if versions.get("tool_plugins"):
1085
+ self.result.environment_info["tool_plugins"] = versions["tool_plugins"]
1086
+ except Exception as exc:
1087
+ logger.debug("Failed to refresh runtime tool versions: %s", exc)
1088
+
1089
+ async def _create_agent(self) -> Agent[Any]:
1090
+ """Create the appropriate agent based on task configuration.
1091
+
1092
+ Uses the AgentRegistry factory to dispatch to registered agent implementations.
1093
+
1094
+ Returns:
1095
+ Agent instance
1096
+
1097
+ Raises:
1098
+ ValueError: If agent type is not supported
1099
+ TypeError: If config doesn't match agent's expected type
1100
+ """
1101
+ from coder_eval.agents import create_agent
1102
+ from coder_eval.plugins import ensure_plugins_loaded
1103
+
1104
+ # Safety net for the production agent-construction path: create_agent no
1105
+ # longer self-loads (to keep plugins -> registry a one-way import edge), so
1106
+ # ensure plugin kinds are registered here before dispatch.
1107
+ ensure_plugins_loaded()
1108
+ assert self.task.agent is not None
1109
+ assert self.task.agent.type is not None
1110
+ return create_agent(self.task.agent.type, self.task.agent, route=self.route)
1111
+
1112
+ async def _communicate_with_retry(
1113
+ self,
1114
+ *,
1115
+ prompt: str,
1116
+ iteration: int,
1117
+ operation_label: str,
1118
+ ) -> TurnRecord:
1119
+ """Run ``agent.communicate`` with retry, partial-preservation, and a per-attempt timeout.
1120
+
1121
+ Shared by the criteria-feedback and simulation loops. Crashed partials
1122
+ from ``AgentCrashError`` / ``TurnTimeoutError`` are appended to
1123
+ ``self.result.iterations`` via the ``on_attempt_error`` hook (terminal
1124
+ failures included) so observational criteria still see them. Each
1125
+ attempt gets a fresh ``turn_timeout``; ``TurnTimeoutError`` is
1126
+ ``AGENT_TIMEOUT`` (``max_retries=0``) so it still terminates after
1127
+ one attempt.
1128
+ """
1129
+ assert self.agent is not None
1130
+ assert self.task.agent is not None
1131
+ assert self.result is not None
1132
+
1133
+ agent = self.agent
1134
+ # Local rebind: pyright doesn't carry "is not None" narrowing into closures.
1135
+ result = self.result
1136
+ run_limits = self.task.run_limits
1137
+ turn_timeout = run_limits.turn_timeout if run_limits else None
1138
+ max_turns = run_limits.max_turns if run_limits else None
1139
+
1140
+ agent_callback: StreamCallback | None = None
1141
+ if self.stream_callback is not None:
1142
+ agent_callback = TaskScopedCallback(self.stream_callback, self._log_task_id)
1143
+
1144
+ def _drain_pending_turn(*, attempt: int) -> None:
1145
+ """Read agent.pending_turn and, if set, append it to result.iterations."""
1146
+ partial = agent.pending_turn
1147
+ if partial is not None:
1148
+ result.iterations.append(partial)
1149
+ logger.debug(
1150
+ "[%s] Drained partial turn record (attempt %d, iteration %d): %d commands",
1151
+ self.task.task_id,
1152
+ attempt + 1,
1153
+ iteration,
1154
+ len(partial.commands),
1155
+ )
1156
+ else:
1157
+ logger.debug(
1158
+ "[%s] No pending_turn to drain on attempt %d (iteration %d)",
1159
+ self.task.task_id,
1160
+ attempt + 1,
1161
+ iteration,
1162
+ )
1163
+
1164
+ async def _on_attempt_failure(
1165
+ err: Exception,
1166
+ attempt: int,
1167
+ ) -> None:
1168
+ if not isinstance(err, (AgentCrashError, TurnTimeoutError)):
1169
+ return
1170
+ _drain_pending_turn(attempt=attempt)
1171
+ try:
1172
+ await agent.discard_pending_turn()
1173
+ except Exception:
1174
+ logger.warning(
1175
+ "[%s] discard_pending_turn raised on attempt %d",
1176
+ self.task.task_id,
1177
+ attempt + 1,
1178
+ exc_info=True,
1179
+ )
1180
+
1181
+ async def _communicate_attempt() -> TurnRecord:
1182
+ coro = agent.communicate(
1183
+ prompt,
1184
+ stream_callback=agent_callback,
1185
+ timeout=turn_timeout,
1186
+ max_turns=max_turns,
1187
+ )
1188
+ if turn_timeout is None:
1189
+ return await coro
1190
+ # Grace buffer: agent's in-band watchdog (sets pending_turn) must beat
1191
+ # wait_for cancel so the slot is populated before we give up.
1192
+ outer_timeout = turn_timeout + _WAIT_FOR_GRACE_SECONDS
1193
+ try:
1194
+ return await asyncio.wait_for(coro, timeout=outer_timeout)
1195
+ except TimeoutError:
1196
+ # Watchdog wedged or too slow. Kill only — drain + discard happen
1197
+ # in _on_attempt_failure when this TurnTimeoutError propagates up.
1198
+ try:
1199
+ await agent.kill()
1200
+ except Exception:
1201
+ logger.warning(
1202
+ "[%s] agent.kill() raised on wait_for backstop path",
1203
+ self.task.task_id,
1204
+ exc_info=True,
1205
+ )
1206
+ raise TurnTimeoutError(
1207
+ turn_timeout,
1208
+ task_id=self.task.task_id,
1209
+ iteration=iteration,
1210
+ ) from None
1211
+
1212
+ turn_record = await execute_with_retry(
1213
+ operation=_communicate_attempt,
1214
+ operation_name=operation_label,
1215
+ context={
1216
+ "task_id": self.task.task_id,
1217
+ "component": "agent",
1218
+ "agent_name": self._agent_name,
1219
+ },
1220
+ on_attempt_error=_on_attempt_failure,
1221
+ )
1222
+ assert turn_record is not None # execute_with_retry returns the turn or raises
1223
+ return turn_record
1224
+
1225
+ @staticmethod
1226
+ def _accumulate_judge_usage(
1227
+ criteria_results: list[CriterionResult],
1228
+ accum: dict[tuple[int, str], TokenUsage],
1229
+ ) -> None:
1230
+ """Fold each turn's per-judge token usage into a dialog-wide running total.
1231
+
1232
+ In simulation ``every_turn`` / ``both`` mode the orchestrator replaces
1233
+ ``success_criteria_results`` on every turn, and each fresh
1234
+ ``JudgeCriterionResult.token_usage`` carries only that turn's judge call
1235
+ (the snapshot/diff is taken per check). Without accumulation only the
1236
+ final turn's slice would survive on the persisted result. We keep a
1237
+ per-criterion running sum and rewrite each judge result's
1238
+ ``token_usage`` to the cumulative total so persisted billing reflects
1239
+ every judge call across the dialog.
1240
+
1241
+ Ledger key is ``(position, criterion_type)`` — a stable criterion
1242
+ identity. ``check_all`` rebuilds ``success_criteria_results`` in the
1243
+ same order as ``task.success_criteria`` every turn (the positional
1244
+ alignment ``calculate_weighted_score`` enforces via ``zip(strict=True)``),
1245
+ so ``position`` is stable across turns; pairing it with the type makes
1246
+ the identity self-documenting and keeps two same-type judges distinct.
1247
+ """
1248
+ for i, r in enumerate(criteria_results):
1249
+ if not isinstance(r, JudgeCriterionResult):
1250
+ continue
1251
+ key = (i, r.criterion_type)
1252
+ prior = accum.get(key)
1253
+ turn_usage = r.token_usage
1254
+ if turn_usage is not None and not turn_usage.is_empty():
1255
+ combined = prior + turn_usage if prior is not None else turn_usage
1256
+ accum[key] = combined
1257
+ r.token_usage = combined
1258
+ elif prior is not None:
1259
+ # No fresh judge tokens this check — carry the running total
1260
+ # forward so it isn't dropped from the latest results list.
1261
+ r.token_usage = prior
1262
+
1263
+ async def _evaluation_loop(self) -> bool:
1264
+ """Run the main evaluation loop.
1265
+
1266
+ Returns:
1267
+ True if task succeeded, False otherwise
1268
+ """
1269
+ assert self.success_checker is not None, "Success checker not initialized"
1270
+ assert self.result is not None, "Result not initialized"
1271
+ # Every resolved task carries an agent config (no-op tasks resolve to a
1272
+ # NoneAgentConfig and run via NoOpAgent).
1273
+ assert self.task.agent is not None
1274
+
1275
+ if self.agent is None:
1276
+ # No agent attached: evaluate-only re-grade of a completed sandbox.
1277
+ # (No-op tasks have a NoOpAgent here, so they take the normal path
1278
+ # below.) Check the criteria directly against the sandbox.
1279
+ assert self.success_checker is not None
1280
+ assert self.result is not None
1281
+ unsupported = [c.type for c in self.task.success_criteria if c.requires_agent]
1282
+ if unsupported:
1283
+ # Only reachable on the evaluate-only path (no agent attached):
1284
+ # re-grading a completed agent run whose trajectory is gone.
1285
+ logger.warning(
1286
+ "Criteria %s require agent execution; results may be incomplete with no agent",
1287
+ unsupported,
1288
+ )
1289
+ self.result.iteration_count = 1
1290
+ # Load reference in evaluate-only mode too: judge criteria with
1291
+ # include_reference=true expect this populated even when no agent
1292
+ # runs. The agent-driven branch below has the same call.
1293
+ reference_code, reference_dir, self._reference_code = load_reference(
1294
+ task=self.task,
1295
+ task_file=self.task_file,
1296
+ cached_reference=self._reference_code,
1297
+ )
1298
+ criteria_results = await asyncio.to_thread(
1299
+ self.success_checker.check_all,
1300
+ self.task.success_criteria,
1301
+ reference_code=reference_code,
1302
+ reference_dir=reference_dir,
1303
+ turn_records=self.result.iterations,
1304
+ )
1305
+ self.result.success_criteria_results = criteria_results
1306
+ return self.result.all_criteria_passed(self.task.success_criteria)
1307
+
1308
+ # Working directory context prepended to every prompt (including feedback).
1309
+ # The agent resumes its session between iterations via session_id.
1310
+ assert self.sandbox is not None and self.sandbox.sandbox_dir is not None
1311
+ sandbox_dir = self.sandbox.sandbox_dir
1312
+
1313
+ # When a SimulationConfig is present and enabled, replace the
1314
+ # criteria-feedback iteration loop with a multi-turn dialog between
1315
+ # the agent and an LLM-simulated user. The single-shot loop below is
1316
+ # skipped entirely — simulated tasks run exactly one dialog per call.
1317
+ if self.task.simulation is not None and self.task.simulation.enabled:
1318
+ # initial_prompt is optional in simulation mode — when unset, the
1319
+ # simulator produces the opening utterance itself.
1320
+ return await self._simulation_dialog_loop(self.task.initial_prompt, sandbox_dir)
1321
+
1322
+ # initial_prompt is guaranteed for real agents (check_prompt_fields); a
1323
+ # no-op (type: none) task runs with no prompt — send empty, NoOpAgent
1324
+ # ignores it and returns an empty turn.
1325
+ current_prompt = self.task.initial_prompt or ""
1326
+
1327
+ iteration = 1
1328
+ self.result.iteration_count = iteration
1329
+
1330
+ # Communicate with agent (with retry logic)
1331
+ prompt_with_cwd = f"Your working directory is: {sandbox_dir.resolve()}\n\n{current_prompt}"
1332
+ logger.debug(f"Sending prompt: {current_prompt[:100]}...")
1333
+
1334
+ # The agent owns its lifecycle events now (AgentStartEvent fires inside
1335
+ # communicate()); the orchestrator is a pure consumer of the stream.
1336
+ turn_record = await self._communicate_with_retry(
1337
+ prompt=prompt_with_cwd,
1338
+ iteration=iteration,
1339
+ operation_label="Agent communication",
1340
+ )
1341
+ self.result.iterations.append(turn_record)
1342
+ self._sync_sandbox_command_path_with_agent()
1343
+
1344
+ logger.debug(f"Agent response received ({len(turn_record.agent_output)} chars)")
1345
+
1346
+ # Check success criteria (pass reference code for reference_comparison criterion)
1347
+ logger.debug("Checking success criteria")
1348
+ reference_code, reference_dir, self._reference_code = load_reference(
1349
+ task=self.task,
1350
+ task_file=self.task_file,
1351
+ cached_reference=self._reference_code,
1352
+ )
1353
+ criteria_results = await asyncio.to_thread(
1354
+ self.success_checker.check_all,
1355
+ self.task.success_criteria,
1356
+ reference_code=reference_code,
1357
+ reference_dir=reference_dir,
1358
+ turn_records=self.result.iterations,
1359
+ )
1360
+ self.result.success_criteria_results = criteria_results
1361
+
1362
+ # Determine if all criteria passed their thresholds. all_passed is
1363
+ # single-sourced via the model gate; passed_count/total_count are kept
1364
+ # only for the human-readable log line below.
1365
+ pairs = list(zip(criteria_results, self.task.success_criteria, strict=True))
1366
+ passed_count = sum(1 for r, c in pairs if r.score >= c.pass_threshold)
1367
+ total_count = len(pairs)
1368
+ all_passed = self.result.all_criteria_passed(self.task.success_criteria)
1369
+
1370
+ # Reuse the model method for weighted score (single source of truth)
1371
+ self.result.calculate_weighted_score(self.task.success_criteria)
1372
+ current_score = self.result.weighted_score or 0.0
1373
+
1374
+ logger.info(f"Success criteria: {passed_count}/{total_count} passed, weighted score: {current_score:.3f}")
1375
+
1376
+ self._emit_criteria_event(criteria_results)
1377
+
1378
+ if turn_record.max_turns_exhausted:
1379
+ self.result.max_turns_exhausted = True
1380
+ logger.warning(
1381
+ "Agent exhausted max_turns (%s) without passing criteria.",
1382
+ self.task.run_limits.max_turns if self.task.run_limits else None,
1383
+ )
1384
+
1385
+ # Soft cumulative-turn check (logs once; never aborts).
1386
+ self._check_expected_turns(iteration=iteration)
1387
+
1388
+ # Budget gate runs AFTER criteria so partial-credit visibility is preserved.
1389
+ self._check_run_limits(iteration=iteration)
1390
+
1391
+ return all_passed
1392
+
1393
+ def _build_simulation_telemetry(
1394
+ self,
1395
+ *,
1396
+ n_trials: int,
1397
+ stop_reason: DialogStopReason,
1398
+ total_turns: int,
1399
+ sim_in: int,
1400
+ sim_out: int,
1401
+ sim_failures: int,
1402
+ ) -> SimulationTelemetry:
1403
+ """Single construction point for SimulationTelemetry across the dialog loop's exit paths."""
1404
+ return SimulationTelemetry(
1405
+ n_trials=n_trials,
1406
+ replicate_index=self.replicate_index,
1407
+ stop_reason=stop_reason.value,
1408
+ simulator_input_tokens=sim_in,
1409
+ simulator_output_tokens=sim_out,
1410
+ simulator_failures=sim_failures,
1411
+ total_turns=total_turns,
1412
+ )
1413
+
1414
+ async def _run_dialog_criteria_check(
1415
+ self, judge_usage_accum: dict[tuple[int, str], TokenUsage]
1416
+ ) -> list[CriterionResult]:
1417
+ """Run the success criteria against the current dialog state and record them.
1418
+
1419
+ The block lifted verbatim from the three identical sites (per-turn,
1420
+ budget-gate fallback, end-of-dialog): (re)load the reference, run
1421
+ ``check_all`` off the event loop, fold this turn's judge usage into the
1422
+ dialog-wide accumulator, store the results, and recompute the weighted score.
1423
+ Whether to additionally set ``all_passed`` and emit a ``CriteriaCheckEvent`` is
1424
+ left to the caller — those differ across the sites (the budget-gate fallback
1425
+ does neither).
1426
+ """
1427
+ assert self.result is not None
1428
+ assert self.success_checker is not None
1429
+ reference_code, reference_dir, self._reference_code = load_reference(
1430
+ task=self.task,
1431
+ task_file=self.task_file,
1432
+ cached_reference=self._reference_code,
1433
+ )
1434
+ criteria_results = await asyncio.to_thread(
1435
+ self.success_checker.check_all,
1436
+ self.task.success_criteria,
1437
+ reference_code=reference_code,
1438
+ reference_dir=reference_dir,
1439
+ turn_records=self.result.iterations,
1440
+ )
1441
+ self._accumulate_judge_usage(criteria_results, judge_usage_accum)
1442
+ self.result.success_criteria_results = criteria_results
1443
+ self.result.calculate_weighted_score(self.task.success_criteria)
1444
+ return criteria_results
1445
+
1446
+ def _user_message_from_sim(
1447
+ self,
1448
+ sim_result: SimulatorResult,
1449
+ started_at: datetime,
1450
+ completed_at: datetime,
1451
+ duration_ms: float,
1452
+ model_id: str | None,
1453
+ ) -> UserMessage:
1454
+ """Map a ``SimulatorResult`` + timing onto the pending-user-turn ``UserMessage``.
1455
+
1456
+ Distinct from the trivial pinned-prompt ``UserMessage(text=initial_prompt)``,
1457
+ which stays inline. Isolated as a named, independently-testable field mapping.
1458
+ """
1459
+ return UserMessage(
1460
+ text=sim_result.text,
1461
+ raw_text=sim_result.raw_text,
1462
+ stop_requested=sim_result.stop_requested,
1463
+ started_at=started_at,
1464
+ completed_at=completed_at,
1465
+ generation_duration_ms=duration_ms,
1466
+ input_tokens=sim_result.input_tokens or 0,
1467
+ output_tokens=sim_result.output_tokens or 0,
1468
+ model=model_id,
1469
+ )
1470
+
1471
+ async def _solicit_user_message(
1472
+ self,
1473
+ simulator: UserSimulator,
1474
+ history: list[tuple[str, str]],
1475
+ sim_model_id: str | None,
1476
+ failure_log_msg: str,
1477
+ ) -> _SolicitedMessage:
1478
+ """Ask the simulator for one utterance, time it, and map it to a ``UserMessage``.
1479
+
1480
+ The DRY unification of the opener and the in-loop next-user-message sites, which
1481
+ are structurally identical apart from the failure log string. Carries NO
1482
+ control-flow decision: on a simulator exception it logs ``failure_log_msg`` and
1483
+ returns ``message=None`` (with ``sim_in=sim_out=0``); callers inspect
1484
+ ``message is None`` (failure) and ``message.stop_requested``, and fold
1485
+ ``sim_in``/``sim_out`` into their running totals.
1486
+ """
1487
+ started_wall = datetime.now()
1488
+ started_mono = time.monotonic()
1489
+ try:
1490
+ sim_result = await simulator.next_user_message(history)
1491
+ except Exception:
1492
+ logger.exception(failure_log_msg)
1493
+ return _SolicitedMessage(message=None, sim_in=0, sim_out=0)
1494
+ completed_wall = datetime.now()
1495
+ duration_ms = (time.monotonic() - started_mono) * 1000
1496
+ message = self._user_message_from_sim(sim_result, started_wall, completed_wall, duration_ms, sim_model_id)
1497
+ return _SolicitedMessage(
1498
+ message=message,
1499
+ sim_in=sim_result.input_tokens or 0,
1500
+ sim_out=sim_result.output_tokens or 0,
1501
+ )
1502
+
1503
+ async def _acquire_opener(
1504
+ self, simulator: UserSimulator, sim_config: SimulationConfig, sim_model_id: str | None
1505
+ ) -> _OpenerOutcome:
1506
+ """Pure-simulation opener (``initial_prompt is None``): ask the simulator to
1507
+ generate turn 1 from empty history.
1508
+
1509
+ Two short-circuit paths write simulation telemetry inline and tell the caller to
1510
+ return immediately: a simulator failure (ERROR, total_turns=0) and an opener that
1511
+ already carries the stop token (STOP_TOKEN, total_turns=0 — running an agent turn
1512
+ just to learn this wastes a turn budget). Otherwise returns the opener message and
1513
+ its token deltas for the caller to fold in.
1514
+ """
1515
+ assert self.result is not None
1516
+ solicited = await self._solicit_user_message(
1517
+ simulator,
1518
+ [],
1519
+ sim_model_id,
1520
+ "User simulator failed to generate opening message — aborting dialog",
1521
+ )
1522
+ if solicited.message is None:
1523
+ self.result.simulation = self._build_simulation_telemetry(
1524
+ n_trials=sim_config.n_trials,
1525
+ stop_reason=DialogStopReason.ERROR,
1526
+ total_turns=0,
1527
+ sim_in=0,
1528
+ sim_out=0,
1529
+ sim_failures=1,
1530
+ )
1531
+ return _OpenerOutcome(short_circuit=True, return_value=False)
1532
+
1533
+ self._log_conversation("USER", 1, solicited.message.text, metadata="simulator-generated opener")
1534
+
1535
+ # Opener carrying the stop token means the simulator judged the task done
1536
+ # before any agent turn ran. Record telemetry and short-circuit.
1537
+ if solicited.message.stop_requested:
1538
+ self.result.simulation = self._build_simulation_telemetry(
1539
+ n_trials=sim_config.n_trials,
1540
+ stop_reason=DialogStopReason.STOP_TOKEN,
1541
+ total_turns=0,
1542
+ sim_in=solicited.sim_in,
1543
+ sim_out=solicited.sim_out,
1544
+ sim_failures=0,
1545
+ )
1546
+ return _OpenerOutcome(short_circuit=True, return_value=False)
1547
+
1548
+ return _OpenerOutcome(
1549
+ short_circuit=False,
1550
+ current_prompt=solicited.message.text,
1551
+ pending_user_turn=solicited.message,
1552
+ sim_in=solicited.sim_in,
1553
+ sim_out=solicited.sim_out,
1554
+ failures=0,
1555
+ )
1556
+
1557
+ async def _simulation_dialog_loop(self, initial_prompt: str | None, sandbox_dir: Path) -> bool: # noqa: PLR0915 — sequential dialog driver; decomposed into helpers, residual length is irreducible without a _DialogState rewrite (see plan Phase 5). Statement count ratcheted by CE022.
1558
+ """Run the task as a multi-turn dialog driven by an LLM user simulator.
1559
+
1560
+ This replaces the criteria-feedback iteration loop for tasks that
1561
+ define a ``simulation`` block. One invocation runs exactly one
1562
+ dialog trajectory (trial). Parallel trials are handled upstream by
1563
+ the batch expander — this method is per-trial.
1564
+
1565
+ Lifecycle:
1566
+ 1. Obtain the opening user utterance. If the task pinned one via
1567
+ ``initial_prompt``, use it verbatim; otherwise ask the simulator
1568
+ to produce it from persona + goal (pure-simulation mode).
1569
+ 2. Send the opening utterance to the agent as turn 1.
1570
+ 3. After each agent reply, optionally check success criteria.
1571
+ Break with ``criteria_passed`` if they pass and
1572
+ ``stop_on_criteria_pass`` is set.
1573
+ 4. Evaluate stop conditions (turn cap, token budget).
1574
+ 5. Ask the simulator for the next user message. If the simulator
1575
+ emits the stop token, break with ``stop_token``.
1576
+ 6. Loop. On any simulator exception, terminate with ``error``.
1577
+ 7. After the dialog ends, run a final criteria check unless one
1578
+ just happened, and return pass/fail.
1579
+
1580
+ Emits the same streaming events as the single-shot loop (the agent emits
1581
+ its ``AgentStart``/``Turn``/``Tool``/``AgentEnd`` lifecycle; the orchestrator
1582
+ adds ``CriteriaCheckEvent``) so downstream UI renderers work unchanged.
1583
+ Simulator telemetry is recorded on ``self.result.simulation``.
1584
+ """
1585
+ assert self.result is not None
1586
+ assert self.task.simulation is not None
1587
+ assert self.agent is not None
1588
+ assert self.success_checker is not None
1589
+ assert self.task.agent is not None
1590
+ sim_config = self.task.simulation
1591
+
1592
+ simulator = UserSimulator(
1593
+ config=sim_config,
1594
+ task_description=self.task.description,
1595
+ initial_prompt=initial_prompt,
1596
+ route=self.route,
1597
+ )
1598
+ await simulator.start()
1599
+
1600
+ # stop_reason is left unset until the loop picks a concrete reason;
1601
+ # the final assertion before telemetry-write catches any exit path
1602
+ # that forgot to set it, instead of silently defaulting.
1603
+ stop_reason: DialogStopReason | None = None
1604
+ simulator_input_tokens = 0
1605
+ simulator_output_tokens = 0
1606
+ simulator_failures = 0
1607
+ total_tokens_used = 0
1608
+ criteria_results: list[CriterionResult] = []
1609
+ criteria_checked_this_turn = False
1610
+ all_passed = False
1611
+ turns_completed = 0
1612
+
1613
+ # UserMessage captured for the upcoming agent call; prepended to the
1614
+ # next turn_record.messages. None outside simulation paths.
1615
+ pending_user_turn: UserMessage | None = None
1616
+ sim_model_id = getattr(sim_config, "model", None)
1617
+ # Track whether we entered the agent-call loop — used by the finally
1618
+ # block to decide whether to persist an orphaned pending_user_turn.
1619
+ agent_turn_attempted = False
1620
+
1621
+ try:
1622
+ # Pure-simulation mode: no pinned opener — ask the simulator to
1623
+ # generate turn 1 from empty history before the agent sees anything.
1624
+ if initial_prompt is None:
1625
+ opener_outcome = await self._acquire_opener(simulator, sim_config, sim_model_id)
1626
+ if opener_outcome.short_circuit:
1627
+ # _acquire_opener already wrote the simulation telemetry (ERROR
1628
+ # on simulator failure, STOP_TOKEN on a stop-carrying opener).
1629
+ return opener_outcome.return_value
1630
+ assert opener_outcome.current_prompt is not None
1631
+ simulator_input_tokens += opener_outcome.sim_in
1632
+ simulator_output_tokens += opener_outcome.sim_out
1633
+ total_tokens_used += opener_outcome.sim_in + opener_outcome.sim_out
1634
+ simulator_failures += opener_outcome.failures
1635
+ current_prompt = opener_outcome.current_prompt
1636
+ pending_user_turn = opener_outcome.pending_user_turn
1637
+ else:
1638
+ current_prompt = initial_prompt
1639
+ pending_user_turn = UserMessage(text=initial_prompt)
1640
+ self._log_conversation("USER", 1, current_prompt, metadata="pinned initial_prompt")
1641
+ # Parallel history of clean (user, agent) pairs for the simulator.
1642
+ # This intentionally excludes the working-directory prefix that gets
1643
+ # prepended to agent prompts — the simulator should see the user's
1644
+ # actual utterances, not framework wrapping.
1645
+ dialog_pairs: list[tuple[str, str]] = []
1646
+
1647
+ check_every_turn = sim_config.check_criteria in ("every_turn", "both")
1648
+
1649
+ # Dialog-wide running total of each judge's token usage. The per-turn
1650
+ # check replaces ``success_criteria_results`` wholesale, so we fold
1651
+ # each turn's judge slice into this accumulator (see
1652
+ # ``_accumulate_judge_usage``) to avoid dropping earlier judge calls.
1653
+ # Keyed by (position, criterion_type) — a stable criterion identity.
1654
+ judge_usage_accum: dict[tuple[int, str], TokenUsage] = {}
1655
+
1656
+ # turns_completed advances in lockstep with the agent's _iteration:
1657
+ # one _communicate_with_retry call per sim turn keeps partials
1658
+ # and the successful retry on the same iteration number.
1659
+ while True:
1660
+ turns_completed += 1
1661
+ self.result.iteration_count = turns_completed
1662
+ logger.info("Simulation turn %s/%s", turns_completed, sim_config.max_turns)
1663
+
1664
+ prompt_with_cwd = f"Your working directory is: {sandbox_dir.resolve()}\n\n{current_prompt}"
1665
+ # Agent emits its own AgentStartEvent inside communicate().
1666
+ agent_turn_attempted = True
1667
+ turn_record = await self._communicate_with_retry(
1668
+ prompt=prompt_with_cwd,
1669
+ iteration=turns_completed,
1670
+ operation_label=f"Agent communication (sim turn {turns_completed})",
1671
+ )
1672
+ if pending_user_turn is not None:
1673
+ turn_record.messages.insert(0, pending_user_turn)
1674
+ pending_user_turn = None
1675
+ self.result.iterations.append(turn_record)
1676
+ self._sync_sandbox_command_path_with_agent()
1677
+ dialog_pairs.append((current_prompt, _extract_utterance(turn_record.agent_output or "")))
1678
+ agent_meta_parts = []
1679
+ if turn_record.duration_seconds is not None:
1680
+ agent_meta_parts.append(f"{turn_record.duration_seconds:.1f}s")
1681
+ if turn_record.token_usage is not None:
1682
+ usage_parts = []
1683
+ if turn_record.token_usage.uncached_input_tokens:
1684
+ usage_parts.append(f"in={turn_record.token_usage.uncached_input_tokens}")
1685
+ if turn_record.token_usage.output_tokens:
1686
+ usage_parts.append(f"out={turn_record.token_usage.output_tokens}")
1687
+ if usage_parts:
1688
+ agent_meta_parts.append(" ".join(usage_parts))
1689
+ if turn_record.commands:
1690
+ agent_meta_parts.append(f"{len(turn_record.commands)} tool calls")
1691
+ self._log_conversation(
1692
+ "AGENT",
1693
+ turns_completed,
1694
+ turn_record.agent_output or "",
1695
+ metadata=", ".join(agent_meta_parts),
1696
+ )
1697
+
1698
+ if turn_record.token_usage is not None:
1699
+ usage = turn_record.token_usage
1700
+ total_tokens_used += (usage.uncached_input_tokens or 0) + (usage.output_tokens or 0)
1701
+
1702
+ criteria_checked_this_turn = False
1703
+ if check_every_turn:
1704
+ criteria_results = await self._run_dialog_criteria_check(judge_usage_accum)
1705
+ criteria_checked_this_turn = True
1706
+ all_passed = self.result.all_criteria_passed(self.task.success_criteria)
1707
+ self._emit_criteria_event(criteria_results)
1708
+
1709
+ # Budget gate: aborts the dialog with a dedicated stop reason and
1710
+ # ensures end-of-dialog criteria still run for partial credit.
1711
+ try:
1712
+ self._check_run_limits(iteration=turns_completed)
1713
+ except BudgetExceededError:
1714
+ stop_reason = DialogStopReason.RUN_LIMIT_EXCEEDED
1715
+ if not criteria_checked_this_turn:
1716
+ # Run for partial-credit side effects only (stores results
1717
+ # on self.result + recomputes score). This fallback site
1718
+ # deliberately neither sets all_passed nor emits an event,
1719
+ # so the return value is unused.
1720
+ await self._run_dialog_criteria_check(judge_usage_accum)
1721
+ raise
1722
+
1723
+ stop_decision = evaluate_stop(
1724
+ config=sim_config,
1725
+ turns_completed=turns_completed,
1726
+ total_tokens_used=total_tokens_used,
1727
+ criteria_all_passed=all_passed,
1728
+ )
1729
+ if stop_decision.stop:
1730
+ assert stop_decision.reason is not None
1731
+ stop_reason = stop_decision.reason
1732
+ break
1733
+
1734
+ # Soft cumulative-turn check (logs once; never aborts the dialog).
1735
+ # Runs BEFORE the max_turns break so a single turn that trips both
1736
+ # the hard cap and the soft target still emits the expected_turns
1737
+ # warning before the dialog terminates.
1738
+ self._check_expected_turns(iteration=turns_completed)
1739
+
1740
+ if turn_record.max_turns_exhausted:
1741
+ self.result.max_turns_exhausted = True
1742
+ stop_reason = DialogStopReason.MAX_TURNS
1743
+ logger.warning(
1744
+ "Agent exhausted its inner max_turns during simulation turn %s; ending dialog.",
1745
+ turns_completed,
1746
+ )
1747
+ break
1748
+
1749
+ solicited = await self._solicit_user_message(
1750
+ simulator, dialog_pairs, sim_model_id, "User simulator failed — ending dialog"
1751
+ )
1752
+ if solicited.message is None:
1753
+ simulator_failures += 1
1754
+ stop_reason = DialogStopReason.ERROR
1755
+ break
1756
+
1757
+ simulator_input_tokens += solicited.sim_in
1758
+ simulator_output_tokens += solicited.sim_out
1759
+ total_tokens_used += solicited.sim_in + solicited.sim_out
1760
+
1761
+ pending_user_turn = solicited.message
1762
+
1763
+ if solicited.message.stop_requested:
1764
+ self._log_conversation(
1765
+ "USER",
1766
+ turns_completed + 1,
1767
+ solicited.message.text,
1768
+ metadata="stop token — dialog ending",
1769
+ )
1770
+ stop_reason = DialogStopReason.STOP_TOKEN
1771
+ break
1772
+
1773
+ current_prompt = solicited.message.text
1774
+ self._log_conversation("USER", turns_completed + 1, current_prompt)
1775
+
1776
+ # Final criteria check for end_of_dialog (or both) modes, or when
1777
+ # every_turn mode did not run a check for the final turn.
1778
+ end_of_dialog_needed = (
1779
+ sim_config.check_criteria in ("end_of_dialog", "both") or not criteria_checked_this_turn
1780
+ )
1781
+ if end_of_dialog_needed:
1782
+ criteria_results = await self._run_dialog_criteria_check(judge_usage_accum)
1783
+ all_passed = self.result.all_criteria_passed(self.task.success_criteria)
1784
+ self._emit_criteria_event(criteria_results)
1785
+
1786
+ assert stop_reason is not None, "dialog loop exited without picking a stop_reason"
1787
+ self.result.simulation = self._build_simulation_telemetry(
1788
+ n_trials=sim_config.n_trials,
1789
+ stop_reason=stop_reason,
1790
+ total_turns=turns_completed,
1791
+ sim_in=simulator_input_tokens,
1792
+ sim_out=simulator_output_tokens,
1793
+ sim_failures=simulator_failures,
1794
+ )
1795
+ logger.info(
1796
+ "Simulation dialog ended: stop_reason=%s turns=%s criteria_passed=%s",
1797
+ stop_reason.value,
1798
+ turns_completed,
1799
+ all_passed,
1800
+ )
1801
+ return all_passed
1802
+ finally:
1803
+ # If an agent turn was attempted but crashed before attaching pending_user_turn to a turn_record,
1804
+ # append it as a standalone entry so its telemetry is not lost. (If the simulator's opener
1805
+ # had stop_requested, we never entered the agent loop, so don't record it.)
1806
+ if agent_turn_attempted and pending_user_turn is not None and self.result is not None:
1807
+ standalone_turn = TurnRecord(
1808
+ iteration=turns_completed,
1809
+ user_input=pending_user_turn.text or "",
1810
+ agent_output="",
1811
+ messages=[pending_user_turn],
1812
+ )
1813
+ self.result.iterations.append(standalone_turn)
1814
+
1815
+ # When the dialog bails out via exception (TurnTimeoutError,
1816
+ # TaskTimeoutError, etc.) before reaching the explicit telemetry
1817
+ # write above, the happy-path write never happens — record partial
1818
+ # telemetry here so analytics still see the run. ``stop_reason``
1819
+ # being None at this point means "exit was not an in-band stop
1820
+ # decision" (i.e., exception-driven), which we classify as ERROR.
1821
+ if self.result is not None and self.result.simulation is None:
1822
+ self.result.simulation = self._build_simulation_telemetry(
1823
+ n_trials=sim_config.n_trials,
1824
+ stop_reason=stop_reason or DialogStopReason.ERROR,
1825
+ total_turns=turns_completed,
1826
+ sim_in=simulator_input_tokens,
1827
+ sim_out=simulator_output_tokens,
1828
+ sim_failures=simulator_failures,
1829
+ )
1830
+ # Always tear down the simulator agent (and its scratch dir) even
1831
+ # when the dialog bails out via exception.
1832
+ await simulator.stop()
1833
+
1834
+ def _log_conversation(self, role: str, turn: int, text: str, metadata: str = "") -> None:
1835
+ """Append one utterance to conversation.log.
1836
+
1837
+ Role is "USER" (from the simulator, or the pinned initial_prompt) or
1838
+ "AGENT". Called from the simulation dialog loop only — single-shot
1839
+ runs do not produce a conversation.log. ``run_dir`` is guaranteed to
1840
+ exist by the time this runs (``task_log_handler`` creates it before
1841
+ the dialog loop starts).
1842
+ """
1843
+ header = f"=== {role} (turn {turn}){f' — {metadata}' if metadata else ''} ==="
1844
+ body = _extract_utterance(text or "").rstrip()
1845
+ with self.conversation_log_path.open("a", encoding="utf-8") as f:
1846
+ f.write(f"{header}\n{body}\n\n")
1847
+
1848
+ def _emit_criteria_event(self, criteria_results: list[CriterionResult]) -> None:
1849
+ """Emit a CriteriaCheckEvent for the current success-criteria state.
1850
+
1851
+ Extracted so the single-shot loop and the simulation dialog loop
1852
+ produce identical streaming output.
1853
+ """
1854
+ assert self.result is not None
1855
+ pairs = list(zip(criteria_results, self.task.success_criteria, strict=True))
1856
+ passed_count = sum(1 for r, c in pairs if r.score >= c.pass_threshold)
1857
+ total_count = len(pairs)
1858
+ current_score = self.result.weighted_score or 0.0
1859
+ criteria_details = [
1860
+ f"{criterion.type}: {'PASS' if result.score >= criterion.pass_threshold else 'FAIL'}"
1861
+ + f" ({result.score:.2f})"
1862
+ for result, criterion in pairs
1863
+ ]
1864
+ criteria_summaries = [
1865
+ CriterionSummary(
1866
+ criterion_type=criterion.type,
1867
+ description=result.description or criterion.description,
1868
+ score=result.score,
1869
+ passed=result.score >= criterion.pass_threshold,
1870
+ failure_reason=_extract_failure_reason(result) if result.score < criterion.pass_threshold else None,
1871
+ )
1872
+ for result, criterion in pairs
1873
+ ]
1874
+ safe_emit(
1875
+ self.stream_callback,
1876
+ CriteriaCheckEvent(
1877
+ task_id=self._log_task_id,
1878
+ passed=passed_count,
1879
+ total=total_count,
1880
+ weighted_score=current_score,
1881
+ details=criteria_details,
1882
+ criteria=criteria_summaries,
1883
+ ),
1884
+ )
1885
+
1886
+ _POST_RUN_MAX_OUTPUT = 100_000 # Truncate stdout/stderr to 100KB
1887
+ _POST_RUN_STREAM_LIMIT = 262_144 # StreamReader per-line buffer (256KB)
1888
+
1889
+ async def _run_command_list(
1890
+ self,
1891
+ commands: list[PreRunCommand] | list[PostRunCommand],
1892
+ results: list[PostRunResult],
1893
+ label: str,
1894
+ ) -> None:
1895
+ """Run a list of shell commands inside the sandbox, capturing output.
1896
+
1897
+ stdout/stderr are streamed line-by-line to the orchestrator logger and
1898
+ accumulated into ``results`` for the report (truncated to
1899
+ ``_POST_RUN_MAX_OUTPUT`` per stream). ``label`` is used in stream/log
1900
+ labels (e.g. ``"pre_run"`` -> ``[pre_run stdout]``).
1901
+
1902
+ For commands carrying ``fail_on_error=True`` (PreRunCommand only), a
1903
+ non-zero exit, timeout, or exception appends the failure result and then
1904
+ raises ``RuntimeError``, aborting the loop. PostRunCommand never has
1905
+ ``fail_on_error`` set, so failures are warning-logged and the loop
1906
+ continues — preserving existing post-run "informational only" semantics.
1907
+ """
1908
+ if not commands or not self.sandbox or not self.sandbox.sandbox_dir or not self.result:
1909
+ return
1910
+
1911
+ sandbox_dir = self.sandbox.sandbox_dir
1912
+ max_out = self._POST_RUN_MAX_OUTPUT
1913
+ human = label.replace("_", "-").capitalize() # "pre_run" -> "Pre-run"
1914
+
1915
+ for cmd in commands:
1916
+ fail_on_error = isinstance(cmd, PreRunCommand) and cmd.fail_on_error
1917
+ start = time.time()
1918
+ logger.info("Running %s command: %s", human.lower(), cmd.command)
1919
+
1920
+ try:
1921
+ proc = await asyncio.create_subprocess_shell(
1922
+ cmd.command,
1923
+ cwd=str(sandbox_dir),
1924
+ stdout=asyncio.subprocess.PIPE,
1925
+ stderr=asyncio.subprocess.PIPE,
1926
+ limit=self._POST_RUN_STREAM_LIMIT,
1927
+ ) # nosec B602,B604 - commands come from task YAML, not user input
1928
+
1929
+ stdout_chunks: list[str] = []
1930
+ stderr_chunks: list[str] = []
1931
+
1932
+ try:
1933
+ await asyncio.wait_for(
1934
+ asyncio.gather(
1935
+ _pump_stream(proc.stdout, logger.info, f"{label} stdout", stdout_chunks),
1936
+ _pump_stream(proc.stderr, logger.warning, f"{label} stderr", stderr_chunks),
1937
+ proc.wait(),
1938
+ ),
1939
+ timeout=cmd.timeout,
1940
+ )
1941
+ except TimeoutError:
1942
+ proc.kill()
1943
+ await proc.wait()
1944
+ results.append(
1945
+ PostRunResult(
1946
+ command=cmd.command,
1947
+ stdout="".join(stdout_chunks)[:max_out],
1948
+ stderr="".join(stderr_chunks)[:max_out],
1949
+ error=f"Timed out after {cmd.timeout}s",
1950
+ duration_seconds=time.time() - start,
1951
+ )
1952
+ )
1953
+ if fail_on_error:
1954
+ raise RuntimeError(f"{human} command timed out after {cmd.timeout}s: {cmd.command!r}") from None
1955
+ logger.warning("%s command '%s' timed out after %ds", human, cmd.command, cmd.timeout)
1956
+ continue
1957
+
1958
+ stdout_text = "".join(stdout_chunks)[:max_out]
1959
+ stderr_text = "".join(stderr_chunks)[:max_out]
1960
+ results.append(
1961
+ PostRunResult(
1962
+ command=cmd.command,
1963
+ exit_code=proc.returncode,
1964
+ stdout=stdout_text,
1965
+ stderr=stderr_text,
1966
+ duration_seconds=time.time() - start,
1967
+ )
1968
+ )
1969
+ if proc.returncode != 0:
1970
+ if fail_on_error:
1971
+ raise RuntimeError(f"{human} command failed (exit {proc.returncode}): {cmd.command!r}")
1972
+ logger.warning(
1973
+ "%s command '%s' exited with code %d: %s",
1974
+ human,
1975
+ cmd.command,
1976
+ proc.returncode,
1977
+ stderr_text[:200],
1978
+ )
1979
+ except RuntimeError:
1980
+ # Propagate abort signal from fail_on_error=True branches unchanged;
1981
+ # otherwise the catch-all below would re-wrap it as a new RuntimeError.
1982
+ raise
1983
+ except Exception as e:
1984
+ results.append(
1985
+ PostRunResult(
1986
+ command=cmd.command,
1987
+ error=str(e),
1988
+ duration_seconds=time.time() - start,
1989
+ )
1990
+ )
1991
+ if fail_on_error:
1992
+ raise RuntimeError(f"{human} command failed: {cmd.command!r}") from e
1993
+ logger.warning("%s command '%s' failed: %s", human, cmd.command, e)
1994
+
1995
+ async def _run_pre_run_commands(self) -> None:
1996
+ """Execute pre-run commands inside the sandbox before evaluation.
1997
+
1998
+ See ``_run_command_list``. A failing command with ``fail_on_error=True``
1999
+ (the default) raises ``RuntimeError``, which propagates to ``run()``'s
2000
+ outer ``except Exception`` handler and lands the run as
2001
+ ``FinalStatus.ERROR``. Post-run commands and cleanup still execute via
2002
+ the ``finally`` block.
2003
+ """
2004
+ if self.result is None:
2005
+ return
2006
+ await self._run_command_list(self.task.pre_run, self.result.pre_run_results, "pre_run")
2007
+
2008
+ async def _run_post_run_commands(self) -> None:
2009
+ """Execute post-run commands inside the sandbox after evaluation.
2010
+
2011
+ See ``_run_command_list``. Post-run commands are informational only —
2012
+ ``fail_on_error`` is not part of ``PostRunCommand``, so failures are
2013
+ warning-logged and never affect the evaluation verdict.
2014
+ """
2015
+ if self.result is None:
2016
+ return
2017
+ await self._run_command_list(self.task.post_run, self.result.post_run_results, "post_run")
2018
+
2019
+ async def _cleanup(self) -> None:
2020
+ """Clean up all resources."""
2021
+ # Stop agent
2022
+ if self.agent:
2023
+ try:
2024
+ await self.agent.stop()
2025
+ except Exception as e:
2026
+ logger.warning(f"Failed to stop agent: {e}")
2027
+
2028
+ # Cleanup sandbox. Preservation and cleanup() are SIBLING try blocks:
2029
+ # a preservation failure (e.g. disk full during preserve_to) must never
2030
+ # skip cleanup(), or the tempdir leaks.
2031
+ if self.sandbox:
2032
+ try:
2033
+ if self.workspace_dir is not None and self.result:
2034
+ # Docker WORKDIR alignment: the agent ran in-place at the image
2035
+ # WORKDIR; copy that workspace out to run_dir/artifacts/<task>
2036
+ # (capture_to grants cross-uid read on the COPY and tolerates
2037
+ # dangling symlinks). Takes precedence over preservation_mode.
2038
+ artifacts_dir = self.run_dir / "artifacts"
2039
+ preserved_path = await asyncio.to_thread(self.sandbox.capture_to, artifacts_dir)
2040
+ self.result.sandbox_path = str(preserved_path)
2041
+ logger.info("Workspace captured out: %s -> %s", self.workspace_dir, preserved_path)
2042
+ elif self.preservation_mode == PreservationMode.MOVE_ON_WRITE and self.result:
2043
+ # Sandbox ran in a tempdir — move it into run_dir/artifacts.
2044
+ artifacts_dir = self.run_dir / "artifacts"
2045
+ preserved_path = await asyncio.to_thread(self.sandbox.preserve_to, artifacts_dir)
2046
+ self.result.sandbox_path = str(preserved_path)
2047
+ logger.info(f"Sandbox preserved to: {preserved_path}")
2048
+ elif self.preservation_mode == PreservationMode.DIRECT_WRITE and self.result:
2049
+ # Sandbox already lives in run_dir/artifacts — nothing to move.
2050
+ # Set sandbox_path first, then grant a+rX (a fallible chmod) so
2051
+ # artifacts written by a root-owned docker container stay
2052
+ # traversable across the host uid boundary (MOVE_ON_WRITE gets
2053
+ # this via preserve_to; DIRECT_WRITE skips it, so apply it here).
2054
+ self.result.sandbox_path = str(self.sandbox.sandbox_dir)
2055
+ await asyncio.to_thread(self.sandbox.grant_read_access)
2056
+ logger.info(f"Sandbox preserved (in-place): {self.sandbox.sandbox_dir}")
2057
+ elif self.preservation_mode == PreservationMode.NONE and self.result:
2058
+ # Sandbox will be deleted by cleanup() below; clear stale path.
2059
+ self.result.sandbox_path = None
2060
+ elif self.result:
2061
+ # Defensive: a future PreservationMode member with no arm here
2062
+ # would otherwise silently fall through. Treat as no-preserve.
2063
+ logger.warning("Unhandled preservation_mode %s; not preserving sandbox.", self.preservation_mode)
2064
+ self.result.sandbox_path = None
2065
+ except Exception as e:
2066
+ logger.warning(f"Failed to preserve sandbox (continuing with cleanup): {e}")
2067
+ if self.result and self.preservation_mode == PreservationMode.MOVE_ON_WRITE:
2068
+ # The artifacts were never moved — don't point at the tempdir
2069
+ # cleanup() is about to delete. DIRECT_WRITE keeps its path:
2070
+ # that sandbox is persistent (cleanup() is a no-op) and the
2071
+ # artifacts still exist even if the a+rX chmod failed.
2072
+ self.result.sandbox_path = None
2073
+
2074
+ try:
2075
+ # cleanup() is a no-op for persistent dirs (is_persistent=True)
2076
+ await asyncio.to_thread(self.sandbox.cleanup, preserve=False)
2077
+ except Exception as e:
2078
+ logger.warning(f"Failed to cleanup sandbox: {e}")