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