use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::time::Instant;
use crate::cm_config::TurnBudgetConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmCallBudgetClass {
Planner,
Executor,
SideCheck,
}
impl LlmCallBudgetClass {
pub fn as_str(self) -> &'static str {
match self {
Self::Planner => "planner",
Self::Executor => "executor",
Self::SideCheck => "side_check",
}
}
}
pub const DEFAULT_MAX_LLM_CALLS_PER_TURN: u32 = 500;
pub const DEFAULT_MAX_OUTER_LOOP_ITERATIONS: u32 = 500;
#[inline]
pub fn turn_wall_clock_exceeded(max_turn_duration_seconds: u64, elapsed_secs: u64) -> bool {
max_turn_duration_seconds > 0 && elapsed_secs > max_turn_duration_seconds
}
#[inline]
pub fn turn_wall_clock_limit_user_message(max_turn_duration_seconds: u64) -> String {
format!("已达到单轮墙钟时间上限 ({}秒)", max_turn_duration_seconds)
}
#[inline]
pub fn turn_llm_calls_limit_user_message(max_llm_calls: u32) -> String {
format!("已达到单轮 LLM 调用次数上限 ({max_llm_calls})")
}
#[inline]
pub fn turn_tokens_limit_user_message(max_turn_tokens: usize) -> String {
format!("已达到单轮 Token 预算上限 (~{max_turn_tokens})")
}
#[inline]
pub fn turn_outer_loop_iterations_limit_user_message(max_iterations: u32) -> String {
format!("达到外层循环安全上限({max_iterations} 轮),已中止以避免重复工具调用死循环")
}
#[inline]
pub fn turn_budget_partial_completion_suffix() -> &'static str {
"(预算已耗尽,以下为已完成部分的摘要)"
}
#[inline]
pub fn is_turn_budget_limit_user_message(msg: &str) -> bool {
msg.contains("单轮墙钟时间上限")
|| msg.contains("单轮 LLM 调用次数上限")
|| msg.contains("单轮 Token 预算上限")
}
#[derive(Debug)]
pub struct TurnBudgetCounter {
started_at: Instant,
llm_calls: AtomicU32,
outer_loop_iterations: AtomicU32,
estimated_tokens: AtomicUsize,
degradation_active: AtomicBool,
}
impl TurnBudgetCounter {
#[inline]
pub fn new_shared() -> Arc<Self> {
Arc::new(Self {
started_at: Instant::now(),
llm_calls: AtomicU32::new(0),
outer_loop_iterations: AtomicU32::new(0),
estimated_tokens: AtomicUsize::new(0),
degradation_active: AtomicBool::new(false),
})
}
#[inline]
pub fn elapsed_secs(&self) -> u64 {
self.started_at.elapsed().as_secs()
}
#[inline]
pub fn record_llm_call(&self) -> u32 {
self.llm_calls.fetch_add(1, Ordering::Relaxed) + 1
}
#[inline]
pub fn llm_calls(&self) -> u32 {
self.llm_calls.load(Ordering::Relaxed)
}
#[inline]
pub fn record_estimated_tokens(&self, tokens: usize) {
if tokens == 0 {
return;
}
self.estimated_tokens.fetch_add(tokens, Ordering::Relaxed);
}
#[inline]
pub fn estimated_tokens(&self) -> usize {
self.estimated_tokens.load(Ordering::Relaxed)
}
#[inline]
pub fn record_outer_loop_iteration(&self) -> u32 {
self.outer_loop_iterations.fetch_add(1, Ordering::Relaxed) + 1
}
#[inline]
pub fn outer_loop_iterations(&self) -> u32 {
self.outer_loop_iterations.load(Ordering::Relaxed)
}
#[inline]
pub fn wall_clock_exceeded(&self, cfg: &TurnBudgetConfig) -> bool {
turn_wall_clock_exceeded(cfg.max_turn_duration_seconds, self.elapsed_secs())
}
#[inline]
pub fn llm_calls_exceeded(&self, max_llm_calls_per_turn: u32) -> bool {
max_llm_calls_per_turn > 0 && self.llm_calls() >= max_llm_calls_per_turn
}
#[inline]
pub fn tokens_exceeded(&self, max_turn_tokens: usize) -> bool {
max_turn_tokens > 0 && self.estimated_tokens() >= max_turn_tokens
}
#[inline]
pub fn outer_loop_iterations_exceeded(&self, max_outer_loop_iterations: u32) -> bool {
max_outer_loop_iterations > 0 && self.outer_loop_iterations() >= max_outer_loop_iterations
}
#[inline]
pub fn budget_usage_percent(&self, cfg: &TurnBudgetConfig) -> u8 {
let max_llm = effective_max_llm_calls_per_turn(cfg);
let llm_pct = if max_llm > 0 {
((self.llm_calls() as u64 * 100) / max_llm as u64).min(100) as u8
} else {
0
};
let token_pct = if cfg.max_turn_tokens > 0 {
((self.estimated_tokens() as u64 * 100) / cfg.max_turn_tokens as u64).min(100) as u8
} else {
0
};
llm_pct.max(token_pct)
}
#[inline]
pub fn is_degradation_active(&self) -> bool {
self.degradation_active.load(Ordering::Relaxed)
}
#[inline]
pub fn maybe_activate_degradation(&self, cfg: &TurnBudgetConfig) {
if !cfg.budget_degradation_enabled {
return;
}
let threshold = cfg.budget_degradation_threshold_percent.clamp(50, 99);
if self.budget_usage_percent(cfg) >= threshold {
self.degradation_active.store(true, Ordering::Relaxed);
}
}
#[inline]
pub fn deny_llm_call_if_exhausted(&self, cfg: &TurnBudgetConfig) -> Result<(), String> {
if self.wall_clock_exceeded(cfg) {
return Err(turn_wall_clock_limit_user_message(
cfg.max_turn_duration_seconds,
));
}
let max_llm = effective_max_llm_calls_per_turn(cfg);
if self.llm_calls_exceeded(max_llm) {
return Err(turn_llm_calls_limit_user_message(max_llm));
}
if self.tokens_exceeded(cfg.max_turn_tokens) {
return Err(turn_tokens_limit_user_message(cfg.max_turn_tokens));
}
Ok(())
}
}
#[inline]
pub fn effective_max_llm_calls_per_turn(cfg: &TurnBudgetConfig) -> u32 {
if cfg.max_llm_calls_per_turn == 0 {
DEFAULT_MAX_LLM_CALLS_PER_TURN
} else {
cfg.max_llm_calls_per_turn
}
}
#[inline]
pub fn effective_max_outer_loop_iterations(cfg: &TurnBudgetConfig) -> u32 {
if cfg.max_outer_loop_iterations == 0 {
DEFAULT_MAX_OUTER_LOOP_ITERATIONS
} else {
cfg.max_outer_loop_iterations
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_means_unlimited_wall_clock() {
assert!(!turn_wall_clock_exceeded(0, 999_999));
}
#[test]
fn exceeded_when_over_cap() {
assert!(!turn_wall_clock_exceeded(60, 60));
assert!(turn_wall_clock_exceeded(60, 61));
}
#[test]
fn counter_records_llm_and_iterations() {
let c = TurnBudgetCounter::new_shared();
assert_eq!(c.record_llm_call(), 1);
assert_eq!(c.record_llm_call(), 2);
assert_eq!(c.record_outer_loop_iteration(), 1);
}
#[test]
fn deny_llm_when_calls_at_cap() {
let c = TurnBudgetCounter::new_shared();
let mut cfg = crate::cm_config::load_config(None).expect("embed default config");
cfg.turn_budget.max_llm_calls_per_turn = 2;
assert!(c.deny_llm_call_if_exhausted(&cfg.turn_budget).is_ok());
c.record_llm_call();
c.record_llm_call();
assert!(c.deny_llm_call_if_exhausted(&cfg.turn_budget).is_err());
}
#[test]
fn deny_llm_when_tokens_at_cap() {
let c = TurnBudgetCounter::new_shared();
let mut cfg = crate::cm_config::load_config(None).expect("embed default config");
cfg.turn_budget.max_turn_tokens = 100;
c.record_estimated_tokens(100);
assert!(c.deny_llm_call_if_exhausted(&cfg.turn_budget).is_err());
}
#[test]
fn degradation_activates_at_threshold() {
let c = TurnBudgetCounter::new_shared();
let mut cfg = crate::cm_config::load_config(None).expect("embed default config");
cfg.turn_budget.budget_degradation_enabled = true;
cfg.turn_budget.budget_degradation_threshold_percent = 80;
cfg.turn_budget.max_llm_calls_per_turn = 10;
assert!(!c.is_degradation_active());
for _ in 0..8 {
c.record_llm_call();
}
c.maybe_activate_degradation(&cfg.turn_budget);
assert!(c.is_degradation_active());
}
#[test]
fn is_budget_limit_message_detects_known_phrases() {
assert!(is_turn_budget_limit_user_message(
&turn_tokens_limit_user_message(1000)
));
assert!(!is_turn_budget_limit_user_message("other error"));
}
}