add per-agent and hive-wide skill invocation stats

This commit is contained in:
damocles 2026-07-27 23:52:04 +02:00
commit ef1554a1b2
4 changed files with 163 additions and 51 deletions

View file

@ -643,6 +643,11 @@ impl Bus {
/// pump on every parsed line. Cheap when the line isn't an
/// assistant message — the field-check short-circuits.
///
/// A `Skill` invocation is one meta-tool (`name == "Skill"`) dispatching
/// to whichever skill matched, so the bare tool name collapses every
/// skill into one undifferentiated count. Special-cased (via
/// [`breakdown_key`]) to key by `Skill:<skill>` instead.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
@ -665,9 +670,9 @@ impl Bus {
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("<unnamed>")
.to_owned();
*counts.entry(name).or_insert(0) += 1;
.unwrap_or("<unnamed>");
let key = breakdown_key(name, block.get("input"));
*counts.entry(key).or_insert(0) += 1;
}
}
@ -914,6 +919,24 @@ fn degraded_mcp_servers(
.collect()
}
/// The `tool_call_breakdown_json` key for one `tool_use` block: the bare
/// tool `name`, except a `Skill` invocation (one meta-tool dispatching to
/// whichever skill matched) is keyed `Skill:<skill>` using the invocation's
/// own `input.skill` field — the fully-qualified `plugin:skill-name` — so
/// distinct skills don't collapse into one undifferentiated `"Skill"`
/// count. Falls back to the bare `"Skill"` key if `input.skill` is ever
/// missing/non-string, so a schema change degrades safely instead of losing
/// the count. Pure so it's unit-testable without a live `Bus`.
fn breakdown_key(name: &str, input: Option<&serde_json::Value>) -> String {
if name != "Skill" {
return name.to_owned();
}
input
.and_then(|i| i.get("skill"))
.and_then(|s| s.as_str())
.map_or_else(|| name.to_owned(), |skill| format!("Skill:{skill}"))
}
#[cfg(test)]
mod tests {
use super::{BusEvent, LiveEvent, StoredEvent};
@ -976,6 +999,30 @@ mod tests {
assert!(super::degraded_mcp_servers(&configured, &reported).is_empty());
}
#[test]
fn breakdown_key_keys_skill_by_input_field() {
let input = serde_json::json!({"skill": "base:async-task-hygiene"});
assert_eq!(
super::breakdown_key("Skill", Some(&input)),
"Skill:base:async-task-hygiene"
);
}
#[test]
fn breakdown_key_non_skill_tool_is_bare_name() {
assert_eq!(super::breakdown_key("Read", None), "Read");
}
#[test]
fn breakdown_key_skill_missing_input_field_degrades_to_bare_name() {
// Schema-drift fallback: an unexpected/missing `input.skill` still
// counts the invocation, just undifferentiated, rather than losing
// it entirely.
assert_eq!(super::breakdown_key("Skill", None), "Skill");
let input = serde_json::json!({"unexpected": "field"});
assert_eq!(super::breakdown_key("Skill", Some(&input)), "Skill");
}
#[test]
fn degraded_mcp_servers_ignores_pending() {
// `pending` is the CLI's normal init-event race for a stdio server