Skip to main content

microclaw_engine/tools/
deep_research.rs

1//! `deep_research` — a deterministic multi-source research gatherer.
2//!
3//! The tool does the mechanical heavy lifting of a research pass: fan out a set
4//! of sub-queries across the configured search provider, deduplicate the
5//! sources, concurrently fetch the top pages (reusing the SSRF-guarded
6//! `web_fetch` path), and return a citation-numbered evidence digest with
7//! source-agreement signals. Semantic cross-verification and synthesis are left
8//! to the agent (or the `researcher` specialist) reading the digest — the tool
9//! itself runs no LLM, so it is cheap, fully deterministic, and unit-testable.
10
11use async_trait::async_trait;
12use futures_util::future::join_all;
13use serde_json::json;
14use std::collections::BTreeSet;
15
16use super::{schema_object, Tool, ToolResult};
17use crate::internal::tool_runtime::web_content_validation::WebContentValidationConfig;
18use crate::internal::tool_runtime::web_fetch::WebFetchUrlValidationConfig;
19use crate::internal::tool_runtime::web_search::SearchProviderConfig;
20use microclaw_core::llm_types::ToolDefinition;
21
22const MAX_SUB_QUERIES: usize = 6;
23const MAX_SOURCES: usize = 10;
24const DEFAULT_FETCH_TOP_N: usize = 4;
25const EXCERPT_CHARS: usize = 600;
26
27pub struct DeepResearchTool {
28    default_timeout_secs: u64,
29    provider: SearchProviderConfig,
30    validation: WebContentValidationConfig,
31    url_validation: WebFetchUrlValidationConfig,
32}
33
34impl DeepResearchTool {
35    pub fn new(
36        default_timeout_secs: u64,
37        provider: SearchProviderConfig,
38        validation: WebContentValidationConfig,
39        url_validation: WebFetchUrlValidationConfig,
40    ) -> Self {
41        Self {
42            default_timeout_secs,
43            provider,
44            validation,
45            url_validation,
46        }
47    }
48}
49
50struct Source {
51    title: String,
52    url: String,
53    domain: String,
54    snippet: String,
55    /// Sub-query indices (1-based, as shown to the agent) that surfaced this URL.
56    matched: BTreeSet<usize>,
57    excerpt: Option<String>,
58}
59
60#[async_trait]
61impl Tool for DeepResearchTool {
62    fn name(&self) -> &str {
63        "deep_research"
64    }
65
66    fn definition(&self) -> ToolDefinition {
67        ToolDefinition {
68            name: "deep_research".into(),
69            description:
70                "Run a structured multi-source research pass: fans out several sub-queries across \
71                 the web, deduplicates sources, fetches the top pages, and returns a \
72                 citation-numbered evidence digest with source-agreement signals. Pass a list of \
73                 focused `queries` (break the question into distinct angles) for best coverage. \
74                 Then synthesize an answer that cites sources by number, e.g. [1][2], and flags \
75                 where sources disagree."
76                    .into(),
77            input_schema: schema_object(
78                json!({
79                    "queries": {
80                        "type": "array",
81                        "items": { "type": "string" },
82                        "description": "Sub-queries / distinct angles to search (1-6 recommended)."
83                    },
84                    "query": {
85                        "type": "string",
86                        "description": "A single query (used when `queries` is omitted)."
87                    },
88                    "fetch": {
89                        "type": "boolean",
90                        "description": "Fetch full page text for the top sources (default true)."
91                    },
92                    "timeout_secs": {
93                        "type": "integer",
94                        "description": "Per-request timeout (defaults to the configured tool budget)."
95                    }
96                }),
97                &[],
98            ),
99        }
100    }
101
102    async fn execute(&self, input: serde_json::Value) -> ToolResult {
103        let queries = match parse_queries(&input) {
104            Ok(q) => q,
105            Err(msg) => return ToolResult::error(msg),
106        };
107        let fetch = input.get("fetch").and_then(|v| v.as_bool()).unwrap_or(true);
108        let timeout_secs = input
109            .get("timeout_secs")
110            .and_then(|v| v.as_u64())
111            .unwrap_or(self.default_timeout_secs)
112            .clamp(1, 60);
113
114        // Fan out: search every sub-query concurrently.
115        let searches = queries.iter().map(|q| {
116            crate::internal::tool_runtime::web_search::search_with_provider(
117                q,
118                &self.provider,
119                timeout_secs,
120            )
121        });
122        let results = join_all(searches).await;
123
124        // Aggregate + dedup by normalized URL, tracking which sub-queries hit it.
125        let mut sources: Vec<Source> = Vec::new();
126        let mut no_result_queries: Vec<usize> = Vec::new();
127        for (idx, result) in results.into_iter().enumerate() {
128            let display_idx = idx + 1;
129            match result {
130                Ok(hits) if !hits.is_empty() => {
131                    for hit in hits {
132                        let norm = normalize_url(&hit.url);
133                        if let Some(existing) =
134                            sources.iter_mut().find(|s| normalize_url(&s.url) == norm)
135                        {
136                            existing.matched.insert(display_idx);
137                        } else {
138                            let mut matched = BTreeSet::new();
139                            matched.insert(display_idx);
140                            sources.push(Source {
141                                domain: domain_of(&hit.url),
142                                title: hit.title,
143                                url: hit.url,
144                                snippet: hit.snippet,
145                                matched,
146                                excerpt: None,
147                            });
148                        }
149                    }
150                }
151                Ok(_) => no_result_queries.push(display_idx),
152                Err(_) => no_result_queries.push(display_idx),
153            }
154        }
155
156        if sources.is_empty() {
157            return ToolResult::success(format!(
158                "No sources found across {} sub-quer{}.",
159                queries.len(),
160                if queries.len() == 1 { "y" } else { "ies" }
161            ));
162        }
163
164        // Rank: sources corroborated by more sub-queries first, then first-seen.
165        sources.sort_by_key(|source| std::cmp::Reverse(source.matched.len()));
166        sources.truncate(MAX_SOURCES);
167
168        // Fetch full text for the top sources (SSRF-guarded), concurrently.
169        if fetch {
170            let fetch_n = DEFAULT_FETCH_TOP_N.min(sources.len());
171            let fetches = sources.iter().take(fetch_n).map(|s| {
172                let url = s.url.clone();
173                let validation = self.validation;
174                let url_validation = self.url_validation.clone();
175                async move {
176                    crate::internal::tool_runtime::web_fetch::fetch_url_with_timeout_and_validation(
177                        &url,
178                        timeout_secs,
179                        validation,
180                        url_validation,
181                    )
182                    .await
183                    .ok()
184                }
185            });
186            let fetched = join_all(fetches).await;
187            for (src, text) in sources.iter_mut().zip(fetched) {
188                if let Some(text) = text {
189                    src.excerpt = Some(truncate_chars(text.trim(), EXCERPT_CHARS));
190                }
191            }
192        }
193
194        ToolResult::success(build_digest(&queries, &sources, &no_result_queries))
195    }
196}
197
198fn parse_queries(input: &serde_json::Value) -> Result<Vec<String>, String> {
199    let mut queries: Vec<String> = Vec::new();
200    if let Some(arr) = input.get("queries").and_then(|v| v.as_array()) {
201        for v in arr {
202            if let Some(s) = v.as_str() {
203                let t = s.trim();
204                if !t.is_empty() && !queries.iter().any(|q| q == t) {
205                    queries.push(t.to_string());
206                }
207            }
208        }
209    }
210    if queries.is_empty() {
211        if let Some(s) = input.get("query").and_then(|v| v.as_str()) {
212            let t = s.trim();
213            if !t.is_empty() {
214                queries.push(t.to_string());
215            }
216        }
217    }
218    if queries.is_empty() {
219        return Err("Provide `queries` (array) or `query` (string).".to_string());
220    }
221    queries.truncate(MAX_SUB_QUERIES);
222    Ok(queries)
223}
224
225fn build_digest(queries: &[String], sources: &[Source], no_result_queries: &[usize]) -> String {
226    let mut out = String::new();
227    out.push_str("# Research digest\n\n");
228    out.push_str(&format!("Sub-queries ({}):\n", queries.len()));
229    for (i, q) in queries.iter().enumerate() {
230        out.push_str(&format!("  {}. {}\n", i + 1, q));
231    }
232    let domains: BTreeSet<&str> = sources.iter().map(|s| s.domain.as_str()).collect();
233    out.push_str(&format!(
234        "\n{} unique source{} across {} independent domain{}.\n\n",
235        sources.len(),
236        plural(sources.len()),
237        domains.len(),
238        plural(domains.len()),
239    ));
240
241    out.push_str("## Sources\n\n");
242    for (i, s) in sources.iter().enumerate() {
243        let matched: Vec<String> = s.matched.iter().map(|m| m.to_string()).collect();
244        out.push_str(&format!("[{}] {}\n", i + 1, s.title));
245        out.push_str(&format!("    {}\n", s.url));
246        out.push_str(&format!(
247            "    {} · surfaced by sub-quer{} {}\n",
248            s.domain,
249            if s.matched.len() == 1 { "y" } else { "ies" },
250            matched.join(", ")
251        ));
252        if let Some(excerpt) = &s.excerpt {
253            out.push_str(&format!("    excerpt: {}\n", excerpt));
254        } else if !s.snippet.trim().is_empty() {
255            out.push_str(&format!("    snippet: {}\n", s.snippet.trim()));
256        }
257        out.push('\n');
258    }
259
260    out.push_str("## Source-agreement signals\n");
261    let corroborated: Vec<String> = sources
262        .iter()
263        .enumerate()
264        .filter(|(_, s)| s.matched.len() >= 2)
265        .map(|(i, _)| format!("[{}]", i + 1))
266        .collect();
267    if corroborated.is_empty() {
268        out.push_str("- No source was surfaced by more than one sub-query; corroborate key claims before trusting them.\n");
269    } else {
270        out.push_str(&format!(
271            "- Corroborated (surfaced by multiple sub-queries): {}\n",
272            corroborated.join(", ")
273        ));
274    }
275    if !no_result_queries.is_empty() {
276        let gaps: Vec<String> = no_result_queries.iter().map(|n| n.to_string()).collect();
277        out.push_str(&format!(
278            "- ⚠️ Coverage gap: sub-quer{} {} returned no results.\n",
279            if no_result_queries.len() == 1 {
280                "y"
281            } else {
282                "ies"
283            },
284            gaps.join(", ")
285        ));
286    }
287    out.push_str(
288        "\nSynthesize an answer that cites sources by number, e.g. [1][2]. Where sources disagree, say so explicitly.\n",
289    );
290    out
291}
292
293fn plural(n: usize) -> &'static str {
294    if n == 1 {
295        ""
296    } else {
297        "s"
298    }
299}
300
301fn domain_of(url: &str) -> String {
302    let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
303    let host = no_scheme.split(['/', '?', '#']).next().unwrap_or(no_scheme);
304    host.trim_start_matches("www.").to_ascii_lowercase()
305}
306
307fn normalize_url(url: &str) -> String {
308    let no_frag = url.split('#').next().unwrap_or(url);
309    no_frag.trim_end_matches('/').to_ascii_lowercase()
310}
311
312fn truncate_chars(s: &str, max: usize) -> String {
313    let collapsed: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
314    if collapsed.chars().count() <= max {
315        return collapsed;
316    }
317    let truncated: String = collapsed.chars().take(max).collect();
318    format!("{truncated}…")
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use serde_json::json;
325
326    fn tool() -> DeepResearchTool {
327        DeepResearchTool::new(
328            15,
329            SearchProviderConfig::default(),
330            WebContentValidationConfig::default(),
331            WebFetchUrlValidationConfig::default(),
332        )
333    }
334
335    #[test]
336    fn definition_has_no_required_fields() {
337        let def = tool().definition();
338        assert_eq!(def.name, "deep_research");
339        assert!(def.input_schema["properties"]["queries"].is_object());
340        assert!(def.input_schema["required"].as_array().unwrap().is_empty());
341    }
342
343    #[tokio::test]
344    async fn errors_without_any_query() {
345        let result = tool().execute(json!({})).await;
346        assert!(result.is_error);
347        assert!(result.content.contains("Provide `queries`"));
348    }
349
350    #[test]
351    fn parse_queries_dedups_and_caps() {
352        let parsed = parse_queries(&json!({
353            "queries": ["a", "a", " b ", "", "c", "d", "e", "f", "g"]
354        }))
355        .unwrap();
356        assert_eq!(parsed, vec!["a", "b", "c", "d", "e", "f"]); // deduped, trimmed, capped at 6
357    }
358
359    #[test]
360    fn parse_queries_falls_back_to_single_query() {
361        let parsed = parse_queries(&json!({ "query": "hello world" })).unwrap();
362        assert_eq!(parsed, vec!["hello world"]);
363    }
364
365    #[test]
366    fn normalize_and_domain_helpers() {
367        assert_eq!(
368            normalize_url("https://Example.com/Path/"),
369            "https://example.com/path"
370        );
371        assert_eq!(normalize_url("https://x.com/p#frag"), "https://x.com/p");
372        assert_eq!(domain_of("https://www.Example.com/a/b"), "example.com");
373        assert_eq!(domain_of("http://docs.rs/foo?x=1"), "docs.rs");
374    }
375
376    #[test]
377    fn digest_shows_citations_and_signals() {
378        let mut matched_a = BTreeSet::new();
379        matched_a.insert(1);
380        matched_a.insert(2);
381        let mut matched_b = BTreeSet::new();
382        matched_b.insert(1);
383        let sources = vec![
384            Source {
385                title: "Alpha".into(),
386                url: "https://a.example/x".into(),
387                domain: "a.example".into(),
388                snippet: "snip a".into(),
389                matched: matched_a,
390                excerpt: Some("full text a".into()),
391            },
392            Source {
393                title: "Beta".into(),
394                url: "https://b.example/y".into(),
395                domain: "b.example".into(),
396                snippet: "snip b".into(),
397                matched: matched_b,
398                excerpt: None,
399            },
400        ];
401        let digest = build_digest(
402            &["q1".to_string(), "q2".to_string(), "q3".to_string()],
403            &sources,
404            &[3],
405        );
406        assert!(digest.contains("[1] Alpha"));
407        assert!(digest.contains("[2] Beta"));
408        assert!(digest.contains("2 independent domains"));
409        assert!(digest.contains("Corroborated"));
410        assert!(digest.contains("[1]")); // corroborated list includes [1]
411        assert!(digest.contains("Coverage gap"));
412        assert!(digest.contains("excerpt: full text a"));
413        assert!(digest.contains("snippet: snip b"));
414    }
415
416    #[test]
417    fn truncate_collapses_whitespace_and_caps() {
418        let out = truncate_chars("a   b\n\nc", 100);
419        assert_eq!(out, "a b c");
420        let long = "x ".repeat(1000);
421        let capped = truncate_chars(&long, 10);
422        assert!(capped.chars().count() <= 11); // 10 + ellipsis
423        assert!(capped.ends_with('…'));
424    }
425}