1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Deserializer, Serialize};
7
8use crate::error::{Error, Result};
9use crate::hash::sha256_bytes;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12#[serde(default, rename_all = "kebab-case")]
13pub struct Objective {
14 pub primary: String,
15 pub constraints: BTreeMap<String, String>,
16 pub select: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21#[serde(default)]
22pub struct Budget {
23 pub max_spend_usd: Option<f64>,
24 pub max_trials: Option<usize>,
25 pub strategy: String,
26 pub random_seed: u64,
27}
28
29impl Default for Budget {
30 fn default() -> Self {
31 Budget {
32 max_spend_usd: None,
33 max_trials: None,
34 strategy: "auto".into(),
35 random_seed: 0,
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41#[serde(default)]
42pub struct Candidates {
43 #[serde(default)]
44 pub llm: BTreeMap<String, LLMCandidate>,
45 #[serde(default)]
46 pub decision: Vec<String>,
47 #[serde(default, deserialize_with = "de_harness")]
48 pub harness: Vec<HarnessCandidate>,
49 #[serde(default)]
50 pub policy: BTreeMap<String, Vec<serde_json::Value>>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(default)]
55pub struct LLMCandidate {
56 #[serde(alias = "any-of")]
58 pub any_of: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(default)]
63pub struct HarnessCandidate {
64 pub name: String,
65 #[serde(default)]
66 pub pattern: Vec<String>,
67 #[serde(default)]
68 pub config: BTreeMap<String, serde_json::Value>,
69}
70
71impl Default for HarnessCandidate {
72 fn default() -> Self {
73 HarnessCandidate {
74 name: String::new(),
75 pattern: Vec::new(),
76 config: BTreeMap::new(),
77 }
78 }
79}
80
81fn de_harness<'de, D>(d: D) -> std::result::Result<Vec<HarnessCandidate>, D::Error>
83where
84 D: Deserializer<'de>,
85{
86 use serde::de::Error as _;
87 let v = serde_json::Value::deserialize(d)?;
88 match v {
89 serde_json::Value::Array(arr) => arr
90 .iter()
91 .map(|item| match item {
92 serde_json::Value::String(s) => Ok(HarnessCandidate {
93 name: s.clone(),
94 ..Default::default()
95 }),
96 other => serde_json::from_value(other.clone()).map_err(D::Error::custom),
97 })
98 .collect(),
99 _ => Ok(vec![]),
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104#[serde(default)]
105pub struct Manifest {
106 #[serde(default)]
107 pub project: BTreeMap<String, serde_json::Value>,
108 #[serde(default, alias = "tasks")]
110 pub evals: BTreeMap<String, serde_json::Value>,
111 #[serde(default)]
112 pub candidates: Candidates,
113 #[serde(default)]
114 pub tools: BTreeMap<String, serde_json::Value>,
115 #[serde(default)]
116 pub skills: BTreeMap<String, serde_json::Value>,
117 #[serde(default)]
118 pub instructions: BTreeMap<String, serde_json::Value>,
119 #[serde(default)]
120 pub context: BTreeMap<String, serde_json::Value>,
121 #[serde(default)]
122 pub objective: Objective,
123 #[serde(default)]
124 pub budget: Budget,
125 #[serde(default, rename = "enforce")]
126 pub enforce: BTreeMap<String, serde_json::Value>,
127 #[serde(default)]
128 pub backend: BTreeMap<String, serde_json::Value>,
129 #[serde(default)]
131 pub target: BTreeMap<String, serde_json::Value>,
132 #[serde(default)]
134 pub harnesses: BTreeMap<String, serde_json::Value>,
135 #[serde(skip)]
136 pub source_path: Option<PathBuf>,
137}
138
139pub fn find_manifest(start: &Path) -> Option<PathBuf> {
141 let mut dir = Some(start);
142 while let Some(d) = dir {
143 let candidate = d.join(crate::MANIFEST_PATH);
144 if candidate.is_file() {
145 return Some(candidate);
146 }
147 dir = d.parent();
148 }
149 None
150}
151
152impl Manifest {
153 pub fn load(path: &Path) -> Result<Manifest> {
154 let text = std::fs::read_to_string(path)
155 .map_err(|e| Error::Manifest(format!("cannot read {}: {e}", path.display())))?;
156 let mut m: Manifest = toml::from_str(&text)
157 .map_err(|e| Error::Manifest(format!("invalid harness.toml: {e}")))?;
158 m.source_path = Some(path.to_path_buf());
159 Ok(m)
160 }
161
162 pub fn root(&self) -> PathBuf {
165 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
166 match self.source_path.as_ref().and_then(|p| p.parent()) {
167 Some(d) if d.as_os_str().is_empty() => cwd,
168 Some(d) if d.is_absolute() => d.to_path_buf(),
169 Some(d) => cwd.join(d),
170 None => cwd,
171 }
172 }
173
174 pub fn from_str(text: &str) -> Result<Manifest> {
176 toml::from_str(text).map_err(|e| Error::Manifest(format!("invalid harness.toml: {e}")))
177 }
178
179 pub fn name(&self) -> String {
180 self.project
181 .get("name")
182 .and_then(|v| v.as_str())
183 .map(|s| s.to_string())
184 .unwrap_or_else(|| "agent".to_string())
185 }
186
187 pub fn eval_packs(&self) -> Vec<String> {
188 self.evals
189 .get("packs")
190 .and_then(|v| v.as_array())
191 .map(|a| {
192 a.iter()
193 .filter_map(|v| v.as_str().map(|s| s.to_string()))
194 .collect()
195 })
196 .unwrap_or_default()
197 }
198
199 #[deprecated(note = "renamed to `eval_packs`")]
200 pub fn task_packs(&self) -> Vec<String> {
201 self.eval_packs()
202 }
203
204 pub fn seeds(&self) -> u64 {
205 self.evals
206 .get("seeds")
207 .and_then(|v| v.as_u64())
208 .unwrap_or(3)
209 }
210
211 pub fn backend_target(&self) -> String {
212 self.backend
213 .get("target")
214 .and_then(|v| v.as_str())
215 .map(|s| s.to_string())
216 .unwrap_or_else(|| "local".to_string())
217 }
218
219 pub fn target_harness(&self) -> Option<String> {
221 self.target
222 .get("harness")
223 .and_then(|v| v.as_str())
224 .filter(|s| !s.is_empty())
225 .map(|s| s.to_string())
226 }
227
228 pub fn search_frac(&self) -> f64 {
229 self.evals
230 .get("splits")
231 .and_then(|v| v.get("search"))
232 .and_then(|v| v.as_f64())
233 .unwrap_or(0.7)
234 }
235
236 pub fn manifest_hash(&self) -> String {
238 if let Some(p) = &self.source_path {
239 if let Ok(bytes) = std::fs::read(p) {
240 return sha256_bytes(&bytes);
241 }
242 }
243 match toml::to_string(self) {
244 Ok(s) => sha256_bytes(s.as_bytes()),
245 Err(_) => String::new(),
246 }
247 }
248}