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