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 ScopedTaskExecution {
435 event_tx,
436 parent_session_id: spec.parent_session_id.as_deref(),
437 emit_start: true,
438 parent_cancellation,
439 admitted_capability_subtask: None,
440 },
441 )
442 .await
443 {
444 Ok(result) => result.into(),
445 Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
446 }
447 }
448
449 async fn acquire_parallel_permit(
450 &self,
451 parent_cancellation: Option<&CancellationToken>,
452 ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
453 let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
454 match parent_cancellation {
455 Some(cancellation) => {
456 tokio::select! {
457 biased;
458 _ = cancellation.cancelled() => {
459 Err("Task cancelled while waiting for parallel provider capacity".to_string())
460 }
461 permit = acquire => permit.map_err(|error| {
462 format!("Parallel provider capacity closed unexpectedly: {error}")
463 }),
464 }
465 }
466 None => acquire.await.map_err(|error| {
467 format!("Parallel provider capacity closed unexpectedly: {error}")
468 }),
469 }
470 }
471
472 pub(super) async fn coerce_to_schema(
476 llm_client: &dyn LlmClient,
477 output: &str,
478 schema: serde_json::Value,
479 cancellation: &CancellationToken,
480 ) -> Result<serde_json::Value> {
481 let req = StructuredRequest {
482 prompt: format!(
483 "Convert the following task result into a single JSON object that conforms to \
484 the required schema. Use only information present in the result.\n\n\
485 --- TASK RESULT ---\n{output}"
486 ),
487 system: Some(
488 "You output exactly one JSON object matching the provided schema.".to_string(),
489 ),
490 schema,
491 schema_name: "step_output".to_string(),
492 schema_description: None,
493 mode: StructuredMode::Tool,
496 max_repair_attempts: 2,
497 };
498 let result = tokio::select! {
499 biased;
500 _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
501 result = generate_blocking(llm_client, &req) => result?,
502 };
503 Ok(result.object)
504 }
505
506 pub(super) async fn generate_structured_task(
507 llm_client: &dyn LlmClient,
508 prompt: &str,
509 system: Option<&str>,
510 schema: serde_json::Value,
511 cancellation: &CancellationToken,
512 ) -> Result<serde_json::Value> {
513 let req = StructuredRequest {
514 prompt: prompt.to_string(),
515 system: Some(format!(
516 "{}\n\nReturn exactly one JSON object matching the provided schema.",
517 system.unwrap_or("Make the requested structured decision without tools.")
518 )),
519 schema,
520 schema_name: "step_output".to_string(),
521 schema_description: None,
522 mode: StructuredMode::Tool,
523 max_repair_attempts: 2,
524 };
525 let result = tokio::select! {
526 biased;
527 _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
528 result = generate_blocking(llm_client, &req) => result?,
529 };
530 Ok(result.object)
531 }
532}
533
534struct ScopedTaskExecutor {
535 executor: Arc<TaskExecutor>,
536 parent_cancellation: CancellationToken,
537}
538
539#[async_trait]
540impl AgentExecutor for ScopedTaskExecutor {
541 async fn execute_step(
542 &self,
543 spec: AgentStepSpec,
544 event_tx: Option<broadcast::Sender<AgentEvent>>,
545 ) -> StepOutcome {
546 self.executor
547 .execute_step_with_parent_cancellation(spec, event_tx, Some(&self.parent_cancellation))
548 .await
549 }
550
551 fn concurrency_hint(&self) -> usize {
552 self.executor.max_parallel_tasks
553 }
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[tokio::test]
561 async fn aborted_join_keeps_the_spawned_branch_index() {
562 let mut join_set = JoinSet::new();
563 let handle = join_set.spawn(async {
564 std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
565 });
566 let mut active_indexes = HashMap::from([(handle.id(), 7)]);
567 handle.abort();
568
569 let error = join_set
570 .join_next_with_id()
571 .await
572 .expect("aborted task should settle")
573 .expect_err("aborted task should return JoinError");
574
575 assert_eq!(
576 take_parallel_task_index(&mut active_indexes, error.id()),
577 Some(7)
578 );
579 assert!(active_indexes.is_empty());
580 }
581}