Skip to main content

a3s_code_core/tools/
task.rs

1//! Task tools for delegated child runs.
2//!
3//! The Task tool allows the main agent to delegate specialized work to focused
4//! child runs. Each child run gets bounded context and the permissions declared
5//! by its agent definition.
6//!
7//! ## Usage
8//!
9//! ```json
10//! {
11//!   "agent": "explore",
12//!   "description": "Find authentication code",
13//!   "prompt": "Search for files related to user authentication..."
14//! }
15//! ```
16
17use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::structured::{generate_blocking, StructuredMode, StructuredRequest};
19use crate::llm::LlmClient;
20use crate::mcp::manager::McpManager;
21use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome, ToolSourceAnchor};
22use crate::subagent::AgentRegistry;
23use crate::tools::types::{Tool, ToolContext, ToolOutput};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use futures::FutureExt;
27use serde::{Deserialize, Serialize};
28use std::any::Any;
29use std::panic::AssertUnwindSafe;
30use std::path::PathBuf;
31use std::sync::Arc;
32use tokio::sync::broadcast;
33use tokio::task::JoinSet;
34use tokio_util::sync::CancellationToken;
35
36const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
37const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
38const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
39const MAX_TASK_SOURCE_ANCHORS: usize = 64;
40const MAX_TASK_SOURCE_CANDIDATES: usize = MAX_TASK_SOURCE_ANCHORS * 4;
41const MAX_TASK_SOURCE_TOOL_BYTES: usize = 64;
42const MAX_TASK_SOURCE_VALUE_BYTES: usize = 4 * 1024;
43const MAX_PARALLEL_TASK_SOURCE_ANCHORS: usize = MAX_TASK_SOURCE_ANCHORS;
44
45/// Task tool parameters
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct TaskParams {
49    /// Agent type to use (explore, general, plan, verification, review, etc.)
50    pub agent: String,
51    /// Short description of the task (for display)
52    pub description: String,
53    /// Detailed prompt for the agent
54    pub prompt: String,
55    /// Optional: run in background (default: false)
56    #[serde(default)]
57    pub background: bool,
58    /// Optional: maximum steps for this task
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub max_steps: Option<usize>,
61    /// Optional: JSON schema the child result must satisfy.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub output_schema: Option<serde_json::Value>,
64}
65
66/// Task tool result
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TaskResult {
69    /// Task output from the delegated child run.
70    pub output: String,
71    /// Child session ID
72    pub session_id: String,
73    /// Agent type used
74    pub agent: String,
75    /// Whether the task succeeded
76    pub success: bool,
77    /// Task ID for tracking
78    pub task_id: String,
79    /// Structured child output validated against an optional output schema.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub structured: Option<serde_json::Value>,
82    /// Source locations observed by successful built-in child tool calls.
83    #[serde(default, skip_serializing_if = "Vec::is_empty")]
84    pub source_anchors: Vec<ToolSourceAnchor>,
85}
86
87mod result_projection;
88use result_projection::*;
89
90mod parallel_execution;
91
92/// Task executor for delegated child runs.
93pub struct TaskExecutor {
94    /// Agent registry for looking up agent definitions
95    registry: Arc<AgentRegistry>,
96    /// LLM client used to power child agent loops
97    llm_client: Arc<dyn LlmClient>,
98    /// Workspace path shared with child agents
99    workspace: String,
100    /// Ordered MCP managers for registering inherited tools in child sessions.
101    mcp_managers: Vec<Arc<McpManager>>,
102    /// Parent capabilities to inherit into child runs.
103    parent_context: Option<crate::child_run::ChildRunContext>,
104    /// Optional lifetime boundary inherited from the session that created this
105    /// executor. Keeping it on the executor prevents cached workflow/executor
106    /// handles from starting new child runs after their session is closed.
107    parent_cancellation: Option<CancellationToken>,
108    max_parallel_tasks: usize,
109    /// Optional shared tracker — when present each task registers a
110    /// `CancellationToken` so callers can cancel by `task_id`.
111    subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
112}
113
114impl TaskExecutor {
115    /// Create a new task executor
116    pub fn new(
117        registry: Arc<AgentRegistry>,
118        llm_client: Arc<dyn LlmClient>,
119        workspace: String,
120    ) -> Self {
121        Self {
122            registry,
123            llm_client,
124            workspace,
125            mcp_managers: Vec::new(),
126            parent_context: None,
127            parent_cancellation: None,
128            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
129            subagent_tracker: None,
130        }
131    }
132
133    /// Create a new task executor with MCP manager for tool inheritance
134    pub fn with_mcp(
135        registry: Arc<AgentRegistry>,
136        llm_client: Arc<dyn LlmClient>,
137        workspace: String,
138        mcp_manager: Arc<McpManager>,
139    ) -> Self {
140        Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
141    }
142
143    /// Create a task executor with ordered MCP capability sources.
144    pub fn with_mcp_managers(
145        registry: Arc<AgentRegistry>,
146        llm_client: Arc<dyn LlmClient>,
147        workspace: String,
148        mcp_managers: Vec<Arc<McpManager>>,
149    ) -> Self {
150        Self {
151            registry,
152            llm_client,
153            workspace,
154            mcp_managers,
155            parent_context: None,
156            parent_cancellation: None,
157            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
158            subagent_tracker: None,
159        }
160    }
161
162    /// Set parent session capabilities to inherit into child runs.
163    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
164        if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
165            self.max_parallel_tasks = max_parallel_tasks.max(1);
166        }
167        self.parent_context = Some(ctx);
168        self
169    }
170
171    /// Bind every run started by this executor to a parent lifetime.
172    ///
173    /// A token that is already cancelled makes execution fail before emitting
174    /// `SubagentStart` or performing MCP/LLM work. In-flight children derive
175    /// their own token so cancellation still cascades without granting them the
176    /// ability to cancel the parent.
177    pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
178        self.parent_cancellation = Some(cancellation);
179        self
180    }
181
182    pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
183        self.max_parallel_tasks = max_parallel_tasks.max(1);
184        self
185    }
186
187    /// Share a tracker with this executor. When set, each task registers
188    /// a `CancellationToken` against the tracker so the parent session
189    /// can cancel by `task_id`.
190    pub fn with_subagent_tracker(
191        mut self,
192        tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
193    ) -> Self {
194        self.subagent_tracker = Some(tracker);
195        self
196    }
197
198    /// Execute a task by spawning an isolated child AgentLoop.
199    ///
200    /// `parent_session_id` flows into the emitted `SubagentStart`/`SubagentEnd`
201    /// events so dashboards can associate child runs with the parent session.
202    pub async fn execute(
203        &self,
204        params: TaskParams,
205        event_tx: Option<broadcast::Sender<AgentEvent>>,
206        parent_session_id: Option<&str>,
207    ) -> Result<TaskResult> {
208        self.execute_with_parent_cancellation(
209            params,
210            event_tx,
211            parent_session_id,
212            self.parent_cancellation.as_ref(),
213        )
214        .await
215    }
216
217    async fn execute_with_parent_cancellation(
218        &self,
219        params: TaskParams,
220        event_tx: Option<broadcast::Sender<AgentEvent>>,
221        parent_session_id: Option<&str>,
222        parent_cancellation: Option<&CancellationToken>,
223    ) -> Result<TaskResult> {
224        let task_id = format!("task-{}", uuid::Uuid::new_v4());
225        self.execute_with_task_id_scoped(
226            task_id,
227            params,
228            event_tx,
229            parent_session_id,
230            true,
231            parent_cancellation,
232        )
233        .await
234    }
235
236    /// Execute a task using a caller-supplied task id. Used by `execute_background`
237    /// so the synchronously-returned task id matches the one in lifecycle events.
238    /// When `emit_start` is `false` the caller is responsible for emitting
239    /// `SubagentStart` themselves (e.g. to avoid a race against a tracker query).
240    pub async fn execute_with_task_id(
241        &self,
242        task_id: String,
243        params: TaskParams,
244        event_tx: Option<broadcast::Sender<AgentEvent>>,
245        parent_session_id: Option<&str>,
246        emit_start: bool,
247    ) -> Result<TaskResult> {
248        self.execute_with_task_id_scoped(
249            task_id,
250            params,
251            event_tx,
252            parent_session_id,
253            emit_start,
254            self.parent_cancellation.as_ref(),
255        )
256        .await
257    }
258
259    async fn execute_with_task_id_scoped(
260        &self,
261        task_id: String,
262        params: TaskParams,
263        event_tx: Option<broadcast::Sender<AgentEvent>>,
264        parent_session_id: Option<&str>,
265        emit_start: bool,
266        parent_cancellation: Option<&CancellationToken>,
267    ) -> Result<TaskResult> {
268        if parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
269            anyhow::bail!("Operation cancelled by parent session");
270        }
271
272        let session_id = format!("task-run-{}", task_id);
273        let started_ms = epoch_ms();
274        let output_schema = params.output_schema.clone();
275
276        let agent = self
277            .registry
278            .get(&params.agent)
279            .context(format!("Unknown agent type: '{}'", params.agent))?;
280        let inherited_security_provider = self
281            .parent_context
282            .as_ref()
283            .and_then(|context| context.security_provider.clone());
284
285        if emit_start {
286            let event = AgentEvent::SubagentStart {
287                task_id: task_id.clone(),
288                session_id: session_id.clone(),
289                parent_session_id: parent_session_id.unwrap_or_default().to_string(),
290                agent: params.agent.clone(),
291                description: params.description.clone(),
292                started_ms,
293            };
294            let event = inherited_security_provider
295                .as_deref()
296                .map(|provider| crate::security::sanitize_agent_event(provider, &event))
297                .unwrap_or(event);
298            if let Some(ref tracker) = self.subagent_tracker {
299                tracker.record_event(&event).await;
300            }
301            if let Some(ref tx) = event_tx {
302                let _ = tx.send(event);
303            }
304        }
305
306        // Build a child ToolExecutor. Task tools are intentionally omitted
307        // here to prevent unlimited delegation nesting.
308        let child_executor = if let Some(ref parent_ctx) = self.parent_context {
309            if let Some(ref services) = parent_ctx.workspace_services {
310                crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
311                    self.workspace.clone(),
312                    Arc::clone(services),
313                    crate::tools::ArtifactStoreLimits::default(),
314                )
315            } else {
316                crate::tools::ToolExecutor::new(self.workspace.clone())
317            }
318        } else {
319            crate::tools::ToolExecutor::new(self.workspace.clone())
320        };
321
322        // Register MCP tools so child agents can access MCP servers.
323        for mcp in &self.mcp_managers {
324            let all_tools = match parent_cancellation {
325                Some(cancellation) => {
326                    tokio::select! {
327                        biased;
328                        _ = cancellation.cancelled() => {
329                            anyhow::bail!("Operation cancelled by parent session");
330                        }
331                        tools = mcp.get_all_tools() => tools,
332                    }
333                }
334                None => mcp.get_all_tools().await,
335            };
336            let mut by_server: std::collections::HashMap<
337                String,
338                Vec<crate::mcp::protocol::McpTool>,
339            > = std::collections::HashMap::new();
340            for (server, tool) in all_tools {
341                by_server.entry(server).or_default().push(tool);
342            }
343            for (server_name, tools) in by_server {
344                let wrappers =
345                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
346                for wrapper in wrappers {
347                    child_executor.register_dynamic_tool(wrapper);
348                }
349            }
350        }
351
352        let child_executor = Arc::new(child_executor);
353
354        let mut child_config = AgentConfig {
355            tools: child_executor.definitions(),
356            ..AgentConfig::default()
357        };
358        agent.apply_to(&mut child_config);
359        if let Some(ref parent_ctx) = self.parent_context {
360            parent_ctx.apply_to(&mut child_config);
361        }
362        if let Some(max_steps) = params.max_steps {
363            child_config.max_tool_rounds = max_steps;
364        }
365        let child_security_provider = child_config.security_provider.clone();
366        let source_security_provider = child_security_provider.clone();
367
368        let cancel_token = parent_cancellation
369            .map(CancellationToken::child_token)
370            .unwrap_or_default();
371        let mut tool_context = ToolContext::new(PathBuf::from(&self.workspace))
372            .with_session_id(session_id.clone())
373            .with_cancellation(cancel_token.clone());
374        if let Some(ref parent_ctx) = self.parent_context {
375            if let Some(ref services) = parent_ctx.workspace_services {
376                tool_context = tool_context.with_workspace_services(Arc::clone(services));
377            }
378        }
379
380        let source_context = tool_context.clone();
381        let agent_loop = AgentLoop::new(
382            Arc::clone(&self.llm_client),
383            child_executor,
384            tool_context,
385            child_config,
386        );
387
388        // Always observe the child event stream so successful source tool calls
389        // survive in TaskResult metadata even when nobody subscribed to live
390        // progress. Forward the same events when a parent broadcast exists.
391        let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
392        let broadcast_tx = event_tx.clone();
393        let progress_task_id = task_id.clone();
394        let progress_session_id = session_id.clone();
395        let child_event_forwarder = tokio::spawn(async move {
396            let mut source_anchors = Vec::new();
397            let mut seen_source_anchors = std::collections::HashSet::new();
398            let mut scanned_source_candidates = 0usize;
399            while let Some(event) = mpsc_rx.recv().await {
400                let event = source_security_provider
401                    .as_deref()
402                    .map(|provider| crate::security::sanitize_agent_event(provider, &event))
403                    .unwrap_or(event);
404                collect_tool_source_anchors(
405                    &event,
406                    &source_context,
407                    &mut source_anchors,
408                    &mut seen_source_anchors,
409                    &mut scanned_source_candidates,
410                );
411                if let Some(ref broadcast_tx) = broadcast_tx {
412                    if let Some(progress) = synthesize_subagent_progress(
413                        &event,
414                        &progress_task_id,
415                        &progress_session_id,
416                    ) {
417                        let _ = broadcast_tx.send(progress);
418                    }
419                    let _ = broadcast_tx.send(event);
420                }
421            }
422            source_anchors
423        });
424        let child_event_tx = Some(mpsc_tx);
425        let child_llm_event_tx = child_event_tx.clone();
426
427        // Register a CancellationToken with the tracker (if shared) so the
428        // parent session's `cancel_subagent_task` can interrupt this run.
429        if let Some(ref tracker) = self.subagent_tracker {
430            tracker
431                .register_canceller(&task_id, cancel_token.clone())
432                .await;
433        }
434
435        let (mut output, mut success) = match agent_loop
436            .execute_with_session(
437                &[],
438                &params.prompt,
439                Some(&session_id),
440                child_event_tx,
441                Some(&cancel_token),
442            )
443            .await
444        {
445            Ok(result) => (result.text, true),
446            Err(e) if cancel_token.is_cancelled() => {
447                (format!("Task cancelled by caller: {}", e), false)
448            }
449            Err(e) => (format!("Task failed: {}", e), false),
450        };
451
452        let mut structured = None;
453        if success {
454            if let Some(schema) = output_schema {
455                let llm_client = agent_loop.scoped_llm_client_for_parts(
456                    Some(&session_id),
457                    &child_llm_event_tx,
458                    &cancel_token,
459                );
460                match Self::coerce_to_schema(&*llm_client, &output, schema, &cancel_token).await {
461                    Ok(object) => structured = Some(object),
462                    Err(error) => {
463                        success = false;
464                        output = format!("{output}\n\n[structured output failed: {error}]");
465                    }
466                }
467            }
468        }
469        if let Some(provider) = child_security_provider.as_deref() {
470            output = provider.sanitize_output(&output);
471            if let Some(value) = &mut structured {
472                *value = sanitize_task_json(provider, value);
473            }
474        }
475
476        // The child loop and optional structured-output pass are the only
477        // producers. Close their sender and drain the bridge before emitting
478        // SubagentEnd so callers never observe a terminal event followed by
479        // stale child deltas or progress events.
480        drop(child_llm_event_tx);
481        let source_anchors = match child_event_forwarder.await {
482            Ok(source_anchors) => source_anchors,
483            Err(error) => {
484                tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
485                Vec::new()
486            }
487        };
488
489        let end_event = AgentEvent::SubagentEnd {
490            task_id: task_id.clone(),
491            session_id: session_id.clone(),
492            agent: params.agent.clone(),
493            output: output.clone(),
494            success,
495            finished_ms: epoch_ms(),
496        };
497        if let Some(ref tracker) = self.subagent_tracker {
498            // The tracker is authoritative even when a background child
499            // finishes after the parent run's event forwarder has closed.
500            if success {
501                tracker
502                    .record_source_anchors(&task_id, &source_anchors)
503                    .await;
504            }
505            tracker.record_event(&end_event).await;
506            tracker.clear_canceller(&task_id).await;
507        }
508        if let Some(ref tx) = event_tx {
509            let _ = tx.send(end_event);
510        }
511
512        Ok(TaskResult {
513            output,
514            session_id,
515            agent: params.agent,
516            success,
517            task_id,
518            structured,
519            source_anchors,
520        })
521    }
522
523    /// Execute a task in the background.
524    ///
525    /// Returns immediately with the task ID; the same id is used in the emitted
526    /// `SubagentStart`/`SubagentEnd` events so callers can correlate. Pre-emits
527    /// `SubagentStart` synchronously when an event channel is available so a
528    /// caller that queries the subagent task tracker right after this call
529    /// observes the task in `Running` state without a race window.
530    pub fn execute_background(
531        self: Arc<Self>,
532        params: TaskParams,
533        event_tx: Option<broadcast::Sender<AgentEvent>>,
534        parent_session_id: Option<String>,
535    ) -> String {
536        let parent_cancellation = self.parent_cancellation.clone();
537        self.execute_background_with_parent_cancellation(
538            params,
539            event_tx,
540            parent_session_id,
541            parent_cancellation,
542        )
543    }
544
545    fn execute_background_with_parent_cancellation(
546        self: Arc<Self>,
547        params: TaskParams,
548        event_tx: Option<broadcast::Sender<AgentEvent>>,
549        parent_session_id: Option<String>,
550        parent_cancellation: Option<CancellationToken>,
551    ) -> String {
552        let task_id = format!("task-{}", uuid::Uuid::new_v4());
553        let session_id = format!("task-run-{}", task_id);
554        let failure_session_id = session_id.clone();
555        let failure_agent = params.agent.clone();
556        let start_event = AgentEvent::SubagentStart {
557            task_id: task_id.clone(),
558            session_id,
559            parent_session_id: parent_session_id.clone().unwrap_or_default(),
560            agent: params.agent.clone(),
561            description: params.description.clone(),
562            started_ms: epoch_ms(),
563        };
564        let security_provider = self
565            .parent_context
566            .as_ref()
567            .and_then(|context| context.security_provider.clone());
568        let start_event = security_provider
569            .as_deref()
570            .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
571            .unwrap_or(start_event);
572
573        if let Some(ref tx) = event_tx {
574            let _ = tx.send(start_event.clone());
575        }
576
577        let task_id_for_spawn = task_id.clone();
578        let task_id_for_log = task_id.clone();
579        tokio::spawn(async move {
580            if let Some(ref tracker) = self.subagent_tracker {
581                tracker.record_event(&start_event).await;
582            }
583            let failure_event_tx = event_tx.clone();
584            if let Err(error) = self
585                .execute_with_task_id_scoped(
586                    task_id_for_spawn,
587                    params,
588                    event_tx,
589                    parent_session_id.as_deref(),
590                    false,
591                    parent_cancellation.as_ref(),
592                )
593                .await
594            {
595                let end_event = AgentEvent::SubagentEnd {
596                    task_id: task_id_for_log.clone(),
597                    session_id: failure_session_id,
598                    agent: failure_agent,
599                    output: format!("Task failed before child execution started: {error}"),
600                    success: false,
601                    finished_ms: epoch_ms(),
602                };
603                let end_event = security_provider
604                    .as_deref()
605                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
606                    .unwrap_or(end_event);
607                if let Some(ref tracker) = self.subagent_tracker {
608                    tracker.record_event(&end_event).await;
609                    tracker.clear_canceller(&task_id_for_log).await;
610                }
611                if let Some(tx) = failure_event_tx {
612                    let _ = tx.send(end_event);
613                }
614                tracing::error!("Background task {} failed: {}", task_id_for_log, error);
615            }
616        });
617
618        task_id
619    }
620}
621
622/// Get the JSON schema for TaskParams
623pub fn task_params_schema() -> serde_json::Value {
624    serde_json::json!({
625        "type": "object",
626        "additionalProperties": false,
627        "properties": {
628            "agent": {
629                "type": "string",
630                "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
631            },
632            "description": {
633                "type": "string",
634                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
635            },
636            "prompt": {
637                "type": "string",
638                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
639            },
640            "background": {
641                "type": "boolean",
642                "description": "Optional. Run the task in the background. Default: false.",
643                "default": false
644            },
645            "max_steps": {
646                "type": "integer",
647                "description": "Optional. Maximum number of steps for this task."
648            },
649            "output_schema": {
650                "type": "object",
651                "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."
652            }
653        },
654        "required": ["agent", "description", "prompt"],
655        "examples": [
656            {
657                "agent": "explore",
658                "description": "Find Rust files",
659                "prompt": "Search the workspace for Rust files and summarize the layout."
660            },
661            {
662                "agent": "general",
663                "description": "Investigate test failure",
664                "prompt": "Inspect the failing tests and explain the root cause.",
665                "max_steps": 6
666            }
667        ]
668    })
669}
670
671/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
672/// This allows the LLM to delegate tasks through the standard tool interface.
673pub struct TaskTool {
674    executor: Arc<TaskExecutor>,
675}
676
677impl TaskTool {
678    /// Create a new TaskTool
679    pub fn new(executor: Arc<TaskExecutor>) -> Self {
680        Self { executor }
681    }
682}
683
684#[async_trait]
685impl Tool for TaskTool {
686    fn name(&self) -> &str {
687        "task"
688    }
689
690    fn description(&self) -> &str {
691        "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."
692    }
693
694    fn parameters(&self) -> serde_json::Value {
695        task_params_schema()
696    }
697
698    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
699        let params: TaskParams =
700            serde_json::from_value(args.clone()).context("Invalid task parameters")?;
701        let parent_cancellation = ctx.cancellation_token();
702
703        if params.background {
704            let task_id = Arc::clone(&self.executor).execute_background_with_parent_cancellation(
705                params,
706                ctx.agent_event_tx.clone(),
707                ctx.session_id.clone(),
708                Some(parent_cancellation),
709            );
710            return Ok(ToolOutput::success(format!(
711                "Task started in background. Task ID: {}",
712                task_id
713            )));
714        }
715
716        let result = self
717            .executor
718            .execute_with_parent_cancellation(
719                params,
720                ctx.agent_event_tx.clone(),
721                ctx.session_id.as_deref(),
722                Some(&parent_cancellation),
723            )
724            .await?;
725        let (content, truncated) = format_task_result_for_context(&result);
726        let metadata = serde_json::json!({
727            "task_id": result.task_id,
728            "session_id": result.session_id,
729            "agent": result.agent,
730            "success": result.success,
731            "output_bytes": result.output.len(),
732            "truncated_for_context": truncated,
733            "artifact_id": task_artifact_id(&result),
734            "artifact_uri": task_artifact_uri(&result),
735            "structured": result.structured,
736            "source_anchors": result.source_anchors,
737        });
738
739        if result.success {
740            Ok(ToolOutput::success(content).with_metadata(metadata))
741        } else {
742            Ok(ToolOutput::error(content).with_metadata(metadata))
743        }
744    }
745}
746
747/// Parameters for parallel task execution
748#[derive(Debug, Clone, Serialize, Deserialize)]
749#[serde(deny_unknown_fields)]
750pub struct ParallelTaskParams {
751    /// List of tasks to execute concurrently
752    pub tasks: Vec<TaskParams>,
753    /// When true, return a successful tool result if at least one child task
754    /// succeeds. Failed child results are still included in content and metadata.
755    #[serde(default)]
756    pub allow_partial_failure: bool,
757    /// Optional total wall-clock timeout for collecting child results.
758    ///
759    /// When the timeout expires, completed child results are returned and any
760    /// unfinished child is marked as failed in the metadata.
761    #[serde(default, alias = "timeoutMs", skip_serializing_if = "Option::is_none")]
762    pub timeout_ms: Option<u64>,
763    /// Optional successful child count that is sufficient for the caller.
764    ///
765    /// This only enables early return when `allow_partial_failure` is true; the
766    /// default remains the barrier behavior of waiting for every child.
767    #[serde(
768        default,
769        alias = "minSuccessCount",
770        skip_serializing_if = "Option::is_none"
771    )]
772    pub min_success_count: Option<usize>,
773}
774
775/// Get the JSON schema for ParallelTaskParams
776pub fn parallel_task_params_schema() -> serde_json::Value {
777    serde_json::json!({
778        "type": "object",
779        "additionalProperties": false,
780        "properties": {
781            "tasks": {
782                "type": "array",
783                "description": "List of tasks to execute in parallel. Each task runs as an independent delegated child run concurrently.",
784                "items": {
785                    "type": "object",
786                    "additionalProperties": false,
787                    "properties": {
788                        "agent": {
789                            "type": "string",
790                            "description": "Required. Canonical agent type for this task."
791                        },
792                        "description": {
793                            "type": "string",
794                            "description": "Required. Short task label for display and tracking."
795                        },
796                        "prompt": {
797                            "type": "string",
798                            "description": "Required. Detailed instruction for the delegated child run."
799                        },
800                        "background": {
801                            "type": "boolean",
802                            "description": "Optional. Run this delegated child task in the background. Default: false.",
803                            "default": false
804                        },
805                        "max_steps": {
806                            "type": "integer",
807                            "description": "Optional. Maximum number of tool/model steps for this delegated child task."
808                        },
809                        "output_schema": {
810                            "type": "object",
811                            "description": "Optional. JSON Schema object the delegated child result must satisfy. When provided, the validated object is returned in each result's metadata."
812                        }
813                    },
814                    "required": ["agent", "description", "prompt"]
815                },
816                "minItems": 1
817            },
818            "allow_partial_failure": {
819                "type": "boolean",
820                "description": "Optional. Defaults to false. When true, the parallel_task tool succeeds if at least one child task succeeds, while preserving failed child results in the output and metadata.",
821                "default": false
822            },
823            "timeout_ms": {
824                "type": "integer",
825                "minimum": 1,
826                "description": "Optional total timeout in milliseconds. On timeout, completed child results are returned and unfinished children are marked failed."
827            },
828            "min_success_count": {
829                "type": "integer",
830                "minimum": 1,
831                "description": "Optional successful child count that is enough to return early. Early return is only used when allow_partial_failure is true."
832            }
833        },
834        "required": ["tasks"],
835        "examples": [
836            {
837                "tasks": [
838                    {
839                        "agent": "explore",
840                        "description": "Find Rust files",
841                        "prompt": "List Rust files under src/."
842                    },
843                    {
844                        "agent": "explore",
845                        "description": "Find tests",
846                        "prompt": "List test files and summarize their purpose."
847                    }
848                ]
849            }
850        ]
851    })
852}
853
854/// ParallelTaskTool allows the LLM to fan out multiple delegated tasks concurrently.
855///
856/// All tasks execute in parallel and the tool returns when all complete.
857pub struct ParallelTaskTool {
858    executor: Arc<TaskExecutor>,
859}
860
861impl ParallelTaskTool {
862    /// Create a new ParallelTaskTool
863    pub fn new(executor: Arc<TaskExecutor>) -> Self {
864        Self { executor }
865    }
866}
867
868#[async_trait]
869impl Tool for ParallelTaskTool {
870    fn name(&self) -> &str {
871        "parallel_task"
872    }
873
874    fn description(&self) -> &str {
875        "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. 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."
876    }
877
878    fn parameters(&self) -> serde_json::Value {
879        parallel_task_params_schema()
880    }
881
882    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
883        let params: ParallelTaskParams =
884            serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
885        let parent_cancellation = ctx.cancellation_token();
886
887        if params.tasks.is_empty() {
888            return Ok(ToolOutput::error("No tasks provided".to_string()));
889        }
890
891        let task_count = params.tasks.len();
892
893        let run = self
894            .executor
895            .execute_parallel_for_tool(
896                params.tasks,
897                ctx.agent_event_tx.clone(),
898                parallel_execution::ParallelToolOptions {
899                    parent_session_id: ctx.session_id.as_deref(),
900                    timeout_ms: params.timeout_ms,
901                    min_success_count: params.min_success_count,
902                    allow_partial_failure: params.allow_partial_failure,
903                    parent_cancellation: Some(&parent_cancellation),
904                },
905            )
906            .await;
907        let results = run.results;
908
909        // Format results with compact per-task excerpts for parent context.
910        let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
911        let mut metadata_results = Vec::new();
912        let source_anchor_counts = parallel_source_anchor_counts(&results);
913        for (i, result) in results.iter().enumerate() {
914            let status = if result.success { "[OK]" } else { "[ERR]" };
915            let (formatted, truncated) = format_task_result_for_context(result);
916            let source_anchors = &result.source_anchors[..source_anchor_counts[i]];
917            metadata_results.push(serde_json::json!({
918                "task_id": result.task_id,
919                "session_id": result.session_id,
920                "agent": result.agent,
921                "success": result.success,
922                "output": formatted.clone(),
923                "structured": result.structured,
924                "source_anchors": source_anchors,
925                "output_bytes": result.output.len(),
926                "truncated_for_context": truncated,
927                "artifact_id": task_artifact_id(result),
928                "artifact_uri": task_artifact_uri(result),
929            }));
930            output.push_str(&format!(
931                "--- Task {} ({}) {} ---\n{}\n\n",
932                i + 1,
933                result.agent,
934                status,
935                formatted
936            ));
937        }
938
939        let success_count = results.iter().filter(|result| result.success).count();
940        let failed_count = results.len().saturating_sub(success_count);
941        let all_success = failed_count == 0;
942        let partial_failure = failed_count > 0 && success_count > 0;
943        if params.allow_partial_failure && partial_failure {
944            output.push_str(&format!(
945                "Partial failure tolerated: {success_count} succeeded, {failed_count} failed.\n"
946            ));
947        }
948        if run.timed_out {
949            output.push_str(&format!(
950                "Parallel task timed out after {} ms; returned completed child results and marked unfinished children failed.\n",
951                run.timeout_ms.unwrap_or_default()
952            ));
953        } else if run.returned_early {
954            output.push_str(&format!(
955                "Parallel task returned after reaching min_success_count={}; unfinished children were marked failed.\n",
956                run.min_success_count.unwrap_or_default()
957            ));
958        }
959
960        let tool_success = all_success || (params.allow_partial_failure && success_count > 0);
961        let output = if tool_success {
962            ToolOutput::success(output)
963        } else {
964            ToolOutput::error(output)
965        };
966
967        Ok(output.with_metadata(serde_json::json!({
968            "task_count": task_count,
969            "result_count": results.len(),
970            "success_count": success_count,
971            "failed_count": failed_count,
972            "all_success": all_success,
973            "partial_failure": partial_failure,
974            "allow_partial_failure": params.allow_partial_failure,
975            "timeout_ms": params.timeout_ms,
976            "timed_out": run.timed_out,
977            "min_success_count": params.min_success_count,
978            "returned_early": run.returned_early,
979            "results": metadata_results,
980        })))
981    }
982}
983
984#[cfg(test)]
985mod tests;