use anyhow::Context;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use crate::traits::{Tool, ToolCapabilities, ToolRole};
pub struct PolicyMetricsTool;
#[derive(Debug, Deserialize)]
struct PolicyMetricsArgs {
#[serde(default)]
format: Option<String>,
}
#[async_trait]
impl Tool for PolicyMetricsTool {
fn name(&self) -> &str {
"policy_metrics"
}
fn description(&self) -> &str {
"Read runtime policy metrics (response routing outcomes, no-progress iterations, and failed-task token burn)"
}
fn schema(&self) -> Value {
json!({
"name": "policy_metrics",
"description": "Read runtime policy/agent-loop metrics. Use this when the user asks about routing behavior, no-progress loops, or token cost of failed tasks.",
"parameters": {
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": ["json", "summary"],
"description": "Output format. json returns machine-readable JSON (default). summary returns a concise text summary."
}
},
"additionalProperties": false
}
})
}
fn tool_role(&self) -> ToolRole {
ToolRole::Universal
}
fn capabilities(&self) -> ToolCapabilities {
ToolCapabilities {
read_only: true,
external_side_effect: false,
needs_approval: false,
idempotent: true,
high_impact_write: false,
}
}
async fn call(&self, arguments: &str) -> anyhow::Result<String> {
let args_input = if arguments.trim().is_empty() {
"{}"
} else {
arguments
};
let args: PolicyMetricsArgs = serde_json::from_str(args_input)
.with_context(|| "policy_metrics arguments must be valid JSON")?;
let metrics = crate::agent::policy_metrics_snapshot();
let response_total =
metrics.response_direct_return_total + metrics.response_fallthrough_total;
let response_direct_return_rate = if response_total > 0 {
metrics.response_direct_return_total as f64 / response_total as f64
} else {
0.0
};
let response_fallthrough_rate = if response_total > 0 {
metrics.response_fallthrough_total as f64 / response_total as f64
} else {
0.0
};
let route_reason_total = metrics.orchestration_route_clarification_required_total
+ metrics.orchestration_route_tools_required_total
+ metrics.orchestration_route_short_correction_direct_reply_total
+ metrics.orchestration_route_acknowledgment_direct_reply_total
+ metrics.orchestration_route_default_continue_total;
let route_reason_tools_required_rate = if route_reason_total > 0 {
metrics.orchestration_route_tools_required_total as f64 / route_reason_total as f64
} else {
0.0
};
let route_reason_return_rate = if route_reason_total > 0 {
(metrics.orchestration_route_clarification_required_total
+ metrics.orchestration_route_short_correction_direct_reply_total
+ metrics.orchestration_route_acknowledgment_direct_reply_total) as f64
/ route_reason_total as f64
} else {
0.0
};
let est_input_token_samples = metrics.est_input_token_samples;
let avg_est_input_tokens_per_call = if est_input_token_samples > 0 {
metrics.est_input_tokens_total as f64 / est_input_token_samples as f64
} else {
0.0
};
let avg_est_tool_tokens_per_call = if est_input_token_samples > 0 {
metrics.est_tool_tokens_total as f64 / est_input_token_samples as f64
} else {
0.0
};
let est_tool_token_share_rate = if metrics.est_input_tokens_total > 0 {
metrics.est_tool_tokens_total as f64 / metrics.est_input_tokens_total as f64
} else {
0.0
};
let est_tool_tokens_high_share_rate = if est_input_token_samples > 0 {
metrics.est_tool_tokens_high_share_total as f64 / est_input_token_samples as f64
} else {
0.0
};
let est_tool_tokens_high_abs_rate = if est_input_token_samples > 0 {
metrics.est_tool_tokens_high_abs_total as f64 / est_input_token_samples as f64
} else {
0.0
};
if args
.format
.as_deref()
.is_some_and(|fmt| fmt.eq_ignore_ascii_case("summary"))
{
return Ok(format!(
"Policy metrics summary\n\
- response_direct_return_total: {}\n\
- response_fallthrough_total: {}\n\
- response_direct_return_rate: {:.3}\n\
- response_fallthrough_rate: {:.3}\n\
- orchestration_route_clarification_required_total: {}\n\
- orchestration_route_tools_required_total: {}\n\
- orchestration_route_short_correction_direct_reply_total: {}\n\
- orchestration_route_acknowledgment_direct_reply_total: {}\n\
- orchestration_route_default_continue_total: {}\n\
- orchestration_route_tools_required_rate: {:.3}\n\
- orchestration_route_return_rate: {:.3}\n\
- context_bleed_prevented_total: {}\n\
- context_mismatch_preflight_drop_total: {}\n\
- followup_mode_overrides_total: {}\n\
- cross_scope_blocked_total: {}\n\
- tool_schema_contract_rejections_total: {}\n\
- route_drift_alert_total: {}\n\
- route_drift_failsafe_activation_total: {}\n\
- route_failsafe_active_turn_total: {}\n\
- tokens_failed_tasks_total: {}\n\
- est_input_token_samples: {}\n\
- est_input_tokens_total: {}\n\
- est_tool_tokens_total: {}\n\
- avg_est_input_tokens_per_call: {:.1}\n\
- avg_est_tool_tokens_per_call: {:.1}\n\
- est_tool_token_share_rate: {:.3}\n\
- est_tool_tokens_high_share_total: {}\n\
- est_tool_tokens_high_share_rate: {:.3}\n\
- est_tool_tokens_high_abs_total: {}\n\
- est_tool_tokens_high_abs_rate: {:.3}\n\
- no_progress_iterations_total: {}\n\
- deferred_no_tool_forced_required_total: {}\n\
- deferred_no_tool_deferral_detected_total: {}\n\
- deferred_no_tool_model_switch_total: {}\n\
- deferred_no_tool_error_marker_total: {}\n\
- llm_payload_invalid_total: {}\n\
- llm_payload_invalid_breakdown_entries: {}",
metrics.response_direct_return_total,
metrics.response_fallthrough_total,
response_direct_return_rate,
response_fallthrough_rate,
metrics.orchestration_route_clarification_required_total,
metrics.orchestration_route_tools_required_total,
metrics.orchestration_route_short_correction_direct_reply_total,
metrics.orchestration_route_acknowledgment_direct_reply_total,
metrics.orchestration_route_default_continue_total,
route_reason_tools_required_rate,
route_reason_return_rate,
metrics.context_bleed_prevented_total,
metrics.context_mismatch_preflight_drop_total,
metrics.followup_mode_overrides_total,
metrics.cross_scope_blocked_total,
metrics.tool_schema_contract_rejections_total,
metrics.route_drift_alert_total,
metrics.route_drift_failsafe_activation_total,
metrics.route_failsafe_active_turn_total,
metrics.tokens_failed_tasks_total,
metrics.est_input_token_samples,
metrics.est_input_tokens_total,
metrics.est_tool_tokens_total,
avg_est_input_tokens_per_call,
avg_est_tool_tokens_per_call,
est_tool_token_share_rate,
metrics.est_tool_tokens_high_share_total,
est_tool_tokens_high_share_rate,
metrics.est_tool_tokens_high_abs_total,
est_tool_tokens_high_abs_rate,
metrics.no_progress_iterations_total,
metrics.deferred_no_tool_forced_required_total,
metrics.deferred_no_tool_deferral_detected_total,
metrics.deferred_no_tool_model_switch_total,
metrics.deferred_no_tool_error_marker_total,
metrics.llm_payload_invalid_total,
metrics.llm_payload_invalid_breakdown.len(),
));
}
let payload = json!({
"metrics": metrics,
"derived": {
"response_total": response_total,
"response_direct_return_rate": response_direct_return_rate,
"response_fallthrough_rate": response_fallthrough_rate,
"orchestration_route_reason_total": route_reason_total,
"orchestration_route_tools_required_rate": route_reason_tools_required_rate,
"orchestration_route_return_rate": route_reason_return_rate,
"route_drift_total": metrics.route_drift_alert_total,
"context_integrity_guard_events_total": metrics.context_bleed_prevented_total
+ metrics.context_mismatch_preflight_drop_total
+ metrics.followup_mode_overrides_total
+ metrics.cross_scope_blocked_total,
"avg_est_input_tokens_per_call": avg_est_input_tokens_per_call,
"avg_est_tool_tokens_per_call": avg_est_tool_tokens_per_call,
"est_tool_token_share_rate": est_tool_token_share_rate,
"est_tool_tokens_high_share_rate": est_tool_tokens_high_share_rate,
"est_tool_tokens_high_abs_rate": est_tool_tokens_high_abs_rate,
"deferred_no_tool_recovery_effectiveness_rate": if metrics.deferred_no_tool_forced_required_total > 0 {
metrics
.deferred_no_tool_forced_required_total
.saturating_sub(metrics.deferred_no_tool_error_marker_total) as f64
/ metrics.deferred_no_tool_forced_required_total as f64
} else {
0.0
}
}
});
Ok(serde_json::to_string_pretty(&payload)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn returns_metrics_json_payload() {
let tool = PolicyMetricsTool;
let output = tool.call("{}").await.unwrap();
let parsed: Value = serde_json::from_str(&output).unwrap();
assert!(parsed.get("metrics").is_some());
assert!(parsed.get("derived").is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("response_direct_return_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("response_fallthrough_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("orchestration_route_tools_required_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("tokens_failed_tasks_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("est_input_token_samples"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("est_tool_tokens_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("no_progress_iterations_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("cross_scope_blocked_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("deferred_no_tool_forced_required_total"))
.is_some());
assert!(parsed
.get("metrics")
.and_then(|m| m.get("deferred_no_tool_model_switch_total"))
.is_some());
}
}