eval-containers 0.1.0

A build system for AI agent evaluations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
//! `eval-containers run` — shell out to the right command for the chosen
//! deployment mode and pass every axis through.
//!
//! Three modes (per benchmarks/RULES.md rule 24 — the triple-mode contract):
//!
//!   --mode compose    (default) → docker compose -f benchmarks/<x>/compose.yaml up
//!   --mode container            → docker run -e EVAL_MODEL=... <eval-image>
//!   --mode job                  → helm template oci://<registry>/charts/eval | kubectl apply -f -  (--local: ./benchmarks/_chart)
//!
//! Mapping flags → manifest, by mode:
//!
//!   - **compose / container** propagate every `--<flag>` through as an
//!     `EVAL_*` environment variable on the spawned subprocess. Compose
//!     interpolates `${EVAL_FOO:-default}` in compose.yaml; container
//!     mode hands them in via `docker run -e`.
//!   - **job** renders the shared Helm chart (`oci://<registry>/charts/eval`,
//!     or `benchmarks/_chart` with `--local`) with a
//!     `--set` for each axis (benchmark/agent/task/model/tags), then
//!     `helm template … | kubectl apply -f -`. A benchmark's bespoke
//!     topology, if any, lives in the chart at `presets/<x>.yaml`.
//!     Helm interpolates the values (kubectl can't), keeps numeric fields
//!     like `task` quoted, and the Job name carries the agent + task so
//!     concurrent applies don't collide.
//!
//! Two axes select what runs (see RULES.md principle 9):
//!
//! - Container tag  → which image to pull (EVAL_*_TAG, flags --*-tag). Run-time.
//! - Upstream ver.  → which software is inside the image. BUILD-time only:
//!   pinned via `ARG *_VERSION`, set at `build`, recorded in the label. There
//!   is no runtime version override here.
//!
//! `--dry-run` short-circuits: compose dumps `docker compose config`,
//! container prints the resolved `docker run` line, job forwards
//! `--dry-run=server` to `kubectl apply` (exercises admission, no state).
//!
//! With `--local`, uses the in-repo `benchmarks/<name>/{compose.yaml,
//! container.Dockerfile}` and the local chart instead of the registry artifact.

use clap::{Args, ValueEnum};
use std::process::Command;

#[derive(Clone, Debug, ValueEnum, Default)]
pub enum Mode {
    /// One container, all 5 units inside (process-compose orchestrates).
    /// Invocation: `docker run`. The simplest surface — no orchestrator.
    Container,
    /// Three services on a compose network (otelcol + gateway + runner).
    /// Invocation: `docker compose up`. Default.
    #[default]
    Compose,
    /// One k8s `Job` + one Pod + three containers (NetworkPolicy on runner).
    /// Invocation: `kubectl apply`. Production k8s surface.
    Job,
}

#[derive(Args)]
pub struct RunArgs {
    /// Benchmark name (positional shortcut for --benchmark, maps to $EVAL_BENCHMARK)
    #[arg(value_name = "BENCHMARK")]
    benchmark_positional: Option<String>,

    /// Benchmark name (maps to $EVAL_BENCHMARK)
    #[arg(long = "benchmark")]
    benchmark_flag: Option<String>,

    /// Deployment surface to use. See benchmarks/RULES.md rule 24.
    #[arg(long, value_enum, default_value_t = Mode::Compose)]
    mode: Mode,

    /// Agent to use (maps to $EVAL_AGENT)
    #[arg(long)]
    agent: Option<String>,

    /// Model to use (maps to $EVAL_MODEL)
    #[arg(long)]
    model: Option<String>,

    /// Task ID within the benchmark (maps to $EVAL_TASK_ID)
    #[arg(long)]
    task_id: Option<String>,

    // ---- Container tags (which image to pull) ----
    /// Benchmark image tag (maps to $EVAL_BENCHMARK_TAG)
    #[arg(long)]
    benchmark_tag: Option<String>,

    /// Agent image tag (maps to $EVAL_AGENT_TAG)
    #[arg(long)]
    agent_tag: Option<String>,

    /// Model image tag (maps to $EVAL_MODEL_TAG)
    #[arg(long)]
    model_tag: Option<String>,

    // NOTE: upstream versions (benchmark dataset revision, agent CLI version,
    // litellm version) are a BUILD-time axis (RULES.md principle 9): pinned via
    // `ARG *_VERSION` in each image and overridden at `build` time, not here.
    // There is no runtime override — the running version is whatever the image
    // was built with, recorded in its label.
    /// Agent timeout in seconds (maps to $EVAL_TIMEOUT)
    #[arg(long)]
    timeout: Option<u32>,

