use std::sync::{
Arc, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
};
use std::time::{Duration, SystemTime};
use crate::runtime::error::RuntimeError;
const DEFAULT_PROVIDER_RETRY_BUDGET: usize = 5;
const DEFAULT_PROVIDER_RETRY_BASE_DELAY: Duration = Duration::from_millis(500);
const DEFAULT_PROVIDER_RETRY_MAX_DELAY: Duration = Duration::from_secs(5);
const DEFAULT_PROVIDER_RETRY_AFTER_CAP: Duration = Duration::from_secs(60);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProviderRetry {
pub base_delay: Duration,
pub max_delay: Duration,
pub retry_after_cap: Duration,
}
impl Default for ProviderRetry {
fn default() -> Self {
Self {
base_delay: DEFAULT_PROVIDER_RETRY_BASE_DELAY,
max_delay: DEFAULT_PROVIDER_RETRY_MAX_DELAY,
retry_after_cap: DEFAULT_PROVIDER_RETRY_AFTER_CAP,
}
}
}
impl ProviderRetry {
pub fn scheduled_delay(&self, attempt: usize) -> Duration {
let shift = attempt.saturating_sub(1).min(u32::BITS as usize - 1) as u32;
let factor = 1u32 << shift;
self.base_delay
.checked_mul(factor)
.unwrap_or(self.max_delay)
.min(self.max_delay)
}
pub fn delay_for(&self, attempt: usize, retry_after: Option<Duration>) -> Duration {
let scheduled = self.scheduled_delay(attempt);
match retry_after {
Some(requested) => scheduled.max(requested.min(self.retry_after_cap)),
None => scheduled,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum EarlyEnd {
StopRequested,
TokenBudget,
}
#[derive(Clone, Default)]
pub struct CancellationToken {
cancelled: Arc<AtomicBool>,
}
impl std::fmt::Debug for CancellationToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CancellationToken")
.field("cancelled", &self.is_cancelled())
.finish()
}
}
impl CancellationToken {
pub fn cancel(&self) {
self.cancelled.store(true, Ordering::SeqCst);
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::SeqCst)
}
}
#[derive(Clone)]
pub struct RunOptions {
pub cancellation: Option<CancellationToken>,
pub stop: Option<CancellationToken>,
pub deadline: Option<SystemTime>,
pub retry_budget: usize,
pub provider_retry: ProviderRetry,
pub tool_budget: Option<usize>,
pub model_budget: Option<usize>,
pub round_strategy: Option<Arc<dyn crate::agent::RoundStrategy>>,
pub token_budget: Option<u64>,
pub token_usage: Arc<AtomicU64>,
pub early_end: Arc<OnceLock<EarlyEnd>>,
}
impl Default for RunOptions {
fn default() -> Self {
Self {
cancellation: None,
stop: None,
deadline: None,
retry_budget: DEFAULT_PROVIDER_RETRY_BUDGET,
provider_retry: ProviderRetry::default(),
tool_budget: None,
model_budget: None,
round_strategy: None,
token_budget: None,
token_usage: Arc::new(AtomicU64::new(0)),
early_end: Arc::new(OnceLock::new()),
}
}
}
impl RunOptions {
pub fn with_round_strategy(mut self, strategy: Arc<dyn crate::agent::RoundStrategy>) -> Self {
self.round_strategy = Some(strategy);
self
}
pub fn with_provider_retry(mut self, provider_retry: ProviderRetry) -> Self {
self.provider_retry = provider_retry;
self
}
pub fn child(&self) -> RunOptions {
RunOptions {
cancellation: self.cancellation.clone(),
stop: self.stop.clone(),
deadline: self.deadline,
token_budget: self.token_budget,
token_usage: Arc::clone(&self.token_usage),
retry_budget: self.retry_budget,
provider_retry: self.provider_retry,
..RunOptions::default()
}
}
pub fn reported_tokens(&self) -> u64 {
self.token_usage.load(Ordering::SeqCst)
}
pub(crate) fn record_tokens(&self, tokens: u64) {
self.token_usage.fetch_add(tokens, Ordering::SeqCst);
}
pub fn ended_early(&self) -> Option<EarlyEnd> {
self.early_end.get().copied()
}
pub(crate) fn record_early_end(&self, end: EarlyEnd) {
let _ = self.early_end.set(end);
}
pub(crate) fn token_budget_exceeded(&self) -> bool {
self.token_budget
.is_some_and(|budget| self.reported_tokens() >= budget)
}
pub(crate) fn check_limits(&self) -> Result<(), RuntimeError> {
if self
.cancellation
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(RuntimeError::Cancelled);
}
if self
.deadline
.is_some_and(|deadline| SystemTime::now() >= deadline)
{
return Err(RuntimeError::DeadlineExceeded);
}
Ok(())
}
pub(crate) fn stop_requested(&self) -> bool {
self.stop
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
}
pub(crate) fn tool_budget(&self) -> usize {
self.tool_budget.unwrap_or(usize::MAX)
}
pub(crate) fn model_budget(&self) -> usize {
self.model_budget.unwrap_or(usize::MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_schedule_is_the_one_mentra_has_always_used() {
let retry = ProviderRetry::default();
let delays: Vec<Duration> = (1..=8)
.map(|attempt| retry.scheduled_delay(attempt))
.collect();
assert_eq!(
delays,
vec![
Duration::from_millis(500),
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
Duration::from_secs(5),
]
);
}
#[test]
fn a_host_schedule_doubles_from_its_own_base_to_its_own_ceiling() {
let retry = ProviderRetry {
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(10),
..ProviderRetry::default()
};
let delays: Vec<Duration> = (1..=5)
.map(|attempt| retry.scheduled_delay(attempt))
.collect();
assert_eq!(
delays,
vec![
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(10),
Duration::from_secs(10),
]
);
}
#[test]
fn a_long_budget_does_not_overflow_the_doubling() {
let retry = ProviderRetry::default();
assert_eq!(retry.scheduled_delay(usize::MAX), retry.max_delay);
assert_eq!(retry.scheduled_delay(64), retry.max_delay);
}
#[test]
fn a_server_that_names_a_longer_wait_gets_it() {
let retry = ProviderRetry::default();
assert_eq!(
retry.delay_for(1, Some(Duration::from_secs(45))),
Duration::from_secs(45)
);
}
#[test]
fn a_server_that_names_a_shorter_wait_does_not_shorten_the_schedule() {
let retry = ProviderRetry {
base_delay: Duration::from_secs(5),
..ProviderRetry::default()
};
assert_eq!(
retry.delay_for(1, Some(Duration::from_secs(1))),
Duration::from_secs(5)
);
}
#[test]
fn a_server_cannot_park_the_run_for_an_hour() {
let retry = ProviderRetry::default();
assert_eq!(
retry.delay_for(1, Some(Duration::from_secs(3600))),
retry.retry_after_cap,
"the header is clamped before it is considered"
);
assert_eq!(retry.retry_after_cap, Duration::from_secs(60));
}
#[test]
fn the_cap_bounds_the_server_and_never_the_host() {
let retry = ProviderRetry {
base_delay: Duration::from_secs(300),
max_delay: Duration::from_secs(300),
retry_after_cap: Duration::from_secs(60),
};
assert_eq!(retry.delay_for(1, None), Duration::from_secs(300));
assert_eq!(
retry.delay_for(1, Some(Duration::from_secs(3600))),
Duration::from_secs(300)
);
}
#[test]
fn a_silent_provider_leaves_the_schedule_alone() {
let retry = ProviderRetry::default();
assert_eq!(retry.delay_for(3, None), retry.scheduled_delay(3));
}
#[test]
fn a_default_run_carries_the_default_schedule() {
assert_eq!(
RunOptions::default().provider_retry,
ProviderRetry::default()
);
assert_eq!(RunOptions::default().retry_budget, 5);
}
#[test]
fn a_delegated_run_meets_the_same_provider_with_the_same_patience() {
let parent = RunOptions {
retry_budget: 9,
..RunOptions::default()
}
.with_provider_retry(ProviderRetry {
base_delay: Duration::from_secs(2),
max_delay: Duration::from_secs(30),
..ProviderRetry::default()
});
let child = parent.child();
assert_eq!(child.provider_retry, parent.provider_retry);
assert_eq!(child.retry_budget, 9);
assert_eq!(
child.model_budget, None,
"what the child does not inherit is an allowance for its own work"
);
}
#[test]
fn a_cancellation_token_shows_whether_it_was_tripped() {
let token = CancellationToken::default();
assert!(format!("{token:?}").contains("cancelled: false"));
token.cancel();
assert!(format!("{token:?}").contains("cancelled: true"));
}
#[test]
fn a_clone_of_a_runs_options_reads_what_that_run_recorded() {
let options = RunOptions {
token_budget: Some(100),
..RunOptions::default()
};
let held = options.clone();
options.record_early_end(EarlyEnd::TokenBudget);
assert_eq!(held.ended_early(), Some(EarlyEnd::TokenBudget));
}
#[test]
fn a_child_run_records_its_early_end_apart_from_its_parent() {
let parent = RunOptions {
token_budget: Some(100),
..RunOptions::default()
};
let child = parent.child();
child.record_early_end(EarlyEnd::TokenBudget);
assert_eq!(child.ended_early(), Some(EarlyEnd::TokenBudget));
assert_eq!(parent.ended_early(), None);
child.record_tokens(60);
assert_eq!(
parent.reported_tokens(),
60,
"what the two do share is the accounting, unchanged"
);
}
#[test]
fn the_first_early_end_recorded_is_the_one_that_stays() {
let options = RunOptions::default();
options.record_early_end(EarlyEnd::StopRequested);
options.record_early_end(EarlyEnd::TokenBudget);
assert_eq!(options.ended_early(), Some(EarlyEnd::StopRequested));
}
}