Skip to content

Commit 52d88f8

Browse files
authored
Merge pull request #51 from CodingThrust/fix-run-frictions
Fix four run frictions hit by real benchmark sessions
2 parents 70d1ad5 + a0330e5 commit 52d88f8

6 files changed

Lines changed: 173 additions & 10 deletions

File tree

‎Makefile‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# test-unit Run only unit tests (no real repo/pred needed)
77
# verify-calibration Test the verifier against known fixtures (no AI needed)
88
# preflight Validate submission.env with one tiny real call before a full run
9-
# run Run the benchmark via Docker → out/submission.json (does NOT upload)
9+
# run Run the benchmark via Docker → out/<stamp>/submission.json (does NOT upload)
1010
# run-local Clone/verify a repo and run a local headless CLI agent
1111
#
1212
# Model/auth configuration lives in submission.env (see submission.env.example). Local
@@ -21,6 +21,7 @@ JOBS ?= 1
2121
SUBS_DIR ?= submissions
2222
SCORED ?= results/scored
2323
ENV_FILE ?= submission.env
24+
STAMP ?= $(shell date +%Y%m%d-%H%M%S)
2425
LOCAL_BACKEND ?= codex
2526
LOCAL_REPO_DIR ?=
2627
LOCAL_OUTPUT ?=
@@ -69,16 +70,18 @@ preflight:
6970
echo "No $(ENV_FILE) — copy submission.env.example and fill it in first"; exit 1; fi
7071
docker run --rm --env-file "$(ENV_FILE)" $(IMAGE) --preflight
7172

72-
## Run the bug-finding agent via Docker → writes ./out/submission.json.
73+
## Run the bug-finding agent via Docker → writes ./out/<stamp>/submission.json (the
74+
## whole-repo trajectory lands alongside it). Each run gets its own timestamped dir so
75+
## successive runs never overwrite each other (override with STAMP=... to reproduce).
7376
## This RUNS the benchmark locally; it does NOT submit — submitting is a separate step
7477
## (open a GitHub PR adding the file, see CONTRIBUTING.md). Config lives in submission.env
7578
## (copy submission.env.example); run `make preflight` first to validate it.
7679
run:
7780
@if [ ! -f "$(ENV_FILE)" ]; then \
7881
echo "No $(ENV_FILE) — copy submission.env.example and fill it in (then: make preflight)"; exit 1; fi
7982
mkdir -p out
80-
docker run --rm --env-file "$(ENV_FILE)" -v "$(PWD)/out:/out" $(IMAGE)
81-
@echo "Wrote ./out/submission.json — now submit it with 'python -m benchmark.submit' (see CONTRIBUTING.md)."
83+
docker run --rm --env-file "$(ENV_FILE)" -e OUTPUT=/out/$(STAMP)/submission.json -v "$(PWD)/out:/out" $(IMAGE)
84+
@echo "Wrote out/$(STAMP)/submission.json (+ trajectory) — now submit it with 'python -m benchmark.submit' (see CONTRIBUTING.md)."
8285

8386
## Lightweight host run through an installed headless agent CLI. The checkout is cloned at
8487
## PR_REF when LOCAL_REPO_DIR is absent; an existing checkout must already match exactly.

