Skip to main content

oxicode_sdk/
agent_group.rs

1//! Agent group — multi-agent orchestration primitives.
2//!
3//! Provides `AgentGroup` for running multiple agents with pipeline or parallel strategies.
4
5use crate::error::SdkResult;
6use anyhow::Result;
7use oxicode_agent::Agent;
8use std::sync::Arc;
9
10/// Multi-agent execution strategy.
11#[derive(Debug, Clone)]
12pub enum GroupStrategy {
13    /// Sequential execution. Each agent receives the previous agent's output.
14    Pipeline,
15
16    /// Parallel execution. All agents run concurrently, results are collected.
17    Parallel {
18        /// Maximum concurrent agent executions.
19        max_concurrency: usize,
20    },
21}
22
23impl Default for GroupStrategy {
24    fn default() -> Self {
25        GroupStrategy::Parallel { max_concurrency: 4 }
26    }
27}
28
29/// Output from a single agent in a group execution.
30#[derive(Debug, Clone)]
31pub struct AgentGroupOutput {
32    /// Agent name or model ID.
33    pub name: String,
34    /// Final response text.
35    pub content: String,
36    /// Whether the agent succeeded.
37    pub success: bool,
38    /// Error message if the agent failed.
39    pub error: Option<String>,
40}
41
42/// Aggregate result from a group execution.
43#[derive(Debug)]
44pub struct GroupResult {
45    /// Per-agent outputs, in agent order.
46    pub results: Vec<AgentGroupOutput>,
47    /// Total execution time in milliseconds.
48    pub total_duration_ms: u64,
49}
50
51impl GroupResult {
52    /// Check if all agents succeeded.
53    pub fn all_succeeded(&self) -> bool {
54        self.results.iter().all(|r| r.success)
55    }
56
57    /// Check if any agents failed.
58    ///
59    /// Unlike `!all_succeeded()`, this clearly conveys intent for callers
60    /// that need to handle partial failures.
61    pub fn has_failures(&self) -> bool {
62        self.results.iter().any(|r| !r.success)
63    }
64
65    /// Number of agents that completed successfully.
66    pub fn success_count(&self) -> usize {
67        self.results.iter().filter(|r| r.success).count()
68    }
69
70    /// Get the combined content from all agents.
71    pub fn combined_content(&self) -> String {
72        self.results
73            .iter()
74            .map(|r| r.content.as_str())
75            .collect::<Vec<_>>()
76            .join("\n\n")
77    }
78}
79
80/// A group of agents that can be executed together.
81pub struct AgentGroup {
82    agents: Vec<Arc<Agent>>,
83    strategy: GroupStrategy,
84}
85
86impl AgentGroup {
87    /// Create a new empty group with the given strategy.
88    pub fn new(strategy: GroupStrategy) -> Self {
89        Self {
90            agents: Vec::new(),
91            strategy,
92        }
93    }
94
95    /// Add an agent to the group.
96    pub fn agent(mut self, agent: Arc<Agent>) -> Self {
97        self.agents.push(agent);
98        self
99    }
100
101    /// Get the number of agents in the group.
102    pub fn len(&self) -> usize {
103        self.agents.len()
104    }
105
106    /// Check if the group is empty.
107    pub fn is_empty(&self) -> bool {
108        self.agents.is_empty()
109    }
110
111    /// Execute the group with the given prompt.
112    pub async fn run(&self, prompt: String) -> SdkResult<GroupResult> {
113        if self.agents.is_empty() {
114            return Ok(GroupResult {
115                results: Vec::new(),
116                total_duration_ms: 0,
117            });
118        }
119
120        let start = std::time::Instant::now();
121        let results = match &self.strategy {
122            GroupStrategy::Pipeline => self.run_pipeline(prompt).await?,
123            GroupStrategy::Parallel { max_concurrency } => {
124                self.run_parallel(prompt, *max_concurrency).await?
125            }
126        };
127
128        Ok(GroupResult {
129            total_duration_ms: start.elapsed().as_millis() as u64,
130            results,
131        })
132    }
133
134    /// Sequential pipeline: each agent receives the previous output.
135    ///
136    /// If an agent fails, the pipeline stops and returns partial results.
137    /// The caller should check [`GroupResult::has_failures`] to detect this.
138    async fn run_pipeline(&self, prompt: String) -> Result<Vec<AgentGroupOutput>> {
139        let mut results = Vec::with_capacity(self.agents.len());
140        let mut current_input = prompt;
141
142        for agent in &self.agents {
143            match agent.run(current_input.clone()).await {
144                Ok((response, _events)) => {
145                    results.push(AgentGroupOutput {
146                        name: agent.model_id(),
147                        content: response.content.clone(),
148                        success: true,
149                        error: None,
150                    });
151                    current_input = response.content;
152                }
153                Err(e) => {
154                    results.push(AgentGroupOutput {
155                        name: agent.model_id(),
156                        content: String::new(),
157                        success: false,
158                        error: Some(e.to_string()),
159                    });
160                    // Pipeline stops on first failure. Partial results are
161                    // returned in the GroupResult — use has_failures() to detect.
162                    tracing::warn!(
163                        agent = agent.model_id(),
164                        index = results.len() - 1,
165                        "Pipeline agent failed, stopping sequential execution"
166                    );
167                    break;
168                }
169            }
170        }
171
172        Ok(results)
173    }
174
175    /// Parallel execution with concurrency limit.
176    ///
177    /// Uses `spawn_blocking` because `Agent::run()` produces `!Send` futures.
178    async fn run_parallel(
179        &self,
180        prompt: String,
181        max_concurrency: usize,
182    ) -> Result<Vec<AgentGroupOutput>> {
183        let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrency));
184        let mut handles = Vec::with_capacity(self.agents.len());
185
186        for agent in self.agents.iter() {
187            let agent = Arc::clone(agent);
188            let prompt = prompt.clone();
189            let sem = Arc::clone(&semaphore);
190
191            handles.push(tokio::task::spawn_blocking(move || {
192                // SAFETY: `new_current_thread().enable_all()` cannot fail to
193                // build a runtime — no configuration that errors is involved.
194                #[allow(clippy::expect_used)]
195                let rt = tokio::runtime::Builder::new_current_thread()
196                    .enable_all()
197                    .build()
198                    .expect("Failed to create runtime");
199                rt.block_on(async move {
200                    // SAFETY: the semaphore is `Arc`-cloned into this closure, so
201                    // it cannot be closed (dropped) while the permit is awaited.
202                    #[allow(clippy::expect_used)]
203                    let _permit = sem.acquire().await.expect("semaphore closed");
204                    match agent.run(prompt).await {
205                        Ok((response, _events)) => AgentGroupOutput {
206                            name: agent.model_id(),
207                            content: response.content,
208                            success: true,
209                            error: None,
210                        },
211                        Err(e) => AgentGroupOutput {
212                            name: agent.model_id(),
213                            content: String::new(),
214                            success: false,
215                            error: Some(e.to_string()),
216                        },
217                    }
218                })
219            }));
220        }
221
222        let mut results = Vec::with_capacity(handles.len());
223        for handle in handles {
224            match handle.await {
225                Ok(output) => results.push(output),
226                Err(e) => results.push(AgentGroupOutput {
227                    name: String::new(),
228                    content: String::new(),
229                    success: false,
230                    error: Some(format!("Join error: {}", e)),
231                }),
232            }
233        }
234        Ok(results)
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_group_strategy_default() {
244        let strategy = GroupStrategy::default();
245        matches!(strategy, GroupStrategy::Parallel { max_concurrency: 4 });
246    }
247
248    #[test]
249    fn test_empty_group() {
250        let group = AgentGroup::new(GroupStrategy::Pipeline);
251        assert!(group.is_empty());
252        assert_eq!(group.len(), 0);
253    }
254
255    #[test]
256    fn test_group_result_all_succeeded() {
257        let result = GroupResult {
258            results: vec![
259                AgentGroupOutput {
260                    name: "a".into(),
261                    content: "ok".into(),
262                    success: true,
263                    error: None,
264                },
265                AgentGroupOutput {
266                    name: "b".into(),
267                    content: "ok".into(),
268                    success: true,
269                    error: None,
270                },
271            ],
272            total_duration_ms: 100,
273        };
274        assert!(result.all_succeeded());
275    }
276
277    #[test]
278    fn test_group_result_combined_content() {
279        let result = GroupResult {
280            results: vec![
281                AgentGroupOutput {
282                    name: "a".into(),
283                    content: "first".into(),
284                    success: true,
285                    error: None,
286                },
287                AgentGroupOutput {
288                    name: "b".into(),
289                    content: "second".into(),
290                    success: true,
291                    error: None,
292                },
293            ],
294            total_duration_ms: 100,
295        };
296        assert_eq!(result.combined_content(), "first\n\nsecond");
297    }
298}