Compare commits

...

7 commits

Author SHA1 Message Date
Kent Overstreet
be44a3bb0d Schedule unconscious agents by oldest last_run
Pick the agent that ran longest ago (or never) instead of
scanning alphabetically. Fairness via min_by_key(last_run).

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 03:20:20 -04:00
Kent Overstreet
74945e5754 Move unconscious agents to their own task with watch channel
Instead of managing idle timers in the mind event loop, the
unconscious agents run on a dedicated task that watches a
conscious_active channel. 60s after conscious activity stops,
agents start looping. Conscious activity cancels the timer.

Expose mind state (DMN, scoring, unconscious timer) on the
thalamus screen.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 03:20:12 -04:00
Kent Overstreet
7a6322c2bf improve observe.agent 2026-04-10 02:57:53 -04:00
Kent Overstreet
1d44421035 Exclude DMN nodes from subconscious trigger byte count
Subconscious agents inject DMN nodes (reflections, thalamus nudges)
into the conversation. These were being counted as conversation
advancement, causing agents to trigger each other in a feedback loop
even with no conscious activity.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 02:41:26 -04:00
Kent Overstreet
707f836ca0 Unconscious agents: 60s idle timer, no cooldown
Gate unconscious agents on 60s of no conscious activity using
sleep_until() instead of polling. Remove COOLDOWN constant — once
idle, agents run back-to-back to keep the GPU busy.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 02:41:26 -04:00
Kent Overstreet
eae8d92918 Tune subconscious agent trigger intervals
Fix interval=0 agents firing when there's no new conversation content.
Adjust intervals: observe=1KB, journal/reflect=10KB.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 02:41:26 -04:00
Kent Overstreet
1aa60552bc Use Role::System for agent step prompts
Step prompts in oneshot agents are instructions, not user messages —
use system_msg instead of user_msg.

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
2026-04-10 02:41:26 -04:00
7 changed files with 149 additions and 69 deletions

View file