‎benchmark/preflight.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
77
1. verify the `pred` binary + version,
88
2. confirm the library's reduction rules are present,
9-
3. make ONE tiny real model call to validate credentials / endpoint.
9+
3. make ONE tiny real model call to validate credentials / endpoint,
10+
4. exercise the per-call cost accounting the agent runs on every step.
1011
1112
Exits 0 only if every check passes — so you can launch the full batch with confidence
1213
instead of discovering a typo'd key or wrong base URL 20 rules in. This is a user-facing
@@ -55,6 +56,7 @@ def run_checks(
5556

5657
# 3. one real model call through the exact batch model config (validates key, endpoint,
5758
# model name, model_kwargs).
59+
model = response = None
5860
try:
5961
model = _build_model(model_name, api_base, max_tokens,
6062
model_kwargs=model_kwargs, api_key=api_key)
@@ -71,6 +73,18 @@ def run_checks(
7173
except Exception as e:
7274
results.append(("model call", False, f"{type(e).__name__}: {e}"))
7375

76+
# 4. the run prices EVERY call — exercise that same accounting path. A raw _query passes
77+
# even for models missing from litellm's price map, but the agent then aborts the
78+
# whole session on the cost-calculation RuntimeError (unless ignore_errors is set).
79+
if response is not None:
80+
try:
81+
model._calculate_cost(response)
82+
results.append(("cost tracking", True, "per-call pricing works (or ignore_errors set)"))
83+
except Exception as e:
84+
results.append(("cost tracking", False,
85+
f"{type(e).__name__}: {e} — set MSWEA_COST_TRACKING=ignore_errors "
86+
"or use a priced model before launching"))
87+
7488
return results
7589

7690

‎benchmark/run_mini.py‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,32 @@ def save_trajectory(messages: list, path: Path) -> None:
4545
"content": _message_text(msg)}) + "\n")
4646

4747

48+
class TrajectoryWriter:
49+
"""Append-only incremental trajectory writer — live progress during a session.
50+
51+
Without this, the trajectory only exists after the session ends, so a healthy slow
52+
run and a dead hang look identical from outside (and a previous crash's leftover
53+
file masquerades as the current run's progress). flush() appends any new messages
54+
after each agent step; save_trajectory() still makes the authoritative final write
55+
at session end, in the identical format.
56+
"""
57+
58+
def __init__(self, path: Path):
59+
self._path = path
60+
self._written = 0
61+
path.parent.mkdir(parents=True, exist_ok=True)
62+
path.unlink(missing_ok=True) # fresh session — never append onto a stale file
63+
64+
def flush(self, messages: list) -> None:
65+
if len(messages) <= self._written:
66+
return
67+
with self._path.open("a", encoding="utf-8") as handle:
68+
for msg in messages[self._written:]:
69+
handle.write(json.dumps({"role": msg.get("role", ""),
70+
"content": _message_text(msg)}) + "\n")
71+
self._written = len(messages)
72+
73+
4874
def _session_usage(agent):
4975
usage = extract_usage(agent.messages)
5076
total_tokens = usage.total_tokens or extract_total_tokens(agent.messages)
@@ -57,7 +83,9 @@ def _build_model(model_name: str, api_base: str | None, max_tokens: int,
5783
format_error_template: str | None = None):
5884
from minisweagent.models.litellm_model import LitellmModel
5985

