use super::{ResourceBytes, ResourceDescriptor, ResourceError, ResourceProvider};
use async_trait::async_trait;
use serde::Serialize;
pub const URI: &str = "doctor://tool-usage";
const LOW_CALL_THRESHOLD: i64 = 5;
const DEFAULT_WINDOW: &str = "30d";
#[async_trait]
pub trait UsageSource: Send + Sync {
async fn snapshot(&self, window: &str) -> UsageSnapshot;
async fn registered_tools(&self) -> Vec<String>;
}
#[derive(Debug, Default, Clone)]
pub struct UsageSnapshot {
pub total_calls: i64,
pub by_tool: Vec<ToolCallStats>,
}
#[derive(Debug, Clone)]
pub struct ToolCallStats {
pub tool: String,
pub calls: i64,
pub errors: i64,
pub overflows: i64,
pub error_rate_pct: f64,
pub overflow_rate_pct: f64,
pub p50_ms: i64,
pub p99_ms: i64,
}
#[derive(Debug, Serialize)]
struct ReportEntry<'a> {
name: &'a str,
calls: i64,
errors: i64,
overflows: i64,
error_rate_pct: f64,
overflow_rate_pct: f64,
p50_ms: i64,
p99_ms: i64,
}
#[derive(Debug, Serialize)]
struct Report<'a> {
window: &'a str,
low_call_threshold: i64,
total_calls: i64,
tools: Vec<ReportEntry<'a>>,
prune_candidates: Vec<&'a str>,
unused_tools: Vec<String>,
}
pub struct ToolUsageProvider<S: UsageSource> {
source: S,
}
impl<S: UsageSource> ToolUsageProvider<S> {
pub fn new(source: S) -> Self {
Self { source }
}
}
#[async_trait]
impl<S: UsageSource + 'static> ResourceProvider for ToolUsageProvider<S> {
fn descriptors(&self) -> Vec<ResourceDescriptor> {
vec![ResourceDescriptor {
uri: URI.into(),
name: "tool-usage".into(),
description: Some(
"Per-tool call counts, error/overflow rates, and prune candidates.".into(),
),
mime_type: "application/json".into(),
}]
}
async fn read(&self, uri: &str) -> Result<ResourceBytes, ResourceError> {
if uri != URI {
return Err(ResourceError::NotFound(uri.into()));
}
let window = DEFAULT_WINDOW;
let snap = self.source.snapshot(window).await;
let registered = self.source.registered_tools().await;
let prune_candidates: Vec<&str> = snap
.by_tool
.iter()
.filter(|t| t.calls > 0 && t.calls < LOW_CALL_THRESHOLD)
.map(|t| t.tool.as_str())
.collect();
let seen: std::collections::HashSet<&str> =
snap.by_tool.iter().map(|t| t.tool.as_str()).collect();
let mut unused: Vec<String> = registered
.into_iter()
.filter(|n| !seen.contains(n.as_str()))
.collect();
unused.sort();
let tools: Vec<ReportEntry<'_>> = snap
.by_tool
.iter()
.map(|t| ReportEntry {
name: &t.tool,
calls: t.calls,
errors: t.errors,
overflows: t.overflows,
error_rate_pct: t.error_rate_pct,
overflow_rate_pct: t.overflow_rate_pct,
p50_ms: t.p50_ms,
p99_ms: t.p99_ms,
})
.collect();
let report = Report {
window,
low_call_threshold: LOW_CALL_THRESHOLD,
total_calls: snap.total_calls,
tools,
prune_candidates,
unused_tools: unused,
};
let text = serde_json::to_string_pretty(&report)
.map_err(|e| ResourceError::Other(anyhow::Error::from(e)))?;
Ok(ResourceBytes::Text(text))
}
}
pub struct AgentUsageSource {
agent: crate::agent::Agent,
tools: Vec<std::sync::Arc<dyn crate::tools::Tool>>,
}
impl AgentUsageSource {
pub fn new(
agent: crate::agent::Agent,
tools: Vec<std::sync::Arc<dyn crate::tools::Tool>>,
) -> Self {
Self { agent, tools }
}
}
#[async_trait]
impl UsageSource for AgentUsageSource {
async fn snapshot(&self, window: &str) -> UsageSnapshot {
let Some(root) = self.agent.project_root().await else {
return UsageSnapshot::default();
};
let conn = match crate::usage::db::open_db(&root) {
Ok(c) => c,
Err(_) => return UsageSnapshot::default(),
};
let stats = match crate::usage::db::query_stats(&conn, window) {
Ok(s) => s,
Err(_) => return UsageSnapshot::default(),
};
UsageSnapshot {
total_calls: stats.total_calls,
by_tool: stats
.by_tool
.into_iter()
.map(|t| ToolCallStats {
tool: t.tool,
calls: t.calls,
errors: t.errors,
overflows: t.overflows,
error_rate_pct: t.error_rate_pct,
overflow_rate_pct: t.overflow_rate_pct,
p50_ms: t.p50_ms,
p99_ms: t.p99_ms,
})
.collect(),
}
}
async fn registered_tools(&self) -> Vec<String> {
self.tools.iter().map(|t| t.name().to_string()).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeSource {
snap: UsageSnapshot,
registered: Vec<String>,
}
#[async_trait]
impl UsageSource for FakeSource {
async fn snapshot(&self, _window: &str) -> UsageSnapshot {
self.snap.clone()
}
async fn registered_tools(&self) -> Vec<String> {
self.registered.clone()
}
}
fn make_stats(tool: &str, calls: i64) -> ToolCallStats {
ToolCallStats {
tool: tool.to_string(),
calls,
errors: 0,
overflows: 0,
error_rate_pct: 0.0,
overflow_rate_pct: 0.0,
p50_ms: 10,
p99_ms: 50,
}
}
#[tokio::test]
async fn descriptor_shape() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot::default(),
registered: vec![],
});
let descs = provider.descriptors();
assert_eq!(descs.len(), 1);
assert_eq!(descs[0].uri, URI);
assert_eq!(descs[0].mime_type, "application/json");
}
#[tokio::test]
async fn unknown_uri_returns_not_found() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot::default(),
registered: vec![],
});
let err = provider.read("doctor://other").await.unwrap_err();
assert!(matches!(err, ResourceError::NotFound(_)));
}
#[tokio::test]
async fn empty_snapshot_produces_empty_report() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot::default(),
registered: vec!["symbols".into(), "tree".into()],
});
let bytes = provider.read(URI).await.unwrap();
let ResourceBytes::Text(json) = bytes else {
panic!("expected text bytes")
};
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["total_calls"], 0);
assert_eq!(parsed["window"], "30d");
assert_eq!(
parsed["unused_tools"],
serde_json::json!(["symbols", "tree"])
);
assert_eq!(parsed["prune_candidates"], serde_json::json!([]));
}
#[tokio::test]
async fn prune_candidate_flagged_when_low_usage() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot {
total_calls: 200,
by_tool: vec![
make_stats("symbols", 197), make_stats("edit_code", 2), make_stats("symbol_at", 1), ],
},
registered: vec![
"symbols".into(),
"edit_code".into(),
"symbol_at".into(),
"tree".into(), ],
});
let ResourceBytes::Text(json) = provider.read(URI).await.unwrap() else {
panic!()
};
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["total_calls"], 200);
assert_eq!(
parsed["prune_candidates"],
serde_json::json!(["edit_code", "symbol_at"])
);
assert_eq!(parsed["unused_tools"], serde_json::json!(["tree"]));
}
#[tokio::test]
async fn unused_tools_sorted_for_stable_output() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot::default(),
registered: vec!["zeta_tool".into(), "alpha_tool".into(), "beta_tool".into()],
});
let ResourceBytes::Text(json) = provider.read(URI).await.unwrap() else {
panic!()
};
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed["unused_tools"],
serde_json::json!(["alpha_tool", "beta_tool", "zeta_tool"])
);
}
#[tokio::test]
async fn tools_with_exactly_threshold_are_not_prune_candidates() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot {
total_calls: 5,
by_tool: vec![make_stats("some_tool", LOW_CALL_THRESHOLD)],
},
registered: vec!["some_tool".into()],
});
let ResourceBytes::Text(json) = provider.read(URI).await.unwrap() else {
panic!()
};
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["prune_candidates"], serde_json::json!([]));
}
#[tokio::test]
async fn registered_tool_with_calls_is_not_listed_as_unused() {
let provider = ToolUsageProvider::new(FakeSource {
snap: UsageSnapshot {
total_calls: 10,
by_tool: vec![make_stats("symbols", 10)],
},
registered: vec!["symbols".into()],
});
let ResourceBytes::Text(json) = provider.read(URI).await.unwrap() else {
panic!()
};
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["unused_tools"], serde_json::json!([]));
assert_eq!(parsed["prune_candidates"], serde_json::json!([]));
}
}