Add enabled toggle to AutoAgent, simplify unconscious scheduling

- AutoAgent.enabled: universal toggle for any auto agent
- Subconscious: should_trigger checks auto.enabled
- Unconscious: simplified from consolidation-plan-driven budgets to
  simple loop with cooldown. Static agent list, max 2 concurrent.
- TUI: unconscious agents shown in F3 subconscious screen under
  separator, with enabled/running/runs display

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-04-09 00:41:18 -04:00
parent ddfdbe6cb1
commit 1df49482fd
5 changed files with 99 additions and 94 deletions

View file

@ -36,6 +36,7 @@ pub struct AutoAgent {
pub steps: Vec<AutoStep>, pub steps: Vec<AutoStep>,
pub current_phase: String, pub current_phase: String,
pub turn: usize, pub turn: usize,
pub enabled: bool,
} }
/// Per-run conversation backend — wraps a forked agent. /// Per-run conversation backend — wraps a forked agent.
@ -107,6 +108,7 @@ impl AutoAgent {
name, tools, steps, name, tools, steps,
current_phase: String::new(), current_phase: String::new(),
turn: 0, turn: 0,
enabled: true,
} }
} }

View file

@ -291,6 +291,7 @@ const AGENTS: &[(&str, u64)] = &[
pub struct SubconsciousSnapshot { pub struct SubconsciousSnapshot {
pub name: String, pub name: String,
pub running: bool, pub running: bool,
pub enabled: bool,
pub current_phase: String, pub current_phase: String,
pub turn: usize, pub turn: usize,
pub last_run_secs_ago: Option<f64>, pub last_run_secs_ago: Option<f64>,
@ -352,7 +353,7 @@ impl SubconsciousAgent {
} }
fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool { 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; } if interval == 0 { return true; }
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
} }
@ -361,6 +362,7 @@ impl SubconsciousAgent {
SubconsciousSnapshot { SubconsciousSnapshot {
name: self.name.clone(), name: self.name.clone(),
running: self.is_running(), running: self.is_running(),
enabled: self.auto.enabled,
current_phase: self.auto.current_phase.clone(), current_phase: self.auto.current_phase.clone(),
turn: self.auto.turn, turn: self.auto.turn,
last_run_secs_ago: self.last_run.map(|t| t.elapsed().as_secs_f64()), last_run_secs_ago: self.last_run.map(|t| t.elapsed().as_secs_f64()),

View file

@ -1,13 +1,8 @@
// unconscious.rs — Graph maintenance agents // unconscious.rs — Graph maintenance agents
// //
// Standalone agents that operate on the memory graph without needing // Standalone agents that operate on the memory graph without needing
// conversation context. Unlike subconscious agents (which fork the // conversation context. Each agent runs in a loop: finish one run,
// conscious agent to share KV cache), unconscious agents create fresh // wait a cooldown, start the next. Agents can be toggled on/off.
// 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.
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -15,31 +10,47 @@ use crate::agent::oneshot::{AutoAgent, AutoStep};
use crate::agent::tools; use crate::agent::tools;
use crate::subconscious::defs; 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 { struct UnconsciousAgent {
name: String, name: String,
/// How many runs are budgeted (from consolidation plan). enabled: bool,
budget: usize,
/// How many runs completed this session.
completed: usize,
/// Currently running task.
handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>>, handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>>,
last_run: Option<Instant>, last_run: Option<Instant>,
runs: usize,
} }
impl UnconsciousAgent { 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 { fn is_running(&self) -> bool {
self.handle.as_ref().is_some_and(|h| !h.is_finished()) self.handle.as_ref().is_some_and(|h| !h.is_finished())
} }
fn should_run(&self) -> bool { fn should_run(&self) -> bool {
if self.is_running() { return false; } if !self.enabled || self.is_running() { return false; }
if self.completed >= self.budget { return false; } match self.last_run {
// Min interval between runs of the same agent type Some(t) => t.elapsed() >= COOLDOWN,
if let Some(last) = self.last_run { None => true,
if last.elapsed() < Duration::from_secs(60) { return false; }
} }
true
} }
} }
@ -48,123 +59,78 @@ impl UnconsciousAgent {
pub struct UnconsciousSnapshot { pub struct UnconsciousSnapshot {
pub name: String, pub name: String,
pub running: bool, pub running: bool,
pub completed: usize, pub enabled: bool,
pub budget: usize, pub runs: usize,
pub last_run_secs_ago: Option<f64>, pub last_run_secs_ago: Option<f64>,
} }
/// Orchestrates standalone graph maintenance agents.
pub struct Unconscious { pub struct Unconscious {
agents: Vec<UnconsciousAgent>, agents: Vec<UnconsciousAgent>,
/// Max concurrent agent runs.
max_concurrent: usize, max_concurrent: usize,
/// When we last refreshed the consolidation plan.
last_plan_refresh: Option<Instant>,
} }
impl Unconscious { impl Unconscious {
pub fn new() -> Self { pub fn new() -> Self {
Self { let agents = AGENTS.iter()
agents: Vec::new(), .filter(|name| defs::get_def(name).is_some())
max_concurrent: 2, .map(|name| UnconsciousAgent::new(name))
last_plan_refresh: None, .collect();
} Self { agents, max_concurrent: 2 }
} }
/// Refresh the consolidation plan and update agent budgets. /// Toggle an agent on/off by name. Returns new enabled state.
fn refresh_plan(&mut self) { pub fn toggle(&mut self, name: &str) -> Option<bool> {
let store = match crate::store::Store::load() { let agent = self.agents.iter_mut().find(|a| a.name == name)?;
Ok(s) => s, agent.enabled = !agent.enabled;
Err(_) => return, Some(agent.enabled)
};
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::<usize>());
} }
pub fn snapshots(&self) -> Vec<UnconsciousSnapshot> { pub fn snapshots(&self) -> Vec<UnconsciousSnapshot> {
self.agents.iter().map(|a| UnconsciousSnapshot { self.agents.iter().map(|a| UnconsciousSnapshot {
name: a.name.clone(), name: a.name.clone(),
running: a.is_running(), running: a.is_running(),
completed: a.completed, enabled: a.enabled,
budget: a.budget, 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()),
}).collect() }).collect()
} }
/// Trigger agents that are due to run. /// Reap finished agents and spawn new ones.
pub fn trigger(&mut self) { pub fn trigger(&mut self) {
// Reap finished agents
for agent in &mut self.agents { for agent in &mut self.agents {
if agent.handle.as_ref().is_some_and(|h| h.is_finished()) { if agent.handle.as_ref().is_some_and(|h| h.is_finished()) {
agent.last_run = Some(Instant::now()); agent.last_run = Some(Instant::now());
agent.completed += 1; agent.runs += 1;
dbglog!("[unconscious] {} completed ({}/{})", dbglog!("[unconscious] {} completed (run {})",
agent.name, agent.completed, agent.budget); agent.name, agent.runs);
agent.handle = None; 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(); let running = self.agents.iter().filter(|a| a.is_running()).count();
if running >= self.max_concurrent { return; } if running >= self.max_concurrent { return; }
let slots = self.max_concurrent - running; let slots = self.max_concurrent - running;
// Find agents that should run, sorted by most work remaining let ready: Vec<usize> = self.agents.iter().enumerate()
let mut candidates: Vec<usize> = self.agents.iter().enumerate()
.filter(|(_, a)| a.should_run()) .filter(|(_, a)| a.should_run())
.map(|(i, _)| i) .map(|(i, _)| i)
.take(slots)
.collect(); .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); self.spawn_agent(idx);
} }
} }
fn spawn_agent(&mut self, idx: usize) { fn spawn_agent(&mut self, idx: usize) {
let name = self.agents[idx].name.clone(); let name = self.agents[idx].name.clone();
dbglog!("[unconscious] spawning {} ({}/{})", dbglog!("[unconscious] spawning {}", name);
name, self.agents[idx].completed + 1, self.agents[idx].budget);
let def = match defs::get_def(&name) { let def = match defs::get_def(&name) {
Some(d) => d, Some(d) => d,
None => return, None => return,
}; };
// Build tools
let all_tools = tools::memory_and_journal_tools(); let all_tools = tools::memory_and_journal_tools();
let effective_tools: Vec<tools::Tool> = if def.tools.is_empty() { let effective_tools: Vec<tools::Tool> = if def.tools.is_empty() {
all_tools all_tools
@ -174,7 +140,6 @@ impl Unconscious {
.collect() .collect()
}; };
// Run query, resolve placeholders, record visits
let mut store = match crate::store::Store::load() { let mut store = match crate::store::Store::load() {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
@ -204,9 +169,7 @@ impl Unconscious {
}).collect(); }).collect();
let mut auto = AutoAgent::new( let mut auto = AutoAgent::new(
name.clone(), name, effective_tools, steps,
effective_tools,
steps,
def.temperature.unwrap_or(0.6), def.temperature.unwrap_or(0.6),
def.priority, def.priority,
); );

View file

@ -107,6 +107,7 @@ struct App {
should_quit: bool, should_quit: bool,
context_info: Option<ContextInfo>, context_info: Option<ContextInfo>,
agent_state: Vec<crate::mind::SubconsciousSnapshot>, agent_state: Vec<crate::mind::SubconsciousSnapshot>,
unconscious_state: Vec<crate::mind::UnconsciousSnapshot>,
walked_count: usize, walked_count: usize,
channel_status: Vec<ChannelStatus>, channel_status: Vec<ChannelStatus>,
idle_info: Option<IdleInfo>, idle_info: Option<IdleInfo>,
@ -130,6 +131,7 @@ impl App {
should_quit: false, should_quit: false,
context_info: None, context_info: None,
agent_state: Vec::new(), agent_state: Vec::new(),
unconscious_state: Vec::new(),
walked_count: 0, walked_count: 0,
channel_status: Vec::new(), idle_info: None, channel_status: Vec::new(), idle_info: None,
} }
@ -370,6 +372,7 @@ async fn run(
idle_state.decay_ewma(); idle_state.decay_ewma();
app.update_idle(&idle_state); app.update_idle(&idle_state);
app.agent_state = mind.subconscious_snapshots().await; app.agent_state = mind.subconscious_snapshots().await;
app.unconscious_state = mind.unconscious_snapshots().await;
app.walked_count = mind.subconscious_walked().await.len(); app.walked_count = mind.subconscious_walked().await.len();
if !startup_done { if !startup_done {
if let Ok(mut ag) = agent.state.try_lock() { if let Ok(mut ag) = agent.state.try_lock() {

View file

@ -102,8 +102,10 @@ impl ScreenView for SubconsciousScreen {
]).areas(area); ]).areas(area);
// Left column: agent list (top) | outputs (middle) | history (bottom, main) // Left column: agent list (top) | outputs (middle) | history (bottom, main)
let agent_count = app.agent_state.len().max(1) as u16; let unc_count = if app.unconscious_state.is_empty() { 0 }
let list_height = (agent_count + 2).min(left.height / 4); 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()) let output_lines = app.agent_state.get(self.selected())
.map(|s| s.state.values().map(|v| v.lines().count() + 1).sum::<usize>()) .map(|s| s.state.values().map(|v| v.lines().count() + 1).sum::<usize>())
.unwrap_or(0); .unwrap_or(0);
@ -162,7 +164,7 @@ impl SubconsciousScreen {
} }
fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) { fn draw_list(&mut self, frame: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app.agent_state.iter().map(|snap| { let mut items: Vec<ListItem> = app.agent_state.iter().map(|snap| {
if snap.running { if snap.running {
ListItem::from(Line::from(vec![ ListItem::from(Line::from(vec![
Span::styled(&snap.name, Style::default().fg(Color::Green)), Span::styled(&snap.name, Style::default().fg(Color::Green)),
@ -191,6 +193,39 @@ impl SubconsciousScreen {
} }
}).collect(); }).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) let mut block = pane_block_focused("agents", self.focus == Pane::Agents)
.title_top(Line::from(screen_legend()).left_aligned()); .title_top(Line::from(screen_legend()).left_aligned());
if self.focus == Pane::Agents { if self.focus == Pane::Agents {