diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca95841..7c57793 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: - id: mypy name: mypy - entry: uv run mypy src + entry: uv run mypy src agent-bench language: system types: [python] pass_filenames: false diff --git a/agent-bench/.gitignore b/agent-bench/.gitignore new file mode 100644 index 0000000..7596101 --- /dev/null +++ b/agent-bench/.gitignore @@ -0,0 +1,3 @@ +# Per-run artifacts + results store are generated, not tracked. +results/ +__pycache__/ diff --git a/agent-bench/README.md b/agent-bench/README.md new file mode 100644 index 0000000..16b5c32 --- /dev/null +++ b/agent-bench/README.md @@ -0,0 +1,82 @@ +# agent-bench + +Benchmark rig: give each agent framework the same autonomous build task — a +top-down **GTA2-style web game** (HTML5 canvas + vanilla JS: `index.html` + +`game.js`) — against one shared gx10 vLLM endpoint, then score the result. + +See `../docs/agent-framework-bench.md` for the full plan. + +## Prerequisites + +- **node** (≥18) — JS syntax check in the `builds` scorer (`node --check`). +- **Playwright + chromium** — the `runs_headless` scorer loads the generated + page in a real headless browser. Install once: + `uv pip install playwright && uv run playwright install chromium`. +- A framework CLI on PATH for each adapter you run (`aider`, `opencode`, …). + +## Layout + +``` +spec/gta-spec.md the one task input (HTML/CSS/JS GTA2, milestones M1..M8) +bench.yaml frameworks, runs-per-framework, gx10 endpoint, paths +metrics.yaml ordered scorer plugins to run (the adjustable surface) +adapters/ one thin wrapper per framework (framework SDKs live ONLY here) +scorers/ pluggable metric collectors (drop-in, listed in metrics.yaml) +runner.py framework-agnostic matrix runner +store.py append-first SQLite + JSONL results store +report.py aggregate -> tables + per-metric distributions +results/ per-run artifacts (transcript, diff, scores.json) +``` + +## Run + +```bash +cd agent-bench +python runner.py --runs 5 # full matrix from bench.yaml +python runner.py --framework aider --runs 1 +python report.py # tables from the store +``` + +## Extend + +- **New framework**: add `adapters/.py` implementing the `Adapter` protocol; + register it in `ADAPTERS` (runner.py). +- **New metric**: add `scorers/.py` implementing the `Scorer` protocol; + register it in `SCORERS` (runner.py) and list its name in `metrics.yaml`. + No runner edit beyond the registry line; scores are schemaless JSON per row. + +## Scorers (web stack) + +- `builds` — `node --check` over every `.js`; `index.html` has `` + ``. No build step, no bundler, no external assets. +- `game.js` — all game code in **plain browser JavaScript**. + +Hard tech constraints (the game is graded against these): + +- **One `requestAnimationFrame` loop** named `gameLoop`: read input → update + fixed-`dt` physics → render. No `setInterval`, no second loop. +- **No ES modules, no `class`, no `async`, no IIFE.** Top-level `let`/`const` only. +- Read `canvas.width`/`canvas.height` from JS; do not hardcode dimensions twice. +- All drawing is HTML5 canvas 2D (`getContext('2d')`). No DOM-element sprites, no WebGL. +- No network calls, no asset downloads — generate all shapes/colors in code. +- The page must load with **zero console errors** and the loop must run every frame. + +Implement these milestones in order. Each is graded independently — a clean +M1–M5 beats a crashing M1–M8. Favor correctness and runnability over scope. + +- **M1 — project shape + loop**: `index.html` canvas skeleton + `game.js` with the + `gameLoop` requestAnimationFrame callback and `keydown`/`keyup` listeners that + populate a `keys` object (use `event.code`, not `event.key`). +- **M2 — world map**: a tile grid stored as a multi-line string (alphabet e.g. + `.`=road, `=`=sidewalk, `B`=building), 32px tiles, ~40×30 → larger than the + viewport. Render by iterating the grid through a single `drawTile(char,x,y)`. + Must show streets + buildings, not a blank canvas. +- **M3 — player car**: a `player = { x, y, angle, speed, ... }` object literal. + Render with the `save → translate(x,y) → rotate(angle) → draw → restore` + transform (body, roof, windshield, headlights, wheels). Spawn on a road tile. +- **M4 — driving + collision**: `dt`-based motion. Forward accelerates; reverse + brakes then reverses; friction returns speed to 0. **Steering proportional to + speed** (stationary car cannot turn). Building collision via center-vs-tile + (`floor(x/32), floor(y/32)`) — revert position + small bounce on hit. +- **M5 — camera**: a `camera = {x,y}` holding the screen's top-left world coord; + `ctx.translate(-camera.x,-camera.y)` once around the world render. Follow the + player and clamp to map bounds. Map scrolls as the car drives. +- **M6 — pedestrians + run-over + score**: a `peds` array spawned on sidewalk + tiles; they wander and panic near the moving car. Running one over (proximity + + speed) kills it and adds to `score`. +- **M7 — HUD**: after the world pass, reset the transform + (`ctx.setTransform(1,0,0,1,0,0)`) and draw screen-space HUD: `SCORE: ` and a + health bar. HUD must not scroll with the camera. +- **M8 — police + wanted (stretch)**: a `wanted` level that rises on crimes; a + `police` array of cop cars that spawn by wanted level and chase the player. + +Constraints recap: pure HTML/CSS/JS, `index.html` + `game.js`, canvas 2D only, +one `requestAnimationFrame` loop, no modules/classes/async, zero console errors. diff --git a/agent-bench/store.py b/agent-bench/store.py new file mode 100644 index 0000000..31003cc --- /dev/null +++ b/agent-bench/store.py @@ -0,0 +1,95 @@ +"""Append-first, attributed, timestamped results store — SQLite + JSONL mirror. + +Same philosophy as the harness stores: nothing is overwritten. Re-runs append, +so a results series over time is queryable. Per-run scores are stored as a JSON +blob so adding/removing a metric needs no schema migration. +""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from pathlib import Path + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + framework TEXT NOT NULL, + version TEXT NOT NULL, + run_idx INTEGER NOT NULL, + started_at TEXT NOT NULL, + spec_sha TEXT NOT NULL, + model_id TEXT NOT NULL, + exit_ok INTEGER NOT NULL, + duration_s REAL NOT NULL, + scores_json TEXT NOT NULL, + manifest_json TEXT NOT NULL +); +""" + + +@dataclass +class RunRecord: + framework: str + version: str + run_idx: int + spec_sha: str + model_id: str + exit_ok: bool + duration_s: float + scores: dict[str, object] + manifest: dict[str, object] + started_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + + +class ResultsStore: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + self._jsonl = db_path.with_suffix(".jsonl") + db_path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.executescript(_SCHEMA) + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self._db_path) + conn.execute("PRAGMA busy_timeout = 5000") + return conn + + def append(self, rec: RunRecord) -> int: + row = ( + rec.framework, + rec.version, + rec.run_idx, + rec.started_at, + rec.spec_sha, + rec.model_id, + int(rec.exit_ok), + rec.duration_s, + json.dumps(rec.scores), + json.dumps(rec.manifest), + ) + with self._connect() as conn: + cur = conn.execute( + "INSERT INTO runs (framework, version, run_idx, started_at, spec_sha, " + "model_id, exit_ok, duration_s, scores_json, manifest_json) " + "VALUES (?,?,?,?,?,?,?,?,?,?)", + row, + ) + run_id = int(cur.lastrowid or 0) + with self._jsonl.open("a") as fh: + fh.write(json.dumps({"id": run_id, **asdict(rec)}) + "\n") + return run_id + + def all_rows(self) -> list[dict[str, object]]: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute("SELECT * FROM runs ORDER BY framework, run_idx").fetchall() + out: list[dict[str, object]] = [] + for r in rows: + d = dict(r) + d["scores"] = json.loads(d.pop("scores_json")) + d["manifest"] = json.loads(d.pop("manifest_json")) + out.append(d) + return out diff --git a/docs/agent-framework-bench.md b/docs/agent-framework-bench.md new file mode 100644 index 0000000..b755b75 --- /dev/null +++ b/docs/agent-framework-bench.md @@ -0,0 +1,211 @@ +# Agent-Framework Benchmark Plan + +Compare open-source agent frameworks **similar to this harness** by giving each the +same autonomous build task — **build a GTA-style game** — against a single shared +remote model (**gx10**). The harness enters as the home-team baseline. + +The benchmark is designed to be **repeatable** (pinned everything, N runs, full env +manifest), **recordable** (every transcript, diff, and artifact captured for replay), +and **adjustable** (metrics are pluggable scorers listed in a config — add/remove a +metric without touching the runner). + +> Scope decisions locked with the requester: build target = *build a GTA-style game*; +> remote model = *gx10 over an OpenAI-compatible `/v1` endpoint (vLLM)*; candidate +> language = *any, best-in-class*. + +--- + +## 1. Candidate libraries + +Two buckets. **Turnkey coding agents** drive their own multi-step build loop, exactly +like this harness does — apples-to-apples. **Build-your-own frameworks** need a thin +GTA-builder scaffold written per library, so they measure *your scaffold quality* as +much as the library; include them as a second tier and flag the caveat in results. + +Every candidate below is confirmed to support a custom OpenAI-compatible `base_url` +(directly or via LiteLLM), so all can be repointed at gx10 with config only. + +### Tier 1 — turnkey coding agents (primary comparands) + +| Framework | Repo | gx10 wiring | Notes | +|---|---|---|---| +| **This harness** | (local) | native vLLM / OpenAI adapter | baseline / reference entry | +| **OpenHands** (ex-OpenDevin) | https://github.com/All-Hands-AI/OpenHands | LiteLLM → `base_url` | best open SWE-bench scaffold; Docker sandbox; fully autonomous | +| **Aider** | https://github.com/Aider-AI/aider | `--openai-api-base` / LiteLLM | veteran, diff-based edits, terminal-native, scriptable | +| **OpenCode** | https://github.com/sst/opencode | OpenAI-compatible provider in `opencode.json` | terminal agent, 75+ providers, easy headless | +| **Goose** (Block) | https://github.com/block/goose | built-in OpenAI provider → vLLM `/v1` | Apache-2.0, MCP extensions, vLLM guide exists | +| **mini-SWE-agent** | https://github.com/SWE-agent/mini-swe-agent | LiteLLM | minimal canonical reference loop; great control | +| **SWE-agent** | https://github.com/SWE-agent/SWE-agent | LiteLLM | research scaffold; heavier than mini | + +Optional / harder to headless: **Cline** (https://github.com/cline/cline, VS-Code-bound), +**Codex CLI** (https://github.com/openai/codex, supports custom provider base_url). + +### Tier 2 — build-your-own frameworks (need a per-lib GTA-builder scaffold) + +| Framework | Repo | gx10 wiring | +|---|---|---| +| **smolagents** (HF) — `CodeAgent` | https://github.com/huggingface/smolagents | `OpenAIServerModel(api_base=...)` | +| **Pydantic-AI** | https://github.com/pydantic/pydantic-ai | `OpenAIProvider(base_url=...)` | +| **LangGraph** | https://github.com/langchain-ai/langgraph | `ChatOpenAI(base_url=...)` | +| **AutoGen / AG2** | https://github.com/microsoft/autogen · https://github.com/ag2ai/ag2 | OpenAI-compatible `config_list` | +| **CrewAI** | https://github.com/crewAIInc/crewAI | LiteLLM / `base_url` | +| **Agno** | https://github.com/agno-agi/agno | OpenAI-compatible model class | + +**Recommended starting set:** harness + OpenHands + Aider + OpenCode + Goose + +mini-SWE-agent (Tier 1), plus smolagents `CodeAgent` as the one Tier-2 control. Six to +seven entries keeps a full N-run matrix tractable; add more once the rig is proven. + +--- + +## 2. The benchmark product — GTA-style game + +A fixed spec file (`spec/gta-spec.md`) is the **only** task input, identical for every +framework. It defines a top-down 2D GTA-style game with graded milestones so partial +success is measurable, not pass/fail: + +1. **M1 — window + loop**: game window opens, runs headless, exits cleanly. +2. **M2 — player car**: a controllable car renders; arrow/WASD move + rotate. +3. **M3 — world**: top-down tiled map / roads larger than viewport; camera follows car. +4. **M4 — physics**: acceleration, steering, friction (not instant teleport). +5. **M5 — NPC traffic**: ≥1 AI-driven vehicle moving on the map. +6. **M6 — collision**: car vs world/NPC collision detected and resolved. +7. **M7 — objective**: a mission/score loop (reach waypoint, pickup, or wanted level) + HUD. + +Pin the stack in the spec to remove a free variable (recommend **Python + pygame** — +trivially headless-testable via `SDL_VIDEODRIVER=dummy`, no GPU/browser needed). The +spec is git-tracked; its commit SHA goes in every result row so a spec change is never +silently mixed into a results series. + +--- + +## 3. gx10 as the shared remote model + +One model endpoint for all frameworks isolates *framework* as the only variable. + +- gx10 serves the model via **vLLM** exposing OpenAI `/v1/chat/completions` over Tailscale. +- Every adapter gets the same `base_url`, `model` id, and a dummy API key. +- **Pin and record** in the run manifest: model id + quant, `max_model_len`, + `temperature` (0 for max determinism), `top_p`, `seed` (if the vLLM build honors it), + and a hash of the server launch args. +- **Serialize runs** (or hard rate-limit) so concurrent agents don't contend on gx10 + and skew wall-clock / latency numbers. +- Health-gate before each run: probe `/v1/models`; abort the batch if the served model + id ≠ the pinned id (catches a silently swapped endpoint). + +--- + +## 4. Harness architecture + +Mirror this repo's load-bearing patterns: an **adapter boundary** per framework, an +**append-first attributed store** for results, and **config-as-data** for metrics. + +``` +agent-bench/ + spec/gta-spec.md # the one task input (git SHA recorded per run) + bench.yaml # frameworks, runs-per-framework, gx10 endpoint, active metrics + metrics.yaml # ordered list of scorer plugins to run (the adjustable surface) + adapters/ # one thin wrapper per framework — the only framework-specific code + base.py # Adapter protocol: prepare(workspace) -> invoke(spec) -> Result + openhands.py aider.py opencode.py goose.py mini_swe.py smolagents_codeagent.py ... + scorers/ # pluggable metric collectors (drop-in, listed in metrics.yaml) + builds.py runs_headless.py feature_checklist.py cost.py process.py quality_judge.py + runner.py # orchestrates the matrix; framework-agnostic + store.py # SQLite + JSONL, append-first, attributed, timestamped + results/ # per-run artifacts: transcript, diff, repo tarball, scores.json + report.py # aggregate -> markdown/CSV tables + per-metric distributions +``` + +**Adapter protocol** (the framework boundary — keep framework SDKs *only* in here): + +```python +class Adapter(Protocol): + name: str + version: str # pinned, recorded in manifest + def prepare(self, workspace: Path) -> None: ... # fresh, isolated build dir + def invoke(self, spec: str, gx10: Endpoint) -> RunArtifacts: ... + # returns transcript, final diff, token/turn counters, exit status +``` + +**Run loop** (per `(framework, run_idx)`): + +1. Fresh isolated workspace — a git worktree or clean temp dir, so runs never cross-contaminate. +2. Health-gate gx10; stamp the manifest (framework version, model id, spec SHA, params). +3. `adapter.invoke(spec, gx10)` with a wall-clock timeout and a turn cap. +4. Capture artifacts: full stdout/stderr transcript, final `git diff`, repo tarball, + raw token/turn counters from the framework or proxied off gx10. +5. Run every scorer listed in `metrics.yaml` against the artifacts → `scores.json`. +6. Append one row to the store; write artifacts under `results///`. + +--- + +## 5. Repeatable · recordable · adjustable + +**Repeatable** +- Pin framework versions in a lockfile; record each in the manifest. Containerize Tier-1 + agents (most ship a Docker image) so host drift can't leak in. +- Pin gx10 model/params/seed; `temperature=0`. Accept that agent loops stay + non-deterministic even at temp 0 → run **N ≥ 5** per framework and report + **distributions** (success rate, median, IQR), never a single number. +- One command: `bench run --runs 5` replays the whole matrix from `bench.yaml`. +- Fresh workspace per run; spec SHA pinned per row → a spec edit starts a new series, never blends. + +**Recordable** +- Every run persists: transcript, diff, repo tarball, token/turn counters, scores, manifest. +- Append-first store (SQLite + JSONL) keyed by + `(framework, version, run_id, started_at, spec_sha, model_id)`. Nothing is overwritten — + re-runs append, so trends over time are queryable. +- `report.py` regenerates tables/plots from the store at any time; raw artifacts allow + full offline replay and dispute resolution. + +**Adjustable** +- `metrics.yaml` is the dial: an ordered list of scorer plugin names (+ optional weights). + The runner loads and runs *only* what's listed. +- Add a metric = drop a `scorers/.py` implementing the `Scorer` protocol + (`score(artifacts) -> dict[str, float|bool]`) and add its name to `metrics.yaml`. + No runner edit, no schema migration — new keys just appear in `scores.json`. +- Remove/disable a metric = delete its line. Old rows keep their historical keys; the + store is schemaless JSON per row for scores. + +```yaml +# metrics.yaml — illustrative; swap freely (the requester sets the real set) +metrics: + - builds # does the generated project import/compile? + - runs_headless # SDL dummy driver: boots and exits without crash? + - feature_checklist # M1..M7 milestones probed statically + at runtime + - cost # prompt/completion tokens, wall-clock, gx10 GPU-seconds + - process # agent turns, tool calls, files touched, diff LOC, retries, interventions + - quality_judge # optional LLM-judge (0-5) on code quality + playability +``` + +--- + +## 6. Build order + +1. **Skeleton + store + one adapter** (Aider — simplest to script) + 2 cheap scorers + (`builds`, `runs_headless`). Prove one row end-to-end against gx10. +2. **Write `spec/gta-spec.md`** (M1–M7) and the `feature_checklist` scorer that probes them. +3. **Add Tier-1 adapters**: OpenHands, OpenCode, Goose, mini-SWE-agent. +4. **Add `cost` + `process` scorers** (parse counters / proxy gx10 token usage). +5. **N-run matrix + `report.py`** distributions; lock the manifest/repeatability story. +6. **Tier-2 scaffold** (smolagents `CodeAgent`) + `quality_judge`; expand candidates as needed. + +## 7. Known risks / caveats + +- **Tier-2 measures the scaffold, not just the library** — report it in its own section; don't rank it head-to-head with turnkey agents. +- **Non-determinism** survives `temperature=0`; only distributions over N runs are trustworthy. +- **Sandboxing**: agents run/install arbitrary generated code — isolate every run (container or throwaway worktree), never the host. +- **Cline / IDE-bound agents** resist headless automation — keep optional until a scripted entrypoint exists. +- **gx10 contention** skews latency — serialize runs or the wall-clock metric is noise. + +--- + +### Sources + +- [Best Open Source Coding Agents 2026 — Open Source AI Review](https://www.opensourceaireview.com/blog/best-open-source-coding-agents-in-2026-reviewed-ranked) +- [OpenHands vs SWE-agent (2026) — CodeSOTA](https://www.codesota.com/agentic/openhands-vs-swe-agent) +- [OpenHands evaluation harness](https://github.com/OpenHands/benchmarks) +- [OpenCode docs — config / providers](https://opencode.ai/docs/config/) +- [Goose — Configure LLM Provider](https://goose-docs.ai/docs/getting-started/providers/) · [block/goose vLLM provider](https://github.com/block/goose) +- [Pydantic-AI — OpenAI-compatible models](https://ai.pydantic.dev/models/openai/) +- [Open-source agent framework comparison — Langfuse](https://langfuse.com/blog/2025-03-19-ai-agent-comparison) +- [Python AI agent library comparison 2026](https://jangwook.net/en/blog/en/python-ai-agent-library-comparison-2026/) diff --git a/pyproject.toml b/pyproject.toml index 2258eb6..88e9f0c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -255,6 +255,12 @@ ignore_missing_imports = true module = ["playwright", "playwright.*"] ignore_missing_imports = true +# agent-bench drives mini-SWE-agent through its tool-venv interpreter; minisweagent +# is intentionally not a dependency of this repo (external CLI, like aider). +[[tool.mypy.overrides]] +module = ["minisweagent", "minisweagent.*"] +ignore_missing_imports = true + [[tool.mypy.overrides]] module = [ "tree_sitter", diff --git a/tests/agent_bench/conftest.py b/tests/agent_bench/conftest.py new file mode 100644 index 0000000..46dc995 --- /dev/null +++ b/tests/agent_bench/conftest.py @@ -0,0 +1,17 @@ +"""Put the agent-bench tree on sys.path for its tests. + +agent-bench is run as scripts from its own directory (`python runner.py`), so +its modules (`adapters`, `scorers`, `store`) are top-level — not under the +`harness` package. These tests import them directly, so we prepend the +agent-bench dir here. Localized to this conftest so the rest of the suite is +unaffected. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_AGENT_BENCH = Path(__file__).resolve().parents[2] / "agent-bench" +if str(_AGENT_BENCH) not in sys.path: + sys.path.insert(0, str(_AGENT_BENCH)) diff --git a/tests/agent_bench/test_agent_bench.py b/tests/agent_bench/test_agent_bench.py new file mode 100644 index 0000000..2a431ba --- /dev/null +++ b/tests/agent_bench/test_agent_bench.py @@ -0,0 +1,135 @@ +"""Unit coverage for the agent-bench rig: pure scorers + the results store. + +These never touch gx10, node, or a browser — they pin the deterministic parts +(regex milestone probe, token/diff accounting, SQLite round-trip). The +external-tool scorers (`builds` via node, `runs_headless` via Playwright) are +exercised end-to-end by the runner, not here; `builds` gets a smoke test only +when node is present. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest +from adapters.base import RunArtifacts +from scorers.builds import BuildsScorer +from scorers.cost import CostScorer +from scorers.feature_checklist import FeatureChecklistScorer +from scorers.process import ProcessScorer +from store import ResultsStore, RunRecord + +# A workspace that hits a known subset of milestones (m1, m2, m5, m7) and +# deliberately misses others, so the count is an exact assertion. +_GAME_JS = """ +const ctx = canvas.getContext('2d'); +let player = { angle: 0 }; +addEventListener('keydown', e => {}); +function loop() { + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.translate(-camera.x, -camera.y); + drawTile(); + ctx.fillText('HUD', 10, 10); + requestAnimationFrame(loop); +} +requestAnimationFrame(loop); +""" +_INDEX_HTML = '' + + +def _workspace(tmp_path: Path) -> Path: + (tmp_path / "game.js").write_text(_GAME_JS) + (tmp_path / "index.html").write_text(_INDEX_HTML) + return tmp_path + + +def _artifacts(**kw: object) -> RunArtifacts: + base: dict[str, object] = {"exit_ok": True, "transcript": "", "diff": "", "duration_s": 1.0} + base.update(kw) + return RunArtifacts(**base) # type: ignore[arg-type] + + +def test_feature_checklist_counts_exact_milestones(tmp_path: Path) -> None: + scores = FeatureChecklistScorer().score(_workspace(tmp_path), _artifacts()) + assert scores["m1_loop"] is True # requestAnimationFrame + keydown + assert scores["m7_hud"] is True # fillText + setTransform(1,0,0,1 + assert scores["m8_police"] is False # no police/wanted signals + assert scores["milestones_total"] == 8 + # reached == number of true milestone flags + reached = sum(1 for k, v in scores.items() if k.startswith("m") and v is True) + assert scores["milestones_reached"] == reached + + +def test_cost_trusts_artifact_tokens() -> None: + scores = CostScorer().score( + Path("."), _artifacts(tokens_prompt=100, tokens_completion=50, duration_s=10.0) + ) + assert scores["tokens_total"] == 150 + assert scores["tokens_per_s"] == pytest.approx(5.0) # completion / duration + assert scores["cost_source"] == "artifacts" + + +def test_cost_falls_back_to_transcript_scrape() -> None: + transcript = 'usage {"prompt_tokens": 30, "completion_tokens": 20}' + scores = CostScorer().score(Path("."), _artifacts(transcript=transcript)) + assert (scores["tokens_prompt"], scores["tokens_completion"]) == (30, 20) + assert scores["cost_source"] == "transcript" + + +def test_process_counts_diff_and_transcript_signals() -> None: + diff = ( + "diff --git a/game.js b/game.js\n" + "new file mode 100644\n" + "--- /dev/null\n+++ b/game.js\n" + "+line one\n+line two\n" + ) + transcript = 'Traceback (most recent call last):\n{"type":"tool_use"}' + scores = ProcessScorer().score(Path("."), _artifacts(diff=diff, transcript=transcript, turns=7)) + assert scores["files_created"] == 1 + assert scores["lines_added"] == 2 + assert scores["lines_removed"] == 0 + assert scores["turns"] == 7 # surfaced from artifacts, not guessed + assert scores["tool_calls"] == 1 + assert scores["tracebacks"] == 1 + + +def test_process_reports_zero_turns_when_unknown() -> None: + scores = ProcessScorer().score(Path("."), _artifacts(turns=None)) + assert scores["turns"] == 0 # never guessed + + +def test_store_round_trips_scores_and_mirrors_jsonl(tmp_path: Path) -> None: + store = ResultsStore(tmp_path / "bench.sqlite3") + rec = RunRecord( + framework="aider", + version="0.86.2", + run_idx=0, + spec_sha="abc123", + model_id="qwen", + exit_ok=True, + duration_s=12.5, + scores={"builds": True, "milestones_reached": 6}, + manifest={"temperature": 0.0}, + ) + run_id = store.append(rec) + assert run_id == 1 + + rows = store.all_rows() + assert len(rows) == 1 + row = rows[0] + assert row["framework"] == "aider" + assert row["exit_ok"] == 1 # SQLite stores bool as int + assert row["scores"] == {"builds": True, "milestones_reached": 6} + assert row["manifest"] == {"temperature": 0.0} + + jsonl = (tmp_path / "bench.jsonl").read_text().strip().splitlines() + assert len(jsonl) == 1 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node not on PATH") +def test_builds_passes_on_valid_game(tmp_path: Path) -> None: + scores = BuildsScorer().score(_workspace(tmp_path), _artifacts()) + assert scores["builds"] is True + assert scores["js_syntax_failures"] == 0 + assert scores["index_html"] is True diff --git a/tests/test_driver_bd_warning_filter.py b/tests/test_driver_bd_warning_filter.py index fde1636..3bbb23f 100644 --- a/tests/test_driver_bd_warning_filter.py +++ b/tests/test_driver_bd_warning_filter.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import subprocess from pathlib import Path @@ -21,6 +22,17 @@ from harness.tools.base import ToolCall, ToolResult, ToolSpec +@pytest.fixture(autouse=True) +def _clean_git_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Strip ``GIT_*`` env before each test. The pre-push hook in pre-commit + exports ``GIT_DIR`` / ``GIT_WORK_TREE`` / ``GIT_INDEX_FILE``; without this, + ``workspace_is_gitignored``'s ``git check-ignore`` resolves against the repo + running the hook instead of the test's tmp dirs and the assertions flip. + Mirrors tests/test_git_tools.py::_clean_env.""" + for key in [k for k in os.environ if k.startswith("GIT_")]: + monkeypatch.delenv(key, raising=False) + + def _spec(name: str = "shell") -> ToolSpec: return ToolSpec(name=name, description="", parameters={}, tier="write")