Skip to main content

vtcode_config/core/
tools.rs

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/// Model-facing tool profile for a session.
9#[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    /// VT Code standard baseline: exec_command, write_stdin, and apply_patch.
14    #[default]
15    #[serde(rename = "vt_code")]
16    #[serde(alias = "codex_default")]
17    #[cfg_attr(feature = "schema", schemars(rename = "vt_code"))]
18    VtCode,
19    /// VTCode specialised tools, including code_search and eligible dynamic tools.
20    #[serde(rename = "advanced_vtcode")]
21    #[cfg_attr(feature = "schema", schemars(rename = "advanced_vtcode"))]
22    AdvancedVtCode,
23}
24
25/// Tools configuration
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27#[derive(Debug, Clone, Deserialize, Serialize)]
28pub struct ToolsConfig {
29    /// Model-facing tool profile.
30    #[serde(default)]
31    pub profile: ToolProfile,
32
33    /// Default policy for tools not explicitly listed
34    #[serde(default = "default_tool_policy")]
35    pub default_policy: ToolPolicy,
36
37    /// Specific tool policies
38    #[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    /// Maximum inner tool-call loops per user turn. Set to `0` to disable the limit.
46    ///
47    /// Prevents infinite tool-calling cycles in interactive chat. This limits how
48    /// many back-and-forths the agent will perform executing tools and
49    /// re-asking the model before returning a final answer.
50    ///
51    #[serde(default = "default_max_tool_loops")]
52    pub max_tool_loops: usize,
53
54    /// Maximum number of times the same tool invocation can be retried with the
55    /// identical arguments within a single turn.
56    #[serde(default = "default_max_repeated_tool_calls")]
57    pub max_repeated_tool_calls: usize,
58
59    /// Maximum consecutive blocked tool calls allowed per turn before forcing a
60    /// turn break. The total fuse is 2x this value in normal mode, 4x in Plan
61    /// Mode, and this value in recovery mode, unless overridden by
62    /// `max_total_blocked_tool_calls_per_turn`.
63    #[serde(default = "default_max_consecutive_blocked_tool_calls_per_turn")]
64    pub max_consecutive_blocked_tool_calls_per_turn: usize,
65
66    /// Optional explicit cap for total blocked tool calls per turn. When unset,
67    /// the runtime derives it from the consecutive cap (2x normal, 4x plan,
68    /// 1x recovery) so existing configs keep their behavior.
69    #[serde(default)]
70    pub max_total_blocked_tool_calls_per_turn: Option<usize>,
71
72    /// Per-tool consecutive blocked-call cap overrides keyed by tool name.
73    /// Allows read-only tools (e.g. `code_search`) to tolerate more denies
74    /// than mutating tools (e.g. `exec_command`) before tripping the fuse.
75    #[serde(default)]
76    #[cfg_attr(feature = "schema", schemars(with = "std::collections::BTreeMap<String, usize>"))]
77    pub blocked_tool_thresholds: IndexMap<String, usize>,
78
79    /// Optional per-second rate limit for tool calls to smooth bursty retries.
80    /// When unset, the runtime defaults apply.
81    #[serde(default = "default_max_tool_rate_per_second")]
82    pub max_tool_rate_per_second: Option<usize>,
83
84    /// Maximum sequential spool-chunk `read_file` calls allowed per turn before
85    /// nudging the agent to switch to targeted extraction/summarization.
86    #[serde(default = "default_max_sequential_spool_chunk_reads")]
87    pub max_sequential_spool_chunk_reads: usize,
88
89    /// Web Fetch tool security configuration
90    #[serde(default)]
91    pub web_fetch: WebFetchConfig,
92
93    /// Web Search tool configuration (provider selection, result caps, timeouts).
94    #[serde(default)]
95    pub web_search: WebSearchConfig,
96
97    /// Dynamic plugin runtime configuration
98    #[serde(default)]
99    pub plugins: PluginRuntimeConfig,
100
101    /// External editor integration settings used by `/edit` and keyboard shortcuts
102    #[serde(default)]
103    pub editor: EditorToolConfig,
104
105    /// Tool-specific loop thresholds (Adaptive Loop Detection)
106    /// Allows setting higher loop limits for read-only tools (e.g., ls, grep)
107    /// and lower limits for mutating tools.
108    #[serde(default)]
109    pub loop_thresholds: IndexMap<String, usize>,
110
111    /// Enables client-local deferred tool loading for providers without a
112    /// hosted tool search (e.g. Gemini). When enabled, tools flagged
113    /// `defer_loading: true` are omitted from the request payload instead
114    /// of being sent eagerly, and a compact summary of what is discoverable
115    /// is appended to the system prompt; the model loads them via the
116    /// local MCP discovery tools. Enabled by default because
117    /// eager MCP schemas are the dominant source of token inflation.
118    #[serde(default = "default_client_tool_search")]
119    pub client_tool_search: bool,
120}
121
122/// External editor integration configuration
123#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
124#[derive(Debug, Clone, Deserialize, Serialize)]
125pub struct EditorToolConfig {
126    /// Enable external editor support for `/edit` and keyboard shortcuts
127    #[serde(default = "default_editor_enabled")]
128    pub enabled: bool,
129
130    /// Preferred editor command override (supports arguments, e.g. "code --wait")
131    #[serde(default)]
132    pub preferred_editor: String,
133
134    /// Suspend the TUI event loop while editor is running
135    #[serde(default = "default_editor_suspend_tui")]
136    pub suspend_tui: bool,
137}
138
139/// Web fetch security mode
140#[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    /// Blocklist mode: allow by default, block listed domains (default)
145    #[default]
146    Restricted,
147    /// Allowlist mode: block by default, allow only listed domains
148    Whitelist,
149}
150
151/// Web search provider identifier.
152///
153/// VT Code only targets the keyless DuckDuckGo HTML endpoint for web
154/// search, so the provider enum is intentionally minimal. `Auto` defers to
155/// the DDG default and is the recommended choice.
156#[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; same as `Duckduckgo` for now (kept for back-compat).
161    #[default]
162    Auto,
163    /// Keyless DuckDuckGo HTML scraping (`https://html.duckduckgo.com/html/`).
164    Duckduckgo,
165}
166
167impl WebSearchProvider {
168    /// Return the canonical runtime backend identifier.
169    #[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/// Web Search tool configuration.
189///
190/// VT Code only uses the keyless DuckDuckGo HTML endpoint. The defaults
191/// below are tuned to be polite to that endpoint and to keep the agent
192/// responsive under constrained network conditions:
193/// - `cooldown_ms`: minimum gap between consecutive live requests on the
194///   same tool instance (avoids bursty hammering that triggers the DDG
195///   anti-bot challenge).
196/// - `cache_ttl_secs`: how long successful results are served from memory
197///   before a fresh request is made.
198/// - `session_max_requests`: hard cap on outbound requests per tool
199///   instance (defends against runaway loops).
200#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
201#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
202pub struct WebSearchConfig {
203    /// Provider selection. The built-in backend is the keyless DuckDuckGo
204    /// HTML endpoint; `auto` is retained as a compatibility alias.
205    #[serde(default)]
206    pub provider: WebSearchProvider,
207
208    /// Default cap on the number of results returned per call. Hard-capped
209    /// at 20 by the runtime to keep responses inline-friendly.
210    #[serde(default = "default_web_search_max_results")]
211    pub max_results: usize,
212
213    /// Per-request timeout in seconds. Capped at 60s by the runtime.
214    #[serde(default = "default_web_search_timeout_secs")]
215    pub timeout_secs: u64,
216
217    /// Minimum gap between consecutive live requests, in milliseconds.
218    /// Defaults to 3000ms (3s).
219    #[serde(default = "default_web_search_cooldown_ms")]
220    pub cooldown_ms: u64,
221
222    /// How long successful search results are cached before a fresh
223    /// request is made, in seconds. Defaults to 300s (5 min).
224    #[serde(default = "default_web_search_cache_ttl_secs")]
225    pub cache_ttl_secs: u64,
226
227    /// Hard cap on outbound network requests per tool instance. Defaults
228    /// to 12 as a conservative VTCode budget; this is not a DuckDuckGo quota.
229    #[serde(default = "default_web_search_session_max_requests")]
230    pub session_max_requests: u32,
231}
232
233/// Web Fetch tool security configuration
234#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
235#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
236pub struct WebFetchConfig {
237    /// Security mode: restricted (blocklist) or whitelist (allowlist)
238    #[serde(default = "default_web_fetch_mode")]
239    pub mode: WebFetchMode,
240
241    /// Inline blocklist - Additional domains to block
242    #[serde(default)]
243    pub blocked_domains: Vec<String>,
244
245    /// Inline whitelist - Domains to allow in restricted mode
246    #[serde(default)]
247    pub allowed_domains: Vec<String>,
248
249    /// Additional blocked patterns
250    #[serde(default)]
251    pub blocked_patterns: Vec<String>,
252
253    /// Strict HTTPS-only mode
254    #[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
325/// Default `WebFetchConfig::allowed_domains` value.
326///
327/// Sourced from the curated TOML allowlist (shipped with the crate at
328/// `data/network_allowlist.toml`) and filtered down to the categories
329/// that make sense as `web_fetch` targets: search engines, specialized
330/// knowledge bases, package registries, code-hosting platforms, and
331/// web-crawl relays other than `defuddle.md`. AI provider endpoints,
332/// OAuth flows, dev infrastructure, and OS-update mirrors are excluded
333/// (those need auth or aren't useful as fetch targets).
334///
335/// The result is cached in a process-global `OnceLock` so the TOML parse
336/// happens exactly once per process even though `WebFetchConfig::default`
337/// is called from serde paths, the tool registry, and tests.
338fn 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/// Tool execution policy
370#[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 execution without confirmation
375    Allow,
376    /// Prompt user for confirmation
377    #[default]
378    Prompt,
379    /// Deny execution
380    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    // Core workflow tools (non-destructive)
464    (tools::START_PLANNING, ToolPolicy::Allow),
465    (tools::TASK_TRACKER, ToolPolicy::Allow),
466    (tools::MATRIX, ToolPolicy::Allow),
467    // Public model-facing tools.
468    (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        // Serde rename_all = "snake_case" means enum variants serialize
683        // lowercase, matching the LLM-facing schema strings.
684        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        // The defaults should include the sites the agent most commonly
707        // needs (looking up a user, fetching a crate's README, etc.).
708        // This is a regression test for the "who is vinhnx?" case where
709        // the default `Restricted` mode blocked github.com / npmjs.com /
710        // crates.io even though none of them are on the blocklist.
711        //
712        // After H1: the inline list was removed in favour of the TOML
713        // allowlist, filtered to web-fetch-relevant categories. Hosts
714        // that aren't in the TOML (`npmjs.com`, `www.npmjs.com`,
715        // `docs.rs`, etc.) are no longer in the defaults — that's
716        // intentional, see the allowlist policy in
717        // `NetworkAllowlist::web_fetch_relevant_domains`.
718        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        // The TOML allowlist is filtered to web-friendly categories for the
738        // web_fetch defaults: search engines, package registries, code
739        // hosting, web-crawl relays (minus defuddle.md), MCP servers, and
740        // specialized knowledge bases. AI provider endpoints and dev
741        // infrastructure are explicitly excluded (those need auth or
742        // aren't useful as fetch targets).
743        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        // H1 (review): the old default merged every category. The new
763        // default must not include AI provider endpoints (LLM inference
764        // APIs that need auth), OAuth flows, dev infrastructure, OS-update
765        // mirrors, or `defuddle.md` (which is a relay, not a fetch
766        // target).
767        //
768        // Search APIs like `api.tavily.com` ARE allowed — those are
769        // legitimate fetch targets the agent might query directly.
770        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        // The TOML has `*.vercel.app` in dev_infra and `*.clerk.accounts.dev`
794        // in auth. After filtering for web_fetch, neither should remain.
795        // A future TOML change that moves a wildcard into a web-relevant
796        // category will be picked up here.
797        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        // `default_web_fetch_allowed_domains` is cached in a OnceLock, but
808        // each call must return a fresh `Vec` so callers can mutate the
809        // returned value without affecting the cached snapshot.
810        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}