AgentArena

Technische Architektur-Dokumentation — Stand Maerz 2026

AgentArena ist eine FastAPI + Docker + SQLite Benchmarking-Plattform fuer AI Coding Agents. Das Alleinstellungsmerkmal: explizites Kosten-Tracking ($/Task) und ein zweicontainer-isoliertes Bewertungssystem, das Manipulation durch den Agenten verhindert.

FastAPI Docker SQLite htmx + SSE 2-Container Isolation

1. Pipeline-Flow

Jeder Benchmark-Run durchlaeuft diese 8 Phasen sequenziell. Der Agent-Container und der Judge-Container existieren nie gleichzeitig — der Agent-Container wird zerstoert, bevor der Judge-Container erzeugt wird.

📄 API Request POST /runs
→
🏃 Runner runner.py
→
🔒 Agent Sandbox Docker container
→
🔗 Adapter Host subprocess
→
📄 git diff arena-checkpoint
→
⚠ Destroy Agent container rm -f
→
🔎 Judge Sandbox frischer Clone
→
🏆 Score 4 Dimensionen
→
🏅 Leaderboard SQLite + htmx

Was passiert in jeder Phase?

Phase Komponente Input Output Fehlerbehandlung
1. API Request main.py task_id, agent_id, k Neuer Run in DB mit Status running HTTP 422 bei fehlenden Parametern
2. Sandbox erstellen sandbox.py Task (repo_url, repo_ref) Laufender Docker-Container mit geklontem Repo + arena-checkpoint Git-Tag RuntimeError wenn Docker oder git clone fehlschlaegt
3. Adapter ausfuehren adapters/ RunContext (task_prompt, success_cmd, time_limit_s) RunResult (diff_text, tokens, cost_usd, trajectory) subprocess.TimeoutExpired → status=timeout
4. Diff sammeln runner.py Host-Tempdir oder Sandbox Unified diff (arena-checkpoint bis HEAD) Leerer Diff = kein Code generiert
5. Agent zerstoeren sandbox.destroy() Container-ID — (Container weg) Best-effort, ignoriert Fehler
6. Judge Sandbox sandbox.create_judge_sandbox() Task + diff_text Frischer Container mit gepatchtem Code; sandbox.patch_applied Warning-Flag wenn Patch fehlschlaegt
7. Judge judge.py Judge-Container + success_cmd JudgeResult (4 Scores + Notes) Neutral-Score 0.5 wenn kein Test-Runner gefunden
8. Metriken + DB metrics.py, runner.py JudgeResult + adapter_result + trajectory Run mit judge_score, metrics_json, cost_usd Composite score = 0 wenn Funktionalitaet = 0

Aus dem Code: Runner-Kernlogik

# core/runner.py (vereinfacht)
async def run_single(self, db, task, agent) -> Run:
    # 1. Sandbox erstellen (git clone + checkpoint)
    sandbox = await asyncio.to_thread(Sandbox.create, task)

    # 2. Adapter holen und ausfuehren (HOST subprocess, nicht im Container)
    adapter = get_adapter(agent.adapter_type, agent_config)
    adapter_result = await adapter.run(ctx, sandbox)

    # 3. Diff aus Host-Tempdir oder Container holen
    diff_text = adapter_result.diff_text or await asyncio.to_thread(sandbox.get_diff)

    # 4. AGENT CONTAINER ZERSTOEREN (Security-Grenze!)
    await asyncio.to_thread(sandbox.destroy)
    sandbox = None

    # 5. FRISCHEN Judge-Container erstellen + Diff einspielen
    judge_sandbox = await asyncio.to_thread(
        Sandbox.create_judge_sandbox, task, diff_text
    )

    # 6. Judge laeuft NUR im frischen Container
    judge_result = await asyncio.to_thread(
        self.judge.judge_run, judge_sandbox, task.success_cmd
    )

2. Sicherheitsmodell: 2-Container-Isolation

Das kritischste Design-Entscheidung: Agent und Judge laufen in separaten, kurzlebigen Docker-Containern. Der Agent hat keine Moeglichkeit, die Testumgebung zu manipulieren, weil der Judge-Container erst nach Zerstoerung des Agent-Containers erstellt wird.

