use std::time::Duration;
#[derive(Debug, Clone, PartialEq)]
pub struct Budget {
pub max_steps: usize,
pub max_tool_calls: usize,
pub max_tokens: u32,
}
impl Default for Budget {
fn default() -> Self {
Self {
max_steps: crate::mcp::toolcall::MAX_TOOL_ITERATIONS,
max_tool_calls: 12,
max_tokens: 8192,
}
}
}
impl Budget {
pub fn new(max_steps: usize, max_tool_calls: usize, max_tokens: u32) -> Self {
Self {
max_steps: max_steps.max(1),
max_tool_calls: max_tool_calls.max(1),
max_tokens: max_tokens.max(1),
}
}
pub fn from_env() -> Self {
let d = Self::default();
Self {
max_steps: env_usize("GHOSTLINK_AGENT_MAX_STEPS", d.max_steps),
max_tool_calls: env_usize("GHOSTLINK_AGENT_MAX_TOOL_CALLS", d.max_tool_calls),
max_tokens: env_u32("GHOSTLINK_AGENT_MAX_TOKENS", d.max_tokens),
}
}
}
fn env_usize(key: &str, default: usize) -> usize {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(default)
.max(1)
}
fn env_u32(key: &str, default: u32) -> u32 {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(default)
.max(1)
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct BudgetSpend {
steps_used: usize,
tool_calls_used: usize,
tokens_used: u32,
tool_calls_blocked: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
FinalAnswer,
StepsExhausted,
ToolCallsExhausted,
TokensExhausted,
}
impl StopReason {
pub fn as_str(self) -> &'static str {
match self {
Self::FinalAnswer => "final_answer",
Self::StepsExhausted => "steps_exhausted",
Self::ToolCallsExhausted => "tool_calls_exhausted",
Self::TokensExhausted => "tokens_exhausted",
}
}
pub fn classify_end(reason: Option<StopReason>) -> StopReason {
reason.unwrap_or(StopReason::FinalAnswer)
}
pub fn is_exhausted(self) -> bool {
matches!(
self,
Self::StepsExhausted | Self::ToolCallsExhausted | Self::TokensExhausted
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BudgetTracker {
budget: Budget,
spend: BudgetSpend,
}
impl BudgetTracker {
pub fn new(budget: Budget) -> Self {
Self {
budget,
spend: BudgetSpend::default(),
}
}
pub fn can_step(&self) -> bool {
!self.spend.tool_calls_blocked
&& self.spend.steps_used < self.budget.max_steps
&& self.spend.tool_calls_used < self.budget.max_tool_calls
&& self.spend.tokens_used < self.budget.max_tokens
}
pub fn begin_step(&mut self) -> bool {
if !self.can_step() {
return false;
}
self.spend.steps_used += 1;
true
}
pub fn charge_tokens(&mut self, tokens: u32) {
self.spend.tokens_used = self.spend.tokens_used.saturating_add(tokens);
}
pub fn charge_tool_calls(&mut self, count: usize) -> bool {
if self.spend.tool_calls_used.saturating_add(count) > self.budget.max_tool_calls {
self.spend.tool_calls_blocked = true;
return false;
}
self.spend.tool_calls_used += count;
true
}
pub fn stop_reason(&self) -> Option<StopReason> {
if self.spend.steps_used >= self.budget.max_steps {
return Some(StopReason::StepsExhausted);
}
if self.spend.tool_calls_blocked || self.spend.tool_calls_used >= self.budget.max_tool_calls
{
return Some(StopReason::ToolCallsExhausted);
}
if self.spend.tokens_used >= self.budget.max_tokens {
return Some(StopReason::TokensExhausted);
}
None
}
pub fn steps_used(&self) -> usize {
self.spend.steps_used
}
pub fn tool_calls_used(&self) -> usize {
self.spend.tool_calls_used
}
pub fn tokens_used(&self) -> u32 {
self.spend.tokens_used
}
pub fn budget(&self) -> &Budget {
&self.budget
}
}
pub fn exhaustion_message(reason: StopReason, tracker: &BudgetTracker) -> String {
let b = tracker.budget();
match reason {
StopReason::StepsExhausted => format!(
"(stopped: step budget of {} exhausted after {} step(s) and {} tool call(s); \
raise GHOSTLINK_AGENT_MAX_STEPS if this task legitimately needs more)",
b.max_steps,
tracker.steps_used(),
tracker.tool_calls_used()
),
StopReason::ToolCallsExhausted => format!(
"(stopped: tool-call budget of {} exhausted after {} call(s) across {} step(s); \
raise GHOSTLINK_AGENT_MAX_TOOL_CALLS if this task legitimately needs more)",
b.max_tool_calls,
tracker.tool_calls_used(),
tracker.steps_used()
),
StopReason::TokensExhausted => format!(
"(stopped: token budget of {} exhausted after {} token(s); \
raise GHOSTLINK_AGENT_MAX_TOKENS if this task legitimately needs more)",
b.max_tokens,
tracker.tokens_used()
),
other => format!("(stopped: {})", other.as_str()),
}
}
pub fn turn_timeout() -> Option<Duration> {
let secs = std::env::var("GHOSTLINK_AGENT_TURN_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(0);
(secs > 0).then(|| Duration::from_secs(secs))
}
#[cfg(test)]
mod tests {
use super::*;
fn tracker() -> BudgetTracker {
BudgetTracker::new(Budget::new(3, 5, 100))
}
#[test]
fn default_step_cap_matches_the_legacy_iteration_limit() {
assert_eq!(
Budget::default().max_steps,
crate::mcp::toolcall::MAX_TOOL_ITERATIONS
);
}
#[test]
fn a_fresh_tracker_can_step() {
let mut t = tracker();
assert!(t.can_step());
assert!(t.begin_step());
assert_eq!(t.steps_used(), 1);
}
#[test]
fn steps_are_capped() {
let mut t = tracker();
for _ in 0..3 {
assert!(t.begin_step(), "should allow up to max_steps");
}
assert!(!t.can_step());
assert!(!t.begin_step(), "must refuse a step past the cap");
assert_eq!(t.steps_used(), 3, "a refused step must not be counted");
}
#[test]
fn tool_call_cap_stops_a_batch_that_would_overflow() {
let mut t = tracker();
assert!(t.charge_tool_calls(4));
assert!(!t.charge_tool_calls(2));
assert_eq!(
t.tool_calls_used(),
4,
"a refused batch must not be counted"
);
assert_eq!(
t.stop_reason(),
Some(StopReason::ToolCallsExhausted),
"a refused batch is still a stop"
);
}
#[test]
fn tokens_are_capped_and_saturate() {
let mut t = tracker();
t.charge_tokens(60);
assert!(t.can_step());
t.charge_tokens(40);
assert_eq!(t.stop_reason(), Some(StopReason::TokensExhausted));
t.charge_tokens(u32::MAX);
assert_eq!(t.tokens_used(), u32::MAX);
assert_eq!(t.stop_reason(), Some(StopReason::TokensExhausted));
}
#[test]
fn steps_are_reported_before_the_other_limits() {
let mut t = BudgetTracker::new(Budget::new(2, 2, 10_000));
t.begin_step();
t.begin_step();
t.charge_tool_calls(2); assert_eq!(t.stop_reason(), Some(StopReason::StepsExhausted));
}
#[test]
fn a_refused_batch_stops_the_turn_even_though_spend_is_below_the_cap() {
let mut t = BudgetTracker::new(Budget::new(9, 5, 10_000));
assert!(t.charge_tool_calls(4));
assert!(!t.charge_tool_calls(2));
assert_eq!(t.tool_calls_used(), 4);
assert_eq!(t.stop_reason(), Some(StopReason::ToolCallsExhausted));
assert!(!t.can_step(), "a refused batch must also halt stepping");
}
#[test]
fn exhaustion_message_names_the_limit_and_the_actual_use() {
let mut t = tracker();
t.begin_step();
t.begin_step();
t.begin_step();
let msg = exhaustion_message(StopReason::StepsExhausted, &t);
assert!(msg.contains("step budget of 3"));
assert!(msg.contains("3 step(s)"));
assert!(msg.contains("GHOSTLINK_AGENT_MAX_STEPS"));
}
#[test]
fn exhaustion_message_distinguishes_the_three_limits() {
let mut calls = BudgetTracker::new(Budget::new(99, 1, 10_000));
calls.charge_tool_calls(1);
let msg = exhaustion_message(StopReason::ToolCallsExhausted, &calls);
assert!(msg.contains("tool-call budget"));
assert!(msg.contains("GHOSTLINK_AGENT_MAX_TOOL_CALLS"));
let mut tokens = BudgetTracker::new(Budget::new(99, 99, 5));
tokens.charge_tokens(5);
let msg = exhaustion_message(StopReason::TokensExhausted, &tokens);
assert!(msg.contains("token budget"));
assert!(msg.contains("GHOSTLINK_AGENT_MAX_TOKENS"));
}
#[test]
fn only_limit_reasons_count_as_exhausted() {
assert!(StopReason::StepsExhausted.is_exhausted());
assert!(StopReason::ToolCallsExhausted.is_exhausted());
assert!(StopReason::TokensExhausted.is_exhausted());
assert!(!StopReason::FinalAnswer.is_exhausted());
}
#[test]
fn env_limits_are_clamped_to_at_least_one() {
let limiter = Budget::new(0, 0, 0);
let mut t = BudgetTracker::new(limiter);
assert!(t.begin_step(), "a zero budget must still permit one step");
assert!(!t.begin_step());
}
}