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}