Skip to main content

heartbit_core/memory/
tools.rs

1//! Per-agent memory tool definitions (store, recall, update, forget, consolidate).
2
3use 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
20/// Create the 5 memory tools bound to a specific memory store, agent name, and tenant scope.
21///
22/// The `scope` is baked into every tool so calls route to the correct tenant
23/// without requiring callers to thread a scope through each individual operation.
24///
25/// If `reflection_threshold` is set, the store tool will include a reflection
26/// hint when cumulative importance exceeds the threshold.
27pub 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
61// --- memory_store ---
62
63struct 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            // SECURITY (F-MEM-2/F-MEM-6): cap LLM-driven confidentiality at
147            // `Confidential`. The `Restricted` level is reserved for the
148            // sensor pipeline (where secrets ingest from email/incidents go)
149            // and means "never in LLM context". An LLM that can call
150            // `memory_store` with `restricted` could "launder" a secret then
151            // exfiltrate via shared_memory_read. Cap defensively here even
152            // though the JSON schema also drops the enum value — schema is
153            // advisory, this cap is the real boundary.
154            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            // Link evolution: find related entries by keyword overlap
188            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
222// --- memory_recall ---
223
224struct 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 lets NamespacedMemory default to the correct
292                        // compound namespace (e.g. "tg:123:assistant"). Passing the
293                        // plain agent_name would bypass NamespacedMemory's scoping.
294                        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            // Results are pre-sorted by the store using its configured scoring
306            // weights. We display rank order rather than recomputing scores
307            // (which may use different weights than the store).
308            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
352// --- memory_update ---
353
354struct 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
404// --- memory_forget ---
405
406struct 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
453// --- memory_consolidate ---
454
455struct 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            // Fetch source memories to merge their keywords and tags.
532            // Use a generous limit — consolidation is not a hot path.
533            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            // Preserve the highest confidentiality level from source entries
554            // so consolidation never downgrades access controls.
555            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            // Create consolidated entry FIRST to prevent data loss.
570            // If store fails, no sources are deleted.
571            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            // Delete source memories, track which were found
598            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                // Clean up the orphaned consolidated entry (best-effort)
610                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        // Verify it's in the store
690        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 some memories
712        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        // Recall all
728        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        // Recall by query
736        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        // Get the ID
775        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    // --- importance tests ---
872
873    #[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        // Value > 10 should clamp to 10
929        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    // --- recall output format tests ---
950
951    #[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        // Content with multi-byte UTF-8 characters (each emoji is 4 bytes)
1008        let content = "🦀".repeat(100); // 400 bytes, 100 chars
1009        store_tool
1010            .execute(
1011                &crate::ExecutionContext::default(),
1012                json!({"content": content}),
1013            )
1014            .await
1015            .unwrap();
1016
1017        // Should not panic on non-ASCII content
1018        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        // 500 ASCII chars — should be truncated to 200 + "..."
1033        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        // Should NOT contain all 500 'a's
1049        assert!(!result.content.contains(&"a".repeat(500)));
1050    }
1051
1052    // --- consolidation tests ---
1053
1054    #[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        // Should now have 1 entry
1104        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        // Verify the orphaned consolidated entry was cleaned up
1202        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); // default
1324    }
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        // Keywords merged from both sources (deduplicated)
1392        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        // Tags merged from sources + explicit input
1398        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    // --- keywords and summary tests ---
1405
1406    #[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 with keyword "performance" not in content
1475        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        // Search for "performance" — should find via keyword match
1487        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    // --- reflection tests ---
1529
1530    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        // Store with importance 10 — should trigger immediately
1547        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        // importance 5 + 5 = 10, second should trigger
1584        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    // --- linking tests ---
1607
1608    #[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 first entry with keywords
1614        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 second entry with overlapping keywords
1626        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        // Check that entries are linked
1638        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    // --- confidentiality tests ---
1657
1658    #[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    /// SECURITY (F-MEM-2/F-MEM-6): when an LLM passes `confidentiality:
1716    /// "restricted"`, the tool MUST cap to `Confidential`. `Restricted` is
1717    /// reserved for sensor-pipeline ingestion of secrets and means "never in
1718    /// LLM context"; allowing the LLM to set it would let a jailbroken agent
1719    /// "launder" a secret then exfiltrate via shared_memory_read.
1720    #[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 entries with different confidentiality levels
1759        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}