1pub 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
53pub struct DeepResearchAgent<M: BaseChatModel> {
58 llm: M,
59 searchers: Vec<Box<dyn BaseTool>>,
60 max_rounds: usize,
61 max_subtopics: usize,
62 max_source_tokens: Option<usize>,
64}
65
66impl<M: BaseChatModel> DeepResearchAgent<M> {
67 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 pub fn with_searcher(mut self, tool: Box<dyn BaseTool>) -> Self {
85 self.searchers.push(tool);
86 self
87 }
88
89 pub fn with_max_rounds(mut self, n: usize) -> Self {
91 self.max_rounds = n.max(1);
92 self
93 }
94
95 pub fn with_max_subtopics(mut self, n: usize) -> Self {
97 self.max_subtopics = n.max(1);
98 self
99 }
100
101 pub fn with_max_source_tokens(mut self, tokens: usize) -> Self {
105 self.max_source_tokens = Some(tokens);
106 self
107 }
108
109 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 all_results = SearchCollector::dedup(all_results);
141
142 rounds_completed = round + 1;
143
144 let (markdown, gaps) = self
146 .synthesize(topic, ¤t_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 follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
165 }
166
167 let (markdown, _) = self
169 .synthesize(topic, ¤t_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 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 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 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 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, ¤t_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 follow_up_queries = self.generate_follow_ups(topic, &gaps).await?;
283 }
284
285 if final_markdown.is_empty() {
287 let (markdown, _) = self
288 .synthesize(topic, ¤t_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 events.push(AgentStreamEvent::FinalAnswer {
305 content: final_markdown,
306 });
307
308 Ok(Box::pin(futures_util::stream::iter(events)))
309 }
310
311 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}