Skip to main content

construct/tools/
swarm.rs

1use super::traits::{Tool, ToolResult};
2use crate::config::{DelegateAgentConfig, SwarmConfig, SwarmStrategy};
3use crate::providers::{self, Provider};
4use crate::security::SecurityPolicy;
5use crate::security::policy::ToolOperation;
6use async_trait::async_trait;
7use serde_json::json;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11
12/// Default timeout for individual agent calls within a swarm.
13const SWARM_AGENT_TIMEOUT_SECS: u64 = 120;
14
15/// Tool that orchestrates multiple agents as a swarm. Supports sequential
16/// (pipeline), parallel (fan-out/fan-in), and router (LLM-selected) strategies.
17pub struct SwarmTool {
18    swarms: Arc<HashMap<String, SwarmConfig>>,
19    agents: Arc<HashMap<String, DelegateAgentConfig>>,
20    security: Arc<SecurityPolicy>,
21    fallback_credential: Option<String>,
22    provider_runtime_options: providers::ProviderRuntimeOptions,
23}
24
25impl SwarmTool {
26    pub fn new(
27        swarms: HashMap<String, SwarmConfig>,
28        agents: HashMap<String, DelegateAgentConfig>,
29        fallback_credential: Option<String>,
30        security: Arc<SecurityPolicy>,
31        provider_runtime_options: providers::ProviderRuntimeOptions,
32    ) -> Self {
33        Self {
34            swarms: Arc::new(swarms),
35            agents: Arc::new(agents),
36            security,
37            fallback_credential,
38            provider_runtime_options,
39        }
40    }
41
42    fn create_provider_for_agent(
43        &self,
44        agent_config: &DelegateAgentConfig,
45        agent_name: &str,
46    ) -> Result<Box<dyn Provider>, ToolResult> {
47        let credential = agent_config
48            .api_key
49            .clone()
50            .or_else(|| self.fallback_credential.clone());
51
52        providers::create_provider_with_options(
53            &agent_config.provider,
54            credential.as_deref(),
55            &self.provider_runtime_options,
56        )
57        .map_err(|e| ToolResult {
58            success: false,
59            output: String::new(),
60            error: Some(format!(
61                "Failed to create provider '{}' for agent '{agent_name}': {e}",
62                agent_config.provider
63            )),
64        })
65    }
66
67    async fn call_agent(
68        &self,
69        agent_name: &str,
70        agent_config: &DelegateAgentConfig,
71        prompt: &str,
72        timeout_secs: u64,
73    ) -> Result<String, String> {
74        let provider = self
75            .create_provider_for_agent(agent_config, agent_name)
76            .map_err(|r| r.error.unwrap_or_default())?;
77
78        let temperature = agent_config.temperature.unwrap_or(0.7);
79
80        let result = tokio::time::timeout(
81            Duration::from_secs(timeout_secs),
82            provider.chat_with_system(
83                agent_config.system_prompt.as_deref(),
84                prompt,
85                &agent_config.model,
86                temperature,
87            ),
88        )
89        .await;
90
91        match result {
92            Ok(Ok(response)) => {
93                if response.trim().is_empty() {
94                    Ok("[Empty response]".to_string())
95                } else {
96                    Ok(response)
97                }
98            }
99            Ok(Err(e)) => Err(format!("Agent '{agent_name}' failed: {e}")),
100            Err(_) => Err(format!(
101                "Agent '{agent_name}' timed out after {timeout_secs}s"
102            )),
103        }
104    }
105
106    async fn execute_sequential(
107        &self,
108        swarm_config: &SwarmConfig,
109        prompt: &str,
110        context: &str,
111    ) -> anyhow::Result<ToolResult> {
112        let mut current_input = if context.is_empty() {
113            prompt.to_string()
114        } else {
115            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
116        };
117
118        let per_agent_timeout = swarm_config.timeout_secs / swarm_config.agents.len().max(1) as u64;
119        let mut results = Vec::new();
120
121        for (i, agent_name) in swarm_config.agents.iter().enumerate() {
122            let agent_config = match self.agents.get(agent_name) {
123                Some(cfg) => cfg,
124                None => {
125                    return Ok(ToolResult {
126                        success: false,
127                        output: String::new(),
128                        error: Some(format!("Swarm references unknown agent '{agent_name}'")),
129                    });
130                }
131            };
132
133            let agent_prompt = if i == 0 {
134                current_input.clone()
135            } else {
136                format!("[Previous agent output]\n{current_input}\n\n[Original task]\n{prompt}")
137            };
138
139            match self
140                .call_agent(agent_name, agent_config, &agent_prompt, per_agent_timeout)
141                .await
142            {
143                Ok(output) => {
144                    results.push(format!(
145                        "[{agent_name} ({}/{})] {output}",
146                        agent_config.provider, agent_config.model
147                    ));
148                    current_input = output;
149                }
150                Err(e) => {
151                    return Ok(ToolResult {
152                        success: false,
153                        output: results.join("\n\n"),
154                        error: Some(e),
155                    });
156                }
157            }
158        }
159
160        Ok(ToolResult {
161            success: true,
162            output: format!(
163                "[Swarm sequential — {} agents]\n\n{}",
164                swarm_config.agents.len(),
165                results.join("\n\n")
166            ),
167            error: None,
168        })
169    }
170
171    async fn execute_parallel(
172        &self,
173        swarm_config: &SwarmConfig,
174        prompt: &str,
175        context: &str,
176    ) -> anyhow::Result<ToolResult> {
177        let full_prompt = if context.is_empty() {
178            prompt.to_string()
179        } else {
180            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
181        };
182
183        let mut join_set = tokio::task::JoinSet::new();
184
185        for agent_name in &swarm_config.agents {
186            let agent_config = match self.agents.get(agent_name) {
187                Some(cfg) => cfg.clone(),
188                None => {
189                    return Ok(ToolResult {
190                        success: false,
191                        output: String::new(),
192                        error: Some(format!("Swarm references unknown agent '{agent_name}'")),
193                    });
194                }
195            };
196
197            let credential = agent_config
198                .api_key
199                .clone()
200                .or_else(|| self.fallback_credential.clone());
201
202            let provider = match providers::create_provider_with_options(
203                &agent_config.provider,
204                credential.as_deref(),
205                &self.provider_runtime_options,
206            ) {
207                Ok(p) => p,
208                Err(e) => {
209                    return Ok(ToolResult {
210                        success: false,
211                        output: String::new(),
212                        error: Some(format!(
213                            "Failed to create provider for agent '{agent_name}': {e}"
214                        )),
215                    });
216                }
217            };
218
219            let name = agent_name.clone();
220            let prompt_clone = full_prompt.clone();
221            let timeout = swarm_config.timeout_secs;
222            let model = agent_config.model.clone();
223            let temperature = agent_config.temperature.unwrap_or(0.7);
224            let system_prompt = agent_config.system_prompt.clone();
225            let provider_name = agent_config.provider.clone();
226
227            join_set.spawn(async move {
228                let result = tokio::time::timeout(
229                    Duration::from_secs(timeout),
230                    provider.chat_with_system(
231                        system_prompt.as_deref(),
232                        &prompt_clone,
233                        &model,
234                        temperature,
235                    ),
236                )
237                .await;
238
239                let output = match result {
240                    Ok(Ok(text)) => {
241                        if text.trim().is_empty() {
242                            "[Empty response]".to_string()
243                        } else {
244                            text
245                        }
246                    }
247                    Ok(Err(e)) => format!("[Error] {e}"),
248                    Err(_) => format!("[Timed out after {timeout}s]"),
249                };
250
251                (name, provider_name, model, output)
252            });
253        }
254
255        let mut results = Vec::new();
256        while let Some(join_result) = join_set.join_next().await {
257            match join_result {
258                Ok((name, provider_name, model, output)) => {
259                    results.push(format!("[{name} ({provider_name}/{model})]\n{output}"));
260                }
261                Err(e) => {
262                    results.push(format!("[join error] {e}"));
263                }
264            }
265        }
266
267        Ok(ToolResult {
268            success: true,
269            output: format!(
270                "[Swarm parallel — {} agents]\n\n{}",
271                swarm_config.agents.len(),
272                results.join("\n\n---\n\n")
273            ),
274            error: None,
275        })
276    }
277
278    async fn execute_router(
279        &self,
280        swarm_config: &SwarmConfig,
281        prompt: &str,
282        context: &str,
283    ) -> anyhow::Result<ToolResult> {
284        if swarm_config.agents.is_empty() {
285            return Ok(ToolResult {
286                success: false,
287                output: String::new(),
288                error: Some("Router swarm has no agents to choose from".into()),
289            });
290        }
291
292        // Build agent descriptions for the router prompt
293        let agent_descriptions: Vec<String> = swarm_config
294            .agents
295            .iter()
296            .filter_map(|name| {
297                self.agents.get(name).map(|cfg| {
298                    let desc = cfg
299                        .system_prompt
300                        .as_deref()
301                        .unwrap_or("General purpose agent");
302                    format!(
303                        "- {name}: {desc} (provider: {}, model: {})",
304                        cfg.provider, cfg.model
305                    )
306                })
307            })
308            .collect();
309
310        // Use the first agent's provider for routing
311        let first_agent_name = &swarm_config.agents[0];
312        let first_agent_config = match self.agents.get(first_agent_name) {
313            Some(cfg) => cfg,
314            None => {
315                return Ok(ToolResult {
316                    success: false,
317                    output: String::new(),
318                    error: Some(format!(
319                        "Swarm references unknown agent '{first_agent_name}'"
320                    )),
321                });
322            }
323        };
324
325        let router_provider = self
326            .create_provider_for_agent(first_agent_config, first_agent_name)
327            .map_err(|r| anyhow::anyhow!(r.error.unwrap_or_default()))?;
328
329        let base_router_prompt = swarm_config
330            .router_prompt
331            .as_deref()
332            .unwrap_or("Pick the single best agent for this task.");
333
334        let routing_prompt = format!(
335            "{base_router_prompt}\n\nAvailable agents:\n{}\n\nUser task: {prompt}\n\n\
336             Respond with ONLY the agent name, nothing else.",
337            agent_descriptions.join("\n")
338        );
339
340        let chosen = tokio::time::timeout(
341            Duration::from_secs(SWARM_AGENT_TIMEOUT_SECS),
342            router_provider.chat_with_system(
343                Some("You are a routing assistant. Respond with only the agent name."),
344                &routing_prompt,
345                &first_agent_config.model,
346                0.0,
347            ),
348        )
349        .await;
350
351        let chosen_name = match chosen {
352            Ok(Ok(name)) => name.trim().to_string(),
353            Ok(Err(e)) => {
354                return Ok(ToolResult {
355                    success: false,
356                    output: String::new(),
357                    error: Some(format!("Router LLM call failed: {e}")),
358                });
359            }
360            Err(_) => {
361                return Ok(ToolResult {
362                    success: false,
363                    output: String::new(),
364                    error: Some("Router LLM call timed out".into()),
365                });
366            }
367        };
368
369        // Case-insensitive matching with fallback to first agent
370        let matched_name = swarm_config
371            .agents
372            .iter()
373            .find(|name| name.eq_ignore_ascii_case(&chosen_name))
374            .cloned()
375            .unwrap_or_else(|| swarm_config.agents[0].clone());
376
377        let agent_config = match self.agents.get(&matched_name) {
378            Some(cfg) => cfg,
379            None => {
380                return Ok(ToolResult {
381                    success: false,
382                    output: String::new(),
383                    error: Some(format!("Router selected unknown agent '{matched_name}'")),
384                });
385            }
386        };
387
388        let full_prompt = if context.is_empty() {
389            prompt.to_string()
390        } else {
391            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
392        };
393
394        match self
395            .call_agent(
396                &matched_name,
397                agent_config,
398                &full_prompt,
399                swarm_config.timeout_secs,
400            )
401            .await
402        {
403            Ok(output) => Ok(ToolResult {
404                success: true,
405                output: format!(
406                    "[Swarm router — selected '{matched_name}' ({}/{})]\n{output}",
407                    agent_config.provider, agent_config.model
408                ),
409                error: None,
410            }),
411            Err(e) => Ok(ToolResult {
412                success: false,
413                output: String::new(),
414                error: Some(e),
415            }),
416        }
417    }
418}
419
420#[async_trait]
421impl Tool for SwarmTool {
422    fn name(&self) -> &str {
423        "swarm"
424    }
425
426    fn description(&self) -> &str {
427        "Orchestrate a swarm of agents to collaboratively handle a task. Supports sequential \
428         (pipeline), parallel (fan-out/fan-in), and router (LLM-selected) strategies."
429    }
430
431    fn parameters_schema(&self) -> serde_json::Value {
432        let swarm_names: Vec<&str> = self.swarms.keys().map(String::as_str).collect();
433        json!({
434            "type": "object",
435            "additionalProperties": false,
436            "properties": {
437                "swarm": {
438                    "type": "string",
439                    "minLength": 1,
440                    "description": format!(
441                        "Name of the swarm to invoke. Available: {}",
442                        if swarm_names.is_empty() {
443                            "(none configured)".to_string()
444                        } else {
445                            swarm_names.join(", ")
446                        }
447                    )
448                },
449                "prompt": {
450                    "type": "string",
451                    "minLength": 1,
452                    "description": "The task/prompt to send to the swarm"
453                },
454                "context": {
455                    "type": "string",
456                    "description": "Optional context to include (e.g. relevant code, prior findings)"
457                }
458            },
459            "required": ["swarm", "prompt"]
460        })
461    }
462
463    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
464        let swarm_name = args
465            .get("swarm")
466            .and_then(|v| v.as_str())
467            .map(str::trim)
468            .ok_or_else(|| anyhow::anyhow!("Missing 'swarm' parameter"))?;
469
470        if swarm_name.is_empty() {
471            return Ok(ToolResult {
472                success: false,
473                output: String::new(),
474                error: Some("'swarm' parameter must not be empty".into()),
475            });
476        }
477
478        let prompt = args
479            .get("prompt")
480            .and_then(|v| v.as_str())
481            .map(str::trim)
482            .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
483
484        if prompt.is_empty() {
485            return Ok(ToolResult {
486                success: false,
487                output: String::new(),
488                error: Some("'prompt' parameter must not be empty".into()),
489            });
490        }
491
492        let context = args
493            .get("context")
494            .and_then(|v| v.as_str())
495            .map(str::trim)
496            .unwrap_or("");
497
498        let swarm_config = match self.swarms.get(swarm_name) {
499            Some(cfg) => cfg,
500            None => {
501                let available: Vec<&str> = self.swarms.keys().map(String::as_str).collect();
502                return Ok(ToolResult {
503                    success: false,
504                    output: String::new(),
505                    error: Some(format!(
506                        "Unknown swarm '{swarm_name}'. Available swarms: {}",
507                        if available.is_empty() {
508                            "(none configured)".to_string()
509                        } else {
510                            available.join(", ")
511                        }
512                    )),
513                });
514            }
515        };
516
517        if swarm_config.agents.is_empty() {
518            return Ok(ToolResult {
519                success: false,
520                output: String::new(),
521                error: Some(format!("Swarm '{swarm_name}' has no agents configured")),
522            });
523        }
524
525        if let Err(error) = self
526            .security
527            .enforce_tool_operation(ToolOperation::Act, "swarm")
528        {
529            return Ok(ToolResult {
530                success: false,
531                output: String::new(),
532                error: Some(error),
533            });
534        }
535
536        match swarm_config.strategy {
537            SwarmStrategy::Sequential => {
538                self.execute_sequential(swarm_config, prompt, context).await
539            }
540            SwarmStrategy::Parallel => self.execute_parallel(swarm_config, prompt, context).await,
541            SwarmStrategy::Router => self.execute_router(swarm_config, prompt, context).await,
542        }
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::security::{AutonomyLevel, SecurityPolicy};
550
551    fn test_security() -> Arc<SecurityPolicy> {
552        Arc::new(SecurityPolicy::default())
553    }
554
555    fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
556        let mut agents = HashMap::new();
557        agents.insert(
558            "researcher".to_string(),
559            DelegateAgentConfig {
560                provider: "ollama".to_string(),
561                model: "llama3".to_string(),
562                system_prompt: Some("You are a research assistant.".to_string()),
563                api_key: None,
564                temperature: Some(0.3),
565                max_depth: 3,
566                agentic: false,
567                allowed_tools: Vec::new(),
568                max_iterations: 10,
569                timeout_secs: None,
570                agentic_timeout_secs: None,
571                skills_directory: None,
572            },
573        );
574        agents.insert(
575            "writer".to_string(),
576            DelegateAgentConfig {
577                provider: "openrouter".to_string(),
578                model: "anthropic/claude-sonnet-4-20250514".to_string(),
579                system_prompt: Some("You are a technical writer.".to_string()),
580                api_key: Some("test-key".to_string()),
581                temperature: Some(0.5),
582                max_depth: 3,
583                agentic: false,
584                allowed_tools: Vec::new(),
585                max_iterations: 10,
586                timeout_secs: None,
587                agentic_timeout_secs: None,
588                skills_directory: None,
589            },
590        );
591        agents
592    }
593
594    fn sample_swarms() -> HashMap<String, SwarmConfig> {
595        let mut swarms = HashMap::new();
596        swarms.insert(
597            "pipeline".to_string(),
598            SwarmConfig {
599                agents: vec!["researcher".to_string(), "writer".to_string()],
600                strategy: SwarmStrategy::Sequential,
601                router_prompt: None,
602                description: Some("Research then write".to_string()),
603                timeout_secs: 300,
604            },
605        );
606        swarms.insert(
607            "fanout".to_string(),
608            SwarmConfig {
609                agents: vec!["researcher".to_string(), "writer".to_string()],
610                strategy: SwarmStrategy::Parallel,
611                router_prompt: None,
612                description: None,
613                timeout_secs: 300,
614            },
615        );
616        swarms.insert(
617            "router".to_string(),
618            SwarmConfig {
619                agents: vec!["researcher".to_string(), "writer".to_string()],
620                strategy: SwarmStrategy::Router,
621                router_prompt: Some("Pick the best agent.".to_string()),
622                description: None,
623                timeout_secs: 300,
624            },
625        );
626        swarms
627    }
628
629    #[test]
630    fn name_and_schema() {
631        let tool = SwarmTool::new(
632            sample_swarms(),
633            sample_agents(),
634            None,
635            test_security(),
636            providers::ProviderRuntimeOptions::default(),
637        );
638        assert_eq!(tool.name(), "swarm");
639        let schema = tool.parameters_schema();
640        assert!(schema["properties"]["swarm"].is_object());
641        assert!(schema["properties"]["prompt"].is_object());
642        assert!(schema["properties"]["context"].is_object());
643        let required = schema["required"].as_array().unwrap();
644        assert!(required.contains(&json!("swarm")));
645        assert!(required.contains(&json!("prompt")));
646        assert_eq!(schema["additionalProperties"], json!(false));
647    }
648
649    #[test]
650    fn description_not_empty() {
651        let tool = SwarmTool::new(
652            sample_swarms(),
653            sample_agents(),
654            None,
655            test_security(),
656            providers::ProviderRuntimeOptions::default(),
657        );
658        assert!(!tool.description().is_empty());
659    }
660
661    #[test]
662    fn schema_lists_swarm_names() {
663        let tool = SwarmTool::new(
664            sample_swarms(),
665            sample_agents(),
666            None,
667            test_security(),
668            providers::ProviderRuntimeOptions::default(),
669        );
670        let schema = tool.parameters_schema();
671        let desc = schema["properties"]["swarm"]["description"]
672            .as_str()
673            .unwrap();
674        assert!(desc.contains("pipeline") || desc.contains("fanout") || desc.contains("router"));
675    }
676
677    #[test]
678    fn empty_swarms_schema() {
679        let tool = SwarmTool::new(
680            HashMap::new(),
681            sample_agents(),
682            None,
683            test_security(),
684            providers::ProviderRuntimeOptions::default(),
685        );
686        let schema = tool.parameters_schema();
687        let desc = schema["properties"]["swarm"]["description"]
688            .as_str()
689            .unwrap();
690        assert!(desc.contains("none configured"));
691    }
692
693    #[tokio::test]
694    async fn unknown_swarm_returns_error() {
695        let tool = SwarmTool::new(
696            sample_swarms(),
697            sample_agents(),
698            None,
699            test_security(),
700            providers::ProviderRuntimeOptions::default(),
701        );
702        let result = tool
703            .execute(json!({"swarm": "nonexistent", "prompt": "test"}))
704            .await
705            .unwrap();
706        assert!(!result.success);
707        assert!(result.error.unwrap().contains("Unknown swarm"));
708    }
709
710    #[tokio::test]
711    async fn missing_swarm_param() {
712        let tool = SwarmTool::new(
713            sample_swarms(),
714            sample_agents(),
715            None,
716            test_security(),
717            providers::ProviderRuntimeOptions::default(),
718        );
719        let result = tool.execute(json!({"prompt": "test"})).await;
720        assert!(result.is_err());
721    }
722
723    #[tokio::test]
724    async fn missing_prompt_param() {
725        let tool = SwarmTool::new(
726            sample_swarms(),
727            sample_agents(),
728            None,
729            test_security(),
730            providers::ProviderRuntimeOptions::default(),
731        );
732        let result = tool.execute(json!({"swarm": "pipeline"})).await;
733        assert!(result.is_err());
734    }
735
736    #[tokio::test]
737    async fn blank_swarm_rejected() {
738        let tool = SwarmTool::new(
739            sample_swarms(),
740            sample_agents(),
741            None,
742            test_security(),
743            providers::ProviderRuntimeOptions::default(),
744        );
745        let result = tool
746            .execute(json!({"swarm": "  ", "prompt": "test"}))
747            .await
748            .unwrap();
749        assert!(!result.success);
750        assert!(result.error.unwrap().contains("must not be empty"));
751    }
752
753    #[tokio::test]
754    async fn blank_prompt_rejected() {
755        let tool = SwarmTool::new(
756            sample_swarms(),
757            sample_agents(),
758            None,
759            test_security(),
760            providers::ProviderRuntimeOptions::default(),
761        );
762        let result = tool
763            .execute(json!({"swarm": "pipeline", "prompt": "  "}))
764            .await
765            .unwrap();
766        assert!(!result.success);
767        assert!(result.error.unwrap().contains("must not be empty"));
768    }
769
770    #[tokio::test]
771    async fn swarm_with_missing_agent_returns_error() {
772        let mut swarms = HashMap::new();
773        swarms.insert(
774            "broken".to_string(),
775            SwarmConfig {
776                agents: vec!["nonexistent_agent".to_string()],
777                strategy: SwarmStrategy::Sequential,
778                router_prompt: None,
779                description: None,
780                timeout_secs: 60,
781            },
782        );
783        let tool = SwarmTool::new(
784            swarms,
785            sample_agents(),
786            None,
787            test_security(),
788            providers::ProviderRuntimeOptions::default(),
789        );
790        let result = tool
791            .execute(json!({"swarm": "broken", "prompt": "test"}))
792            .await
793            .unwrap();
794        assert!(!result.success);
795        assert!(result.error.unwrap().contains("unknown agent"));
796    }
797
798    #[tokio::test]
799    async fn swarm_with_empty_agents_returns_error() {
800        let mut swarms = HashMap::new();
801        swarms.insert(
802            "empty".to_string(),
803            SwarmConfig {
804                agents: Vec::new(),
805                strategy: SwarmStrategy::Parallel,
806                router_prompt: None,
807                description: None,
808                timeout_secs: 60,
809            },
810        );
811        let tool = SwarmTool::new(
812            swarms,
813            sample_agents(),
814            None,
815            test_security(),
816            providers::ProviderRuntimeOptions::default(),
817        );
818        let result = tool
819            .execute(json!({"swarm": "empty", "prompt": "test"}))
820            .await
821            .unwrap();
822        assert!(!result.success);
823        assert!(result.error.unwrap().contains("no agents configured"));
824    }
825
826    #[tokio::test]
827    async fn swarm_blocked_in_readonly_mode() {
828        let readonly = Arc::new(SecurityPolicy {
829            autonomy: AutonomyLevel::ReadOnly,
830            ..SecurityPolicy::default()
831        });
832        let tool = SwarmTool::new(
833            sample_swarms(),
834            sample_agents(),
835            None,
836            readonly,
837            providers::ProviderRuntimeOptions::default(),
838        );
839        let result = tool
840            .execute(json!({"swarm": "pipeline", "prompt": "test"}))
841            .await
842            .unwrap();
843        assert!(!result.success);
844        assert!(
845            result
846                .error
847                .as_deref()
848                .unwrap_or("")
849                .contains("read-only mode")
850        );
851    }
852
853    #[tokio::test]
854    async fn swarm_blocked_when_rate_limited() {
855        let limited = Arc::new(SecurityPolicy {
856            max_actions_per_hour: 0,
857            ..SecurityPolicy::default()
858        });
859        let tool = SwarmTool::new(
860            sample_swarms(),
861            sample_agents(),
862            None,
863            limited,
864            providers::ProviderRuntimeOptions::default(),
865        );
866        let result = tool
867            .execute(json!({"swarm": "pipeline", "prompt": "test"}))
868            .await
869            .unwrap();
870        assert!(!result.success);
871        assert!(
872            result
873                .error
874                .as_deref()
875                .unwrap_or("")
876                .contains("Rate limit exceeded")
877        );
878    }
879
880    #[tokio::test]
881    async fn sequential_invalid_provider_returns_error() {
882        let mut swarms = HashMap::new();
883        swarms.insert(
884            "seq".to_string(),
885            SwarmConfig {
886                agents: vec!["researcher".to_string()],
887                strategy: SwarmStrategy::Sequential,
888                router_prompt: None,
889                description: None,
890                timeout_secs: 60,
891            },
892        );
893        // researcher uses "ollama" which won't be running in CI
894        let tool = SwarmTool::new(
895            swarms,
896            sample_agents(),
897            None,
898            test_security(),
899            providers::ProviderRuntimeOptions::default(),
900        );
901        let result = tool
902            .execute(json!({"swarm": "seq", "prompt": "test"}))
903            .await
904            .unwrap();
905        // Should fail at provider creation or call level
906        assert!(!result.success);
907    }
908
909    #[tokio::test]
910    async fn parallel_invalid_provider_returns_error() {
911        let mut swarms = HashMap::new();
912        swarms.insert(
913            "par".to_string(),
914            SwarmConfig {
915                agents: vec!["researcher".to_string()],
916                strategy: SwarmStrategy::Parallel,
917                router_prompt: None,
918                description: None,
919                timeout_secs: 60,
920            },
921        );
922        let tool = SwarmTool::new(
923            swarms,
924            sample_agents(),
925            None,
926            test_security(),
927            providers::ProviderRuntimeOptions::default(),
928        );
929        let result = tool
930            .execute(json!({"swarm": "par", "prompt": "test"}))
931            .await
932            .unwrap();
933        // Parallel strategy returns success with error annotations in output
934        assert!(result.success || result.error.is_some());
935    }
936
937    #[tokio::test]
938    async fn router_invalid_provider_returns_error() {
939        let mut swarms = HashMap::new();
940        swarms.insert(
941            "rout".to_string(),
942            SwarmConfig {
943                agents: vec!["researcher".to_string()],
944                strategy: SwarmStrategy::Router,
945                router_prompt: Some("Pick.".to_string()),
946                description: None,
947                timeout_secs: 60,
948            },
949        );
950        let tool = SwarmTool::new(
951            swarms,
952            sample_agents(),
953            None,
954            test_security(),
955            providers::ProviderRuntimeOptions::default(),
956        );
957        let result = tool
958            .execute(json!({"swarm": "rout", "prompt": "test"}))
959            .await
960            .unwrap();
961        assert!(!result.success);
962    }
963}