Skip to content

Commit b0178a0

Browse files
authored
Merge pull request #94 from ClickHouse/feat/agent-arena-online-evaluation
feat(agent-arena): add the continuous online evaluation loop
2 parents c9988c8 + 63728d5 commit b0178a0

39 files changed

Lines changed: 5465 additions & 186 deletions

‎workshops/agent_arena/.env.example‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,19 @@ export ARENA_RO_PASSWORD=
77
# OpenRouter (LLM provider)
88
export OPENROUTER_API_KEY=sk-or-...
99
export OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
10+
# policy-v2 is the current default; Module 03 explicitly overrides it to
11+
# policy-v1 when starting the stale seeded release.
12+
export AGENT_ARENA_POLICY_VERSION=policy-v2
13+
# Non-secret defaults for the calibrated workshop winner.
14+
export WINNER_MODEL=qwen3.7-flash
15+
export WINNER_PROMPT=P2_fewshot
16+
export WINNER_CONFIG_ID=qwen3.7-flash__P2_fewshot
1017
# Instructor only (public workshops): a Management key from
1118
# https://openrouter.ai/settings/management-keys — used by
1219
# scripts/provision_workshop_keys.py to mint capped learner keys. Admin-level:
1320
# NEVER give this to learners or commit it. Learners only get OPENROUTER_API_KEY.
1421
# export OPENROUTER_PROVISIONING_KEY=sk-or-v1-...
15-
# LangFuse Cloud (eval store + tracing)
22+
# Langfuse Cloud (eval store + tracing)
1623
export LANGFUSE_BASE_URL=https://us.cloud.langfuse.com
1724
export LANGFUSE_PUBLIC_KEY=pk-lf-...
1825
export LANGFUSE_SECRET_KEY=sk-lf-...

‎workshops/agent_arena/README.md‎

Lines changed: 369 additions & 32 deletions
Large diffs are not rendered by default.

‎workshops/agent_arena/agents/llm.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
import time
88
import urllib.request
9+
from collections.abc import Mapping
910
from urllib.error import HTTPError
1011
from dataclasses import dataclass
1112

@@ -58,6 +59,45 @@ def _parse_response(resp: dict) -> ConverseResult:
5859
return ConverseResult(text=text, usage=usage)
5960

6061

