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:
parent
7b9ba5be6c
commit
b442a07530
11 changed files with 381 additions and 61 deletions
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue