Skip to main content

loopsmith_core/config/
mod.rs

1//! The A–H config model, one module per section.
2//!
3//! The split follows the template'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.
11
12use serde::{Deserialize, Serialize};
13
14pub mod constraints;
15pub mod context;
16pub mod default_skills;
17pub mod gates;
18pub mod goals;
19pub mod graph;
20pub mod guidelines;
21pub mod info;
22pub mod providers;
23pub mod skills;
24pub mod success;
25pub mod triggers;
26pub mod validation;
27pub mod work;
28
29pub use constraints::{ConstraintSet, Constraints};
30pub use context::ContextPolicy;
31pub use default_skills::{is_safe_repo_url, DefaultSkill, SkillOrigin};
32pub use gates::StopGates;
33pub use goals::Goal;
34pub use graph::{Concurrency, GraphSpec, NodeSpec, Role, Tier};
35pub use guidelines::{parse_chain, ExecutionGuidelines, Guideline, Phase};
36pub use info::InfoItem;
37pub use providers::{ProviderKind, ProviderRouting, ProviderSpec};
38pub use skills::{AcquisitionSource, SkillPolicy};
39pub use success::SuccessScenario;
40pub use triggers::Trigger;
41pub use validation::{CompareOp, Detector, Mode, Validation};
42pub use work::WorkItem;
43
44/// Reserved target name meaning "the loop as a whole" rather than one goal.
45pub const OVERALL: &str = "overall";
46
47/// Shared serde default. Named rather than inlined because four sections
48/// default a boolean to true and a literal `true` cannot be a serde default.
49pub(crate) fn yes() -> bool {
50    true
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct LoopConfig {
56    /// Loop identity. Becomes the generated skill name.
57    pub name: String,
58    #[serde(default = "default_version")]
59    pub version: String,
60    #[serde(default)]
61    pub description: String,
62
63    /// A — static context every node receives.
64    #[serde(default)]
65    pub information: Vec<InfoItem>,
66    /// B — the manual work that must happen before automation is allowed.
67    #[serde(default)]
68    pub pre_execution: Vec<WorkItem>,
69    /// C — named goals.
70    pub goals: Vec<Goal>,
71    /// D — how each goal is checked.
72    pub validations: Vec<Validation>,
73    /// E — what counts as success.
74    #[serde(default)]
75    pub success: Vec<SuccessScenario>,
76    /// F — the layered exits.
77    #[serde(default)]
78    pub stop_gates: StopGates,
79    /// G — time and event triggers.
80    #[serde(default)]
81    pub schedules: Vec<Trigger>,
82    /// H — constraints applied per node or globally.
83    #[serde(default)]
84    pub constraints: Constraints,
85    /// I — named phases with their own standing instruction and ordering.
86    #[serde(default)]
87    pub execution_guidelines: ExecutionGuidelines,
88    /// J — sub-agents installed before the loop starts.
89    #[serde(default)]
90    pub default_skills: Vec<DefaultSkill>,
91
92    /// Execution graph. Nodes are units of work; edges are real dependencies.
93    #[serde(default)]
94    pub graph: GraphSpec,
95    /// Provider routing. Every provider is a command template, so any CLI or
96    /// HTTP endpoint reachable from a shell is usable without a Rust change.
97    #[serde(default)]
98    pub providers: ProviderRouting,
99    /// Sub-agent acquisition policy.
100    #[serde(default)]
101    pub skills: SkillPolicy,
102    /// How much of the previous iterations each prompt carries.
103    #[serde(default)]
104    pub context: ContextPolicy,
105}
106
107fn default_version() -> String {
108    "0.1.0".into()
109}
110
111impl LoopConfig {
112    pub fn goal_names(&self) -> Vec<&str> {
113        self.goals.iter().map(|g| g.name.as_str()).collect()
114    }
115
116    pub fn blocking_validations_for(&self, target: &str) -> Vec<&Validation> {
117        self.validations
118            .iter()
119            .filter(|v| v.target == target && v.blocking)
120            .collect()
121    }
122
123    pub fn provider(&self, id: &str) -> Option<&ProviderSpec> {
124        self.providers.providers.iter().find(|p| p.id == id)
125    }
126
127    /// Resolve a tier to the ordered list of provider ids to try.
128    pub fn cascade_for(&self, tier: Tier) -> Vec<&ProviderSpec> {
129        let key = match tier {
130            Tier::Cheap => "cheap",
131            Tier::Standard => "standard",
132            Tier::Strong => "strong",
133        };
134        if let Some(ids) = self.providers.cascade.get(key) {
135            return ids.iter().filter_map(|id| self.provider(id)).collect();
136        }
137        self.providers
138            .providers
139            .iter()
140            .filter(|p| p.tiers.is_empty() || p.tiers.contains(&tier))
141            .collect()
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    const MINIMAL: &str = r#"
150name: t
151goals:
152  - name: g1
153    description: a goal with a long enough description
154validations:
155  - target: g1
156    name: v1
157    mode: objective
158    statement: it works
159    detector: { type: file_exists, path: out.txt }
160"#;
161
162    fn parse(text: &str) -> Result<LoopConfig, serde_yaml::Error> {
163        serde_yaml::from_str::<LoopConfig>(text)
164    }
165
166    #[test]
167    fn the_minimal_config_parses() {
168        let cfg = parse(MINIMAL).expect("minimal config parses");
169        assert_eq!(cfg.name, "t");
170        assert_eq!(cfg.version, "0.1.0");
171        assert_eq!(cfg.stop_gates.max_iterations, 10);
172    }
173
174    #[test]
175    fn a_misspelled_top_level_section_is_refused_not_ignored() {
176        // Without `deny_unknown_fields` this parses happily and the loop runs
177        // with `stop_gates` at its defaults — the author's ceilings silently
178        // discarded. That is how a budget cap becomes a surprise invoice.
179        let typo = MINIMAL.to_string() + "stop_gate:\n  max_iterations: 2\n";
180        let err = parse(&typo).expect_err("a misspelled section must be refused");
181        assert!(
182            err.to_string().contains("stop_gate"),
183            "the error must name the offending key, got: {err}"
184        );
185    }
186
187    #[test]
188    fn a_misspelled_nested_field_is_refused_not_ignored() {
189        let typo = MINIMAL.to_string() + "stop_gates:\n  max_iteration: 2\n";
190        let err = parse(&typo).expect_err("a misspelled field must be refused");
191        assert!(err.to_string().contains("max_iteration"), "got: {err}");
192    }
193
194    #[test]
195    fn provider_kind_aliases_still_resolve() {
196        // The aliases are the reason nobody has to remember that snake_case
197        // renders `OpenAi` as `open_ai`. They must survive the section split.
198        for (written, expected) in [
199            ("claude", ProviderKind::ClaudeCode),
200            ("claude-code", ProviderKind::ClaudeCode),
201            ("openai", ProviderKind::OpenAi),
202            ("open_ai", ProviderKind::OpenAi),
203            ("OpenAI", ProviderKind::OpenAi),
204            ("grok", ProviderKind::GrokCli),
205            ("custom", ProviderKind::Byok),
206            ("MCP", ProviderKind::Mcp),
207        ] {
208            let text = format!(
209                "{MINIMAL}providers:\n  providers:\n    - id: p\n      kind: {written}\n      command: echo\n"
210            );
211            let cfg = parse(&text).unwrap_or_else(|e| panic!("`{written}` should parse: {e}"));
212            assert_eq!(cfg.providers.providers[0].kind, expected, "for `{written}`");
213        }
214    }
215}