Skip to main content

agent_teams/consensus/
mod.rs

1//! Consensus protocol for multi-agent agreement.
2//!
3//! When multiple agents answer the same question, the consensus module
4//! provides strategies to select or combine their responses into a single
5//! decision.
6//!
7//! All resolution functions are **pure** (no I/O) and fully testable.
8
9use std::collections::HashMap;
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// Types
16// ---------------------------------------------------------------------------
17
18/// Strategy for reaching consensus among agent responses.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum ConsensusStrategy {
22    /// Most common answer wins (requires strict majority: > n/2).
23    Majority,
24    /// Responses weighted by agent trust/confidence scores.
25    Weighted,
26    /// All non-timed-out responses must agree.
27    Unanimous,
28    /// Present all responses to a human for final decision.
29    HumanInTheLoop,
30}
31
32/// A request to reach consensus among a set of agents.
33#[derive(Debug, Clone)]
34pub struct ConsensusRequest {
35    /// The prompt / question sent to all agents.
36    pub prompt: String,
37    /// Agent names that should participate.
38    pub agents: Vec<String>,
39    /// Strategy to use for resolution.
40    pub strategy: ConsensusStrategy,
41    /// Maximum time to wait for all responses.
42    pub timeout: Duration,
43    /// Per-agent weights (used with [`ConsensusStrategy::Weighted`]).
44    pub weights: Option<HashMap<String, f32>>,
45}
46
47/// A single agent's response to the consensus prompt.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct AgentResponse {
50    /// Agent name.
51    pub agent: String,
52    /// The agent's answer content.
53    pub content: String,
54    /// Weight / confidence (default 1.0).
55    pub weight: f32,
56    /// Whether this agent timed out (response may be empty/partial).
57    pub timed_out: bool,
58}
59
60/// Result of a consensus resolution.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ConsensusResult {
63    /// The chosen answer (if consensus was reached).
64    pub decision: Option<String>,
65    /// All collected responses.
66    pub responses: Vec<AgentResponse>,
67    /// Which strategy was used.
68    pub strategy_used: ConsensusStrategy,
69    /// Whether consensus was actually reached.
70    pub consensus_reached: bool,
71}
72
73// ---------------------------------------------------------------------------
74// Resolution functions (pure, no I/O)
75// ---------------------------------------------------------------------------
76
77/// Dispatch to the appropriate resolution function.
78pub fn resolve(strategy: ConsensusStrategy, responses: &[AgentResponse]) -> ConsensusResult {
79    match strategy {
80        ConsensusStrategy::Majority => resolve_majority(responses),
81        ConsensusStrategy::Weighted => resolve_weighted(responses),
82        ConsensusStrategy::Unanimous => resolve_unanimous(responses),
83        ConsensusStrategy::HumanInTheLoop => resolve_human_in_the_loop(responses),
84    }
85}
86
87/// Majority vote: group by exact content match, most votes wins.
88///
89/// Consensus is reached only if the winner has a strict majority (> n/2)
90/// among non-timed-out responses.
91pub fn resolve_majority(responses: &[AgentResponse]) -> ConsensusResult {
92    let active: Vec<&AgentResponse> = responses.iter().filter(|r| !r.timed_out).collect();
93
94    if active.is_empty() {
95        return ConsensusResult {
96            decision: None,
97            responses: responses.to_vec(),
98            strategy_used: ConsensusStrategy::Majority,
99            consensus_reached: false,
100        };
101    }
102
103    let mut counts: HashMap<&str, usize> = HashMap::new();
104    for r in &active {
105        *counts.entry(r.content.as_str()).or_insert(0) += 1;
106    }
107
108    let (winner, max_count) = counts
109        .iter()
110        .max_by_key(|&(_, &count)| count)
111        .map(|(&content, &count)| (content.to_string(), count))
112        .unwrap();
113
114    let threshold = active.len() / 2 + 1;
115    let consensus_reached = max_count >= threshold;
116
117    ConsensusResult {
118        decision: if consensus_reached {
119            Some(winner)
120        } else {
121            None
122        },
123        responses: responses.to_vec(),
124        strategy_used: ConsensusStrategy::Majority,
125        consensus_reached,
126    }
127}
128
129/// Weighted vote: group by content, sum weights, highest total wins.
130pub fn resolve_weighted(responses: &[AgentResponse]) -> ConsensusResult {
131    let active: Vec<&AgentResponse> = responses.iter().filter(|r| !r.timed_out).collect();
132
133    if active.is_empty() {
134        return ConsensusResult {
135            decision: None,
136            responses: responses.to_vec(),
137            strategy_used: ConsensusStrategy::Weighted,
138            consensus_reached: false,
139        };
140    }
141
142    let mut weight_sums: HashMap<&str, f32> = HashMap::new();
143    for r in &active {
144        *weight_sums.entry(r.content.as_str()).or_insert(0.0) += r.weight;
145    }
146
147    let (winner, _) = weight_sums
148        .iter()
149        .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
150        .map(|(&content, &weight)| (content.to_string(), weight))
151        .unwrap();
152
153    ConsensusResult {
154        decision: Some(winner),
155        responses: responses.to_vec(),
156        strategy_used: ConsensusStrategy::Weighted,
157        consensus_reached: true,
158    }
159}
160
161/// Unanimous: all non-timed-out responses must have identical content.
162pub fn resolve_unanimous(responses: &[AgentResponse]) -> ConsensusResult {
163    let active: Vec<&AgentResponse> = responses.iter().filter(|r| !r.timed_out).collect();
164
165    if active.is_empty() {
166        return ConsensusResult {
167            decision: None,
168            responses: responses.to_vec(),
169            strategy_used: ConsensusStrategy::Unanimous,
170            consensus_reached: false,
171        };
172    }
173
174    let first = &active[0].content;
175    let all_agree = active.iter().all(|r| r.content == *first);
176
177    ConsensusResult {
178        decision: if all_agree {
179            Some(first.clone())
180        } else {
181            None
182        },
183        responses: responses.to_vec(),
184        strategy_used: ConsensusStrategy::Unanimous,
185        consensus_reached: all_agree,
186    }
187}
188
189/// Human-in-the-loop: return all responses without making a decision.
190pub fn resolve_human_in_the_loop(responses: &[AgentResponse]) -> ConsensusResult {
191    ConsensusResult {
192        decision: None,
193        responses: responses.to_vec(),
194        strategy_used: ConsensusStrategy::HumanInTheLoop,
195        consensus_reached: false,
196    }
197}
198
199// ---------------------------------------------------------------------------
200// Tests
201// ---------------------------------------------------------------------------
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn make_response(agent: &str, content: &str) -> AgentResponse {
208        AgentResponse {
209            agent: agent.to_string(),
210            content: content.to_string(),
211            weight: 1.0,
212            timed_out: false,
213        }
214    }
215
216    fn make_weighted_response(agent: &str, content: &str, weight: f32) -> AgentResponse {
217        AgentResponse {
218            agent: agent.to_string(),
219            content: content.to_string(),
220            weight,
221            timed_out: false,
222        }
223    }
224
225    fn make_timed_out(agent: &str) -> AgentResponse {
226        AgentResponse {
227            agent: agent.to_string(),
228            content: String::new(),
229            weight: 1.0,
230            timed_out: true,
231        }
232    }
233
234    #[test]
235    fn majority_clear_winner() {
236        let responses = vec![
237            make_response("a1", "yes"),
238            make_response("a2", "yes"),
239            make_response("a3", "no"),
240        ];
241        let result = resolve_majority(&responses);
242
243        assert!(result.consensus_reached);
244        assert_eq!(result.decision, Some("yes".to_string()));
245        assert_eq!(result.strategy_used, ConsensusStrategy::Majority);
246    }
247
248    #[test]
249    fn majority_no_consensus() {
250        // 2 vs 2 — no strict majority
251        let responses = vec![
252            make_response("a1", "yes"),
253            make_response("a2", "no"),
254            make_response("a3", "yes"),
255            make_response("a4", "no"),
256        ];
257        let result = resolve_majority(&responses);
258
259        assert!(!result.consensus_reached);
260        assert!(result.decision.is_none());
261    }
262
263    #[test]
264    fn majority_with_timeouts() {
265        // 2 active, 1 timed out — "yes" has 2/2 active = clear majority
266        let responses = vec![
267            make_response("a1", "yes"),
268            make_response("a2", "yes"),
269            make_timed_out("a3"),
270        ];
271        let result = resolve_majority(&responses);
272
273        assert!(result.consensus_reached);
274        assert_eq!(result.decision, Some("yes".to_string()));
275    }
276
277    #[test]
278    fn unanimous_all_agree() {
279        let responses = vec![
280            make_response("a1", "42"),
281            make_response("a2", "42"),
282            make_response("a3", "42"),
283        ];
284        let result = resolve_unanimous(&responses);
285
286        assert!(result.consensus_reached);
287        assert_eq!(result.decision, Some("42".to_string()));
288    }
289
290    #[test]
291    fn unanimous_disagreement() {
292        let responses = vec![
293            make_response("a1", "42"),
294            make_response("a2", "43"),
295        ];
296        let result = resolve_unanimous(&responses);
297
298        assert!(!result.consensus_reached);
299        assert!(result.decision.is_none());
300    }
301
302    #[test]
303    fn weighted_higher_weight_wins() {
304        let responses = vec![
305            make_weighted_response("expert", "A", 5.0),
306            make_weighted_response("junior1", "B", 1.0),
307            make_weighted_response("junior2", "B", 1.0),
308        ];
309        let result = resolve_weighted(&responses);
310
311        assert!(result.consensus_reached);
312        assert_eq!(result.decision, Some("A".to_string()));
313    }
314
315    #[test]
316    fn human_in_the_loop_no_decision() {
317        let responses = vec![
318            make_response("a1", "option A"),
319            make_response("a2", "option B"),
320        ];
321        let result = resolve_human_in_the_loop(&responses);
322
323        assert!(!result.consensus_reached);
324        assert!(result.decision.is_none());
325        assert_eq!(result.responses.len(), 2);
326    }
327
328    #[test]
329    fn all_timed_out() {
330        let responses = vec![
331            make_timed_out("a1"),
332            make_timed_out("a2"),
333        ];
334
335        let majority = resolve_majority(&responses);
336        assert!(!majority.consensus_reached);
337        assert!(majority.decision.is_none());
338
339        let unanimous = resolve_unanimous(&responses);
340        assert!(!unanimous.consensus_reached);
341
342        let weighted = resolve_weighted(&responses);
343        assert!(!weighted.consensus_reached);
344    }
345
346    #[test]
347    fn serde_round_trip() {
348        let result = ConsensusResult {
349            decision: Some("answer".to_string()),
350            responses: vec![make_response("a1", "answer")],
351            strategy_used: ConsensusStrategy::Majority,
352            consensus_reached: true,
353        };
354
355        let json = serde_json::to_string_pretty(&result).unwrap();
356        let parsed: ConsensusResult = serde_json::from_str(&json).unwrap();
357
358        assert_eq!(parsed.decision, Some("answer".to_string()));
359        assert!(parsed.consensus_reached);
360        assert_eq!(parsed.strategy_used, ConsensusStrategy::Majority);
361        assert_eq!(parsed.responses.len(), 1);
362        assert_eq!(parsed.responses[0].agent, "a1");
363    }
364
365    #[test]
366    fn resolve_dispatch() {
367        let responses = vec![
368            make_response("a1", "yes"),
369            make_response("a2", "yes"),
370            make_response("a3", "no"),
371        ];
372
373        let r1 = resolve(ConsensusStrategy::Majority, &responses);
374        assert_eq!(r1.strategy_used, ConsensusStrategy::Majority);
375        assert!(r1.consensus_reached);
376
377        let r2 = resolve(ConsensusStrategy::Unanimous, &responses);
378        assert_eq!(r2.strategy_used, ConsensusStrategy::Unanimous);
379        assert!(!r2.consensus_reached);
380
381        let r3 = resolve(ConsensusStrategy::HumanInTheLoop, &responses);
382        assert_eq!(r3.strategy_used, ConsensusStrategy::HumanInTheLoop);
383        assert!(!r3.consensus_reached);
384    }
385}