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