Skip to main content

nexo_driver_loop/
config.rs

1//! Driver runtime configuration. The full YAML at
2//! `config/driver/claude.yaml` deserialises into [`DriverConfig`].
3//!
4//! Env-var substitution (`${VAR}` and `${VAR:-default}`) happens
5//! *before* yaml parsing — same convention as `crates/config`.
6
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use nexo_config::types::llm::AutoCompactionConfig;
11use nexo_driver_types::{ExtractMemoriesConfig, SmCompactConfig};
12use serde::Deserialize;
13
14use crate::error::DriverError;
15
16#[derive(Clone, Debug, Deserialize)]
17pub struct DriverConfig {
18    /// Top-level Claude CLI config (binary, default args, timeouts).
19    /// Flattened from the YAML root for ergonomic access.
20    #[serde(flatten)]
21    pub claude: nexo_driver_claude::ClaudeConfig,
22    #[serde(with = "humantime_serde", default = "default_setup_timeout")]
23    pub setup_timeout: Duration,
24    pub binding_store: BindingStoreConfig,
25    pub permission: PermissionConfig,
26    pub workspace: WorkspaceConfig,
27    pub driver: DriverBinConfig,
28    #[serde(default)]
29    pub acceptance: AcceptanceConfig,
30    /// Replay-policy + LlmDecider deny-shortcut tuning.
31    #[serde(default)]
32    pub replay_policy: ReplayPolicyConfig,
33    /// Opportunistic /compact injection.
34    #[serde(default)]
35    pub compact_policy: CompactPolicyConfig,
36}
37
38#[derive(Clone, Debug, Deserialize)]
39pub struct CompactPolicyConfig {
40    #[serde(default = "default_compact_enabled")]
41    pub enabled: bool,
42    /// Model context window in tokens. `0` disables the policy.
43    #[serde(default)]
44    pub context_window: u64,
45    #[serde(default = "default_compact_threshold")]
46    pub threshold: f64,
47    #[serde(default = "default_compact_min_gap")]
48    pub min_turns_between_compacts: u32,
49    /// Auto-compaction triggers (token pressure + age).
50    #[serde(default)]
51    pub auto: Option<AutoCompactionConfig>,
52    /// Session-memory compact persistence config.
53    #[serde(default)]
54    pub sm_compact: Option<SmCompactConfig>,
55    /// Post-turn memory extraction config.
56    #[serde(default)]
57    pub extract_memories: Option<ExtractMemoriesConfig>,
58}
59
60impl Default for CompactPolicyConfig {
61    fn default() -> Self {
62        Self {
63            enabled: default_compact_enabled(),
64            context_window: 0,
65            threshold: default_compact_threshold(),
66            min_turns_between_compacts: default_compact_min_gap(),
67            auto: None,
68            sm_compact: None,
69            extract_memories: None,
70        }
71    }
72}
73
74fn default_compact_enabled() -> bool {
75    true
76}
77fn default_compact_threshold() -> f64 {
78    0.7
79}
80fn default_compact_min_gap() -> u32 {
81    5
82}
83
84#[derive(Clone, Debug, Deserialize)]
85pub struct ReplayPolicyConfig {
86    #[serde(default = "default_max_fresh_session_retries")]
87    pub max_fresh_session_retries: u32,
88    #[serde(default)]
89    pub deny_shortcut: DenyShortcutConfig,
90}
91
92impl Default for ReplayPolicyConfig {
93    fn default() -> Self {
94        Self {
95            max_fresh_session_retries: default_max_fresh_session_retries(),
96            deny_shortcut: DenyShortcutConfig::default(),
97        }
98    }
99}
100
101#[derive(Clone, Debug, Deserialize)]
102pub struct DenyShortcutConfig {
103    #[serde(default = "default_deny_shortcut_enabled")]
104    pub enabled: bool,
105    #[serde(default = "default_deny_shortcut_threshold")]
106    pub threshold: f32,
107    #[serde(default = "default_deny_shortcut_min_hits")]
108    pub min_hits: usize,
109}
110
111impl Default for DenyShortcutConfig {
112    fn default() -> Self {
113        Self {
114            enabled: default_deny_shortcut_enabled(),
115            threshold: default_deny_shortcut_threshold(),
116            min_hits: default_deny_shortcut_min_hits(),
117        }
118    }
119}
120
121fn default_max_fresh_session_retries() -> u32 {
122    1
123}
124fn default_deny_shortcut_enabled() -> bool {
125    true
126}
127fn default_deny_shortcut_threshold() -> f32 {
128    0.6
129}
130fn default_deny_shortcut_min_hits() -> usize {
131    3
132}
133
134#[derive(Clone, Debug, Deserialize)]
135pub struct BindingStoreConfig {
136    pub kind: BindingStoreKind,
137    #[serde(default)]
138    pub path: Option<PathBuf>,
139    #[serde(default, with = "humantime_serde::option")]
140    pub idle_ttl: Option<Duration>,
141    #[serde(default, with = "humantime_serde::option")]
142    pub max_age: Option<Duration>,
143}
144
145#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
146#[serde(rename_all = "snake_case")]
147pub enum BindingStoreKind {
148    Sqlite,
149    Memory,
150}
151
152#[derive(Clone, Debug, Deserialize)]
153pub struct PermissionConfig {
154    pub socket: PathBuf,
155    #[serde(with = "humantime_serde", default = "default_decision_timeout")]
156    pub decision_timeout: Duration,
157    #[serde(default = "default_session_cache_max")]
158    pub session_cache_max: usize,
159    pub decider: DeciderConfig,
160}
161
162#[derive(Clone, Debug, Deserialize)]
163#[serde(tag = "kind", rename_all = "snake_case")]
164pub enum DeciderConfig {
165    Llm {
166        provider: String,
167        #[serde(default)]
168        model: Option<String>,
169        #[serde(default = "default_decider_max_tokens")]
170        max_tokens: u32,
171        #[serde(default)]
172        system_prompt_path: Option<PathBuf>,
173        /// Semantic memory of past decisions.
174        #[serde(default)]
175        memory: Option<DeciderMemoryConfig>,
176    },
177    AllowAll,
178    DenyAll {
179        reason: String,
180    },
181}
182
183#[derive(Clone, Debug, Deserialize)]
184pub struct DeciderMemoryConfig {
185    #[serde(default)]
186    pub enabled: bool,
187    #[serde(default)]
188    pub path: Option<PathBuf>,
189    pub embedding_provider: EmbeddingProviderConfig,
190    #[serde(default = "default_recall_k")]
191    pub recall_k: usize,
192    #[serde(default)]
193    pub namespace: NamespaceConfig,
194}
195
196#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
197#[serde(rename_all = "snake_case")]
198pub enum NamespaceConfig {
199    #[default]
200    PerGoal,
201    Global,
202}
203
204#[derive(Clone, Debug, Deserialize)]
205#[serde(tag = "kind", rename_all = "snake_case")]
206pub enum EmbeddingProviderConfig {
207    Http {
208        base_url: String,
209        model: String,
210        #[serde(default)]
211        api_key_env: Option<String>,
212    },
213}
214
215fn default_recall_k() -> usize {
216    5
217}
218
219#[derive(Clone, Debug, Deserialize)]
220pub struct WorkspaceConfig {
221    pub root: PathBuf,
222    #[serde(default)]
223    pub cleanup_on_done: bool,
224    #[serde(default)]
225    pub git: WorkspaceGitConfig,
226}
227
228#[derive(Clone, Debug, Default, Deserialize)]
229pub struct WorkspaceGitConfig {
230    #[serde(default)]
231    pub enabled: bool,
232    #[serde(default)]
233    pub source_repo: Option<PathBuf>,
234    #[serde(default = "default_base_ref")]
235    pub base_ref: String,
236}
237
238fn default_base_ref() -> String {
239    "HEAD".into()
240}
241
242#[derive(Clone, Debug, Deserialize)]
243pub struct DriverBinConfig {
244    pub bin_path: PathBuf,
245    #[serde(default = "default_emit_nats_events")]
246    pub emit_nats_events: bool,
247}
248
249#[derive(Clone, Debug, Default, Deserialize)]
250pub struct AcceptanceConfig {
251    #[serde(default, with = "humantime_serde::option")]
252    pub default_shell_timeout: Option<Duration>,
253    /// Bytes of stdout+stderr attached as evidence on each
254    /// `AcceptanceFailure`. Default 4 KiB.
255    #[serde(default)]
256    pub evidence_byte_limit: Option<usize>,
257}
258
259fn default_setup_timeout() -> Duration {
260    Duration::from_secs(30)
261}
262fn default_decision_timeout() -> Duration {
263    Duration::from_secs(30)
264}
265fn default_session_cache_max() -> usize {
266    1024
267}
268fn default_decider_max_tokens() -> u32 {
269    256
270}
271fn default_emit_nats_events() -> bool {
272    true
273}
274
275impl DriverConfig {
276    pub fn from_yaml_str(yaml: &str) -> Result<Self, DriverError> {
277        let substituted = substitute_env_vars(yaml);
278        serde_yaml::from_str(&substituted).map_err(|e| DriverError::Yaml(e.to_string()))
279    }
280
281    pub fn from_yaml_file(path: &Path) -> Result<Self, DriverError> {
282        let raw = std::fs::read_to_string(path)?;
283        Self::from_yaml_str(&raw)
284    }
285}
286
287/// Substitute `${VAR}` and `${VAR:-default}` against process env.
288/// Patterns we don't recognise are left intact.
289fn substitute_env_vars(input: &str) -> String {
290    let mut out = String::with_capacity(input.len());
291    let bytes = input.as_bytes();
292    let mut i = 0;
293    // `last_copy` marks the start of the next ASCII-safe slice we
294    // owe to the output. Copying via `push_str(&input[..])` keeps
295    // multi-byte UTF-8 (em-dashes, accents, …) intact — the
296    // earlier byte-by-byte `as char` cast split codepoints into
297    // bytes, and bytes like 0x80–0x9F are C1 control characters
298    // that YAML rejects with "control characters are not allowed".
299    let mut last_copy = 0;
300    while i < bytes.len() {
301        if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
302            if let Some(end) = find_close_brace(bytes, i + 2) {
303                let inner = &input[i + 2..end];
304                let (name, fallback) = match inner.find(":-") {
305                    Some(pos) => (&inner[..pos], Some(&inner[pos + 2..])),
306                    None => (inner, None),
307                };
308                if is_var_name(name) {
309                    out.push_str(&input[last_copy..i]);
310                    let value = std::env::var(name).ok();
311                    let resolved = value.as_deref().or(fallback).unwrap_or("");
312                    out.push_str(resolved);
313                    i = end + 1;
314                    last_copy = i;
315                    continue;
316                }
317            }
318        }
319        i += 1;
320    }
321    out.push_str(&input[last_copy..]);
322    out
323}
324
325fn find_close_brace(bytes: &[u8], from: usize) -> Option<usize> {
326    bytes[from..]
327        .iter()
328        .position(|&b| b == b'}')
329        .map(|p| from + p)
330}
331
332fn is_var_name(s: &str) -> bool {
333    let mut chars = s.chars();
334    match chars.next() {
335        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
336        _ => return false,
337    }
338    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    const MIN_YAML: &str = r#"
346binary: claude
347binding_store:
348  kind: memory
349permission:
350  socket: /tmp/driver.sock
351  decider:
352    kind: allow_all
353workspace:
354  root: /tmp/claude-runs
355driver:
356  bin_path: /usr/local/bin/nexo-driver-permission-mcp
357"#;
358
359    #[test]
360    fn parses_minimum_yaml_with_defaults() {
361        let cfg = DriverConfig::from_yaml_str(MIN_YAML).unwrap();
362        assert_eq!(cfg.binding_store.kind, BindingStoreKind::Memory);
363        assert!(matches!(cfg.permission.decider, DeciderConfig::AllowAll));
364        assert_eq!(cfg.setup_timeout, Duration::from_secs(30));
365        assert_eq!(cfg.permission.decision_timeout, Duration::from_secs(30));
366        assert!(cfg.driver.emit_nats_events);
367        assert!(!cfg.workspace.cleanup_on_done);
368    }
369
370    #[test]
371    fn env_substitution_basic() {
372        std::env::set_var("NEXO_DRIVER_TEST_PATH", "/run/x.sock");
373        let yaml = r#"
374binary: claude
375binding_store:
376  kind: memory
377permission:
378  socket: ${NEXO_DRIVER_TEST_PATH}
379  decider: { kind: allow_all }
380workspace:
381  root: /tmp/claude-runs
382driver:
383  bin_path: /usr/local/bin/nexo-driver-permission-mcp
384"#;
385        let cfg = DriverConfig::from_yaml_str(yaml).unwrap();
386        assert_eq!(cfg.permission.socket, PathBuf::from("/run/x.sock"));
387        std::env::remove_var("NEXO_DRIVER_TEST_PATH");
388    }
389
390    #[test]
391    fn env_substitution_with_default_fallback() {
392        std::env::remove_var("NEXO_DRIVER_TEST_UNSET");
393        let yaml = r#"
394binary: claude
395binding_store:
396  kind: memory
397permission:
398  socket: ${NEXO_DRIVER_TEST_UNSET:-/fallback.sock}
399  decider: { kind: allow_all }
400workspace:
401  root: /tmp/claude-runs
402driver:
403  bin_path: /usr/local/bin/nexo-driver-permission-mcp
404"#;
405        let cfg = DriverConfig::from_yaml_str(yaml).unwrap();
406        assert_eq!(cfg.permission.socket, PathBuf::from("/fallback.sock"));
407    }
408
409    #[test]
410    fn unknown_var_pattern_left_intact() {
411        // `$NOT_BRACED` is not our pattern — we only handle `${...}`.
412        let yaml = "$NOT_BRACED stays\n";
413        let out = substitute_env_vars(yaml);
414        assert_eq!(out, "$NOT_BRACED stays\n");
415    }
416}