use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AgentShape {
pub subject: Subject,
pub fixture: Fixture,
pub run: RunConfig,
pub judge: JudgeConfig,
pub tasks: Tasks,
#[serde(default)]
pub commands: Option<ExpectedCommands>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ExpectedCommands {
#[serde(default)]
pub top_level: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Subject {
pub name: String,
pub binary: String,
pub description: String,
#[serde(default)]
pub version_pin: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Fixture {
pub setup: String,
#[serde(default)]
pub cleanup: Option<String>,
pub workdir: String,
#[serde(default)]
pub strip_env: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RunConfig {
pub n: u32,
pub models: Vec<String>,
pub turn_cap: u32,
pub timeout_seconds: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct JudgeConfig {
pub model: String,
#[serde(default = "default_true")]
pub double_score: bool,
pub rubric: String,
pub required_fields: Vec<String>,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Tasks {
#[serde(default)]
pub tuning: Vec<Task>,
#[serde(default)]
pub holdout: Vec<Task>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Task {
pub id: String,
pub summary: String,
pub prompt: String,
#[serde(default)]
pub success_criteria: Vec<String>,
pub author: String,
pub created_at: String,
pub sealed_against_tag: String,
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = include_str!("../examples/agent-shape.example.toml");
#[test]
fn deserializes_example() {
let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
assert!(!parsed.subject.name.is_empty());
assert!(!parsed.subject.binary.is_empty());
assert!(parsed.run.n >= 1);
assert!(parsed.run.turn_cap >= 1 && parsed.run.turn_cap <= 5);
assert!(!parsed.tasks.tuning.is_empty());
assert!(parsed.tasks.holdout.is_empty());
assert!(!parsed.judge.rubric.is_empty());
assert!(parsed.judge.required_fields.contains(&"score".to_string()));
}
#[test]
fn task_provenance_is_complete() {
let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
for task in &parsed.tasks.tuning {
assert!(!task.author.is_empty(), "task {} missing author", task.id);
assert!(
!task.created_at.is_empty(),
"task {} missing created_at",
task.id
);
assert!(
!task.sealed_against_tag.is_empty(),
"task {} missing sealed_against_tag",
task.id
);
}
}
#[test]
fn holdout_uses_same_shape() {
let cfg = AgentShape {
subject: Subject {
name: "x".into(),
binary: "x".into(),
description: "x".into(),
version_pin: None,
},
fixture: Fixture {
setup: "true".into(),
cleanup: None,
workdir: "/tmp".into(),
strip_env: vec![],
},
run: RunConfig {
n: 1,
models: vec!["claude-sonnet-4-6".into()],
turn_cap: 3,
timeout_seconds: 60,
},
judge: JudgeConfig {
model: "claude-haiku-4-5".into(),
double_score: false,
rubric: "r".into(),
required_fields: vec!["score".into()],
},
tasks: Tasks {
tuning: vec![],
holdout: vec![Task {
id: "h1".into(),
summary: "s".into(),
prompt: "p".into(),
success_criteria: vec![],
author: "a".into(),
created_at: "2026-04-24".into(),
sealed_against_tag: "v0.1.0".into(),
}],
},
commands: None,
};
let ser = toml::to_string(&cfg).expect("serialize");
let back: AgentShape = toml::from_str(&ser).expect("round-trip");
assert_eq!(back.tasks.holdout.len(), 1);
assert_eq!(back.tasks.holdout[0].id, "h1");
}
}