use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::constants::{defaults, tools};
use crate::core::plugins::PluginRuntimeConfig;
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ToolsConfig {
#[serde(default = "default_tool_policy")]
pub default_policy: ToolPolicy,
#[serde(default)]
#[cfg_attr(
feature = "schema",
schemars(with = "std::collections::BTreeMap<String, ToolPolicy>")
)]
pub policies: IndexMap<String, ToolPolicy>,
#[serde(default = "default_max_tool_loops")]
pub max_tool_loops: usize,
#[serde(default = "default_max_repeated_tool_calls")]
pub max_repeated_tool_calls: usize,
#[serde(default = "default_max_consecutive_blocked_tool_calls_per_turn")]
pub max_consecutive_blocked_tool_calls_per_turn: usize,
#[serde(default = "default_max_tool_rate_per_second")]
pub max_tool_rate_per_second: Option<usize>,
#[serde(default = "default_max_sequential_spool_chunk_reads")]
pub max_sequential_spool_chunk_reads: usize,
#[serde(default)]
pub web_fetch: WebFetchConfig,
#[serde(default)]
pub plugins: PluginRuntimeConfig,
#[serde(default)]
pub editor: EditorToolConfig,
#[serde(default)]
pub loop_thresholds: IndexMap<String, usize>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct EditorToolConfig {
#[serde(default = "default_editor_enabled")]
pub enabled: bool,
#[serde(default)]
pub preferred_editor: String,
#[serde(default = "default_editor_suspend_tui")]
pub suspend_tui: bool,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct WebFetchConfig {
#[serde(default = "default_web_fetch_mode")]
pub mode: String,
#[serde(default)]
pub dynamic_blocklist_enabled: bool,
#[serde(default)]
pub dynamic_blocklist_path: String,
#[serde(default)]
pub dynamic_whitelist_enabled: bool,
#[serde(default)]
pub dynamic_whitelist_path: String,
#[serde(default)]
pub blocked_domains: Vec<String>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub blocked_patterns: Vec<String>,
#[serde(default)]
pub enable_audit_logging: bool,
#[serde(default)]
pub audit_log_path: String,
#[serde(default = "default_strict_https")]
pub strict_https_only: bool,
}
impl Default for ToolsConfig {
fn default() -> Self {
let policies = DEFAULT_TOOL_POLICIES
.iter()
.map(|(tool, policy)| ((*tool).into(), *policy))
.collect::<IndexMap<_, _>>();
Self {
default_policy: default_tool_policy(),
policies,
max_tool_loops: default_max_tool_loops(),
max_repeated_tool_calls: default_max_repeated_tool_calls(),
max_consecutive_blocked_tool_calls_per_turn:
default_max_consecutive_blocked_tool_calls_per_turn(),
max_tool_rate_per_second: default_max_tool_rate_per_second(),
max_sequential_spool_chunk_reads: default_max_sequential_spool_chunk_reads(),
web_fetch: WebFetchConfig::default(),
plugins: PluginRuntimeConfig::default(),
editor: EditorToolConfig::default(),
loop_thresholds: IndexMap::new(),
}
}
}
impl ToolsConfig {
#[inline]
pub fn tool_loop_limit_reached(&self, completed_tool_loops: usize) -> bool {
tool_loop_limit_reached(completed_tool_loops, self.max_tool_loops)
}
#[inline]
pub fn tool_call_delay(&self) -> Option<Duration> {
tool_call_delay_for_rate(self.max_tool_rate_per_second)
}
}
#[inline]
pub const fn tool_loop_limit_reached(completed_tool_loops: usize, max_tool_loops: usize) -> bool {
max_tool_loops > 0 && completed_tool_loops >= max_tool_loops
}
#[inline]
pub fn tool_call_delay_for_rate(max_per_second: Option<usize>) -> Option<Duration> {
let rate = max_per_second?;
if rate == 0 {
return None;
}
let nanos = 1_000_000_000u64.saturating_div(rate as u64).max(1);
Some(Duration::from_nanos(nanos))
}
const DEFAULT_BLOCKLIST_PATH: &str = "~/.vtcode/web_fetch_blocklist.json";
const DEFAULT_WHITELIST_PATH: &str = "~/.vtcode/web_fetch_whitelist.json";
const DEFAULT_AUDIT_LOG_PATH: &str = "~/.vtcode/web_fetch_audit.log";
impl Default for WebFetchConfig {
fn default() -> Self {
Self {
mode: default_web_fetch_mode(),
dynamic_blocklist_enabled: false,
dynamic_blocklist_path: DEFAULT_BLOCKLIST_PATH.into(),
dynamic_whitelist_enabled: false,
dynamic_whitelist_path: DEFAULT_WHITELIST_PATH.into(),
blocked_domains: Vec::new(),
allowed_domains: Vec::new(),
blocked_patterns: Vec::new(),
enable_audit_logging: false,
audit_log_path: DEFAULT_AUDIT_LOG_PATH.into(),
strict_https_only: true,
}
}
}
impl Default for EditorToolConfig {
fn default() -> Self {
Self {
enabled: default_editor_enabled(),
preferred_editor: String::new(),
suspend_tui: default_editor_suspend_tui(),
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ToolPolicy {
Allow,
Prompt,
Deny,
}
#[inline]
const fn default_tool_policy() -> ToolPolicy {
ToolPolicy::Prompt
}
#[inline]
const fn default_max_tool_loops() -> usize {
defaults::DEFAULT_MAX_TOOL_LOOPS
}
#[inline]
const fn default_max_repeated_tool_calls() -> usize {
defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS
}
#[inline]
const fn default_max_consecutive_blocked_tool_calls_per_turn() -> usize {
defaults::DEFAULT_MAX_CONSECUTIVE_BLOCKED_TOOL_CALLS_PER_TURN
}
#[inline]
const fn default_max_tool_rate_per_second() -> Option<usize> {
None
}
#[inline]
const fn default_max_sequential_spool_chunk_reads() -> usize {
defaults::DEFAULT_MAX_SEQUENTIAL_SPOOL_CHUNK_READS_PER_TURN
}
#[inline]
fn default_web_fetch_mode() -> String {
"restricted".into()
}
fn default_strict_https() -> bool {
true
}
#[inline]
const fn default_editor_enabled() -> bool {
true
}
#[inline]
const fn default_editor_suspend_tui() -> bool {
true
}
const DEFAULT_TOOL_POLICIES: &[(&str, ToolPolicy)] = &[
(tools::UNIFIED_SEARCH, ToolPolicy::Allow),
(tools::READ_FILE, ToolPolicy::Allow),
(tools::WRITE_FILE, ToolPolicy::Allow),
(tools::EDIT_FILE, ToolPolicy::Allow),
(tools::CREATE_FILE, ToolPolicy::Allow),
(tools::DELETE_FILE, ToolPolicy::Prompt),
(tools::APPLY_PATCH, ToolPolicy::Prompt),
(tools::SEARCH_REPLACE, ToolPolicy::Prompt),
(tools::UNIFIED_EXEC, ToolPolicy::Prompt),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_config_defaults_are_enabled() {
let config = ToolsConfig::default();
assert!(config.editor.enabled);
assert!(config.editor.preferred_editor.is_empty());
assert!(config.editor.suspend_tui);
}
#[test]
fn disabled_tool_loop_limit_never_trips() {
assert!(!tool_loop_limit_reached(1, 0));
assert!(!tool_loop_limit_reached(32, 0));
assert!(tool_loop_limit_reached(2, 2));
}
#[test]
fn tools_config_reports_tool_loop_limit() {
let config = ToolsConfig {
max_tool_loops: 2,
..Default::default()
};
assert!(!config.tool_loop_limit_reached(1));
assert!(config.tool_loop_limit_reached(2));
}
#[test]
fn tool_call_delay_for_rate_ignores_unset_or_zero_limits() {
assert_eq!(tool_call_delay_for_rate(None), None);
assert_eq!(tool_call_delay_for_rate(Some(0)), None);
}
#[test]
fn tool_call_delay_for_rate_uses_per_second_interval() {
assert_eq!(
tool_call_delay_for_rate(Some(4)),
Some(Duration::from_millis(250))
);
}
#[test]
fn default_tool_policies_only_seed_canonical_exec_surface() {
let config = ToolsConfig::default();
assert_eq!(
config.policies.get(tools::UNIFIED_EXEC),
Some(&ToolPolicy::Prompt)
);
for legacy_tool in [
tools::RUN_PTY_CMD,
tools::READ_PTY_SESSION,
tools::LIST_PTY_SESSIONS,
tools::SEND_PTY_INPUT,
tools::CLOSE_PTY_SESSION,
tools::EXECUTE_CODE,
] {
assert!(!config.policies.contains_key(legacy_tool));
}
}
#[test]
fn editor_config_deserializes_from_toml() {
let config: ToolsConfig = toml::from_str(
r#"
default_policy = "prompt"
[editor]
enabled = false
preferred_editor = "code --wait"
suspend_tui = false
"#,
)
.expect("tools config should parse");
assert!(!config.editor.enabled);
assert_eq!(config.editor.preferred_editor, "code --wait");
assert!(!config.editor.suspend_tui);
}
}