use std::collections::HashMap;
use std::fmt;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HealthStatus {
Healthy,
Degraded,
Unhealthy,
}
impl fmt::Display for HealthStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Healthy => write!(f, "healthy"),
Self::Degraded => write!(f, "degraded"),
Self::Unhealthy => write!(f, "unhealthy"),
}
}
}
const EWMA_SCALE: u64 = 1_000_000;
pub struct ToolStats {
total_calls: AtomicU64,
success_count: AtomicU64,
failure_count: AtomicU64,
total_duration_ns: AtomicU64,
max_duration_ns: AtomicU64,
ewma_success: AtomicU64,
}
impl Default for ToolStats {
fn default() -> Self {
Self::new()
}
}
impl ToolStats {
#[must_use]
pub fn new() -> Self {
Self {
total_calls: AtomicU64::new(0),
success_count: AtomicU64::new(0),
failure_count: AtomicU64::new(0),
total_duration_ns: AtomicU64::new(0),
max_duration_ns: AtomicU64::new(0),
ewma_success: AtomicU64::new(EWMA_SCALE),
}
}
pub fn record_success(&self, duration: Duration) {
self.total_calls.fetch_add(1, Ordering::Relaxed);
self.success_count.fetch_add(1, Ordering::Relaxed);
let ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
self.total_duration_ns.fetch_add(ns, Ordering::Relaxed);
self.max_duration_ns.fetch_max(ns, Ordering::Relaxed);
self.ewma_success
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |prev| {
Some(update_ewma(prev, true))
})
.ok();
}
pub fn record_failure(&self, duration: Duration) {
self.total_calls.fetch_add(1, Ordering::Relaxed);
self.failure_count.fetch_add(1, Ordering::Relaxed);
let ns = u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX);
self.total_duration_ns.fetch_add(ns, Ordering::Relaxed);
self.ewma_success
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |prev| {
Some(update_ewma(prev, false))
})
.ok();
}
#[must_use]
pub fn total_calls(&self) -> u64 {
self.total_calls.load(Ordering::Relaxed)
}
#[must_use]
pub fn success_count(&self) -> u64 {
self.success_count.load(Ordering::Relaxed)
}
#[must_use]
pub fn failure_count(&self) -> u64 {
self.failure_count.load(Ordering::Relaxed)
}
#[must_use]
pub fn success_rate(&self) -> f64 {
let total = self.total_calls.load(Ordering::Relaxed);
if total == 0 {
return 1.0;
}
let successes = self.success_count.load(Ordering::Relaxed);
let rate = successes
.saturating_mul(EWMA_SCALE)
.checked_div(total)
.unwrap_or(0);
f64::from(u32::try_from(rate).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(EWMA_SCALE).unwrap_or(u32::MAX))
}
#[must_use]
pub fn health_score(&self) -> f64 {
let v = self.ewma_success.load(Ordering::Relaxed).min(EWMA_SCALE);
let ewma = f64::from(u32::try_from(v).unwrap_or(u32::MAX))
/ f64::from(u32::try_from(EWMA_SCALE).unwrap_or(u32::MAX));
0.3 * self.success_rate() + 0.7 * ewma
}
#[must_use]
pub fn avg_duration(&self) -> Duration {
let total = self.total_calls.load(Ordering::Relaxed);
if total == 0 {
return Duration::ZERO;
}
let total_ns = self.total_duration_ns.load(Ordering::Relaxed);
let avg_ns = u128::from(total_ns)
.checked_div(u128::from(total))
.unwrap_or(0);
Duration::from_nanos(u64::try_from(avg_ns).unwrap_or(u64::MAX))
}
#[must_use]
pub fn max_duration(&self) -> Duration {
Duration::from_nanos(self.max_duration_ns.load(Ordering::Relaxed))
}
}
fn update_ewma(prev: u64, is_success: bool) -> u64 {
let sample = u64::from(is_success).saturating_mul(EWMA_SCALE);
let next = (prev
.saturating_mul(7)
.saturating_add(sample.saturating_mul(3)))
/ 10;
next.min(EWMA_SCALE)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u32)]
enum CircuitState {
Closed = 0,
Open = 1,
HalfOpen = 2,
}
impl From<u32> for CircuitState {
fn from(value: u32) -> Self {
match value {
0 => Self::Closed,
1 => Self::Open,
_ => Self::HalfOpen,
}
}
}
impl fmt::Display for CircuitState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Closed => write!(f, "closed"),
Self::Open => write!(f, "open"),
Self::HalfOpen => write!(f, "half-open"),
}
}
}
#[derive(Debug, Clone)]
pub struct CircuitBreakerConfig {
pub failure_threshold: u64,
pub recovery_duration: Duration,
}
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 3,
recovery_duration: Duration::from_secs(30),
}
}
}
pub struct ToolCircuitBreaker {
state: AtomicU32,
consecutive_failures: AtomicU64,
failure_threshold: u64,
recovery_duration: Duration,
last_failure_time: Mutex<Option<Instant>>,
}
impl ToolCircuitBreaker {
#[must_use]
pub fn new(recovery_duration: Duration, failure_threshold: u64) -> Self {
Self {
state: AtomicU32::new(CircuitState::Closed as u32),
consecutive_failures: AtomicU64::new(0),
failure_threshold,
recovery_duration,
last_failure_time: Mutex::new(None),
}
}
#[must_use]
pub fn from_config(config: &CircuitBreakerConfig) -> Self {
Self::new(config.recovery_duration, config.failure_threshold)
}
#[must_use]
pub fn allow_request(&self) -> bool {
match self.state.load(Ordering::Acquire).into() {
CircuitState::Closed => true,
CircuitState::HalfOpen => false,
CircuitState::Open => {
let recovered = self
.last_failure_time
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.map(|t| t.elapsed() >= self.recovery_duration)
.unwrap_or(false);
if recovered {
self.state
.compare_exchange(
CircuitState::Open as u32,
CircuitState::HalfOpen as u32,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
} else {
false
}
}
}
}
pub fn record_success(&self) {
self.consecutive_failures.store(0, Ordering::Release);
self.state
.store(CircuitState::Closed as u32, Ordering::Release);
}
pub fn record_failure(&self) {
let failures = self
.consecutive_failures
.fetch_add(1, Ordering::AcqRel)
.saturating_add(1);
if let Ok(mut guard) = self.last_failure_time.lock() {
*guard = Some(Instant::now());
}
let current_state = self.state.load(Ordering::Acquire).into();
match current_state {
CircuitState::Closed => {
if failures >= self.failure_threshold {
self.state
.store(CircuitState::Open as u32, Ordering::Release);
}
}
CircuitState::HalfOpen => {
self.state
.store(CircuitState::Open as u32, Ordering::Release);
}
CircuitState::Open => {
}
}
}
#[must_use]
pub fn state_label(&self) -> &'static str {
match self.state.load(Ordering::Acquire).into() {
CircuitState::Closed => "closed",
CircuitState::Open => "open",
CircuitState::HalfOpen => "half-open",
}
}
#[must_use]
pub fn consecutive_failures(&self) -> u64 {
self.consecutive_failures.load(Ordering::Relaxed)
}
#[must_use]
pub fn is_closed(&self) -> bool {
matches!(
self.state.load(Ordering::Acquire).into(),
CircuitState::Closed
)
}
#[must_use]
pub fn is_open(&self) -> bool {
matches!(
self.state.load(Ordering::Acquire).into(),
CircuitState::Open
)
}
#[must_use]
pub fn is_half_open(&self) -> bool {
matches!(
self.state.load(Ordering::Acquire).into(),
CircuitState::HalfOpen
)
}
}
pub struct ToolHealthRegistry {
stats: Mutex<HashMap<String, Arc<ToolStats>>>,
breakers: Mutex<HashMap<String, Arc<ToolCircuitBreaker>>>,
breaker_config: CircuitBreakerConfig,
}
impl Default for ToolHealthRegistry {
fn default() -> Self {
Self::new()
}
}
impl ToolHealthRegistry {
#[must_use]
pub fn new() -> Self {
Self {
stats: Mutex::new(HashMap::new()),
breakers: Mutex::new(HashMap::new()),
breaker_config: CircuitBreakerConfig::default(),
}
}
#[must_use]
pub fn with_config(mut self, config: CircuitBreakerConfig) -> Self {
self.breaker_config = config;
self
}
#[must_use]
pub fn get_stats(&self, tool_name: &str) -> Arc<ToolStats> {
let guard = self
.stats
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(stats) = guard.get(tool_name) {
return Arc::clone(stats);
}
drop(guard);
let mut guard = self
.stats
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(
guard
.entry(tool_name.to_string())
.or_insert_with(|| Arc::new(ToolStats::new())),
)
}
#[must_use]
pub fn get_circuit_breaker(&self, tool_name: &str) -> Arc<ToolCircuitBreaker> {
let guard = self
.breakers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(cb) = guard.get(tool_name) {
return Arc::clone(cb);
}
drop(guard);
let mut guard = self
.breakers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
Arc::clone(
guard
.entry(tool_name.to_string())
.or_insert_with(|| Arc::new(ToolCircuitBreaker::from_config(&self.breaker_config))),
)
}
#[must_use]
pub fn is_tool_available(&self, tool_name: &str) -> bool {
let breaker = self.get_circuit_breaker(tool_name);
if !breaker.allow_request() {
return false;
}
if breaker.is_half_open() {
return true;
}
self.get_health_status(tool_name) != HealthStatus::Unhealthy
}
#[must_use]
pub fn get_health_status(&self, tool_name: &str) -> HealthStatus {
let breaker = self.get_circuit_breaker(tool_name);
if breaker.is_open() {
return HealthStatus::Unhealthy;
}
let score = self.get_stats(tool_name).health_score();
if score >= 0.8 {
HealthStatus::Healthy
} else if score >= 0.5 {
HealthStatus::Degraded
} else {
HealthStatus::Unhealthy
}
}
pub fn record_success(&self, tool_name: &str, duration: Duration) {
self.get_stats(tool_name).record_success(duration);
self.get_circuit_breaker(tool_name).record_success();
}
pub fn record_failure(&self, tool_name: &str, duration: Duration) {
self.get_stats(tool_name).record_failure(duration);
self.get_circuit_breaker(tool_name).record_failure();
}
#[must_use]
pub fn health_summary(&self) -> HashMap<String, (HealthStatus, f64)> {
let entries: Vec<(String, Arc<ToolStats>)> = {
let guard = self
.stats
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard
.iter()
.map(|(n, s)| (n.clone(), Arc::clone(s)))
.collect()
};
entries
.into_iter()
.map(|(name, stats)| {
let score = stats.health_score();
let status = self.get_health_status(&name);
(name, (status, score))
})
.collect()
}
#[must_use]
pub fn tool_count(&self) -> usize {
self.stats
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len()
}
}
#[derive(Debug, Clone, Default)]
pub struct HealthRouter {
fallbacks: HashMap<String, Vec<String>>,
}
impl HealthRouter {
#[must_use]
pub fn new() -> Self {
Self {
fallbacks: HashMap::new(),
}
}
#[must_use]
pub fn fallbacks_for(&self, tool_name: &str) -> &[String] {
self.fallbacks.get(tool_name).map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn resolve_tool(&self, tool_name: &str, registry: &ToolHealthRegistry) -> String {
if registry.is_tool_available(tool_name) {
return tool_name.to_string();
}
for fallback in self.fallbacks_for(tool_name) {
if registry.is_tool_available(fallback) {
return fallback.clone();
}
}
tool_name.to_string()
}
}
#[derive(Debug, Clone, Default)]
pub struct HealthRouterBuilder {
fallbacks: HashMap<String, Vec<String>>,
}
impl HealthRouterBuilder {
#[must_use]
pub fn new() -> Self {
Self {
fallbacks: HashMap::new(),
}
}
#[must_use]
pub fn add_fallback(mut self, primary: &str, alternatives: Vec<String>) -> Self {
self.fallbacks.insert(primary.to_string(), alternatives);
self
}
#[must_use]
pub fn build(self) -> HealthRouter {
HealthRouter {
fallbacks: self.fallbacks,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_stats_starts_healthy() {
let stats = ToolStats::new();
assert_eq!(stats.total_calls(), 0);
assert_eq!(stats.success_count(), 0);
assert_eq!(stats.failure_count(), 0);
assert!((stats.success_rate() - 1.0).abs() < f64::EPSILON);
assert!(stats.health_score() > 0.9);
assert_eq!(stats.avg_duration(), Duration::ZERO);
assert_eq!(stats.max_duration(), Duration::ZERO);
}
#[test]
fn tool_stats_records_success() {
let stats = ToolStats::new();
stats.record_success(Duration::from_millis(100));
stats.record_success(Duration::from_millis(200));
assert_eq!(stats.total_calls(), 2);
assert_eq!(stats.success_count(), 2);
assert_eq!(stats.failure_count(), 0);
assert!(stats.success_rate() > 0.99);
assert_eq!(stats.max_duration(), Duration::from_millis(200));
}
#[test]
fn tool_stats_records_failure() {
let stats = ToolStats::new();
stats.record_failure(Duration::from_secs(5));
assert_eq!(stats.total_calls(), 1);
assert_eq!(stats.success_count(), 0);
assert_eq!(stats.failure_count(), 1);
assert!(stats.success_rate() < 0.01);
}
#[test]
fn tool_stats_avg_duration() {
let stats = ToolStats::new();
stats.record_success(Duration::from_millis(100));
stats.record_success(Duration::from_millis(300));
let avg = stats.avg_duration();
assert!(avg >= Duration::from_millis(199) && avg <= Duration::from_millis(201));
}
#[test]
fn tool_stats_ewma_responds_to_failures() {
let stats = ToolStats::new();
let initial = stats.health_score();
assert!(initial > 0.9);
for _ in 0..10 {
stats.record_failure(Duration::from_millis(100));
}
let after_failures = stats.health_score();
assert!(
after_failures < 0.3,
"expected score < 0.3, got {after_failures}"
);
}
#[test]
fn tool_stats_ewma_recovers_on_success() {
let stats = ToolStats::new();
for _ in 0..10 {
stats.record_failure(Duration::from_millis(100));
}
let low = stats.health_score();
assert!(low < 0.3);
for _ in 0..20 {
stats.record_success(Duration::from_millis(100));
}
let recovered = stats.health_score();
assert!(
recovered > low,
"expected recovery: {recovered} should be > {low}"
);
}
#[test]
fn update_ewma_function() {
let mut ewma = EWMA_SCALE;
ewma = update_ewma(ewma, false);
assert_eq!(ewma, 700_000);
ewma = update_ewma(ewma, false);
assert_eq!(ewma, 490_000);
ewma = update_ewma(ewma, true);
assert_eq!(ewma, 643_000);
}
#[test]
fn circuit_state_from_u32() {
assert_eq!(CircuitState::from(0u32), CircuitState::Closed);
assert_eq!(CircuitState::from(1u32), CircuitState::Open);
assert_eq!(CircuitState::from(2u32), CircuitState::HalfOpen);
assert_eq!(CircuitState::from(99u32), CircuitState::HalfOpen);
}
#[test]
fn circuit_state_display() {
assert_eq!(format!("{}", CircuitState::Closed), "closed");
assert_eq!(format!("{}", CircuitState::Open), "open");
assert_eq!(format!("{}", CircuitState::HalfOpen), "half-open");
}
#[test]
fn circuit_breaker_starts_closed() {
let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 3);
assert!(cb.is_closed());
assert!(!cb.is_open());
assert_eq!(cb.state_label(), "closed");
assert!(cb.allow_request());
assert_eq!(cb.consecutive_failures(), 0);
}
#[test]
fn circuit_breaker_opens_after_threshold() {
let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 3);
cb.record_failure();
cb.record_failure();
assert!(cb.is_closed(), "2 failures < threshold 3");
cb.record_failure();
assert!(cb.is_open(), "3 failures should open breaker");
assert!(!cb.allow_request());
assert_eq!(cb.state_label(), "open");
}
#[test]
fn circuit_breaker_success_resets() {
let cb = ToolCircuitBreaker::new(Duration::from_secs(30), 2);
cb.record_failure();
assert_eq!(cb.consecutive_failures(), 1);
cb.record_success();
assert_eq!(cb.consecutive_failures(), 0);
assert!(cb.is_closed());
}
#[test]
fn circuit_breaker_half_open_probe() {
let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
cb.record_failure();
assert!(cb.is_open());
std::thread::sleep(Duration::from_millis(60));
assert!(cb.allow_request());
assert_eq!(cb.state_label(), "half-open");
}
#[test]
fn circuit_breaker_half_open_success_closes() {
let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
cb.record_failure();
assert!(cb.is_open());
std::thread::sleep(Duration::from_millis(60));
assert!(cb.allow_request());
cb.record_success();
assert!(cb.is_closed());
}
#[test]
fn circuit_breaker_half_open_failure_reopens() {
let cb = ToolCircuitBreaker::new(Duration::from_millis(50), 1);
cb.record_failure();
assert!(cb.is_open());
std::thread::sleep(Duration::from_millis(60));
assert!(cb.allow_request());
cb.record_failure();
assert!(cb.is_open());
assert!(!cb.allow_request());
}
#[test]
fn circuit_breaker_from_config() {
let config = CircuitBreakerConfig {
failure_threshold: 5,
recovery_duration: Duration::from_secs(60),
};
let cb = ToolCircuitBreaker::from_config(&config);
for _ in 0..4 {
cb.record_failure();
}
assert!(cb.is_closed(), "4 failures < threshold 5");
cb.record_failure();
assert!(cb.is_open(), "5 failures should open breaker");
}
#[test]
fn health_status_display() {
assert_eq!(format!("{}", HealthStatus::Healthy), "healthy");
assert_eq!(format!("{}", HealthStatus::Degraded), "degraded");
assert_eq!(format!("{}", HealthStatus::Unhealthy), "unhealthy");
}
#[test]
fn registry_starts_empty() {
let registry = ToolHealthRegistry::new();
assert_eq!(registry.tool_count(), 0);
}
#[test]
fn registry_auto_registers_on_record() {
let registry = ToolHealthRegistry::new();
registry.record_success("bash", Duration::from_millis(100));
assert_eq!(registry.tool_count(), 1);
registry.record_failure("grep", Duration::from_millis(50));
assert_eq!(registry.tool_count(), 2);
}
#[test]
fn registry_tracks_per_tool_stats() {
let registry = ToolHealthRegistry::new();
registry.record_success("bash", Duration::from_millis(100));
registry.record_success("bash", Duration::from_millis(200));
registry.record_failure("bash", Duration::from_secs(5));
let stats = registry.get_stats("bash");
assert_eq!(stats.total_calls(), 3);
assert_eq!(stats.success_count(), 2);
assert_eq!(stats.failure_count(), 1);
}
#[test]
fn registry_health_status_classification() {
let registry = ToolHealthRegistry::new();
registry.record_success("tool_a", Duration::from_millis(10));
assert_eq!(registry.get_health_status("tool_a"), HealthStatus::Healthy);
let low_threshold_registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
failure_threshold: 2,
recovery_duration: Duration::from_secs(30),
});
for _ in 0..5 {
low_threshold_registry.record_failure("tool_b", Duration::from_millis(10));
}
let status = low_threshold_registry.get_health_status("tool_b");
assert!(
status == HealthStatus::Unhealthy,
"expected Unhealthy, got {status}"
);
}
#[test]
fn registry_is_tool_available() {
let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
failure_threshold: 2,
recovery_duration: Duration::from_secs(30),
});
registry.record_success("tool_a", Duration::from_millis(10));
assert!(registry.is_tool_available("tool_a"));
registry.record_failure("tool_b", Duration::from_millis(10));
registry.record_failure("tool_b", Duration::from_millis(10));
assert!(!registry.is_tool_available("tool_b"));
}
#[test]
fn registry_health_summary() {
let registry = ToolHealthRegistry::new();
registry.record_success("bash", Duration::from_millis(100));
registry.record_failure("grep", Duration::from_millis(50));
let summary = registry.health_summary();
assert_eq!(summary.len(), 2);
assert!(summary.contains_key("bash"));
assert!(summary.contains_key("grep"));
let (bash_status, bash_score) = &summary["bash"];
assert_eq!(*bash_status, HealthStatus::Healthy);
assert!(*bash_score > 0.5);
}
#[test]
fn health_router_no_fallbacks() {
let router = HealthRouter::new();
assert!(router.fallbacks_for("unknown").is_empty());
}
#[test]
fn health_router_with_fallbacks() {
let router = HealthRouterBuilder::new()
.add_fallback("bash", vec!["sh".to_string(), "python".to_string()])
.build();
assert_eq!(router.fallbacks_for("bash"), vec!["sh", "python"]);
assert!(router.fallbacks_for("unknown").is_empty());
}
#[test]
fn health_router_resolve_healthy_primary() {
let registry = ToolHealthRegistry::new();
registry.record_success("bash", Duration::from_millis(10));
let router = HealthRouterBuilder::new()
.add_fallback("bash", vec!["sh".to_string()])
.build();
assert_eq!(router.resolve_tool("bash", ®istry), "bash");
}
#[test]
fn health_router_resolve_falls_back_to_alternative() {
let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
failure_threshold: 1,
recovery_duration: Duration::from_secs(30),
});
registry.record_failure("bash", Duration::from_millis(10));
registry.record_success("sh", Duration::from_millis(10));
let router = HealthRouterBuilder::new()
.add_fallback("bash", vec!["sh".to_string()])
.build();
let resolved = router.resolve_tool("bash", ®istry);
assert_eq!(resolved, "sh", "should fall back to 'sh'");
}
#[test]
fn health_router_resolve_returns_primary_when_no_healthy_alternative() {
let registry = ToolHealthRegistry::new().with_config(CircuitBreakerConfig {
failure_threshold: 1,
recovery_duration: Duration::from_secs(30),
});
registry.record_failure("bash", Duration::from_millis(10));
registry.record_failure("sh", Duration::from_millis(10));
let router = HealthRouterBuilder::new()
.add_fallback("bash", vec!["sh".to_string()])
.build();
assert_eq!(router.resolve_tool("bash", ®istry), "bash");
}
#[test]
fn registry_concurrent_access() {
use std::sync::Arc;
use std::thread;
let registry = Arc::new(ToolHealthRegistry::new());
let mut handles = vec![];
for i in 0..4 {
let reg = Arc::clone(®istry);
handles.push(thread::spawn(move || {
let tool_name = format!("tool_{i}");
for j in 0..100 {
if j % 3 == 0 {
reg.record_failure(&tool_name, Duration::from_millis(j));
} else {
reg.record_success(&tool_name, Duration::from_millis(j));
}
}
}));
}
for handle in handles {
handle.join().unwrap();
}
assert_eq!(registry.tool_count(), 4);
for i in 0..4u64 {
let tool_name = format!("tool_{i}");
let stats = registry.get_stats(&tool_name);
assert_eq!(stats.total_calls(), 100);
let failures = stats.failure_count();
assert!(
(33..=35).contains(&failures),
"tool_{i}: expected ~34 failures, got {failures}"
);
}
}
}