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}