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.
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.
| 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 |
# 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 )
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.
Ohne Isolation koennte ein Agent:
patch_applied Flag warnt wenn Diff nicht anwendbar# 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
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).
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)
| Modell | Input ($/1M) | Cache Write | Cache Read | Output ($/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 |
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.
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
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
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)
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 '))
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)
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.
| Metrik | Was wird gemessen |
|---|---|
directness_score | Wie direkt der Agent zur Loesung gelangt (0-1) |
loop_count | Repetitive Tool-Call-Sequenzen (1er, 2er, 3er-Zyklen) |
stall_rate | Anteil von Steps ohne Fortschritt (≥5 Read-Steps in Folge) |
backtrack_count | Ueberschriebene Dateien (innerhalb 10 Steps) |
self_correction_rate | Edit → Test → Edit-gleiche-Datei Pattern |
exploration_ratio | Read/Grep/Glob / gesamt |
turns_to_first_edit | Wieviele Steps bis zum ersten Edit/Write |
| Phase | Tools |
|---|---|
| exploration | Read, Grep, Glob, Bash(ls/find/cat) |
| implementation | Edit, Write, NotebookEdit |
| testing | Bash mit pytest/unittest/make test |
| debugging | Read/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 )
Ein guter Agent aendert nur was noetig ist. Hoher Churn (viele Zeilen geaendert) deutet auf Overengineering oder unnoetige Refactorings hin.
| Metrik | Beschreibung | Berechnung |
|---|---|---|
lines_added | Zeilen mit + | Alle +-Zeilen ausser +++-Header |
lines_removed | Zeilen mit - | Alle --Zeilen ausser ----Header |
files_changed | Distinct Files | aus +++ b/ Headern |
hunks | Diff-Bloecke | Anzahl @@-Marker |
churn | Gesamtveraenderung | lines_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.
Jeder Run trackt Tokens und rechnet Kosten in USD um. Das ist das Alleinstellungsmerkmal von AgentArena gegenueber anderen Benchmarks.
# --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!
# 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 misst die Wahrscheinlichkeit, dass mindestens einer von k unabhaengigen Runs erfolgreich ist. Der unbiased Estimator vermeidet Overfitting gegenueber dem naiven Ansatz.
# 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.
Kombiniert Judge-Score, Trajectory-Metriken und Diff-Qualitaet zu einem Gesamtwert. Funktionalitaet wirkt als Multiplikator: schlaegt der Test fehl, ist alles 0.
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.
Die Webapp streamt Fortschritts-Updates in Echtzeit ohne Polling. Kein WebSocket-Overhead, kein periodisches Refetching — SSE ist ideal fuer unidirektionale Streams.
| Phase | Status-Text |
|---|---|
starting | Run wurde erstellt |
creating_sandbox | Docker-Container wird hochgefahren |
running_agent | Adapter laeuft (kann Minuten dauern) |
collecting_diff | git diff wird gesammelt |
creating_judge_sandbox | Judge-Container wird erstellt |
judging | 4-Dimensionen-Bewertung laeuft |
done | Score verfuegbar, Connection close |
<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")
slug | Unique Identifier |
repo_url | Git-Repository URL |
success_cmd | pytest Befehl |
time_limit_s | Timeout (default 300s) |
token_limit | Max Tokens (200k) |
difficulty | easy/medium/hard |
name | Unique Name |
adapter_type | claude_code / aider / codex |
config_json | Adapter-Konfiguration |
status | pending/running/success/failure/error/timeout |
judge_score | 0.0 – 1.0 |
cost_usd | Kosten in Dollar |
diff_text | Unified Diff |
trajectory | JSON Array (Tool-Events) |
metrics_json | Alle Metriken als JSON |
pass_at_k_group | UUID fuer k-Gruppen |
MiniMax-M2.5claude-sonnet-4-6Aider 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.
CodexAdapter testen. GPT-4o als Basis-Vergleich. Lokale Modelle via Ollama.
Schwerere Aufgaben (Refactoring, Multi-file, API Integration). Task-YAML fuer einfaches Hinzufuegen.
Parallele Runs fuer statistische Belastbarkeit. Variance-Analyse: Welche Agenten sind konsistenter?