From 64dbcbf061e7b448d55ac5393feefab3d1d7233e Mon Sep 17 00:00:00 2001 From: Kent Overstreet Date: Thu, 2 Apr 2026 14:56:02 -0400 Subject: [PATCH] unify memory tracking: entries are the single source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/agent/runner.rs | 114 ++++++++++++++++--------------- src/agent/types.rs | 4 -- src/subconscious/subconscious.rs | 1 + 3 files changed, 60 insertions(+), 59 deletions(-) diff --git a/src/agent/runner.rs b/src/agent/runner.rs index a93dae2..7392ad9 100644 --- a/src/agent/runner.rs +++ b/src/agent/runner.rs @@ -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 { + /// 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 { // Run agent orchestration cycle (surface-observe, reflect, journal) - if let Some(hook_output) = self.run_agent_cycle() { - let enriched = format!("{}\n\n\n{}\n", - 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!( + "\n--- {} (surfaced) ---\n{}\n", + 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!( + "\n--- subconscious reflection ---\n{}\n", + 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; - } 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 - } - } - } - _ => {} + "memory_render" => + args.get("key").and_then(|v| v.as_str()).map(String::from), + _ => None, } - } + } else { + 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 = 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 = 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, diff --git a/src/agent/types.rs b/src/agent/types.rs index 9491a7e..ea35f1c 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -402,10 +402,6 @@ pub struct ContextState { pub personality: Vec<(String, String)>, pub journal: Vec, pub working_stack: Vec, - /// 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, /// Conversation entries — messages and memory, interleaved in order. /// Does NOT include system prompt, personality, or journal. pub entries: Vec, diff --git a/src/subconscious/subconscious.rs b/src/subconscious/subconscious.rs index 5c3e1cf..24caeaf 100644 --- a/src/subconscious/subconscious.rs +++ b/src/subconscious/subconscious.rs @@ -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,