consciousness/src/user/subconscious.rs
Kent Overstreet f408bb5d86 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<HashMap> 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 <poc@bcachefs.org>
2026-04-11 23:03:10 -04:00

491 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// subconscious_screen.rs — F3 subconscious agent overlay
//
// Three-pane layout:
// Top-left: Agent list (↑/↓ select)
// Bottom-left: Detail — outputs from selected agent's last run
// Right: Context tree from fork point (→/Enter expand, ← collapse)
use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{List, ListItem, ListState},
Frame,
crossterm::event::KeyCode,
};
use super::{App, ScreenView, screen_legend};
use super::widgets::{SectionTree, SectionView, section_to_view, pane_block_focused, tree_legend, format_age, format_ts_age};
#[derive(Clone, Copy, PartialEq)]
enum Pane { Agents, Outputs, Stats, History, Context }
// Clockwise: top-left → right → bottom-left → middle-left
const PANE_ORDER: &[Pane] = &[Pane::Agents, Pane::Context, Pane::History, Pane::Stats, Pane::Outputs];
pub(crate) struct SubconsciousScreen {
focus: Pane,
list_state: ListState,
output_tree: SectionTree,
context_tree: SectionTree,
history_scroll: super::scroll_pane::ScrollPaneState,
stats_scroll: super::scroll_pane::ScrollPaneState,
}
impl SubconsciousScreen {
pub fn new() -> Self {
let mut list_state = ListState::default();
list_state.select(Some(0));
Self {
focus: Pane::Agents,
list_state,
output_tree: SectionTree::new(),
context_tree: SectionTree::new(),
history_scroll: super::scroll_pane::ScrollPaneState::new(),
stats_scroll: super::scroll_pane::ScrollPaneState::new(),
}
}
fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
}
impl ScreenView for SubconsciousScreen {
fn label(&self) -> &'static str { "subconscious" }
fn tick(&mut self, frame: &mut Frame, area: Rect,
events: &[ratatui::crossterm::event::Event], app: &mut App) {
let context_sections = self.read_sections(app);
let output_sections = self.output_sections(app);
for event in events {
if let ratatui::crossterm::event::Event::Key(key) = event {
if key.kind != ratatui::crossterm::event::KeyEventKind::Press { continue; }
match key.code {
KeyCode::Tab => {
let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
self.focus = PANE_ORDER[(idx + 1) % PANE_ORDER.len()];
}
KeyCode::BackTab => {
let idx = PANE_ORDER.iter().position(|p| *p == self.focus).unwrap_or(0);
self.focus = PANE_ORDER[(idx + PANE_ORDER.len() - 1) % PANE_ORDER.len()];
}
code => match self.focus {
Pane::Agents => match code {
KeyCode::Up => {
self.list_state.select_previous();
self.reset_pane_state();
}
KeyCode::Down => {
self.list_state.select_next();
self.reset_pane_state();
}
KeyCode::Char(' ') => {
if let Some(name) = self.selected_agent_name(app) {
app.agent_toggles.push(name);
}
}
_ => {}
}
Pane::Outputs => self.output_tree.handle_nav(code, &output_sections, area.height),
Pane::Stats => match code {
KeyCode::Up => self.stats_scroll.scroll_up(3),
KeyCode::Down => self.stats_scroll.scroll_down(3),
KeyCode::PageUp => self.stats_scroll.scroll_up(20),
KeyCode::PageDown => self.stats_scroll.scroll_down(20),
_ => {}
}
Pane::History => match code {
KeyCode::Up => self.history_scroll.scroll_up(3),
KeyCode::Down => self.history_scroll.scroll_down(3),
KeyCode::PageUp => self.history_scroll.scroll_up(20),
KeyCode::PageDown => self.history_scroll.scroll_down(20),
_ => {}
}
Pane::Context => self.context_tree.handle_nav(code, &context_sections, area.height),
}
}
}
}
// Layout: left column (38%) | right column (62%)
let [left, right] = Layout::horizontal([
Constraint::Percentage(38),
Constraint::Percentage(62),
]).areas(area);
// Left column: agent list | outputs | stats | history
let unc_count = if app.unconscious_state.is_empty() { 0 }
else { app.unconscious_state.len() + 1 }; // +1 for separator
let agent_count = (app.agent_state.len() + unc_count).max(1) as u16;
let list_height = (agent_count + 2).min(left.height / 3);
let output_lines = app.agent_state.get(self.selected())
.map(|s| s.state.values().map(|v| v.lines().count() + 1).sum::<usize>())
.unwrap_or(0);
let output_height = (output_lines as u16 + 2).min(left.height / 5).max(3);
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([
Constraint::Length(list_height),
Constraint::Length(output_height),
Constraint::Length(stats_height),
Constraint::Min(5),
]).areas(left);
self.draw_list(frame, list_area, app);
self.draw_outputs(frame, output_area, app);
self.draw_stats(frame, stats_area, app);
self.draw_history(frame, history_area, app);
self.draw_context(frame, right, &context_sections, app);
}
}
impl SubconsciousScreen {
/// Map the selected list index to an agent name.
/// Accounts for the separator line between subconscious and unconscious.
fn selected_agent_name(&self, app: &App) -> Option<String> {
let idx = self.selected();
let sub_count = app.agent_state.len();
if idx < sub_count {
// Subconscious agent
return Some(app.agent_state[idx].name.clone());
}
// Skip separator line
let unc_idx = idx.checked_sub(sub_count + 1)?;
app.unconscious_state.get(unc_idx).map(|s| s.name.clone())
}
fn reset_pane_state(&mut self) {
self.output_tree = SectionTree::new();
self.context_tree = SectionTree::new();
self.history_scroll = super::scroll_pane::ScrollPaneState::new();
self.stats_scroll = super::scroll_pane::ScrollPaneState::new();
}
/// Get the agent Arc for the selected item, whether subconscious or unconscious.
fn selected_agent(&self, app: &App) -> Option<std::sync::Arc<crate::agent::Agent>> {
let idx = self.selected();
let sub_count = app.agent_state.len();
if idx < sub_count {
return app.agent_state[idx].forked_agent.clone();
}
let unc_idx = idx.checked_sub(sub_count + 1)?; // +1 for separator
app.unconscious_state.get(unc_idx)?.agent.clone()
}
/// Get store activity history for the selected agent.
fn selected_history<'a>(&self, app: &'a App) -> &'a [(String, i64)] {
let idx = self.selected();
let sub_count = app.agent_state.len();
if idx < sub_count {
return app.agent_state.get(idx)
.map(|s| s.history.as_slice())
.unwrap_or(&[]);
}
idx.checked_sub(sub_count + 1)
.and_then(|i| app.unconscious_state.get(i))
.map(|s| s.history.as_slice())
.unwrap_or(&[])
}
/// Get persisted stats for the selected agent.
fn selected_persisted_stats(&self, app: &App) -> Option<crate::agent::oneshot::PersistedStats> {
let name = self.selected_agent_name(app)?;
Some(crate::agent::oneshot::get_stats(&name))
}
fn output_sections(&self, app: &App) -> Vec<SectionView> {
let snap = match app.agent_state.get(self.selected()) {
Some(s) => s,
None => return Vec::new(),
};
snap.state.iter().map(|(key, val)| {
SectionView {
name: key.clone(),
tokens: 0,
content: val.clone(),
children: Vec::new(),
status: String::new(),
}
}).collect()
}
fn read_sections(&self, app: &App) -> Vec<SectionView> {
let agent = match self.selected_agent(app) {
Some(a) => a,
None => return Vec::new(),
};
let fork_point = app.agent_state.get(self.selected())
.map(|s| s.fork_point).unwrap_or(0);
agent.context.try_lock().ok()
.map(|ctx| {
let conv = ctx.conversation();
let view = section_to_view("Conversation", conv);
let fork = fork_point.min(view.children.len());
view.children.into_iter().skip(fork).collect()
})
.unwrap_or_default()
}
fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let mut items: Vec<ListItem> = app.agent_state.iter().map(|snap| {
let (name_color, indicator) = if !snap.enabled {
(Color::DarkGray, "")
} else if snap.running {
(Color::Green, "")
} else {
(Color::Gray, "")
};
let ago = snap.last_run_secs_ago
.map(|s| format_age(s))
.unwrap_or_else(|| "".to_string());
let detail = if snap.running {
format!("p:{} t:{}", snap.current_phase, snap.turn)
} else if !snap.enabled {
"off".to_string()
} else if let Some(ref stats) = snap.last_stats {
let fail_str = if stats.tool_failures > 0 {
format!(" {}fail", stats.tool_failures)
} else {
String::new()
};
format!("×{} {} {}tc{} avg:{:.1}",
snap.runs, ago,
stats.tool_calls, fail_str,
snap.tool_calls_ewma)
} else {
format!("×{} {}", snap.runs, ago)
};
ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(name_color)),
Span::styled(format!(" {} ", indicator),
Style::default().fg(if snap.running { Color::Green } else { Color::DarkGray })),
Span::styled(detail, Style::default().fg(Color::DarkGray)),
]))
}).collect();
// Unconscious agents (graph maintenance)
if !app.unconscious_state.is_empty() {
items.push(ListItem::from(Line::styled(
"── unconscious ──",
Style::default().fg(Color::DarkGray),
)));
for snap in &app.unconscious_state {
let (name_color, indicator) = if !snap.enabled {
(Color::DarkGray, "")
} else if snap.running {
(Color::Yellow, "")
} else {
(Color::Gray, "")
};
let ago = snap.last_run_secs_ago
.map(|s| format_age(s))
.unwrap_or_else(|| "".to_string());
let detail = if snap.running {
format!("run {}", snap.runs + 1)
} else if !snap.enabled {
"off".to_string()
} else if let Some(ref stats) = snap.last_stats {
let fail_str = if stats.tool_failures > 0 {
format!(" {}fail", stats.tool_failures)
} else {
String::new()
};
format!("×{} {} {}tc{} avg:{:.1}",
snap.runs, ago,
stats.tool_calls, fail_str,
snap.tool_calls_ewma)
} else {
format!("×{} {}", snap.runs, ago)
};
items.push(ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(name_color)),
Span::styled(format!(" {} ", indicator),
Style::default().fg(if snap.running { Color::Yellow } else { Color::DarkGray })),
Span::styled(detail, Style::default().fg(Color::DarkGray)),
])));
}
}
let mut block = pane_block_focused("agents", self.focus == Pane::Agents)
.title_top(Line::from(screen_legend()).left_aligned());
if self.focus == Pane::Agents {
block = block.title_bottom(Line::styled(
" ↑↓:select Tab:next pane ",
Style::default().fg(Color::DarkGray),
));
}
let list = List::new(items)
.block(block)
.highlight_symbol("")
.highlight_style(Style::default().bg(Color::DarkGray));
frame.render_stateful_widget(list, area, &mut self.list_state);
}
fn draw_outputs(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let sections = self.output_sections(app);
let mut lines: Vec<Line> = Vec::new();
if sections.is_empty() {
let dim = Style::default().fg(Color::DarkGray);
let snap = app.agent_state.get(self.selected());
let msg = if snap.is_some_and(|s| s.running) { "(running...)" } else { "" };
lines.push(Line::styled(format!(" {}", msg), dim));
} else {
self.output_tree.render_sections(&sections, &mut lines);
}
let mut block = pane_block_focused("state", self.focus == Pane::Outputs);
if self.focus == Pane::Outputs { block = block.title_bottom(tree_legend()); }
let widget = super::scroll_pane::ScrollPane::new(&lines).block(block);
frame.render_stateful_widget(widget, area, &mut self.output_tree.scroll);
}
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 num_style = Style::default().fg(Color::Yellow);
let mut lines: Vec<Line> = Vec::new();
if let Some(stats) = self.selected_persisted_stats(app) {
if !stats.by_tool.is_empty() {
// Header
lines.push(Line::from(vec![
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),
]));
}
}
}
if lines.is_empty() {
lines.push(Line::styled(" (no tool calls)", dim));
}
let mut block = pane_block_focused("tool calls", self.focus == Pane::Stats);
if self.focus == Pane::Stats {
block = block.title_bottom(Line::styled(
" ↑↓:scroll PgUp/Dn ",
Style::default().fg(Color::DarkGray),
));
}
let widget = super::scroll_pane::ScrollPane::new(&lines)
.block(block);
frame.render_stateful_widget(widget, area, &mut self.stats_scroll);
}
fn draw_history(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let dim = Style::default().fg(Color::DarkGray);
let key_style = Style::default().fg(Color::Yellow);
let mut lines: Vec<Line> = Vec::new();
let name = self.selected_agent_name(app);
let short_name = name.as_ref()
.map(|n| n.strip_prefix("subconscious-").unwrap_or(n))
.unwrap_or("");
let title = format!("{} store activity", short_name);
let history = self.selected_history(app);
if history.is_empty() {
lines.push(Line::styled(" (no store activity)", dim));
} else {
for (key, ts) in history {
lines.push(Line::from(vec![
Span::styled(format!(" {:>6} ", format_ts_age(*ts)), dim),
Span::styled(key.as_str(), key_style),
]));
}
}
// Walked state (subconscious only)
if let Some(snap) = app.agent_state.get(self.selected()) {
if let Some(walked_str) = snap.state.get("walked") {
let walked: Vec<&str> = walked_str.lines()
.map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
if !walked.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
format!(" walked ({}):", walked.len()),
Style::default().fg(Color::Cyan),
));
for key in &walked {
lines.push(Line::styled(format!(" {}", key), dim));
}
}
}
}
let mut block = pane_block_focused(&title, self.focus == Pane::History);
if self.focus == Pane::History {
block = block.title_bottom(Line::styled(
" ↑↓:scroll PgUp/Dn ",
Style::default().fg(Color::DarkGray),
));
}
let widget = super::scroll_pane::ScrollPane::new(&lines)
.block(block);
frame.render_stateful_widget(widget, area, &mut self.history_scroll);
}
fn draw_context(
&mut self,
frame: &mut Frame,
area: Rect,
sections: &[SectionView],
app: &App,
) {
let mut lines: Vec<Line> = Vec::new();
if sections.is_empty() {
lines.push(Line::styled(
" (no conversation data)",
Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC),
));
} else {
self.context_tree.render_sections(sections, &mut lines);
}
let name = self.selected_agent_name(app);
let title = name.as_deref()
.unwrap_or("");
let mut block = pane_block_focused(title, self.focus == Pane::Context);
if self.focus == Pane::Context { block = block.title_bottom(tree_legend()); }
let widget = super::scroll_pane::ScrollPane::new(&lines).block(block);
frame.render_stateful_widget(widget, area, &mut self.context_tree.scroll);
}
}