1use crate::errors::AppError;
12use crate::output;
13use crate::paths::AppPaths;
14use crate::storage::connection::open_ro;
15use crate::storage::{entities, memories};
16
17use serde::Serialize;
18
19use std::collections::HashSet;
20use std::sync::Arc;
21use tokio::sync::Semaphore;
22use tokio::task::JoinSet;
23
24mod args;
25mod envelope;
26mod pipeline;
27
28pub use args::DeepResearchArgs;
29use envelope::{
30 DeepResearchResponse, DeepResult, GraphContext, GraphContextEntity, GraphContextRel, MergedHit,
31 ResearchStats,
32};
33pub(super) use envelope::{EvidenceChain, EvidenceNode, SubQuery, SubQueryResult};
34use pipeline::{compute_sub_embeddings, execute_sub_query, resolve_sub_queries, RetrievalKnobs};
35
36#[cfg(test)]
37use pipeline::{decompose_query, decompose_query_with_sources};
38
39#[tracing::instrument(skip_all, level = "debug", name = "deep_research")]
41pub fn run(
42 args: DeepResearchArgs,
43 backends: crate::cli::BackendChoice,
44 fail_on_degraded: bool,
45) -> Result<(), AppError> {
46 tracing::debug!(target: "deep_research", query = %args.query, k = args.k, "starting deep research");
47
48 let paths = AppPaths::resolve(args.db.as_deref())?;
56 crate::storage::connection::ensure_db_ready(&paths)?;
57 let sub_query_plan = resolve_sub_queries(&args)?;
59 let sub_query_texts: Vec<String> = sub_query_plan.iter().map(|s| s.text.clone()).collect();
60 let (sub_embeddings, vec_degraded, degraded_reason_code) =
61 compute_sub_embeddings(&paths, &sub_query_texts, backends);
62 if let Some(err) = crate::query_embedding::degradation_failure(
67 fail_on_degraded,
68 vec_degraded,
69 degraded_reason_code,
70 ) {
71 return Err(err);
72 }
73
74 let rt = tokio::runtime::Builder::new_multi_thread()
79 .enable_all()
80 .build()
81 .map_err(|e| AppError::Internal(anyhow::anyhow!("failed to build tokio runtime: {e}")))?;
82 rt.block_on(run_async(
83 args,
84 backends,
85 sub_query_plan,
86 sub_embeddings,
87 vec_degraded,
88 ))
89}
90
91async fn run_async(
99 args: DeepResearchArgs,
100 _backends: crate::cli::BackendChoice,
101 sub_queries: Vec<SubQuery>,
102 sub_embeddings: Vec<Option<Arc<Vec<f32>>>>,
103 vec_degraded: bool,
104) -> Result<(), AppError> {
105 let crate::cli::BackendChoice {
106 llm: _llm_backend,
107 embedding: _embedding_backend,
108 } = _backends;
109 let start = std::time::Instant::now();
110
111 if args.query.trim().is_empty() {
112 return Err(AppError::Validation(crate::i18n::validation::empty_query()));
113 }
114
115 if args.max_cost_usd.is_some() {
116 tracing::warn!(
120 target: "deep_research",
121 "--max-cost-usd is inert: deep-research has no LLM mode, so nothing is billed"
122 );
123 }
124
125 let namespace = crate::namespace::resolve_namespace(args.namespace.as_deref())?;
126 let paths = AppPaths::resolve(args.db.as_deref())?;
127 crate::storage::connection::ensure_db_ready(&paths)?;
128
129 let sub_query_texts: Vec<String> = sub_queries.iter().map(|s| s.text.clone()).collect();
131
132 if vec_degraded {
137 tracing::debug!(target: "deep_research", "vector degraded: at least one sub-query fell back to FTS5");
138 }
139
140 let cpu_count = std::thread::available_parallelism()
156 .map(|n| n.get())
157 .unwrap_or(4);
158 let ram_per_task_mb = crate::constants::llm_worker_rss_mb().max(1);
159 let available_mb = crate::memory_guard::available_memory_mb();
164 let ram_permits = if available_mb == 0 {
165 cpu_count
166 } else {
167 usize::try_from(available_mb / 2 / ram_per_task_mb).unwrap_or(cpu_count)
168 };
169 let permits = args
170 .max_concurrency
171 .unwrap_or_else(|| cpu_count.min(ram_permits).min(8))
172 .min(sub_queries.len())
173 .max(1);
174 let semaphore = Arc::new(Semaphore::new(permits));
175 let timeout_dur = std::time::Duration::from_secs(args.timeout);
176
177 let mut join_set: JoinSet<Result<SubQueryResult, (usize, String)>> = JoinSet::new();
178
179 for (idx, sq_text) in sub_query_texts.iter().enumerate() {
180 let sem = Arc::clone(&semaphore);
181 let emb = sub_embeddings[idx].clone();
183 let ns = namespace.clone();
184 let db_path = paths.db.clone();
185 let query_text = sq_text.clone();
186 let knobs = RetrievalKnobs {
187 k: args.k,
188 max_hops: args.max_hops,
189 min_weight: args.min_weight,
190 rrf_k: args.rrf_k,
191 graph_decay: args.graph_decay,
192 graph_min_score: args.graph_min_score,
193 max_neighbors_per_hop: args.max_neighbors_per_hop,
194 };
195
196 join_set.spawn(async move {
197 let _permit = sem
198 .acquire_owned()
199 .await
200 .map_err(|e| (idx, format!("semaphore closed: {e}")))?;
201
202 let result = tokio::time::timeout(timeout_dur, async move {
204 execute_sub_query(
205 idx,
206 &query_text,
207 emb.as_ref().map(|v| v.as_slice()),
208 &ns,
209 &db_path,
210 knobs,
211 )
212 })
213 .await;
214
215 match result {
216 Ok(inner) => inner.map_err(|e| (idx, e)),
217 Err(_) => Err((idx, "timeout".to_string())),
218 }
219 });
220 }
221
222 let mut sub_query_results: Vec<SubQueryResult> = Vec::with_capacity(sub_queries.len());
224 let mut failed_count = 0usize;
225 let mut timed_out_count = 0usize;
226
227 while let Some(join_result) = join_set.join_next().await {
228 match join_result {
229 Ok(Ok(sqr)) => sub_query_results.push(sqr),
230 Ok(Err((_idx, reason))) => {
231 if reason == "timeout" {
232 timed_out_count += 1;
233 } else {
234 failed_count += 1;
235 }
236 tracing::warn!(target: "deep_research", sub_query_id = _idx, reason = %reason, "sub-query failed");
237 }
238 Err(join_err) => {
239 failed_count += 1;
240 if join_err.is_panic() {
241 tracing::error!(target: "deep_research", error = %join_err, "sub-query task panicked");
242 } else {
243 tracing::warn!(target: "deep_research", error = %join_err, "sub-query task cancelled");
244 }
245 }
246 }
247 }
248
249 let mut merged: crate::hash::AHashMap<i64, MergedHit> =
252 crate::hash::AHashMap::with_capacity_and_hasher(
253 sub_query_results.len() * args.k,
254 Default::default(),
255 );
256
257 for sqr in &sub_query_results {
258 for (mem_id, score, source, snippet, body, hop) in &sqr.hits {
259 let entry = merged.entry(*mem_id).or_insert_with(|| {
260 (
261 *score,
262 source.clone(),
263 snippet.clone(),
264 body.clone(),
265 *hop,
266 Vec::new(),
267 )
268 });
269 if *score > entry.0 {
271 entry.0 = *score;
272 entry.1 = source.clone();
273 entry.2 = snippet.clone();
274 entry.3 = body.clone();
275 entry.4 = *hop;
276 }
277 if !entry.5.contains(&sqr.sub_query_id) {
278 entry.5.push(sqr.sub_query_id);
279 }
280 }
281 }
282
283 let conn = open_ro(&paths.db)?;
285 let mut results: Vec<DeepResult> = Vec::with_capacity(merged.len().min(args.max_results));
286
287 let mut ranked: Vec<(i64, MergedHit)> = merged.into_iter().collect();
289 ranked.sort_by(|a, b| {
290 b.1 .0
291 .partial_cmp(&a.1 .0)
292 .unwrap_or(std::cmp::Ordering::Equal)
293 });
294 crate::agent_surface::universe::record(crate::agent_surface::universe::QueryCeiling {
302 applied: args.max_results,
303 offset: 0,
304 source: crate::agent_surface::universe::CeilingSource::Flag,
305 kind: crate::agent_surface::universe::CeilingKind::TopK,
306 universe_total: None,
307 });
308 ranked.truncate(args.max_results);
309
310 for (mem_id, (score, source, snippet, body, hop, sq_ids)) in ranked {
311 let name = match memories::read_full(&conn, mem_id)? {
312 Some(row) => row.name,
313 None => continue,
314 };
315 results.push(DeepResult {
316 name,
317 score,
318 source,
319 sub_query_ids: sq_ids,
320 snippet,
321 body: if args.with_bodies { Some(body) } else { None },
322 hop_distance: hop,
323 });
324 }
325
326 let completed_count = sub_query_results.len();
330 let mut evidence_chains: Vec<EvidenceChain> = Vec::with_capacity(completed_count * 2);
331 let mut seen_chain_keys: HashSet<String> = HashSet::with_capacity(completed_count * 2);
332
333 for sqr in sub_query_results {
334 for chain in sqr.chains {
335 let key = format!("{}->{}", chain.from, chain.to);
337 if seen_chain_keys.insert(key) {
338 evidence_chains.push(chain);
339 }
340 }
341 }
342
343 evidence_chains.retain(|c| c.depth >= 2);
345 evidence_chains.sort_by(|a, b| {
346 b.total_weight
347 .partial_cmp(&a.total_weight)
348 .unwrap_or(std::cmp::Ordering::Equal)
349 });
350
351 let unique_memories = results.len();
352 let evidence_count = evidence_chains.len();
353
354 let graph_context = if !results.is_empty() {
356 let result_names: Vec<&str> = results.iter().map(|r| r.name.as_str()).collect();
357 let mut ctx_entities: Vec<GraphContextEntity> = Vec::with_capacity(results.len());
358 let mut ctx_rels: Vec<GraphContextRel> = Vec::with_capacity(results.len() * 2);
359 let mut seen_entity_ids: crate::hash::AHashSet<i64> =
360 crate::hash::AHashSet::with_capacity_and_hasher(results.len(), Default::default());
361
362 for name in &result_names {
363 if let Ok(Some(eid)) = entities::find_entity_id(&conn, &namespace, name) {
364 if seen_entity_ids.insert(eid) {
365 let etype: String = conn
366 .query_row(
367 "SELECT COALESCE(type,'concept') FROM entities WHERE id = ?1",
368 rusqlite::params![eid],
369 |r| r.get(0),
370 )
371 .unwrap_or_else(|_| "concept".to_string());
372 let degree: u32 = conn
373 .query_row(
374 "SELECT COUNT(*) FROM relationships WHERE source_id = ?1 OR target_id = ?1",
375 rusqlite::params![eid],
376 |r| r.get(0),
377 )
378 .unwrap_or(0);
379 ctx_entities.push(GraphContextEntity {
380 name: name.to_string(),
381 entity_type: etype,
382 degree,
383 });
384 }
385 }
386 }
387
388 let entity_ids: Vec<i64> = seen_entity_ids.iter().copied().collect();
389 if entity_ids.len() >= 2 {
390 let placeholders: String = entity_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
391 let sql = format!(
392 "SELECT s.name, t.name, r.relation, r.weight \
393 FROM relationships r \
394 JOIN entities s ON s.id = r.source_id \
395 JOIN entities t ON t.id = r.target_id \
396 WHERE r.source_id IN ({placeholders}) AND r.target_id IN ({placeholders}) \
397 LIMIT ?"
398 );
399 if let Ok(mut stmt) = conn.prepare(&sql) {
400 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
401 Vec::with_capacity(entity_ids.len() * 2 + 1);
402 for id in &entity_ids {
403 params.push(Box::new(*id));
404 }
405 for id in &entity_ids {
406 params.push(Box::new(*id));
407 }
408 params.push(Box::new(
409 i64::try_from(crate::constants::K_DEEP_RESEARCH_GRAPH_EDGES_LIMIT)
410 .unwrap_or(i64::MAX),
411 ));
412 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
413 params.iter().map(|p| p.as_ref()).collect();
414 if let Ok(rows) = stmt.query_map(param_refs.as_slice(), |r| {
415 Ok((
416 r.get::<_, String>(0)?,
417 r.get::<_, String>(1)?,
418 r.get::<_, String>(2)?,
419 r.get::<_, f64>(3)?,
420 ))
421 }) {
422 for row in rows.flatten() {
423 ctx_rels.push(GraphContextRel {
424 from: row.0,
425 to: row.1,
426 relation: row.2,
427 weight: row.3,
428 });
429 }
430 }
431 }
432 }
433
434 if ctx_entities.is_empty() {
435 None
436 } else {
437 Some(GraphContext {
438 entities: ctx_entities,
439 relationships: ctx_rels,
440 })
441 }
442 } else {
443 None
444 };
445
446 tracing::debug!(target: "deep_research",
447 total_results = results.len(),
448 total_chains = evidence_chains.len(),
449 "assembly complete"
450 );
451
452 let response = DeepResearchResponse {
454 query: args.query,
455 sub_queries,
456 results,
457 evidence_chains,
458 graph_context,
459 stats: ResearchStats {
460 sub_queries_total: sub_query_texts.len(),
461 sub_queries_completed: completed_count,
462 sub_queries_failed: failed_count,
463 sub_queries_timed_out: timed_out_count,
464 unique_memories_found: unique_memories,
465 evidence_chains_found: evidence_count,
466 elapsed_ms: start.elapsed().as_millis() as u64,
467 vec_degraded,
468 },
469 };
470
471 if let Some(path) = args.output.as_ref() {
472 crate::atomic_io::write_json_atomic(path, &response)?;
478 if !path.exists() {
479 return Err(AppError::Validation(
480 crate::i18n::validation::deep_research_output_missing(&path.display().to_string()),
481 ));
482 }
483 let meta = std::fs::metadata(path).map_err(AppError::Io)?;
484 if meta.len() == 0 {
485 return Err(AppError::Validation(
486 crate::i18n::validation::deep_research_output_empty(&path.display().to_string()),
487 ));
488 }
489 let file_bytes = std::fs::read(path).map_err(AppError::Io)?;
490 let digest = blake3::hash(&file_bytes).to_hex().to_string();
491 #[derive(Serialize)]
492 struct WrittenAck {
493 written: String,
494 bytes: u64,
495 blake3: String,
496 sub_queries_total: usize,
497 unique_memories_found: usize,
498 elapsed_ms: u64,
499 }
500 output::emit_json(&WrittenAck {
501 written: path.display().to_string(),
502 bytes: meta.len(),
503 blake3: digest,
504 sub_queries_total: response.stats.sub_queries_total,
505 unique_memories_found: response.stats.unique_memories_found,
506 elapsed_ms: response.stats.elapsed_ms,
507 })?;
508 } else {
509 output::emit_json(&response)?;
510 }
511
512 Ok(())
513}
514
515#[cfg(test)]
516mod tests;