Skip to main content

agent_deadline/
lib.rs

1//! Cooperative per-task deadline primitive for AI agent workflows.
2//!
3//! Agent loops chain LLM and tool calls. Each step has its own network
4//! timeout, but rarely is there a single wall-clock cap on the whole task.
5//! This crate is a small zero-dependency [`Deadline`] type that the loop
6//! checks between steps and hands to downstream calls as their remaining
7//! timeout.
8//!
9//! Cooperative model: code checks the deadline. Nothing is preempted.
10//!
11//! # Example
12//!
13//! ```
14//! use std::time::Duration;
15//! use agent_deadline::Deadline;
16//!
17//! let deadline = Deadline::after(Duration::from_secs(30));
18//!
19//! // Between agent steps, check whether the cap has passed.
20//! deadline.check_or_err().unwrap();
21//!
22//! // Plug the remaining time into per-call timeouts.
23//! let remaining = deadline.remaining();
24//! assert!(remaining <= Duration::from_secs(30));
25//! ```
26//!
27//! # Intersecting nested deadlines
28//!
29//! A sub-step often has its own soft cap that should never exceed the
30//! parent task's remaining time. [`Deadline::intersect`] picks the earlier
31//! of the two.
32//!
33//! ```
34//! use std::time::Duration;
35//! use agent_deadline::Deadline;
36//!
37//! let task = Deadline::after(Duration::from_secs(30));
38//! let retry = Deadline::after(Duration::from_secs(5));
39//!
40//! let tighter = task.intersect(&retry);
41//! assert!(tighter.remaining() <= Duration::from_secs(5));
42//! ```
43
44use std::error::Error;
45use std::fmt;
46use std::time::{Duration, Instant};
47
48/// Internal representation of when a deadline fires.
49///
50/// `Instant` is used because it is monotonic on every supported platform,
51/// so the deadline is unaffected by NTP jumps or wall-clock resets.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum DeadlineAt {
54    At(Instant),
55    Never,
56}
57
58/// A monotonic-time deadline for cooperative task cancellation.
59///
60/// Build one with [`Deadline::after`] (relative) or [`Deadline::at`]
61/// (absolute), then call [`Deadline::check_or_err`] between agent steps
62/// and [`Deadline::remaining`] when handing the timeout to a per-call API.
63///
64/// Deadlines are immutable. To tighten a deadline for a nested operation
65/// without touching the original, use [`Deadline::intersect`].
66#[derive(Debug, Clone, Copy)]
67pub struct Deadline {
68    at: DeadlineAt,
69    /// Captured at build time so `elapsed` keeps measuring against the
70    /// original task start, even after an intersection.
71    created_at: Instant,
72}
73
74impl Deadline {
75    /// Build a deadline that fires `duration` from now.
76    ///
77    /// A `Duration::ZERO` produces an already-expired deadline, which is
78    /// occasionally useful in tests.
79    pub fn after(duration: Duration) -> Self {
80        let now = Instant::now();
81        // `checked_add` returns None on overflow with very large durations.
82        // Treat that as "never" so callers asking for a near-infinite cap
83        // do not get a silently truncated deadline.
84        let at = match now.checked_add(duration) {
85            Some(t) => DeadlineAt::At(t),
86            None => DeadlineAt::Never,
87        };
88        Self { at, created_at: now }
89    }
90
91    /// Build a deadline that fires at an exact monotonic instant.
92    ///
93    /// Use this when you already have an `Instant` from another source
94    /// (for example a parent task's deadline expressed as an `Instant`).
95    pub fn at(instant: Instant) -> Self {
96        Self {
97            at: DeadlineAt::At(instant),
98            created_at: Instant::now(),
99        }
100    }
101
102    /// A deadline that never fires.
103    ///
104    /// Useful as a default for callers that may or may not want a cap.
105    /// [`Self::remaining`] returns `Duration::MAX`, [`Self::expired`] is
106    /// always `false`, and [`Self::check_or_err`] is a no-op.
107    pub fn never() -> Self {
108        Self {
109            at: DeadlineAt::Never,
110            created_at: Instant::now(),
111        }
112    }
113
114    /// `true` if this deadline was built with [`Deadline::never`].
115    pub fn is_never(&self) -> bool {
116        matches!(self.at, DeadlineAt::Never)
117    }
118
119    /// The absolute instant at which this deadline fires, if any.
120    ///
121    /// Returns `None` for [`Deadline::never`].
122    pub fn instant(&self) -> Option<Instant> {
123        match self.at {
124            DeadlineAt::At(i) => Some(i),
125            DeadlineAt::Never => None,
126        }
127    }
128
129    /// `true` once the current monotonic time is at or past the deadline.
130    ///
131    /// Always `false` for [`Deadline::never`].
132    pub fn expired(&self) -> bool {
133        match self.at {
134            DeadlineAt::Never => false,
135            DeadlineAt::At(at) => Instant::now() >= at,
136        }
137    }
138
139    /// Duration left until the deadline. Saturates at zero. Never negative.
140    ///
141    /// Returns [`Duration::MAX`] for [`Deadline::never`]. Hand this to any
142    /// timeout-taking API.
143    pub fn remaining(&self) -> Duration {
144        match self.at {
145            DeadlineAt::Never => Duration::MAX,
146            DeadlineAt::At(at) => at.saturating_duration_since(Instant::now()),
147        }
148    }
149
150    /// Convenience for callers that want the remaining time as `f64` seconds.
151    ///
152    /// Returns `f64::INFINITY` for [`Deadline::never`].
153    pub fn remaining_seconds(&self) -> f64 {
154        match self.at {
155            DeadlineAt::Never => f64::INFINITY,
156            DeadlineAt::At(_) => self.remaining().as_secs_f64(),
157        }
158    }
159
160    /// Seconds since this deadline was constructed.
161    ///
162    /// Survives an [`Self::intersect`]: the elapsed counter still measures
163    /// against the original task start.
164    pub fn elapsed(&self) -> Duration {
165        self.created_at.elapsed()
166    }
167
168    /// Cooperative check: return `Err(DeadlineExceeded)` if the deadline
169    /// has passed.
170    ///
171    /// No-op for [`Deadline::never`]. Call between agent steps.
172    pub fn check_or_err(&self) -> Result<(), DeadlineExceeded> {
173        match self.at {
174            DeadlineAt::Never => Ok(()),
175            DeadlineAt::At(at) => {
176                let now = Instant::now();
177                if now >= at {
178                    Err(DeadlineExceeded {
179                        elapsed: now.saturating_duration_since(self.created_at),
180                    })
181                } else {
182                    Ok(())
183                }
184            }
185        }
186    }
187
188    /// Return a new deadline that fires at the earlier of `self` and `other`.
189    ///
190    /// `created_at` is preserved from `self` so [`Self::elapsed`] keeps
191    /// measuring against the original task start, not the intersection point.
192    pub fn intersect(&self, other: &Deadline) -> Deadline {
193        let tighter = match (self.at, other.at) {
194            (DeadlineAt::Never, other_at) => other_at,
195            (self_at, DeadlineAt::Never) => self_at,
196            (DeadlineAt::At(a), DeadlineAt::At(b)) => DeadlineAt::At(a.min(b)),
197        };
198        Deadline {
199            at: tighter,
200            created_at: self.created_at,
201        }
202    }
203
204    /// Convenience: intersect with a deadline `duration` from now.
205    pub fn intersect_after(&self, duration: Duration) -> Deadline {
206        self.intersect(&Deadline::after(duration))
207    }
208}
209
210impl Default for Deadline {
211    /// Default deadline is [`Deadline::never`]. Useful in struct fields
212    /// where "no cap" is the sane default.
213    fn default() -> Self {
214        Self::never()
215    }
216}
217
218impl PartialEq for Deadline {
219    /// Two deadlines compare equal when they fire at the same instant
220    /// (or both are `never`). `created_at` is not part of identity.
221    fn eq(&self, other: &Self) -> bool {
222        self.at == other.at
223    }
224}
225
226impl Eq for Deadline {}
227
228/// Error returned by [`Deadline::check_or_err`] when the cap has passed.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub struct DeadlineExceeded {
231    /// Time since the deadline was created.
232    pub elapsed: Duration,
233}
234
235impl DeadlineExceeded {
236    /// Elapsed time since the deadline was created, as `f64` seconds.
237    pub fn elapsed_seconds(&self) -> f64 {
238        self.elapsed.as_secs_f64()
239    }
240}
241
242impl fmt::Display for DeadlineExceeded {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        write!(
245            f,
246            "deadline exceeded: elapsed {:.6}s past the configured cap",
247            self.elapsed_seconds()
248        )
249    }
250}
251
252impl Error for DeadlineExceeded {}
253
254// ---- optional serde support ----
255//
256// `Instant` is not portably serializable: it has no fixed reference point
257// across processes. For diagnostic snapshots we serialize the *remaining*
258// duration at the moment of serialization, plus a `never` flag.
259// Round-tripping reconstructs a deadline that fires the same duration from
260// the deserializing process's `now`. This is a snapshot, not a faithful
261// clone, and that is the documented intent.
262
263#[cfg(feature = "serde")]
264mod serde_impl {
265    use super::{Deadline, DeadlineAt};
266    use serde::{Deserialize, Deserializer, Serialize, Serializer};
267    use std::time::Duration;
268
269    #[derive(Serialize, Deserialize)]
270    struct DeadlineSnapshot {
271        never: bool,
272        remaining_secs: f64,
273    }
274
275    impl Serialize for Deadline {
276        fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
277            // JSON has no `Infinity`, so for the `never` case we just emit
278            // a sentinel `remaining_secs = 0.0` and rely on the `never` flag.
279            let snap = match self.at {
280                DeadlineAt::Never => DeadlineSnapshot {
281                    never: true,
282                    remaining_secs: 0.0,
283                },
284                DeadlineAt::At(_) => DeadlineSnapshot {
285                    never: false,
286                    remaining_secs: self.remaining().as_secs_f64(),
287                },
288            };
289            snap.serialize(ser)
290        }
291    }
292
293    impl<'de> Deserialize<'de> for Deadline {
294        fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
295            let snap = DeadlineSnapshot::deserialize(de)?;
296            if snap.never {
297                return Ok(Deadline::never());
298            }
299            let remaining = if snap.remaining_secs.is_finite() && snap.remaining_secs > 0.0 {
300                Duration::from_secs_f64(snap.remaining_secs)
301            } else {
302                Duration::ZERO
303            };
304            Ok(Deadline::after(remaining))
305        }
306    }
307}