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.
Files changed (92) hide show
  1. roller/__init__.py +31 -0
  2. roller/__main__.py +6 -0
  3. roller/actf.py +431 -0
  4. roller/artifacts.py +124 -0
  5. roller/assets/deepseek/journal.mjs +44 -0
  6. roller/assets/deepseek/roller.patch.yml +15 -0
  7. roller/assets/deepseek/runner.py +149 -0
  8. roller/assets/hermes/runner.py +322 -0
  9. roller/assets/openclaw_capture.py +32 -0
  10. roller/assets/openclaw_drain.mjs +21 -0
  11. roller/assets/opencode/journal.mjs +42 -0
  12. roller/assets/opencode/runner.py +203 -0
  13. roller/assets/pi/runner.mjs +160 -0
  14. roller/assets/pi/runner.py +93 -0
  15. roller/benches/__init__.py +73 -0
  16. roller/benches/base.py +306 -0
  17. roller/benches/grading.py +553 -0
  18. roller/benches/pinchbench.py +976 -0
  19. roller/benches/qwenclaw/__init__.py +49 -0
  20. roller/benches/qwenclaw/adapter.py +167 -0
  21. roller/benches/qwenclaw/grader_runtime.py +395 -0
  22. roller/benches/qwenclaw/report.py +49 -0
  23. roller/benches/qwenclaw/task_parser.py +181 -0
  24. roller/benches/qwenclaw/workspace.py +202 -0
  25. roller/benches/report.py +166 -0
  26. roller/benches/skillsbench.py +708 -0
  27. roller/benches/transcripts.py +102 -0
  28. roller/benches/wildclaw.py +569 -0
  29. roller/bridge_capture.py +73 -0
  30. roller/bundles.py +545 -0
  31. roller/capture.py +385 -0
  32. roller/capture_protocol.py +301 -0
  33. roller/capture_runtime.py +48 -0
  34. roller/capture_states.py +37 -0
  35. roller/claude_capture.py +185 -0
  36. roller/claude_code_actf.py +594 -0
  37. roller/cli.py +1732 -0
  38. roller/codex_actf.py +584 -0
  39. roller/codex_capture.py +99 -0
  40. roller/codex_tools.py +137 -0
  41. roller/collector.py +818 -0
  42. roller/compaction_sft.py +672 -0
  43. roller/conversation.py +432 -0
  44. roller/deepseek_actf.py +135 -0
  45. roller/harness_registry.py +61 -0
  46. roller/harnesses/__init__.py +29 -0
  47. roller/harnesses/base.py +64 -0
  48. roller/harnesses/bridge.py +448 -0
  49. roller/harnesses/claude_code.py +839 -0
  50. roller/harnesses/codex.py +808 -0
  51. roller/harnesses/config_overrides.py +100 -0
  52. roller/harnesses/container.py +206 -0
  53. roller/harnesses/deepseek.py +104 -0
  54. roller/harnesses/docker.py +72 -0
  55. roller/harnesses/gateway_ports.py +155 -0
  56. roller/harnesses/hermes.py +119 -0
  57. roller/harnesses/openclaw.py +1605 -0
  58. roller/harnesses/opencode.py +193 -0
  59. roller/harnesses/pi.py +111 -0
  60. roller/hermes_actf.py +147 -0
  61. roller/image_build.py +206 -0
  62. roller/integrity.py +332 -0
  63. roller/model_protocols.py +201 -0
  64. roller/models.py +169 -0
  65. roller/openclaw_capture.py +140 -0
  66. roller/openclaw_delivery.py +211 -0
  67. roller/openclaw_usage.py +59 -0
  68. roller/opencode_actf.py +305 -0
  69. roller/opencode_capture.py +96 -0
  70. roller/pi_actf.py +172 -0
  71. roller/response_metadata.py +89 -0
  72. roller/run_lock.py +297 -0
  73. roller/sft.py +471 -0
  74. roller/storage.py +53 -0
  75. roller/tasks.py +689 -0
  76. roller/thinking.py +50 -0
  77. roller/thinking_capabilities.py +152 -0
  78. roller/trajectory_audit.py +386 -0
  79. roller/usage.py +168 -0
  80. roller/version.py +1 -0
  81. roller/viewer/__init__.py +3 -0
  82. roller/viewer/app.py +722 -0
  83. roller/viewer/static/app.js +752 -0
  84. roller/viewer/static/index.html +105 -0
  85. roller/viewer/static/style.css +213 -0
  86. roller/wire_trace.py +650 -0
  87. roller_agent-0.2.0.dist-info/METADATA +397 -0
  88. roller_agent-0.2.0.dist-info/RECORD +92 -0
  89. roller_agent-0.2.0.dist-info/WHEEL +5 -0
  90. roller_agent-0.2.0.dist-info/entry_points.txt +2 -0
  91. roller_agent-0.2.0.dist-info/licenses/LICENSE +201 -0
  92. 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)