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:
parent
ddfdbe6cb1
commit
1df49482fd
5 changed files with 99 additions and 94 deletions
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue