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};
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#[derive(Debug, Deserialize, JsonSchema)]
23struct MemorySearchInput {
24 query: String,
26 #[serde(default)]
28 limit: Option<usize>,
29}
30
31fn 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
47pub struct MemorySearchDispatcher {
53 store: Arc<dyn MemoryStore>,
54 scope: MemorySearchScope,
55 tool_defs: Arc<[Arc<ToolDef>]>,
56}
57
58impl MemorySearchDispatcher {
59 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 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 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 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 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 #[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 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 #[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}