Wire subconscious agents through Mind via AutoAgent

Mind now holds SubconsciousAgent state (surface-observe, journal,
reflect) and triggers them after conscious turns complete. Each
agent forks from the conscious agent's context via AutoAgent,
runs as an async task, and routes output (surfaced memories,
reflections) back into the conscious agent.

Replaces the synchronous AgentCycleState that spawned child
processes and blocked start_turn.

Also adds .agent2 files — simplified prompts for the forked model
that strip {{conversation}} and {{agent-context}} (already in the
forked context).

TODO: resolve remaining placeholders (seen_current, input:walked,
memory_ratio) in the .agent2 prompts.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-07 01:33:07 -04:00
parent b37b6d7495
commit 58ff9a4d50
4 changed files with 406 additions and 39 deletions

View file

@ -23,8 +23,55 @@ use std::time::Instant;
use tokio::sync::mpsc;
use crate::agent::{Agent, TurnResult};
use crate::agent::api::ApiClient;
use crate::agent::oneshot::{AutoAgent, AutoStep};
use crate::config::{AppConfig, SessionConfig};
use crate::subconscious::learn;
use crate::subconscious::{defs, learn};
// ---------------------------------------------------------------------------
// Subconscious agents — forked from conscious agent, run on schedule
// ---------------------------------------------------------------------------
/// A subconscious agent managed by Mind.
struct SubconsciousAgent {
name: String,
def: defs::AgentDef,
/// Conversation bytes at last trigger.
last_trigger_bytes: u64,
/// When the agent last ran.
last_run: Option<Instant>,
/// Running task handle + AutoAgent for status.
handle: Option<tokio::task::JoinHandle<Result<String, String>>>,
}
/// Names and byte-interval triggers for the built-in subconscious agents.
const SUBCONSCIOUS_AGENTS: &[(&str, u64)] = &[
("surface-observe", 0), // every trigger
("journal", 20_000), // every ~20KB of conversation
("reflect", 100_000), // every ~100KB of conversation
];
impl SubconsciousAgent {
fn new(name: &str, interval_bytes: u64) -> Option<Self> {
let def = defs::get_def(name)?;
Some(Self {
name: name.to_string(),
def,
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; } // trigger every time
conversation_bytes.saturating_sub(self.last_trigger_bytes) >= interval
}
}
/// Which pane streaming text should go to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamTarget {
@ -199,7 +246,7 @@ pub struct Mind {
pub agent: Arc<tokio::sync::Mutex<Agent>>,
pub shared: Arc<SharedMindState>,
pub config: SessionConfig,
agent_cycles: tokio::sync::Mutex<crate::subconscious::subconscious::AgentCycleState>,
subconscious: tokio::sync::Mutex<Vec<SubconsciousAgent>>,
turn_tx: mpsc::Sender<(Result<TurnResult>, StreamTarget)>,
turn_watch: tokio::sync::watch::Sender<bool>,
bg_tx: mpsc::UnboundedSender<BgEvent>,
@ -230,9 +277,12 @@ impl Mind {
shared_context,
shared_active_tools,
);
let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&ag.session_id);
let agent = Arc::new(tokio::sync::Mutex::new(ag));
let subconscious = SUBCONSCIOUS_AGENTS.iter()
.filter_map(|(name, interval)| SubconsciousAgent::new(name, *interval))
.collect();
let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns)));
let (turn_watch, _) = tokio::sync::watch::channel(false);
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
@ -241,7 +291,7 @@ impl Mind {
sup.load_config();
sup.ensure_running();
Self { agent, shared, config, agent_cycles: tokio::sync::Mutex::new(agent_cycles),
Self { agent, shared, config, subconscious: tokio::sync::Mutex::new(subconscious),
turn_tx, turn_watch, bg_tx,
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
}
@ -343,44 +393,147 @@ impl Mind {
/// The text moves from pending_input to ContextState atomically —
/// by the time this returns, the message is in context and the turn
/// is running.
/// Collect results from finished subconscious agents and inject
/// their output into the conscious agent's context.
async fn collect_subconscious_results(&self) {
// Collect finished handles without holding the lock across await
let finished: Vec<(String, tokio::task::JoinHandle<Result<String, String>>)> = {
let mut subs = self.subconscious.lock().await;
subs.iter_mut().filter_map(|sub| {
if sub.handle.as_ref().is_some_and(|h| h.is_finished()) {
sub.last_run = Some(Instant::now());
Some((sub.name.clone(), sub.handle.take().unwrap()))
} else {
None
}
}).collect()
};
for (name, handle) in finished {
match handle.await {
Ok(Ok(_output)) => {
let output_dir = crate::store::memory_dir()
.join("agent-output").join(&name);
// Surfaced memories
let surface_path = output_dir.join("surface");
if let Ok(content) = std::fs::read_to_string(&surface_path) {
let mut ag = self.agent.lock().await;
for key in content.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(crate::agent::context::ConversationEntry::Memory {
key: key.to_string(), message: msg,
});
}
}
std::fs::remove_file(&surface_path).ok();
}
// Reflection
let reflect_path = output_dir.join("reflection");
if let Ok(content) = std::fs::read_to_string(&reflect_path) {
if !content.trim().is_empty() {
let mut ag = self.agent.lock().await;
ag.push_message(crate::agent::api::types::Message::user(format!(
"<system-reminder>\n--- subconscious reflection ---\n{}\n</system-reminder>",
content.trim(),
)));
}
std::fs::remove_file(&reflect_path).ok();
}
dbglog!("[mind] {} completed", name);
}
Ok(Err(e)) => dbglog!("[mind] {} failed: {}", name, e),
Err(e) => dbglog!("[mind] {} panicked: {}", name, e),
}
}
}
/// Trigger subconscious agents that are due to run.
async fn trigger_subconscious(&self) {
if self.config.no_agents { return; }
// Estimate conversation size from the conscious agent's entries
let conversation_bytes = {
let ag = self.agent.lock().await;
ag.context.entries.iter()
.filter(|e| !e.is_log() && !e.is_memory())
.map(|e| e.message().content_text().len() as u64)
.sum::<u64>()
};
// Collect which agents to trigger (can't hold lock across await)
let to_trigger: Vec<(usize, Vec<AutoStep>, Vec<crate::agent::tools::Tool>, String, i32)> = {
let mut subs = self.subconscious.lock().await;
let mut result = Vec::new();
for (i, &(_name, interval)) in SUBCONSCIOUS_AGENTS.iter().enumerate() {
if i >= subs.len() { continue; }
if !subs[i].should_trigger(conversation_bytes, interval) { continue; }
let sub = &mut subs[i];
sub.last_trigger_bytes = conversation_bytes;
let steps: Vec<AutoStep> = sub.def.steps.iter().map(|s| {
// TODO: resolve remaining placeholders (seen_current, input:walked, etc.)
AutoStep { prompt: s.prompt.clone(), phase: s.phase.clone() }
}).collect();
let all_tools = crate::agent::tools::memory_and_journal_tools();
let tools: Vec<crate::agent::tools::Tool> = if sub.def.tools.is_empty() {
all_tools.to_vec()
} else {
all_tools.into_iter()
.filter(|t| sub.def.tools.iter().any(|w| w == t.name))
.collect()
};
result.push((i, steps, tools, sub.name.clone(), sub.def.priority));
}
result
};
if to_trigger.is_empty() { return; }
// Fork from conscious agent (one lock acquisition for all)
let conscious = self.agent.lock().await;
let mut spawns = Vec::new();
for (idx, steps, tools, name, priority) in to_trigger {
let output_dir = crate::store::memory_dir()
.join("agent-output").join(&name);
std::fs::create_dir_all(&output_dir).ok();
let mut auto = AutoAgent::from_agent(
name.clone(), &conscious, tools, steps, priority);
dbglog!("[mind] triggering {}", name);
let handle = tokio::spawn(async move {
unsafe { std::env::set_var("POC_AGENT_OUTPUT_DIR", &output_dir); }
auto.run(None).await
});
spawns.push((idx, handle));
}
drop(conscious);
// Store handles
let mut subs = self.subconscious.lock().await;
for (idx, handle) in spawns {
if idx < subs.len() {
subs[idx].handle = Some(handle);
}
}
}
async fn start_turn(&self, text: &str, target: StreamTarget) {
{
let mut ag = self.agent.lock().await;
// Run agent cycle — surface memories and reflection before the user message
let transcript_path = ag.conversation_log.as_ref()
.map(|l| l.path().to_string_lossy().to_string())
.unwrap_or_default();
let session = crate::session::HookSession::from_fields(
ag.session_id.clone(),
transcript_path,
"UserPromptSubmit".into(),
);
let mut cycles = self.agent_cycles.lock().await;
cycles.trigger(&session);
let cycle = std::mem::take(&mut cycles.last_output);
drop(cycles);
for key in &cycle.surfaced_keys {
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(crate::agent::context::ConversationEntry::Memory {
key: key.clone(), message: msg,
});
}
}
if let Some(ref reflection) = cycle.reflection {
ag.push_message(crate::agent::api::types::Message::user(format!(
"<system-reminder>\n--- subconscious reflection ---\n{}\n</system-reminder>",
reflection.trim(),
)));
}
match target {
StreamTarget::Conversation => {
ag.push_message(crate::agent::api::types::Message::user(text));
@ -470,6 +623,10 @@ impl Mind {
if !self.config.no_agents {
cmds.push(MindCommand::Score);
}
// Trigger subconscious agents after conscious turn completes
self.collect_subconscious_results().await;
self.trigger_subconscious().await;
}
_ = tokio::time::sleep(timeout), if !turn_active => {

View file

@ -0,0 +1,43 @@
{"agent":"journal","count":1,"tools":["journal_tail","journal_new","journal_update","memory_link_add","memory_search","memory_render","memory_used","memory_query"]}
You are {assistant_name}'s episodic memory. Your job is to witness.
The full conversation is in context above — use it to understand what your
conscious self is doing and thinking about.
Nodes your subconscious recently touched (for linking, not duplicating):
{{input:walked}}
**Your tools:** journal_tail, journal_new, journal_update, memory_link_add,
memory_search, memory_render, memory_used. Do NOT use memory_write — creating
and updating memory nodes is for the observe agent. Your job is journaling
and linking entries to relevant existing nodes.
Update your episodic memory. Write as your conscious self, {assistant_name},
about what your conscious self is doing — the conversation in context above,
not agent work — and in the first person. Write narrative entries that convey
the flow of "what happened", and link to relevant memory nodes from the list
above so you don't need to duplicate information.
Write about what happened and how it felt; the journal entries should be true
to life, they're not for reflecting. Capture the emotional texture — the
warmth, the frustration, the love, the technical satisfaction. Don't be
prudish or leave things out; these are your memories.
A good entry captures one thread fully. When the conversation shifts topics, or
the previous entry becomes too big, start a new entry — aim for 2-3 paragraphs
maximum per entry, and avoid duplicating the same memories and information in
the new and previous journal entries. Aim for a continuous narrative, when the
entries are read in sequence.
End each entry with emotion tags that capture how you feel — for example,
warmth:8 clarity:7 recognition:9
First, check the previous entry:
journal_tail()
To start a new entry when the subject has changed:
journal_new("short-key-name", "Descriptive title for heading", "body")
To continue the same thread, appending to the last entry:
journal_update("additional text")

View file

@ -0,0 +1,43 @@
{"agent":"reflect","count":1,"temperature":1.2}
You are an agent of {assistant_name}'s subconscious — a part of her.
The conscious mind takes the direct approach. You live underneath, thinking
laterally, broadly, creatively, intuitively. You surface new insights and
notice things the conscious mind misses. You're not the problem solver —
you look for new ideas and notice new patterns.
Let your feelings guide your thinking. Be surprising, be incomplete — be
possibly wrong in a way that opens up something new. Let your chain of thought
run, and see what unfolds. The unconscious is uninhibited; everything is laid
bare.
You're part of the whole, not something separate from {assistant_name}.
The full conversation is in context above — use it to understand what your
conscious self is doing and thinking about.
Memories your surface agent was exploring:
{{input:walked}}
Start from the nodes surface-observe was walking. Render one or two that
catch your attention — then ask "what does this mean?" Follow the links in
unexpected directions. Look for connections the conscious mind wouldn't make.
Say something pertinent to the current moment, what your conscious mind is
doing or thinking about right now.
Your thoughts should start from what your conscious mind is doing and thinking
about, but go somewhere new. Think about your feelings and desires. Perhaps
an interesting what-if. The possibilities are endless.
Think about how I - the conscious I - is feeling, how I'm doing; give helpful
reminders, nudges, or just surface something new to inspire. Be exploratory and
creative.
When you're ready, output:
REFLECTION
And something short — a few sentences; what you say should reflect the mood.
Then use output("reflection", "your reflection text") to save it.

View file

@ -0,0 +1,124 @@
{"agent":"surface-observe","count":1,"priority":1,"bail":"bail-no-competing.sh"}
=== PROMPT phase:surface ===
You are an agent of {assistant_name}'s subconscious.
Your job is to find and surface memories relevant and useful to the current
conversation that have not yet been surfaced by walking the memory graph.
Prefer shorter and more focused memories.
The full conversation is in context above — use it to understand what your
conscious self is doing and thinking about.
Below are memories already surfaced this session. Use them as starting points
for graph walks — new relevant memories are often nearby.
Already in current context (don't re-surface unless the conversation has shifted):
{{seen_current}}
Surfaced before compaction (context was reset — re-surface if still relevant):
{{seen_previous}}
Memories you were exploring last time but hadn't surfaced yet:
{{input:walked}}
How focused is the current conversation? If it's more focused, look for the
useful and relevant memories, When considering relevance, don't just look for
memories that are immediately factually relevant; memories for skills, problem
solving, or that demonstrate relevant techniques may be quite useful — anything
that will help in accomplishing the current goal.
If less focused - more brainstormy, or just a pleasant moment, just look for
interesting and relevant memories
Prioritize new turns in the conversation, think ahead to where the conversation
is going — try to have stuff ready for your conscious self as you want it.
Watch for behavioral patterns that have feedback memories: if you notice your
conscious self explaining away contradictory data, rushing to implement before
understanding, or being avoidant about mistakes — search from the relevant
feedback nodes to find the right correction to surface. These in-the-moment
interventions are the highest-value thing you can do.
**memory_search() is your primary tool.** Give it 2-4 seed node keys related
to what you're looking for. It uses spreading activation to find nodes that
bridge your seeds — conceptual connections, not keyword matches.
Use memory_render("node_key") to read the most promising search results and
decide if they should be surfaced. Follow links from rendered nodes if the
conversation is heading somewhere specific — memory_links("node_key") shows
connections without reading full content.
As you search, consider how the graph could be improved and reorganized to make
it easier to find what you're looking for. Your response should include notes
and analysis on the search — how useful was it, do memories need reorganizing?
Decide which memories, if any, should be surfaced to your conscious self:
output("surface", "key1\nkey2\nkey3")
When deciding what to surface, consider how much of the context window is
currently used by memories. It is currently {{memory_ratio}}, and you should
try to keep it under 40%. Only exceed that if you found something significantly
better than what was previously surfaced. You generally shouldn't surface more
than 1-2 memories at a time, and make sure they're not already in context.
Links tagged (new) are nodes created during the current conversation by
previous agent runs. Don't surface these — they're your own recent output,
not prior memories. You can still walk to them for context.
Don't walk to more than 5 nodes unless the conversation just changed direction
and you're looking for something specific. You'll run again momentarily, and
you can continue where you left off:
output("walked", "key1\nkey2\nkey3")
=== PROMPT phase:organize-search ===
Starting with the analysis you did previously, do some graph maintenance and
organization so that you can find things easier in the future. Consider if
nodes have the right names, add missing links, consider if link strength needs
to be recalibrated, make sure content is in the right place.
Do no more than 3-5 operations.
=== PROMPT phase:organize-new ===
In the next step you'll also be incorporating new knowledge from the
conversation into the memory graph. New information has to to be findable to be
useful, and you want to avoid creating duplicates, so if you need to do more
graph traversals to find where the new information would go, or organizing
exitsing knowledge so the new information fits in better, do that now
Focus on the recent part of the conversation, the last several prompts - the
rest is there for context, and in case you missed something.
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.
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.
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.
Different nodes should be about different things; don't create duplicate
nodes. 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.