Skip to main content

wm_tools/expansion/
research.rs

1//! Research tools — `research.topic`, `research.repo`, `research.rabbit_hole`.
2//!
3//! Port of the v26 web-research orchestrators onto the web tooling in
4//! [`super::web`] (Bing search + bounded fetch + SSRF guard):
5//!
6//! - `research.topic` — search → fetch top sources → extract key terms →
7//!   synthesize; optionally stores the result in the Research galaxy
8//! - `research.repo` — GitHub repo README deep-read (raw + rendered)
9//! - `research.rabbit_hole` — bounded recursive spiral: search the topic,
10//!   extract unfamiliar terms, search each, fetch top results, synthesize
11//!
12//! All synthesis is extractive (frequency-based) — no LLM dependency, so
13//! the pipeline works air-gapped against the search backend.
14
15#![forbid(unsafe_code)]
16
17use async_trait::async_trait;
18
19use serde_json::{Value, json};
20use std::collections::HashMap;
21use std::sync::Arc;
22use std::sync::Mutex;
23use std::time::Duration;
24use wm_cognitive::{EventType, GanYingBus};
25use wm_core::security::is_url_safe;
26use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
27use wm_memory::{Memory, MemoryStore};
28
29use super::web::{Fetched, fetch_bounded, web_search};
30
31const STOPWORDS: &[&str] = &[
32    "the",
33    "a",
34    "an",
35    "and",
36    "or",
37    "but",
38    "for",
39    "with",
40    "of",
41    "in",
42    "on",
43    "at",
44    "to",
45    "from",
46    "by",
47    "is",
48    "are",
49    "was",
50    "were",
51    "be",
52    "been",
53    "being",
54    "it",
55    "its",
56    "this",
57    "that",
58    "these",
59    "those",
60    "as",
61    "into",
62    "over",
63    "under",
64    "about",
65    "between",
66    "after",
67    "before",
68    "during",
69    "through",
70    "against",
71    "within",
72    "without",
73    "not",
74    "no",
75    "nor",
76    "so",
77    "such",
78    "too",
79    "very",
80    "can",
81    "will",
82    "just",
83    "don",
84    "does",
85    "did",
86    "has",
87    "have",
88    "had",
89    "more",
90    "most",
91    "other",
92    "some",
93    "which",
94    "what",
95    "when",
96    "where",
97    "why",
98    "how",
99    "all",
100    "any",
101    "both",
102    "each",
103    "few",
104    "own",
105    "same",
106    "than",
107    "then",
108    "there",
109    "they",
110    "them",
111    "their",
112    "you",
113    "your",
114    "we",
115    "our",
116    "us",
117    "also",
118    "may",
119    "could",
120    "would",
121    "should",
122    "if",
123    "else",
124    "while",
125    "via",
126    "etc",
127    "e.g",
128    "i.e",
129    "per",
130    "new",
131    "use",
132    "used",
133    "using",
134    "one",
135    "two",
136    "via",
137    "e.g",
138    "say",
139    "says",
140    "said",
141    "get",
142    "got",
143    "see",
144    "also",
145    "well",
146    "way",
147    "like",
148    "make",
149    "made",
150    "much",
151    "many",
152    "even",
153    "still",
154    "though",
155    "however",
156    "include",
157    "includes",
158    "including",
159    "provide",
160    "provides",
161    "provide",
162    "based",
163    "because",
164];
165
166/// Lowercase a word and keep only alphabetic characters.
167fn clean_word(w: &str) -> Option<String> {
168    let cleaned: String = w
169        .chars()
170        .filter(|c| c.is_alphabetic())
171        .map(|c| c.to_ascii_lowercase())
172        .collect();
173    if cleaned.len() < 4 || STOPWORDS.contains(&cleaned.as_str()) {
174        return None;
175    }
176    Some(cleaned)
177}
178
179/// Extract the most frequent meaningful terms from text (frequency-based,
180/// stopword-filtered). A term must appear in **at least two sources** to
181/// count — this filters single-page junk (bot walls, error pages).
182fn key_terms(texts: &[String], limit: usize) -> Vec<String> {
183    let mut counts: HashMap<String, usize> = HashMap::new();
184    let mut per_source: HashMap<String, Vec<usize>> = HashMap::new();
185    for (idx, text) in texts.iter().enumerate() {
186        for token in text
187            .split(|c: char| !c.is_alphabetic() && c != '-' && c != ' ')
188            .flat_map(|s| s.split_whitespace())
189        {
190            if let Some(word) = clean_word(token) {
191                *counts.entry(word.clone()).or_default() += 1;
192                let seen = per_source.entry(word).or_default();
193                if !seen.contains(&idx) {
194                    seen.push(idx);
195                }
196            }
197        }
198    }
199    let mut terms: Vec<(String, usize, usize)> = counts
200        .into_iter()
201        .filter(|(word, _)| per_source.get(word).map_or(0, Vec::len) >= 2)
202        .map(|(word, count)| {
203            let sources = per_source.get(&word).map_or(0, Vec::len);
204            (word, count, sources)
205        })
206        .collect();
207    terms.sort_by(|a, b| {
208        b.1.cmp(&a.1)
209            .then_with(|| b.2.cmp(&a.2))
210            .then_with(|| a.0.cmp(&b.0))
211    });
212    terms.into_iter().take(limit).map(|(t, _, _)| t).collect()
213}
214
215/// Fetch several URLs sequentially with a per-URL char budget.
216async fn fetch_sources(
217    urls: &[String],
218    max_sources: usize,
219    max_chars: usize,
220    timeout: Duration,
221) -> Vec<Fetched> {
222    let mut fetched = Vec::new();
223    for url in urls.iter().take(max_sources) {
224        if !is_url_safe(url) {
225            continue;
226        }
227        let url_c = url.clone();
228        let max_c = max_chars;
229        let t = timeout;
230        if let Ok(Ok(f)) =
231            tokio::task::spawn_blocking(move || fetch_bounded(&url_c, max_c, t)).await
232        {
233            if !f.content.trim().is_empty() {
234                fetched.push(f);
235            }
236        }
237    }
238    fetched
239}
240
241/// Build the per-source finding entry.
242fn finding(f: &Fetched) -> Value {
243    let domain = f
244        .url
245        .strip_prefix("https://")
246        .or_else(|| f.url.strip_prefix("http://"))
247        .and_then(|d| d.split('/').next())
248        .unwrap_or("")
249        .to_string();
250    json!({
251        "url": f.url,
252        "domain": domain,
253        "title": f.title,
254        "content": f.content,
255        "content_length": f.content.len(),
256    })
257}
258
259/// Store a research report in the Research galaxy (best-effort).
260fn store_research(store: Option<&Arc<MemoryStore>>, topic: &str, report: &Value) -> Option<String> {
261    let store = store?;
262    let content = format!(
263        "RESEARCH: {topic}\n{}",
264        report
265            .get("synthesis")
266            .and_then(Value::as_str)
267            .unwrap_or("")
268    );
269    let mut memory = Memory::new(Galaxy::Research, content);
270    memory = memory.with_tags(vec!["research".into(), topic.to_ascii_lowercase()]);
271    if let Some(sources) = report.get("sources").and_then(Value::as_array) {
272        if let Some(first) = sources.first() {
273            if let Some(url) = first.get("url").and_then(Value::as_str) {
274                memory = memory.with_source(url.to_string(), 0.8);
275            }
276        }
277    }
278    store.put(Galaxy::Research, &memory).ok()?;
279    Some(memory.metadata.id.to_string())
280}
281
282// ── research.topic ───────────────────────────────────────────────────
283
284/// `research.topic` — deep research on a topic: search, fetch, synthesize.
285pub struct ResearchTopicTool {
286    store: Option<Arc<MemoryStore>>,
287    stats: ToolStats,
288    effects: EffectRow,
289}
290
291impl ResearchTopicTool {
292    #[must_use]
293    pub fn new(store: Option<Arc<MemoryStore>>) -> Self {
294        Self {
295            store,
296            stats: ToolStats::default(),
297            effects: EffectRow::read_only(vec![
298                Resource::Network,
299                Resource::Galaxy("research".into()),
300            ]),
301        }
302    }
303}
304
305#[async_trait]
306impl Tool for ResearchTopicTool {
307    fn name(&self) -> &str {
308        "research.topic"
309    }
310    fn gana(&self) -> Gana {
311        Gana::Mound
312    }
313    fn effects(&self) -> &EffectRow {
314        &self.effects
315    }
316    fn description(&self) -> &str {
317        "Deep research on a topic: search, fetch top sources, extract key terms, synthesize. Args: topic (required), num_results (default 6), max_sources (default 4), max_chars_per_source (default 15000), store_memories (default true)."
318    }
319    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
320        let topic = args
321            .get("topic")
322            .and_then(Value::as_str)
323            .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic is required".into()))?;
324        let num_results = args.get("num_results").and_then(Value::as_u64).unwrap_or(6) as usize;
325        let max_sources = args.get("max_sources").and_then(Value::as_u64).unwrap_or(4) as usize;
326        let max_chars = args
327            .get("max_chars_per_source")
328            .and_then(Value::as_u64)
329            .unwrap_or(15_000) as usize;
330        let store_memories = args
331            .get("store_memories")
332            .and_then(Value::as_bool)
333            .unwrap_or(true);
334        let timeout = Duration::from_secs(20);
335
336        let topic_s = topic.to_string();
337        let results =
338            tokio::task::spawn_blocking(move || web_search(&topic_s, num_results, timeout))
339                .await
340                .map_err(|e| {
341                    wm_core::CoreError::Tool(format!("research.topic search task: {e}"))
342                })??;
343
344        let urls: Vec<String> = results
345            .iter()
346            .filter(|r| !r.url.is_empty())
347            .map(|r| r.url.clone())
348            .collect();
349        let fetched = fetch_sources(&urls, max_sources, max_chars, timeout).await;
350
351        let contents: Vec<String> = fetched.iter().map(|f| f.content.clone()).collect();
352        let terms = key_terms(&contents, 14);
353
354        let findings: Vec<Value> = fetched.iter().map(finding).collect();
355        let synthesis = if findings.is_empty() {
356            format!(
357                "No readable sources found for '{topic}' — refine the topic or check network access."
358            )
359        } else {
360            let source_list: Vec<String> = findings
361                .iter()
362                .map(|f| {
363                    let title = f.get("title").and_then(Value::as_str).unwrap_or("");
364                    let domain = f.get("domain").and_then(Value::as_str).unwrap_or("");
365                    format!("{title} ({domain})")
366                })
367                .collect();
368            format!(
369                "Research on '{topic}' synthesized from {} source(s): {}. Key terms across sources: {}.",
370                findings.len(),
371                source_list.join("; "),
372                terms.join(", ")
373            )
374        };
375
376        let report = json!({
377            "status": "success",
378            "topic": topic,
379            "query": topic,
380            "sources_fetched": findings.len(),
381            "search_results": results.len(),
382            "key_terms": terms,
383            "synthesis": synthesis,
384            "sources": findings,
385        });
386
387        let mut report = report;
388        if store_memories {
389            if let Some(id) = store_research(self.store.as_ref(), topic, &report) {
390                report["memory_id"] = json!(id);
391            }
392        }
393        Ok(report)
394    }
395    fn stats(&self) -> &ToolStats {
396        &self.stats
397    }
398}
399
400// ── research.repo ────────────────────────────────────────────────────
401
402/// `research.repo` — deep-read a GitHub repo's README and structure.
403pub struct ResearchRepoTool {
404    stats: ToolStats,
405    effects: EffectRow,
406}
407
408impl ResearchRepoTool {
409    #[must_use]
410    pub fn new() -> Self {
411        Self {
412            stats: ToolStats::default(),
413            effects: EffectRow::read_only(vec![Resource::Network]),
414        }
415    }
416}
417
418impl Default for ResearchRepoTool {
419    fn default() -> Self {
420        Self::new()
421    }
422}
423
424#[async_trait]
425impl Tool for ResearchRepoTool {
426    fn name(&self) -> &str {
427        "research.repo"
428    }
429    fn gana(&self) -> Gana {
430        Gana::Mound
431    }
432    fn effects(&self) -> &EffectRow {
433        &self.effects
434    }
435    fn description(&self) -> &str {
436        "Research a GitHub repo by deep-reading its README. Args: repo (required, owner/name), max_chars (default 50000)."
437    }
438    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
439        let repo = args.get("repo").and_then(Value::as_str).ok_or_else(|| {
440            wm_core::CoreError::InvalidArgs("repo (owner/name) is required".into())
441        })?;
442        let max_chars = args
443            .get("max_chars")
444            .and_then(Value::as_u64)
445            .unwrap_or(50_000) as usize;
446        let timeout = Duration::from_secs(25);
447
448        let repo = repo.trim().trim_start_matches("https://github.com/");
449        let parts: Vec<&str> = repo.split('/').filter(|p| !p.is_empty()).collect();
450        if parts.len() < 2 {
451            return Err(wm_core::CoreError::InvalidArgs(
452                "repo must be 'owner/name'".into(),
453            ));
454        }
455        let owner = parts[0];
456        let name = parts[1];
457
458        // Try the raw README endpoints in order; fall back to the rendered page.
459        let mut raw: Option<Fetched> = None;
460        for candidate in [
461            "README.md",
462            "README.rst",
463            "readme.md",
464            "readme.rst",
465            "README",
466        ] {
467            let url = format!("https://raw.githubusercontent.com/{owner}/{name}/HEAD/{candidate}");
468            if !is_url_safe(&url) {
469                continue;
470            }
471            let url_c = url.clone();
472            let max_c = max_chars;
473            let t = timeout;
474            if let Ok(Ok(f)) =
475                tokio::task::spawn_blocking(move || fetch_bounded(&url_c, max_c, t)).await
476            {
477                if !f.content.trim().is_empty() {
478                    raw = Some(f);
479                    break;
480                }
481            }
482        }
483
484        let (content, source_url, title) = if let Some(f) = raw {
485            (f.content, f.url, name.to_string())
486        } else {
487            let page_url = format!("https://github.com/{owner}/{name}");
488            let page_url_c = page_url.clone();
489            let max_c = max_chars;
490            let t = timeout;
491            let fetched = tokio::task::spawn_blocking(move || fetch_bounded(&page_url_c, max_c, t))
492                .await
493                .map_err(|e| wm_core::CoreError::Tool(format!("research.repo task: {e}")))?
494                .map_err(|e| wm_core::CoreError::Tool(format!("research.repo: {e}")))?;
495            (fetched.content, page_url, name.to_string())
496        };
497
498        // Extract headings (markdown or HTML) as a structure outline.
499        let mut sections: Vec<String> = Vec::new();
500        for line in content.lines() {
501            let line = line.trim();
502            if let Some(h) = line.strip_prefix("## ") {
503                sections.push(h.to_string());
504            } else if line.starts_with("### ") {
505                sections.push(line.trim_start_matches("### ").to_string());
506            }
507        }
508
509        let description = content
510            .lines()
511            .map(str::trim)
512            .find(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with('!'))
513            .unwrap_or("")
514            .to_string();
515
516        Ok(json!({
517            "status": "success",
518            "repo": format!("{owner}/{name}"),
519            "source_url": source_url,
520            "title": title,
521            "description": description,
522            "sections": sections,
523            "content_length": content.len(),
524            "content": content,
525        }))
526    }
527    fn stats(&self) -> &ToolStats {
528        &self.stats
529    }
530}
531
532// ── research.rabbit_hole ─────────────────────────────────────────────
533
534/// `research.rabbit_hole` — bounded recursive spiral research.
535///
536/// Explores a topic by searching, extracting unfamiliar terms from the
537/// results, searching each term, and fetching the top pages — then
538/// synthesizing the whole exploration. Depth is bounded (default 2) and
539/// parallelism is bounded, so a call cannot fan out unboundedly.
540pub struct ResearchRabbitHoleTool {
541    store: Option<Arc<MemoryStore>>,
542    bus: Option<Arc<Mutex<GanYingBus>>>,
543    stats: ToolStats,
544    effects: EffectRow,
545}
546
547impl ResearchRabbitHoleTool {
548    #[must_use]
549    pub fn new(store: Option<Arc<MemoryStore>>) -> Self {
550        Self {
551            store,
552            bus: None,
553            stats: ToolStats::default(),
554            effects: EffectRow::read_only(vec![
555                Resource::Network,
556                Resource::Galaxy("research".into()),
557            ]),
558        }
559    }
560
561    /// Attach the Gan Ying Bus. When present, a `PatternDetected` event is
562    /// emitted for the explored terms so background reflection can awaken
563    /// (ROADMAP v9.1 §3.3). Emission is best-effort and never fails the call.
564    #[must_use]
565    pub fn with_bus(mut self, bus: Arc<Mutex<GanYingBus>>) -> Self {
566        self.bus = Some(bus);
567        self
568    }
569}
570
571/// Publish a rabbit-hole discovery to the Gan Ying Bus as `PatternDetected`.
572/// Best-effort: a locked or missing bus is silently skipped.
573fn publish_pattern_detected(
574    bus: Option<&Arc<Mutex<GanYingBus>>>,
575    topic: &str,
576    terms: &[String],
577    depth_used: usize,
578) {
579    if terms.is_empty() {
580        return;
581    }
582    let Some(bus) = bus else {
583        return;
584    };
585    if let Ok(mut bus) = bus.lock() {
586        bus.emit_with(
587            EventType::PatternDetected,
588            "research.rabbit_hole",
589            json!({
590                "topic": topic,
591                "terms": terms,
592                "entries_count": terms.len(),
593                "depth_used": depth_used,
594            }),
595            0.7,
596            false,
597        );
598    }
599}
600
601#[async_trait]
602impl Tool for ResearchRabbitHoleTool {
603    fn name(&self) -> &str {
604        "research.rabbit_hole"
605    }
606    fn gana(&self) -> Gana {
607        Gana::Mound
608    }
609    fn effects(&self) -> &EffectRow {
610        &self.effects
611    }
612    fn description(&self) -> &str {
613        "Recursive spiral research: search the topic, extract unfamiliar terms, search each term, fetch top results, synthesize. Args: topic (required), max_depth (default 2, max 3), num_search_results (default 5), fetch_top_results (default 2), max_parallel_terms (default 6), store_memories (default true)."
614    }
615    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
616        let topic = args
617            .get("topic")
618            .and_then(Value::as_str)
619            .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic is required".into()))?;
620        let max_depth = args
621            .get("max_depth")
622            .and_then(Value::as_u64)
623            .unwrap_or(2)
624            .clamp(1, 3) as usize;
625        let num_results = args
626            .get("num_search_results")
627            .and_then(Value::as_u64)
628            .unwrap_or(5) as usize;
629        let fetch_top = args
630            .get("fetch_top_results")
631            .and_then(Value::as_u64)
632            .unwrap_or(2) as usize;
633        let max_parallel_terms = args
634            .get("max_parallel_terms")
635            .and_then(Value::as_u64)
636            .unwrap_or(6)
637            .clamp(1, 12) as usize;
638        let max_chars = args
639            .get("max_chars_per_fetch")
640            .and_then(Value::as_u64)
641            .unwrap_or(50_000) as usize;
642        let store_memories = args
643            .get("store_memories")
644            .and_then(Value::as_bool)
645            .unwrap_or(true);
646        let timeout = Duration::from_secs(20);
647
648        // Level 0: search the topic itself.
649        let topic_s = topic.to_string();
650        let level0 =
651            tokio::task::spawn_blocking(move || web_search(&topic_s, num_results, timeout))
652                .await
653                .map_err(|e| {
654                    wm_core::CoreError::Tool(format!("research.rabbit_hole search task: {e}"))
655                })??;
656
657        // Fetch the top topic results.
658        let topic_urls: Vec<String> = level0
659            .iter()
660            .filter(|r| !r.url.is_empty())
661            .map(|r| r.url.clone())
662            .collect();
663        let topic_fetched = fetch_sources(&topic_urls, fetch_top, max_chars, timeout).await;
664
665        // Extract unfamiliar terms from titles + snippets of the first level.
666        let mut terms: Vec<String> = Vec::new();
667        {
668            let mut seen: Vec<String> = Vec::new();
669            let candidates: Vec<String> = level0
670                .iter()
671                .flat_map(|r| {
672                    r.title
673                        .split_whitespace()
674                        .chain(r.snippet.split_whitespace())
675                        .map(str::to_string)
676                        .collect::<Vec<_>>()
677                })
678                .collect();
679            for word in candidates {
680                let word = word.trim_matches(|c: char| !c.is_alphanumeric());
681                if word.len() < 5 || STOPWORDS.contains(&word.to_ascii_lowercase().as_str()) {
682                    continue;
683                }
684                if word.eq_ignore_ascii_case(topic) {
685                    continue;
686                }
687                let key = word.to_ascii_lowercase();
688                if !seen.contains(&key) {
689                    seen.push(key);
690                    terms.push(word.to_string());
691                }
692                if terms.len() >= max_parallel_terms {
693                    break;
694                }
695            }
696        }
697
698        // Level 1: search each unfamiliar term.
699        let mut entries: Vec<Value> = Vec::new();
700        for term in terms.iter().take(max_parallel_terms) {
701            let term_s = term.clone();
702            let n = num_results.max(3);
703            let t = timeout;
704            let results = tokio::task::spawn_blocking(move || web_search(&term_s, n, t))
705                .await
706                .map_err(|e| {
707                    wm_core::CoreError::Tool(format!("research.rabbit_hole term task: {e}"))
708                })??;
709            let best = results.first();
710            entries.push(json!({
711                "term": term,
712                "depth": 1,
713                "definition": best.map(|b| b.snippet.clone()).unwrap_or_default(),
714                "source": best.map(|b| b.url.clone()).unwrap_or_default(),
715                "related_terms": results.iter().take(3).map(|r| r.title.clone()).collect::<Vec<_>>(),
716            }));
717        }
718
719        // Level 2: recurse on the most interesting term.
720        let mut extra_entries: Vec<Value> = Vec::new();
721        if max_depth >= 2 && !entries.is_empty() {
722            // pick the term whose search returned the most results
723            let mut best_term = String::new();
724            let mut best_score = 0usize;
725            for entry in &entries {
726                let related = entry
727                    .get("related_terms")
728                    .and_then(Value::as_array)
729                    .map_or(0, Vec::len);
730                if related > best_score {
731                    best_score = related;
732                    best_term = entry
733                        .get("term")
734                        .and_then(Value::as_str)
735                        .unwrap_or("")
736                        .to_string();
737                }
738            }
739            if !best_term.is_empty() && !best_term.eq_ignore_ascii_case(topic) {
740                let bt = best_term.clone();
741                let n = num_results.max(3);
742                let t = timeout;
743                let results = tokio::task::spawn_blocking(move || web_search(&bt, n, t))
744                    .await
745                    .map_err(|e| {
746                        wm_core::CoreError::Tool(format!("research.rabbit_hole depth-2 task: {e}"))
747                    })??;
748                for r in results.iter().take(3) {
749                    extra_entries.push(json!({
750                        "term": best_term,
751                        "depth": 2,
752                        "definition": r.snippet,
753                        "source": r.url,
754                        "related_terms": [],
755                    }));
756                }
757            }
758        }
759        entries.extend(extra_entries);
760
761        // Synthesis: what was explored and what was found.
762        let mut connections: Vec<String> = Vec::new();
763        for entry in &entries {
764            if let (Some(term), Some(src)) = (
765                entry.get("term").and_then(Value::as_str),
766                entry.get("source").and_then(Value::as_str),
767            ) {
768                if !src.is_empty() {
769                    connections.push(format!("{term} → {src}"));
770                }
771            }
772        }
773        let synthesis = format!(
774            "Rabbit-hole exploration of '{topic}' (depth {max_depth}): {} unfamiliar term(s) explored — {}. Top sources on the topic itself: {}.",
775            entries.len(),
776            entries
777                .iter()
778                .map(|e| e.get("term").and_then(Value::as_str).unwrap_or(""))
779                .collect::<Vec<_>>()
780                .join(", "),
781            topic_fetched
782                .iter()
783                .map(|f| f.title.as_str())
784                .collect::<Vec<_>>()
785                .join("; "),
786        );
787
788        let topic_sources: Vec<Value> = topic_fetched.iter().map(finding).collect();
789        let report = json!({
790            "status": "success",
791            "title": topic,
792            "topics": entries.iter().map(|e| e["term"].clone()).collect::<Vec<_>>(),
793            "entries_count": entries.len(),
794            "synthesis": synthesis,
795            "connections_count": connections.len(),
796            "connections": connections,
797            "depth_used": max_depth,
798            "entries": entries,
799            "sources": topic_sources,
800        });
801
802        let mut report = report;
803        if store_memories {
804            if let Some(id) = store_research(self.store.as_ref(), topic, &report) {
805                report["memory_id"] = json!(id);
806            }
807        }
808        // Gan Ying loop: announce the explored terms so background
809        // reflection can awaken. Best-effort; never fails the call.
810        if !entries.is_empty() {
811            let terms: Vec<String> = entries
812                .iter()
813                .filter_map(|e| e.get("term").and_then(Value::as_str))
814                .map(str::to_string)
815                .collect();
816            publish_pattern_detected(self.bus.as_ref(), topic, &terms, max_depth);
817        }
818        Ok(report)
819    }
820    fn stats(&self) -> &ToolStats {
821        &self.stats
822    }
823}
824
825/// Register the research tools (3). `store` enables `store_memories`;
826/// `bus` wires the rabbit-hole Gan Ying loop (ROADMAP v9.1 §3.3).
827#[must_use]
828pub fn register_research(
829    registry: &wm_dispatch::ToolRegistry,
830    store: &Arc<MemoryStore>,
831    bus: Option<&Arc<Mutex<GanYingBus>>>,
832) -> wm_dispatch::ToolRegistry {
833    let mut rabbit_hole = ResearchRabbitHoleTool::new(Some(store.clone()));
834    if let Some(bus) = bus {
835        rabbit_hole = rabbit_hole.with_bus(Arc::clone(bus));
836    }
837    registry
838        .register(Arc::new(ResearchTopicTool::new(Some(store.clone()))))
839        .register(Arc::new(ResearchRepoTool::new()))
840        .register(Arc::new(rabbit_hole))
841}
842
843#[cfg(test)]
844mod tests {
845    use super::*;
846
847    #[test]
848    fn key_terms_filters_stopwords() {
849        let texts = vec![
850            "The architecture of the agentic system and the governance layer".to_string(),
851            "The governance layer architecture and the agentic system".to_string(),
852            "Architecture governance agentic system".to_string(),
853        ];
854        let terms = key_terms(&texts, 5);
855        assert_eq!(terms.len(), 5);
856        assert!(!terms.contains(&"the".to_string()));
857        assert!(terms.contains(&"governance".to_string()));
858        assert!(terms.contains(&"architecture".to_string()));
859        assert!(terms.contains(&"agentic".to_string()));
860        assert!(terms.contains(&"system".to_string()));
861        assert!(terms.contains(&"layer".to_string()));
862    }
863
864    #[test]
865    fn key_terms_require_two_sources() {
866        // "onlyonce" appears in a single text — must be filtered out.
867        let texts = vec![
868            "onlyonce appears only here in the alpha bravo".to_string(),
869            "alpha bravo both sources share the delta".to_string(),
870        ];
871        let terms = key_terms(&texts, 10);
872        assert!(!terms.contains(&"onlyonce".to_string()));
873        assert!(!terms.contains(&"delta".to_string()));
874        assert!(terms.contains(&"bravo".to_string()));
875        assert!(terms.contains(&"alpha".to_string()));
876    }
877
878    #[test]
879    fn repo_name_validation() {
880        let tool = ResearchRepoTool::new();
881        assert_eq!(tool.name(), "research.repo");
882        assert_eq!(ResearchTopicTool::new(None).name(), "research.topic");
883        assert_eq!(
884            ResearchRabbitHoleTool::new(None).name(),
885            "research.rabbit_hole"
886        );
887    }
888
889    #[tokio::test]
890    async fn topic_requires_topic() {
891        let tool = ResearchTopicTool::new(None);
892        let mut ctx = Context::default();
893        assert!(tool.call(&mut ctx, json!({})).await.is_err());
894    }
895
896    #[tokio::test]
897    async fn rabbit_hole_requires_topic() {
898        let tool = ResearchRabbitHoleTool::new(None);
899        let mut ctx = Context::default();
900        assert!(tool.call(&mut ctx, json!({})).await.is_err());
901    }
902
903    #[test]
904    fn rabbit_hole_publishes_pattern_detected() {
905        let bus = Arc::new(Mutex::new(GanYingBus::default()));
906        let seen: Arc<Mutex<Vec<EventType>>> = Arc::new(Mutex::new(Vec::new()));
907        let seen_cb = Arc::clone(&seen);
908        bus.lock().unwrap().subscribe(
909            wm_cognitive::SubscriptionFilter::All,
910            Box::new(move |event| {
911                seen_cb.lock().unwrap().push(event.event_type);
912            }),
913        );
914        let terms = vec!["photosynthesis".to_string(), "chlorophyll".to_string()];
915        publish_pattern_detected(Some(&bus), "plants", &terms, 2);
916        assert_eq!(
917            seen.lock().unwrap().as_slice(),
918            &[EventType::PatternDetected]
919        );
920
921        // Empty terms and a missing bus emit nothing.
922        publish_pattern_detected(Some(&bus), "plants", &[], 2);
923        publish_pattern_detected(None, "plants", &terms, 2);
924        assert_eq!(seen.lock().unwrap().len(), 1);
925    }
926
927    #[tokio::test]
928    async fn repo_requires_owner_and_name() {
929        let tool = ResearchRepoTool::new();
930        let mut ctx = Context::default();
931        assert!(
932            tool.call(&mut ctx, json!({"repo": "singleword"}))
933                .await
934                .is_err()
935        );
936    }
937
938    #[test]
939    fn effects_declare_network_read() {
940        let topic = ResearchTopicTool::new(None);
941        assert_eq!(topic.effects().reads[0], Resource::Network);
942        assert!(topic.effects().writes.is_empty());
943    }
944}