use std::time::{Duration, SystemTime};
use mentra::runtime::{CancellationToken, RunOptions};
use super::RunError;
use crate::budget::BudgetPool;
#[derive(Debug, Clone, Default)]
pub struct TurnOptions {
pub cancel: Option<CancellationToken>,
pub stop: Option<CancellationToken>,
pub deadline: Option<Duration>,
pub tool_budget: Option<usize>,
pub token_budget: Option<u64>,
pub budget: Option<BudgetPool>,
}
impl TurnOptions {
pub fn cancellable() -> (Self, CancellationToken) {
let token = CancellationToken::default();
(
Self {
cancel: Some(token.clone()),
..Self::default()
},
token,
)
}
pub fn stoppable() -> (Self, CancellationToken) {
let token = CancellationToken::default();
(
Self {
stop: Some(token.clone()),
..Self::default()
},
token,
)
}
pub fn with_cancel(self, cancel: CancellationToken) -> Self {
Self {
cancel: Some(cancel),
..self
}
}
pub fn with_stop(self, stop: CancellationToken) -> Self {
Self {
stop: Some(stop),
..self
}
}
pub fn with_deadline(self, deadline: Duration) -> Self {
Self {
deadline: Some(deadline),
..self
}
}
pub fn with_tool_budget(self, tool_budget: usize) -> Self {
Self {
tool_budget: Some(tool_budget),
..self
}
}
pub fn with_token_budget(self, token_budget: u64) -> Self {
Self {
token_budget: Some(token_budget),
..self
}
}
pub fn with_budget(self, budget: BudgetPool) -> Self {
Self {
budget: Some(budget),
..self
}
}
pub(super) fn into_run_options(self) -> RunOptions {
let options = RunOptions {
cancellation: self.cancel,
stop: self.stop,
deadline: self.deadline.map(|after| SystemTime::now() + after),
tool_budget: self.tool_budget,
token_budget: self.token_budget,
..RunOptions::default()
};
match self.budget {
Some(pool) => RunOptions {
token_budget: Some(pool.turn_bound(self.token_budget)),
token_usage: pool.counter(),
..options
},
None => options,
}
}
}
pub(super) fn bounded(options: TurnOptions, bounds: &TurnOptions) -> TurnOptions {
let budget = options.budget.clone().or_else(|| bounds.budget.clone());
TurnOptions {
deadline: options.deadline.or(bounds.deadline),
tool_budget: options.tool_budget.or(bounds.tool_budget),
token_budget: options.token_budget.or(bounds.token_budget),
budget,
..options
}
}
pub(super) fn drawable(options: &TurnOptions) -> Result<(), RunError> {
let Some(pool) = &options.budget else {
return Ok(());
};
if pool.is_exhausted() {
return Err(RunError::BudgetExhausted {
limit: pool.limit(),
spent: pool.spent(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run::RunUsage;
#[test]
fn attaching_a_token_does_not_unbound_a_configured_run() {
let configured = TurnOptions::default()
.with_deadline(Duration::from_secs(600))
.with_tool_budget(12);
let (options, token) = TurnOptions::cancellable();
let merged = bounded(options, &configured);
assert_eq!(merged.deadline, Some(Duration::from_secs(600)));
assert_eq!(merged.tool_budget, Some(12));
assert!(merged.cancel.is_some(), "the token still arrives");
assert!(!token.is_cancelled());
}
#[test]
fn stopping_and_cancelling_are_different_signals() {
let (cancellable, cancel) = TurnOptions::cancellable();
let (stoppable, stop) = TurnOptions::stoppable();
assert!(cancellable.cancel.is_some() && cancellable.stop.is_none());
assert!(stoppable.stop.is_some() && stoppable.cancel.is_none());
cancel.cancel();
assert!(
!stop.is_cancelled(),
"one turn's stop button is not another's"
);
}
#[test]
fn a_turn_can_carry_both_signals_at_once() {
let (options, cancel) = TurnOptions::cancellable();
let stop = CancellationToken::default();
let both = options.with_stop(stop.clone());
assert!(
both.cancel.is_some(),
"the first signal survives the second"
);
assert!(both.stop.is_some());
assert!(!cancel.is_cancelled() && !stop.is_cancelled());
}
#[test]
fn attaching_a_token_returns_a_new_value() {
let base = TurnOptions::default();
let armed = base.clone().with_cancel(CancellationToken::default());
assert!(base.cancel.is_none(), "the original must be untouched");
assert!(armed.cancel.is_some());
}
#[test]
fn an_explicit_bound_wins_over_the_configured_one() {
let configured = TurnOptions::default().with_deadline(Duration::from_secs(600));
let explicit = TurnOptions::default().with_deadline(Duration::from_secs(30));
assert_eq!(
bounded(explicit, &configured).deadline,
Some(Duration::from_secs(30))
);
}
#[test]
fn a_prepared_run_is_unbounded_until_it_is_bounded() {
let unset = TurnOptions::default();
assert_eq!(bounded(TurnOptions::default(), &unset).deadline, None);
assert_eq!(bounded(TurnOptions::default(), &unset).tool_budget, None);
assert_eq!(bounded(TurnOptions::default(), &unset).token_budget, None);
assert!(bounded(TurnOptions::default(), &unset).budget.is_none());
}
#[test]
fn a_pool_bounds_the_turn_at_the_whole_jobs_allowance() {
let pool = BudgetPool::new(500_000);
let options = TurnOptions::default().with_budget(pool.clone());
assert_eq!(options.into_run_options().token_budget, Some(500_000));
}
#[test]
fn a_pooled_turn_reports_into_the_pools_own_counter() {
let pool = BudgetPool::new(1_000);
let run_options = TurnOptions::default()
.with_budget(pool.clone())
.into_run_options();
pool.record(RunUsage {
input_tokens: 300,
..RunUsage::default()
});
assert_eq!(
run_options.reported_tokens(),
300,
"mentra reads the spending the pool records, and the reverse"
);
}
#[test]
fn an_unpooled_turn_gets_a_counter_of_its_own() {
let first = TurnOptions::default()
.with_token_budget(100)
.into_run_options();
let second = TurnOptions::default()
.with_token_budget(100)
.into_run_options();
assert!(!std::sync::Arc::ptr_eq(
&first.token_usage,
&second.token_usage
));
}
#[test]
fn a_per_turn_cap_and_a_pool_both_bind() {
let pool = BudgetPool::new(500_000);
pool.record(RunUsage {
input_tokens: 200_000,
..RunUsage::default()
});
let capped = TurnOptions::default()
.with_budget(pool.clone())
.with_token_budget(50_000);
assert_eq!(capped.into_run_options().token_budget, Some(250_000));
let generous = TurnOptions::default()
.with_budget(pool)
.with_token_budget(u64::MAX);
assert_eq!(generous.into_run_options().token_budget, Some(500_000));
}
#[test]
fn attaching_a_token_does_not_detach_the_pool() {
let pool = BudgetPool::new(1_000);
let configured = TurnOptions::default().with_budget(pool.clone());
let (options, _token) = TurnOptions::stoppable();
assert_eq!(bounded(options, &configured).budget, Some(pool));
}
#[test]
fn an_explicit_pool_wins_over_the_configured_one() {
let configured = TurnOptions::default().with_budget(BudgetPool::new(1_000));
let explicit = BudgetPool::new(50);
let merged = bounded(
TurnOptions::default().with_budget(explicit.clone()),
&configured,
);
assert_eq!(merged.budget, Some(explicit));
}
#[test]
fn a_turn_on_a_spent_pool_is_refused_rather_than_sent() {
let pool = BudgetPool::new(100);
let options = TurnOptions::default().with_budget(pool.clone());
assert!(drawable(&options).is_ok(), "a full pool draws");
pool.record(RunUsage {
input_tokens: 120,
..RunUsage::default()
});
let refused = drawable(&options).expect_err("a spent pool refuses");
assert!(matches!(
refused,
RunError::BudgetExhausted {
limit: 100,
spent: 120
}
));
}
#[test]
fn a_turn_with_no_pool_is_always_drawable() {
assert!(drawable(&TurnOptions::default().with_token_budget(0)).is_ok());
}
}