Skip to content

agent-bench: framework benchmark rig + aider & mini-SWE-agent comparands - #2

Open
PositiveControl wants to merge 11 commits into
mainfrom
worktree-agent-framework-bench
Open

agent-bench: framework benchmark rig + aider & mini-SWE-agent comparands#2
PositiveControl wants to merge 11 commits into
mainfrom
worktree-agent-framework-bench

Conversation

@PositiveControl

Copy link
Copy Markdown
Owner

What

Adds agent-bench — a local-first rig that gives each agent framework the same autonomous build task (a top-down HTML5-canvas web game) against one shared gx10 vLLM endpoint, then scores the result. Framework-agnostic runner + pluggable adapters + pluggable scorers + append-first results store.

Comparands (live, validated end-to-end against gx10 Qwen3-Coder-30B-A3B-Instruct-FP8)

framework turns wall-clock milestones runs_headless
aider 1-shot ~82 s 6/8 ✗ — build hangs on load (unbounded init loop)
mini-swe-agent 17 ~926 s 7/8 ✅ — game runs (362 rAF ticks, 0 errors)

The aider row is a true-positive failure the rig caught (unbounded while(!validPosition) spawn loop). opencode + goose adapters exist but aren't runnable in this env yet (opencode 1.x hangs on run-init; goose CLI absent).

Scorers

builds (node --check) · runs_headless (Playwright chromium) · feature_checklist (M1–M8 regex) · cost (tokens/wall-clock/$) · process (diff LOC / turns / tool calls / tracebacks).

Notable engineering

  • mini-SWE-agent is driven through its Python API via _mini_driver.py (its mini CLI is interactive-only — crashes on a non-tty), run by the tool venv's interpreter. minisweagent stays out of repo deps (external CLI, like aider).
  • runs_headless hardened: a build that hangs the main thread records cleanly as runs_headless: false, reason: "load timeout …" instead of wedging the run.

Standards / gates

  • agent-bench is strict-mypy-clean; mypy hook extended to src agent-bench.
  • New unit tests (tests/agent_bench/) cover the deterministic surface (scorers + store round-trip); external-tool scorers covered by the runner e2e.
  • Also fixes a pre-existing test-isolation flake unrelated to agent-bench: test_driver_bd_warning_filter.py now strips inherited GIT_* env (the pre-push hook exports GIT_DIR/GIT_WORK_TREE), mirroring test_git_tools.py.
  • Green: ruff · ruff format · mypy (src + agent-bench) · pytest (full suite, under the pre-push hook env).

🤖 Generated with Claude Code

PositiveControl and others added 11 commits June 15, 2026 11:46
Plan to benchmark 13 OSS agent frameworks against this harness by giving
each the same autonomous build task (a GTA-style game) over one shared
gx10 vLLM /v1 endpoint. Two tiers: turnkey coding agents (apples-to-apples)
and build-your-own frameworks (scaffold-bound, flagged).

Harness invariants carried over: adapter boundary per framework, append-first
attributed results store, metrics-as-config. Repeatable (pinned + N-run
distributions), recordable (full artifact capture + replay), adjustable
(scorer plugins listed in metrics.yaml).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
End-to-end rig wired against the plan: framework-agnostic matrix runner,
append-first SQLite+JSONL results store, metrics-as-config (metrics.yaml ->
scorer registry), gx10 /v1/models health-gate with pinned-model check.

- adapters/: Adapter protocol + aider adapter (drives `aider` non-interactively
  at gx10 via OPENAI_API_BASE; snapshots git diff as the artifact)
- scorers/: builds (py_compile), runs_headless (SDL dummy boot window),
  feature_checklist (static M1..M7 probe)
- spec/gta-spec.md: the one task input, 7 graded milestones, pygame stack
- bench.yaml pinned to gx10 model Qwen/Qwen3-Coder-30B-A3B-Instruct
- store.py append-first, schemaless per-run scores blob; report.py discovers
  metric keys from rows

Smoke-tested: scorers -> store -> report path runs; ruff clean. Remaining
adapters (OpenHands/OpenCode/Goose/mini-SWE) and cost/process scorers are TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds two more turnkey-agent adapters and the first cost metric.

