Three fixes for conversation quality issues: 1. loop_.rs and channels now read max_tool_iterations from AgentConfig instead of using a hardcoded constant of 10, making it configurable. 2. Memory recall now filters entries below a configurable min_relevance_score threshold (default 0.4), preventing unrelated memories from bleeding into conversation context. 3. Default hybrid search weights rebalanced from 70/30 vector/keyword to 40/60, reducing cross-topic semantic bleed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
141 lines
3.5 KiB
Rust
141 lines
3.5 KiB
Rust
use crate::memory::Memory;
|
|
use async_trait::async_trait;
|
|
use std::fmt::Write;
|
|
|
|
#[async_trait]
|
|
pub trait MemoryLoader: Send + Sync {
|
|
async fn load_context(&self, memory: &dyn Memory, user_message: &str)
|
|
-> anyhow::Result<String>;
|
|
}
|
|
|
|
pub struct DefaultMemoryLoader {
|
|
limit: usize,
|
|
min_relevance_score: f64,
|
|
}
|
|
|
|
impl Default for DefaultMemoryLoader {
|
|
fn default() -> Self {
|
|
Self {
|
|
limit: 5,
|
|
min_relevance_score: 0.4,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DefaultMemoryLoader {
|
|
pub fn new(limit: usize, min_relevance_score: f64) -> Self {
|
|
Self {
|
|
limit: limit.max(1),
|
|
min_relevance_score,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MemoryLoader for DefaultMemoryLoader {
|
|
async fn load_context(
|
|
&self,
|
|
memory: &dyn Memory,
|
|
user_message: &str,
|
|
) -> anyhow::Result<String> {
|
|
let entries = memory.recall(user_message, self.limit, None).await?;
|
|
if entries.is_empty() {
|
|
return Ok(String::new());
|
|
}
|
|
|
|
let mut context = String::from("[Memory context]\n");
|
|
for entry in entries {
|
|
if let Some(score) = entry.score {
|
|
if score < self.min_relevance_score {
|
|
continue;
|
|
}
|
|
}
|
|
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
|
|
}
|
|
|
|
// If all entries were below threshold, return empty
|
|
if context == "[Memory context]\n" {
|
|
return Ok(String::new());
|
|
}
|
|
|
|
context.push('\n');
|
|
Ok(context)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::memory::{Memory, MemoryCategory, MemoryEntry};
|
|
|
|
struct MockMemory;
|
|
|
|
#[async_trait]
|
|
impl Memory for MockMemory {
|
|
async fn store(
|
|
&self,
|
|
_key: &str,
|
|
_content: &str,
|
|
_category: MemoryCategory,
|
|
_session_id: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn recall(
|
|
&self,
|
|
_query: &str,
|
|
limit: usize,
|
|
_session_id: Option<&str>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>> {
|
|
if limit == 0 {
|
|
return Ok(vec![]);
|
|
}
|
|
Ok(vec![MemoryEntry {
|
|
id: "1".into(),
|
|
key: "k".into(),
|
|
content: "v".into(),
|
|
category: MemoryCategory::Conversation,
|
|
timestamp: "now".into(),
|
|
session_id: None,
|
|
score: None,
|
|
}])
|
|
}
|
|
|
|
async fn get(&self, _key: &str) -> anyhow::Result<Option<MemoryEntry>> {
|
|
Ok(None)
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
_category: Option<&MemoryCategory>,
|
|
_session_id: Option<&str>,
|
|
) -> anyhow::Result<Vec<MemoryEntry>> {
|
|
Ok(vec![])
|
|
}
|
|
|
|
async fn forget(&self, _key: &str) -> anyhow::Result<bool> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn count(&self) -> anyhow::Result<usize> {
|
|
Ok(0)
|
|
}
|
|
|
|
async fn health_check(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn name(&self) -> &str {
|
|
"mock"
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn default_loader_formats_context() {
|
|
let loader = DefaultMemoryLoader::default();
|
|
let context = loader.load_context(&MockMemory, "hello").await.unwrap();
|
|
assert!(context.contains("[Memory context]"));
|
|
assert!(context.contains("- k: v"));
|
|
}
|
|
}
|