consciousness/src/mind/dmn.rs

496 lines
18 KiB
Rust
Raw Normal View History

// 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::Engaged => Duration::from_secs(5),
State::Working => Duration::from_secs(3),
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::ConversationEntry;
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
];
/// Lightweight snapshot for the TUI.
#[derive(Clone, Default)]
pub struct SubconsciousSnapshot {
pub name: String,
pub running: bool,
pub current_phase: String,
pub turn: usize,
pub last_run_secs_ago: Option<f64>,
pub last_run_entries: Vec<ConversationEntry>,
}
struct SubconsciousAgent {
auto: AutoAgent,
last_trigger_bytes: u64,
last_run: Option<Instant>,
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 { auto, last_trigger_bytes: 0, last_run: None, 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.is_running() { return false; }
if interval == 0 { return true; }
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
}
fn snapshot(&self) -> SubconsciousSnapshot {
SubconsciousSnapshot {
name: self.auto.name.clone(),
running: self.is_running(),
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()),
last_run_entries: self.auto.last_run_entries.clone(),
}
}
}
/// Background agent orchestration — owns the subconscious agents
/// and their shared state (walked keys, etc.).
pub struct Subconscious {
agents: Vec<SubconsciousAgent>,
pub walked: Vec<String>,
}
impl Subconscious {
pub fn new() -> Self {
let agents = AGENTS.iter()
.filter_map(|(name, _)| SubconsciousAgent::new(name))
.collect();
Self { agents, walked: Vec::new() }
}
pub fn snapshots(&self) -> Vec<SubconsciousSnapshot> {
self.agents.iter().map(|s| s.snapshot()).collect()
}
/// Collect results from finished agents, inject outputs into the
/// conscious agent's context.
pub async fn collect_results(&mut self, agent: &Arc<tokio::sync::Mutex<Agent>>) {
let finished: Vec<(usize, tokio::task::JoinHandle<(AutoAgent, Result<String, String>)>)> =
self.agents.iter_mut().enumerate().filter_map(|(i, sub)| {
if sub.handle.as_ref().is_some_and(|h| h.is_finished()) {
sub.last_run = Some(Instant::now());
Some((i, sub.handle.take().unwrap()))
} else {
None
}
}).collect();
for (idx, handle) in finished {
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[idx].auto = auto_back;
match result {
Ok(_) => {
let name = self.agents[idx].auto.name.clone();
let outputs = std::mem::take(&mut self.agents[idx].auto.outputs);
if let Some(walked_str) = outputs.get("walked") {
self.walked = walked_str.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
}
if let Some(surface_str) = outputs.get("surface") {
let mut ag = agent.lock().await;
for key in surface_str.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
if let Some(rendered) = crate::cli::node::render_node(
&crate::store::Store::load().unwrap_or_default(), key,
) {
let mut msg = crate::agent::api::types::Message::user(format!(
"<system-reminder>\n--- {} (surfaced) ---\n{}\n</system-reminder>",
key, rendered,
));
msg.stamp();
ag.push_entry(ConversationEntry::Memory {
key: key.to_string(), message: msg,
});
}
}
}
if let Some(reflection) = outputs.get("reflection") {
if !reflection.trim().is_empty() {
let mut ag = agent.lock().await;
ag.push_message(crate::agent::api::types::Message::user(format!(
"<system-reminder>\n--- subconscious reflection ---\n{}\n</system-reminder>",
reflection.trim(),
)));
}
}
if let Some(nudge) = outputs.get("thalamus") {
let nudge = nudge.trim();
if !nudge.is_empty() && nudge != "ok" {
let mut ag = agent.lock().await;
ag.push_message(crate::agent::api::types::Message::user(format!(
"<system-reminder>\n--- thalamus ---\n{}\n</system-reminder>",
nudge,
)));
}
}
dbglog!("[subconscious] {} completed", name);
}
Err(e) => dbglog!("[subconscious] agent failed: {}", e),
}
}
}
/// Trigger subconscious agents that are due to run.
pub async fn trigger(&mut self, agent: &Arc<tokio::sync::Mutex<Agent>>) {
let (conversation_bytes, memory_keys) = {
let ag = agent.lock().await;
let bytes = ag.context.entries.iter()
.filter(|e| !e.is_log() && !e.is_memory())
.map(|e| e.message().content_text().len() as u64)
.sum::<u64>();
let keys: Vec<String> = ag.context.entries.iter().filter_map(|e| {
if let ConversationEntry::Memory { key, .. } = e {
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; }
let conscious = agent.lock().await;
let walked = self.walked.clone();
for (idx, mut auto) in to_run {
dbglog!("[subconscious] triggering {}", auto.name);
let forked = conscious.fork(auto.tools.clone());
let keys = memory_keys.clone();
let w = walked.clone();
self.agents[idx].handle = Some(tokio::spawn(async move {
let result = auto.run_forked(&forked, &keys, &w).await;
(auto, result)
}));
}
}
}