Skip to main content

evolve_core/
agent_config.rs

1//! Universal `AgentConfig`: the unit that Evolve actually evolves.
2//!
3//! Each project has a current "champion" config and optionally an active "challenger".
4//! When `P(challenger > champion) > 0.95` (Bayesian posterior over session outcomes),
5//! the challenger is promoted.
6//!
7//! The struct is intentionally narrow at the universal level: only fields that *every*
8//! adapter can usefully consume some interpretation of. Tool-specific knobs (Claude Code
9//! hook configs, Cursor file globs, Aider edit-format selection) live in [`AgentConfig::extensions`]
10//! as opaque per-adapter JSON blobs that the adapter itself knows how to unpack.
11
12use serde::de::DeserializeOwned;
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15use std::hash::{Hash, Hasher};
16
17/// The unit Evolve evolves. Each version is stored once in SQLite and referenced
18/// by [`ConfigId`](crate::ids::ConfigId).
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20pub struct AgentConfig {
21    /// Free-form prefix prepended to the agent's effective system prompt.
22    pub system_prompt_prefix: String,
23    /// Preferred model. Adapters interpret this against their own model catalog.
24    pub model_pref: ModelPref,
25    /// Behavioral rules the agent is asked to follow (e.g., "always run tests after edits").
26    pub behavioral_rules: BTreeSet<String>,
27    /// Tools/permissions the agent is allowed to use. Interpretation is per-adapter.
28    pub tool_permissions: BTreeSet<String>,
29    /// Whether the agent is asked to be terse, normal, or verbose in its responses.
30    pub response_style: ResponseStyle,
31    /// Per-tool extension fields. Keyed by adapter id. Opaque blob each adapter understands.
32    #[serde(default)]
33    pub extensions: BTreeMap<String, serde_json::Value>,
34}
35
36/// Preferred model. Adapters map this onto their own model catalogs.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
38#[serde(rename_all = "snake_case")]
39pub enum ModelPref {
40    /// Anthropic Claude Opus tier.
41    ClaudeOpus,
42    /// Anthropic Claude Sonnet tier.
43    ClaudeSonnet,
44    /// Anthropic Claude Haiku tier.
45    ClaudeHaiku,
46    /// OpenAI GPT-4o.
47    Gpt4o,
48    /// OpenAI GPT-4o-mini.
49    Gpt4oMini,
50    /// Local Ollama model identified by tag (e.g., `"qwen2.5:7b"`).
51    Ollama(String),
52    /// Adapter picks whatever cheap model is available.
53    AnyCheap,
54}
55
56/// How verbose / how much narration the agent should produce.
57#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
58#[serde(rename_all = "snake_case")]
59pub enum ResponseStyle {
60    /// Minimal narration, code-first.
61    Terse,
62    /// Default — balanced explanation.
63    Normal,
64    /// Detailed, walks through reasoning.
65    Verbose,
66}
67
68impl AgentConfig {
69    /// Construct a sane starting `AgentConfig` for the given adapter.
70    ///
71    /// Accepts the adapter id as `&str` to avoid coupling this module to the adapter
72    /// crate; in practice callers use `adapter_id.as_str()`.
73    pub fn default_for(adapter_id: &str) -> Self {
74        let (prefix, rules, perms, model) = match adapter_id {
75            "claude-code" => (
76                "You are working alongside the user in their codebase. Prefer small, \
77                 verifiable edits over speculative refactors. Run tests after changes \
78                 when feasible.",
79                [
80                    "always run tests after structural edits",
81                    "ask before deleting files",
82                ]
83                .iter()
84                .map(|s| s.to_string())
85                .collect::<BTreeSet<_>>(),
86                ["bash", "edit", "read", "grep", "glob"]
87                    .iter()
88                    .map(|s| s.to_string())
89                    .collect::<BTreeSet<_>>(),
90                ModelPref::ClaudeSonnet,
91            ),
92            "cursor" => (
93                "Generate suggestions that fit the surrounding code style. Prefer \
94                 minimal diffs that keep the user in flow.",
95                [
96                    "match existing code style",
97                    "do not invent new APIs without justification",
98                ]
99                .iter()
100                .map(|s| s.to_string())
101                .collect::<BTreeSet<_>>(),
102                ["edit", "read"]
103                    .iter()
104                    .map(|s| s.to_string())
105                    .collect::<BTreeSet<_>>(),
106                ModelPref::AnyCheap,
107            ),
108            "aider" => (
109                "Apply edits as small, atomic git commits with conventional commit \
110                 messages. Run lint and tests before considering a change complete.",
111                [
112                    "one logical change per commit",
113                    "use conventional commit messages",
114                ]
115                .iter()
116                .map(|s| s.to_string())
117                .collect::<BTreeSet<_>>(),
118                ["edit", "read", "shell"]
119                    .iter()
120                    .map(|s| s.to_string())
121                    .collect::<BTreeSet<_>>(),
122                ModelPref::ClaudeSonnet,
123            ),
124            _ => (
125                "You are a careful, helpful coding assistant.",
126                BTreeSet::new(),
127                BTreeSet::new(),
128                ModelPref::AnyCheap,
129            ),
130        };
131
132        AgentConfig {
133            system_prompt_prefix: prefix.to_string(),
134            model_pref: model,
135            behavioral_rules: rules,
136            tool_permissions: perms,
137            response_style: ResponseStyle::Normal,
138            extensions: BTreeMap::new(),
139        }
140    }
141
142    /// Stable hash used as a cache key and to detect "did anything change" cheaply.
143    ///
144    /// Hashes the canonical JSON form rather than the in-memory layout so that
145    /// reordering of `BTreeMap`/`BTreeSet` (canonical) and field reordering on
146    /// non-canonical inputs do not affect the result.
147    pub fn fingerprint(&self) -> u64 {
148        let json = serde_json::to_string(self).expect("AgentConfig serializes");
149        let mut hasher = std::collections::hash_map::DefaultHasher::new();
150        json.hash(&mut hasher);
151        hasher.finish()
152    }
153
154    /// Read a typed extension blob registered under `key`.
155    pub fn extension<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
156        self.extensions
157            .get(key)
158            .and_then(|v| serde_json::from_value(v.clone()).ok())
159    }
160
161    /// Insert or replace a typed extension blob under `key`.
162    pub fn set_extension<T: Serialize>(&mut self, key: &str, value: &T) -> serde_json::Result<()> {
163        let v = serde_json::to_value(value)?;
164        self.extensions.insert(key.to_string(), v);
165        Ok(())
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn roundtrips_through_serde_json() {
175        let cfg = AgentConfig::default_for("claude-code");
176        let json = serde_json::to_string(&cfg).unwrap();
177        let back: AgentConfig = serde_json::from_str(&json).unwrap();
178        assert_eq!(cfg, back);
179    }
180
181    #[test]
182    fn fingerprint_stable_across_clones() {
183        let cfg = AgentConfig::default_for("claude-code");
184        let h1 = cfg.fingerprint();
185        let h2 = cfg.clone().fingerprint();
186        assert_eq!(h1, h2);
187    }
188
189    #[test]
190    fn fingerprint_changes_when_prefix_changes() {
191        let mut cfg = AgentConfig::default_for("claude-code");
192        let h_before = cfg.fingerprint();
193        cfg.system_prompt_prefix.push_str(" Extra clause.");
194        let h_after = cfg.fingerprint();
195        assert_ne!(h_before, h_after);
196    }
197
198    #[test]
199    fn fingerprint_changes_when_model_changes() {
200        let mut cfg = AgentConfig::default_for("claude-code");
201        let h_before = cfg.fingerprint();
202        cfg.model_pref = ModelPref::ClaudeOpus;
203        let h_after = cfg.fingerprint();
204        assert_ne!(h_before, h_after);
205    }
206
207    #[test]
208    fn fingerprint_changes_when_a_rule_is_added() {
209        let mut cfg = AgentConfig::default_for("claude-code");
210        let h_before = cfg.fingerprint();
211        cfg.behavioral_rules
212            .insert("never edit .env files".to_string());
213        let h_after = cfg.fingerprint();
214        assert_ne!(h_before, h_after);
215    }
216
217    #[test]
218    fn default_for_known_adapters_is_non_empty() {
219        for adapter in ["claude-code", "cursor", "aider"] {
220            let cfg = AgentConfig::default_for(adapter);
221            assert!(
222                !cfg.system_prompt_prefix.is_empty(),
223                "{adapter} default has empty system_prompt_prefix",
224            );
225        }
226    }
227
228    #[test]
229    fn default_for_unknown_adapter_returns_safe_fallback() {
230        let cfg = AgentConfig::default_for("never-heard-of-it");
231        assert!(!cfg.system_prompt_prefix.is_empty());
232        assert!(cfg.behavioral_rules.is_empty());
233        assert!(cfg.tool_permissions.is_empty());
234    }
235
236    #[test]
237    fn extension_roundtrip_with_typed_payload() {
238        #[derive(Serialize, Deserialize, PartialEq, Debug)]
239        struct CursorExt {
240            cursorrules_extra: String,
241            file_glob_overrides: Vec<String>,
242        }
243
244        let mut cfg = AgentConfig::default_for("cursor");
245        let ext = CursorExt {
246            cursorrules_extra: "no inline scripts".to_string(),
247            file_glob_overrides: vec!["**/*.tsx".to_string()],
248        };
249        cfg.set_extension("cursor", &ext).unwrap();
250
251        let back: CursorExt = cfg.extension("cursor").unwrap();
252        assert_eq!(ext, back);
253    }
254
255    #[test]
256    fn extension_returns_none_when_key_absent() {
257        let cfg = AgentConfig::default_for("claude-code");
258        let value: Option<String> = cfg.extension("does-not-exist");
259        assert!(value.is_none());
260    }
261}
262
263#[cfg(test)]
264mod proptests {
265    use super::*;
266    use proptest::collection::{btree_map, btree_set};
267    use proptest::prelude::*;
268
269    fn arb_model_pref() -> impl Strategy<Value = ModelPref> {
270        prop_oneof![
271            Just(ModelPref::ClaudeOpus),
272            Just(ModelPref::ClaudeSonnet),
273            Just(ModelPref::ClaudeHaiku),
274            Just(ModelPref::Gpt4o),
275            Just(ModelPref::Gpt4oMini),
276            Just(ModelPref::AnyCheap),
277            "[a-z][a-z0-9.:-]{2,15}".prop_map(ModelPref::Ollama),
278        ]
279    }
280
281    fn arb_response_style() -> impl Strategy<Value = ResponseStyle> {
282        prop_oneof![
283            Just(ResponseStyle::Terse),
284            Just(ResponseStyle::Normal),
285            Just(ResponseStyle::Verbose),
286        ]
287    }
288
289    fn arb_agent_config() -> impl Strategy<Value = AgentConfig> {
290        (
291            "[ -~]{0,200}",                               // system_prompt_prefix
292            arb_model_pref(),                             // model_pref
293            btree_set("[a-z ]{1,30}", 0..6),              // behavioral_rules
294            btree_set("[a-z_]{1,15}", 0..6),              // tool_permissions
295            arb_response_style(),                         // response_style
296            btree_map("[a-z]{1,10}", any::<i32>(), 0..3), // extensions (typed as i32 for simplicity)
297        )
298            .prop_map(|(prefix, model, rules, perms, style, ext_raw)| {
299                let extensions = ext_raw
300                    .into_iter()
301                    .map(|(k, v)| (k, serde_json::Value::from(v)))
302                    .collect();
303                AgentConfig {
304                    system_prompt_prefix: prefix,
305                    model_pref: model,
306                    behavioral_rules: rules,
307                    tool_permissions: perms,
308                    response_style: style,
309                    extensions,
310                }
311            })
312    }
313
314    proptest! {
315        #[test]
316        fn json_roundtrip_is_identity(cfg in arb_agent_config()) {
317            let json = serde_json::to_string(&cfg).unwrap();
318            let back: AgentConfig = serde_json::from_str(&json).unwrap();
319            prop_assert_eq!(cfg, back);
320        }
321
322        #[test]
323        fn fingerprint_invariant_under_json_roundtrip(cfg in arb_agent_config()) {
324            let json = serde_json::to_string(&cfg).unwrap();
325            let back: AgentConfig = serde_json::from_str(&json).unwrap();
326            prop_assert_eq!(cfg.fingerprint(), back.fingerprint());
327        }
328
329        #[test]
330        fn equal_configs_have_equal_fingerprints(cfg in arb_agent_config()) {
331            let twin = cfg.clone();
332            prop_assert_eq!(cfg.fingerprint(), twin.fingerprint());
333        }
334    }
335}