1use 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
23fn 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#[derive(Debug, Deserialize, JsonSchema)]
35struct MemorySearchInput {
36 query: String,
38 #[serde(default)]
40 limit: Option<usize>,
41}
42
43fn 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
59pub struct MemorySearchDispatcher {
65 store: Arc<dyn MemoryStore>,
66 scope: MemorySearchScope,
67 tool_defs: Arc<[Arc<ToolDef>]>,
68}
69
70impl MemorySearchDispatcher {
71 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 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 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 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 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 #[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 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 #[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 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}