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