    /// Hard cap on model spend in USD for this run (maps to
    /// $EVAL_MODEL_MAX_BUDGET). The litellm proxy enforces it and
    /// returns an error once spend crosses the cap, which crashes
    /// the agent's next request. Default: $1.
    #[arg(long)]
    max_budget: Option<f64>,

    /// Use the in-repo `benchmarks/<name>/` artifacts instead of the
    /// published registry artifact. For development.
    #[arg(long)]
    local: bool,

    /// Render and print what would happen — don't actually deploy. For
    /// `--mode job` this forwards `--dry-run=server` to `kubectl apply`,
    /// which exercises admission webhooks without persisting state. For
    /// `--mode compose` and `--mode container` this prints the resolved
    /// docker invocation and stops.
    #[arg(long)]
    dry_run: bool,

    /// Kubernetes namespace to target (maps to `kubectl -n <ns>`). Only
    /// applies to `--mode job`. Defaults to the current kubectl
    /// context's namespace.
    #[arg(long, short = 'n')]
    namespace: Option<String>,

    /// (`--mode job`) Layer a platform Helm values file on top of the
    /// chart values — e.g. `deploy/values-openshift.yaml`, which sets
    /// the anyuid SCC service account. Passed to helm as an extra `-f`.
    #[arg(long)]
    overlay: Option<String>,
}

/// Upstream gateway credentials forwarded into the container in single-image
/// mode (where the gateway runs in-process). Mirrors the keys the `eval-secrets`
/// Secret supplies in k8s and that `compose/services.yaml` reads from the shell.
const GATEWAY_CRED_VARS: &[&str] = &["OPENAI_API_KEY", "OPENAI_API_BASE"];

/// The shared Helm chart, published as
/// `oci://{registry}/charts/<CHART_NAME>:<CHART_VERSION>` and rendered by
/// `--mode job` (non-local). Mirrors `benchmarks/_chart/Chart.yaml`
/// (`name`/`version`); the guard test below fails if they drift.
const CHART_NAME: &str = "eval";
const CHART_VERSION: &str = "0.1.0";

pub fn execute(registry: &str, args: RunArgs) -> Result<(), String> {
    // Resolve benchmark: --benchmark flag wins over positional, either must be set.
    let benchmark = args
        .benchmark_flag
        .clone()
        .or_else(|| args.benchmark_positional.clone())
        .ok_or_else(|| "benchmark required (positional or --benchmark)".to_string())?;

    // Build the env var set. Every flag maps to EVAL_* per src/RULES.md rule 10.
    let mut envs: Vec<(&str, String)> = vec![
        ("EVAL_REGISTRY", registry.to_string()),
        ("EVAL_BENCHMARK", benchmark.clone()),
    ];
    if let Some(ref v) = args.agent {
        envs.push(("EVAL_AGENT", v.clone()));
    }
    if let Some(ref v) = args.model {
        envs.push(("EVAL_MODEL", v.clone()));
    }
    if let Some(ref v) = args.task_id {
        envs.push(("EVAL_TASK_ID", v.clone()));
    }

    // Container tags
    if let Some(ref v) = args.benchmark_tag {
        envs.push(("EVAL_BENCHMARK_TAG", v.clone()));
    }
    if let Some(ref v) = args.agent_tag {
        envs.push(("EVAL_AGENT_TAG", v.clone()));
    }
    if let Some(ref v) = args.model_tag {
        envs.push(("EVAL_MODEL_TAG", v.clone()));
    }

    if let Some(timeout) = args.timeout {
        envs.push(("EVAL_TIMEOUT", timeout.to_string()));
    }
    if let Some(budget) = args.max_budget {
        envs.push(("EVAL_MODEL_MAX_BUDGET", budget.to_string()));
    }

    if args.overlay.is_some() && !matches!(args.mode, Mode::Job) {
        return Err("--overlay applies only to `--mode job`".into());
    }

    match args.mode {
        Mode::Compose => run_compose(registry, &benchmark, &envs, args.local, args.dry_run),
        Mode::Container => run_container(
            registry,
            &benchmark,
            &args.agent,
            &envs,
            args.local,
            args.dry_run,
        ),
        Mode::Job => run_job(registry, &benchmark, &args, &envs),
    }
}

