Skip to main content

ai_agents_runtime/orchestration/
concurrent.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use ai_agents_core::{AgentError, Result};
5use ai_agents_llm::LLMProvider;
6use ai_agents_observability::{current_observation_context, with_observation_context};
7use ai_agents_state::{AggregationConfig, ConcurrentAgentRef, PartialFailureAction};
8use tokio::task::JoinSet;
9use tracing::{info, warn};
10
11use super::aggregation;
12use super::types::{AgentResult, ConcurrentResult};
13use crate::Agent;
14use crate::runtime::{current_runtime_gate_identity_stack, scope_runtime_gate_identity_stack};
15use crate::spawner::AgentRegistry;
16use crate::turn_context::current_turn_actor_context;
17
18/// Run multiple agents in parallel and aggregate results.
19/// The explicit arguments preserve the public orchestration call contract and its independent controls.
20/// The complete immutable root-gate ancestry is captured before spawning so every child preserves cycle detection.
21#[allow(clippy::too_many_arguments)]
22pub async fn concurrent(
23    registry: &AgentRegistry,
24    input: &str,
25    agents: &[ConcurrentAgentRef],
26    aggregation_config: &AggregationConfig,
27    llm: Option<&dyn LLMProvider>,
28    min_required: Option<usize>,
29    timeout_ms: Option<u64>,
30    on_partial_failure: PartialFailureAction,
31    vote_parallelism: Option<usize>,
32) -> Result<ConcurrentResult> {
33    if agents.is_empty() {
34        return Err(AgentError::Config(
35            "No agents for concurrent execution".into(),
36        ));
37    }
38
39    let start = Instant::now();
40    let mut join_set = JoinSet::new();
41    //
42    // Tokio task-locals do not cross JoinSet::spawn, so all children inherit one immutable snapshot of the caller's complete root ownership chain.
43    //
44    let gate_identity_stack = current_runtime_gate_identity_stack();
45
46    for (agent_index, agent_ref) in agents.iter().enumerate() {
47        let agent_id = agent_ref.id().to_string();
48        let agent = registry.get(&agent_id).ok_or_else(|| {
49            AgentError::Other(format!("Agent not found in registry: {}", agent_id))
50        })?;
51        let input_owned = input.to_string();
52        let timeout = timeout_ms;
53        let actor_context = current_turn_actor_context();
54        let observation_context = current_observation_context();
55        let gate_identity_stack = gate_identity_stack.clone();
56
57        join_set.spawn(async move {
58            scope_runtime_gate_identity_stack(&gate_identity_stack, async move {
59                let agent_start = Instant::now();
60                let run = async {
61                    if let Some(context) = actor_context {
62                        agent.chat_with_actor_context(&input_owned, context).await
63                    } else {
64                        agent.chat(&input_owned).await
65                    }
66                };
67                let result = if let Some(t) = timeout {
68                    match tokio::time::timeout(tokio::time::Duration::from_millis(t), async {
69                        if let Some(context) = observation_context.clone() {
70                            with_observation_context(context, run).await
71                        } else {
72                            run.await
73                        }
74                    })
75                    .await
76                    {
77                        Ok(r) => r,
78                        Err(_) => Err(AgentError::Other(format!(
79                            "Agent {} timed out after {}ms",
80                            agent_id, t
81                        ))),
82                    }
83                } else if let Some(context) = observation_context {
84                    with_observation_context(context, run).await
85                } else {
86                    run.await
87                };
88
89                let duration_ms = agent_start.elapsed().as_millis() as u64;
90                match result {
91                    Ok(response) => AgentResult {
92                        agent_index,
93                        agent_id,
94                        response: Some(response),
95                        duration_ms,
96                        success: true,
97                        error: None,
98                    },
99                    Err(e) => AgentResult {
100                        agent_index,
101                        agent_id,
102                        response: None,
103                        duration_ms,
104                        success: false,
105                        error: Some(e.to_string()),
106                    },
107                }
108            })
109            .await
110        });
111    }
112
113    let mut results = Vec::with_capacity(agents.len());
114    while let Some(join_result) = join_set.join_next().await {
115        match join_result {
116            Ok(agent_result) => results.push(agent_result),
117            Err(e) => {
118                warn!(error = %e, "Concurrent task panicked");
119            }
120        }
121    }
122
123    results.sort_by_key(|result| result.agent_index);
124
125    let success_count = results.iter().filter(|r| r.success).count();
126    let failed_count = results.len() - success_count;
127
128    // Abort on any failure if configured.
129    if failed_count > 0 && matches!(on_partial_failure, PartialFailureAction::Abort) {
130        let failed_agents: Vec<_> = results
131            .iter()
132            .filter(|r| !r.success)
133            .map(|r| r.agent_id.as_str())
134            .collect();
135        return Err(AgentError::Other(format!(
136            "Concurrent execution aborted: {} agent(s) failed [{}]",
137            failed_count,
138            failed_agents.join(", ")
139        )));
140    }
141
142    // Check minimum required successes.
143    if let Some(min) = min_required
144        && success_count < min
145    {
146        return Err(AgentError::Other(format!(
147            "Only {} of {} required agents succeeded",
148            success_count, min
149        )));
150    }
151
152    // Build agent weight map for voting aggregation.
153    let agent_weights: HashMap<String, f64> = agents
154        .iter()
155        .map(|a| (a.id().to_string(), a.weight()))
156        .collect();
157
158    let strategy_name = format!("{:?}", aggregation_config.strategy);
159    let response = aggregation::aggregate(
160        &results,
161        aggregation_config,
162        llm,
163        &agent_weights,
164        vote_parallelism,
165    )
166    .await?;
167
168    info!(
169        agents = results.len(),
170        successes = success_count,
171        duration_ms = start.elapsed().as_millis() as u64,
172        "Concurrent execution completed"
173    );
174
175    Ok(ConcurrentResult {
176        response,
177        agent_results: results,
178        aggregation_strategy: strategy_name,
179    })
180}