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

View file

@ -402,10 +402,6 @@ pub struct ContextState {
pub personality: Vec<(String, String)>,
pub journal: Vec<crate::agent::journal::JournalEntry>,
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.
/// Does NOT include system prompt, personality, or journal.
pub entries: Vec<ConversationEntry>,

View file

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