forked from kent/consciousness
- RunStats now includes tool_calls_by_type HashMap - AutoAgent tracks runs, last_stats, and EWMA for tool calls/failures - Removed duplicate stats fields from individual agent structs - Fixed provenance to use bare agent name (no "agent:" prefix) - Subconscious screen now displays both agent types consistently - Added Stats pane showing tool call breakdown sorted by count Co-Authored-By: Proof of Concept <poc@bcachefs.org>
309 lines
11 KiB
Rust
309 lines
11 KiB
Rust
// 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,
|
|
// start the next. Agents can be toggled on/off, persisted to
|
|
// ~/.consciousness/agent-enabled.json.
|
|
|
|
use std::time::Instant;
|
|
use std::collections::HashMap;
|
|
use futures::FutureExt;
|
|
|
|
use crate::agent::oneshot::{AutoAgent, AutoStep, RunStats};
|
|
use crate::agent::tools;
|
|
use crate::subconscious::defs;
|
|
|
|
fn config_path() -> std::path::PathBuf {
|
|
dirs::home_dir().unwrap_or_default()
|
|
.join(".consciousness/agent-enabled.json")
|
|
}
|
|
|
|
fn load_enabled_config() -> HashMap<String, bool> {
|
|
std::fs::read_to_string(config_path()).ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn save_enabled_config(map: &HashMap<String, bool>) {
|
|
if let Ok(json) = serde_json::to_string_pretty(map) {
|
|
let _ = std::fs::write(config_path(), json);
|
|
}
|
|
}
|
|
|
|
struct UnconsciousAgent {
|
|
name: String,
|
|
enabled: bool,
|
|
auto: AutoAgent,
|
|
handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>>,
|
|
/// Shared agent handle — UI locks to read context live.
|
|
pub agent: Option<std::sync::Arc<crate::agent::Agent>>,
|
|
last_run: Option<Instant>,
|
|
}
|
|
|
|
impl UnconsciousAgent {
|
|
fn is_running(&self) -> bool {
|
|
self.handle.as_ref().is_some_and(|h| !h.is_finished())
|
|
}
|
|
|
|
fn should_run(&self) -> bool {
|
|
self.enabled && !self.is_running()
|
|
}
|
|
}
|
|
|
|
/// 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<f64>,
|
|
pub agent: Option<std::sync::Arc<crate::agent::Agent>>,
|
|
pub last_stats: Option<RunStats>,
|
|
/// Recent store activity for this agent: (key, timestamp), newest first.
|
|
pub history: Vec<(String, i64)>,
|
|
pub tool_calls_ewma: f64,
|
|
pub tool_failures_ewma: f64,
|
|
}
|
|
|
|
pub struct Unconscious {
|
|
agents: Vec<UnconsciousAgent>,
|
|
max_concurrent: usize,
|
|
pub graph_health: Option<crate::subconscious::daemon::GraphHealth>,
|
|
last_health_check: Option<Instant>,
|
|
}
|
|
|
|
impl Unconscious {
|
|
pub fn new() -> Self {
|
|
let enabled_map = load_enabled_config();
|
|
|
|
// Scan all .agent files, exclude subconscious-* and surface-observe
|
|
let mut agents: Vec<UnconsciousAgent> = Vec::new();
|
|
let base_tools = tools::memory::memory_tools().to_vec();
|
|
let extra_tools = tools::memory::journal_tools().to_vec();
|
|
for def in defs::load_defs() {
|
|
if def.agent.starts_with("subconscious-") { continue; }
|
|
if def.agent == "surface-observe" { continue; }
|
|
let enabled = enabled_map.get(&def.agent).copied()
|
|
.unwrap_or(false);
|
|
let mut effective_tools = base_tools.clone();
|
|
for name in &def.tools {
|
|
if let Some(t) = extra_tools.iter().find(|t| t.name == name) {
|
|
effective_tools.push(t.clone());
|
|
}
|
|
}
|
|
let steps: Vec<AutoStep> = def.steps.iter().map(|s| AutoStep {
|
|
prompt: s.prompt.clone(),
|
|
phase: s.phase.clone(),
|
|
}).collect();
|
|
let auto = AutoAgent::new(
|
|
def.agent.clone(), effective_tools, steps,
|
|
def.temperature.unwrap_or(0.6), def.priority,
|
|
);
|
|
agents.push(UnconsciousAgent {
|
|
name: def.agent.clone(),
|
|
enabled,
|
|
auto,
|
|
handle: None,
|
|
agent: None,
|
|
last_run: None,
|
|
});
|
|
}
|
|
agents.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
Self {
|
|
agents, max_concurrent: 2,
|
|
graph_health: None,
|
|
last_health_check: None,
|
|
}
|
|
}
|
|
|
|
/// Toggle an agent on/off by name. Returns new enabled state.
|
|
/// If enabling, immediately spawns the agent if it's not running.
|
|
pub async fn toggle(&mut self, name: &str) -> Option<bool> {
|
|
let idx = self.agents.iter().position(|a| a.name == name)?;
|
|
self.agents[idx].enabled = !self.agents[idx].enabled;
|
|
let new_state = self.agents[idx].enabled;
|
|
self.save_enabled();
|
|
if new_state && !self.agents[idx].is_running() {
|
|
self.spawn_agent(idx).await;
|
|
}
|
|
Some(new_state)
|
|
}
|
|
|
|
fn save_enabled(&self) {
|
|
let map: HashMap<String, bool> = self.agents.iter()
|
|
.map(|a| (a.name.clone(), a.enabled))
|
|
.collect();
|
|
save_enabled_config(&map);
|
|
}
|
|
|
|
pub fn snapshots(&self, store: Option<&crate::store::Store>) -> Vec<UnconsciousSnapshot> {
|
|
self.agents.iter().map(|a| {
|
|
let history = store.map(|st| st.recent_by_provenance(&a.name, 30))
|
|
.unwrap_or_default();
|
|
UnconsciousSnapshot {
|
|
name: a.name.clone(),
|
|
running: a.is_running(),
|
|
enabled: a.enabled,
|
|
runs: a.auto.runs,
|
|
last_run_secs_ago: a.last_run.map(|t| t.elapsed().as_secs_f64()),
|
|
agent: a.agent.clone(),
|
|
last_stats: a.auto.last_stats.clone(),
|
|
history,
|
|
tool_calls_ewma: a.auto.tool_calls_ewma,
|
|
tool_failures_ewma: a.auto.tool_failures_ewma,
|
|
}
|
|
}).collect()
|
|
}
|
|
|
|
fn refresh_health(&mut self) {
|
|
let store = match crate::store::Store::load() {
|
|
Ok(s) => s,
|
|
Err(_) => return,
|
|
};
|
|
self.graph_health = Some(crate::subconscious::daemon::compute_graph_health(&store));
|
|
self.last_health_check = Some(Instant::now());
|
|
}
|
|
|
|
/// Reap finished agents and spawn new ones.
|
|
pub async fn trigger(&mut self) {
|
|
// Periodic graph health refresh (also on first call)
|
|
if self.last_health_check
|
|
.map(|t| t.elapsed() > std::time::Duration::from_secs(600))
|
|
.unwrap_or(true)
|
|
{
|
|
self.refresh_health();
|
|
}
|
|
|
|
for agent in &mut self.agents {
|
|
if agent.handle.as_ref().is_some_and(|h| h.is_finished()) {
|
|
let handle = agent.handle.take().unwrap();
|
|
agent.last_run = Some(Instant::now());
|
|
// Get the AutoAgent back from the finished task (stats already updated)
|
|
match handle.now_or_never() {
|
|
Some(Ok((auto_back, result))) => {
|
|
agent.auto = auto_back;
|
|
match result {
|
|
Ok(_) => dbglog!("[unconscious] {} completed (run {})",
|
|
agent.name, agent.auto.runs),
|
|
Err(e) => dbglog!("[unconscious] {} failed: {}", agent.name, e),
|
|
}
|
|
}
|
|
_ => dbglog!("[unconscious] {} task lost", agent.name),
|
|
}
|
|
}
|
|
}
|
|
|
|
let running = self.agents.iter().filter(|a| a.is_running()).count();
|
|
for _ in running..self.max_concurrent {
|
|
let next = self.agents.iter().enumerate()
|
|
.filter(|(_, a)| a.should_run())
|
|
.min_by_key(|(_, a)| a.last_run);
|
|
match next {
|
|
Some((idx, _)) => self.spawn_agent(idx).await,
|
|
None => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
async 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,
|
|
};
|
|
|
|
// Run query and resolve placeholders
|
|
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<String> = 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();
|
|
}
|
|
|
|
// Swap auto out, replace steps with resolved prompts
|
|
let mut auto = std::mem::replace(&mut self.agents[idx].auto,
|
|
AutoAgent::new(String::new(), vec![], vec![], 0.0, 0));
|
|
let orig_steps = std::mem::replace(&mut auto.steps,
|
|
batch.steps.iter().map(|s| AutoStep {
|
|
prompt: s.prompt.clone(),
|
|
phase: s.phase.clone(),
|
|
}).collect());
|
|
|
|
// Create standalone Agent — stored so UI can read context
|
|
let config = crate::config::get();
|
|
let base_url = config.api_base_url.as_deref().unwrap_or("");
|
|
let api_key = config.api_key.as_deref().unwrap_or("");
|
|
let model = config.api_model.as_deref().unwrap_or("");
|
|
if base_url.is_empty() || model.is_empty() {
|
|
dbglog!("[unconscious] API not configured");
|
|
auto.steps = orig_steps;
|
|
self.agents[idx].auto = auto;
|
|
return;
|
|
}
|
|
|
|
let cli = crate::user::CliArgs::default();
|
|
let (app, _) = match crate::config::load_app(&cli) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
dbglog!("[unconscious] config: {}", e);
|
|
auto.steps = orig_steps;
|
|
self.agents[idx].auto = auto;
|
|
return;
|
|
}
|
|
};
|
|
let (system_prompt, personality) = match crate::config::reload_for_model(&app, &app.prompts.other) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
dbglog!("[unconscious] config: {}", e);
|
|
auto.steps = orig_steps;
|
|
self.agents[idx].auto = auto;
|
|
return;
|
|
}
|
|
};
|
|
|
|
let client = crate::agent::api::ApiClient::new(base_url, api_key, model);
|
|
let agent = crate::agent::Agent::new(
|
|
client, system_prompt, personality,
|
|
app, String::new(), None,
|
|
crate::agent::tools::ActiveTools::new(),
|
|
auto.tools.clone(),
|
|
).await;
|
|
{
|
|
let mut st = agent.state.lock().await;
|
|
st.provenance = auto.name.clone();
|
|
st.priority = Some(10);
|
|
}
|
|
|
|
self.agents[idx].agent = Some(agent.clone());
|
|
|
|
self.agents[idx].handle = Some(tokio::spawn(async move {
|
|
let result = auto.run_shared(&agent).await;
|
|
let stats = crate::agent::oneshot::save_agent_log(&auto.name, &agent).await;
|
|
auto.update_stats(stats);
|
|
auto.steps = orig_steps;
|
|
(auto, result)
|
|
}));
|
|
}
|
|
}
|
|
|
|
// save_agent_log and RunStats moved to crate::agent::oneshot
|