1use std::collections::BTreeMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::agent::{Agent, AgentFactory};
11use crate::consensus::{ConsensusConfig, ConsensusEngine};
12use crate::error::{MagiError, ProviderError};
13use crate::provider::{CompletionConfig, LlmProvider};
14use crate::reporting::{MagiReport, ReportConfig, ReportFormatter};
15use crate::schema::{AgentName, AgentOutput, Mode};
16use crate::validate::{ValidationLimits, Validator};
17use tokio::task::AbortHandle;
18
19#[non_exhaustive]
23#[derive(Debug, Clone)]
24pub struct MagiConfig {
25 pub timeout: Duration,
27 pub max_input_len: usize,
29 pub completion: CompletionConfig,
31}
32
33impl Default for MagiConfig {
34 fn default() -> Self {
35 Self {
36 timeout: Duration::from_secs(300),
37 max_input_len: 1_048_576,
38 completion: CompletionConfig::default(),
39 }
40 }
41}
42
43pub struct MagiBuilder {
62 default_provider: Arc<dyn LlmProvider>,
63 agent_providers: BTreeMap<AgentName, Arc<dyn LlmProvider>>,
64 custom_prompts: BTreeMap<AgentName, String>,
65 prompts_dir: Option<PathBuf>,
66 config: MagiConfig,
67 validation_limits: ValidationLimits,
68 consensus_config: ConsensusConfig,
69 report_config: ReportConfig,
70}
71
72impl MagiBuilder {
73 pub fn new(default_provider: Arc<dyn LlmProvider>) -> Self {
78 Self {
79 default_provider,
80 agent_providers: BTreeMap::new(),
81 custom_prompts: BTreeMap::new(),
82 prompts_dir: None,
83 config: MagiConfig::default(),
84 validation_limits: ValidationLimits::default(),
85 consensus_config: ConsensusConfig::default(),
86 report_config: ReportConfig::default(),
87 }
88 }
89
90 pub fn with_provider(mut self, name: AgentName, provider: Arc<dyn LlmProvider>) -> Self {
96 self.agent_providers.insert(name, provider);
97 self
98 }
99
100 pub fn with_custom_prompt(mut self, name: AgentName, prompt: String) -> Self {
106 self.custom_prompts.insert(name, prompt);
107 self
108 }
109
110 pub fn with_prompts_dir(mut self, dir: PathBuf) -> Self {
115 self.prompts_dir = Some(dir);
116 self
117 }
118
119 pub fn with_timeout(mut self, timeout: Duration) -> Self {
124 self.config.timeout = timeout;
125 self
126 }
127
128 pub fn with_max_input_len(mut self, max: usize) -> Self {
133 self.config.max_input_len = max;
134 self
135 }
136
137 pub fn with_completion_config(mut self, config: CompletionConfig) -> Self {
142 self.config.completion = config;
143 self
144 }
145
146 pub fn with_validation_limits(mut self, limits: ValidationLimits) -> Self {
151 self.validation_limits = limits;
152 self
153 }
154
155 pub fn with_consensus_config(mut self, config: ConsensusConfig) -> Self {
160 self.consensus_config = config;
161 self
162 }
163
164 pub fn with_report_config(mut self, config: ReportConfig) -> Self {
169 self.report_config = config;
170 self
171 }
172
173 pub fn build(self) -> Result<Magi, MagiError> {
180 let mut factory = AgentFactory::new(self.default_provider);
181 for (name, provider) in self.agent_providers {
182 factory = factory.with_provider(name, provider);
183 }
184 for (name, prompt) in self.custom_prompts {
185 factory = factory.with_custom_prompt(name, prompt);
186 }
187 if let Some(dir) = self.prompts_dir {
188 factory = factory.from_directory(&dir)?;
189 }
190
191 Ok(Magi {
192 config: self.config,
193 agent_factory: factory,
194 validator: Validator::with_limits(self.validation_limits),
195 consensus_engine: ConsensusEngine::new(self.consensus_config),
196 formatter: ReportFormatter::with_config(self.report_config),
197 })
198 }
199}
200
201struct AbortGuard(Vec<AbortHandle>);
207
208impl Drop for AbortGuard {
209 fn drop(&mut self) {
210 for handle in &self.0 {
211 handle.abort();
212 }
213 }
214}
215
216pub struct Magi {
233 config: MagiConfig,
234 agent_factory: AgentFactory,
235 validator: Validator,
236 consensus_engine: ConsensusEngine,
237 formatter: ReportFormatter,
238}
239
240impl Magi {
241 pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
249 MagiBuilder::new(provider).build().expect(
251 "Magi::new uses all defaults and cannot fail; \
252 this is an internal invariant violation",
253 )
254 }
255
256 pub fn builder(provider: Arc<dyn LlmProvider>) -> MagiBuilder {
261 MagiBuilder::new(provider)
262 }
263
264 pub async fn analyze(&self, mode: &Mode, content: &str) -> Result<MagiReport, MagiError> {
277 if content.len() > self.config.max_input_len {
279 return Err(MagiError::InputTooLarge {
280 size: content.len(),
281 max: self.config.max_input_len,
282 });
283 }
284
285 let agents = self.agent_factory.create_agents(*mode);
287
288 let prompt = build_prompt(mode, content);
290
291 let agent_results = self.launch_agents(agents, &prompt).await;
293
294 let (successful, failed_agents) = self.process_results(agent_results)?;
296
297 let consensus = self.consensus_engine.determine(&successful)?;
299
300 let banner = self.formatter.format_banner(&successful, &consensus);
302 let report = self.formatter.format_report(&successful, &consensus);
303
304 let degraded = successful.len() < 3;
306 Ok(MagiReport {
307 agents: successful,
308 consensus,
309 banner,
310 report,
311 degraded,
312 failed_agents,
313 })
314 }
315
316 async fn launch_agents(
327 &self,
328 agents: Vec<Agent>,
329 prompt: &str,
330 ) -> Vec<(AgentName, Result<String, MagiError>)> {
331 let timeout = self.config.timeout;
332 let completion = self.config.completion.clone();
333 let mut handles = Vec::new();
334 let mut abort_handles = Vec::new();
335
336 for agent in agents {
337 let name = agent.name();
338 let user_prompt = prompt.to_string();
339 let config = completion.clone();
340
341 let handle = tokio::spawn(async move {
342 let result =
343 tokio::time::timeout(timeout, agent.execute(&user_prompt, &config)).await;
344 match result {
345 Ok(Ok(response)) => Ok(response),
346 Ok(Err(provider_err)) => Err(MagiError::Provider(provider_err)),
347 Err(_elapsed) => Err(MagiError::Provider(ProviderError::Timeout {
348 message: format!("agent timed out after {timeout:?}"),
349 })),
350 }
351 });
352 abort_handles.push(handle.abort_handle());
353 handles.push((name, handle));
354 }
355
356 let _guard = AbortGuard(abort_handles);
359
360 let mut results = Vec::new();
361 for (name, handle) in handles {
362 match handle.await {
363 Ok(result) => results.push((name, result)),
364 Err(join_err) => results.push((
365 name,
366 Err(MagiError::Provider(ProviderError::Process {
367 exit_code: None,
368 stderr: format!("agent task panicked: {join_err}"),
369 })),
370 )),
371 }
372 }
373
374 results
375 }
376
377 fn process_results(
383 &self,
384 results: Vec<(AgentName, Result<String, MagiError>)>,
385 ) -> Result<(Vec<AgentOutput>, BTreeMap<AgentName, String>), MagiError> {
386 let mut successful = Vec::new();
387 let mut failed_agents = BTreeMap::new();
388
389 for (name, result) in results {
390 match result {
391 Ok(raw) => match parse_agent_response(&raw) {
392 Ok(output) => match self.validator.validate(&output) {
393 Ok(()) => successful.push(output),
394 Err(e) => {
395 failed_agents.insert(name, format!("validation: {e}"));
396 }
397 },
398 Err(e) => {
399 failed_agents.insert(name, format!("parse: {e}"));
400 }
401 },
402 Err(e) => {
403 failed_agents.insert(name, e.to_string());
404 }
405 }
406 }
407
408 let min_agents = self.consensus_engine.min_agents();
409 if successful.len() < min_agents {
410 return Err(MagiError::InsufficientAgents {
411 succeeded: successful.len(),
412 required: min_agents,
413 });
414 }
415
416 Ok((successful, failed_agents))
417 }
418}
419
420fn build_prompt(mode: &Mode, content: &str) -> String {
426 format!("MODE: {mode}\nCONTEXT:\n{content}")
427}
428
429fn parse_agent_response(raw: &str) -> Result<AgentOutput, MagiError> {
442 let trimmed = raw.trim();
443
444 let stripped = if trimmed.starts_with("```") {
446 let without_opening = if let Some(rest) = trimmed.strip_prefix("```json") {
447 rest
448 } else {
449 trimmed.strip_prefix("```").unwrap_or(trimmed)
450 };
451 without_opening
452 .strip_suffix("```")
453 .unwrap_or(without_opening)
454 .trim()
455 } else {
456 trimmed
457 };
458
459 if let Ok(output) = serde_json::from_str::<AgentOutput>(stripped) {
462 return Ok(output);
463 }
464
465 for (start, _) in stripped.match_indices('{') {
470 let candidate = &stripped[start..];
471 if let Ok(output) = serde_json::from_str::<AgentOutput>(candidate) {
472 return Ok(output);
473 }
474 }
475
476 Err(MagiError::Deserialization(
477 "no valid JSON object found in agent response".to_string(),
478 ))
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::schema::*;
485 use std::sync::Arc;
486 use std::sync::atomic::{AtomicUsize, Ordering};
487 use std::time::Duration;
488
489 fn mock_agent_json(agent: &str, verdict: &str, confidence: f64) -> String {
491 format!(
492 r#"{{
493 "agent": "{agent}",
494 "verdict": "{verdict}",
495 "confidence": {confidence},
496 "summary": "Summary from {agent}",
497 "reasoning": "Reasoning from {agent}",
498 "findings": [],
499 "recommendation": "Recommendation from {agent}"
500 }}"#
501 )
502 }
503
504 struct MockProvider {
508 name: String,
509 model: String,
510 responses: Vec<Result<String, ProviderError>>,
511 call_count: AtomicUsize,
512 }
513
514 impl MockProvider {
515 fn success(name: &str, model: &str, responses: Vec<String>) -> Self {
516 Self {
517 name: name.to_string(),
518 model: model.to_string(),
519 responses: responses.into_iter().map(Ok).collect(),
520 call_count: AtomicUsize::new(0),
521 }
522 }
523
524 fn mixed(name: &str, model: &str, responses: Vec<Result<String, ProviderError>>) -> Self {
525 Self {
526 name: name.to_string(),
527 model: model.to_string(),
528 responses,
529 call_count: AtomicUsize::new(0),
530 }
531 }
532
533 fn calls(&self) -> usize {
534 self.call_count.load(Ordering::SeqCst)
535 }
536 }
537
538 #[async_trait::async_trait]
539 impl LlmProvider for MockProvider {
540 async fn complete(
541 &self,
542 _system_prompt: &str,
543 _user_prompt: &str,
544 _config: &CompletionConfig,
545 ) -> Result<String, ProviderError> {
546 let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
547 let idx = idx % self.responses.len();
548 self.responses[idx].clone()
549 }
550
551 fn name(&self) -> &str {
552 &self.name
553 }
554
555 fn model(&self) -> &str {
556 &self.model
557 }
558 }
559
560 #[tokio::test]
564 async fn test_analyze_unanimous_approve_returns_complete_report() {
565 let responses = vec![
566 mock_agent_json("melchior", "approve", 0.9),
567 mock_agent_json("balthasar", "approve", 0.85),
568 mock_agent_json("caspar", "approve", 0.95),
569 ];
570 let provider = Arc::new(MockProvider::success("mock", "test-model", responses));
571 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
572
573 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
574 let report = result.expect("analyze should succeed");
575
576 assert_eq!(report.agents.len(), 3);
577 assert!(!report.degraded);
578 assert!(report.failed_agents.is_empty());
579 assert_eq!(report.consensus.consensus_verdict, Verdict::Approve);
580 assert!(!report.banner.is_empty());
581 assert!(!report.report.is_empty());
582 }
583
584 #[tokio::test]
588 async fn test_analyze_one_agent_timeout_degrades_gracefully() {
589 let responses = vec![
590 Ok(mock_agent_json("melchior", "approve", 0.9)),
591 Ok(mock_agent_json("balthasar", "approve", 0.85)),
592 Err(ProviderError::Timeout {
593 message: "exceeded timeout".to_string(),
594 }),
595 ];
596 let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
597 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
598
599 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
600 let report = result.expect("analyze should succeed with degradation");
601
602 assert!(report.degraded);
603 assert_eq!(report.failed_agents.len(), 1);
604 assert_eq!(report.agents.len(), 2);
605 }
606
607 #[tokio::test]
611 async fn test_analyze_one_agent_bad_json_degrades_gracefully() {
612 let responses = vec![
613 Ok(mock_agent_json("melchior", "approve", 0.9)),
614 Ok(mock_agent_json("balthasar", "approve", 0.85)),
615 Ok("not valid json at all".to_string()),
616 ];
617 let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
618 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
619
620 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
621 let report = result.expect("analyze should succeed with degradation");
622
623 assert!(report.degraded);
624 }
625
626 #[tokio::test]
630 async fn test_analyze_two_agents_fail_returns_insufficient_agents() {
631 let responses = vec![
632 Ok(mock_agent_json("melchior", "approve", 0.9)),
633 Err(ProviderError::Timeout {
634 message: "timeout".to_string(),
635 }),
636 Err(ProviderError::Network {
637 message: "connection refused".to_string(),
638 }),
639 ];
640 let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
641 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
642
643 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
644
645 match result {
646 Err(MagiError::InsufficientAgents {
647 succeeded,
648 required,
649 }) => {
650 assert_eq!(succeeded, 1);
651 assert_eq!(required, 2);
652 }
653 other => panic!("Expected InsufficientAgents, got: {other:?}"),
654 }
655 }
656
657 #[tokio::test]
661 async fn test_analyze_all_agents_fail_returns_insufficient_agents() {
662 let responses = vec![
663 Err(ProviderError::Timeout {
664 message: "timeout".to_string(),
665 }),
666 Err(ProviderError::Network {
667 message: "network".to_string(),
668 }),
669 Err(ProviderError::Auth {
670 message: "auth".to_string(),
671 }),
672 ];
673 let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
674 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
675
676 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
677
678 match result {
679 Err(MagiError::InsufficientAgents {
680 succeeded,
681 required,
682 }) => {
683 assert_eq!(succeeded, 0);
684 assert_eq!(required, 2);
685 }
686 other => panic!("Expected InsufficientAgents, got: {other:?}"),
687 }
688 }
689
690 #[tokio::test]
694 async fn test_analyze_plain_text_response_treated_as_failure() {
695 let responses = vec![
696 Ok(mock_agent_json("melchior", "approve", 0.9)),
697 Ok(mock_agent_json("balthasar", "approve", 0.85)),
698 Ok("I think the code is good".to_string()),
699 ];
700 let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
701 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
702
703 let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
704 let report = result.expect("should succeed with degradation");
705
706 assert!(report.degraded);
707 assert_eq!(report.agents.len(), 2);
708 }
709
710 #[tokio::test]
714 async fn test_magi_new_creates_with_defaults() {
715 let responses = vec![
716 mock_agent_json("melchior", "approve", 0.9),
717 mock_agent_json("balthasar", "approve", 0.85),
718 mock_agent_json("caspar", "approve", 0.95),
719 ];
720 let provider = Arc::new(MockProvider::success(
721 "test-provider",
722 "test-model",
723 responses,
724 ));
725 let magi = Magi::new(provider as Arc<dyn LlmProvider>);
726
727 let result = magi.analyze(&Mode::CodeReview, "test content").await;
728 let report = result.expect("should succeed");
729
730 assert_eq!(report.agents.len(), 3);
732 }
733
734 #[tokio::test]
738 async fn test_builder_with_mixed_providers_and_custom_config() {
739 let default_responses = vec![
740 mock_agent_json("melchior", "approve", 0.9),
741 mock_agent_json("balthasar", "approve", 0.85),
742 ];
743 let caspar_responses = vec![mock_agent_json("caspar", "reject", 0.8)];
744
745 let default_provider = Arc::new(MockProvider::success(
746 "default-provider",
747 "model-a",
748 default_responses,
749 ));
750 let caspar_provider = Arc::new(MockProvider::success(
751 "caspar-provider",
752 "model-b",
753 caspar_responses,
754 ));
755
756 let magi = MagiBuilder::new(default_provider.clone() as Arc<dyn LlmProvider>)
757 .with_provider(
758 AgentName::Caspar,
759 caspar_provider.clone() as Arc<dyn LlmProvider>,
760 )
761 .with_timeout(Duration::from_secs(60))
762 .build()
763 .expect("build should succeed");
764
765 let result = magi.analyze(&Mode::CodeReview, "test content").await;
766 let report = result.expect("should succeed");
767
768 assert_eq!(report.agents.len(), 3);
769 assert!(caspar_provider.calls() > 0);
771 }
772
773 #[tokio::test]
777 async fn test_analyze_input_too_large_rejects_without_launching_agents() {
778 let responses = vec![mock_agent_json("melchior", "approve", 0.9)];
779 let provider = Arc::new(MockProvider::success("mock", "test-model", responses));
780
781 let magi = MagiBuilder::new(provider.clone() as Arc<dyn LlmProvider>)
782 .with_max_input_len(100)
783 .build()
784 .expect("build should succeed");
785
786 let content = "x".repeat(200);
787 let result = magi.analyze(&Mode::CodeReview, &content).await;
788
789 match result {
790 Err(MagiError::InputTooLarge { size, max }) => {
791 assert_eq!(size, 200);
792 assert_eq!(max, 100);
793 }
794 other => panic!("Expected InputTooLarge, got: {other:?}"),
795 }
796
797 assert_eq!(provider.calls(), 0, "No agents should have been launched");
799 }
800
801 #[test]
805 fn test_magi_config_default_values() {
806 let config = MagiConfig::default();
807 assert_eq!(config.timeout, Duration::from_secs(300));
808 assert_eq!(config.max_input_len, 1_048_576);
809 }
810
811 #[test]
815 fn test_build_prompt_formats_mode_and_content() {
816 let result = build_prompt(&Mode::CodeReview, "fn main() {}");
817 assert_eq!(result, "MODE: code-review\nCONTEXT:\nfn main() {}");
818 }
819
820 #[test]
824 fn test_parse_agent_response_strips_code_fences() {
825 let json = mock_agent_json("melchior", "approve", 0.9);
826 let raw = format!("```json\n{json}\n```");
827
828 let result = parse_agent_response(&raw);
829 let output = result.expect("should parse successfully");
830 assert_eq!(output.agent, AgentName::Melchior);
831 assert_eq!(output.verdict, Verdict::Approve);
832 }
833
834 #[test]
836 fn test_parse_agent_response_extracts_json_from_preamble() {
837 let json = mock_agent_json("melchior", "approve", 0.9);
838 let raw = format!("Here is my analysis:\n{json}");
839
840 let result = parse_agent_response(&raw);
841 assert!(result.is_ok(), "should find JSON in preamble text");
842 }
843
844 #[test]
846 fn test_parse_agent_response_fails_on_invalid_input() {
847 let result = parse_agent_response("no json here");
848 assert!(result.is_err(), "should fail on invalid input");
849 }
850
851 #[test]
855 fn test_magi_builder_build_returns_result() {
856 let responses = vec![mock_agent_json("melchior", "approve", 0.9)];
857 let provider =
858 Arc::new(MockProvider::success("mock", "model", responses)) as Arc<dyn LlmProvider>;
859
860 let magi = MagiBuilder::new(provider).build();
861 assert!(magi.is_ok());
862 }
863}