vtcode_config/
timeouts.rs

1use anyhow::{Result, ensure};
2use serde::{Deserialize, Serialize};
3
4#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
5#[derive(Debug, Clone, Deserialize, Serialize)]
6pub struct TimeoutsConfig {
7    /// Maximum duration (in seconds) for standard, non-PTY tools.
8    #[serde(default = "TimeoutsConfig::default_default_ceiling_seconds")]
9    pub default_ceiling_seconds: u64,
10    /// Maximum duration (in seconds) for PTY-backed commands.
11    #[serde(default = "TimeoutsConfig::default_pty_ceiling_seconds")]
12    pub pty_ceiling_seconds: u64,
13    /// Maximum duration (in seconds) for MCP calls.
14    #[serde(default = "TimeoutsConfig::default_mcp_ceiling_seconds")]
15    pub mcp_ceiling_seconds: u64,
16    /// Maximum duration (in seconds) for streaming API responses.
17    #[serde(default = "TimeoutsConfig::default_streaming_ceiling_seconds")]
18    pub streaming_ceiling_seconds: u64,
19    /// Percentage (0-100) of the ceiling after which the UI should warn.
20    #[serde(default = "TimeoutsConfig::default_warning_threshold_percent")]
21    pub warning_threshold_percent: u8,
22    /// Adaptive timeout decay ratio (0.1-1.0). Lower relaxes faster back to ceiling.
23    #[serde(default = "TimeoutsConfig::default_decay_ratio")]
24    pub adaptive_decay_ratio: f64,
25    /// Number of consecutive successes before relaxing adaptive ceiling.
26    #[serde(default = "TimeoutsConfig::default_success_streak")]
27    pub adaptive_success_streak: u32,
28    /// Minimum timeout floor in milliseconds when applying adaptive clamps.
29    #[serde(default = "TimeoutsConfig::default_min_floor_ms")]
30    pub adaptive_min_floor_ms: u64,
31}
32
33impl Default for TimeoutsConfig {
34    fn default() -> Self {
35        Self {
36            default_ceiling_seconds: Self::default_default_ceiling_seconds(),
37            pty_ceiling_seconds: Self::default_pty_ceiling_seconds(),
38            mcp_ceiling_seconds: Self::default_mcp_ceiling_seconds(),
39            streaming_ceiling_seconds: Self::default_streaming_ceiling_seconds(),
40            warning_threshold_percent: Self::default_warning_threshold_percent(),
41            adaptive_decay_ratio: Self::default_decay_ratio(),
42            adaptive_success_streak: Self::default_success_streak(),
43            adaptive_min_floor_ms: Self::default_min_floor_ms(),
44        }
45    }
46}
47
48impl TimeoutsConfig {
49    const MIN_CEILING_SECONDS: u64 = 15;
50
51    const fn default_default_ceiling_seconds() -> u64 {
52        180
53    }
54
55    const fn default_pty_ceiling_seconds() -> u64 {
56        300
57    }
58
59    const fn default_mcp_ceiling_seconds() -> u64 {
60        120
61    }
62
63    const fn default_streaming_ceiling_seconds() -> u64 {
64        600
65    }
66
67    const fn default_warning_threshold_percent() -> u8 {
68        80
69    }
70
71    const fn default_decay_ratio() -> f64 {
72        0.875
73    }
74
75    const fn default_success_streak() -> u32 {
76        5
77    }
78
79    const fn default_min_floor_ms() -> u64 {
80        1_000
81    }
82
83    /// Convert the configured threshold into a fraction (0.0-1.0).
84    pub fn warning_threshold_fraction(&self) -> f32 {
85        f32::from(self.warning_threshold_percent) / 100.0
86    }
87
88    /// Normalize a ceiling value into an optional duration.
89    pub fn ceiling_duration(&self, seconds: u64) -> Option<std::time::Duration> {
90        if seconds == 0 {
91            None
92        } else {
93            Some(std::time::Duration::from_secs(seconds))
94        }
95    }
96
97    pub fn validate(&self) -> Result<()> {
98        ensure!(
99            self.warning_threshold_percent > 0 && self.warning_threshold_percent < 100,
100            "timeouts.warning_threshold_percent must be between 1 and 99",
101        );
102
103        ensure!(
104            (0.1..=1.0).contains(&self.adaptive_decay_ratio),
105            "timeouts.adaptive_decay_ratio must be between 0.1 and 1.0"
106        );
107        ensure!(
108            self.adaptive_success_streak > 0,
109            "timeouts.adaptive_success_streak must be at least 1"
110        );
111        ensure!(
112            self.adaptive_min_floor_ms >= 100,
113            "timeouts.adaptive_min_floor_ms must be at least 100ms"
114        );
115
116        ensure!(
117            self.default_ceiling_seconds == 0
118                || self.default_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
119            "timeouts.default_ceiling_seconds must be at least {} seconds (or 0 to disable)",
120            Self::MIN_CEILING_SECONDS
121        );
122
123        ensure!(
124            self.pty_ceiling_seconds == 0 || self.pty_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
125            "timeouts.pty_ceiling_seconds must be at least {} seconds (or 0 to disable)",
126            Self::MIN_CEILING_SECONDS
127        );
128
129        ensure!(
130            self.mcp_ceiling_seconds == 0 || self.mcp_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
131            "timeouts.mcp_ceiling_seconds must be at least {} seconds (or 0 to disable)",
132            Self::MIN_CEILING_SECONDS
133        );
134
135        ensure!(
136            self.streaming_ceiling_seconds == 0
137                || self.streaming_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
138            "timeouts.streaming_ceiling_seconds must be at least {} seconds (or 0 to disable)",
139            Self::MIN_CEILING_SECONDS
140        );
141
142        Ok(())
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::TimeoutsConfig;
149
150    #[test]
151    fn default_values_are_safe() {
152        let config = TimeoutsConfig::default();
153        assert_eq!(config.default_ceiling_seconds, 180);
154        assert_eq!(config.pty_ceiling_seconds, 300);
155        assert_eq!(config.mcp_ceiling_seconds, 120);
156        assert_eq!(config.streaming_ceiling_seconds, 600);
157        assert_eq!(config.warning_threshold_percent, 80);
158        assert!(config.validate().is_ok());
159    }
160
161    #[test]
162    fn zero_ceiling_disables_limit() {
163        let config = TimeoutsConfig {
164            default_ceiling_seconds: 0,
165            ..Default::default()
166        };
167        assert!(config.validate().is_ok());
168        assert!(
169            config
170                .ceiling_duration(config.default_ceiling_seconds)
171                .is_none()
172        );
173    }
174
175    #[test]
176    fn warning_threshold_bounds_are_enforced() {
177        let config_low = TimeoutsConfig {
178            warning_threshold_percent: 0,
179            ..Default::default()
180        };
181        assert!(config_low.validate().is_err());
182
183        let config_high = TimeoutsConfig {
184            warning_threshold_percent: 100,
185            ..Default::default()
186        };
187        assert!(config_high.validate().is_err());
188    }
189}