60-
kwargs = dict(model_kwargs or {})
86+
# A hung API call must fail fast and retry — never freeze a whole-repo session
87+
# indefinitely. User-supplied MODEL_KWARGS still wins on any key.
88+
kwargs = {"timeout": 300, "num_retries": 2, **dict(model_kwargs or {})}
6189
if max_tokens:
6290
kwargs["max_tokens"] = max_tokens
6391
if api_base:
@@ -108,11 +136,25 @@ def run_repo_session(
108136
agent_config, model_config, strategy = _load_agent_config(
109137
config_path, CONFIG_FILE, strategy)
110138
safe_model = safe_model_label(model_name)
139+
writer = None
111140
if trajectory_dir is not None:
112141
trajectory_dir = Path(trajectory_dir).resolve()
113142
trajectory_dir.mkdir(parents=True, exist_ok=True)
143+
writer = TrajectoryWriter(trajectory_dir / f"{safe_model}_whole-repo.jsonl")
144+
145+
class _StepFlushingAgent(DefaultAgent):
146+
"""DefaultAgent that flushes the trajectory after every step (best-effort)."""
147+
148+
def step(self):
149+
result = super().step()
150+
if writer is not None:
151+
try:
152+
writer.flush(self.messages)
153+
except OSError:
154+
pass # observability must never kill the session
155+
return result
114156

115-
agent = DefaultAgent(
157+
agent = _StepFlushingAgent(
116158
_build_model(
117159
model_name, api_base, max_tokens, model_kwargs=model_kwargs, api_key=api_key,
118160
observation_template=model_config.get("observation_template"),

‎benchmark/tests/test_preflight.py‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
class _FakeModel:
1414
"""Mimics mini-swe-agent's LitellmModel low-level path used by preflight: _query returns
15-
a litellm-shaped response with a .usage block."""
16-
def __init__(self, prompt=6, completion=4, raise_exc=None):
15+
a litellm-shaped response with a .usage block; _calculate_cost is the per-call pricing."""
16+
def __init__(self, prompt=6, completion=4, raise_exc=None, cost_exc=None):
1717
self._usage = SimpleNamespace(prompt_tokens=prompt, completion_tokens=completion,
1818
prompt_tokens_details=None)
1919
self._raise = raise_exc
20+
self._cost_exc = cost_exc
2021

2122
def _prepare_messages_for_api(self, messages):
2223
return messages
@@ -27,6 +28,11 @@ def _query(self, messages):
2728
return SimpleNamespace(usage=self._usage,
2829
choices=[SimpleNamespace(message=SimpleNamespace(content="OK"))])
2930

31+
def _calculate_cost(self, response):
32+
if self._cost_exc is not None:
33+
raise self._cost_exc
34+
return {"cost": 0.001}
35+
3036

3137
def _patch(monkeypatch, *, ver="0.6.0", rules=("a", "b"), model=None, build_exc=None):
3238
monkeypatch.setattr(pf, "verify_pred_version", lambda *a, **k: ver)
@@ -43,7 +49,7 @@ class TestRunChecks:
4349
def test_all_pass(self, monkeypatch):
4450
_patch(monkeypatch)
4551
results = pf.run_checks("anthropic/x", repo_dir="/repo")
46-
assert [ok for _, ok, _ in results] == [True, True, True]
52+
assert [ok for _, ok, _ in results] == [True, True, True, True]
4753
assert pf.format_report(results) is True
4854

4955
def test_pred_failure_reported(self, monkeypatch):
@@ -71,3 +77,17 @@ def test_model_call_tokens_shown(self, monkeypatch):
7177
results = pf.run_checks("anthropic/x", repo_dir="/repo")
7278
detail = dict((n, d) for n, _, d in results)["model call"]
7379
assert "2000000 tokens" in detail
80+
81+
def test_cost_tracking_failure_fails(self, monkeypatch):
82+
"""A model missing from litellm's price map passes _query but kills the real run —
83+
preflight must catch that (the openai/kimi-for-coding failure mode)."""
84+
_patch(monkeypatch, model=_FakeModel(cost_exc=RuntimeError("model isn't mapped yet")))
85+
results = pf.run_checks("anthropic/x", repo_dir="/repo")
86+
call = dict((n, (ok, d)) for n, ok, d in results)["cost tracking"]
87+
assert call[0] is False and "ignore_errors" in call[1]
88+
assert pf.format_report(results) is False
89+
90+
def test_no_cost_check_when_model_call_fails(self, monkeypatch):
91+
_patch(monkeypatch, model=_FakeModel(raise_exc=RuntimeError("401")))
92+
results = pf.run_checks("anthropic/x", repo_dir="/repo")
93+
assert [n for n, _, _ in results] == ["pred binary", "library rules", "model call"]

‎benchmark/tests/test_run_mini.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Tests for benchmark/run_mini.py model construction.
3+
4+
The real minisweagent is stubbed via sys.modules so these run anywhere.
5+
"""
6+
import sys
7+
import types
8+
9+
import pytest
10+
11+
from benchmark.run_mini import _build_model
12+
13+
14+
class _FakeLitellmModel:
15+
def __init__(self, model_name, model_kwargs, **config):
16+
self.model_name = model_name
17+
self.model_kwargs = model_kwargs
18+
self.config = config
19+
20+
21+
@pytest.fixture
22+
def _fake_litellm(monkeypatch):
23+
fake = types.ModuleType("minisweagent.models.litellm_model")
24+
fake.LitellmModel = _FakeLitellmModel
25+
monkeypatch.setitem(sys.modules, "minisweagent.models.litellm_model", fake)
26+
27+
28+
class TestBuildModel:
29+
def test_defaults_timeout_and_retries(self, _fake_litellm):
30+
"""A hung API call must fail fast, not freeze the whole-repo session."""
31+
model = _build_model("openai/x", None, 8192)
32+
assert model.model_kwargs["timeout"] == 300
33+
assert model.model_kwargs["num_retries"] == 2
34+
35+
def test_user_kwargs_override_defaults(self, _fake_litellm):
36+
model = _build_model("openai/x", None, 8192, model_kwargs={"timeout": 60})
37+
assert model.model_kwargs["timeout"] == 60
38+
assert model.model_kwargs["num_retries"] == 2
39+
40+
def test_endpoint_config_passthrough(self, _fake_litellm):
41+
model = _build_model("openai/x", "https://api.example/v1", 4096, api_key="k")
42+
assert model.model_kwargs["api_base"] == "https://api.example/v1"
43+
assert model.model_kwargs["api_key"] == "k"
44+
assert model.model_kwargs["max_tokens"] == 4096

‎benchmark/tests/test_trajectory.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,43 @@ def test_creates_parent_dirs(self, tmp_path):
5454
out = tmp_path / "deep" / "nested" / "traj.jsonl"
5555
save_trajectory(_fake_messages(), out)
5656
assert out.exists()
57+
58+
59+
class TestTrajectoryWriter:
60+
"""Incremental (per-step) trajectory flushing — the live view of a running session."""
61+
62+
def test_incremental_appends(self, tmp_path):
63+
from benchmark.run_mini import TrajectoryWriter
64+
out = tmp_path / "traj.jsonl"
65+
w = TrajectoryWriter(out)
66+
msgs = _fake_messages()
67+
w.flush(msgs[:2])
68+
w.flush(msgs)
69+
lines = out.read_text(encoding="utf-8").strip().splitlines()
70+
assert len(lines) == len(msgs)
71+
72+
def test_no_duplicate_lines_on_repeat_flush(self, tmp_path):
73+
from benchmark.run_mini import TrajectoryWriter
74+
out = tmp_path / "traj.jsonl"
75+
w = TrajectoryWriter(out)
76+
msgs = _fake_messages()
77+
w.flush(msgs)
78+
w.flush(msgs)
79+
assert len(out.read_text(encoding="utf-8").strip().splitlines()) == len(msgs)
80+
81+
def test_starts_fresh_not_appending_stale(self, tmp_path):
82+
from benchmark.run_mini import TrajectoryWriter, save_trajectory
83+
out = tmp_path / "traj.jsonl"
84+
save_trajectory(_fake_messages(), out) # leftover from a previous run
85+
w = TrajectoryWriter(out)
86+
w.flush(_fake_messages()[:1])
87+
assert len(out.read_text(encoding="utf-8").strip().splitlines()) == 1
88+
89+
def test_matches_final_save_format(self, tmp_path):
90+
from benchmark.run_mini import TrajectoryWriter, save_trajectory
91+
msgs = _fake_messages()
92+
incremental = tmp_path / "inc.jsonl"
93+
TrajectoryWriter(incremental).flush(msgs)
94+
final = tmp_path / "final.jsonl"
95+
save_trajectory(msgs, final)
96+
assert incremental.read_text(encoding="utf-8") == final.read_text(encoding="utf-8")

0 commit comments

Comments
 (0)