The Universal Arena
The full reference: projects, scorers, metrics, constraints, targets, the UI.
A config-driven harness for answering one question for your project: which model should we use, and what does that choice cost us?
The engine knows nothing about any project. A project is a folder containing a config file and some test cases; point the arena at it and it runs every model against every case, scores the outputs with pluggable scorers, and ranks the field by a weighted composite of the criteria you said mattered.
pip install -e . # or: pip install agent-arena
arena evaluate --project projects/support_triage # runs offline, no API key
The five-minute version
arena init projects/my_project # scaffold
# edit config.yaml (models, weights, constraints) and tests.yaml (your cases)
arena validate --project projects/my_project # config, tests, credentials
arena evaluate --project projects/my_project --dry-run # plan + cost estimate
arena evaluate --project projects/my_project
You get a console leaderboard, a Markdown report, a JSON dump, and a SQLite database you can query across runs.
To evaluate a completely different project, copy the template, change the test cases and the weights, and run the same command. There is no second code path.
Anatomy of a project
projects/my_project/
config.yaml models, weights, constraints, budgets ← the whole contract
tests.yaml your test cases (or tests.jsonl, or a tests/ folder)
scorers/ optional — grading logic only you can write
hooks.py optional — touch outputs before they are graded
results/ written by the arena: reports + arena.sqlite
Discovery is by convention: any test*.{yaml,yml,json,jsonl} beside the config
plus everything under tests/ is picked up, and a scorers/ folder is loaded
automatically. Override either with tests.paths / scorers.paths.
Two complete examples ship with the repo, deliberately shaped differently:
| Project | Shape | Shows off |
|---|---|---|
projects/support_triage |
High-volume one-word classification | Cost/latency-heavy weights, an accuracy floor, per-tag breakdown |
projects/doc_extraction |
Structured extraction from documents | json_match, a project-local scorer, a post-process hook, deployment + privacy gates |
Both run entirely offline against simulated models, so you can see a real leaderboard before spending anything.
Test-case schema
Mandatory: input. Almost always wanted: reference and eval_type.
- id: refund_intent # stable name used in reports and the DB
input: "My order never arrived, I want my money back."
reference: refund # a list means "any of these is correct"
eval_type: classification # defaults to scorers.default
context: "Reply with one word." # per-case system prompt
tags: [billing, easy] # filter runs; break the report down by tag
weight: 2 # counts double toward accuracy
max_tokens: 8 # per-case generation overrides
temperature: 0
params: {labels: [refund, billing, spam]} # passed to the scorer
inputmay be a string or a[{role, content}]message list.- Common aliases are accepted (
prompt/questionforinput,expected/answer/goldforreference,systemforcontext). - Any field the schema does not recognise is kept in
metadatarather than dropped, so your own bookkeeping survives the round trip. - A file may be a bare list, or a mapping with
defaults:(applied to every case in that file) andtests:. enabled: falseorskip: trueparks a case without deleting it.
Scorers
eval_type selects the scorer. Built in:
eval_type |
Grades by |
|---|---|
exact_match |
Normalised string equality (case/punctuation/whitespace insensitive by default) |
contains |
Substring containment; mode: any or all, with partial credit |
regex |
Regex search, optionally comparing a capture group |
classification |
Which of params.labels the answer names |
numeric |
The number in the answer, within abs_tol/rel_tol |
json_match |
JSON-parses the answer; mode: subset or exact |
semantic |
Similarity above a threshold — lexical by default, or your embedding function |
llm_judge |
A judge model's 0–1 verdict against a rubric |
code_exec |
Runs the generated code against your assertions in a subprocess |
manual |
Records the output unscored, for human review |
Anything else goes in the project's scorers/ folder. Three registration
styles, all equivalent:
from agent_arena.scorers import Scorer, ScoreResult, scorer
class ToneScorer(Scorer): # 1) a Scorer subclass
name = "tone"
def score(self, output, reference, context):
polite = "please" in output.lower()
return ScoreResult(score=1.0 if polite else 0.0, passed=polite,
metrics={"politeness": 1.0 if polite else 0.0})
@scorer("has_citation", requires_reference=False) # 2) a decorated function
def has_citation(output, reference, context):
return ScoreResult(score=1.0 if "[" in output else 0.0)
SCORERS = {"tone_v2": ToneScorer} # 3) an explicit mapping
A ScoreResult may carry metrics={...}; those become project-specific
metrics you can weight in the composite by name, exactly like accuracy.
Two notes on the ones that surprise people:
semanticis lexical by default. Token-set cosine over content words — no model, no network, and honest about being shallow. Pointparams.embeddingat a(text) -> list[float]function for real embeddings.code_execexecutes model output in a subprocess with a timeout and a scratch directory. That is not a sandbox. Run untrusted outputs in a container.
Models and providers
models:
- claude-opus-5 # provider inferred from the id
- gpt-4o
- gemini-2.5-flash
- key: opus_low_effort # long form: stable key + params
model: claude-opus-5
label: "Opus 5 (low effort)"
params: {output_config: {effort: low}}
- key: local_llama # anything LiteLLM can reach
model: ollama/llama3.3
provider: litellm
api_base: http://localhost:11434
card: {input_usd_per_mtok: 0, output_usd_per_mtok: 0, context_tokens: 131072}
Provider resolution: an explicit provider: wins; otherwise the model id's
prefix decides (claude-* → anthropic, gpt-*/o3-* → openai, gemini-* →
gemini, mock:* → the offline mock, llama*/qwen*/mistral*/ollama/* →
local); a bare api_base: also means local; any other vendor/model id goes
to LiteLLM.
Provider SDKs are imported lazily, so the arena installs and its whole test suite runs with none of them present:
pip install 'agent-arena[anthropic]' # or [openai], [gemini], [litellm], [all]
Local models
Models on your own machine need no SDK at all — the local connector is
stdlib urllib speaking the OpenAI-compatible POST /v1/chat/completions that
Ollama, LM Studio, llama.cpp and vLLM all expose:
models:
- llama3.2 # Ollama on :11434, inferred
- {key: qwen, model: ollama/qwen2.5-coder:7b}
- {key: studio, model: my-finetune, api_base: http://localhost:1234/v1}
They are priced at $0.00 per call — true, for marginal API cost — and satisfy
on_prem, training_opt_out and zero_data_retention privacy gates outright,
which is usually the whole reason to consider them. A server that is not
running, or a model that has not been pulled, is skipped with a reason
before the run starts rather than failing every test case:
! llama32 llama3.2 model 'llama3.2' is not served by http://localhost:11434/v1
(available: qwen2.5:7b) — try `ollama pull llama3.2`
See demo.md for a full local-model walkthrough.
A model whose API key is missing is skipped with a note, not failed — the rest of the run still produces an answer.
The mock: provider is a deterministic offline model. It is what makes the
examples runnable without keys, and it is worth keeping in a real project as a
synthetic baseline that proves your scorers and weights behave before you
spend anything:
- key: sim_small
model: mock:small
params: {mode: flaky, accuracy: 78, latency_ms: 190}
Modes: oracle (always right), flaky (right accuracy% of the time,
deterministically per test), fixed, echo, empty.
Metrics, weights and the composite
Built-in metrics: accuracy, pass_rate, reliability, cost, latency,
latency_p95, tokens — plus any custom metric your scorers or hooks emit.
metrics:
weights:
accuracy: 0.55
cost: 0.25
latency: 0.20
cost:
budget_usd_per_1k_calls: 2.0
latency:
target_ms: 800
tie_breaker: accuracy
Weights are normalised, so {5, 3, 2} and {0.5, 0.3, 0.2} mean the same
thing. Each metric is scaled to [0, 1] where 1 is best, then combined.
Two normalisation modes:
| Mode | Behaviour | Use when |
|---|---|---|
minmax (default) |
Best in the field scores 1.0, worst 0.0 | You only want a ranking |
target / budget |
Absolute. Lower-is-better: the target is a ceiling and the score is the headroom under it (1 - value/target) — free scores 1.0, half the budget 0.5, at or over budget 0.0. Higher-is-better: the target is a goal and the score is value/target, capped at 1.0. |
You have a real number in mind |
Giving a budget/target switches that metric to absolute mode
automatically. It matters: under minmax, "cheapest" wins even if every
option is unaffordable; under budget, a model is judged against your actual
constraint.
Units, so the numbers mean what you think: cost is USD per 1,000 calls,
latency is mean milliseconds, accuracy is 0–1.
Hard constraints
Some requirements are not trade-offs. A model failing one is disqualified, not ranked lower — and disqualified models are excluded from the min-max ranges, so an unusable outlier cannot distort how the real candidates compare.
constraints:
min_accuracy: 0.70
max_error_rate: 0.05
max_latency_p95_ms: 4000
max_cost_per_1k_calls_usd: 10
deployment:
required_features: [structured_outputs, function_calling]
min_context_tokens: 200000
privacy:
required: [dpa, training_opt_out]
Privacy is treated carefully on purpose. Whether a DPA is in place, whether training opt-out applies, whether zero-data-retention is enabled — these are properties of your contract and platform, not of the model, so the shipped catalog declares almost none of them. A model with no privacy facts on its card fails a privacy gate rather than passing silently, which is the safe direction for a compliance requirement. Declare what you have verified:
pricing:
models:
claude-opus-5:
privacy: {dpa: true, training_opt_out: true}
Cost and the model catalog
agent_arena/connectors/model_cards.json ships prices, context windows and
capabilities for the models the maintainers can source. A model that is not
in the catalog gets no cost metric rather than a guessed one — its cost
weight is redistributed across its other metrics and the report says so.
Scoring an unknown price as zero would make an unpriced model look free;
scoring it as worst would punish it for a gap in our data.
Supply your own numbers — negotiated rates, a provider we do not ship, the current introductory pricing — and they layer over the catalog:
pricing:
path: pricing.yaml # a file
models: # or inline
claude-sonnet-5: {input_usd_per_mtok: 2.0, output_usd_per_mtok: 10.0}
Narrow ids inherit from broader ones, so claude-haiku-4-5-20251001 picks up
claude-haiku-4-5, and mock:frontier inherits the mock card while
overriding its price.
arena models --project projects/my_project # what is priced, what is not
Always verify against your provider's current price list before making a decision on cost.
Hooks
hooks:
pre_request: "hooks.py:add_context"
post_process: "hooks.py:strip_pii"
on_result: "hooks.py:notify"
post_process(output, test_case, context) runs before scoring. Return a
string to replace the output, or a mapping to decide the verdict outright:
def strip_pii(output, test_case, context):
return EMAIL_RE.sub("[email]", output)
def check_schema(output, test_case, context):
ok = validate(output)
return {"output": output, "passed": ok, "score": 1.0 if ok else 0.0,
"metrics": {"schema_errors": 0 if ok else 1}}
pre_request(request, test_case, model_key) can inject retrieved context or
few-shot examples before the call.
Results database
Every call lands in SQLite (results/arena.sqlite by default) with the run
that produced it, so you can answer questions no single report can:
arena history --project projects/my_project # runs over time
arena history --project projects/my_project --model claude-opus-5 # regressions
arena history --project projects/my_project --flaky # unstable tests
arena report --project projects/my_project # re-read a run
Tables: runs (one row per sweep, with the config snapshot and git sha),
results (one row per call), rankings (the leaderboard as computed). Point
several projects at one file with output.db if you would rather query across
them.
CLI and Python API
arena evaluate --project P [--models A B] [--trials N] [--tags t1 t2]
[--ids case1] [--limit N] [--concurrency N]
[--dry-run] [--json] [--fail-under 0.8]
arena validate --project P # config, tests, scorers, credentials
arena tests --project P # what will run
arena models --project P # prices, context windows, features
arena scorers --project P # available eval types, built-in and local
arena report --project P [--run-id ID]
arena history --project P [--model K] [--flaky]
arena init PATH
arena ui [--projects-dir DIR] [--port 8420] [--host 127.0.0.1] [--no-browser]
--fail-under exits non-zero when the best composite falls below a threshold,
which is the hook for running the arena in CI as a model-regression gate.
from agent_arena import run
result = run("projects/my_project", trials=3, tags=["smoke"])
print(result.winner.key, result.winner.composite)
for entry in result.leaderboard.ranked:
print(entry.key, entry.raw("accuracy"), entry.raw("cost"))
Lower-level pieces (ArenaRunner, ProjectConfig, ScorerRegistry,
PriceBook, ResultStore) are all importable if you want to embed the arena
in something larger.
Pipeline targets — evaluating a system
The arena's default unit is one prompt → one completion. Most shipped systems are not that: they retrieve, plan, call tools, critique, then synthesise. A target puts one of those on the same leaderboard as a plain model.
targets:
- key: rag_v1
run: pipelines/rag.py:answer
- key: rag_v2_with_critic
run: pipelines/rag.py:answer
params: {critic: true} # reaches the callable as `params`
models:
- key: single_call_baseline # the control, in the same run
model: claude-sonnet-5
targets: and models: are one list under two names — use whichever reads
better; a project may use both. Everything downstream is unchanged: the same
scorers grade a target, the same weights rank it, the same constraints can
disqualify it.
The callable contract
def answer(prompt):
return "billing"
That is the whole minimum. The prompt arrives positionally; anything else is optional and you receive only what you declare, so a target never has to accept arguments it does not care about:
| Keyword | What it carries |
|---|---|
messages |
The case's input as a role/content list |
system |
The project's system prompt |
test_id, trial |
Which case, which repeat |
tags |
The case's tags |
reference |
The expected answer — for logging, not for cheating |
params |
The entry's params: merged with the request's |
def answer(prompt, *, tags, test_id): # gets exactly these two
...
def answer(prompt, **context): # gets all of them
...
Reporting what it cost
Return a string, or a mapping when the pipeline knows more than the arena can see from outside:
def answer(prompt):
return {
"output": "...", # or "text" / "answer"
"input_tokens": 4210, # totalled across every internal call
"output_tokens": 380,
"cost_usd": 0.0092, # real end-to-end spend for this one case
"latency_ms": 1840.0,
"metrics": {"agent_calls": 3, "retrieved_docs": 8},
}
Two things follow, both deliberate:
- A reported
cost_usdis trusted over the price catalog. The catalog cannot price a pipeline; the pipeline can. Report nothing and cost is left unmeasured rather than guessed — and its weight is redistributed, with a note saying so. metricsare weightable by name, exactly like a scorer's custom metrics. Putagent_calls: 0.1inmetrics.weightsand the leaderboard will start preferring architectures that use fewer agents.
Failure handling
A target that raises fails that call, not the run — the same as a provider
error, retried per run.retries and then recorded. A target whose file or
function is missing is caught at load time by arena validate, and its
import is checked in preflight, so a typo costs nothing rather than failing on
the first case after other models have already been paid for.
The worked example
projects/pipeline_demo/ compares three architectures for one support-triage
task, fully offline:
# model composite accuracy cost latency status
1 single_agent 0.907 100.0% $0.90 220ms ranked
- peer_to_peer — 27.3% $1.80 440ms DISQUALIFIED
- supervisor_worker — 63.6% $2.70 660ms DISQUALIFIED
✗ peer_to_peer: accuracy 27.3% below the required 80.0%
✗ supervisor_worker: accuracy 63.6% below the required 80.0%
The task hides one fact — whether the account is on credit hold — that the first
stage sees and the last stage needs. peer_to_peer hands off a fixed-format
string with no field for it, so it loses it every time. supervisor_worker
hands off a free-text summary, which keeps the fact when it is prominent and
drops it when it is buried late in a long ticket — the intermittent failure that
reads like a prompt problem for weeks before anyone finds the structural cause.
This is the multi-agent study's finding, made rankable: the study proved a coordination failure exists and is separable from a capability failure; targets let you price it and gate on it. See the study for the original experiment.
The browser UI
arena ui # http://localhost:8420
arena ui --projects-dir ~/evals --port 8421
Same engine, no terminal. It exists because the person who knows the accuracy floor and the budget is usually not the person who writes YAML, and a tool whose answer only reaches developers is answering the wrong audience.
What it does
| Screen | What it is for |
|---|---|
| Your evaluations | Every project folder under --projects-dir, with its last winner |
| New evaluation | A five-step wizard that writes config.yaml + tests.yaml |
| Evaluation | What will run, what matters, the examples, and the Run button |
| Running | Live progress plus a feed of what each model actually answered |
| Result | The verdict in sentences, the full table, and what-if sliders |
| Past runs | Every stored run and an accuracy trend per model |
The wizard
Step one asks what job the AI is doing, in seven plain descriptions rather than by scorer name:
| The question it asks | What it configures |
|---|---|
| Sort things into categories | classification, plus the label list |
| Pull specific details out of text | json_match |
| Answer questions | semantic |
| Find a specific fact | contains |
| Calculate a number | numeric |
| Write or summarise | llm_judge (warns that the judge costs money) |
| Match an exact answer | exact_match |
Each preset also carries a starting system prompt, a sensible max_tokens, and
default weights — a classification job starts cost-sensitive, an extraction job
starts accuracy-dominated. The output is an ordinary project folder: the CLI can
run it, and a developer can edit it by hand afterwards.
What-if re-scoring
The one thing the UI can do that the CLI cannot. Move the priority sliders on a
finished run and the leaderboard is rebuilt from the answers already stored
— no model calls, no spend. It rehydrates CallResult rows out of SQLite and
calls the same build_leaderboard, so a what-if and a fresh run with those
weights cannot disagree.
This is usually where the real conversation happens: "we said cost mattered a quarter — if it only mattered a twentieth, would we still pick this?"
Plain-English layer
agent_arena/web/language.py holds every translation, as pure functions over
already-computed results. It invents no judgement of its own — the ranking, the
disqualifications and the caveats all come from core/metrics.py; this module
only re-words them.
0.833 → "83 out of 100"
$0.06/1k calls → "6¢ per 1,000 uses"
190 ms → "190 milliseconds (instant)"
accuracy 50.0% below 70.0% → "It only gets 50 out of 100 right,
which is below the floor you set."
The "too close to call" rule is enforced in the UI too: inside 0.02 composite the verdict drops to low confidence and says so, rather than crowning a winner a twelve-case sweep cannot support.
Operational notes
- Stdlib only.
http.serverplus vanilla JS. No Flask, no npm, no CDN. It works on a locked-down laptop with no internet. - Localhost by default, and no login. It reads and writes your project files and can spend against your API keys. Binding it to a non-loopback address puts that on your network unauthenticated — the command prints a warning if you do.
- Guards: project names are restricted to a safe character set and re-checked
against the projects directory before any write; the
Hostheader is allow-listed against DNS rebinding; static serving cannot walk out ofweb/static/; a second click on Run returns the in-flight job instead of starting a second paid run. - Editing limits. The visual editors refuse to rewrite a project that spreads its test cases across several files or uses custom hooks, rather than clobbering something it does not fully model.
Design decisions worth knowing
These change what the numbers mean, so they are stated rather than buried.
Accuracy is measured over completed calls only. Failed calls are counted
separately as reliability. Otherwise a model that errors on 90% of calls and
answers the remaining 10% correctly would report 100% accuracy. If you do not
weight reliability and errors occurred, the report tells you.
An unmeasurable metric redistributes its weight instead of scoring zero — see Cost.
Disqualified models are excluded from min-max ranges. A model that fails a hard constraint should not change how the qualifying models compare to each other.
A metric everyone ties on scores 1.0 for everyone. It carries no information, so it should not swing the composite in either direction.
Small margins are called out. When the top two are within 0.02, the report says so and suggests more trials — a leaderboard implies more precision than a 12-case sweep can support.
The mock provider is deterministic per (model, test, trial), so demo
runs, tests, and CI are reproducible.
Relationship to the multi-agent handoff study
This repo also contains the original Agent Arena: a study of how multi-agent
architectures lose information at coordination boundaries, now at
studies/multi_agent_handoff/. The two are
independent and answer different questions:
| Question | Varies | Holds fixed | |
|---|---|---|---|
| Multi-agent study | Does decomposing a task across agents introduce failures? | The architecture | The model |
| Universal arena | Which model should this project use? | The model | The task |
They share a philosophy — structured evidence over vibes — but no code. The universal arena is additive; nothing in the original harness changed. The study is complete and frozen, and is not part of the installable package — it keeps its own dependencies and its own README.
Source: docs/UNIVERSAL_ARENA.md