1use std::path::Path;
5
6use crate::error::ConfigError;
7use crate::root::Config;
8
9impl Config {
10 pub fn load(path: &Path) -> Result<Self, ConfigError> {
18 let mut config = if path.exists() {
19 let content = std::fs::read_to_string(path)?;
20 toml::from_str::<Self>(&content)?
21 } else {
22 Self::default()
23 };
24
25 config.apply_env_overrides();
26 config.normalize_legacy_runtime_defaults();
27 Ok(config)
28 }
29
30 pub fn dump_defaults() -> Result<String, crate::error::ConfigError> {
53 let defaults = Self::default();
54 toml::to_string_pretty(&defaults).map_err(|e| {
55 crate::error::ConfigError::Validation(format!("failed to serialize defaults: {e}"))
56 })
57 }
58
59 #[allow(clippy::too_many_lines)]
65 pub fn validate(&self) -> Result<(), ConfigError> {
66 if self.memory.history_limit > 10_000 {
67 return Err(ConfigError::Validation(format!(
68 "history_limit must be <= 10000, got {}",
69 self.memory.history_limit
70 )));
71 }
72 if self.memory.context_budget_tokens > 1_000_000 {
73 return Err(ConfigError::Validation(format!(
74 "context_budget_tokens must be <= 1000000, got {}",
75 self.memory.context_budget_tokens
76 )));
77 }
78 if self.agent.max_tool_iterations > 100 {
79 return Err(ConfigError::Validation(format!(
80 "max_tool_iterations must be <= 100, got {}",
81 self.agent.max_tool_iterations
82 )));
83 }
84 if self.a2a.rate_limit == 0 {
85 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
86 }
87 if self.gateway.rate_limit == 0 {
88 return Err(ConfigError::Validation(
89 "gateway.rate_limit must be > 0".into(),
90 ));
91 }
92 if self.gateway.max_body_size > 10_485_760 {
93 return Err(ConfigError::Validation(format!(
94 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
95 self.gateway.max_body_size
96 )));
97 }
98 if self.memory.token_safety_margin <= 0.0 {
99 return Err(ConfigError::Validation(format!(
100 "token_safety_margin must be > 0.0, got {}",
101 self.memory.token_safety_margin
102 )));
103 }
104 if self.memory.tool_call_cutoff == 0 {
105 return Err(ConfigError::Validation(
106 "tool_call_cutoff must be >= 1".into(),
107 ));
108 }
109 if let crate::memory::CompressionStrategy::Proactive {
110 threshold_tokens,
111 max_summary_tokens,
112 } = &self.memory.compression.strategy
113 {
114 if *threshold_tokens < 1_000 {
115 return Err(ConfigError::Validation(format!(
116 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
117 )));
118 }
119 if *max_summary_tokens < 128 {
120 return Err(ConfigError::Validation(format!(
121 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
122 )));
123 }
124 }
125 if !self.memory.soft_compaction_threshold.is_finite()
126 || self.memory.soft_compaction_threshold <= 0.0
127 || self.memory.soft_compaction_threshold >= 1.0
128 {
129 return Err(ConfigError::Validation(format!(
130 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
131 self.memory.soft_compaction_threshold
132 )));
133 }
134 if !self.memory.hard_compaction_threshold.is_finite()
135 || self.memory.hard_compaction_threshold <= 0.0
136 || self.memory.hard_compaction_threshold >= 1.0
137 {
138 return Err(ConfigError::Validation(format!(
139 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
140 self.memory.hard_compaction_threshold
141 )));
142 }
143 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
144 return Err(ConfigError::Validation(format!(
145 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
146 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
147 )));
148 }
149 if self.memory.graph.temporal_decay_rate < 0.0
150 || self.memory.graph.temporal_decay_rate > 10.0
151 {
152 return Err(ConfigError::Validation(format!(
153 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
154 self.memory.graph.temporal_decay_rate
155 )));
156 }
157 if self.memory.compression.probe.enabled {
158 let probe = &self.memory.compression.probe;
159 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
160 return Err(ConfigError::Validation(format!(
161 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
162 probe.threshold
163 )));
164 }
165 if !probe.hard_fail_threshold.is_finite()
166 || probe.hard_fail_threshold < 0.0
167 || probe.hard_fail_threshold >= 1.0
168 {
169 return Err(ConfigError::Validation(format!(
170 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
171 probe.hard_fail_threshold
172 )));
173 }
174 if probe.hard_fail_threshold >= probe.threshold {
175 return Err(ConfigError::Validation(format!(
176 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
177 memory.compression.probe.threshold ({})",
178 probe.hard_fail_threshold, probe.threshold
179 )));
180 }
181 if probe.max_questions < 1 {
182 return Err(ConfigError::Validation(
183 "memory.compression.probe.max_questions must be >= 1".into(),
184 ));
185 }
186 if probe.timeout_secs < 1 {
187 return Err(ConfigError::Validation(
188 "memory.compression.probe.timeout_secs must be >= 1".into(),
189 ));
190 }
191 }
192 {
194 use std::collections::HashSet;
195 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
196 for s in &self.mcp.servers {
197 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
199 return Err(ConfigError::Validation(format!(
200 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
201 s.id
202 )));
203 }
204 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
206 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
207 if !seen_oauth_vault_keys.insert(key.clone()) {
208 return Err(ConfigError::Validation(format!(
209 "MCP server '{}' has vault key collision ('{key}'): another server \
210 with the same normalized ID already uses this key",
211 s.id
212 )));
213 }
214 }
215 }
216 }
217
218 self.experiments
219 .validate()
220 .map_err(ConfigError::Validation)?;
221
222 if self.orchestration.plan_cache.enabled {
223 self.orchestration
224 .plan_cache
225 .validate()
226 .map_err(ConfigError::Validation)?;
227 }
228
229 let ct = self.orchestration.completeness_threshold;
230 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
231 return Err(ConfigError::Validation(format!(
232 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
233 )));
234 }
235
236 if self.agent.focus.compression_interval == 0 {
238 return Err(ConfigError::Validation(
239 "agent.focus.compression_interval must be >= 1".into(),
240 ));
241 }
242 if self.agent.focus.min_messages_per_focus == 0 {
243 return Err(ConfigError::Validation(
244 "agent.focus.min_messages_per_focus must be >= 1".into(),
245 ));
246 }
247
248 if self.memory.sidequest.interval_turns == 0 {
250 return Err(ConfigError::Validation(
251 "memory.sidequest.interval_turns must be >= 1".into(),
252 ));
253 }
254 if !self.memory.sidequest.max_eviction_ratio.is_finite()
255 || self.memory.sidequest.max_eviction_ratio <= 0.0
256 || self.memory.sidequest.max_eviction_ratio > 1.0
257 {
258 return Err(ConfigError::Validation(format!(
259 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
260 self.memory.sidequest.max_eviction_ratio
261 )));
262 }
263
264 let sct = self.llm.semantic_cache_threshold;
265 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
266 return Err(ConfigError::Validation(format!(
267 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
268 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
269 )));
270 }
271
272 self.validate_provider_names()?;
273
274 Ok(())
275 }
276
277 #[allow(clippy::too_many_lines)]
278 fn validate_provider_names(&self) -> Result<(), ConfigError> {
279 use std::collections::HashSet;
280 let known: HashSet<String> = self
281 .llm
282 .providers
283 .iter()
284 .map(super::providers::ProviderEntry::effective_name)
285 .collect();
286
287 let fields: &[(&str, &crate::providers::ProviderName)] = &[
288 (
289 "memory.tiers.scene_provider",
290 &self.memory.tiers.scene_provider,
291 ),
292 (
293 "memory.compression.compress_provider",
294 &self.memory.compression.compress_provider,
295 ),
296 (
297 "memory.consolidation.consolidation_provider",
298 &self.memory.consolidation.consolidation_provider,
299 ),
300 (
301 "memory.admission.admission_provider",
302 &self.memory.admission.admission_provider,
303 ),
304 (
305 "memory.admission.goal_utility_provider",
306 &self.memory.admission.goal_utility_provider,
307 ),
308 (
309 "memory.store_routing.routing_classifier_provider",
310 &self.memory.store_routing.routing_classifier_provider,
311 ),
312 (
313 "skills.learning.feedback_provider",
314 &self.skills.learning.feedback_provider,
315 ),
316 (
317 "skills.learning.arise_trace_provider",
318 &self.skills.learning.arise_trace_provider,
319 ),
320 (
321 "skills.learning.stem_provider",
322 &self.skills.learning.stem_provider,
323 ),
324 (
325 "skills.learning.erl_extract_provider",
326 &self.skills.learning.erl_extract_provider,
327 ),
328 (
329 "mcp.pruning.pruning_provider",
330 &self.mcp.pruning.pruning_provider,
331 ),
332 (
333 "mcp.tool_discovery.embedding_provider",
334 &self.mcp.tool_discovery.embedding_provider,
335 ),
336 (
337 "security.response_verification.verifier_provider",
338 &self.security.response_verification.verifier_provider,
339 ),
340 (
341 "orchestration.planner_provider",
342 &self.orchestration.planner_provider,
343 ),
344 (
345 "orchestration.verify_provider",
346 &self.orchestration.verify_provider,
347 ),
348 (
349 "orchestration.tool_provider",
350 &self.orchestration.tool_provider,
351 ),
352 ];
353
354 for (field, name) in fields {
355 if !name.is_empty() && !known.contains(name.as_str()) {
356 return Err(ConfigError::Validation(format!(
357 "{field} = {:?} does not match any [[llm.providers]] entry",
358 name.as_str()
359 )));
360 }
361 }
362
363 if let Some(triage) = self
364 .llm
365 .complexity_routing
366 .as_ref()
367 .and_then(|cr| cr.triage_provider.as_ref())
368 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
369 {
370 return Err(ConfigError::Validation(format!(
371 "llm.complexity_routing.triage_provider = {:?} does not match any \
372 [[llm.providers]] entry",
373 triage.as_str()
374 )));
375 }
376
377 if let Some(embed) = self
378 .llm
379 .router
380 .as_ref()
381 .and_then(|r| r.bandit.as_ref())
382 .map(|b| &b.embedding_provider)
383 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
384 {
385 return Err(ConfigError::Validation(format!(
386 "llm.router.bandit.embedding_provider = {:?} does not match any \
387 [[llm.providers]] entry",
388 embed.as_str()
389 )));
390 }
391
392 Ok(())
393 }
394
395 fn normalize_legacy_runtime_defaults(&mut self) {
396 use crate::defaults::{
397 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
398 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
399 is_legacy_default_sqlite_path,
400 };
401
402 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
403 self.memory.sqlite_path = default_sqlite_path();
404 }
405
406 for skill_path in &mut self.skills.paths {
407 if is_legacy_default_skills_path(skill_path) {
408 *skill_path = default_skills_dir();
409 }
410 }
411
412 if is_legacy_default_debug_dir(&self.debug.output_dir) {
413 self.debug.output_dir = default_debug_dir();
414 }
415
416 if is_legacy_default_log_file(&self.logging.file) {
417 self.logging.file = default_log_file_path();
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 fn config_with_sct(threshold: f32) -> Config {
427 let mut cfg = Config::default();
428 cfg.llm.semantic_cache_threshold = threshold;
429 cfg
430 }
431
432 #[test]
433 fn semantic_cache_threshold_valid_zero() {
434 assert!(config_with_sct(0.0).validate().is_ok());
435 }
436
437 #[test]
438 fn semantic_cache_threshold_valid_mid() {
439 assert!(config_with_sct(0.5).validate().is_ok());
440 }
441
442 #[test]
443 fn semantic_cache_threshold_valid_one() {
444 assert!(config_with_sct(1.0).validate().is_ok());
445 }
446
447 #[test]
448 fn semantic_cache_threshold_invalid_negative() {
449 let err = config_with_sct(-0.1).validate().unwrap_err();
450 assert!(
451 err.to_string().contains("semantic_cache_threshold"),
452 "unexpected error: {err}"
453 );
454 }
455
456 #[test]
457 fn semantic_cache_threshold_invalid_above_one() {
458 let err = config_with_sct(1.1).validate().unwrap_err();
459 assert!(
460 err.to_string().contains("semantic_cache_threshold"),
461 "unexpected error: {err}"
462 );
463 }
464
465 #[test]
466 fn semantic_cache_threshold_invalid_nan() {
467 let err = config_with_sct(f32::NAN).validate().unwrap_err();
468 assert!(
469 err.to_string().contains("semantic_cache_threshold"),
470 "unexpected error: {err}"
471 );
472 }
473
474 #[test]
475 fn semantic_cache_threshold_invalid_infinity() {
476 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
477 assert!(
478 err.to_string().contains("semantic_cache_threshold"),
479 "unexpected error: {err}"
480 );
481 }
482
483 #[test]
484 fn semantic_cache_threshold_invalid_neg_infinity() {
485 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
486 assert!(
487 err.to_string().contains("semantic_cache_threshold"),
488 "unexpected error: {err}"
489 );
490 }
491
492 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
493 let mut cfg = Config::default();
494 cfg.memory.compression.probe.enabled = enabled;
495 cfg.memory.compression.probe.threshold = threshold;
496 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
497 cfg
498 }
499
500 #[test]
501 fn probe_disabled_skips_validation() {
502 let cfg = probe_config(false, 0.0, 1.0);
504 assert!(cfg.validate().is_ok());
505 }
506
507 #[test]
508 fn probe_valid_thresholds() {
509 let cfg = probe_config(true, 0.6, 0.35);
510 assert!(cfg.validate().is_ok());
511 }
512
513 #[test]
514 fn probe_threshold_zero_invalid() {
515 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
516 assert!(
517 err.to_string().contains("probe.threshold"),
518 "unexpected error: {err}"
519 );
520 }
521
522 #[test]
523 fn probe_hard_fail_threshold_above_one_invalid() {
524 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
525 assert!(
526 err.to_string().contains("probe.hard_fail_threshold"),
527 "unexpected error: {err}"
528 );
529 }
530
531 #[test]
532 fn probe_hard_fail_gte_threshold_invalid() {
533 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
534 assert!(
535 err.to_string().contains("probe.hard_fail_threshold"),
536 "unexpected error: {err}"
537 );
538 }
539
540 fn config_with_completeness_threshold(ct: f32) -> Config {
541 let mut cfg = Config::default();
542 cfg.orchestration.completeness_threshold = ct;
543 cfg
544 }
545
546 #[test]
547 fn completeness_threshold_valid_zero() {
548 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
549 }
550
551 #[test]
552 fn completeness_threshold_valid_default() {
553 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
554 }
555
556 #[test]
557 fn completeness_threshold_valid_one() {
558 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
559 }
560
561 #[test]
562 fn completeness_threshold_invalid_negative() {
563 let err = config_with_completeness_threshold(-0.1)
564 .validate()
565 .unwrap_err();
566 assert!(
567 err.to_string().contains("completeness_threshold"),
568 "unexpected error: {err}"
569 );
570 }
571
572 #[test]
573 fn completeness_threshold_invalid_above_one() {
574 let err = config_with_completeness_threshold(1.1)
575 .validate()
576 .unwrap_err();
577 assert!(
578 err.to_string().contains("completeness_threshold"),
579 "unexpected error: {err}"
580 );
581 }
582
583 #[test]
584 fn completeness_threshold_invalid_nan() {
585 let err = config_with_completeness_threshold(f32::NAN)
586 .validate()
587 .unwrap_err();
588 assert!(
589 err.to_string().contains("completeness_threshold"),
590 "unexpected error: {err}"
591 );
592 }
593
594 #[test]
595 fn completeness_threshold_invalid_infinity() {
596 let err = config_with_completeness_threshold(f32::INFINITY)
597 .validate()
598 .unwrap_err();
599 assert!(
600 err.to_string().contains("completeness_threshold"),
601 "unexpected error: {err}"
602 );
603 }
604
605 fn config_with_provider(name: &str) -> Config {
606 let mut cfg = Config::default();
607 cfg.llm.providers.push(crate::providers::ProviderEntry {
608 provider_type: crate::providers::ProviderKind::Ollama,
609 name: Some(name.into()),
610 ..Default::default()
611 });
612 cfg
613 }
614
615 #[test]
616 fn validate_provider_names_all_empty_ok() {
617 let cfg = Config::default();
618 assert!(cfg.validate_provider_names().is_ok());
619 }
620
621 #[test]
622 fn validate_provider_names_matching_provider_ok() {
623 let mut cfg = config_with_provider("fast");
624 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
625 assert!(cfg.validate_provider_names().is_ok());
626 }
627
628 #[test]
629 fn validate_provider_names_unknown_provider_err() {
630 let mut cfg = config_with_provider("fast");
631 cfg.memory.admission.admission_provider =
632 crate::providers::ProviderName::new("nonexistent");
633 let err = cfg.validate_provider_names().unwrap_err();
634 let msg = err.to_string();
635 assert!(
636 msg.contains("admission_provider") && msg.contains("nonexistent"),
637 "unexpected error: {msg}"
638 );
639 }
640
641 #[test]
642 fn validate_provider_names_triage_provider_none_ok() {
643 let mut cfg = config_with_provider("fast");
644 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
645 triage_provider: None,
646 ..Default::default()
647 });
648 assert!(cfg.validate_provider_names().is_ok());
649 }
650
651 #[test]
652 fn validate_provider_names_triage_provider_matching_ok() {
653 let mut cfg = config_with_provider("fast");
654 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
655 triage_provider: Some(crate::providers::ProviderName::new("fast")),
656 ..Default::default()
657 });
658 assert!(cfg.validate_provider_names().is_ok());
659 }
660
661 #[test]
662 fn validate_provider_names_triage_provider_unknown_err() {
663 let mut cfg = config_with_provider("fast");
664 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
665 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
666 ..Default::default()
667 });
668 let err = cfg.validate_provider_names().unwrap_err();
669 let msg = err.to_string();
670 assert!(
671 msg.contains("triage_provider") && msg.contains("ghost"),
672 "unexpected error: {msg}"
673 );
674 }
675
676 #[test]
679 fn toml_float_fields_deserialise_correctly() {
680 let toml = r"
681[llm.router.reputation]
682enabled = true
683decay_factor = 0.95
684weight = 0.3
685
686[llm.router.bandit]
687enabled = false
688cost_weight = 0.3
689alpha = 1.0
690decay_factor = 0.99
691
692[skills]
693disambiguation_threshold = 0.25
694cosine_weight = 0.7
695";
696 let wrapped = format!(
698 "{}\n{}",
699 toml,
700 r"[memory.semantic]
701mmr_lambda = 0.7
702"
703 );
704 let router: crate::providers::RouterConfig = toml::from_str(
706 r"[reputation]
707enabled = true
708decay_factor = 0.95
709weight = 0.3
710",
711 )
712 .expect("RouterConfig with float fields must deserialise");
713 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
714
715 let bandit: crate::providers::BanditConfig =
716 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
717 .expect("BanditConfig with float fields must deserialise");
718 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
719
720 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
721 .expect("SemanticConfig with float fields must deserialise");
722 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
723
724 let skills: crate::features::SkillsConfig =
725 toml::from_str("disambiguation_threshold = 0.25\n")
726 .expect("SkillsConfig with float fields must deserialise");
727 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
728
729 let _ = wrapped; }
731}