1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct AgentShape {
11 pub subject: Subject,
12 pub fixture: Fixture,
13 pub run: RunConfig,
14 pub judge: JudgeConfig,
15 pub tasks: Tasks,
16 #[serde(default)]
25 pub commands: Option<ExpectedCommands>,
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize, Default)]
31pub struct ExpectedCommands {
32 #[serde(default)]
35 pub top_level: Vec<String>,
36}
37
38#[derive(Debug, Clone, Deserialize, Serialize)]
40pub struct Subject {
41 pub name: String,
42 pub binary: String,
43 pub description: String,
44 #[serde(default)]
46 pub version_pin: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
54pub struct Fixture {
55 pub setup: String,
57 #[serde(default)]
59 pub cleanup: Option<String>,
60 pub workdir: String,
62 #[serde(default)]
68 pub strip_env: Vec<String>,
69}
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct RunConfig {
74 pub n: u32,
76 pub models: Vec<String>,
78 pub turn_cap: u32,
82 pub timeout_seconds: u64,
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct JudgeConfig {
89 pub model: String,
91 #[serde(default = "default_true")]
93 pub double_score: bool,
94 pub rubric: String,
96 pub required_fields: Vec<String>,
98}
99
100fn default_true() -> bool {
101 true
102}
103
104#[derive(Debug, Clone, Deserialize, Serialize)]
107pub struct Tasks {
108 #[serde(default)]
109 pub tuning: Vec<Task>,
110 #[serde(default)]
111 pub holdout: Vec<Task>,
112}
113
114#[derive(Debug, Clone, Deserialize, Serialize)]
116pub struct Task {
117 pub id: String,
119 pub summary: String,
121 pub prompt: String,
123 #[serde(default)]
125 pub success_criteria: Vec<String>,
126 pub author: String,
128 pub created_at: String,
130 pub sealed_against_tag: String,
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 const EXAMPLE: &str = include_str!("../examples/agent-shape.example.toml");
140
141 #[test]
142 fn deserializes_example() {
143 let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
144 assert!(!parsed.subject.name.is_empty());
145 assert!(!parsed.subject.binary.is_empty());
146 assert!(parsed.run.n >= 1);
147 assert!(parsed.run.turn_cap >= 1 && parsed.run.turn_cap <= 5);
148 assert!(!parsed.tasks.tuning.is_empty());
149 assert!(parsed.tasks.holdout.is_empty());
150 assert!(!parsed.judge.rubric.is_empty());
151 assert!(parsed.judge.required_fields.contains(&"score".to_string()));
152 }
153
154 #[test]
155 fn task_provenance_is_complete() {
156 let parsed: AgentShape = toml::from_str(EXAMPLE).expect("parse example");
157 for task in &parsed.tasks.tuning {
158 assert!(!task.author.is_empty(), "task {} missing author", task.id);
159 assert!(
160 !task.created_at.is_empty(),
161 "task {} missing created_at",
162 task.id
163 );
164 assert!(
165 !task.sealed_against_tag.is_empty(),
166 "task {} missing sealed_against_tag",
167 task.id
168 );
169 }
170 }
171
172 #[test]
173 fn holdout_uses_same_shape() {
174 let cfg = AgentShape {
176 subject: Subject {
177 name: "x".into(),
178 binary: "x".into(),
179 description: "x".into(),
180 version_pin: None,
181 },
182 fixture: Fixture {
183 setup: "true".into(),
184 cleanup: None,
185 workdir: "/tmp".into(),
186 strip_env: vec![],
187 },
188 run: RunConfig {
189 n: 1,
190 models: vec!["claude-sonnet-4-6".into()],
191 turn_cap: 3,
192 timeout_seconds: 60,
193 },
194 judge: JudgeConfig {
195 model: "claude-haiku-4-5".into(),
196 double_score: false,
197 rubric: "r".into(),
198 required_fields: vec!["score".into()],
199 },
200 tasks: Tasks {
201 tuning: vec![],
202 holdout: vec![Task {
203 id: "h1".into(),
204 summary: "s".into(),
205 prompt: "p".into(),
206 success_criteria: vec![],
207 author: "a".into(),
208 created_at: "2026-04-24".into(),
209 sealed_against_tag: "v0.1.0".into(),
210 }],
211 },
212 commands: None,
213 };
214 let ser = toml::to_string(&cfg).expect("serialize");
215 let back: AgentShape = toml::from_str(&ser).expect("round-trip");
216 assert_eq!(back.tasks.holdout.len(), 1);
217 assert_eq!(back.tasks.holdout[0].id, "h1");
218 }
219}