1use 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 #[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 #[serde(default)]
32 pub replay_policy: ReplayPolicyConfig,
33 #[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 #[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 #[serde(default)]
51 pub auto: Option<AutoCompactionConfig>,
52 #[serde(default)]
54 pub sm_compact: Option<SmCompactConfig>,
55 #[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 #[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 #[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
287fn 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 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 let yaml = "$NOT_BRACED stays\n";
413 let out = substitute_env_vars(yaml);
414 assert_eq!(out, "$NOT_BRACED stays\n");
415 }
416}