Skip to main content

lc_agents/deep_research/
mod.rs

1// src/agents/deep_research/mod.rs
2//! Deep Research Agent: multi-round research with sub-topic decomposition,
3//! parallel search, and comprehensive report synthesis with citations.
4//!
5//! # Flow
6//!
7//! 1. **Planner** - LLM decomposes the research topic into sub-topics and
8//!    generates search queries for each.
9//! 2. **Searcher** - Executes searches in parallel across all configured
10//!    search tools, collecting and deduplicating results.
11//! 3. **Synthesizer** - Aggregates findings and uses the LLM to write a
12//!    comprehensive markdown report with inline citations.
13//! 4. **Multi-round** - If information gaps remain after synthesis, the
14//!    agent generates follow-up queries and repeats the search-synthesize
15//!    cycle up to `max_rounds`.
16//!
17//! # Example
18//!
19//! ```ignore
20//! use langchainrust::agents::deep_research::DeepResearchAgent;
21//! use langchainrust::DuckDuckGoSearchTool;
22//!
23//! let agent = DeepResearchAgent::new(llm)
24//!     .with_searcher(Box::new(DuckDuckGoSearchTool::new()))
25//!     .with_max_rounds(3)
26//!     .with_max_subtopics(5);
27//!
28//! let report = agent.research("Impact of AI on healthcare").await?;
29//! println!("{}", report.markdown);
30//! ```
31
32pub 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/// Error types for deep research operations.
45#[derive(Debug, thiserror::Error)]
46pub enum ResearchError {
47    /// LLM invocation error.
48    #[error("LLM error: {0}")]
49    Llm(String),
50
51    /// Search tool execution error.
52    #[error("search error: {0}")]
53    Search(String),
54
55    /// No search results were found for any query.
56    #[error("no results found")]
57    NoResults,
58}
59
60/// A citation referencing a source used in the research report.
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
62pub struct Citation {
63    /// 1-based citation index used in the report body (e.g. \[1\]).
64    pub index: usize,
65    /// Human-readable source title or description.
66    pub source: String,
67    /// Optional URL for the source.
68    pub url: Option<String>,
69    /// Short snippet quoted or paraphrased from the source.
70    pub snippet: String,
71}
72
73/// The final output of a deep research run.
74#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
75pub struct ResearchReport {
76    /// The full report in markdown format with inline citation markers.
77    pub markdown: String,
78    /// Ordered list of citations referenced in the report.
79    pub citations: Vec<Citation>,
80    /// Sub-topics that were investigated.
81    pub subtopics: Vec<String>,
82    /// Number of research rounds completed.
83    pub rounds_completed: usize,
84}
85
86/// Multi-round deep research agent.
87///
88/// Decomposes a topic into sub-topics, searches in parallel, synthesizes
89/// a comprehensive report with citations, and iterates if gaps remain.
90pub struct DeepResearchAgent<M: BaseChatModel> {
91    llm: M,
92    searchers: Vec<Box<dyn BaseTool>>,
93    max_rounds: usize,
94    max_subtopics: usize,
95    /// Maximum number of tokens for source text in synthesis prompts.
96    max_source_tokens: Option<usize>,
97}
98
99impl<M: BaseChatModel> DeepResearchAgent<M> {
100    /// Creates a new `DeepResearchAgent` with the given LLM.
101    ///
102    /// At least one search tool must be added via `with_searcher` before
103    /// calling `research`.
104    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    /// Adds a search tool to the agent.
115    ///
116    /// Multiple search tools can be added; all are queried in parallel.
117    pub fn with_searcher(mut self, tool: Box<dyn BaseTool>) -> Self {
118        self.searchers.push(tool);
119        self
120    }
121
122    /// Sets the maximum number of research rounds (default: 2).
123    pub fn with_max_rounds(mut self, n: usize) -> Self {
124        self.max_rounds = n.max(1);
125        self
126    }
127
128    /// Sets the maximum number of sub-topics to decompose (default: 5).
129    pub fn with_max_subtopics(mut self, n: usize) -> Self {
130        self.max_subtopics = n.max(1);
131        self
132    }
133
134    /// Sets the maximum number of tokens for source text in synthesis prompts.
135    ///
136    /// When set, source snippets are truncated to fit within this budget.
137    pub fn with_max_source_tokens(mut self, tokens: usize) -> Self {
138        self.max_source_tokens = Some(tokens);
139        self
140    }
141
142    /// Runs the full deep research pipeline on the given topic.
143    ///
144    /// Returns a `ResearchReport` containing the markdown report,
145    /// citations, sub-topics, and round count.
146    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            // Deduplicate after each round
173            all_results = SearchCollector::dedup(all_results);
174
175            rounds_completed = round + 1;
176
177            // Synthesize report from accumulated results
178            let (markdown, gaps) = self
179                .synthesize(topic, &current_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            // Generate follow-up queries for the next round
197            follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
198        }
199
200        // Final synthesis if we exhausted rounds
201        let (markdown, _) = self
202            .synthesize(topic, &current_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    // -- Private helpers -------------------------------------------------------
218
219    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
289/// Parses the LLM output for gap→query mapping into a flat list of queries.
290///
291/// Expected format: `[{"gap": "...", "queries": ["q1", "q2"]}, ...]`
292///
293/// Validates that every gap in the input list has at least one corresponding query.
294/// If a gap has no queries in the parsed output, a warning is logged and a
295/// fallback query is generated from the gap text itself.
296fn 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    // Collect all queries, ensuring each original gap is covered.
317    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        // Check if this mapping corresponds to any original gap (fuzzy match by substring)
322        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 any uncovered gap, generate a fallback query from the gap text itself.
333    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    // -- Mock LLM that returns a sequence of responses -------------------------
369
370    #[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    /// Mock LLM that returns responses from a preset list in order.
382    /// Each call to `chat` advances to the next response.
383    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    // -- Mock search tool ------------------------------------------------------
484
485    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    // -- Tests -----------------------------------------------------------------
514
515    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        // Plan response: sub-topics with queries
535        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        // Synthesis response: report with no gaps
542        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        // Round 1: plan
565        let plan_json = r#"[
566            {"name": "AI Ethics", "queries": ["AI ethics healthcare"]}
567        ]"#
568        .to_string();
569
570        // Round 1: synthesis with gaps
571        let synthesis1_json = "{\"report\": \"# AI Ethics in Healthcare\\n\\nSome info [1].\", \"gaps\": [\"Regulatory frameworks for AI in healthcare\"]}".to_string();
572
573        // Follow-up queries (gap→query mapping format)
574        let follow_up_json = r#"[{"gap": "Regulatory frameworks for AI in healthcare", "queries": ["AI healthcare regulation 2024"]}]"#.to_string();
575
576        // Round 2: synthesis with no gaps
577        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        // "Cost analysis" has no mapping, so the gap text itself is used as a query
695        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    /// Verify that cross-round citation numbering is consistent:
708    /// citation[1] from round 1 should still map to the same source after round 2.
709    #[tokio::test]
710    async fn test_cross_round_citation_numbering() {
711        // Plan response
712        let plan_json = r#"[
713            {"name": "AI Ethics", "queries": ["AI ethics healthcare"]}
714        ]"#
715        .to_string();
716
717        // Round 1 synthesis with 2 sources
718        let synthesis1_json = "{\"report\": \"# AI Ethics\\n\\nEthics matter [1]. Privacy concerns [2].\", \"gaps\": [\"Regulatory frameworks\"]}".to_string();
719
720        // Follow-up queries
721        let follow_up_json =
722            r#"[{"gap": "Regulatory frameworks", "queries": ["AI regulation 2024"]}]"#.to_string();
723
724        // Round 2 synthesis — should still reference [1] and [2] from round 1
725        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        // Verify citations are built from accumulated results across rounds
745        assert!(
746            !report.citations.is_empty(),
747            "should have citations from accumulated results"
748        );
749
750        // Verify citation numbering is 1-based and sequential
751        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        // Verify that the first citation still maps to the original source
760        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}