Container A Agent Sandbox
  • 1
    docker run python:3.12-slim
    --network=arena-net --memory=2g
  • 2
    git clone --depth=1 repo_url /workspace
    + apt install git, pip install pytest
  • 3
    git tag arena-checkpoint
    Snapshot vor Agent-Arbeit
  • 4
    Adapter laeuft auf HOST
    claude/aider als subprocess
  • 5
    git diff arena-checkpoint
    Unified diff gespeichert
  • X
    docker rm -f container_id
    Zerstoert. Kein shared state.
Security
Boundary
🔒
nur
diff_text
→
Container B Judge Sandbox
  • 1
    docker run python:3.12-slim
    frischer Container, nichts vom Agenten
  • 2
    git clone --depth=1 repo_url /workspace
    + apt install git patch
  • 3
    docker cp agent.patch Container
    Diff als temp file eingeschleust
  • 4
    git apply agent.patch
    Fallback: patch -p1
  • 5
    pytest, ruff, radon ausfuehren
    in sauberem Environment
  • ✓
    Score berechnen + docker rm
    JudgeResult zurueck an Runner

Warum ist das wichtig?

Ohne Isolation koennte ein Agent:

  • Test-Dateien so modifizieren, dass sie immer bestehen
  • pytest durch ein eigenes Script ersetzen
  • Git-Historie manipulieren um alte Fehler zu verstecken
  • Environment-Variablen setzen die den Judge beeinflussen

Garantien des Modells

  • Judge sieht nur den Diff (Text), nie den Agent-Container
  • Jeder Run startet mit einem frischen Repo-Clone
  • Kein shared filesystem, kein shared network state zwischen Containern
  • patch_applied Flag warnt wenn Diff nicht anwendbar

Sandbox.create_judge_sandbox() im Detail

# core/sandbox.py
@classmethod
def create_judge_sandbox(cls, task, diff_text: str) -> "Sandbox":
    # Frischer Container — kein Bezug zum Agent-Container
    container_id = docker_run(image="python:3.12-slim")
    sandbox = cls(container_id)

    # Frischer Clone direkt aus Remote
    sandbox.exec(f"git clone --depth=1 -b {repo_ref} {repo_url} .")

    # Diff als Datei in Container kopieren
    subprocess.run(["docker", "cp", tmp_path, f"{container_id}:/tmp/agent.patch"])

    # Patch anwenden (git apply, Fallback: patch -p1)
    rc, out, err = sandbox.exec(
        "git apply --whitespace=fix /tmp/agent.patch || patch -p1 < /tmp/agent.patch"
    )
    sandbox.patch_applied = rc == 0
    return sandbox

3. Adapter-Architektur

Alle Adapter implementieren das AgentAdapter ABC und laufen auf dem HOST-System, nicht im Container. Der Grund: Claude Code und Aider koennen nicht sinnvoll innerhalb von Docker-Containern ausgefuehrt werden (OAuth-Zustand, API-Keys, Dateisystem-Zugriff).

