Skip to main content

a3s_code_core/tools/task/
parallel_execution.rs

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