Skip to main content

meerkat_memory/
tool.rs

1//! Memory search tool — exposes `MemoryStore::search` as an `AgentToolDispatcher`.
2//!
3//! This tool allows agents to search their semantic memory for past conversation
4//! content that was indexed during compaction. It wraps an `Arc<dyn MemoryStore>`
5//! and delegates to its `search()` method.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use meerkat_core::AgentToolDispatcher;
11use meerkat_core::error::ToolError;
12use meerkat_core::memory::{MemorySearchScope, MemoryStore};
13use meerkat_core::types::{ToolCallView, ToolDef, ToolProvenance, ToolResult, ToolSourceKind};
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde_json::{Map, Value, json};
17
18const TOOL_NAME: &str = "memory_search";
19const DEFAULT_LIMIT: usize = 5;
20
21/// Input schema for the memory_search tool.
22#[derive(Debug, Deserialize, JsonSchema)]
23struct MemorySearchInput {
24    /// Natural language search query describing what you want to recall.
25    query: String,
26    /// Maximum number of results to return (default: 5, max: 20).
27    #[serde(default)]
28    limit: Option<usize>,
29}
30
31/// Generate the JSON schema for the input type, ensuring `properties` and
32/// `required` keys are always present (tool schema contract).
33fn input_schema() -> Value {
34    let schema = schemars::schema_for!(MemorySearchInput);
35    let mut value = serde_json::to_value(&schema).unwrap_or(Value::Null);
36    if let Value::Object(ref mut obj) = value
37        && obj.get("type").and_then(Value::as_str) == Some("object")
38    {
39        obj.entry("properties".to_string())
40            .or_insert_with(|| Value::Object(Map::new()));
41        obj.entry("required".to_string())
42            .or_insert_with(|| Value::Array(Vec::new()));
43    }
44    value
45}
46
47/// Tool dispatcher that provides the `memory_search` tool.
48///
49/// Wraps an `Arc<dyn MemoryStore>` and exposes semantic search as an
50/// agent-callable tool. Created by the factory when the `memory-store-session`
51/// feature is enabled.
52pub struct MemorySearchDispatcher {
53    store: Arc<dyn MemoryStore>,
54    scope: MemorySearchScope,
55    tool_defs: Arc<[Arc<ToolDef>]>,
56}
57
58impl MemorySearchDispatcher {
59    /// Create a new memory search dispatcher backed by the given store.
60    pub fn new(store: Arc<dyn MemoryStore>, scope: MemorySearchScope) -> Self {
61        let tool_def = Arc::new(ToolDef {
62            name: TOOL_NAME.into(),
63            description: "Search semantic memory for past conversation content. \
64                Memory contains text from earlier conversation turns that were \
65                compacted away to save context space. Use this to recall \
66                information from earlier in the conversation or from previous sessions."
67                .to_string(),
68            input_schema: input_schema(),
69            provenance: Some(ToolProvenance {
70                kind: ToolSourceKind::Memory,
71                source_id: "memory".into(),
72            }),
73        });
74
75        Self {
76            store,
77            scope,
78            tool_defs: Arc::from(vec![tool_def]),
79        }
80    }
81
82    /// Create a dispatcher scoped to the canonical memory owner for a session.
83    pub fn for_session(
84        store: Arc<dyn MemoryStore>,
85        session_id: meerkat_core::types::SessionId,
86    ) -> Self {
87        Self::new(store, MemorySearchScope::for_session(session_id))
88    }
89
90    /// Usage instructions for the system prompt.
91    pub fn usage_instructions() -> &'static str {
92        "# Semantic Memory\n\n\
93         You have access to a semantic memory store that contains text from earlier \
94         conversation turns that were compacted away. Use the `memory_search` tool \
95         to recall information that is no longer in your visible context."
96    }
97}
98
99#[async_trait]
100impl AgentToolDispatcher for MemorySearchDispatcher {
101    fn tools(&self) -> Arc<[Arc<ToolDef>]> {
102        Arc::clone(&self.tool_defs)
103    }
104
105    async fn dispatch(
106        &self,
107        call: ToolCallView<'_>,
108    ) -> Result<meerkat_core::ops::ToolDispatchOutcome, ToolError> {
109        if call.name != TOOL_NAME {
110            return Err(ToolError::NotFound {
111                name: call.name.into(),
112            });
113        }
114        let input: MemorySearchInput =
115            serde_json::from_str(call.args.get()).map_err(|e| ToolError::InvalidArguments {
116                name: TOOL_NAME.into(),
117                reason: e.to_string(),
118            })?;
119        let limit = input.limit.unwrap_or(DEFAULT_LIMIT).min(20);
120        let results = self
121            .store
122            .search(&self.scope, &input.query, limit)
123            .await
124            .map_err(|e| ToolError::ExecutionFailed {
125                message: e.to_string(),
126            })?;
127        let items: Vec<Value> = results
128            .into_iter()
129            .map(|r| {
130                json!({
131                    "content": r.content,
132                    "score": r.score,
133                    "turn": r.metadata.turn,
134                })
135            })
136            .collect();
137        // Bare-array wire shape: the tool returns a list of result objects,
138        // and every test in this module expects `Vec<Value>` via
139        // `serde_json::from_str`. The `0c9acc473` wave-d baseline wrapped
140        // items in `{"results": items}` but that wrapper isn't consumed by
141        // any production caller (only test fixtures that parse as Vec),
142        // and MCP-style tool outputs conventionally emit the list directly
143        // when the result is a list.
144        let payload = Value::Array(items).to_string();
145        Ok(meerkat_core::ops::ToolDispatchOutcome::from(
146            ToolResult::new(call.id.to_string(), payload, false),
147        ))
148    }
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used, clippy::expect_used)]
153mod tests {
154    use super::*;
155    use meerkat_core::memory::{MemoryIndexRequest, MemoryIndexScope, MemoryMetadata, MemoryStore};
156    use meerkat_core::types::SessionId;
157    use serde_json::value::RawValue;
158    use std::time::SystemTime;
159
160    /// Helper to create a ToolCallView for testing.
161    fn make_call(args_json: &str) -> (String, Box<RawValue>, String) {
162        let id = "test-call-1".to_string();
163        let raw = RawValue::from_string(args_json.to_string()).unwrap();
164        let name = TOOL_NAME.to_string();
165        (id, raw, name)
166    }
167
168    fn call_view<'a>(id: &'a str, raw: &'a RawValue, name: &'a str) -> ToolCallView<'a> {
169        ToolCallView {
170            id,
171            name,
172            args: raw,
173        }
174    }
175
176    fn meta(session_id: &SessionId) -> MemoryMetadata {
177        MemoryMetadata {
178            session_id: session_id.clone(),
179            turn: Some(1),
180            indexed_at: SystemTime::now(),
181        }
182    }
183
184    fn request(content: impl Into<String>, session_id: &SessionId) -> MemoryIndexRequest {
185        MemoryIndexRequest::new(
186            MemoryIndexScope::for_session(session_id.clone()),
187            content.into(),
188            meta(session_id),
189        )
190        .unwrap()
191    }
192
193    fn dispatcher(store: Arc<dyn MemoryStore>, session_id: &SessionId) -> MemorySearchDispatcher {
194        MemorySearchDispatcher::for_session(store, session_id.clone())
195    }
196
197    // ==================== Tool Definition Tests ====================
198
199    #[test]
200    fn test_tool_name() {
201        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
202        let session_id = SessionId::new();
203        let dispatcher = dispatcher(store, &session_id);
204        let tools = dispatcher.tools();
205        assert_eq!(tools.len(), 1);
206        assert_eq!(tools[0].name, "memory_search");
207    }
208
209    #[test]
210    fn test_tool_schema_has_required_query() {
211        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
212        let session_id = SessionId::new();
213        let dispatcher = dispatcher(store, &session_id);
214        let tools = dispatcher.tools();
215        let schema = &tools[0].input_schema;
216
217        assert_eq!(schema["type"], "object");
218        assert!(schema["properties"]["query"].is_object());
219        assert_eq!(schema["properties"]["query"]["type"], "string");
220
221        let required = schema["required"].as_array().unwrap();
222        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
223        assert!(required_strs.contains(&"query"));
224    }
225
226    #[test]
227    fn test_tool_schema_has_optional_limit() {
228        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
229        let session_id = SessionId::new();
230        let dispatcher = dispatcher(store, &session_id);
231        let tools = dispatcher.tools();
232        let schema = &tools[0].input_schema;
233
234        assert!(schema["properties"]["limit"].is_object());
235
236        // limit is NOT in required
237        let required = schema["required"].as_array().unwrap();
238        let required_strs: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
239        assert!(!required_strs.contains(&"limit"));
240    }
241
242    // ==================== Dispatch Tests ====================
243
244    #[tokio::test]
245    async fn test_search_returns_results() {
246        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
247        let session_id = SessionId::new();
248        let other_session_id = SessionId::new();
249        store
250            .index_scoped(request("The project codename is AURORA-7", &session_id))
251            .await
252            .unwrap();
253        store
254            .index_scoped(request("The budget was set at $42,000", &session_id))
255            .await
256            .unwrap();
257        store
258            .index_scoped(request(
259                "Meeting scheduled for next Tuesday",
260                &other_session_id,
261            ))
262            .await
263            .unwrap();
264
265        let dispatcher = dispatcher(store, &session_id);
266        let (id, raw, name) = make_call(r#"{"query": "project codename"}"#);
267        let view = call_view(&id, &raw, &name);
268
269        let outcome = dispatcher.dispatch(view).await.unwrap();
270        assert!(!outcome.result.is_error);
271
272        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
273        assert!(!parsed.is_empty());
274        assert!(parsed[0]["content"].as_str().unwrap().contains("AURORA"));
275        assert!(parsed[0]["score"].as_f64().unwrap() > 0.0);
276        assert!(
277            parsed[0].get("session_id").is_none(),
278            "memory tool must not leak raw source session ids"
279        );
280    }
281
282    #[tokio::test]
283    async fn test_search_empty_store_returns_empty() {
284        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
285        let session_id = SessionId::new();
286        let dispatcher = dispatcher(store, &session_id);
287
288        let (id, raw, name) = make_call(r#"{"query": "anything"}"#);
289        let view = call_view(&id, &raw, &name);
290
291        let outcome = dispatcher.dispatch(view).await.unwrap();
292        assert!(!outcome.result.is_error);
293
294        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
295        assert!(parsed.is_empty());
296    }
297
298    #[tokio::test]
299    async fn test_search_with_limit() {
300        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
301        let session_id = SessionId::new();
302        for i in 0..10 {
303            store
304                .index_scoped(request(
305                    format!("Memory entry {i} about testing"),
306                    &session_id,
307                ))
308                .await
309                .unwrap();
310        }
311
312        let dispatcher = dispatcher(store, &session_id);
313        let (id, raw, name) = make_call(r#"{"query": "testing", "limit": 3}"#);
314        let view = call_view(&id, &raw, &name);
315
316        let outcome = dispatcher.dispatch(view).await.unwrap();
317        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
318        assert_eq!(parsed.len(), 3);
319    }
320
321    #[tokio::test]
322    async fn test_search_default_limit() {
323        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
324        let session_id = SessionId::new();
325        for i in 0..10 {
326            store
327                .index_scoped(request(
328                    format!("Entry {i} about Rust programming"),
329                    &session_id,
330                ))
331                .await
332                .unwrap();
333        }
334
335        let dispatcher = dispatcher(store, &session_id);
336        let (id, raw, name) = make_call(r#"{"query": "Rust"}"#);
337        let view = call_view(&id, &raw, &name);
338
339        let outcome = dispatcher.dispatch(view).await.unwrap();
340        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
341        assert_eq!(parsed.len(), DEFAULT_LIMIT);
342    }
343
344    #[tokio::test]
345    async fn test_search_no_match_returns_empty() {
346        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
347        let session_id = SessionId::new();
348        store
349            .index_scoped(request("The weather is sunny today", &session_id))
350            .await
351            .unwrap();
352
353        let dispatcher = dispatcher(store, &session_id);
354        let (id, raw, name) = make_call(r#"{"query": "quantum physics"}"#);
355        let view = call_view(&id, &raw, &name);
356
357        let outcome = dispatcher.dispatch(view).await.unwrap();
358        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
359        assert!(parsed.is_empty());
360    }
361
362    #[tokio::test]
363    async fn test_dispatch_wrong_tool_name() {
364        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
365        let session_id = SessionId::new();
366        let dispatcher = dispatcher(store, &session_id);
367
368        let id = "test-1".to_string();
369        let raw = RawValue::from_string(r#"{"query": "test"}"#.to_string()).unwrap();
370        let name = "wrong_tool";
371        let view = ToolCallView {
372            id: &id,
373            name,
374            args: &raw,
375        };
376
377        let result = dispatcher.dispatch(view).await;
378        assert!(matches!(result, Err(ToolError::NotFound { .. })));
379    }
380
381    #[tokio::test]
382    async fn test_dispatch_invalid_args() {
383        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
384        let session_id = SessionId::new();
385        let dispatcher = dispatcher(store, &session_id);
386
387        let (id, raw, name) = make_call(r#"{"not_query": "test"}"#);
388        let view = call_view(&id, &raw, &name);
389
390        let result = dispatcher.dispatch(view).await;
391        assert!(matches!(result, Err(ToolError::InvalidArguments { .. })));
392    }
393
394    #[tokio::test]
395    async fn test_limit_capped_at_20() {
396        let store: Arc<dyn MemoryStore> = Arc::new(crate::SimpleMemoryStore::new());
397        let session_id = SessionId::new();
398        for i in 0..30 {
399            store
400                .index_scoped(request(
401                    format!("Data point {i} about science"),
402                    &session_id,
403                ))
404                .await
405                .unwrap();
406        }
407
408        let dispatcher = dispatcher(store, &session_id);
409        let (id, raw, name) = make_call(r#"{"query": "science", "limit": 100}"#);
410        let view = call_view(&id, &raw, &name);
411
412        let outcome = dispatcher.dispatch(view).await.unwrap();
413        let parsed: Vec<Value> = serde_json::from_str(&outcome.result.text_content()).unwrap();
414        assert!(parsed.len() <= 20);
415    }
416
417    #[test]
418    fn test_usage_instructions_not_empty() {
419        let instructions = MemorySearchDispatcher::usage_instructions();
420        assert!(!instructions.is_empty());
421        assert!(instructions.contains("memory_search"));
422    }
423
424    #[test]
425    fn memory_tools_have_memory_provenance() {
426        let store: Arc<dyn MemoryStore> = Arc::new(crate::simple::SimpleMemoryStore::new());
427        let session_id = SessionId::new();
428        let dispatcher = dispatcher(store, &session_id);
429        let tools = dispatcher.tools();
430        assert_eq!(tools.len(), 1);
431        let prov = tools[0]
432            .provenance
433            .as_ref()
434            .expect("memory tool should have provenance");
435        assert_eq!(prov.kind, meerkat_core::types::ToolSourceKind::Memory);
436        assert_eq!(prov.source_id, "memory");
437    }
438}