1use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21pub mod alerts;
22pub mod bundles;
23pub mod constraints;
24pub mod default_skills;
25pub mod environment;
26pub mod evolution;
27pub mod gates;
28pub mod goals;
29pub mod graph;
30pub mod guidelines;
31pub mod info;
32pub mod legacy;
33pub mod memory;
34pub mod protected;
35pub mod providers;
36pub mod recovery;
37pub mod skills;
38pub mod success;
39pub mod triggers;
40pub mod validation;
41pub mod work;
42
43pub use alerts::{Alert, Metric};
44pub use bundles::{Execution, Intent, Safety};
45pub use constraints::{ConstraintSet, Constraints};
46pub use default_skills::{is_safe_repo_url, DefaultSkill, SkillOrigin, TrustLevel};
47pub use environment::{Environment, Features};
48pub use evolution::{Baseline, Evolution, ProposalKind};
49pub use gates::{GateKind, GateOutcome, GateRule, Gates, StopGates};
50pub use goals::Goal;
51pub use graph::{Concurrency, GraphSpec, Isolation, Join, NodeSpec, Role, Tier};
52pub use guidelines::{parse_chain, ExecutionGuidelines, Guideline, Phase};
53pub use info::InfoItem;
54pub use memory::{MemoryPolicy, NamespacePolicy, Namespaces, Promotion};
55pub use protected::{Protected, ProtectedComponent};
56pub use providers::{ProviderKind, ProviderRouting, ProviderSpec};
57pub use recovery::{Backoff, FailureClass, Recovery, RecoveryAction};
58pub use skills::{AcquisitionSource, SkillPolicy};
59pub use success::SuccessScenario;
60pub use triggers::{Trigger, TriggerPolicy, TriggerSpec};
61pub use validation::{CompareOp, Detector, Mode, Validation};
62pub use work::WorkItem;
63
64pub const OVERALL: &str = "overall";
66
67pub(crate) fn yes() -> bool {
70 true
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct LoopConfig {
76 pub name: String,
78 #[serde(default = "default_version")]
79 pub version: String,
80 #[serde(default)]
81 pub description: String,
82
83 #[serde(default)]
86 pub environment: Environment,
87 #[serde(default)]
89 pub features: Features,
90
91 #[serde(default)]
93 pub intent: Intent,
94 #[serde(default)]
96 pub execution: Execution,
97 #[serde(default)]
99 pub safety: Safety,
100 #[serde(default)]
102 pub evolution: Evolution,
103}
104
105fn default_version() -> String {
106 "0.1.0".into()
107}
108
109impl LoopConfig {
110 pub fn goal_names(&self) -> Vec<&str> {
111 self.intent.goals.iter().map(|g| g.name.as_str()).collect()
112 }
113
114 pub fn blocking_validations_for(&self, target: &str) -> Vec<&Validation> {
115 self.safety.blocking_checks_for(target)
116 }
117
118 pub fn provider(&self, id: &str) -> Option<&ProviderSpec> {
119 self.execution
120 .providers
121 .providers
122 .iter()
123 .find(|p| p.id == id)
124 }
125
126 pub fn cascade_for(&self, tier: Tier) -> Vec<&ProviderSpec> {
128 let key = match tier {
129 Tier::Cheap => "cheap",
130 Tier::Standard => "standard",
131 Tier::Strong => "strong",
132 };
133 if let Some(ids) = self.execution.providers.cascade.get(key) {
134 return ids.iter().filter_map(|id| self.provider(id)).collect();
135 }
136 self.execution
137 .providers
138 .providers
139 .iter()
140 .filter(|p| p.tiers.is_empty() || p.tiers.contains(&tier))
141 .collect()
142 }
143
144 pub fn evolution_enabled(&self) -> bool {
150 self.features.self_evolution && self.evolution.enabled
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 const MINIMAL: &str = r#"
159name: t
160intent:
161 goals:
162 - name: g1
163 description: a goal with a long enough description
164safety:
165 checks:
166 - target: g1
167 name: v1
168 mode: objective
169 statement: it works
170 detector: { type: file_exists, path: out.txt }
171"#;
172
173 const LEGACY: &str = r#"
175name: t
176goals:
177 - name: g1
178 description: a goal with a long enough description
179validations:
180 - target: g1
181 name: v1
182 mode: objective
183 statement: it works
184 detector: { type: file_exists, path: out.txt }
185"#;
186
187 fn parse(text: &str) -> Result<LoopConfig, serde_yaml::Error> {
188 serde_yaml::from_str::<LoopConfig>(text)
189 }
190
191 fn parse_any(text: &str) -> Result<(LoopConfig, Vec<legacy::Moved>), serde_yaml::Error> {
193 let doc: serde_yaml::Value = serde_yaml::from_str(text)?;
194 let (doc, moved) = legacy::migrate(&doc);
195 Ok((serde_yaml::from_value(doc)?, moved))
196 }
197
198 #[test]
199 fn the_minimal_config_parses() {
200 let cfg = parse(MINIMAL).expect("minimal config parses");
201 assert_eq!(cfg.name, "t");
202 assert_eq!(cfg.version, "0.1.0");
203 assert_eq!(cfg.safety.gates.stop.max_iterations, 10);
204 assert_eq!(cfg.environment, Environment::Dev);
205 }
206
207 #[test]
208 fn a_legacy_config_parses_to_exactly_the_same_thing() {
209 let (from_legacy, moved) = parse_any(LEGACY).expect("legacy config parses");
213 let modern = parse(MINIMAL).expect("modern config parses");
214 assert_eq!(
215 serde_yaml::to_string(&from_legacy).unwrap(),
216 serde_yaml::to_string(&modern).unwrap()
217 );
218 assert_eq!(moved.len(), 2, "goals and validations moved");
219 }
220
221 #[test]
222 fn a_misspelled_top_level_section_is_refused_not_ignored() {
223 let typo = MINIMAL.to_string() + "saftey:\n gates: {}\n";
227 let err = parse(&typo).expect_err("a misspelled section must be refused");
228 assert!(err.to_string().contains("saftey"), "got: {err}");
229 }
230
231 #[test]
232 fn a_misspelled_nested_field_is_refused_not_ignored() {
233 let typo = MINIMAL.to_string() + " gates:\n stop:\n max_iteration: 2\n";
234 let err = parse(&typo).expect_err("a misspelled field must be refused");
235 assert!(err.to_string().contains("max_iteration"), "got: {err}");
236 }
237
238 #[test]
239 fn evolution_needs_both_switches() {
240 let mut cfg = parse(MINIMAL).unwrap();
241 assert!(!cfg.evolution_enabled(), "off by default");
242
243 cfg.evolution.enabled = true;
244 assert!(!cfg.evolution_enabled(), "the feature switch still gates it");
245
246 cfg.features.self_evolution = true;
247 assert!(cfg.evolution_enabled());
248
249 cfg.evolution.enabled = false;
250 assert!(!cfg.evolution_enabled(), "the section still gates it");
251 }
252
253 #[test]
254 fn provider_kind_aliases_still_resolve() {
255 for (written, expected) in [
258 ("claude", ProviderKind::ClaudeCode),
259 ("claude-code", ProviderKind::ClaudeCode),
260 ("openai", ProviderKind::OpenAi),
261 ("open_ai", ProviderKind::OpenAi),
262 ("OpenAI", ProviderKind::OpenAi),
263 ("grok", ProviderKind::GrokCli),
264 ("custom", ProviderKind::Byok),
265 ("MCP", ProviderKind::Mcp),
266 ] {
267 let text = format!(
268 "{MINIMAL}execution:\n providers:\n providers:\n - id: p\n kind: {written}\n command: echo\n"
269 );
270 let cfg = parse(&text).unwrap_or_else(|e| panic!("`{written}` should parse: {e}"));
271 assert_eq!(
272 cfg.execution.providers.providers[0].kind, expected,
273 "for `{written}`"
274 );
275 }
276 }
277}