62+
_REASONING_KEYS = frozenset({"max_tokens", "effort", "exclude", "enabled"})
63+
_REASONING_EFFORTS = frozenset({
64+
"max", "xhigh", "high", "medium", "low", "minimal", "none",
65+
})
66+
67+
_REASONING_MAPPING_ERROR = "reasoning must be a mapping"
68+
_REASONING_FIELDS_ERROR = "reasoning contains unsupported fields"
69+
_REASONING_MAX_TOKENS_ERROR = "reasoning max_tokens has an invalid value"
70+
_REASONING_EFFORT_ERROR = "reasoning effort has an invalid value"
71+
_REASONING_EXCLUDE_ERROR = "reasoning exclude has an invalid value"
72+
_REASONING_ENABLED_ERROR = "reasoning enabled has an invalid value"
73+
_REASONING_MODE_ERROR = "reasoning mode fields are mutually exclusive"
74+
75+
76+
def _validate_reasoning(reasoning: object) -> dict:
77+
if not isinstance(reasoning, Mapping):
78+
raise TypeError(_REASONING_MAPPING_ERROR)
79+
if not set(reasoning).issubset(_REASONING_KEYS):
80+
raise ValueError(_REASONING_FIELDS_ERROR)
81+
if "max_tokens" in reasoning:
82+
max_tokens = reasoning["max_tokens"]
83+
if (isinstance(max_tokens, bool) or
84+
not isinstance(max_tokens, int) or
85+
max_tokens <= 0):
86+
raise ValueError(_REASONING_MAX_TOKENS_ERROR)
87+
if ("effort" in reasoning and
88+
(not isinstance(reasoning["effort"], str) or
89+
reasoning["effort"] not in _REASONING_EFFORTS)):
90+
raise ValueError(_REASONING_EFFORT_ERROR)
91+
if "exclude" in reasoning and type(reasoning["exclude"]) is not bool:
92+
raise ValueError(_REASONING_EXCLUDE_ERROR)
93+
if "enabled" in reasoning and type(reasoning["enabled"]) is not bool:
94+
raise ValueError(_REASONING_ENABLED_ERROR)
95+
mode_fields = {"max_tokens", "effort", "enabled"}.intersection(reasoning)
96+
if len(mode_fields) > 1:
97+
raise ValueError(_REASONING_MODE_ERROR)
98+
return dict(reasoning)
99+
100+
61101
class OpenRouterClient:
62102
def __init__(self, base_url: str, api_key: str):
63103
self._base = base_url.rstrip("/")
@@ -72,6 +112,8 @@ def converse(self, model_id: str, system: str, messages: list[dict],
72112
max_tok = inference.get("max_tokens", inference.get("maxTokens"))
73113
if max_tok is not None:
74114
body["max_tokens"] = max_tok
115+
if "reasoning" in inference:
116+
body["reasoning"] = _validate_reasoning(inference["reasoning"])
75117
req = urllib.request.Request(
76118
f"{self._base}/chat/completions",
77119
data=json.dumps(body).encode(),

‎workshops/agent_arena/agents/loop.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,15 @@ def run_agent(question: str, model_cfg: ModelCfg, prompt_cfg: PromptCfg,
6161
attempt + 1, usage_total, "ok", transcript)
6262
except Exception as e: # noqa: BLE001
6363
last_err, hint = str(e), "sql_exec_error"
64-
if attempt < max_retries and prompt_cfg.self_correct:
65-
transcript.append({"role": "user",
66-
"content": f"That query failed: {last_err}. "
67-
"Return a corrected ClickHouse SQL query."})
68-
messages = messages + correction_turn(last_sql or "", last_err or "")
69-
continue
64+
if attempt < max_retries:
65+
if not resp.text.strip():
66+
continue
67+
if prompt_cfg.self_correct:
68+
transcript.append({"role": "user",
69+
"content": f"That query failed: {last_err}. "
70+
"Return a corrected ClickHouse SQL query."})
71+
messages = messages + correction_turn(last_sql or "", last_err or "")
72+
continue
7073
break
7174

7275
return AgentResult(last_sql, None, None, last_err,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from dataclasses import dataclass
2+
from pathlib import Path
3+
import re
4+
5+
import yaml
6+
7+
8+
_VERSION = re.compile(r"^policy-v[0-9]+$")
9+
10+
11+
@dataclass(frozen=True)
12+
class PolicyContext:
13+
version: str
14+
metrics: dict[str, str]
15+
rendered: str
16+
17+
18+
def load_policy(version: str, root: Path = Path("policies")) -> PolicyContext:
19+
if not _VERSION.fullmatch(version):
20+
raise ValueError(f"unknown policy version: {version}")
21+
22+
path = root / f"{version}.yaml"
23+
if not path.is_file():
24+
raise ValueError(f"unknown policy version: {version}")
25+
26+
raw = yaml.safe_load(path.read_text())
27+
if not isinstance(raw, dict):
28+
raise ValueError("policy document must be a mapping")
29+
metrics = raw.get("metrics")
30+
if raw.get("version") != version:
31+
raise ValueError(f"policy version mismatch in {path}")
32+
if not isinstance(metrics, dict) or not metrics or not all(
33+
isinstance(name, str) and isinstance(definition, str) and definition.strip()
34+
for name, definition in metrics.items()
35+
):
36+
raise ValueError("metrics must be a non-empty mapping of names to definitions")
37+
38+
lines = [f"# Business metric policy ({version})"]
39+
lines.extend(
40+
f"- {name}: {definition.strip()}" for name, definition in metrics.items()
41+
)
42+
return PolicyContext(version, metrics, "\n".join(lines))
43+
44+
45+
def with_policy(schema_context: str, policy: PolicyContext) -> str:
46+
return f"{schema_context.rstrip()}\n\n{policy.rendered}\n"

‎workshops/agent_arena/config.yaml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ openrouter:
2323
inference:
2424
temperature: 0.0
2525
max_tokens: 2048 # headroom for reasoning models
26+
reasoning:
27+
max_tokens: 512 # leave output budget for the final SQL answer
2628

2729
langfuse:
2830
host: ${LANGFUSE_BASE_URL}

‎workshops/agent_arena/eval/golden.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class GoldenQuestion:
1212
tags: list = field(default_factory=list)
1313
notes: str = ""
1414
fewshot_holdout: bool = False
15+
float_dp: int | None = None
1516

1617

1718
def load_golden(path: str = "golden/questions.yaml") -> list[GoldenQuestion]:
@@ -22,6 +23,7 @@ def load_golden(path: str = "golden/questions.yaml") -> list[GoldenQuestion]:
2223
ordered=q.get("ordered", False), golden_sql=q["golden_sql"],
2324
tags=q.get("tags", []), notes=q.get("notes", ""),
2425
fewshot_holdout=q.get("fewshot_holdout", False),
26+
float_dp=q.get("float_dp"),
2527
) for q in raw]
2628

2729

‎workshops/agent_arena/eval/harness.py‎

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@
1111
from agents.chclient import ROClickHouseClient
1212
from agents.llm import OpenRouterClient, cost_usd
1313
from agents.loop import run_agent
14+
from agents.policy import load_policy, with_policy
1415
from eval.golden import GoldenQuestion, load_golden, fewshot_examples
1516
from eval.langfuse_adapter import LangfuseTracer, emit_agent_trace
1617
from eval.serialize import result_payload, golden_payload
1718

1819

19-
def main() -> None:
20+
DEFAULT_REQUIRED_SCORE_NAMES = (
21+
"correctness",
22+
"agent-arena-llm-judge",
23+
)
24+
25+
26+
def parse_args(argv=None):
2027
ap = argparse.ArgumentParser()
2128
ap.add_argument("--run-id", default=None)
2229
ap.add_argument("--eval-timeout", type=int, default=180,
@@ -28,10 +35,41 @@ def main() -> None:
2835
ap.add_argument("--models-file", default="",
2936
help="JSON file of model specs [{id,name,family,price_per_1m_in,price_per_1m_out}] "
3037
"to run instead of config.yaml models (used by the web UI)")
31-
args = ap.parse_args()
38+
ap.add_argument("--policy-version", default="policy-v2")
39+
ap.add_argument("--wait-for-score", action="append", default=[])
40+
return ap.parse_args(argv)
41+
42+
43+
def effective_run_id(run_id: str, policy_version: str) -> str:
44+
return f"{run_id}--{policy_version}"
45+
46+
47+
def _dataset_item(q: GoldenQuestion, *, rows, cols, default_float_dp: int) -> dict:
48+
float_dp = q.float_dp if q.float_dp is not None else default_float_dp
49+
return {
50+
"id": q.id,
51+
"question": q.question,
52+
"expected_output": golden_payload(
53+
golden_sql=q.golden_sql,
54+
rows=rows,
55+
cols=cols,
56+
ordered=q.ordered,
57+
),
58+
"metadata": {
59+
"tier": q.tier,
60+
"ordered": q.ordered,
61+
"float_dp": float_dp,
62+
},
63+
}
64+
65+
66+
def main(argv=None) -> None:
67+
args = parse_args(argv)
3268

3369
cfg = load_config()
34-
run_id = args.run_id or f"run-{uuid.uuid4().hex[:8]}"
70+
release = args.run_id or f"run-{uuid.uuid4().hex[:8]}"
71+
policy = load_policy(args.policy_version)
72+
run_id = effective_run_id(release, policy.version)
3573

3674
# The web UI can hand the harness an arbitrary set of models (from the live
3775
# OpenRouter catalog) via a JSON file, overriding config.yaml's model list.
@@ -56,7 +94,7 @@ def main() -> None:
5694
llm = OpenRouterClient(cfg.openrouter.base_url, cfg.openrouter.api_key)
5795

5896
with open("schema/schema_context.md") as f:
59-
schema_ctx = f.read()
97+
schema_ctx = with_policy(f.read(), policy)
6098

6199
all_questions = load_golden()
62100
# Hold out the few-shot example questions so P2 is not graded on its own
@@ -71,15 +109,15 @@ def main() -> None:
71109
golden_cache[q.id] = (gr.rows, gr.cols)
72110

73111
# Upload the golden set as a Langfuse Dataset (each config becomes a Run).
74-
tracer.ensure_dataset([{
75-
"id": q.id, "question": q.question,
76-
"expected_output": golden_payload(golden_sql=q.golden_sql,
77-
rows=golden_cache[q.id][0],
78-
cols=golden_cache[q.id][1],
79-
ordered=q.ordered),
80-
"metadata": {"tier": q.tier, "ordered": q.ordered,
81-
"float_dp": cfg.eval.float_dp},
82-
} for q in questions])
112+
tracer.ensure_dataset([
113+
_dataset_item(
114+
q,
115+
rows=golden_cache[q.id][0],
116+
cols=golden_cache[q.id][1],
117+
default_float_dp=cfg.eval.float_dp,
118+
)
119+
for q in questions
120+
])
83121

84122
model_names, prompt_names = cfg.resolved_grid()
85123
if args.models:
@@ -126,9 +164,11 @@ def task(*, item, _m=mname, _p=pname, _mc=mcfg, _pc=pcfg,
126164
payload, c, latency_ms, ar = run_one(_m, _p, _mc, _pc, _ex, _cid, q)
127165
trace_id = emit_agent_trace(
128166
trace_name="agent_run", session_id=_sid,
129-
tags=[_cid, f"run:{run_id}", _m, _p],
167+
tags=[_cid, f"run:{run_id}", policy.version, _m, _p],
130168
metadata={"config_id": _cid, "question_id": q.id,
131-
"model": _m, "prompt": _p, "run_id": run_id},
169+
"model": _m, "prompt": _p, "run_id": run_id,
170+
"policy_version": policy.version,
171+
"release": release},
132172
question=q.question, model=_m,
133173
transcript=ar.transcript, sql=ar.sql,
134174
output_payload=payload, usage=ar.usage)
@@ -148,7 +188,13 @@ def task(*, item, _m=mname, _p=pname, _mc=mcfg, _pc=pcfg,
148188
tracer.flush()
149189

150190
if pending_trace_ids:
151-
_wait_for_scores(tracer, pending_trace_ids, args.eval_timeout, args.eval_poll)
191+
_wait_for_scores(
192+
tracer,
193+
pending_trace_ids,
194+
args.eval_timeout,
195+
args.eval_poll,
196+
args.wait_for_score,
197+
)
152198
print(f"done. run_id={run_id}", flush=True)
153199

154200

@@ -193,25 +239,39 @@ def _question_for_item(item, qmap) -> GoldenQuestion:
193239
golden_sql=str(golden_sql))
194240

195241

196-
def _wait_for_scores(tracer, trace_ids, timeout, poll) -> None:
197-
"""Wait until both required asynchronous Langfuse evaluators have finished."""
242+
def _wait_for_scores(tracer, trace_ids, timeout, poll, required_names=None) -> None:
243+
"""Compatibility wrapper for the two default asynchronous evaluators."""
244+
_wait_for_required_scores(
245+
tracer, trace_ids, timeout, poll, required_names=required_names
246+
)
247+
248+
249+
def _wait_for_required_scores(
250+
tracer, trace_ids, timeout, poll, required_names=None
251+
) -> None:
252+
"""Wait for the default scores and every additional exact score name."""
253+
required = list(dict.fromkeys([
254+
*DEFAULT_REQUIRED_SCORE_NAMES,
255+
*(required_names or []),
256+
]))
198257
remaining = set(trace_ids)
199258
print(f"grading via LangFuse evaluators — waiting on {len(remaining)} traces "
200259
f"(timeout {timeout}s)…", flush=True)
201260
deadline = time.time() + timeout
202-
last_missing = {tid: {"correctness", "llm_judge"} for tid in remaining}
261+
last_missing = {tid: set(required) for tid in remaining}
203262
while remaining and time.time() < deadline:
204263
tids = list(remaining)
205264
with ThreadPoolExecutor(max_workers=min(8, len(tids))) as pool:
206265
score_lists = list(pool.map(tracer.fetch_trace_scores, tids))
207266
done = []
208267
for tid, scores in zip(tids, score_lists):
209-
corr, judge, _outcome = _classify_scores(scores)
210-
missing = set()
211-
if corr is None:
212-
missing.add("correctness")
213-
if judge is None:
214-
missing.add("llm_judge")
268+
available = {
269+
score.get("name")
270+
for score in scores or []
271+
if score.get("name")
272+
and (score.get("value") is not None or score.get("string") is not None)
273+
}
274+
missing = set(required) - available
215275
last_missing[tid] = missing
216276
if not missing:
217277
done.append(tid)
@@ -224,7 +284,7 @@ def _wait_for_scores(tracer, trace_ids, timeout, poll) -> None:
224284
missing_names = sorted({name for tid in remaining for name in last_missing[tid]})
225285
raise RuntimeError(
226286
f"{len(remaining)} traces are missing {', '.join(missing_names)} after {timeout}s; "
227-
"configure both Langfuse evaluators for Experiments on arena-golden. "
287+
"configure the required Langfuse evaluators for Experiments on arena-golden. "
228288
"See eval/langfuse_evaluators/README.md."
229289
)
230290
print(f"Langfuse scored all {len(trace_ids)} traces; leaderboard ready", flush=True)

0 commit comments

Comments
 (0)