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