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
36mod error;
37#[cfg(test)]
38mod tests;
39mod types;
40
41pub use error::ResearchError;
42pub use planner::{ResearchPlan, SubTopic};
43pub use searcher::{SearchCollector, SearchResult};
44pub use synthesizer::SynthesisOutput;
45pub use types::{Citation, ResearchReport};
46
47use lc_core::language_models::BaseChatModel;
48use lc_core::tools::BaseTool;
49use lc_schema::Message;
50
51use types::parse_gap_queries;
52
53/// Multi-round deep research agent.
54///
55/// Decomposes a topic into sub-topics, searches in parallel, synthesizes
56/// a comprehensive report with citations, and iterates if gaps remain.
57pub struct DeepResearchAgent<M: BaseChatModel> {
58    llm: M,
59    searchers: Vec<Box<dyn BaseTool>>,
60    max_rounds: usize,
61    max_subtopics: usize,
62    /// Maximum number of tokens for source text in synthesis prompts.
63    max_source_tokens: Option<usize>,
64}
65
66impl<M: BaseChatModel> DeepResearchAgent<M> {
67    /// Creates a new `DeepResearchAgent` with the given LLM.
68    ///
69    /// At least one search tool must be added via `with_searcher` before
70    /// calling `research`.
71    pub fn new(llm: M) -> Self {
72        Self {
73            llm,
74            searchers: Vec::new(),
75            max_rounds: 2,
76            max_subtopics: 5,
77            max_source_tokens: None,
78        }
79    }
80
81    /// Adds a search tool to the agent.
82    ///
83    /// Multiple search tools can be added; all are queried in parallel.
84    pub fn with_searcher(mut self, tool: Box<dyn BaseTool>) -> Self {
85        self.searchers.push(tool);
86        self
87    }
88
89    /// Sets the maximum number of research rounds (default: 2).
90    pub fn with_max_rounds(mut self, n: usize) -> Self {
91        self.max_rounds = n.max(1);
92        self
93    }
94
95    /// Sets the maximum number of sub-topics to decompose (default: 5).
96    pub fn with_max_subtopics(mut self, n: usize) -> Self {
97        self.max_subtopics = n.max(1);
98        self
99    }
100
101    /// Sets the maximum number of tokens for source text in synthesis prompts.
102    ///
103    /// When set, source snippets are truncated to fit within this budget.
104    pub fn with_max_source_tokens(mut self, tokens: usize) -> Self {
105        self.max_source_tokens = Some(tokens);
106        self
107    }
108
109    /// Runs the full deep research pipeline on the given topic.
110    ///
111    /// Returns a `ResearchReport` containing the markdown report,
112    /// citations, sub-topics, and round count.
113    pub async fn research(&self, topic: &str) -> Result<ResearchReport, ResearchError> {
114        if self.searchers.is_empty() {
115            return Err(ResearchError::Search(
116                "no search tools configured; add at least one with with_searcher()".to_string(),
117            ));
118        }
119
120        let mut all_results: Vec<SearchResult> = Vec::new();
121        let mut rounds_completed: usize = 0;
122        let current_plan = self.plan(topic).await?;
123        let mut follow_up_queries: Vec<String> = Vec::new();
124
125        for round in 0..self.max_rounds {
126            let queries = if round == 0 {
127                current_plan.all_queries()
128            } else {
129                follow_up_queries.clone()
130            };
131
132            if queries.is_empty() {
133                break;
134            }
135
136            let round_results = self.search(&queries).await?;
137            all_results.extend(round_results);
138
139            // Deduplicate after each round
140            all_results = SearchCollector::dedup(all_results);
141
142            rounds_completed = round + 1;
143
144            // Synthesize report from accumulated results
145            let (markdown, gaps) = self
146                .synthesize(topic, &current_plan, &all_results, self.max_source_tokens)
147                .await?;
148
149            if gaps.is_empty() || round + 1 >= self.max_rounds {
150                let citations = self.build_citations(&all_results);
151                return Ok(ResearchReport {
152                    markdown,
153                    citations,
154                    subtopics: current_plan
155                        .subtopics
156                        .iter()
157                        .map(|s| s.name.clone())
158                        .collect(),
159                    rounds_completed,
160                });
161            }
162
163            // Generate follow-up queries for the next round
164            follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
165        }
166
167        // Final synthesis if we exhausted rounds
168        let (markdown, _) = self
169            .synthesize(topic, &current_plan, &all_results, self.max_source_tokens)
170            .await?;
171        let citations = self.build_citations(&all_results);
172        Ok(ResearchReport {
173            markdown,
174            citations,
175            subtopics: current_plan
176                .subtopics
177                .iter()
178                .map(|s| s.name.clone())
179                .collect(),
180            rounds_completed,
181        })
182    }
183
184    /// Streams the deep research execution, emitting pipeline step events.
185    ///
186    /// Emits `AgentStreamEvent::PipelineStep` events for planning, searching,
187    /// and synthesizing, and `AgentStreamEvent::FinalAnswer` when the report is ready.
188    pub async fn stream_research(
189        &self,
190        topic: &str,
191    ) -> Result<
192        std::pin::Pin<
193            Box<dyn futures_util::Stream<Item = crate::streaming::AgentStreamEvent> + Send>,
194        >,
195        ResearchError,
196    > {
197        use crate::streaming::AgentStreamEvent;
198
199        if self.searchers.is_empty() {
200            return Err(ResearchError::Search(
201                "no search tools configured; add at least one with with_searcher()".to_string(),
202            ));
203        }
204
205        let mut events: Vec<AgentStreamEvent> = Vec::new();
206
207        // Step 1: Plan
208        events.push(AgentStreamEvent::PipelineStep {
209            step: "planning".to_string(),
210            detail: Some("Decomposing topic into subtopics...".to_string()),
211        });
212
213        let current_plan = self.plan(topic).await?;
214
215        events.push(AgentStreamEvent::PipelineStep {
216            step: "planned".to_string(),
217            detail: Some(format!(
218                "Subtopics: {}",
219                current_plan
220                    .subtopics
221                    .iter()
222                    .map(|s| s.name.clone())
223                    .collect::<Vec<_>>()
224                    .join(", ")
225            )),
226        });
227
228        // Step 2: Multi-round search
229        let mut all_results: Vec<SearchResult> = Vec::new();
230        let mut rounds_completed: usize = 0;
231        let mut follow_up_queries: Vec<String> = Vec::new();
232        let mut final_markdown = String::new();
233        let mut final_citations: Vec<Citation> = Vec::new();
234
235        for round in 0..self.max_rounds {
236            let queries = if round == 0 {
237                current_plan.all_queries()
238            } else {
239                follow_up_queries.clone()
240            };
241
242            if queries.is_empty() {
243                break;
244            }
245
246            events.push(AgentStreamEvent::PipelineStep {
247                step: "searching".to_string(),
248                detail: Some(format!(
249                    "Round {}: searching {} queries",
250                    round + 1,
251                    queries.len()
252                )),
253            });
254
255            let round_results = self.search(&queries).await?;
256            all_results.extend(round_results);
257            all_results = SearchCollector::dedup(all_results);
258            rounds_completed = round + 1;
259
260            // Synthesize after each round
261            events.push(AgentStreamEvent::PipelineStep {
262                step: "synthesizing".to_string(),
263                detail: Some(format!("Round {} synthesis", round + 1)),
264            });
265
266            let (markdown, gaps) = self
267                .synthesize(topic, &current_plan, &all_results, self.max_source_tokens)
268                .await?;
269
270            if gaps.is_empty() || round + 1 >= self.max_rounds {
271                final_markdown = markdown;
272                final_citations = self.build_citations(&all_results);
273                break;
274            }
275
276            events.push(AgentStreamEvent::PipelineStep {
277                step: "gaps_found".to_string(),
278                detail: Some(format!("{} information gaps identified", gaps.len())),
279            });
280
281            // Generate follow-up queries for the next round
282            follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
283        }
284
285        // Final if we exhausted rounds without a final synthesis
286        if final_markdown.is_empty() {
287            let (markdown, _) = self
288                .synthesize(topic, &current_plan, &all_results, self.max_source_tokens)
289                .await?;
290            final_markdown = markdown;
291            final_citations = self.build_citations(&all_results);
292        }
293
294        events.push(AgentStreamEvent::PipelineStep {
295            step: "completed".to_string(),
296            detail: Some(format!(
297                "Citations: {}, Rounds: {}",
298                final_citations.len(),
299                rounds_completed
300            )),
301        });
302
303        // Final answer
304        events.push(AgentStreamEvent::FinalAnswer {
305            content: final_markdown,
306        });
307
308        Ok(Box::pin(futures_util::stream::iter(events)))
309    }
310
311    // -- Private helpers -------------------------------------------------------
312
313    async fn plan(&self, topic: &str) -> Result<ResearchPlan, ResearchError> {
314        planner::plan(&self.llm, topic, self.max_subtopics).await
315    }
316
317    async fn search(&self, queries: &[String]) -> Result<Vec<SearchResult>, ResearchError> {
318        searcher::search(&self.searchers, queries).await
319    }
320
321    async fn synthesize(
322        &self,
323        topic: &str,
324        plan: &ResearchPlan,
325        results: &[SearchResult],
326        max_source_tokens: Option<usize>,
327    ) -> Result<(String, Vec<String>), ResearchError> {
328        synthesizer::synthesize(&self.llm, topic, plan, results, max_source_tokens).await
329    }
330
331    async fn generate_follow_ups(
332        &self,
333        topic: &str,
334        gaps: &[String],
335    ) -> Result<Vec<String>, ResearchError> {
336        let prompt = format!(
337            "Research topic: {}\n\n\
338             The following information gaps remain after initial research:\n\
339             {}\n\n\
340             For each gap, generate 1-3 specific search queries to fill it. \
341             Output a JSON array of objects, each with \"gap\" and \"queries\" fields. \
342             Every gap listed above must appear as a \"gap\" key in the output.\n\
343             Example: [{{\"gap\": \"gap description\", \"queries\": [\"query1\", \"query2\"]}}]\n\
344             Only output the JSON array, nothing else.",
345            topic,
346            gaps.iter()
347                .enumerate()
348                .map(|(i, g)| format!("{}. {}", i + 1, g))
349                .collect::<Vec<_>>()
350                .join("\n"),
351        );
352        let messages = vec![
353            Message::system("You are a research assistant. Output only valid JSON."),
354            Message::human(prompt),
355        ];
356        let response = crate::retry::retry_chat(
357            &self.llm,
358            messages,
359            None,
360            &crate::retry::RetryConfig::default(),
361        )
362        .await
363        .map_err(|e| ResearchError::Llm(format!("{:?}", e)))?;
364
365        parse_gap_queries(&response.content, gaps)
366    }
367
368    pub(crate) fn build_citations(&self, results: &[SearchResult]) -> Vec<Citation> {
369        results
370            .iter()
371            .enumerate()
372            .map(|(i, r)| Citation {
373                index: i + 1,
374                source: r.title.clone(),
375                url: if r.url.is_empty() {
376                    None
377                } else {
378                    Some(r.url.clone())
379                },
380                snippet: r.snippet.clone(),
381            })
382            .collect()
383    }
384}
385
386impl<M: BaseChatModel> std::fmt::Debug for DeepResearchAgent<M> {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("DeepResearchAgent")
389            .field("max_rounds", &self.max_rounds)
390            .field("max_subtopics", &self.max_subtopics)
391            .field("searchers_count", &self.searchers.len())
392            .finish_non_exhaustive()
393    }
394}