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, Deserialize, 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    /// You.com Search API (`https://ydc-index.io/v1/search`). Requires a
166    /// `YDC_API_KEY` environment variable; the key is read at request time
167    /// and never logged. Opt-in only — the default provider stays
168    /// DuckDuckGo.
169    Youcom,
170}
171
172/// Web Search tool configuration.
173///
174/// VT Code only uses the keyless DuckDuckGo HTML endpoint. The defaults
175/// below are tuned to be polite to that endpoint and to keep the agent
176/// responsive under low-quota conditions:
177/// - `cooldown_ms`: minimum gap between consecutive live requests on the
178///   same tool instance (avoids bursty hammering that triggers the DDG
179///   anti-bot challenge).
180/// - `cache_ttl_secs`: how long successful results are served from memory
181///   before a fresh request is made.
182/// - `session_max_requests`: hard cap on outbound requests per tool
183///   instance (defends against runaway loops).
184#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
185#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
186pub struct WebSearchConfig {
187    /// Provider selection. Supported backends are the keyless DuckDuckGo
188    /// HTML endpoint (default) and the You.com Search API (opt-in, requires
189    /// `YDC_API_KEY`).
190    #[serde(default)]
191    pub provider: WebSearchProvider,
192
193    /// Default cap on the number of results returned per call. Hard-capped
194    /// at 20 by the runtime to keep responses inline-friendly.
195    #[serde(default = "default_web_search_max_results")]
196    pub max_results: usize,
197
198    /// Per-request timeout in seconds. Capped at 60s by the runtime.
199    #[serde(default = "default_web_search_timeout_secs")]
200    pub timeout_secs: u64,
201
202    /// Minimum gap between consecutive live requests, in milliseconds.
203    /// Defaults to 3000ms (3s).
204    #[serde(default = "default_web_search_cooldown_ms")]
205    pub cooldown_ms: u64,
206
207    /// How long successful search results are cached before a fresh
208    /// request is made, in seconds. Defaults to 300s (5 min).
209    #[serde(default = "default_web_search_cache_ttl_secs")]
210    pub cache_ttl_secs: u64,
211
212    /// Hard cap on outbound network requests per tool instance. Defaults
213    /// to 12 to stay well below DDG's soft session quotas.
214    #[serde(default = "default_web_search_session_max_requests")]
215    pub session_max_requests: u32,
216}
217
218/// Web Fetch tool security configuration
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
221pub struct WebFetchConfig {
222    /// Security mode: restricted (blocklist) or whitelist (allowlist)
223    #[serde(default = "default_web_fetch_mode")]
224    pub mode: WebFetchMode,
225
226    /// Inline blocklist - Additional domains to block
227    #[serde(default)]
228    pub blocked_domains: Vec<String>,
229
230    /// Inline whitelist - Domains to allow in restricted mode
231    #[serde(default)]
232    pub allowed_domains: Vec<String>,
233
234    /// Additional blocked patterns
235    #[serde(default)]
236    pub blocked_patterns: Vec<String>,
237
238    /// Strict HTTPS-only mode
239    #[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
310/// Default `WebFetchConfig::allowed_domains` value.
311///
312/// Sourced from the curated TOML allowlist (shipped with the crate at
313/// `data/network_allowlist.toml`) and filtered down to the categories
314/// that make sense as `web_fetch` targets: search engines, specialized
315/// knowledge bases, package registries, code-hosting platforms, and
316/// web-crawl relays other than `defuddle.md`. AI provider endpoints,
317/// OAuth flows, dev infrastructure, and OS-update mirrors are excluded
318/// (those need auth or aren't useful as fetch targets).
319///
320/// The result is cached in a process-global `OnceLock` so the TOML parse
321/// happens exactly once per process even though `WebFetchConfig::default`
322/// is called from serde paths, the tool registry, and tests.
323fn 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/// Tool execution policy
355#[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 execution without confirmation
360    Allow,
361    /// Prompt user for confirmation
362    #[default]
363    Prompt,
364    /// Deny execution
365    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    // Core workflow tools (non-destructive)
449    (tools::START_PLANNING, ToolPolicy::Allow),
450    (tools::TASK_TRACKER, ToolPolicy::Allow),
451    (tools::MATRIX, ToolPolicy::Allow),
452    // Public model-facing tools.
453    (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        // Serde rename_all = "snake_case" means enum variants serialize
668        // lowercase, matching the LLM-facing schema strings.
669        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        // The defaults should include the sites the agent most commonly
699        // needs (looking up a user, fetching a crate's README, etc.).
700        // This is a regression test for the "who is vinhnx?" case where
701        // the default `Restricted` mode blocked github.com / npmjs.com /
702        // crates.io even though none of them are on the blocklist.
703        //
704        // After H1: the inline list was removed in favour of the TOML
705        // allowlist, filtered to web-fetch-relevant categories. Hosts
706        // that aren't in the TOML (`npmjs.com`, `www.npmjs.com`,
707        // `docs.rs`, etc.) are no longer in the defaults — that's
708        // intentional, see the allowlist policy in
709        // `NetworkAllowlist::web_fetch_relevant_domains`.
710        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        // The TOML allowlist is filtered to web-friendly categories for the
730        // web_fetch defaults: search engines, package registries, code
731        // hosting, web-crawl relays (minus defuddle.md), MCP servers, and
732        // specialized knowledge bases. AI provider endpoints and dev
733        // infrastructure are explicitly excluded (those need auth or
734        // aren't useful as fetch targets).
735        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        // H1 (review): the old default merged every category. The new
755        // default must not include AI provider endpoints (LLM inference
756        // APIs that need auth), OAuth flows, dev infrastructure, OS-update
757        // mirrors, or `defuddle.md` (which is a relay, not a fetch
758        // target).
759        //
760        // Search APIs like `api.tavily.com` ARE allowed — those are
761        // legitimate fetch targets the agent might query directly.
762        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        // The TOML has `*.vercel.app` in dev_infra and `*.clerk.accounts.dev`
786        // in auth. After filtering for web_fetch, neither should remain.
787        // A future TOML change that moves a wildcard into a web-relevant
788        // category will be picked up here.
789        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        // `default_web_fetch_allowed_domains` is cached in a OnceLock, but
800        // each call must return a fresh `Vec` so callers can mutate the
801        // returned value without affecting the cached snapshot.
802        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}