Skip to main content

car_agents/
planner.rs

1//! Planner agent — given a goal, produce an ordered list of action steps.
2//!
3//! Bridges natural language goals to CAR's ActionProposal format.
4//! Uses the inference engine to generate plans, then optionally scores
5//! them via car-planner for validation.
6
7use crate::{generate_with, AgentContext, AgentGenerator, AgentResult};
8use car_inference::intent::IntentHint;
9use car_inference::{GenerateParams, GenerateRequest};
10use std::sync::Arc;
11
12/// Planner agent configuration.
13#[derive(Debug, Clone)]
14pub struct PlanConfig {
15    pub max_tokens: usize,
16    pub temperature: f64,
17    pub model: Option<String>,
18    /// If true, include available tools in the prompt.
19    pub include_tools: bool,
20    /// Route planning quality-first. **ON by default** — a plan is a
21    /// high-leverage blueprint: a cheap-but-wrong plan misdirects everything
22    /// downstream, so the commodity planner uses the best model out of the box
23    /// rather than waiting to be opted in. Unlike the active-planner (which has
24    /// a tool palette to classify against), a commodity `PlannerAgent` has no
25    /// view of what its plan will wield, so the safe default is quality-first.
26    /// Set `false` for known-benign / latency-sensitive planning. Maps to
27    /// `IntentHint.high_stakes` → the Quality routing posture; a no-op when
28    /// `model` pins an explicit model (the router consults intent only on the
29    /// unpinned arm).
30    pub high_stakes: bool,
31}
32
33impl Default for PlanConfig {
34    fn default() -> Self {
35        Self {
36            max_tokens: 2048,
37            temperature: 0.2,
38            model: None,
39            include_tools: true,
40            high_stakes: true,
41        }
42    }
43}
44
45/// Planner: goal → ordered action steps.
46pub struct PlannerAgent {
47    ctx: AgentContext,
48    config: PlanConfig,
49    generator: Option<Arc<AgentGenerator>>,
50}
51
52impl PlannerAgent {
53    pub fn new(ctx: AgentContext) -> Self {
54        Self {
55            ctx,
56            config: PlanConfig::default(),
57            generator: None,
58        }
59    }
60
61    pub fn with_config(ctx: AgentContext, config: PlanConfig) -> Self {
62        Self {
63            ctx,
64            config,
65            generator: None,
66        }
67    }
68
69    /// Construct a planner with an injected generation boundary.
70    pub fn with_generator(
71        ctx: AgentContext,
72        config: PlanConfig,
73        generator: Arc<AgentGenerator>,
74    ) -> Self {
75        Self {
76            ctx,
77            config,
78            generator: Some(generator),
79        }
80    }
81
82    /// Generate a plan for achieving a goal.
83    pub async fn plan(&self, goal: &str, context: Option<&str>) -> AgentResult {
84        let prompt = format!(
85            "You are a planning agent. Break down the following goal into concrete, ordered steps.\n\n\
86            Goal: {goal}\n\n\
87            For each step, specify:\n\
88            1. What to do (action)\n\
89            2. What it depends on (which previous steps must complete)\n\
90            3. What it produces (output/state change)\n\
91            4. How to verify it worked\n\n\
92            Format as a numbered list. Be specific and actionable — no vague steps like 'analyze the situation.'"
93        );
94
95        let start = std::time::Instant::now();
96        let req = GenerateRequest {
97            prompt,
98            model: self.config.model.clone(),
99            params: GenerateParams {
100                temperature: self.config.temperature,
101                max_tokens: self.config.max_tokens,
102                ..Default::default()
103            },
104            context: context.map(String::from),
105            context_stable_prefix: None,
106            tools: None,
107            images: None,
108            messages: None,
109            cache_control: false,
110            response_format: None,
111            // Stakes-aware routing: plan quality-first by default (cost is the
112            // wrong axis when a wrong plan misdirects all downstream work).
113            intent: IntentHint::high_stakes_if(self.config.high_stakes),
114            client_ref: None,
115            expected_row_digest: None,
116            expected_catalog_revision: None,
117            caller: None,
118        };
119
120        match generate_with(&self.ctx, self.generator.as_ref(), req).await {
121            Ok(result) => {
122                // Count steps as a rough quality signal
123                let step_count = result
124                    .text
125                    .lines()
126                    .filter(|l| l.trim_start().starts_with(|c: char| c.is_ascii_digit()))
127                    .count();
128                let confidence = if step_count >= 3 { 0.8 } else { 0.5 };
129                AgentResult {
130                    agent: "planner".into(),
131                    output: result.text,
132                    confidence,
133                    model_used: result.model_used,
134                    latency_ms: start.elapsed().as_millis() as u64,
135                }
136            }
137            Err(e) => AgentResult {
138                agent: "planner".into(),
139                output: format!("Planning failed: {}", e),
140                confidence: 0.0,
141                model_used: String::new(),
142                latency_ms: start.elapsed().as_millis() as u64,
143            },
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn defaults_to_quality_first_out_of_the_box() {
154        // The commodity planner routes quality-first by default — no opt-in.
155        // Planning is leverage; a cheap-but-wrong plan misdirects downstream.
156        assert!(
157            PlanConfig::default().high_stakes,
158            "planner must be high_stakes by default"
159        );
160        // And that default maps to a real high-stakes IntentHint.
161        let intent = IntentHint::high_stakes_if(PlanConfig::default().high_stakes)
162            .expect("default config yields a high-stakes intent");
163        assert!(intent.high_stakes);
164    }
165
166    #[test]
167    fn can_opt_out_for_benign_planning() {
168        let cfg = PlanConfig {
169            high_stakes: false,
170            ..Default::default()
171        };
172        assert!(
173            IntentHint::high_stakes_if(cfg.high_stakes).is_none(),
174            "opting out yields no intent override (routes as before)"
175        );
176    }
177}