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 web_search: WebSearchConfig,
#[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, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WebFetchMode {
#[default]
Restricted,
Whitelist,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum WebSearchProvider {
#[default]
Auto,
Duckduckgo,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct WebSearchConfig {
#[serde(default)]
pub provider: WebSearchProvider,
#[serde(default = "default_web_search_max_results")]
pub max_results: usize,
#[serde(default = "default_web_search_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_web_search_cooldown_ms")]
pub cooldown_ms: u64,
#[serde(default = "default_web_search_cache_ttl_secs")]
pub cache_ttl_secs: u64,
#[serde(default = "default_web_search_session_max_requests")]
pub session_max_requests: u32,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct WebFetchConfig {
#[serde(default = "default_web_fetch_mode")]
pub mode: WebFetchMode,
#[serde(default)]
pub blocked_domains: Vec<String>,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub blocked_patterns: Vec<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.clone()))
.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(),
web_search: WebSearchConfig::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))
}
impl Default for WebFetchConfig {
fn default() -> Self {
Self {
mode: default_web_fetch_mode(),
blocked_domains: Vec::new(),
allowed_domains: default_web_fetch_allowed_domains(),
blocked_patterns: Vec::new(),
strict_https_only: true,
}
}
}
fn default_web_fetch_allowed_domains() -> Vec<String> {
use std::sync::OnceLock;
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
CACHE
.get_or_init(|| {
crate::network_allowlist::NetworkAllowlist::load_default().web_fetch_relevant_domains()
})
.clone()
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
provider: WebSearchProvider::default(),
max_results: default_web_search_max_results(),
timeout_secs: default_web_search_timeout_secs(),
cooldown_ms: default_web_search_cooldown_ms(),
cache_ttl_secs: default_web_search_cache_ttl_secs(),
session_max_requests: default_web_search_session_max_requests(),
}
}
}
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, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ToolPolicy {
Allow,
#[default]
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() -> WebFetchMode {
WebFetchMode::Restricted
}
fn default_strict_https() -> bool {
true
}
#[inline]
const fn default_web_search_max_results() -> usize {
8
}
#[inline]
const fn default_web_search_timeout_secs() -> u64 {
20
}
#[inline]
const fn default_web_search_cooldown_ms() -> u64 {
3_000
}
#[inline]
const fn default_web_search_cache_ttl_secs() -> u64 {
300
}
#[inline]
const fn default_web_search_session_max_requests() -> u32 {
12
}
#[inline]
const fn default_editor_enabled() -> bool {
true
}
#[inline]
const fn default_editor_suspend_tui() -> bool {
true
}
const DEFAULT_TOOL_POLICIES: &[(&str, ToolPolicy)] = &[
(tools::START_PLANNING, ToolPolicy::Allow),
(tools::TASK_TRACKER, ToolPolicy::Allow),
(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::Allow),
];
#[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::Allow)
);
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);
}
#[test]
fn web_search_config_deserializes_from_toml() {
let config: ToolsConfig = toml::from_str(
r#"
default_policy = "prompt"
[web_search]
provider = "duckduckgo"
max_results = 12
timeout_secs = 25
cooldown_ms = 1500
cache_ttl_secs = 120
session_max_requests = 5
"#,
)
.expect("tools config should parse");
assert_eq!(config.web_search.provider, WebSearchProvider::Duckduckgo);
assert_eq!(config.web_search.max_results, 12);
assert_eq!(config.web_search.timeout_secs, 25);
assert_eq!(config.web_search.cooldown_ms, 1500);
assert_eq!(config.web_search.cache_ttl_secs, 120);
assert_eq!(config.web_search.session_max_requests, 5);
}
#[test]
fn web_search_config_defaults_are_polite() {
let config = WebSearchConfig::default();
assert_eq!(config.provider, WebSearchProvider::Auto);
assert_eq!(config.max_results, 8);
assert_eq!(config.timeout_secs, 20);
assert!(config.cooldown_ms >= 1_000);
assert!(config.cache_ttl_secs >= 60);
assert!(config.session_max_requests > 0);
}
#[test]
fn web_search_provider_serializes_lowercase() {
let json = serde_json::to_value(WebSearchProvider::Duckduckgo).unwrap();
assert_eq!(json, serde_json::json!("duckduckgo"));
}
#[test]
fn web_fetch_default_allowed_domains_seed_common_dev_sites() {
let allowed = WebFetchConfig::default().allowed_domains;
for host in [
"github.com",
"api.github.com",
"raw.githubusercontent.com",
"crates.io",
"index.crates.io",
"registry.npmjs.org",
"pypi.org",
] {
assert!(
allowed.iter().any(|d| d == host),
"default allowed_domains should include {host}; got {allowed:?}"
);
}
}
#[test]
fn web_fetch_default_allowed_domains_include_relevant_categories() {
let allowed = WebFetchConfig::default().allowed_domains;
for host in [
"github.com",
"crates.io",
"registry.npmjs.org",
"pypi.org",
"en.wikipedia.org",
"r.jina.ai",
"api.tavily.com",
] {
assert!(
allowed.iter().any(|d| d == host),
"default allowed_domains should include {host}; got {allowed:?}"
);
}
}
#[test]
fn web_fetch_default_allowed_domains_exclude_ai_and_dev_infra() {
let allowed = WebFetchConfig::default().allowed_domains;
for host in [
"api.anthropic.com",
"api.openai.com",
"api.fireworks.ai",
"api.deepseek.com",
"defuddle.md",
"*.auth0.com",
"*.workers.dev",
"*.vercel.app",
"us.i.posthog.com",
"security.ubuntu.com",
"archive.ubuntu.com",
] {
assert!(
!allowed.iter().any(|d| d == host),
"default allowed_domains must NOT include {host}; got {allowed:?}"
);
}
}
#[test]
fn web_fetch_default_allowed_domains_preserves_wildcards_in_relevant_categories() {
let allowed = WebFetchConfig::default().allowed_domains;
let wildcards: Vec<&str> = allowed
.iter()
.map(|s| s.as_str())
.filter(|s| s.starts_with("*."))
.collect();
assert!(
wildcards.is_empty(),
"expected no wildcards in web_fetch defaults (dev_infra/auth are excluded); got {wildcards:?}"
);
}
#[test]
fn web_fetch_default_allowed_domains_returns_fresh_vec() {
let mut a = WebFetchConfig::default().allowed_domains;
a.push("evil.example".to_string());
let b = WebFetchConfig::default().allowed_domains;
assert!(!b.iter().any(|d| d == "evil.example"));
}
}