- adapters/_proc.py: extracted shared subprocess + git helpers (run, init_repo,
  snapshot_diff, transcript_of); aider refactored onto it (no behavior change)
- adapters/opencode.py: `opencode run --model gx10/<model> --format json`;
  gx10 wired as a custom @ai-sdk/openai-compatible provider written to a
  per-workspace opencode.json
- adapters/goose.py: `goose run --no-session --quiet -t <spec>`; gx10 base_url
  decomposed into OPENAI_HOST + OPENAI_BASE_PATH; keyring disabled, key from env
- scorers/cost.py: tokens_{prompt,completion,total}, wall_clock_s, tokens_per_s,
  cost_usd (+cost_source). Tokens from artifact fields, else transcript regex
  fallback (JSON usage + aider k/M lines). $ priced via BENCH_PRICE_*_PER_MTOK
  env (default $0 for self-hosted gx10)
- runner registries + bench.yaml + metrics.yaml updated

Smoke-tested: registries load (3 adapters, 4 scorers), goose endpoint split and
opencode provider-config shape verified; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
opencode adapter now parses the JSONL event stream and populates
RunArtifacts.tokens_prompt / tokens_completion / turns directly, so the cost
scorer reports cost_source="artifacts" (exact) instead of falling back to the
transcript regex.

- _parse_usage sums step_finish events: part.tokens.input -> prompt,
  output+reasoning -> completion; cache read/write + opencode's own part.cost
  captured in extra. turns = step_finish count.
- Per-step semantics (AI-SDK convention; undocumented by opencode) — per-step
  breakdown stashed in extra.per_step_tokens for audit; one-line switch to max
  if a release turns out to report cumulative totals.
- Tolerant reader: JSONL or single top-level array, skips non-JSON lines.

Verified: multi-event sample -> prompt 2000 / completion 550 / turns 2;
cost scorer flips to cost_source="artifacts"; array + empty + garbage inputs
handled; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fifth scorer: how the agent worked, not whether it passed.

- Deterministic from the unified diff: files_touched, files_created,
  lines_added/removed, diff_loc.
- Soft signals from artifacts, reported as 0 (never guessed) when the framework
  doesn't surface them: turns (from artifacts.turns — exact for opencode),
  tool_calls (counts opencode `"type":"tool_use"` JSONL markers), tracebacks.
- Registered in runner.SCORERS + metrics.yaml.

Verified: sample 2-file diff -> files 2 / created 1 / +4 -1 / loc 5; turns 2,
tool_calls 2, tracebacks 1; 5 scorers load; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Benchmark product is now a top-down GTA2-style web game (index.html + game.js,
vanilla JS, canvas 2D) instead of pygame. Validated end-to-end against live gx10
(gx10-5fb9, vLLM 0.21.0, Qwen/Qwen3-Coder-30B-A3B-Instruct) via the aider adapter.

Spec + scorers reworked for the web stack:
- spec/gta-spec.md: self-contained HTML/CSS/JS GTA2 spec, milestones M1..M8, hard
  tech constraints (one requestAnimationFrame loop, no modules/classes, canvas-2D
  only, zero console errors).
- builds: node --check over every .js + index.html has <canvas>+<script>.
- runs_headless: Playwright headless chromium — loads index.html, asserts
  loads_clean / loop_alive (rAF ticked) / renders (canvas non-blank). 10s goto
  timeout fast-fails a page that blocks the main thread (reports the init-loop case).
- feature_checklist: static regex probe of M1..M8 over game.js + index.html.
- _proc.snapshot_diff: exclude framework cruft (.aider*, injected opencode.json,
  .gitignore, node_modules, lockfiles) so diff LOC measures the game, not scratch.
- bench.yaml -> live gx10 endpoint; AIDER_VERSION 0.86.2.

Each run builds from scratch in its own wiped results/<fw>/<run>/workspace; the
reference scratch/workspace is never touched (read-only oracle: scores 8/8 + runs).

