use super::builder::AgentService;
use std::collections::HashMap;
use std::time::{Duration, Instant};
use uuid::Uuid;
const NON_TOOL_DIMENSIONS: &[&str] = &[
"phantom_intent_loop",
"phantom_tool_call",
"self_improve_exact_match_fail",
"sticky_fallback_regression",
"thinking_persistence_qwen36",
"", ];
pub(super) const TOOL_HEALTH_WINDOW_DAYS: i64 = 7;
pub(crate) const TOOL_HEALTH_MIN_SAMPLES: i64 = 5;
pub(crate) const TOOL_HEALTH_MAX_RATE: f64 = 0.5;
pub(super) const TOOL_HEALTH_REFRESH_INTERVAL: Duration = Duration::from_secs(300);
#[derive(Default)]
pub(crate) struct ToolHealthCache {
fetched_at: Option<Instant>,
annotations: HashMap<String, String>,
}
pub(crate) fn build_tool_health_annotations(
stats: &[crate::db::repository::feedback_ledger::DimensionStats],
) -> HashMap<String, String> {
let mut out = HashMap::new();
for s in stats {
if NON_TOOL_DIMENSIONS.contains(&s.dimension.as_str()) {
continue;
}
if s.total_events < TOOL_HEALTH_MIN_SAMPLES || s.success_rate >= TOOL_HEALTH_MAX_RATE {
continue;
}
out.insert(s.dimension.clone(), health_annotation_text(s));
}
out
}
fn health_annotation_text(s: &crate::db::repository::feedback_ledger::DimensionStats) -> String {
let successes = s.successes.max(0);
let total = s.total_events.max(0);
if successes == 0 {
format!(
" [TOOL HEALTH WARNING: 0/{total} recent calls succeeded in the last \
{TOOL_HEALTH_WINDOW_DAYS} days; this tool appears broken, prefer an \
alternative tool if one exists]"
)
} else {
format!(
" [TOOL HEALTH WARNING: only {successes}/{total} recent calls succeeded \
in the last {TOOL_HEALTH_WINDOW_DAYS} days; verify this tool works \
before relying on it]"
)
}
}
pub(crate) fn enrich_metadata(
tool_name: &str,
error_snippet: Option<&str>,
tool_input: Option<&serde_json::Value>,
) -> Option<String> {
let snippet = error_snippet.unwrap_or("");
if tool_name == "bash" && !snippet.is_empty() {
return Some(bash_failure_metadata(snippet, tool_input));
}
let cmd_suffix = if tool_name == "bash"
&& let Some(input) = tool_input
&& let Some(cmd) = input.get("command").and_then(|v| v.as_str())
&& !cmd.is_empty()
{
let cmd_short: String = cmd.chars().take(300).collect();
Some(format!(" | cmd={cmd_short}"))
} else {
None
};
match (snippet.is_empty(), cmd_suffix) {
(true, None) => None,
(true, Some(suffix)) => Some(suffix.strip_prefix(" | ").unwrap_or(&suffix).to_string()),
(false, None) => Some(snippet.to_string()),
(false, Some(suffix)) => Some(format!("{snippet}{suffix}")),
}
}
const STDERR_HEAD_CAP: usize = 160;
fn bash_failure_metadata(snippet: &str, tool_input: Option<&serde_json::Value>) -> String {
use crate::brain::bash_failure::{BashFailureKind, classify, exit_code, stderr_head};
let class = match classify(snippet) {
BashFailureKind::ModelError => "model_error",
BashFailureKind::Environmental => "environmental",
BashFailureKind::Unknown => "unknown",
};
let mut meta = format!("class={class}");
if let Some(code) = exit_code(snippet) {
meta.push_str(&format!(" | exit={code}"));
}
if let Some(cmd) = tool_input
.and_then(|i| i.get("command"))
.and_then(|v| v.as_str())
.filter(|c| !c.is_empty())
{
let cmd_short: String = cmd.chars().take(300).collect();
meta.push_str(&format!(" | cmd={cmd_short}"));
}
match stderr_head(snippet, STDERR_HEAD_CAP) {
Some(head) => meta.push_str(&format!(" | stderr_head={head}")),
None => {
let head: String = snippet.trim().chars().take(STDERR_HEAD_CAP).collect();
if !head.is_empty() {
meta.push_str(&format!(" | {head}"));
}
}
}
meta
}
impl AgentService {
pub(super) fn tool_health_annotations(&self) -> HashMap<String, String> {
let stale = {
let cache = self.tool_health_cache.read().unwrap();
cache
.fetched_at
.map(|t| t.elapsed() >= TOOL_HEALTH_REFRESH_INTERVAL)
.unwrap_or(true)
};
if stale {
let pool = self.context.pool();
let cache_handle = std::sync::Arc::clone(&self.tool_health_cache);
tokio::spawn(async move {
let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
let window_since = (chrono::Utc::now()
- chrono::Duration::days(TOOL_HEALTH_WINDOW_DAYS))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string();
match repo
.stats_by_dimension_since("tool_", Some(&window_since))
.await
{
Ok(stats) => {
let annotations = build_tool_health_annotations(&stats);
let mut cache = cache_handle.write().unwrap();
cache.fetched_at = Some(Instant::now());
cache.annotations = annotations;
}
Err(e) => {
tracing::debug!("tool health refresh failed: {e}");
}
}
});
}
self.tool_health_cache.read().unwrap().annotations.clone()
}
pub(super) fn record_tool_feedback(
&self,
session_id: Uuid,
tool_name: &str,
tool_input: Option<&serde_json::Value>,
success: bool,
error_snippet: Option<&str>,
) {
let pool = self.context.pool();
let sid = session_id.to_string();
let tname = tool_name.to_string();
let enriched = enrich_metadata(tool_name, error_snippet, tool_input);
let meta = enriched.map(|s| s.chars().take(500).collect::<String>());
let recoverable = !success
&& crate::brain::feedback_policy::is_recoverable_tool_failure(tool_name, error_snippet);
tokio::spawn(async move {
let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
let event = if success {
"tool_success"
} else if recoverable {
"recoverable_failure"
} else {
"tool_failure"
};
let val = if success { 1.0 } else { 0.0 };
if let Err(e) = repo.record(&sid, event, &tname, val, meta.as_deref()).await {
tracing::debug!("feedback ledger write failed: {e}");
}
});
}
pub(super) fn record_tool_discovery_miss(
&self,
session_id: Uuid,
tool_name: &str,
tool_input: Option<&serde_json::Value>,
error_snippet: Option<&str>,
) {
let pool = self.context.pool();
let sid = session_id.to_string();
let tname = tool_name.to_string();
let enriched = enrich_metadata(tool_name, error_snippet, tool_input);
let meta = enriched.map(|s| s.chars().take(500).collect::<String>());
tokio::spawn(async move {
let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
if let Err(e) = repo
.record(&sid, "discovery_miss", &tname, 0.0, meta.as_deref())
.await
{
tracing::debug!("feedback ledger write failed: {e}");
}
});
}
pub(super) fn record_provider_feedback(
&self,
session_id: Uuid,
event_type: &str,
dimension: &str,
metadata: Option<&str>,
) {
let pool = self.context.pool();
let sid = session_id.to_string();
let et = event_type.to_string();
let dim = dimension.to_string();
let meta = metadata.map(|s| s.chars().take(500).collect::<String>());
tokio::spawn(async move {
let repo = crate::db::repository::FeedbackLedgerRepository::new(pool);
if let Err(e) = repo.record(&sid, &et, &dim, 0.0, meta.as_deref()).await {
tracing::debug!("feedback ledger write failed: {e}");
}
});
}
}