/// `--mode compose` → docker compose -f compose.yaml up
fn run_compose(
    registry: &str,
    benchmark: &str,
    envs: &[(&str, String)],
    local: bool,
    dry_run: bool,
) -> Result<(), String> {
    let compose_ref = if local {
        format!("./benchmarks/{benchmark}/compose.yaml")
    } else {
        format!("oci://{registry}/evaluate")
    };
    let env_str = envs
        .iter()
        .map(|(k, v)| format!("{k}={v}"))
        .collect::<Vec<_>>()
        .join(" ");
    eprintln!("$ {env_str} docker compose -f {compose_ref} up -y --abort-on-container-exit");
    if dry_run {
        // For compose, dry-run means show the resolved manifest (which
        // includes all `${EVAL_*:-default}` interpolations) and stop.
        // `docker compose config` is the canonical render command.
        eprintln!("(--dry-run: showing resolved compose config, not running)");
        let mut cmd = Command::new("docker");
        cmd.arg("compose").arg("-f").arg(&compose_ref).arg("config");
        for (k, v) in envs {
            cmd.env(k, v);
        }
        let status = cmd
            .status()
            .map_err(|e| format!("failed to run docker compose config: {e}"))?;
        if !status.success() {
            return Err(format!("docker compose config failed with {status}"));
        }
        return Ok(());
    }

    let mut cmd = Command::new("docker");
    cmd.arg("compose").arg("-f").arg(&compose_ref);
    // `-y`: a published `oci://` stack prompts to confirm (and echoes) the
    // variables it injects; assume yes so the run stays non-interactive.
    cmd.arg("up").arg("-y").arg("--abort-on-container-exit");
    for (k, v) in envs {
        cmd.env(k, v);
    }
    let status = cmd
        .status()
        .map_err(|e| format!("failed to run docker compose: {e}"))?;
    if !status.success() {
        return Err(format!("docker compose failed with {status}"));
    }
    Ok(())
}

/// `--mode container` → docker run -e ... <eval-image>
///
/// In `--local` mode the image is built first from
/// `benchmarks/<x>/container.Dockerfile`. Otherwise the registry-published
/// `evals/<benchmark>--<agent>:<tag>` image is pulled.
fn run_container(
    registry: &str,
    benchmark: &str,
    agent: &Option<String>,
    envs: &[(&str, String)],
    local: bool,
    dry_run: bool,
) -> Result<(), String> {
    let agent = agent
        .clone()
        .ok_or_else(|| "--agent is required in container mode".to_string())?;
    // Per-task benchmarks bake one eval image per task: address it by the
    // task-aware name (evals/<b>-<task>--<a>) and build container.Dockerfile with
    // EVAL_TASK_ID so its ARG/FROM resolve. Shared-env benchmarks ignore the
    // (harmless) build-arg. (benchmarks/RULES.md — eval-image naming.)
    let task_id = envs
        .iter()
        .find(|(k, _)| *k == "EVAL_TASK_ID")
        .map(|(_, v)| v.clone());
    let per_task = eval_containers::benchmark::is_per_task_by_name(benchmark);
    if per_task && task_id.is_none() {
        return Err(format!(
            "{benchmark} is a per-task benchmark — pass --task-id <id> in container mode"
        ));
    }
    let local_tag = match (per_task, task_id.as_deref()) {
        (true, Some(t)) => format!("evals/{benchmark}-{t}--{agent}:local"),
        _ => format!("evals/{benchmark}--{agent}:local"),
    };
    let image = if local {
        // Build from the per-benchmark container.Dockerfile, then run.
        let dockerfile = format!("./benchmarks/{benchmark}/container.Dockerfile");
        let shown_arg = task_id
            .as_deref()
            .map(|t| format!(" --build-arg EVAL_TASK_ID={t}"))
            .unwrap_or_default();
        eprintln!("$ docker build -f {dockerfile}{shown_arg} -t {local_tag} .");
        let mut build = Command::new("docker");
        build.arg("build").arg("-f").arg(&dockerfile);
        if let Some(t) = task_id.as_deref() {
            build.arg("--build-arg").arg(format!("EVAL_TASK_ID={t}"));
        }
        build.arg("-t").arg(&local_tag).arg(".");
        let status = build
            .status()
            .map_err(|e| format!("failed to docker build: {e}"))?;
        if !status.success() {
            return Err(format!("docker build failed with {status}"));
        }
        local_tag
    } else if per_task {
        // task_id is guaranteed Some by the per_task check above.
        eval_containers::naming::eval_task_image(
            registry,
            benchmark,
            task_id.as_deref().unwrap(),
            &agent,
            "latest",
        )
    } else {
        eval_containers::naming::eval_image(registry, benchmark, &agent, "latest")
    };

    let env_str = envs
        .iter()
        .map(|(k, v)| format!("-e {k}={v}"))
        .collect::<Vec<_>>()
        .join(" ");
    eprintln!("$ docker run --rm {env_str} -v output:/output {image}");
    if dry_run {
        eprintln!("(--dry-run: stopping before docker run)");
        return Ok(());
    }

    let mut cmd = Command::new("docker");
    cmd.arg("run").arg("--rm");
    for (k, v) in envs {
        cmd.arg("-e").arg(format!("{k}={v}"));
    }
    // Single-image mode runs the gateway in-container, so it needs the upstream
    // credentials the gateway service gets from `eval-secrets` (k8s) or the
    // shell env (compose). Forward them from the caller's environment with
    // docker's `-e NAME` passthrough (no value → not rendered into logs); unset
    // vars are skipped, so this is a no-op when the caller didn't provide them.
    for var in GATEWAY_CRED_VARS {
        if std::env::var_os(var).is_some() {
            cmd.arg("-e").arg(var);
        }
    }
    cmd.arg("-v").arg("output:/output");
    cmd.arg(&image);
    let status = cmd
        .status()
        .map_err(|e| format!("failed to docker run: {e}"))?;
    if !status.success() {
        return Err(format!("docker run failed with {status}"));
    }
    Ok(())
}