Live aider run: builds true, 6/8 milestones, 2 files / 436 LOC (cruft excluded).
runs_headless correctly False — the model emitted an unbounded init loop
(game.js while(!validPosition)) that hangs the browser. Exactly the runtime
signal static probes miss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RunsHeadlessScorer._find_entry returned whatever path it was handed.
Path.as_uri() rejects relative paths, so a relative workspace raised
"relative path can't be expressed as a file URI" — caught by the broad
except and reported as a bogus "playwright error", masking the real
load-timeout verdict. Resolve the entry to absolute so a hung page
(e.g. an unbounded init loop) records cleanly as runs_headless:false
with reason "load timeout (page blocks main thread)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gx10 now serves the FP8 quant; repin the model id so the health gate
passes (was aborting on Qwen3-Coder-30B-A3B-Instruct vs ...-FP8).

Validated the full loop end-to-end: one live aider run -> all five
scorers -> report.py renders a clean single-schema distribution
(39.2 tok/s on FP8, 6/8 milestones, runs_headless correctly false on
aider's unbounded init-loop hang). Stale mixed-schema rows from the
pygame->web pivot were cleared from the store first.

README status rewritten to reflect: rig proven on one framework;
remaining work is an N-run matrix + a second comparand (mini-SWE next).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second comparand. mini-SWE-agent's `mini` CLI is interactive-only — it
wires asyncio stdin readers and crashes on a non-tty (OSError [Errno 22]
in _add_reader), even with --yolo/--agent-class default. So drive its
documented Python API (DefaultAgent + LocalEnvironment + LitellmModel,
per the project's own run/hello_world.py) through a thin driver run by
the tool venv's interpreter:

- adapters/_mini_driver.py: builds the agent rooted at the workspace,
  runs the task to a terminal state (Submitted / LimitsExceeded /
  TimeExceeded), emits a metrics JSON (turns=n_calls, summed
  prompt/completion tokens, cost, exit_status) + a readable transcript.
- adapters/mini_swe.py: locates the tool venv python via the `mini`
  shim's shebang (stable, no uv-cache hash; falls back to `uv tool run`),
  shells the driver through the shared _proc helpers, and populates
  RunArtifacts.tokens_*/turns so the cost + process scorers read
  source="artifacts" rather than scraping the transcript.

minisweagent stays out of the bench's deps — it's an external CLI on
PATH (uv tool install mini-swe-agent), same convention as aider.

Validated end-to-end against gx10 FP8: builds + runs_headless both pass,
7/8 milestones, 17 turns, 140k tokens, ~926s. First real cross-framework
contrast vs aider (1-shot, 82s, 6/8, runs_headless false on its init-loop
hang) — exactly the comparison the bench exists to produce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prep for merging agent-bench into main. The per-commit hooks scoped mypy
to src/, so the bench tree was untyped and untested. Close both gaps:

- Type-clean agent-bench under strict mypy and extend the mypy hook to
  `src agent-bench`. Fixes: typed dict/list generics in the mini driver
  and metrics reader, an explicit numeric coercion in report.py (no more
  float(object)), a robust isinstance-guarded token sum, and a typed
  _opt_int accessor so RunArtifacts fields stay int | None. Drops a stale
  type: ignore.
- Register a minisweagent missing-imports override (it's an external CLI,
  never a repo dependency — same rationale as the playwright override).
- Add tests/agent_bench/: unit coverage for the deterministic surface —
  feature_checklist milestone probe, cost token accounting (artifacts +
  transcript-scrape paths), process diff/transcript counters, and the
  SQLite store round-trip. builds gets a node-guarded smoke test. The
  external-tool scorers (node, Playwright, gx10) stay covered by the
  runner end-to-end. A local conftest puts the agent-bench dir on path.

Gates green: ruff, ruff format, mypy (src + agent-bench, 215 files),
pytest (3905 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-existing flake, surfaced when running the full suite under the
pre-push hook: pre-commit's pre-push exports GIT_DIR / GIT_WORK_TREE /
GIT_INDEX_FILE, so workspace_is_gitignored's `git check-ignore`
resolved against the repo running the hook instead of each test's
tmp_path, flipping the gitignored/tracked assertions. test_git_tools.py
and test_driver_cli.py already guard against this; the bd_warning_filter
tests never did.

Add an autouse fixture that strips GIT_* per test (monkeypatch.delenv),
mirroring tests/test_git_tools.py::_clean_env. Verified: the file's 13
tests pass both bare and under a simulated GIT_DIR/GIT_WORK_TREE env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant