use std::collections::HashMap;
use std::time::Instant;
use serde::{Deserialize, Serialize};
const DEFAULT_SLOW_ITERATION_THRESHOLD_MS: u64 = 30_000;
const DEFAULT_ERROR_RATE_THRESHOLD: f64 = 0.3;
const DEFAULT_HEARTBEAT_TIMEOUT_SECS: u64 = 120;
const DEFAULT_STALL_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthStatus {
Healthy,
Degraded,
Unhealthy,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DegradationSignal {
SlowIterations {
avg_ms: u64,
threshold_ms: u64,
},
HighTokenUsage {
tokens_used: u64,
budget: u64,
},
LoopDetected {
repeated_action: String,
count: u32,
},
MissedHeartbeat {
last_seen_secs_ago: u64,
},
HighErrorRate {
error_rate: f64,
threshold: f64,
},
Stalled {
idle_secs: u64,
},
}
#[derive(Debug, Clone, Default)]
pub struct PerformanceMetrics {
pub iterations: u32,
pub total_tokens: u64,
pub total_cost: f64,
pub errors: u32,
pub tool_calls: u32,
pub files_modified: u32,
pub last_activity: Option<Instant>,
}
impl PerformanceMetrics {
pub fn error_rate(&self) -> f64 {
if self.tool_calls == 0 {
0.0
} else {
self.errors as f64 / self.tool_calls as f64
}
}
pub fn avg_tokens_per_iteration(&self) -> u64 {
if self.iterations == 0 {
0
} else {
self.total_tokens / self.iterations as u64
}
}
}
pub struct HealthMonitor {
agents: HashMap<String, AgentHealth>,
config: HealthMonitorConfig,
}
struct AgentHealth {
status: HealthStatus,
metrics: PerformanceMetrics,
signals: Vec<DegradationSignal>,
last_heartbeat: Instant,
}
#[derive(Debug, Clone)]
pub struct HealthMonitorConfig {
pub slow_iteration_threshold_ms: u64,
pub error_rate_threshold: f64,
pub heartbeat_timeout_secs: u64,
pub stall_timeout_secs: u64,
}
impl Default for HealthMonitorConfig {
fn default() -> Self {
Self {
slow_iteration_threshold_ms: DEFAULT_SLOW_ITERATION_THRESHOLD_MS,
error_rate_threshold: DEFAULT_ERROR_RATE_THRESHOLD,
heartbeat_timeout_secs: DEFAULT_HEARTBEAT_TIMEOUT_SECS,
stall_timeout_secs: DEFAULT_STALL_TIMEOUT_SECS,
}
}
}
impl HealthMonitor {
pub fn new(config: HealthMonitorConfig) -> Self {
Self {
agents: HashMap::new(),
config,
}
}
pub fn register(&mut self, agent_id: &str) {
self.agents.insert(
agent_id.to_string(),
AgentHealth {
status: HealthStatus::Healthy,
metrics: PerformanceMetrics::default(),
signals: Vec::new(),
last_heartbeat: Instant::now(),
},
);
}
pub fn heartbeat(&mut self, agent_id: &str) {
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.last_heartbeat = Instant::now();
agent.metrics.last_activity = Some(Instant::now());
}
}
pub fn update_metrics(&mut self, agent_id: &str, metrics: PerformanceMetrics) {
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.metrics = metrics;
}
}
pub fn evaluate_all(&mut self) -> Vec<(String, HealthStatus, Vec<DegradationSignal>)> {
let mut results = Vec::new();
let config = self.config.clone();
for (id, agent) in &mut self.agents {
let mut signals = Vec::new();
let secs_since_heartbeat = agent.last_heartbeat.elapsed().as_secs();
if secs_since_heartbeat > config.heartbeat_timeout_secs {
signals.push(DegradationSignal::MissedHeartbeat {
last_seen_secs_ago: secs_since_heartbeat,
});
}
let error_rate = agent.metrics.error_rate();
if error_rate > config.error_rate_threshold && agent.metrics.tool_calls > 5 {
signals.push(DegradationSignal::HighErrorRate {
error_rate,
threshold: config.error_rate_threshold,
});
}
if let Some(last) = agent.metrics.last_activity {
let idle_secs = last.elapsed().as_secs();
if idle_secs > config.stall_timeout_secs {
signals.push(DegradationSignal::Stalled { idle_secs });
}
}
agent.status = if signals.is_empty() {
HealthStatus::Healthy
} else if signals
.iter()
.any(|s| matches!(s, DegradationSignal::MissedHeartbeat { .. }))
{
HealthStatus::Unknown
} else {
HealthStatus::Degraded
};
agent.signals = signals.clone();
if agent.status != HealthStatus::Healthy {
results.push((id.clone(), agent.status, signals));
}
}
results
}
pub fn status(&self, agent_id: &str) -> HealthStatus {
self.agents
.get(agent_id)
.map(|a| a.status)
.unwrap_or(HealthStatus::Unknown)
}
pub fn unregister(&mut self, agent_id: &str) {
self.agents.remove(agent_id);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_rate_zero_when_no_tool_calls() {
let m = PerformanceMetrics::default();
assert!((m.error_rate() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn error_rate_correct_value() {
let m = PerformanceMetrics {
errors: 3,
tool_calls: 10,
..Default::default()
};
assert!((m.error_rate() - 0.3).abs() < f64::EPSILON);
}
#[test]
fn avg_tokens_per_iteration_zero_when_no_iterations() {
let m = PerformanceMetrics::default();
assert_eq!(m.avg_tokens_per_iteration(), 0);
}
#[test]
fn avg_tokens_per_iteration_correct_value() {
let m = PerformanceMetrics {
iterations: 4,
total_tokens: 1000,
..Default::default()
};
assert_eq!(m.avg_tokens_per_iteration(), 250);
}
#[test]
fn health_monitor_register_and_status() {
let mut monitor = HealthMonitor::new(HealthMonitorConfig::default());
assert_eq!(monitor.status("agent-1"), HealthStatus::Unknown);
monitor.register("agent-1");
assert_eq!(monitor.status("agent-1"), HealthStatus::Healthy);
monitor.unregister("agent-1");
assert_eq!(monitor.status("agent-1"), HealthStatus::Unknown);
}
}