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