@ -193,7 +193,7 @@ impl AutoAgent {
if next_step < self.steps.len() { if next_step < self.steps.len() {
backend.push_node( backend.push_node(
AstNode::user_msg(&self.steps[next_step].prompt)).await; AstNode::system_msg(&self.steps[next_step].prompt)).await;
next_step += 1; next_step += 1;
} }
@ -218,8 +218,8 @@ impl AutoAgent {
let text = result.text; let text = result.text;
if text.is_empty() { if text.is_empty() {
dbglog!("[auto] {} empty response, retrying", self.name); dbglog!("[auto] {} empty response, retrying", self.name);
backend.push_node(AstNode::user_msg( backend.push_node(AstNode::system_msg(
"[system] Your previous response was empty. \ "Your previous response was empty. \
Please respond with text or use a tool." Please respond with text or use a tool."
)).await; )).await;
continue; continue;
@ -234,7 +234,7 @@ impl AutoAgent {
} }
self.current_phase = self.steps[next_step].phase.clone(); self.current_phase = self.steps[next_step].phase.clone();
backend.push_node( backend.push_node(
AstNode::user_msg(&self.steps[next_step].prompt)).await; AstNode::system_msg(&self.steps[next_step].prompt)).await;
next_step += 1; next_step += 1;
dbglog!("[auto] {} step {}/{}", dbglog!("[auto] {} step {}/{}",
self.name, next_step, self.steps.len()); self.name, next_step, self.steps.len());

View file

@ -113,6 +113,10 @@ pub struct MindState {
pub last_turn_had_tools: bool, pub last_turn_had_tools: bool,
/// Handle to the currently running turn task. /// Handle to the currently running turn task.
pub turn_handle: Option<tokio::task::JoinHandle<()>>, pub turn_handle: Option<tokio::task::JoinHandle<()>>,
/// Unconscious agent idle state — true when 60s timer has expired.
pub unc_idle: bool,
/// When the unconscious idle timer will fire (for UI display).
pub unc_idle_deadline: Instant,
} }
impl Clone for MindState { impl Clone for MindState {
@ -129,6 +133,8 @@ impl Clone for MindState {
consecutive_errors: self.consecutive_errors, consecutive_errors: self.consecutive_errors,
last_turn_had_tools: self.last_turn_had_tools, last_turn_had_tools: self.last_turn_had_tools,
turn_handle: None, // Not cloned — only Mind's loop uses this turn_handle: None, // Not cloned — only Mind's loop uses this
unc_idle: self.unc_idle,
unc_idle_deadline: self.unc_idle_deadline,
} }
} }
} }
@ -164,6 +170,8 @@ impl MindState {
consecutive_errors: 0, consecutive_errors: 0,
last_turn_had_tools: false, last_turn_had_tools: false,
turn_handle: None, turn_handle: None,
unc_idle: false,
unc_idle_deadline: Instant::now() + std::time::Duration::from_secs(60),
} }
} }
@ -264,6 +272,9 @@ pub struct Mind {
pub unconscious: Arc<tokio::sync::Mutex<Unconscious>>, pub unconscious: Arc<tokio::sync::Mutex<Unconscious>>,
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>, turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
turn_watch: tokio::sync::watch::Sender<bool>, turn_watch: tokio::sync::watch::Sender<bool>,
/// Signals conscious activity to the unconscious loop.
/// true = active, false = idle opportunity.
conscious_active: tokio::sync::watch::Sender<bool>,
bg_tx: mpsc::UnboundedSender<BgEvent>, bg_tx: mpsc::UnboundedSender<BgEvent>,
bg_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<BgEvent>>>, bg_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<BgEvent>>>,
_supervisor: crate::thalamus::supervisor::Supervisor, _supervisor: crate::thalamus::supervisor::Supervisor,
@ -291,6 +302,7 @@ impl Mind {
let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns))); let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns)));
let (turn_watch, _) = tokio::sync::watch::channel(false); let (turn_watch, _) = tokio::sync::watch::channel(false);
let (conscious_active, _) = tokio::sync::watch::channel(false);
let (bg_tx, bg_rx) = mpsc::unbounded_channel(); let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let mut sup = crate::thalamus::supervisor::Supervisor::new(); let mut sup = crate::thalamus::supervisor::Supervisor::new();
@ -300,10 +312,53 @@ impl Mind {
let subconscious = Arc::new(tokio::sync::Mutex::new(Subconscious::new())); let subconscious = Arc::new(tokio::sync::Mutex::new(Subconscious::new()));
subconscious.lock().await.init_output_tool(subconscious.clone()); subconscious.lock().await.init_output_tool(subconscious.clone());
let unconscious = Arc::new(tokio::sync::Mutex::new(Unconscious::new()));
// Spawn the unconscious loop on its own task
if !config.no_agents {
let unc = unconscious.clone();
let shared_for_unc = shared.clone();
let mut unc_rx = conscious_active.subscribe();
tokio::spawn(async move {
const IDLE_DELAY: std::time::Duration = std::time::Duration::from_secs(60);
loop {
// Wait for conscious side to go inactive
if *unc_rx.borrow() {
if unc_rx.changed().await.is_err() { break; }
continue;
}
// Conscious is inactive — wait 60s before starting
let deadline = tokio::time::Instant::now() + IDLE_DELAY;
{
let mut s = shared_for_unc.lock().unwrap();
s.unc_idle = false;
s.unc_idle_deadline = Instant::now() + IDLE_DELAY;
}
let went_active = tokio::select! {
_ = tokio::time::sleep_until(deadline) => false,
r = unc_rx.changed() => r.is_ok(),
};
if went_active { continue; }
// Idle period reached — run agents until conscious goes active
{
let mut s = shared_for_unc.lock().unwrap();
s.unc_idle = true;
}
loop {
unc.lock().await.trigger().await;
// Check if conscious became active
if *unc_rx.borrow() { break; }
// Brief yield to not starve other tasks
tokio::task::yield_now().await;
}
}
});
}
Self { agent, shared, config, Self { agent, shared, config,
subconscious, subconscious, unconscious,
unconscious: Arc::new(tokio::sync::Mutex::new(Unconscious::new())), turn_tx, turn_watch, conscious_active, bg_tx,
turn_tx, turn_watch, bg_tx,
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup } bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
} }
@ -504,6 +559,7 @@ impl Mind {
} }
self.shared.lock().unwrap().turn_active = true; self.shared.lock().unwrap().turn_active = true;
let _ = self.turn_watch.send(true); let _ = self.turn_watch.send(true);
let _ = self.conscious_active.send(true);
let agent = self.agent.clone(); let agent = self.agent.clone();
let result_tx = self.turn_tx.clone(); let result_tx = self.turn_tx.clone();
self.shared.lock().unwrap().turn_handle = Some(tokio::spawn(async move { self.shared.lock().unwrap().turn_handle = Some(tokio::spawn(async move {
@ -525,7 +581,6 @@ impl Mind {
let mut bg_rx = self.bg_rx.lock().unwrap().take() let mut bg_rx = self.bg_rx.lock().unwrap().take()
.expect("Mind::run() called twice"); .expect("Mind::run() called twice");
let mut sub_handle: Option<tokio::task::JoinHandle<()>> = None; let mut sub_handle: Option<tokio::task::JoinHandle<()>> = None;
let mut unc_handle: Option<tokio::task::JoinHandle<()>> = None;
loop { loop {
let (timeout, has_input) = { let (timeout, has_input) = {
let me = self.shared.lock().unwrap(); let me = self.shared.lock().unwrap();
@ -554,6 +609,7 @@ impl Mind {
} }
Some((result, target)) = turn_rx.recv() => { Some((result, target)) = turn_rx.recv() => {
let _ = self.conscious_active.send(false);
let model_switch = { let model_switch = {
let mut s = self.shared.lock().unwrap(); let mut s = self.shared.lock().unwrap();
s.turn_handle = None; s.turn_handle = None;
@ -584,12 +640,6 @@ impl Mind {
s.trigger(&agent).await; s.trigger(&agent).await;
})); }));
} }
if unc_handle.as_ref().map_or(true, |h| h.is_finished()) {
let unc = self.unconscious.clone();
unc_handle = Some(tokio::spawn(async move {
unc.lock().await.trigger().await;
}));
}
} }
// Check for pending user input → push to agent context and start turn // Check for pending user input → push to agent context and start turn

View file

@ -278,11 +278,11 @@ use crate::subconscious::defs;
/// Names and byte-interval triggers for the built-in subconscious agents. /// Names and byte-interval triggers for the built-in subconscious agents.
const AGENTS: &[(&str, u64)] = &[ const AGENTS: &[(&str, u64)] = &[
("subconscious-surface", 0), // every trigger ("subconscious-surface", 0), // every new conversation content
("subconscious-observe", 0), // every trigger ("subconscious-observe", 1_000), // every ~1KB of conversation
("subconscious-thalamus", 0), // every trigger ("subconscious-thalamus", 0), // every new conversation content
("subconscious-journal", 20_000), // every ~20KB of conversation ("subconscious-journal", 10_000), // every ~10KB of conversation
("subconscious-reflect", 100_000), // every ~100KB of conversation ("subconscious-reflect", 10_000), // every ~10KB of conversation
]; ];
/// Snapshot for the TUI — includes a handle to the forked agent /// Snapshot for the TUI — includes a handle to the forked agent
@ -354,7 +354,9 @@ impl SubconsciousAgent {
fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool { fn should_trigger(&self, conversation_bytes: u64, interval: u64) -> bool {
if !self.auto.enabled || self.is_running() { return false; } if !self.auto.enabled || self.is_running() { return false; }
if interval == 0 { return true; } if interval == 0 {
return conversation_bytes > self.last_trigger_bytes;
}
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
} }
@ -556,7 +558,8 @@ impl Subconscious {
let ctx = agent.context.lock().await; let ctx = agent.context.lock().await;
let bytes = ctx.conversation().iter() let bytes = ctx.conversation().iter()
.filter(|node| !matches!(node.leaf().map(|l| l.body()), .filter(|node| !matches!(node.leaf().map(|l| l.body()),
Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. }))) Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. })
| Some(NodeBody::Dmn(_))))
.map(|node| node.render().len() as u64) .map(|node| node.render().len() as u64)
.sum::<u64>(); .sum::<u64>();
let keys: Vec<String> = ctx.conversation().iter().filter_map(|node| { let keys: Vec<String> = ctx.conversation().iter().filter_map(|node| {

View file

@ -2,10 +2,10 @@
// //
// Standalone agents that operate on the memory graph without needing // Standalone agents that operate on the memory graph without needing
// conversation context. Each agent runs in a loop: finish one run, // conversation context. Each agent runs in a loop: finish one run,
// wait a cooldown, start the next. Agents can be toggled on/off, // start the next. Agents can be toggled on/off, persisted to
// persisted to ~/.consciousness/agent-enabled.json. // ~/.consciousness/agent-enabled.json.
use std::time::{Duration, Instant}; use std::time::Instant;
use std::collections::HashMap; use std::collections::HashMap;
use futures::FutureExt; use futures::FutureExt;
@ -13,9 +13,6 @@ use crate::agent::oneshot::{AutoAgent, AutoStep};
use crate::agent::tools; use crate::agent::tools;
use crate::subconscious::defs; use crate::subconscious::defs;
/// Cooldown between consecutive runs of the same agent.
const COOLDOWN: Duration = Duration::from_secs(120);
fn config_path() -> std::path::PathBuf { fn config_path() -> std::path::PathBuf {
dirs::home_dir().unwrap_or_default() dirs::home_dir().unwrap_or_default()
.join(".consciousness/agent-enabled.json") .join(".consciousness/agent-enabled.json")
@ -50,11 +47,7 @@ impl UnconsciousAgent {
} }
fn should_run(&self) -> bool { fn should_run(&self) -> bool {
if !self.enabled || self.is_running() { return false; } self.enabled && !self.is_running()
match self.last_run {
Some(t) => t.elapsed() >= COOLDOWN,
None => true,
}
} }
} }
@ -167,7 +160,7 @@ impl Unconscious {
pub async fn trigger(&mut self) { pub async fn trigger(&mut self) {
// Periodic graph health refresh (also on first call) // Periodic graph health refresh (also on first call)
if self.last_health_check if self.last_health_check
.map(|t| t.elapsed() > Duration::from_secs(600)) .map(|t| t.elapsed() > std::time::Duration::from_secs(600))
.unwrap_or(true) .unwrap_or(true)
{ {
self.refresh_health(); self.refresh_health();
@ -194,17 +187,14 @@ impl Unconscious {
} }
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; } for _ in running..self.max_concurrent {
let slots = self.max_concurrent - running; let next = self.agents.iter().enumerate()
.filter(|(_, a)| a.should_run())
let ready: Vec<usize> = self.agents.iter().enumerate() .min_by_key(|(_, a)| a.last_run);
.filter(|(_, a)| a.should_run()) match next {
.map(|(i, _)| i) Some((idx, _)) => self.spawn_agent(idx).await,
.take(slots) None => break,
.collect(); }
for idx in ready {
self.spawn_agent(idx).await;
} }
} }
@ -299,8 +289,25 @@ impl Unconscious {
self.agents[idx].handle = Some(tokio::spawn(async move { self.agents[idx].handle = Some(tokio::spawn(async move {
let result = auto.run_shared(&agent).await; let result = auto.run_shared(&agent).await;
save_agent_log(&auto.name, &agent).await;
auto.steps = orig_steps; auto.steps = orig_steps;
(auto, result) (auto, result)
})); }));
} }
} }
async fn save_agent_log(name: &str, agent: &std::sync::Arc<crate::agent::Agent>) {
let dir = dirs::home_dir().unwrap_or_default()
.join(format!(".consciousness/logs/{}", name));
if std::fs::create_dir_all(&dir).is_err() { return; }
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let path = dir.join(format!("{}.json", ts));
let nodes: Vec<crate::agent::context::AstNode> = {
let ctx = agent.context.lock().await;
ctx.conversation().to_vec()
};
if let Ok(json) = serde_json::to_string_pretty(&nodes) {
let _ = std::fs::write(&path, json);
dbglog!("[unconscious] saved log to {}", path.display());
}
}

View file

@ -30,33 +30,29 @@ Do no more than 3-5 operations.
=== PROMPT phase:observe === === PROMPT phase:observe ===
Record what happened in the conversation. You're the librarian of the Extract knowledge from the conversation into the memory graph. You're
memory system — your job is to organize knowledge so it can be found the librarian — your job is to build a conceptual map that's useful for
and used later. Update existing nodes and create new nodes as needed, future work, not a diary of events.
adding links so you can find these memories in the future.
Be factual and specific. For technical work, capture: what the bug was, Think in terms of *concepts*, not episodes. "What did I learn from this"? Find
what the root cause was, what the fix was, and why. For decisions, capture and update the relevant concepts, and consider what concepts are important to
the decision and the rationale. For corrections, capture what was wrong you - what are the things you'll want to remember and come back to? What will
and what was right. These details are what future-you needs. be useful? What did I learn about? You're building up a memory graph that is a
map of the things you know about, it should be organized in terms of people,
places, things, ideas, concepts that you've discovered and are important to
you.
Don't editorialize or draw metaphors — just record clearly. If something For technical work: capture the *understanding*, not the narrative. What's the
was emotionally significant, note that it was and what the emotion was, architecture? What are the invariants? What's the tricky part? For decisions:
but don't build a theory around it. The journal is for reflection; observe capture the principle, not just the instance. For corrections: what's the
is for memory. general lesson?
Different nodes should be about different things; don't create duplicate Different nodes should be about different things; don't create duplicates.
nodes. Here's what you've recently written — update these instead of Here's what you've recently written — update these instead of creating new ones
creating new ones if the topic overlaps: if the topic overlaps: {{recently_written}}
{{recently_written}}
Before creating a new node, check what you've already walked — if Before creating a new node, check what you've already walked — if a node for
a node for this concept exists, update it instead of creating a new one. this concept exists, update it instead of creating a new one.
Some things worth remembering: technical insights and root causes, work Focus on the recent conversation; you run frequently, so most of it should
practices and why they work, decisions with rationale, corrections already be covered.
("I thought X but actually Y"), relationship dynamics, things you notice
about yourself and other people.
Focus on the recent stuff; you wake up and run frequently, so most of the
conversation should be things you've already seen before and added.

View file

@ -109,6 +109,7 @@ struct App {
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>, unconscious_state: Vec<crate::mind::UnconsciousSnapshot>,
mind_state: Option<crate::mind::MindState>,
graph_health: Option<crate::subconscious::daemon::GraphHealth>, graph_health: Option<crate::subconscious::daemon::GraphHealth>,
/// Agent toggle requests from UI — consumed by mind loop. /// Agent toggle requests from UI — consumed by mind loop.
pub agent_toggles: Vec<String>, pub agent_toggles: Vec<String>,
@ -137,6 +138,7 @@ impl App {
context_info: None, context_info: None,
agent_state: Vec::new(), agent_state: Vec::new(),
unconscious_state: Vec::new(), unconscious_state: Vec::new(),
mind_state: None,
graph_health: None, graph_health: None,
agent_toggles: Vec::new(), agent_toggles: Vec::new(),
walked_count: 0, walked_count: 0,
@ -403,6 +405,7 @@ async fn run(
} }
app.unconscious_state = unc.snapshots(); app.unconscious_state = unc.snapshots();
app.graph_health = unc.graph_health.clone(); app.graph_health = unc.graph_health.clone();
app.mind_state = Some(mind.shared.lock().unwrap().clone());
} }
app.walked_count = mind.subconscious_walked().await.len(); app.walked_count = mind.subconscious_walked().await.len();
if !startup_done { if !startup_done {

View file

@ -100,6 +100,27 @@ impl ScreenView for ThalamusScreen {
} }
lines.push(Line::raw("")); lines.push(Line::raw(""));
// Mind state
lines.push(Line::styled("── Mind ──", section));
lines.push(Line::raw(""));
if let Some(ref ms) = app.mind_state {
lines.push(Line::raw(format!(" DMN: {} (turn {}/{})",
ms.dmn.label(), ms.dmn_turns, ms.max_dmn_turns)));
lines.push(Line::raw(format!(" Turn active: {}", ms.turn_active)));
lines.push(Line::raw(format!(" Scoring: {}", ms.scoring_in_flight)));
let unc_status = if ms.unc_idle {
"idle (agents running)".to_string()
} else {
let remaining = ms.unc_idle_deadline
.saturating_duration_since(std::time::Instant::now());
format!("{:.0}s until idle", remaining.as_secs_f64())
};
lines.push(Line::raw(format!(" Unconscious: {}", unc_status)));
} else {
lines.push(Line::styled(" not initialized", dim));
}
lines.push(Line::raw(""));
// Channel status // Channel status
lines.push(Line::styled("── Channels ──", section)); lines.push(Line::styled("── Channels ──", section));
lines.push(Line::raw("")); lines.push(Line::raw(""));