1use crate::{generate_with, AgentContext, AgentGenerator, AgentResult};
8use car_inference::intent::IntentHint;
9use car_inference::{GenerateParams, GenerateRequest};
10use std::sync::Arc;
11
12#[derive(Debug, Clone)]
14pub struct PlanConfig {
15 pub max_tokens: usize,
16 pub temperature: f64,
17 pub model: Option<String>,
18 pub include_tools: bool,
20 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
45pub 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 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 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 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 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 assert!(
157 PlanConfig::default().high_stakes,
158 "planner must be high_stakes by default"
159 );
160 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}