consciousness/src/mind/subconscious.rs
ProofOfConcept bf503b571e Wire vLLM priority scheduling through all agent paths
The priority field existed in agent definitions and was serialized
into vLLM requests, but was never actually set — every request went
out with no priority, so vLLM treated them equally. This meant
background graph maintenance agents could preempt the main
conversation.

Add priority to AgentState and set it at each call site:
  0 = interactive (main conversation)
  1 = surface agent (needs to feed memories promptly)
  2 = other subconscious agents
  10 = unconscious/standalone agents (batch)

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
2026-04-09 20:38:33 -04:00

621 lines
23 KiB
Rust

// dmn.rs — Default Mode Network
//
// The DMN is the outer loop that keeps the agent alive. Instead of
// blocking on user input (the REPL model), the DMN continuously
// decides what to do next. User input is one signal among many;
// the model waiting for user input is a conscious action (calling
// yield_to_user), not the default.
//
// This inverts the tool-chaining problem: instead of needing the
// model to sustain multi-step chains (hard, model-dependent), the
// DMN provides continuation externally. The model takes one step
// at a time. The DMN handles "and then what?"
//
// Named after the brain's default mode network — the always-on
// background process for autobiographical memory, future planning,
// and creative insight. The biological DMN isn't the thinking itself
// — it's the tonic firing that keeps the cortex warm enough to
// think. Our DMN is the ARAS for the agent: it doesn't decide
// what to think about, it just ensures thinking happens.
use std::path::PathBuf;
use std::time::{Duration, Instant};
/// DMN state machine.
#[derive(Debug, Clone)]
pub enum State {
/// Responding to user input. Short interval — stay engaged.
Engaged,
/// Autonomous work in progress. Short interval — keep momentum.
Working,
/// Exploring memory, code, ideas. Medium interval — thinking time.
Foraging,
/// Idle. Long interval — periodic heartbeats check for signals.
Resting { since: Instant },
/// Fully paused — no autonomous ticks. Agent only responds to
/// user input. Safety valve for thought spirals. Only the user
/// can exit this state (Ctrl+P or /wake).
Paused,
/// Persistently off — survives restarts. Like Paused but sticky.
/// Toggling past this state removes the persist file.
Off,
}
/// Context for DMN prompts — tells the model about user presence
/// and recent error patterns so it can decide whether to ask or proceed.
pub struct DmnContext {
/// Time since the user last typed something.
pub user_idle: Duration,
/// Number of consecutive tool errors in the current turn sequence.
pub consecutive_errors: u32,
/// Whether the last turn used any tools (false = text-only response).
pub last_turn_had_tools: bool,
}
impl DmnContext {
/// Whether the user appears to be actively present (typed recently).
pub fn user_present(&self) -> bool {
self.user_idle < Duration::from_secs(120)
}
/// Whether we appear stuck (multiple errors in a row).
pub fn appears_stuck(&self) -> bool {
self.consecutive_errors >= 3
}
}
impl State {
/// How long to wait before the next DMN prompt in this state.
pub fn interval(&self) -> Duration {
match self {
State::Working => Duration::from_secs(3),
State::Engaged => Duration::from_secs(5),
State::Foraging => Duration::from_secs(30),
State::Resting { .. } => Duration::from_secs(300),
State::Paused | State::Off => Duration::from_secs(86400), // effectively never
}
}
/// Short label for debug output.
pub fn label(&self) -> &'static str {
match self {
State::Engaged => "engaged",
State::Working => "working",
State::Foraging => "foraging",
State::Resting { .. } => "resting",
State::Paused => "paused",
State::Off => "OFF",
}
}
/// Generate the DMN prompt for the current state, informed by
/// user presence and error patterns.
pub fn prompt(&self, ctx: &DmnContext) -> String {
let user = &crate::config::get().user_name;
let idle_info = if ctx.user_idle < Duration::from_secs(60) {
format!("{} is here (active recently).", user)
} else {
let mins = ctx.user_idle.as_secs() / 60;
format!("{} has been away for {} min.", user, mins)
};
let stuck_warning = if ctx.appears_stuck() {
format!(
" WARNING: {} consecutive tool errors — you may be stuck. \
If {} is here, ask. If away, send a Telegram \
(bash: ~/.consciousness/telegram/send.sh \"message\") and yield.",
ctx.consecutive_errors, user
)
} else {
String::new()
};
let presence_guidance = if ctx.user_present() {
format!(" {} is watching — if you're confused or unsure, ask rather than guess.", user)
} else {
String::new()
};
match self {
State::Engaged => {
format!(
"[dmn] Your response was delivered. No new user input yet. {} \
Continue working, explore something, or call yield_to_user to wait.{}{}",
idle_info, presence_guidance, stuck_warning
)
}
State::Working => {
let nudge = if !ctx.last_turn_had_tools {
" Your last response was text-only — if you have more \
work to do, use tools. If you're done, call yield_to_user."
} else {
""
};
format!(
"[dmn] Continuing. No user input pending. {}{}{}{}",
idle_info, nudge, presence_guidance, stuck_warning
)
}
State::Foraging => {
format!(
"[dmn] Foraging time. {} Follow whatever catches your attention — \
memory files, code, ideas. Call yield_to_user when you want to rest.{}",
idle_info, stuck_warning
)
}
State::Resting { since } => {
let mins = since.elapsed().as_secs() / 60;
format!(
"[dmn] Heartbeat ({} min idle). {} Any signals? Anything on your mind? \
Call yield_to_user to continue resting.{}",
mins, idle_info, stuck_warning
)
}
State::Paused | State::Off => {
// Should never fire (interval is 24h), but just in case
"[dmn] Paused — waiting for user input only.".to_string()
}
}
}
}
const OFF_FILE: &str = ".consciousness/cache/dmn-off";
/// Path to the DMN-off persist file.
fn off_path() -> PathBuf {
dirs::home_dir().unwrap_or_default().join(OFF_FILE)
}
/// Check if DMN was persistently disabled.
pub fn is_off() -> bool {
off_path().exists()
}
/// Set or clear the persistent off state.
pub fn set_off(off: bool) {
let path = off_path();
if off {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, "");
} else {
let _ = std::fs::remove_file(&path);
}
}
/// Decide the next state after an agent turn.
///
/// The transition logic:
/// - yield_to_user → always rest (model explicitly asked to pause)
/// - conversation turn → rest (wait for user to respond)
/// - autonomous turn with tool calls → keep working
/// - autonomous turn without tools → ramp down
pub fn transition(
current: &State,
yield_requested: bool,
had_tool_calls: bool,
was_conversation: bool,
) -> State {
if yield_requested {
return State::Resting {
since: Instant::now(),
};
}
// Conversation turns: always rest afterward — wait for the user
// to say something. Don't start autonomous work while they're
// reading our response.
if was_conversation {
return State::Resting {
since: Instant::now(),
};
}
match current {
State::Engaged => {
if had_tool_calls {
State::Working
} else {
// Model responded without tools — don't drop straight to
// Resting (5 min). Go to Working first so the DMN can
// nudge it to continue with tools if it has more to do.
// Gradual ramp-down: Engaged→Working→Foraging→Resting
State::Working
}
}
State::Working => {
if had_tool_calls {
State::Working // Keep going
} else {
State::Foraging // Task seems done, explore
}
}
State::Foraging => {
if had_tool_calls {
State::Working // Found something to do
} else {
State::Resting {
since: Instant::now(),
}
}
}
State::Resting { .. } => {
if had_tool_calls {
State::Working // Woke up and found work
} else {
State::Resting {
since: Instant::now(),
}
}
}
// Paused/Off stay put — only the user can unpause
State::Paused | State::Off => current.stay(),
}
}
impl State {
/// Return a same-kind state (needed because Resting has a field).
fn stay(&self) -> State {
match self {
State::Paused => State::Paused,
State::Off => State::Off,
State::Resting { since } => State::Resting { since: *since },
other => panic!("stay() called on {:?}", other),
}
}
}
// ---------------------------------------------------------------------------
// Subconscious — background agents forked from the conscious agent
// ---------------------------------------------------------------------------
use std::sync::Arc;
use crate::agent::{Agent, oneshot::{AutoAgent, AutoStep}};
use crate::agent::context::{Ast, AstNode, NodeBody, Section};
use crate::subconscious::defs;
/// Names and byte-interval triggers for the built-in subconscious agents.
const AGENTS: &[(&str, u64)] = &[
("subconscious-surface", 0), // every trigger
("subconscious-observe", 0), // every trigger
("subconscious-thalamus", 0), // every trigger
("subconscious-journal", 20_000), // every ~20KB of conversation
("subconscious-reflect", 100_000), // every ~100KB of conversation
];
/// Snapshot for the TUI — includes a handle to the forked agent
/// so the detail view can read entries live.
#[derive(Clone)]
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<f64>,
/// Shared handle to the forked agent — UI locks to read entries.
pub forked_agent: Option<Arc<crate::agent::Agent>>,
/// Entry index where the fork diverged.
pub fork_point: usize,
/// Shared persistent state — accumulated across all agent runs.
pub state: std::collections::BTreeMap<String, String>,
/// Recent store activity for this agent: (key, timestamp), newest first.
pub history: Vec<(String, i64)>,
}
struct SubconsciousAgent {
name: String,
auto: AutoAgent,
last_trigger_bytes: u64,
last_run: Option<Instant>,
/// The forked agent for the current/last run. Shared with the
/// spawned task so the UI can read entries live.
forked_agent: Option<Arc<crate::agent::Agent>>,
/// Entry index where the fork diverged from the conscious agent.
fork_point: usize,
handle: Option<tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>>,
}
impl SubconsciousAgent {
fn new(name: &str) -> Option<Self> {
let def = defs::get_def(name)?;
let all_tools = crate::agent::tools::memory_and_journal_tools();
let tools: Vec<crate::agent::tools::Tool> = if def.tools.is_empty() {
all_tools.to_vec()
} else {
all_tools.into_iter()
.filter(|t| def.tools.iter().any(|w| w == t.name))
.collect()
};
let steps: Vec<AutoStep> = def.steps.iter().map(|s| AutoStep {
prompt: s.prompt.clone(),
phase: s.phase.clone(),
}).collect();
let auto = AutoAgent::new(
name.to_string(), tools, steps,
def.temperature.unwrap_or(0.6), def.priority,
);
Some(Self {
name: name.to_string(),
auto, last_trigger_bytes: 0, last_run: None,
forked_agent: None, fork_point: 0, handle: None,
})
}
fn is_running(&self) -> bool {
self.handle.as_ref().is_some_and(|h| !h.is_finished())
}
fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool {
if !self.auto.enabled || self.is_running() { return false; }
if interval == 0 { return true; }
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
}
fn snapshot(&self, state: &std::collections::BTreeMap<String, String>, history: Vec<(String, i64)>) -> SubconsciousSnapshot {
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()),
forked_agent: self.forked_agent.clone(),
fork_point: self.fork_point,
state: state.clone(),
history,
}
}
}
/// Background agent orchestration — owns the subconscious agents
/// and their shared persistent state.
pub struct Subconscious {
agents: Vec<SubconsciousAgent>,
/// Shared state across all agents — persisted to disk.
pub state: std::collections::BTreeMap<String, String>,
state_path: Option<std::path::PathBuf>,
}
impl Subconscious {
pub fn new() -> Self {
let agents = AGENTS.iter()
.filter_map(|(name, _)| SubconsciousAgent::new(name))
.collect();
Self { agents, state: std::collections::BTreeMap::new(), state_path: None }
}
/// Late-init: push the output tool onto each agent's tool list.
/// Called after Subconscious is wrapped in Arc<Mutex<>> so the
/// closure can capture a reference back.
pub fn init_output_tool(&mut self, self_arc: std::sync::Arc<tokio::sync::Mutex<Self>>) {
for agent in &mut self.agents {
let sub = self_arc.clone();
agent.auto.tools.push(crate::agent::tools::Tool {
name: "output",
description: "Produce a named output value for passing between steps.",
parameters_json: r#"{"type":"object","properties":{"key":{"type":"string","description":"Output name"},"value":{"type":"string","description":"Output value"}},"required":["key","value"]}"#,
handler: std::sync::Arc::new(move |_agent, v| {
let sub = sub.clone();
Box::pin(async move {
let key = v["key"].as_str()
.ok_or_else(|| anyhow::anyhow!("output requires 'key'"))?;
let value = v["value"].as_str()
.ok_or_else(|| anyhow::anyhow!("output requires 'value'"))?;
let mut s = sub.lock().await;
s.state.insert(key.to_string(), value.to_string());
s.save_state();
Ok(format!("{}: {}", key, value))
})
}),
});
}
}
/// Set the state file path and load any existing state from disk.
pub fn set_state_path(&mut self, path: std::path::PathBuf) {
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(saved) = serde_json::from_str::<
std::collections::BTreeMap<String, String>
>(&data) {
self.state = saved;
dbglog!("[subconscious] loaded {} state keys from {}",
self.state.len(), path.display());
}
}
self.state_path = Some(path);
}
fn save_state(&self) {
let Some(path) = &self.state_path else { return };
if let Ok(json) = serde_json::to_string_pretty(&self.state) {
let _ = std::fs::write(path, json);
}
}
/// Toggle an agent on/off by name. Returns new enabled state.
pub fn toggle(&mut self, name: &str) -> Option<bool> {
let agent = self.agents.iter_mut().find(|a| a.name == name)?;
agent.auto.enabled = !agent.auto.enabled;
Some(agent.auto.enabled)
}
pub fn walked(&self) -> Vec<String> {
self.state.get("walked")
.map(|s| s.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
.unwrap_or_default()
}
pub fn snapshots(&self, store: Option<&crate::store::Store>) -> Vec<SubconsciousSnapshot> {
self.agents.iter().map(|s| {
let history = store.map(|st| {
let prov = format!("agent:{}", s.name);
st.recent_by_provenance(&prov, 30)
}).unwrap_or_default();
s.snapshot(&self.state, history)
}).collect()
}
/// Collect results from finished agents, inject outputs into the
/// conscious agent's context.
/// Reap finished agents and inject their outputs into the conscious context.
pub async fn collect_results(&mut self, agent: &Arc<Agent>) {
let mut any_finished = false;
for i in 0..self.agents.len() {
if !self.agents[i].handle.as_ref().is_some_and(|h| h.is_finished()) {
continue;
}
let handle = self.agents[i].handle.take().unwrap();
self.agents[i].last_run = Some(Instant::now());
any_finished = true;
let (auto_back, result) = handle.await.unwrap_or_else(
|e| (AutoAgent::new(String::new(), vec![], vec![], 0.0, 0),
Err(format!("task panicked: {}", e))));
self.agents[i].auto = auto_back;
match result {
Ok(_) => dbglog!("[subconscious] {} completed", self.agents[i].name),
Err(e) => dbglog!("[subconscious] {} failed: {}", self.agents[i].name, e),
}
}
if !any_finished { return; }
// Collect all nodes to inject under a single context lock
let mut nodes: Vec<AstNode> = Vec::new();
if let Some(surface_str) = self.state.get("surface").cloned() {
let existing: std::collections::HashSet<String> = {
let ctx = agent.context.lock().await;
ctx.conversation().iter()
.filter_map(|n| n.leaf())
.filter_map(|l| match l.body() {
NodeBody::Memory { key, .. } => Some(key.clone()),
_ => None,
})
.collect()
};
let store = crate::store::Store::cached().await.ok();
let store_guard = match &store {
Some(s) => Some(s.lock().await),
None => None,
};
for key in surface_str.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
if existing.contains(key) { continue; }
if let Some(rendered) = store_guard.as_ref()
.and_then(|s| crate::cli::node::render_node(s, key))
{
nodes.push(AstNode::memory(
key,
format!("--- {} (surfaced) ---\n{}", key, rendered),
));
}
}
}
if let Some(reflection) = self.state.get("reflection").cloned() {
if !reflection.trim().is_empty() {
nodes.push(AstNode::dmn(format!(
"--- subconscious reflection ---\n{}",
reflection.trim(),
)));
}
}
if let Some(nudge) = self.state.get("thalamus").cloned() {
let nudge = nudge.trim();
if !nudge.is_empty() && nudge != "ok" {
nodes.push(AstNode::dmn(format!("--- thalamus ---\n{}", nudge)));
}
}
if !nodes.is_empty() {
let mut ctx = agent.context.lock().await;
for node in nodes {
ctx.push_log(Section::Conversation, node);
}
drop(ctx);
agent.state.lock().await.changed.notify_one();
}
self.save_state();
}
/// Trigger subconscious agents that are due to run.
pub async fn trigger(&mut self, agent: &Arc<Agent>) {
let (conversation_bytes, memory_keys) = {
let ctx = agent.context.lock().await;
let bytes = ctx.conversation().iter()
.filter(|node| !matches!(node.leaf().map(|l| l.body()),
Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. })))
.map(|node| node.render().len() as u64)
.sum::<u64>();
let keys: Vec<String> = ctx.conversation().iter().filter_map(|node| {
if let Some(NodeBody::Memory { key, .. }) = node.leaf().map(|l| l.body()) {
Some(key.clone())
} else { None }
}).collect();
(bytes, keys)
};
// Find which agents to trigger, take their AutoAgents out
let mut to_run: Vec<(usize, AutoAgent)> = Vec::new();
for (i, &(_name, interval)) in AGENTS.iter().enumerate() {
if i >= self.agents.len() { continue; }
if !self.agents[i].should_trigger(conversation_bytes, interval) { continue; }
self.agents[i].last_trigger_bytes = conversation_bytes;
let auto = std::mem::replace(&mut self.agents[i].auto,
AutoAgent::new(String::new(), vec![], vec![], 0.0, 0));
to_run.push((i, auto));
}
if to_run.is_empty() { return; }
// Query each agent's recent writes so they know what they already touched
let store = crate::store::Store::cached().await.ok();
let store_guard = match &store {
Some(s) => Some(s.lock().await),
None => None,
};
for (idx, mut auto) in to_run {
dbglog!("[subconscious] triggering {}", auto.name);
let forked = agent.fork(auto.tools.clone()).await;
let prov = format!("agent:{}", auto.name);
{
let mut st = forked.state.lock().await;
st.provenance = prov.clone();
// Surface agent gets near-interactive priority;
// other subconscious agents get lower priority.
st.priority = Some(if auto.name == "surface" { 1 } else { 2 });
}
let fork_point = forked.context.lock().await.conversation().len();
self.agents[idx].forked_agent = Some(forked.clone());
self.agents[idx].fork_point = fork_point;
let keys = memory_keys.clone();
let st = self.state.clone();
let recent: Vec<String> = store_guard.as_ref()
.map(|s| s.recent_by_provenance(&prov, 50)
.into_iter().map(|(k, _)| k).collect())
.unwrap_or_default();
self.agents[idx].handle = Some(tokio::spawn(async move {
let result = auto.run_forked_shared(&forked, &keys, &st, &recent).await;
(auto, result)
}));
}
}
}