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/__init__.py +40 -0
- commitarena/__main__.py +3 -0
- commitarena/agent.py +205 -0
- commitarena/cli.py +313 -0
- commitarena/demo/LICENSE-sqlparse +25 -0
- commitarena/demo/results.jsonl +33 -0
- commitarena/demo/run.json +36 -0
- commitarena/demo/tasks.json +5603 -0
- commitarena/errors.py +40 -0
- commitarena/llm.py +206 -0
- commitarena/mine.py +248 -0
- commitarena/report.py +132 -0
- commitarena/report_template.html +394 -0
- commitarena/runner.py +298 -0
- commitarena/sandbox.py +180 -0
- commitarena/tasks.py +229 -0
- commitarena/testrun.py +77 -0
- commitarena-0.1.1.dist-info/METADATA +242 -0
- commitarena-0.1.1.dist-info/RECORD +22 -0
- commitarena-0.1.1.dist-info/WHEEL +4 -0
- commitarena-0.1.1.dist-info/entry_points.txt +2 -0
- commitarena-0.1.1.dist-info/licenses/LICENSE +21 -0
commitarena/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""commitarena: replay real bug fixes from git history against any model, in a sandbox with no network."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.1"
|
|
4
|
+
|
|
5
|
+
from .agent import SYSTEM_PROMPT, AgentConfig # noqa: E402
|
|
6
|
+
from .errors import ( # noqa: E402
|
|
7
|
+
BudgetExceeded,
|
|
8
|
+
CommitArenaError,
|
|
9
|
+
DockerUnavailable,
|
|
10
|
+
MissingAPIKey,
|
|
11
|
+
ModelUnavailable,
|
|
12
|
+
TaskError,
|
|
13
|
+
)
|
|
14
|
+
from .llm import Budget, ModelSpec # noqa: E402
|
|
15
|
+
from .runner import ContextFile, RunResult, run_suite, run_task # noqa: E402
|
|
16
|
+
from .sandbox import EnvSpec, SandboxConfig, ensure_docker # noqa: E402
|
|
17
|
+
from .tasks import Task, load_tasks # noqa: E402
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"SYSTEM_PROMPT",
|
|
21
|
+
"AgentConfig",
|
|
22
|
+
"Budget",
|
|
23
|
+
"BudgetExceeded",
|
|
24
|
+
"CommitArenaError",
|
|
25
|
+
"ContextFile",
|
|
26
|
+
"DockerUnavailable",
|
|
27
|
+
"EnvSpec",
|
|
28
|
+
"MissingAPIKey",
|
|
29
|
+
"ModelSpec",
|
|
30
|
+
"ModelUnavailable",
|
|
31
|
+
"RunResult",
|
|
32
|
+
"SandboxConfig",
|
|
33
|
+
"Task",
|
|
34
|
+
"TaskError",
|
|
35
|
+
"__version__",
|
|
36
|
+
"ensure_docker",
|
|
37
|
+
"load_tasks",
|
|
38
|
+
"run_suite",
|
|
39
|
+
"run_task",
|
|
40
|
+
]
|
commitarena/__main__.py
ADDED
commitarena/agent.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The minimal agent harness: one `bash` tool, same loop and prompt for every model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from .errors import BudgetExceeded
|
|
11
|
+
from .llm import ChatClient, Usage
|
|
12
|
+
from .sandbox import Sandbox
|
|
13
|
+
|
|
14
|
+
SYSTEM_PROMPT = """\
|
|
15
|
+
You are a software engineer fixing a bug in a Python repository. The repository is at /work inside a \
|
|
16
|
+
Linux container with no network access (pip install will not work; the dependencies are already \
|
|
17
|
+
installed). Use the `bash` tool to inspect files, edit them (for example with python, sed or a heredoc) \
|
|
18
|
+
and run tests. Each call runs in a fresh shell starting in /work; long outputs are truncated.
|
|
19
|
+
|
|
20
|
+
Work in small steps: read the failing tests, find the cause in the source code, make a minimal fix, \
|
|
21
|
+
and run the tests. Do not modify test files: they are restored before scoring. When the tests pass, \
|
|
22
|
+
reply with a one-line summary and no tool call to finish."""
|
|
23
|
+
|
|
24
|
+
NUDGE = (
|
|
25
|
+
"Your last reply was empty or cut off at the token limit. Keep your reasoning short and call the bash "
|
|
26
|
+
"tool, or reply with a one-line summary if you are done."
|
|
27
|
+
)
|
|
28
|
+
MAX_NUDGES = 2 # consecutive empty or truncated replies before giving up
|
|
29
|
+
|
|
30
|
+
BASH_TOOL = {
|
|
31
|
+
"type": "function",
|
|
32
|
+
"function": {
|
|
33
|
+
"name": "bash",
|
|
34
|
+
"description": "Run a bash command in /work and return its combined stdout/stderr and exit code.",
|
|
35
|
+
"parameters": {
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {"command": {"type": "string", "description": "The command to run."}},
|
|
38
|
+
"required": ["command"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AgentConfig:
|
|
46
|
+
max_steps: int = 30 # model calls
|
|
47
|
+
max_wall_time: int = 1200 # seconds for the whole attempt
|
|
48
|
+
max_output_chars: int = 6000 # per tool result, head + tail kept
|
|
49
|
+
max_tokens: int = 4096 # per model reply
|
|
50
|
+
temperature: float = 0.0
|
|
51
|
+
system_prompt: str = SYSTEM_PROMPT
|
|
52
|
+
# Older tool outputs are elided once the conversation passes this size, so a 16K-token local
|
|
53
|
+
# model keeps the task text and the latest outputs (about 3.5 characters per token).
|
|
54
|
+
max_context_chars: int = 40000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AgentOutcome:
|
|
59
|
+
steps: int = 0
|
|
60
|
+
tool_calls: int = 0
|
|
61
|
+
usage: Usage = field(default_factory=Usage)
|
|
62
|
+
stop_reason: str = "" # finished | max_steps | time_limit | no_action | budget_exceeded | model_error
|
|
63
|
+
error: str = ""
|
|
64
|
+
final_message: str = ""
|
|
65
|
+
trajectory: list[dict] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def truncate(text: str, limit: int) -> str:
|
|
69
|
+
if len(text) <= limit:
|
|
70
|
+
return text
|
|
71
|
+
half = limit // 2
|
|
72
|
+
return (
|
|
73
|
+
f"{text[:half]}\n... [{len(text) - limit} characters truncated. Use grep -n or sed -n 'START,ENDp' "
|
|
74
|
+
f"FILE to see specific lines] ...\n{text[-half:]}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
ELIDED = "[older output elided to fit the context window]"
|
|
79
|
+
REPEAT_NOTE = "[note: you already ran this exact command and got the same output. Try a different approach.]"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fit_context(messages: list[dict], max_chars: int, keep_last: int = 4) -> list[dict]:
|
|
83
|
+
"""Copy of `messages` with the oldest tool outputs elided until the total fits in `max_chars`.
|
|
84
|
+
|
|
85
|
+
The system prompt, the task and the last `keep_last` messages are never elided."""
|
|
86
|
+
size = sum(len(m.get("content") or "") for m in messages)
|
|
87
|
+
if size <= max_chars:
|
|
88
|
+
return messages
|
|
89
|
+
out = [dict(m) for m in messages]
|
|
90
|
+
for m in out[2 : max(2, len(out) - keep_last)]:
|
|
91
|
+
if size <= max_chars:
|
|
92
|
+
break
|
|
93
|
+
if m["role"] == "tool" and m["content"] != ELIDED:
|
|
94
|
+
size -= len(m["content"]) - len(ELIDED)
|
|
95
|
+
m["content"] = ELIDED
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def message_text(content) -> str:
|
|
100
|
+
"""Reply text. Some providers return a list of blocks (e.g. thinking + text); keep the text ones."""
|
|
101
|
+
if content is None:
|
|
102
|
+
return ""
|
|
103
|
+
if isinstance(content, str):
|
|
104
|
+
return content
|
|
105
|
+
parts = []
|
|
106
|
+
for block in content:
|
|
107
|
+
kind = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
|
|
108
|
+
text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None)
|
|
109
|
+
if kind in (None, "text") and isinstance(text, str):
|
|
110
|
+
parts.append(text)
|
|
111
|
+
return "".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _assistant_dict(msg) -> dict:
|
|
115
|
+
d: dict = {"role": "assistant", "content": message_text(msg.content)}
|
|
116
|
+
if msg.tool_calls:
|
|
117
|
+
d["tool_calls"] = [
|
|
118
|
+
{
|
|
119
|
+
"id": tc.id,
|
|
120
|
+
"type": "function",
|
|
121
|
+
"function": {"name": tc.function.name, "arguments": tc.function.arguments},
|
|
122
|
+
}
|
|
123
|
+
for tc in msg.tool_calls
|
|
124
|
+
]
|
|
125
|
+
return d
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_agent(
|
|
129
|
+
client: ChatClient,
|
|
130
|
+
sandbox: Sandbox,
|
|
131
|
+
prompt: str,
|
|
132
|
+
config: AgentConfig | None = None,
|
|
133
|
+
system_extra: str = "",
|
|
134
|
+
on_step: Callable[[int, str], None] | None = None,
|
|
135
|
+
) -> AgentOutcome:
|
|
136
|
+
cfg = config or AgentConfig()
|
|
137
|
+
system = cfg.system_prompt + (f"\n\n{system_extra}" if system_extra else "")
|
|
138
|
+
messages: list[dict] = [{"role": "system", "content": system}, {"role": "user", "content": prompt}]
|
|
139
|
+
out = AgentOutcome()
|
|
140
|
+
out.trajectory = messages
|
|
141
|
+
start = time.monotonic()
|
|
142
|
+
nudges = 0
|
|
143
|
+
seen: dict[str, str] = {} # command -> last output, to flag exact repeats
|
|
144
|
+
while True:
|
|
145
|
+
if out.steps >= cfg.max_steps:
|
|
146
|
+
out.stop_reason = "max_steps"
|
|
147
|
+
break
|
|
148
|
+
if time.monotonic() - start > cfg.max_wall_time:
|
|
149
|
+
out.stop_reason = "time_limit"
|
|
150
|
+
break
|
|
151
|
+
try:
|
|
152
|
+
resp, usage = client.chat(
|
|
153
|
+
fit_context(messages, cfg.max_context_chars),
|
|
154
|
+
tools=[BASH_TOOL],
|
|
155
|
+
temperature=cfg.temperature,
|
|
156
|
+
top_p=1.0,
|
|
157
|
+
max_tokens=cfg.max_tokens,
|
|
158
|
+
)
|
|
159
|
+
except BudgetExceeded as e:
|
|
160
|
+
out.stop_reason, out.error = "budget_exceeded", str(e)
|
|
161
|
+
break
|
|
162
|
+
out.steps += 1
|
|
163
|
+
out.usage.add(usage)
|
|
164
|
+
if not resp.choices:
|
|
165
|
+
out.stop_reason, out.error = "model_error", "empty response"
|
|
166
|
+
break
|
|
167
|
+
msg = resp.choices[0].message
|
|
168
|
+
finish = getattr(resp.choices[0], "finish_reason", None)
|
|
169
|
+
text = message_text(msg.content)
|
|
170
|
+
if not msg.tool_calls and (finish == "length" or not text.strip()):
|
|
171
|
+
# Thinking models can spend the whole reply budget reasoning. Ask once more instead of
|
|
172
|
+
# treating an empty or truncated reply as "done". Same rule for every model.
|
|
173
|
+
if nudges < MAX_NUDGES:
|
|
174
|
+
nudges += 1
|
|
175
|
+
messages.append({"role": "assistant", "content": text.strip() or "(no reply)"})
|
|
176
|
+
messages.append({"role": "user", "content": NUDGE})
|
|
177
|
+
continue
|
|
178
|
+
out.stop_reason = "no_action"
|
|
179
|
+
break
|
|
180
|
+
messages.append(_assistant_dict(msg))
|
|
181
|
+
if not msg.tool_calls:
|
|
182
|
+
out.stop_reason = "finished"
|
|
183
|
+
out.final_message = text
|
|
184
|
+
break
|
|
185
|
+
nudges = 0
|
|
186
|
+
for tc in msg.tool_calls:
|
|
187
|
+
out.tool_calls += 1
|
|
188
|
+
try:
|
|
189
|
+
args = json.loads(tc.function.arguments or "{}")
|
|
190
|
+
command = args["command"] if tc.function.name == "bash" else None
|
|
191
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
192
|
+
command = None
|
|
193
|
+
if not isinstance(command, str):
|
|
194
|
+
result = 'error: call the bash tool with a JSON object {"command": "..."}'
|
|
195
|
+
else:
|
|
196
|
+
if on_step:
|
|
197
|
+
on_step(out.steps, command)
|
|
198
|
+
r = sandbox.exec(command)
|
|
199
|
+
result = truncate(r.output, cfg.max_output_chars) + f"\n[exit code {r.exit_code}]"
|
|
200
|
+
if seen.get(command) == result:
|
|
201
|
+
result += "\n" + REPEAT_NOTE
|
|
202
|
+
else:
|
|
203
|
+
seen[command] = result
|
|
204
|
+
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
|
|
205
|
+
return out
|
commitarena/cli.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Command line interface: mine, run, report, demo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .errors import CommitArenaError
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
add_completion=False,
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
rich_markup_mode="rich",
|
|
22
|
+
help="Your git history is the benchmark. Replay real bug fixes against any model, in a sandbox with "
|
|
23
|
+
"no network.",
|
|
24
|
+
)
|
|
25
|
+
# Wide enough for the leaderboard when output is piped (Rich falls back to 80 columns).
|
|
26
|
+
console = Console(width=None if sys.stdout.isatty() else 120, highlight=False)
|
|
27
|
+
err = Console(stderr=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
console.print(f"commitarena {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def _root(
|
|
38
|
+
version: Annotated[
|
|
39
|
+
bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")
|
|
40
|
+
] = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_pairs(values: list[str] | None, what: str) -> dict[str, str]:
|
|
46
|
+
out: dict[str, str] = {}
|
|
47
|
+
for v in values or []:
|
|
48
|
+
if "=" not in v:
|
|
49
|
+
raise CommitArenaError(f"{what} must look like MODEL=VALUE, got '{v}'.")
|
|
50
|
+
k, val = v.rsplit("=", 1)
|
|
51
|
+
out[k.strip()] = val.strip()
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _csv(value: str | None) -> list[str]:
|
|
56
|
+
return [x.strip() for x in (value or "").split(",") if x.strip()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def mine(
|
|
61
|
+
repo: Annotated[str, typer.Argument(help="Git URL or local path of a Python repo that uses pytest.")],
|
|
62
|
+
out: Annotated[Path | None, typer.Option(help="Where to write tasks.json.")] = None,
|
|
63
|
+
rev: Annotated[str, typer.Option(help="Branch or commit to walk back from.")] = "HEAD",
|
|
64
|
+
since: Annotated[str | None, typer.Option(help="Only commits after this date (git --since).")] = None,
|
|
65
|
+
until: Annotated[str | None, typer.Option(help="Only commits before this date (git --until).")] = None,
|
|
66
|
+
scan: Annotated[int, typer.Option(help="How many recent commits to scan.")] = 200,
|
|
67
|
+
max_tasks: Annotated[int, typer.Option(help="Stop after this many validated tasks.")] = 10,
|
|
68
|
+
python: Annotated[str, typer.Option(help="Python version of the test image.")] = "3.12",
|
|
69
|
+
setup: Annotated[
|
|
70
|
+
str, typer.Option(help="Install command run once at image build time (with network).")
|
|
71
|
+
] = "pip install --no-cache-dir -e .",
|
|
72
|
+
test_timeout: Annotated[int, typer.Option(help="Seconds allowed for one test-suite run.")] = 600,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Find commits that fix a bug with a test, and keep those whose new tests fail without the fix."""
|
|
75
|
+
from .mine import mine as do_mine
|
|
76
|
+
from .sandbox import EnvSpec
|
|
77
|
+
from .tasks import repo_slug
|
|
78
|
+
|
|
79
|
+
out = out or Path("tasks") / repo_slug(repo) / "tasks.json"
|
|
80
|
+
|
|
81
|
+
def on_event(kind: str, d: dict) -> None:
|
|
82
|
+
if kind == "build":
|
|
83
|
+
console.print(f"[dim]building test image {d['image']} (network on, only for this step)[/dim]")
|
|
84
|
+
elif kind == "candidates":
|
|
85
|
+
console.print(f"{d['count']} candidate commits touch both source and tests")
|
|
86
|
+
elif kind == "task":
|
|
87
|
+
console.print(f" [green]valid[/green] {d['id']} {d['f2p']} fail->pass, {d['p2p']} pass->pass")
|
|
88
|
+
elif kind == "reject":
|
|
89
|
+
console.print(f" [dim]skip {d['sha']} {d['reason']}[/dim]")
|
|
90
|
+
|
|
91
|
+
tasks, meta = do_mine(
|
|
92
|
+
repo, out=out, rev=rev, since=since, until=until, scan=scan, max_tasks=max_tasks,
|
|
93
|
+
env=EnvSpec(python=python, setup=setup), test_timeout=test_timeout, on_event=on_event,
|
|
94
|
+
) # fmt: skip
|
|
95
|
+
console.print(f"\n[bold]{len(tasks)} validated tasks[/bold] written to {out}")
|
|
96
|
+
if tasks:
|
|
97
|
+
console.print(f"Next: commitarena run --tasks {out} --models ollama/qwen3:4b")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def results_table(results, cutoffs: dict[str, str] | None = None) -> Table:
|
|
101
|
+
from .report import leaderboard
|
|
102
|
+
|
|
103
|
+
rows = leaderboard([r.to_dict() if hasattr(r, "to_dict") else r for r in results], cutoffs or {})
|
|
104
|
+
t = Table(title="Leaderboard", title_justify="left")
|
|
105
|
+
t.add_column("model", no_wrap=True)
|
|
106
|
+
for col in ("pass", "rate", "before cutoff", "after cutoff", "cost $", "tokens", "avg s", "avg steps"):
|
|
107
|
+
t.add_column(col, justify="right")
|
|
108
|
+
|
|
109
|
+
def split(s):
|
|
110
|
+
return f"{s['passed']}/{s['total']}" if s["total"] else "-"
|
|
111
|
+
|
|
112
|
+
for r in rows:
|
|
113
|
+
t.add_row(
|
|
114
|
+
r["model"], f"{r['passed']}/{r['total']}", f"{r['rate'] * 100:.0f}%", split(r["before"]),
|
|
115
|
+
split(r["after"]), f"{r['cost']:.4f}", f"{r['tokens']:,}", f"{r['avg_time']:.0f}",
|
|
116
|
+
f"{r['avg_steps']:.1f}",
|
|
117
|
+
) # fmt: skip
|
|
118
|
+
return t
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@app.command()
|
|
122
|
+
def run(
|
|
123
|
+
tasks: Annotated[Path, typer.Option(help="tasks.json from `commitarena mine`.")],
|
|
124
|
+
models: Annotated[str, typer.Option(help="Comma-separated provider/model, e.g. ollama/qwen3:4b.")],
|
|
125
|
+
out: Annotated[Path | None, typer.Option(help="Run directory (resumable).")] = None,
|
|
126
|
+
max_cost: Annotated[float, typer.Option(help="Stop before any call once this many USD are spent.")] = 1.0,
|
|
127
|
+
task_ids: Annotated[str | None, typer.Option(help="Comma-separated task ids to run.")] = None,
|
|
128
|
+
limit: Annotated[int | None, typer.Option(help="Run only the first N tasks.")] = None,
|
|
129
|
+
max_steps: Annotated[int, typer.Option(help="Model calls per attempt.")] = 30,
|
|
130
|
+
max_time: Annotated[int, typer.Option(help="Seconds per attempt.")] = 1200,
|
|
131
|
+
context_file: Annotated[
|
|
132
|
+
Path | None, typer.Option(help="AGENTS.md/CLAUDE.md to inject into the attempt.")
|
|
133
|
+
] = None,
|
|
134
|
+
context_mode: Annotated[str, typer.Option(help="both | repo | prompt")] = "both",
|
|
135
|
+
strip_context_files: Annotated[
|
|
136
|
+
bool, typer.Option(help="Remove the repo's own AGENTS.md / CLAUDE.md first.")
|
|
137
|
+
] = False,
|
|
138
|
+
cutoff: Annotated[
|
|
139
|
+
list[str] | None, typer.Option(help="Training cutoff, MODEL=YYYY-MM-DD (repeatable).")
|
|
140
|
+
] = None,
|
|
141
|
+
price: Annotated[
|
|
142
|
+
list[str] | None, typer.Option(help="Price override, MODEL=IN,OUT in USD per 1M tokens (repeatable).")
|
|
143
|
+
] = None,
|
|
144
|
+
cpus: Annotated[float, typer.Option(help="CPU limit of the sandbox.")] = 2.0,
|
|
145
|
+
memory: Annotated[str, typer.Option(help="Memory limit of the sandbox.")] = "2g",
|
|
146
|
+
command_timeout: Annotated[int, typer.Option(help="Seconds per agent command.")] = 120,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Run every task against every model with the same minimal agent, then write report.html."""
|
|
149
|
+
from .agent import AgentConfig
|
|
150
|
+
from .llm import Budget, ChatClient, ModelSpec
|
|
151
|
+
from .report import write_report
|
|
152
|
+
from .runner import ContextFile, run_suite
|
|
153
|
+
from .sandbox import SandboxConfig, ensure_docker
|
|
154
|
+
from .tasks import load_tasks
|
|
155
|
+
|
|
156
|
+
prices = {}
|
|
157
|
+
for k, v in _parse_pairs(price, "--price").items():
|
|
158
|
+
try:
|
|
159
|
+
pin, pout = (float(x) for x in v.split(","))
|
|
160
|
+
except ValueError as e:
|
|
161
|
+
raise CommitArenaError(f"--price for {k} must be IN,OUT (numbers), got '{v}'.") from e
|
|
162
|
+
prices[k] = (pin, pout)
|
|
163
|
+
specs = [ModelSpec.parse(m, prices.get(m)) for m in _csv(models)]
|
|
164
|
+
if not specs:
|
|
165
|
+
raise CommitArenaError("--models is empty.")
|
|
166
|
+
for s in specs: # validate keys and prices before any work
|
|
167
|
+
ChatClient(s)._sdk()
|
|
168
|
+
if s.is_local:
|
|
169
|
+
_warn_ollama_context(s.name)
|
|
170
|
+
task_list = load_tasks(tasks, _csv(task_ids) or None)
|
|
171
|
+
if limit:
|
|
172
|
+
task_list = task_list[:limit]
|
|
173
|
+
ensure_docker()
|
|
174
|
+
out = out or Path("runs") / datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
175
|
+
ctx = ContextFile.from_path(context_file, context_mode) if context_file else None
|
|
176
|
+
budget = Budget(max_cost=max_cost)
|
|
177
|
+
cutoffs = _parse_pairs(cutoff, "--cutoff")
|
|
178
|
+
console.print(
|
|
179
|
+
f"{len(task_list)} tasks x {len(specs)} models, sandbox --network none, {cpus} CPU, {memory}, "
|
|
180
|
+
f"budget ${max_cost:.2f}. Results in {out}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def on_start(task, model):
|
|
184
|
+
console.print(f"[bold]{model.id}[/bold] {task.id} [dim]({task.date[:10]})[/dim]")
|
|
185
|
+
|
|
186
|
+
def on_step(step, command):
|
|
187
|
+
first = command.strip().splitlines()[0] if command.strip() else ""
|
|
188
|
+
console.print(f" [dim]{step:>2} $ {first[:100]}[/dim]")
|
|
189
|
+
|
|
190
|
+
def on_result(r):
|
|
191
|
+
mark = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]"
|
|
192
|
+
console.print(
|
|
193
|
+
f" {mark} {r.fail_to_pass_passed}/{r.fail_to_pass_total} fail->pass, "
|
|
194
|
+
f"{r.pass_to_pass_failed} broken, {r.steps} steps, {r.wall_time_s:.0f}s, "
|
|
195
|
+
f"${r.cost_usd:.4f} ({r.stop_reason}) total ${budget.spent:.4f}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
results, abort = run_suite(
|
|
199
|
+
task_list, specs, out, budget=budget, context=ctx, strip_context_files=strip_context_files,
|
|
200
|
+
agent_config=AgentConfig(max_steps=max_steps, max_wall_time=max_time),
|
|
201
|
+
sandbox_config=SandboxConfig(cpus=cpus, memory=memory, command_timeout=command_timeout),
|
|
202
|
+
cutoffs=cutoffs, on_result=on_result, on_start=on_start, on_step=on_step,
|
|
203
|
+
) # fmt: skip
|
|
204
|
+
report = write_report(out)
|
|
205
|
+
console.print(results_table(results, _load_cutoffs(out)))
|
|
206
|
+
console.print(f"Spent ${budget.spent:.4f}. Report: {report}")
|
|
207
|
+
if abort:
|
|
208
|
+
err.print(f"[red]{abort}[/red]")
|
|
209
|
+
raise typer.Exit(2)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _warn_ollama_context(name: str) -> None:
|
|
213
|
+
"""Ollama's OpenAI endpoint ignores num_ctx; a small default window silently drops the task text."""
|
|
214
|
+
import json
|
|
215
|
+
import os
|
|
216
|
+
import urllib.request
|
|
217
|
+
|
|
218
|
+
from .llm import _ollama_base
|
|
219
|
+
|
|
220
|
+
if os.environ.get("OLLAMA_CONTEXT_LENGTH"):
|
|
221
|
+
return
|
|
222
|
+
try:
|
|
223
|
+
req = urllib.request.Request(
|
|
224
|
+
_ollama_base().removesuffix("/v1") + "/api/show", data=json.dumps({"model": name}).encode()
|
|
225
|
+
)
|
|
226
|
+
with urllib.request.urlopen(req, timeout=10) as r:
|
|
227
|
+
info = json.load(r)
|
|
228
|
+
except Exception: # noqa: BLE001 - the real call reports connection problems
|
|
229
|
+
return
|
|
230
|
+
if "num_ctx" not in (info.get("parameters") or ""):
|
|
231
|
+
err.print(
|
|
232
|
+
f"[yellow]warning:[/yellow] ollama/{name} has no num_ctx set, so Ollama may serve it with a small "
|
|
233
|
+
f"default context (often 4096 tokens) and drop the task text. Create a variant:\n"
|
|
234
|
+
f" printf 'FROM {name}\\nPARAMETER num_ctx 16384\\n' > Modelfile && "
|
|
235
|
+
f"ollama create {name.split(':')[0]}:16k -f Modelfile"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _load_cutoffs(run_dir: Path) -> dict[str, str]:
|
|
240
|
+
import json
|
|
241
|
+
|
|
242
|
+
from .report import DEFAULT_CUTOFFS
|
|
243
|
+
|
|
244
|
+
meta = json.loads((run_dir / "run.json").read_text()) if (run_dir / "run.json").exists() else {}
|
|
245
|
+
return {**DEFAULT_CUTOFFS, **meta.get("cutoffs", {})}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@app.command()
|
|
249
|
+
def report(
|
|
250
|
+
run_dir: Annotated[Path, typer.Argument(help="Run directory written by `commitarena run`.")],
|
|
251
|
+
out: Annotated[Path | None, typer.Option(help="Output HTML (default: <run_dir>/report.html).")] = None,
|
|
252
|
+
cutoff: Annotated[
|
|
253
|
+
list[str] | None, typer.Option(help="Override a training cutoff, MODEL=YYYY-MM-DD.")
|
|
254
|
+
] = None,
|
|
255
|
+
open_browser: Annotated[bool, typer.Option("--open", help="Open the report in a browser.")] = False,
|
|
256
|
+
) -> None:
|
|
257
|
+
"""Render the static HTML report: leaderboard, task x model matrix, cutoff split, diffs."""
|
|
258
|
+
from .report import load_run, write_report
|
|
259
|
+
|
|
260
|
+
if not (run_dir / "results.jsonl").exists():
|
|
261
|
+
raise CommitArenaError(f"No results.jsonl in {run_dir}. Run `commitarena run` first.")
|
|
262
|
+
path = write_report(run_dir, out, _parse_pairs(cutoff, "--cutoff"))
|
|
263
|
+
data = load_run(run_dir)
|
|
264
|
+
console.print(
|
|
265
|
+
results_table(data["results"], {**_load_cutoffs(run_dir), **_parse_pairs(cutoff, "--cutoff")})
|
|
266
|
+
)
|
|
267
|
+
console.print(f"Report: {path}")
|
|
268
|
+
if open_browser:
|
|
269
|
+
webbrowser.open(path.resolve().as_uri())
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command()
|
|
273
|
+
def demo(
|
|
274
|
+
out: Annotated[Path, typer.Option(help="Where to write the demo report.")] = Path(
|
|
275
|
+
"commitarena-demo.html"
|
|
276
|
+
),
|
|
277
|
+
open_browser: Annotated[bool, typer.Option("--open/--no-open", help="Open it in a browser.")] = True,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Show a RECORDED real run (no keys, no Docker): leaderboard in the terminal plus the HTML report."""
|
|
280
|
+
from .report import demo_dir, load_run, write_report
|
|
281
|
+
|
|
282
|
+
d = demo_dir()
|
|
283
|
+
data = load_run(d)
|
|
284
|
+
m = data["meta"]
|
|
285
|
+
console.print(
|
|
286
|
+
f"[bold]Recorded result[/bold] (not a live run): {m.get('updated_at', '')[:10]}, commitarena "
|
|
287
|
+
f"{m.get('version')}, models {', '.join(m.get('models', []))}, on {m.get('hardware', '?')}."
|
|
288
|
+
)
|
|
289
|
+
console.print(f"Repo: {m.get('repo', '')} ({m.get('repo_license', '')})")
|
|
290
|
+
console.print(results_table(data["results"], data["cutoffs"]))
|
|
291
|
+
path = write_report(d, out)
|
|
292
|
+
console.print(f"Report: {path}")
|
|
293
|
+
console.print("Run it yourself: commitarena mine <repo> && commitarena run --tasks ... --models ...")
|
|
294
|
+
if open_browser:
|
|
295
|
+
webbrowser.open(path.resolve().as_uri())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _sigterm(signum, frame): # noqa: ARG001
|
|
299
|
+
raise KeyboardInterrupt
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main() -> None:
|
|
303
|
+
import signal
|
|
304
|
+
|
|
305
|
+
signal.signal(signal.SIGTERM, _sigterm) # so sandboxes are removed on kill
|
|
306
|
+
try:
|
|
307
|
+
app()
|
|
308
|
+
except CommitArenaError as e:
|
|
309
|
+
err.print(f"[red]error:[/red] {e}")
|
|
310
|
+
sys.exit(1)
|
|
311
|
+
except KeyboardInterrupt:
|
|
312
|
+
err.print("[yellow]interrupted[/yellow]")
|
|
313
|
+
sys.exit(130)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Copyright (c) 2016, Andi Albrecht <albrecht.andi@gmail.com>
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
5
|
+
are permitted provided that the following conditions are met:
|
|
6
|
+
|
|
7
|
+
* Redistributions of source code must retain the above copyright notice,
|
|
8
|
+
this list of conditions and the following disclaimer.
|
|
9
|
+
* Redistributions in binary form must reproduce the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer in the documentation
|
|
11
|
+
and/or other materials provided with the distribution.
|
|
12
|
+
* Neither the name of the authors nor the names of its contributors may be
|
|
13
|
+
used to endorse or promote products derived from this software without
|
|
14
|
+
specific prior written permission.
|
|
15
|
+
|
|
16
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
17
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
18
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
19
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
20
|
+
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
21
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
22
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
23
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
24
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
25
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|