/// `--mode job` → `helm template oci://<registry>/charts/eval … | kubectl apply -f -`
/// (or `./benchmarks/_chart` with `--local`).
///
/// The shared chart (`benchmarks/_chart`) renders the otelcol+gateway+runner
/// Job; the axes (benchmark/agent/task/model/tags/versions) come in via `--set`,
/// and a benchmark's bespoke topology (if any) from the chart's
/// `presets/<x>.yaml`. Platform composition (e.g. the OpenShift
/// anyuid SCC) layers in as an extra `-f <values>` via `--overlay`. Helm fills
/// the values, keeps numeric fields (task) quoted, and leaves the runner
/// command's `$?`/`$rc` untouched — no kustomize overlay to synthesize.
/// See doctrine/benchmarks/RULES.md.
///
/// Cluster `eval-secrets` Secret still provides upstream credentials.
fn run_job(
    registry: &str,
    benchmark: &str,
    args: &RunArgs,
    _envs: &[(&str, String)],
) -> Result<(), String> {
    let agent = args.agent.as_deref().unwrap_or("claude-code");
    let task = args.task_id.as_deref().unwrap_or("0");

    // Chart source mirrors compose/container: `--local` renders the in-repo
    // chart; otherwise pull the published OCI chart so `--mode job` needs no repo
    // checkout (src/RULES.md principle 8 registry-aware, principle 9 local-first).
    let chart = if args.local {
        let local = "./benchmarks/_chart".to_string();
        if !std::path::Path::new(&local).exists() {
            return Err("--local needs ./benchmarks/_chart; run from the repo root".into());
        }
        local
    } else {
        format!("oci://{registry}/charts/{CHART_NAME}")
    };

    // helm template <release> <chart> [--version <v>] [-f <overlay>] --set benchmark=… …
    // The benchmark is named via --set; its bespoke topology (if any) lives in
    // the chart at presets/<benchmark>.yaml, so no per-benchmark file is passed.
    // The release name is a DNS-1123 label (Helm forbids `_`); per-task task ids
    // carry forbidden chars (SWE-bench's `sympy__sympy-24066`), so sanitize it or
    // `--mode job` can't render for per-task benchmarks (benchmarks/RULES.md 24f).
    let release =
        eval_containers::naming::release_name(&format!("{benchmark}-{agent}-task-{task}"));
    let mut helm: Vec<String> = vec!["template".into(), release, chart];
    // OCI charts are versioned; pin the published version (the `--local` dir needs none).
    if !args.local {
        helm.push("--version".into());
        helm.push(CHART_VERSION.into());
    }

    // Platform composition: --overlay points at a Helm values file (e.g.
    // deploy/values-openshift.yaml), layered on top of the chart values.
    if let Some(ov) = &args.overlay {
        if !std::path::Path::new(ov).exists() {
            return Err(format!(
                "overlay values file not found: {ov} (a platform overlay is now a \
                 Helm values file, e.g. deploy/values-openshift.yaml)"
            ));
        }
        helm.push("-f".into());
        helm.push(ov.clone());
    }

    // Per-run axes → --set (one each, so values containing commas are safe).
    // --model maps to EVAL_MODEL (the upstream the fixed gateway proxies to)
    // plus the runner's MODEL logging tag — matching the prior behavior.
    let mut sets: Vec<String> = vec![
        format!("benchmark={benchmark}"),
        format!("registry={registry}"),
        format!("agent={agent}"),
        format!("task={task}"),
    ];
    // Per-task benchmarks bake one eval image per task, so the chart must render
    // the task-aware runner image (evals/<b>-<task>--<a>). Each runs as one Job
    // per task — they can't use the Indexed dataset Job (one image × N indices);
    // the chart enforces that with a perTask+datasetSize guard. (benchmarks/RULES.md.)
    if eval_containers::benchmark::is_per_task_by_name(benchmark) {
        sets.push("perTask=true".into());
    }
    if let Some(m) = &args.model {
        sets.push(format!("evalModel={m}"));
        sets.push(format!("model={m}"));
    }
    if let Some(t) = args.timeout {
        sets.push(format!("timeout={t}"));
    }
    if let Some(t) = &args.model_tag {
        sets.push(format!("gatewayTag={t}"));
    }
    // The combined runner image is produced per-agent, so --agent-tag wins over
    // --benchmark-tag when both are set.
    if let Some(t) = args.agent_tag.as_ref().or(args.benchmark_tag.as_ref()) {
        sets.push(format!("runnerTag={t}"));
    }
    if let Some(b) = args.max_budget {
        sets.push(format!("maxBudget={b}"));
    }
    for s in &sets {
        helm.push("--set".into());
        helm.push(s.clone());
    }

    // kubectl apply [-n ns] [--dry-run=server] -f -
    let mut apply_args: Vec<String> = vec!["apply".into()];
    if args.dry_run {
        apply_args.push("--dry-run=server".into());
    }
    if let Some(ns) = &args.namespace {
        apply_args.push("-n".into());
        apply_args.push(ns.clone());
    }

    eprintln!(
        "$ helm {} | kubectl {} -f -",
        helm.join(" "),
        apply_args.join(" ")
    );
    eprintln!("(Note: cluster needs `eval-secrets` Secret with OPENAI_API_KEY+OPENAI_API_BASE.)");

    let helm_out = Command::new("helm")
        .args(&helm)
        .output()
        .map_err(|e| format!("failed to run helm template (is helm installed?): {e}"))?;
    if !helm_out.status.success() {
        return Err(format!(
            "helm template failed: {}",
            String::from_utf8_lossy(&helm_out.stderr)
        ));
    }

    use std::process::Stdio;
    let mut apply_cmd = Command::new("kubectl");
    for a in &apply_args {
        apply_cmd.arg(a);
    }
    apply_cmd.args(["-f", "-"]);
    let mut apply = apply_cmd
        .stdin(Stdio::piped())
        .spawn()
        .map_err(|e| format!("failed to spawn kubectl apply: {e}"))?;
    {
        use std::io::Write;
        apply
            .stdin
            .as_mut()
            .unwrap()
            .write_all(&helm_out.stdout)
            .map_err(|e| format!("failed to pipe manifest to kubectl apply: {e}"))?;
    }
    let status = apply
        .wait()
        .map_err(|e| format!("failed to wait on kubectl apply: {e}"))?;
    if !status.success() {
        return Err(format!("kubectl apply failed with {status}"));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{CHART_NAME, CHART_VERSION};

    // `--mode job` (non-local) renders `oci://…/charts/{CHART_NAME}` pinned to
    // {CHART_VERSION}; both MUST track benchmarks/_chart/Chart.yaml, or the
    // published chart and the CLI silently drift apart.
    #[test]
    fn chart_consts_match_chart_yaml() {
        let yaml = std::fs::read_to_string("benchmarks/_chart/Chart.yaml")
            .expect("read benchmarks/_chart/Chart.yaml from the repo root");
        assert!(
            yaml.lines()
                .any(|l| l.trim() == format!("name: {CHART_NAME}")),
            "CHART_NAME ({CHART_NAME}) must match Chart.yaml `name`"
        );
        assert!(
            yaml.lines()
                .any(|l| l.trim() == format!("version: {CHART_VERSION}")),
            "CHART_VERSION ({CHART_VERSION}) must match Chart.yaml `version`"
        );
    }
}