Add per-agent run stats (messages, tool calls by type)

compute_run_stats() walks the conversation AST after each agent
completes, counting messages and tool calls by tool name. Stats
are returned from save_agent_log(), stored on UnconsciousAgent,
and displayed in the agent list UI.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-10 13:44:41 -04:00
parent b4dfd3c092
commit aade8a9cce
2 changed files with 59 additions and 16 deletions

View file

@ -34,11 +34,12 @@ struct UnconsciousAgent {
name: String, name: String,
enabled: bool, enabled: bool,
auto: AutoAgent, auto: AutoAgent,
handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>>, handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>, RunStats)>>,
/// Shared agent handle — UI locks to read context live. /// Shared agent handle — UI locks to read context live.
pub agent: Option<std::sync::Arc<crate::agent::Agent>>, pub agent: Option<std::sync::Arc<crate::agent::Agent>>,
last_run: Option<Instant>, last_run: Option<Instant>,
runs: usize, runs: usize,
last_stats: Option<RunStats>,
} }
impl UnconsciousAgent { impl UnconsciousAgent {
@ -60,6 +61,7 @@ pub struct UnconsciousSnapshot {
pub runs: usize, pub runs: usize,
pub last_run_secs_ago: Option<f64>, pub last_run_secs_ago: Option<f64>,
pub agent: Option<std::sync::Arc<crate::agent::Agent>>, pub agent: Option<std::sync::Arc<crate::agent::Agent>>,
pub last_stats: Option<RunStats>,
} }
pub struct Unconscious { pub struct Unconscious {
@ -105,6 +107,7 @@ impl Unconscious {
agent: None, agent: None,
last_run: None, last_run: None,
runs: 0, runs: 0,
last_stats: None,
}); });
} }
agents.sort_by(|a, b| a.name.cmp(&b.name)); agents.sort_by(|a, b| a.name.cmp(&b.name));
@ -144,6 +147,7 @@ impl Unconscious {
runs: a.runs, runs: a.runs,
last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()), last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()),
agent: a.agent.clone(), agent: a.agent.clone(),
last_stats: a.last_stats.clone(),
}).collect() }).collect()
} }
@ -173,8 +177,9 @@ impl Unconscious {
agent.runs += 1; agent.runs += 1;
// Get the AutoAgent back from the finished task // Get the AutoAgent back from the finished task
match handle.now_or_never() { match handle.now_or_never() {
Some(Ok((auto_back, result))) => { Some(Ok((auto_back, result, stats))) => {
agent.auto = auto_back; agent.auto = auto_back;
agent.last_stats = Some(stats);
match result { match result {
Ok(_) => dbglog!("[unconscious] {} completed (run {})", Ok(_) => dbglog!("[unconscious] {} completed (run {})",
agent.name, agent.runs), agent.name, agent.runs),
@ -289,30 +294,64 @@ impl Unconscious {
self.agents[idx].handle = Some(tokio::spawn(async move { self.agents[idx].handle = Some(tokio::spawn(async move {
let result = auto.run_shared(&agent).await; let result = auto.run_shared(&agent).await;
save_agent_log(&auto.name, &agent).await; let stats = save_agent_log(&auto.name, &agent).await;
auto.steps = orig_steps; auto.steps = orig_steps;
(auto, result) (auto, result, stats)
})); }));
} }
} }
pub async fn save_agent_log(name: &str, agent: &std::sync::Arc<crate::agent::Agent>) { pub async fn save_agent_log(name: &str, agent: &std::sync::Arc<crate::agent::Agent>) -> RunStats {
let dir = dirs::home_dir().unwrap_or_default() let dir = dirs::home_dir().unwrap_or_default()
.join(format!(".consciousness/logs/{}", name)); .join(format!(".consciousness/logs/{}", name));
if std::fs::create_dir_all(&dir).is_err() { return; } let ctx = agent.context.lock().await;
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S"); let stats = compute_run_stats(ctx.conversation());
let path = dir.join(format!("{}.json", ts)); if std::fs::create_dir_all(&dir).is_ok() {
let sections: serde_json::Value = { let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let ctx = agent.context.lock().await; let path = dir.join(format!("{}.json", ts));
serde_json::json!({ let sections = serde_json::json!({
"system": ctx.system(), "system": ctx.system(),
"identity": ctx.identity(), "identity": ctx.identity(),
"journal": ctx.journal(), "journal": ctx.journal(),
"conversation": ctx.conversation(), "conversation": ctx.conversation(),
}) "stats": stats,
}; });
if let Ok(json) = serde_json::to_string_pretty(&sections) { if let Ok(json) = serde_json::to_string_pretty(&sections) {
let _ = std::fs::write(&path, json); let _ = std::fs::write(&path, json);
dbglog!("[unconscious] saved log to {}", path.display()); }
} }
dbglog!("[unconscious] {} — {} msgs, {} tool calls",
name, stats.messages, stats.tool_calls);
stats
}
#[derive(Clone, serde::Serialize)]
pub struct RunStats {
pub messages: usize,
pub tool_calls: usize,
pub tool_calls_by_type: HashMap<String, usize>,
}
fn compute_run_stats(conversation: &[crate::agent::context::AstNode]) -> RunStats {
use crate::agent::context::{AstNode, NodeBody};
let mut messages = 0usize;
let mut tool_calls = 0usize;
let mut by_type: HashMap<String, usize> = HashMap::new();
for node in conversation {
if let AstNode::Branch { children, .. } = node {
messages += 1;
for child in children {
if let AstNode::Leaf(leaf) = child {
if let NodeBody::ToolCall { name, .. } = leaf.body() {
tool_calls += 1;
*by_type.entry(name.to_string()).or_default() += 1;
}
}
}
}
}
RunStats { messages, tool_calls, tool_calls_by_type: by_type }
} }

View file

@ -250,6 +250,10 @@ impl SubconsciousScreen {
format!("run {}", snap.runs + 1) format!("run {}", snap.runs + 1)
} else if !snap.enabled { } else if !snap.enabled {
"off".to_string() "off".to_string()
} else if let Some(ref stats) = snap.last_stats {
format!("×{} {} {}msg {}tc",
snap.runs, ago,
stats.messages, stats.tool_calls)
} else { } else {
format!("×{} {}", snap.runs, ago) format!("×{} {}", snap.runs, ago)
}; };