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

@ -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]