Skip to main content

a3s_code_core/tools/task/
parallel_execution.rs

1use super::*;
2use std::collections::HashMap;
3
4pub(super) struct ParallelToolOptions<'a> {
5    pub(super) parent_session_id: Option<&'a str>,
6    pub(super) timeout_ms: Option<u64>,
7    pub(super) min_success_count: Option<usize>,
8    pub(super) allow_partial_failure: bool,
9    pub(super) parent_cancellation: Option<&'a CancellationToken>,
10}
11
12impl TaskExecutor {
13    /// Execute multiple tasks in parallel.
14    ///
15    /// Spawns all tasks concurrently and waits for all to complete.
16    /// Returns results in the same order as the input tasks. Routed through
17    /// the [`AgentExecutor`] seam so the
18    /// same fan-out works whether steps run locally (default) or are placed
19    /// on remote nodes by a host.
20    pub async fn execute_parallel(
21        self: &Arc<Self>,
22        tasks: Vec<TaskParams>,
23        event_tx: Option<broadcast::Sender<AgentEvent>>,
24        parent_session_id: Option<&str>,
25    ) -> Vec<TaskResult> {
26        self.execute_parallel_with_parent_cancellation(
27            tasks,
28            event_tx,
29            parent_session_id,
30            self.parent_cancellation.as_ref(),
31        )
32        .await
33    }
34
35    async fn execute_parallel_with_parent_cancellation(
36        self: &Arc<Self>,
37        tasks: Vec<TaskParams>,
38        event_tx: Option<broadcast::Sender<AgentEvent>>,
39        parent_session_id: Option<&str>,
40        parent_cancellation: Option<&CancellationToken>,
41    ) -> Vec<TaskResult> {
42        let parent = parent_session_id.map(|s| s.to_string());
43        let specs = tasks
44            .into_iter()
45            .map(|params| AgentStepSpec {
46                task_id: format!("task-{}", uuid::Uuid::new_v4()),
47                agent: params.agent,
48                description: params.description,
49                prompt: params.prompt,
50                max_steps: params.max_steps,
51                parent_session_id: parent.clone(),
52                output_schema: params.output_schema,
53            })
54            .collect();
55
56        let executor: Arc<dyn AgentExecutor> = match parent_cancellation {
57            Some(cancellation) => Arc::new(ScopedTaskExecutor {
58                executor: Arc::clone(self),
59                parent_cancellation: cancellation.clone(),
60            }),
61            None => Arc::<Self>::clone(self),
62        };
63        crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
64            .await
65            .into_iter()
66            .map(TaskResult::from)
67            .collect()
68    }
69
70    pub(super) async fn execute_parallel_for_tool(
71        self: &Arc<Self>,
72        tasks: Vec<TaskParams>,
73        event_tx: Option<broadcast::Sender<AgentEvent>>,
74        options: ParallelToolOptions<'_>,
75    ) -> ParallelTaskRun {
76        let ParallelToolOptions {
77            parent_session_id,
78            timeout_ms,
79            min_success_count,
80            allow_partial_failure,
81            parent_cancellation,
82        } = options;
83        let parallel_cancellation = parent_cancellation
84            .map(CancellationToken::child_token)
85            .unwrap_or_default();
86        let should_return_early = allow_partial_failure && min_success_count.is_some();
87        if timeout_ms.is_none() && !should_return_early {
88            return ParallelTaskRun {
89                results: self
90                    .execute_parallel_with_parent_cancellation(
91                        tasks,
92                        event_tx,
93                        parent_session_id,
94                        Some(&parallel_cancellation),
95                    )
96                    .await,
97                timed_out: false,
98                returned_early: false,
99                timeout_ms: None,
100                min_success_count: None,
101            };
102        }
103
104        let task_count = tasks.len();
105        let parent = parent_session_id.map(ToString::to_string);
106        let specs = tasks
107            .into_iter()
108            .map(|params| AgentStepSpec {
109                task_id: format!("task-{}", uuid::Uuid::new_v4()),
110                agent: params.agent,
111                description: params.description,
112                prompt: params.prompt,
113                max_steps: params.max_steps,
114                parent_session_id: parent.clone(),
115                output_schema: params.output_schema,
116            })
117            .collect::<Vec<_>>();
118        let labels = specs
119            .iter()
120            .map(|spec| (spec.task_id.clone(), spec.agent.clone()))
121            .collect::<Vec<_>>();
122        let target_successes = min_success_count
123            .unwrap_or(task_count)
124            .clamp(1, task_count.max(1));
125
126        let max_concurrency = self.max_parallel_tasks.max(1);
127        let scoped_executor: Arc<dyn AgentExecutor> = Arc::new(ScopedTaskExecutor {
128            executor: Arc::clone(self),
129            parent_cancellation: parallel_cancellation.clone(),
130        });
131        let mut pending = specs.into_iter().enumerate();
132        let mut join_set = JoinSet::new();
133        let mut active_indexes = HashMap::new();
134        let mut active_count = 0usize;
135        while active_count < max_concurrency {
136            let Some((index, spec)) = pending.next() else {
137                break;
138            };
139            let task_id = spawn_parallel_task_step(
140                &mut join_set,
141                Arc::clone(&scoped_executor),
142                event_tx.clone(),
143                index,
144                spec,
145            );
146            active_indexes.insert(task_id, index);
147            active_count += 1;
148        }
149
150        let mut results: Vec<Option<TaskResult>> = vec![None; task_count];
151        let mut completed_count = 0usize;
152        let mut success_count = 0usize;
153        let mut timed_out = false;
154        let mut returned_early = false;
155        let deadline = timeout_ms.map(|timeout| {
156            tokio::time::Instant::now() + std::time::Duration::from_millis(timeout.max(1))
157        });
158
159        while completed_count < task_count {
160            if should_return_early && success_count >= target_successes {
161                returned_early = true;
162                break;
163            }
164
165            let next = match deadline {
166                Some(deadline) => {
167                    tokio::select! {
168                        result = join_set.join_next_with_id() => result,
169                        _ = tokio::time::sleep_until(deadline) => {
170                            timed_out = true;
171                            break;
172                        }
173                    }
174                }
175                None => join_set.join_next_with_id().await,
176            };
177
178            let Some(joined) = next else {
179                break;
180            };
181            active_count = active_count.saturating_sub(1);
182            let (index, outcome) = match joined {
183                Ok((task_id, (reported_index, Ok(outcome)))) => {
184                    let index = take_parallel_task_index(&mut active_indexes, task_id)
185                        .unwrap_or(reported_index);
186                    if index != reported_index {
187                        tracing::error!(
188                            tracked_index = index,
189                            reported_index,
190                            "parallel branch returned a mismatched task index"
191                        );
192                    }
193                    (index, outcome)
194                }
195                Ok((task_id, (reported_index, Err(error)))) => {
196                    let index = take_parallel_task_index(&mut active_indexes, task_id)
197                        .unwrap_or(reported_index);
198                    let (task_id, agent) = labels
199                        .get(index)
200                        .cloned()
201                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
202                    (index, StepOutcome::failed(task_id, agent, error))
203                }
204                Err(error) => {
205                    let index = take_parallel_task_index(&mut active_indexes, error.id())
206                        .unwrap_or_else(|| {
207                        tracing::error!(%error, "parallel branch join failed without a tracked index");
208                        usize::MAX
209                    });
210                    let (task_id, agent) = labels
211                        .get(index)
212                        .cloned()
213                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
214                    (
215                        index,
216                        StepOutcome::failed(task_id, agent, error.to_string()),
217                    )
218                }
219            };
220            let accepted = index < task_count && results[index].is_none();
221            if accepted {
222                if outcome.success {
223                    success_count += 1;
224                }
225                results[index] = Some(TaskResult::from(outcome));
226                completed_count += 1;
227            }
228
229            if accepted && should_return_early && success_count >= target_successes {
230                returned_early = true;
231                break;
232            }
233
234            while active_count < max_concurrency {
235                let Some((index, spec)) = pending.next() else {
236                    break;
237                };
238                let task_id = spawn_parallel_task_step(
239                    &mut join_set,
240                    Arc::clone(&scoped_executor),
241                    event_tx.clone(),
242                    index,
243                    spec,
244                );
245                active_indexes.insert(task_id, index);
246                active_count += 1;
247            }
248        }
249
250        if timed_out || returned_early || active_count > 0 {
251            parallel_cancellation.cancel();
252            settle_cancelled_parallel_tasks(&mut join_set).await;
253        }
254
255        let unfinished_message = if timed_out {
256            format!(
257                "Task timed out before parallel_task finished collecting child results after {} ms.",
258                timeout_ms.unwrap_or_default()
259            )
260        } else if returned_early {
261            format!(
262                "Task cancelled after parallel_task collected {success_count} successful child result(s)."
263            )
264        } else {
265            "Task did not return a result before parallel_task ended.".to_string()
266        };
267        let results = results
268            .into_iter()
269            .enumerate()
270            .map(|(index, result)| {
271                result.unwrap_or_else(|| {
272                    let (task_id, agent) = labels
273                        .get(index)
274                        .cloned()
275                        .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
276                    TaskResult::from(StepOutcome::failed(
277                        task_id,
278                        agent,
279                        unfinished_message.clone(),
280                    ))
281                })
282            })
283            .collect();
284
285        ParallelTaskRun {
286            results,
287            timed_out,
288            returned_early,
289            timeout_ms,
290            min_success_count,
291        }
292    }
293}
294
295async fn settle_cancelled_parallel_tasks(
296    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
297) {
298    const SETTLEMENT_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
299    let deadline = tokio::time::Instant::now() + SETTLEMENT_GRACE;
300    while !join_set.is_empty() {
301        match tokio::time::timeout_at(deadline, join_set.join_next()).await {
302            Ok(Some(_)) => {}
303            Ok(None) => return,
304            Err(_) => break,
305        }
306    }
307
308    if join_set.is_empty() {
309        return;
310    }
311    join_set.abort_all();
312    while join_set.join_next().await.is_some() {}
313}
314
315fn spawn_parallel_task_step(
316    join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
317    executor: Arc<dyn AgentExecutor>,
318    event_tx: Option<broadcast::Sender<AgentEvent>>,
319    index: usize,
320    spec: AgentStepSpec,
321) -> tokio::task::Id {
322    join_set
323        .spawn(async move {
324            let outcome = AssertUnwindSafe(executor.execute_step(spec, event_tx))
325                .catch_unwind()
326                .await
327                .map_err(panic_payload_to_string);
328            (index, outcome)
329        })
330        .id()
331}
332
333fn take_parallel_task_index(
334    active_indexes: &mut HashMap<tokio::task::Id, usize>,
335    task_id: tokio::task::Id,
336) -> Option<usize> {
337    active_indexes.remove(&task_id)
338}
339
340fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
341    if let Some(message) = payload.downcast_ref::<&str>() {
342        return format!("parallel branch panicked: {message}");
343    }
344    if let Some(message) = payload.downcast_ref::<String>() {
345        return format!("parallel branch panicked: {message}");
346    }
347    "parallel branch panicked: unknown panic payload".to_string()
348}
349
350pub(super) struct ParallelTaskRun {
351    pub(super) results: Vec<TaskResult>,
352    pub(super) timed_out: bool,
353    pub(super) returned_early: bool,
354    pub(super) timeout_ms: Option<u64>,
355    pub(super) min_success_count: Option<usize>,
356}
357
358impl From<TaskResult> for StepOutcome {
359    fn from(r: TaskResult) -> Self {
360        StepOutcome {
361            task_id: r.task_id,
362            session_id: r.session_id,
363            agent: r.agent,
364            output: r.output,
365            success: r.success,
366            structured: r.structured,
367            source_anchors: r.source_anchors,
368        }
369    }
370}
371
372impl From<StepOutcome> for TaskResult {
373    fn from(o: StepOutcome) -> Self {
374        TaskResult {
375            output: o.output,
376            session_id: o.session_id,
377            agent: o.agent,
378            success: o.success,
379            task_id: o.task_id,
380            structured: o.structured,
381            source_anchors: o.source_anchors,
382        }
383    }
384}
385
386/// The local, in-process executor: every step runs as a child `AgentLoop` on
387/// this node's tokio runtime. This is the default; a host substitutes
388/// its own [`AgentExecutor`] to place steps across a cluster.
389#[async_trait]
390impl AgentExecutor for TaskExecutor {
391    async fn execute_step(
392        &self,
393        spec: AgentStepSpec,
394        event_tx: Option<broadcast::Sender<AgentEvent>>,
395    ) -> StepOutcome {
396        self.execute_step_with_parent_cancellation(
397            spec,
398            event_tx,
399            self.parent_cancellation.as_ref(),
400        )
401        .await
402    }
403
404    fn concurrency_hint(&self) -> usize {
405        self.max_parallel_tasks
406    }
407}
408
409impl TaskExecutor {
410    async fn execute_step_with_parent_cancellation(
411        &self,
412        spec: AgentStepSpec,
413        event_tx: Option<broadcast::Sender<AgentEvent>>,
414        parent_cancellation: Option<&CancellationToken>,
415    ) -> StepOutcome {
416        let agent = spec.agent.clone();
417        let task_id = spec.task_id.clone();
418        let _permit = match self.acquire_parallel_permit(parent_cancellation).await {
419            Ok(permit) => permit,
420            Err(error) => return StepOutcome::failed(task_id, agent, error),
421        };
422        let params = TaskParams {
423            agent: spec.agent,
424            description: spec.description,
425            prompt: spec.prompt,
426            background: false,
427            max_steps: spec.max_steps,
428            output_schema: spec.output_schema,
429        };
430        match self
431            .execute_with_task_id_scoped(
432                task_id.clone(),
433                params,
434                event_tx,
435                spec.parent_session_id.as_deref(),
436                true,
437                parent_cancellation,
438            )
439            .await
440        {
441            Ok(result) => result.into(),
442            Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
443        }
444    }
445
446    async fn acquire_parallel_permit(
447        &self,
448        parent_cancellation: Option<&CancellationToken>,
449    ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
450        let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
451        match parent_cancellation {
452            Some(cancellation) => {
453                tokio::select! {
454                    biased;
455                    _ = cancellation.cancelled() => {
456                        Err("Task cancelled while waiting for parallel provider capacity".to_string())
457                    }
458                    permit = acquire => permit.map_err(|error| {
459                        format!("Parallel provider capacity closed unexpectedly: {error}")
460                    }),
461                }
462            }
463            None => acquire.await.map_err(|error| {
464                format!("Parallel provider capacity closed unexpectedly: {error}")
465            }),
466        }
467    }
468
469    /// Coerce a step's free-text output into a JSON object validated against
470    /// `schema`, reusing the structured-output machinery with built-in repair.
471    /// This is one extra LLM call beyond the step's own run.
472    pub(super) async fn coerce_to_schema(
473        llm_client: &dyn LlmClient,
474        output: &str,
475        schema: serde_json::Value,
476        cancellation: &CancellationToken,
477    ) -> Result<serde_json::Value> {
478        let req = StructuredRequest {
479            prompt: format!(
480                "Convert the following task result into a single JSON object that conforms to \
481                 the required schema. Use only information present in the result.\n\n\
482                 --- TASK RESULT ---\n{output}"
483            ),
484            system: Some(
485                "You output exactly one JSON object matching the provided schema.".to_string(),
486            ),
487            schema,
488            schema_name: "step_output".to_string(),
489            schema_description: None,
490            // Request tool mode when available; unknown providers safely
491            // downgrade to prompt+schema parsing.
492            mode: StructuredMode::Tool,
493            max_repair_attempts: 2,
494        };
495        let result = tokio::select! {
496            biased;
497            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
498            result = generate_blocking(llm_client, &req) => result?,
499        };
500        Ok(result.object)
501    }
502
503    pub(super) async fn generate_structured_task(
504        llm_client: &dyn LlmClient,
505        prompt: &str,
506        system: Option<&str>,
507        schema: serde_json::Value,
508        cancellation: &CancellationToken,
509    ) -> Result<serde_json::Value> {
510        let req = StructuredRequest {
511            prompt: prompt.to_string(),
512            system: Some(format!(
513                "{}\n\nReturn exactly one JSON object matching the provided schema.",
514                system.unwrap_or("Make the requested structured decision without tools.")
515            )),
516            schema,
517            schema_name: "step_output".to_string(),
518            schema_description: None,
519            mode: StructuredMode::Tool,
520            max_repair_attempts: 2,
521        };
522        let result = tokio::select! {
523            biased;
524            _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
525            result = generate_blocking(llm_client, &req) => result?,
526        };
527        Ok(result.object)
528    }
529}
530
531struct ScopedTaskExecutor {
532    executor: Arc<TaskExecutor>,
533    parent_cancellation: CancellationToken,
534}
535
536#[async_trait]
537impl AgentExecutor for ScopedTaskExecutor {
538    async fn execute_step(
539        &self,
540        spec: AgentStepSpec,
541        event_tx: Option<broadcast::Sender<AgentEvent>>,
542    ) -> StepOutcome {
543        self.executor
544            .execute_step_with_parent_cancellation(spec, event_tx, Some(&self.parent_cancellation))
545            .await
546    }
547
548    fn concurrency_hint(&self) -> usize {
549        self.executor.max_parallel_tasks
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[tokio::test]
558    async fn aborted_join_keeps_the_spawned_branch_index() {
559        let mut join_set = JoinSet::new();
560        let handle = join_set.spawn(async {
561            std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
562        });
563        let mut active_indexes = HashMap::from([(handle.id(), 7)]);
564        handle.abort();
565
566        let error = join_set
567            .join_next_with_id()
568            .await
569            .expect("aborted task should settle")
570            .expect_err("aborted task should return JoinError");
571
572        assert_eq!(
573            take_parallel_task_index(&mut active_indexes, error.id()),
574            Some(7)
575        );
576        assert!(active_indexes.is_empty());
577    }
578}