Skip to main content

agentic_config/
validation.rs

1//! Advisory validation for `AgenticConfig`.
2//!
3//! Validation is advisory - it produces warnings but doesn't prevent
4//! the config from being used. This allows tools to work with imperfect
5//! configs while still surfacing potential issues.
6
7use crate::types::AgenticConfig;
8use std::collections::BTreeSet;
9
10/// An advisory warning about a configuration issue.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct AdvisoryWarning {
13    /// Machine-readable warning code.
14    pub code: &'static str,
15
16    /// Human-readable warning message.
17    pub message: String,
18
19    /// Config path to the problematic field.
20    pub path: &'static str,
21}
22
23impl AdvisoryWarning {
24    /// Create a new advisory warning.
25    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
40/// Detect deprecated config keys in raw TOML before deserialization.
41///
42/// This inspects the merged TOML Value to detect keys that are no longer
43/// used and emit advisory warnings. The config will still load successfully,
44/// but users will be notified that they should update their configuration.
45pub 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
70// TODO(2): This list must be kept in sync with AgenticConfig fields in types.rs.
71// Consider generating dynamically via schemars introspection, or adding a compile-time
72// test that extracts field names from AgenticConfig's JsonSchema and verifies they
73// match this list. Currently requires manual updates when adding new config sections.
74// See research/pr127-group7-type-safety-external-type-dependencies.md for analysis.
75
76/// Known top-level keys for unknown key detection.
77/// Unknown keys at root level produce advisory warnings.
78const 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
93/// Detect unknown top-level keys in raw TOML before deserialization.
94///
95/// Unknown keys at the root are ignored by serde, so we emit an advisory warning
96/// to help users catch typos like `[servics]` instead of `[services]`.
97pub 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
116/// Validate a configuration and return advisory warnings.
117///
118/// This does NOT fail on issues - it only collects warnings that
119/// callers can choose to display or log.
120pub fn validate(cfg: &AgenticConfig) -> Vec<AdvisoryWarning> {
121    let mut warnings = vec![];
122
123    // Validate service URLs
124    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    // Validate log level
151    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    // Validate subagents model values are not empty
165    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    // Validate reasoning model values are not empty
188    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    // Validate OpenRouter format for reasoning models (should contain '/')
204    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    // Validate reasoning_effort enum
237    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    // Validate orchestrator.compaction_threshold is in (0,1]
278    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    // Validate web_retrieval: default_search_results <= max_search_results
299    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    // Validate web_retrieval.summarizer.model is not empty
308    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    // Validate cli_tools.max_depth is reasonable
317    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(); // Missing provider prefix
583
584        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(); // Not GPT-5
596
597        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()); // Invalid value
609
610        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; // Invalid
635
636        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}