System design
Why the arena is shaped this way, and what happens end to end during a run.
The problem
Most model comparisons answer someone else's question. MMLU, LMArena and a vendor's own chart tell you how a model performs on a task you did not choose, aggregated over criteria you did not pick. That is close to useless for a shipping decision, because your accuracy floor, your latency budget and your price ceiling are yours.
Agent Arena inverts that. It knows nothing about any task. You describe the task and what "best" means to you; it runs the comparison and defends the answer.
Three design commitments
Everything else follows from these.
1. A project is a folder, not code
A project is config.yaml plus tests.yaml. To evaluate something completely
different, copy the template and change the files. There is no second code path
and no plugin system.
This is the decision that makes the tool universal. The runner never learns what
a support ticket is, or what a currency amount is; it learns that there are
models, cases, a scorer name and a set of weights. Domain knowledge lives in the
project folder — in the test cases, in the scorer options, and optionally in a
scorers/*.py file the project owns.
The cost is real: a project cannot express a workflow the config schema has no
word for. The escape hatch is a run: target (scoring.md), which
puts an arbitrary Python callable on the leaderboard beside a plain model.
2. Never fabricate a number
A model with no sourced price gets no cost metric rather than an estimated
one. A sweep that cannot separate its top two says so instead of crowning a
winner. A model that fails a hard constraint is DISQUALIFIED with the reason
printed, not ranked fourth.
The last one matters most. A leaderboard that ranks an unusable model below three usable ones is lying by omission: it implies the ordering is meaningful all the way down. Disqualification changes the shape of the answer, which is what an accuracy floor actually means.
This commitment has a visible consequence worth understanding: because cost is nulled for the whole run unless every completed call was priced, adding one unpriced model removes the cost axis for all of them. That is deliberate — a composite that silently weights cost across a partial set would be a fabricated number.
3. The engine is stdlib-only
PyYAML is the single runtime dependency, and even that is optional — JSON config
works without it. Every provider SDK imports lazily, inside the method that needs
it, so pip install agent-arena pulls in nothing you did not ask for and the
whole test suite runs with no provider SDK present.
The reasons are practical: installation is instant, the tool works on a locked-down machine, the supply-chain surface is near zero, and there is no transitive CVE treadmill. See ../security/dependency-policy.md.
The run lifecycle
What happens between arena evaluate --project p and a row in SQLite.
1 load_config(path) core/config.py
└─ parse config.yaml → ProjectConfig, validate, resolve paths
2 load_test_cases(discover_test_files) core/testcase.py
└─ tests.yaml → [TestCase], apply tag/id/limit filters
3 ArenaRunner.preflight() core/runner.py
└─ for each model: can it run? missing key → SKIPPED, not failed
4 plan the matrix core/runner.py
└─ runnable models × cases × trials → N jobs
5 ThreadPoolExecutor(run.concurrency)
└─ each job: connector.generate() → score → CallResult
· retries via core/retry.py (terminal vs retryable, jitter, Retry-After)
· hooks.apply_post_process before grading
· store.record_result() as each completes
· progress callback emits call_complete
6 build_leaderboard() core/metrics.py
└─ aggregate raw metrics → normalize → weight → composite
→ apply constraints → DISQUALIFIED or ranked
7 store.finish_run() + report core/store.py, core/report.py
└─ rankings persisted; markdown/JSON written to results/
Following one call
arena evaluate --project projects/support_triage with trials: 3:
cli.cmd_evaluatebuilds an overrides dict from the flags and callsArenaRunner.from_project, which callsload_config.ProjectConfig.from_dictparses each entry undermodels:andtargets:into aModelSpecviaModelSpec.parse, raisingConfigErrorwith the position on a bad field.preflight()asksconnectors.registry.requires_api_keywhat credential each model needs. A model whose key is absent is recorded inskipped— the run continues without it. This is why a missingOPENAI_API_KEYdoes not fail a run that also contains mock models.store.start_runinserts a row intorunswith the config snapshot, the arena version and the git SHA, and returns a run id.- For each
(spec, case, trial),_executebuilds aGenerationRequest, calls_generate_with_retries, and hands the text to the scorer resolved fromcase.eval_type. - Cost comes from
generation.cost_usdwhen the connector reported its own spend — a pipeline target knows its real end-to-end cost and the catalog cannot — otherwise fromprice_book.get(model).cost_usd(...). build_leaderboardaggregates everyCallResultby model key, normalizes each metric, applies the weights, and gates onconstraints:.finish_runwrites the rankings, andcore/report.pyrenders markdown and JSON intoresults/<run_id>/.
Extension points
| You want to | Do this | Where |
|---|---|---|
| Grade something the builtins cannot | Drop a Scorer subclass in the project's scorers/ |
scoring.md |
| Touch outputs before grading | hooks.py in the project folder |
scoring.md |
| Compare a whole pipeline, not a model | A run: target pointing at a callable |
../guides/comparing-pipelines.md |
| Add a provider the arena cannot reach | provider: litellm, or a new Connector |
connectors.md |
| Price a model the catalog omits | A card: override or a project pricing file |
connectors.md |
| Weight a number a scorer emits | Name it in metrics.weights |
metrics.md |
Tensions the code actually resolves
Stdlib-only versus convenience. The web UI would be shorter with a
framework, and the .env parser and the OS-keyring integration would be a
one-line dependency each. They are hand-written instead. The trade is worth it
only because the surface is small; a project that needed HTTP/2 or async
streaming would have to revisit it honestly rather than adding "just one".
Honesty versus a clean single number. A composite score is what makes a leaderboard sortable, and it is also the thing most likely to mislead. The resolution guard, the disqualification path and the null-cost rule all exist to keep the composite from claiming more than the evidence supports.
Config-driven versus a plugin system. A folder is easier to write, review
and share than a plugin. The limit is that the schema has to name a capability
before a project can use it, which is why providers: and budgets: were added
to the schema rather than exposed as hooks.