Skip to main content

oxicode_agent/tools/
memory_recall.rs

1//! `memory_recall` tool — search the memory backend for relevant items.
2
3use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use super::{AgentTool, AgentToolResult, MemoryItem, ToolContext, ToolError};
7
8/// Default number of results returned by [`MemoryRecallTool`] when `limit` is omitted.
9const DEFAULT_LIMIT: usize = 5;
10/// Maximum number of results [`MemoryRecallTool`] will return.
11const MAX_LIMIT: usize = 20;
12
13/// Tool that searches the configured `MemoryBackend` for memories matching
14/// a query and returns the matches in a compact, model-friendly format.
15///
16/// Requires `ctx.memory` to be set; otherwise returns an error.
17pub 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
85/// Format memory search results into a compact, model-friendly string.
86/// Plan §5.d: surface Brain IDs and scope so the agent can cite the
87/// durable authority's records.
88fn 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    /// Returns canned search results and records the requested `k`.
121    #[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}