From f408bb5d8605dc09386f97ba09c3b16cfb964794 Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Sat, 11 Apr 2026 23:03:10 -0400 Subject: [PATCH] Persist agent stats across restarts, add per-tool metrics grid Stats now survive daemon restarts via ~/.consciousness/agent-stats.json, loaded into a global Mutex on first access. Each tool type tracks last count, EWMA (alpha=0.3), and total calls. UI shows a grid view: tool | last | avg | total, sorted by total desc. Failures row appears at bottom if any occurred. Also fixes temperature/priority not being applied to spawned agents. Co-Authored-By: Proof of Concept --- src/agent/oneshot.rs | 108 +++++++++++++++++++++++++++++++-------- src/mind/subconscious.rs | 17 +++--- src/mind/unconscious.rs | 17 +++--- src/user/subconscious.rs | 62 ++++++++++++++-------- 4 files changed, 148 insertions(+), 56 deletions(-) diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index cd74077..7dbc206 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -22,7 +22,8 @@ use super::Agent; // Agent logging — shared by Mind and CLI paths // --------------------------------------------------------------------------- -#[derive(Clone, Default, serde::Serialize)] +/// Stats from a single run. +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] pub struct RunStats { pub messages: usize, pub tool_calls: usize, @@ -30,6 +31,57 @@ pub struct RunStats { pub tool_calls_by_type: HashMap, } +/// Per-tool accumulated stats. +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct ToolStats { + pub last: usize, + pub ewma: f64, + pub total: usize, +} + +/// Persisted stats for an agent (survives restarts). +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct PersistedStats { + pub runs: usize, + pub last_stats: Option, + /// Per-tool-type stats: last, ewma, total. + pub by_tool: HashMap, + /// Failed calls stats. + pub failures: ToolStats, +} + +fn stats_path() -> std::path::PathBuf { + dirs::home_dir().unwrap_or_default() + .join(".consciousness/agent-stats.json") +} + +static AGENT_STATS: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn stats_map() -> &'static std::sync::Mutex> { + AGENT_STATS.get_or_init(|| { + let map: HashMap = std::fs::read_to_string(stats_path()).ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default(); + std::sync::Mutex::new(map) + }) +} + +pub fn get_stats(name: &str) -> PersistedStats { + stats_map().lock().ok() + .and_then(|m| m.get(name).cloned()) + .unwrap_or_default() +} + +pub fn set_stats(name: &str, stats: PersistedStats) { + if let Ok(mut map) = stats_map().lock() { + map.insert(name.to_string(), stats); + if let Ok(json) = serde_json::to_string_pretty(&*map) { + let _ = std::fs::write(stats_path(), json); + } + } +} + /// Save agent conversation to JSON log file. /// Used by both mind-run agents and CLI-run agents. pub async fn save_agent_log(name: &str, agent: &std::sync::Arc) -> RunStats { @@ -109,13 +161,11 @@ pub struct AutoAgent { pub current_phase: String, pub turn: usize, pub enabled: bool, - // Stats tracking - pub runs: usize, - pub last_stats: Option, - pub tool_calls_ewma: f64, - pub tool_failures_ewma: f64, + pub temperature: f32, + pub priority: i32, } + /// Per-run conversation backend — wraps a forked agent. struct Backend(std::sync::Arc); @@ -178,18 +228,16 @@ impl AutoAgent { name: String, tools: Vec, steps: Vec, - _temperature: f32, - _priority: i32, + temperature: f32, + priority: i32, ) -> Self { Self { name, tools, steps, current_phase: String::new(), turn: 0, enabled: true, - runs: 0, - last_stats: None, - tool_calls_ewma: 0.0, - tool_failures_ewma: 0.0, + temperature, + priority, } } @@ -225,7 +273,8 @@ impl AutoAgent { let mut st = agent.state.lock().await; st.provenance = format!("standalone:{}", self.name); st.tools = self.tools.clone(); - st.priority = Some(10); + st.temperature = self.temperature; + st.priority = Some(self.priority); } let mut backend = Backend(agent.clone()); @@ -265,14 +314,33 @@ impl AutoAgent { } /// Update stats after a run completes. Called with the stats from save_agent_log. - pub fn update_stats(&mut self, stats: RunStats) { + pub fn update_stats(&self, run_stats: RunStats) { const ALPHA: f64 = 0.3; - self.runs += 1; - self.tool_calls_ewma = ALPHA * (stats.tool_calls as f64) - + (1.0 - ALPHA) * self.tool_calls_ewma; - self.tool_failures_ewma = ALPHA * (stats.tool_failures as f64) - + (1.0 - ALPHA) * self.tool_failures_ewma; - self.last_stats = Some(stats); + let old = get_stats(&self.name); + + // Update per-tool stats + let mut by_tool = old.by_tool; + for (tool, count) in &run_stats.tool_calls_by_type { + let entry = by_tool.entry(tool.clone()).or_default(); + entry.last = *count; + entry.ewma = ALPHA * (*count as f64) + (1.0 - ALPHA) * entry.ewma; + entry.total += count; + } + + // Update failure stats + let failures = ToolStats { + last: run_stats.tool_failures, + ewma: ALPHA * (run_stats.tool_failures as f64) + (1.0 - ALPHA) * old.failures.ewma, + total: old.failures.total + run_stats.tool_failures, + }; + + let new = PersistedStats { + runs: old.runs + 1, + last_stats: Some(run_stats), + by_tool, + failures, + }; + set_stats(&self.name, new); } async fn run_with_backend( diff --git a/src/mind/subconscious.rs b/src/mind/subconscious.rs index c9976de..fead155 100644 --- a/src/mind/subconscious.rs +++ b/src/mind/subconscious.rs @@ -365,21 +365,23 @@ impl SubconsciousAgent { } fn snapshot(&self, state: &std::collections::BTreeMap, history: Vec<(String, i64)>) -> SubconsciousSnapshot { + let stats = crate::agent::oneshot::get_stats(&self.name); + let tool_calls_ewma: f64 = stats.by_tool.values().map(|t| t.ewma).sum(); SubconsciousSnapshot { name: self.name.clone(), running: self.is_running(), enabled: self.auto.enabled, current_phase: self.auto.current_phase.clone(), turn: self.auto.turn, - runs: self.auto.runs, + runs: stats.runs, last_run_secs_ago: self.last_run.map(|t| t.elapsed().as_secs_f64()), forked_agent: self.forked_agent.clone(), fork_point: self.fork_point, state: state.clone(), history, - last_stats: self.auto.last_stats.clone(), - tool_calls_ewma: self.auto.tool_calls_ewma, - tool_failures_ewma: self.auto.tool_failures_ewma, + last_stats: stats.last_stats.clone(), + tool_calls_ewma, + tool_failures_ewma: stats.failures.ewma, } } } @@ -485,7 +487,7 @@ impl Subconscious { any_finished = true; let (auto_back, result) = handle.await.unwrap_or_else( - |e| (AutoAgent::new(String::new(), vec![], vec![], 0.0, 0), + |e| (AutoAgent::new(String::new(), vec![], vec![], 0.6, 0), Err(format!("task panicked: {}", e)))); self.agents[i].auto = auto_back; @@ -584,7 +586,7 @@ impl Subconscious { self.agents[i].last_trigger_bytes = conversation_bytes; let auto = std::mem::replace(&mut self.agents[i].auto, - AutoAgent::new(String::new(), vec![], vec![], 0.0, 0)); + AutoAgent::new(String::new(), vec![], vec![], 0.6, 0)); to_run.push((i, auto)); } @@ -604,9 +606,10 @@ impl Subconscious { { let mut st = forked.state.lock().await; st.provenance = auto.name.clone(); + st.temperature = auto.temperature; // Surface agent gets near-interactive priority; // other subconscious agents get lower priority. - st.priority = Some(if auto.name == "surface" { 1 } else { 2 }); + st.priority = Some(if auto.name == "surface" { 1 } else { auto.priority }); } let fork_point = forked.context.lock().await.conversation().len(); diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index b166da0..f67c83d 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -142,17 +142,19 @@ impl Unconscious { self.agents.iter().map(|a| { let history = store.map(|st| st.recent_by_provenance(&a.name, 30)) .unwrap_or_default(); + let stats = crate::agent::oneshot::get_stats(&a.name); + let tool_calls_ewma: f64 = stats.by_tool.values().map(|t| t.ewma).sum(); UnconsciousSnapshot { name: a.name.clone(), running: a.is_running(), enabled: a.enabled, - runs: a.auto.runs, + runs: stats.runs, last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()), agent: a.agent.clone(), - last_stats: a.auto.last_stats.clone(), + last_stats: stats.last_stats.clone(), history, - tool_calls_ewma: a.auto.tool_calls_ewma, - tool_failures_ewma: a.auto.tool_failures_ewma, + tool_calls_ewma, + tool_failures_ewma: stats.failures.ewma, } }).collect() } @@ -186,7 +188,7 @@ impl Unconscious { agent.auto = auto_back; match result { Ok(_) => dbglog!("[unconscious] {} completed (run {})", - agent.name, agent.auto.runs), + agent.name, crate::agent::oneshot::get_stats(&agent.name).runs), Err(e) => dbglog!("[unconscious] {} failed: {}", agent.name, e), } } @@ -242,7 +244,7 @@ impl Unconscious { // Swap auto out, replace steps with resolved prompts let mut auto = std::mem::replace(&mut self.agents[idx].auto, - AutoAgent::new(String::new(), vec![], vec![], 0.0, 0)); + AutoAgent::new(String::new(), vec![], vec![], 0.6, 0)); let orig_steps = std::mem::replace(&mut auto.steps, batch.steps.iter().map(|s| AutoStep { prompt: s.prompt.clone(), @@ -291,7 +293,8 @@ impl Unconscious { { let mut st = agent.state.lock().await; st.provenance = auto.name.clone(); - st.priority = Some(10); + st.priority = Some(auto.priority); + st.temperature = auto.temperature; } self.agents[idx].agent = Some(agent.clone()); diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index 55aa4a7..41c0824 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -124,8 +124,8 @@ impl ScreenView for SubconsciousScreen { .map(|s| s.state.values().map(|v| v.lines().count() + 1).sum::()) .unwrap_or(0); let output_height = (output_lines as u16 + 2).min(left.height / 5).max(3); - let stats_lines = self.selected_stats(app) - .map(|s| s.tool_calls_by_type.len()) + let stats_lines = self.selected_persisted_stats(app) + .map(|s| s.by_tool.len()) .unwrap_or(0); let stats_height = (stats_lines as u16 + 2).min(left.height / 5).max(3); let [list_area, output_area, stats_area, history_area] = Layout::vertical([ @@ -191,17 +191,10 @@ impl SubconsciousScreen { .unwrap_or(&[]) } - /// Get last run stats for the selected agent. - fn selected_stats<'a>(&self, app: &'a App) -> Option<&'a crate::agent::oneshot::RunStats> { - let idx = self.selected(); - let sub_count = app.agent_state.len(); - if idx < sub_count { - return app.agent_state.get(idx) - .and_then(|s| s.last_stats.as_ref()); - } - idx.checked_sub(sub_count + 1) - .and_then(|i| app.unconscious_state.get(i)) - .and_then(|s| s.last_stats.as_ref()) + /// Get persisted stats for the selected agent. + fn selected_persisted_stats(&self, app: &App) -> Option { + let name = self.selected_agent_name(app)?; + Some(crate::agent::oneshot::get_stats(&name)) } fn output_sections(&self, app: &App) -> Vec { @@ -355,21 +348,46 @@ impl SubconsciousScreen { fn draw_stats(&mut self, frame: &mut Frame, area: Rect, app: &App) { let dim = Style::default().fg(Color::DarkGray); + let header_style = Style::default().fg(Color::DarkGray); let name_style = Style::default().fg(Color::Cyan); - let count_style = Style::default().fg(Color::Yellow); + let num_style = Style::default().fg(Color::Yellow); let mut lines: Vec = Vec::new(); - if let Some(stats) = self.selected_stats(app) { - // Sort by count descending - let mut tools: Vec<_> = stats.tool_calls_by_type.iter().collect(); - tools.sort_by(|a, b| b.1.cmp(a.1)); - - for (name, count) in tools { + if let Some(stats) = self.selected_persisted_stats(app) { + if !stats.by_tool.is_empty() { + // Header lines.push(Line::from(vec![ - Span::styled(format!(" {:>3} ", count), count_style), - Span::styled(name.as_str(), name_style), + Span::styled(" tool ", header_style), + Span::styled("last ", header_style), + Span::styled(" avg ", header_style), + Span::styled("total", header_style), ])); + + // Sort by total descending + let mut tools: Vec<_> = stats.by_tool.iter().collect(); + tools.sort_by(|a, b| b.1.total.cmp(&a.1.total)); + + for (name, tool_stats) in tools { + let short_name = name.strip_prefix("memory_").unwrap_or(name); + lines.push(Line::from(vec![ + Span::styled(format!(" {:<20} ", short_name), name_style), + Span::styled(format!("{:>4} ", tool_stats.last), num_style), + Span::styled(format!("{:>4.1} ", tool_stats.ewma), dim), + Span::styled(format!("{:>5}", tool_stats.total), num_style), + ])); + } + + // Failures row if any + if stats.failures.total > 0 { + lines.push(Line::raw("")); + lines.push(Line::from(vec![ + Span::styled(" failures ", Style::default().fg(Color::Red)), + Span::styled(format!("{:>4} ", stats.failures.last), num_style), + Span::styled(format!("{:>4.1} ", stats.failures.ewma), dim), + Span::styled(format!("{:>5}", stats.failures.total), num_style), + ])); + } } }