Skip to main content

car_agents/
summarizer.rs

1//! Summarizer agent — compress context for handoff between agents.
2//!
3//! When Agent A produces a 10K token output and Agent B needs only the key
4//! points, the Summarizer bridges them. Essential for pipeline workflows
5//! where context grows with each step.
6
7use crate::{generate_with, AgentContext, AgentGenerator, AgentResult};
8use car_inference::{GenerateParams, GenerateRequest};
9use std::sync::Arc;
10
11/// Summarizer configuration.
12#[derive(Debug, Clone)]
13pub struct SummaryConfig {
14    /// Target length for summary (in approximate tokens).
15    pub target_tokens: usize,
16    pub temperature: f64,
17    pub model: Option<String>,
18}
19
20impl Default for SummaryConfig {
21    fn default() -> Self {
22        Self {
23            target_tokens: 500,
24            temperature: 0.2,
25            model: None,
26        }
27    }
28}
29
30/// Summarizer: long context → compressed handoff.
31pub struct Summarizer {
32    ctx: AgentContext,
33    config: SummaryConfig,
34    generator: Option<Arc<AgentGenerator>>,
35}
36
37impl Summarizer {
38    pub fn new(ctx: AgentContext) -> Self {
39        Self {
40            ctx,
41            config: SummaryConfig::default(),
42            generator: None,
43        }
44    }
45
46    pub fn with_config(ctx: AgentContext, config: SummaryConfig) -> Self {
47        Self {
48            ctx,
49            config,
50            generator: None,
51        }
52    }
53
54    /// Construct a summarizer with an injected generation boundary.
55    pub fn with_generator(
56        ctx: AgentContext,
57        config: SummaryConfig,
58        generator: Arc<AgentGenerator>,
59    ) -> Self {
60        Self {
61            ctx,
62            config,
63            generator: Some(generator),
64        }
65    }
66
67    /// Summarize content, optionally focused on a specific aspect.
68    pub async fn summarize(&self, content: &str, focus: Option<&str>) -> AgentResult {
69        let focus_instruction = focus
70            .map(|f| format!("\nFocus specifically on: {f}"))
71            .unwrap_or_default();
72
73        let prompt = format!(
74            "Summarize the following content in approximately {} tokens. \
75            Preserve all specific facts, numbers, names, and actionable items. \
76            Drop generic preamble and filler.{focus_instruction}\n\n\
77            Content:\n{content}",
78            self.config.target_tokens,
79        );
80
81        let start = std::time::Instant::now();
82        let req = GenerateRequest {
83            prompt,
84            model: self.config.model.clone(),
85            params: GenerateParams {
86                temperature: self.config.temperature,
87                max_tokens: self.config.target_tokens * 2, // headroom
88                ..Default::default()
89            },
90            context: None,
91            context_stable_prefix: None,
92            tools: None,
93            images: None,
94            messages: None,
95            cache_control: false,
96            response_format: None,
97            intent: None,
98            client_ref: None,
99            expected_row_digest: None,
100            expected_catalog_revision: None,
101            caller: None,
102        };
103
104        match generate_with(&self.ctx, self.generator.as_ref(), req).await {
105            Ok(result) => {
106                let compression = if !content.is_empty() {
107                    1.0 - (result.text.len() as f64 / content.len() as f64)
108                } else {
109                    0.0
110                };
111                AgentResult {
112                    agent: "summarizer".into(),
113                    output: result.text,
114                    confidence: if compression > 0.3 { 0.8 } else { 0.5 },
115                    model_used: result.model_used,
116                    latency_ms: start.elapsed().as_millis() as u64,
117                }
118            }
119            Err(e) => AgentResult {
120                agent: "summarizer".into(),
121                output: format!("Summarization failed: {}", e),
122                confidence: 0.0,
123                model_used: String::new(),
124                latency_ms: start.elapsed().as_millis() as u64,
125            },
126        }
127    }
128
129    /// Synthesize a direct, user-facing answer from upstream research.
130    ///
131    /// Unlike `summarize()` (which compresses content for inter-agent handoff),
132    /// this is for the FINAL step of a pipeline: the output is what the user
133    /// actually sees. We use a different prompt that tells the LLM to write
134    /// an answer — not to condense, not to drop "preamble", and explicitly
135    /// NOT to turn the content into an ordered checklist of steps unless the
136    /// user asked for steps.
137    pub async fn synthesize_answer(&self, research: &str, goal: &str) -> AgentResult {
138        // Detect broad review-shaped goals — these deserve a structured
139        // multi-section answer rather than a one-paragraph summary.
140        let g = goal.trim().to_lowercase();
141        let is_broad_review = g.split_whitespace().count() < 8
142            && (g.starts_with("review")
143                || g.starts_with("analy")
144                || g.contains("codebase")
145                || g.starts_with("describe")
146                || g.starts_with("overview")
147                || g == "what is this");
148
149        let structure_instruction = if is_broad_review {
150            "\nBecause this is a broad review ask, structure your answer with ALL of the following sections. Fill each with concrete specifics (file paths, symbol names, numbers). Do not skip any section.\n\
151             ## Overview\n  — one paragraph: what the project is and what it does, grounded in real components from the research.\n\
152             ## Main Components\n  — the major subsystems/services, each with its directory path and a one-line purpose.\n\
153             ## Key Integrations\n  — external systems (databases, auth, APIs, cloud services) and the files that handle them.\n\
154             ## Top Risks or Gaps\n  — 3–5 concrete things that look fragile, under-tested, or deserve attention. Cite files.\n\
155             ## Recommended Next Actions\n  — 3 high-value things a new engineer could do this week.\n"
156        } else {
157            ""
158        };
159
160        let prompt = format!(
161            "You are writing the FINAL user-facing answer to a question about a codebase. \
162            Another agent has already done the research. Your job is to turn that research \
163            into a clear, direct, genuinely useful answer.\n\n\
164            Rules:\n\
165            1. ANSWER the user's question. Do not outline HOW to answer it. Do NOT return \
166               a list of steps or a workflow unless the user explicitly asked for steps.\n\
167            2. Be specific. Every claim should cite a file path, symbol, or number when \
168               the research supports it. Vague statements (\"well-organized\", \"robust\") \
169               are forbidden unless backed by evidence.\n\
170            3. Use markdown structure (headings, bullets, code spans) to make the answer \
171               scannable.\n\
172            4. If the research is thin on a point, say so — do not invent details.\n\
173            5. Lead with the answer. Minimal preamble.\n{structure_instruction}\n\
174            ## User's question\n{goal}\n\n\
175            ## Research\n{research}\n\n\
176            Now write the final answer:"
177        );
178
179        let start = std::time::Instant::now();
180        let req = GenerateRequest {
181            prompt,
182            model: self.config.model.clone(),
183            params: GenerateParams {
184                temperature: self.config.temperature.max(0.3),
185                // Final answers can be much longer than a handoff summary. Cap
186                // high so the LLM doesn't truncate mid-sentence on rich research.
187                max_tokens: 4096,
188                ..Default::default()
189            },
190            context: None,
191            context_stable_prefix: None,
192            tools: None,
193            images: None,
194            messages: None,
195            cache_control: false,
196            response_format: None,
197            intent: None,
198            client_ref: None,
199            expected_row_digest: None,
200            expected_catalog_revision: None,
201            caller: None,
202        };
203
204        match generate_with(&self.ctx, self.generator.as_ref(), req).await {
205            Ok(result) => AgentResult {
206                agent: "summarizer".into(),
207                output: result.text,
208                confidence: 0.85,
209                model_used: result.model_used,
210                latency_ms: start.elapsed().as_millis() as u64,
211            },
212            Err(e) => AgentResult {
213                agent: "summarizer".into(),
214                output: format!("Synthesis failed: {}", e),
215                confidence: 0.0,
216                model_used: String::new(),
217                latency_ms: start.elapsed().as_millis() as u64,
218            },
219        }
220    }
221}