1use super::error::EvalFileError;
2use crate::agents::{DockerImage, ImageBuildRequest};
3use crate::{GitRepoSpec, Task, TaskRun, Workspace, WorkspaceError};
4use schemars::JsonSchema;
5use serde::Deserialize;
6use serde::de::DeserializeOwned;
7use std::collections::{BTreeMap, BTreeSet};
8use std::hash::{DefaultHasher, Hash, Hasher};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Deserialize)]
14#[serde(try_from = "DockerSpecRepr")]
15pub enum DockerSpec {
16 Prebuilt { image: String },
18 Build { file: PathBuf, image: Option<String>, context: Option<PathBuf> },
22}
23
24#[derive(Debug, Clone)]
27pub(crate) struct ResolvedDocker {
28 pub image: DockerImage,
29 pub build: Option<ImageBuildRequest>,
30}
31
32#[derive(Debug, Clone, Deserialize, JsonSchema)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct TaskSpec {
35 pub prompt: String,
36 #[serde(default)]
37 pub workspace: WorkspaceSpec,
38}
39
40#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
42#[serde(rename_all = "camelCase")]
43pub enum WorkspaceSpec {
44 #[default]
45 Empty,
46 Files(BTreeMap<String, String>),
48 Dir(PathBuf),
50 Git(GitRepoSpec),
52}
53
54#[derive(Debug, Clone)]
57pub enum PathOrInline<T> {
58 Path(PathBuf),
59 Inline(T),
60}
61
62pub type JudgeRef = PathOrInline<JudgeSpec>;
63
64#[derive(Debug, Clone, Deserialize, JsonSchema)]
66#[serde(rename_all = "camelCase", deny_unknown_fields)]
67pub struct JudgeSpec {
68 pub model: String,
70
71 #[serde(default)]
73 pub instructions: Option<String>,
74
75 #[serde(default)]
78 pub context_files: Vec<String>,
79
80 #[serde(default)]
82 pub criteria: Vec<JudgeCriterionSpec>,
83}
84
85#[derive(Debug, Clone, Deserialize, JsonSchema)]
86#[serde(rename_all = "camelCase", deny_unknown_fields)]
87pub struct JudgeCriterionSpec {
88 pub id: String,
89 pub description: String,
90 #[serde(default = "default_blocking")]
91 pub blocking: bool,
92 #[serde(default = "default_weight")]
93 pub weight: f64,
94 #[serde(default = "default_threshold")]
95 pub threshold: f64,
96}
97
98impl JudgeCriterionSpec {
99 pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
100 Self {
101 id: id.into(),
102 description: description.into(),
103 blocking: default_blocking(),
104 weight: default_weight(),
105 threshold: default_threshold(),
106 }
107 }
108
109 pub fn blocking(mut self, blocking: bool) -> Self {
110 self.blocking = blocking;
111 self
112 }
113
114 pub fn weight(mut self, weight: f64) -> Self {
115 self.weight = weight;
116 self
117 }
118
119 pub fn threshold(mut self, threshold: f64) -> Self {
120 self.threshold = threshold;
121 self
122 }
123}
124#[derive(Debug, Clone, Deserialize, JsonSchema)]
125#[serde(rename_all = "camelCase")]
126pub enum ToolCallExpectation {
127 AtLeast(usize),
128 Exactly(usize),
129}
130
131#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
134#[serde(rename_all = "camelCase", deny_unknown_fields)]
135pub struct Expect {
136 #[serde(default)]
138 pub tool_calls: BTreeMap<String, ToolCallExpectation>,
139
140 #[serde(default)]
142 pub files: BTreeMap<String, String>,
143
144 #[serde(default)]
146 pub files_contain: BTreeMap<String, String>,
147
148 #[serde(default)]
150 pub judge: Option<JudgeRef>,
151}
152
153#[derive(Debug, Clone, Deserialize, JsonSchema)]
154#[serde(rename_all = "camelCase", deny_unknown_fields)]
155pub struct AgentSpec {
156 pub command: Vec<String>,
157
158 #[serde(default)]
159 pub env: BTreeMap<String, String>,
160}
161
162impl AgentSpec {
163 pub(crate) fn validate(&self) -> Result<(), String> {
164 let Some(program) = self.command.first() else {
165 return Err("agent command must not be empty".to_string());
166 };
167 if program.trim().is_empty() {
168 return Err("agent command program must not be empty".to_string());
169 }
170 for key in self.env.keys() {
171 if key.trim().is_empty() || key.contains('=') {
172 return Err(format!("agent env contains invalid key `{key}`"));
173 }
174 }
175 Ok(())
176 }
177}
178
179impl DockerSpec {
180 pub(crate) fn resolve(&self, base_dir: &Path) -> Result<ResolvedDocker, EvalFileError> {
183 match self {
184 Self::Prebuilt { image } => Ok(ResolvedDocker { image: DockerImage::parse(image)?, build: None }),
185 Self::Build { file, image, context } => {
186 let dockerfile = canonical_docker_path(base_dir.join(file))?;
187 let context = canonical_docker_path(
188 context.as_ref().map_or_else(|| base_dir.to_path_buf(), |context| base_dir.join(context)),
189 )?;
190 let tag = image.clone().unwrap_or_else(|| derived_tag(&dockerfile, &context));
191 Ok(ResolvedDocker {
192 image: DockerImage::parse(&tag)?,
193 build: Some(ImageBuildRequest { dockerfile, context, tag }),
194 })
195 }
196 }
197 }
198}
199
200impl TaskSpec {
201 pub(crate) fn build(&self, base_dir: &Path) -> Result<Task, WorkspaceError> {
202 Ok(Task::new(self.prompt.clone(), self.workspace.build(base_dir)?))
203 }
204}
205
206impl WorkspaceSpec {
207 pub(crate) fn build(&self, base_dir: &Path) -> Result<Workspace, WorkspaceError> {
208 match self {
209 Self::Empty => Workspace::empty(),
210 Self::Files(files) => Workspace::from_files(files),
211 Self::Dir(dir) => Workspace::from_dir(base_dir.join(dir)),
212 Self::Git(git) => Workspace::from_git_repo(git.clone()),
213 }
214 }
215}
216
217impl Expect {
218 pub(crate) fn evaluate(&self, run: &TaskRun) -> Vec<String> {
219 let mut failures = Vec::new();
220
221 for (tool, expectation) in &self.tool_calls {
222 let actual = run.transcript().tool_call_count(tool);
223 if let Some(failure) = expectation.failure(tool, actual) {
224 failures.push(failure);
225 }
226 }
227
228 for (path, expected) in &self.files {
229 match read_workspace_file(run, path) {
230 Ok(actual) if &actual == expected => {}
231 Ok(actual) => failures
232 .push(format!("file `{path}` content mismatch:\n expected: {expected:?}\n actual: {actual:?}")),
233 Err(error) => failures.push(format!("file `{path}` could not be read: {error}")),
234 }
235 }
236
237 for (path, needle) in &self.files_contain {
238 match read_workspace_file(run, path) {
239 Ok(actual) if actual.contains(needle) => {}
240 Ok(_) => failures.push(format!("file `{path}` does not contain {needle:?}")),
241 Err(error) => failures.push(format!("file `{path}` could not be read: {error}")),
242 }
243 }
244
245 failures
246 }
247}
248
249fn read_workspace_file(run: &TaskRun, relative_path: &str) -> std::io::Result<String> {
250 std::fs::read_to_string(run.workspace().join(relative_path))
251}
252
253impl JudgeRef {
254 pub fn resolve(&self, base_dir: &Path) -> Result<JudgeSpec, EvalFileError> {
256 match self {
257 JudgeRef::Inline(spec) => {
258 spec.validate().map_err(|message| EvalFileError::InvalidInlineJudge { message })?;
259 Ok(spec.clone())
260 }
261 JudgeRef::Path(path) => {
262 let path = base_dir.join(path);
263 let content = std::fs::read_to_string(&path)
264 .map_err(|source| EvalFileError::ReadJudgeFile { path: path.clone(), source })?;
265 let spec: JudgeSpec = serde_json::from_str(&content)
266 .map_err(|source| EvalFileError::ParseJudgeFile { path: path.clone(), source })?;
267 spec.validate().map_err(|message| EvalFileError::InvalidInlineJudge { message })?;
268 Ok(spec)
269 }
270 }
271 }
272}
273
274impl<'de, T> Deserialize<'de> for PathOrInline<T>
275where
276 T: DeserializeOwned,
277{
278 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
279 match serde_json::Value::deserialize(deserializer)? {
280 serde_json::Value::String(path) => Ok(Self::Path(PathBuf::from(path))),
281 value => serde_json::from_value(value).map(Self::Inline).map_err(serde::de::Error::custom),
282 }
283 }
284}
285
286impl JsonSchema for DockerSpec {
287 fn schema_name() -> std::borrow::Cow<'static, str> {
288 "DockerSpec".into()
289 }
290
291 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
292 DockerSpecRepr::json_schema(generator)
293 }
294}
295
296impl<T: JsonSchema> JsonSchema for PathOrInline<T> {
297 fn schema_name() -> std::borrow::Cow<'static, str> {
298 std::borrow::Cow::Owned(format!("PathOr{}", T::schema_name()))
299 }
300
301 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
302 let inline = generator.subschema_for::<T>().to_value();
303 schemars::Schema::try_from(serde_json::json!({
304 "oneOf": [{ "type": "string" }, inline],
305 }))
306 .expect("PathOrInline schema must be valid")
307 }
308}
309
310#[derive(Deserialize, JsonSchema)]
311#[serde(rename_all = "camelCase", deny_unknown_fields)]
312struct DockerSpecRepr {
313 #[serde(default)]
314 image: Option<String>,
315 #[serde(default)]
316 file: Option<PathBuf>,
317 #[serde(default)]
318 context: Option<PathBuf>,
319}
320
321impl TryFrom<DockerSpecRepr> for DockerSpec {
322 type Error = &'static str;
323
324 fn try_from(repr: DockerSpecRepr) -> Result<Self, Self::Error> {
325 match (repr.file, repr.image, repr.context) {
326 (Some(file), image, context) => Ok(Self::Build { file, image, context }),
327 (None, Some(image), None) => Ok(Self::Prebuilt { image }),
328 (None, Some(_), Some(_)) => Err("docker `context` requires a Dockerfile `file`"),
329 (None, None, _) => Err("docker must specify a prebuilt `image` and/or a Dockerfile `file`"),
330 }
331 }
332}
333
334impl ToolCallExpectation {
335 pub(crate) fn failure(&self, tool: &str, actual: usize) -> Option<String> {
336 match self {
337 Self::AtLeast(expected) if actual < *expected => Some(format!(
338 "expected tool `{tool}` to be called at least {expected} time(s), but was called {actual}"
339 )),
340 Self::Exactly(expected) if actual != *expected => {
341 Some(format!("expected tool `{tool}` to be called exactly {expected} time(s), but was called {actual}"))
342 }
343 _ => None,
344 }
345 }
346}
347
348impl JudgeSpec {
349 pub(crate) fn validate(&self) -> Result<(), String> {
350 if self.criteria.is_empty() {
351 return Err("judge criteria must not be empty".to_string());
352 }
353
354 let mut ids = BTreeSet::new();
355 for criterion in &self.criteria {
356 if criterion.id.trim().is_empty() {
357 return Err("judge criterion id must not be empty".to_string());
358 }
359 if !ids.insert(criterion.id.clone()) {
360 return Err(format!("duplicate judge criterion id `{}`", criterion.id));
361 }
362 if criterion.description.trim().is_empty() {
363 return Err(format!("judge criterion `{}` description must not be empty", criterion.id));
364 }
365 if !criterion.weight.is_finite() || criterion.weight <= 0.0 {
366 return Err(format!("judge criterion `{}` weight must be positive and finite", criterion.id));
367 }
368 if !criterion.threshold.is_finite() || !(0.0..=1.0).contains(&criterion.threshold) {
369 return Err(format!("judge criterion `{}` threshold must be between 0.0 and 1.0", criterion.id));
370 }
371 }
372
373 Ok(())
374 }
375}
376
377fn canonical_docker_path(path: PathBuf) -> Result<PathBuf, EvalFileError> {
378 path.canonicalize().map_err(|source| EvalFileError::DockerPath { path, source })
379}
380
381fn derived_tag(dockerfile: &Path, context: &Path) -> String {
385 let mut hasher = DefaultHasher::new();
386 (dockerfile, context).hash(&mut hasher);
387 format!("aether-eval-sandbox:{:016x}", hasher.finish())
388}
389
390fn default_blocking() -> bool {
391 true
392}
393
394fn default_weight() -> f64 {
395 1.0
396}
397
398fn default_threshold() -> f64 {
399 1.0
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn agent_spec_parses_command_only_json() {
408 let agent: AgentSpec = serde_json::from_str(
409 r#"{"command":["node","/app/eval-agent.js"],"env":{"AETHER_EVAL_MODEL":"test:model"}}"#,
410 )
411 .unwrap();
412
413 assert_eq!(agent.command, vec!["node", "/app/eval-agent.js"]);
414 assert_eq!(agent.env["AETHER_EVAL_MODEL"], "test:model");
415 }
416
417 #[test]
418 fn agent_spec_rejects_empty_command() {
419 let agent: AgentSpec = serde_json::from_str(r#"{"command":[]}"#).unwrap();
420 let error = agent.validate().unwrap_err();
421 assert_eq!(error, "agent command must not be empty");
422 }
423
424 #[test]
425 fn agent_spec_rejects_blank_program() {
426 let agent: AgentSpec = serde_json::from_str(r#"{"command":[" "]}"#).unwrap();
427
428 let error = agent.validate().unwrap_err();
429 assert_eq!(error, "agent command program must not be empty");
430 }
431
432 #[test]
433 fn agent_spec_rejects_invalid_env_keys() {
434 for json in [r#"{"command":["agent"],"env":{"":"x"}}"#, r#"{"command":["agent"],"env":{"BAD=KEY":"x"}}"#] {
435 let agent: AgentSpec = serde_json::from_str(json).unwrap();
436 let error = agent.validate().unwrap_err();
437 assert!(error.contains("agent env contains invalid key"), "got: {error}");
438 }
439 }
440
441 #[test]
442 fn agent_spec_rejects_old_tagged_shapes() {
443 for json in [r#"{"type":"aether","settings":{}}"#, r#"{"type":"command","command":["agent"]}"#] {
444 let error = serde_json::from_str::<AgentSpec>(json).unwrap_err().to_string();
445
446 assert!(error.contains("unknown field `type`"), "got: {error}");
447 }
448 }
449
450 #[test]
451 fn judge_ref_parses_path_or_inline() {
452 let path: JudgeRef = serde_json::from_str(r#""shared/maintainer.judge.json""#).unwrap();
453 assert!(matches!(path, JudgeRef::Path(_)));
454
455 let inline: JudgeRef =
456 serde_json::from_str(r#"{"model":"m","criteria":[{"id":"a","description":"ok"}]}"#).unwrap();
457 assert!(matches!(inline, JudgeRef::Inline(_)));
458 }
459
460 #[test]
461 fn judge_ref_preserves_inline_validation_errors() {
462 let error = serde_json::from_str::<JudgeRef>(r#"{"criteria":[{"id":"a","description":"ok"}]}"#).unwrap_err();
463
464 assert!(error.to_string().contains("missing field `model`"), "got: {error}");
465 }
466
467 #[test]
468 fn derived_tags_differ_per_dockerfile_and_match_for_identical_builds() {
469 let tag_a = derived_tag(Path::new("/repo/a/Dockerfile"), Path::new("/repo"));
470 let tag_b = derived_tag(Path::new("/repo/b/Dockerfile"), Path::new("/repo"));
471
472 assert_eq!(tag_a, derived_tag(Path::new("/repo/a/Dockerfile"), Path::new("/repo")));
473 assert_ne!(tag_a, tag_b);
474 }
475}