1use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::structured::{
19 generate_blocking, parse_validated_output, StructuredMode, StructuredRequest,
20};
21use crate::llm::LlmClient;
22use crate::mcp::manager::McpManager;
23use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome, ToolSourceAnchor};
24use crate::subagent::AgentRegistry;
25use crate::tools::types::{Tool, ToolContext, ToolOutput};
26use anyhow::{Context, Result};
27use async_trait::async_trait;
28use futures::FutureExt;
29use serde::{Deserialize, Serialize};
30use std::any::Any;
31use std::panic::AssertUnwindSafe;
32use std::path::PathBuf;
33use std::sync::Arc;
34use tokio::sync::broadcast;
35use tokio::task::JoinSet;
36use tokio_util::sync::CancellationToken;
37
38const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
39const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
40const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
41const MAX_TASK_SOURCE_ANCHORS: usize = 64;
42const MAX_TASK_SOURCE_CANDIDATES: usize = MAX_TASK_SOURCE_ANCHORS * 4;
43const MAX_TASK_SOURCE_TOOL_BYTES: usize = 64;
44const MAX_TASK_SOURCE_VALUE_BYTES: usize = 4 * 1024;
45const MAX_PARALLEL_TASK_SOURCE_ANCHORS: usize = MAX_TASK_SOURCE_ANCHORS;
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct TaskParams {
51 pub agent: String,
53 pub description: String,
55 pub prompt: String,
57 #[serde(default)]
59 pub background: bool,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub max_steps: Option<usize>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub output_schema: Option<serde_json::Value>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct TaskResult {
71 pub output: String,
73 pub session_id: String,
75 pub agent: String,
77 pub success: bool,
79 pub task_id: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub structured: Option<serde_json::Value>,
84 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub source_anchors: Vec<ToolSourceAnchor>,
87}
88
89mod result_projection;
90use result_projection::*;
91
92mod parallel_execution;
93
94const MAX_PARALLEL_TASKS_PER_CALL: usize = 32;
95
96pub struct TaskExecutor {
98 registry: Arc<AgentRegistry>,
100 llm_client: Arc<dyn LlmClient>,
102 workspace: String,
104 mcp_managers: Vec<Arc<McpManager>>,
106 parent_context: Option<crate::child_run::ChildRunContext>,
108 parent_cancellation: Option<CancellationToken>,
112 max_parallel_tasks: usize,
113 parallel_permits: Arc<tokio::sync::Semaphore>,
117 subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
120}
121
122impl TaskExecutor {
123 pub fn new(
125 registry: Arc<AgentRegistry>,
126 llm_client: Arc<dyn LlmClient>,
127 workspace: String,
128 ) -> Self {
129 Self {
130 registry,
131 llm_client,
132 workspace,
133 mcp_managers: Vec::new(),
134 parent_context: None,
135 parent_cancellation: None,
136 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
137 parallel_permits: Arc::new(tokio::sync::Semaphore::new(
138 crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
139 )),
140 subagent_tracker: None,
141 }
142 }
143
144 pub fn with_mcp(
146 registry: Arc<AgentRegistry>,
147 llm_client: Arc<dyn LlmClient>,
148 workspace: String,
149 mcp_manager: Arc<McpManager>,
150 ) -> Self {
151 Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
152 }
153
154 pub fn with_mcp_managers(
156 registry: Arc<AgentRegistry>,
157 llm_client: Arc<dyn LlmClient>,
158 workspace: String,
159 mcp_managers: Vec<Arc<McpManager>>,
160 ) -> Self {
161 Self {
162 registry,
163 llm_client,
164 workspace,
165 mcp_managers,
166 parent_context: None,
167 parent_cancellation: None,
168 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
169 parallel_permits: Arc::new(tokio::sync::Semaphore::new(
170 crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
171 )),
172 subagent_tracker: None,
173 }
174 }
175
176 pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
178 if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
179 let max_parallel_tasks = max_parallel_tasks.max(1);
180 self.max_parallel_tasks = max_parallel_tasks;
181 self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
182 }
183 self.parent_context = Some(ctx);
184 self
185 }
186
187 pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
194 self.parent_cancellation = Some(cancellation);
195 self
196 }
197
198 pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
199 let max_parallel_tasks = max_parallel_tasks.max(1);
200 self.max_parallel_tasks = max_parallel_tasks;
201 self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
202 self
203 }
204
205 pub fn with_subagent_tracker(
209 mut self,
210 tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
211 ) -> Self {
212 self.subagent_tracker = Some(tracker);
213 self
214 }
215
216 pub async fn execute(
221 &self,
222 params: TaskParams,
223 event_tx: Option<broadcast::Sender<AgentEvent>>,
224 parent_session_id: Option<&str>,
225 ) -> Result<TaskResult> {
226 self.execute_with_parent_cancellation(
227 params,
228 event_tx,
229 parent_session_id,
230 self.parent_cancellation.as_ref(),
231 )
232 .await
233 }
234
235 async fn execute_with_parent_cancellation(
236 &self,
237 params: TaskParams,
238 event_tx: Option<broadcast::Sender<AgentEvent>>,
239 parent_session_id: Option<&str>,
240 parent_cancellation: Option<&CancellationToken>,
241 ) -> Result<TaskResult> {
242 let task_id = format!("task-{}", uuid::Uuid::new_v4());
243 self.execute_with_task_id_scoped(
244 task_id,
245 params,
246 event_tx,
247 parent_session_id,
248 true,
249 parent_cancellation,
250 )
251 .await
252 }
253
254 pub async fn execute_with_task_id(
259 &self,
260 task_id: String,
261 params: TaskParams,
262 event_tx: Option<broadcast::Sender<AgentEvent>>,
263 parent_session_id: Option<&str>,
264 emit_start: bool,
265 ) -> Result<TaskResult> {
266 self.execute_with_task_id_scoped(
267 task_id,
268 params,
269 event_tx,
270 parent_session_id,
271 emit_start,
272 self.parent_cancellation.as_ref(),
273 )
274 .await
275 }
276
277 async fn execute_with_task_id_scoped(
278 &self,
279 task_id: String,
280 params: TaskParams,
281 event_tx: Option<broadcast::Sender<AgentEvent>>,
282 parent_session_id: Option<&str>,
283 emit_start: bool,
284 parent_cancellation: Option<&CancellationToken>,
285 ) -> Result<TaskResult> {
286 if parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
287 anyhow::bail!("Operation cancelled by parent session");
288 }
289
290 let session_id = format!("task-run-{}", task_id);
291 let started_ms = epoch_ms();
292 let output_schema = params.output_schema.clone();
293
294 let agent = self
295 .registry
296 .get(¶ms.agent)
297 .context(format!("Unknown agent type: '{}'", params.agent))?;
298 let tool_free = agent.tool_free;
299 let tool_free_system = agent.prompt.clone();
300 let inherited_security_provider = self
301 .parent_context
302 .as_ref()
303 .and_then(|context| context.security_provider.clone());
304
305 if emit_start {
306 let event = AgentEvent::SubagentStart {
307 task_id: task_id.clone(),
308 session_id: session_id.clone(),
309 parent_session_id: parent_session_id.unwrap_or_default().to_string(),
310 agent: params.agent.clone(),
311 description: params.description.clone(),
312 started_ms,
313 };
314 let event = inherited_security_provider
315 .as_deref()
316 .map(|provider| crate::security::sanitize_agent_event(provider, &event))
317 .unwrap_or(event);
318 if let Some(ref tracker) = self.subagent_tracker {
319 tracker.record_event(&event).await;
320 }
321 if let Some(ref tx) = event_tx {
322 let _ = tx.send(event);
323 }
324 }
325
326 let child_executor = if let Some(ref parent_ctx) = self.parent_context {
329 if let Some(ref services) = parent_ctx.workspace_services {
330 crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
331 self.workspace.clone(),
332 Arc::clone(services),
333 crate::tools::ArtifactStoreLimits::default(),
334 )
335 } else {
336 crate::tools::ToolExecutor::new(self.workspace.clone())
337 }
338 } else {
339 crate::tools::ToolExecutor::new(self.workspace.clone())
340 };
341
342 for mcp in &self.mcp_managers {
344 let all_tools = match parent_cancellation {
345 Some(cancellation) => {
346 tokio::select! {
347 biased;
348 _ = cancellation.cancelled() => {
349 anyhow::bail!("Operation cancelled by parent session");
350 }
351 tools = mcp.get_all_tools() => tools,
352 }
353 }
354 None => mcp.get_all_tools().await,
355 };
356 let mut by_server: std::collections::HashMap<
357 String,
358 Vec<crate::mcp::protocol::McpTool>,
359 > = std::collections::HashMap::new();
360 for (server, tool) in all_tools {
361 by_server.entry(server).or_default().push(tool);
362 }
363 for (server_name, tools) in by_server {
364 let wrappers =
365 crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
366 for wrapper in wrappers {
367 child_executor.register_dynamic_tool(wrapper);
368 }
369 }
370 }
371
372 let child_executor = Arc::new(child_executor);
373
374 let mut child_config = AgentConfig {
375 tools: child_executor.definitions(),
376 ..AgentConfig::default()
377 };
378 agent.apply_to(&mut child_config);
379 if let Some(ref parent_ctx) = self.parent_context {
380 parent_ctx.apply_to(&mut child_config);
381 }
382 child_config.planning_mode = crate::prompts::PlanningMode::Disabled;
387 if let Some(max_steps) = params.max_steps {
388 child_config.max_tool_rounds = max_steps;
389 }
390 let child_security_provider = child_config.security_provider.clone();
391 let source_security_provider = child_security_provider.clone();
392
393 let cancel_token = parent_cancellation
394 .map(CancellationToken::child_token)
395 .unwrap_or_default();
396 let mut tool_context = ToolContext::new(PathBuf::from(&self.workspace))
397 .with_session_id(session_id.clone())
398 .with_cancellation(cancel_token.clone());
399 if let Some(ref parent_ctx) = self.parent_context {
400 if let Some(ref services) = parent_ctx.workspace_services {
401 tool_context = tool_context.with_workspace_services(Arc::clone(services));
402 }
403 }
404
405 let source_context = tool_context.clone();
406 let agent_loop = AgentLoop::new(
407 Arc::clone(&self.llm_client),
408 child_executor,
409 tool_context,
410 child_config,
411 );
412
413 let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
417 let broadcast_tx = event_tx.clone();
418 let progress_task_id = task_id.clone();
419 let progress_session_id = session_id.clone();
420 let child_event_forwarder = tokio::spawn(async move {
421 let mut source_anchors = Vec::new();
422 let mut seen_source_anchors = std::collections::HashSet::new();
423 let mut scanned_source_candidates = 0usize;
424 while let Some(event) = mpsc_rx.recv().await {
425 let event = source_security_provider
426 .as_deref()
427 .map(|provider| crate::security::sanitize_agent_event(provider, &event))
428 .unwrap_or(event);
429 collect_tool_source_anchors(
430 &event,
431 &source_context,
432 &mut source_anchors,
433 &mut seen_source_anchors,
434 &mut scanned_source_candidates,
435 );
436 if let Some(ref broadcast_tx) = broadcast_tx {
437 if let Some(progress) = synthesize_subagent_progress(
438 &event,
439 &progress_task_id,
440 &progress_session_id,
441 ) {
442 let _ = broadcast_tx.send(progress);
443 }
444 let _ = broadcast_tx.send(event);
445 }
446 }
447 source_anchors
448 });
449 let child_event_tx = Some(mpsc_tx);
450 let child_llm_event_tx = child_event_tx.clone();
451
452 if let Some(ref tracker) = self.subagent_tracker {
455 tracker
456 .register_canceller(&task_id, cancel_token.clone())
457 .await;
458 }
459
460 let structured_prompt = output_schema
461 .as_ref()
462 .filter(|_| !tool_free)
463 .map(|schema| structured_task_prompt(¶ms.prompt, schema));
464 let execution_prompt = structured_prompt.as_deref().unwrap_or(¶ms.prompt);
465
466 let mut structured = None;
467 let (mut output, mut success) = if tool_free && output_schema.is_some() {
468 let llm_client = agent_loop.scoped_llm_client_for_parts(
469 Some(&session_id),
470 &child_llm_event_tx,
471 &cancel_token,
472 );
473 match Self::generate_structured_task(
474 &*llm_client,
475 ¶ms.prompt,
476 tool_free_system.as_deref(),
477 output_schema.clone().expect("schema checked above"),
478 &cancel_token,
479 )
480 .await
481 {
482 Ok(object) => {
483 let output = serde_json::to_string_pretty(&object)
484 .unwrap_or_else(|_| object.to_string());
485 structured = Some(object);
486 (output, true)
487 }
488 Err(error) if cancel_token.is_cancelled() => {
489 (format!("Task cancelled by caller: {error}"), false)
490 }
491 Err(error) => (format!("Task failed: {error}"), false),
492 }
493 } else {
494 match agent_loop
495 .execute_with_session(
496 &[],
497 execution_prompt,
498 Some(&session_id),
499 child_event_tx.clone(),
500 Some(&cancel_token),
501 )
502 .await
503 {
504 Ok(_) if cancel_token.is_cancelled() => {
505 ("Task cancelled by caller".to_string(), false)
506 }
507 Ok(result) if result.text.trim().is_empty() => (
508 "Task failed: child agent returned no final output".to_string(),
509 false,
510 ),
511 Ok(result) if AgentLoop::is_synthetic_failure_output(&result.text) => {
512 (format!("Task failed: {}", result.text), false)
513 }
514 Ok(result) => (result.text, true),
515 Err(e) if cancel_token.is_cancelled() => {
516 (format!("Task cancelled by caller: {}", e), false)
517 }
518 Err(e) => (format!("Task failed: {}", e), false),
519 }
520 };
521
522 if success && !tool_free {
523 if let Some(schema) = output_schema {
524 if let Some(object) = parse_validated_output(&output, &schema) {
525 structured = Some(object);
526 } else {
527 let llm_client = agent_loop.scoped_llm_client_for_parts(
528 Some(&session_id),
529 &child_llm_event_tx,
530 &cancel_token,
531 );
532 match Self::coerce_to_schema(&*llm_client, &output, schema, &cancel_token).await
533 {
534 Ok(object) => structured = Some(object),
535 Err(error) => {
536 success = false;
537 output = format!("{output}\n\n[structured output failed: {error}]");
538 }
539 }
540 }
541 }
542 }
543 if let Some(provider) = child_security_provider.as_deref() {
544 output = provider.sanitize_output(&output);
545 if let Some(value) = &mut structured {
546 *value = sanitize_task_json(provider, value);
547 }
548 }
549
550 drop(child_event_tx);
555 drop(child_llm_event_tx);
556 let source_anchors = match child_event_forwarder.await {
557 Ok(source_anchors) => source_anchors,
558 Err(error) => {
559 tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
560 Vec::new()
561 }
562 };
563
564 let end_event = AgentEvent::SubagentEnd {
565 task_id: task_id.clone(),
566 session_id: session_id.clone(),
567 agent: params.agent.clone(),
568 output: output.clone(),
569 success,
570 finished_ms: epoch_ms(),
571 };
572 if let Some(ref tracker) = self.subagent_tracker {
573 if success {
576 tracker
577 .record_source_anchors(&task_id, &source_anchors)
578 .await;
579 }
580 tracker.record_event(&end_event).await;
581 tracker.clear_canceller(&task_id).await;
582 }
583 if let Some(ref tx) = event_tx {
584 let _ = tx.send(end_event);
585 }
586
587 Ok(TaskResult {
588 output,
589 session_id,
590 agent: params.agent,
591 success,
592 task_id,
593 structured,
594 source_anchors,
595 })
596 }
597
598 pub fn execute_background(
606 self: Arc<Self>,
607 params: TaskParams,
608 event_tx: Option<broadcast::Sender<AgentEvent>>,
609 parent_session_id: Option<String>,
610 ) -> String {
611 let parent_cancellation = self.parent_cancellation.clone();
612 self.execute_background_with_parent_cancellation(
613 params,
614 event_tx,
615 parent_session_id,
616 parent_cancellation,
617 )
618 }
619
620 fn execute_background_with_parent_cancellation(
621 self: Arc<Self>,
622 params: TaskParams,
623 event_tx: Option<broadcast::Sender<AgentEvent>>,
624 parent_session_id: Option<String>,
625 parent_cancellation: Option<CancellationToken>,
626 ) -> String {
627 let task_id = format!("task-{}", uuid::Uuid::new_v4());
628 let session_id = format!("task-run-{}", task_id);
629 let failure_session_id = session_id.clone();
630 let failure_agent = params.agent.clone();
631 let start_event = AgentEvent::SubagentStart {
632 task_id: task_id.clone(),
633 session_id,
634 parent_session_id: parent_session_id.clone().unwrap_or_default(),
635 agent: params.agent.clone(),
636 description: params.description.clone(),
637 started_ms: epoch_ms(),
638 };
639 let security_provider = self
640 .parent_context
641 .as_ref()
642 .and_then(|context| context.security_provider.clone());
643 let start_event = security_provider
644 .as_deref()
645 .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
646 .unwrap_or(start_event);
647
648 if let Some(ref tx) = event_tx {
649 let _ = tx.send(start_event.clone());
650 }
651
652 let task_id_for_spawn = task_id.clone();
653 let task_id_for_log = task_id.clone();
654 tokio::spawn(async move {
655 if let Some(ref tracker) = self.subagent_tracker {
656 tracker.record_event(&start_event).await;
657 }
658 let failure_event_tx = event_tx.clone();
659 if let Err(error) = self
660 .execute_with_task_id_scoped(
661 task_id_for_spawn,
662 params,
663 event_tx,
664 parent_session_id.as_deref(),
665 false,
666 parent_cancellation.as_ref(),
667 )
668 .await
669 {
670 let end_event = AgentEvent::SubagentEnd {
671 task_id: task_id_for_log.clone(),
672 session_id: failure_session_id,
673 agent: failure_agent,
674 output: format!("Task failed before child execution started: {error}"),
675 success: false,
676 finished_ms: epoch_ms(),
677 };
678 let end_event = security_provider
679 .as_deref()
680 .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
681 .unwrap_or(end_event);
682 if let Some(ref tracker) = self.subagent_tracker {
683 tracker.record_event(&end_event).await;
684 tracker.clear_canceller(&task_id_for_log).await;
685 }
686 if let Some(tx) = failure_event_tx {
687 let _ = tx.send(end_event);
688 }
689 tracing::error!("Background task {} failed: {}", task_id_for_log, error);
690 }
691 });
692
693 task_id
694 }
695}
696
697fn structured_task_prompt(prompt: &str, schema: &serde_json::Value) -> String {
698 let schema = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
699 format!(
700 "{prompt}\n\n\
701 FINAL OUTPUT CONTRACT\n\
702 Complete the requested investigation before answering. Your final response must contain \
703 exactly one JSON value matching the JSON Schema below, with no Markdown fence or prose \
704 outside the JSON. This contract applies to the final response only; use the available \
705 tools as needed before finalizing.\n\n\
706 {schema}"
707 )
708}
709
710pub fn task_params_schema() -> serde_json::Value {
712 serde_json::json!({
713 "type": "object",
714 "additionalProperties": false,
715 "properties": {
716 "agent": {
717 "type": "string",
718 "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
719 },
720 "description": {
721 "type": "string",
722 "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
723 },
724 "prompt": {
725 "type": "string",
726 "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
727 },
728 "background": {
729 "type": "boolean",
730 "description": "Optional. Run the task in the background. Default: false.",
731 "default": false
732 },
733 "max_steps": {
734 "type": "integer",
735 "description": "Optional. Maximum number of steps for this task."
736 },
737 "output_schema": {
738 "type": "object",
739 "description": "Optional. JSON Schema object the delegated result must satisfy. When provided, the child output is coerced into a validated structured object and returned in metadata."
740 }
741 },
742 "required": ["agent", "description", "prompt"],
743 "examples": [
744 {
745 "agent": "explore",
746 "description": "Find Rust files",
747 "prompt": "Search the workspace for Rust files and summarize the layout."
748 },
749 {
750 "agent": "general",
751 "description": "Investigate test failure",
752 "prompt": "Inspect the failing tests and explain the root cause.",
753 "max_steps": 6
754 }
755 ]
756 })
757}
758
759pub struct TaskTool {
762 executor: Arc<TaskExecutor>,
763}
764
765impl TaskTool {
766 pub fn new(executor: Arc<TaskExecutor>) -> Self {
768 Self { executor }
769 }
770}
771
772#[async_trait]
773impl Tool for TaskTool {
774 fn name(&self) -> &str {
775 "task"
776 }
777
778 fn description(&self) -> &str {
779 "Delegate a bounded task to a specialized child run. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
780 }
781
782 fn parameters(&self) -> serde_json::Value {
783 task_params_schema()
784 }
785
786 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
787 let params: TaskParams =
788 serde_json::from_value(args.clone()).context("Invalid task parameters")?;
789 let parent_cancellation = ctx.cancellation_token();
790
791 if params.background {
792 let task_id = Arc::clone(&self.executor).execute_background_with_parent_cancellation(
793 params,
794 ctx.agent_event_tx.clone(),
795 ctx.session_id.clone(),
796 Some(parent_cancellation),
797 );
798 return Ok(ToolOutput::success(format!(
799 "Task started in background. Task ID: {}",
800 task_id
801 )));
802 }
803
804 let result = self
805 .executor
806 .execute_with_parent_cancellation(
807 params,
808 ctx.agent_event_tx.clone(),
809 ctx.session_id.as_deref(),
810 Some(&parent_cancellation),
811 )
812 .await?;
813 let (content, truncated) = format_task_result_for_context(&result);
814 let metadata = serde_json::json!({
815 "task_id": result.task_id,
816 "session_id": result.session_id,
817 "agent": result.agent,
818 "success": result.success,
819 "output_bytes": result.output.len(),
820 "truncated_for_context": truncated,
821 "artifact_id": task_artifact_id(&result),
822 "artifact_uri": task_artifact_uri(&result),
823 "structured": result.structured,
824 "source_anchors": result.source_anchors,
825 });
826
827 if result.success {
828 Ok(ToolOutput::success(content).with_metadata(metadata))
829 } else {
830 Ok(ToolOutput::error(content).with_metadata(metadata))
831 }
832 }
833}
834
835mod parallel_params;
836pub use parallel_params::{parallel_task_params_schema, ParallelTaskParams};
837
838pub struct ParallelTaskTool {
842 executor: Arc<TaskExecutor>,
843}
844
845impl ParallelTaskTool {
846 pub fn new(executor: Arc<TaskExecutor>) -> Self {
848 Self { executor }
849 }
850}
851
852#[async_trait]
853impl Tool for ParallelTaskTool {
854 fn name(&self) -> &str {
855 "parallel_task"
856 }
857
858 fn description(&self) -> &str {
859 "Fan out 2 or more INDEPENDENT subtasks as delegated child runs that execute concurrently; results are returned when all complete. By default any failed child makes the tool fail; evidence-gathering callers may set allow_partial_failure=true to continue when at least one child succeeds. Transient provider failures may be retried once only for explicitly read-only branches; successful and potentially mutating branches are never replayed. Use this only when the work genuinely splits into branches that can be investigated or implemented separately (e.g. inspect several unrelated modules at once, or run review and verification in parallel). Do NOT use it for trivial, conversational, or single-step requests, or for steps that depend on one another — handle those directly. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
860 }
861
862 fn parameters(&self) -> serde_json::Value {
863 parallel_task_params_schema()
864 }
865
866 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
867 let started_at = std::time::Instant::now();
868 let params: ParallelTaskParams =
869 serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
870 let parent_cancellation = ctx.cancellation_token();
871
872 if params.tasks.is_empty() {
873 return Ok(ToolOutput::error("No tasks provided".to_string()));
874 }
875 if params.tasks.len() > MAX_PARALLEL_TASKS_PER_CALL {
876 return Ok(ToolOutput::error(format!(
877 "parallel_task accepts at most {MAX_PARALLEL_TASKS_PER_CALL} tasks"
878 )));
879 }
880
881 let task_count = params.tasks.len();
882 let tasks = params.tasks.clone();
883
884 let mut run = self
885 .executor
886 .execute_parallel_for_tool(
887 tasks.clone(),
888 ctx.agent_event_tx.clone(),
889 parallel_execution::ParallelToolOptions {
890 parent_session_id: ctx.session_id.as_deref(),
891 timeout_ms: params.timeout_ms,
892 min_success_count: params.min_success_count,
893 allow_partial_failure: params.allow_partial_failure,
894 parent_cancellation: Some(&parent_cancellation),
895 },
896 )
897 .await;
898 let retry_summary = self
899 .executor
900 .retry_transient_parallel_failures(
901 &tasks,
902 parallel_execution::ParallelRetryOptions {
903 event_tx: ctx.agent_event_tx.clone(),
904 parent_session_id: ctx.session_id.as_deref(),
905 parent_cancellation: &parent_cancellation,
906 total_timeout_ms: params.timeout_ms,
907 started_at,
908 },
909 &mut run,
910 )
911 .await;
912 let results = run.results;
913
914 let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
916 let mut metadata_results = Vec::new();
917 let source_anchor_counts = parallel_source_anchor_counts(&results);
918 for (i, result) in results.iter().enumerate() {
919 let status = if result.success { "[OK]" } else { "[ERR]" };
920 let (formatted, truncated) = format_task_result_for_context(result);
921 let (output_excerpt, _) = compact_task_output(&result.output);
922 let source_anchors = &result.source_anchors[..source_anchor_counts[i]];
923 metadata_results.push(serde_json::json!({
924 "task_id": result.task_id,
925 "session_id": result.session_id,
926 "agent": result.agent,
927 "success": result.success,
928 "error_message": (!result.success).then(|| {
929 crate::text::truncate_utf8(&result.output, 1024).to_string()
930 }),
931 "output_excerpt": output_excerpt,
932 "structured": result.structured,
933 "source_anchors": source_anchors,
934 "output_bytes": result.output.len(),
935 "truncated_for_context": truncated,
936 "artifact_id": task_artifact_id(result),
937 "artifact_uri": task_artifact_uri(result),
938 "retry_attempts": retry_summary.attempts_by_index.get(i).copied().unwrap_or_default(),
939 }));
940 output.push_str(&format!(
941 "--- Task {} ({}) {} ---\n{}\n\n",
942 i + 1,
943 result.agent,
944 status,
945 formatted
946 ));
947 }
948
949 let success_count = results.iter().filter(|result| result.success).count();
950 let failed_count = results.len().saturating_sub(success_count);
951 let all_success = failed_count == 0;
952 let partial_failure = failed_count > 0 && success_count > 0;
953 if retry_summary.recovered_task_count > 0 {
954 output.push_str(&format!(
955 "Recovered {} transient read-only child failure(s) by retrying only failed branches.\n",
956 retry_summary.recovered_task_count
957 ));
958 }
959 if params.allow_partial_failure && partial_failure {
960 output.push_str(&format!(
961 "Partial failure tolerated: {success_count} succeeded, {failed_count} failed.\n"
962 ));
963 }
964 if run.timed_out {
965 output.push_str(&format!(
966 "Parallel task timed out after {} ms; returned completed child results and marked unfinished children failed.\n",
967 run.timeout_ms.unwrap_or_default()
968 ));
969 } else if run.returned_early {
970 output.push_str(&format!(
971 "Parallel task returned after reaching min_success_count={}; unfinished children were marked failed.\n",
972 run.min_success_count.unwrap_or_default()
973 ));
974 }
975
976 let tool_success = all_success || (params.allow_partial_failure && success_count > 0);
977 let mut output = if tool_success {
978 ToolOutput::success(output)
979 } else {
980 ToolOutput::error(output)
981 };
982 if !tool_success && failed_count > 0 {
983 output.error_kind = Some(crate::tools::ToolErrorKind::PartialFailure {
984 failed: failed_count,
985 total: results.len(),
986 });
987 }
988
989 Ok(output.with_metadata(serde_json::json!({
990 "task_count": task_count,
991 "result_count": results.len(),
992 "success_count": success_count,
993 "failed_count": failed_count,
994 "all_success": all_success,
995 "partial_failure": partial_failure,
996 "allow_partial_failure": params.allow_partial_failure,
997 "timeout_ms": params.timeout_ms,
998 "timed_out": run.timed_out,
999 "min_success_count": params.min_success_count,
1000 "returned_early": run.returned_early,
1001 "retry_attempt_count": retry_summary.retry_attempt_count,
1002 "retried_task_count": retry_summary.retried_task_count,
1003 "recovered_task_count": retry_summary.recovered_task_count,
1004 "duration_ms": started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1005 "results": metadata_results,
1006 })))
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests;