1mod error;
2mod judge;
3mod report;
4mod runner;
5mod types;
6
7pub use error::EvalFileError;
8pub use judge::{Judge, JudgeBuilder, JudgeContext, JudgeCriterionResponse, JudgeError, JudgeRubricResponse, judge};
9pub use report::{EvalFilesReport, EvalOutcome, EvalStreamEvent, EvalToolCall, JudgeCriterionSummary, JudgeSummary};
10pub use runner::{WorkspaceRetention, run_eval_spec_streaming, run_eval_specs};
11use serde_json::from_str;
12pub(crate) use types::ResolvedDocker;
13pub use types::{
14 AgentSpec, DockerSpec, Expect, JudgeCriterionSpec, JudgeRef, JudgeSpec, TaskSpec, ToolCallExpectation,
15 WorkspaceSpec,
16};
17
18use schemars::{JsonSchema, Schema, schema_for};
19use serde::Deserialize;
20use std::{
21 collections::BTreeSet,
22 fs::read_to_string,
23 path::{Path, PathBuf},
24};
25
26use crate::{CONTAINER_AETHER_HOME, DockerAgent, default_eval_env_vars};
27
28const DEFAULT_EVALS_DIR: &str = "evals";
29
30#[derive(Debug, Clone, Deserialize, JsonSchema)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct EvalSpec {
34 pub name: String,
35 pub docker: DockerSpec,
37
38 pub agent: AgentSpec,
40
41 pub task: TaskSpec,
42 #[serde(default)]
43 pub expect: Expect,
44}
45
46impl EvalSpec {
47 pub fn schema() -> Schema {
49 schema_for!(Self)
50 }
51}
52
53#[derive(Debug, Clone, Default)]
55pub struct EvalSpecLoadOptions {
56 pub paths: Vec<PathBuf>,
58 pub filter: Option<String>,
60}
61
62#[derive(Debug)]
64pub struct ResolvedEvalSpec {
65 pub name: String,
66 pub base_dir: PathBuf,
67 pub(crate) docker: ResolvedDocker,
68 pub(crate) agent: AgentSpec,
69 pub(crate) task: TaskSpec,
70 pub(crate) expect: Expect,
71 pub(crate) judge: Option<JudgeSpec>,
72}
73
74impl ResolvedEvalSpec {
75 pub fn load(options: EvalSpecLoadOptions) -> Result<Vec<Self>, EvalFileError> {
79 let paths = if options.paths.is_empty() { vec![PathBuf::from(DEFAULT_EVALS_DIR)] } else { options.paths };
80 let mut spec_paths = Vec::new();
81 for path in &paths {
82 discover_specs_at(path, &mut spec_paths)?;
83 }
84
85 spec_paths.sort();
86 spec_paths.dedup();
87 let mut cases = spec_paths.into_iter().map(Self::load_file).collect::<Result<Vec<_>, _>>()?;
88 if cases.is_empty() {
89 return Err(EvalFileError::NoEvalFilesFound { paths });
90 }
91
92 reject_duplicate_names(&cases)?;
93
94 if let Some(name) = &options.filter {
95 cases.retain(|case| &case.name == name);
96 if cases.is_empty() {
97 return Err(EvalFileError::NoMatchingEval { name: name.clone() });
98 }
99 }
100
101 Ok(cases)
102 }
103
104 pub fn load_file(path: impl AsRef<Path>) -> Result<Self, EvalFileError> {
107 let path = path.as_ref();
108 let path =
109 path.canonicalize().map_err(|source| EvalFileError::ReadEvalFile { path: path.to_path_buf(), source })?;
110
111 let content =
112 read_to_string(&path).map_err(|source| EvalFileError::ReadEvalFile { path: path.clone(), source })?;
113
114 let eval: EvalSpec =
115 from_str(&content).map_err(|source| EvalFileError::ParseEvalFile { path: path.clone(), source })?;
116
117 let base_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
118 Self::resolve(eval, base_dir)
119 }
120
121 pub fn resolve(eval: EvalSpec, base_dir: impl Into<PathBuf>) -> Result<Self, EvalFileError> {
124 let EvalSpec { name, docker, agent, task, expect } = eval;
125 let base_dir = base_dir.into();
126 agent.validate().map_err(|message| EvalFileError::InvalidAgentCommand { message })?;
127
128 let docker = docker.resolve(&base_dir)?;
129 let judge = expect.judge.as_ref().map(|judge| judge.resolve(&base_dir)).transpose()?;
130
131 Ok(Self { name, base_dir, docker, agent, task, expect, judge })
132 }
133}
134
135impl ResolvedEvalSpec {
136 pub(crate) fn name(&self) -> &str {
137 &self.name
138 }
139
140 pub(crate) fn task(&self) -> Result<crate::Task, crate::WorkspaceError> {
141 self.task.build(&self.base_dir)
142 }
143
144 pub(crate) fn agent(&self) -> DockerAgent {
145 DockerAgent::new(self.docker.image.clone(), self.agent.command.clone())
146 .with_env_vars(default_eval_env_vars())
147 .with_env_vars(self.agent.env.clone())
148 .with_ephemeral_mount(CONTAINER_AETHER_HOME)
149 }
150
151 pub(crate) fn expectations(&self) -> &Expect {
152 &self.expect
153 }
154}
155
156fn discover_specs_at(path: &Path, spec_paths: &mut Vec<PathBuf>) -> Result<(), EvalFileError> {
157 if path.is_file() {
158 spec_paths.push(path.to_path_buf());
159 return Ok(());
160 }
161
162 if !path.is_dir() {
163 return Err(EvalFileError::EvalPathNotFound { path: path.to_path_buf() });
164 }
165
166 let mut entries = std::fs::read_dir(path)
167 .map_err(|source| EvalFileError::DiscoverEvalFiles { path: path.to_path_buf(), source })?
168 .collect::<Result<Vec<_>, _>>()
169 .map_err(|source| EvalFileError::DiscoverEvalFiles { path: path.to_path_buf(), source })?;
170
171 entries.sort_by_key(std::fs::DirEntry::path);
172 for entry in entries {
173 let path = entry.path();
174 if path.is_dir() {
175 discover_specs_at(&path, spec_paths)?;
176 } else if is_discovered_spec(&path) {
177 spec_paths.push(path);
178 }
179 }
180
181 Ok(())
182}
183
184fn is_discovered_spec(path: &Path) -> bool {
185 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
186 return false;
187 };
188
189 file_name.ends_with(".eval.json")
190}
191
192fn reject_duplicate_names(cases: &[ResolvedEvalSpec]) -> Result<(), EvalFileError> {
193 let mut names = BTreeSet::new();
194 for case in cases {
195 if !names.insert(case.name.as_str()) {
196 return Err(EvalFileError::DuplicateEvalName { name: case.name.clone() });
197 }
198 }
199 Ok(())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn parses_camel_case_spec_with_inline_files() {
208 let spec: EvalSpec = serde_json::from_str(
209 r#"{"docker":{"file":"Dockerfile","image":"sandbox:latest","context":"../../.."},"name":"edit","agent":{"command":["agent"]},"task":{"prompt":"do it","workspace":{"files":{"a.txt":"hi"}}},"expect":{"toolCalls":{"coding__read_file":{"atLeast":1},"coding__edit_file":{"exactly":1}},"files":{"a.txt":"bye"},"judge":{"model":"anthropic:claude-sonnet-4-5","instructions":"grade it","contextFiles":["a.txt"],"criteria":[{"id":"behavior","description":"did it work?"},{"id":"clarity","description":"explained it","blocking":false,"weight":0.5,"threshold":0.7}]}}}"#,
210 )
211 .unwrap();
212
213 assert!(matches!(
214 &spec.docker,
215 DockerSpec::Build { file, image: Some(image), context: Some(context) }
216 if file == Path::new("Dockerfile") && image == "sandbox:latest" && context == Path::new("../../..")
217 ));
218 assert_eq!(spec.name, "edit");
219 assert!(matches!(&spec.task.workspace, WorkspaceSpec::Files(files) if files["a.txt"] == "hi"));
220 assert!(matches!(spec.expect.tool_calls["coding__read_file"], ToolCallExpectation::AtLeast(1)));
221 assert!(matches!(spec.expect.tool_calls["coding__edit_file"], ToolCallExpectation::Exactly(1)));
222 let Some(JudgeRef::Inline(judge)) = &spec.expect.judge else {
223 panic!("expected an inline judge, got {:?}", spec.expect.judge);
224 };
225 assert_eq!(judge.instructions.as_deref(), Some("grade it"));
226 assert_eq!(judge.context_files, vec!["a.txt".to_string()]);
227 assert_eq!(judge.criteria[0].id, "behavior");
228 assert!(judge.criteria[0].blocking);
229 assert!((judge.criteria[0].weight - 1.0).abs() < f64::EPSILON);
230 assert!((judge.criteria[0].threshold - 1.0).abs() < f64::EPSILON);
231 assert!(!judge.criteria[1].blocking);
232 assert!((judge.criteria[1].weight - 0.5).abs() < f64::EPSILON);
233 assert!((judge.criteria[1].threshold - 0.7).abs() < f64::EPSILON);
234 }
235
236 #[test]
237 fn parses_git_and_dir_workspaces() {
238 let git: EvalSpec = serde_json::from_str(
239 r#"{"docker":{"image":"sandbox:latest"},"name":"g","agent":{"command":["agent"]},"task":{"prompt":"p","workspace":{"git":{"url":"u","startCommit":"a","goldCommit":"b"}}}}"#,
240 )
241 .unwrap();
242 assert!(matches!(git.task.workspace, WorkspaceSpec::Git(_)));
243
244 let dir: EvalSpec = serde_json::from_str(
245 r#"{"docker":{"image":"sandbox:latest"},"name":"d","agent":{"command":["agent"]},"task":{"prompt":"p","workspace":{"dir":"fixtures/x"}}}"#,
246 )
247 .unwrap();
248 assert!(matches!(dir.task.workspace, WorkspaceSpec::Dir(_)));
249 }
250
251 #[test]
252 fn rejects_invalid_docker_specs() {
253 for (json, expected) in [
254 (r#"{"docker":{},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#, "docker must specify"),
255 (
256 r#"{"docker":{"image":"sandbox:latest","context":"x"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#,
257 "`context` requires a Dockerfile `file`",
258 ),
259 (
260 r#"{"docker":{"dockerfile":"Dockerfile"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"}}"#,
261 "unknown field `dockerfile`",
262 ),
263 ] {
264 let error = serde_json::from_str::<EvalSpec>(json).unwrap_err().to_string();
265 assert!(error.contains(expected), "expected {expected:?} in {error:?}");
266 }
267 }
268
269 #[test]
270 fn rejects_invalid_tool_call_expectations() {
271 for json in [
272 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"toolCalls":{"bash":{}}}}"#,
273 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"toolCalls":{"bash":{"atLeast":1,"exactly":1}}}}"#,
274 ] {
275 assert!(serde_json::from_str::<EvalSpec>(json).is_err(), "expected rejection for {json}");
276 }
277
278 let unknown = serde_json::from_str::<EvalSpec>(
279 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"toolCalls":{"bash":{"minimum":1}}}}"#,
280 )
281 .unwrap_err()
282 .to_string();
283 assert!(unknown.contains("unknown variant `minimum`"), "got: {unknown}");
284 }
285
286 #[test]
287 fn rejects_judge_without_model() {
288 let result: Result<EvalSpec, _> = serde_json::from_str(
289 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"criteria":[{"id":"ok","description":"ok"}]}}}"#,
290 );
291
292 assert!(result.unwrap_err().to_string().contains("missing field `model`"));
293 }
294
295 #[test]
296 fn rejects_unknown_fields() {
297 for json in [
298 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"bogus":true}"#,
299 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"runtime":{"type":"acp","command":["a"]}}"#,
300 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"baseDir":"."}"#,
301 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"judge":null}"#,
302 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"type":"aether","command":["nope"]},"task":{"prompt":"p"}}"#,
303 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["node"],"modelName":"m"},"task":{"prompt":"p"}}"#,
304 ] {
305 let result: Result<EvalSpec, _> = serde_json::from_str(json);
306 assert!(result.is_err());
307 }
308 }
309
310 #[test]
311 fn rejects_workspace_with_more_than_one_source() {
312 let result: Result<EvalSpec, _> = serde_json::from_str(
313 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p","workspace":{"files":{},"dir":"x"}}}"#,
314 );
315
316 assert!(result.is_err());
317 }
318
319 #[test]
320 fn rejects_workspace_with_unknown_field_by_name() {
321 let result: Result<EvalSpec, _> = serde_json::from_str(
322 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p","workspace":{"dirr":"x"}}}"#,
323 );
324
325 assert!(result.unwrap_err().to_string().contains("unknown variant `dirr`"));
326 }
327
328 #[test]
329 fn rejects_git_workspace_with_unknown_field_by_name() {
330 let result: Result<EvalSpec, _> = serde_json::from_str(
331 r#"{"docker":{"image":"sandbox:latest"},"name":"g","agent":{"command":["agent"]},"task":{"prompt":"p","workspace":{"git":{"url":"u","startCommit":"a","goldComit":"b"}}}}"#,
332 );
333
334 assert!(result.unwrap_err().to_string().contains("unknown field `goldComit`"));
335 }
336
337 #[test]
338 fn rejects_invalid_judge_criteria() {
339 for (json, expected) in [
340 (
341 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[]}}}"#,
342 "criteria must not be empty",
343 ),
344 (
345 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[{"id":"","description":"ok"}]}}}"#,
346 "id must not be empty",
347 ),
348 (
349 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok"},{"id":"a","description":"ok"}]}}}"#,
350 "duplicate judge criterion id `a`",
351 ),
352 (
353 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[{"id":"a","description":""}]}}}"#,
354 "description must not be empty",
355 ),
356 (
357 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok","weight":0.0}]}}}"#,
358 "weight must be positive and finite",
359 ),
360 (
361 r#"{"docker":{"image":"sandbox:latest"},"name":"c","agent":{"command":["agent"]},"task":{"prompt":"p"},"expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok","threshold":1.5}]}}}"#,
362 "threshold must be between 0.0 and 1.0",
363 ),
364 ] {
365 let eval = serde_json::from_str::<EvalSpec>(json).unwrap();
366 let error = ResolvedEvalSpec::resolve(eval, ".").unwrap_err().to_string();
367 assert!(error.contains(expected), "expected {expected:?} in {error:?}");
368 }
369 }
370}