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