Skip to main content

car_agents/
researcher.rs

1//! Researcher agent — given a question, gather information and return structured findings.
2//!
3//! Uses the inference engine with memory context to produce grounded answers.
4//! Designed as the first step in most workflows: understand before acting.
5
6use crate::{generate_with, AgentContext, AgentGenerator, AgentResult};
7use car_inference::{GenerateParams, GenerateRequest};
8use std::sync::Arc;
9
10/// Researcher agent configuration.
11#[derive(Debug, Clone)]
12pub struct ResearchConfig {
13    /// Maximum tokens for the research response.
14    pub max_tokens: usize,
15    /// Temperature (lower = more focused, higher = more exploratory).
16    pub temperature: f64,
17    /// Optional model override.
18    pub model: Option<String>,
19}
20
21impl Default for ResearchConfig {
22    fn default() -> Self {
23        Self {
24            max_tokens: 4096,
25            temperature: 0.3,
26            model: None,
27        }
28    }
29}
30
31/// Researcher: search, read, gather → structured findings.
32pub struct Researcher {
33    ctx: AgentContext,
34    config: ResearchConfig,
35    generator: Option<Arc<AgentGenerator>>,
36}
37
38impl Researcher {
39    pub fn new(ctx: AgentContext) -> Self {
40        Self {
41            ctx,
42            config: ResearchConfig::default(),
43            generator: None,
44        }
45    }
46
47    pub fn with_config(ctx: AgentContext, config: ResearchConfig) -> Self {
48        Self {
49            ctx,
50            config,
51            generator: None,
52        }
53    }
54
55    /// Construct a researcher with an injected generation boundary.
56    pub fn with_generator(
57        ctx: AgentContext,
58        config: ResearchConfig,
59        generator: Arc<AgentGenerator>,
60    ) -> Self {
61        Self {
62            ctx,
63            config,
64            generator: Some(generator),
65        }
66    }
67
68    /// Research a question, optionally grounded in memory context.
69    ///
70    /// The context (AST scan of the codebase, file tree, knowledge maps) is
71    /// inlined directly into the prompt so the LLM definitely sees it. The
72    /// prompt enforces specificity: every finding must cite a concrete file
73    /// path or symbol. Broad/vague questions ("review the codebase") are
74    /// expanded into a multi-axis investigation so the synthesizer has real
75    /// material to work with downstream.
76    pub async fn research(&self, question: &str, context: Option<&str>) -> AgentResult {
77        let q_lower = question.trim().to_lowercase();
78        let is_broad_review = q_lower.split_whitespace().count() < 8
79            && (q_lower.starts_with("review")
80                || q_lower.starts_with("analy")
81                || q_lower.contains("codebase")
82                || q_lower == "what is this"
83                || q_lower.starts_with("describe")
84                || q_lower.starts_with("overview"));
85
86        let inline_context = context
87            .map(|c| format!("\n\n## Codebase snapshot\n{}\n", c.trim()))
88            .unwrap_or_default();
89
90        let investigation_axes = if is_broad_review {
91            "\nBecause the question is a broad review of a whole codebase, your research MUST cover ALL of the following axes. Do not skip any.\n\
92             1. **Architecture & components** — what are the main subsystems/services? For each, give the directory path and the one-line purpose.\n\
93             2. **Key entry points** — where does execution start? Name the specific files and functions.\n\
94             3. **Data & integrations** — what external systems does it talk to? (DBs, APIs, auth providers, cloud services). Cite the files that handle them.\n\
95             4. **Risks & gaps** — what looks fragile, under-tested, or deserves attention? Be concrete with file paths.\n\
96             5. **Next actions** — what are the 3 highest-value things a new engineer could do this week?\n"
97        } else {
98            ""
99        };
100
101        let prompt = format!(
102            "You are a codebase research agent. Your job is to answer the user's question with concrete, specific findings grounded in the actual files and symbols of the repository.{inline_context}\n\
103            ## Question\n\
104            {question}\n\
105            {investigation_axes}\n\
106            ## Rules\n\
107            - Every factual claim must cite a specific file path (and a symbol name when applicable). Vague statements like \"the codebase has good structure\" are forbidden — name the files and structures.\n\
108            - Prefer real evidence from the snapshot above over general assumptions about what codebases usually contain.\n\
109            - If the snapshot does not give you enough signal on a point, say so explicitly rather than inventing content.\n\
110            - Use markdown headings and bullets. Keep each bullet information-dense.\n\n\
111            Write your research below:"
112        );
113
114        let start = std::time::Instant::now();
115        let req = GenerateRequest {
116            prompt,
117            model: self.config.model.clone(),
118            params: GenerateParams {
119                temperature: self.config.temperature,
120                max_tokens: self.config.max_tokens,
121                ..Default::default()
122            },
123            context: context.map(String::from),
124            context_stable_prefix: None,
125            tools: None,
126            images: None,
127            messages: None,
128            cache_control: false,
129            response_format: None,
130            intent: None,
131            client_ref: None,
132            expected_row_digest: None,
133            expected_catalog_revision: None,
134            caller: None,
135        };
136
137        match generate_with(&self.ctx, self.generator.as_ref(), req).await {
138            Ok(result) => {
139                // Confidence proxy: research that cites concrete file paths
140                // is much more reliable than vague prose. Count path-shaped
141                // tokens as a specificity signal.
142                let path_hits = result.text.matches('/').count()
143                    + result.text.matches(".rs").count()
144                    + result.text.matches(".ts").count()
145                    + result.text.matches(".tsx").count()
146                    + result.text.matches(".cs").count()
147                    + result.text.matches(".py").count();
148                let confidence = match path_hits {
149                    0 => 0.35,
150                    1..=3 => 0.55,
151                    4..=10 => 0.75,
152                    _ => 0.9,
153                };
154                AgentResult {
155                    agent: "researcher".into(),
156                    output: result.text,
157                    confidence,
158                    model_used: result.model_used,
159                    latency_ms: start.elapsed().as_millis() as u64,
160                }
161            }
162            Err(e) => AgentResult {
163                agent: "researcher".into(),
164                output: format!("Research failed: {}", e),
165                confidence: 0.0,
166                model_used: String::new(),
167                latency_ms: start.elapsed().as_millis() as u64,
168            },
169        }
170    }
171}