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 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 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 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 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 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 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 #[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 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 #[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 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}