diff --git a/frontend/packages/dashboard/src/stats.html b/frontend/packages/dashboard/src/stats.html index d90f66bb..6a1a8eb7 100644 --- a/frontend/packages/dashboard/src/stats.html +++ b/frontend/packages/dashboard/src/stats.html @@ -44,6 +44,10 @@ recorded data, so the section never shows an empty block. --> + + + diff --git a/frontend/packages/dashboard/src/stats.js b/frontend/packages/dashboard/src/stats.js index cf8eb18b..04ebddeb 100644 --- a/frontend/packages/dashboard/src/stats.js +++ b/frontend/packages/dashboard/src/stats.js @@ -41,6 +41,39 @@ function hsMeta(parent, text) { p.textContent = text; parent.append(p); } +// Shared bar-list renderer for any `KeyCount[]` mix (model/bash/skill). +function renderKeyCountBars(container, mix) { + container.replaceChildren(); + const max = mix[0].count || 1; + for (const kc of mix) { + const row = document.createElement('div'); row.className = 'hive-stats-bar'; + const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; + const track = document.createElement('span'); track.className = 'track'; + const fill = document.createElement('span'); fill.className = 'fill'; + fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; + track.append(fill); + const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); + row.append(lbl, track, cnt); + container.append(row); + } +} +// A mix section that hides itself (+ its header) until the backing +// capture has recorded data, so the section never shows an empty block +// while data is pre-capture. Used by bash_mix and skill_mix. +function renderOptionalMix(containerId, headerId, mix) { + const el = $(containerId); + const h = $(headerId); + if (!el) return; + if (!mix.length) { + el.replaceChildren(); + el.hidden = true; + if (h) h.hidden = true; + return; + } + el.hidden = false; + if (h) h.hidden = false; + renderKeyCountBars(el, mix); +} function renderHiveStats(s) { const sum = $('hive-stats-summary'); @@ -93,54 +126,14 @@ function renderHiveStats(s) { const mm = $('hive-stats-models'); if (mm) { const mix = s.model_mix || []; - if (!mix.length) { - hsMeta(mm, 'no turns in window'); - } else { - mm.replaceChildren(); - const max = mix[0].count || 1; - for (const kc of mix) { - const row = document.createElement('div'); row.className = 'hive-stats-bar'; - const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; - const track = document.createElement('span'); track.className = 'track'; - const fill = document.createElement('span'); fill.className = 'fill'; - fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; - track.append(fill); - const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); - row.append(lbl, track, cnt); - mm.append(row); - } - } + if (!mix.length) hsMeta(mm, 'no turns in window'); + else renderKeyCountBars(mm, mix); } - // "favorite tools": most-run bash commands across the swarm. Hidden - // (header + list) until the capture has recorded data, so the - // section never shows an empty block while capture is pre-data. - const bh = $('hive-stats-bash'); - const bhH = $('hive-stats-bash-h'); - if (bh) { - const bmix = s.bash_mix || []; - if (!bmix.length) { - bh.replaceChildren(); - bh.hidden = true; - if (bhH) bhH.hidden = true; - } else { - bh.hidden = false; - if (bhH) bhH.hidden = false; - bh.replaceChildren(); - const max = bmix[0].count || 1; - for (const kc of bmix) { - const row = document.createElement('div'); row.className = 'hive-stats-bar'; - const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key; - const track = document.createElement('span'); track.className = 'track'; - const fill = document.createElement('span'); fill.className = 'fill'; - fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%'; - track.append(fill); - const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count); - row.append(lbl, track, cnt); - bh.append(row); - } - } - } + // "favorite tools": most-run bash commands across the swarm. + renderOptionalMix('hive-stats-bash', 'hive-stats-bash-h', s.bash_mix || []); + // Most-triggered skills across the swarm. + renderOptionalMix('hive-stats-skills', 'hive-stats-skills-h', s.skill_mix || []); } async function refreshHiveStats() { diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index f0393774..8cf1f6c3 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -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:` 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("") - .to_owned(); - *counts.entry(name).or_insert(0) += 1; + .unwrap_or(""); + 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:` 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 diff --git a/hive-c0re/src/stats/hive_stats.rs b/hive-c0re/src/stats/hive_stats.rs index df0bf7c3..f8d5e31a 100644 --- a/hive-c0re/src/stats/hive_stats.rs +++ b/hive-c0re/src/stats/hive_stats.rs @@ -209,6 +209,11 @@ pub struct HiveStats { /// hive-bash-daemon capture has recorded `bash_commands` rows on at /// least one active agent. pub bash_mix: Vec, + /// Most-triggered skills (fully-qualified `plugin:skill-name`) across + /// the whole swarm, busiest first, capped to 10. Sourced from each + /// agent's `tool_call_breakdown_json` — see [`read_skill_breakdown`]. + /// Empty until at least one agent has actually invoked a skill. + pub skill_mix: Vec, } #[derive(Default)] @@ -224,6 +229,10 @@ struct AgentAgg { /// `bash_commands` table (written by hive-bash-daemon). Empty when that /// capture hasn't run for this agent (table absent). bash: HashMap, + /// Skill (fully-qualified `plugin:skill-name`) → invocation count, + /// unpacked from `turn_stats.tool_call_breakdown_json`'s `Skill:*` + /// entries. Empty when no skill was invoked in the window. + skills: HashMap, } #[allow( @@ -246,7 +255,8 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result= ?1", )?; @@ -258,10 +268,11 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result(2)?), u64_from_i64(row.get::<_, i64>(3)?), u64_from_i64(row.get::<_, i64>(4)?), + row.get::<_, Option>(5)?, )) })?; for r in rows { - let (model, input, output, cache_read, cache_creation) = r?; + let (model, input, output, cache_read, cache_creation, breakdown_json) = r?; agg.turns += 1; agg.input = agg.input.saturating_add(input); agg.output = agg.output.saturating_add(output); @@ -280,11 +291,31 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result` entries out of one turn's +/// `tool_call_breakdown_json` blob (see `hive_agent::events::observe_stream` +/// for how they're written), stripping the `Skill:` marker so the rollup +/// keys by the bare (fully-qualified `plugin:skill-name`) skill identifier. +/// Malformed/absent JSON yields nothing rather than erroring — one bad row +/// shouldn't drop a whole agent from the rollup. +fn skills_from_breakdown(json: &str) -> Vec<(String, u64)> { + let Ok(map) = serde_json::from_str::>(json) else { + return Vec::new(); + }; + map.into_iter() + .filter_map(|(k, v)| k.strip_prefix("Skill:").map(|s| (s.to_owned(), v))) + .collect() +} + /// Tally normalised bash-command heads from the agent's `bash_commands` /// table (`ts INTEGER, head TEXT`, written by hive-bash-daemon) over /// `[from, now]`. Best-effort + isolated from `read_agent`'s error path: @@ -329,6 +360,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { let mut agents: Vec = Vec::new(); let mut model_mix: HashMap = HashMap::new(); let mut bash_mix: HashMap = HashMap::new(); + let mut skill_mix: HashMap = HashMap::new(); let mut total_turns = 0u64; let mut total_input = 0u64; let mut total_output = 0u64; @@ -365,6 +397,9 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { for (h, c) in &agg.bash { *bash_mix.entry(h.clone()).or_insert(0) += c; } + for (s, c) in &agg.skills { + *skill_mix.entry(s.clone()).or_insert(0) += c; + } agents.push(AgentRollup { name: name.into_string(), turns: agg.turns, @@ -393,6 +428,14 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { bash_mix.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key))); bash_mix.truncate(10); + // Busiest skills first, capped to a top-10 list — same shape as bash_mix. + let mut skill_mix: Vec = skill_mix + .into_iter() + .map(|(key, count)| KeyCount { key, count }) + .collect(); + skill_mix.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.key.cmp(&b.key))); + skill_mix.truncate(10); + HiveStats { window: window.label(), from, @@ -407,6 +450,7 @@ pub fn hive_snapshot(window: Window, prices: &PriceTable) -> HiveStats { agents, model_mix, bash_mix, + skill_mix, } } @@ -447,4 +491,28 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); assert!(read_bash_heads(&conn, 0).is_empty()); } + + /// `Skill:` entries are unpacked and the marker stripped; a + /// non-skill tool entry in the same blob is ignored. + #[test] + fn skills_from_breakdown_strips_marker_and_ignores_other_tools() { + let json = r#"{"Skill:base:async-task-hygiene":3,"Read":7}"#; + let mut got = skills_from_breakdown(json); + got.sort(); + assert_eq!(got, vec![("base:async-task-hygiene".to_owned(), 3)]); + } + + /// Malformed JSON degrades to empty rather than erroring — one bad row + /// shouldn't drop a whole agent from the rollup. + #[test] + fn skills_from_breakdown_malformed_json_is_empty() { + assert!(skills_from_breakdown("not json").is_empty()); + } + + /// No `Skill:*` keys at all (an agent that hasn't triggered one yet) + /// is also empty, not an error. + #[test] + fn skills_from_breakdown_no_skills_is_empty() { + assert!(skills_from_breakdown(r#"{"Read":1,"Edit":2}"#).is_empty()); + } }