Skip to main content

aether_evals/spec/
runner.rs

1use super::ResolvedEvalSpec;
2use super::error::EvalFileError;
3use super::judge::Judge;
4use super::report::{EvalOutcome, JudgeSummary};
5use crate::agents::{DockerAgent, build_images};
6use crate::evals::TaskRun;
7use futures::StreamExt;
8use futures::stream::iter;
9use llm::StreamingModelProvider;
10use llm::parser::ModelProviderParser;
11use std::num::NonZeroUsize;
12
13/// Whether the eval workspace is retained on disk after the run.
14#[derive(Debug, Clone, Copy)]
15pub enum WorkspaceRetention {
16    Discard,
17    Retain,
18}
19
20/// Run resolved eval specs. Agents and judge models are assembled before anything runs, so a
21/// broken case fails fast. Docker image builds are deduplicated and run concurrently before eval
22/// execution. Outcomes are returned in input order.
23pub async fn run_eval_specs(
24    specs: impl IntoIterator<Item = ResolvedEvalSpec>,
25    retention: WorkspaceRetention,
26    max_concurrency: NonZeroUsize,
27) -> Result<Vec<EvalOutcome>, EvalFileError> {
28    let mut prepared = Vec::new();
29    for spec in specs {
30        let agent = spec.agent();
31        let judge_llm = parse_judge_llm(&spec).await?;
32        prepared.push((spec, agent, judge_llm));
33    }
34
35    build_images(prepared.iter().filter_map(|(case, _, _)| case.docker.build.clone()), max_concurrency).await?;
36    let futures = prepared
37        .into_iter()
38        .map(|(case, agent, judge_llm)| async move { run_eval_spec(case, agent, judge_llm, retention).await });
39
40    let evals = iter(futures).buffered(max_concurrency.get()).collect().await;
41    Ok(evals)
42}
43
44async fn run_eval_spec(
45    spec: ResolvedEvalSpec,
46    agent: DockerAgent,
47    judge_llm: Option<Box<dyn StreamingModelProvider>>,
48    retention: WorkspaceRetention,
49) -> EvalOutcome {
50    let task = match spec.task() {
51        Ok(task) => task,
52        Err(error) => return EvalOutcome::setup_failed(spec.name(), error),
53    };
54
55    let run = match task.run(&agent).await {
56        Ok(run) => run,
57        Err(error) => {
58            let (run, error) = error.into_parts();
59            return EvalOutcome::from_task_run(
60                spec.name(),
61                run,
62                vec![format!("agent run failed: {error}")],
63                None,
64                retention,
65            );
66        }
67    };
68
69    let mut failures = spec.expectations().evaluate(&run);
70    let judge = run_judge(&spec, &run, &mut failures, judge_llm.as_deref()).await;
71    EvalOutcome::from_task_run(spec.name(), run, failures, judge, retention)
72}
73
74async fn run_judge(
75    case: &ResolvedEvalSpec,
76    run: &TaskRun,
77    failures: &mut Vec<String>,
78    judge_llm: Option<&dyn StreamingModelProvider>,
79) -> Option<JudgeSummary> {
80    let Some(spec) = &case.judge else {
81        return None;
82    };
83    let llm = judge_llm.expect("judge model is parsed before any eval runs when a judge is configured");
84    match Judge::new(llm, run, case.expectations(), spec).run().await {
85        Ok(summary) => {
86            failures.extend(summary.blocking_failures());
87            Some(summary)
88        }
89        Err(error) => {
90            failures.push(format!("judge failed: {error}"));
91            None
92        }
93    }
94}
95
96async fn parse_judge_llm(spec: &ResolvedEvalSpec) -> Result<Option<Box<dyn StreamingModelProvider>>, EvalFileError> {
97    let Some(judge) = &spec.judge else {
98        return Ok(None);
99    };
100    let (llm, _) = ModelProviderParser::default()
101        .parse(&judge.model)
102        .await
103        .map_err(|source| EvalFileError::JudgeModel { model: judge.model.clone(), source })?;
104    Ok(Some(llm))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::FakeAgent;
111    use crate::agents::{DockerImage, ImageBuildError};
112    use crate::spec::{EvalFileError, EvalSpec, EvalSpecLoadOptions, ResolvedEvalSpec};
113    use std::fs::{read_to_string, remove_dir_all};
114    use std::path::{Path, PathBuf};
115
116    fn eval_case(json: &str) -> ResolvedEvalSpec {
117        ResolvedEvalSpec::resolve(serde_json::from_value(eval_json_with_defaults(json)).unwrap(), ".").unwrap()
118    }
119
120    fn eval_case_in(json: &str, base_dir: &Path) -> ResolvedEvalSpec {
121        ResolvedEvalSpec::resolve(serde_json::from_value(eval_json_with_defaults(json)).unwrap(), base_dir).unwrap()
122    }
123
124    fn eval_json_with_defaults(json: &str) -> serde_json::Value {
125        let mut value: serde_json::Value = serde_json::from_str(json).unwrap();
126        if let serde_json::Value::Object(object) = &mut value {
127            object.entry("docker").or_insert_with(|| serde_json::json!({ "image": "sandbox:latest" }));
128            object.entry("agent").or_insert_with(|| serde_json::json!({ "command": ["agent"] }));
129        }
130        value
131    }
132
133    async fn run_case_with_agent(
134        case: ResolvedEvalSpec,
135        agent: &impl crate::Agent,
136        retention: WorkspaceRetention,
137    ) -> EvalOutcome {
138        let task = match case.task() {
139            Ok(task) => task,
140            Err(error) => return EvalOutcome::setup_failed(case.name(), error),
141        };
142        match task.run(agent).await {
143            Ok(run) => {
144                let failures = case.expectations().evaluate(&run);
145                EvalOutcome::from_task_run(case.name(), run, failures, None, retention)
146            }
147            Err(error) => {
148                let (run, error) = error.into_parts();
149                EvalOutcome::from_task_run(
150                    case.name(),
151                    run,
152                    vec![format!("agent run failed: {error}")],
153                    None,
154                    retention,
155                )
156            }
157        }
158    }
159
160    #[tokio::test]
161    async fn run_passes_when_all_expectations_met() {
162        let case = eval_case(
163            r#"{"name":"expectations","task":{"prompt":"do the thing","workspace":{"files":{"notes.txt":"beta\n"}}},"expect":{"toolCalls":{"bash":{"exactly":1}},"files":{"notes.txt":"beta\n"},"filesContain":{"notes.txt":"beta"}}}"#,
164        );
165
166        let outcome =
167            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Discard).await;
168
169        assert!(outcome.passed, "failures: {:?}", outcome.failures);
170    }
171
172    #[tokio::test]
173    async fn run_reports_tool_calls_for_native_assertions() {
174        let case = eval_case(r#"{"name":"observations","task":{"prompt":"p"}}"#);
175
176        let outcome = run_case_with_agent(
177            case,
178            &FakeAgent::with_tool_call("weather__get_current", "ok"),
179            WorkspaceRetention::Discard,
180        )
181        .await;
182
183        assert_eq!(outcome.tool_calls.len(), 1);
184        assert_eq!(outcome.tool_calls[0].name, "weather__get_current");
185        assert_eq!(outcome.tool_calls[0].arguments, Some(serde_json::json!({})));
186        assert_eq!(outcome.tool_calls[0].raw_arguments, "{}");
187    }
188
189    #[tokio::test]
190    async fn run_keep_workspace_persists_directory_and_reports_path() {
191        let case = eval_case(r#"{"name":"keep","task":{"prompt":"p","workspace":{"files":{"notes.txt":"hi\n"}}}}"#);
192
193        let outcome =
194            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Retain).await;
195
196        let workspace = outcome.retained_workspace.expect("workspace is reported when retained");
197        assert!(workspace.root_path.exists(), "retained workspace should outlive the run");
198        assert_eq!(workspace.root_path, workspace.path);
199        assert_eq!(read_to_string(workspace.path.join("notes.txt")).unwrap(), "hi\n");
200        remove_dir_all(&workspace.root_path).expect("caller cleans up the retained workspace");
201    }
202
203    #[tokio::test]
204    async fn run_reports_no_retained_workspace_when_not_retained() {
205        let case = eval_case(r#"{"name":"drop","task":{"prompt":"p","workspace":{"files":{"a.txt":"x"}}}}"#);
206
207        let outcome =
208            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Discard).await;
209
210        assert!(outcome.retained_workspace.is_none());
211    }
212
213    #[tokio::test]
214    async fn run_reports_missing_tool_and_wrong_count() {
215        let case = eval_case(
216            r#"{"name":"counts","task":{"prompt":"p"},"expect":{"toolCalls":{"read":{"atLeast":1},"bash":{"exactly":2}}}}"#,
217        );
218
219        let outcome =
220            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Discard).await;
221
222        assert!(!outcome.passed);
223        assert!(outcome.failures.iter().any(|f| f.contains("`read`")));
224        assert!(outcome.failures.iter().any(|f| f.contains("`bash`") && f.contains("2 time")));
225    }
226
227    #[tokio::test]
228    async fn run_reports_file_mismatch_and_missing_file() {
229        let case = eval_case(
230            r#"{"name":"files","task":{"prompt":"p","workspace":{"files":{"notes.txt":"alpha\n"}}},"expect":{"files":{"notes.txt":"beta\n"},"filesContain":{"missing.txt":"x"}}}"#,
231        );
232
233        let outcome =
234            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Discard).await;
235
236        assert!(!outcome.passed);
237        assert!(outcome.failures.iter().any(|f| f.contains("`notes.txt`") && f.contains("mismatch")));
238        assert!(outcome.failures.iter().any(|f| f.contains("`missing.txt`") && f.contains("could not be read")));
239    }
240
241    #[tokio::test]
242    async fn run_records_fault_as_failed_eval_instead_of_aborting() {
243        let base_dir = tempfile::tempdir().unwrap();
244        let case = eval_case_in(
245            r#"{"name":"broken","task":{"prompt":"p","workspace":{"dir":"missing-dir"}}}"#,
246            base_dir.path(),
247        );
248
249        let outcome =
250            run_case_with_agent(case, &FakeAgent::with_tool_call("bash", "ok"), WorkspaceRetention::Discard).await;
251
252        assert!(!outcome.passed);
253        assert!(outcome.failures[0].contains("workspace setup failed"), "got: {:?}", outcome.failures);
254    }
255
256    #[test]
257    fn load_rejects_missing_docker_section() {
258        let error = serde_json::from_str::<EvalSpec>(r#"{"name":"c","task":{"prompt":"p"}}"#).unwrap_err();
259
260        assert!(error.to_string().contains("missing field `docker`"), "got: {error}");
261    }
262
263    #[test]
264    fn load_rejects_missing_dockerfile() {
265        let base_dir = tempfile::tempdir().unwrap();
266        let eval: EvalSpec = serde_json::from_str(
267            r#"{"docker":{"file":"Dockerfile"},"name":"x","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#,
268        )
269        .unwrap();
270
271        let error = ResolvedEvalSpec::resolve(eval, base_dir.path()).unwrap_err();
272
273        assert!(matches!(error, EvalFileError::DockerPath { .. }));
274    }
275
276    #[tokio::test]
277    async fn run_evals_rejects_conflicting_image_tags_before_running() {
278        let dir = tempfile::tempdir().unwrap();
279        for (subdir, name) in [("a", "one"), ("b", "two")] {
280            let eval_dir = dir.path().join(subdir);
281            std::fs::create_dir_all(&eval_dir).unwrap();
282            std::fs::write(eval_dir.join("Dockerfile"), "FROM scratch").unwrap();
283            std::fs::write(
284                eval_dir.join(format!("{name}.eval.json")),
285                format!(
286                    r#"{{"docker":{{"file":"Dockerfile","image":"same:tag"}},"name":"{name}","agent":{{"command":["agent"]}},"task":{{"prompt":"p"}}}}"#
287                ),
288            )
289            .unwrap();
290        }
291        let cases = ResolvedEvalSpec::load(EvalSpecLoadOptions { paths: vec![dir.path().to_path_buf()], filter: None })
292            .unwrap();
293        let error =
294            run_eval_specs(cases, WorkspaceRetention::Discard, NonZeroUsize::new(2).unwrap()).await.unwrap_err();
295
296        assert!(matches!(error, EvalFileError::ImageBuild(ImageBuildError::ConflictingTag { .. })));
297    }
298
299    #[tokio::test]
300    async fn run_evals_rejects_invalid_judge_model_before_running() {
301        let dir = tempfile::tempdir().unwrap();
302        std::fs::write(
303            dir.path().join("bad-judge.eval.json"),
304            r#"{"docker":{"image":"sandbox:latest"},"name":"bad-judge","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"not-a-provider:nope","criteria":[{"id":"a","description":"ok"}]}}}"#,
305        )
306        .unwrap();
307
308        let cases = ResolvedEvalSpec::load(EvalSpecLoadOptions { paths: vec![dir.path().to_path_buf()], filter: None })
309            .unwrap();
310        let error =
311            run_eval_specs(cases, WorkspaceRetention::Discard, NonZeroUsize::new(1).unwrap()).await.unwrap_err();
312
313        assert!(matches!(error, EvalFileError::JudgeModel { .. }), "got: {error:?}");
314    }
315
316    #[test]
317    fn load_reads_spec_and_returns_its_directory() {
318        let dir = tempfile::tempdir().unwrap();
319        let path = dir.path().join("example.eval.json");
320        std::fs::write(
321            &path,
322            r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#,
323        )
324        .unwrap();
325
326        let case = ResolvedEvalSpec::load_file(&path).unwrap();
327
328        let docker = &case.docker;
329        assert_eq!(docker.image, DockerImage::new("sandbox", "latest"));
330        assert!(docker.build.is_none());
331        assert_eq!(case.name, "c");
332        assert_eq!(case.base_dir, dir.path().canonicalize().unwrap());
333    }
334
335    #[test]
336    fn load_resolves_dockerfile_build_relative_to_spec() {
337        let dir = tempfile::tempdir().unwrap();
338        std::fs::write(dir.path().join("Dockerfile"), "FROM scratch").unwrap();
339        let path = dir.path().join("example.eval.json");
340        std::fs::write(
341            &path,
342            r#"{"docker":{"file":"Dockerfile","image":"sandbox:dev"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#,
343        )
344        .unwrap();
345
346        let case = ResolvedEvalSpec::load_file(&path).unwrap();
347
348        let docker = &case.docker;
349        assert_eq!(docker.image, DockerImage::new("sandbox", "dev"));
350        let build = docker.build.as_ref().expect("expected a build request");
351        assert_eq!(build.dockerfile, dir.path().canonicalize().unwrap().join("Dockerfile"));
352        assert_eq!(build.context, dir.path().canonicalize().unwrap());
353        assert_eq!(build.tag, "sandbox:dev");
354    }
355
356    #[test]
357    fn load_distinguishes_missing_file_from_invalid_json() {
358        let missing = ResolvedEvalSpec::load_file("/nonexistent/example.eval.json").unwrap_err();
359        assert!(matches!(missing, EvalFileError::ReadEvalFile { .. }));
360
361        let dir = tempfile::tempdir().unwrap();
362        let path = dir.path().join("example.eval.json");
363        std::fs::write(&path, "not json").unwrap();
364        assert!(matches!(ResolvedEvalSpec::load_file(&path).unwrap_err(), EvalFileError::ParseEvalFile { .. }));
365    }
366
367    #[test]
368    fn load_resolves_shared_judge_file_relative_to_spec() {
369        let dir = tempfile::tempdir().unwrap();
370        std::fs::create_dir_all(dir.path().join("shared")).unwrap();
371        std::fs::write(
372            dir.path().join("shared/maintainer.judge.json"),
373            r#"{"model":"anthropic:claude-sonnet-4-5","instructions":"grade it","criteria":[{"id":"behavior","description":"works"}]}"#,
374        )
375        .unwrap();
376        let path = dir.path().join("nested/example.eval.json");
377        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
378        std::fs::write(
379            &path,
380            r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":"../shared/maintainer.judge.json"}}"#,
381        )
382        .unwrap();
383
384        let case = ResolvedEvalSpec::load_file(&path).unwrap();
385
386        let judge = case.judge.as_ref().expect("expected a resolved judge");
387        assert_eq!(judge.model, "anthropic:claude-sonnet-4-5");
388        assert_eq!(judge.instructions.as_deref(), Some("grade it"));
389        assert_eq!(judge.criteria[0].id, "behavior");
390    }
391
392    #[test]
393    fn load_rejects_missing_or_invalid_shared_judge_file() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("example.eval.json");
396        std::fs::write(
397            &path,
398            r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":"missing.judge.json"}}"#,
399        )
400        .unwrap();
401        assert!(matches!(ResolvedEvalSpec::load_file(&path).unwrap_err(), EvalFileError::ReadJudgeFile { .. }));
402
403        std::fs::write(dir.path().join("bad.judge.json"), r#"{"model":"m","criteria":[]}"#).unwrap();
404        std::fs::write(
405            &path,
406            r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":"bad.judge.json"}}"#,
407        )
408        .unwrap();
409        let error = ResolvedEvalSpec::load_file(&path).unwrap_err();
410        assert!(matches!(&error, EvalFileError::InvalidInlineJudge { .. }));
411        assert!(error.to_string().contains("criteria must not be empty"), "got: {error}");
412    }
413
414    #[test]
415    fn discovers_and_loads_specs_recursively() {
416        let dir = tempfile::tempdir().unwrap();
417        write_named_spec(dir.path().join("smoke.eval.json"), "smoke");
418        write_named_spec(dir.path().join("nested/edit.eval.json"), "edit");
419        write_named_spec(dir.path().join("nested/settings.json"), "settings");
420
421        let cases = ResolvedEvalSpec::load(EvalSpecLoadOptions { paths: vec![dir.path().to_path_buf()], filter: None })
422            .unwrap();
423        let names = cases.iter().map(|case| case.name.clone()).collect::<Vec<_>>();
424
425        assert_eq!(names, vec!["edit", "smoke"]);
426    }
427
428    #[test]
429    fn explicit_file_path_is_loaded_regardless_of_name() {
430        let dir = tempfile::tempdir().unwrap();
431        let path = dir.path().join("eval.json");
432        write_named_spec(&path, "explicit");
433
434        let cases = ResolvedEvalSpec::load(EvalSpecLoadOptions { paths: vec![path], filter: None }).unwrap();
435
436        assert_eq!(cases.len(), 1);
437        assert_eq!(cases[0].name, "explicit");
438        assert_eq!(cases[0].base_dir, dir.path().canonicalize().unwrap());
439    }
440
441    #[test]
442    fn load_filters_by_eval_name() {
443        let dir = tempfile::tempdir().unwrap();
444        write_named_spec(dir.path().join("smoke.eval.json"), "smoke");
445        write_named_spec(dir.path().join("edit.eval.json"), "edit");
446
447        let cases = ResolvedEvalSpec::load(EvalSpecLoadOptions {
448            paths: vec![dir.path().to_path_buf()],
449            filter: Some("smoke".to_string()),
450        })
451        .unwrap();
452
453        assert_eq!(cases.len(), 1);
454        assert_eq!(cases[0].name, "smoke");
455    }
456
457    #[test]
458    fn load_reports_missing_filter_match() {
459        let dir = tempfile::tempdir().unwrap();
460        write_named_spec(dir.path().join("smoke.eval.json"), "smoke");
461
462        let error = ResolvedEvalSpec::load(EvalSpecLoadOptions {
463            paths: vec![dir.path().to_path_buf()],
464            filter: Some("missing".to_string()),
465        })
466        .unwrap_err();
467
468        assert!(matches!(error, EvalFileError::NoMatchingEval { .. }));
469    }
470
471    #[test]
472    fn discover_reports_missing_paths() {
473        let missing = ResolvedEvalSpec::load(EvalSpecLoadOptions {
474            paths: vec![PathBuf::from("/nonexistent/evals")],
475            filter: None,
476        })
477        .unwrap_err();
478
479        assert!(matches!(missing, EvalFileError::EvalPathNotFound { .. }));
480    }
481
482    fn write_named_spec(path: impl AsRef<Path>, name: &str) {
483        let path = path.as_ref();
484        if let Some(parent) = path.parent() {
485            std::fs::create_dir_all(parent).unwrap();
486        }
487        std::fs::write(
488            path,
489            format!(r#"{{"docker":{{"image":"sandbox:latest"}},"name":"{name}","agent":{{"command":["agent"]}},"task":{{"prompt":"p"}}}}"#),
490        )
491        .unwrap();
492    }
493}