Skip to main content

scx_forge_agent/
model_timeout.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3
4use std::error::Error;
5use std::fmt;
6use std::time::{Duration, Instant};
7
8use anyhow::Error as AnyhowError;
9
10/// Default wall-clock cap for one planner or coding model turn.
11pub const DEFAULT_TURN_TIMEOUT_SECS: u64 = 5 * 60;
12
13#[derive(Debug, Clone)]
14pub struct ModelTurnTimeout {
15    limit: Duration,
16    elapsed: Duration,
17}
18
19impl ModelTurnTimeout {
20    fn new(limit: Duration, elapsed: Duration) -> Self {
21        Self { limit, elapsed }
22    }
23
24    pub fn summary(&self) -> String {
25        format!(
26            "model turn exceeded the {} time limit (elapsed {})",
27            format_duration(self.limit),
28            format_duration(self.elapsed)
29        )
30    }
31}
32
33impl fmt::Display for ModelTurnTimeout {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.write_str(&self.summary())
36    }
37}
38
39impl Error for ModelTurnTimeout {}
40
41#[derive(Debug, Clone, Copy)]
42pub struct ModelTurnDeadline {
43    started: Instant,
44    limit: Duration,
45}
46
47impl ModelTurnDeadline {
48    pub fn new(limit: Duration) -> Self {
49        Self {
50            started: Instant::now(),
51            limit,
52        }
53    }
54
55    pub fn check(&self) -> Result<(), ModelTurnTimeout> {
56        if self.expired() {
57            Err(self.timeout())
58        } else {
59            Ok(())
60        }
61    }
62
63    pub fn expired(&self) -> bool {
64        self.started.elapsed() >= self.limit
65    }
66
67    pub fn remaining(&self) -> Duration {
68        self.limit
69            .checked_sub(self.started.elapsed())
70            .unwrap_or(Duration::ZERO)
71    }
72
73    pub fn timeout(&self) -> ModelTurnTimeout {
74        ModelTurnTimeout::new(self.limit, self.started.elapsed())
75    }
76}
77
78pub fn is(err: &AnyhowError) -> bool {
79    err.downcast_ref::<ModelTurnTimeout>().is_some()
80}
81
82pub fn summary(err: &AnyhowError) -> String {
83    err.downcast_ref::<ModelTurnTimeout>()
84        .map(ModelTurnTimeout::summary)
85        .unwrap_or_else(|| err.to_string())
86}
87
88fn format_duration(duration: Duration) -> String {
89    let secs = duration.as_secs();
90    if secs >= 60 && secs % 60 == 0 {
91        format!("{}m", secs / 60)
92    } else {
93        format!("{secs}s")
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn summarizes_timeout() {
103        let err = ModelTurnTimeout::new(Duration::from_secs(300), Duration::from_secs(301));
104
105        assert_eq!(
106            err.summary(),
107            "model turn exceeded the 5m time limit (elapsed 301s)"
108        );
109    }
110}