fix(memory): prevent autosave key collisions across runtime flows

Fixes #221 - SQLite Memory Override bug.

This PR resolves memory overwrite behavior in autosave paths by replacing fixed memory keys with unique keys, and improves short-horizon recall quality in channel runtime.

**Root Cause**
SQLite memory uses a unique constraint on `memories.key` and writes with `ON CONFLICT(key) DO UPDATE`.
Several autosave paths reused fixed keys (or sender-stable keys), so newer messages overwrote earlier conversation entries.

**Changes**
- Channel runtime: autosave key changed from `channel_sender` to `channel_sender_messageId`
- Added memory-context injection before provider calls (aligned with agent loop behavior)
- Agent loop: autosave keys changed from fixed `user_msg`/`assistant_resp` to UUID-suffixed keys
- Gateway: Webhook/WhatsApp autosave keys changed to UUID-suffixed keys

All CI checks passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chummy 2026-02-16 11:55:52 +08:00 committed by GitHub
parent 7b9ba5be6c
commit b442a07530
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 381 additions and 61 deletions

View file

@ -11,6 +11,7 @@ use std::fmt::Write;
use std::io::Write as IoWrite;
use std::sync::Arc;
use std::time::Instant;
use uuid::Uuid;
/// Maximum agentic tool-use iterations per user message to prevent runaway loops.
const MAX_TOOL_ITERATIONS: usize = 10;
@ -19,6 +20,10 @@ const MAX_TOOL_ITERATIONS: usize = 10;
/// When exceeded, the oldest messages are dropped (system prompt is always preserved).
const MAX_HISTORY_MESSAGES: usize = 50;
fn autosave_memory_key(prefix: &str) -> String {
format!("{prefix}_{}", Uuid::new_v4())
}
/// Trim conversation history to prevent unbounded growth.
/// Preserves the system prompt (first message if role=system) and the most recent messages.
fn trim_history(history: &mut Vec<ChatMessage>) {
@ -90,7 +95,9 @@ fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
.to_string();
// Arguments in OpenAI format are a JSON string that needs parsing
let arguments = if let Some(args_str) = function.get("arguments").and_then(|v| v.as_str()) {
let arguments = if let Some(args_str) =
function.get("arguments").and_then(|v| v.as_str())
{
serde_json::from_str::<serde_json::Value>(args_str)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()))
} else {
@ -182,11 +189,7 @@ async fn agent_turn(
if tool_calls.is_empty() {
// No tool calls — this is the final response
history.push(ChatMessage::assistant(&response));
return Ok(if text.is_empty() {
response
} else {
text
});
return Ok(if text.is_empty() { response } else { text });
}
// Print any text the LLM produced alongside tool calls
@ -235,9 +238,7 @@ async fn agent_turn(
// Add assistant message with tool calls + tool results to history
history.push(ChatMessage::assistant(&response));
history.push(ChatMessage::user(format!(
"[Tool results]\n{tool_results}"
)));
history.push(ChatMessage::user(format!("[Tool results]\n{tool_results}")));
}
anyhow::bail!("Agent exceeded maximum tool iterations ({MAX_TOOL_ITERATIONS})")
@ -252,7 +253,8 @@ fn build_tool_instructions(tools_registry: &[Box<dyn Tool>]) -> String {
instructions.push_str("```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n");
instructions.push_str("You may use multiple tool calls in a single response. ");
instructions.push_str("After tool execution, results appear in <tool_result> tags. ");
instructions.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
instructions
.push_str("Continue reasoning with the results until you can give a final answer.\n\n");
instructions.push_str("### Available Tools\n\n");
for tool in tools_registry {
@ -397,8 +399,9 @@ pub async fn run(
if let Some(msg) = message {
// Auto-save user message to memory
if config.memory.auto_save {
let user_key = autosave_memory_key("user_msg");
let _ = mem
.store("user_msg", &msg, MemoryCategory::Conversation)
.store(&user_key, &msg, MemoryCategory::Conversation)
.await;
}
@ -429,8 +432,9 @@ pub async fn run(
// Auto-save assistant response to daily log
if config.memory.auto_save {
let summary = truncate_with_ellipsis(&response, 100);
let response_key = autosave_memory_key("assistant_resp");
let _ = mem
.store("assistant_resp", &summary, MemoryCategory::Daily)
.store(&response_key, &summary, MemoryCategory::Daily)
.await;
}
} else {
@ -451,8 +455,9 @@ pub async fn run(
while let Some(msg) = rx.recv().await {
// Auto-save conversation turns
if config.memory.auto_save {
let user_key = autosave_memory_key("user_msg");
let _ = mem
.store("user_msg", &msg.content, MemoryCategory::Conversation)
.store(&user_key, &msg.content, MemoryCategory::Conversation)
.await;
}
@ -489,8 +494,9 @@ pub async fn run(
if config.memory.auto_save {
let summary = truncate_with_ellipsis(&response, 100);
let response_key = autosave_memory_key("assistant_resp");
let _ = mem
.store("assistant_resp", &summary, MemoryCategory::Daily)
.store(&response_key, &summary, MemoryCategory::Daily)
.await;
}
}
@ -510,6 +516,8 @@ pub async fn run(
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
use tempfile::TempDir;
#[test]
fn parse_tool_calls_extracts_single_call() {
@ -646,12 +654,9 @@ After text."#;
assert_eq!(history[0].content, "system prompt");
// Trimmed to limit
assert_eq!(history.len(), MAX_HISTORY_MESSAGES + 1); // +1 for system
// Most recent messages preserved
// Most recent messages preserved
let last = &history[history.len() - 1];
assert_eq!(
last.content,
format!("msg {}", MAX_HISTORY_MESSAGES + 19)
);
assert_eq!(last.content, format!("msg {}", MAX_HISTORY_MESSAGES + 19));
}
#[test]
@ -664,4 +669,35 @@ After text."#;
trim_history(&mut history);
assert_eq!(history.len(), 3);
}
#[test]
fn autosave_memory_key_has_prefix_and_uniqueness() {
let key1 = autosave_memory_key("user_msg");
let key2 = autosave_memory_key("user_msg");
assert!(key1.starts_with("user_msg_"));
assert!(key2.starts_with("user_msg_"));
assert_ne!(key1, key2);
}
#[tokio::test]
async fn autosave_memory_keys_preserve_multiple_turns() {
let tmp = TempDir::new().unwrap();
let mem = SqliteMemory::new(tmp.path()).unwrap();
let key1 = autosave_memory_key("user_msg");
let key2 = autosave_memory_key("user_msg");
mem.store(&key1, "I'm Paul", MemoryCategory::Conversation)
.await
.unwrap();
mem.store(&key2, "I'm 45", MemoryCategory::Conversation)
.await
.unwrap();
assert_eq!(mem.count().await.unwrap(), 2);
let recalled = mem.recall("45", 5).await.unwrap();
assert!(recalled.iter().any(|entry| entry.content.contains("45")));
}
}