commitarena 0.1.1__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.
commitarena/runner.py ADDED
@@ -0,0 +1,298 @@
1
+ """Run one task with one model, score it with the original tests, and run whole suites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ import time
12
+ from collections.abc import Callable
13
+ from dataclasses import asdict, dataclass, field
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+
17
+ from . import __version__
18
+ from .agent import AgentConfig, run_agent
19
+ from .errors import BudgetExceeded, CommitArenaError
20
+ from .llm import Budget, ChatClient, ModelSpec
21
+ from .sandbox import Sandbox, SandboxConfig, build_image, ensure_docker, image_exists
22
+ from .tasks import (
23
+ Task,
24
+ changed_files,
25
+ extract_commit,
26
+ init_workspace_git,
27
+ is_test_path,
28
+ load_tasks,
29
+ resolve_repo,
30
+ save_tasks,
31
+ unified_diff,
32
+ write_files,
33
+ )
34
+ from .testrun import run_tests
35
+
36
+ CONTEXT_NAMES = ("AGENTS.md", "CLAUDE.md")
37
+
38
+
39
+ @dataclass
40
+ class ContextFile:
41
+ """A context file (AGENTS.md, CLAUDE.md, ...) injected into the attempt.
42
+
43
+ in_repo: write it at the repo root inside the sandbox.
44
+ in_prompt: append its content to the system prompt.
45
+ """
46
+
47
+ content: str
48
+ name: str = "AGENTS.md"
49
+ in_repo: bool = True
50
+ in_prompt: bool = True
51
+
52
+ @classmethod
53
+ def from_path(cls, path: str | os.PathLike, mode: str = "both", name: str | None = None) -> ContextFile:
54
+ if mode not in ("both", "repo", "prompt"):
55
+ raise CommitArenaError("--context-mode must be one of: both, repo, prompt")
56
+ p = Path(path)
57
+ return cls(p.read_text(), name or p.name, mode in ("both", "repo"), mode in ("both", "prompt"))
58
+
59
+
60
+ @dataclass
61
+ class RunResult:
62
+ task_id: str
63
+ model: str
64
+ passed: bool
65
+ fail_to_pass_passed: int = 0
66
+ fail_to_pass_total: int = 0
67
+ pass_to_pass_failed: int = 0
68
+ pass_to_pass_total: int = 0
69
+ cost_usd: float = 0.0
70
+ tokens_in: int = 0
71
+ tokens_out: int = 0
72
+ steps: int = 0
73
+ tool_calls: int = 0
74
+ wall_time_s: float = 0.0
75
+ stop_reason: str = ""
76
+ error: str = ""
77
+ patch: str = "" # model's diff on non-test files (what is scored)
78
+ ignored_test_edits: list[str] = field(default_factory=list)
79
+ task_date: str = ""
80
+ context_file: str | None = None
81
+ started_at: str = ""
82
+
83
+ def to_dict(self) -> dict:
84
+ return asdict(self)
85
+
86
+ @classmethod
87
+ def from_dict(cls, d: dict) -> RunResult:
88
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
89
+
90
+
91
+ def ensure_task_image(task: Task) -> None:
92
+ if image_exists(task.image):
93
+ return
94
+ repo = resolve_repo(task.repo)
95
+ tmp = Path(tempfile.mkdtemp(prefix="commitarena-img-"))
96
+ try:
97
+ extract_commit(repo, task.commit, tmp / "snap")
98
+ build_image(task.image, task.env_spec, tmp / "snap", tmp)
99
+ finally:
100
+ shutil.rmtree(tmp, ignore_errors=True)
101
+
102
+
103
+ def score_workspace(task: Task, pristine: Path, agent_tree: Path, sandbox: SandboxConfig, test_timeout: int):
104
+ """Apply only the agent's non-test changes onto the pristine workspace and run the suite."""
105
+ changes = changed_files(pristine, agent_tree)
106
+ code = {p: c for p, c in changes.items() if not is_test_path(p)}
107
+ ignored = sorted(p for p in changes if is_test_path(p))
108
+ scored = pristine.parent / "scored"
109
+ shutil.copytree(pristine, scored, ignore=shutil.ignore_patterns(".git"))
110
+ for rel, data in code.items():
111
+ target = scored / rel
112
+ if data is None:
113
+ target.unlink(missing_ok=True)
114
+ else:
115
+ target.parent.mkdir(parents=True, exist_ok=True)
116
+ target.write_bytes(data)
117
+ patch = unified_diff(pristine, scored, only=sorted(code))
118
+ with Sandbox(task.image, sandbox) as sb:
119
+ sb.copy_in(scored)
120
+ outcomes = run_tests(sb, test_timeout)
121
+ return outcomes, patch, ignored
122
+
123
+
124
+ def run_task(
125
+ task: Task,
126
+ model: str | ModelSpec,
127
+ *,
128
+ budget: Budget | None = None,
129
+ context: ContextFile | None = None,
130
+ strip_context_files: bool = False,
131
+ agent_config: AgentConfig | None = None,
132
+ sandbox_config: SandboxConfig | None = None,
133
+ test_timeout: int = 600,
134
+ on_step: Callable[[int, str], None] | None = None,
135
+ keep_trajectory: list | None = None,
136
+ ) -> RunResult:
137
+ """Run one attempt of `task` with `model` and score it. Raises CommitArenaError on fatal setup errors."""
138
+ spec = model if isinstance(model, ModelSpec) else ModelSpec.parse(model)
139
+ budget = budget or Budget()
140
+ sb_cfg = sandbox_config or SandboxConfig()
141
+ client = ChatClient(spec, budget)
142
+ ensure_docker()
143
+ ensure_task_image(task)
144
+ started = datetime.now(UTC).isoformat(timespec="seconds")
145
+ t0 = time.monotonic()
146
+ tmp = Path(tempfile.mkdtemp(prefix="commitarena-run-"))
147
+ try:
148
+ pristine = tmp / "pristine"
149
+ extract_commit(resolve_repo(task.repo), task.parent, pristine)
150
+ write_files(pristine, task.test_files)
151
+ if strip_context_files:
152
+ for n in CONTEXT_NAMES:
153
+ (pristine / n).unlink(missing_ok=True)
154
+ system_extra = ""
155
+ if context:
156
+ if context.in_repo:
157
+ (pristine / context.name).write_text(context.content)
158
+ if context.in_prompt:
159
+ system_extra = f"# Project instructions ({context.name})\n\n{context.content}"
160
+ workspace = tmp / "workspace"
161
+ shutil.copytree(pristine, workspace)
162
+ init_workspace_git(workspace)
163
+ agent_tree = tmp / "agent"
164
+ with Sandbox(task.image, sb_cfg) as sb:
165
+ sb.copy_in(workspace)
166
+ outcome = run_agent(client, sb, task.prompt, agent_config, system_extra, on_step)
167
+ sb.copy_out(agent_tree)
168
+ if keep_trajectory is not None:
169
+ keep_trajectory.extend(outcome.trajectory)
170
+ outcomes, patch, ignored = score_workspace(task, pristine, agent_tree, sb_cfg, test_timeout)
171
+ passed_set = outcomes.passed()
172
+ f2p_ok = sum(1 for t in task.fail_to_pass if t in passed_set)
173
+ p2p_bad = sum(1 for t in task.pass_to_pass if t not in passed_set)
174
+ return RunResult(
175
+ task_id=task.id,
176
+ model=spec.id,
177
+ passed=outcomes.ok and f2p_ok == len(task.fail_to_pass) and p2p_bad == 0,
178
+ fail_to_pass_passed=f2p_ok,
179
+ fail_to_pass_total=len(task.fail_to_pass),
180
+ pass_to_pass_failed=p2p_bad,
181
+ pass_to_pass_total=len(task.pass_to_pass),
182
+ cost_usd=round(outcome.usage.cost_usd, 6),
183
+ tokens_in=outcome.usage.tokens_in,
184
+ tokens_out=outcome.usage.tokens_out,
185
+ steps=outcome.steps,
186
+ tool_calls=outcome.tool_calls,
187
+ wall_time_s=round(time.monotonic() - t0, 1),
188
+ stop_reason=outcome.stop_reason,
189
+ error=outcome.error,
190
+ patch=patch,
191
+ ignored_test_edits=ignored,
192
+ task_date=task.date,
193
+ context_file=context.name if context else None,
194
+ started_at=started,
195
+ )
196
+ finally:
197
+ shutil.rmtree(tmp, ignore_errors=True)
198
+
199
+
200
+ def hardware() -> str:
201
+ mem = ""
202
+ try:
203
+ if platform.system() == "Darwin":
204
+ b = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True).stdout)
205
+ chip = subprocess.run(
206
+ ["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True
207
+ ).stdout.strip()
208
+ mem = f"{chip}, {b // 2**30} GB RAM"
209
+ elif Path("/proc/meminfo").exists():
210
+ kb = int(Path("/proc/meminfo").read_text().split()[1])
211
+ mem = f"{platform.processor() or platform.machine()}, {kb // 2**20} GB RAM"
212
+ except Exception: # noqa: BLE001 - best effort only
213
+ pass
214
+ return f"{platform.system()} {platform.machine()}" + (f" ({mem})" if mem else "")
215
+
216
+
217
+ def run_suite(
218
+ tasks: list[Task],
219
+ models: list[ModelSpec],
220
+ out_dir: Path,
221
+ *,
222
+ budget: Budget,
223
+ context: ContextFile | None = None,
224
+ strip_context_files: bool = False,
225
+ agent_config: AgentConfig | None = None,
226
+ sandbox_config: SandboxConfig | None = None,
227
+ cutoffs: dict[str, str] | None = None,
228
+ on_result: Callable[[RunResult], None] | None = None,
229
+ on_start: Callable[[Task, ModelSpec], None] | None = None,
230
+ on_step: Callable[[int, str], None] | None = None,
231
+ ) -> tuple[list[RunResult], str | None]:
232
+ """Run every (task, model) pair sequentially. Resumable: pairs already in results.jsonl are skipped.
233
+
234
+ Returns (results, abort_message). abort_message is set when the budget stopped the run.
235
+ """
236
+ out_dir.mkdir(parents=True, exist_ok=True)
237
+ (out_dir / "trajectories").mkdir(exist_ok=True)
238
+ results_path = out_dir / "results.jsonl"
239
+ done: dict[tuple[str, str], RunResult] = {}
240
+ if results_path.exists():
241
+ for line in results_path.read_text().splitlines():
242
+ if line.strip():
243
+ r = RunResult.from_dict(json.loads(line))
244
+ done[(r.task_id, r.model)] = r
245
+ meta_path = out_dir / "run.json"
246
+ meta = json.loads(meta_path.read_text()) if meta_path.exists() else {}
247
+ meta.update(
248
+ {
249
+ "tool": "commitarena",
250
+ "version": __version__,
251
+ "updated_at": datetime.now(UTC).isoformat(timespec="seconds"),
252
+ "hardware": hardware(),
253
+ "models": sorted({*meta.get("models", []), *(m.id for m in models)}),
254
+ "cutoffs": {**meta.get("cutoffs", {}), **(cutoffs or {})},
255
+ "agent": {k: v for k, v in asdict(agent_config or AgentConfig()).items() if k != "system_prompt"},
256
+ "sandbox": asdict(sandbox_config or SandboxConfig()),
257
+ "context_file": context.name if context else None,
258
+ "strip_context_files": strip_context_files,
259
+ }
260
+ )
261
+ meta.setdefault("created_at", meta["updated_at"])
262
+ meta_path.write_text(json.dumps(meta, indent=1))
263
+ tasks_by_id = {t.id: t for t in tasks}
264
+ existing_tasks = out_dir / "tasks.json"
265
+ if existing_tasks.exists():
266
+ for t in load_tasks(existing_tasks):
267
+ tasks_by_id.setdefault(t.id, t)
268
+ save_tasks(existing_tasks, list(tasks_by_id.values()))
269
+ results = list(done.values())
270
+ abort = None
271
+ for model in models:
272
+ for task in tasks:
273
+ if (task.id, model.id) in done:
274
+ continue
275
+ try:
276
+ budget.check()
277
+ except BudgetExceeded as e:
278
+ return results, str(e)
279
+ if on_start:
280
+ on_start(task, model)
281
+ traj: list = []
282
+ res = run_task(
283
+ task, model, budget=budget, context=context, strip_context_files=strip_context_files,
284
+ agent_config=agent_config,
285
+ sandbox_config=sandbox_config, on_step=on_step, keep_trajectory=traj,
286
+ ) # fmt: skip
287
+ results.append(res)
288
+ with results_path.open("a") as f:
289
+ f.write(json.dumps(res.to_dict(), ensure_ascii=False) + "\n")
290
+ safe = model.id.replace("/", "_").replace(":", "_")
291
+ (out_dir / "trajectories" / f"{task.id}__{safe}.json").write_text(
292
+ json.dumps(traj, indent=1, ensure_ascii=False)
293
+ )
294
+ if on_result:
295
+ on_result(res)
296
+ if res.stop_reason == "budget_exceeded":
297
+ return results, res.error
298
+ return results, abort
commitarena/sandbox.py ADDED
@@ -0,0 +1,180 @@
1
+ """Docker sandbox: every command runs in a container started with ``--network none``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import shutil
8
+ import subprocess
9
+ import tarfile
10
+ import uuid
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from .errors import DockerUnavailable
15
+
16
+ WORKDIR = "/work"
17
+
18
+
19
+ def _docker(
20
+ *args: str, input: bytes | None = None, timeout: float | None = None
21
+ ) -> subprocess.CompletedProcess:
22
+ try:
23
+ return subprocess.run(["docker", *args], input=input, capture_output=True, timeout=timeout)
24
+ except FileNotFoundError as e:
25
+ raise DockerUnavailable(
26
+ "Docker is not installed (no 'docker' command on PATH). Install Docker Desktop or Docker Engine."
27
+ ) from e
28
+
29
+
30
+ def ensure_docker() -> None:
31
+ """Raise DockerUnavailable with an actionable message if the daemon is not reachable."""
32
+ try:
33
+ cp = _docker("info", "--format", "{{.ServerVersion}}", timeout=20)
34
+ except subprocess.TimeoutExpired as e:
35
+ raise DockerUnavailable("Docker did not answer within 20 s. Is Docker Desktop starting up?") from e
36
+ if cp.returncode != 0:
37
+ raise DockerUnavailable(
38
+ "Docker is not running (docker info failed). Start Docker Desktop or the docker service and retry."
39
+ )
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SandboxConfig:
44
+ cpus: float = 2.0
45
+ memory: str = "2g"
46
+ pids_limit: int = 512
47
+ command_timeout: int = 120 # seconds per command
48
+ network: str = "none"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class EnvSpec:
53
+ """How to build the per-repo test image (built once, with network; tasks run without)."""
54
+
55
+ python: str = "3.12"
56
+ setup: str = "pip install --no-cache-dir -e ."
57
+ extra_packages: str = "pytest==9.2.0"
58
+
59
+ def dockerfile(self) -> str:
60
+ return (
61
+ f"FROM python:{self.python}-slim\n"
62
+ "RUN apt-get update && apt-get install -y --no-install-recommends git "
63
+ "&& rm -rf /var/lib/apt/lists/*\n"
64
+ f"COPY snapshot/ {WORKDIR}/\n"
65
+ f"WORKDIR {WORKDIR}\n"
66
+ f"RUN pip install --no-cache-dir {self.extra_packages} && {self.setup}\n"
67
+ # Keep installed deps (and the editable .pth pointing at /work); drop the snapshot.
68
+ f"RUN find {WORKDIR} -mindepth 1 -delete\n"
69
+ f"ENV PYTHONPATH={WORKDIR}:{WORKDIR}/src PYTHONDONTWRITEBYTECODE=1 PIP_NO_INDEX=1\n"
70
+ )
71
+
72
+ def image_tag(self, slug: str) -> str:
73
+ h = hashlib.sha256(self.dockerfile().encode()).hexdigest()[:10]
74
+ return f"commitarena-env-{slug}:{h}"
75
+
76
+
77
+ def image_exists(tag: str) -> bool:
78
+ return _docker("image", "inspect", tag).returncode == 0
79
+
80
+
81
+ def build_image(tag: str, env: EnvSpec, snapshot_dir: Path, build_root: Path) -> None:
82
+ """Build the env image from a snapshot of the repo (network is used only here)."""
83
+ ensure_docker()
84
+ ctx = build_root / f"ctx-{uuid.uuid4().hex[:8]}"
85
+ shutil.copytree(snapshot_dir, ctx / "snapshot", ignore=shutil.ignore_patterns(".git"))
86
+ (ctx / "Dockerfile").write_text(env.dockerfile())
87
+ try:
88
+ cp = _docker("build", "-q", "-t", tag, str(ctx), timeout=1800)
89
+ finally:
90
+ shutil.rmtree(ctx, ignore_errors=True)
91
+ if cp.returncode != 0:
92
+ tail = cp.stderr.decode(errors="replace")[-2000:]
93
+ raise DockerUnavailable(f"Building image {tag} failed:\n{tail}")
94
+
95
+
96
+ @dataclass
97
+ class ExecResult:
98
+ exit_code: int
99
+ output: str
100
+ timed_out: bool = False
101
+
102
+
103
+ def _tar_dir(src: Path) -> bytes:
104
+ buf = io.BytesIO()
105
+ with tarfile.open(fileobj=buf, mode="w") as tar:
106
+ for p in sorted(src.rglob("*")):
107
+ tar.add(p, arcname=str(p.relative_to(src)), recursive=False)
108
+ return buf.getvalue()
109
+
110
+
111
+ class Sandbox:
112
+ """A long-lived container with no network. Use as a context manager."""
113
+
114
+ def __init__(self, image: str, config: SandboxConfig | None = None):
115
+ self.image = image
116
+ self.config = config or SandboxConfig()
117
+ self.name = f"commitarena-{uuid.uuid4().hex[:12]}"
118
+ self.started = False
119
+
120
+ def __enter__(self) -> Sandbox:
121
+ self.start()
122
+ return self
123
+
124
+ def __exit__(self, *exc) -> None:
125
+ self.stop()
126
+
127
+ def start(self) -> None:
128
+ c = self.config
129
+ cp = _docker(
130
+ "run", "-d", "--rm", "--name", self.name, "--label", "commitarena=sandbox",
131
+ "--network", c.network,
132
+ "--cpus", str(c.cpus), "--memory", c.memory, "--memory-swap", c.memory,
133
+ "--pids-limit", str(c.pids_limit),
134
+ "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
135
+ "-w", WORKDIR, self.image, "sleep", "infinity",
136
+ timeout=120,
137
+ ) # fmt: skip
138
+ if cp.returncode != 0:
139
+ raise DockerUnavailable(
140
+ f"Could not start sandbox container: {cp.stderr.decode(errors='replace')}"
141
+ )
142
+ self.started = True
143
+
144
+ def stop(self) -> None:
145
+ if self.started:
146
+ _docker("rm", "-f", self.name, timeout=60)
147
+ self.started = False
148
+
149
+ def copy_in(self, src: Path) -> None:
150
+ """Copy a host directory into /work. Files end up owned by root (no CAP_DAC_OVERRIDE needed)."""
151
+ cp = _docker(
152
+ "exec", "-i", self.name, "tar", "-xf", "-", "-C", WORKDIR, "--no-same-owner",
153
+ input=_tar_dir(src), timeout=300,
154
+ ) # fmt: skip
155
+ if cp.returncode != 0:
156
+ raise DockerUnavailable(f"Copy into sandbox failed: {cp.stderr.decode(errors='replace')}")
157
+
158
+ def copy_out(self, dest: Path) -> None:
159
+ cp = _docker("exec", self.name, "tar", "-cf", "-", "-C", WORKDIR, ".", timeout=300)
160
+ if cp.returncode != 0:
161
+ raise DockerUnavailable(f"Copy out of sandbox failed: {cp.stderr.decode(errors='replace')}")
162
+ dest.mkdir(parents=True, exist_ok=True)
163
+ with tarfile.open(fileobj=io.BytesIO(cp.stdout)) as tar:
164
+ tar.extractall(dest, filter="data")
165
+
166
+ def exec(self, command: str, timeout: int | None = None) -> ExecResult:
167
+ t = int(timeout or self.config.command_timeout)
168
+ if "\x00" in command:
169
+ return ExecResult(126, "error: the command contains a null byte; remove it and try again")
170
+ try:
171
+ cp = _docker(
172
+ "exec", self.name, "timeout", "-s", "KILL", str(t), "bash", "-c", "exec 2>&1\n" + command,
173
+ timeout=t + 30,
174
+ ) # fmt: skip
175
+ except subprocess.TimeoutExpired:
176
+ return ExecResult(124, f"[command timed out after {t}s]", True)
177
+ out = cp.stdout.decode(errors="replace") + cp.stderr.decode(errors="replace")
178
+ if cp.returncode in (124, 137) and t:
179
+ return ExecResult(cp.returncode, out + f"\n[command killed: time limit {t}s]", True)
180
+ return ExecResult(cp.returncode, out)