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    /// Options: "auto", "duckduckgo".
206    #[serde(default)]
207    pub provider: WebSearchProvider,
208
209    /// Default cap on the number of results returned per call. Hard-capped
210    /// at 20 by the runtime to keep responses inline-friendly.
211    #[serde(default = "default_web_search_max_results")]
212    pub max_results: usize,
213
214    /// Per-request timeout in seconds. Capped at 60s by the runtime.
215    #[serde(default = "default_web_search_timeout_secs")]
216    pub timeout_secs: u64,
217
218    /// Minimum gap between consecutive live requests, in milliseconds.
219    /// Defaults to 3000ms (3s).
220    #[serde(default = "default_web_search_cooldown_ms")]
221    pub cooldown_ms: u64,
222
223    /// How long successful search results are cached before a fresh
224    /// request is made, in seconds. Defaults to 300s (5 min).
225    #[serde(default = "default_web_search_cache_ttl_secs")]
226    pub cache_ttl_secs: u64,
227
228    /// Hard cap on outbound network requests per tool instance. Defaults
229    /// to 12 as a conservative VTCode budget; this is not a DuckDuckGo quota.
230    #[serde(default = "default_web_search_session_max_requests")]
231    pub session_max_requests: u32,
232}
233
234/// Web Fetch tool security configuration
235#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
236#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
237pub struct WebFetchConfig {
238    /// Security mode: restricted (blocklist) or whitelist (allowlist)
239    #[serde(default = "default_web_fetch_mode")]
240    pub mode: WebFetchMode,
241
242    /// Inline blocklist - Additional domains to block
243    #[serde(default)]
244    pub blocked_domains: Vec<String>,
245
246    /// Inline whitelist - Domains to allow in restricted mode
247    #[serde(default)]
248    pub allowed_domains: Vec<String>,
249
250    /// Additional blocked patterns
251    #[serde(default)]
252    pub blocked_patterns: Vec<String>,
253
254    /// Strict HTTPS-only mode
255    #[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
326/// Default `WebFetchConfig::allowed_domains` value.
327///
328/// Sourced from the curated TOML allowlist (shipped with the crate at
329/// `data/network_allowlist.toml`) and filtered down to the categories
330/// that make sense as `web_fetch` targets: search engines, specialized
331/// knowledge bases, package registries, code-hosting platforms, and
332/// web-crawl relays other than `defuddle.md`. AI provider endpoints,
333/// OAuth flows, dev infrastructure, and OS-update mirrors are excluded
334/// (those need auth or aren't useful as fetch targets).
335///
336/// The result is cached in a process-global `OnceLock` so the TOML parse
337/// happens exactly once per process even though `WebFetchConfig::default`
338/// is called from serde paths, the tool registry, and tests.
339fn 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/// Tool execution policy
371#[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 execution without confirmation
376    Allow,
377    /// Prompt user for confirmation
378    #[default]
379    Prompt,
380    /// Deny execution
381    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    // Core workflow tools (non-destructive)
465    (tools::START_PLANNING, ToolPolicy::Allow),
466    (tools::TASK_TRACKER, ToolPolicy::Allow),
467    (tools::MATRIX, ToolPolicy::Allow),
468    // Public model-facing tools.
469    (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        // Serde rename_all = "snake_case" means enum variants serialize
684        // lowercase, matching the LLM-facing schema strings.
685        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        // The defaults should include the sites the agent most commonly
708        // needs (looking up a user, fetching a crate's README, etc.).
709        // This is a regression test for the "who is vinhnx?" case where
710        // the default `Restricted` mode blocked github.com / npmjs.com /
711        // crates.io even though none of them are on the blocklist.
712        //
713        // After H1: the inline list was removed in favour of the TOML
714        // allowlist, filtered to web-fetch-relevant categories. Hosts
715        // that aren't in the TOML (`npmjs.com`, `www.npmjs.com`,
716        // `docs.rs`, etc.) are no longer in the defaults — that's
717        // intentional, see the allowlist policy in
718        // `NetworkAllowlist::web_fetch_relevant_domains`.
719        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        // The TOML allowlist is filtered to web-friendly categories for the
739        // web_fetch defaults: search engines, package registries, code
740        // hosting, web-crawl relays (minus defuddle.md), MCP servers, and
741        // specialized knowledge bases. AI provider endpoints and dev
742        // infrastructure are explicitly excluded (those need auth or
743        // aren't useful as fetch targets).
744        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        // H1 (review): the old default merged every category. The new
764        // default must not include AI provider endpoints (LLM inference
765        // APIs that need auth), OAuth flows, dev infrastructure, OS-update
766        // mirrors, or `defuddle.md` (which is a relay, not a fetch
767        // target).
768        //
769        // Search APIs like `api.tavily.com` ARE allowed — those are
770        // legitimate fetch targets the agent might query directly.
771        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        // The TOML has `*.vercel.app` in dev_infra and `*.clerk.accounts.dev`
795        // in auth. After filtering for web_fetch, neither should remain.
796        // A future TOML change that moves a wildcard into a web-relevant
797        // category will be picked up here.
798        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        // `default_web_fetch_allowed_domains` is cached in a OnceLock, but
809        // each call must return a fresh `Vec` so callers can mutate the
810        // returned value without affecting the cached snapshot.
811        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}