1pub mod planner;
33pub mod searcher;
34pub mod synthesizer;
35
36pub use planner::{ResearchPlan, SubTopic};
37pub use searcher::{SearchCollector, SearchResult};
38pub use synthesizer::SynthesisOutput;
39
40use lc_core::language_models::BaseChatModel;
41use lc_core::tools::BaseTool;
42use lc_schema::Message;
43
44#[derive(Debug, thiserror::Error)]
46pub enum ResearchError {
47 #[error("LLM error: {0}")]
49 Llm(String),
50
51 #[error("search error: {0}")]
53 Search(String),
54
55 #[error("no results found")]
57 NoResults,
58}
59
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct Citation {
63 pub index: usize,
65 pub source: String,
67 pub url: Option<String>,
69 pub snippet: String,
71}
72
73#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
75pub struct ResearchReport {
76 pub markdown: String,
78 pub citations: Vec<Citation>,
80 pub subtopics: Vec<String>,
82 pub rounds_completed: usize,
84}
85
86pub struct DeepResearchAgent<M: BaseChatModel> {
91 llm: M,
92 searchers: Vec<Box<dyn BaseTool>>,
93 max_rounds: usize,
94 max_subtopics: usize,
95 max_source_tokens: Option<usize>,
97}
98
99impl<M: BaseChatModel> DeepResearchAgent<M> {
100 pub fn new(llm: M) -> Self {
105 Self {
106 llm,
107 searchers: Vec::new(),
108 max_rounds: 2,
109 max_subtopics: 5,
110 max_source_tokens: None,
111 }
112 }
113
114 pub fn with_searcher(mut self, tool: Box<dyn BaseTool>) -> Self {
118 self.searchers.push(tool);
119 self
120 }
121
122 pub fn with_max_rounds(mut self, n: usize) -> Self {
124 self.max_rounds = n.max(1);
125 self
126 }
127
128 pub fn with_max_subtopics(mut self, n: usize) -> Self {
130 self.max_subtopics = n.max(1);
131 self
132 }
133
134 pub fn with_max_source_tokens(mut self, tokens: usize) -> Self {
138 self.max_source_tokens = Some(tokens);
139 self
140 }
141
142 pub async fn research(&self, topic: &str) -> Result<ResearchReport, ResearchError> {
147 if self.searchers.is_empty() {
148 return Err(ResearchError::Search(
149 "no search tools configured; add at least one with with_searcher()".to_string(),
150 ));
151 }
152
153 let mut all_results: Vec<SearchResult> = Vec::new();
154 let mut rounds_completed: usize = 0;
155 let current_plan = self.plan(topic).await?;
156 let mut follow_up_queries: Vec<String> = Vec::new();
157
158 for round in 0..self.max_rounds {
159 let queries = if round == 0 {
160 current_plan.all_queries()
161 } else {
162 follow_up_queries.clone()
163 };
164
165 if queries.is_empty() {
166 break;
167 }
168
169 let round_results = self.search(&queries).await?;
170 all_results.extend(round_results);
171
172 all_results = SearchCollector::dedup(all_results);
174
175 rounds_completed = round + 1;
176
177 let (markdown, gaps) = self
179 .synthesize(topic, ¤t_plan, &all_results, self.max_source_tokens)
180 .await?;
181
182 if gaps.is_empty() || round + 1 >= self.max_rounds {
183 let citations = self.build_citations(&all_results);
184 return Ok(ResearchReport {
185 markdown,
186 citations,
187 subtopics: current_plan
188 .subtopics
189 .iter()
190 .map(|s| s.name.clone())
191 .collect(),
192 rounds_completed,
193 });
194 }
195
196 follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
198 }
199
200 let (markdown, _) = self
202 .synthesize(topic, ¤t_plan, &all_results, self.max_source_tokens)
203 .await?;
204 let citations = self.build_citations(&all_results);
205 Ok(ResearchReport {
206 markdown,
207 citations,
208 subtopics: current_plan
209 .subtopics
210 .iter()
211 .map(|s| s.name.clone())
212 .collect(),
213 rounds_completed,
214 })
215 }
216
217 async fn plan(&self, topic: &str) -> Result<ResearchPlan, ResearchError> {
220 planner::plan(&self.llm, topic, self.max_subtopics).await
221 }
222
223 async fn search(&self, queries: &[String]) -> Result<Vec<SearchResult>, ResearchError> {
224 searcher::search(&self.searchers, queries).await
225 }
226
227 async fn synthesize(
228 &self,
229 topic: &str,
230 plan: &ResearchPlan,
231 results: &[SearchResult],
232 max_source_tokens: Option<usize>,
233 ) -> Result<(String, Vec<String>), ResearchError> {
234 synthesizer::synthesize(&self.llm, topic, plan, results, max_source_tokens).await
235 }
236
237 async fn generate_follow_ups(
238 &self,
239 topic: &str,
240 gaps: &[String],
241 ) -> Result<Vec<String>, ResearchError> {
242 let prompt = format!(
243 "Research topic: {}\n\n\
244 The following information gaps remain after initial research:\n\
245 {}\n\n\
246 For each gap, generate 1-3 specific search queries to fill it. \
247 Output a JSON array of objects, each with \"gap\" and \"queries\" fields. \
248 Every gap listed above must appear as a \"gap\" key in the output.\n\
249 Example: [{{\"gap\": \"gap description\", \"queries\": [\"query1\", \"query2\"]}}]\n\
250 Only output the JSON array, nothing else.",
251 topic,
252 gaps.iter()
253 .enumerate()
254 .map(|(i, g)| format!("{}. {}", i + 1, g))
255 .collect::<Vec<_>>()
256 .join("\n"),
257 );
258 let messages = vec![
259 Message::system("You are a research assistant. Output only valid JSON."),
260 Message::human(prompt),
261 ];
262 let response = self
263 .llm
264 .chat(messages, None)
265 .await
266 .map_err(|e| ResearchError::Llm(format!("{:?}", e)))?;
267
268 parse_gap_queries(&response.content, gaps)
269 }
270
271 fn build_citations(&self, results: &[SearchResult]) -> Vec<Citation> {
272 results
273 .iter()
274 .enumerate()
275 .map(|(i, r)| Citation {
276 index: i + 1,
277 source: r.title.clone(),
278 url: if r.url.is_empty() {
279 None
280 } else {
281 Some(r.url.clone())
282 },
283 snippet: r.snippet.clone(),
284 })
285 .collect()
286 }
287}
288
289fn parse_gap_queries(
297 content: &str,
298 original_gaps: &[String],
299) -> Result<Vec<String>, ResearchError> {
300 #[derive(serde::Deserialize)]
301 struct GapMapping {
302 #[allow(dead_code)]
303 gap: String,
304 queries: Vec<String>,
305 }
306
307 let json_str = planner::extract_json(content);
308 let mappings: Vec<GapMapping> = serde_json::from_str(&json_str).map_err(|e| {
309 let preview: String = content.chars().take(200).collect();
310 ResearchError::Llm(format!(
311 "failed to parse gap→query mapping: {} | raw: {}",
312 e, preview
313 ))
314 })?;
315
316 let mut all_queries: Vec<String> = Vec::new();
318 let mut covered_gaps: std::collections::HashSet<usize> = std::collections::HashSet::new();
319
320 for mapping in &mappings {
321 for (i, gap) in original_gaps.iter().enumerate() {
323 if !covered_gaps.contains(&i)
324 && (mapping.gap.contains(gap.as_str()) || gap.contains(mapping.gap.as_str()))
325 {
326 covered_gaps.insert(i);
327 }
328 }
329 all_queries.extend(mapping.queries.iter().filter(|q| !q.is_empty()).cloned());
330 }
331
332 for (i, gap) in original_gaps.iter().enumerate() {
334 if !covered_gaps.contains(&i) {
335 log::warn!(
336 "Deep Research: gap '{}' has no follow-up queries, using gap as query",
337 gap
338 );
339 all_queries.push(gap.clone());
340 }
341 }
342
343 Ok(all_queries)
344}
345
346impl<M: BaseChatModel> std::fmt::Debug for DeepResearchAgent<M> {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 f.debug_struct("DeepResearchAgent")
349 .field("max_rounds", &self.max_rounds)
350 .field("max_subtopics", &self.max_subtopics)
351 .field("searchers_count", &self.searchers.len())
352 .finish_non_exhaustive()
353 }
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use async_trait::async_trait;
360 use futures_util::Stream;
361 use lc_core::language_models::{BaseLanguageModel, LLMResult};
362 use lc_core::runnables::Runnable;
363 use lc_core::runnables::RunnableConfig;
364 use lc_core::tools::ToolError;
365 use std::pin::Pin;
366 use std::sync::{Arc, Mutex};
367
368 #[derive(Debug)]
371 struct MockError(String);
372
373 impl std::fmt::Display for MockError {
374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 write!(f, "MockError: {}", self.0)
376 }
377 }
378
379 impl std::error::Error for MockError {}
380
381 struct SequentialMockLLM {
384 responses: Arc<Mutex<Vec<String>>>,
385 }
386
387 impl SequentialMockLLM {
388 fn new(responses: Vec<String>) -> Self {
389 Self {
390 responses: Arc::new(Mutex::new(responses)),
391 }
392 }
393
394 fn next_response(&self) -> String {
395 let mut guard = self.responses.lock().unwrap();
396 if guard.is_empty() {
397 return r#"["follow-up query"]"#.to_string();
398 }
399 guard.remove(0)
400 }
401 }
402
403 #[async_trait]
404 impl Runnable<Vec<Message>, LLMResult> for SequentialMockLLM {
405 type Error = MockError;
406
407 async fn invoke(
408 &self,
409 _input: Vec<Message>,
410 _config: Option<RunnableConfig>,
411 ) -> Result<LLMResult, Self::Error> {
412 Ok(LLMResult {
413 content: self.next_response(),
414 model: "mock".to_string(),
415 token_usage: None,
416 tool_calls: None,
417 thinking_content: None,
418 })
419 }
420 }
421
422 #[async_trait]
423 impl BaseLanguageModel<Vec<Message>, LLMResult> for SequentialMockLLM {
424 fn model_name(&self) -> &str {
425 "mock"
426 }
427
428 fn get_num_tokens(&self, text: &str) -> usize {
429 text.len() / 4
430 }
431
432 fn temperature(&self) -> Option<f32> {
433 None
434 }
435
436 fn max_tokens(&self) -> Option<usize> {
437 None
438 }
439
440 fn with_temperature(self, _temp: f32) -> Self
441 where
442 Self: Sized,
443 {
444 self
445 }
446
447 fn with_max_tokens(self, _max: usize) -> Self
448 where
449 Self: Sized,
450 {
451 self
452 }
453 }
454
455 #[async_trait]
456 impl BaseChatModel for SequentialMockLLM {
457 async fn chat(
458 &self,
459 _messages: Vec<Message>,
460 _config: Option<RunnableConfig>,
461 ) -> Result<LLMResult, Self::Error> {
462 Ok(LLMResult {
463 content: self.next_response(),
464 model: "mock".to_string(),
465 token_usage: None,
466 tool_calls: None,
467 thinking_content: None,
468 })
469 }
470
471 async fn stream_chat(
472 &self,
473 _messages: Vec<Message>,
474 _config: Option<RunnableConfig>,
475 ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>
476 {
477 let content = self.next_response();
478 let stream = futures_util::stream::once(async move { Ok(content) });
479 Ok(Box::pin(stream))
480 }
481 }
482
483 struct MockSearchTool {
486 results: Vec<SearchResult>,
487 }
488
489 impl MockSearchTool {
490 fn new(results: Vec<SearchResult>) -> Self {
491 Self { results }
492 }
493 }
494
495 #[async_trait]
496 impl BaseTool for MockSearchTool {
497 fn name(&self) -> &str {
498 "mock_search"
499 }
500
501 fn description(&self) -> &str {
502 "A mock search tool for testing"
503 }
504
505 async fn run(&self, _input: String) -> Result<String, ToolError> {
506 let output = serde_json::json!({
507 "results": self.results,
508 });
509 Ok(output.to_string())
510 }
511 }
512
513 fn sample_search_results() -> Vec<SearchResult> {
516 vec![
517 SearchResult {
518 query: "AI healthcare".to_string(),
519 title: "AI in Medicine".to_string(),
520 snippet: "AI is transforming healthcare diagnostics.".to_string(),
521 url: "https://example.com/ai-medicine".to_string(),
522 },
523 SearchResult {
524 query: "AI diagnostics".to_string(),
525 title: "Diagnostic AI Tools".to_string(),
526 snippet: "New AI tools improve diagnostic accuracy.".to_string(),
527 url: "https://example.com/diagnostic-ai".to_string(),
528 },
529 ]
530 }
531
532 #[tokio::test]
533 async fn test_research_single_round_no_gaps() {
534 let plan_json = r#"[
536 {"name": "AI in Diagnostics", "queries": ["AI diagnostics healthcare"]},
537 {"name": "AI in Treatment", "queries": ["AI treatment planning"]}
538 ]"#
539 .to_string();
540
541 let synthesis_json = "{\"report\": \"# AI in Healthcare\\n\\nAI is transforming healthcare [1]. New tools improve diagnostics [2].\", \"gaps\": []}".to_string();
543
544 let llm = SequentialMockLLM::new(vec![plan_json, synthesis_json]);
545 let search_results = sample_search_results();
546 let mock_search = MockSearchTool::new(search_results);
547
548 let agent = DeepResearchAgent::new(llm)
549 .with_searcher(Box::new(mock_search))
550 .with_max_rounds(1)
551 .with_max_subtopics(3);
552
553 let report = agent.research("AI in healthcare").await.unwrap();
554 assert!(!report.markdown.is_empty());
555 assert_eq!(report.rounds_completed, 1);
556 assert_eq!(report.subtopics.len(), 2);
557 assert_eq!(report.citations.len(), 2);
558 assert_eq!(report.citations[0].index, 1);
559 assert_eq!(report.citations[0].source, "AI in Medicine");
560 }
561
562 #[tokio::test]
563 async fn test_research_multi_round_with_gaps() {
564 let plan_json = r#"[
566 {"name": "AI Ethics", "queries": ["AI ethics healthcare"]}
567 ]"#
568 .to_string();
569
570 let synthesis1_json = "{\"report\": \"# AI Ethics in Healthcare\\n\\nSome info [1].\", \"gaps\": [\"Regulatory frameworks for AI in healthcare\"]}".to_string();
572
573 let follow_up_json = r#"[{"gap": "Regulatory frameworks for AI in healthcare", "queries": ["AI healthcare regulation 2024"]}]"#.to_string();
575
576 let synthesis2_json = "{\"report\": \"# AI Ethics in Healthcare\\n\\nSome info [1]. Regulatory frameworks are evolving [2].\", \"gaps\": []}".to_string();
578
579 let llm = SequentialMockLLM::new(vec![
580 plan_json,
581 synthesis1_json,
582 follow_up_json,
583 synthesis2_json,
584 ]);
585
586 let search_results = sample_search_results();
587 let mock_search = MockSearchTool::new(search_results);
588
589 let agent = DeepResearchAgent::new(llm)
590 .with_searcher(Box::new(mock_search))
591 .with_max_rounds(2)
592 .with_max_subtopics(3);
593
594 let report = agent.research("AI ethics in healthcare").await.unwrap();
595 assert!(!report.markdown.is_empty());
596 assert_eq!(report.rounds_completed, 2);
597 }
598
599 #[tokio::test]
600 async fn test_research_no_search_tools() {
601 let llm = SequentialMockLLM::new(vec![]);
602 let agent: DeepResearchAgent<SequentialMockLLM> = DeepResearchAgent::new(llm);
603
604 let result = agent.research("test topic").await;
605 assert!(result.is_err());
606 let err_msg = result.unwrap_err().to_string();
607 assert!(err_msg.contains("no search tools"));
608 }
609
610 #[tokio::test]
611 async fn test_citation_building() {
612 let results = sample_search_results();
613 let llm = SequentialMockLLM::new(vec![]);
614 let agent = DeepResearchAgent::new(llm);
615
616 let citations = agent.build_citations(&results);
617 assert_eq!(citations.len(), 2);
618 assert_eq!(citations[0].index, 1);
619 assert_eq!(citations[0].source, "AI in Medicine");
620 assert_eq!(
621 citations[0].url,
622 Some("https://example.com/ai-medicine".to_string())
623 );
624 assert_eq!(citations[1].index, 2);
625 assert!(citations[1].snippet.contains("diagnostic accuracy"));
626 }
627
628 #[test]
629 fn test_research_error_display() {
630 let err = ResearchError::Llm("timeout".to_string());
631 assert_eq!(format!("{}", err), "LLM error: timeout");
632
633 let err = ResearchError::Search("no tools".to_string());
634 assert_eq!(format!("{}", err), "search error: no tools");
635
636 let err = ResearchError::NoResults;
637 assert_eq!(format!("{}", err), "no results found");
638 }
639
640 #[test]
641 fn test_citation_serialization() {
642 let citation = Citation {
643 index: 1,
644 source: "Test Source".to_string(),
645 url: Some("https://example.com".to_string()),
646 snippet: "A test snippet".to_string(),
647 };
648 let json = serde_json::to_string(&citation).unwrap();
649 let deserialized: Citation = serde_json::from_str(&json).unwrap();
650 assert_eq!(deserialized.index, 1);
651 assert_eq!(deserialized.source, "Test Source");
652 }
653
654 #[test]
655 fn test_research_report_serialization() {
656 let report = ResearchReport {
657 markdown: "# Test Report\n\nContent [1].".to_string(),
658 citations: vec![Citation {
659 index: 1,
660 source: "Source".to_string(),
661 url: None,
662 snippet: "snippet".to_string(),
663 }],
664 subtopics: vec!["Topic A".to_string()],
665 rounds_completed: 1,
666 };
667 let json = serde_json::to_string(&report).unwrap();
668 let deserialized: ResearchReport = serde_json::from_str(&json).unwrap();
669 assert_eq!(deserialized.rounds_completed, 1);
670 assert_eq!(deserialized.subtopics.len(), 1);
671 }
672
673 #[test]
674 fn test_parse_gap_queries_valid() {
675 let content = r#"[{"gap": "Regulatory frameworks", "queries": ["AI regulation 2024", "FDA AI policy"]}, {"gap": "Cost analysis", "queries": ["AI healthcare cost savings"]}]"#;
676 let gaps = vec![
677 "Regulatory frameworks".to_string(),
678 "Cost analysis".to_string(),
679 ];
680 let queries = parse_gap_queries(content, &gaps).unwrap();
681 assert_eq!(queries.len(), 3);
682 assert!(queries.contains(&"AI regulation 2024".to_string()));
683 assert!(queries.contains(&"AI healthcare cost savings".to_string()));
684 }
685
686 #[test]
687 fn test_parse_gap_queries_uncovered_gap_fallback() {
688 let content = r#"[{"gap": "Regulatory frameworks", "queries": ["AI regulation"]}]"#;
689 let gaps = vec![
690 "Regulatory frameworks".to_string(),
691 "Cost analysis".to_string(),
692 ];
693 let queries = parse_gap_queries(content, &gaps).unwrap();
694 assert!(queries.contains(&"Cost analysis".to_string()));
696 assert!(queries.contains(&"AI regulation".to_string()));
697 }
698
699 #[test]
700 fn test_parse_gap_queries_invalid_json() {
701 let content = "not json";
702 let gaps = vec!["Some gap".to_string()];
703 let result = parse_gap_queries(content, &gaps);
704 assert!(result.is_err());
705 }
706
707 #[tokio::test]
710 async fn test_cross_round_citation_numbering() {
711 let plan_json = r#"[
713 {"name": "AI Ethics", "queries": ["AI ethics healthcare"]}
714 ]"#
715 .to_string();
716
717 let synthesis1_json = "{\"report\": \"# AI Ethics\\n\\nEthics matter [1]. Privacy concerns [2].\", \"gaps\": [\"Regulatory frameworks\"]}".to_string();
719
720 let follow_up_json =
722 r#"[{"gap": "Regulatory frameworks", "queries": ["AI regulation 2024"]}]"#.to_string();
723
724 let synthesis2_json = "{\"report\": \"# AI Ethics\\n\\nEthics matter [1]. Privacy concerns [2]. Regulatory frameworks are evolving [3].\", \"gaps\": []}".to_string();
726
727 let llm = SequentialMockLLM::new(vec![
728 plan_json,
729 synthesis1_json,
730 follow_up_json,
731 synthesis2_json,
732 ]);
733
734 let search_results = sample_search_results();
735 let mock_search = MockSearchTool::new(search_results);
736
737 let agent = DeepResearchAgent::new(llm)
738 .with_searcher(Box::new(mock_search))
739 .with_max_rounds(2)
740 .with_max_subtopics(3);
741
742 let report = agent.research("AI ethics in healthcare").await.unwrap();
743
744 assert!(
746 !report.citations.is_empty(),
747 "should have citations from accumulated results"
748 );
749
750 for (i, citation) in report.citations.iter().enumerate() {
752 assert_eq!(
753 citation.index,
754 i + 1,
755 "citation index should be sequential starting at 1"
756 );
757 }
758
759 assert_eq!(
761 report.citations[0].source, "AI in Medicine",
762 "citation [1] should still map to the first source from round 1"
763 );
764 }
765}