Skip to main content

vtcode_core/tools/registry/
timeout.rs

1//! Tool timeout policies and adaptive tuning.
2//!
3//! This module contains timeout management for tool executions,
4//! including category-based timeouts and adaptive timeout adjustments
5//! based on historical latency data.
6
7use std::collections::VecDeque;
8use std::time::Duration;
9
10use crate::config::TimeoutsConfig;
11
12/// Categories of tools with different timeout requirements.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum ToolTimeoutCategory {
15    /// Standard tool execution.
16    Default,
17    /// PTY-based interactive commands (longer timeouts).
18    Pty,
19    /// MCP tool execution (moderate timeouts).
20    Mcp,
21}
22
23impl ToolTimeoutCategory {
24    /// Human-readable label for the category.
25    pub fn label(&self) -> &'static str {
26        match self {
27            ToolTimeoutCategory::Default => "standard",
28            ToolTimeoutCategory::Pty => "PTY",
29            ToolTimeoutCategory::Mcp => "MCP",
30        }
31    }
32}
33
34/// Policy for tool execution timeouts per category.
35#[derive(Debug, Clone)]
36pub struct ToolTimeoutPolicy {
37    default_ceiling: Option<Duration>,
38    pty_ceiling: Option<Duration>,
39    mcp_ceiling: Option<Duration>,
40    warning_fraction: f32,
41}
42
43impl Default for ToolTimeoutPolicy {
44    fn default() -> Self {
45        Self {
46            default_ceiling: Some(Duration::from_secs(180)),
47            pty_ceiling: Some(Duration::from_secs(300)),
48            mcp_ceiling: Some(Duration::from_secs(120)),
49            warning_fraction: 0.75,
50        }
51    }
52}
53
54impl ToolTimeoutPolicy {
55    /// Create a timeout policy from configuration.
56    pub fn from_config(config: &TimeoutsConfig) -> Self {
57        Self {
58            default_ceiling: config.ceiling_duration(config.default_ceiling_seconds),
59            pty_ceiling: config.ceiling_duration(config.pty_ceiling_seconds),
60            mcp_ceiling: config.ceiling_duration(config.mcp_ceiling_seconds),
61            warning_fraction: config.warning_threshold_fraction().clamp(0.0, 0.99),
62        }
63    }
64
65    /// Validate a single ceiling duration against bounds.
66    #[inline]
67    fn validate_ceiling(ceiling: Option<Duration>, name: &str) -> anyhow::Result<()> {
68        if let Some(ceiling) = ceiling {
69            if ceiling < Duration::from_secs(1) {
70                anyhow::bail!(
71                    "{} must be at least 1 second (got {}s)",
72                    name,
73                    ceiling.as_secs()
74                );
75            }
76            if ceiling > Duration::from_secs(3600) {
77                anyhow::bail!(
78                    "{} must not exceed 3600 seconds/1 hour (got {}s)",
79                    name,
80                    ceiling.as_secs()
81                );
82            }
83        }
84        Ok(())
85    }
86
87    /// Validate the timeout policy configuration.
88    ///
89    /// Ensures that:
90    /// - Ceiling values are within reasonable bounds (1s - 3600s)
91    /// - Warning fraction is between 0.0 and 1.0
92    /// - No ceiling is configured as 0 seconds
93    pub fn validate(&self) -> anyhow::Result<()> {
94        Self::validate_ceiling(self.default_ceiling, "default_ceiling_seconds")?;
95        Self::validate_ceiling(self.pty_ceiling, "pty_ceiling_seconds")?;
96        Self::validate_ceiling(self.mcp_ceiling, "mcp_ceiling_seconds")?;
97
98        // Validate warning fraction
99        if self.warning_fraction <= 0.0 {
100            anyhow::bail!(
101                "warning_threshold_percent must be greater than 0 (got {})",
102                self.warning_fraction * 100.0
103            );
104        }
105        if self.warning_fraction >= 1.0 {
106            anyhow::bail!(
107                "warning_threshold_percent must be less than 100 (got {})",
108                self.warning_fraction * 100.0
109            );
110        }
111
112        Ok(())
113    }
114
115    /// Get the ceiling timeout for a given category.
116    pub fn ceiling_for(&self, category: ToolTimeoutCategory) -> Option<Duration> {
117        match category {
118            ToolTimeoutCategory::Default => self.default_ceiling,
119            ToolTimeoutCategory::Pty => self.pty_ceiling.or(self.default_ceiling),
120            ToolTimeoutCategory::Mcp => self.mcp_ceiling.or(self.default_ceiling),
121        }
122    }
123
124    /// Get the warning threshold fraction.
125    pub fn warning_fraction(&self) -> f32 {
126        self.warning_fraction
127    }
128}
129
130/// Tracks latency samples for adaptive timeout calculation.
131#[derive(Debug, Clone, Default)]
132pub struct ToolLatencyStats {
133    pub(super) samples: VecDeque<Duration>,
134    pub(super) max_samples: usize,
135}
136
137impl ToolLatencyStats {
138    /// Create a new latency tracker with a maximum sample count.
139    pub fn new(max_samples: usize) -> Self {
140        Self {
141            samples: VecDeque::with_capacity(max_samples),
142            max_samples,
143        }
144    }
145
146    /// Record a new latency sample.
147    pub fn record(&mut self, duration: Duration) {
148        if self.samples.len() >= self.max_samples {
149            self.samples.pop_front();
150        }
151        self.samples.push_back(duration);
152    }
153
154    /// Calculate the percentile latency from recorded samples.
155    pub fn percentile(&self, pct: f64) -> Option<Duration> {
156        if self.samples.is_empty() {
157            return None;
158        }
159        let mut sorted: Vec<Duration> = self.samples.iter().copied().collect();
160        sorted.sort_unstable();
161        let idx =
162            ((pct.clamp(0.0, 1.0)) * (sorted.len().saturating_sub(1) as f64)).round() as usize;
163        sorted.get(idx).copied()
164    }
165}
166
167/// Tuning parameters for adaptive timeout adjustment.
168#[derive(Debug, Clone, Copy)]
169pub struct AdaptiveTimeoutTuning {
170    /// Ratio to decay timeout toward ceiling on success.
171    pub decay_ratio: f64,
172    /// Number of consecutive successes before decaying.
173    pub success_streak: u32,
174    /// Minimum floor for adaptive timeout in milliseconds.
175    pub min_floor_ms: u64,
176}
177
178impl Default for AdaptiveTimeoutTuning {
179    fn default() -> Self {
180        Self {
181            decay_ratio: 0.875,  // relax toward ceiling by 12.5%
182            success_streak: 5,   // decay after 5 consecutive successes
183            min_floor_ms: 1_000, // never clamp below 1s
184        }
185    }
186}
187
188impl AdaptiveTimeoutTuning {
189    /// Create adaptive tuning parameters from configuration.
190    pub fn from_config(timeouts: &TimeoutsConfig) -> Self {
191        Self {
192            decay_ratio: timeouts.adaptive_decay_ratio,
193            success_streak: timeouts.adaptive_success_streak,
194            min_floor_ms: timeouts.adaptive_min_floor_ms,
195        }
196    }
197}