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 parallel_lifecycle: None,
61 }),
62 None => Arc::<Self>::clone(self),
63 };
64 crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
65 .await
66 .into_iter()
67 .map(TaskResult::from)
68 .collect()
69 }
70
71 pub(super) async fn execute_parallel_for_tool(
72 self: &Arc<Self>,
73 tasks: Vec<TaskParams>,
74 event_tx: Option<broadcast::Sender<AgentEvent>>,
75 options: ParallelToolOptions<'_>,
76 ) -> ParallelTaskRun {
77 let ParallelToolOptions {
78 parent_session_id,
79 timeout_ms,
80 min_success_count,
81 allow_partial_failure,
82 parent_cancellation,
83 } = options;
84 let parallel_cancellation = parent_cancellation
85 .map(CancellationToken::child_token)
86 .unwrap_or_default();
87 let should_return_early = allow_partial_failure && min_success_count.is_some();
88 if timeout_ms.is_none() && !should_return_early {
89 return ParallelTaskRun {
90 results: self
91 .execute_parallel_with_parent_cancellation(
92 tasks,
93 event_tx,
94 parent_session_id,
95 Some(¶llel_cancellation),
96 )
97 .await,
98 timed_out: false,
99 returned_early: false,
100 timeout_ms: None,
101 min_success_count: None,
102 };
103 }
104
105 let task_count = tasks.len();
106 let parent = parent_session_id.map(ToString::to_string);
107 let specs = tasks
108 .into_iter()
109 .map(|params| AgentStepSpec {
110 task_id: format!("task-{}", uuid::Uuid::new_v4()),
111 agent: params.agent,
112 description: params.description,
113 prompt: params.prompt,
114 max_steps: params.max_steps,
115 parent_session_id: parent.clone(),
116 output_schema: params.output_schema,
117 })
118 .collect::<Vec<_>>();
119 let labels = specs
120 .iter()
121 .map(|spec| (spec.task_id.clone(), spec.agent.clone()))
122 .collect::<Vec<_>>();
123 let target_successes = min_success_count
124 .unwrap_or(task_count)
125 .clamp(1, task_count.max(1));
126
127 let max_concurrency = self.max_parallel_tasks.max(1);
128 let parallel_lifecycle = Arc::new(ParallelTaskLifecycle::default());
129 let scoped_executor: Arc<dyn AgentExecutor> = Arc::new(ScopedTaskExecutor {
130 executor: Arc::clone(self),
131 parent_cancellation: parallel_cancellation.clone(),
132 parallel_lifecycle: Some(Arc::clone(¶llel_lifecycle)),
133 });
134 let mut pending = specs.into_iter().enumerate();
135 let mut join_set = JoinSet::new();
136 let mut active_indexes = HashMap::new();
137 let mut active_count = 0usize;
138 while active_count < max_concurrency {
139 let Some((index, spec)) = pending.next() else {
140 break;
141 };
142 let task_id = spawn_parallel_task_step(
143 &mut join_set,
144 Arc::clone(&scoped_executor),
145 event_tx.clone(),
146 index,
147 spec,
148 );
149 active_indexes.insert(task_id, index);
150 active_count += 1;
151 }
152
153 let mut results: Vec<Option<TaskResult>> = vec![None; task_count];
154 let mut completed_count = 0usize;
155 let mut success_count = 0usize;
156 let mut timed_out = false;
157 let mut returned_early = false;
158 let deadline = timeout_ms.map(|timeout| {
159 tokio::time::Instant::now() + std::time::Duration::from_millis(timeout.max(1))
160 });
161
162 while completed_count < task_count {
163 if should_return_early && success_count >= target_successes {
164 returned_early = true;
165 break;
166 }
167
168 let next = match deadline {
169 Some(deadline) => {
170 tokio::select! {
171 result = join_set.join_next_with_id() => result,
172 _ = tokio::time::sleep_until(deadline) => {
173 timed_out = true;
174 break;
175 }
176 }
177 }
178 None => join_set.join_next_with_id().await,
179 };
180
181 let Some(joined) = next else {
182 break;
183 };
184 active_count = active_count.saturating_sub(1);
185 let (index, outcome) = match joined {
186 Ok((task_id, (reported_index, Ok(outcome)))) => {
187 let index = take_parallel_task_index(&mut active_indexes, task_id)
188 .unwrap_or(reported_index);
189 if index != reported_index {
190 tracing::error!(
191 tracked_index = index,
192 reported_index,
193 "parallel branch returned a mismatched task index"
194 );
195 }
196 (index, outcome)
197 }
198 Ok((task_id, (reported_index, Err(error)))) => {
199 let index = take_parallel_task_index(&mut active_indexes, task_id)
200 .unwrap_or(reported_index);
201 let (task_id, agent) = labels
202 .get(index)
203 .cloned()
204 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
205 (index, StepOutcome::failed(task_id, agent, error))
206 }
207 Err(error) => {
208 let index = take_parallel_task_index(&mut active_indexes, error.id())
209 .unwrap_or_else(|| {
210 tracing::error!(%error, "parallel branch join failed without a tracked index");
211 usize::MAX
212 });
213 let (task_id, agent) = labels
214 .get(index)
215 .cloned()
216 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
217 (
218 index,
219 StepOutcome::failed(task_id, agent, error.to_string()),
220 )
221 }
222 };
223 let accepted = index < task_count && results[index].is_none();
224 if accepted {
225 if outcome.success {
226 success_count += 1;
227 }
228 results[index] = Some(TaskResult::from(outcome));
229 completed_count += 1;
230 }
231
232 if accepted && should_return_early && success_count >= target_successes {
233 returned_early = true;
234 break;
235 }
236
237 while active_count < max_concurrency {
238 let Some((index, spec)) = pending.next() else {
239 break;
240 };
241 let task_id = spawn_parallel_task_step(
242 &mut join_set,
243 Arc::clone(&scoped_executor),
244 event_tx.clone(),
245 index,
246 spec,
247 );
248 active_indexes.insert(task_id, index);
249 active_count += 1;
250 }
251 }
252
253 let unfinished_message = if timed_out {
254 format!(
255 "Task timed out before parallel_task finished collecting child results after {} ms.",
256 timeout_ms.unwrap_or_default()
257 )
258 } else if returned_early {
259 format!(
260 "Task cancelled after parallel_task collected {success_count} successful child result(s)."
261 )
262 } else {
263 "Task did not return a result before parallel_task ended.".to_string()
264 };
265 if timed_out || returned_early || active_count > 0 {
266 let cancelled_indexes = active_indexes.values().copied().collect::<Vec<_>>();
267 parallel_cancellation.cancel();
268 settle_cancelled_parallel_tasks(&mut join_set, &mut active_indexes).await;
269 self.emit_abandoned_parallel_task_ends(
270 &cancelled_indexes,
271 &labels,
272 event_tx.as_ref(),
273 &unfinished_message,
274 Some(¶llel_lifecycle),
275 )
276 .await;
277 }
278
279 let results = results
280 .into_iter()
281 .enumerate()
282 .map(|(index, result)| {
283 result.unwrap_or_else(|| {
284 let (task_id, agent) = labels
285 .get(index)
286 .cloned()
287 .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
288 TaskResult::from(StepOutcome::failed(
289 task_id,
290 agent,
291 unfinished_message.clone(),
292 ))
293 })
294 })
295 .collect();
296
297 ParallelTaskRun {
298 results,
299 timed_out,
300 returned_early,
301 timeout_ms,
302 min_success_count,
303 }
304 }
305
306 async fn emit_abandoned_parallel_task_ends(
311 &self,
312 indexes: &[usize],
313 labels: &[(String, String)],
314 event_tx: Option<&broadcast::Sender<AgentEvent>>,
315 output: &str,
316 lifecycle: Option<&ParallelTaskLifecycle>,
317 ) {
318 for &index in indexes {
319 let Some((task_id, agent)) = labels.get(index) else {
320 continue;
321 };
322 if let Some(lifecycle) = lifecycle {
323 if !lifecycle.is_started(task_id) || lifecycle.is_ended(task_id) {
324 continue;
325 }
326 }
327 let event = AgentEvent::SubagentEnd {
328 task_id: task_id.clone(),
329 session_id: format!("task-run-{task_id}"),
330 agent: agent.clone(),
331 output: output.to_string(),
332 success: false,
333 finished_ms: epoch_ms(),
334 };
335 let event = self
336 .parent_context
337 .as_ref()
338 .and_then(|context| context.security_provider.as_deref())
339 .map(|provider| crate::security::sanitize_agent_event(provider, &event))
340 .unwrap_or(event);
341
342 if let Some(tracker) = &self.subagent_tracker {
343 let _ = tracker.cancel(task_id).await;
348 tracker.record_event(&event).await;
349 tracker.clear_canceller(task_id).await;
350 }
351 if let Some(tx) = event_tx {
352 let _ = tx.send(event);
353 }
354 if let Some(lifecycle) = lifecycle {
355 lifecycle.mark_ended(task_id);
356 }
357 }
358 }
359}
360
361async fn settle_cancelled_parallel_tasks(
362 join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
363 active_indexes: &mut HashMap<tokio::task::Id, usize>,
364) {
365 const SETTLEMENT_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
366 let deadline = tokio::time::Instant::now() + SETTLEMENT_GRACE;
367 while !join_set.is_empty() {
368 match tokio::time::timeout_at(deadline, join_set.join_next_with_id()).await {
369 Ok(Some(Ok((task_id, _)))) => {
370 active_indexes.remove(&task_id);
371 }
372 Ok(Some(Err(error))) => {
373 active_indexes.remove(&error.id());
374 }
375 Ok(None) => return,
376 Err(_) => break,
377 }
378 }
379
380 if join_set.is_empty() {
381 return;
382 }
383 join_set.abort_all();
384 while let Some(joined) = join_set.join_next_with_id().await {
385 match joined {
386 Ok((task_id, _)) => {
387 active_indexes.remove(&task_id);
388 }
389 Err(error) => {
390 active_indexes.remove(&error.id());
391 }
392 }
393 }
394 active_indexes.clear();
395}
396
397fn spawn_parallel_task_step(
398 join_set: &mut JoinSet<(usize, std::result::Result<StepOutcome, String>)>,
399 executor: Arc<dyn AgentExecutor>,
400 event_tx: Option<broadcast::Sender<AgentEvent>>,
401 index: usize,
402 spec: AgentStepSpec,
403) -> tokio::task::Id {
404 join_set
405 .spawn(async move {
406 let outcome = AssertUnwindSafe(executor.execute_step(spec, event_tx))
407 .catch_unwind()
408 .await
409 .map_err(panic_payload_to_string);
410 (index, outcome)
411 })
412 .id()
413}
414
415fn take_parallel_task_index(
416 active_indexes: &mut HashMap<tokio::task::Id, usize>,
417 task_id: tokio::task::Id,
418) -> Option<usize> {
419 active_indexes.remove(&task_id)
420}
421
422fn panic_payload_to_string(payload: Box<dyn Any + Send>) -> String {
423 if let Some(message) = payload.downcast_ref::<&str>() {
424 return format!("parallel branch panicked: {message}");
425 }
426 if let Some(message) = payload.downcast_ref::<String>() {
427 return format!("parallel branch panicked: {message}");
428 }
429 "parallel branch panicked: unknown panic payload".to_string()
430}
431
432pub(super) struct ParallelTaskRun {
433 pub(super) results: Vec<TaskResult>,
434 pub(super) timed_out: bool,
435 pub(super) returned_early: bool,
436 pub(super) timeout_ms: Option<u64>,
437 pub(super) min_success_count: Option<usize>,
438}
439
440impl From<TaskResult> for StepOutcome {
441 fn from(r: TaskResult) -> Self {
442 StepOutcome {
443 task_id: r.task_id,
444 session_id: r.session_id,
445 agent: r.agent,
446 output: r.output,
447 success: r.success,
448 structured: r.structured,
449 source_anchors: r.source_anchors,
450 }
451 }
452}
453
454impl From<StepOutcome> for TaskResult {
455 fn from(o: StepOutcome) -> Self {
456 TaskResult {
457 output: o.output,
458 session_id: o.session_id,
459 agent: o.agent,
460 success: o.success,
461 task_id: o.task_id,
462 structured: o.structured,
463 source_anchors: o.source_anchors,
464 }
465 }
466}
467
468#[async_trait]
472impl AgentExecutor for TaskExecutor {
473 async fn execute_step(
474 &self,
475 spec: AgentStepSpec,
476 event_tx: Option<broadcast::Sender<AgentEvent>>,
477 ) -> StepOutcome {
478 self.execute_step_with_parent_cancellation(
479 spec,
480 event_tx,
481 self.parent_cancellation.as_ref(),
482 None,
483 )
484 .await
485 }
486
487 fn concurrency_hint(&self) -> usize {
488 self.max_parallel_tasks
489 }
490}
491
492impl TaskExecutor {
493 async fn execute_step_with_parent_cancellation(
494 &self,
495 spec: AgentStepSpec,
496 event_tx: Option<broadcast::Sender<AgentEvent>>,
497 parent_cancellation: Option<&CancellationToken>,
498 parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
499 ) -> StepOutcome {
500 let agent = spec.agent.clone();
501 let task_id = spec.task_id.clone();
502 let _permit = match self.acquire_parallel_permit(parent_cancellation).await {
503 Ok(permit) => permit,
504 Err(error) => return StepOutcome::failed(task_id, agent, error),
505 };
506 let params = TaskParams {
507 agent: spec.agent,
508 description: spec.description,
509 prompt: spec.prompt,
510 background: false,
511 max_steps: spec.max_steps,
512 output_schema: spec.output_schema,
513 };
514 match self
515 .execute_with_task_id_scoped(
516 task_id.clone(),
517 params,
518 ScopedTaskExecution {
519 event_tx,
520 parent_session_id: spec.parent_session_id.as_deref(),
521 emit_start: true,
522 parent_cancellation,
523 admitted_capability_subtask: None,
524 parallel_lifecycle,
525 },
526 )
527 .await
528 {
529 Ok(result) => result.into(),
530 Err(e) => StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
531 }
532 }
533
534 async fn acquire_parallel_permit(
535 &self,
536 parent_cancellation: Option<&CancellationToken>,
537 ) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, String> {
538 let acquire = Arc::clone(&self.parallel_permits).acquire_owned();
539 match parent_cancellation {
540 Some(cancellation) => {
541 tokio::select! {
542 biased;
543 _ = cancellation.cancelled() => {
544 Err("Task cancelled while waiting for parallel provider capacity".to_string())
545 }
546 permit = acquire => permit.map_err(|error| {
547 format!("Parallel provider capacity closed unexpectedly: {error}")
548 }),
549 }
550 }
551 None => acquire.await.map_err(|error| {
552 format!("Parallel provider capacity closed unexpectedly: {error}")
553 }),
554 }
555 }
556
557 pub(super) async fn coerce_to_schema(
561 llm_client: &dyn LlmClient,
562 output: &str,
563 schema: serde_json::Value,
564 cancellation: &CancellationToken,
565 ) -> Result<serde_json::Value> {
566 let req = StructuredRequest {
567 prompt: format!(
568 "Convert the following task result into a single JSON object that conforms to \
569 the required schema. Use only information present in the result.\n\n\
570 --- TASK RESULT ---\n{output}"
571 ),
572 system: Some(
573 "You output exactly one JSON object matching the provided schema.".to_string(),
574 ),
575 schema,
576 schema_name: "step_output".to_string(),
577 schema_description: None,
578 mode: StructuredMode::Tool,
581 max_repair_attempts: 2,
582 };
583 let result = tokio::select! {
584 biased;
585 _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
586 result = generate_blocking(llm_client, &req) => result?,
587 };
588 Ok(result.object)
589 }
590
591 pub(super) async fn generate_structured_task(
592 llm_client: &dyn LlmClient,
593 prompt: &str,
594 system: Option<&str>,
595 schema: serde_json::Value,
596 cancellation: &CancellationToken,
597 ) -> Result<serde_json::Value> {
598 let req = StructuredRequest {
599 prompt: prompt.to_string(),
600 system: Some(format!(
601 "{}\n\nReturn exactly one JSON object matching the provided schema.",
602 system.unwrap_or("Make the requested structured decision without tools.")
603 )),
604 schema,
605 schema_name: "step_output".to_string(),
606 schema_description: None,
607 mode: StructuredMode::Tool,
608 max_repair_attempts: 2,
609 };
610 let result = tokio::select! {
611 biased;
612 _ = cancellation.cancelled() => anyhow::bail!("Operation cancelled by user"),
613 result = generate_blocking(llm_client, &req) => result?,
614 };
615 Ok(result.object)
616 }
617}
618
619struct ScopedTaskExecutor {
620 executor: Arc<TaskExecutor>,
621 parent_cancellation: CancellationToken,
622 parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
623}
624
625#[async_trait]
626impl AgentExecutor for ScopedTaskExecutor {
627 async fn execute_step(
628 &self,
629 spec: AgentStepSpec,
630 event_tx: Option<broadcast::Sender<AgentEvent>>,
631 ) -> StepOutcome {
632 self.executor
633 .execute_step_with_parent_cancellation(
634 spec,
635 event_tx,
636 Some(&self.parent_cancellation),
637 self.parallel_lifecycle.clone(),
638 )
639 .await
640 }
641
642 fn concurrency_hint(&self) -> usize {
643 self.executor.max_parallel_tasks
644 }
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 struct NoopLlmClient;
652
653 #[async_trait::async_trait]
654 impl LlmClient for NoopLlmClient {
655 async fn complete(
656 &self,
657 _messages: &[crate::llm::Message],
658 _system: Option<&str>,
659 _tools: &[crate::llm::ToolDefinition],
660 ) -> anyhow::Result<crate::llm::LlmResponse> {
661 anyhow::bail!("NoopLlmClient must not be called")
662 }
663
664 async fn complete_streaming(
665 &self,
666 _messages: &[crate::llm::Message],
667 _system: Option<&str>,
668 _tools: &[crate::llm::ToolDefinition],
669 _cancel_token: CancellationToken,
670 ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
671 anyhow::bail!("NoopLlmClient must not be called")
672 }
673 }
674
675 #[tokio::test]
676 async fn aborted_join_keeps_the_spawned_branch_index() {
677 let mut join_set = JoinSet::new();
678 let handle = join_set.spawn(async {
679 std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
680 });
681 let mut active_indexes = HashMap::from([(handle.id(), 7)]);
682 handle.abort();
683
684 let error = join_set
685 .join_next_with_id()
686 .await
687 .expect("aborted task should settle")
688 .expect_err("aborted task should return JoinError");
689
690 assert_eq!(
691 take_parallel_task_index(&mut active_indexes, error.id()),
692 Some(7)
693 );
694 assert!(active_indexes.is_empty());
695 }
696
697 #[tokio::test]
698 async fn abandoned_parallel_settlement_emits_terminal_end_and_updates_tracker() {
699 use crate::subagent_task_tracker::{InMemorySubagentTaskTracker, SubagentStatus};
700
701 let tracker = Arc::new(InMemorySubagentTaskTracker::new());
702 let task_id = "task-abandoned".to_string();
703 let lifecycle = Arc::new(ParallelTaskLifecycle::default());
704 lifecycle.mark_started(&task_id);
705 tracker
706 .record_event(&AgentEvent::SubagentStart {
707 task_id: task_id.clone(),
708 session_id: format!("task-run-{task_id}"),
709 parent_session_id: "parent".to_string(),
710 agent: "worker".to_string(),
711 description: "abandoned branch".to_string(),
712 started_ms: 1,
713 })
714 .await;
715 tracker
716 .register_canceller(&task_id, CancellationToken::new())
717 .await;
718
719 let executor = TaskExecutor::new(
720 Arc::new(AgentRegistry::new()),
721 Arc::new(NoopLlmClient),
722 ".".to_string(),
723 )
724 .with_subagent_tracker(Arc::clone(&tracker));
725 let (event_tx, mut event_rx) = broadcast::channel(8);
726 executor
727 .emit_abandoned_parallel_task_ends(
728 &[0],
729 &[(task_id.clone(), "worker".to_string())],
730 Some(&event_tx),
731 "Task cancelled after parallel_task collected one successful child result(s).",
732 Some(&lifecycle),
733 )
734 .await;
735
736 let event = event_rx.try_recv().expect("synthetic end event");
737 match event {
738 AgentEvent::SubagentEnd {
739 task_id: event_task_id,
740 success,
741 output,
742 ..
743 } => {
744 assert_eq!(event_task_id, task_id);
745 assert!(!success);
746 assert!(output.contains("cancelled"));
747 }
748 other => panic!("expected SubagentEnd, got {other:?}"),
749 }
750 assert_eq!(
751 tracker.get(&task_id).await.unwrap().status,
752 SubagentStatus::Cancelled
753 );
754 }
755
756 #[tokio::test]
757 async fn cancelled_parallel_settlement_drains_join_set() {
758 let mut join_set = JoinSet::new();
759 let handle = join_set.spawn(async {
760 std::future::pending::<(usize, std::result::Result<StepOutcome, String>)>().await
761 });
762 let mut active_indexes = HashMap::from([(handle.id(), 3)]);
763
764 settle_cancelled_parallel_tasks(&mut join_set, &mut active_indexes).await;
765 assert!(join_set.is_empty());
766 assert!(active_indexes.is_empty());
767 }
768}