1use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6
7use chrono::Utc;
8use serde::Deserialize;
9use serde_json::json;
10use uuid::Uuid;
11
12use crate::auth::TenantScope;
13use crate::error::Error;
14use crate::llm::types::ToolDefinition;
15use crate::tool::{Tool, ToolOutput};
16
17use super::reflection::ReflectionTracker;
18use super::{Memory, MemoryEntry, MemoryQuery};
19
20pub fn memory_tools_with_reflection(
28 memory: Arc<dyn Memory>,
29 agent_name: &str,
30 scope: TenantScope,
31 reflection_threshold: Option<u32>,
32) -> Vec<Arc<dyn Tool>> {
33 let tracker = reflection_threshold.map(|t| Arc::new(ReflectionTracker::new(t)));
34 vec![
35 Arc::new(MemoryStoreTool {
36 memory: memory.clone(),
37 agent_name: agent_name.into(),
38 scope: scope.clone(),
39 reflection_tracker: tracker,
40 }),
41 Arc::new(MemoryRecallTool {
42 memory: memory.clone(),
43 scope: scope.clone(),
44 }),
45 Arc::new(MemoryUpdateTool {
46 memory: memory.clone(),
47 scope: scope.clone(),
48 }),
49 Arc::new(MemoryForgetTool {
50 memory: memory.clone(),
51 scope: scope.clone(),
52 }),
53 Arc::new(MemoryConsolidateTool {
54 memory,
55 agent_name: agent_name.into(),
56 scope,
57 }),
58 ]
59}
60
61struct MemoryStoreTool {
64 memory: Arc<dyn Memory>,
65 agent_name: String,
66 scope: TenantScope,
67 reflection_tracker: Option<Arc<ReflectionTracker>>,
68}
69
70#[derive(Deserialize)]
71struct StoreInput {
72 content: String,
73 #[serde(default = "super::default_category")]
74 category: String,
75 #[serde(default)]
76 tags: Vec<String>,
77 #[serde(default = "super::default_importance")]
78 importance: u8,
79 #[serde(default)]
80 keywords: Vec<String>,
81 #[serde(default)]
82 summary: Option<String>,
83 #[serde(default)]
84 confidentiality: super::Confidentiality,
85}
86
87impl Tool for MemoryStoreTool {
88 fn definition(&self) -> ToolDefinition {
89 ToolDefinition {
90 name: "memory_store".into(),
91 description: "Store a new memory. Use this to remember important facts, \
92 observations, preferences, or procedures for later recall."
93 .into(),
94 input_schema: json!({
95 "type": "object",
96 "properties": {
97 "content": {
98 "type": "string",
99 "description": "The content to memorize"
100 },
101 "category": {
102 "type": "string",
103 "enum": ["fact", "observation", "preference", "procedure"],
104 "description": "Category of memory (default: fact)"
105 },
106 "tags": {
107 "type": "array",
108 "items": {"type": "string"},
109 "description": "Optional tags for organization"
110 },
111 "importance": {
112 "type": "integer",
113 "minimum": 1,
114 "maximum": 10,
115 "description": "Importance score 1-10 (default: 5). Higher = more likely to surface in recall."
116 },
117 "keywords": {
118 "type": "array",
119 "items": {"type": "string"},
120 "description": "Keywords for improved retrieval (BM25 scoring). Provide 3-5 key terms."
121 },
122 "summary": {
123 "type": "string",
124 "description": "One-sentence summary providing context for this memory."
125 },
126 "confidentiality": {
127 "type": "string",
128 "enum": ["public", "internal", "confidential"],
129 "description": "Access level. 'public' = shareable, 'internal' = verified+ only, 'confidential' = owner only. Default: public. ('restricted' is reserved for sensor-pipeline ingestion of secrets and is rejected here — security: F-MEM-2/F-MEM-6.)"
130 }
131 },
132 "required": ["content"]
133 }),
134 }
135 }
136
137 fn execute(
138 &self,
139 _ctx: &crate::ExecutionContext,
140 input: serde_json::Value,
141 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
142 Box::pin(async move {
143 let input: StoreInput =
144 serde_json::from_value(input).map_err(|e| Error::Memory(e.to_string()))?;
145
146 let confidentiality = if input.confidentiality > super::Confidentiality::Confidential {
155 super::Confidentiality::Confidential
156 } else {
157 input.confidentiality
158 };
159 let id = Uuid::new_v4().to_string();
160 let now = Utc::now();
161 let entry = MemoryEntry {
162 id: id.clone(),
163 agent: self.agent_name.clone(),
164 content: input.content,
165 category: input.category,
166 tags: input.tags,
167 created_at: now,
168 last_accessed: now,
169 access_count: 0,
170 importance: input.importance.clamp(1, 10),
171 memory_type: super::MemoryType::default(),
172 keywords: input.keywords,
173 summary: input.summary,
174 strength: 1.0,
175 related_ids: vec![],
176 source_ids: vec![],
177 embedding: None,
178 confidentiality,
179 author_user_id: None,
180 author_tenant_id: None,
181 };
182
183 let importance = entry.importance;
184 let keywords = entry.keywords.clone();
185 self.memory.store(&self.scope, entry).await?;
186
187 if !keywords.is_empty()
189 && let Ok(existing) = self
190 .memory
191 .recall(
192 &self.scope,
193 MemoryQuery {
194 limit: 20,
195 ..Default::default()
196 },
197 )
198 .await
199 {
200 for e in &existing {
201 if e.id == id || e.keywords.is_empty() {
202 continue;
203 }
204 let jaccard = super::consolidation::jaccard_similarity(&keywords, &e.keywords);
205 if jaccard >= 0.2 {
206 let _ = self.memory.add_link(&self.scope, &id, &e.id).await;
207 }
208 }
209 }
210
211 let mut msg = format!("Stored memory with id: {id}");
212 if let Some(ref tracker) = self.reflection_tracker
213 && tracker.record(importance)
214 {
215 msg.push_str(super::reflection::REFLECTION_HINT);
216 }
217 Ok(ToolOutput::success(msg))
218 })
219 }
220}
221
222struct MemoryRecallTool {
225 memory: Arc<dyn Memory>,
226 scope: TenantScope,
227}
228
229#[derive(Deserialize)]
230struct RecallInput {
231 #[serde(default)]
232 query: Option<String>,
233 #[serde(default)]
234 category: Option<String>,
235 #[serde(default)]
236 tags: Vec<String>,
237 #[serde(default = "super::default_recall_limit")]
238 limit: usize,
239}
240
241impl Tool for MemoryRecallTool {
242 fn definition(&self) -> ToolDefinition {
243 ToolDefinition {
244 name: "memory_recall".into(),
245 description: "Search and retrieve stored memories. Filter by text query, \
246 category, or tags."
247 .into(),
248 input_schema: json!({
249 "type": "object",
250 "properties": {
251 "query": {
252 "type": "string",
253 "description": "Text to search for in memories"
254 },
255 "category": {
256 "type": "string",
257 "enum": ["fact", "observation", "preference", "procedure"],
258 "description": "Filter by category"
259 },
260 "tags": {
261 "type": "array",
262 "items": {"type": "string"},
263 "description": "Filter by tags (matches any)"
264 },
265 "limit": {
266 "type": "integer",
267 "description": "Max results to return (default: 10)"
268 }
269 }
270 }),
271 }
272 }
273
274 fn execute(
275 &self,
276 _ctx: &crate::ExecutionContext,
277 input: serde_json::Value,
278 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
279 Box::pin(async move {
280 let input: RecallInput =
281 serde_json::from_value(input).map_err(|e| Error::Memory(e.to_string()))?;
282
283 let results = self
284 .memory
285 .recall(
286 &self.scope,
287 MemoryQuery {
288 text: input.query,
289 category: input.category,
290 tags: input.tags,
291 agent: None,
295 limit: input.limit,
296 ..Default::default()
297 },
298 )
299 .await?;
300
301 if results.is_empty() {
302 return Ok(ToolOutput::success("No memories found."));
303 }
304
305 let formatted: Vec<String> = results
309 .iter()
310 .enumerate()
311 .map(|(rank, e)| {
312 let display_content = if e.content.len() > 200 {
313 let truncated: String = e.content.chars().take(200).collect();
314 format!("{truncated}...")
315 } else {
316 e.content.clone()
317 };
318 let mt = match e.memory_type {
319 crate::memory::MemoryType::Episodic => "episodic",
320 crate::memory::MemoryType::Semantic => "semantic",
321 crate::memory::MemoryType::Reflection => "reflection",
322 };
323 let mut line = format!(
324 "- #{} [{}] ({}, {}, importance:{}, strength:{:.2}) {}\n Tags: {:?} | Accessed: {} times",
325 rank + 1,
326 e.id,
327 e.category,
328 mt,
329 e.importance,
330 e.strength,
331 display_content,
332 e.tags,
333 e.access_count,
334 );
335 if !e.keywords.is_empty() {
336 line.push_str(&format!(" | Keywords: {:?}", e.keywords));
337 }
338 line
339 })
340 .collect();
341
342 let count = results.len();
343 let noun = if count == 1 { "memory" } else { "memories" };
344 Ok(ToolOutput::success(format!(
345 "Found {count} {noun}:\n{}",
346 formatted.join("\n")
347 )))
348 })
349 }
350}
351
352struct MemoryUpdateTool {
355 memory: Arc<dyn Memory>,
356 scope: TenantScope,
357}
358
359#[derive(Deserialize)]
360struct UpdateInput {
361 id: String,
362 content: String,
363}
364
365impl Tool for MemoryUpdateTool {
366 fn definition(&self) -> ToolDefinition {
367 ToolDefinition {
368 name: "memory_update".into(),
369 description: "Update an existing memory entry by ID with new content.".into(),
370 input_schema: json!({
371 "type": "object",
372 "properties": {
373 "id": {
374 "type": "string",
375 "description": "ID of the memory to update"
376 },
377 "content": {
378 "type": "string",
379 "description": "New content for the memory"
380 }
381 },
382 "required": ["id", "content"]
383 }),
384 }
385 }
386
387 fn execute(
388 &self,
389 _ctx: &crate::ExecutionContext,
390 input: serde_json::Value,
391 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
392 Box::pin(async move {
393 let input: UpdateInput =
394 serde_json::from_value(input).map_err(|e| Error::Memory(e.to_string()))?;
395
396 self.memory
397 .update(&self.scope, &input.id, input.content)
398 .await?;
399 Ok(ToolOutput::success(format!("Updated memory: {}", input.id)))
400 })
401 }
402}
403
404struct MemoryForgetTool {
407 memory: Arc<dyn Memory>,
408 scope: TenantScope,
409}
410
411#[derive(Deserialize)]
412struct ForgetInput {
413 id: String,
414}
415
416impl Tool for MemoryForgetTool {
417 fn definition(&self) -> ToolDefinition {
418 ToolDefinition {
419 name: "memory_forget".into(),
420 description: "Delete a memory entry by ID.".into(),
421 input_schema: json!({
422 "type": "object",
423 "properties": {
424 "id": {
425 "type": "string",
426 "description": "ID of the memory to delete"
427 }
428 },
429 "required": ["id"]
430 }),
431 }
432 }
433
434 fn execute(
435 &self,
436 _ctx: &crate::ExecutionContext,
437 input: serde_json::Value,
438 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
439 Box::pin(async move {
440 let input: ForgetInput =
441 serde_json::from_value(input).map_err(|e| Error::Memory(e.to_string()))?;
442
443 let removed = self.memory.forget(&self.scope, &input.id).await?;
444 if removed {
445 Ok(ToolOutput::success(format!("Deleted memory: {}", input.id)))
446 } else {
447 Ok(ToolOutput::error(format!("Memory not found: {}", input.id)))
448 }
449 })
450 }
451}
452
453struct MemoryConsolidateTool {
456 memory: Arc<dyn Memory>,
457 agent_name: String,
458 scope: TenantScope,
459}
460
461#[derive(Deserialize)]
462struct ConsolidateInput {
463 source_ids: Vec<String>,
464 content: String,
465 #[serde(default = "super::default_category")]
466 category: String,
467 #[serde(default)]
468 tags: Vec<String>,
469 #[serde(default = "super::default_importance")]
470 importance: u8,
471}
472
473impl Tool for MemoryConsolidateTool {
474 fn definition(&self) -> ToolDefinition {
475 ToolDefinition {
476 name: "memory_consolidate".into(),
477 description: "Consolidate multiple memories into one. Provide the IDs of source \
478 memories to merge and the new consolidated content. Source memories \
479 are deleted and replaced with the new entry."
480 .into(),
481 input_schema: json!({
482 "type": "object",
483 "properties": {
484 "source_ids": {
485 "type": "array",
486 "items": {"type": "string"},
487 "minItems": 2,
488 "description": "IDs of memories to consolidate (minimum 2)"
489 },
490 "content": {
491 "type": "string",
492 "description": "The consolidated content summarizing the source memories"
493 },
494 "category": {
495 "type": "string",
496 "enum": ["fact", "observation", "preference", "procedure"],
497 "description": "Category for the consolidated memory (default: fact)"
498 },
499 "tags": {
500 "type": "array",
501 "items": {"type": "string"},
502 "description": "Tags for the consolidated memory"
503 },
504 "importance": {
505 "type": "integer",
506 "minimum": 1,
507 "maximum": 10,
508 "description": "Importance score 1-10 (default: 5)"
509 }
510 },
511 "required": ["source_ids", "content"]
512 }),
513 }
514 }
515
516 fn execute(
517 &self,
518 _ctx: &crate::ExecutionContext,
519 input: serde_json::Value,
520 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
521 Box::pin(async move {
522 let input: ConsolidateInput =
523 serde_json::from_value(input).map_err(|e| Error::Memory(e.to_string()))?;
524
525 if input.source_ids.len() < 2 {
526 return Ok(ToolOutput::error(
527 "Consolidation requires at least 2 source memory IDs.",
528 ));
529 }
530
531 let sources = self
534 .memory
535 .recall(
536 &self.scope,
537 MemoryQuery {
538 limit: 1000,
539 ..Default::default()
540 },
541 )
542 .await
543 .unwrap_or_default();
544
545 let source_set: std::collections::HashSet<&str> =
546 input.source_ids.iter().map(|s| s.as_str()).collect();
547
548 let mut merged_keywords: std::collections::HashSet<String> =
549 std::collections::HashSet::new();
550 let mut merged_tags: std::collections::HashSet<String> =
551 input.tags.iter().cloned().collect();
552
553 let mut max_confidentiality = super::Confidentiality::default();
556 for entry in &sources {
557 if source_set.contains(entry.id.as_str()) {
558 merged_keywords.extend(entry.keywords.iter().cloned());
559 merged_tags.extend(entry.tags.iter().cloned());
560 if entry.confidentiality > max_confidentiality {
561 max_confidentiality = entry.confidentiality;
562 }
563 }
564 }
565
566 let keywords: Vec<String> = merged_keywords.into_iter().collect();
567 let tags: Vec<String> = merged_tags.into_iter().collect();
568
569 let new_id = Uuid::new_v4().to_string();
572 let now = Utc::now();
573 let entry = MemoryEntry {
574 id: new_id.clone(),
575 agent: self.agent_name.clone(),
576 content: input.content,
577 category: input.category,
578 tags,
579 created_at: now,
580 last_accessed: now,
581 access_count: 0,
582 importance: input.importance.clamp(1, 10),
583 memory_type: super::MemoryType::Semantic,
584 keywords,
585 summary: None,
586 strength: 1.0,
587 related_ids: vec![],
588 source_ids: input.source_ids.clone(),
589 embedding: None,
590 confidentiality: max_confidentiality,
591 author_user_id: None,
592 author_tenant_id: None,
593 };
594
595 self.memory.store(&self.scope, entry).await?;
596
597 let total = input.source_ids.len();
599 let mut deleted = 0;
600 let mut not_found = Vec::new();
601 for id in &input.source_ids {
602 match self.memory.forget(&self.scope, id).await? {
603 true => deleted += 1,
604 false => not_found.push(id.clone()),
605 }
606 }
607
608 if deleted == 0 {
609 if let Err(e) = self.memory.forget(&self.scope, &new_id).await {
611 tracing::warn!(id = %new_id, error = %e, "failed to clean up orphaned consolidation entry");
612 }
613 return Ok(ToolOutput::error(
614 "None of the source memories were found. No consolidation performed.",
615 ));
616 }
617
618 let mut msg = format!("Consolidated {deleted} memories into new memory: {new_id}");
619 if !not_found.is_empty() {
620 msg.push_str(&format!(
621 "\nWarning: {} of {} source memories not found: {:?}",
622 not_found.len(),
623 total,
624 not_found
625 ));
626 }
627 Ok(ToolOutput::success(msg))
628 })
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::auth::TenantScope;
636 use crate::memory::Confidentiality;
637 use crate::memory::in_memory::InMemoryStore;
638
639 fn test_scope() -> TenantScope {
640 TenantScope::default()
641 }
642
643 fn setup() -> (Arc<dyn Memory>, Vec<Arc<dyn Tool>>) {
644 let store: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
645 let tools = memory_tools_with_reflection(store.clone(), "test-agent", test_scope(), None);
646 (store, tools)
647 }
648
649 fn find_tool<'a>(tools: &'a [Arc<dyn Tool>], name: &str) -> &'a Arc<dyn Tool> {
650 tools
651 .iter()
652 .find(|t| t.definition().name == name)
653 .unwrap_or_else(|| panic!("tool {name} not found"))
654 }
655
656 #[test]
657 fn creates_five_tools() {
658 let (_store, tools) = setup();
659 assert_eq!(tools.len(), 5);
660
661 let names: Vec<String> = tools.iter().map(|t| t.definition().name).collect();
662 assert!(names.contains(&"memory_store".to_string()));
663 assert!(names.contains(&"memory_recall".to_string()));
664 assert!(names.contains(&"memory_update".to_string()));
665 assert!(names.contains(&"memory_forget".to_string()));
666 assert!(names.contains(&"memory_consolidate".to_string()));
667 }
668
669 #[tokio::test]
670 async fn store_tool_creates_memory() {
671 let (store, tools) = setup();
672 let tool = find_tool(&tools, "memory_store");
673
674 let result = tool
675 .execute(
676 &crate::ExecutionContext::default(),
677 json!({
678 "content": "Rust is memory-safe",
679 "category": "fact",
680 "tags": ["rust", "safety"]
681 }),
682 )
683 .await
684 .unwrap();
685
686 assert!(!result.is_error);
687 assert!(result.content.contains("Stored memory with id:"));
688
689 let entries = store
691 .recall(
692 &test_scope(),
693 MemoryQuery {
694 limit: 10,
695 ..Default::default()
696 },
697 )
698 .await
699 .unwrap();
700 assert_eq!(entries.len(), 1);
701 assert_eq!(entries[0].content, "Rust is memory-safe");
702 assert_eq!(entries[0].agent, "test-agent");
703 }
704
705 #[tokio::test]
706 async fn recall_tool_finds_memories() {
707 let (_store, tools) = setup();
708 let store_tool = find_tool(&tools, "memory_store");
709 let recall_tool = find_tool(&tools, "memory_recall");
710
711 store_tool
713 .execute(
714 &crate::ExecutionContext::default(),
715 json!({"content": "Rust is fast", "category": "fact"}),
716 )
717 .await
718 .unwrap();
719 store_tool
720 .execute(
721 &crate::ExecutionContext::default(),
722 json!({"content": "Python is slow", "category": "observation"}),
723 )
724 .await
725 .unwrap();
726
727 let result = recall_tool
729 .execute(&crate::ExecutionContext::default(), json!({}))
730 .await
731 .unwrap();
732 assert!(!result.is_error);
733 assert!(result.content.contains("Found 2 memories"));
734
735 let result = recall_tool
737 .execute(
738 &crate::ExecutionContext::default(),
739 json!({"query": "rust"}),
740 )
741 .await
742 .unwrap();
743 assert!(result.content.contains("Found 1 memory:"));
744 assert!(result.content.contains("Rust is fast"));
745 }
746
747 #[tokio::test]
748 async fn recall_tool_empty_result() {
749 let (_store, tools) = setup();
750 let recall_tool = find_tool(&tools, "memory_recall");
751
752 let result = recall_tool
753 .execute(&crate::ExecutionContext::default(), json!({}))
754 .await
755 .unwrap();
756 assert!(!result.is_error);
757 assert_eq!(result.content, "No memories found.");
758 }
759
760 #[tokio::test]
761 async fn update_tool_modifies_memory() {
762 let (store, tools) = setup();
763 let store_tool = find_tool(&tools, "memory_store");
764 let update_tool = find_tool(&tools, "memory_update");
765
766 store_tool
767 .execute(
768 &crate::ExecutionContext::default(),
769 json!({"content": "original"}),
770 )
771 .await
772 .unwrap();
773
774 let entries = store
776 .recall(
777 &test_scope(),
778 MemoryQuery {
779 limit: 1,
780 ..Default::default()
781 },
782 )
783 .await
784 .unwrap();
785 let id = &entries[0].id;
786
787 let result = update_tool
788 .execute(
789 &crate::ExecutionContext::default(),
790 json!({"id": id, "content": "updated"}),
791 )
792 .await
793 .unwrap();
794 assert!(!result.is_error);
795
796 let entries = store
797 .recall(
798 &test_scope(),
799 MemoryQuery {
800 limit: 1,
801 ..Default::default()
802 },
803 )
804 .await
805 .unwrap();
806 assert_eq!(entries[0].content, "updated");
807 }
808
809 #[tokio::test]
810 async fn forget_tool_deletes_memory() {
811 let (store, tools) = setup();
812 let store_tool = find_tool(&tools, "memory_store");
813 let forget_tool = find_tool(&tools, "memory_forget");
814
815 store_tool
816 .execute(
817 &crate::ExecutionContext::default(),
818 json!({"content": "to delete"}),
819 )
820 .await
821 .unwrap();
822
823 let entries = store
824 .recall(
825 &test_scope(),
826 MemoryQuery {
827 limit: 1,
828 ..Default::default()
829 },
830 )
831 .await
832 .unwrap();
833 let id = &entries[0].id;
834
835 let result = forget_tool
836 .execute(&crate::ExecutionContext::default(), json!({"id": id}))
837 .await
838 .unwrap();
839 assert!(!result.is_error);
840 assert!(result.content.contains("Deleted"));
841
842 let entries = store
843 .recall(
844 &test_scope(),
845 MemoryQuery {
846 limit: 10,
847 ..Default::default()
848 },
849 )
850 .await
851 .unwrap();
852 assert!(entries.is_empty());
853 }
854
855 #[tokio::test]
856 async fn forget_tool_nonexistent() {
857 let (_store, tools) = setup();
858 let forget_tool = find_tool(&tools, "memory_forget");
859
860 let result = forget_tool
861 .execute(
862 &crate::ExecutionContext::default(),
863 json!({"id": "nonexistent"}),
864 )
865 .await
866 .unwrap();
867 assert!(result.is_error);
868 assert!(result.content.contains("not found"));
869 }
870
871 #[tokio::test]
874 async fn store_tool_default_importance() {
875 let (store, tools) = setup();
876 let tool = find_tool(&tools, "memory_store");
877
878 tool.execute(
879 &crate::ExecutionContext::default(),
880 json!({"content": "test"}),
881 )
882 .await
883 .unwrap();
884
885 let entries = store
886 .recall(
887 &test_scope(),
888 MemoryQuery {
889 limit: 1,
890 ..Default::default()
891 },
892 )
893 .await
894 .unwrap();
895 assert_eq!(entries[0].importance, 5);
896 }
897
898 #[tokio::test]
899 async fn store_tool_custom_importance() {
900 let (store, tools) = setup();
901 let tool = find_tool(&tools, "memory_store");
902
903 tool.execute(
904 &crate::ExecutionContext::default(),
905 json!({"content": "critical", "importance": 9}),
906 )
907 .await
908 .unwrap();
909
910 let entries = store
911 .recall(
912 &test_scope(),
913 MemoryQuery {
914 limit: 1,
915 ..Default::default()
916 },
917 )
918 .await
919 .unwrap();
920 assert_eq!(entries[0].importance, 9);
921 }
922
923 #[tokio::test]
924 async fn store_tool_clamps_importance() {
925 let (store, tools) = setup();
926 let tool = find_tool(&tools, "memory_store");
927
928 tool.execute(
930 &crate::ExecutionContext::default(),
931 json!({"content": "over", "importance": 15}),
932 )
933 .await
934 .unwrap();
935
936 let entries = store
937 .recall(
938 &test_scope(),
939 MemoryQuery {
940 limit: 1,
941 ..Default::default()
942 },
943 )
944 .await
945 .unwrap();
946 assert_eq!(entries[0].importance, 10);
947 }
948
949 #[tokio::test]
952 async fn recall_tool_shows_rank() {
953 let (_store, tools) = setup();
954 let store_tool = find_tool(&tools, "memory_store");
955 let recall_tool = find_tool(&tools, "memory_recall");
956
957 store_tool
958 .execute(
959 &crate::ExecutionContext::default(),
960 json!({"content": "first memory"}),
961 )
962 .await
963 .unwrap();
964 store_tool
965 .execute(
966 &crate::ExecutionContext::default(),
967 json!({"content": "second memory"}),
968 )
969 .await
970 .unwrap();
971
972 let result = recall_tool
973 .execute(&crate::ExecutionContext::default(), json!({}))
974 .await
975 .unwrap();
976 assert!(result.content.contains("#1"), "should show rank #1");
977 assert!(result.content.contains("#2"), "should show rank #2");
978 }
979
980 #[tokio::test]
981 async fn recall_tool_shows_importance() {
982 let (_store, tools) = setup();
983 let store_tool = find_tool(&tools, "memory_store");
984 let recall_tool = find_tool(&tools, "memory_recall");
985
986 store_tool
987 .execute(
988 &crate::ExecutionContext::default(),
989 json!({"content": "important thing", "importance": 8}),
990 )
991 .await
992 .unwrap();
993
994 let result = recall_tool
995 .execute(&crate::ExecutionContext::default(), json!({}))
996 .await
997 .unwrap();
998 assert!(result.content.contains("importance:8"));
999 }
1000
1001 #[tokio::test]
1002 async fn recall_tool_truncates_long_content_safely() {
1003 let (_store, tools) = setup();
1004 let store_tool = find_tool(&tools, "memory_store");
1005 let recall_tool = find_tool(&tools, "memory_recall");
1006
1007 let content = "🦀".repeat(100); store_tool
1010 .execute(
1011 &crate::ExecutionContext::default(),
1012 json!({"content": content}),
1013 )
1014 .await
1015 .unwrap();
1016
1017 let result = recall_tool
1019 .execute(&crate::ExecutionContext::default(), json!({}))
1020 .await
1021 .unwrap();
1022 assert!(!result.is_error);
1023 assert!(result.content.contains("Found 1 memory"));
1024 }
1025
1026 #[tokio::test]
1027 async fn recall_tool_truncates_very_long_content() {
1028 let (_store, tools) = setup();
1029 let store_tool = find_tool(&tools, "memory_store");
1030 let recall_tool = find_tool(&tools, "memory_recall");
1031
1032 let content = "a".repeat(500);
1034 store_tool
1035 .execute(
1036 &crate::ExecutionContext::default(),
1037 json!({"content": content}),
1038 )
1039 .await
1040 .unwrap();
1041
1042 let result = recall_tool
1043 .execute(&crate::ExecutionContext::default(), json!({}))
1044 .await
1045 .unwrap();
1046 assert!(!result.is_error);
1047 assert!(result.content.contains("..."));
1048 assert!(!result.content.contains(&"a".repeat(500)));
1050 }
1051
1052 #[tokio::test]
1055 async fn consolidate_tool_merges_memories() {
1056 let (store, tools) = setup();
1057 let store_tool = find_tool(&tools, "memory_store");
1058 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1059
1060 store_tool
1061 .execute(
1062 &crate::ExecutionContext::default(),
1063 json!({"content": "fact A"}),
1064 )
1065 .await
1066 .unwrap();
1067 store_tool
1068 .execute(
1069 &crate::ExecutionContext::default(),
1070 json!({"content": "fact B"}),
1071 )
1072 .await
1073 .unwrap();
1074
1075 let entries = store
1076 .recall(
1077 &test_scope(),
1078 MemoryQuery {
1079 limit: 10,
1080 ..Default::default()
1081 },
1082 )
1083 .await
1084 .unwrap();
1085 assert_eq!(entries.len(), 2);
1086 let id_a = entries[0].id.clone();
1087 let id_b = entries[1].id.clone();
1088
1089 let result = consolidate_tool
1090 .execute(
1091 &crate::ExecutionContext::default(),
1092 json!({
1093 "source_ids": [id_a, id_b],
1094 "content": "Combined A and B",
1095 "category": "fact"
1096 }),
1097 )
1098 .await
1099 .unwrap();
1100 assert!(!result.is_error);
1101 assert!(result.content.contains("Consolidated 2 memories"));
1102
1103 let entries = store
1105 .recall(
1106 &test_scope(),
1107 MemoryQuery {
1108 limit: 10,
1109 ..Default::default()
1110 },
1111 )
1112 .await
1113 .unwrap();
1114 assert_eq!(entries.len(), 1);
1115 assert_eq!(entries[0].content, "Combined A and B");
1116 }
1117
1118 #[tokio::test]
1119 async fn consolidate_tool_requires_minimum_two() {
1120 let (_store, tools) = setup();
1121 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1122
1123 let result = consolidate_tool
1124 .execute(
1125 &crate::ExecutionContext::default(),
1126 json!({
1127 "source_ids": ["only-one"],
1128 "content": "merged"
1129 }),
1130 )
1131 .await
1132 .unwrap();
1133 assert!(result.is_error);
1134 assert!(result.content.contains("at least 2"));
1135 }
1136
1137 #[tokio::test]
1138 async fn consolidate_tool_partial_not_found() {
1139 let (store, tools) = setup();
1140 let store_tool = find_tool(&tools, "memory_store");
1141 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1142
1143 store_tool
1144 .execute(
1145 &crate::ExecutionContext::default(),
1146 json!({"content": "exists"}),
1147 )
1148 .await
1149 .unwrap();
1150
1151 let entries = store
1152 .recall(
1153 &test_scope(),
1154 MemoryQuery {
1155 limit: 1,
1156 ..Default::default()
1157 },
1158 )
1159 .await
1160 .unwrap();
1161 let real_id = entries[0].id.clone();
1162
1163 let result = consolidate_tool
1164 .execute(
1165 &crate::ExecutionContext::default(),
1166 json!({
1167 "source_ids": [real_id, "nonexistent-id"],
1168 "content": "partial consolidation"
1169 }),
1170 )
1171 .await
1172 .unwrap();
1173 assert!(!result.is_error);
1174 assert!(result.content.contains("Consolidated 1 memories"));
1175 assert!(result.content.contains("Warning"));
1176 assert!(result.content.contains("nonexistent-id"));
1177 }
1178
1179 #[tokio::test]
1180 async fn consolidate_tool_all_not_found() {
1181 let (store, tools) = setup();
1182 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1183
1184 let result = consolidate_tool
1185 .execute(
1186 &crate::ExecutionContext::default(),
1187 json!({
1188 "source_ids": ["fake1", "fake2"],
1189 "content": "nope"
1190 }),
1191 )
1192 .await
1193 .unwrap();
1194 assert!(result.is_error);
1195 assert!(
1196 result
1197 .content
1198 .contains("None of the source memories were found")
1199 );
1200
1201 let all = store
1203 .recall(
1204 &test_scope(),
1205 MemoryQuery {
1206 limit: 100,
1207 ..Default::default()
1208 },
1209 )
1210 .await
1211 .unwrap();
1212 assert!(all.is_empty(), "orphaned entry should have been cleaned up");
1213 }
1214
1215 #[tokio::test]
1216 async fn consolidate_tool_preserves_importance() {
1217 let (store, tools) = setup();
1218 let store_tool = find_tool(&tools, "memory_store");
1219 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1220
1221 store_tool
1222 .execute(
1223 &crate::ExecutionContext::default(),
1224 json!({"content": "a", "importance": 3}),
1225 )
1226 .await
1227 .unwrap();
1228 store_tool
1229 .execute(
1230 &crate::ExecutionContext::default(),
1231 json!({"content": "b", "importance": 7}),
1232 )
1233 .await
1234 .unwrap();
1235
1236 let entries = store
1237 .recall(
1238 &test_scope(),
1239 MemoryQuery {
1240 limit: 10,
1241 ..Default::default()
1242 },
1243 )
1244 .await
1245 .unwrap();
1246 let id_a = entries[0].id.clone();
1247 let id_b = entries[1].id.clone();
1248
1249 consolidate_tool
1250 .execute(
1251 &crate::ExecutionContext::default(),
1252 json!({
1253 "source_ids": [id_a, id_b],
1254 "content": "merged",
1255 "importance": 9
1256 }),
1257 )
1258 .await
1259 .unwrap();
1260
1261 let entries = store
1262 .recall(
1263 &test_scope(),
1264 MemoryQuery {
1265 limit: 1,
1266 ..Default::default()
1267 },
1268 )
1269 .await
1270 .unwrap();
1271 assert_eq!(entries[0].importance, 9);
1272 }
1273
1274 #[tokio::test]
1275 async fn consolidate_tool_default_importance() {
1276 let (store, tools) = setup();
1277 let store_tool = find_tool(&tools, "memory_store");
1278 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1279
1280 store_tool
1281 .execute(&crate::ExecutionContext::default(), json!({"content": "x"}))
1282 .await
1283 .unwrap();
1284 store_tool
1285 .execute(&crate::ExecutionContext::default(), json!({"content": "y"}))
1286 .await
1287 .unwrap();
1288
1289 let entries = store
1290 .recall(
1291 &test_scope(),
1292 MemoryQuery {
1293 limit: 10,
1294 ..Default::default()
1295 },
1296 )
1297 .await
1298 .unwrap();
1299 let id_x = entries[0].id.clone();
1300 let id_y = entries[1].id.clone();
1301
1302 consolidate_tool
1303 .execute(
1304 &crate::ExecutionContext::default(),
1305 json!({
1306 "source_ids": [id_x, id_y],
1307 "content": "consolidated"
1308 }),
1309 )
1310 .await
1311 .unwrap();
1312
1313 let entries = store
1314 .recall(
1315 &test_scope(),
1316 MemoryQuery {
1317 limit: 1,
1318 ..Default::default()
1319 },
1320 )
1321 .await
1322 .unwrap();
1323 assert_eq!(entries[0].importance, 5); }
1325
1326 #[tokio::test]
1327 async fn consolidate_tool_merges_source_keywords_and_tags() {
1328 let (store, tools) = setup();
1329 let store_tool = find_tool(&tools, "memory_store");
1330 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1331
1332 store_tool
1333 .execute(
1334 &crate::ExecutionContext::default(),
1335 json!({
1336 "content": "first fact",
1337 "keywords": ["rust", "performance"],
1338 "tags": ["lang"]
1339 }),
1340 )
1341 .await
1342 .unwrap();
1343 store_tool
1344 .execute(
1345 &crate::ExecutionContext::default(),
1346 json!({
1347 "content": "second fact",
1348 "keywords": ["safety", "rust"],
1349 "tags": ["design"]
1350 }),
1351 )
1352 .await
1353 .unwrap();
1354
1355 let entries = store
1356 .recall(
1357 &test_scope(),
1358 MemoryQuery {
1359 limit: 10,
1360 ..Default::default()
1361 },
1362 )
1363 .await
1364 .unwrap();
1365 let ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1366
1367 consolidate_tool
1368 .execute(
1369 &crate::ExecutionContext::default(),
1370 json!({
1371 "source_ids": ids,
1372 "content": "merged fact",
1373 "tags": ["summary"]
1374 }),
1375 )
1376 .await
1377 .unwrap();
1378
1379 let entries = store
1380 .recall(
1381 &test_scope(),
1382 MemoryQuery {
1383 limit: 1,
1384 ..Default::default()
1385 },
1386 )
1387 .await
1388 .unwrap();
1389 assert_eq!(entries.len(), 1);
1390
1391 let kw = &entries[0].keywords;
1393 assert!(kw.contains(&"rust".to_string()));
1394 assert!(kw.contains(&"performance".to_string()));
1395 assert!(kw.contains(&"safety".to_string()));
1396
1397 let tags = &entries[0].tags;
1399 assert!(tags.contains(&"summary".to_string()));
1400 assert!(tags.contains(&"lang".to_string()));
1401 assert!(tags.contains(&"design".to_string()));
1402 }
1403
1404 #[tokio::test]
1407 async fn store_tool_accepts_keywords() {
1408 let (store, tools) = setup();
1409 let tool = find_tool(&tools, "memory_store");
1410
1411 tool.execute(
1412 &crate::ExecutionContext::default(),
1413 json!({
1414 "content": "Rust has zero-cost abstractions",
1415 "keywords": ["rust", "zero-cost", "abstractions"]
1416 }),
1417 )
1418 .await
1419 .unwrap();
1420
1421 let entries = store
1422 .recall(
1423 &test_scope(),
1424 MemoryQuery {
1425 limit: 1,
1426 ..Default::default()
1427 },
1428 )
1429 .await
1430 .unwrap();
1431 assert_eq!(
1432 entries[0].keywords,
1433 vec!["rust", "zero-cost", "abstractions"]
1434 );
1435 }
1436
1437 #[tokio::test]
1438 async fn store_tool_accepts_summary() {
1439 let (store, tools) = setup();
1440 let tool = find_tool(&tools, "memory_store");
1441
1442 tool.execute(
1443 &crate::ExecutionContext::default(),
1444 json!({
1445 "content": "Detailed technical analysis of Rust ownership model",
1446 "summary": "Rust ownership analysis"
1447 }),
1448 )
1449 .await
1450 .unwrap();
1451
1452 let entries = store
1453 .recall(
1454 &test_scope(),
1455 MemoryQuery {
1456 limit: 1,
1457 ..Default::default()
1458 },
1459 )
1460 .await
1461 .unwrap();
1462 assert_eq!(
1463 entries[0].summary.as_deref(),
1464 Some("Rust ownership analysis")
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn store_tool_keywords_improve_recall() {
1470 let (_store, tools) = setup();
1471 let store_tool = find_tool(&tools, "memory_store");
1472 let recall_tool = find_tool(&tools, "memory_recall");
1473
1474 store_tool
1476 .execute(
1477 &crate::ExecutionContext::default(),
1478 json!({
1479 "content": "Rust is great for systems programming",
1480 "keywords": ["performance", "speed", "systems"]
1481 }),
1482 )
1483 .await
1484 .unwrap();
1485
1486 let result = recall_tool
1488 .execute(
1489 &crate::ExecutionContext::default(),
1490 json!({"query": "performance"}),
1491 )
1492 .await
1493 .unwrap();
1494 assert!(!result.is_error);
1495 assert!(
1496 result.content.contains("Rust is great"),
1497 "keywords should enable finding the memory"
1498 );
1499 }
1500
1501 #[tokio::test]
1502 async fn recall_tool_shows_keywords_in_output() {
1503 let (_store, tools) = setup();
1504 let store_tool = find_tool(&tools, "memory_store");
1505 let recall_tool = find_tool(&tools, "memory_recall");
1506
1507 store_tool
1508 .execute(
1509 &crate::ExecutionContext::default(),
1510 json!({
1511 "content": "test content",
1512 "keywords": ["test-keyword"]
1513 }),
1514 )
1515 .await
1516 .unwrap();
1517
1518 let result = recall_tool
1519 .execute(&crate::ExecutionContext::default(), json!({}))
1520 .await
1521 .unwrap();
1522 assert!(
1523 result.content.contains("test-keyword"),
1524 "recall output should show keywords"
1525 );
1526 }
1527
1528 fn setup_with_reflection(threshold: u32) -> (Arc<dyn Memory>, Vec<Arc<dyn Tool>>) {
1531 let store: Arc<dyn Memory> = Arc::new(InMemoryStore::new());
1532 let tools = memory_tools_with_reflection(
1533 store.clone(),
1534 "test-agent",
1535 test_scope(),
1536 Some(threshold),
1537 );
1538 (store, tools)
1539 }
1540
1541 #[tokio::test]
1542 async fn store_includes_reflection_hint_when_triggered() {
1543 let (_store, tools) = setup_with_reflection(10);
1544 let store_tool = find_tool(&tools, "memory_store");
1545
1546 let result = store_tool
1548 .execute(
1549 &crate::ExecutionContext::default(),
1550 json!({"content": "very important", "importance": 10}),
1551 )
1552 .await
1553 .unwrap();
1554 assert!(
1555 result.content.contains("Reflection suggested"),
1556 "should include reflection hint"
1557 );
1558 }
1559
1560 #[tokio::test]
1561 async fn store_no_reflection_below_threshold() {
1562 let (_store, tools) = setup_with_reflection(20);
1563 let store_tool = find_tool(&tools, "memory_store");
1564
1565 let result = store_tool
1566 .execute(
1567 &crate::ExecutionContext::default(),
1568 json!({"content": "minor fact", "importance": 3}),
1569 )
1570 .await
1571 .unwrap();
1572 assert!(
1573 !result.content.contains("Reflection suggested"),
1574 "should not trigger reflection below threshold"
1575 );
1576 }
1577
1578 #[tokio::test]
1579 async fn store_reflection_accumulates() {
1580 let (_store, tools) = setup_with_reflection(10);
1581 let store_tool = find_tool(&tools, "memory_store");
1582
1583 let r1 = store_tool
1585 .execute(
1586 &crate::ExecutionContext::default(),
1587 json!({"content": "fact A", "importance": 5}),
1588 )
1589 .await
1590 .unwrap();
1591 assert!(!r1.content.contains("Reflection suggested"));
1592
1593 let r2 = store_tool
1594 .execute(
1595 &crate::ExecutionContext::default(),
1596 json!({"content": "fact B", "importance": 5}),
1597 )
1598 .await
1599 .unwrap();
1600 assert!(
1601 r2.content.contains("Reflection suggested"),
1602 "should trigger after accumulation"
1603 );
1604 }
1605
1606 #[tokio::test]
1609 async fn store_creates_links_for_keyword_overlap() {
1610 let (store, tools) = setup();
1611 let store_tool = find_tool(&tools, "memory_store");
1612
1613 store_tool
1615 .execute(
1616 &crate::ExecutionContext::default(),
1617 json!({
1618 "content": "Rust is fast",
1619 "keywords": ["rust", "performance", "speed"]
1620 }),
1621 )
1622 .await
1623 .unwrap();
1624
1625 store_tool
1627 .execute(
1628 &crate::ExecutionContext::default(),
1629 json!({
1630 "content": "Rust has great perf",
1631 "keywords": ["rust", "performance"]
1632 }),
1633 )
1634 .await
1635 .unwrap();
1636
1637 let entries = store
1639 .recall(
1640 &test_scope(),
1641 MemoryQuery {
1642 limit: 10,
1643 ..Default::default()
1644 },
1645 )
1646 .await
1647 .unwrap();
1648
1649 let has_links = entries.iter().any(|e| !e.related_ids.is_empty());
1650 assert!(
1651 has_links,
1652 "entries with overlapping keywords should be linked"
1653 );
1654 }
1655
1656 #[tokio::test]
1659 async fn store_tool_default_confidentiality_is_public() {
1660 let (store, tools) = setup();
1661 let tool = find_tool(&tools, "memory_store");
1662
1663 tool.execute(
1664 &crate::ExecutionContext::default(),
1665 json!({"content": "test"}),
1666 )
1667 .await
1668 .unwrap();
1669
1670 let entries = store
1671 .recall(
1672 &test_scope(),
1673 MemoryQuery {
1674 limit: 1,
1675 ..Default::default()
1676 },
1677 )
1678 .await
1679 .unwrap();
1680 assert_eq!(
1681 entries[0].confidentiality,
1682 Confidentiality::Public,
1683 "omitted confidentiality should default to Public"
1684 );
1685 }
1686
1687 #[tokio::test]
1688 async fn store_tool_with_confidentiality_param() {
1689 let (store, tools) = setup();
1690 let tool = find_tool(&tools, "memory_store");
1691
1692 tool.execute(
1693 &crate::ExecutionContext::default(),
1694 json!({
1695 "content": "private note",
1696 "confidentiality": "confidential"
1697 }),
1698 )
1699 .await
1700 .unwrap();
1701
1702 let entries = store
1703 .recall(
1704 &test_scope(),
1705 MemoryQuery {
1706 limit: 1,
1707 ..Default::default()
1708 },
1709 )
1710 .await
1711 .unwrap();
1712 assert_eq!(entries[0].confidentiality, Confidentiality::Confidential);
1713 }
1714
1715 #[tokio::test]
1721 async fn store_tool_caps_restricted_to_confidential() {
1722 let (store, tools) = setup();
1723 let tool = find_tool(&tools, "memory_store");
1724
1725 tool.execute(
1726 &crate::ExecutionContext::default(),
1727 json!({
1728 "content": "api_key=sk-12345",
1729 "confidentiality": "restricted"
1730 }),
1731 )
1732 .await
1733 .unwrap();
1734
1735 let entries = store
1736 .recall(
1737 &test_scope(),
1738 MemoryQuery {
1739 limit: 1,
1740 ..Default::default()
1741 },
1742 )
1743 .await
1744 .unwrap();
1745 assert_eq!(
1746 entries[0].confidentiality,
1747 Confidentiality::Confidential,
1748 "LLM-passed Restricted MUST be capped to Confidential"
1749 );
1750 }
1751
1752 #[tokio::test]
1753 async fn consolidate_preserves_max_confidentiality() {
1754 let (store, tools) = setup();
1755 let store_tool = find_tool(&tools, "memory_store");
1756 let consolidate_tool = find_tool(&tools, "memory_consolidate");
1757
1758 store_tool
1760 .execute(
1761 &crate::ExecutionContext::default(),
1762 json!({"content": "public fact", "confidentiality": "public"}),
1763 )
1764 .await
1765 .unwrap();
1766 store_tool
1767 .execute(
1768 &crate::ExecutionContext::default(),
1769 json!({"content": "private note", "confidentiality": "confidential"}),
1770 )
1771 .await
1772 .unwrap();
1773
1774 let entries = store
1775 .recall(
1776 &test_scope(),
1777 MemoryQuery {
1778 limit: 10,
1779 ..Default::default()
1780 },
1781 )
1782 .await
1783 .unwrap();
1784 let ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
1785
1786 consolidate_tool
1787 .execute(
1788 &crate::ExecutionContext::default(),
1789 json!({
1790 "source_ids": ids,
1791 "content": "merged result"
1792 }),
1793 )
1794 .await
1795 .unwrap();
1796
1797 let entries = store
1798 .recall(
1799 &test_scope(),
1800 MemoryQuery {
1801 limit: 1,
1802 ..Default::default()
1803 },
1804 )
1805 .await
1806 .unwrap();
1807 assert_eq!(entries.len(), 1);
1808 assert_eq!(
1809 entries[0].confidentiality,
1810 Confidentiality::Confidential,
1811 "consolidation should preserve the highest confidentiality level"
1812 );
1813 }
1814
1815 #[tokio::test]
1816 async fn store_no_links_without_keywords() {
1817 let (store, tools) = setup();
1818 let store_tool = find_tool(&tools, "memory_store");
1819
1820 store_tool
1821 .execute(
1822 &crate::ExecutionContext::default(),
1823 json!({"content": "no keywords A"}),
1824 )
1825 .await
1826 .unwrap();
1827 store_tool
1828 .execute(
1829 &crate::ExecutionContext::default(),
1830 json!({"content": "no keywords B"}),
1831 )
1832 .await
1833 .unwrap();
1834
1835 let entries = store
1836 .recall(
1837 &test_scope(),
1838 MemoryQuery {
1839 limit: 10,
1840 ..Default::default()
1841 },
1842 )
1843 .await
1844 .unwrap();
1845
1846 let has_links = entries.iter().any(|e| !e.related_ids.is_empty());
1847 assert!(!has_links, "entries without keywords should not be linked");
1848 }
1849}