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