Skip to main content

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    /// Maximum duration (in seconds) for an explicit long-running command wait.
20    #[serde(default = "TimeoutsConfig::default_long_running_command_ceiling_seconds")]
21    pub long_running_command_ceiling_seconds: u64,
22    /// Percentage (0-100) of the ceiling after which the UI should warn.
23    #[serde(default = "TimeoutsConfig::default_warning_threshold_percent")]
24    pub warning_threshold_percent: u8,
25    /// Adaptive timeout decay ratio (0.1-1.0). Lower relaxes faster back to ceiling.
26    #[serde(default = "TimeoutsConfig::default_decay_ratio")]
27    pub adaptive_decay_ratio: f64,
28    /// Number of consecutive successes before relaxing adaptive ceiling.
29    #[serde(default = "TimeoutsConfig::default_success_streak")]
30    pub adaptive_success_streak: u32,
31    /// Minimum timeout floor in milliseconds when applying adaptive clamps.
32    #[serde(default = "TimeoutsConfig::default_min_floor_ms")]
33    pub adaptive_min_floor_ms: u64,
34}
35
36impl Default for TimeoutsConfig {
37    fn default() -> Self {
38        Self {
39            default_ceiling_seconds: Self::default_default_ceiling_seconds(),
40            pty_ceiling_seconds: Self::default_pty_ceiling_seconds(),
41            mcp_ceiling_seconds: Self::default_mcp_ceiling_seconds(),
42            streaming_ceiling_seconds: Self::default_streaming_ceiling_seconds(),
43            long_running_command_ceiling_seconds: Self::default_long_running_command_ceiling_seconds(),
44            warning_threshold_percent: Self::default_warning_threshold_percent(),
45            adaptive_decay_ratio: Self::default_decay_ratio(),
46            adaptive_success_streak: Self::default_success_streak(),
47            adaptive_min_floor_ms: Self::default_min_floor_ms(),
48        }
49    }
50}
51
52impl TimeoutsConfig {
53    const MIN_CEILING_SECONDS: u64 = 15;
54
55    const fn default_default_ceiling_seconds() -> u64 {
56        180
57    }
58
59    const fn default_pty_ceiling_seconds() -> u64 {
60        300
61    }
62
63    const fn default_mcp_ceiling_seconds() -> u64 {
64        120
65    }
66
67    const fn default_streaming_ceiling_seconds() -> u64 {
68        600
69    }
70
71    const fn default_long_running_command_ceiling_seconds() -> u64 {
72        3_600
73    }
74
75    const fn default_warning_threshold_percent() -> u8 {
76        80
77    }
78
79    const fn default_decay_ratio() -> f64 {
80        0.875
81    }
82
83    const fn default_success_streak() -> u32 {
84        5
85    }
86
87    const fn default_min_floor_ms() -> u64 {
88        1_000
89    }
90
91    /// Convert the configured threshold into a fraction (0.0-1.0).
92    pub fn warning_threshold_fraction(&self) -> f32 {
93        f32::from(self.warning_threshold_percent) / 100.0
94    }
95
96    /// Normalize a ceiling value into an optional duration.
97    pub fn ceiling_duration(&self, seconds: u64) -> Option<std::time::Duration> {
98        if seconds == 0 {
99            None
100        } else {
101            Some(std::time::Duration::from_secs(seconds))
102        }
103    }
104
105    pub(crate) fn validate(&self) -> Result<()> {
106        ensure!(
107            self.warning_threshold_percent > 0 && self.warning_threshold_percent < 100,
108            "timeouts.warning_threshold_percent must be between 1 and 99",
109        );
110
111        ensure!(
112            (0.1..=1.0).contains(&self.adaptive_decay_ratio),
113            "timeouts.adaptive_decay_ratio must be between 0.1 and 1.0"
114        );
115        ensure!(self.adaptive_success_streak > 0, "timeouts.adaptive_success_streak must be at least 1");
116        ensure!(self.adaptive_min_floor_ms >= 100, "timeouts.adaptive_min_floor_ms must be at least 100ms");
117
118        ensure!(
119            self.default_ceiling_seconds == 0 || self.default_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
120            "timeouts.default_ceiling_seconds must be at least {} seconds (or 0 to disable)",
121            Self::MIN_CEILING_SECONDS
122        );
123
124        ensure!(
125            self.pty_ceiling_seconds == 0 || self.pty_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
126            "timeouts.pty_ceiling_seconds must be at least {} seconds (or 0 to disable)",
127            Self::MIN_CEILING_SECONDS
128        );
129
130        ensure!(
131            self.mcp_ceiling_seconds == 0 || self.mcp_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
132            "timeouts.mcp_ceiling_seconds must be at least {} seconds (or 0 to disable)",
133            Self::MIN_CEILING_SECONDS
134        );
135
136        ensure!(
137            self.streaming_ceiling_seconds == 0 || 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        ensure!(
143            self.long_running_command_ceiling_seconds == 0
144                || self.long_running_command_ceiling_seconds >= Self::MIN_CEILING_SECONDS,
145            "timeouts.long_running_command_ceiling_seconds must be at least {} seconds (or 0 to disable)",
146            Self::MIN_CEILING_SECONDS
147        );
148
149        Ok(())
150    }
151}
152
153/// Resolve a user-supplied timeout into a bounded, non-zero value.
154pub fn resolve_timeout(user_timeout: Option<u64>) -> u64 {
155    use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
156
157    match user_timeout {
158        None | Some(0) => DEFAULT_TIMEOUT_SECS,
159        Some(value) if value < MIN_TIMEOUT_SECS => MIN_TIMEOUT_SECS,
160        Some(value) if value > MAX_TIMEOUT_SECS => MAX_TIMEOUT_SECS,
161        Some(value) => value,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::TimeoutsConfig;
168    use super::resolve_timeout;
169    use crate::constants::execution::{DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS};
170
171    #[test]
172    fn default_values_are_safe() {
173        let config = TimeoutsConfig::default();
174        assert_eq!(config.default_ceiling_seconds, 180);
175        assert_eq!(config.pty_ceiling_seconds, 300);
176        assert_eq!(config.mcp_ceiling_seconds, 120);
177        assert_eq!(config.streaming_ceiling_seconds, 600);
178        assert_eq!(config.long_running_command_ceiling_seconds, 3_600);
179        assert_eq!(config.warning_threshold_percent, 80);
180        assert!(config.validate().is_ok());
181    }
182
183    #[test]
184    fn zero_ceiling_disables_limit() {
185        let config = TimeoutsConfig { default_ceiling_seconds: 0, ..Default::default() };
186        assert!(config.validate().is_ok());
187        assert!(config.ceiling_duration(config.default_ceiling_seconds).is_none());
188    }
189
190    #[test]
191    fn warning_threshold_bounds_are_enforced() {
192        let config_low = TimeoutsConfig { warning_threshold_percent: 0, ..Default::default() };
193        assert!(config_low.validate().is_err());
194
195        let config_high = TimeoutsConfig {
196            warning_threshold_percent: 100,
197            ..Default::default()
198        };
199        assert!(config_high.validate().is_err());
200    }
201
202    #[test]
203    fn resolve_timeout_applies_bounds() {
204        assert_eq!(resolve_timeout(None), DEFAULT_TIMEOUT_SECS);
205        assert_eq!(resolve_timeout(Some(0)), DEFAULT_TIMEOUT_SECS);
206        assert_eq!(resolve_timeout(Some(1)), MIN_TIMEOUT_SECS);
207        assert_eq!(resolve_timeout(Some(MAX_TIMEOUT_SECS + 1)), MAX_TIMEOUT_SECS);
208        assert_eq!(resolve_timeout(Some(120)), 120);
209    }
210}