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, tools};
6use crate::core::plugins::PluginRuntimeConfig;
7
8/// Tools configuration
9#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct ToolsConfig {
12    /// Default policy for tools not explicitly listed
13    #[serde(default = "default_tool_policy")]
14    pub default_policy: ToolPolicy,
15
16    /// Specific tool policies
17    #[serde(default)]
18    #[cfg_attr(
19        feature = "schema",
20        schemars(with = "std::collections::BTreeMap<String, ToolPolicy>")
21    )]
22    pub policies: IndexMap<String, ToolPolicy>,
23
24    /// Maximum inner tool-call loops per user turn. Set to `0` to disable the limit.
25    ///
26    /// Prevents infinite tool-calling cycles in interactive chat. This limits how
27    /// many back-and-forths the agent will perform executing tools and
28    /// re-asking the model before returning a final answer.
29    ///
30    #[serde(default = "default_max_tool_loops")]
31    pub max_tool_loops: usize,
32
33    /// Maximum number of times the same tool invocation can be retried with the
34    /// identical arguments within a single turn.
35    #[serde(default = "default_max_repeated_tool_calls")]
36    pub max_repeated_tool_calls: usize,
37
38    /// Maximum consecutive blocked tool calls allowed per turn before forcing a
39    /// turn break. This prevents long blocked-call churn from consuming CPU.
40    #[serde(default = "default_max_consecutive_blocked_tool_calls_per_turn")]
41    pub max_consecutive_blocked_tool_calls_per_turn: usize,
42
43    /// Optional per-second rate limit for tool calls to smooth bursty retries.
44    /// When unset, the runtime defaults apply.
45    #[serde(default = "default_max_tool_rate_per_second")]
46    pub max_tool_rate_per_second: Option<usize>,
47
48    /// Maximum sequential spool-chunk `read_file` calls allowed per turn before
49    /// nudging the agent to switch to targeted extraction/summarization.
50    #[serde(default = "default_max_sequential_spool_chunk_reads")]
51    pub max_sequential_spool_chunk_reads: usize,
52
53    /// Web Fetch tool security configuration
54    #[serde(default)]
55    pub web_fetch: WebFetchConfig,
56
57    /// Dynamic plugin runtime configuration
58    #[serde(default)]
59    pub plugins: PluginRuntimeConfig,
60
61    /// External editor integration settings used by `/edit` and keyboard shortcuts
62    #[serde(default)]
63    pub editor: EditorToolConfig,
64
65    /// Tool-specific loop thresholds (Adaptive Loop Detection)
66    /// Allows setting higher loop limits for read-only tools (e.g., ls, grep)
67    /// and lower limits for mutating tools.
68    #[serde(default)]
69    pub loop_thresholds: IndexMap<String, usize>,
70}
71
72/// External editor integration configuration
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct EditorToolConfig {
76    /// Enable external editor support for `/edit` and keyboard shortcuts
77    #[serde(default = "default_editor_enabled")]
78    pub enabled: bool,
79
80    /// Preferred editor command override (supports arguments, e.g. "code --wait")
81    #[serde(default)]
82    pub preferred_editor: String,
83
84    /// Suspend the TUI event loop while editor is running
85    #[serde(default = "default_editor_suspend_tui")]
86    pub suspend_tui: bool,
87}
88
89/// Web Fetch tool security configuration
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[derive(Debug, Clone, Deserialize, Serialize)]
92pub struct WebFetchConfig {
93    /// Security mode: "restricted" (blocklist) or "whitelist" (allowlist)
94    #[serde(default = "default_web_fetch_mode")]
95    pub mode: String,
96
97    /// Enable dynamic blocklist loading from external file
98    #[serde(default)]
99    pub dynamic_blocklist_enabled: bool,
100
101    /// Path to dynamic blocklist file
102    #[serde(default)]
103    pub dynamic_blocklist_path: String,
104
105    /// Enable dynamic whitelist loading from external file
106    #[serde(default)]
107    pub dynamic_whitelist_enabled: bool,
108
109    /// Path to dynamic whitelist file
110    #[serde(default)]
111    pub dynamic_whitelist_path: String,
112
113    /// Inline blocklist - Additional domains to block
114    #[serde(default)]
115    pub blocked_domains: Vec<String>,
116
117    /// Inline whitelist - Domains to allow in restricted mode
118    #[serde(default)]
119    pub allowed_domains: Vec<String>,
120
121    /// Additional blocked patterns
122    #[serde(default)]
123    pub blocked_patterns: Vec<String>,
124
125    /// Enable audit logging of URL validation decisions
126    #[serde(default)]
127    pub enable_audit_logging: bool,
128
129    /// Path to audit log file
130    #[serde(default)]
131    pub audit_log_path: String,
132
133    /// Strict HTTPS-only mode
134    #[serde(default = "default_strict_https")]
135    pub strict_https_only: bool,
136}
137
138impl Default for ToolsConfig {
139    fn default() -> Self {
140        let policies = DEFAULT_TOOL_POLICIES
141            .iter()
142            .map(|(tool, policy)| ((*tool).into(), *policy))
143            .collect::<IndexMap<_, _>>();
144        Self {
145            default_policy: default_tool_policy(),
146            policies,
147            max_tool_loops: default_max_tool_loops(),
148            max_repeated_tool_calls: default_max_repeated_tool_calls(),
149            max_consecutive_blocked_tool_calls_per_turn:
150                default_max_consecutive_blocked_tool_calls_per_turn(),
151            max_tool_rate_per_second: default_max_tool_rate_per_second(),
152            max_sequential_spool_chunk_reads: default_max_sequential_spool_chunk_reads(),
153            web_fetch: WebFetchConfig::default(),
154            plugins: PluginRuntimeConfig::default(),
155            editor: EditorToolConfig::default(),
156            loop_thresholds: IndexMap::new(),
157        }
158    }
159}
160
161impl ToolsConfig {
162    #[inline]
163    pub fn tool_loop_limit_reached(&self, completed_tool_loops: usize) -> bool {
164        tool_loop_limit_reached(completed_tool_loops, self.max_tool_loops)
165    }
166
167    #[inline]
168    pub fn tool_call_delay(&self) -> Option<Duration> {
169        tool_call_delay_for_rate(self.max_tool_rate_per_second)
170    }
171}
172
173#[inline]
174pub const fn tool_loop_limit_reached(completed_tool_loops: usize, max_tool_loops: usize) -> bool {
175    max_tool_loops > 0 && completed_tool_loops >= max_tool_loops
176}
177
178#[inline]
179pub fn tool_call_delay_for_rate(max_per_second: Option<usize>) -> Option<Duration> {
180    let rate = max_per_second?;
181    if rate == 0 {
182        return None;
183    }
184
185    let nanos = 1_000_000_000u64.saturating_div(rate as u64).max(1);
186    Some(Duration::from_nanos(nanos))
187}
188
189const DEFAULT_BLOCKLIST_PATH: &str = "~/.vtcode/web_fetch_blocklist.json";
190const DEFAULT_WHITELIST_PATH: &str = "~/.vtcode/web_fetch_whitelist.json";
191const DEFAULT_AUDIT_LOG_PATH: &str = "~/.vtcode/web_fetch_audit.log";
192
193impl Default for WebFetchConfig {
194    fn default() -> Self {
195        Self {
196            mode: default_web_fetch_mode(),
197            dynamic_blocklist_enabled: false,
198            dynamic_blocklist_path: DEFAULT_BLOCKLIST_PATH.into(),
199            dynamic_whitelist_enabled: false,
200            dynamic_whitelist_path: DEFAULT_WHITELIST_PATH.into(),
201            blocked_domains: Vec::new(),
202            allowed_domains: Vec::new(),
203            blocked_patterns: Vec::new(),
204            enable_audit_logging: false,
205            audit_log_path: DEFAULT_AUDIT_LOG_PATH.into(),
206            strict_https_only: true,
207        }
208    }
209}
210
211impl Default for EditorToolConfig {
212    fn default() -> Self {
213        Self {
214            enabled: default_editor_enabled(),
215            preferred_editor: String::new(),
216            suspend_tui: default_editor_suspend_tui(),
217        }
218    }
219}
220
221/// Tool execution policy
222#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
223#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
224#[serde(rename_all = "lowercase")]
225pub enum ToolPolicy {
226    /// Allow execution without confirmation
227    Allow,
228    /// Prompt user for confirmation
229    Prompt,
230    /// Deny execution
231    Deny,
232}
233
234#[inline]
235const fn default_tool_policy() -> ToolPolicy {
236    ToolPolicy::Prompt
237}
238
239#[inline]
240const fn default_max_tool_loops() -> usize {
241    defaults::DEFAULT_MAX_TOOL_LOOPS
242}
243
244#[inline]
245const fn default_max_repeated_tool_calls() -> usize {
246    defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS
247}
248
249#[inline]
250const fn default_max_consecutive_blocked_tool_calls_per_turn() -> usize {
251    defaults::DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN
252}
253
254#[inline]
255const fn default_max_tool_rate_per_second() -> Option<usize> {
256    None
257}
258
259#[inline]
260const fn default_max_sequential_spool_chunk_reads() -> usize {
261    defaults::DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN
262}
263
264#[inline]
265fn default_web_fetch_mode() -> String {
266    "restricted".into()
267}
268
269fn default_strict_https() -> bool {
270    true
271}
272
273#[inline]
274const fn default_editor_enabled() -> bool {
275    true
276}
277
278#[inline]
279const fn default_editor_suspend_tui() -> bool {
280    true
281}
282
283const DEFAULT_TOOL_POLICIES: &[(&str, ToolPolicy)] = &[
284    // Search operations (non-destructive)
285    (tools::UNIFIED_SEARCH, ToolPolicy::Allow),
286    // File operations (non-destructive)
287    (tools::READ_FILE, ToolPolicy::Allow),
288    // File operations (write/create)
289    (tools::WRITE_FILE, ToolPolicy::Allow),
290    (tools::EDIT_FILE, ToolPolicy::Allow),
291    (tools::CREATE_FILE, ToolPolicy::Allow),
292    // File operations (destructive - require confirmation)
293    (tools::DELETE_FILE, ToolPolicy::Prompt),
294    (tools::APPLY_PATCH, ToolPolicy::Prompt),
295    (tools::SEARCH_REPLACE, ToolPolicy::Prompt),
296    // Canonical execution interface. Compatibility aliases normalize to this at runtime.
297    (tools::UNIFIED_EXEC, ToolPolicy::Prompt),
298];
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn editor_config_defaults_are_enabled() {
306        let config = ToolsConfig::default();
307        assert!(config.editor.enabled);
308        assert!(config.editor.preferred_editor.is_empty());
309        assert!(config.editor.suspend_tui);
310    }
311
312    #[test]
313    fn disabled_tool_loop_limit_never_trips() {
314        assert!(!tool_loop_limit_reached(1, 0));
315        assert!(!tool_loop_limit_reached(32, 0));
316        assert!(tool_loop_limit_reached(2, 2));
317    }
318
319    #[test]
320    fn tools_config_reports_tool_loop_limit() {
321        let config = ToolsConfig {
322            max_tool_loops: 2,
323            ..Default::default()
324        };
325
326        assert!(!config.tool_loop_limit_reached(1));
327        assert!(config.tool_loop_limit_reached(2));
328    }
329
330    #[test]
331    fn tool_call_delay_for_rate_ignores_unset_or_zero_limits() {
332        assert_eq!(tool_call_delay_for_rate(None), None);
333        assert_eq!(tool_call_delay_for_rate(Some(0)), None);
334    }
335
336    #[test]
337    fn tool_call_delay_for_rate_uses_per_second_interval() {
338        assert_eq!(
339            tool_call_delay_for_rate(Some(4)),
340            Some(Duration::from_millis(250))
341        );
342    }
343
344    #[test]
345    fn default_tool_policies_only_seed_canonical_exec_surface() {
346        let config = ToolsConfig::default();
347
348        assert_eq!(
349            config.policies.get(tools::UNIFIED_EXEC),
350            Some(&ToolPolicy::Prompt)
351        );
352        for legacy_tool in [
353            tools::RUN_PTY_CMD,
354            tools::READ_PTY_SESSION,
355            tools::LIST_PTY_SESSIONS,
356            tools::SEND_PTY_INPUT,
357            tools::CLOSE_PTY_SESSION,
358            tools::EXECUTE_CODE,
359        ] {
360            assert!(!config.policies.contains_key(legacy_tool));
361        }
362    }
363
364    #[test]
365    fn editor_config_deserializes_from_toml() {
366        let config: ToolsConfig = toml::from_str(
367            r#"
368default_policy = "prompt"
369
370[editor]
371enabled = false
372preferred_editor = "code --wait"
373suspend_tui = false
374"#,
375        )
376        .expect("tools config should parse");
377
378        assert!(!config.editor.enabled);
379        assert_eq!(config.editor.preferred_editor, "code --wait");
380        assert!(!config.editor.suspend_tui);
381    }
382}