Skip to main content

mentra/runtime/control/
run.rs

1use std::sync::{
2    Arc, OnceLock,
3    atomic::{AtomicBool, AtomicU64, Ordering},
4};
5use std::time::{Duration, SystemTime};
6
7use crate::runtime::error::RuntimeError;
8
9use super::RunLocals;
10
11const DEFAULT_PROVIDER_RETRY_BUDGET: usize = 5;
12const DEFAULT_PROVIDER_RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
13const DEFAULT_PROVIDER_RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
14const DEFAULT_PROVIDER_RETRY_AFTER_CAP: Duration = Duration::from_secs(60);
15
16/// How long a run waits between provider retries.
17///
18/// *How many* retries it gets stays on [`RunOptions::retry_budget`]; see that
19/// field for why the count did not move in here. This is the other half: what
20/// each of those attempts waits for.
21///
22/// The defaults reproduce mentra's historical schedule exactly — 500 ms,
23/// doubling, capped at 5 s — which is shaped for a blip: a connection reset, a
24/// tunnel restart, a 502 from a proxy that is already coming back. A rate limit
25/// is a different failure. It lasts as long as the window it belongs to, which
26/// is routinely a minute, and the whole default budget elapses in about twelve
27/// and a half seconds — five attempts into a limit that was never going to lift
28/// in that time, and then a lost turn. A host that knows it is behind a metered
29/// gateway can say so here instead of living with a schedule chosen for a
30/// different failure.
31///
32/// ```rust
33/// use std::time::Duration;
34/// use mentra::runtime::{ProviderRetry, RunOptions};
35///
36/// // Wait out a minute-long rate-limit window rather than a blip.
37/// let options = RunOptions {
38///     retry_budget: 8,
39///     ..RunOptions::default()
40/// }
41/// .with_provider_retry(ProviderRetry {
42///     base_delay: Duration::from_secs(1),
43///     max_delay: Duration::from_secs(30),
44///     ..ProviderRetry::default()
45/// });
46/// # let _ = options;
47/// ```
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub struct ProviderRetry {
50    /// The wait before the second attempt, doubled before each attempt after
51    /// it.
52    pub base_delay: Duration,
53    /// The ceiling the doubling stops at. Reached and then held, so a long
54    /// budget spends its tail attempts at a steady interval rather than an
55    /// ever-growing one.
56    pub max_delay: Duration,
57    /// The longest wait a *server* may impose through `Retry-After`.
58    ///
59    /// A server that answers `Retry-After: 3600` is not describing a rate
60    /// limit any run should sit through, and honoring it unconditionally hands
61    /// a remote party control of how long this process blocks. The header is
62    /// clamped to this before it is considered. It never shortens
63    /// [`max_delay`](Self::max_delay): a schedule the host chose is the host's
64    /// business, and this bounds only what the other end asked for.
65    pub retry_after_cap: Duration,
66}
67
68impl Default for ProviderRetry {
69    fn default() -> Self {
70        Self {
71            base_delay: DEFAULT_PROVIDER_RETRY_BASE_DELAY,
72            max_delay: DEFAULT_PROVIDER_RETRY_MAX_DELAY,
73            retry_after_cap: DEFAULT_PROVIDER_RETRY_AFTER_CAP,
74        }
75    }
76}
77
78impl ProviderRetry {
79    /// The wait this schedule prescribes before the attempt that follows
80    /// `attempt` (one-based), before anything the provider said is considered.
81    pub fn scheduled_delay(&self, attempt: usize) -> Duration {
82        // Clamped to `u32`'s width, not `usize`'s: the factor is a `u32`, and
83        // shifting one by 32 or more is a panic in debug and nonsense in
84        // release. Unreachable at the default budget of five, reachable the
85        // moment a host raises it — which is the point of this type.
86        let shift = attempt.saturating_sub(1).min(u32::BITS as usize - 1) as u32;
87        let factor = 1u32 << shift;
88        self.base_delay
89            .checked_mul(factor)
90            .unwrap_or(self.max_delay)
91            .min(self.max_delay)
92    }
93
94    /// The wait actually taken before the attempt that follows `attempt`, given
95    /// what the provider asked for in `retry_after`.
96    ///
97    /// The longer of the two wins, because they answer different questions: the
98    /// schedule is the host's floor on how hard it is willing to hammer a
99    /// provider, and `Retry-After` is the provider's floor on when it will
100    /// answer again. Waiting the shorter of them satisfies neither. The
101    /// server's number is clamped to
102    /// [`retry_after_cap`](Self::retry_after_cap) first.
103    pub fn delay_for(&self, attempt: usize, retry_after: Option<Duration>) -> Duration {
104        let scheduled = self.scheduled_delay(attempt);
105        match retry_after {
106            Some(requested) => scheduled.max(requested.min(self.retry_after_cap)),
107            None => scheduled,
108        }
109    }
110}
111
112/// Why a run ended before its work was done, when a bound rather than the model
113/// decided that.
114///
115/// The two graceful bounds — [`RunOptions::stop`] and
116/// [`RunOptions::token_budget`] — deliberately end a run the way the model
117/// finishing does: at a round boundary, transcript committed, `Ok`. That is the
118/// right *behavior* and a silent *report*, because what the caller receives is
119/// then identical for "the model was done" and "the runner refused to start
120/// another round". A caller that has to tell those apart — a CLI owing a
121/// distinct exit code, a supervisor deciding whether to prompt again — is
122/// otherwise left either recomputing the comparison the runner already made or
123/// reading prose, and the first answers a slightly different question (what is
124/// true *now*, not what was true at the boundary) while the second is not a
125/// contract at all. This records the runner's own decision at the moment it
126/// made it.
127///
128/// Read it back through [`RunOptions::ended_early`].
129#[derive(Clone, Copy, Debug, PartialEq, Eq)]
130#[non_exhaustive]
131pub enum EarlyEnd {
132    /// The run ended at a round boundary because [`RunOptions::stop`] was
133    /// tripped.
134    ///
135    /// Reported in preference to [`TokenBudget`](Self::TokenBudget) when both
136    /// were true at that boundary. A stop is an instruction the caller issued,
137    /// and the runner would have ended there with no budget set at all; a
138    /// crossed budget is an ambient bound that merely also held. Naming the
139    /// bound would tell a caller its allowance ran out when what actually
140    /// happened is that it asked to stop — and the runner's own control flow
141    /// agrees, since it checks `stop` first and never reaches the budget check.
142    StopRequested,
143    /// The run ended at a round boundary because cumulative reported usage had
144    /// reached or passed [`RunOptions::token_budget`].
145    ///
146    /// See that field for why this can only ever be noticed at a boundary, and
147    /// for what the run keeps when it is.
148    TokenBudget,
149}
150
151/// A shared flag a caller trips to stop a run.
152///
153/// `Debug` prints whether it has been tripped, so a host that embeds one in
154/// its own options struct can still derive `Debug` on that struct.
155#[derive(Clone, Default)]
156pub struct CancellationToken {
157    cancelled: Arc<AtomicBool>,
158}
159
160impl std::fmt::Debug for CancellationToken {
161    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162        f.debug_struct("CancellationToken")
163            .field("cancelled", &self.is_cancelled())
164            .finish()
165    }
166}
167
168impl CancellationToken {
169    pub fn cancel(&self) {
170        self.cancelled.store(true, Ordering::SeqCst);
171    }
172
173    pub fn is_cancelled(&self) -> bool {
174        self.cancelled.load(Ordering::SeqCst)
175    }
176}
177
178#[derive(Clone)]
179pub struct RunOptions {
180    pub cancellation: Option<CancellationToken>,
181    /// A **graceful** stop signal, distinct from [`cancellation`](Self::cancellation).
182    ///
183    /// When this token is tripped (via [`CancellationToken::cancel`]) the run ends
184    /// **successfully** at the next round boundary — the committed transcript is
185    /// kept (the run resolves like the model self-terminating with no further tool
186    /// calls), rather than failing and rolling the run back the way `cancellation`
187    /// does. Use it to stop gathering once enough work is done while preserving the
188    /// gathered context for a follow-up turn on the same agent. `None` (the default)
189    /// never stops the run.
190    pub stop: Option<CancellationToken>,
191    pub deadline: Option<SystemTime>,
192    /// How many times one provider request may be re-attempted after a
193    /// transient failure, before the run gives up and reports the error.
194    ///
195    /// The count stayed here rather than moving into [`ProviderRetry`] beside
196    /// the schedule it belongs with, because
197    /// `RunOptions { retry_budget: 3, ..default() }` is how every host that has
198    /// ever changed this wrote it, and there is no spelling of a moved public
199    /// field that keeps those compiling. One number, one home;
200    /// [`provider_retry`](Self::provider_retry) holds the rest.
201    ///
202    /// **Retries are model requests.** Each attempt increments the same counter
203    /// [`model_budget`](field@Self::model_budget) bounds, so a run with both set can
204    /// exhaust its model budget on retries and end in
205    /// [`ModelBudgetExceeded`](crate::error::RuntimeError::ModelBudgetExceeded)
206    /// without the model ever having answered. That is deliberate:
207    /// `model_budget` bounds how many times this run may reach for the
208    /// provider, and an attempt that failed still reached. A host raising this
209    /// budget to sit out a rate limit should raise `model_budget` with it, or
210    /// leave `model_budget` at `None`, where no such interaction exists.
211    ///
212    /// Inherited by [`child`](Self::child) runs, along with
213    /// [`provider_retry`](Self::provider_retry); see that method for why a
214    /// delegated run meets the same provider with the same patience.
215    pub retry_budget: usize,
216    /// How long each of those retries waits. See [`ProviderRetry`]; the default
217    /// schedule is mentra's historical one, unchanged.
218    pub provider_retry: ProviderRetry,
219    pub tool_budget: Option<usize>,
220    /// A bound on how many provider requests this run may make, counting failed
221    /// attempts — see [`retry_budget`](Self::retry_budget). `None` (the
222    /// default) never bounds the run.
223    pub model_budget: Option<usize>,
224    /// A per-run [`RoundStrategy`](crate::agent::RoundStrategy) invoked at each
225    /// round boundary (after a committed tool round and after a committed
226    /// tool-free assistant message). It is owned by this single `Agent::run`
227    /// invocation, never by a shared [`Runtime`](crate::Runtime), so one run's
228    /// steering and stop state cannot leak into another run. `None` (the default)
229    /// reproduces mentra's built-in round loop exactly.
230    pub round_strategy: Option<Arc<dyn crate::agent::RoundStrategy>>,
231    /// A **soft** aggregate token bound on this run's reported usage, distinct
232    /// from [`model_budget`](field@Self::model_budget) (which caps the number of
233    /// provider *requests*, not tokens).
234    ///
235    /// Usage is known only after a provider response completes: after an
236    /// ordinary round has streamed in full, or after an in-run compaction has
237    /// been successfully applied. This can therefore never be a hard ceiling.
238    /// A round — including the compaction it may perform before its main model
239    /// request — is allowed to finish even if those reports push cumulative
240    /// usage from under the bound to well past it. Once the round completes, the
241    /// bound is checked at the same boundary where [`stop`](Self::stop) is
242    /// checked: if cumulative reported `input_tokens + output_tokens` (summed
243    /// across every response in this run and any [`child`](Self::child) sharing
244    /// this handle) has reached or exceeded the bound, the run ends
245    /// **gracefully** there, exactly as `stop` does — the committed transcript is
246    /// kept, not rolled back. Cache-read and cache-creation tokens are not
247    /// counted. `None` (the default) never stops the run. This is never an
248    /// expense bound: mentra has no injected price source and makes no monetary
249    /// claim.
250    pub token_budget: Option<u64>,
251    /// Shared cumulative `input_tokens + output_tokens` counter backing
252    /// [`token_budget`](Self::token_budget) and read back through
253    /// [`reported_tokens`](Self::reported_tokens). Held behind an `Arc` so a
254    /// [`child`](Self::child) run reports into the same aggregate as its parent —
255    /// that is the intended way to share it. This field is `pub` only so
256    /// `RunOptions { .., ..RunOptions::default() }` construction keeps working;
257    /// leave it at its default (a fresh, zeroed counter) unless you are
258    /// deliberately aliasing a specific run's accounting.
259    pub token_usage: Arc<AtomicU64>,
260    /// Where a run records *why* it ended early, read back through
261    /// [`ended_early`](Self::ended_early).
262    ///
263    /// The counterpart of [`token_usage`](Self::token_usage) for the decision
264    /// rather than the count: the runner knows at the boundary it stops at
265    /// which bound stopped it, and this is how that reaches a caller holding a
266    /// clone of these options instead of being re-derived — or lost. Written at
267    /// most once, first writer winning, because a run ends at exactly one
268    /// boundary and because both conditions that produce an entry are sticky
269    /// under a fixed bound: a tripped stop token stays tripped, and a crossed
270    /// cumulative total stays crossed, so a later turn on this handle ends the
271    /// same way and would record the same answer. Raising
272    /// [`token_budget`](Self::token_budget) on a clone is the one way to make
273    /// the entry stale — the deliberate aliasing `token_usage` warns about,
274    /// seen from the reporting side. Like that field, this one is `pub` only so
275    /// `RunOptions { .., ..RunOptions::default() }` construction keeps working;
276    /// leave it at its default (a fresh, empty slot).
277    pub early_end: Arc<OnceLock<EarlyEnd>>,
278    /// Values the host attaches to this run, read by the tools it calls
279    /// through [`ToolContext::run_local`](crate::tool::ToolContext::run_local).
280    /// Empty by default. Inherited by [`child`](Self::child) runs; see
281    /// [`RunLocals`] for why.
282    pub locals: RunLocals,
283}
284
285impl Default for RunOptions {
286    fn default() -> Self {
287        Self {
288            cancellation: None,
289            stop: None,
290            deadline: None,
291            retry_budget: DEFAULT_PROVIDER_RETRY_BUDGET,
292            provider_retry: ProviderRetry::default(),
293            tool_budget: None,
294            model_budget: None,
295            round_strategy: None,
296            token_budget: None,
297            token_usage: Arc::new(AtomicU64::new(0)),
298            early_end: Arc::new(OnceLock::new()),
299            locals: RunLocals::default(),
300        }
301    }
302}
303
304impl RunOptions {
305    /// Attaches a per-run [`RoundStrategy`](crate::agent::RoundStrategy) to these
306    /// options, returning the updated value.
307    pub fn with_round_strategy(mut self, strategy: Arc<dyn crate::agent::RoundStrategy>) -> Self {
308        self.round_strategy = Some(strategy);
309        self
310    }
311
312    /// Sets the provider retry schedule on these options, returning the updated
313    /// value. Leaves [`retry_budget`](Self::retry_budget), which counts the
314    /// attempts this schedule spaces, alone.
315    pub fn with_provider_retry(mut self, provider_retry: ProviderRetry) -> Self {
316        self.provider_retry = provider_retry;
317        self
318    }
319
320    /// Sets the values this run's tools read, returning the updated value.
321    pub fn with_locals(mut self, locals: RunLocals) -> Self {
322        self.locals = locals;
323        self
324    }
325
326    /// Derives [`RunOptions`] for work spawned during this run — a subagent or a
327    /// delegated run — sharing this run's aggregate safety bounds: the same
328    /// [`cancellation`](Self::cancellation) and [`stop`](Self::stop) tokens (so
329    /// cancelling or gracefully stopping the parent also ends the child), the same
330    /// [`deadline`](Self::deadline), and the same [`token_budget`](Self::token_budget)
331    /// bound backed by the *same* accounting handle — a child's reported usage
332    /// adds to the parent's running total, so parent and child together trip one
333    /// shared bound rather than each getting an independent one.
334    ///
335    /// [`retry_budget`](Self::retry_budget) and
336    /// [`provider_retry`](Self::provider_retry) carry too, for a different
337    /// reason: they are not an allowance either run spends but a description of
338    /// the provider both of them dial. How long that endpoint's rate-limit
339    /// window lasts does not change because the caller delegated, so a child
340    /// that reset them would meet the same limiter with the schedule the parent
341    /// had already found too short — and a host would have to restate its own
342    /// policy at every delegation boundary to prevent it. Unlike the bounds
343    /// above they aggregate nothing: a child's retries are its own, and being
344    /// patient costs the parent none of them. `deadline` and `cancellation`,
345    /// which do carry, remain the bound on how long all that patience may take.
346    ///
347    /// [`locals`](Self::locals) carry as well: they say who the run works
348    /// for, and a delegated run works for the same caller, so a tool the child
349    /// calls must read what the parent's tools read.
350    ///
351    /// The rest (`tool_budget`, `model_budget`, `round_strategy`) resets to
352    /// [`RunOptions::default`]: those express per-run policy a child sets
353    /// independently, bounding work it does rather than describing what it
354    /// talks to.
355    ///
356    /// [`early_end`](Self::early_end) resets too, for a different reason — it
357    /// records a decision rather than carrying a bound. A child that ends on the
358    /// shared budget ended *its own* run at *its own* boundary; the parent then
359    /// reaches its next boundary and records for itself, so keeping the slots
360    /// apart loses nothing, while sharing one would let a child's ending be read
361    /// as the parent's — including on a parent that went on to finish its work
362    /// normally.
363    ///
364    /// mentra applies this itself on exactly one path: the `task` intrinsic's
365    /// delegated subagent runs on the parent run's derived child options, so a
366    /// model that delegates work cannot spend outside the bounds its own run was
367    /// given. Every other subagent path is host-driven — call this when
368    /// threading `RunOptions` into a subagent's or delegated run's own
369    /// `Agent::run`/`resume` call, including through
370    /// [`Session::spawn_subagent_with_options`](crate::Session::spawn_subagent_with_options)
371    /// and, for a custom tool that spawns its own subagent,
372    /// [`ToolContext::child_run_options`](crate::tool::ToolContext::child_run_options).
373    pub fn child(&self) -> RunOptions {
374        RunOptions {
375            cancellation: self.cancellation.clone(),
376            stop: self.stop.clone(),
377            deadline: self.deadline,
378            token_budget: self.token_budget,
379            token_usage: Arc::clone(&self.token_usage),
380            retry_budget: self.retry_budget,
381            provider_retry: self.provider_retry,
382            locals: self.locals.clone(),
383            ..RunOptions::default()
384        }
385    }
386
387    /// Cumulative `input_tokens + output_tokens` reported so far against
388    /// [`token_budget`](Self::token_budget), aggregated across this run and any
389    /// [`child`](Self::child) run sharing this handle.
390    pub fn reported_tokens(&self) -> u64 {
391        self.token_usage.load(Ordering::SeqCst)
392    }
393
394    pub(crate) fn record_tokens(&self, tokens: u64) {
395        self.token_usage.fetch_add(tokens, Ordering::SeqCst);
396    }
397
398    /// Why the run ended early, or `None` when none did — the model finished, or
399    /// the run failed outright and reported that as an error.
400    ///
401    /// Read from a clone: [`Agent::run`](crate::Agent::run) takes its options by
402    /// value, so a caller that wants this keeps a `clone()` of what it passed
403    /// in. The slot is shared behind an `Arc`, exactly as the counter behind
404    /// [`reported_tokens`](Self::reported_tokens) is.
405    pub fn ended_early(&self) -> Option<EarlyEnd> {
406        self.early_end.get().copied()
407    }
408
409    /// Records why the runner is ending this run early, keeping the first answer
410    /// if one is already there. See [`early_end`](Self::early_end) for why the
411    /// first is the one that stays true.
412    pub(crate) fn record_early_end(&self, end: EarlyEnd) {
413        let _ = self.early_end.set(end);
414    }
415
416    /// Whether cumulative reported usage has reached or exceeded
417    /// [`token_budget`](Self::token_budget). `false` when no bound is set.
418    pub(crate) fn token_budget_exceeded(&self) -> bool {
419        self.token_budget
420            .is_some_and(|budget| self.reported_tokens() >= budget)
421    }
422
423    pub(crate) fn check_limits(&self) -> Result<(), RuntimeError> {
424        if self
425            .cancellation
426            .as_ref()
427            .is_some_and(CancellationToken::is_cancelled)
428        {
429            return Err(RuntimeError::Cancelled);
430        }
431
432        if self
433            .deadline
434            .is_some_and(|deadline| SystemTime::now() >= deadline)
435        {
436            return Err(RuntimeError::DeadlineExceeded);
437        }
438
439        Ok(())
440    }
441
442    /// Whether a graceful stop has been requested via [`stop`](Self::stop). The
443    /// runner checks this at each round boundary, where the transcript is at a
444    /// consistent point, and ends the run successfully when it is set.
445    pub(crate) fn stop_requested(&self) -> bool {
446        self.stop
447            .as_ref()
448            .is_some_and(CancellationToken::is_cancelled)
449    }
450
451    pub(crate) fn tool_budget(&self) -> usize {
452        self.tool_budget.unwrap_or(usize::MAX)
453    }
454
455    pub(crate) fn model_budget(&self) -> usize {
456        self.model_budget.unwrap_or(usize::MAX)
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn a_child_run_carries_its_parents_locals() {
466        #[derive(Debug, PartialEq)]
467        struct Caller(&'static str);
468        let parent = RunOptions::default().with_locals(RunLocals::new().with(Caller("nous")));
469
470        let child = parent.child();
471
472        assert_eq!(
473            child.locals.get::<Caller>().as_deref(),
474            Some(&Caller("nous"))
475        );
476    }
477
478    /// What the runner waited before each retry before this type existed:
479    /// 500 ms doubling to a 5 s ceiling. Pinned so a future edit to the
480    /// defaults has to be a deliberate one.
481    #[test]
482    fn the_default_schedule_is_the_one_mentra_has_always_used() {
483        let retry = ProviderRetry::default();
484
485        let delays: Vec<Duration> = (1..=8)
486            .map(|attempt| retry.scheduled_delay(attempt))
487            .collect();
488
489        assert_eq!(
490            delays,
491            vec![
492                Duration::from_millis(500),
493                Duration::from_secs(1),
494                Duration::from_secs(2),
495                Duration::from_secs(4),
496                Duration::from_secs(5),
497                Duration::from_secs(5),
498                Duration::from_secs(5),
499                Duration::from_secs(5),
500            ]
501        );
502    }
503
504    #[test]
505    fn a_host_schedule_doubles_from_its_own_base_to_its_own_ceiling() {
506        let retry = ProviderRetry {
507            base_delay: Duration::from_secs(2),
508            max_delay: Duration::from_secs(10),
509            ..ProviderRetry::default()
510        };
511
512        let delays: Vec<Duration> = (1..=5)
513            .map(|attempt| retry.scheduled_delay(attempt))
514            .collect();
515
516        assert_eq!(
517            delays,
518            vec![
519                Duration::from_secs(2),
520                Duration::from_secs(4),
521                Duration::from_secs(8),
522                Duration::from_secs(10),
523                Duration::from_secs(10),
524            ]
525        );
526    }
527
528    #[test]
529    fn a_long_budget_does_not_overflow_the_doubling() {
530        // The factor is a `u32`; the old code clamped the shift to `usize`'s
531        // width, so a host generous enough to allow a 64th attempt got a panic
532        // instead of a delay. Unreachable at a budget of five, reachable the
533        // moment raising the budget is the supported thing to do.
534        let retry = ProviderRetry::default();
535
536        assert_eq!(retry.scheduled_delay(usize::MAX), retry.max_delay);
537        assert_eq!(retry.scheduled_delay(64), retry.max_delay);
538    }
539
540    #[test]
541    fn a_server_that_names_a_longer_wait_gets_it() {
542        let retry = ProviderRetry::default();
543
544        // Attempt 1's own schedule is 500 ms; the limit lasts a minute.
545        assert_eq!(
546            retry.delay_for(1, Some(Duration::from_secs(45))),
547            Duration::from_secs(45)
548        );
549    }
550
551    #[test]
552    fn a_server_that_names_a_shorter_wait_does_not_shorten_the_schedule() {
553        // `Retry-After` is the provider's floor on when it will answer, not a
554        // licence to hammer it sooner than the host chose to.
555        let retry = ProviderRetry {
556            base_delay: Duration::from_secs(5),
557            ..ProviderRetry::default()
558        };
559
560        assert_eq!(
561            retry.delay_for(1, Some(Duration::from_secs(1))),
562            Duration::from_secs(5)
563        );
564    }
565
566    #[test]
567    fn a_server_cannot_park_the_run_for_an_hour() {
568        let retry = ProviderRetry::default();
569
570        assert_eq!(
571            retry.delay_for(1, Some(Duration::from_secs(3600))),
572            retry.retry_after_cap,
573            "the header is clamped before it is considered"
574        );
575        assert_eq!(retry.retry_after_cap, Duration::from_secs(60));
576    }
577
578    #[test]
579    fn the_cap_bounds_the_server_and_never_the_host() {
580        // A host that chose to wait five minutes between attempts keeps that
581        // schedule; the cap exists to bound a remote party, not the caller.
582        let retry = ProviderRetry {
583            base_delay: Duration::from_secs(300),
584            max_delay: Duration::from_secs(300),
585            retry_after_cap: Duration::from_secs(60),
586        };
587
588        assert_eq!(retry.delay_for(1, None), Duration::from_secs(300));
589        assert_eq!(
590            retry.delay_for(1, Some(Duration::from_secs(3600))),
591            Duration::from_secs(300)
592        );
593    }
594
595    #[test]
596    fn a_silent_provider_leaves_the_schedule_alone() {
597        let retry = ProviderRetry::default();
598
599        assert_eq!(retry.delay_for(3, None), retry.scheduled_delay(3));
600    }
601
602    #[test]
603    fn a_default_run_carries_the_default_schedule() {
604        assert_eq!(
605            RunOptions::default().provider_retry,
606            ProviderRetry::default()
607        );
608        assert_eq!(RunOptions::default().retry_budget, 5);
609    }
610
611    #[test]
612    fn a_delegated_run_meets_the_same_provider_with_the_same_patience() {
613        // A child dials the endpoint its parent dialled, and that endpoint's
614        // rate-limit window did not shorten because the work was delegated.
615        // Resetting here would hand the subagent the blip-shaped schedule the
616        // parent had already found too short against the same limiter.
617        let parent = RunOptions {
618            retry_budget: 9,
619            ..RunOptions::default()
620        }
621        .with_provider_retry(ProviderRetry {
622            base_delay: Duration::from_secs(2),
623            max_delay: Duration::from_secs(30),
624            ..ProviderRetry::default()
625        });
626
627        let child = parent.child();
628
629        assert_eq!(child.provider_retry, parent.provider_retry);
630        assert_eq!(child.retry_budget, 9);
631        assert_eq!(
632            child.model_budget, None,
633            "what the child does not inherit is an allowance for its own work"
634        );
635    }
636
637    #[test]
638    fn a_cancellation_token_shows_whether_it_was_tripped() {
639        let token = CancellationToken::default();
640        assert!(format!("{token:?}").contains("cancelled: false"));
641
642        token.cancel();
643        assert!(format!("{token:?}").contains("cancelled: true"));
644    }
645
646    #[test]
647    fn a_clone_of_a_runs_options_reads_what_that_run_recorded() {
648        // The mechanism an embedder depends on: `Agent::run` takes its options
649        // by value, so the only way to hear back from a run is to have kept a
650        // clone — which shares the slot, exactly as it shares the counter.
651        let options = RunOptions {
652            token_budget: Some(100),
653            ..RunOptions::default()
654        };
655        let held = options.clone();
656
657        options.record_early_end(EarlyEnd::TokenBudget);
658
659        assert_eq!(held.ended_early(), Some(EarlyEnd::TokenBudget));
660    }
661
662    #[test]
663    fn a_child_run_records_its_early_end_apart_from_its_parent() {
664        // A delegated run that ends on the shared budget has ended its own run,
665        // not its parent's. The parent reaches its own next boundary and records
666        // there; until it does, claiming it ended early would be a guess — and a
667        // wrong one for a parent that goes on to finish its work.
668        let parent = RunOptions {
669            token_budget: Some(100),
670            ..RunOptions::default()
671        };
672        let child = parent.child();
673
674        child.record_early_end(EarlyEnd::TokenBudget);
675
676        assert_eq!(child.ended_early(), Some(EarlyEnd::TokenBudget));
677        assert_eq!(parent.ended_early(), None);
678        child.record_tokens(60);
679        assert_eq!(
680            parent.reported_tokens(),
681            60,
682            "what the two do share is the accounting, unchanged"
683        );
684    }
685
686    #[test]
687    fn the_first_early_end_recorded_is_the_one_that_stays() {
688        // Both conditions are sticky under a fixed bound, so a handle reused for
689        // a second turn ends the same way it ended the first. Keeping the first
690        // answer makes that explicit rather than depending on it.
691        let options = RunOptions::default();
692
693        options.record_early_end(EarlyEnd::StopRequested);
694        options.record_early_end(EarlyEnd::TokenBudget);
695
696        assert_eq!(options.ended_early(), Some(EarlyEnd::StopRequested));
697    }
698}