1use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use super::{AgentTool, AgentToolResult, MemoryItem, ToolContext, ToolError};
7
8const DEFAULT_LIMIT: usize = 5;
10const MAX_LIMIT: usize = 20;
12
13pub struct MemoryRecallTool;
18
19#[async_trait]
20impl AgentTool for MemoryRecallTool {
21 fn name(&self) -> &str {
22 "memory_recall"
23 }
24
25 fn label(&self) -> &str {
26 "Memory Recall"
27 }
28
29 fn description(&self) -> &str {
30 "Search long-term memory for information relevant to a query. \
31 Returns the most relevant stored memories (facts, preferences, \
32 context, summaries)."
33 }
34
35 fn essential(&self) -> bool {
36 false
37 }
38
39 fn parameters_schema(&self) -> Value {
40 json!({
41 "type": "object",
42 "properties": {
43 "query": {
44 "type": "string",
45 "description": "What to search for in memory."
46 },
47 "limit": {
48 "type": "integer",
49 "minimum": 1,
50 "maximum": 20,
51 "default": 5,
52 "description": "Maximum number of results to return."
53 }
54 },
55 "required": ["query"]
56 })
57 }
58
59 async fn execute(
60 &self,
61 _tool_call_id: &str,
62 params: Value,
63 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
64 ctx: &ToolContext,
65 ) -> Result<AgentToolResult, ToolError> {
66 let backend = ctx.memory.as_ref().ok_or("Memory not configured")?;
67
68 let query = params
69 .get("query")
70 .and_then(|v| v.as_str())
71 .ok_or("Missing required parameter: query")?;
72
73 let limit = params
74 .get("limit")
75 .and_then(|v| v.as_u64())
76 .map(|l| (l as usize).clamp(1, MAX_LIMIT))
77 .unwrap_or(DEFAULT_LIMIT);
78
79 let results = backend.search(query, limit).await?;
80
81 Ok(AgentToolResult::success(format_results(&results, query)))
82 }
83}
84
85fn format_results(items: &[MemoryItem], query: &str) -> String {
89 if items.is_empty() {
90 return format!("No matching memories for query '{}'.", query);
91 }
92 let mut out = format!(
93 "Found {} memor{} for query '{}':\n\n",
94 items.len(),
95 if items.len() == 1 { "y" } else { "ies" },
96 query
97 );
98 for (i, item) in items.iter().enumerate() {
99 out.push_str(&format!(
100 "{}. [{}] scope='{}' id={} — {}\n",
101 i + 1,
102 item.kind,
103 item.subject,
104 item.id,
105 item.content
106 ));
107 }
108 out
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::tools::MemoryBackend;
115 use parking_lot::Mutex;
116 use std::future::Future;
117 use std::pin::Pin;
118 use std::sync::Arc;
119
120 #[derive(Debug)]
122 struct MockMemory {
123 items: Vec<MemoryItem>,
124 last_k: Mutex<Option<usize>>,
125 }
126
127 impl MemoryBackend for MockMemory {
128 fn put<'a>(
129 &'a self,
130 _content: &'a str,
131 _kind: &'a str,
132 _subject: &'a str,
133 ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
134 Box::pin(async move { Ok("mem-1".to_string()) })
135 }
136
137 fn search<'a>(
138 &'a self,
139 _query: &'a str,
140 k: usize,
141 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
142 *self.last_k.lock() = Some(k);
143 let items: Vec<MemoryItem> = self.items.iter().take(k).cloned().collect();
144 Box::pin(async move { Ok(items) })
145 }
146
147 fn list<'a>(
148 &'a self,
149 _subject: &'a str,
150 ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
151 Box::pin(async move { Ok(vec![]) })
152 }
153
154 fn delete<'a>(
155 &'a self,
156 _id: &'a str,
157 ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
158 Box::pin(async move { Ok(()) })
159 }
160 }
161
162 fn make_item(id: &str, kind: &str, content: &str) -> MemoryItem {
163 MemoryItem {
164 id: id.into(),
165 kind: kind.into(),
166 content: content.into(),
167 subject: "s".into(),
168 }
169 }
170
171 #[tokio::test]
172 async fn recall_returns_formatted_results() {
173 let mock = Arc::new(MockMemory {
174 items: vec![
175 make_item("1", "fact", "Rust is fast"),
176 make_item("2", "preference", "Likes dark mode"),
177 ],
178 last_k: Mutex::new(None),
179 });
180 let ctx = ToolContext::default().with_memory(mock.clone());
181 let result = MemoryRecallTool
182 .execute("c1", json!({"query": "rust", "limit": 5}), None, &ctx)
183 .await
184 .unwrap();
185 assert!(result.success);
186 assert!(result.output.contains("[fact] scope='s' id=1"));
187 assert!(result.output.contains("Rust is fast"));
188 assert!(result.output.contains("[preference] scope='s' id=2"));
189 assert!(result.output.contains("Likes dark mode"));
190 }
191
192 #[tokio::test]
193 async fn recall_reports_empty_results() {
194 let mock = Arc::new(MockMemory {
195 items: vec![],
196 last_k: Mutex::new(None),
197 });
198 let ctx = ToolContext::default().with_memory(mock);
199 let result = MemoryRecallTool
200 .execute("c1", json!({"query": "nothing"}), None, &ctx)
201 .await
202 .unwrap();
203 assert!(result.success);
204 assert!(
205 result
206 .output
207 .contains("No matching memories for query 'nothing'."),
208 "output was: {}",
209 result.output
210 );
211 }
212
213 #[tokio::test]
214 async fn recall_uses_default_limit() {
215 let mock = Arc::new(MockMemory {
216 items: vec![],
217 last_k: Mutex::new(None),
218 });
219 let ctx = ToolContext::default().with_memory(mock.clone());
220 MemoryRecallTool
221 .execute("c1", json!({"query": "x"}), None, &ctx)
222 .await
223 .unwrap();
224 assert_eq!(*mock.last_k.lock(), Some(DEFAULT_LIMIT));
225 }
226
227 #[tokio::test]
228 async fn recall_clamps_oversized_limit() {
229 let mock = Arc::new(MockMemory {
230 items: vec![],
231 last_k: Mutex::new(None),
232 });
233 let ctx = ToolContext::default().with_memory(mock.clone());
234 MemoryRecallTool
235 .execute("c1", json!({"query": "x", "limit": 100}), None, &ctx)
236 .await
237 .unwrap();
238 assert_eq!(*mock.last_k.lock(), Some(MAX_LIMIT));
239 }
240
241 #[tokio::test]
242 async fn recall_clamps_zero_limit() {
243 let mock = Arc::new(MockMemory {
244 items: vec![],
245 last_k: Mutex::new(None),
246 });
247 let ctx = ToolContext::default().with_memory(mock.clone());
248 MemoryRecallTool
249 .execute("c1", json!({"query": "x", "limit": 0}), None, &ctx)
250 .await
251 .unwrap();
252 assert_eq!(*mock.last_k.lock(), Some(1));
253 }
254
255 #[tokio::test]
256 async fn recall_errors_when_memory_not_configured() {
257 let ctx = ToolContext::default();
258 let err = MemoryRecallTool
259 .execute("c1", json!({"query": "x"}), None, &ctx)
260 .await
261 .unwrap_err();
262 assert_eq!(err, "Memory not configured");
263 }
264
265 #[tokio::test]
266 async fn recall_rejects_missing_query() {
267 let mock = Arc::new(MockMemory {
268 items: vec![],
269 last_k: Mutex::new(None),
270 });
271 let ctx = ToolContext::default().with_memory(mock);
272 let err = MemoryRecallTool
273 .execute("c1", json!({"limit": 3}), None, &ctx)
274 .await
275 .unwrap_err();
276 assert!(err.contains("query"));
277 }
278}