Skip to main content

car_agents/
coordinator.rs

1//! Coordinator agent — decides which agents to invoke and in what pattern.
2//!
3//! Given a goal, the Coordinator classifies it, selects the right agents,
4//! picks a coordination pattern (pipeline, swarm, supervisor), and produces
5//! an execution plan. This is the meta-agent that orchestrates other agents.
6
7use crate::{generate_with, AgentContext, AgentGenerator, AgentResult};
8use car_inference::{GenerateParams, GenerateRequest};
9use std::sync::Arc;
10
11/// Coordination patterns available.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Pattern {
15    /// Single agent, one shot.
16    Solo,
17    /// Sequential chain: each agent's output feeds the next.
18    Pipeline,
19    /// Parallel: multiple agents on the same problem, pick best.
20    Swarm,
21    /// Iterative: agent does work, supervisor reviews, repeat.
22    Supervisor,
23}
24
25/// Coordinator's decision: which agents in what pattern.
26#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
27pub struct CoordinationPlan {
28    pub pattern: Pattern,
29    pub agents: Vec<String>,
30    pub reasoning: String,
31}
32
33/// Coordinator configuration.
34#[derive(Debug, Clone)]
35pub struct CoordinatorConfig {
36    pub max_tokens: usize,
37    pub temperature: f64,
38    pub model: Option<String>,
39}
40
41impl Default for CoordinatorConfig {
42    fn default() -> Self {
43        Self {
44            max_tokens: 1024,
45            temperature: 0.2,
46            model: None,
47        }
48    }
49}
50
51/// Coordinator: goal → which agents + which pattern.
52pub struct Coordinator {
53    ctx: AgentContext,
54    config: CoordinatorConfig,
55    generator: Option<Arc<AgentGenerator>>,
56}
57
58impl Coordinator {
59    pub fn new(ctx: AgentContext) -> Self {
60        Self {
61            ctx,
62            config: CoordinatorConfig::default(),
63            generator: None,
64        }
65    }
66
67    pub fn with_config(ctx: AgentContext, config: CoordinatorConfig) -> Self {
68        Self {
69            ctx,
70            config,
71            generator: None,
72        }
73    }
74
75    /// Construct a coordinator with an injected generation boundary.
76    pub fn with_generator(
77        ctx: AgentContext,
78        config: CoordinatorConfig,
79        generator: Arc<AgentGenerator>,
80    ) -> Self {
81        Self {
82            ctx,
83            config,
84            generator: Some(generator),
85        }
86    }
87
88    /// Decide how to handle a goal: which agents and what coordination pattern.
89    pub async fn coordinate(&self, goal: &str) -> (CoordinationPlan, AgentResult) {
90        let prompt = format!(
91            "You are a coordination agent. Given a goal, decide which agents to use and how to coordinate them.\n\n\
92            Available agents:\n\
93            - researcher: Searches the codebase and gathers concrete information, files, and evidence.\n\
94            - summarizer: Synthesizes the researcher's findings into a direct, polished ANSWER to the user's question. Use this for analytical goals (review, describe, explain, audit, identify, summarize, what/how/why questions).\n\
95            - planner: Breaks a goal into ordered action STEPS the user or another agent will execute. Only use this when the goal is to MAKE A CHANGE (implement, fix, add, refactor, build, migrate) and the user genuinely wants a step-by-step workflow back.\n\
96            - verifier: Checks that the previous agent's output actually addresses the goal.\n\n\
97            CRITICAL: If the user is asking you to describe/review/explain/identify/audit something, the pipeline must END with summarizer, NOT planner. The summarizer produces the final answer text. The planner turns answers into checklists — that is the wrong output shape for analytical goals.\n\n\
98            Available patterns:\n\
99            - solo: One agent handles it alone.\n\
100            - pipeline: Sequential chain (A → B → C). The LAST agent's output is what the user sees.\n\
101            - swarm: Multiple agents in parallel, pick best result.\n\
102            - supervisor: Agent works, supervisor reviews, iterate.\n\n\
103            Default choice for analytical goals: pipeline [researcher, summarizer, verifier].\n\
104            Default choice for change-making goals: pipeline [researcher, planner, verifier].\n\n\
105            Goal: {goal}\n\n\
106            Respond with EXACTLY this JSON format:\n\
107            {{\"pattern\": \"pipeline\", \"agents\": [\"researcher\", \"summarizer\", \"verifier\"], \"reasoning\": \"why this pattern\"}}"
108        );
109
110        let start = std::time::Instant::now();
111        let req = GenerateRequest {
112            prompt,
113            model: self.config.model.clone(),
114            params: GenerateParams {
115                temperature: self.config.temperature,
116                max_tokens: self.config.max_tokens,
117                ..Default::default()
118            },
119            context: None,
120            context_stable_prefix: None,
121            tools: None,
122            images: None,
123            messages: None,
124            cache_control: false,
125            response_format: None,
126            intent: None,
127            client_ref: None,
128            expected_row_digest: None,
129            expected_catalog_revision: None,
130            caller: None,
131        };
132
133        match generate_with(&self.ctx, self.generator.as_ref(), req).await {
134            Ok(result) => {
135                // Parse the coordination plan from the response
136                let plan = parse_plan(&result.text).unwrap_or_else(|| {
137                    // Default: pipeline with researcher → summarizer → verifier.
138                    // Summarizer (not planner) because most bare calls are
139                    // analytical — users want an answer, not a checklist.
140                    CoordinationPlan {
141                        pattern: Pattern::Pipeline,
142                        agents: vec!["researcher".into(), "summarizer".into(), "verifier".into()],
143                        reasoning: "default pipeline (failed to parse model response)".into(),
144                    }
145                });
146
147                let agent_result = AgentResult {
148                    agent: "coordinator".into(),
149                    output: result.text,
150                    confidence: if plan.reasoning.contains("default") {
151                        0.5
152                    } else {
153                        0.8
154                    },
155                    model_used: result.model_used,
156                    latency_ms: start.elapsed().as_millis() as u64,
157                };
158
159                (plan, agent_result)
160            }
161            Err(e) => {
162                let plan = CoordinationPlan {
163                    pattern: Pattern::Pipeline,
164                    agents: vec!["researcher".into(), "summarizer".into()],
165                    reasoning: format!("fallback (coordination failed: {})", e),
166                };
167                let agent_result = AgentResult {
168                    agent: "coordinator".into(),
169                    output: format!("Coordination failed: {}", e),
170                    confidence: 0.3,
171                    model_used: String::new(),
172                    latency_ms: start.elapsed().as_millis() as u64,
173                };
174                (plan, agent_result)
175            }
176        }
177    }
178}
179
180/// Parse a CoordinationPlan from LLM text output.
181fn parse_plan(text: &str) -> Option<CoordinationPlan> {
182    // Try to extract JSON from the response
183    let start = text.find('{')?;
184    let end = text.rfind('}')? + 1;
185    let json_str = &text[start..end];
186    serde_json::from_str(json_str).ok()
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn parse_plan_from_json() {
195        let text = r#"Here's the plan: {"pattern": "pipeline", "agents": ["researcher", "verifier"], "reasoning": "research then verify"}"#;
196        let plan = parse_plan(text).unwrap();
197        assert_eq!(plan.pattern, Pattern::Pipeline);
198        assert_eq!(plan.agents, vec!["researcher", "verifier"]);
199    }
200
201    #[test]
202    fn parse_plan_from_clean_json() {
203        let text = r#"{"pattern": "swarm", "agents": ["researcher", "researcher"], "reasoning": "parallel research"}"#;
204        let plan = parse_plan(text).unwrap();
205        assert_eq!(plan.pattern, Pattern::Swarm);
206    }
207
208    #[test]
209    fn parse_plan_fails_gracefully() {
210        assert!(parse_plan("not json").is_none());
211    }
212}