unify memory tracking: entries are the single source of truth

Memory tool results (memory_render) are now pushed as
ConversationEntry::Memory with the node key, instead of plain
Messages. Remove loaded_nodes from ContextState — the debug
screen reads memory info from Memory entries in the conversation.

Surfaced memories from surface-observe are pushed as separate
Memory entries, reflections as separate system-reminder messages.
User input is no longer polluted with hook output.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 14:56:02 -04:00
parent a21cf31ad2
commit 64dbcbf061
3 changed files with 60 additions and 59 deletions

View file

@ -102,7 +102,6 @@ impl Agent {
personality, personality,
journal: Vec::new(), journal: Vec::new(),
working_stack: Vec::new(), working_stack: Vec::new(),
loaded_nodes: Vec::new(),
entries: Vec::new(), entries: Vec::new(),
}; };
let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S")); let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
@ -144,8 +143,8 @@ impl Agent {
msgs msgs
} }
/// Run agent orchestration cycle and return formatted output to inject. /// Run agent orchestration cycle, returning structured output.
fn run_agent_cycle(&mut self) -> Option<String> { fn run_agent_cycle(&mut self) -> crate::subconscious::subconscious::AgentCycleOutput {
let transcript_path = self.conversation_log.as_ref() let transcript_path = self.conversation_log.as_ref()
.map(|l| l.path().to_string_lossy().to_string()) .map(|l| l.path().to_string_lossy().to_string())
.unwrap_or_default(); .unwrap_or_default();
@ -157,12 +156,7 @@ impl Agent {
); );
self.agent_cycles.trigger(&session); self.agent_cycles.trigger(&session);
let text = crate::subconscious::subconscious::format_agent_output(&self.agent_cycles.last_output); std::mem::take(&mut self.agent_cycles.last_output)
if text.trim().is_empty() {
None
} else {
Some(text)
}
} }
/// Push a conversation message — stamped and logged. /// Push a conversation message — stamped and logged.
@ -201,13 +195,32 @@ impl Agent {
target: StreamTarget, target: StreamTarget,
) -> Result<TurnResult> { ) -> Result<TurnResult> {
// Run agent orchestration cycle (surface-observe, reflect, journal) // Run agent orchestration cycle (surface-observe, reflect, journal)
if let Some(hook_output) = self.run_agent_cycle() { let cycle = self.run_agent_cycle();
let enriched = format!("{}\n\n<system-reminder>\n{}\n</system-reminder>",
user_input, hook_output); // Surfaced memories — each as a separate Memory entry
self.push_message(Message::user(enriched)); for key in &cycle.surfaced_keys {
} else { if let Some(rendered) = crate::cli::node::render_node(
self.push_message(Message::user(user_input)); &crate::store::Store::load().unwrap_or_default(), key,
) {
let mut msg = Message::user(format!(
"<system-reminder>\n--- {} (surfaced) ---\n{}\n</system-reminder>",
key, rendered,
));
msg.stamp();
self.push_entry(ConversationEntry::Memory { key: key.clone(), message: msg });
}
} }
// Reflection — separate system reminder
if let Some(ref reflection) = cycle.reflection {
self.push_message(Message::user(format!(
"<system-reminder>\n--- subconscious reflection ---\n{}\n</system-reminder>",
reflection.trim(),
)));
}
// User input — clean, just what was typed
self.push_message(Message::user(user_input));
let _ = ui_tx.send(UiMessage::AgentUpdate(self.agent_cycles.snapshots())); let _ = ui_tx.send(UiMessage::AgentUpdate(self.agent_cycles.snapshots()));
let mut overflow_retries: u32 = 0; let mut overflow_retries: u32 = 0;
@ -482,37 +495,16 @@ impl Agent {
Err(e) => format!("Error: {:#}", e), Err(e) => format!("Error: {:#}", e),
}; };
// Track loaded/updated nodes // Disambiguate memory renders from other tool results
if result.is_ok() { let memory_key = if result.is_ok() {
match call.function.name.as_str() { match call.function.name.as_str() {
"memory_render" | "memory_links" => { "memory_render" =>
if let Some(key) = args.get("key").and_then(|v| v.as_str()) { args.get("key").and_then(|v| v.as_str()).map(String::from),
if let Some(node) = crate::hippocampus::memory::MemoryNode::load(key) { _ => None,
// Replace if already tracked, otherwise add
if let Some(existing) = self.context.loaded_nodes.iter_mut()
.find(|n| n.key == node.key) {
*existing = node;
} else {
self.context.loaded_nodes.push(node);
}
}
}
}
"memory_write" => {
if let Some(key) = args.get("key").and_then(|v| v.as_str()) {
if let Some(node) = crate::hippocampus::memory::MemoryNode::load(key) {
// Refresh if already tracked
if let Some(existing) = self.context.loaded_nodes.iter_mut()
.find(|n| n.key == node.key) {
*existing = node;
}
// Don't auto-add writes — only renders register nodes
}
}
}
_ => {}
} }
} } else {
None
};
let output = tools::ToolOutput { let output = tools::ToolOutput {
text, text,
@ -526,7 +518,13 @@ impl Agent {
result: output.text.clone(), result: output.text.clone(),
}); });
let _ = ui_tx.send(UiMessage::ToolFinished { id: call.id.clone() }); let _ = ui_tx.send(UiMessage::ToolFinished { id: call.id.clone() });
self.push_message(Message::tool_result(&call.id, &output.text)); let mut msg = Message::tool_result(&call.id, &output.text);
msg.stamp();
if let Some(key) = memory_key {
self.push_entry(ConversationEntry::Memory { key, message: msg });
} else {
self.push_entry(ConversationEntry::Message(msg));
}
ds.had_tool_calls = true; ds.had_tool_calls = true;
if output.text.starts_with("Error:") { if output.text.starts_with("Error:") {
ds.tool_errors += 1; ds.tool_errors += 1;
@ -654,23 +652,29 @@ impl Agent {
children: stack_children, children: stack_children,
}); });
// Loaded memory nodes — tracked by memory tools // Memory nodes — extracted from Memory entries in the conversation
if !self.context.loaded_nodes.is_empty() { let memory_entries: Vec<&ConversationEntry> = self.context.entries.iter()
let node_children: Vec<ContextSection> = self.context.loaded_nodes.iter() .filter(|e| e.is_memory())
.map(|node| { .collect();
let rendered = node.render(); if !memory_entries.is_empty() {
let node_children: Vec<ContextSection> = memory_entries.iter()
.map(|entry| {
let key = match entry {
ConversationEntry::Memory { key, .. } => key.as_str(),
_ => unreachable!(),
};
let text = entry.message().content_text();
ContextSection { ContextSection {
name: format!("{} (v{}, w={:.2}, {} links)", name: key.to_string(),
node.key, node.version, node.weight, node.links.len()), tokens: count(text),
tokens: count(&rendered), content: String::new(),
content: String::new(), // don't duplicate in debug view
children: Vec::new(), children: Vec::new(),
} }
}) })
.collect(); .collect();
let node_tokens: usize = node_children.iter().map(|c| c.tokens).sum(); let node_tokens: usize = node_children.iter().map(|c| c.tokens).sum();
sections.push(ContextSection { sections.push(ContextSection {
name: format!("Memory nodes ({} loaded)", self.context.loaded_nodes.len()), name: format!("Memory nodes ({} loaded)", memory_entries.len()),
tokens: node_tokens, tokens: node_tokens,
content: String::new(), content: String::new(),
children: node_children, children: node_children,

View file

@ -402,10 +402,6 @@ pub struct ContextState {
pub personality: Vec<(String, String)>, pub personality: Vec<(String, String)>,
pub journal: Vec<crate::agent::journal::JournalEntry>, pub journal: Vec<crate::agent::journal::JournalEntry>,
pub working_stack: Vec<String>, pub working_stack: Vec<String>,
/// Memory nodes currently loaded — for debug display and refresh.
/// Content is NOT duplicated here; the actual content is in entries
/// as ConversationEntry::Memory.
pub loaded_nodes: Vec<crate::hippocampus::memory::MemoryNode>,
/// Conversation entries — messages and memory, interleaved in order. /// Conversation entries — messages and memory, interleaved in order.
/// Does NOT include system prompt, personality, or journal. /// Does NOT include system prompt, personality, or journal.
pub entries: Vec<ConversationEntry>, pub entries: Vec<ConversationEntry>,

View file

@ -13,6 +13,7 @@ use std::time::{Duration, Instant, SystemTime};
pub use crate::session::HookSession; pub use crate::session::HookSession;
/// Output from a single agent orchestration cycle. /// Output from a single agent orchestration cycle.
#[derive(Default)]
pub struct AgentCycleOutput { pub struct AgentCycleOutput {
/// Memory node keys surfaced by surface-observe. /// Memory node keys surfaced by surface-observe.
pub surfaced_keys: Vec<String>, pub surfaced_keys: Vec<String>,