🚀 ClaudeCodeAdapter
CLI-Befehl
claude --print --dangerously-skip-permissions --output-format json
Auth
OAuth (kein API-Key noetig)
Repo-Strategie
Klont in Host-Tempdir (arena-claude-*)
Token-Tracking
entry.usage.input_tokens, total_cost_usd direkt aus JSON
Modell
claude-sonnet-4-6 (aus modelUsage)
Kosten (Run #5)
~$0.15
⚒ AiderAdapter
CLI-Befehl
aider --yes-always --model openai/MiniMax-M2.5 --message ...
Auth
MINIMAX_API_KEY aus ~/.claude/.env
API-Routing
litellm → OPENAI_API_BASE=https://api.minimax.io/v1
Token-Tracking
Regex aus stdout: "Tokens: X sent, Y received, Cost: $Z"
Modell
MiniMax-M2.5 (via OpenAI-kompatibler Endpoint)
Kosten (Run #10)
~$0.00 (MiniMax)
💻 CodexAdapter
CLI-Befehl
codex --quiet --full-auto
Status
Noch nicht getestet
Auth
OPENAI_API_KEY
Pricing
gpt-4o: $2.50/$10.0 per 1M

Gemeinsames Pattern: Host-Clone-Strategy

Alle Adapter klonen das Repo in ein Host-Tempverzeichnis, setzen einen Git-Checkpoint-Tag, fuehren dann den CLI-Prozess aus, und sammeln den Diff vor dem Cleanup.

# Muster fuer alle Adapter (adapters/claude_code.py, adapters/aider.py)
tmpdir = tempfile.mkdtemp(prefix="arena-claude-")  # oder arena-aider-

# 1. Repo klonen
subprocess.run(["git", "clone", "--depth=1", "-b", repo_ref, repo_url, tmpdir])

# 2. Checkpoint vor Agent-Arbeit
subprocess.run(["git", "tag", "arena-checkpoint"], cwd=tmpdir)

# 3. CLI ausfuehren (timeout = task.time_limit_s)
proc = subprocess.run(cmd, cwd=tmpdir, capture_output=True, timeout=ctx.time_limit_s)

# 4. Diff sammeln (excludiert __pycache__ und .pyc)
diff = subprocess.run(
    ["git", "diff", "arena-checkpoint", "--", ".",
     ":!**/__pycache__/", ":!**/*.pyc"],
    cwd=tmpdir
)

# 5. Cleanup (always, via try/finally)
shutil.rmtree(tmpdir, ignore_errors=True)

Preistabellen

ModellInput ($/1M)Cache WriteCache ReadOutput ($/1M)
claude-opus-4-6$5.00$6.25$0.50$25.00
claude-sonnet-4-6$3.00$3.75$0.30$15.00
claude-haiku-4-5$1.00$1.25$0.10$5.00
gpt-4o$2.50——$10.00
MiniMax-M2.5$0.05——$0.05

4. Judge-System: 4 Dimensionen

Der Judge bewertet den Code in vier Dimensionen. Funktionalitaet wirkt als Gatekeeper: besteht die Aufgabe nicht, ist der Overall-Score 0 unabhaengig von allen anderen Dimensionen.

overall_score = functional_score × 0.50 + regression_score × 0.20 + lint_score × 0.15 + complexity_score × 0.15

Functional Score (50%)

Messmethode: Fuehrt success_cmd (pytest) im Judge-Container aus. Prueft Exit-Code und parst Testanzahl aus stdout.

Scoring: Exit-Code 0 = 1.0, Exit-Code != 0 = 0.0. Test-Count wird separat getrackt (tests_total, tests_passed).

Beispiel: 5/5 tests passed. Exit 0 → score=1.0

if rc == 0:
    result.functional_score = 1.0
else:
    result.functional_score = 0.0

Regression Score (20%)

Messmethode: Fuehrt breiteren Test-Suite aus. Kandidaten in Reihenfolge: pytest --tb=no -q, python -m pytest, make test.

Scoring: passed / total. Neutralscore 0.5 wenn kein Test-Runner gefunden.

Crash-Detection: "X errors in Y.Zs" (ohne "passed"/"failed") wird als Sammelfehler erkannt und uebersprungen, nicht als echter Test-Ausfall gewertet.

# Crash detection
is_crash = rc != 0 and passed == 0 \
           and crash_match and not has_real_tests
if total > 0 and not is_crash:
    result.regression_score = passed / total

Lint Score (15%)

Messmethode: ruff check --output-format=json vor und nach Aenderungen. Der git stash Trick: Stash die Agent-Aenderungen, zaehle Warnings im Original, pop den Stash, zaehle erneut.

Scoring: Delta-basiert. delta <= 0 = 1.0. Jede neue Warning kostet 0.1 Punkte. Fallback: py_compile bei fehlendem ruff.

# git stash trick fuer Before-State
sandbox.exec("git stash")  # temporaer zuruecksetzen
before = count_ruff_warnings(sandbox, files)
sandbox.exec("git stash pop")  # Agent-Aenderungen wiederherstellen
after = count_ruff_warnings(sandbox, files)
# score: max(0, 1.0 - delta * 0.1)

Complexity Score (15%)

Messmethode: radon cc --json berechnet zyklomatische Komplexitaet pro Funktion, gemittelt ueber alle geaenderten Dateien. Selber git-stash-Trick wie beim Lint.

Fallback: Einfacher Proxy aus max. Einrueckungstiefe + Anzahl Funktionen/Klassen pro Datei wenn radon nicht verfuegbar.

# Fallback-Proxy (simple_complexity)
max_indent = max(len(l) - len(l.lstrip()) for l in lines)
func_count = sum(1 for l in lines
             if l.strip().startswith('def ')
             or l.strip().startswith('class '))

pytest Output Parser

Der Parser erkennt verschiedene pytest-Ausgabeformate robuster als ein einfaches Regex-Match:

# core/metrics.py — parse_pytest_output()
# Erkennt: "5 passed", "3 failed, 2 passed", "1 error, 2 failed, 3 passed"
passed_match = re.search(r"(\d+)\s+passed", output)
failed_match = re.search(r"(\d+)\s+failed", output)
error_match  = re.search(r"(\d+)\s+error",  output)
# Fallback: "X/Y tests passed" (unittest-Stil)
fraction = re.search(r"(\d+)/(\d+)\s+(?:tests?\s+)?passed", output)

5. Metriken-System

Trajectory Analysis
Diff-Minimality
Cost Tracking
Pass@k
Composite Score

Trajectory Analysis

Eine Trajectory ist eine Liste von Tool-Use-Events mit tool_name, input_preview, timestamp. Die Analyse erkennt ineffiziente Muster in der Problemloesungsstrategie des Agenten.

Erkannte Metriken

MetrikWas wird gemessen
directness_scoreWie direkt der Agent zur Loesung gelangt (0-1)
loop_countRepetitive Tool-Call-Sequenzen (1er, 2er, 3er-Zyklen)
stall_rateAnteil von Steps ohne Fortschritt (≥5 Read-Steps in Folge)
backtrack_countUeberschriebene Dateien (innerhalb 10 Steps)
self_correction_rateEdit → Test → Edit-gleiche-Datei Pattern
exploration_ratioRead/Grep/Glob / gesamt
turns_to_first_editWieviele Steps bis zum ersten Edit/Write

Phasen-Klassifikation

PhaseTools
explorationRead, Grep, Glob, Bash(ls/find/cat)
implementationEdit, Write, NotebookEdit
testingBash mit pytest/unittest/make test
debuggingRead/Edit nach fehlgeschlagenem Test
# Directness Score — kombiniert 5 Sub-Metriken
score = (
    0.35 * trajectory_efficiency   # Edit/Write-Ratio
  + 0.25 * early_edit_score         # wie frueh erster Edit
  - 0.20 * repeat_penalty           # gleiche Datei wiederholt gelesen
  - 0.10 * loop_penalty             # zyklische Tool-Patterns
  - 0.10 * stall_rate               # lange Read-Phasen ohne Edit
)

Diff-Minimality Analyse

Ein guter Agent aendert nur was noetig ist. Hoher Churn (viele Zeilen geaendert) deutet auf Overengineering oder unnoetige Refactorings hin.

MetrikBeschreibungBerechnung
lines_addedZeilen mit +Alle +-Zeilen ausser +++-Header
lines_removedZeilen mit -Alle --Zeilen ausser ----Header
files_changedDistinct Filesaus +++ b/ Headern
hunksDiff-BloeckeAnzahl @@-Marker
churnGesamtveraenderunglines_added + lines_removed

Im Composite Score: diff_minimality = 1.0 / (1.0 + churn / 50.0) — Sigmoid-artige Normalisierung, churn=0 → 1.0, churn=50 → 0.5.

Cost Tracking

Jeder Run trackt Tokens und rechnet Kosten in USD um. Das ist das Alleinstellungsmerkmal von AgentArena gegenueber anderen Benchmarks.

Claude Code (direkt aus JSON)

# --output-format json liefert:
entry["usage"]["input_tokens"]
entry["usage"]["output_tokens"]
entry["usage"]["cache_creation_input_tokens"]
entry["usage"]["cache_read_input_tokens"]
entry["total_cost_usd"]  # direkt verfuegbar!

Aider (aus stdout geparst)

# Regex aus Aider-Output:
_TOKEN_RE = re.compile(
  r"Tokens:\s*(?P<sent>\d+)\s+sent,"
  r"\s*(?P<recv>\d+)\s+received"
)
_COST_RE = re.compile(
  r"Cost:\s*\$(?P<cost>[0-9]+\.[0-9]+)"
)
# Kosten-Kalkulation wenn kein direkter Wert (Fallback)
cost = (tokens_input * rate_in + tokens_output * rate_out) / 1_000_000

# Effizienz-Score fuer Composite (normalisiert auf 0-1)
efficiency = 1.0 / (1.0 + cost_usd / 0.50)  # $0 = 1.0, $0.50 = 0.5

Pass@k Unbiased Estimator

Pass@k misst die Wahrscheinlichkeit, dass mindestens einer von k unabhaengigen Runs erfolgreich ist. Der unbiased Estimator vermeidet Overfitting gegenueber dem naiven Ansatz.

pass@k = 1 − C(n−c, k) / C(n, k)

n = gesamt Runs, c = korrekte Runs, k = Ziel-k
# core/metrics.py
def compute_pass_at_k(n: int, c: int, k: int) -> float:
    if n < k: return 0.0
    if c == 0: return 0.0
    if n - c < k: return 1.0
    return 1.0 - comb(n - c, k) / comb(n, k)

Runner fuehrt k Runs parallel aus mittels asyncio.gather(), jeder in eigenem DB-Session, getaggt mit gemeinsamer pass_at_k_group UUID.

Composite Score

Kombiniert Judge-Score, Trajectory-Metriken und Diff-Qualitaet zu einem Gesamtwert. Funktionalitaet wirkt als Multiplikator: schlaegt der Test fehl, ist alles 0.

composite = functionality × (
  0.25 × directness +
  0.25 × efficiency +
  0.20 × diff_minimality +
  0.15 × no_loops +
  0.15 × low_correction
)

Gatekeeper-Logik: Ist functionality == 0.0, wird sofort 0.0 zurueckgegeben, ohne die anderen Dimensionen zu berechnen. Nur ein funktionierender Code kann ein guter Code sein.

Judge Score Visualisierung (Beispiel: Run #10, Aider + MiniMax)

Functional (50%) 1.0 × 0.50 = 0.50
1.00
Regression (20%) 1.0 × 0.20 = 0.20
1.00
Lint (15%) 1.0 × 0.15 = 0.15
1.00
Complexity (15%) 1.0 × 0.15 = 0.15
1.00
Overall Score 1.00

6. Live-Updates via Server-Sent Events

Die Webapp streamt Fortschritts-Updates in Echtzeit ohne Polling. Kein WebSocket-Overhead, kein periodisches Refetching — SSE ist ideal fuer unidirektionale Streams.

SSE Endpoint: GET /runs/{id}/stream

Event-Typen

PhaseStatus-Text
startingRun wurde erstellt
creating_sandboxDocker-Container wird hochgefahren
running_agentAdapter laeuft (kann Minuten dauern)
collecting_diffgit diff wird gesammelt
creating_judge_sandboxJudge-Container wird erstellt
judging4-Dimensionen-Bewertung laeuft
doneScore verfuegbar, Connection close

htmx-Integration

<div
  hx-ext="sse"
  sse-connect="/runs/{{id}}/stream"
  sse-swap="message"
  hx-target="#status-box">
  <div id="status-box">Warte...</div>
</div>
# main.py — SSE Generator
async def run_status_stream(run_id: int):
    while True:
        progress = runner.get_progress(run_id)
        if progress:
            status = progress.get("status", "unknown")
            elapsed = time.time() - progress.get("started_at", time.time())
            yield f"data: {json.dumps({'status': status, 'elapsed': elapsed})}\n\n"
        else:
            yield "data: {\"status\": \"done\"}\n\n"
            break
        await asyncio.sleep(0.5)

return StreamingResponse(run_status_stream(run_id), media_type="text/event-stream")

7. Dateistruktur

AgentArena/ ├── main.py # FastAPI app, Routen, SSE-Endpoints, Jinja2-Templates ├── models.py # SQLAlchemy Models: Task, Agent, Run, RunStatus (Enum) ├── database.py # SessionLocal, engine, Base — SQLite via SQLAlchemy ├── config.py # Pricing-Tabellen, Docker-Config, CLI-Pfade, Pass@k Defaults ├── core/ │ ├── runner.py # Orchestrierung: run_single(), run_pass_at_k(), get_progress() │ ├── sandbox.py # Docker-Lifecycle: create(), exec(), destroy(), create_judge_sandbox() │ ├── judge.py # 4-Dimensionen Judge: functional, regression, lint, complexity │ └── metrics.py # Trajectory-Analyse, Diff-Analyse, Pass@k, Composite Score ├── adapters/ │ ├── base.py # AgentAdapter ABC, RunContext dataclass, RunResult dataclass │ ├── claude_code.py # Claude Code CLI — OAuth, JSON output, Host-Tempdir │ ├── aider.py # Aider CLI — MiniMax via litellm, Regex-Token-Parsing │ └── codex.py # Codex CLI — noch nicht getestet ├── templates/ # Jinja2 HTML-Templates │ ├── base.html # Basis-Layout, Tailwind CDN, htmx, SSE-Extension │ ├── index.html # Dashboard: Leaderboard, Run-Trigger │ └── run_detail.html # Run-Details: Diff, Judge-Breakdown, Trajectory, SSE ├── tasks/ # Task-Definitionen als YAML │ └── *.yaml # slug, title, repo_url, success_cmd, time_limit_s ├── docs/ │ └── architecture.html # Diese Datei └── static/ # CSS/JS Assets

Datenmodelle

Task

slugUnique Identifier
repo_urlGit-Repository URL
success_cmdpytest Befehl
time_limit_sTimeout (default 300s)
token_limitMax Tokens (200k)
difficultyeasy/medium/hard

Agent

nameUnique Name
adapter_typeclaude_code / aider / codex
config_jsonAdapter-Konfiguration

Run

statuspending/running/success/failure/error/timeout
judge_score0.0 – 1.0
cost_usdKosten in Dollar
diff_textUnified Diff
trajectoryJSON Array (Tool-Events)
metrics_jsonAlle Metriken als JSON
pass_at_k_groupUUID fuer k-Gruppen

8. Bisherige Ergebnisse

Rank
Agent
Score
Tests
Kosten
Dauer
Modell
1
Aider
1.00
5/5
~$0.00
~90s
MiniMax-M2.5
2
Claude Code
0.80
5/5
~$0.15
~120s
claude-sonnet-4-6

Wichtige Beobachtung

Aider mit MiniMax-M2.5 erzielte einen perfekten Score bei nahezu keinen Kosten. MiniMax-M2.5 kostet $0.05/1M Tokens — ca. 200x guenstiger als Claude Sonnet.

Dies unterstreicht die Notwendigkeit von explizitem Kosten-Tracking: Reine Score-Vergleiche ohne Kostendimension sind irreführend.

Cost-Performance Ratio

$0.00
Aider + MiniMax
Score 1.0
$0.15
Claude Code Sonnet
Score 0.80

Was kommt als naechstes?

Mehr Agenten

CodexAdapter testen. GPT-4o als Basis-Vergleich. Lokale Modelle via Ollama.

Mehr Tasks

Schwerere Aufgaben (Refactoring, Multi-file, API Integration). Task-YAML fuer einfaches Hinzufuegen.

Pass@k Analyse

Parallele Runs fuer statistische Belastbarkeit. Variance-Analyse: Welche Agenten sind konsistenter?


AgentArena — Erstellt Maerz 2026 — FastAPI + Docker + SQLite