Skip to main content

rustant_core/
council.rs

1//! LLM Council — Multi-model deliberation for planning tasks.
2//!
3//! Inspired by [karpathy/llm-council](https://github.com/karpathy/llm-council).
4//! Sends a question to multiple LLM providers concurrently, optionally runs
5//! peer review, and synthesizes a final answer via a chairman model.
6//!
7//! # Three-stage deliberation protocol
8//!
9//! 1. **Parallel Query** — All council members receive the question concurrently.
10//! 2. **Peer Review** (optional) — Each model reviews others' responses anonymously.
11//! 3. **Chairman Synthesis** — A designated model synthesizes all responses + reviews.
12
13use crate::brain::LlmProvider;
14use crate::config::{CouncilConfig, CouncilMemberConfig, VotingStrategy};
15use crate::error::LlmError;
16use crate::types::{CompletionRequest, Message, TokenUsage};
17use std::sync::Arc;
18use std::time::Instant;
19use tracing::{debug, info, warn};
20
21/// A detected provider available in the environment.
22#[derive(Debug, Clone)]
23pub struct DetectedProvider {
24    /// Provider type: "openai", "anthropic", "gemini", "ollama".
25    pub provider_type: String,
26    /// Model name.
27    pub model: String,
28    /// Environment variable containing the API key.
29    pub api_key_env: String,
30    /// Whether this is a local provider (no API key needed).
31    pub is_local: bool,
32    /// Optional base URL override.
33    pub base_url: Option<String>,
34}
35
36/// Response from a single council member.
37#[derive(Debug, Clone)]
38pub struct CouncilMemberResponse {
39    /// Model name that produced this response.
40    pub model_name: String,
41    /// Provider type.
42    pub provider: String,
43    /// The response text.
44    pub response_text: String,
45    /// Token usage for this response.
46    pub usage: TokenUsage,
47    /// Estimated cost in USD.
48    pub cost: f64,
49    /// Response latency in milliseconds.
50    pub latency_ms: u64,
51}
52
53/// A peer review from one model reviewing another's response.
54#[derive(Debug, Clone)]
55pub struct PeerReview {
56    /// Model that performed this review.
57    pub reviewer_model: String,
58    /// Index of the response being reviewed (0-based).
59    pub reviewed_index: usize,
60    /// Score from 1-10.
61    pub score: u8,
62    /// Reasoning for the score.
63    pub reasoning: String,
64    /// Identified strengths.
65    pub strengths: Vec<String>,
66    /// Identified weaknesses.
67    pub weaknesses: Vec<String>,
68}
69
70/// Result of a full council deliberation.
71#[derive(Debug, Clone)]
72pub struct CouncilResult {
73    /// The final synthesized answer.
74    pub synthesis: String,
75    /// Individual member responses.
76    pub member_responses: Vec<CouncilMemberResponse>,
77    /// Peer reviews (empty if peer review was disabled).
78    pub peer_reviews: Vec<PeerReview>,
79    /// Total token usage across all stages.
80    pub total_usage: TokenUsage,
81    /// Total estimated cost in USD.
82    pub total_cost: f64,
83    /// Total deliberation time in milliseconds.
84    pub total_latency_ms: u64,
85}
86
87/// The planning council: holds members and orchestrates deliberation.
88pub struct PlanningCouncil {
89    /// Council member providers paired with their configs.
90    members: Vec<(Arc<dyn LlmProvider>, CouncilMemberConfig)>,
91    /// Index of the chairman in the members vec.
92    chairman_index: usize,
93    /// Council configuration.
94    config: CouncilConfig,
95}
96
97impl PlanningCouncil {
98    /// Create a new planning council.
99    ///
100    /// Requires at least 2 members. Returns an error if fewer are provided.
101    /// Auto-selects the chairman as the member with the largest context window,
102    /// unless `config.chairman_model` is explicitly set.
103    pub fn new(
104        members: Vec<(Arc<dyn LlmProvider>, CouncilMemberConfig)>,
105        config: CouncilConfig,
106    ) -> Result<Self, LlmError> {
107        if members.len() < 2 {
108            return Err(LlmError::ApiRequest {
109                message: format!("Council requires at least 2 members, got {}", members.len()),
110            });
111        }
112
113        // Select chairman: explicit config or largest context window.
114        let chairman_index = if let Some(ref chairman_model) = config.chairman_model {
115            members
116                .iter()
117                .position(|(_, cfg)| cfg.model == *chairman_model)
118                .unwrap_or(0)
119        } else {
120            members
121                .iter()
122                .enumerate()
123                .max_by_key(|(_, (provider, _))| provider.context_window())
124                .map(|(i, _)| i)
125                .unwrap_or(0)
126        };
127
128        info!(
129            members = members.len(),
130            chairman = members[chairman_index].1.model.as_str(),
131            strategy = %config.voting_strategy,
132            "Planning council created"
133        );
134
135        Ok(Self {
136            members,
137            chairman_index,
138            config,
139        })
140    }
141
142    /// Run the full three-stage deliberation protocol.
143    pub async fn deliberate(&self, question: &str) -> Result<CouncilResult, LlmError> {
144        let start = Instant::now();
145
146        // Stage 1: Parallel query to all members.
147        info!(
148            members = self.members.len(),
149            "Stage 1: Querying council members"
150        );
151        let member_responses = self.stage_query(question).await?;
152
153        // Stage 2: Optional peer review.
154        let peer_reviews = if self.config.enable_peer_review && self.members.len() > 2 {
155            info!("Stage 2: Running peer review");
156            self.stage_peer_review(question, &member_responses).await
157        } else {
158            debug!("Skipping peer review (disabled or < 3 members)");
159            Vec::new()
160        };
161
162        // Stage 3: Chairman synthesis.
163        info!(
164            chairman = self.members[self.chairman_index].1.model.as_str(),
165            "Stage 3: Chairman synthesis"
166        );
167        let synthesis = self
168            .stage_synthesis(question, &member_responses, &peer_reviews)
169            .await?;
170
171        // Compute totals.
172        let mut total_usage = TokenUsage {
173            input_tokens: 0,
174            output_tokens: 0,
175        };
176        let mut total_cost = 0.0;
177
178        for resp in &member_responses {
179            total_usage.input_tokens += resp.usage.input_tokens;
180            total_usage.output_tokens += resp.usage.output_tokens;
181            total_cost += resp.cost;
182        }
183
184        // Add synthesis usage (estimated from response length).
185        total_usage.output_tokens += synthesis.len() / 4; // rough estimate
186
187        let total_latency_ms = start.elapsed().as_millis() as u64;
188
189        info!(
190            total_cost = format!("${:.4}", total_cost),
191            total_latency_ms,
192            responses = member_responses.len(),
193            reviews = peer_reviews.len(),
194            "Council deliberation complete"
195        );
196
197        Ok(CouncilResult {
198            synthesis,
199            member_responses,
200            peer_reviews,
201            total_usage,
202            total_cost,
203            total_latency_ms,
204        })
205    }
206
207    /// Stage 1: Send the question to all members concurrently.
208    async fn stage_query(&self, question: &str) -> Result<Vec<CouncilMemberResponse>, LlmError> {
209        let futures: Vec<_> = self
210            .members
211            .iter()
212            .map(|(provider, cfg)| {
213                let provider = Arc::clone(provider);
214                let model = cfg.model.clone();
215                let provider_name = cfg.provider.clone();
216                let max_tokens = self.config.max_member_tokens;
217                let question = question.to_string();
218
219                async move {
220                    let start = Instant::now();
221                    let request = CompletionRequest {
222                        messages: vec![
223                            Message::system(
224                                "You are a council member deliberating on a planning question. \
225                                 Provide your best analysis with concrete, actionable recommendations.",
226                            ),
227                            Message::user(&question),
228                        ],
229                        tools: None,
230                        temperature: 0.7,
231                        max_tokens: Some(max_tokens),
232                        stop_sequences: vec![],
233                        model: Some(model.clone()),
234                    };
235
236                    let result = provider.complete(request).await;
237                    let latency_ms = start.elapsed().as_millis() as u64;
238
239                    match result {
240                        Ok(response) => {
241                            let (cost_in, cost_out) = provider.cost_per_token();
242                            let cost = (response.usage.input_tokens as f64 * cost_in)
243                                + (response.usage.output_tokens as f64 * cost_out);
244                            let text = response
245                                .message
246                                .content
247                                .as_text()
248                                .unwrap_or("")
249                                .to_string();
250
251                            Ok(CouncilMemberResponse {
252                                model_name: model,
253                                provider: provider_name,
254                                response_text: text,
255                                usage: response.usage,
256                                cost,
257                                latency_ms,
258                            })
259                        }
260                        Err(e) => {
261                            warn!(
262                                model = model.as_str(),
263                                error = %e,
264                                "Council member failed to respond"
265                            );
266                            Err(e)
267                        }
268                    }
269                }
270            })
271            .collect();
272
273        let results = futures::future::join_all(futures).await;
274
275        // Collect successful responses; warn about failures.
276        let mut responses = Vec::new();
277        for result in results {
278            match result {
279                Ok(resp) => responses.push(resp),
280                Err(e) => {
281                    warn!(error = %e, "Skipping failed council member");
282                }
283            }
284        }
285
286        if responses.is_empty() {
287            return Err(LlmError::ApiRequest {
288                message: "All council members failed to respond".to_string(),
289            });
290        }
291
292        Ok(responses)
293    }
294
295    /// Stage 2: Each member reviews other members' responses anonymously.
296    async fn stage_peer_review(
297        &self,
298        question: &str,
299        responses: &[CouncilMemberResponse],
300    ) -> Vec<PeerReview> {
301        let mut reviews = Vec::new();
302
303        // Build anonymous response labels.
304        let labels: Vec<String> = (0..responses.len())
305            .map(|i| format!("Response {}", (b'A' + i as u8) as char))
306            .collect();
307
308        let mut responses_text = String::new();
309        for (i, resp) in responses.iter().enumerate() {
310            responses_text.push_str(&format!(
311                "\n--- {} ---\n{}\n",
312                labels[i], resp.response_text
313            ));
314        }
315
316        for (reviewer_idx, (provider, cfg)) in self.members.iter().enumerate() {
317            for (reviewed_idx, _) in responses.iter().enumerate() {
318                if reviewer_idx == reviewed_idx {
319                    continue; // Don't review yourself.
320                }
321
322                let prompt = format!(
323                    "You are reviewing responses to this question:\n\n\"{}\"\n\n\
324                     Here are all the responses:\n{}\n\n\
325                     Please review {} specifically.\n\
326                     Rate it 1-10 and provide:\n\
327                     - Score (1-10)\n\
328                     - Brief reasoning\n\
329                     - Key strengths (bullet points)\n\
330                     - Key weaknesses (bullet points)\n\n\
331                     Format your response as:\n\
332                     SCORE: <number>\n\
333                     REASONING: <text>\n\
334                     STRENGTHS:\n- <point>\n\
335                     WEAKNESSES:\n- <point>",
336                    question, responses_text, labels[reviewed_idx]
337                );
338
339                let request = CompletionRequest {
340                    messages: vec![
341                        Message::system(
342                            "You are an impartial reviewer evaluating LLM responses. \
343                             Be objective and constructive.",
344                        ),
345                        Message::user(&prompt),
346                    ],
347                    tools: None,
348                    temperature: 0.3,
349                    max_tokens: Some(512),
350                    stop_sequences: vec![],
351                    model: Some(cfg.model.clone()),
352                };
353
354                match provider.complete(request).await {
355                    Ok(response) => {
356                        let text = response.message.content.as_text().unwrap_or("").to_string();
357                        let review = parse_peer_review(&cfg.model, reviewed_idx, &text);
358                        reviews.push(review);
359                    }
360                    Err(e) => {
361                        warn!(
362                            reviewer = cfg.model.as_str(),
363                            error = %e,
364                            "Peer review failed"
365                        );
366                    }
367                }
368            }
369        }
370
371        reviews
372    }
373
374    /// Stage 3: Chairman synthesizes all responses and reviews into a final answer.
375    async fn stage_synthesis(
376        &self,
377        question: &str,
378        responses: &[CouncilMemberResponse],
379        reviews: &[PeerReview],
380    ) -> Result<String, LlmError> {
381        let (chairman_provider, chairman_cfg) = &self.members[self.chairman_index];
382
383        // Build synthesis prompt.
384        let mut prompt = format!(
385            "You are the chairman of an LLM council. Multiple models have responded \
386             to the following question:\n\n\"{}\"\n\n",
387            question
388        );
389
390        // Add anonymized responses.
391        for (i, resp) in responses.iter().enumerate() {
392            let label = (b'A' + i as u8) as char;
393            prompt.push_str(&format!(
394                "--- Response {} ---\n{}\n\n",
395                label, resp.response_text
396            ));
397        }
398
399        // Add peer reviews if available.
400        if !reviews.is_empty() {
401            prompt.push_str("--- Peer Reviews ---\n");
402            for review in reviews {
403                let reviewed_label = (b'A' + review.reviewed_index as u8) as char;
404                prompt.push_str(&format!(
405                    "Review of Response {} (score: {}/10): {}\n",
406                    reviewed_label, review.score, review.reasoning
407                ));
408            }
409            prompt.push('\n');
410        }
411
412        let strategy_instruction = match self.config.voting_strategy {
413            VotingStrategy::ChairmanSynthesis => {
414                "Synthesize the best elements from all responses into a comprehensive, \
415                 well-structured final answer. Resolve any contradictions and add your own insights."
416            }
417            VotingStrategy::HighestScore => {
418                "Identify the highest-quality response based on peer reviews. \
419                 Present it as the final answer with minimal modifications."
420            }
421            VotingStrategy::MajorityConsensus => {
422                "Identify the points where most responses agree. \
423                 Present the consensus view, noting any significant dissenting perspectives."
424            }
425        };
426
427        prompt.push_str(&format!(
428            "Your task: {}\n\nProvide your final synthesized answer:",
429            strategy_instruction
430        ));
431
432        let request = CompletionRequest {
433            messages: vec![
434                Message::system(
435                    "You are the chairman of an LLM council, responsible for producing \
436                     a final synthesized answer from multiple model responses.",
437                ),
438                Message::user(&prompt),
439            ],
440            tools: None,
441            temperature: 0.5,
442            max_tokens: Some(self.config.max_member_tokens * 2),
443            stop_sequences: vec![],
444            model: Some(chairman_cfg.model.clone()),
445        };
446
447        let response = chairman_provider.complete(request).await?;
448        Ok(response.message.content.as_text().unwrap_or("").to_string())
449    }
450}
451
452/// Parse a peer review response into structured data.
453fn parse_peer_review(reviewer_model: &str, reviewed_index: usize, text: &str) -> PeerReview {
454    let mut score: u8 = 5;
455    let mut reasoning = String::new();
456    let mut strengths = Vec::new();
457    let mut weaknesses = Vec::new();
458
459    let mut in_strengths = false;
460    let mut in_weaknesses = false;
461
462    for line in text.lines() {
463        let trimmed = line.trim();
464
465        if let Some(s) = trimmed.strip_prefix("SCORE:") {
466            if let Ok(n) = s.trim().parse::<u8>() {
467                score = n.clamp(1, 10);
468            }
469            in_strengths = false;
470            in_weaknesses = false;
471        } else if let Some(r) = trimmed.strip_prefix("REASONING:") {
472            reasoning = r.trim().to_string();
473            in_strengths = false;
474            in_weaknesses = false;
475        } else if trimmed == "STRENGTHS:" {
476            in_strengths = true;
477            in_weaknesses = false;
478        } else if trimmed == "WEAKNESSES:" {
479            in_strengths = false;
480            in_weaknesses = true;
481        } else if let Some(item) = trimmed.strip_prefix("- ") {
482            if in_strengths {
483                strengths.push(item.to_string());
484            } else if in_weaknesses {
485                weaknesses.push(item.to_string());
486            }
487        }
488    }
489
490    PeerReview {
491        reviewer_model: reviewer_model.to_string(),
492        reviewed_index,
493        score,
494        reasoning,
495        strengths,
496        weaknesses,
497    }
498}
499
500/// Heuristic to determine if a task should use council deliberation.
501///
502/// Returns `true` for planning/analysis tasks, `false` for concrete/action tasks.
503pub fn should_use_council(task: &str) -> bool {
504    let lower = task.to_lowercase();
505
506    // Planning keywords that suggest deliberation would be valuable.
507    let planning_keywords = [
508        "plan",
509        "design",
510        "architect",
511        "strategy",
512        "approach",
513        "compare",
514        "evaluate",
515        "trade-off",
516        "tradeoff",
517        "pros and cons",
518        "best way to",
519        "how should",
520        "what approach",
521        "recommend",
522        "analyze",
523        "brainstorm",
524        "review my",
525        "help me decide",
526        "which is better",
527    ];
528
529    // Concrete action keywords that don't need council.
530    let concrete_keywords = [
531        "fix",
532        "write",
533        "create file",
534        "delete",
535        "run",
536        "execute",
537        "install",
538        "commit",
539        "push",
540        "deploy",
541        "read file",
542        "open",
543        "close",
544        "set",
545        "update",
546    ];
547
548    // Check for planning keywords.
549    let has_planning = planning_keywords.iter().any(|kw| lower.contains(kw));
550
551    // Check for concrete keywords.
552    let has_concrete = concrete_keywords.iter().any(|kw| lower.contains(kw));
553
554    // Planning tasks that aren't also concrete actions.
555    has_planning && !has_concrete
556}
557
558/// Auto-detect available LLM providers from environment variables and Ollama.
559///
560/// Checks for:
561/// - OPENAI_API_KEY → OpenAI
562/// - ANTHROPIC_API_KEY → Anthropic
563/// - GEMINI_API_KEY or GOOGLE_API_KEY → Gemini
564/// - Ollama at localhost:11434 (with 3-second timeout)
565pub async fn detect_available_providers() -> Vec<DetectedProvider> {
566    let mut providers = Vec::new();
567
568    // Check cloud providers via env vars.
569    if std::env::var("OPENAI_API_KEY").is_ok() {
570        providers.push(DetectedProvider {
571            provider_type: "openai".to_string(),
572            model: "gpt-4o".to_string(),
573            api_key_env: "OPENAI_API_KEY".to_string(),
574            is_local: false,
575            base_url: None,
576        });
577    }
578
579    if std::env::var("ANTHROPIC_API_KEY").is_ok() {
580        providers.push(DetectedProvider {
581            provider_type: "anthropic".to_string(),
582            model: "claude-sonnet-4-20250514".to_string(),
583            api_key_env: "ANTHROPIC_API_KEY".to_string(),
584            is_local: false,
585            base_url: None,
586        });
587    }
588
589    if std::env::var("GEMINI_API_KEY")
590        .or_else(|_| std::env::var("GOOGLE_API_KEY"))
591        .is_ok()
592    {
593        let env_key = if std::env::var("GEMINI_API_KEY").is_ok() {
594            "GEMINI_API_KEY"
595        } else {
596            "GOOGLE_API_KEY"
597        };
598        providers.push(DetectedProvider {
599            provider_type: "gemini".to_string(),
600            model: "gemini-2.0-flash".to_string(),
601            api_key_env: env_key.to_string(),
602            is_local: false,
603            base_url: None,
604        });
605    }
606
607    // Check Ollama.
608    match detect_ollama_models().await {
609        Ok(models) => {
610            for model in models {
611                providers.push(DetectedProvider {
612                    provider_type: "ollama".to_string(),
613                    model,
614                    api_key_env: String::new(),
615                    is_local: true,
616                    base_url: Some("http://127.0.0.1:11434/v1".to_string()),
617                });
618            }
619        }
620        Err(e) => {
621            debug!(error = %e, "Ollama not detected");
622        }
623    }
624
625    providers
626}
627
628/// Detect available Ollama models by querying the Ollama API.
629///
630/// Returns a list of model names. Uses a 3-second timeout.
631pub async fn detect_ollama_models() -> Result<Vec<String>, LlmError> {
632    let client = reqwest::Client::builder()
633        .timeout(std::time::Duration::from_secs(3))
634        .build()
635        .map_err(|e| LlmError::Connection {
636            message: format!("Failed to build HTTP client: {}", e),
637        })?;
638
639    let response = client
640        .get("http://127.0.0.1:11434/api/tags")
641        .send()
642        .await
643        .map_err(|e| LlmError::Connection {
644            message: format!("Ollama not available: {}", e),
645        })?;
646
647    let body: serde_json::Value = response.json().await.map_err(|e| LlmError::ResponseParse {
648        message: format!("Failed to parse Ollama response: {}", e),
649    })?;
650
651    let models = body["models"]
652        .as_array()
653        .map(|arr| {
654            arr.iter()
655                .filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
656                .collect()
657        })
658        .unwrap_or_default();
659
660    Ok(models)
661}
662
663/// Convert detected providers into council member configs.
664pub fn providers_to_council_members(providers: &[DetectedProvider]) -> Vec<CouncilMemberConfig> {
665    providers
666        .iter()
667        .map(|p| CouncilMemberConfig {
668            provider: p.provider_type.clone(),
669            model: p.model.clone(),
670            api_key_env: p.api_key_env.clone(),
671            base_url: p.base_url.clone(),
672            weight: 1.0,
673        })
674        .collect()
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::brain::MockLlmProvider;
681
682    #[test]
683    fn test_should_use_council_planning_tasks() {
684        assert!(should_use_council(
685            "Help me plan the architecture for a new API"
686        ));
687        assert!(should_use_council("What's the best approach for caching?"));
688        assert!(should_use_council(
689            "Compare REST vs GraphQL for this project"
690        ));
691        assert!(should_use_council(
692            "Analyze the trade-offs of microservices"
693        ));
694        assert!(should_use_council("Help me design a database schema"));
695        assert!(should_use_council(
696            "What strategy should I use for testing?"
697        ));
698        assert!(should_use_council("Brainstorm ideas for the landing page"));
699    }
700
701    #[test]
702    fn test_should_use_council_concrete_tasks() {
703        assert!(!should_use_council("Fix the bug in main.rs"));
704        assert!(!should_use_council("Write a function to sort an array"));
705        assert!(!should_use_council("Create file config.toml"));
706        assert!(!should_use_council("Delete the old test file"));
707        assert!(!should_use_council("Run cargo test"));
708        assert!(!should_use_council("Install the reqwest dependency"));
709        assert!(!should_use_council("Read file src/main.rs"));
710    }
711
712    #[test]
713    fn test_should_use_council_mixed() {
714        // "plan" + "write" → concrete wins
715        assert!(!should_use_council("Plan and write the implementation"));
716        // Pure planning
717        assert!(should_use_council(
718            "Help me plan the implementation approach"
719        ));
720    }
721
722    #[test]
723    fn test_council_requires_at_least_two_members() {
724        let provider = Arc::new(MockLlmProvider::new()) as Arc<dyn LlmProvider>;
725        let config = CouncilConfig::default();
726
727        // One member should fail
728        let result = PlanningCouncil::new(
729            vec![(provider.clone(), CouncilMemberConfig::default())],
730            config.clone(),
731        );
732        assert!(result.is_err());
733
734        // Two members should succeed
735        let result = PlanningCouncil::new(
736            vec![
737                (provider.clone(), CouncilMemberConfig::default()),
738                (
739                    provider.clone(),
740                    CouncilMemberConfig {
741                        model: "model-b".to_string(),
742                        ..Default::default()
743                    },
744                ),
745            ],
746            config,
747        );
748        assert!(result.is_ok());
749    }
750
751    #[tokio::test]
752    async fn test_council_deliberation_with_mocks() {
753        let provider_a = Arc::new(MockLlmProvider::with_response("Response from model A"))
754            as Arc<dyn LlmProvider>;
755        let provider_b = Arc::new(MockLlmProvider::with_response("Response from model B"))
756            as Arc<dyn LlmProvider>;
757        let provider_c = Arc::new(MockLlmProvider::with_response("Response from model C"))
758            as Arc<dyn LlmProvider>;
759
760        let config = CouncilConfig {
761            enabled: true,
762            enable_peer_review: false, // Skip peer review for speed
763            ..Default::default()
764        };
765
766        let council = PlanningCouncil::new(
767            vec![
768                (
769                    provider_a,
770                    CouncilMemberConfig {
771                        model: "model-a".to_string(),
772                        ..Default::default()
773                    },
774                ),
775                (
776                    provider_b,
777                    CouncilMemberConfig {
778                        model: "model-b".to_string(),
779                        ..Default::default()
780                    },
781                ),
782                (
783                    provider_c,
784                    CouncilMemberConfig {
785                        model: "model-c".to_string(),
786                        ..Default::default()
787                    },
788                ),
789            ],
790            config,
791        )
792        .unwrap();
793
794        let result = council
795            .deliberate("What architecture should we use?")
796            .await
797            .unwrap();
798
799        // Should have 3 member responses
800        assert_eq!(result.member_responses.len(), 3);
801        // Peer reviews disabled
802        assert!(result.peer_reviews.is_empty());
803        // Synthesis should exist
804        assert!(!result.synthesis.is_empty());
805        // Verify totals are computed (mock providers have 0 cost)
806        let _ = result.total_cost;
807        let _ = result.total_latency_ms;
808    }
809
810    #[tokio::test]
811    async fn test_detect_available_providers_no_panic() {
812        // Should not panic even if no providers are available.
813        let providers = detect_available_providers().await;
814        // We can't assert much here since it depends on the environment,
815        // but it should not panic.
816        let _ = providers;
817    }
818
819    #[test]
820    fn test_parse_peer_review() {
821        let text = "SCORE: 8\n\
822                    REASONING: Good analysis with practical recommendations.\n\
823                    STRENGTHS:\n\
824                    - Clear structure\n\
825                    - Actionable steps\n\
826                    WEAKNESSES:\n\
827                    - Missing error handling consideration\n\
828                    - No cost analysis";
829
830        let review = parse_peer_review("reviewer-model", 0, text);
831        assert_eq!(review.score, 8);
832        assert_eq!(review.reviewed_index, 0);
833        assert!(review.reasoning.contains("Good analysis"));
834        assert_eq!(review.strengths.len(), 2);
835        assert_eq!(review.weaknesses.len(), 2);
836        assert_eq!(review.strengths[0], "Clear structure");
837        assert_eq!(review.weaknesses[0], "Missing error handling consideration");
838    }
839
840    #[test]
841    fn test_parse_peer_review_malformed() {
842        // Should handle malformed input gracefully
843        let review = parse_peer_review("reviewer", 1, "Just some random text without format");
844        assert_eq!(review.score, 5); // Default
845        assert!(review.reasoning.is_empty());
846        assert!(review.strengths.is_empty());
847        assert!(review.weaknesses.is_empty());
848    }
849
850    #[test]
851    fn test_council_peer_review_anonymization() {
852        // Verify that the prompt uses labels (A, B, C), not model names.
853        // This is tested indirectly through the stage_peer_review implementation.
854        // The labels vec should use letters not model names.
855        let labels: Vec<String> = (0..3)
856            .map(|i| format!("Response {}", (b'A' + i as u8) as char))
857            .collect();
858        assert_eq!(labels, vec!["Response A", "Response B", "Response C"]);
859    }
860
861    #[test]
862    fn test_providers_to_council_members() {
863        let providers = vec![
864            DetectedProvider {
865                provider_type: "openai".to_string(),
866                model: "gpt-4o".to_string(),
867                api_key_env: "OPENAI_API_KEY".to_string(),
868                is_local: false,
869                base_url: None,
870            },
871            DetectedProvider {
872                provider_type: "ollama".to_string(),
873                model: "llama3.2".to_string(),
874                api_key_env: String::new(),
875                is_local: true,
876                base_url: Some("http://127.0.0.1:11434/v1".to_string()),
877            },
878        ];
879
880        let members = providers_to_council_members(&providers);
881        assert_eq!(members.len(), 2);
882        assert_eq!(members[0].provider, "openai");
883        assert_eq!(members[0].model, "gpt-4o");
884        assert_eq!(members[1].provider, "ollama");
885        assert_eq!(members[1].model, "llama3.2");
886        assert_eq!(
887            members[1].base_url,
888            Some("http://127.0.0.1:11434/v1".to_string())
889        );
890    }
891
892    #[tokio::test]
893    async fn test_council_with_two_members_no_peer_review() {
894        // Minimum viable council: 2 members, peer review auto-skipped (< 3 members).
895        let provider_a =
896            Arc::new(MockLlmProvider::with_response("Answer A")) as Arc<dyn LlmProvider>;
897        let provider_b =
898            Arc::new(MockLlmProvider::with_response("Answer B")) as Arc<dyn LlmProvider>;
899
900        let config = CouncilConfig {
901            enabled: true,
902            enable_peer_review: true, // Enabled but should be skipped (< 3 members)
903            ..Default::default()
904        };
905
906        let council = PlanningCouncil::new(
907            vec![
908                (
909                    provider_a,
910                    CouncilMemberConfig {
911                        model: "model-a".to_string(),
912                        ..Default::default()
913                    },
914                ),
915                (
916                    provider_b,
917                    CouncilMemberConfig {
918                        model: "model-b".to_string(),
919                        ..Default::default()
920                    },
921                ),
922            ],
923            config,
924        )
925        .unwrap();
926
927        let result = council.deliberate("Compare approaches").await.unwrap();
928
929        assert_eq!(result.member_responses.len(), 2);
930        // Peer review should be empty because < 3 members
931        assert!(result.peer_reviews.is_empty());
932        assert!(!result.synthesis.is_empty());
933    }
934
935    #[tokio::test]
936    async fn test_council_cost_tracking() {
937        let provider_a =
938            Arc::new(MockLlmProvider::with_response("Cost test A")) as Arc<dyn LlmProvider>;
939        let provider_b =
940            Arc::new(MockLlmProvider::with_response("Cost test B")) as Arc<dyn LlmProvider>;
941        let provider_c =
942            Arc::new(MockLlmProvider::with_response("Cost test C")) as Arc<dyn LlmProvider>;
943
944        let config = CouncilConfig {
945            enabled: true,
946            enable_peer_review: false,
947            ..Default::default()
948        };
949
950        let council = PlanningCouncil::new(
951            vec![
952                (
953                    provider_a,
954                    CouncilMemberConfig {
955                        model: "model-a".to_string(),
956                        ..Default::default()
957                    },
958                ),
959                (
960                    provider_b,
961                    CouncilMemberConfig {
962                        model: "model-b".to_string(),
963                        ..Default::default()
964                    },
965                ),
966                (
967                    provider_c,
968                    CouncilMemberConfig {
969                        model: "model-c".to_string(),
970                        ..Default::default()
971                    },
972                ),
973            ],
974            config,
975        )
976        .unwrap();
977
978        let result = council.deliberate("Test cost tracking").await.unwrap();
979
980        // Total cost should be the sum of all member costs
981        let member_cost_sum: f64 = result.member_responses.iter().map(|r| r.cost).sum();
982        assert!((result.total_cost - member_cost_sum).abs() < f64::EPSILON);
983    }
984}