ConversationEntry enum: typed memory vs conversation messages

Replace untyped message list with ConversationEntry enum:
- Message(Message) — regular conversation turn
- Memory { key, message } — memory content with preserved message
  for KV cache round-tripping

Budget counts memory vs conversation by matching on enum variant.
Debug screen labels memory entries with [memory: key]. No heuristic
tool-name scanning.

Custom serde: Memory serializes with a memory_key field alongside
the message fields, deserializes by checking for the field.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 03:26:00 -04:00
parent eb4dae04cb
commit b9e3568385
3 changed files with 153 additions and 93 deletions

View file

@ -103,7 +103,7 @@ impl Agent {
journal: Vec::new(),
working_stack: Vec::new(),
loaded_nodes: Vec::new(),
messages: Vec::new(),
entries: Vec::new(),
};
let session_id = format!("poc-agent-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
let agent_cycles = crate::subconscious::subconscious::AgentCycleState::new(&session_id);
@ -140,7 +140,7 @@ impl Agent {
if !jnl.is_empty() {
msgs.push(Message::user(jnl));
}
msgs.extend(self.context.messages.iter().cloned());
msgs.extend(self.context.entries.iter().map(|e| e.api_message().clone()));
msgs
}
@ -173,7 +173,7 @@ impl Agent {
eprintln!("warning: failed to log message: {:#}", e);
}
}
self.context.messages.push(msg);
self.context.entries.push(ConversationEntry::Message(msg));
}
/// Push a context-only message (system prompt, identity context,
@ -673,32 +673,41 @@ impl Agent {
}
// Conversation — each message as a child
let conv_messages = &self.context.messages;
let conv_messages = &self.context.entries;
let conv_children: Vec<ContextSection> = conv_messages.iter().enumerate()
.map(|(i, msg)| {
let text = msg.content.as_ref()
.map(|(i, entry)| {
let m = entry.message();
let text = m.content.as_ref()
.map(|c| c.as_text().to_string())
.unwrap_or_default();
let tool_info = msg.tool_calls.as_ref().map(|tc| {
let tool_info = m.tool_calls.as_ref().map(|tc| {
tc.iter()
.map(|c| c.function.name.clone())
.collect::<Vec<_>>()
.join(", ")
});
let label = match (&msg.role, &tool_info) {
(_, Some(tools)) => format!("[tool_call: {}]", tools),
_ => {
let preview: String = text.chars().take(60).collect();
let preview = preview.replace('\n', " ");
if text.len() > 60 { format!("{}...", preview) } else { preview }
let label = if entry.is_memory() {
if let ConversationEntry::Memory { key, .. } = entry {
format!("[memory: {}]", key)
} else { unreachable!() }
} else {
match &tool_info {
Some(tools) => format!("[tool_call: {}]", tools),
None => {
let preview: String = text.chars().take(60).collect();
let preview = preview.replace('\n', " ");
if text.len() > 60 { format!("{}...", preview) } else { preview }
}
}
};
let tokens = count(&text);
let role_name = match msg.role {
Role::Assistant => "PoC",
Role::User => "Kent",
Role::Tool => "tool",
Role::System => "system",
let role_name = if entry.is_memory() { "mem" } else {
match m.role {
Role::Assistant => "PoC",
Role::User => "Kent",
Role::Tool => "tool",
Role::System => "system",
}
};
ContextSection {
name: format!("[{}] {}: {}", i, role_name, label),
@ -846,7 +855,8 @@ impl Agent {
/// all previous ones. The tool result message (right before each image
/// message) already records what was loaded, so no info is lost.
fn age_out_images(&mut self) {
for msg in &mut self.context.messages {
for entry in &mut self.context.entries {
let msg = entry.message_mut();
if let Some(MessageContent::Parts(parts)) = &msg.content {
let has_images = parts.iter().any(|p| matches!(p, ContentPart::ImageUrl { .. }));
if !has_images {
@ -891,7 +901,8 @@ impl Agent {
let mut strip_ids: Vec<String> = Vec::new();
let mut strip_msg_indices: Vec<usize> = Vec::new();
for (i, msg) in self.context.messages.iter().enumerate() {
for (i, entry) in self.context.entries.iter().enumerate() {
let msg = entry.message();
if msg.role != Role::Assistant {
continue;
}
@ -917,8 +928,8 @@ impl Agent {
}
// Remove in reverse order to preserve indices
self.context.messages.retain(|msg| {
// Strip the assistant messages we identified
self.context.entries.retain(|entry| {
let msg = entry.message();
if msg.role == Role::Assistant {
if let Some(calls) = &msg.tool_calls {
if calls.iter().all(|c| strip_ids.contains(&c.id)) {
@ -926,7 +937,6 @@ impl Agent {
}
}
}
// Strip matching tool results
if msg.role == Role::Tool {
if let Some(ref id) = msg.tool_call_id {
if strip_ids.contains(id) {
@ -955,7 +965,8 @@ impl Agent {
/// Internal compaction — rebuilds context window from current messages.
fn do_compact(&mut self) {
let conversation: Vec<Message> = self.context.messages.clone();
let conversation: Vec<Message> = self.context.entries.iter()
.map(|e| e.api_message().clone()).collect();
let (messages, journal) = crate::agent::context::build_context_window(
&self.context,
&conversation,
@ -963,7 +974,8 @@ impl Agent {
&self.tokenizer,
);
self.context.journal = journal::parse_journal_text(&journal);
self.context.messages = messages;
self.context.entries = messages.into_iter()
.map(ConversationEntry::Message).collect();
self.last_prompt_tokens = 0;
self.publish_context_state();
@ -1025,8 +1037,9 @@ impl Agent {
dbglog!("[restore] journal text: {} chars, {} lines",
journal.len(), journal.lines().count());
self.context.journal = journal::parse_journal_text(&journal);
self.context.messages = messages;
dbglog!("[restore] built context window: {} messages", self.context.messages.len());
self.context.entries = messages.into_iter()
.map(ConversationEntry::Message).collect();
dbglog!("[restore] built context window: {} entries", self.context.entries.len());
self.last_prompt_tokens = 0;
self.publish_context_state();
@ -1043,19 +1056,19 @@ impl Agent {
&self.client.model
}
/// Get the conversation history for persistence.
pub fn messages(&self) -> &[Message] {
&self.context.messages
/// Get the conversation entries for persistence.
pub fn entries(&self) -> &[ConversationEntry] {
&self.context.entries
}
/// Mutable access to conversation history (for /retry).
pub fn messages_mut(&mut self) -> &mut Vec<Message> {
&mut self.context.messages
/// Mutable access to conversation entries (for /retry).
pub fn entries_mut(&mut self) -> &mut Vec<ConversationEntry> {
&mut self.context.entries
}
/// Restore from a saved conversation.
pub fn restore(&mut self, messages: Vec<Message>) {
self.context.messages = messages;
/// Restore from saved conversation entries.
pub fn restore(&mut self, entries: Vec<ConversationEntry>) {
self.context.entries = entries;
}
}