roller-agent 0.2.0__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.
- roller/__init__.py +31 -0
- roller/__main__.py +6 -0
- roller/actf.py +431 -0
- roller/artifacts.py +124 -0
- roller/assets/deepseek/journal.mjs +44 -0
- roller/assets/deepseek/roller.patch.yml +15 -0
- roller/assets/deepseek/runner.py +149 -0
- roller/assets/hermes/runner.py +322 -0
- roller/assets/openclaw_capture.py +32 -0
- roller/assets/openclaw_drain.mjs +21 -0
- roller/assets/opencode/journal.mjs +42 -0
- roller/assets/opencode/runner.py +203 -0
- roller/assets/pi/runner.mjs +160 -0
- roller/assets/pi/runner.py +93 -0
- roller/benches/__init__.py +73 -0
- roller/benches/base.py +306 -0
- roller/benches/grading.py +553 -0
- roller/benches/pinchbench.py +976 -0
- roller/benches/qwenclaw/__init__.py +49 -0
- roller/benches/qwenclaw/adapter.py +167 -0
- roller/benches/qwenclaw/grader_runtime.py +395 -0
- roller/benches/qwenclaw/report.py +49 -0
- roller/benches/qwenclaw/task_parser.py +181 -0
- roller/benches/qwenclaw/workspace.py +202 -0
- roller/benches/report.py +166 -0
- roller/benches/skillsbench.py +708 -0
- roller/benches/transcripts.py +102 -0
- roller/benches/wildclaw.py +569 -0
- roller/bridge_capture.py +73 -0
- roller/bundles.py +545 -0
- roller/capture.py +385 -0
- roller/capture_protocol.py +301 -0
- roller/capture_runtime.py +48 -0
- roller/capture_states.py +37 -0
- roller/claude_capture.py +185 -0
- roller/claude_code_actf.py +594 -0
- roller/cli.py +1732 -0
- roller/codex_actf.py +584 -0
- roller/codex_capture.py +99 -0
- roller/codex_tools.py +137 -0
- roller/collector.py +818 -0
- roller/compaction_sft.py +672 -0
- roller/conversation.py +432 -0
- roller/deepseek_actf.py +135 -0
- roller/harness_registry.py +61 -0
- roller/harnesses/__init__.py +29 -0
- roller/harnesses/base.py +64 -0
- roller/harnesses/bridge.py +448 -0
- roller/harnesses/claude_code.py +839 -0
- roller/harnesses/codex.py +808 -0
- roller/harnesses/config_overrides.py +100 -0
- roller/harnesses/container.py +206 -0
- roller/harnesses/deepseek.py +104 -0
- roller/harnesses/docker.py +72 -0
- roller/harnesses/gateway_ports.py +155 -0
- roller/harnesses/hermes.py +119 -0
- roller/harnesses/openclaw.py +1605 -0
- roller/harnesses/opencode.py +193 -0
- roller/harnesses/pi.py +111 -0
- roller/hermes_actf.py +147 -0
- roller/image_build.py +206 -0
- roller/integrity.py +332 -0
- roller/model_protocols.py +201 -0
- roller/models.py +169 -0
- roller/openclaw_capture.py +140 -0
- roller/openclaw_delivery.py +211 -0
- roller/openclaw_usage.py +59 -0
- roller/opencode_actf.py +305 -0
- roller/opencode_capture.py +96 -0
- roller/pi_actf.py +172 -0
- roller/response_metadata.py +89 -0
- roller/run_lock.py +297 -0
- roller/sft.py +471 -0
- roller/storage.py +53 -0
- roller/tasks.py +689 -0
- roller/thinking.py +50 -0
- roller/thinking_capabilities.py +152 -0
- roller/trajectory_audit.py +386 -0
- roller/usage.py +168 -0
- roller/version.py +1 -0
- roller/viewer/__init__.py +3 -0
- roller/viewer/app.py +722 -0
- roller/viewer/static/app.js +752 -0
- roller/viewer/static/index.html +105 -0
- roller/viewer/static/style.css +213 -0
- roller/wire_trace.py +650 -0
- roller_agent-0.2.0.dist-info/METADATA +397 -0
- roller_agent-0.2.0.dist-info/RECORD +92 -0
- roller_agent-0.2.0.dist-info/WHEEL +5 -0
- roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
- roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
- roller_agent-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from roller.benches.base import BenchBinding, GradeSpec
|
|
17
|
+
from roller.harnesses.base import Harness, InteractiveHarness, InteractiveSession
|
|
18
|
+
from roller.models import HarnessResult, RunRequest
|
|
19
|
+
from roller.storage import atomic_write_json
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
ENV_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
24
|
+
GRADER_CONTAINER_DIR = "/tmp/roller-bench"
|
|
25
|
+
RUNNER_CONTAINER_PATH = f"{GRADER_CONTAINER_DIR}/_grade_runner.py"
|
|
26
|
+
TRANSCRIPT_CONTAINER_PATH = f"{GRADER_CONTAINER_DIR}/transcript.jsonl"
|
|
27
|
+
|
|
28
|
+
# The grader is a plain function in the bench's own source. It is invoked with
|
|
29
|
+
# the transcript and workspace the agent actually produced, which is why it has
|
|
30
|
+
# to run in the container rather than on the host.
|
|
31
|
+
_RUNNER_TEMPLATE = """\
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
def _load_transcript(path):
|
|
36
|
+
import pathlib
|
|
37
|
+
file_path = pathlib.Path(path)
|
|
38
|
+
if not file_path.is_file():
|
|
39
|
+
return []
|
|
40
|
+
try:
|
|
41
|
+
raw = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
42
|
+
except OSError:
|
|
43
|
+
return []
|
|
44
|
+
try:
|
|
45
|
+
parsed = json.loads(raw)
|
|
46
|
+
except Exception:
|
|
47
|
+
parsed = None
|
|
48
|
+
if isinstance(parsed, list):
|
|
49
|
+
return parsed
|
|
50
|
+
if isinstance(parsed, dict):
|
|
51
|
+
for key in ("transcript", "messages", "chat", "events"):
|
|
52
|
+
value = parsed.get(key)
|
|
53
|
+
if isinstance(value, list):
|
|
54
|
+
return value
|
|
55
|
+
return [parsed]
|
|
56
|
+
rows = []
|
|
57
|
+
for line in raw.splitlines():
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
rows.append(json.loads(line))
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
return rows
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__ROLLER_GRADER_SOURCE__
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _main():
|
|
72
|
+
kwargs = json.loads(__ROLLER_KWARGS__)
|
|
73
|
+
if __ROLLER_NEEDS_TRANSCRIPT__:
|
|
74
|
+
kwargs["transcript"] = _load_transcript(__ROLLER_TRANSCRIPT_PATH__)
|
|
75
|
+
kwargs["workspace_path"] = __ROLLER_WORKSPACE__
|
|
76
|
+
result = __ROLLER_ENTRYPOINT__(**kwargs)
|
|
77
|
+
if not isinstance(result, dict):
|
|
78
|
+
raise TypeError("grader must return a dict, got %r" % type(result).__name__)
|
|
79
|
+
sys.stdout.write("__ROLLER_SCORE__" + json.dumps(result, ensure_ascii=False))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_main()
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class GradeOutcome:
|
|
88
|
+
scores: dict[str, Any]
|
|
89
|
+
elapsed_seconds: float
|
|
90
|
+
error: str | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _error_scores(message: str) -> dict[str, Any]:
|
|
94
|
+
return {"overall_score": 0.0, "score_eligible": False, "error": message}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_runner_source(spec: GradeSpec, workspace_path: str) -> str:
|
|
98
|
+
"""Wrap the bench's grader source in a self-contained runner script."""
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
_RUNNER_TEMPLATE.replace("__ROLLER_GRADER_SOURCE__", spec.code)
|
|
102
|
+
.replace("__ROLLER_KWARGS__", repr(json.dumps(spec.kwargs)))
|
|
103
|
+
.replace("__ROLLER_NEEDS_TRANSCRIPT__", repr(bool(spec.needs_transcript)))
|
|
104
|
+
.replace("__ROLLER_TRANSCRIPT_PATH__", repr(TRANSCRIPT_CONTAINER_PATH))
|
|
105
|
+
.replace("__ROLLER_WORKSPACE__", repr(workspace_path))
|
|
106
|
+
.replace("__ROLLER_ENTRYPOINT__", spec.entrypoint)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_grader_stdout(stdout: str) -> dict[str, Any] | None:
|
|
111
|
+
"""Recover the score payload from grader stdout.
|
|
112
|
+
|
|
113
|
+
Graders routinely print progress and judge traffic, so the payload is
|
|
114
|
+
tagged rather than assumed to be the whole of stdout.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
marker = "__ROLLER_SCORE__"
|
|
118
|
+
index = stdout.rfind(marker)
|
|
119
|
+
if index >= 0:
|
|
120
|
+
candidate = stdout[index + len(marker):].strip()
|
|
121
|
+
try:
|
|
122
|
+
payload = json.loads(candidate)
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
payload = None
|
|
125
|
+
if isinstance(payload, dict):
|
|
126
|
+
return payload
|
|
127
|
+
for line in reversed(stdout.strip().splitlines()):
|
|
128
|
+
line = line.strip()
|
|
129
|
+
if not line.startswith("{"):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
payload = json.loads(line)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
if isinstance(payload, dict):
|
|
136
|
+
return payload
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ContainerGrader:
|
|
141
|
+
"""Run a bench's grader inside a live sample container."""
|
|
142
|
+
|
|
143
|
+
def __init__(self, spec: GradeSpec) -> None:
|
|
144
|
+
self.spec = spec
|
|
145
|
+
|
|
146
|
+
def _env_args(self, task_id: str) -> list[str]:
|
|
147
|
+
args: list[str] = []
|
|
148
|
+
required = set(self.spec.env_names)
|
|
149
|
+
for name in (*self.spec.env_names, *self.spec.optional_env_names):
|
|
150
|
+
if not ENV_NAME_RE.fullmatch(name):
|
|
151
|
+
raise ValueError(f"invalid grading environment variable name: {name!r}")
|
|
152
|
+
value = os.environ.get(name)
|
|
153
|
+
if not value:
|
|
154
|
+
if name in required:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"[%s] grading environment variable %s is unset; grader may fail",
|
|
157
|
+
task_id,
|
|
158
|
+
name,
|
|
159
|
+
)
|
|
160
|
+
continue
|
|
161
|
+
# Docker/Podman copies NAME from this process's environment. Do not
|
|
162
|
+
# place secret values in argv, where process listings can expose them.
|
|
163
|
+
args.extend(["-e", name])
|
|
164
|
+
return args
|
|
165
|
+
|
|
166
|
+
def grade(
|
|
167
|
+
self,
|
|
168
|
+
runtime: Any,
|
|
169
|
+
container: str,
|
|
170
|
+
*,
|
|
171
|
+
task_id: str,
|
|
172
|
+
workspace_path: str,
|
|
173
|
+
transcript_container_path: str | None,
|
|
174
|
+
) -> GradeOutcome:
|
|
175
|
+
started = time.monotonic()
|
|
176
|
+
workspace = self.spec.workspace_path or workspace_path
|
|
177
|
+
prepare = runtime.run(
|
|
178
|
+
["exec", container, "mkdir", "-p", GRADER_CONTAINER_DIR], timeout=30
|
|
179
|
+
)
|
|
180
|
+
if prepare.returncode != 0:
|
|
181
|
+
detail = (prepare.stderr or prepare.stdout or "").strip()[-500:]
|
|
182
|
+
return GradeOutcome(
|
|
183
|
+
_error_scores(f"grader staging failed: {detail}"),
|
|
184
|
+
time.monotonic() - started,
|
|
185
|
+
error=detail or "grader staging failed",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self.spec.needs_transcript:
|
|
189
|
+
self._stage_transcript(runtime, container, transcript_container_path, task_id)
|
|
190
|
+
|
|
191
|
+
staging_error = self._stage_files(runtime, container, workspace, task_id)
|
|
192
|
+
if staging_error is not None:
|
|
193
|
+
# Without its answer key the grader would score a correct run zero,
|
|
194
|
+
# which reads as a model failure. Surface it as a grading error.
|
|
195
|
+
return GradeOutcome(
|
|
196
|
+
_error_scores(f"grader staging failed: {staging_error}"),
|
|
197
|
+
time.monotonic() - started,
|
|
198
|
+
error=staging_error,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
runner_source = build_runner_source(self.spec, workspace)
|
|
202
|
+
with tempfile.NamedTemporaryFile(
|
|
203
|
+
"w", suffix=".py", delete=False, encoding="utf-8"
|
|
204
|
+
) as handle:
|
|
205
|
+
handle.write(runner_source)
|
|
206
|
+
host_runner = handle.name
|
|
207
|
+
try:
|
|
208
|
+
copied = runtime.run(
|
|
209
|
+
["cp", host_runner, f"{container}:{RUNNER_CONTAINER_PATH}"], timeout=60
|
|
210
|
+
)
|
|
211
|
+
if copied.returncode != 0:
|
|
212
|
+
detail = (copied.stderr or copied.stdout or "").strip()[-500:]
|
|
213
|
+
return GradeOutcome(
|
|
214
|
+
_error_scores(f"grader copy failed: {detail}"),
|
|
215
|
+
time.monotonic() - started,
|
|
216
|
+
error=detail or "grader copy failed",
|
|
217
|
+
)
|
|
218
|
+
try:
|
|
219
|
+
result = runtime.run(
|
|
220
|
+
[
|
|
221
|
+
"exec",
|
|
222
|
+
*self._env_args(task_id),
|
|
223
|
+
"-w",
|
|
224
|
+
workspace,
|
|
225
|
+
container,
|
|
226
|
+
"python3",
|
|
227
|
+
RUNNER_CONTAINER_PATH,
|
|
228
|
+
],
|
|
229
|
+
timeout=self.spec.timeout_seconds,
|
|
230
|
+
)
|
|
231
|
+
except subprocess.TimeoutExpired:
|
|
232
|
+
message = f"grading exceeded {self.spec.timeout_seconds}s"
|
|
233
|
+
return GradeOutcome(
|
|
234
|
+
_error_scores(message), time.monotonic() - started, error=message
|
|
235
|
+
)
|
|
236
|
+
finally:
|
|
237
|
+
Path(host_runner).unlink(missing_ok=True)
|
|
238
|
+
|
|
239
|
+
elapsed = time.monotonic() - started
|
|
240
|
+
if result.returncode != 0:
|
|
241
|
+
detail = (result.stderr or result.stdout or "unknown error").strip()[-2000:]
|
|
242
|
+
return GradeOutcome(
|
|
243
|
+
_error_scores(f"grader failed: {detail}"), elapsed, error=detail
|
|
244
|
+
)
|
|
245
|
+
scores = parse_grader_stdout(result.stdout or "")
|
|
246
|
+
if scores is None:
|
|
247
|
+
detail = (result.stdout or "").strip()[-500:]
|
|
248
|
+
message = f"grader produced no JSON payload: {detail}"
|
|
249
|
+
return GradeOutcome(_error_scores(message), elapsed, error=message)
|
|
250
|
+
return GradeOutcome(scores, elapsed)
|
|
251
|
+
|
|
252
|
+
def _stage_files(
|
|
253
|
+
self, runtime: Any, container: str, workspace: str, task_id: str
|
|
254
|
+
) -> str | None:
|
|
255
|
+
"""Copy the spec's ``stage`` sources into the container's workspace.
|
|
256
|
+
|
|
257
|
+
This runs after the agent's last turn, so files placed here — answer
|
|
258
|
+
keys, reference outputs — were never visible to the model. Returns a
|
|
259
|
+
failure description, or ``None`` when everything landed.
|
|
260
|
+
"""
|
|
261
|
+
|
|
262
|
+
for source, target in self.spec.stage:
|
|
263
|
+
host = Path(source)
|
|
264
|
+
if not host.exists():
|
|
265
|
+
return f"staged source is missing on the host: {source}"
|
|
266
|
+
destination = f"{workspace}/{target}"
|
|
267
|
+
parent = str(PurePosixPath(destination).parent)
|
|
268
|
+
# ``docker cp`` needs the destination's parent to exist, and for a
|
|
269
|
+
# directory source the destination itself.
|
|
270
|
+
container_dir = destination if host.is_dir() else parent
|
|
271
|
+
made = runtime.run(
|
|
272
|
+
["exec", container, "mkdir", "-p", container_dir], timeout=30
|
|
273
|
+
)
|
|
274
|
+
if made.returncode != 0:
|
|
275
|
+
detail = (made.stderr or made.stdout or "").strip()[-300:]
|
|
276
|
+
return f"could not create {container_dir}: {detail}"
|
|
277
|
+
host_argument = f"{host}/." if host.is_dir() else str(host)
|
|
278
|
+
copied = runtime.run(
|
|
279
|
+
["cp", host_argument, f"{container}:{destination}"], timeout=120
|
|
280
|
+
)
|
|
281
|
+
if copied.returncode != 0:
|
|
282
|
+
detail = (copied.stderr or copied.stdout or "").strip()[-300:]
|
|
283
|
+
return f"could not copy {source} to {destination}: {detail}"
|
|
284
|
+
logger.debug("[%s] staged %s -> %s for grading", task_id, source, destination)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _stage_transcript(
|
|
289
|
+
runtime: Any,
|
|
290
|
+
container: str,
|
|
291
|
+
transcript_container_path: str | None,
|
|
292
|
+
task_id: str,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Copy the live session transcript to a fixed path for the grader.
|
|
295
|
+
|
|
296
|
+
Each harness stores its transcript somewhere different; normalizing the
|
|
297
|
+
location here keeps bench graders harness-agnostic.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
if not transcript_container_path:
|
|
301
|
+
return
|
|
302
|
+
script = (
|
|
303
|
+
f"if [ -f {shlex.quote(transcript_container_path)} ]; then "
|
|
304
|
+
f"cp {shlex.quote(transcript_container_path)} "
|
|
305
|
+
f"{shlex.quote(TRANSCRIPT_CONTAINER_PATH)}; fi"
|
|
306
|
+
)
|
|
307
|
+
result = runtime.run(["exec", container, "/bin/sh", "-c", script], timeout=60)
|
|
308
|
+
if result.returncode != 0:
|
|
309
|
+
logger.warning(
|
|
310
|
+
"[%s] transcript staging failed: %s",
|
|
311
|
+
task_id,
|
|
312
|
+
(result.stderr or result.stdout or "").strip()[-300:],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def transcript_container_path(
|
|
317
|
+
session: InteractiveSession, spec: GradeSpec | None = None
|
|
318
|
+
) -> str | None:
|
|
319
|
+
"""Best-effort location of a session's transcript inside its container.
|
|
320
|
+
|
|
321
|
+
Only OpenClaw exposes a derivable session file; for other harnesses a bench
|
|
322
|
+
can pin the path via ``GradeSpec.transcript_path``.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
if spec is not None and spec.transcript_path:
|
|
326
|
+
return spec.transcript_path
|
|
327
|
+
prepare = getattr(session, "prepare_transcript_for_grading", None)
|
|
328
|
+
if callable(prepare):
|
|
329
|
+
prepared = prepare()
|
|
330
|
+
if isinstance(prepared, str) and prepared:
|
|
331
|
+
return prepared
|
|
332
|
+
explicit = getattr(session, "transcript_container_path", None)
|
|
333
|
+
if isinstance(explicit, str) and explicit:
|
|
334
|
+
return explicit
|
|
335
|
+
harness = getattr(session, "harness", None)
|
|
336
|
+
config = getattr(harness, "config", None)
|
|
337
|
+
state_dir = getattr(session, "state_dir", None)
|
|
338
|
+
session_id = getattr(config, "session_id", None)
|
|
339
|
+
if isinstance(state_dir, str) and isinstance(session_id, str):
|
|
340
|
+
return f"{state_dir}/agents/main/sessions/{session_id}.jsonl"
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def workspace_container_path(session: InteractiveSession) -> str:
|
|
345
|
+
harness = getattr(session, "harness", None)
|
|
346
|
+
config = getattr(harness, "config", None)
|
|
347
|
+
value = getattr(config, "workspace_container_path", None)
|
|
348
|
+
return value if isinstance(value, str) and value else "/tmp_workspace"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def prepare_benchmark_workspace(
|
|
352
|
+
session: InteractiveSession, spec: GradeSpec | None
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Expose a bench's canonical workspace path before the agent runs."""
|
|
355
|
+
|
|
356
|
+
if spec is None or spec.workspace_path is None:
|
|
357
|
+
return
|
|
358
|
+
actual = workspace_container_path(session)
|
|
359
|
+
expected = spec.workspace_path
|
|
360
|
+
if actual == expected:
|
|
361
|
+
return
|
|
362
|
+
container = getattr(session, "container", None)
|
|
363
|
+
runtime = getattr(getattr(session, "harness", None), "runtime", None)
|
|
364
|
+
if (
|
|
365
|
+
not getattr(session, "container_started", False)
|
|
366
|
+
or not container
|
|
367
|
+
or runtime is None
|
|
368
|
+
):
|
|
369
|
+
raise RuntimeError(
|
|
370
|
+
"cannot prepare benchmark workspace alias without a live container"
|
|
371
|
+
)
|
|
372
|
+
expected_path = PurePosixPath(expected)
|
|
373
|
+
script = (
|
|
374
|
+
f"mkdir -p {shlex.quote(str(expected_path.parent))}; "
|
|
375
|
+
f"if [ -L {shlex.quote(expected)} ]; then "
|
|
376
|
+
f'test "$(readlink -f {shlex.quote(expected)})" = '
|
|
377
|
+
f'"$(readlink -f {shlex.quote(actual)})"; '
|
|
378
|
+
f"elif [ -e {shlex.quote(expected)} ]; then "
|
|
379
|
+
f"echo 'benchmark workspace alias target already exists' >&2; exit 1; "
|
|
380
|
+
f"else ln -s {shlex.quote(actual)} {shlex.quote(expected)}; fi"
|
|
381
|
+
)
|
|
382
|
+
result = runtime.run(
|
|
383
|
+
["exec", container, "/bin/sh", "-c", script],
|
|
384
|
+
timeout=30,
|
|
385
|
+
)
|
|
386
|
+
if result.returncode != 0:
|
|
387
|
+
detail = (result.stderr or result.stdout or "").strip()[-500:]
|
|
388
|
+
raise RuntimeError(
|
|
389
|
+
f"benchmark workspace alias {expected} -> {actual} failed"
|
|
390
|
+
+ (f": {detail}" if detail else "")
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class GradedHarness(Harness):
|
|
395
|
+
"""Drive an interactive harness, then grade before the container dies.
|
|
396
|
+
|
|
397
|
+
Grading has to observe the container's filesystem, so it cannot run after
|
|
398
|
+
``run()`` returns — by then the harness has already torn the container
|
|
399
|
+
down. This wrapper reproduces the plain run loop and slips the grader in
|
|
400
|
+
between the agent's last turn and teardown, leaving the wrapped harness
|
|
401
|
+
untouched.
|
|
402
|
+
"""
|
|
403
|
+
|
|
404
|
+
def __init__(
|
|
405
|
+
self,
|
|
406
|
+
inner: InteractiveHarness,
|
|
407
|
+
binding: BenchBinding,
|
|
408
|
+
*,
|
|
409
|
+
write_score_file: bool = True,
|
|
410
|
+
grading_semaphore: threading.Semaphore | None = None,
|
|
411
|
+
) -> None:
|
|
412
|
+
if not isinstance(inner, InteractiveHarness):
|
|
413
|
+
raise TypeError(
|
|
414
|
+
f"bench grading requires an interactive harness; {type(inner).__name__} "
|
|
415
|
+
"cannot expose a live container"
|
|
416
|
+
)
|
|
417
|
+
self.inner = inner
|
|
418
|
+
self.binding = binding
|
|
419
|
+
self.write_score_file = write_score_file
|
|
420
|
+
self.grading_semaphore = grading_semaphore
|
|
421
|
+
self.name = inner.name
|
|
422
|
+
|
|
423
|
+
def run(self, request: RunRequest, output_dir: Path, attempt: int) -> HarnessResult:
|
|
424
|
+
session = self.inner.create_session(request, output_dir, attempt)
|
|
425
|
+
status = "error"
|
|
426
|
+
error: str | None = None
|
|
427
|
+
started = time.monotonic()
|
|
428
|
+
try:
|
|
429
|
+
session.start()
|
|
430
|
+
self._prepare_session(session)
|
|
431
|
+
for index, prompt in enumerate(self._prompts(request)):
|
|
432
|
+
remaining = request.timeout_seconds - (time.monotonic() - started)
|
|
433
|
+
if remaining <= 0:
|
|
434
|
+
raise TimeoutError(f"task exceeded {request.timeout_seconds}s")
|
|
435
|
+
if (
|
|
436
|
+
index < len(request.task.new_session)
|
|
437
|
+
and request.task.new_session[index]
|
|
438
|
+
):
|
|
439
|
+
session.reset()
|
|
440
|
+
session.send(prompt, remaining)
|
|
441
|
+
status = "ok"
|
|
442
|
+
except (TimeoutError, subprocess.TimeoutExpired):
|
|
443
|
+
status = "timeout"
|
|
444
|
+
error = f"task exceeded {request.timeout_seconds}s"
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
status = "error"
|
|
447
|
+
error = str(exc)
|
|
448
|
+
|
|
449
|
+
outcome = self._grade(session, request, status)
|
|
450
|
+
result = session.finish(status, error)
|
|
451
|
+
if outcome is not None:
|
|
452
|
+
self._attach(result, request, output_dir, outcome)
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
def _prepare_session(self, session: InteractiveSession) -> None:
|
|
456
|
+
prepare_benchmark_workspace(session, self.binding.grade)
|
|
457
|
+
|
|
458
|
+
def _prompts(self, request: RunRequest) -> tuple[str, ...]:
|
|
459
|
+
"""Defer to the wrapped harness's own prompt expansion when it has one."""
|
|
460
|
+
|
|
461
|
+
getter = getattr(self.inner, "_prompts", None)
|
|
462
|
+
if callable(getter):
|
|
463
|
+
return tuple(getter(request))
|
|
464
|
+
return request.task.prompts
|
|
465
|
+
|
|
466
|
+
def _grade(
|
|
467
|
+
self, session: InteractiveSession, request: RunRequest, status: str
|
|
468
|
+
) -> GradeOutcome | None:
|
|
469
|
+
spec = self.binding.grade
|
|
470
|
+
if spec is None:
|
|
471
|
+
return None
|
|
472
|
+
if status != "ok":
|
|
473
|
+
# Agent timeouts/errors are excluded by benchmark protocol. A timed
|
|
474
|
+
# out harness may already have stopped its container, so attempting
|
|
475
|
+
# an exec here only obscures the real failure with a runtime error.
|
|
476
|
+
message = f"agent {status}; benchmark grading skipped"
|
|
477
|
+
return GradeOutcome(_error_scores(message), 0.0, error=message)
|
|
478
|
+
container = getattr(session, "container", None)
|
|
479
|
+
if not getattr(session, "container_started", False) or not container:
|
|
480
|
+
# The container never came up, so there is nothing to inspect. A
|
|
481
|
+
# zero here is the honest score: the agent produced no work.
|
|
482
|
+
return GradeOutcome(
|
|
483
|
+
_error_scores(f"container unavailable for grading (status={status})"),
|
|
484
|
+
0.0,
|
|
485
|
+
error="container unavailable",
|
|
486
|
+
)
|
|
487
|
+
runtime = getattr(getattr(session, "harness", None), "runtime", None)
|
|
488
|
+
if runtime is None:
|
|
489
|
+
return GradeOutcome(
|
|
490
|
+
_error_scores("harness exposes no docker runtime"),
|
|
491
|
+
0.0,
|
|
492
|
+
error="no docker runtime",
|
|
493
|
+
)
|
|
494
|
+
grader = ContainerGrader(spec)
|
|
495
|
+
grade_started = time.monotonic()
|
|
496
|
+
try:
|
|
497
|
+
if self.grading_semaphore is None:
|
|
498
|
+
logger.info("[%s] benchmark grading started", request.task.id)
|
|
499
|
+
outcome = grader.grade(
|
|
500
|
+
runtime,
|
|
501
|
+
container,
|
|
502
|
+
task_id=request.task.id,
|
|
503
|
+
workspace_path=workspace_container_path(session),
|
|
504
|
+
transcript_container_path=transcript_container_path(session, spec),
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
logger.info("[%s] waiting for benchmark grader slot", request.task.id)
|
|
508
|
+
with self.grading_semaphore:
|
|
509
|
+
logger.info("[%s] benchmark grading started", request.task.id)
|
|
510
|
+
outcome = grader.grade(
|
|
511
|
+
runtime,
|
|
512
|
+
container,
|
|
513
|
+
task_id=request.task.id,
|
|
514
|
+
workspace_path=workspace_container_path(session),
|
|
515
|
+
transcript_container_path=transcript_container_path(session, spec),
|
|
516
|
+
)
|
|
517
|
+
logger.info(
|
|
518
|
+
"[%s] benchmark grading finished in %.1fs%s",
|
|
519
|
+
request.task.id,
|
|
520
|
+
time.monotonic() - grade_started,
|
|
521
|
+
f": {outcome.error}" if outcome.error else "",
|
|
522
|
+
)
|
|
523
|
+
return outcome
|
|
524
|
+
except Exception as exc:
|
|
525
|
+
logger.exception("[%s] grading raised", request.task.id)
|
|
526
|
+
return GradeOutcome(_error_scores(str(exc)), 0.0, error=str(exc))
|
|
527
|
+
|
|
528
|
+
def _attach(
|
|
529
|
+
self,
|
|
530
|
+
result: HarnessResult,
|
|
531
|
+
request: RunRequest,
|
|
532
|
+
output_dir: Path,
|
|
533
|
+
outcome: GradeOutcome,
|
|
534
|
+
) -> None:
|
|
535
|
+
spec = self.binding.grade
|
|
536
|
+
payload = {
|
|
537
|
+
"bench": self.binding.name,
|
|
538
|
+
"category": self.binding.category,
|
|
539
|
+
"metadata": dict(self.binding.metadata),
|
|
540
|
+
"task_id": request.task.id,
|
|
541
|
+
"model": request.model,
|
|
542
|
+
"score_key": spec.score_key if spec is not None else "overall_score",
|
|
543
|
+
"elapsed_seconds": round(outcome.elapsed_seconds, 3),
|
|
544
|
+
"error": outcome.error,
|
|
545
|
+
"scores": outcome.scores,
|
|
546
|
+
}
|
|
547
|
+
if isinstance(result.metadata, dict):
|
|
548
|
+
result.metadata["bench"] = payload
|
|
549
|
+
if self.write_score_file:
|
|
550
|
+
try:
|
|
551
|
+
atomic_write_json(output_dir / "score.json", payload)
|
|
552
|
+
except OSError as exc:
|
|
553
|
+
logger.warning("[%s] could not write score.json: %s", request.task.id, exc)
|