car-agents 0.15.0

Built-in commodity agents for Common Agent Runtime
Documentation
//! Researcher agent — given a question, gather information and return structured findings.
//!
//! Uses the inference engine with memory context to produce grounded answers.
//! Designed as the first step in most workflows: understand before acting.

use crate::{AgentContext, AgentResult};
use car_inference::{GenerateParams, GenerateRequest};

/// Researcher agent configuration.
#[derive(Debug, Clone)]
pub struct ResearchConfig {
    /// Maximum tokens for the research response.
    pub max_tokens: usize,
    /// Temperature (lower = more focused, higher = more exploratory).
    pub temperature: f64,
    /// Optional model override.
    pub model: Option<String>,
}

impl Default for ResearchConfig {
    fn default() -> Self {
        Self {
            max_tokens: 4096,
            temperature: 0.3,
            model: None,
        }
    }
}

/// Researcher: search, read, gather → structured findings.
pub struct Researcher {
    ctx: AgentContext,
    config: ResearchConfig,
}

impl Researcher {
    pub fn new(ctx: AgentContext) -> Self {
        Self {
            ctx,
            config: ResearchConfig::default(),
        }
    }

    pub fn with_config(ctx: AgentContext, config: ResearchConfig) -> Self {
        Self { ctx, config }
    }

    /// Research a question, optionally grounded in memory context.
    ///
    /// The context (AST scan of the codebase, file tree, knowledge maps) is
    /// inlined directly into the prompt so the LLM definitely sees it. The
    /// prompt enforces specificity: every finding must cite a concrete file
    /// path or symbol. Broad/vague questions ("review the codebase") are
    /// expanded into a multi-axis investigation so the synthesizer has real
    /// material to work with downstream.
    pub async fn research(&self, question: &str, context: Option<&str>) -> AgentResult {
        let q_lower = question.trim().to_lowercase();
        let is_broad_review = q_lower.split_whitespace().count() < 8
            && (q_lower.starts_with("review")
                || q_lower.starts_with("analy")
                || q_lower.contains("codebase")
                || q_lower == "what is this"
                || q_lower.starts_with("describe")
                || q_lower.starts_with("overview"));

        let inline_context = context
            .map(|c| format!("\n\n## Codebase snapshot\n{}\n", c.trim()))
            .unwrap_or_default();

        let investigation_axes = if is_broad_review {
            "\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\
             1. **Architecture & components** — what are the main subsystems/services? For each, give the directory path and the one-line purpose.\n\
             2. **Key entry points** — where does execution start? Name the specific files and functions.\n\
             3. **Data & integrations** — what external systems does it talk to? (DBs, APIs, auth providers, cloud services). Cite the files that handle them.\n\
             4. **Risks & gaps** — what looks fragile, under-tested, or deserves attention? Be concrete with file paths.\n\
             5. **Next actions** — what are the 3 highest-value things a new engineer could do this week?\n"
        } else {
            ""
        };

        let prompt = format!(
            "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\
            ## Question\n\
            {question}\n\
            {investigation_axes}\n\
            ## Rules\n\
            - 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\
            - Prefer real evidence from the snapshot above over general assumptions about what codebases usually contain.\n\
            - If the snapshot does not give you enough signal on a point, say so explicitly rather than inventing content.\n\
            - Use markdown headings and bullets. Keep each bullet information-dense.\n\n\
            Write your research below:"
        );

        let start = std::time::Instant::now();
        let req = GenerateRequest {
            prompt,
            model: self.config.model.clone(),
            params: GenerateParams {
                temperature: self.config.temperature,
                max_tokens: self.config.max_tokens,
                ..Default::default()
            },
            context: context.map(String::from),
            tools: None,
            images: None,
            messages: None,
            cache_control: false,
            response_format: None,
            intent: None,
        };

        match self.ctx.inference.generate_tracked(req).await {
            Ok(result) => {
                // Confidence proxy: research that cites concrete file paths
                // is much more reliable than vague prose. Count path-shaped
                // tokens as a specificity signal.
                let path_hits = result.text.matches('/').count()
                    + result.text.matches(".rs").count()
                    + result.text.matches(".ts").count()
                    + result.text.matches(".tsx").count()
                    + result.text.matches(".cs").count()
                    + result.text.matches(".py").count();
                let confidence = match path_hits {
                    0 => 0.35,
                    1..=3 => 0.55,
                    4..=10 => 0.75,
                    _ => 0.9,
                };
                AgentResult {
                    agent: "researcher".into(),
                    output: result.text,
                    confidence,
                    model_used: result.model_used,
                    latency_ms: start.elapsed().as_millis() as u64,
                }
            }
            Err(e) => AgentResult {
                agent: "researcher".into(),
                output: format!("Research failed: {}", e),
                confidence: 0.0,
                model_used: String::new(),
                latency_ms: start.elapsed().as_millis() as u64,
            },
        }
    }
}