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#[derive(Debug, Clone, Copy)]
15pub enum WorkspaceRetention {
16 Discard,
17 Retain,
18}
19
20pub 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}