diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index a9bdb9e..b2dc6e4 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -36,6 +36,7 @@ pub struct AutoAgent { pub steps: Vec, pub current_phase: String, pub turn: usize, + pub enabled: bool, } /// Per-run conversation backend — wraps a forked agent. @@ -107,6 +108,7 @@ impl AutoAgent { name, tools, steps, current_phase: String::new(), turn: 0, + enabled: true, } } diff --git a/src/mind/subconscious.rs b/src/mind/subconscious.rs index 35189b6..f800210 100644 --- a/src/mind/subconscious.rs +++ b/src/mind/subconscious.rs @@ -291,6 +291,7 @@ const AGENTS: &[(&str, u64)] = &[ pub struct SubconsciousSnapshot { pub name: String, pub running: bool, + pub enabled: bool, pub current_phase: String, pub turn: usize, pub last_run_secs_ago: Option, @@ -352,7 +353,7 @@ impl SubconsciousAgent { } fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool { - if self.is_running() { return false; } + if !self.auto.enabled || self.is_running() { return false; } if interval == 0 { return true; } conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval } @@ -361,6 +362,7 @@ impl SubconsciousAgent { SubconsciousSnapshot { name: self.name.clone(), running: self.is_running(), + enabled: self.auto.enabled, current_phase: self.auto.current_phase.clone(), turn: self.auto.turn, last_run_secs_ago: self.last_run.map(|t| t.elapsed().as_secs_f64()), diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index d33c8fc..68f3501 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -1,13 +1,8 @@ // unconscious.rs — Graph maintenance agents // // Standalone agents that operate on the memory graph without needing -// conversation context. Unlike subconscious agents (which fork the -// conscious agent to share KV cache), unconscious agents create fresh -// Agent instances and select their own target nodes via queries -// defined in their .agent files. -// -// Scheduling is driven by the consolidation plan (neuro/scoring.rs), -// which analyzes graph health metrics and allocates agent runs. +// conversation context. Each agent runs in a loop: finish one run, +// wait a cooldown, start the next. Agents can be toggled on/off. use std::time::{Duration, Instant}; @@ -15,31 +10,47 @@ use crate::agent::oneshot::{AutoAgent, AutoStep}; use crate::agent::tools; use crate::subconscious::defs; -/// A single unconscious agent type and its runtime state. +/// Agent types to run. Each must have a matching .agent file. +const AGENTS: &[&str] = &[ + "organize", + "linker", + "distill", + "split", + "separator", +]; + +/// Cooldown between consecutive runs of the same agent. +const COOLDOWN: Duration = Duration::from_secs(120); + struct UnconsciousAgent { name: String, - /// How many runs are budgeted (from consolidation plan). - budget: usize, - /// How many runs completed this session. - completed: usize, - /// Currently running task. + enabled: bool, handle: Option)>>, last_run: Option, + runs: usize, } impl UnconsciousAgent { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + enabled: true, + handle: None, + last_run: None, + runs: 0, + } + } + fn is_running(&self) -> bool { self.handle.as_ref().is_some_and(|h| !h.is_finished()) } fn should_run(&self) -> bool { - if self.is_running() { return false; } - if self.completed >= self.budget { return false; } - // Min interval between runs of the same agent type - if let Some(last) = self.last_run { - if last.elapsed() < Duration::from_secs(60) { return false; } + if !self.enabled || self.is_running() { return false; } + match self.last_run { + Some(t) => t.elapsed() >= COOLDOWN, + None => true, } - true } } @@ -48,123 +59,78 @@ impl UnconsciousAgent { pub struct UnconsciousSnapshot { pub name: String, pub running: bool, - pub completed: usize, - pub budget: usize, + pub enabled: bool, + pub runs: usize, pub last_run_secs_ago: Option, } -/// Orchestrates standalone graph maintenance agents. pub struct Unconscious { agents: Vec, - /// Max concurrent agent runs. max_concurrent: usize, - /// When we last refreshed the consolidation plan. - last_plan_refresh: Option, } impl Unconscious { pub fn new() -> Self { - Self { - agents: Vec::new(), - max_concurrent: 2, - last_plan_refresh: None, - } + let agents = AGENTS.iter() + .filter(|name| defs::get_def(name).is_some()) + .map(|name| UnconsciousAgent::new(name)) + .collect(); + Self { agents, max_concurrent: 2 } } - /// Refresh the consolidation plan and update agent budgets. - fn refresh_plan(&mut self) { - let store = match crate::store::Store::load() { - Ok(s) => s, - Err(_) => return, - }; - let plan = crate::neuro::consolidation_plan_quick(&store); - - // Update existing agents or create new ones - for (agent_name, &count) in &plan.counts { - if count == 0 { continue; } - // Only include agents that have .agent definitions - if defs::get_def(agent_name).is_none() { continue; } - - if let Some(existing) = self.agents.iter_mut().find(|a| a.name == *agent_name) { - existing.budget = count; - } else { - self.agents.push(UnconsciousAgent { - name: agent_name.clone(), - budget: count, - completed: 0, - handle: None, - last_run: None, - }); - } - } - - self.last_plan_refresh = Some(Instant::now()); - dbglog!("[unconscious] plan refreshed: {} agent types, {} total runs", - self.agents.len(), - self.agents.iter().map(|a| a.budget).sum::()); + /// Toggle an agent on/off by name. Returns new enabled state. + pub fn toggle(&mut self, name: &str) -> Option { + let agent = self.agents.iter_mut().find(|a| a.name == name)?; + agent.enabled = !agent.enabled; + Some(agent.enabled) } pub fn snapshots(&self) -> Vec { self.agents.iter().map(|a| UnconsciousSnapshot { name: a.name.clone(), running: a.is_running(), - completed: a.completed, - budget: a.budget, + enabled: a.enabled, + runs: a.runs, last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()), }).collect() } - /// Trigger agents that are due to run. + /// Reap finished agents and spawn new ones. pub fn trigger(&mut self) { - // Reap finished agents for agent in &mut self.agents { if agent.handle.as_ref().is_some_and(|h| h.is_finished()) { agent.last_run = Some(Instant::now()); - agent.completed += 1; - dbglog!("[unconscious] {} completed ({}/{})", - agent.name, agent.completed, agent.budget); + agent.runs += 1; + dbglog!("[unconscious] {} completed (run {})", + agent.name, agent.runs); agent.handle = None; } } - // Refresh plan every 30 minutes (or on first call) - let should_refresh = self.last_plan_refresh - .map(|t| t.elapsed() > Duration::from_secs(1800)) - .unwrap_or(true); - if should_refresh { - self.refresh_plan(); - } - - // Count currently running let running = self.agents.iter().filter(|a| a.is_running()).count(); if running >= self.max_concurrent { return; } let slots = self.max_concurrent - running; - // Find agents that should run, sorted by most work remaining - let mut candidates: Vec = self.agents.iter().enumerate() + let ready: Vec = self.agents.iter().enumerate() .filter(|(_, a)| a.should_run()) .map(|(i, _)| i) + .take(slots) .collect(); - candidates.sort_by_key(|&i| std::cmp::Reverse( - self.agents[i].budget - self.agents[i].completed - )); - for idx in candidates.into_iter().take(slots) { + for idx in ready { self.spawn_agent(idx); } } fn spawn_agent(&mut self, idx: usize) { let name = self.agents[idx].name.clone(); - dbglog!("[unconscious] spawning {} ({}/{})", - name, self.agents[idx].completed + 1, self.agents[idx].budget); + dbglog!("[unconscious] spawning {}", name); let def = match defs::get_def(&name) { Some(d) => d, None => return, }; - // Build tools let all_tools = tools::memory_and_journal_tools(); let effective_tools: Vec = if def.tools.is_empty() { all_tools @@ -174,7 +140,6 @@ impl Unconscious { .collect() }; - // Run query, resolve placeholders, record visits let mut store = match crate::store::Store::load() { Ok(s) => s, Err(e) => { @@ -204,9 +169,7 @@ impl Unconscious { }).collect(); let mut auto = AutoAgent::new( - name.clone(), - effective_tools, - steps, + name, effective_tools, steps, def.temperature.unwrap_or(0.6), def.priority, ); diff --git a/src/user/mod.rs b/src/user/mod.rs index 5172a1a..dcb9a18 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -107,6 +107,7 @@ struct App { should_quit: bool, context_info: Option, agent_state: Vec, + unconscious_state: Vec, walked_count: usize, channel_status: Vec, idle_info: Option, @@ -130,6 +131,7 @@ impl App { should_quit: false, context_info: None, agent_state: Vec::new(), + unconscious_state: Vec::new(), walked_count: 0, channel_status: Vec::new(), idle_info: None, } @@ -370,6 +372,7 @@ async fn run( idle_state.decay_ewma(); app.update_idle(&idle_state); app.agent_state = mind.subconscious_snapshots().await; + app.unconscious_state = mind.unconscious_snapshots().await; app.walked_count = mind.subconscious_walked().await.len(); if !startup_done { if let Ok(mut ag) = agent.state.try_lock() { diff --git a/src/user/subconscious.rs b/src/user/subconscious.rs index 0d50305..e81ecf0 100644 --- a/src/user/subconscious.rs +++ b/src/user/subconscious.rs @@ -102,8 +102,10 @@ impl ScreenView for SubconsciousScreen { ]).areas(area); // Left column: agent list (top) | outputs (middle) | history (bottom, main) - let agent_count = app.agent_state.len().max(1) as u16; - let list_height = (agent_count + 2).min(left.height / 4); + 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::()) .unwrap_or(0); @@ -162,7 +164,7 @@ impl SubconsciousScreen { } fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) { - let items: Vec = app.agent_state.iter().map(|snap| { + let mut items: Vec = app.agent_state.iter().map(|snap| { if snap.running { ListItem::from(Line::from(vec![ Span::styled(&snap.name, Style::default().fg(Color::Green)), @@ -191,6 +193,39 @@ impl SubconsciousScreen { } }).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 { + 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 {