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
|
|
@ -26,6 +26,7 @@ use crate::memory::{self, Memory};
|
|||
use crate::providers::{self, Provider};
|
||||
use crate::util::truncate_with_ellipsis;
|
||||
use anyhow::Result;
|
||||
use std::fmt::Write;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -36,6 +37,26 @@ const DEFAULT_CHANNEL_INITIAL_BACKOFF_SECS: u64 = 2;
|
|||
const DEFAULT_CHANNEL_MAX_BACKOFF_SECS: u64 = 60;
|
||||
const CHANNEL_MESSAGE_TIMEOUT_SECS: u64 = 90;
|
||||
|
||||
fn conversation_memory_key(msg: &traits::ChannelMessage) -> String {
|
||||
format!("{}_{}_{}", msg.channel, msg.sender, msg.id)
|
||||
}
|
||||
|
||||
async fn build_memory_context(mem: &dyn Memory, user_msg: &str) -> String {
|
||||
let mut context = String::new();
|
||||
|
||||
if let Ok(entries) = mem.recall(user_msg, 5).await {
|
||||
if !entries.is_empty() {
|
||||
context.push_str("[Memory context]\n");
|
||||
for entry in &entries {
|
||||
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
|
||||
}
|
||||
context.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
context
|
||||
}
|
||||
|
||||
fn spawn_supervised_listener(
|
||||
ch: Arc<dyn Channel>,
|
||||
tx: tokio::sync::mpsc::Sender<traits::ChannelMessage>,
|
||||
|
|
@ -78,7 +99,8 @@ fn spawn_supervised_listener(
|
|||
|
||||
/// Load OpenClaw format bootstrap files into the prompt.
|
||||
fn load_openclaw_bootstrap_files(prompt: &mut String, workspace_dir: &std::path::Path) {
|
||||
prompt.push_str("The following workspace files define your identity, behavior, and context.\n\n");
|
||||
prompt
|
||||
.push_str("The following workspace files define your identity, behavior, and context.\n\n");
|
||||
|
||||
let bootstrap_files = [
|
||||
"AGENTS.md",
|
||||
|
|
@ -681,17 +703,26 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
truncate_with_ellipsis(&msg.content, 80)
|
||||
);
|
||||
|
||||
let memory_context = build_memory_context(mem.as_ref(), &msg.content).await;
|
||||
|
||||
// Auto-save to memory
|
||||
if config.memory.auto_save {
|
||||
let autosave_key = conversation_memory_key(&msg);
|
||||
let _ = mem
|
||||
.store(
|
||||
&format!("{}_{}", msg.channel, msg.sender),
|
||||
&autosave_key,
|
||||
&msg.content,
|
||||
crate::memory::MemoryCategory::Conversation,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let enriched_message = if memory_context.is_empty() {
|
||||
msg.content.clone()
|
||||
} else {
|
||||
format!("{memory_context}{}", msg.content)
|
||||
};
|
||||
|
||||
let target_channel = channels.iter().find(|ch| ch.name() == msg.channel);
|
||||
|
||||
// Show typing indicator while processing
|
||||
|
|
@ -707,7 +738,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
|
||||
let llm_result = tokio::time::timeout(
|
||||
Duration::from_secs(CHANNEL_MESSAGE_TIMEOUT_SECS),
|
||||
provider.chat_with_system(Some(&system_prompt), &msg.content, &model, temperature),
|
||||
provider.chat_with_system(Some(&system_prompt), &enriched_message, &model, temperature),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
|
@ -773,6 +804,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
|
@ -998,6 +1030,96 @@ mod tests {
|
|||
assert!(prompt.contains(&format!("Working directory: `{}`", ws.path().display())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_memory_key_uses_message_id() {
|
||||
let msg = traits::ChannelMessage {
|
||||
id: "msg_abc123".into(),
|
||||
sender: "U123".into(),
|
||||
content: "hello".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
};
|
||||
|
||||
assert_eq!(conversation_memory_key(&msg), "slack_U123_msg_abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conversation_memory_key_is_unique_per_message() {
|
||||
let msg1 = traits::ChannelMessage {
|
||||
id: "msg_1".into(),
|
||||
sender: "U123".into(),
|
||||
content: "first".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
};
|
||||
let msg2 = traits::ChannelMessage {
|
||||
id: "msg_2".into(),
|
||||
sender: "U123".into(),
|
||||
content: "second".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 2,
|
||||
};
|
||||
|
||||
assert_ne!(
|
||||
conversation_memory_key(&msg1),
|
||||
conversation_memory_key(&msg2)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn autosave_keys_preserve_multiple_conversation_facts() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
let msg1 = traits::ChannelMessage {
|
||||
id: "msg_1".into(),
|
||||
sender: "U123".into(),
|
||||
content: "I'm Paul".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 1,
|
||||
};
|
||||
let msg2 = traits::ChannelMessage {
|
||||
id: "msg_2".into(),
|
||||
sender: "U123".into(),
|
||||
content: "I'm 45".into(),
|
||||
channel: "slack".into(),
|
||||
timestamp: 2,
|
||||
};
|
||||
|
||||
mem.store(
|
||||
&conversation_memory_key(&msg1),
|
||||
&msg1.content,
|
||||
MemoryCategory::Conversation,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
mem.store(
|
||||
&conversation_memory_key(&msg2),
|
||||
&msg2.content,
|
||||
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")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_memory_context_includes_recalled_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
mem.store("age_fact", "Age is 45", MemoryCategory::Conversation)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let context = build_memory_context(&mem, "age").await;
|
||||
assert!(context.contains("[Memory context]"));
|
||||
assert!(context.contains("Age is 45"));
|
||||
}
|
||||
|
||||
// ── AIEOS Identity Tests (Issue #168) ─────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue