1use 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 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 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 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 sources.sort_by_key(|source| std::cmp::Reverse(source.matched.len()));
166 sources.truncate(MAX_SOURCES);
167
168 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"]); }
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]")); 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); assert!(capped.ends_with('…'));
424 }
425}