1use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13 pub code: &'static str,
15
16 pub message: String,
18
19 pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24 pub fn new(code: &'static str, path: &'static str, message: impl Into<String>) -> Self {
26 Self {
27 code,
28 path,
29 message: message.into(),
30 }
31 }
32}
33
34impl std::fmt::Display for AdvisoryWarning {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 write!(f, "[{}] {}: {}", self.code, self.path, self.message)
37 }
38}
39
40pub fn detect_deprecated_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
46 let mut warnings = Vec::new();
47
48 if let Some(tbl) = v.as_table() {
49 if let Some(thoughts) = tbl.get("thoughts").and_then(toml::Value::as_table)
50 && thoughts.contains_key("mount_dirs")
51 {
52 warnings.push(AdvisoryWarning::new(
53 "config.deprecated.thoughts.mount_dirs",
54 "thoughts.mount_dirs",
55 "The legacy thoughts.mount_dirs key is no longer supported. The agentic [thoughts] section now only models add_reference_timeout_secs.",
56 ));
57 }
58 if tbl.contains_key("models") {
59 warnings.push(AdvisoryWarning::new(
60 "config.deprecated.models",
61 "models",
62 "The 'models' section has been replaced by 'subagents' and 'reasoning'.",
63 ));
64 }
65 }
66
67 warnings
68}
69
70const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
79 "$schema",
80 "subagents",
81 "reasoning",
82 "services",
83 "orchestrator",
84 "web_retrieval",
85 "cli_tools",
86 "review",
87 "thoughts",
88 "logging",
89];
90
91const GPT5_2_COMPLETION_TOKENS_DOC_MAX: u32 = 128_000;
92
93pub fn detect_unknown_top_level_keys_toml(v: &toml::Value) -> Vec<AdvisoryWarning> {
98 let mut warnings = Vec::new();
99 let Some(tbl) = v.as_table() else {
100 return warnings;
101 };
102
103 for key in tbl.keys() {
104 if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
105 warnings.push(AdvisoryWarning::new(
106 "config.unknown_top_level_key",
107 "$",
108 format!("Unknown top-level key '{key}' will be ignored"),
109 ));
110 }
111 }
112
113 warnings
114}
115
116pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
121 let mut warnings = vec![];
122
123 validate_url(
125 &cfg.services.anthropic.base_url,
126 "services.anthropic.base_url",
127 "services.anthropic.base_url.invalid",
128 &mut warnings,
129 );
130
131 validate_url(
132 &cfg.services.exa.base_url,
133 "services.exa.base_url",
134 "services.exa.base_url.invalid",
135 &mut warnings,
136 );
137 validate_url(
138 &cfg.services.linear.base_url,
139 "services.linear.base_url",
140 "services.linear.base_url.invalid",
141 &mut warnings,
142 );
143 validate_url(
144 &cfg.services.github.base_url,
145 "services.github.base_url",
146 "services.github.base_url.invalid",
147 &mut warnings,
148 );
149
150 let valid_levels = ["trace", "debug", "info", "warn", "error"];
152 if !valid_levels.contains(&cfg.logging.level.to_lowercase().as_str()) {
153 warnings.push(AdvisoryWarning {
154 code: "logging.level.invalid",
155 path: "logging.level",
156 message: format!(
157 "Unknown log level '{}'. Expected one of: {}",
158 cfg.logging.level,
159 valid_levels.join(", ")
160 ),
161 });
162 }
163
164 if cfg.subagents.locator_model.trim().is_empty() {
166 warnings.push(AdvisoryWarning::new(
167 "subagents.locator_model.empty",
168 "subagents.locator_model",
169 "value is empty",
170 ));
171 }
172 if cfg.subagents.analyzer_model.trim().is_empty() {
173 warnings.push(AdvisoryWarning::new(
174 "subagents.analyzer_model.empty",
175 "subagents.analyzer_model",
176 "value is empty",
177 ));
178 }
179 validate_low_nonzero_timeout(
180 cfg.subagents.runtime_timeout_secs,
181 30,
182 "subagents.runtime_timeout_secs",
183 "subagents.runtime_timeout_secs.suspicious",
184 &mut warnings,
185 );
186
187 if cfg.reasoning.optimizer_model.trim().is_empty() {
189 warnings.push(AdvisoryWarning::new(
190 "reasoning.optimizer_model.empty",
191 "reasoning.optimizer_model",
192 "value is empty",
193 ));
194 }
195 if cfg.reasoning.executor_model.trim().is_empty() {
196 warnings.push(AdvisoryWarning::new(
197 "reasoning.executor_model.empty",
198 "reasoning.executor_model",
199 "value is empty",
200 ));
201 }
202
203 if !cfg.reasoning.optimizer_model.trim().is_empty()
205 && !cfg.reasoning.optimizer_model.contains('/')
206 {
207 warnings.push(AdvisoryWarning::new(
208 "reasoning.optimizer_model.format",
209 "reasoning.optimizer_model",
210 "expected OpenRouter format like `anthropic/claude-sonnet-4.6`",
211 ));
212 }
213
214 if !cfg.reasoning.executor_model.trim().is_empty()
215 && !cfg.reasoning.executor_model.contains('/')
216 {
217 warnings.push(AdvisoryWarning::new(
218 "reasoning.executor_model.format",
219 "reasoning.executor_model",
220 "expected OpenRouter format like `openai/gpt-5.2`",
221 ));
222 } else if !cfg.reasoning.executor_model.trim().is_empty()
223 && !cfg
224 .reasoning
225 .executor_model
226 .to_lowercase()
227 .contains("gpt-5")
228 {
229 warnings.push(AdvisoryWarning::new(
230 "reasoning.executor_model.suspicious",
231 "reasoning.executor_model",
232 "executor_model does not look like a GPT-5 model; reasoning_effort may not work",
233 ));
234 }
235
236 if let Some(eff) = cfg.reasoning.reasoning_effort.as_deref() {
238 let eff_lc = eff.trim().to_lowercase();
239 if !matches!(eff_lc.as_str(), "low" | "medium" | "high" | "xhigh") {
240 warnings.push(AdvisoryWarning::new(
241 "reasoning.reasoning_effort.invalid",
242 "reasoning.reasoning_effort",
243 "expected one of: low, medium, high, xhigh",
244 ));
245 }
246 }
247
248 if cfg
249 .reasoning
250 .executor_model
251 .to_lowercase()
252 .contains("gpt-5.2")
253 && let Some(n) = cfg.reasoning.max_completion_tokens
254 && n > GPT5_2_COMPLETION_TOKENS_DOC_MAX
255 {
256 warnings.push(AdvisoryWarning::new(
257 "reasoning.max_completion_tokens.exceeds_doc",
258 "reasoning.max_completion_tokens",
259 format!(
260 "max_completion_tokens={n} exceeds documented GPT-5.2 ceiling {GPT5_2_COMPLETION_TOKENS_DOC_MAX}; request may be rejected or truncate unexpectedly (warn-only; not clamped)."
261 ),
262 ));
263 }
264
265 if let Some(n) = cfg.reasoning.max_input_tokens
266 && n > 250_000
267 {
268 warnings.push(AdvisoryWarning::new(
269 "reasoning.max_input_tokens.suspicious",
270 "reasoning.max_input_tokens",
271 format!(
272 "max_input_tokens={n} exceeds the tool's default prompt cap (250000); ensure executor model supports this context size (warn-only)."
273 ),
274 ));
275 }
276
277 if !(0.0..=1.0).contains(&cfg.orchestrator.compaction_threshold) {
279 warnings.push(AdvisoryWarning::new(
280 "orchestrator.compaction_threshold.out_of_range",
281 "orchestrator.compaction_threshold",
282 "expected a value between 0.0 and 1.0",
283 ));
284 }
285
286 validate_command_entries(
287 &cfg.orchestrator.commands.allow,
288 "orchestrator.commands.allow",
289 &mut warnings,
290 );
291 validate_command_entries(
292 &cfg.orchestrator.commands.deny,
293 "orchestrator.commands.deny",
294 &mut warnings,
295 );
296 validate_command_overlap(cfg, &mut warnings);
297
298 if cfg.web_retrieval.default_search_results > cfg.web_retrieval.max_search_results {
300 warnings.push(AdvisoryWarning::new(
301 "web_retrieval.default_exceeds_max",
302 "web_retrieval.default_search_results",
303 "default_search_results exceeds max_search_results",
304 ));
305 }
306
307 if cfg.web_retrieval.summarizer.model.trim().is_empty() {
309 warnings.push(AdvisoryWarning::new(
310 "web_retrieval.summarizer.model.empty",
311 "web_retrieval.summarizer.model",
312 "value is empty",
313 ));
314 }
315
316 if cfg.cli_tools.max_depth == 0 {
318 warnings.push(AdvisoryWarning::new(
319 "cli_tools.max_depth.zero",
320 "cli_tools.max_depth",
321 "max_depth is 0, directory listing may be limited",
322 ));
323 }
324
325 validate_low_nonzero_timeout(
326 cfg.cli_tools.just_execute_timeout_secs,
327 5,
328 "cli_tools.just_execute_timeout_secs",
329 "cli_tools.just_execute_timeout_secs.suspicious",
330 &mut warnings,
331 );
332 validate_low_nonzero_timeout(
333 cfg.cli_tools.just_search_timeout_secs,
334 2,
335 "cli_tools.just_search_timeout_secs",
336 "cli_tools.just_search_timeout_secs.suspicious",
337 &mut warnings,
338 );
339 validate_low_nonzero_timeout(
340 cfg.services.linear.connect_timeout_secs,
341 1,
342 "services.linear.connect_timeout_secs",
343 "services.linear.connect_timeout_secs.suspicious",
344 &mut warnings,
345 );
346 validate_low_nonzero_timeout(
347 cfg.services.linear.request_timeout_secs,
348 5,
349 "services.linear.request_timeout_secs",
350 "services.linear.request_timeout_secs.suspicious",
351 &mut warnings,
352 );
353 validate_low_nonzero_timeout(
354 cfg.services.github.total_timeout_secs,
355 5,
356 "services.github.total_timeout_secs",
357 "services.github.total_timeout_secs.suspicious",
358 &mut warnings,
359 );
360 validate_low_nonzero_timeout(
361 cfg.review.run_timeout_secs,
362 30,
363 "review.run_timeout_secs",
364 "review.run_timeout_secs.suspicious",
365 &mut warnings,
366 );
367 validate_low_nonzero_timeout(
368 cfg.thoughts.add_reference_timeout_secs,
369 5,
370 "thoughts.add_reference_timeout_secs",
371 "thoughts.add_reference_timeout_secs.suspicious",
372 &mut warnings,
373 );
374
375 warnings
376}
377
378fn validate_low_nonzero_timeout(
379 value: u64,
380 minimum_recommended: u64,
381 path: &'static str,
382 code: &'static str,
383 warnings: &mut Vec<AdvisoryWarning>,
384) {
385 if value != 0 && value < minimum_recommended {
386 warnings.push(AdvisoryWarning::new(
387 code,
388 path,
389 format!(
390 "value {value}s is very low; {minimum_recommended}s or higher is usually safer, or use 0 to disable"
391 ),
392 ));
393 }
394}
395
396fn validate_command_entries(
397 entries: &[String],
398 path: &'static str,
399 warnings: &mut Vec<AdvisoryWarning>,
400) {
401 let mut seen = BTreeSet::new();
402 let mut duplicates = BTreeSet::new();
403
404 for entry in entries {
405 let trimmed = entry.trim();
406
407 if trimmed.is_empty() {
408 warnings.push(AdvisoryWarning::new(
409 if path.ends_with("allow") {
410 "orchestrator.commands.allow.empty_entry"
411 } else {
412 "orchestrator.commands.deny.empty_entry"
413 },
414 path,
415 format!("entry {entry:?} becomes empty after trimming"),
416 ));
417 continue;
418 }
419
420 if trimmed != entry {
421 warnings.push(AdvisoryWarning::new(
422 if path.ends_with("allow") {
423 "orchestrator.commands.allow.trimmed_entry"
424 } else {
425 "orchestrator.commands.deny.trimmed_entry"
426 },
427 path,
428 format!(
429 "entry {entry:?} has surrounding whitespace; effective value is {trimmed:?}"
430 ),
431 ));
432 }
433
434 if !seen.insert(trimmed.to_string()) {
435 duplicates.insert(trimmed.to_string());
436 }
437 }
438
439 if !duplicates.is_empty() {
440 let duplicates = duplicates.into_iter().collect::<Vec<_>>().join(", ");
441 warnings.push(AdvisoryWarning::new(
442 if path.ends_with("allow") {
443 "orchestrator.commands.allow.duplicate"
444 } else {
445 "orchestrator.commands.deny.duplicate"
446 },
447 path,
448 format!("duplicate command entries after trimming: {duplicates}"),
449 ));
450 }
451}
452
453fn validate_command_overlap(cfg: &AgenticConfig, warnings: &mut Vec<AdvisoryWarning>) {
454 let allow = cfg
455 .orchestrator
456 .commands
457 .allow
458 .iter()
459 .map(|entry| entry.trim())
460 .filter(|entry| !entry.is_empty())
461 .map(str::to_string)
462 .collect::<BTreeSet<_>>();
463 let deny = cfg
464 .orchestrator
465 .commands
466 .deny
467 .iter()
468 .map(|entry| entry.trim())
469 .filter(|entry| !entry.is_empty())
470 .map(str::to_string)
471 .collect::<BTreeSet<_>>();
472
473 let overlap = allow.intersection(&deny).cloned().collect::<Vec<_>>();
474 if overlap.is_empty() {
475 return;
476 }
477
478 warnings.push(AdvisoryWarning::new(
479 "orchestrator.commands.overlap",
480 "orchestrator.commands",
481 format!(
482 "commands appear in both allow and deny: {}. deny wins at runtime",
483 overlap.join(", ")
484 ),
485 ));
486}
487
488fn validate_url(
489 url: &str,
490 path: &'static str,
491 code: &'static str,
492 warnings: &mut Vec<AdvisoryWarning>,
493) {
494 if !url.starts_with("http://") && !url.starts_with("https://") {
495 warnings.push(AdvisoryWarning {
496 code,
497 path,
498 message: format!("Expected an http(s) URL, got: '{url}'"),
499 });
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_default_config_has_no_warnings() {
509 let config = AgenticConfig::default();
510 let warnings = validate(&config);
511 assert!(
512 warnings.is_empty(),
513 "Default config should have no warnings: {warnings:?}"
514 );
515 }
516
517 #[test]
518 fn test_invalid_anthropic_url_warns() {
519 let mut config = AgenticConfig::default();
520 config.services.anthropic.base_url = "not-a-url".into();
521
522 let warnings = validate(&config);
523 assert_eq!(warnings.len(), 1);
524 assert_eq!(warnings[0].code, "services.anthropic.base_url.invalid");
525 }
526
527 #[test]
528 fn test_invalid_linear_and_github_urls_warn() {
529 let mut config = AgenticConfig::default();
530 config.services.linear.base_url = "linear".into();
531 config.services.github.base_url = "github".into();
532
533 let warnings = validate(&config);
534 assert!(
535 warnings
536 .iter()
537 .any(|w| w.code == "services.linear.base_url.invalid")
538 );
539 assert!(
540 warnings
541 .iter()
542 .any(|w| w.code == "services.github.base_url.invalid")
543 );
544 }
545
546 #[test]
547 fn test_invalid_log_level_warns() {
548 let mut config = AgenticConfig::default();
549 config.logging.level = "verbose".into();
550
551 let warnings = validate(&config);
552 assert!(warnings.iter().any(|w| w.code == "logging.level.invalid"));
553 }
554
555 #[test]
556 fn test_warning_display() {
557 let warning = AdvisoryWarning {
558 code: "test.code",
559 path: "test.path",
560 message: "Test message".into(),
561 };
562 let display = format!("{warning}");
563 assert_eq!(display, "[test.code] test.path: Test message");
564 }
565
566 #[test]
567 fn test_empty_subagent_model_warns() {
568 let mut config = AgenticConfig::default();
569 config.subagents.locator_model = String::new();
570
571 let warnings = validate(&config);
572 assert!(
573 warnings
574 .iter()
575 .any(|w| w.code == "subagents.locator_model.empty")
576 );
577 }
578
579 #[test]
580 fn test_reasoning_optimizer_model_format_warns() {
581 let mut config = AgenticConfig::default();
582 config.reasoning.optimizer_model = "claude-sonnet-4.6".into(); let warnings = validate(&config);
585 assert!(
586 warnings
587 .iter()
588 .any(|w| w.code == "reasoning.optimizer_model.format")
589 );
590 }
591
592 #[test]
593 fn test_reasoning_executor_model_suspicious_warns() {
594 let mut config = AgenticConfig::default();
595 config.reasoning.executor_model = "anthropic/claude-sonnet-4.6".into(); let warnings = validate(&config);
598 assert!(
599 warnings
600 .iter()
601 .any(|w| w.code == "reasoning.executor_model.suspicious")
602 );
603 }
604
605 #[test]
606 fn test_reasoning_effort_invalid_warns() {
607 let mut config = AgenticConfig::default();
608 config.reasoning.reasoning_effort = Some("extreme".into()); let warnings = validate(&config);
611 assert!(
612 warnings
613 .iter()
614 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
615 );
616 }
617
618 #[test]
619 fn test_reasoning_effort_valid_no_warning() {
620 let mut config = AgenticConfig::default();
621 config.reasoning.reasoning_effort = Some("high".into());
622
623 let warnings = validate(&config);
624 assert!(
625 !warnings
626 .iter()
627 .any(|w| w.code == "reasoning.reasoning_effort.invalid")
628 );
629 }
630
631 #[test]
632 fn test_orchestrator_compaction_threshold_out_of_range() {
633 let mut config = AgenticConfig::default();
634 config.orchestrator.compaction_threshold = 1.5; let warnings = validate(&config);
637 assert!(
638 warnings
639 .iter()
640 .any(|w| w.code == "orchestrator.compaction_threshold.out_of_range")
641 );
642 }
643
644 #[test]
645 fn test_orchestrator_allow_empty_entry_warns() {
646 let mut config = AgenticConfig::default();
647 config.orchestrator.commands.allow = vec![" ".into()];
648
649 let warnings = validate(&config);
650 let warning = warnings
651 .iter()
652 .find(|w| w.code == "orchestrator.commands.allow.empty_entry")
653 .expect("empty allow warning expected");
654
655 assert_eq!(warning.path, "orchestrator.commands.allow");
656 assert!(warning.message.contains("becomes empty after trimming"));
657 }
658
659 #[test]
660 fn test_orchestrator_deny_trimmed_entry_warns() {
661 let mut config = AgenticConfig::default();
662 config.orchestrator.commands.deny = vec![" plan ".into()];
663
664 let warnings = validate(&config);
665 let warning = warnings
666 .iter()
667 .find(|w| w.code == "orchestrator.commands.deny.trimmed_entry")
668 .expect("trimmed deny warning expected");
669
670 assert_eq!(warning.path, "orchestrator.commands.deny");
671 assert!(warning.message.contains("effective value is \"plan\""));
672 }
673
674 #[test]
675 fn test_orchestrator_allow_duplicate_warns() {
676 let mut config = AgenticConfig::default();
677 config.orchestrator.commands.allow = vec!["plan".into(), " plan ".into()];
678
679 let warnings = validate(&config);
680 let warning = warnings
681 .iter()
682 .find(|w| w.code == "orchestrator.commands.allow.duplicate")
683 .expect("duplicate allow warning expected");
684
685 assert_eq!(warning.path, "orchestrator.commands.allow");
686 assert!(warning.message.contains("plan"));
687 }
688
689 #[test]
690 fn test_orchestrator_command_overlap_warns_with_deny_wins_message() {
691 let mut config = AgenticConfig::default();
692 config.orchestrator.commands.allow = vec!["plan".into()];
693 config.orchestrator.commands.deny = vec![" plan ".into()];
694
695 let warnings = validate(&config);
696 let warning = warnings
697 .iter()
698 .find(|w| w.code == "orchestrator.commands.overlap")
699 .expect("overlap warning expected");
700
701 assert_eq!(warning.path, "orchestrator.commands");
702 assert!(warning.message.contains("plan"));
703 assert!(warning.message.contains("deny wins at runtime"));
704 }
705
706 #[test]
707 fn test_web_retrieval_default_exceeds_max() {
708 let mut config = AgenticConfig::default();
709 config.web_retrieval.default_search_results = 100;
710 config.web_retrieval.max_search_results = 20;
711
712 let warnings = validate(&config);
713 assert!(
714 warnings
715 .iter()
716 .any(|w| w.code == "web_retrieval.default_exceeds_max")
717 );
718 }
719
720 #[test]
721 fn test_detect_deprecated_thoughts_toml() {
722 let toml_val: toml::Value = toml::from_str(
723 r"
724[thoughts]
725mount_dirs = {}
726",
727 )
728 .unwrap();
729
730 let warnings = detect_deprecated_keys_toml(&toml_val);
731 assert!(
732 warnings
733 .iter()
734 .any(|w| w.code == "config.deprecated.thoughts.mount_dirs")
735 );
736 }
737
738 #[test]
739 fn test_supported_thoughts_section_is_not_deprecated() {
740 let toml_val: toml::Value = toml::from_str(
741 r"
742[thoughts]
743add_reference_timeout_secs = 600
744",
745 )
746 .unwrap();
747
748 let warnings = detect_deprecated_keys_toml(&toml_val);
749 assert!(warnings.is_empty());
750 }
751
752 #[test]
753 fn test_detect_deprecated_reasoning_token_limit_toml_is_silent() {
754 let toml_val: toml::Value = toml::from_str(
755 r"
756[reasoning]
757token_limit = 12345
758",
759 )
760 .unwrap();
761
762 let warnings = detect_deprecated_keys_toml(&toml_val);
763 assert!(warnings.is_empty());
764 }
765
766 #[test]
767 fn test_reasoning_max_completion_tokens_above_doc_max_warns() {
768 let mut config = AgenticConfig::default();
769 config.reasoning.max_completion_tokens = Some(128_001);
770
771 let warnings = validate(&config);
772 assert!(
773 warnings
774 .iter()
775 .any(|w| w.code == "reasoning.max_completion_tokens.exceeds_doc")
776 );
777 }
778
779 #[test]
780 fn test_reasoning_max_input_tokens_above_default_cap_warns() {
781 let mut config = AgenticConfig::default();
782 config.reasoning.max_input_tokens = Some(250_001);
783
784 let warnings = validate(&config);
785 assert!(
786 warnings
787 .iter()
788 .any(|w| w.code == "reasoning.max_input_tokens.suspicious")
789 );
790 }
791
792 #[test]
793 fn test_low_nonzero_timeout_values_warn() {
794 let mut config = AgenticConfig::default();
795 config.subagents.runtime_timeout_secs = 1;
796 config.cli_tools.just_execute_timeout_secs = 1;
797 config.cli_tools.just_search_timeout_secs = 1;
798 config.services.linear.request_timeout_secs = 1;
799 config.services.github.total_timeout_secs = 1;
800 config.review.run_timeout_secs = 1;
801 config.thoughts.add_reference_timeout_secs = 1;
802
803 let warnings = validate(&config);
804 for code in [
805 "subagents.runtime_timeout_secs.suspicious",
806 "cli_tools.just_execute_timeout_secs.suspicious",
807 "cli_tools.just_search_timeout_secs.suspicious",
808 "services.linear.request_timeout_secs.suspicious",
809 "services.github.total_timeout_secs.suspicious",
810 "review.run_timeout_secs.suspicious",
811 "thoughts.add_reference_timeout_secs.suspicious",
812 ] {
813 assert!(warnings.iter().any(|w| w.code == code), "missing {code}");
814 }
815 }
816}