use chrono::Utc;
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn env_str(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn env_u64(key: &str, default: u64) -> u64 {
std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}
enum CircuitStatus {
Closed,
HalfOpen,
Open(u64),
}
pub fn cmd_token_budget(tool: Option<String>) -> i32 {
if std::env::var("YANA_BUDGET_BYPASS").ok().as_deref() == Some("1") {
println!("[token-budget-guard] BYPASS active");
return 0;
}
let project_root = match yana_rt::flock_v1::project_root_from_env() {
Ok(root) => root,
Err(error) => {
eprintln!("[token-budget-guard] {error:#}");
return 1;
}
};
let default_budget = project_root.join("core/memory/L2_session/token-budget.json");
let default_circuit = project_root.join("core/memory/L2_session/circuit-state.json");
let budget_path = std::env::var("YANA_TOKEN_BUDGET")
.unwrap_or_else(|_| default_budget.to_string_lossy().into_owned());
let circuit_path = std::env::var("YANA_CIRCUIT_STATE")
.unwrap_or_else(|_| default_circuit.to_string_lossy().into_owned());
let max_loop_tokens = env_u64("YANA_MAX_LOOP_TOKENS", 50_000);
let max_attempts = env_u64("YANA_MAX_FIX_ATTEMPTS", 5);
let cooldown_seconds = env_u64("YANA_CIRCUIT_COOLDOWN", 60);
let log_file = env_str("YANA_LOG", "/tmp/yana-ai-audit.log");
let fast_tier_model = env_str("YANA_FAST_TIER_MODEL", "claude-haiku-4-5-20251001");
let tool_name = tool
.or_else(|| std::env::var("CLAUDE_TOOL_NAME").ok())
.unwrap_or_else(|| "unknown".to_string());
let params = TokenBudgetParams {
tool_name: &tool_name,
budget_path: &budget_path,
circuit_path: &circuit_path,
max_loop_tokens,
max_attempts,
cooldown_seconds,
log_file: &log_file,
fast_tier_model: &fast_tier_model,
};
match yana_rt::flock_v1::with_lock(
"key/state/token-budget.json",
&project_root,
std::time::Duration::from_secs(10),
|| {
run_critical_section(¶ms)
},
) {
Ok(code) => code,
Err(lock_err) => {
eprintln!("[token-budget-guard] lock unavailable: {lock_err:#}");
1
}
}
}
struct TokenBudgetParams<'a> {
tool_name: &'a str,
budget_path: &'a str,
circuit_path: &'a str,
max_loop_tokens: u64,
max_attempts: u64,
cooldown_seconds: u64,
log_file: &'a str,
fast_tier_model: &'a str,
}
fn run_critical_section(p: &TokenBudgetParams) -> i32 {
let TokenBudgetParams {
tool_name, budget_path, circuit_path, max_loop_tokens,
max_attempts, cooldown_seconds, log_file, fast_tier_model,
} = *p;
let timestamp = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
let now_epoch = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
let mut budget = load_or_init(&budget_path, || {
json!({
"session_start": timestamp,
"total_tokens_used": 0,
"actions": [],
"loop_attempts": {},
"fast_tier_triggered": false,
})
});
let mut circuits = load_or_init(&circuit_path, || json!({ "circuits": {} }));
let status = circuit_status_for(&circuits, &tool_name, now_epoch, cooldown_seconds);
if let CircuitStatus::Open(remaining) = status {
append_log(&log_file, &format!(
"[{timestamp}] CIRCUIT-OPEN tool='{tool_name}' cooldown_remaining={remaining}s"
));
return super::deny_json(&format!(
"[token-budget-guard] Circuit breaker OPEN for '{tool_name}' — too many \
consecutive attempts detected. Blocked for {remaining}s more (cooldown). \
Switch to {fast_tier_model} for faster/cheaper retries, or wait out the cooldown."
));
}
let total_tokens = budget.get("total_tokens_used").and_then(Value::as_u64).unwrap_or(0);
let loop_count = budget
.get("loop_attempts")
.and_then(|v| v.get(&tool_name))
.and_then(Value::as_u64)
.unwrap_or(0);
if loop_count >= max_attempts {
let prev_open_count = circuits
.get("circuits")
.and_then(|c| c.get(&tool_name))
.and_then(|e| e.get("open_count"))
.and_then(Value::as_u64)
.unwrap_or(0);
let open_count = prev_open_count + 1;
let stored_cooldown = match open_count {
1 => 60,
2 => 300,
_ => 1800,
};
ensure_object(&mut circuits, "circuits");
circuits["circuits"][tool_name] = json!({
"state": "open",
"opened_at": timestamp,
"opened_at_epoch": now_epoch,
"open_count": open_count,
"cooldown_seconds": stored_cooldown,
"reason": format!("Loop: {tool_name} called >={max_attempts} times without success"),
});
write_json(&circuit_path, &circuits);
budget["fast_tier_triggered"] = json!(true);
budget["fast_tier_tool"] = json!(tool_name);
write_json(&budget_path, &budget);
append_log(&log_file, &format!(
"[{timestamp}] CIRCUIT-TRIGGERED tool='{tool_name}' loop_count={loop_count} tokens={total_tokens}"
));
return super::deny_json(&format!(
"[token-budget-guard] Circuit breaker OPENED for '{tool_name}' — called \
{loop_count}/{max_attempts} times without success (loop detected). Blocked for \
{stored_cooldown}s. Switch to {fast_tier_model} for faster/cheaper retries, or \
stop and re-plan with a different approach."
));
}
if total_tokens > max_loop_tokens {
println!("[token-budget-guard] BUDGET WARNING: {total_tokens} tokens used (limit: {max_loop_tokens})");
println!("[token-budget-guard] Run /cost-report to review ROI before continuing");
}
if matches!(status, CircuitStatus::HalfOpen) {
if let Some(entry) = circuits.get_mut("circuits").and_then(|c| c.get_mut(&tool_name)) {
entry["state"] = json!("closed");
entry["closed_at"] = json!(Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
write_json(&circuit_path, &circuits);
}
println!("[token-budget-guard] Circuit CLOSED for {tool_name} — probe succeeded");
}
ensure_object(&mut budget, "loop_attempts");
let new_count = budget["loop_attempts"].get(tool_name).and_then(Value::as_u64).unwrap_or(0) + 1;
budget["loop_attempts"][tool_name] = json!(new_count);
write_json(&budget_path, &budget);
println!("[token-budget-guard] OK — {tool_name} (attempt {} / {max_attempts})", loop_count + 1);
0
}
fn circuit_status_for(circuits: &Value, tool: &str, now_epoch: u64, cooldown_seconds: u64) -> CircuitStatus {
let info = circuits.get("circuits").and_then(|c| c.get(tool));
let state = info.and_then(|i| i.get("state")).and_then(Value::as_str).unwrap_or("closed");
match state {
"open" => {
let opened_at_epoch = info.and_then(|i| i.get("opened_at_epoch")).and_then(Value::as_u64).unwrap_or(0);
let elapsed = now_epoch.saturating_sub(opened_at_epoch);
if elapsed >= cooldown_seconds {
CircuitStatus::HalfOpen
} else {
CircuitStatus::Open(cooldown_seconds - elapsed)
}
}
"half-open" => CircuitStatus::HalfOpen,
_ => CircuitStatus::Closed,
}
}
fn ensure_object(parent: &mut Value, key: &str) {
if !parent.get(key).is_some_and(Value::is_object) {
parent[key] = json!({});
}
}
fn load_or_init(path: &str, default: impl Fn() -> Value) -> Value {
if let Ok(raw) = fs::read_to_string(path) {
if let Ok(parsed) = serde_json::from_str::<Value>(&raw) {
return parsed;
}
}
let value = default();
write_json(path, &value);
value
}
fn write_json(path: &str, value: &Value) {
if let Some(parent) = Path::new(path).parent() {
let _ = fs::create_dir_all(parent);
}
let json = serde_json::to_string_pretty(value).unwrap_or_default();
let tmp_path = format!("{path}.tmp.{}", std::process::id());
if fs::write(&tmp_path, json).is_ok() {
let _ = fs::rename(&tmp_path, path);
}
}
fn append_log(log_file: &str, line: &str) {
use std::io::Write;
if let Ok(mut f) = fs::OpenOptions::new().create(true).append(true).open(log_file) {
let _ = writeln!(f, "{line}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn circuit_open_denies_with_exit_2() {
let dir = std::env::temp_dir().join(format!(
"yana-token-budget-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let budget_path = dir.join("budget.json");
let circuit_path = dir.join("circuit.json");
std::fs::write(&budget_path, r#"{"total_tokens_used":0,"loop_attempts":{}}"#).unwrap();
let now_epoch = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
let circuit_json = json!({
"circuits": {
"Bash": { "state": "open", "opened_at_epoch": now_epoch }
}
});
std::fs::write(&circuit_path, circuit_json.to_string()).unwrap();
let log_path = dir.join("audit.log");
let params = TokenBudgetParams {
tool_name: "Bash",
budget_path: budget_path.to_str().unwrap(),
circuit_path: circuit_path.to_str().unwrap(),
max_loop_tokens: 50_000,
max_attempts: 5,
cooldown_seconds: 60,
log_file: log_path.to_str().unwrap(),
fast_tier_model: "claude-haiku-4-5-20251001",
};
let code = run_critical_section(¶ms);
assert_eq!(code, 2, "circuit-open must return exit 2 (deny), not {code}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn circuit_trigger_denies_with_exit_2() {
let dir = std::env::temp_dir().join(format!(
"yana-token-budget-test-trigger-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let budget_path = dir.join("budget.json");
let circuit_path = dir.join("circuit.json");
std::fs::write(
&budget_path,
r#"{"total_tokens_used":0,"loop_attempts":{"Bash":5}}"#,
)
.unwrap();
std::fs::write(&circuit_path, r#"{"circuits":{}}"#).unwrap();
let log_path = dir.join("audit.log");
let params = TokenBudgetParams {
tool_name: "Bash",
budget_path: budget_path.to_str().unwrap(),
circuit_path: circuit_path.to_str().unwrap(),
max_loop_tokens: 50_000,
max_attempts: 5,
cooldown_seconds: 60,
log_file: log_path.to_str().unwrap(),
fast_tier_model: "claude-haiku-4-5-20251001",
};
let code = run_critical_section(¶ms);
assert_eq!(code, 2, "circuit-trigger must return exit 2 (deny), not {code}");
std::fs::remove_dir_all(&dir).ok();
}
}