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