ricecoder_agents/
coordinator.rs

1//! Agent coordinator for aggregating and prioritizing results
2
3use crate::error::Result;
4use crate::models::{AgentOutput, Finding, Suggestion};
5use std::collections::HashMap;
6
7/// Represents a finding with its source agent information
8#[derive(Debug, Clone)]
9struct FindingWithSource {
10    /// The finding itself
11    finding: Finding,
12    /// ID of the agent that produced this finding
13    agent_id: String,
14}
15
16/// Represents a suggestion with its source agent information
17#[derive(Debug, Clone)]
18struct SuggestionWithSource {
19    /// The suggestion itself
20    suggestion: Suggestion,
21    /// ID of the agent that produced this suggestion
22    agent_id: String,
23}
24
25/// Agent coordinator for aggregating and prioritizing results
26pub struct AgentCoordinator {
27    /// Tracks findings by their source agents for traceability
28    findings_by_source: HashMap<String, Vec<Finding>>,
29    /// Tracks suggestions by their source agents for traceability
30    suggestions_by_source: HashMap<String, Vec<Suggestion>>,
31}
32
33impl AgentCoordinator {
34    /// Create a new agent coordinator
35    pub fn new() -> Self {
36        Self {
37            findings_by_source: HashMap::new(),
38            suggestions_by_source: HashMap::new(),
39        }
40    }
41
42    /// Aggregate results from multiple agents
43    pub fn aggregate(&self, outputs: Vec<AgentOutput>) -> Result<AgentOutput> {
44        let mut aggregated = AgentOutput::default();
45        let mut findings_with_source = Vec::new();
46        let mut suggestions_with_source = Vec::new();
47
48        // Collect findings and suggestions with source tracking
49        for output in outputs {
50            let agent_id = output.metadata.agent_id.clone();
51
52            for finding in output.findings {
53                findings_with_source.push(FindingWithSource {
54                    finding,
55                    agent_id: agent_id.clone(),
56                });
57            }
58
59            for suggestion in output.suggestions {
60                suggestions_with_source.push(SuggestionWithSource {
61                    suggestion,
62                    agent_id: agent_id.clone(),
63                });
64            }
65
66            // Aggregate generated content
67            aggregated.generated.extend(output.generated);
68        }
69
70        // Deduplicate findings while maintaining source information
71        let deduplicated_findings = self.deduplicate_findings_with_source(findings_with_source);
72        aggregated.findings = deduplicated_findings;
73
74        // Deduplicate suggestions while maintaining source information
75        let deduplicated_suggestions =
76            self.deduplicate_suggestions_with_source(suggestions_with_source);
77        aggregated.suggestions = deduplicated_suggestions;
78
79        // Sort by severity
80        aggregated
81            .findings
82            .sort_by(|a, b| b.severity.cmp(&a.severity));
83
84        Ok(aggregated)
85    }
86
87    /// Deduplicate findings while maintaining source information
88    fn deduplicate_findings_with_source(
89        &self,
90        findings_with_source: Vec<FindingWithSource>,
91    ) -> Vec<Finding> {
92        let mut seen = HashMap::new();
93        let mut deduplicated = Vec::new();
94
95        for item in findings_with_source {
96            let key = (item.finding.category.clone(), item.finding.message.clone());
97
98            match seen.entry(key) {
99                std::collections::hash_map::Entry::Occupied(mut entry) => {
100                    let sources: &mut Vec<String> = entry.get_mut();
101                    if !sources.contains(&item.agent_id) {
102                        sources.push(item.agent_id);
103                    }
104                }
105                std::collections::hash_map::Entry::Vacant(entry) => {
106                    entry.insert(vec![item.agent_id]);
107                    deduplicated.push(item.finding);
108                }
109            }
110        }
111
112        deduplicated
113    }
114
115    /// Deduplicate suggestions while maintaining source information
116    fn deduplicate_suggestions_with_source(
117        &self,
118        suggestions_with_source: Vec<SuggestionWithSource>,
119    ) -> Vec<Suggestion> {
120        let mut seen = HashMap::new();
121        let mut deduplicated = Vec::new();
122
123        for item in suggestions_with_source {
124            let key = item.suggestion.description.clone();
125
126            match seen.entry(key) {
127                std::collections::hash_map::Entry::Occupied(mut entry) => {
128                    let sources: &mut Vec<String> = entry.get_mut();
129                    if !sources.contains(&item.agent_id) {
130                        sources.push(item.agent_id);
131                    }
132                }
133                std::collections::hash_map::Entry::Vacant(entry) => {
134                    entry.insert(vec![item.agent_id]);
135                    deduplicated.push(item.suggestion);
136                }
137            }
138        }
139
140        deduplicated
141    }
142
143    /// Deduplicate findings (legacy method for backward compatibility)
144    /// Reserved for future use when deduplication strategy needs to be applied
145    #[allow(dead_code)]
146    fn deduplicate_findings(&self, findings: &mut Vec<Finding>) {
147        let mut seen = HashMap::new();
148        findings.retain(|finding| {
149            let key = (finding.category.clone(), finding.message.clone());
150            seen.insert(key, true).is_none()
151        });
152    }
153
154    /// Resolve conflicts between findings
155    ///
156    /// Conflicts occur when multiple agents report different findings for the same code location.
157    /// This method applies severity-based prioritization to resolve conflicts:
158    /// - Critical findings take precedence over Warning and Info
159    /// - Warning findings take precedence over Info
160    /// - When findings have the same severity, all are kept
161    pub fn resolve_conflicts(&self, findings: &[Finding]) -> Vec<Finding> {
162        if findings.is_empty() {
163            return Vec::new();
164        }
165
166        // Group findings by location
167        let mut findings_by_location: HashMap<String, Vec<Finding>> = HashMap::new();
168
169        for finding in findings {
170            let location_key = if let Some(loc) = &finding.location {
171                format!("{}:{}:{}", loc.file.display(), loc.line, loc.column)
172            } else {
173                format!("{}:{}", finding.category, finding.message)
174            };
175
176            findings_by_location
177                .entry(location_key)
178                .or_default()
179                .push(finding.clone());
180        }
181
182        // Resolve conflicts at each location by keeping highest severity
183        let mut resolved = Vec::new();
184
185        for (_, location_findings) in findings_by_location {
186            if location_findings.is_empty() {
187                continue;
188            }
189
190            // Find the highest severity at this location
191            let max_severity = location_findings
192                .iter()
193                .map(|f| f.severity)
194                .max()
195                .unwrap_or(crate::models::Severity::Info);
196
197            // Keep all findings with the highest severity
198            for finding in location_findings {
199                if finding.severity == max_severity {
200                    resolved.push(finding);
201                }
202            }
203        }
204
205        resolved
206    }
207
208    /// Prioritize findings by severity
209    pub fn prioritize(&self, findings: &mut [Finding]) {
210        findings.sort_by(|a, b| b.severity.cmp(&a.severity));
211    }
212
213    /// Get findings grouped by source agent
214    pub fn findings_by_source(&self) -> &HashMap<String, Vec<Finding>> {
215        &self.findings_by_source
216    }
217
218    /// Get suggestions grouped by source agent
219    pub fn suggestions_by_source(&self) -> &HashMap<String, Vec<Suggestion>> {
220        &self.suggestions_by_source
221    }
222}
223
224impl Default for AgentCoordinator {
225    fn default() -> Self {
226        Self::new()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::models::{AgentMetadata, Severity};
234
235    fn create_test_finding(id: &str, severity: Severity) -> Finding {
236        Finding {
237            id: id.to_string(),
238            severity,
239            category: "test".to_string(),
240            message: "test message".to_string(),
241            location: None,
242            suggestion: None,
243        }
244    }
245
246    fn create_test_output_with_agent(agent_id: &str, findings: Vec<Finding>) -> AgentOutput {
247        let mut output = AgentOutput::default();
248        output.findings = findings;
249        output.metadata = AgentMetadata {
250            agent_id: agent_id.to_string(),
251            execution_time_ms: 100,
252            tokens_used: 50,
253        };
254        output
255    }
256
257    #[test]
258    fn test_aggregate_empty() {
259        let coordinator = AgentCoordinator::new();
260        let result = coordinator.aggregate(vec![]).unwrap();
261        assert_eq!(result.findings.len(), 0);
262    }
263
264    #[test]
265    fn test_aggregate_single_output() {
266        let coordinator = AgentCoordinator::new();
267        let output = create_test_output_with_agent(
268            "agent1",
269            vec![create_test_finding("f1", Severity::Warning)],
270        );
271
272        let result = coordinator.aggregate(vec![output]).unwrap();
273        assert_eq!(result.findings.len(), 1);
274    }
275
276    #[test]
277    fn test_aggregate_multiple_outputs() {
278        let coordinator = AgentCoordinator::new();
279        let output1 = create_test_output_with_agent(
280            "agent1",
281            vec![Finding {
282                id: "f1".to_string(),
283                severity: Severity::Warning,
284                category: "category1".to_string(),
285                message: "message1".to_string(),
286                location: None,
287                suggestion: None,
288            }],
289        );
290
291        let output2 = create_test_output_with_agent(
292            "agent2",
293            vec![Finding {
294                id: "f2".to_string(),
295                severity: Severity::Critical,
296                category: "category2".to_string(),
297                message: "message2".to_string(),
298                location: None,
299                suggestion: None,
300            }],
301        );
302
303        let result = coordinator.aggregate(vec![output1, output2]).unwrap();
304        assert_eq!(result.findings.len(), 2);
305    }
306
307    #[test]
308    fn test_deduplicate_findings() {
309        let coordinator = AgentCoordinator::new();
310        let mut findings = vec![
311            create_test_finding("f1", Severity::Warning),
312            create_test_finding("f2", Severity::Warning),
313        ];
314
315        coordinator.deduplicate_findings(&mut findings);
316        // Both have same category and message, so should be deduplicated to 1
317        assert_eq!(findings.len(), 1);
318    }
319
320    #[test]
321    fn test_prioritize_findings() {
322        let coordinator = AgentCoordinator::new();
323        let mut findings = vec![
324            create_test_finding("f1", Severity::Info),
325            create_test_finding("f2", Severity::Critical),
326            create_test_finding("f3", Severity::Warning),
327        ];
328
329        coordinator.prioritize(&mut findings);
330        assert_eq!(findings[0].severity, Severity::Critical);
331        assert_eq!(findings[1].severity, Severity::Warning);
332        assert_eq!(findings[2].severity, Severity::Info);
333    }
334
335    #[test]
336    fn test_deduplicate_findings_with_source() {
337        let coordinator = AgentCoordinator::new();
338        let output1 = create_test_output_with_agent(
339            "agent1",
340            vec![Finding {
341                id: "f1".to_string(),
342                severity: Severity::Warning,
343                category: "quality".to_string(),
344                message: "naming issue".to_string(),
345                location: None,
346                suggestion: None,
347            }],
348        );
349
350        let output2 = create_test_output_with_agent(
351            "agent2",
352            vec![Finding {
353                id: "f2".to_string(),
354                severity: Severity::Warning,
355                category: "quality".to_string(),
356                message: "naming issue".to_string(),
357                location: None,
358                suggestion: None,
359            }],
360        );
361
362        let result = coordinator.aggregate(vec![output1, output2]).unwrap();
363        // Should deduplicate to 1 finding (same category and message)
364        assert_eq!(result.findings.len(), 1);
365    }
366
367    #[test]
368    fn test_aggregate_with_suggestions() {
369        let coordinator = AgentCoordinator::new();
370        let mut output1 = create_test_output_with_agent("agent1", vec![]);
371        output1.suggestions.push(Suggestion {
372            id: "s1".to_string(),
373            description: "Use better naming".to_string(),
374            diff: None,
375            auto_fixable: true,
376        });
377
378        let mut output2 = create_test_output_with_agent("agent2", vec![]);
379        output2.suggestions.push(Suggestion {
380            id: "s2".to_string(),
381            description: "Use better naming".to_string(),
382            diff: None,
383            auto_fixable: true,
384        });
385
386        let result = coordinator.aggregate(vec![output1, output2]).unwrap();
387        // Should deduplicate suggestions with same description
388        assert_eq!(result.suggestions.len(), 1);
389    }
390
391    #[test]
392    fn test_aggregate_preserves_severity_order() {
393        let coordinator = AgentCoordinator::new();
394        let mut f1 = create_test_finding("f1", Severity::Info);
395        f1.category = "cat1".to_string();
396        f1.message = "msg1".to_string();
397
398        let mut f2 = create_test_finding("f2", Severity::Critical);
399        f2.category = "cat2".to_string();
400        f2.message = "msg2".to_string();
401
402        let mut f3 = create_test_finding("f3", Severity::Warning);
403        f3.category = "cat3".to_string();
404        f3.message = "msg3".to_string();
405
406        let output = create_test_output_with_agent("agent1", vec![f1, f2, f3]);
407
408        let result = coordinator.aggregate(vec![output]).unwrap();
409        assert_eq!(result.findings[0].severity, Severity::Critical);
410        assert_eq!(result.findings[1].severity, Severity::Warning);
411        assert_eq!(result.findings[2].severity, Severity::Info);
412    }
413
414    #[test]
415    fn test_resolve_conflicts() {
416        let coordinator = AgentCoordinator::new();
417        let mut f1 = create_test_finding("f1", Severity::Warning);
418        f1.category = "category1".to_string();
419        f1.message = "message1".to_string();
420
421        let mut f2 = create_test_finding("f2", Severity::Critical);
422        f2.category = "category2".to_string();
423        f2.message = "message2".to_string();
424
425        let findings = vec![f1, f2];
426        let resolved = coordinator.resolve_conflicts(&findings);
427        // Different categories/messages, so both should be kept
428        assert_eq!(resolved.len(), 2);
429    }
430
431    #[test]
432    fn test_resolve_conflicts_prioritizes_severity() {
433        let coordinator = AgentCoordinator::new();
434        let mut f1 = create_test_finding("f1", Severity::Info);
435        f1.location = Some(crate::models::CodeLocation {
436            file: std::path::PathBuf::from("test.rs"),
437            line: 10,
438            column: 5,
439        });
440
441        let mut f2 = create_test_finding("f2", Severity::Critical);
442        f2.location = Some(crate::models::CodeLocation {
443            file: std::path::PathBuf::from("test.rs"),
444            line: 10,
445            column: 5,
446        });
447
448        let findings = vec![f1, f2];
449        let resolved = coordinator.resolve_conflicts(&findings);
450
451        // Should keep only the Critical finding
452        assert_eq!(resolved.len(), 1);
453        assert_eq!(resolved[0].severity, Severity::Critical);
454    }
455
456    #[test]
457    fn test_resolve_conflicts_keeps_same_severity() {
458        let coordinator = AgentCoordinator::new();
459        let mut f1 = create_test_finding("f1", Severity::Warning);
460        f1.location = Some(crate::models::CodeLocation {
461            file: std::path::PathBuf::from("test.rs"),
462            line: 10,
463            column: 5,
464        });
465
466        let mut f2 = create_test_finding("f2", Severity::Warning);
467        f2.location = Some(crate::models::CodeLocation {
468            file: std::path::PathBuf::from("test.rs"),
469            line: 10,
470            column: 5,
471        });
472
473        let findings = vec![f1, f2];
474        let resolved = coordinator.resolve_conflicts(&findings);
475
476        // Should keep both findings with same severity
477        assert_eq!(resolved.len(), 2);
478    }
479
480    #[test]
481    fn test_resolve_conflicts_different_locations() {
482        let coordinator = AgentCoordinator::new();
483        let mut f1 = create_test_finding("f1", Severity::Warning);
484        f1.location = Some(crate::models::CodeLocation {
485            file: std::path::PathBuf::from("test.rs"),
486            line: 10,
487            column: 5,
488        });
489
490        let mut f2 = create_test_finding("f2", Severity::Critical);
491        f2.location = Some(crate::models::CodeLocation {
492            file: std::path::PathBuf::from("test.rs"),
493            line: 20,
494            column: 5,
495        });
496
497        let findings = vec![f1, f2];
498        let resolved = coordinator.resolve_conflicts(&findings);
499
500        // Should keep both findings at different locations
501        assert_eq!(resolved.len(), 2);
502    }
503
504    #[test]
505    fn test_aggregate_with_generated_content() {
506        let coordinator = AgentCoordinator::new();
507        let mut output = create_test_output_with_agent("agent1", vec![]);
508        output.generated.push(crate::models::GeneratedContent {
509            file: std::path::PathBuf::from("test.rs"),
510            content: "// generated".to_string(),
511        });
512
513        let result = coordinator.aggregate(vec![output]).unwrap();
514        assert_eq!(result.generated.len(), 1);
515    }
516}