Skip to main content

loopsmith_core/config/
mod.rs

1//! The config model, one module per named section.
2//!
3//! The split follows the config's own section boundaries rather than Rust
4//! convenience, so "where does `stop_gates` live" has the same answer in the
5//! docs, the schema, and the code.
6//!
7//! Every struct here carries `deny_unknown_fields`. Without it a misspelled key
8//! is silently dropped and the loop runs with a default the author never chose
9//! — which is exactly how `max_revisions_per_node` came to be documented in
10//! four places and read in none. The one place that constraint shaped the
11//! design rather than merely decorating it is [`triggers::TriggerSpec`], which
12//! nests where flattening would have read better, because serde will not allow
13//! both.
14//!
15//! Sections are grouped into four bundles — see [`bundles`] for why — and every
16//! 0.3 spelling still parses via [`legacy`].
17
18use 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
64/// Reserved target name meaning "the loop as a whole" rather than one goal.
65pub const OVERALL: &str = "overall";
66
67/// Shared serde default. Named rather than inlined because several sections
68/// default a boolean to true and a literal `true` cannot be a serde default.
69pub(crate) fn yes() -> bool {
70    true
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
74#[serde(deny_unknown_fields)]
75pub struct LoopConfig {
76    /// Loop identity. Becomes the generated skill name.
77    pub name: String,
78    #[serde(default = "default_version")]
79    pub version: String,
80    #[serde(default)]
81    pub description: String,
82
83    /// Which deployment this config describes. Read before anything else,
84    /// because it decides how strictly the rest is enforced.
85    #[serde(default)]
86    pub environment: Environment,
87    /// Coarse capability switches, each defaulting to the safe answer.
88    #[serde(default)]
89    pub features: Features,
90
91    /// What the loop is for, and how anyone would know it worked.
92    #[serde(default)]
93    pub intent: Intent,
94    /// How the work gets done.
95    #[serde(default)]
96    pub execution: Execution,
97    /// What must not happen, and when this stops.
98    #[serde(default)]
99    pub safety: Safety,
100    /// How the loop is allowed to change itself.
101    #[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    /// Resolve a tier to the ordered list of provider ids to try.
127    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    /// Whether self-evolution is on at both the feature switch and the section.
145    ///
146    /// Two switches rather than one because they answer to different people:
147    /// `features` is an operator's blanket answer for the machine, `evolution`
148    /// is the author's answer for this loop. Either being off is off.
149    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    /// The same loop in the 0.3 spelling.
174    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    /// Route a document through the legacy transform the way the loader does.
192    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        // The migration is only trustworthy if the two spellings are the same
210        // loop. Comparing the serialised form catches a field the transform
211        // relocated but subtly altered.
212        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        // Without `deny_unknown_fields` this parses happily and the loop runs
224        // with its gates at their defaults — the author's ceilings silently
225        // discarded. That is how a budget cap becomes a surprise invoice.
226        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        // The aliases are the reason nobody has to remember that snake_case
256        // renders `OpenAi` as `open_ai`. They must survive the bundle move.
257        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}