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

View file

@ -113,6 +113,10 @@ pub struct MindState {
pub last_turn_had_tools: bool,
/// Handle to the currently running turn task.
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 {
@ -129,6 +133,8 @@ impl Clone for MindState {
consecutive_errors: self.consecutive_errors,
last_turn_had_tools: self.last_turn_had_tools,
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,
last_turn_had_tools: false,
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>>,
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
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_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<BgEvent>>>,
_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 (turn_watch, _) = tokio::sync::watch::channel(false);
let (conscious_active, _) = tokio::sync::watch::channel(false);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
let mut sup = crate::thalamus::supervisor::Supervisor::new();
@ -300,10 +312,53 @@ impl Mind {
let subconscious = Arc::new(tokio::sync::Mutex::new(Subconscious::new()));
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,
subconscious,
unconscious: Arc::new(tokio::sync::Mutex::new(Unconscious::new())),
turn_tx, turn_watch, bg_tx,
subconscious, unconscious,
turn_tx, turn_watch, conscious_active, bg_tx,
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
}
@ -504,6 +559,7 @@ impl Mind {
}
self.shared.lock().unwrap().turn_active = true;
let _ = self.turn_watch.send(true);
let _ = self.conscious_active.send(true);
let agent = self.agent.clone();
let result_tx = self.turn_tx.clone();
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()
.expect("Mind::run() called twice");
let mut sub_handle: Option<tokio::task::JoinHandle<()>> = None;
let mut unc_handle: Option<tokio::task::JoinHandle<()>> = None;
loop {
let (timeout, has_input) = {
let me = self.shared.lock().unwrap();
@ -554,6 +609,7 @@ impl Mind {
}
Some((result, target)) = turn_rx.recv() => {
let _ = self.conscious_active.send(false);
let model_switch = {
let mut s = self.shared.lock().unwrap();
s.turn_handle = None;
@ -584,12 +640,6 @@ impl Mind {
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

View file

@ -278,11 +278,11 @@ 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
("subconscious-surface", 0), // every new conversation content
("subconscious-observe", 1_000), // every ~1KB of conversation
("subconscious-thalamus", 0), // every new conversation content
("subconscious-journal", 10_000), // every ~10KB of conversation
("subconscious-reflect", 10_000), // every ~10KB of conversation
];
/// 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 {
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
}
@ -556,7 +558,8 @@ impl Subconscious {
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 { .. })))
Some(NodeBody::Log(_)) | Some(NodeBody::Memory { .. })
| Some(NodeBody::Dmn(_))))
.map(|node| node.render().len() as u64)
.sum::<u64>();
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
// conversation context. Each agent runs in a loop: finish one run,
// wait a cooldown, start the next. Agents can be toggled on/off,
// persisted to ~/.consciousness/agent-enabled.json.
// start the next. Agents can be toggled on/off, persisted to
// ~/.consciousness/agent-enabled.json.
use std::time::{Duration, Instant};
use std::time::Instant;
use std::collections::HashMap;
use futures::FutureExt;
@ -13,9 +13,6 @@ use crate::agent::oneshot::{AutoAgent, AutoStep};
use crate::agent::tools;
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 {
dirs::home_dir().unwrap_or_default()
.join(".consciousness/agent-enabled.json")
@ -50,11 +47,7 @@ impl UnconsciousAgent {
}
fn should_run(&self) -> bool {
if !self.enabled || self.is_running() { return false; }
match self.last_run {
Some(t) => t.elapsed() >= COOLDOWN,
None => true,
}
self.enabled && !self.is_running()
}
}
@ -167,7 +160,7 @@ impl Unconscious {
pub async fn trigger(&mut self) {
// Periodic graph health refresh (also on first call)
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)
{
self.refresh_health();
@ -194,17 +187,14 @@ impl Unconscious {
}
let running = self.agents.iter().filter(|a| a.is_running()).count();
if running >= self.max_concurrent { return; }
let slots = self.max_concurrent - running;
let ready: Vec<usize> = self.agents.iter().enumerate()
for _ in running..self.max_concurrent {
let next = self.agents.iter().enumerate()
.filter(|(_, a)| a.should_run())
.map(|(i, _)| i)
.take(slots)
.collect();
for idx in ready {
self.spawn_agent(idx).await;
.min_by_key(|(_, a)| a.last_run);
match next {
Some((idx, _)) => self.spawn_agent(idx).await,
None => break,
}
}
}
@ -299,8 +289,25 @@ impl Unconscious {
self.agents[idx].handle = Some(tokio::spawn(async move {
let result = auto.run_shared(&agent).await;
save_agent_log(&auto.name, &agent).await;
auto.steps = orig_steps;
(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 ===
Record what happened in the conversation. You're the librarian of the
memory system — your job is to organize knowledge so it can be found
and used later. Update existing nodes and create new nodes as needed,
adding links so you can find these memories in the future.
Extract knowledge from the conversation into the memory graph. You're
the librarian — your job is to build a conceptual map that's useful for
future work, not a diary of events.
Be factual and specific. For technical work, capture: what the bug was,
what the root cause was, what the fix was, and why. For decisions, capture
the decision and the rationale. For corrections, capture what was wrong
and what was right. These details are what future-you needs.
Think in terms of *concepts*, not episodes. "What did I learn from this"? Find
and update the relevant concepts, and consider what concepts are important to
you - what are the things you'll want to remember and come back to? What will
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
was emotionally significant, note that it was and what the emotion was,
but don't build a theory around it. The journal is for reflection; observe
is for memory.
For technical work: capture the *understanding*, not the narrative. What's the
architecture? What are the invariants? What's the tricky part? For decisions:
capture the principle, not just the instance. For corrections: what's the
general lesson?
Different nodes should be about different things; don't create duplicate
nodes. Here's what you've recently written — update these instead of
creating new ones if the topic overlaps:
{{recently_written}}
Different nodes should be about different things; don't create duplicates.
Here's what you've recently written — update these instead of creating new ones
if the topic overlaps: {{recently_written}}
Before creating a new node, check what you've already walked — if
a node for this concept exists, update it instead of creating a new one.
Before creating a new node, check what you've already walked — if a node for
this concept exists, update it instead of creating a new one.
Some things worth remembering: technical insights and root causes, work
practices and why they work, decisions with rationale, corrections
("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.
Focus on the recent conversation; you run frequently, so most of it should
already be covered.

View file

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

View file

@ -100,6 +100,27 @@ impl ScreenView for ThalamusScreen {
}
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
lines.push(Line::styled("── Channels ──", section));
lines.push(Line::raw(""));