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 pub fn validate(&self) -> Result<(), ConfigError> {
65 self.validate_scalar_bounds()?;
66 self.validate_memory_compression()?;
67 self.validate_memory_probe_and_graph()?;
68 self.validate_mcp_servers()?;
69 self.experiments
70 .validate()
71 .map_err(ConfigError::Validation)?;
72 if self.orchestration.plan_cache.enabled {
73 self.orchestration
74 .plan_cache
75 .validate()
76 .map_err(ConfigError::Validation)?;
77 }
78 self.validate_orchestration()?;
79 self.validate_focus_and_sidequest()?;
80 self.validate_llm_and_skills()?;
81 self.validate_provider_names()?;
82 self.validate_mcp_misc()?;
83 Ok(())
84 }
85
86 fn validate_scalar_bounds(&self) -> Result<(), ConfigError> {
88 if self.memory.history_limit > 10_000 {
89 return Err(ConfigError::Validation(format!(
90 "history_limit must be <= 10000, got {}",
91 self.memory.history_limit
92 )));
93 }
94 if self.memory.context_budget_tokens > 1_000_000 {
95 return Err(ConfigError::Validation(format!(
96 "context_budget_tokens must be <= 1000000, got {}",
97 self.memory.context_budget_tokens
98 )));
99 }
100 if self.agent.max_tool_iterations > 100 {
101 return Err(ConfigError::Validation(format!(
102 "max_tool_iterations must be <= 100, got {}",
103 self.agent.max_tool_iterations
104 )));
105 }
106 if self.a2a.rate_limit == 0 {
107 return Err(ConfigError::Validation("a2a.rate_limit must be > 0".into()));
108 }
109 if self.gateway.rate_limit == 0 {
110 return Err(ConfigError::Validation(
111 "gateway.rate_limit must be > 0".into(),
112 ));
113 }
114 if self.gateway.max_body_size > 10_485_760 {
115 return Err(ConfigError::Validation(format!(
116 "gateway.max_body_size must be <= 10485760 (10 MiB), got {}",
117 self.gateway.max_body_size
118 )));
119 }
120 if self.memory.token_safety_margin <= 0.0 {
121 return Err(ConfigError::Validation(format!(
122 "token_safety_margin must be > 0.0, got {}",
123 self.memory.token_safety_margin
124 )));
125 }
126 if self.memory.tool_call_cutoff == 0 {
127 return Err(ConfigError::Validation(
128 "tool_call_cutoff must be >= 1".into(),
129 ));
130 }
131 Ok(())
132 }
133
134 fn validate_memory_compression(&self) -> Result<(), ConfigError> {
136 if let crate::memory::CompressionStrategy::Proactive {
137 threshold_tokens,
138 max_summary_tokens,
139 } = &self.memory.compression.strategy
140 {
141 if *threshold_tokens < 1_000 {
142 return Err(ConfigError::Validation(format!(
143 "compression.threshold_tokens must be >= 1000, got {threshold_tokens}"
144 )));
145 }
146 if *max_summary_tokens < 128 {
147 return Err(ConfigError::Validation(format!(
148 "compression.max_summary_tokens must be >= 128, got {max_summary_tokens}"
149 )));
150 }
151 }
152 if !self.memory.soft_compaction_threshold.is_finite()
153 || self.memory.soft_compaction_threshold <= 0.0
154 || self.memory.soft_compaction_threshold >= 1.0
155 {
156 return Err(ConfigError::Validation(format!(
157 "soft_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
158 self.memory.soft_compaction_threshold
159 )));
160 }
161 if !self.memory.hard_compaction_threshold.is_finite()
162 || self.memory.hard_compaction_threshold <= 0.0
163 || self.memory.hard_compaction_threshold >= 1.0
164 {
165 return Err(ConfigError::Validation(format!(
166 "hard_compaction_threshold must be in (0.0, 1.0) exclusive, got {}",
167 self.memory.hard_compaction_threshold
168 )));
169 }
170 if self.memory.soft_compaction_threshold >= self.memory.hard_compaction_threshold {
171 return Err(ConfigError::Validation(format!(
172 "soft_compaction_threshold ({}) must be less than hard_compaction_threshold ({})",
173 self.memory.soft_compaction_threshold, self.memory.hard_compaction_threshold,
174 )));
175 }
176 Ok(())
177 }
178
179 fn validate_memory_probe_and_graph(&self) -> Result<(), ConfigError> {
181 if self.memory.graph.temporal_decay_rate < 0.0
182 || self.memory.graph.temporal_decay_rate > 10.0
183 {
184 return Err(ConfigError::Validation(format!(
185 "memory.graph.temporal_decay_rate must be in [0.0, 10.0], got {}",
186 self.memory.graph.temporal_decay_rate
187 )));
188 }
189 if self.memory.compression.probe.enabled {
190 let probe = &self.memory.compression.probe;
191 if !probe.threshold.is_finite() || probe.threshold <= 0.0 || probe.threshold > 1.0 {
192 return Err(ConfigError::Validation(format!(
193 "memory.compression.probe.threshold must be in (0.0, 1.0], got {}",
194 probe.threshold
195 )));
196 }
197 if !probe.hard_fail_threshold.is_finite()
198 || probe.hard_fail_threshold < 0.0
199 || probe.hard_fail_threshold >= 1.0
200 {
201 return Err(ConfigError::Validation(format!(
202 "memory.compression.probe.hard_fail_threshold must be in [0.0, 1.0), got {}",
203 probe.hard_fail_threshold
204 )));
205 }
206 if probe.hard_fail_threshold >= probe.threshold {
207 return Err(ConfigError::Validation(format!(
208 "memory.compression.probe.hard_fail_threshold ({}) must be less than \
209 memory.compression.probe.threshold ({})",
210 probe.hard_fail_threshold, probe.threshold
211 )));
212 }
213 if probe.max_questions < 1 {
214 return Err(ConfigError::Validation(
215 "memory.compression.probe.max_questions must be >= 1".into(),
216 ));
217 }
218 if probe.timeout_secs < 1 {
219 return Err(ConfigError::Validation(
220 "memory.compression.probe.timeout_secs must be >= 1".into(),
221 ));
222 }
223 }
224 Ok(())
225 }
226
227 fn validate_mcp_servers(&self) -> Result<(), ConfigError> {
229 use std::collections::HashSet;
230 let mut seen_oauth_vault_keys: HashSet<String> = HashSet::new();
231 for s in &self.mcp.servers {
232 if !s.headers.is_empty() && s.oauth.as_ref().is_some_and(|o| o.enabled) {
234 return Err(ConfigError::Validation(format!(
235 "MCP server '{}': cannot use both 'headers' and 'oauth' simultaneously",
236 s.id
237 )));
238 }
239 if s.oauth.as_ref().is_some_and(|o| o.enabled) {
241 let key = format!("ZEPH_MCP_OAUTH_{}", s.id.to_uppercase().replace('-', "_"));
242 if !seen_oauth_vault_keys.insert(key.clone()) {
243 return Err(ConfigError::Validation(format!(
244 "MCP server '{}' has vault key collision ('{key}'): another server \
245 with the same normalized ID already uses this key",
246 s.id
247 )));
248 }
249 }
250 }
251 Ok(())
252 }
253
254 fn validate_orchestration(&self) -> Result<(), ConfigError> {
256 let ct = self.orchestration.completeness_threshold;
257 if !ct.is_finite() || !(0.0..=1.0).contains(&ct) {
258 return Err(ConfigError::Validation(format!(
259 "orchestration.completeness_threshold must be in [0.0, 1.0], got {ct}"
260 )));
261 }
262 if self.orchestration.cascade_chain_threshold == 1 {
264 return Err(ConfigError::Validation(
265 "orchestration.cascade_chain_threshold=1 aborts on every failure; \
266 use 0 to disable linear-chain cascade abort instead"
267 .into(),
268 ));
269 }
270 let cfrat = self.orchestration.cascade_failure_rate_abort_threshold;
271 if !cfrat.is_finite() || !(0.0..=1.0).contains(&cfrat) {
272 return Err(ConfigError::Validation(format!(
273 "orchestration.cascade_failure_rate_abort_threshold must be in [0.0, 1.0], got {cfrat}"
274 )));
275 }
276 if self.orchestration.lineage_ttl_secs == 0 {
277 return Err(ConfigError::Validation(
278 "orchestration.lineage_ttl_secs must be > 0; \
279 set cascade_chain_threshold=0 to disable lineage tracking instead"
280 .into(),
281 ));
282 }
283 Ok(())
284 }
285
286 fn validate_focus_and_sidequest(&self) -> Result<(), ConfigError> {
288 if self.agent.focus.compression_interval == 0 {
289 return Err(ConfigError::Validation(
290 "agent.focus.compression_interval must be >= 1".into(),
291 ));
292 }
293 if self.agent.focus.min_messages_per_focus == 0 {
294 return Err(ConfigError::Validation(
295 "agent.focus.min_messages_per_focus must be >= 1".into(),
296 ));
297 }
298 if self.agent.focus.auto_consolidate_min_window == 0 {
299 return Err(ConfigError::Validation(
300 "agent.focus.auto_consolidate_min_window must be >= 1 \
301 (set focus.enabled = false to disable auto-consolidation)"
302 .into(),
303 ));
304 }
305 if self.memory.sidequest.interval_turns == 0 {
306 return Err(ConfigError::Validation(
307 "memory.sidequest.interval_turns must be >= 1".into(),
308 ));
309 }
310 if !self.memory.sidequest.max_eviction_ratio.is_finite()
311 || self.memory.sidequest.max_eviction_ratio <= 0.0
312 || self.memory.sidequest.max_eviction_ratio > 1.0
313 {
314 return Err(ConfigError::Validation(format!(
315 "memory.sidequest.max_eviction_ratio must be in (0.0, 1.0], got {}",
316 self.memory.sidequest.max_eviction_ratio
317 )));
318 }
319 Ok(())
320 }
321
322 fn validate_llm_and_skills(&self) -> Result<(), ConfigError> {
324 let sct = self.llm.semantic_cache_threshold;
325 if !(sct.is_finite() && (0.0..=1.0).contains(&sct)) {
326 return Err(ConfigError::Validation(format!(
327 "llm.semantic_cache_threshold must be in [0.0, 1.0], got {sct} \
328 (override via ZEPH_LLM_SEMANTIC_CACHE_THRESHOLD env var)"
329 )));
330 }
331 if self.memory.memcot.enabled && !self.memory.memcot.distill_provider.is_empty() {
333 self.llm.warn_non_fast_tier_provider(
334 &self.memory.memcot.distill_provider,
335 "memory.memcot.distill_provider",
336 &self.memory.memcot.fast_tier_models,
337 );
338 }
339 if self.skills.evaluation.enabled {
341 let weight_sum = self.skills.evaluation.weight_correctness
342 + self.skills.evaluation.weight_reusability
343 + self.skills.evaluation.weight_specificity;
344 if (weight_sum - 1.0_f32).abs() > 1e-3 {
345 return Err(ConfigError::Validation(format!(
346 "skills.evaluation weights must sum to 1.0 (got {weight_sum:.4})"
347 )));
348 }
349 }
350 Ok(())
351 }
352
353 fn validate_mcp_misc(&self) -> Result<(), ConfigError> {
355 if self.mcp.output_schema_hint_bytes < 64 {
356 return Err(ConfigError::Validation(format!(
357 "mcp.output_schema_hint_bytes must be >= 64, got {}; \
358 use forward_output_schema = false to disable forwarding",
359 self.mcp.output_schema_hint_bytes
360 )));
361 }
362 Ok(())
363 }
364
365 fn validate_provider_names(&self) -> Result<(), ConfigError> {
366 let known = self.known_provider_names();
367 self.validate_named_provider_refs(&known)?;
368 self.validate_optional_provider_refs(&known)?;
369 Ok(())
370 }
371
372 fn known_provider_names(&self) -> std::collections::HashSet<String> {
374 self.llm
375 .providers
376 .iter()
377 .map(super::providers::ProviderEntry::effective_name)
378 .collect()
379 }
380
381 fn validate_named_provider_refs(
386 &self,
387 known: &std::collections::HashSet<String>,
388 ) -> Result<(), ConfigError> {
389 let fields: &[(&str, &crate::providers::ProviderName)] = &[
390 (
391 "memory.tiers.scene_provider",
392 &self.memory.tiers.scene_provider,
393 ),
394 (
395 "memory.compression.compress_provider",
396 &self.memory.compression.compress_provider,
397 ),
398 (
399 "memory.consolidation.consolidation_provider",
400 &self.memory.consolidation.consolidation_provider,
401 ),
402 (
403 "memory.admission.admission_provider",
404 &self.memory.admission.admission_provider,
405 ),
406 (
407 "memory.admission.goal_utility_provider",
408 &self.memory.admission.goal_utility_provider,
409 ),
410 (
411 "memory.store_routing.routing_classifier_provider",
412 &self.memory.store_routing.routing_classifier_provider,
413 ),
414 (
415 "skills.learning.feedback_provider",
416 &self.skills.learning.feedback_provider,
417 ),
418 (
419 "skills.learning.arise_trace_provider",
420 &self.skills.learning.arise_trace_provider,
421 ),
422 (
423 "skills.learning.stem_provider",
424 &self.skills.learning.stem_provider,
425 ),
426 (
427 "skills.learning.erl_extract_provider",
428 &self.skills.learning.erl_extract_provider,
429 ),
430 (
431 "mcp.pruning.pruning_provider",
432 &self.mcp.pruning.pruning_provider,
433 ),
434 (
435 "mcp.tool_discovery.embedding_provider",
436 &self.mcp.tool_discovery.embedding_provider,
437 ),
438 (
439 "security.response_verification.verifier_provider",
440 &self.security.response_verification.verifier_provider,
441 ),
442 (
443 "orchestration.planner_provider",
444 &self.orchestration.planner_provider,
445 ),
446 (
447 "orchestration.verify_provider",
448 &self.orchestration.verify_provider,
449 ),
450 (
451 "orchestration.tool_provider",
452 &self.orchestration.tool_provider,
453 ),
454 (
455 "skills.evaluation.provider",
456 &self.skills.evaluation.provider,
457 ),
458 (
459 "skills.proactive_exploration.provider",
460 &self.skills.proactive_exploration.provider,
461 ),
462 (
463 "memory.compression_spectrum.promotion_provider",
464 &self.memory.compression_spectrum.promotion_provider,
465 ),
466 ];
467
468 for (field, name) in fields {
469 if !name.is_empty() && !known.contains(name.as_str()) {
470 return Err(ConfigError::Validation(format!(
471 "{field} = {:?} does not match any [[llm.providers]] entry",
472 name.as_str()
473 )));
474 }
475 }
476 Ok(())
477 }
478
479 fn validate_optional_provider_refs(
481 &self,
482 known: &std::collections::HashSet<String>,
483 ) -> Result<(), ConfigError> {
484 if let Some(triage) = self
485 .llm
486 .complexity_routing
487 .as_ref()
488 .and_then(|cr| cr.triage_provider.as_ref())
489 .filter(|t| !t.is_empty() && !known.contains(t.as_str()))
490 {
491 return Err(ConfigError::Validation(format!(
492 "llm.complexity_routing.triage_provider = {:?} does not match any \
493 [[llm.providers]] entry",
494 triage.as_str()
495 )));
496 }
497
498 if let Some(embed) = self
499 .llm
500 .router
501 .as_ref()
502 .and_then(|r| r.bandit.as_ref())
503 .map(|b| &b.embedding_provider)
504 .filter(|p| !p.is_empty() && !known.contains(p.as_str()))
505 {
506 return Err(ConfigError::Validation(format!(
507 "llm.router.bandit.embedding_provider = {:?} does not match any \
508 [[llm.providers]] entry",
509 embed.as_str()
510 )));
511 }
512
513 Ok(())
514 }
515
516 fn normalize_legacy_runtime_defaults(&mut self) {
517 use crate::defaults::{
518 default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
519 is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
520 is_legacy_default_sqlite_path,
521 };
522
523 if is_legacy_default_sqlite_path(&self.memory.sqlite_path) {
524 self.memory.sqlite_path = default_sqlite_path();
525 }
526
527 for skill_path in &mut self.skills.paths {
528 if is_legacy_default_skills_path(skill_path) {
529 *skill_path = default_skills_dir();
530 }
531 }
532
533 if is_legacy_default_debug_dir(&self.debug.output_dir) {
534 self.debug.output_dir = default_debug_dir();
535 }
536
537 if is_legacy_default_log_file(&self.logging.file) {
538 self.logging.file = default_log_file_path();
539 }
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 fn config_with_sct(threshold: f32) -> Config {
548 let mut cfg = Config::default();
549 cfg.llm.semantic_cache_threshold = threshold;
550 cfg
551 }
552
553 #[test]
554 fn semantic_cache_threshold_valid_zero() {
555 assert!(config_with_sct(0.0).validate().is_ok());
556 }
557
558 #[test]
559 fn semantic_cache_threshold_valid_mid() {
560 assert!(config_with_sct(0.5).validate().is_ok());
561 }
562
563 #[test]
564 fn semantic_cache_threshold_valid_one() {
565 assert!(config_with_sct(1.0).validate().is_ok());
566 }
567
568 #[test]
569 fn semantic_cache_threshold_invalid_negative() {
570 let err = config_with_sct(-0.1).validate().unwrap_err();
571 assert!(
572 err.to_string().contains("semantic_cache_threshold"),
573 "unexpected error: {err}"
574 );
575 }
576
577 #[test]
578 fn semantic_cache_threshold_invalid_above_one() {
579 let err = config_with_sct(1.1).validate().unwrap_err();
580 assert!(
581 err.to_string().contains("semantic_cache_threshold"),
582 "unexpected error: {err}"
583 );
584 }
585
586 #[test]
587 fn semantic_cache_threshold_invalid_nan() {
588 let err = config_with_sct(f32::NAN).validate().unwrap_err();
589 assert!(
590 err.to_string().contains("semantic_cache_threshold"),
591 "unexpected error: {err}"
592 );
593 }
594
595 #[test]
596 fn semantic_cache_threshold_invalid_infinity() {
597 let err = config_with_sct(f32::INFINITY).validate().unwrap_err();
598 assert!(
599 err.to_string().contains("semantic_cache_threshold"),
600 "unexpected error: {err}"
601 );
602 }
603
604 #[test]
605 fn semantic_cache_threshold_invalid_neg_infinity() {
606 let err = config_with_sct(f32::NEG_INFINITY).validate().unwrap_err();
607 assert!(
608 err.to_string().contains("semantic_cache_threshold"),
609 "unexpected error: {err}"
610 );
611 }
612
613 fn probe_config(enabled: bool, threshold: f32, hard_fail_threshold: f32) -> Config {
614 let mut cfg = Config::default();
615 cfg.memory.compression.probe.enabled = enabled;
616 cfg.memory.compression.probe.threshold = threshold;
617 cfg.memory.compression.probe.hard_fail_threshold = hard_fail_threshold;
618 cfg
619 }
620
621 #[test]
622 fn probe_disabled_skips_validation() {
623 let cfg = probe_config(false, 0.0, 1.0);
625 assert!(cfg.validate().is_ok());
626 }
627
628 #[test]
629 fn probe_valid_thresholds() {
630 let cfg = probe_config(true, 0.6, 0.35);
631 assert!(cfg.validate().is_ok());
632 }
633
634 #[test]
635 fn probe_threshold_zero_invalid() {
636 let err = probe_config(true, 0.0, 0.0).validate().unwrap_err();
637 assert!(
638 err.to_string().contains("probe.threshold"),
639 "unexpected error: {err}"
640 );
641 }
642
643 #[test]
644 fn probe_hard_fail_threshold_above_one_invalid() {
645 let err = probe_config(true, 0.6, 1.0).validate().unwrap_err();
646 assert!(
647 err.to_string().contains("probe.hard_fail_threshold"),
648 "unexpected error: {err}"
649 );
650 }
651
652 #[test]
653 fn probe_hard_fail_gte_threshold_invalid() {
654 let err = probe_config(true, 0.3, 0.9).validate().unwrap_err();
655 assert!(
656 err.to_string().contains("probe.hard_fail_threshold"),
657 "unexpected error: {err}"
658 );
659 }
660
661 fn config_with_completeness_threshold(ct: f32) -> Config {
662 let mut cfg = Config::default();
663 cfg.orchestration.completeness_threshold = ct;
664 cfg
665 }
666
667 #[test]
668 fn completeness_threshold_valid_zero() {
669 assert!(config_with_completeness_threshold(0.0).validate().is_ok());
670 }
671
672 #[test]
673 fn completeness_threshold_valid_default() {
674 assert!(config_with_completeness_threshold(0.7).validate().is_ok());
675 }
676
677 #[test]
678 fn completeness_threshold_valid_one() {
679 assert!(config_with_completeness_threshold(1.0).validate().is_ok());
680 }
681
682 #[test]
683 fn completeness_threshold_invalid_negative() {
684 let err = config_with_completeness_threshold(-0.1)
685 .validate()
686 .unwrap_err();
687 assert!(
688 err.to_string().contains("completeness_threshold"),
689 "unexpected error: {err}"
690 );
691 }
692
693 #[test]
694 fn completeness_threshold_invalid_above_one() {
695 let err = config_with_completeness_threshold(1.1)
696 .validate()
697 .unwrap_err();
698 assert!(
699 err.to_string().contains("completeness_threshold"),
700 "unexpected error: {err}"
701 );
702 }
703
704 #[test]
705 fn completeness_threshold_invalid_nan() {
706 let err = config_with_completeness_threshold(f32::NAN)
707 .validate()
708 .unwrap_err();
709 assert!(
710 err.to_string().contains("completeness_threshold"),
711 "unexpected error: {err}"
712 );
713 }
714
715 #[test]
716 fn completeness_threshold_invalid_infinity() {
717 let err = config_with_completeness_threshold(f32::INFINITY)
718 .validate()
719 .unwrap_err();
720 assert!(
721 err.to_string().contains("completeness_threshold"),
722 "unexpected error: {err}"
723 );
724 }
725
726 fn config_with_provider(name: &str) -> Config {
727 let mut cfg = Config::default();
728 cfg.llm.providers.push(crate::providers::ProviderEntry {
729 provider_type: crate::providers::ProviderKind::Ollama,
730 name: Some(name.into()),
731 ..Default::default()
732 });
733 cfg
734 }
735
736 #[test]
737 fn validate_provider_names_all_empty_ok() {
738 let cfg = Config::default();
739 assert!(cfg.validate_provider_names().is_ok());
740 }
741
742 #[test]
743 fn validate_provider_names_matching_provider_ok() {
744 let mut cfg = config_with_provider("fast");
745 cfg.memory.admission.admission_provider = crate::providers::ProviderName::new("fast");
746 assert!(cfg.validate_provider_names().is_ok());
747 }
748
749 #[test]
750 fn validate_provider_names_unknown_provider_err() {
751 let mut cfg = config_with_provider("fast");
752 cfg.memory.admission.admission_provider =
753 crate::providers::ProviderName::new("nonexistent");
754 let err = cfg.validate_provider_names().unwrap_err();
755 let msg = err.to_string();
756 assert!(
757 msg.contains("admission_provider") && msg.contains("nonexistent"),
758 "unexpected error: {msg}"
759 );
760 }
761
762 #[test]
763 fn validate_provider_names_triage_provider_none_ok() {
764 let mut cfg = config_with_provider("fast");
765 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
766 triage_provider: None,
767 ..Default::default()
768 });
769 assert!(cfg.validate_provider_names().is_ok());
770 }
771
772 #[test]
773 fn validate_provider_names_triage_provider_matching_ok() {
774 let mut cfg = config_with_provider("fast");
775 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
776 triage_provider: Some(crate::providers::ProviderName::new("fast")),
777 ..Default::default()
778 });
779 assert!(cfg.validate_provider_names().is_ok());
780 }
781
782 #[test]
783 fn validate_provider_names_triage_provider_unknown_err() {
784 let mut cfg = config_with_provider("fast");
785 cfg.llm.complexity_routing = Some(crate::providers::ComplexityRoutingConfig {
786 triage_provider: Some(crate::providers::ProviderName::new("ghost")),
787 ..Default::default()
788 });
789 let err = cfg.validate_provider_names().unwrap_err();
790 let msg = err.to_string();
791 assert!(
792 msg.contains("triage_provider") && msg.contains("ghost"),
793 "unexpected error: {msg}"
794 );
795 }
796
797 #[test]
800 fn toml_float_fields_deserialise_correctly() {
801 let toml = r"
802[llm.router.reputation]
803enabled = true
804decay_factor = 0.95
805weight = 0.3
806
807[llm.router.bandit]
808enabled = false
809cost_weight = 0.3
810alpha = 1.0
811decay_factor = 0.99
812
813[skills]
814disambiguation_threshold = 0.25
815cosine_weight = 0.7
816";
817 let wrapped = format!(
819 "{}\n{}",
820 toml,
821 r"[memory.semantic]
822mmr_lambda = 0.7
823"
824 );
825 let router: crate::providers::RouterConfig = toml::from_str(
827 r"[reputation]
828enabled = true
829decay_factor = 0.95
830weight = 0.3
831",
832 )
833 .expect("RouterConfig with float fields must deserialise");
834 assert!((router.reputation.unwrap().decay_factor - 0.95).abs() < f64::EPSILON);
835
836 let bandit: crate::providers::BanditConfig =
837 toml::from_str("cost_weight = 0.3\nalpha = 1.0\n")
838 .expect("BanditConfig with float fields must deserialise");
839 assert!((bandit.cost_weight - 0.3_f32).abs() < f32::EPSILON);
840
841 let semantic: crate::memory::SemanticConfig = toml::from_str("mmr_lambda = 0.7\n")
842 .expect("SemanticConfig with float fields must deserialise");
843 assert!((semantic.mmr_lambda - 0.7_f32).abs() < f32::EPSILON);
844
845 let skills: crate::features::SkillsConfig =
846 toml::from_str("disambiguation_threshold = 0.25\n")
847 .expect("SkillsConfig with float fields must deserialise");
848 assert!((skills.disambiguation_threshold - 0.25_f32).abs() < f32::EPSILON);
849
850 let _ = wrapped; }
852
853 #[test]
854 fn focus_auto_consolidate_min_window_zero_rejected() {
855 let mut cfg = Config::default();
856 cfg.agent.focus.auto_consolidate_min_window = 0;
857 let err = cfg.validate().unwrap_err().to_string();
858 assert!(
859 err.contains("auto_consolidate_min_window"),
860 "expected auto_consolidate_min_window in error, got: {err}"
861 );
862 }
863
864 #[test]
865 fn focus_auto_consolidate_min_window_one_accepted() {
866 let mut cfg = Config::default();
867 cfg.agent.focus.auto_consolidate_min_window = 1;
868 assert!(cfg.validate().is_ok());
869 }
870}