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 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(¶llel_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#[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 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 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}