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::{AgentContext, AgentResult};
8use car_inference::{GenerateParams, GenerateRequest};
9
10/// Coordination patterns available.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum Pattern {
14    /// Single agent, one shot.
15    Solo,
16    /// Sequential chain: each agent's output feeds the next.
17    Pipeline,
18    /// Parallel: multiple agents on the same problem, pick best.
19    Swarm,
20    /// Iterative: agent does work, supervisor reviews, repeat.
21    Supervisor,
22}
23
24/// Coordinator's decision: which agents in what pattern.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct CoordinationPlan {
27    pub pattern: Pattern,
28    pub agents: Vec<String>,
29    pub reasoning: String,
30}
31
32/// Coordinator configuration.
33#[derive(Debug, Clone)]
34pub struct CoordinatorConfig {
35    pub max_tokens: usize,
36    pub temperature: f64,
37    pub model: Option<String>,
38}
39
40impl Default for CoordinatorConfig {
41    fn default() -> Self {
42        Self {
43            max_tokens: 1024,
44            temperature: 0.2,
45            model: None,
46        }
47    }
48}
49
50/// Coordinator: goal → which agents + which pattern.
51pub struct Coordinator {
52    ctx: AgentContext,
53    config: CoordinatorConfig,
54}
55
56impl Coordinator {
57    pub fn new(ctx: AgentContext) -> Self {
58        Self {
59            ctx,
60            config: CoordinatorConfig::default(),
61        }
62    }
63
64    pub fn with_config(ctx: AgentContext, config: CoordinatorConfig) -> Self {
65        Self { ctx, config }
66    }
67
68    /// Decide how to handle a goal: which agents and what coordination pattern.
69    pub async fn coordinate(&self, goal: &str) -> (CoordinationPlan, AgentResult) {
70        let prompt = format!(
71            "You are a coordination agent. Given a goal, decide which agents to use and how to coordinate them.\n\n\
72            Available agents:\n\
73            - researcher: Searches the codebase and gathers concrete information, files, and evidence.\n\
74            - 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\
75            - 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\
76            - verifier: Checks that the previous agent's output actually addresses the goal.\n\n\
77            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\
78            Available patterns:\n\
79            - solo: One agent handles it alone.\n\
80            - pipeline: Sequential chain (A → B → C). The LAST agent's output is what the user sees.\n\
81            - swarm: Multiple agents in parallel, pick best result.\n\
82            - supervisor: Agent works, supervisor reviews, iterate.\n\n\
83            Default choice for analytical goals: pipeline [researcher, summarizer, verifier].\n\
84            Default choice for change-making goals: pipeline [researcher, planner, verifier].\n\n\
85            Goal: {goal}\n\n\
86            Respond with EXACTLY this JSON format:\n\
87            {{\"pattern\": \"pipeline\", \"agents\": [\"researcher\", \"summarizer\", \"verifier\"], \"reasoning\": \"why this pattern\"}}"
88        );
89
90        let start = std::time::Instant::now();
91        let req = GenerateRequest {
92            prompt,
93            model: self.config.model.clone(),
94            params: GenerateParams {
95                temperature: self.config.temperature,
96                max_tokens: self.config.max_tokens,
97                ..Default::default()
98            },
99            context: None,
100            tools: None,
101            images: None,
102            messages: None,
103            cache_control: false,
104            response_format: None,
105            intent: None,
106        };
107
108        match self.ctx.inference.generate_tracked(req).await {
109            Ok(result) => {
110                // Parse the coordination plan from the response
111                let plan = parse_plan(&result.text).unwrap_or_else(|| {
112                    // Default: pipeline with researcher → summarizer → verifier.
113                    // Summarizer (not planner) because most bare calls are
114                    // analytical — users want an answer, not a checklist.
115                    CoordinationPlan {
116                        pattern: Pattern::Pipeline,
117                        agents: vec!["researcher".into(), "summarizer".into(), "verifier".into()],
118                        reasoning: "default pipeline (failed to parse model response)".into(),
119                    }
120                });
121
122                let agent_result = AgentResult {
123                    agent: "coordinator".into(),
124                    output: result.text,
125                    confidence: if plan.reasoning.contains("default") {
126                        0.5
127                    } else {
128                        0.8
129                    },
130                    model_used: result.model_used,
131                    latency_ms: start.elapsed().as_millis() as u64,
132                };
133
134                (plan, agent_result)
135            }
136            Err(e) => {
137                let plan = CoordinationPlan {
138                    pattern: Pattern::Pipeline,
139                    agents: vec!["researcher".into(), "summarizer".into()],
140                    reasoning: format!("fallback (coordination failed: {})", e),
141                };
142                let agent_result = AgentResult {
143                    agent: "coordinator".into(),
144                    output: format!("Coordination failed: {}", e),
145                    confidence: 0.3,
146                    model_used: String::new(),
147                    latency_ms: start.elapsed().as_millis() as u64,
148                };
149                (plan, agent_result)
150            }
151        }
152    }
153}
154
155/// Parse a CoordinationPlan from LLM text output.
156fn parse_plan(text: &str) -> Option<CoordinationPlan> {
157    // Try to extract JSON from the response
158    let start = text.find('{')?;
159    let end = text.rfind('}')? + 1;
160    let json_str = &text[start..end];
161    serde_json::from_str(json_str).ok()
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn parse_plan_from_json() {
170        let text = r#"Here's the plan: {"pattern": "pipeline", "agents": ["researcher", "verifier"], "reasoning": "research then verify"}"#;
171        let plan = parse_plan(text).unwrap();
172        assert_eq!(plan.pattern, Pattern::Pipeline);
173        assert_eq!(plan.agents, vec!["researcher", "verifier"]);
174    }
175
176    #[test]
177    fn parse_plan_from_clean_json() {
178        let text = r#"{"pattern": "swarm", "agents": ["researcher", "researcher"], "reasoning": "parallel research"}"#;
179        let plan = parse_plan(text).unwrap();
180        assert_eq!(plan.pattern, Pattern::Swarm);
181    }
182
183    #[test]
184    fn parse_plan_fails_gracefully() {
185        assert!(parse_plan("not json").is_none());
186    }
187}