mod error;
mod judge;
mod report;
mod runner;
mod types;
pub use error::EvalFileError;
pub use report::{EvalFilesReport, EvalOutcome, JudgeCriterionSummary, JudgeSummary};
pub use runner::{EvalRunOptions, run_eval_files};
pub(crate) use types::{DockerSpec, Expect, JudgeSpec, ResolvedDocker, SettingsRef, WorkspaceSpec};
#[cfg(test)]
use types::{JudgeRef, ToolCallExpectation};
use self::judge::{Judge, JudgeError};
use crate::agents::{Agent, DockerAetherAgent};
use crate::{EvalRunError, WorkspaceError, run_eval};
use aether_project::AetherSettings;
use llm::StreamingModelProvider;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug)]
pub(crate) struct EvalCase {
pub eval: EvalSpec,
pub base_dir: PathBuf,
pub(crate) docker: Option<ResolvedDocker>,
pub(crate) judge: Option<JudgeSpec>,
pub(crate) settings: Option<AetherSettings>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct EvalSpec {
#[serde(default)]
pub docker: Option<DockerSpec>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub settings: Option<SettingsRef>,
pub name: String,
pub prompt: String,
#[serde(default)]
pub workspace: Option<WorkspaceSpec>,
#[serde(default)]
pub expect: Expect,
}
impl EvalSpec {
pub(crate) fn load(path: impl AsRef<Path>) -> Result<EvalCase, EvalFileError> {
let path = path.as_ref();
let path =
path.canonicalize().map_err(|source| EvalFileError::ReadEvalFile { path: path.to_path_buf(), source })?;
let content = std::fs::read_to_string(&path)
.map_err(|source| EvalFileError::ReadEvalFile { path: path.clone(), source })?;
let eval: EvalSpec = serde_json::from_str(&content)
.map_err(|source| EvalFileError::ParseEvalFile { path: path.clone(), source })?;
let base_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
EvalCase::new(eval, base_dir)
}
pub(crate) fn load_all(paths: &[PathBuf]) -> Result<Vec<EvalCase>, EvalFileError> {
let mut spec_paths = Vec::new();
for path in paths {
discover_specs_at(path, &mut spec_paths)?;
}
spec_paths.sort();
spec_paths.dedup();
spec_paths.into_iter().map(Self::load).collect()
}
}
impl EvalCase {
pub(crate) fn new(eval: EvalSpec, base_dir: impl Into<PathBuf>) -> Result<Self, EvalFileError> {
let base_dir = base_dir.into();
let docker = eval.docker.as_ref().map(|docker| docker.resolve(&base_dir)).transpose()?;
let judge = eval.expect.judge.as_ref().map(|judge| judge.resolve(&base_dir)).transpose()?;
let settings = eval.settings.as_ref().map(|settings| settings.resolve(&base_dir)).transpose()?;
Ok(Self { eval, base_dir, docker, judge, settings })
}
pub(crate) fn agent(&self) -> Result<DockerAetherAgent, EvalFileError> {
let docker = self.docker.as_ref().ok_or(EvalFileError::NoImage)?;
let mut agent = DockerAetherAgent::new(docker.image.clone());
if let Some(settings) = &self.settings {
agent = agent.with_settings(settings.clone());
}
if let Some(agent_name) = self.eval.agent.as_deref() {
agent = agent.with_agent(agent_name);
}
Ok(agent)
}
pub(crate) async fn run_with(
&self,
agent: &impl Agent,
judge_llm: Option<&dyn StreamingModelProvider>,
) -> EvalOutcome {
match self.run_checks(agent, judge_llm).await {
Ok(outcome) => outcome,
Err(error) => EvalOutcome {
name: self.eval.name.clone(),
passed: false,
failures: vec![error.to_string()],
judge: None,
failure_context: None,
},
}
}
async fn run_checks(
&self,
agent: &impl Agent,
judge_llm: Option<&dyn StreamingModelProvider>,
) -> Result<EvalOutcome, EvalRunFailure> {
let workspace = match &self.eval.workspace {
Some(spec) => spec.build(&self.base_dir)?,
None => crate::Workspace::empty()?,
};
let report = run_eval(agent, &self.eval.prompt, workspace).await?;
let mut failures = self.eval.expect.evaluate(&report);
let judge = match &self.judge {
Some(spec) => {
let llm = judge_llm.expect("judge model is parsed before any eval runs when a judge is configured");
let summary = Judge::new(llm, &report, &self.eval.expect, spec).run().await?;
failures.extend(summary.blocking_failures());
Some(summary)
}
None => None,
};
let passed = failures.is_empty();
let failure_context = (!passed).then(|| report.failure_context());
Ok(EvalOutcome { name: self.eval.name.clone(), passed, failures, judge, failure_context })
}
}
fn discover_specs_at(path: &Path, spec_paths: &mut Vec<PathBuf>) -> Result<(), EvalFileError> {
if path.is_file() {
spec_paths.push(path.to_path_buf());
return Ok(());
}
if !path.is_dir() {
return Err(EvalFileError::EvalPathNotFound { path: path.to_path_buf() });
}
let mut entries = std::fs::read_dir(path)
.map_err(|source| EvalFileError::DiscoverEvalFiles { path: path.to_path_buf(), source })?
.collect::<Result<Vec<_>, _>>()
.map_err(|source| EvalFileError::DiscoverEvalFiles { path: path.to_path_buf(), source })?;
entries.sort_by_key(std::fs::DirEntry::path);
for entry in entries {
let path = entry.path();
if path.is_dir() {
discover_specs_at(&path, spec_paths)?;
} else if is_discovered_spec(&path) {
spec_paths.push(path);
}
}
Ok(())
}
fn is_discovered_spec(path: &Path) -> bool {
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
return false;
};
file_name.ends_with(".eval.json")
}
#[derive(Debug, Error)]
enum EvalRunFailure {
#[error("workspace setup failed: {0}")]
Workspace(#[from] WorkspaceError),
#[error("agent run failed: {0}")]
Run(#[from] EvalRunError),
#[error("judge failed: {0}")]
Judge(#[from] JudgeError),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_camel_case_spec_with_inline_files() {
let spec: EvalSpec = serde_json::from_str(
r#"{
"docker": { "file": "Dockerfile", "image": "sandbox:latest", "context": "../../.." },
"name": "edit",
"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 }
]
}
}
}"#,
)
.unwrap();
assert!(matches!(
&spec.docker,
Some(DockerSpec::Build { file, image: Some(image), context: Some(context) })
if file == Path::new("Dockerfile") && image == "sandbox:latest" && context == Path::new("../../..")
));
assert_eq!(spec.name, "edit");
assert!(matches!(&spec.workspace, Some(WorkspaceSpec::Files(files)) if files["a.txt"] == "hi"));
assert!(matches!(spec.expect.tool_calls["coding__read_file"], ToolCallExpectation::AtLeast(1)));
assert!(matches!(spec.expect.tool_calls["coding__edit_file"], ToolCallExpectation::Exactly(1)));
let Some(JudgeRef::Inline(judge)) = &spec.expect.judge else {
panic!("expected an inline judge, got {:?}", spec.expect.judge);
};
assert_eq!(judge.instructions.as_deref(), Some("grade it"));
assert_eq!(judge.context_files, vec!["a.txt".to_string()]);
assert_eq!(judge.criteria[0].id, "behavior");
assert!(judge.criteria[0].blocking);
assert!((judge.criteria[0].weight - 1.0).abs() < f64::EPSILON);
assert!((judge.criteria[0].threshold - 1.0).abs() < f64::EPSILON);
assert!(!judge.criteria[1].blocking);
assert!((judge.criteria[1].weight - 0.5).abs() < f64::EPSILON);
assert!((judge.criteria[1].threshold - 0.7).abs() < f64::EPSILON);
}
#[test]
fn parses_git_and_dir_workspaces() {
let git: EvalSpec = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"g","prompt":"p","workspace":{"git":{"url":"u","startCommit":"a","goldCommit":"b"}}}"#,
)
.unwrap();
assert!(matches!(git.workspace, Some(WorkspaceSpec::Git(_))));
let dir: EvalSpec = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"d","prompt":"p","workspace":{"dir":"fixtures/x"}}"#,
)
.unwrap();
assert!(matches!(dir.workspace, Some(WorkspaceSpec::Dir(_))));
}
#[test]
fn rejects_invalid_docker_specs() {
for (json, expected) in [
(r#"{"docker":{},"name":"c","prompt":"p"}"#, "docker must specify"),
(
r#"{"docker":{"image":"sandbox:latest","context":"x"},"name":"c","prompt":"p"}"#,
"`context` requires a Dockerfile `file`",
),
(r#"{"docker":{"dockerfile":"Dockerfile"},"name":"c","prompt":"p"}"#, "unknown field `dockerfile`"),
] {
let error = serde_json::from_str::<EvalSpec>(json).unwrap_err().to_string();
assert!(error.contains(expected), "expected {expected:?} in {error:?}");
}
}
#[test]
fn rejects_invalid_tool_call_expectations() {
for (json, expected) in [
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"toolCalls":{"bash":{}}}}"#,
"exactly one of `atLeast` or `exactly`",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"toolCalls":{"bash":{"atLeast":1,"exactly":1}}}}"#,
"exactly one of `atLeast` or `exactly`",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"toolCalls":{"bash":{"minimum":1}}}}"#,
"unknown field `minimum`",
),
] {
let error = serde_json::from_str::<EvalSpec>(json).unwrap_err().to_string();
assert!(error.contains(expected), "expected {expected:?} in {error:?}");
}
}
#[test]
fn rejects_judge_without_model() {
let result: Result<EvalSpec, _> = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"criteria":[{"id":"ok","description":"ok"}]}}}"#,
);
assert!(result.unwrap_err().to_string().contains("missing field `model`"));
}
#[test]
fn rejects_unknown_fields() {
let result: Result<EvalSpec, _> =
serde_json::from_str(r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","bogus":true}"#);
assert!(result.is_err());
}
#[test]
fn rejects_workspace_with_more_than_one_source() {
let result: Result<EvalSpec, _> = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","workspace":{"files":{},"dir":"x"}}"#,
);
assert!(result.unwrap_err().to_string().contains("exactly one of"));
}
#[test]
fn rejects_workspace_with_unknown_field_by_name() {
let result: Result<EvalSpec, _> = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","workspace":{"dirr":"x"}}"#,
);
assert!(result.unwrap_err().to_string().contains("unknown field `dirr`"));
}
#[test]
fn rejects_git_workspace_with_unknown_field_by_name() {
let result: Result<EvalSpec, _> = serde_json::from_str(
r#"{"docker":{"image":"sandbox:latest"},"name":"g","prompt":"p","workspace":{"git":{"url":"u","startCommit":"a","goldComit":"b"}}}"#,
);
assert!(result.unwrap_err().to_string().contains("unknown field `goldComit`"));
}
#[test]
fn rejects_invalid_judge_criteria() {
for (json, expected) in [
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[]}}}"#,
"criteria must not be empty",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[{"id":"","description":"ok"}]}}}"#,
"id must not be empty",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok"},{"id":"a","description":"ok"}]}}}"#,
"duplicate judge criterion id `a`",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[{"id":"a","description":""}]}}}"#,
"description must not be empty",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok","weight":0.0}]}}}"#,
"weight must be positive and finite",
),
(
r#"{"docker":{"image":"sandbox:latest"},"name":"c","prompt":"p","expect":{"judge":{"model":"m","criteria":[{"id":"a","description":"ok","threshold":1.5}]}}}"#,
"threshold must be between 0.0 and 1.0",
),
] {
let eval = serde_json::from_str::<EvalSpec>(json).unwrap();
let error = EvalCase::new(eval, ".").unwrap_err().to_string();
assert!(error.contains(expected), "expected {expected:?} in {error:?}");
}
}
}