Skip to main content

wm_tools/expansion/
reasoning.rs

1//! Reasoning tools — reasoning.bicameral, think, explain.
2//!
3//! Gana::ThreeStars — "Explanation, bicameral reasoning, think"
4//!
5//! These tools provide structured reasoning capabilities: bicameral
6//! (pros/cons) analysis, general-purpose thinking with memory context,
7//! and explanation generation for memory content.
8
9#![forbid(unsafe_code)]
10
11use async_trait::async_trait;
12
13use serde_json::{Value, json};
14use std::sync::Arc;
15use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
16use wm_memory::MemoryStore;
17
18use super::common::{galaxy_name, parse_galaxy};
19
20// ── reasoning.bicameral ──────────────────────────────────────────────
21
22/// Bicameral reasoning: analyze a topic from multiple perspectives.
23///
24/// Searches memories for supporting and opposing evidence, then structures
25/// the analysis as a bicameral (two-chamber) debate with pros, cons, and
26/// a synthesis.
27pub struct ReasoningBicameralTool {
28    store: Arc<MemoryStore>,
29    stats: ToolStats,
30    effects: EffectRow,
31}
32
33impl ReasoningBicameralTool {
34    pub fn new(store: Arc<MemoryStore>) -> Self {
35        Self {
36            store,
37            stats: ToolStats::default(),
38            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
39        }
40    }
41}
42
43#[async_trait]
44impl Tool for ReasoningBicameralTool {
45    fn name(&self) -> &str {
46        "reasoning.bicameral"
47    }
48    fn gana(&self) -> Gana {
49        Gana::ThreeStars
50    }
51    fn effects(&self) -> &EffectRow {
52        &self.effects
53    }
54    fn description(&self) -> &str {
55        "Analyze a topic from multiple perspectives using bicameral reasoning"
56    }
57    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
58        let topic = args
59            .get("topic")
60            .and_then(|v| v.as_str())
61            .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic (string) required".into()))?;
62        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
63        let scan_limit = args
64            .get("scan_limit")
65            .and_then(serde_json::Value::as_u64)
66            .unwrap_or(500) as usize;
67
68        let galaxies: Vec<Galaxy> = match galaxy_str {
69            Some(g) => vec![parse_galaxy(g)?],
70            None => Galaxy::memory_galaxies().to_vec(),
71        };
72
73        let topic_lower = topic.to_lowercase();
74        let topic_words: Vec<&str> = topic_lower.split_whitespace().collect();
75
76        // Collect memories matching the topic
77        let mut supporting: Vec<Value> = Vec::new();
78        let mut opposing: Vec<Value> = Vec::new();
79        let mut neutral: Vec<Value> = Vec::new();
80
81        // Opposition keywords
82        let opposition_markers = [
83            "however",
84            "but",
85            "against",
86            "con",
87            "negative",
88            "problem",
89            "issue",
90            "criticism",
91            "drawback",
92            "limitation",
93            "fail",
94            "wrong",
95            "disagree",
96        ];
97        let support_markers = [
98            "good",
99            "great",
100            "excellent",
101            "pro",
102            "positive",
103            "benefit",
104            "advantage",
105            "support",
106            "agree",
107            "correct",
108            "effective",
109            "success",
110            "strong",
111        ];
112
113        for galaxy in &galaxies {
114            let mems = self.store.scan(*galaxy, scan_limit)?;
115            for mem in mems {
116                // model_exclude memories never enter reasoning evidence.
117                if mem.metadata.model_exclude {
118                    continue;
119                }
120                let content_lower = mem.content.to_lowercase();
121                if !topic_words.iter().any(|tw| content_lower.contains(tw)) {
122                    continue;
123                }
124
125                let entry = json!({
126                    "galaxy": galaxy_name(*galaxy),
127                    "id": mem.metadata.id,
128                    "content_preview": mem.content.chars().take(150).collect::<String>(),
129                    "importance": mem.metadata.importance,
130                    "tags": mem.metadata.tags,
131                });
132
133                let has_opposition = opposition_markers.iter().any(|m| content_lower.contains(m));
134                let has_support = support_markers.iter().any(|m| content_lower.contains(m));
135
136                if has_opposition && !has_support {
137                    opposing.push(entry);
138                } else if has_support && !has_opposition {
139                    supporting.push(entry);
140                } else {
141                    neutral.push(entry);
142                }
143            }
144        }
145
146        let total = supporting.len() + opposing.len() + neutral.len();
147        let balance = if total == 0 {
148            0.0
149        } else {
150            ((supporting.len() as f64 - opposing.len() as f64) / total as f64 * 100.0).round()
151        };
152
153        // Synthesis
154        let synthesis = if total == 0 {
155            format!(
156                "No memories found related to '{topic}'. Consider creating memories on this topic first."
157            )
158        } else if supporting.len() > opposing.len() * 2 {
159            format!(
160                "The evidence strongly favors '{}' with {} supporting vs {} opposing memories.",
161                topic,
162                supporting.len(),
163                opposing.len()
164            )
165        } else if opposing.len() > supporting.len() * 2 {
166            format!(
167                "The evidence predominantly opposes '{}' with {} opposing vs {} supporting memories.",
168                topic,
169                opposing.len(),
170                supporting.len()
171            )
172        } else {
173            format!(
174                "The evidence on '{}' is balanced: {} supporting, {} opposing, {} neutral. Further investigation recommended.",
175                topic,
176                supporting.len(),
177                opposing.len(),
178                neutral.len()
179            )
180        };
181
182        Ok(json!({
183            "status": "success",
184            "topic": topic,
185            "total_evidence": total,
186            "supporting": supporting,
187            "opposing": opposing,
188            "neutral": neutral,
189            "balance_score": balance,
190            "synthesis": synthesis,
191        }))
192    }
193    fn stats(&self) -> &ToolStats {
194        &self.stats
195    }
196}
197
198// ── think ────────────────────────────────────────────────────────────
199
200/// General-purpose thinking tool that gathers relevant memories and
201/// produces a structured analysis with key insights.
202///
203/// Unlike single-purpose tools, `think` performs a holistic analysis:
204/// gathers context from memories, identifies key themes, and produces
205/// a structured response with observations and questions.
206pub struct ThinkTool {
207    store: Arc<MemoryStore>,
208    stats: ToolStats,
209    effects: EffectRow,
210}
211
212impl ThinkTool {
213    pub fn new(store: Arc<MemoryStore>) -> Self {
214        Self {
215            store,
216            stats: ToolStats::default(),
217            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
218        }
219    }
220}
221
222#[async_trait]
223impl Tool for ThinkTool {
224    fn name(&self) -> &str {
225        "think"
226    }
227    fn gana(&self) -> Gana {
228        Gana::ThreeStars
229    }
230    fn effects(&self) -> &EffectRow {
231        &self.effects
232    }
233    fn description(&self) -> &str {
234        "Gather memory context and produce structured analysis with insights"
235    }
236    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
237        let query = args
238            .get("query")
239            .and_then(|v| v.as_str())
240            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
241        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
242        let depth = args
243            .get("depth")
244            .and_then(|v| v.as_str())
245            .unwrap_or("standard");
246        let max_memories = match depth {
247            "shallow" => 10usize,
248            "deep" => 100,
249            _ => 50,
250        };
251
252        let galaxies: Vec<Galaxy> = match galaxy_str {
253            Some(g) => vec![parse_galaxy(g)?],
254            None => Galaxy::memory_galaxies().to_vec(),
255        };
256
257        let query_lower = query.to_lowercase();
258        let query_words: Vec<&str> = query_lower.split_whitespace().collect();
259
260        // Gather relevant memories
261        let mut relevant: Vec<(Galaxy, wm_memory::Memory, u32)> = Vec::new(); // (galaxy, memory, match_score)
262
263        for galaxy in &galaxies {
264            let mems = self.store.scan(*galaxy, max_memories)?;
265            for mem in mems {
266                // model_exclude memories never enter model context.
267                if mem.metadata.model_exclude {
268                    continue;
269                }
270                let content_lower = mem.content.to_lowercase();
271                let mut score = 0u32;
272                for word in &query_words {
273                    if content_lower.contains(word) {
274                        score += 1;
275                    }
276                }
277                // Also check tag matches
278                for tag in &mem.metadata.tags {
279                    if query_words.iter().any(|w| tag.contains(w)) {
280                        score += 2;
281                    }
282                }
283                if score > 0 {
284                    relevant.push((*galaxy, mem, score));
285                }
286            }
287        }
288
289        // Sort by match score descending, then by importance
290        relevant.sort_by(|a, b| {
291            b.2.cmp(&a.2).then_with(|| {
292                b.1.metadata
293                    .importance
294                    .partial_cmp(&a.1.metadata.importance)
295                    .unwrap_or(std::cmp::Ordering::Equal)
296            })
297        });
298
299        let total_matches = relevant.len();
300        let top_memories: Vec<Value> = relevant
301            .iter()
302            .take(10)
303            .map(|(galaxy, mem, score)| {
304                json!({
305                    "galaxy": galaxy_name(*galaxy),
306                    "id": mem.metadata.id,
307                    "content_preview": mem.content.chars().take(200).collect::<String>(),
308                    "match_score": score,
309                    "importance": mem.metadata.importance,
310                    "tags": mem.metadata.tags,
311                })
312            })
313            .collect();
314
315        // Extract key themes from matched memories
316        let mut theme_tags: std::collections::HashMap<String, u32> =
317            std::collections::HashMap::new();
318        for (_, mem, _) in &relevant {
319            for tag in &mem.metadata.tags {
320                *theme_tags.entry(tag.clone()).or_default() += 1;
321            }
322        }
323        let mut themes: Vec<(String, u32)> = theme_tags.into_iter().collect();
324        themes.sort_by_key(|x| std::cmp::Reverse(x.1));
325        let key_themes: Vec<String> = themes.iter().take(5).map(|(t, _)| t.clone()).collect();
326
327        // Generate observations
328        let mut observations: Vec<String> = Vec::new();
329        if total_matches == 0 {
330            observations.push(format!(
331                "No existing memories match '{query}'. This appears to be a novel topic."
332            ));
333        } else {
334            observations.push(format!(
335                "Found {} relevant memories across {} galaxies.",
336                total_matches,
337                galaxies.len()
338            ));
339            if !key_themes.is_empty() {
340                observations.push(format!("Key themes: {}", key_themes.join(", ")));
341            }
342            let avg_importance: f32 = relevant
343                .iter()
344                .map(|(_, m, _)| m.metadata.importance)
345                .sum::<f32>()
346                / total_matches as f32;
347            observations.push(format!(
348                "Average importance of matched memories: {avg_importance:.2}"
349            ));
350            if total_matches > 20 {
351                observations
352                    .push("High memory density — consider consolidation or synthesis.".into());
353            } else if total_matches < 3 {
354                observations.push(
355                    "Low memory density — this topic may benefit from further exploration.".into(),
356                );
357            }
358        }
359
360        // Generate questions for further inquiry
361        let questions: Vec<String> = if total_matches == 0 {
362            vec![format!("What existing knowledge relates to '{}'?", query)]
363        } else {
364            vec![
365                format!(
366                    "What patterns emerge from these {} memories about '{}'?",
367                    total_matches, query
368                ),
369                "Are there contradictions or gaps in the existing knowledge?".into(),
370                "What connections exist between these memories and other topics?".into(),
371            ]
372        };
373
374        Ok(json!({
375            "status": "success",
376            "query": query,
377            "depth": depth,
378            "total_matches": total_matches,
379            "key_themes": key_themes,
380            "observations": observations,
381            "questions": questions,
382            "relevant_memories": top_memories,
383        }))
384    }
385    fn stats(&self) -> &ToolStats {
386        &self.stats
387    }
388}
389
390// ── explain ──────────────────────────────────────────────────────────
391
392/// Explain a memory or topic by gathering context from related memories.
393///
394/// Given a memory ID or topic string, finds related memories and produces
395/// a structured explanation with context, relationships, and summary.
396pub struct ExplainTool {
397    store: Arc<MemoryStore>,
398    stats: ToolStats,
399    effects: EffectRow,
400}
401
402impl ExplainTool {
403    pub fn new(store: Arc<MemoryStore>) -> Self {
404        Self {
405            store,
406            stats: ToolStats::default(),
407            effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
408        }
409    }
410}
411
412#[async_trait]
413impl Tool for ExplainTool {
414    fn name(&self) -> &str {
415        "explain"
416    }
417    fn gana(&self) -> Gana {
418        Gana::ThreeStars
419    }
420    fn effects(&self) -> &EffectRow {
421        &self.effects
422    }
423    fn description(&self) -> &str {
424        "Explain a memory or topic by gathering context from related memories"
425    }
426    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
427        // Accept either a memory_id or a topic string
428        let memory_id = args.get("memory_id").and_then(|v| v.as_str());
429        let topic = args.get("topic").and_then(|v| v.as_str());
430
431        if memory_id.is_none() && topic.is_none() {
432            return Err(wm_core::CoreError::InvalidArgs(
433                "Either memory_id (string) or topic (string) required".into(),
434            ));
435        }
436
437        let max_context = args
438            .get("max_context")
439            .and_then(serde_json::Value::as_u64)
440            .unwrap_or(10) as usize;
441
442        // Find the target memory if memory_id is provided
443        let mut target_memory: Option<(Galaxy, wm_memory::Memory)> = None;
444        if let Some(id_str) = memory_id {
445            let uuid = uuid::Uuid::parse_str(id_str)
446                .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
447            for galaxy in Galaxy::memory_galaxies() {
448                if let Ok(Some(mem)) = self.store.get(galaxy, uuid) {
449                    target_memory = Some((galaxy, mem));
450                    break;
451                }
452            }
453            if target_memory.is_none() {
454                return Err(wm_core::CoreError::NotFound(format!(
455                    "Memory {id_str} not found in any galaxy"
456                )));
457            }
458        }
459
460        // Determine search terms from target memory or topic
461        let (search_text, target_info): (String, Option<Value>) = match &target_memory {
462            Some((galaxy, mem)) => (
463                mem.content.clone(),
464                Some(json!({
465                    "galaxy": galaxy_name(*galaxy),
466                    "id": mem.metadata.id,
467                    "content": mem.content,
468                    "importance": mem.metadata.importance,
469                    "tags": mem.metadata.tags,
470                    "created_at": mem.metadata.created_at.to_rfc3339(),
471                })),
472            ),
473            None => (topic.unwrap().to_string(), None),
474        };
475
476        let search_lower = search_text.to_lowercase();
477        let search_words: Vec<&str> = search_lower.split_whitespace().collect();
478
479        // Find related memories
480        let mut related: Vec<(Galaxy, wm_memory::Memory, u32)> = Vec::new();
481        let target_id = target_memory.as_ref().map(|(_, m)| m.metadata.id);
482
483        for galaxy in Galaxy::all() {
484            let mems = self.store.scan(galaxy, 500)?;
485            for mem in mems {
486                // model_exclude memories never enter model context.
487                if mem.metadata.model_exclude {
488                    continue;
489                }
490                // Skip the target memory itself
491                if Some(mem.metadata.id) == target_id {
492                    continue;
493                }
494                let content_lower = mem.content.to_lowercase();
495                let mut score = 0u32;
496                for word in &search_words {
497                    if content_lower.contains(word) {
498                        score += 1;
499                    }
500                }
501                // Tag overlap
502                if let Some((_, target_mem)) = &target_memory {
503                    for tag in &mem.metadata.tags {
504                        if target_mem.metadata.tags.contains(tag) {
505                            score += 3;
506                        }
507                    }
508                }
509                if score > 0 {
510                    related.push((galaxy, mem, score));
511                }
512            }
513        }
514
515        related.sort_by_key(|x| std::cmp::Reverse(x.2));
516        let total_related = related.len();
517
518        let context_memories: Vec<Value> = related
519            .iter()
520            .take(max_context)
521            .map(|(galaxy, mem, score)| {
522                json!({
523                    "galaxy": galaxy_name(*galaxy),
524                    "id": mem.metadata.id,
525                    "content_preview": mem.content.chars().take(150).collect::<String>(),
526                    "relevance_score": score,
527                    "tags": mem.metadata.tags,
528                })
529            })
530            .collect();
531
532        // Build explanation summary
533        let summary = if total_related == 0 {
534            "This memory/topic appears to be isolated with no related memories. Consider creating connections.".into()
535        } else {
536            let galaxies_involved: std::collections::HashSet<&str> = related
537                .iter()
538                .take(max_context)
539                .map(|(g, _, _)| galaxy_name(*g))
540                .collect();
541            format!(
542                "Found {} related memories across {} galaxies. The topic connects to multiple knowledge areas.",
543                total_related,
544                galaxies_involved.len()
545            )
546        };
547
548        Ok(json!({
549            "status": "success",
550            "target": target_info,
551            "topic": topic,
552            "total_related": total_related,
553            "context_memories": context_memories,
554            "summary": summary,
555        }))
556    }
557    fn stats(&self) -> &ToolStats {
558        &self.stats
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
567        let tmp = tempfile::tempdir().unwrap();
568        let store = MemoryStore::open_default(tmp.path()).unwrap();
569        (tmp, Arc::new(store))
570    }
571
572    fn seed_memories(store: &Arc<MemoryStore>) -> Vec<uuid::Uuid> {
573        let mut ids = Vec::new();
574        let entries = [
575            (
576                Galaxy::Codex,
577                "Rust is a great systems programming language with memory safety",
578                vec!["rust", "programming"],
579                0.9,
580            ),
581            (
582                Galaxy::Codex,
583                "However Rust has a steep learning curve for beginners",
584                vec!["rust", "programming"],
585                0.6,
586            ),
587            (
588                Galaxy::Research,
589                "Python is excellent for data science and rapid prototyping",
590                vec!["python", "data"],
591                0.8,
592            ),
593            (
594                Galaxy::Research,
595                "But Python performance is a problem for compute-intensive tasks",
596                vec!["python", "performance"],
597                0.5,
598            ),
599            (
600                Galaxy::Codex,
601                "Rust ownership model prevents memory leaks at compile time",
602                vec!["rust", "memory"],
603                0.85,
604            ),
605            (
606                Galaxy::Tutorial,
607                "Rust traits enable polymorphism without runtime overhead",
608                vec!["rust", "traits"],
609                0.7,
610            ),
611        ];
612
613        for (galaxy, content, tags, imp) in entries {
614            let mut mem = wm_memory::Memory::new(galaxy, content.into());
615            mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
616            mem.metadata.importance = imp;
617            ids.push(mem.metadata.id);
618            store.put(galaxy, &mem).unwrap();
619        }
620        ids
621    }
622
623    #[tokio::test]
624    async fn bicameral_finds_supporting_and_opposing() {
625        let (_tmp, store) = open_store();
626        seed_memories(&store);
627
628        let tool = ReasoningBicameralTool::new(store);
629        let result = tool
630            .call(&mut Context::default(), json!({"topic": "rust"}))
631            .await
632            .unwrap();
633        let obj = result.as_object().unwrap();
634        assert_eq!(obj["status"], "success");
635        assert!(obj["total_evidence"].as_u64().unwrap() >= 4);
636        let supporting = obj["supporting"].as_array().unwrap();
637        let opposing = obj["opposing"].as_array().unwrap();
638        assert!(!supporting.is_empty() || !opposing.is_empty());
639        assert!(obj["synthesis"].as_str().unwrap().contains("rust"));
640    }
641
642    #[tokio::test]
643    async fn bicameral_no_matches() {
644        let (_tmp, store) = open_store();
645        let tool = ReasoningBicameralTool::new(store);
646        let result = tool
647            .call(&mut Context::default(), json!({"topic": "nonexistent"}))
648            .await
649            .unwrap();
650        let obj = result.as_object().unwrap();
651        assert_eq!(obj["total_evidence"], 0);
652        assert!(obj["synthesis"].as_str().unwrap().contains("No memories"));
653    }
654
655    #[tokio::test]
656    async fn bicameral_missing_topic() {
657        let (_tmp, store) = open_store();
658        let tool = ReasoningBicameralTool::new(store);
659        let result = tool.call(&mut Context::default(), json!({})).await;
660        assert!(result.is_err());
661    }
662
663    #[tokio::test]
664    async fn think_gathers_context() {
665        let (_tmp, store) = open_store();
666        seed_memories(&store);
667
668        let tool = ThinkTool::new(store);
669        let result = tool
670            .call(
671                &mut Context::default(),
672                json!({"query": "rust programming", "depth": "standard"}),
673            )
674            .await
675            .unwrap();
676        let obj = result.as_object().unwrap();
677        assert_eq!(obj["status"], "success");
678        assert!(obj["total_matches"].as_u64().unwrap() >= 4);
679        let themes = obj["key_themes"].as_array().unwrap();
680        assert!(!themes.is_empty());
681        let observations = obj["observations"].as_array().unwrap();
682        assert!(!observations.is_empty());
683        let questions = obj["questions"].as_array().unwrap();
684        assert!(!questions.is_empty());
685    }
686
687    #[tokio::test]
688    async fn think_shallow_depth() {
689        let (_tmp, store) = open_store();
690        seed_memories(&store);
691
692        let tool = ThinkTool::new(store);
693        let result = tool
694            .call(
695                &mut Context::default(),
696                json!({"query": "rust", "depth": "shallow"}),
697            )
698            .await
699            .unwrap();
700        let obj = result.as_object().unwrap();
701        assert_eq!(obj["depth"], "shallow");
702    }
703
704    #[tokio::test]
705    async fn think_missing_query() {
706        let (_tmp, store) = open_store();
707        let tool = ThinkTool::new(store);
708        let result = tool.call(&mut Context::default(), json!({})).await;
709        assert!(result.is_err());
710    }
711
712    #[tokio::test]
713    async fn model_excluded_memories_never_enter_evidence() {
714        let (_tmp, store) = open_store();
715        seed_memories(&store);
716        // A private-to-model memory that matches the query strongly.
717        let mut excluded = wm_memory::Memory::new(
718            Galaxy::Codex,
719            "rust programming secret internal design".to_string(),
720        );
721        excluded.metadata.model_exclude = true;
722        excluded.metadata.importance = 1.0;
723        store.put(Galaxy::Codex, &excluded).unwrap();
724
725        let tool = ThinkTool::new(store);
726        let result = tool
727            .call(
728                &mut Context::default(),
729                json!({"query": "rust programming", "depth": "standard"}),
730            )
731            .await
732            .unwrap();
733        let obj = result.as_object().unwrap();
734        let top = obj["relevant_memories"].as_array().unwrap();
735        for m in top {
736            let preview = m["content_preview"].as_str().unwrap_or("");
737            assert!(
738                !preview.contains("secret"),
739                "model_exclude memory leaked into reasoning evidence: {m}"
740            );
741        }
742    }
743
744    #[tokio::test]
745    async fn explain_by_memory_id() {
746        let (_tmp, store) = open_store();
747        let ids = seed_memories(&store);
748
749        let tool = ExplainTool::new(store);
750        let result = tool
751            .call(
752                &mut Context::default(),
753                json!({"memory_id": ids[0].to_string()}),
754            )
755            .await
756            .unwrap();
757        let obj = result.as_object().unwrap();
758        assert_eq!(obj["status"], "success");
759        assert!(obj["target"].is_object());
760        assert!(obj["total_related"].as_u64().unwrap() >= 3);
761        assert!(
762            obj["summary"]
763                .as_str()
764                .unwrap()
765                .contains("related memories")
766        );
767    }
768
769    #[tokio::test]
770    async fn explain_by_topic() {
771        let (_tmp, store) = open_store();
772        seed_memories(&store);
773
774        let tool = ExplainTool::new(store);
775        let result = tool
776            .call(
777                &mut Context::default(),
778                json!({"topic": "rust memory safety"}),
779            )
780            .await
781            .unwrap();
782        let obj = result.as_object().unwrap();
783        assert_eq!(obj["status"], "success");
784        assert!(obj["total_related"].as_u64().unwrap() >= 2);
785    }
786
787    #[tokio::test]
788    async fn explain_missing_args() {
789        let (_tmp, store) = open_store();
790        let tool = ExplainTool::new(store);
791        let result = tool.call(&mut Context::default(), json!({})).await;
792        assert!(result.is_err());
793    }
794
795    #[tokio::test]
796    async fn explain_invalid_uuid() {
797        let (_tmp, store) = open_store();
798        let tool = ExplainTool::new(store);
799        let result = tool
800            .call(&mut Context::default(), json!({"memory_id": "not-a-uuid"}))
801            .await;
802        assert!(result.is_err());
803    }
804
805    #[tokio::test]
806    async fn explain_not_found() {
807        let (_tmp, store) = open_store();
808        let tool = ExplainTool::new(store);
809        let result = tool
810            .call(
811                &mut Context::default(),
812                json!({"memory_id": "00000000-0000-0000-0000-000000000000"}),
813            )
814            .await;
815        assert!(result.is_err());
816    }
817}