Skip to main content

oxicode_sdk/coordination/
group_ext.rs

1//! CoordinatedGroup — AgentHandle-based coordination strategies.
2
3use std::sync::Arc;
4
5use crate::coordination::consensus::Consensus;
6use crate::coordination::shared_memory::{MemoryKey, SharedMemory};
7use crate::coordination::work_queue::{WorkQueue, WorkResult};
8use crate::lifecycle::AgentHandle;
9
10/// Builder for `CoordinatedGroup`.
11pub struct CoordinatedGroupBuilder {
12    handles: Vec<AgentHandle>,
13    work_queue: Option<Arc<WorkQueue>>,
14    shared_memory: Option<Arc<SharedMemory>>,
15    consensus: Option<Arc<Consensus>>,
16}
17
18impl CoordinatedGroupBuilder {
19    fn new() -> Self {
20        Self {
21            handles: Vec::new(),
22            work_queue: None,
23            shared_memory: None,
24            consensus: None,
25        }
26    }
27
28    /// Add an agent handle to the group.
29    pub fn handle(mut self, handle: AgentHandle) -> Self {
30        self.handles.push(handle);
31        self
32    }
33
34    /// Set the work queue.
35    pub fn work_queue(mut self, queue: Arc<WorkQueue>) -> Self {
36        self.work_queue = Some(queue);
37        self
38    }
39
40    /// Set the shared memory.
41    pub fn shared_memory(mut self, memory: Arc<SharedMemory>) -> Self {
42        self.shared_memory = Some(memory);
43        self
44    }
45
46    /// Set the consensus manager.
47    pub fn consensus(mut self, consensus: Arc<Consensus>) -> Self {
48        self.consensus = Some(consensus);
49        self
50    }
51
52    /// Build the coordinated group.
53    pub fn build(self) -> CoordinatedGroup {
54        CoordinatedGroup {
55            handles: self.handles,
56            work_queue: self.work_queue,
57            shared_memory: self.shared_memory,
58            consensus: self.consensus,
59        }
60    }
61}
62
63/// A group of agent handles with coordination primitives.
64///
65/// Supports fan-out (parallel work), voting, and map-reduce patterns.
66pub struct CoordinatedGroup {
67    handles: Vec<AgentHandle>,
68    work_queue: Option<Arc<WorkQueue>>,
69    shared_memory: Option<Arc<SharedMemory>>,
70    consensus: Option<Arc<Consensus>>,
71}
72
73impl CoordinatedGroup {
74    /// Create a new builder.
75    pub fn builder() -> CoordinatedGroupBuilder {
76        CoordinatedGroupBuilder::new()
77    }
78
79    /// Number of agents in the group.
80    pub fn len(&self) -> usize {
81        self.handles.len()
82    }
83
84    /// Whether the group is empty.
85    pub fn is_empty(&self) -> bool {
86        self.handles.is_empty()
87    }
88
89    /// Fan-out: distribute payloads across agents via the work queue.
90    ///
91    /// Each agent claims and executes one item in parallel.
92    pub async fn fan_out(
93        &self,
94        work_type: &str,
95        payloads: Vec<serde_json::Value>,
96    ) -> Vec<WorkResult> {
97        let queue = match &self.work_queue {
98            Some(q) => q,
99            None => return Vec::new(),
100        };
101
102        // Enqueue all work items
103        for payload in &payloads {
104            queue.enqueue(work_type, payload.clone(), 0);
105        }
106
107        // Each agent claims and runs
108        let mut join_handles = Vec::new();
109        for handle in &self.handles {
110            let queue = Arc::clone(queue);
111            let handle = handle.clone();
112
113            join_handles.push(tokio::spawn(async move {
114                let item = queue.claim(handle.agent_id(), None);
115                if let Some(item) = item {
116                    queue.start(&item.id).ok();
117                    let prompt = format!(
118                        "Complete this task:\n{}",
119                        serde_json::to_string_pretty(&item.payload).unwrap_or_default()
120                    );
121                    let start = std::time::Instant::now();
122                    match handle.run(prompt).await {
123                        Ok((response, _)) => {
124                            let result = WorkResult {
125                                success: true,
126                                content: response.content,
127                                error: None,
128                                duration_ms: start.elapsed().as_millis() as u64,
129                                tokens_used: None,
130                            };
131                            queue.complete(&item.id, result.clone()).ok();
132                            Some(result)
133                        }
134                        Err(e) => {
135                            let result = WorkResult {
136                                success: false,
137                                content: String::new(),
138                                error: Some(e.to_string()),
139                                duration_ms: start.elapsed().as_millis() as u64,
140                                tokens_used: None,
141                            };
142                            queue.complete(&item.id, result.clone()).ok();
143                            Some(result)
144                        }
145                    }
146                } else {
147                    None
148                }
149            }));
150        }
151
152        // Collect results
153        let mut results = Vec::new();
154        for jh in join_handles {
155            if let Ok(Some(result)) = jh.await {
156                results.push(result);
157            }
158        }
159        results
160    }
161
162    /// Vote: each agent casts a vote on a question.
163    ///
164    /// Uses the consensus module. Returns `None` if no consensus is configured.
165    pub async fn vote(
166        &self,
167        question: &str,
168        options: &[&str],
169    ) -> Option<crate::coordination::consensus::VoteResult> {
170        let consensus = self.consensus.as_ref()?;
171
172        let vote_id = format!("vote-{}", uuid::Uuid::new_v4());
173        let voter_ids: Vec<String> = self
174            .handles
175            .iter()
176            .map(|h| h.agent_id().to_string())
177            .collect();
178        consensus.start(&vote_id, voter_ids, 0.5);
179
180        // Each agent "decides" by running a prompt
181        for handle in &self.handles {
182            let options_str = options.join(", ");
183            let prompt = format!(
184                "Choose one option for this question. Reply with ONLY the option name, nothing else.\n\nQuestion: {}\nOptions: {}",
185                question, options_str
186            );
187
188            // For vote, we try to run but handle failures gracefully
189            if let Ok((response, _)) = handle.run(prompt).await {
190                let choice = response.content.trim().to_string();
191                // Find the closest matching option
192                let matched = options
193                    .iter()
194                    .find(|o| choice.contains(**o))
195                    .map(|o| o.to_string())
196                    .unwrap_or(choice);
197                consensus.vote(&vote_id, handle.agent_id(), matched).ok();
198            }
199        }
200
201        consensus.status(&vote_id)
202    }
203
204    /// Map-reduce: each agent processes a payload, results stored in shared memory.
205    pub async fn map_reduce(
206        &self,
207        work_type: &str,
208        payloads: Vec<serde_json::Value>,
209        reduce_key: &MemoryKey,
210    ) -> anyhow::Result<Vec<WorkResult>> {
211        let memory = self.shared_memory.as_ref();
212        let results = self.fan_out(work_type, payloads).await;
213
214        // Store results in shared memory if available
215        if let Some(memory) = memory {
216            let results_json = serde_json::to_string(&results)?;
217            memory.write(
218                reduce_key,
219                serde_json::json!(results_json),
220                "coordinator",
221                None,
222            )?;
223        }
224
225        Ok(results)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn builder_creates_group() {
235        let group = CoordinatedGroup::builder().build();
236        assert!(group.is_empty());
237        assert_eq!(group.len(), 0);
238    }
239}