1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4
5use crate::constants::{defaults, tool_limits, tools};
6use crate::core::plugins::PluginRuntimeConfig;
7
8#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum ToolProfile {
13 #[default]
15 #[serde(rename = "vt_code")]
16 #[serde(alias = "codex_default")]
17 #[cfg_attr(feature = "schema", schemars(rename = "vt_code"))]
18 VtCode,
19 #[serde(rename = "advanced_vtcode")]
21 #[cfg_attr(feature = "schema", schemars(rename = "advanced_vtcode"))]
22 AdvancedVtCode,
23}
24
25#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct ToolsConfig {
29 #[serde(default)]
31 pub profile: ToolProfile,
32
33 #[serde(default = "default_tool_policy")]
35 pub default_policy: ToolPolicy,
36
37 #[serde(default)]
39 #[cfg_attr(
40 feature = "schema",
41 schemars(with = "std::collections::BTreeMap<String, ToolPolicy>")
42 )]
43 pub policies: IndexMap<String, ToolPolicy>,
44
45 #[serde(default = "default_max_tool_loops")]
52 pub max_tool_loops: usize,
53
54 #[serde(default = "default_max_repeated_tool_calls")]
57 pub max_repeated_tool_calls: usize,
58
59 #[serde(default = "default_max_consecutive_blocked_tool_calls_per_turn")]
64 pub max_consecutive_blocked_tool_calls_per_turn: usize,
65
66 #[serde(default)]
70 pub max_total_blocked_tool_calls_per_turn: Option<usize>,
71
72 #[serde(default)]
76 #[cfg_attr(feature = "schema", schemars(with = "std::collections::BTreeMap<String, usize>"))]
77 pub blocked_tool_thresholds: IndexMap<String, usize>,
78
79 #[serde(default = "default_max_tool_rate_per_second")]
82 pub max_tool_rate_per_second: Option<usize>,
83
84 #[serde(default = "default_max_sequential_spool_chunk_reads")]
87 pub max_sequential_spool_chunk_reads: usize,
88
89 #[serde(default)]
91 pub web_fetch: WebFetchConfig,
92
93 #[serde(default)]
95 pub web_search: WebSearchConfig,
96
97 #[serde(default)]
99 pub plugins: PluginRuntimeConfig,
100
101 #[serde(default)]
103 pub editor: EditorToolConfig,
104
105 #[serde(default)]
109 pub loop_thresholds: IndexMap<String, usize>,
110
111 #[serde(default = "default_client_tool_search")]
119 pub client_tool_search: bool,
120}
121
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct EditorToolConfig {
126 #[serde(default = "default_editor_enabled")]
128 pub enabled: bool,
129
130 #[serde(default)]
132 pub preferred_editor: String,
133
134 #[serde(default = "default_editor_suspend_tui")]
136 pub suspend_tui: bool,
137}
138
139#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
142#[serde(rename_all = "snake_case")]
143pub enum WebFetchMode {
144 #[default]
146 Restricted,
147 Whitelist,
149}
150
151#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
157#[derive(Debug, Clone, Copy, Default, Serialize, PartialEq, Eq, Hash)]
158#[serde(rename_all = "snake_case")]
159pub enum WebSearchProvider {
160 #[default]
162 Auto,
163 Duckduckgo,
165}
166
167impl WebSearchProvider {
168 #[must_use]
170 pub const fn backend_id(self) -> &'static str {
171 match self {
172 Self::Auto | Self::Duckduckgo => "duckduckgo",
173 }
174 }
175}
176
177impl<'de> Deserialize<'de> for WebSearchProvider {
178 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
179 let value = String::deserialize(deserializer)?;
180 match value.as_str() {
181 "auto" => Ok(Self::Auto),
182 "duckduckgo" => Ok(Self::Duckduckgo),
183 _ => Err(serde::de::Error::unknown_variant(&value, &["auto", "duckduckgo"])),
184 }
185 }
186}
187
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
202pub struct WebSearchConfig {
203 #[serde(default)]
206 pub provider: WebSearchProvider,
207
208 #[serde(default = "default_web_search_max_results")]
211 pub max_results: usize,
212
213 #[serde(default = "default_web_search_timeout_secs")]
215 pub timeout_secs: u64,
216
217 #[serde(default = "default_web_search_cooldown_ms")]
220 pub cooldown_ms: u64,
221
222 #[serde(default = "default_web_search_cache_ttl_secs")]
225 pub cache_ttl_secs: u64,
226
227 #[serde(default = "default_web_search_session_max_requests")]
230 pub session_max_requests: u32,
231}
232
233#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
235#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
236pub struct WebFetchConfig {
237 #[serde(default = "default_web_fetch_mode")]
239 pub mode: WebFetchMode,
240
241 #[serde(default)]
243 pub blocked_domains: Vec<String>,
244
245 #[serde(default)]
247 pub allowed_domains: Vec<String>,
248
249 #[serde(default)]
251 pub blocked_patterns: Vec<String>,
252
253 #[serde(default = "default_strict_https")]
255 pub strict_https_only: bool,
256}
257
258impl Default for ToolsConfig {
259 fn default() -> Self {
260 let policies = DEFAULT_TOOL_POLICIES
261 .iter()
262 .map(|(tool, policy)| ((*tool).into(), policy.clone()))
263 .collect::<IndexMap<_, _>>();
264 Self {
265 profile: ToolProfile::default(),
266 default_policy: default_tool_policy(),
267 policies,
268 max_tool_loops: default_max_tool_loops(),
269 max_repeated_tool_calls: default_max_repeated_tool_calls(),
270 max_consecutive_blocked_tool_calls_per_turn: default_max_consecutive_blocked_tool_calls_per_turn(),
271 max_total_blocked_tool_calls_per_turn: None,
272 blocked_tool_thresholds: IndexMap::new(),
273 max_tool_rate_per_second: default_max_tool_rate_per_second(),
274 max_sequential_spool_chunk_reads: default_max_sequential_spool_chunk_reads(),
275 web_fetch: WebFetchConfig::default(),
276 web_search: WebSearchConfig::default(),
277 plugins: PluginRuntimeConfig::default(),
278 editor: EditorToolConfig::default(),
279 loop_thresholds: IndexMap::new(),
280 client_tool_search: default_client_tool_search(),
281 }
282 }
283}
284
285impl ToolsConfig {
286 #[inline]
287 fn tool_loop_limit_reached(&self, completed_tool_loops: usize) -> bool {
288 tool_loop_limit_reached(completed_tool_loops, self.max_tool_loops)
289 }
290
291 #[inline]
292 pub fn tool_call_delay(&self) -> Option<Duration> {
293 tool_call_delay_for_rate(self.max_tool_rate_per_second)
294 }
295}
296
297#[inline]
298pub const fn tool_loop_limit_reached(completed_tool_loops: usize, max_tool_loops: usize) -> bool {
299 max_tool_loops > 0 && completed_tool_loops >= max_tool_loops
300}
301
302#[inline]
303pub fn tool_call_delay_for_rate(max_per_second: Option<usize>) -> Option<Duration> {
304 let rate = max_per_second?;
305 if rate == 0 {
306 return None;
307 }
308
309 let nanos = 1_000_000_000u64.saturating_div(rate as u64).max(1);
310 Some(Duration::from_nanos(nanos))
311}
312
313impl Default for WebFetchConfig {
314 fn default() -> Self {
315 Self {
316 mode: default_web_fetch_mode(),
317 blocked_domains: Vec::new(),
318 allowed_domains: default_web_fetch_allowed_domains(),
319 blocked_patterns: Vec::new(),
320 strict_https_only: true,
321 }
322 }
323}
324
325fn default_web_fetch_allowed_domains() -> Vec<String> {
339 use std::sync::OnceLock;
340 static CACHE: OnceLock<Vec<String>> = OnceLock::new();
341 CACHE
342 .get_or_init(|| crate::network_allowlist::NetworkAllowlist::load_default().web_fetch_relevant_domains())
343 .clone()
344}
345
346impl Default for WebSearchConfig {
347 fn default() -> Self {
348 Self {
349 provider: WebSearchProvider::default(),
350 max_results: default_web_search_max_results(),
351 timeout_secs: default_web_search_timeout_secs(),
352 cooldown_ms: default_web_search_cooldown_ms(),
353 cache_ttl_secs: default_web_search_cache_ttl_secs(),
354 session_max_requests: default_web_search_session_max_requests(),
355 }
356 }
357}
358
359impl Default for EditorToolConfig {
360 fn default() -> Self {
361 Self {
362 enabled: default_editor_enabled(),
363 preferred_editor: String::new(),
364 suspend_tui: default_editor_suspend_tui(),
365 }
366 }
367}
368
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
371#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
372#[serde(rename_all = "lowercase")]
373pub enum ToolPolicy {
374 Allow,
376 #[default]
378 Prompt,
379 Deny,
381}
382
383#[inline]
384const fn default_tool_policy() -> ToolPolicy {
385 ToolPolicy::Prompt
386}
387
388#[inline]
389const fn default_max_tool_loops() -> usize {
390 tool_limits::DEFAULT_MAX_TOOL_LOOPS
391}
392
393#[inline]
394const fn default_max_repeated_tool_calls() -> usize {
395 defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS
396}
397
398#[inline]
399const fn default_max_consecutive_blocked_tool_calls_per_turn() -> usize {
400 defaults::DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN
401}
402
403#[inline]
404const fn default_max_tool_rate_per_second() -> Option<usize> {
405 None
406}
407
408#[inline]
409const fn default_max_sequential_spool_chunk_reads() -> usize {
410 defaults::DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN
411}
412
413#[inline]
414fn default_web_fetch_mode() -> WebFetchMode {
415 WebFetchMode::Restricted
416}
417
418fn default_strict_https() -> bool {
419 true
420}
421
422#[inline]
423const fn default_web_search_max_results() -> usize {
424 8
425}
426
427#[inline]
428const fn default_web_search_timeout_secs() -> u64 {
429 20
430}
431
432#[inline]
433const fn default_web_search_cooldown_ms() -> u64 {
434 3_000
435}
436
437#[inline]
438const fn default_web_search_cache_ttl_secs() -> u64 {
439 300
440}
441
442#[inline]
443const fn default_web_search_session_max_requests() -> u32 {
444 12
445}
446
447#[inline]
448const fn default_editor_enabled() -> bool {
449 true
450}
451
452#[inline]
453const fn default_editor_suspend_tui() -> bool {
454 true
455}
456
457#[inline]
458const fn default_client_tool_search() -> bool {
459 true
460}
461
462const DEFAULT_TOOL_POLICIES: &[(&str, ToolPolicy)] = &[
463 (tools::START_PLANNING, ToolPolicy::Allow),
465 (tools::TASK_TRACKER, ToolPolicy::Allow),
466 (tools::MATRIX, ToolPolicy::Allow),
467 (tools::CODE_SEARCH, ToolPolicy::Allow),
469 (tools::EXEC_COMMAND, ToolPolicy::Allow),
470 (tools::WRITE_STDIN, ToolPolicy::Allow),
471 (tools::APPLY_PATCH, ToolPolicy::Prompt),
472];
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn tools_config_defaults_to_vt_code_profile() {
480 assert_eq!(ToolsConfig::default().profile, ToolProfile::VtCode);
481 }
482
483 #[test]
484 fn tools_config_defaults_to_shared_tool_loop_budget() {
485 assert_eq!(ToolsConfig::default().max_tool_loops, tool_limits::DEFAULT_MAX_TOOL_LOOPS);
486 }
487
488 #[test]
489 fn vt_code_profile_round_trips_through_toml() {
490 let config: ToolsConfig = toml::from_str("profile = \"vt_code\"").expect("vt_code tool profile should parse");
491 assert_eq!(config.profile, ToolProfile::VtCode);
492
493 let serialised = toml::to_string(&config).expect("tools config should serialise");
494 assert!(serialised.contains("profile = \"vt_code\""));
495
496 let round_tripped: ToolsConfig = toml::from_str(&serialised).expect("serialised tools config should parse");
497 assert_eq!(round_tripped.profile, ToolProfile::VtCode);
498 }
499
500 #[test]
501 fn tool_profile_round_trips_through_toml() {
502 let config: ToolsConfig =
503 toml::from_str("profile = \"advanced_vtcode\"").expect("advanced tool profile should parse");
504 assert_eq!(config.profile, ToolProfile::AdvancedVtCode);
505
506 let serialised = toml::to_string(&config).expect("tools config should serialise");
507 assert!(serialised.contains("profile = \"advanced_vtcode\""));
508
509 let round_tripped: ToolsConfig = toml::from_str(&serialised).expect("serialised tools config should parse");
510 assert_eq!(round_tripped.profile, ToolProfile::AdvancedVtCode);
511 }
512
513 #[test]
514 fn tool_profile_rejects_unintended_derived_spelling() {
515 let error = toml::from_str::<ToolsConfig>("profile = \"advanced_vt_code\"")
516 .expect_err("unintended spelling should fail")
517 .to_string();
518
519 assert!(error.contains("unknown variant `advanced_vt_code`"), "{error}");
520 assert!(error.contains("`advanced_vtcode`"), "{error}");
521 }
522
523 #[test]
524 fn invalid_tool_profile_reports_allowed_values() {
525 let error = toml::from_str::<ToolsConfig>("profile = \"experimental\"")
526 .expect_err("unknown tool profile should fail")
527 .to_string();
528
529 assert!(error.contains("unknown variant `experimental`"), "{error}");
530 assert!(error.contains("`vt_code`"), "{error}");
531 assert!(error.contains("`advanced_vtcode`"), "{error}");
532 }
533
534 #[cfg(feature = "schema")]
535 #[test]
536 fn tools_config_schema_includes_profile_values() {
537 let schema = schemars::schema_for!(ToolsConfig);
538 let schema_json = serde_json::to_value(schema).expect("schema should serialise");
539 let schema_text = serde_json::to_string(&schema_json).expect("schema should stringify");
540
541 assert!(schema_json["properties"].get("profile").is_some());
542 assert!(schema_text.contains("vt_code"));
543 assert!(schema_text.contains("advanced_vtcode"));
544 }
545
546 #[test]
547 fn editor_config_defaults_are_enabled() {
548 let config = ToolsConfig::default();
549 assert!(config.editor.enabled);
550 assert!(config.editor.preferred_editor.is_empty());
551 assert!(config.editor.suspend_tui);
552 }
553
554 #[test]
555 fn disabled_tool_loop_limit_never_trips() {
556 assert!(!tool_loop_limit_reached(1, 0));
557 assert!(!tool_loop_limit_reached(32, 0));
558 assert!(tool_loop_limit_reached(2, 2));
559 }
560
561 #[test]
562 fn tools_config_reports_tool_loop_limit() {
563 let config = ToolsConfig { max_tool_loops: 2, ..Default::default() };
564
565 assert!(!config.tool_loop_limit_reached(1));
566 assert!(config.tool_loop_limit_reached(2));
567 }
568
569 #[test]
570 fn tool_call_delay_for_rate_ignores_unset_or_zero_limits() {
571 assert_eq!(tool_call_delay_for_rate(None), None);
572 assert_eq!(tool_call_delay_for_rate(Some(0)), None);
573 }
574
575 #[test]
576 fn tool_call_delay_for_rate_uses_per_second_interval() {
577 assert_eq!(tool_call_delay_for_rate(Some(4)), Some(Duration::from_millis(250)));
578 }
579
580 #[test]
581 fn default_tool_policies_only_seed_current_public_surface() {
582 let config = ToolsConfig::default();
583
584 assert_eq!(config.policies.get(tools::EXEC_COMMAND), Some(&ToolPolicy::Allow));
585 assert_eq!(config.policies.get(tools::WRITE_STDIN), Some(&ToolPolicy::Allow));
586 assert_eq!(config.policies.get(tools::CODE_SEARCH), Some(&ToolPolicy::Allow));
587 assert_eq!(config.policies.get(tools::APPLY_PATCH), Some(&ToolPolicy::Prompt));
588 for legacy_tool in [
589 tools::UNIFIED_EXEC,
590 tools::UNIFIED_SEARCH,
591 tools::UNIFIED_FILE,
592 tools::READ_FILE,
593 tools::WRITE_FILE,
594 tools::EDIT_FILE,
595 tools::RUN_PTY_CMD,
596 tools::READ_PTY_SESSION,
597 tools::LIST_PTY_SESSIONS,
598 tools::SEND_PTY_INPUT,
599 tools::CLOSE_PTY_SESSION,
600 tools::EXECUTE_CODE,
601 ] {
602 assert!(!config.policies.contains_key(legacy_tool));
603 }
604 }
605
606 #[test]
607 fn client_tool_search_defaults_to_enabled() {
608 let config = ToolsConfig::default();
609 assert!(config.client_tool_search);
610
611 let deserialized: ToolsConfig = toml::from_str("default_policy = \"prompt\"\n")
612 .expect("tools config should parse without client_tool_search");
613 assert!(deserialized.client_tool_search);
614
615 let disabled: ToolsConfig = toml::from_str(
616 r#"
617default_policy = "prompt"
618client_tool_search = false
619"#,
620 )
621 .expect("tools config should parse with client_tool_search disabled");
622 assert!(!disabled.client_tool_search);
623 }
624
625 #[test]
626 fn editor_config_deserializes_from_toml() {
627 let config: ToolsConfig = toml::from_str(
628 r#"
629default_policy = "prompt"
630
631[editor]
632enabled = false
633preferred_editor = "code --wait"
634suspend_tui = false
635"#,
636 )
637 .expect("tools config should parse");
638
639 assert!(!config.editor.enabled);
640 assert_eq!(config.editor.preferred_editor, "code --wait");
641 assert!(!config.editor.suspend_tui);
642 }
643
644 #[test]
645 fn web_search_config_deserializes_from_toml() {
646 let config: ToolsConfig = toml::from_str(
647 r#"
648default_policy = "prompt"
649
650[web_search]
651provider = "duckduckgo"
652max_results = 12
653timeout_secs = 25
654cooldown_ms = 1500
655cache_ttl_secs = 120
656session_max_requests = 5
657"#,
658 )
659 .expect("tools config should parse");
660
661 assert_eq!(config.web_search.provider, WebSearchProvider::Duckduckgo);
662 assert_eq!(config.web_search.max_results, 12);
663 assert_eq!(config.web_search.timeout_secs, 25);
664 assert_eq!(config.web_search.cooldown_ms, 1500);
665 assert_eq!(config.web_search.cache_ttl_secs, 120);
666 assert_eq!(config.web_search.session_max_requests, 5);
667 }
668
669 #[test]
670 fn web_search_config_defaults_are_polite() {
671 let config = WebSearchConfig::default();
672 assert_eq!(config.provider, WebSearchProvider::Auto);
673 assert_eq!(config.max_results, 8);
674 assert_eq!(config.timeout_secs, 20);
675 assert!(config.cooldown_ms >= 1_000);
676 assert!(config.cache_ttl_secs >= 60);
677 assert!(config.session_max_requests > 0);
678 }
679
680 #[test]
681 fn web_search_provider_serializes_lowercase() {
682 let json = serde_json::to_value(WebSearchProvider::Duckduckgo).unwrap();
685 assert_eq!(json, serde_json::json!("duckduckgo"));
686 }
687
688 #[test]
689 fn web_search_provider_backend_id_is_canonical() {
690 assert_eq!(WebSearchProvider::Auto.backend_id(), "duckduckgo");
691 assert_eq!(WebSearchProvider::Auto.backend_id(), WebSearchProvider::Duckduckgo.backend_id());
692 }
693
694 #[test]
695 fn web_search_unsupported_provider_uses_generic_error() {
696 let error = serde_json::from_value::<WebSearchProvider>(serde_json::json!("unsupported-provider"))
697 .unwrap_err()
698 .to_string();
699 assert!(error.contains("unknown variant"));
700 assert!(error.contains("auto"));
701 assert!(error.contains("duckduckgo"));
702 }
703
704 #[test]
705 fn web_fetch_default_allowed_domains_seed_common_dev_sites() {
706 let allowed = WebFetchConfig::default().allowed_domains;
719 for host in [
720 "github.com",
721 "api.github.com",
722 "raw.githubusercontent.com",
723 "crates.io",
724 "index.crates.io",
725 "registry.npmjs.org",
726 "pypi.org",
727 ] {
728 assert!(
729 allowed.iter().any(|d| d == host),
730 "default allowed_domains should include {host}; got {allowed:?}"
731 );
732 }
733 }
734
735 #[test]
736 fn web_fetch_default_allowed_domains_include_relevant_categories() {
737 let allowed = WebFetchConfig::default().allowed_domains;
744 for host in [
745 "github.com",
746 "crates.io",
747 "registry.npmjs.org",
748 "pypi.org",
749 "en.wikipedia.org",
750 "r.jina.ai",
751 "api.tavily.com",
752 ] {
753 assert!(
754 allowed.iter().any(|d| d == host),
755 "default allowed_domains should include {host}; got {allowed:?}"
756 );
757 }
758 }
759
760 #[test]
761 fn web_fetch_default_allowed_domains_exclude_ai_and_dev_infra() {
762 let allowed = WebFetchConfig::default().allowed_domains;
771 for host in [
772 "api.anthropic.com",
773 "api.openai.com",
774 "api.fireworks.ai",
775 "api.deepseek.com",
776 "defuddle.md",
777 "*.auth0.com",
778 "*.workers.dev",
779 "*.vercel.app",
780 "us.i.posthog.com",
781 "security.ubuntu.com",
782 "archive.ubuntu.com",
783 ] {
784 assert!(
785 !allowed.iter().any(|d| d == host),
786 "default allowed_domains must NOT include {host}; got {allowed:?}"
787 );
788 }
789 }
790
791 #[test]
792 fn web_fetch_default_allowed_domains_preserves_wildcards_in_relevant_categories() {
793 let allowed = WebFetchConfig::default().allowed_domains;
798 let wildcards: Vec<&str> = allowed.iter().map(|s| s.as_str()).filter(|s| s.starts_with("*.")).collect();
799 assert!(
800 wildcards.is_empty(),
801 "expected no wildcards in web_fetch defaults (dev_infra/auth are excluded); got {wildcards:?}"
802 );
803 }
804
805 #[test]
806 fn web_fetch_default_allowed_domains_returns_fresh_vec() {
807 let mut a = WebFetchConfig::default().allowed_domains;
811 a.push("evil.example".to_string());
812 let b = WebFetchConfig::default().allowed_domains;
813 assert!(!b.iter().any(|d| d == "evil.example"));
814 }
815
816 #[test]
817 fn blocked_tool_caps_default_to_derived_total() {
818 let config = ToolsConfig::default();
819 assert!(config.max_total_blocked_tool_calls_per_turn.is_none());
820 assert!(config.blocked_tool_thresholds.is_empty());
821 }
822
823 #[test]
824 fn blocked_tool_caps_deserialize_from_toml() {
825 let config: ToolsConfig = toml::from_str(
826 r#"
827max_consecutive_blocked_tool_calls_per_turn = 3
828max_total_blocked_tool_calls_per_turn = 10
829
830[blocked_tool_thresholds]
831exec_command = 3
832code_search = 6
833"#,
834 )
835 .expect("blocked tool caps should parse");
836 assert_eq!(config.max_consecutive_blocked_tool_calls_per_turn, 3);
837 assert_eq!(config.max_total_blocked_tool_calls_per_turn, Some(10));
838 assert_eq!(config.blocked_tool_thresholds.get("exec_command"), Some(&3));
839 assert_eq!(config.blocked_tool_thresholds.get("code_search"), Some(&6));
840 }
841}