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,203 @@
1
+ """Container-side bridge for the pinned OpenCode CLI; standard library only."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import subprocess
9
+ import time
10
+
11
+ VERSION = "1.18.30"
12
+ TOOLS = ("bash", "read", "write", "edit", "apply_patch", "glob", "grep", "skill")
13
+
14
+
15
+ def atomic_json(path, value):
16
+ temporary = path.with_suffix(".tmp")
17
+ temporary.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
18
+ temporary.replace(path)
19
+
20
+
21
+ def native_reply(export):
22
+ messages = export.get("messages", [])
23
+ users = [m for m in messages if m.get("info", {}).get("role") == "user"]
24
+ if not users:
25
+ raise ValueError("OpenCode export has no user message")
26
+ user_id = users[-1]["info"]["id"]
27
+ assistants = [m for m in messages if m.get("info", {}).get("role") == "assistant" and m["info"].get("parentID") == user_id]
28
+ if not assistants:
29
+ raise ValueError("OpenCode turn has no assistant reply")
30
+ last = assistants[-1]
31
+ info = last["info"]
32
+ if info.get("error") or not info.get("time", {}).get("completed"):
33
+ raise ValueError("OpenCode turn did not complete: " + json.dumps(info.get("error"), ensure_ascii=False))
34
+ if info.get("finish") not in {"stop", "end-turn"}:
35
+ raise ValueError("OpenCode turn ended without a final response: " + str(info.get("finish")))
36
+ content = "\n".join(p["text"] for p in last.get("parts", []) if p.get("type") == "text" and not p.get("synthetic"))
37
+ if not content.strip():
38
+ raise ValueError("OpenCode turn returned an empty reply")
39
+ return content
40
+
41
+
42
+ def export_session(state, session_id, timeout):
43
+ """Export a session without routing its potentially large JSON through a pipe."""
44
+ export_path = state / "session-export.json"
45
+ with export_path.open("w", encoding="utf-8") as output:
46
+ completed = subprocess.run(
47
+ ["opencode", "export", session_id],
48
+ stdout=output,
49
+ stderr=subprocess.PIPE,
50
+ text=True,
51
+ timeout=timeout,
52
+ )
53
+ if completed.returncode:
54
+ raise RuntimeError("OpenCode session export failed: " + completed.stderr[-1000:])
55
+ return json.loads(export_path.read_text(encoding="utf-8"))
56
+
57
+
58
+ def run_turn(config, state):
59
+ deadline = time.monotonic() + config["timeout_seconds"]
60
+
61
+ def remaining():
62
+ value = deadline - time.monotonic()
63
+ if value <= 0:
64
+ raise TimeoutError("OpenCode turn timed out")
65
+ return value
66
+
67
+ effort = config.get("thinking")
68
+ options = {} if effort is None else {"reasoningEffort": "none" if effort == "off" else effort}
69
+ model = config["model"]
70
+ native_config = {
71
+ "$schema": "https://opencode.ai/config.json",
72
+ "model": "roller/" + model,
73
+ "enabled_providers": ["roller"],
74
+ "provider": {"roller": {
75
+ "npm": "@ai-sdk/openai-compatible", "name": "Roller API",
76
+ "options": {"baseURL": os.environ["OPENAI_BASE_URL"], "apiKey": "{env:OPENAI_API_KEY}", "timeout": max(1, int(remaining() * 1000))},
77
+ "models": {model: {
78
+ "id": model, "name": model, "tool_call": True, "reasoning": True,
79
+ "interleaved": {"field": "reasoning_content"},
80
+ "limit": {"context": config["context_length"], "output": config["output_tokens"]},
81
+ "modalities": {
82
+ "input": ["text", "image"] if config.get("vision", True) else ["text"],
83
+ "output": ["text"],
84
+ },
85
+ "options": options,
86
+ }},
87
+ }},
88
+ "plugin": ["file:///roller/opencode_journal.mjs"],
89
+ "share": "disabled", "autoupdate": False, "snapshot": False,
90
+ "compaction": {"auto": False, "prune": False},
91
+ "lsp": False, "formatter": False,
92
+ "permission": {"*": "deny", **{name: "allow" for name in TOOLS}, "external_directory": "allow",
93
+ **({"task": "allow"} if config.get("capture_calls") else {})},
94
+ "agent": {"build": {"steps": config["max_steps"]}, "title": {"disable": True}, "summary": {"disable": True}},
95
+ }
96
+ if config.get("capture_calls"):
97
+ # Global task permissions otherwise override Explore's native tool
98
+ # restrictions. Retain a distinct child tool profile when delegating.
99
+ native_config["agent"]["general"] = {"steps": config["max_steps"]}
100
+ native_config["agent"]["explore"] = {"steps": config["max_steps"], "permission": {
101
+ "write": "deny", "edit": "deny", "apply_patch": "deny",
102
+ "task": "deny", "skill": "deny",
103
+ }}
104
+ atomic_json(state / "opencode.json", native_config)
105
+ session_file = state / "session.json"
106
+ session = json.loads(session_file.read_text()) if session_file.exists() else {}
107
+ args = ["opencode", "run", "--format", "json", "--thinking", "--agent", "build", "--model", "roller/" + model, "--title", "Roller task"]
108
+ if session.get("id"):
109
+ args += ["--session", session["id"]]
110
+ stream_path = state / "cli.jsonl"
111
+ with stream_path.open("w", encoding="utf-8") as output:
112
+ process = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=output, stderr=None, text=True)
113
+ try:
114
+ process.communicate(config["message"], timeout=remaining())
115
+ except subprocess.TimeoutExpired:
116
+ process.kill()
117
+ process.wait(timeout=10)
118
+ raise TimeoutError("OpenCode CLI timed out") from None
119
+ events = []
120
+ # JSONL is LF-delimited; str.splitlines() would corrupt records containing
121
+ # legal U+2028/U+2029 characters in model or tool output.
122
+ for line in stream_path.read_text(encoding="utf-8").split("\n"):
123
+ if not line.strip():
124
+ continue
125
+ try:
126
+ row = json.loads(line)
127
+ except json.JSONDecodeError:
128
+ continue # CLI diagnostics are separate from the authoritative journal.
129
+ if isinstance(row, dict):
130
+ events.append(row)
131
+ ids = {e["sessionID"] for e in events if isinstance(e.get("sessionID"), str)}
132
+ if len(ids) != 1 or (session.get("id") and ids != {session["id"]}):
133
+ raise ValueError("OpenCode CLI did not return exactly the expected session")
134
+ session_id = ids.pop()
135
+ atomic_json(session_file, {"id": session_id})
136
+ # OpenCode/Bun can truncate large exports when stdout is a pipe (the
137
+ # failures cluster at pipe-buffer-sized boundaries). Stream directly to
138
+ # a regular file so tool-heavy sessions are exported in full.
139
+ export = export_session(state, session_id, remaining())
140
+ if export.get("info", {}).get("id") != session_id:
141
+ raise ValueError("OpenCode exported a different session")
142
+ with (state / "trace.jsonl").open("a", encoding="utf-8") as journal:
143
+ journal.write(json.dumps({"type": "session_export", "data": export}, ensure_ascii=False) + "\n")
144
+ if config.get("capture_calls"):
145
+ # Preserve child sessions too. Read exact native IDs from task metadata
146
+ # and session events; never infer ownership from CLI output ordering.
147
+ ids = set()
148
+ for line in (state / "trace.jsonl").read_text(encoding="utf-8").split("\n"):
149
+ if not line.strip():
150
+ continue
151
+ row = json.loads(line)
152
+ event = row.get("event", {})
153
+ if event.get("type") in {"session.created", "session.updated"}:
154
+ info = event.get("properties", {}).get("info", {})
155
+ if info.get("id"):
156
+ ids.add(info["id"])
157
+ if event.get("type") == "message.part.updated":
158
+ child = event.get("properties", {}).get("part", {}).get("state", {}).get("metadata", {}).get("sessionId")
159
+ if child:
160
+ ids.add(child)
161
+ for child_id in sorted(ids - {session_id}):
162
+ try:
163
+ child_export = export_session(state, child_id, remaining())
164
+ record = {"type": "session_export", "data": child_export}
165
+ except Exception as exc:
166
+ # Evidence failures do not change the completed root turn.
167
+ record = {"type": "capture_error", "session_id": child_id,
168
+ "error": "child session export failed: " + type(exc).__name__}
169
+ with (state / "trace.jsonl").open("a", encoding="utf-8") as journal:
170
+ journal.write(json.dumps(record, ensure_ascii=False) + "\n")
171
+ errors = [e.get("error") for e in events if e.get("type") == "error"]
172
+ if process.returncode or errors:
173
+ raise RuntimeError("OpenCode CLI failed: " + json.dumps(errors, ensure_ascii=False)[:2000])
174
+ return {"ok": True, "content": native_reply(export), "session_id": session_id}
175
+
176
+
177
+ def main():
178
+ parser = argparse.ArgumentParser()
179
+ parser.add_argument("--check", action="store_true")
180
+ parser.add_argument("--config")
181
+ parser.add_argument("--state-dir", default="/roller/opencode-state")
182
+ args = parser.parse_args()
183
+ if args.check:
184
+ version = subprocess.check_output(["opencode", "--version"], text=True, timeout=30).strip()
185
+ if version != VERSION:
186
+ raise ValueError(f"OpenCode version mismatch: expected {VERSION}, got {version}")
187
+ print(version)
188
+ return
189
+ state = Path(args.state_dir)
190
+ state.mkdir(parents=True, exist_ok=True)
191
+ destination = state / "result.json"
192
+ destination.unlink(missing_ok=True)
193
+ try:
194
+ result = run_turn(json.loads(Path(args.config).read_text()), state)
195
+ except Exception as exc:
196
+ result = {"ok": False, "error": str(exc)}
197
+ atomic_json(destination, result)
198
+ if not result["ok"]:
199
+ raise SystemExit(1)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ main()
@@ -0,0 +1,160 @@
1
+ // Container-only bridge to the pinned official Pi AgentSession SDK.
2
+ import fs from "node:fs";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import {
6
+ createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager,
7
+ } from "/opt/roller-pi/node_modules/@earendil-works/pi-coding-agent/dist/index.js";
8
+ import { configureHttpDispatcher } from "/opt/roller-pi/node_modules/@earendil-works/pi-coding-agent/dist/core/http-dispatcher.js";
9
+
10
+ const VERSION = "0.85.1";
11
+ const STATE = "/roller/pi-state";
12
+ const agentDir = "/roller/pi-home/agent";
13
+ const redact = (value) => {
14
+ const text = JSON.stringify(value);
15
+ return process.env.PI_API_KEY ? text.replaceAll(process.env.PI_API_KEY, "[REDACTED]") : text;
16
+ };
17
+ const journal = (value) => fs.appendFileSync(`${STATE}/trace.jsonl`, redact(value) + "\n");
18
+
19
+ function check() {
20
+ for (const name of ["pi-coding-agent", "pi-agent-core", "pi-ai"]) {
21
+ const installed = JSON.parse(fs.readFileSync(`/opt/roller-pi/node_modules/@earendil-works/${name}/package.json`, "utf8")).version;
22
+ if (installed !== VERSION) throw new Error(`${name} version mismatch: expected ${VERSION}, got ${installed}`);
23
+ }
24
+ return VERSION;
25
+ }
26
+
27
+ async function serve(config) {
28
+ check();
29
+ // Use Pi's own HTTP transport, including HTTP(S)_PROXY and NO_PROXY.
30
+ configureHttpDispatcher(Math.max(1, Math.ceil(config.timeout_seconds * 1000)));
31
+ journal({type: "metadata", schema: "roller.pi-events/v1", version: VERSION,
32
+ session_id: config.session_id, model: config.model, api: "openai-completions"});
33
+ const settingsManager = SettingsManager.inMemory({
34
+ compaction: {enabled: false}, retry: {enabled: false, provider: {maxRetries: 0}},
35
+ enableInstallTelemetry: false, enableAnalytics: false, enableSkillCommands: false,
36
+ packages: [], extensions: [], skills: [], prompts: [], themes: [],
37
+ });
38
+ const loader = new DefaultResourceLoader({
39
+ cwd: process.cwd(), agentDir, settingsManager,
40
+ noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
41
+ // Keep Pi's native system prompt and add only task-authorized Context.
42
+ systemPromptOverride: () => undefined,
43
+ appendSystemPromptOverride: () => [config.system_prompt],
44
+ agentsFilesOverride: () => ({agentsFiles: (config.context_paths ?? []).filter((target) => fs.existsSync(target)).map((target) => {
45
+ const file = path.resolve(target);
46
+ return {path: file, content: fs.readFileSync(file, "utf8")};
47
+ })}),
48
+ });
49
+ await loader.reload();
50
+ const modelRuntime = await ModelRuntime.create({
51
+ authPath: `${agentDir}/auth.json`, modelsPath: null,
52
+ modelsStorePath: `${agentDir}/models-store.json`, allowModelNetwork: false, refreshOnCreate: false,
53
+ });
54
+ modelRuntime.registerProvider("roller", {
55
+ api: "openai-completions", baseUrl: process.env.PI_BASE_URL, apiKey: "$PI_API_KEY",
56
+ models: [{id: config.model, name: config.model, reasoning: true, input: ["text", "image"],
57
+ thinkingLevelMap: {off: "none", xhigh: "xhigh", max: "max"},
58
+ cost: {input: 0, output: 0, cacheRead: 0, cacheWrite: 0},
59
+ contextWindow: config.context_length, maxTokens: config.output_tokens,
60
+ compat: {supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: true,
61
+ maxTokensField: "max_tokens"},
62
+ }],
63
+ });
64
+ const model = modelRuntime.getModel("roller", config.model);
65
+ if (!model) throw new Error("Pi did not register the requested model");
66
+ const {session} = await createAgentSession({
67
+ cwd: process.cwd(), agentDir, modelRuntime, model, settingsManager,
68
+ thinkingLevel: config.thinking ?? "off", tools: config.tools, resourceLoader: loader,
69
+ sessionManager: SessionManager.inMemory(process.cwd()),
70
+ });
71
+ let currentTurn = 0;
72
+ let steps = 0;
73
+ let active = false;
74
+ const nativeStream = session.agent.streamFunction;
75
+ session.agent.streamFunction = async (model, context, options) => {
76
+ if (steps >= config.max_steps) throw new Error(`Pi max steps exceeded (${config.max_steps})`);
77
+ steps += 1;
78
+ journal({type: "context", turn: currentTurn, context});
79
+ return nativeStream(model, context, {...options, maxTokens: config.output_tokens, maxRetries: 0,
80
+ ...(config.capture_calls ? {headers: {...options?.headers, "x-roller-native-session-id": config.session_id}} : {})});
81
+ };
82
+ session.agent.onPayload = (body) => {
83
+ journal({type: "request", turn: currentTurn, body});
84
+ return body;
85
+ };
86
+ session.subscribe((event) => {
87
+ // Final message events are authoritative. Deltas remain in the raw trace.
88
+ journal({type: "event", turn: currentTurn, event});
89
+ });
90
+ const server = net.createServer((socket) => {
91
+ socket.setEncoding("utf8");
92
+ let buffer = "";
93
+ let received = false;
94
+ socket.on("error", () => {}); // The host may close the socket at its deadline.
95
+ socket.on("data", async (chunk) => {
96
+ buffer += chunk;
97
+ // Split strictly on LF; prompts may contain Unicode line separators.
98
+ const end = buffer.indexOf("\n");
99
+ if (received || end < 0) return;
100
+ received = true;
101
+ if (active) {
102
+ socket.end(redact({ok: false, error: "Pi session already has an active turn"}) + "\n");
103
+ return;
104
+ }
105
+ active = true;
106
+ let timer;
107
+ let timedOut = false;
108
+ try {
109
+ const payload = JSON.parse(buffer.slice(0, end));
110
+ if (payload.turn !== currentTurn + 1 || typeof payload.message !== "string") {
111
+ throw new Error("Invalid Pi turn sequence or message");
112
+ }
113
+ currentTurn = payload.turn;
114
+ steps = 0;
115
+ const before = session.messages.length;
116
+ journal({type: "turn_start", turn: currentTurn});
117
+ timer = setTimeout(() => {
118
+ timedOut = true;
119
+ void session.abort();
120
+ }, Math.max(1, payload.timeout_seconds * 1000));
121
+ await session.prompt(payload.message, {expandPromptTemplates: false});
122
+ const messages = session.messages.slice(before);
123
+ const assistants = messages.filter((message) => message.role === "assistant");
124
+ const last = assistants.at(-1);
125
+ const content = last?.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
126
+ const failure = assistants.find((message) => !["stop", "toolUse"].includes(message.stopReason));
127
+ if (timedOut) throw new Error("Pi turn timed out");
128
+ if (failure || last?.stopReason !== "stop" || !content?.trim()) {
129
+ throw new Error(failure?.errorMessage || `Pi turn did not complete: ${failure?.stopReason ?? last?.stopReason ?? "empty response"}`);
130
+ }
131
+ journal({type: "turn_end", turn: currentTurn});
132
+ socket.end(redact({ok: true, content}) + "\n");
133
+ } catch (error) {
134
+ journal({type: "runner_error", turn: currentTurn, error: String(error)});
135
+ socket.end(redact({ok: false, error: String(error), timeout: timedOut}) + "\n");
136
+ server.close();
137
+ session.dispose();
138
+ } finally {
139
+ clearTimeout(timer);
140
+ active = false;
141
+ }
142
+ });
143
+ });
144
+ server.on("error", (error) => {
145
+ journal({type: "runner_error", error: String(error)});
146
+ process.exitCode = 1;
147
+ session.dispose();
148
+ });
149
+ server.listen(`${STATE}/runner.sock`, () => fs.chmodSync(`${STATE}/runner.sock`, 0o600));
150
+ }
151
+
152
+ try {
153
+ if (process.argv[2] === "--check") console.log(check());
154
+ else if (process.argv[2] === "--serve") await serve(JSON.parse(fs.readFileSync(process.argv[3], "utf8")));
155
+ else throw new Error("Expected --check or --serve <config>");
156
+ } catch (error) {
157
+ if (process.argv[2] !== "--check") journal({type: "runner_error", error: String(error)});
158
+ console.error(redact({error: String(error)}));
159
+ process.exitCode = 1;
160
+ }
@@ -0,0 +1,93 @@
1
+ """Container-only bridge to the pinned official Pi Harness SDK.
2
+
3
+ A private Unix socket keeps one native AgentSession process alive
4
+ across Roller turns. The host owns the hard deadline and container cleanup.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import os
11
+ from pathlib import Path
12
+ import socket
13
+ import subprocess
14
+ import sys
15
+ import time
16
+
17
+ STATE = Path("/roller/pi-state")
18
+ SOCKET = str(STATE / "runner.sock")
19
+
20
+
21
+ def check():
22
+ result = subprocess.run(["node", "/roller/pi_sdk.mjs", "--check"],
23
+ capture_output=True, text=True, timeout=30)
24
+ if result.returncode:
25
+ raise RuntimeError("Pi SDK check failed: " + (result.stderr or result.stdout).strip()[-2000:])
26
+ return result.stdout.strip()
27
+
28
+
29
+ def redact(value):
30
+ text = json.dumps(value, ensure_ascii=False)
31
+ key = os.environ.get("PI_API_KEY")
32
+ return text.replace(key, "[REDACTED]") if key else text
33
+
34
+
35
+ def journal(value):
36
+ with (STATE / "trace.jsonl").open("a", encoding="utf-8") as stream:
37
+ stream.write(redact(value) + "\n")
38
+
39
+
40
+ def write_result(value):
41
+ temp = STATE / "result.json.tmp"
42
+ temp.write_text(redact(value), encoding="utf-8")
43
+ temp.replace(STATE / "result.json")
44
+
45
+
46
+ def send(config):
47
+ process = None
48
+ if not Path(SOCKET).exists():
49
+ with (STATE / "runtime.log").open("a") as log:
50
+ process = subprocess.Popen(["node", "/roller/pi_sdk.mjs", "--serve", str(STATE / "turn.json")],
51
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log,
52
+ start_new_session=True)
53
+ deadline = time.monotonic() + config["timeout_seconds"]
54
+ with socket.socket(socket.AF_UNIX) as client:
55
+ while True:
56
+ if process is not None and process.poll() is not None:
57
+ raise RuntimeError("Pi runtime startup failed; see the native journal and rollout log")
58
+ try:
59
+ client.connect(SOCKET)
60
+ break
61
+ except (FileNotFoundError, ConnectionRefusedError):
62
+ if time.monotonic() >= deadline:
63
+ raise TimeoutError("Pi runtime startup timed out")
64
+ time.sleep(0.05)
65
+ client.settimeout(max(0.001, deadline - time.monotonic()))
66
+ client.sendall((json.dumps(config) + "\n").encode())
67
+ with client.makefile("r", encoding="utf-8") as stream:
68
+ value = json.loads(stream.readline())
69
+ write_result(value)
70
+ return 0 if value["ok"] else 1
71
+
72
+
73
+ def main():
74
+ parser = argparse.ArgumentParser()
75
+ parser.add_argument("--check", action="store_true")
76
+ parser.add_argument("--config", type=Path)
77
+ args = parser.parse_args()
78
+ try:
79
+ if args.check:
80
+ print(check())
81
+ return 0
82
+ config = json.loads(args.config.read_text())
83
+ return send(config)
84
+ except Exception as exc:
85
+ if not args.check:
86
+ journal({"type": "runner_error", "error": str(exc)})
87
+ write_result({"ok": False, "error": str(exc), "timeout": isinstance(exc, TimeoutError)})
88
+ print(redact({"error": str(exc)}), file=sys.stderr)
89
+ return 1
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
@@ -0,0 +1,73 @@
1
+ """Benchmark adapters: turn upstream benches into Roller tasks, then score them.
2
+
3
+ Adding a bench means writing one :class:`BenchAdapter` and registering it in
4
+ ``BENCHES`` below. Nothing else in Roller needs to change: the adapter emits
5
+ ordinary ``roller.task/v1`` rows whose optional ``bench`` block carries the
6
+ task's scorer, and the collect path already ignores row keys it does not know.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from roller.benches.base import (
12
+ BENCH_SCHEMA,
13
+ BenchAdapter,
14
+ BenchBinding,
15
+ BenchFormatError,
16
+ GradeSpec,
17
+ binding_from_task_row,
18
+ )
19
+ from roller.benches.pinchbench import PinchBenchAdapter
20
+ from roller.benches.report import (
21
+ SCORE_FILE,
22
+ format_report,
23
+ mean_aggregate,
24
+ read_scores,
25
+ sample_score,
26
+ )
27
+ from roller.benches.qwenclaw import QwenClawBenchAdapter
28
+ from roller.benches.skillsbench import SkillsBenchAdapter
29
+ from roller.benches.wildclaw import WildClawBenchAdapter
30
+
31
+ BENCHES: dict[str, BenchAdapter] = {
32
+ adapter.name: adapter
33
+ for adapter in (
34
+ PinchBenchAdapter(),
35
+ QwenClawBenchAdapter(),
36
+ SkillsBenchAdapter(),
37
+ WildClawBenchAdapter(),
38
+ )
39
+ }
40
+
41
+
42
+ def get_bench(name: str) -> BenchAdapter:
43
+ try:
44
+ return BENCHES[name]
45
+ except KeyError:
46
+ known = ", ".join(sorted(BENCHES)) or "none registered"
47
+ raise KeyError(f"unknown bench {name!r}; available: {known}") from None
48
+
49
+
50
+ def list_benches() -> list[BenchAdapter]:
51
+ return [BENCHES[name] for name in sorted(BENCHES)]
52
+
53
+
54
+ __all__ = [
55
+ "BENCHES",
56
+ "BENCH_SCHEMA",
57
+ "SCORE_FILE",
58
+ "BenchAdapter",
59
+ "BenchBinding",
60
+ "BenchFormatError",
61
+ "GradeSpec",
62
+ "PinchBenchAdapter",
63
+ "QwenClawBenchAdapter",
64
+ "SkillsBenchAdapter",
65
+ "WildClawBenchAdapter",
66
+ "binding_from_task_row",
67
+ "format_report",
68
+ "get_bench",
69
+ "list_benches",
70
+ "mean_aggregate",
71
+ "read_scores",
72
+ "sample_score",
73
+ ]