// unconscious.rs — Graph maintenance agents // // Standalone agents that operate on the memory graph without needing // 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}; use crate::agent::oneshot::{AutoAgent, AutoStep}; use crate::agent::tools; use crate::subconscious::defs; /// 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, 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.enabled || self.is_running() { return false; } match self.last_run { Some(t) => t.elapsed() >= COOLDOWN, None => true, } } } /// Snapshot for the TUI. #[derive(Clone)] pub struct UnconsciousSnapshot { pub name: String, pub running: bool, pub enabled: bool, pub runs: usize, pub last_run_secs_ago: Option, } pub struct Unconscious { agents: Vec, max_concurrent: usize, } impl Unconscious { pub fn new() -> Self { let agents = AGENTS.iter() .filter(|name| defs::get_def(name).is_some()) .map(|name| UnconsciousAgent::new(name)) .collect(); Self { agents, max_concurrent: 2 } } /// 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(), enabled: a.enabled, runs: a.runs, last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()), }).collect() } /// Reap finished agents and spawn new ones. pub fn trigger(&mut self) { 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.runs += 1; dbglog!("[unconscious] {} completed (run {})", agent.name, agent.runs); agent.handle = None; } } let running = self.agents.iter().filter(|a| a.is_running()).count(); if running >= self.max_concurrent { return; } let slots = self.max_concurrent - running; let ready: Vec = self.agents.iter().enumerate() .filter(|(_, a)| a.should_run()) .map(|(i, _)| i) .take(slots) .collect(); 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); let def = match defs::get_def(&name) { Some(d) => d, None => return, }; let all_tools = tools::memory_and_journal_tools(); let effective_tools: Vec = if def.tools.is_empty() { all_tools } else { all_tools.into_iter() .filter(|t| def.tools.iter().any(|w| w == t.name)) .collect() }; let mut store = match crate::store::Store::load() { Ok(s) => s, Err(e) => { dbglog!("[unconscious] store load failed: {}", e); return; } }; let exclude: std::collections::HashSet = std::collections::HashSet::new(); let batch = match defs::run_agent( &store, &def, def.count.unwrap_or(5), &exclude, ) { Ok(b) => b, Err(e) => { dbglog!("[unconscious] {} query failed: {}", name, e); return; } }; if !batch.node_keys.is_empty() { store.record_agent_visits(&batch.node_keys, &name).ok(); } let steps: Vec = batch.steps.iter().map(|s| AutoStep { prompt: s.prompt.clone(), phase: s.phase.clone(), }).collect(); let mut auto = AutoAgent::new( name, effective_tools, steps, def.temperature.unwrap_or(0.6), def.priority, ); self.agents[idx].handle = Some(tokio::spawn(async move { let result = auto.run(None).await; (auto, result) })); } }