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,49 @@
|
|
|
1
|
+
"""QwenClawBench adapter package."""
|
|
2
|
+
|
|
3
|
+
from roller.benches.qwenclaw.adapter import (
|
|
4
|
+
DATASET_DIR_NAME,
|
|
5
|
+
TASK_SCHEMA,
|
|
6
|
+
QwenClawBenchAdapter,
|
|
7
|
+
)
|
|
8
|
+
from roller.benches.qwenclaw.grader_runtime import (
|
|
9
|
+
AUTO_PENALTY_THRESHOLD,
|
|
10
|
+
GRADE_TIMEOUT_SECONDS,
|
|
11
|
+
JUDGE_ENV_NAMES,
|
|
12
|
+
OUTPUT_DIR_NAME,
|
|
13
|
+
WORKSPACE_CONTAINER_PATH,
|
|
14
|
+
QwenClawGradedHarness,
|
|
15
|
+
build_grader_source,
|
|
16
|
+
)
|
|
17
|
+
from roller.benches.qwenclaw.report import aggregate_samples
|
|
18
|
+
from roller.benches.qwenclaw.task_parser import (
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
20
|
+
QwenClawTask,
|
|
21
|
+
parse_task_markdown,
|
|
22
|
+
split_sections,
|
|
23
|
+
)
|
|
24
|
+
from roller.benches.qwenclaw.workspace import (
|
|
25
|
+
materialize_workspace,
|
|
26
|
+
workspace_compatibility_script,
|
|
27
|
+
)
|
|
28
|
+
from roller.benches.transcripts import session_trajectory
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AUTO_PENALTY_THRESHOLD",
|
|
32
|
+
"DATASET_DIR_NAME",
|
|
33
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
34
|
+
"GRADE_TIMEOUT_SECONDS",
|
|
35
|
+
"JUDGE_ENV_NAMES",
|
|
36
|
+
"OUTPUT_DIR_NAME",
|
|
37
|
+
"TASK_SCHEMA",
|
|
38
|
+
"WORKSPACE_CONTAINER_PATH",
|
|
39
|
+
"QwenClawBenchAdapter",
|
|
40
|
+
"QwenClawGradedHarness",
|
|
41
|
+
"QwenClawTask",
|
|
42
|
+
"aggregate_samples",
|
|
43
|
+
"build_grader_source",
|
|
44
|
+
"materialize_workspace",
|
|
45
|
+
"parse_task_markdown",
|
|
46
|
+
"session_trajectory",
|
|
47
|
+
"split_sections",
|
|
48
|
+
"workspace_compatibility_script",
|
|
49
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""QwenClawBench adapter assembly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from roller.benches.base import (
|
|
12
|
+
BenchAdapter,
|
|
13
|
+
BenchBinding,
|
|
14
|
+
BenchFormatError,
|
|
15
|
+
GradeSpec,
|
|
16
|
+
validate_timeout_multiplier,
|
|
17
|
+
)
|
|
18
|
+
from roller.benches.qwenclaw.grader_runtime import (
|
|
19
|
+
GRADE_TIMEOUT_SECONDS,
|
|
20
|
+
JUDGE_ENV_NAMES,
|
|
21
|
+
OUTPUT_DIR_NAME,
|
|
22
|
+
WORKSPACE_CONTAINER_PATH,
|
|
23
|
+
QwenClawGradedHarness,
|
|
24
|
+
build_grader_source,
|
|
25
|
+
)
|
|
26
|
+
from roller.benches.qwenclaw.report import aggregate_samples
|
|
27
|
+
from roller.benches.qwenclaw.task_parser import parse_task_markdown
|
|
28
|
+
from roller.benches.qwenclaw.workspace import materialize_workspace
|
|
29
|
+
|
|
30
|
+
TASK_SCHEMA = "roller.task/v1"
|
|
31
|
+
DATASET_DIR_NAME = "qwenclawbench-v1.1-100"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QwenClawBenchAdapter(BenchAdapter):
|
|
35
|
+
name = "qwenclawbench"
|
|
36
|
+
description = "QwenClawBench — 100 real-user OpenClaw tasks across 8 domains"
|
|
37
|
+
|
|
38
|
+
def __init__(self, cache_root: Path | None = None) -> None:
|
|
39
|
+
self.cache_root = cache_root
|
|
40
|
+
|
|
41
|
+
def load(
|
|
42
|
+
self,
|
|
43
|
+
root: Path,
|
|
44
|
+
*,
|
|
45
|
+
categories: Sequence[str] | None = None,
|
|
46
|
+
task_ids: Sequence[str] | None = None,
|
|
47
|
+
variant: str | None = None,
|
|
48
|
+
timeout_multiplier: float = 1.0,
|
|
49
|
+
) -> list[dict[str, Any]]:
|
|
50
|
+
if variant is not None:
|
|
51
|
+
raise BenchFormatError(f"{self.name}: this bench has no variants")
|
|
52
|
+
multiplier = validate_timeout_multiplier(timeout_multiplier)
|
|
53
|
+
dataset = self._dataset_root(root)
|
|
54
|
+
tasks_root = dataset / "tasks"
|
|
55
|
+
assets_root = dataset / "assets"
|
|
56
|
+
wanted_categories = set(categories or ())
|
|
57
|
+
wanted_ids = set(task_ids or ())
|
|
58
|
+
rows: list[dict[str, Any]] = []
|
|
59
|
+
seen_ids: set[str] = set()
|
|
60
|
+
available_categories: set[str] = set()
|
|
61
|
+
|
|
62
|
+
for task_path in sorted(tasks_root.glob("task_*.md")):
|
|
63
|
+
task = parse_task_markdown(
|
|
64
|
+
task_path.read_text(encoding="utf-8"),
|
|
65
|
+
str(task_path),
|
|
66
|
+
source_path=task_path,
|
|
67
|
+
)
|
|
68
|
+
if task.task_id in seen_ids:
|
|
69
|
+
raise BenchFormatError(
|
|
70
|
+
f"duplicate QwenClawBench task id: {task.task_id}"
|
|
71
|
+
)
|
|
72
|
+
seen_ids.add(task.task_id)
|
|
73
|
+
if task.category:
|
|
74
|
+
available_categories.add(task.category)
|
|
75
|
+
if wanted_categories and task.category not in wanted_categories:
|
|
76
|
+
continue
|
|
77
|
+
if wanted_ids and task.task_id not in wanted_ids:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
workspace = materialize_workspace(task, assets_root, self.cache_root)
|
|
81
|
+
env_names = (
|
|
82
|
+
JUDGE_ENV_NAMES if task.grading_type in {"llm_judge", "hybrid"} else ()
|
|
83
|
+
)
|
|
84
|
+
binding = BenchBinding(
|
|
85
|
+
name=self.name,
|
|
86
|
+
category=task.category or None,
|
|
87
|
+
metadata={
|
|
88
|
+
"upstream_task_id": task.task_id,
|
|
89
|
+
"name": task.name,
|
|
90
|
+
"subcategory": task.subcategory,
|
|
91
|
+
"grading_type": task.grading_type,
|
|
92
|
+
"grading_weights": task.grading_weights,
|
|
93
|
+
"input_modality": task.input_modality,
|
|
94
|
+
"external_dependency": task.external_dependency,
|
|
95
|
+
"run_index": 1,
|
|
96
|
+
"runs_per_task": 1,
|
|
97
|
+
},
|
|
98
|
+
grade=GradeSpec(
|
|
99
|
+
code=build_grader_source(task),
|
|
100
|
+
entrypoint="roller_grade",
|
|
101
|
+
env_names=env_names,
|
|
102
|
+
timeout_seconds=GRADE_TIMEOUT_SECONDS,
|
|
103
|
+
needs_transcript=True,
|
|
104
|
+
workspace_path=WORKSPACE_CONTAINER_PATH,
|
|
105
|
+
score_key="overall_score",
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
rows.append(
|
|
109
|
+
{
|
|
110
|
+
"schema_version": TASK_SCHEMA,
|
|
111
|
+
"id": task.task_id,
|
|
112
|
+
"harness": "openclaw",
|
|
113
|
+
"prompt": task.prompt,
|
|
114
|
+
"workspace": str(workspace),
|
|
115
|
+
"files": {"output_dir": OUTPUT_DIR_NAME},
|
|
116
|
+
"timeout_seconds": max(
|
|
117
|
+
1, math.ceil(task.timeout_seconds * multiplier)
|
|
118
|
+
),
|
|
119
|
+
"bench": binding.as_dict(),
|
|
120
|
+
}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
missing_categories = wanted_categories - available_categories
|
|
124
|
+
if missing_categories:
|
|
125
|
+
raise BenchFormatError(
|
|
126
|
+
"unknown QwenClawBench categories: "
|
|
127
|
+
+ ", ".join(sorted(missing_categories))
|
|
128
|
+
)
|
|
129
|
+
missing_ids = wanted_ids - seen_ids
|
|
130
|
+
if missing_ids:
|
|
131
|
+
raise BenchFormatError(
|
|
132
|
+
"unknown QwenClawBench task ids: " + ", ".join(sorted(missing_ids))
|
|
133
|
+
)
|
|
134
|
+
return rows
|
|
135
|
+
|
|
136
|
+
def wrap_harness(self, harness, binding, *, grading_semaphore=None):
|
|
137
|
+
if binding.grade is None:
|
|
138
|
+
return harness
|
|
139
|
+
grade = replace(
|
|
140
|
+
binding.grade, transcript_path="/tmp/roller-qwenclaw-transcript.json"
|
|
141
|
+
)
|
|
142
|
+
return QwenClawGradedHarness(
|
|
143
|
+
harness,
|
|
144
|
+
replace(binding, grade=grade),
|
|
145
|
+
grading_semaphore=grading_semaphore,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def aggregate(self, samples: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
|
149
|
+
return aggregate_samples(samples)
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _dataset_root(root: Path) -> Path:
|
|
153
|
+
candidates = [
|
|
154
|
+
root,
|
|
155
|
+
root / "data" / DATASET_DIR_NAME,
|
|
156
|
+
]
|
|
157
|
+
data_root = root / "data"
|
|
158
|
+
if data_root.is_dir():
|
|
159
|
+
candidates.extend(
|
|
160
|
+
path for path in sorted(data_root.iterdir()) if path.is_dir()
|
|
161
|
+
)
|
|
162
|
+
for candidate in candidates:
|
|
163
|
+
if (candidate / "tasks").is_dir() and (candidate / "assets").is_dir():
|
|
164
|
+
return candidate.resolve()
|
|
165
|
+
raise BenchFormatError(
|
|
166
|
+
f"{root}: expected tasks/ and assets/, or data/{DATASET_DIR_NAME}/"
|
|
167
|
+
)
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Self-contained QwenClawBench grader generation and harness integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from roller.benches.grading import (
|
|
11
|
+
GradedHarness,
|
|
12
|
+
GradeOutcome,
|
|
13
|
+
workspace_container_path,
|
|
14
|
+
)
|
|
15
|
+
from roller.benches.qwenclaw.task_parser import QwenClawTask
|
|
16
|
+
from roller.benches.qwenclaw.workspace import workspace_compatibility_script
|
|
17
|
+
from roller.benches.transcripts import grader_messages, session_trajectory
|
|
18
|
+
|
|
19
|
+
IMPORT_LINE_RE = re.compile(
|
|
20
|
+
r"^(?P<indent>[ \t]*)import\s+(?P<names>[A-Za-z0-9_., \t]+)$",
|
|
21
|
+
re.MULTILINE,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
WORKSPACE_CONTAINER_PATH = "/home/node/workspace"
|
|
25
|
+
OUTPUT_DIR_NAME = ".roller_outputs"
|
|
26
|
+
GRADE_TIMEOUT_SECONDS = 1800
|
|
27
|
+
AUTO_PENALTY_THRESHOLD = 0.75
|
|
28
|
+
|
|
29
|
+
JUDGE_ENV_NAMES = (
|
|
30
|
+
"QWENCLAW_JUDGE_BASE_URL",
|
|
31
|
+
"QWENCLAW_JUDGE_API_KEY",
|
|
32
|
+
"QWENCLAW_JUDGE_MODEL",
|
|
33
|
+
"QWENCLAW_JUDGE_MAX_RETRIES",
|
|
34
|
+
"JUDGE_BASE_URL",
|
|
35
|
+
"JUDGE_API_KEY",
|
|
36
|
+
"JUDGE_MODEL",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_GRADER_RUNTIME = r"""
|
|
40
|
+
import json as _roller_json
|
|
41
|
+
import math as _roller_math
|
|
42
|
+
import os as _roller_os
|
|
43
|
+
import re as _roller_re
|
|
44
|
+
import shutil as _roller_shutil
|
|
45
|
+
import time as _roller_time
|
|
46
|
+
import urllib.error as _roller_urlerror
|
|
47
|
+
import urllib.request as _roller_urlrequest
|
|
48
|
+
from pathlib import Path as _RollerPath
|
|
49
|
+
|
|
50
|
+
_roller_automated_grade = globals().get("grade")
|
|
51
|
+
_ROLLER_GRADING_TYPE = __ROLLER_GRADING_TYPE__
|
|
52
|
+
_ROLLER_PROMPT = __ROLLER_PROMPT__
|
|
53
|
+
_ROLLER_EXPECTED = __ROLLER_EXPECTED__
|
|
54
|
+
_ROLLER_RUBRIC = __ROLLER_RUBRIC__
|
|
55
|
+
_ROLLER_WEIGHTS = __ROLLER_WEIGHTS__
|
|
56
|
+
_ROLLER_OUTPUT_DIR = __ROLLER_OUTPUT_DIR__
|
|
57
|
+
|
|
58
|
+
def _roller_numeric(scores):
|
|
59
|
+
result = {}
|
|
60
|
+
for key, value in (scores or {}).items():
|
|
61
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
62
|
+
result[str(key)] = float(value)
|
|
63
|
+
return result
|
|
64
|
+
|
|
65
|
+
def _roller_average(scores):
|
|
66
|
+
values = list(_roller_numeric(scores).values())
|
|
67
|
+
return sum(values) / len(values) if values else 0.0
|
|
68
|
+
|
|
69
|
+
def _roller_text(content):
|
|
70
|
+
if isinstance(content, str):
|
|
71
|
+
return content
|
|
72
|
+
if isinstance(content, dict):
|
|
73
|
+
for key in ("text", "thinking", "reasoning"):
|
|
74
|
+
value = content.get(key)
|
|
75
|
+
if isinstance(value, str):
|
|
76
|
+
return value
|
|
77
|
+
return ""
|
|
78
|
+
if isinstance(content, list):
|
|
79
|
+
return "\n".join(filter(None, (_roller_text(item) for item in content)))
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
def _roller_summary(transcript):
|
|
83
|
+
parts = []
|
|
84
|
+
for event in transcript or []:
|
|
85
|
+
if not isinstance(event, dict) or event.get("type") != "message":
|
|
86
|
+
continue
|
|
87
|
+
message = event.get("message") or {}
|
|
88
|
+
role = message.get("role")
|
|
89
|
+
content = message.get("content") or []
|
|
90
|
+
if role == "assistant":
|
|
91
|
+
for item in content:
|
|
92
|
+
item_type = item.get("type") if isinstance(item, dict) else None
|
|
93
|
+
if isinstance(item, str) or item_type == "text":
|
|
94
|
+
text = _roller_text(item)
|
|
95
|
+
if text:
|
|
96
|
+
parts.append("Assistant: %s" % text[:2000])
|
|
97
|
+
elif item_type in ("thinking", "reasoning"):
|
|
98
|
+
text = _roller_text(item)
|
|
99
|
+
if text:
|
|
100
|
+
parts.append("Reasoning: %s" % text[:2000])
|
|
101
|
+
elif isinstance(item, dict) and item.get("type") in ("toolCall", "tool_use"):
|
|
102
|
+
arguments = item.get("arguments", item.get("input", {}))
|
|
103
|
+
parts.append("Tool: %s(%s)" % (
|
|
104
|
+
item.get("name"),
|
|
105
|
+
_roller_json.dumps(arguments, ensure_ascii=False),
|
|
106
|
+
))
|
|
107
|
+
elif role == "toolResult":
|
|
108
|
+
text = _roller_text(content)
|
|
109
|
+
if text:
|
|
110
|
+
parts.append("Result: %s" % text[:2000])
|
|
111
|
+
elif role == "user":
|
|
112
|
+
text = _roller_text(content)
|
|
113
|
+
if text:
|
|
114
|
+
parts.append("User: %s" % text[:2000])
|
|
115
|
+
return "\n".join(parts)
|
|
116
|
+
|
|
117
|
+
def _roller_judge_prompt(transcript):
|
|
118
|
+
return (
|
|
119
|
+
"You are a grading function. Your ONLY job is to output a single JSON object.\n\n"
|
|
120
|
+
"CRITICAL RULES:\n"
|
|
121
|
+
"- Do NOT use any tools (no Read, Write, exec, or any other tool calls)\n"
|
|
122
|
+
"- Do NOT create files or run commands\n"
|
|
123
|
+
"- Do NOT write any prose, explanation, or commentary outside the JSON\n"
|
|
124
|
+
"- Respond with ONLY a JSON object — nothing else\n\n"
|
|
125
|
+
"Be a strict evaluator. Reserve 1.0 for genuinely excellent performance. "
|
|
126
|
+
"An average acceptable completion should score around 0.6-0.7. "
|
|
127
|
+
"Deduct points for unnecessary steps, verbose output, and inefficient tool usage.\n\n"
|
|
128
|
+
"## Task\n%s\n\n## Expected Behavior\n%s\n\n"
|
|
129
|
+
"## Agent Transcript (summarized)\n%s\n\n## Grading Rubric\n%s\n\n"
|
|
130
|
+
"Score each criterion from 0.0 to 1.0.\n\n"
|
|
131
|
+
"Respond with ONLY this JSON structure (no markdown, no code fences, no extra text):\n"
|
|
132
|
+
'{"scores": {"criterion_name": 0.0}, "total": 0.0, "notes": "brief justification"}'
|
|
133
|
+
) % (_ROLLER_PROMPT, _ROLLER_EXPECTED, _roller_summary(transcript), _ROLLER_RUBRIC)
|
|
134
|
+
|
|
135
|
+
def _roller_json_objects(text):
|
|
136
|
+
objects = []
|
|
137
|
+
depth = 0
|
|
138
|
+
current = []
|
|
139
|
+
for char in text:
|
|
140
|
+
if char == "{":
|
|
141
|
+
if depth == 0:
|
|
142
|
+
current = []
|
|
143
|
+
depth += 1
|
|
144
|
+
if depth:
|
|
145
|
+
current.append(char)
|
|
146
|
+
if char == "}" and depth:
|
|
147
|
+
depth -= 1
|
|
148
|
+
if depth == 0:
|
|
149
|
+
objects.append("".join(current))
|
|
150
|
+
return objects
|
|
151
|
+
|
|
152
|
+
def _roller_parse_judge(text):
|
|
153
|
+
text = (text or "").strip()
|
|
154
|
+
fenced = _roller_re.search(r"```(?:json)?\s*(.*?)\s*```", text, _roller_re.DOTALL)
|
|
155
|
+
candidates = ([fenced.group(1)] if fenced else []) + list(reversed(_roller_json_objects(text)))
|
|
156
|
+
parsed = {}
|
|
157
|
+
for candidate in candidates:
|
|
158
|
+
try:
|
|
159
|
+
value = _roller_json.loads(candidate)
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(value, dict):
|
|
163
|
+
parsed = value
|
|
164
|
+
if "scores" in value:
|
|
165
|
+
break
|
|
166
|
+
scores = {}
|
|
167
|
+
source_scores = parsed.get("scores", parsed.get("criteria_scores", {}))
|
|
168
|
+
if isinstance(source_scores, dict):
|
|
169
|
+
for key, value in source_scores.items():
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
value = value.get("score")
|
|
172
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
173
|
+
if not _roller_math.isfinite(value):
|
|
174
|
+
raise ValueError("judge criterion score must be finite: %s" % key)
|
|
175
|
+
scores[str(key)] = float(value)
|
|
176
|
+
total = parsed.get("total", parsed.get("score"))
|
|
177
|
+
if not isinstance(total, (int, float)) or isinstance(total, bool):
|
|
178
|
+
if not scores:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"judge response did not contain a numeric total or criterion score"
|
|
181
|
+
)
|
|
182
|
+
total = _roller_average(scores)
|
|
183
|
+
if not _roller_math.isfinite(total):
|
|
184
|
+
raise ValueError("judge total score must be finite")
|
|
185
|
+
return scores, max(0.0, min(1.0, float(total))), str(
|
|
186
|
+
parsed.get("notes", parsed.get("justification", parsed.get("reasoning", "")))
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _roller_call_judge(transcript):
|
|
190
|
+
base = (
|
|
191
|
+
_roller_os.environ.get("QWENCLAW_JUDGE_BASE_URL")
|
|
192
|
+
or _roller_os.environ.get("JUDGE_BASE_URL")
|
|
193
|
+
or ""
|
|
194
|
+
).strip()
|
|
195
|
+
key = (
|
|
196
|
+
_roller_os.environ.get("QWENCLAW_JUDGE_API_KEY")
|
|
197
|
+
or _roller_os.environ.get("JUDGE_API_KEY")
|
|
198
|
+
or ""
|
|
199
|
+
).strip()
|
|
200
|
+
model = (
|
|
201
|
+
_roller_os.environ.get("QWENCLAW_JUDGE_MODEL")
|
|
202
|
+
or _roller_os.environ.get("JUDGE_MODEL")
|
|
203
|
+
or "claude-opus-4-5-20251101"
|
|
204
|
+
).strip()
|
|
205
|
+
if not base or not key:
|
|
206
|
+
raise RuntimeError(
|
|
207
|
+
"LLM judge requires QWENCLAW_JUDGE_BASE_URL and "
|
|
208
|
+
"QWENCLAW_JUDGE_API_KEY (JUDGE_BASE_URL/JUDGE_API_KEY also accepted)"
|
|
209
|
+
)
|
|
210
|
+
endpoint = base.rstrip("/")
|
|
211
|
+
if not endpoint.endswith("/chat/completions"):
|
|
212
|
+
endpoint += "/chat/completions"
|
|
213
|
+
payload = _roller_json.dumps({
|
|
214
|
+
"model": model.split("/", 1)[-1] if "/" in model else model,
|
|
215
|
+
"messages": [{"role": "user", "content": _roller_judge_prompt(transcript)}],
|
|
216
|
+
"temperature": 0.0,
|
|
217
|
+
"max_tokens": 20480,
|
|
218
|
+
}).encode("utf-8")
|
|
219
|
+
retries = int(_roller_os.environ.get("QWENCLAW_JUDGE_MAX_RETRIES", "3"))
|
|
220
|
+
retries = max(1, min(100, retries))
|
|
221
|
+
last_error = None
|
|
222
|
+
for attempt in range(retries):
|
|
223
|
+
request = _roller_urlrequest.Request(
|
|
224
|
+
endpoint,
|
|
225
|
+
data=payload,
|
|
226
|
+
headers={"Content-Type": "application/json", "Authorization": "Bearer " + key},
|
|
227
|
+
method="POST",
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
with _roller_urlrequest.urlopen(request, timeout=600) as response:
|
|
231
|
+
body = _roller_json.loads(response.read().decode("utf-8"))
|
|
232
|
+
choices = body.get("choices") or []
|
|
233
|
+
if not choices:
|
|
234
|
+
raise RuntimeError("judge API returned no choices")
|
|
235
|
+
return _roller_parse_judge((choices[0].get("message") or {}).get("content", ""))
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
last_error = exc
|
|
238
|
+
if attempt + 1 < retries:
|
|
239
|
+
_roller_time.sleep(min(5 * (2 ** attempt), 30))
|
|
240
|
+
raise RuntimeError("judge API failed after %d attempt(s): %s" % (retries, last_error))
|
|
241
|
+
|
|
242
|
+
def _roller_snapshot(workspace_path):
|
|
243
|
+
source = _RollerPath(workspace_path)
|
|
244
|
+
destination = source / _ROLLER_OUTPUT_DIR
|
|
245
|
+
if destination.exists():
|
|
246
|
+
_roller_shutil.rmtree(destination)
|
|
247
|
+
destination.mkdir(parents=True)
|
|
248
|
+
for item in source.iterdir():
|
|
249
|
+
if item.name in {_ROLLER_OUTPUT_DIR, ".git"}:
|
|
250
|
+
continue
|
|
251
|
+
target = destination / item.name
|
|
252
|
+
if item.is_symlink():
|
|
253
|
+
continue
|
|
254
|
+
if item.is_dir():
|
|
255
|
+
_roller_shutil.copytree(item, target, symlinks=True)
|
|
256
|
+
elif item.is_file():
|
|
257
|
+
_roller_shutil.copy2(item, target)
|
|
258
|
+
|
|
259
|
+
def roller_grade(transcript, workspace_path):
|
|
260
|
+
try:
|
|
261
|
+
automated_scores = {}
|
|
262
|
+
automated_score = 0.0
|
|
263
|
+
if _ROLLER_GRADING_TYPE in {"automated", "hybrid"}:
|
|
264
|
+
if not callable(_roller_automated_grade):
|
|
265
|
+
raise RuntimeError("automated grader function is missing")
|
|
266
|
+
automated_scores = _roller_numeric(
|
|
267
|
+
_roller_automated_grade(transcript, workspace_path)
|
|
268
|
+
)
|
|
269
|
+
automated_score = _roller_average(automated_scores)
|
|
270
|
+
judge_scores = {}
|
|
271
|
+
judge_score = 0.0
|
|
272
|
+
notes = ""
|
|
273
|
+
if _ROLLER_GRADING_TYPE in {"llm_judge", "hybrid"}:
|
|
274
|
+
judge_scores, judge_score, notes = _roller_call_judge(transcript)
|
|
275
|
+
if _ROLLER_GRADING_TYPE == "automated":
|
|
276
|
+
overall = simple = automated_score
|
|
277
|
+
elif _ROLLER_GRADING_TYPE == "llm_judge":
|
|
278
|
+
overall = simple = judge_score
|
|
279
|
+
else:
|
|
280
|
+
auto_weight = float(_ROLLER_WEIGHTS.get("automated", 0.5))
|
|
281
|
+
judge_weight = float(_ROLLER_WEIGHTS.get("llm_judge", 0.5))
|
|
282
|
+
weight = auto_weight + judge_weight
|
|
283
|
+
if weight <= 0:
|
|
284
|
+
auto_weight = judge_weight = 0.5
|
|
285
|
+
weight = 1.0
|
|
286
|
+
simple = (automated_score * auto_weight + judge_score * judge_weight) / weight
|
|
287
|
+
adjusted = 0.0 if automated_score < 0.75 else judge_score
|
|
288
|
+
overall = (automated_score * auto_weight + adjusted * judge_weight) / weight
|
|
289
|
+
return {
|
|
290
|
+
"overall_score": overall,
|
|
291
|
+
"score_simple": simple,
|
|
292
|
+
"automated_score": automated_score,
|
|
293
|
+
"llm_judge_score": judge_score,
|
|
294
|
+
"automated": automated_scores,
|
|
295
|
+
"llm_judge": judge_scores,
|
|
296
|
+
"notes": notes,
|
|
297
|
+
}
|
|
298
|
+
finally:
|
|
299
|
+
_roller_snapshot(workspace_path)
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _remove_unused_yaml_import(code: str) -> str:
|
|
304
|
+
tree = ast.parse(code)
|
|
305
|
+
if any(
|
|
306
|
+
isinstance(node, ast.Name)
|
|
307
|
+
and node.id == "yaml"
|
|
308
|
+
and isinstance(node.ctx, ast.Load)
|
|
309
|
+
for node in ast.walk(tree)
|
|
310
|
+
):
|
|
311
|
+
return code
|
|
312
|
+
|
|
313
|
+
def remove_yaml(match: re.Match[str]) -> str:
|
|
314
|
+
names = [name.strip() for name in match.group("names").split(",")]
|
|
315
|
+
if "yaml" not in names:
|
|
316
|
+
return match.group(0)
|
|
317
|
+
names.remove("yaml")
|
|
318
|
+
if not names:
|
|
319
|
+
return ""
|
|
320
|
+
return f"{match.group('indent')}import {', '.join(names)}"
|
|
321
|
+
|
|
322
|
+
return IMPORT_LINE_RE.sub(remove_yaml, code)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def build_grader_source(task: QwenClawTask) -> str:
|
|
326
|
+
automated = _remove_unused_yaml_import(task.automated_code)
|
|
327
|
+
runtime = (
|
|
328
|
+
_GRADER_RUNTIME.replace("__ROLLER_GRADING_TYPE__", repr(task.grading_type))
|
|
329
|
+
.replace("__ROLLER_PROMPT__", repr(task.prompt))
|
|
330
|
+
.replace("__ROLLER_EXPECTED__", repr(task.expected_behavior))
|
|
331
|
+
.replace("__ROLLER_RUBRIC__", repr(task.llm_judge_rubric))
|
|
332
|
+
.replace("__ROLLER_WEIGHTS__", repr(task.grading_weights))
|
|
333
|
+
.replace("__ROLLER_OUTPUT_DIR__", repr(OUTPUT_DIR_NAME))
|
|
334
|
+
)
|
|
335
|
+
return (automated + "\n\n" if automated else "") + runtime.strip() + "\n"
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _session_trajectory(session):
|
|
339
|
+
# Preserve the old module-level monkeypatch point after moving this class
|
|
340
|
+
# into a submodule.
|
|
341
|
+
from roller.benches import qwenclaw
|
|
342
|
+
|
|
343
|
+
return qwenclaw.session_trajectory(session)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class QwenClawGradedHarness(GradedHarness):
|
|
347
|
+
"""Normalize each native journal before the upstream grader sees it."""
|
|
348
|
+
|
|
349
|
+
def _prepare_session(self, session) -> None:
|
|
350
|
+
super()._prepare_session(session)
|
|
351
|
+
workspace = workspace_container_path(session)
|
|
352
|
+
result = self.inner.runtime.run(
|
|
353
|
+
[
|
|
354
|
+
"exec",
|
|
355
|
+
session.container,
|
|
356
|
+
"/bin/sh",
|
|
357
|
+
"-c",
|
|
358
|
+
workspace_compatibility_script(workspace),
|
|
359
|
+
],
|
|
360
|
+
timeout=30,
|
|
361
|
+
)
|
|
362
|
+
if result.returncode != 0:
|
|
363
|
+
detail = (result.stderr or result.stdout or "").strip()[-500:]
|
|
364
|
+
raise RuntimeError(
|
|
365
|
+
"QwenClawBench workspace compatibility setup failed"
|
|
366
|
+
+ (f": {detail}" if detail else "")
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _grade(self, session, request, status):
|
|
370
|
+
if not getattr(session, "container_started", False):
|
|
371
|
+
return super()._grade(session, request, status)
|
|
372
|
+
try:
|
|
373
|
+
events = grader_messages(_session_trajectory(session))
|
|
374
|
+
with tempfile.NamedTemporaryFile(
|
|
375
|
+
"w", suffix=".json", encoding="utf-8"
|
|
376
|
+
) as handle:
|
|
377
|
+
json.dump(events, handle, ensure_ascii=False)
|
|
378
|
+
handle.flush()
|
|
379
|
+
copied = self.inner.runtime.run(
|
|
380
|
+
[
|
|
381
|
+
"cp",
|
|
382
|
+
handle.name,
|
|
383
|
+
f"{session.container}:/tmp/roller-qwenclaw-transcript.json",
|
|
384
|
+
],
|
|
385
|
+
timeout=60,
|
|
386
|
+
)
|
|
387
|
+
if copied.returncode:
|
|
388
|
+
raise RuntimeError(
|
|
389
|
+
"QwenClawBench normalized transcript staging failed"
|
|
390
|
+
)
|
|
391
|
+
return super()._grade(session, request, status)
|
|
392
|
+
except Exception as exc: # noqa: BLE001 - grading failures become score metadata
|
|
393
|
+
return GradeOutcome(
|
|
394
|
+
{"overall_score": 0.0, "error": str(exc)}, 0.0, str(exc)
|
|
395
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""QwenClawBench sample aggregation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from roller.benches.report import mean_aggregate, sample_score
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def aggregate_samples(samples: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
|
12
|
+
rows = list(samples)
|
|
13
|
+
report = mean_aggregate(rows)
|
|
14
|
+
grouped: dict[str, list[float]] = {}
|
|
15
|
+
for row in rows:
|
|
16
|
+
metadata = row.get("bench_metadata")
|
|
17
|
+
upstream_id = (
|
|
18
|
+
metadata.get("upstream_task_id")
|
|
19
|
+
if isinstance(metadata, dict)
|
|
20
|
+
else row.get("task_id")
|
|
21
|
+
)
|
|
22
|
+
scores = row.get("bench_scores")
|
|
23
|
+
value = (
|
|
24
|
+
sample_score(scores, row.get("bench_score_key") or "overall_score")
|
|
25
|
+
if isinstance(scores, dict)
|
|
26
|
+
else None
|
|
27
|
+
)
|
|
28
|
+
if isinstance(upstream_id, str):
|
|
29
|
+
grouped.setdefault(upstream_id, []).append(
|
|
30
|
+
value if value is not None else 0.0
|
|
31
|
+
)
|
|
32
|
+
task_means = {
|
|
33
|
+
task_id: sum(values) / len(values) for task_id, values in grouped.items()
|
|
34
|
+
}
|
|
35
|
+
perfect = sum(value >= 1.0 - 1e-9 for value in task_means.values())
|
|
36
|
+
report["average"] = (
|
|
37
|
+
round(sum(task_means.values()) / len(task_means), 6)
|
|
38
|
+
if task_means
|
|
39
|
+
else None
|
|
40
|
+
)
|
|
41
|
+
report["upstream_task_count"] = len(task_means)
|
|
42
|
+
report["strict_accuracy"] = (
|
|
43
|
+
round(perfect / len(task_means), 6) if task_means else None
|
|
44
|
+
)
|
|
45
|
+
report["perfect_task_count"] = perfect
|
|
46
|
+
report["runs_per_task"] = {
|
|
47
|
+
task_id: len(values) for task_id, values in sorted(grouped.items())
|
|
48
|
+
}
|
|
49
|
+
return report
|