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 one or more specialized
4//! tasks to focused child runs. Each child run gets bounded context and the
5//! permissions declared by its agent definition.
6//!
7//! ## Usage
8//!
9//! ```json
10//! {"tasks": [{
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::{
19    generate_blocking, parse_validated_output, StructuredMode, StructuredRequest,
20};
21use crate::llm::{LlmClient, ToolDefinition};
22use crate::mcp::{McpBinding, McpManager};
23use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome, ToolSourceAnchor};
24use crate::subagent::{AgentDefinition, 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;
46const TASK_TOOL_DESCRIPTION: &str = "Delegate one or more bounded tasks to specialized child runs. Pass one item for a focused child run or multiple INDEPENDENT items for concurrent fan-out. A single item may run in the background; multi-item calls are collected by the parent. By default any failed child makes a multi-item call fail; evidence-gathering callers may set allow_partial_failure=true. Choose canonical worker names from the live agent catalog. Custom agents from agent_dirs and .a3s/agents are supported; .claude/agents is read for compatibility.";
47const PARALLEL_TASK_TOOL_DESCRIPTION: &str = "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. Child output never authorizes branch replay; provider and child runtimes own any typed retry policy below this boundary. Use this only when the work genuinely splits into branches that can be investigated or implemented separately. Do not use it for trivial, conversational, single-step, or dependent work. Choose canonical worker names from the live agent catalog.";
48
49/// Task tool parameters
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TaskParams {
53    /// Agent type to use (explore, general, plan, verification, review, etc.)
54    pub agent: String,
55    /// Short description of the task (for display)
56    pub description: String,
57    /// Detailed prompt for the agent
58    pub prompt: String,
59    /// Optional: run in background (default: false)
60    #[serde(default)]
61    pub background: bool,
62    /// Optional: maximum steps for this task
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub max_steps: Option<usize>,
65    /// Optional: JSON schema the child result must satisfy.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub output_schema: Option<serde_json::Value>,
68}
69
70/// Task tool result
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TaskResult {
73    /// Task output from the delegated child run.
74    pub output: String,
75    /// Child session ID
76    pub session_id: String,
77    /// Agent type used
78    pub agent: String,
79    /// Whether the task succeeded
80    pub success: bool,
81    /// Task ID for tracking
82    pub task_id: String,
83    /// Structured child output validated against an optional output schema.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub structured: Option<serde_json::Value>,
86    /// Source locations observed by successful built-in child tool calls.
87    #[serde(default, skip_serializing_if = "Vec::is_empty")]
88    pub source_anchors: Vec<ToolSourceAnchor>,
89}
90
91struct ScopedTaskExecution<'a> {
92    event_tx: Option<broadcast::Sender<AgentEvent>>,
93    parent_session_id: Option<&'a str>,
94    emit_start: bool,
95    parent_cancellation: Option<&'a CancellationToken>,
96    admitted_capability_subtask: Option<crate::capability::AgentCapabilitySubtask>,
97}
98
99mod result_projection;
100use result_projection::*;
101
102mod parallel_execution;
103
104const MAX_PARALLEL_TASKS_PER_CALL: usize = 32;
105
106/// Task executor for delegated child runs.
107#[derive(Clone)]
108pub struct TaskExecutor {
109    /// Agent registry for looking up agent definitions
110    registry: Arc<AgentRegistry>,
111    /// LLM client used to power child agent loops
112    llm_client: Arc<dyn LlmClient>,
113    /// Workspace path shared with child agents
114    workspace: String,
115    /// Ordered MCP managers for registering inherited tools in child sessions.
116    mcp_managers: Vec<Arc<McpManager>>,
117    /// Exact projected MCP bindings inherited from the admitted parent Run.
118    mcp_bindings: Vec<Arc<McpBinding>>,
119    /// Exact host tools owned by this executor. They are installed only in
120    /// child executors and remain bounded by the composed parent/child
121    /// governance context.
122    scoped_tools: Vec<Arc<dyn Tool>>,
123    /// Parent capabilities to inherit into child runs.
124    parent_context: Option<crate::child_run::ChildRunContext>,
125    /// Search configuration captured from the invoking parent context.
126    search_config: Option<Arc<crate::config::SearchConfig>>,
127    /// Agent-scoped search admission shared with delegated child runs.
128    search_bulkhead: Option<a3s_search::Bulkhead>,
129    /// Agent-scoped headless retry allowance shared with delegated child runs.
130    search_retry_budget: Option<a3s_search::RetryBudget>,
131    /// Parent-session request flights shared with delegated child runs.
132    search_request_coalescer: Option<a3s_search::SearchCoalescer>,
133    /// Weak capability parents captured from the invoking Tool Turn.
134    capability_context: Option<crate::capability::AgentToolCapabilityContext>,
135    /// Optional lifetime boundary inherited from the session that created this
136    /// executor. Keeping it on the executor prevents cached workflow/executor
137    /// handles from starting new child runs after their session is closed.
138    parent_cancellation: Option<CancellationToken>,
139    max_parallel_tasks: usize,
140    /// Shared across every fan-out started by this executor. Per-call wave
141    /// limits alone are insufficient when a dynamic workflow launches several
142    /// `parallel_task` host steps concurrently.
143    parallel_permits: Arc<tokio::sync::Semaphore>,
144    /// Optional shared tracker — when present each task registers a
145    /// `CancellationToken` so callers can cancel by `task_id`.
146    subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
147    /// Agent-wide scheduler for independent delegated work.
148    task_scheduler: Option<Arc<crate::task_scheduler::TaskScheduler>>,
149    /// Host-started workflow executors have no parent lease, so their
150    /// foreground steps must be admitted independently. Model-invoked task
151    /// tools inherit the enclosing run's lease and leave this false.
152    schedule_foreground: bool,
153}
154
155impl TaskExecutor {
156    /// Create a new task executor
157    pub fn new(
158        registry: Arc<AgentRegistry>,
159        llm_client: Arc<dyn LlmClient>,
160        workspace: String,
161    ) -> Self {
162        Self {
163            registry,
164            llm_client,
165            workspace,
166            mcp_managers: Vec::new(),
167            mcp_bindings: Vec::new(),
168            scoped_tools: Vec::new(),
169            parent_context: None,
170            search_config: None,
171            search_bulkhead: None,
172            search_retry_budget: None,
173            search_request_coalescer: None,
174            capability_context: None,
175            parent_cancellation: None,
176            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
177            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
178                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
179            )),
180            subagent_tracker: None,
181            task_scheduler: None,
182            schedule_foreground: false,
183        }
184    }
185
186    /// Create a new task executor with MCP manager for tool inheritance
187    pub fn with_mcp(
188        registry: Arc<AgentRegistry>,
189        llm_client: Arc<dyn LlmClient>,
190        workspace: String,
191        mcp_manager: Arc<McpManager>,
192    ) -> Self {
193        Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
194    }
195
196    /// Create a task executor with ordered MCP capability sources.
197    pub fn with_mcp_managers(
198        registry: Arc<AgentRegistry>,
199        llm_client: Arc<dyn LlmClient>,
200        workspace: String,
201        mcp_managers: Vec<Arc<McpManager>>,
202    ) -> Self {
203        Self {
204            registry,
205            llm_client,
206            workspace,
207            mcp_managers,
208            mcp_bindings: Vec::new(),
209            scoped_tools: Vec::new(),
210            parent_context: None,
211            search_config: None,
212            search_bulkhead: None,
213            search_retry_budget: None,
214            search_request_coalescer: None,
215            capability_context: None,
216            parent_cancellation: None,
217            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
218            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
219                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
220            )),
221            subagent_tracker: None,
222            task_scheduler: None,
223            schedule_foreground: false,
224        }
225    }
226
227    /// Add immutable MCP bindings already admitted by the parent capability
228    /// Run. These bindings are never rediscovered through a manager.
229    pub(crate) fn with_projected_mcp_bindings(mut self, bindings: Vec<Arc<McpBinding>>) -> Self {
230        self.mcp_bindings = bindings;
231        self
232    }
233
234    /// Install exact host-provided tools only in child runs created by this
235    /// executor. Registration does not grant invocation authority: every call
236    /// still crosses composed parent and child governance. A name collision
237    /// with another child capability fails before model execution.
238    pub fn with_scoped_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
239        self.scoped_tools = tools;
240        self
241    }
242
243    /// Set parent session capabilities to inherit into child runs.
244    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
245        if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
246            let max_parallel_tasks = max_parallel_tasks.max(1);
247            self.max_parallel_tasks = max_parallel_tasks;
248            self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
249        }
250        self.parent_context = Some(ctx);
251        self
252    }
253
254    fn scoped_for_invocation(self: &Arc<Self>, ctx: &ToolContext) -> Arc<Self> {
255        let mut scoped = self.as_ref().clone();
256        scoped.search_config = ctx.search_config.clone();
257        scoped.search_bulkhead = Some(ctx.search_bulkhead());
258        scoped.search_retry_budget = Some(ctx.search_retry_budget());
259        scoped.search_request_coalescer = Some(ctx.search_request_coalescer());
260        scoped.capability_context = ctx.capability_context();
261        if ctx.has_run_governance() {
262            scoped.parent_context = scoped.parent_context.take().map(|parent| {
263                parent.with_run_governance(
264                    ctx.run_permission_checker(),
265                    ctx.run_confirmation_manager(),
266                )
267            });
268        }
269        Arc::new(scoped)
270    }
271
272    fn child_tool_context(
273        &self,
274        session_id: String,
275        cancellation: CancellationToken,
276    ) -> ToolContext {
277        let mut context = ToolContext::new(PathBuf::from(&self.workspace))
278            .with_session_id(session_id)
279            .with_cancellation(cancellation);
280        if let (Some(bulkhead), Some(retry_budget)) =
281            (&self.search_bulkhead, &self.search_retry_budget)
282        {
283            context = context.with_search_runtime(bulkhead.clone(), retry_budget.clone());
284        }
285        if let Some(search_config) = &self.search_config {
286            context = context.with_search_config(search_config.as_ref().clone());
287        }
288        if let Some(coalescer) = &self.search_request_coalescer {
289            context = context.with_search_request_coalescer(coalescer.clone());
290        }
291        context
292    }
293
294    /// Bind every run started by this executor to a parent lifetime.
295    ///
296    /// A token that is already cancelled makes execution fail before emitting
297    /// `SubagentStart` or performing MCP/LLM work. In-flight children derive
298    /// their own token so cancellation still cascades without granting them the
299    /// ability to cancel the parent.
300    pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
301        self.parent_cancellation = Some(cancellation);
302        self
303    }
304
305    pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
306        let max_parallel_tasks = max_parallel_tasks.max(1);
307        self.max_parallel_tasks = max_parallel_tasks;
308        self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
309        self
310    }
311
312    /// Share a tracker with this executor. When set, each task registers
313    /// a `CancellationToken` against the tracker so the parent session
314    /// can cancel by `task_id`.
315    pub fn with_subagent_tracker(
316        mut self,
317        tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
318    ) -> Self {
319        self.subagent_tracker = Some(tracker);
320        self
321    }
322
323    /// Admit independent delegated work through the owning agent's scheduler.
324    pub fn with_task_scheduler(
325        mut self,
326        scheduler: Arc<crate::task_scheduler::TaskScheduler>,
327        schedule_foreground: bool,
328    ) -> Self {
329        self.task_scheduler = Some(scheduler);
330        self.schedule_foreground = schedule_foreground;
331        self
332    }
333
334    fn visible_agents(&self) -> Vec<AgentDefinition> {
335        self.registry.list_visible()
336    }
337
338    /// Execute a task by spawning an isolated child AgentLoop.
339    ///
340    /// `parent_session_id` flows into the emitted `SubagentStart`/`SubagentEnd`
341    /// events so dashboards can associate child runs with the parent session.
342    pub async fn execute(
343        &self,
344        params: TaskParams,
345        event_tx: Option<broadcast::Sender<AgentEvent>>,
346        parent_session_id: Option<&str>,
347    ) -> Result<TaskResult> {
348        self.execute_with_parent_cancellation(
349            params,
350            event_tx,
351            parent_session_id,
352            self.parent_cancellation.as_ref(),
353        )
354        .await
355    }
356
357    async fn execute_with_parent_cancellation(
358        &self,
359        params: TaskParams,
360        event_tx: Option<broadcast::Sender<AgentEvent>>,
361        parent_session_id: Option<&str>,
362        parent_cancellation: Option<&CancellationToken>,
363    ) -> Result<TaskResult> {
364        let task_id = format!("task-{}", uuid::Uuid::new_v4());
365        self.execute_with_task_id_scoped(
366            task_id,
367            params,
368            ScopedTaskExecution {
369                event_tx,
370                parent_session_id,
371                emit_start: true,
372                parent_cancellation,
373                admitted_capability_subtask: None,
374            },
375        )
376        .await
377    }
378
379    /// Execute a task using a caller-supplied task id. Used by `execute_background`
380    /// so the synchronously-returned task id matches the one in lifecycle events.
381    /// When `emit_start` is `false` the caller is responsible for emitting
382    /// `SubagentStart` themselves (e.g. to avoid a race against a tracker query).
383    pub async fn execute_with_task_id(
384        &self,
385        task_id: String,
386        params: TaskParams,
387        event_tx: Option<broadcast::Sender<AgentEvent>>,
388        parent_session_id: Option<&str>,
389        emit_start: bool,
390    ) -> Result<TaskResult> {
391        self.execute_with_task_id_scoped(
392            task_id,
393            params,
394            ScopedTaskExecution {
395                event_tx,
396                parent_session_id,
397                emit_start,
398                parent_cancellation: self.parent_cancellation.as_ref(),
399                admitted_capability_subtask: None,
400            },
401        )
402        .await
403    }
404
405    async fn execute_with_task_id_scoped(
406        &self,
407        task_id: String,
408        params: TaskParams,
409        execution: ScopedTaskExecution<'_>,
410    ) -> Result<TaskResult> {
411        let ScopedTaskExecution {
412            event_tx,
413            parent_session_id,
414            emit_start,
415            parent_cancellation,
416            admitted_capability_subtask,
417        } = execution;
418        let was_promoted = admitted_capability_subtask.is_some();
419        if !was_promoted && parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
420            anyhow::bail!("Operation cancelled by parent session");
421        }
422
423        let capability_subtask = match admitted_capability_subtask {
424            Some(subtask) => Some(subtask),
425            None => self
426                .capability_context
427                .as_ref()
428                .map(|context| context.admit_subtask(task_id.clone(), params.background))
429                .transpose()?,
430        };
431        let cancel_token = capability_subtask.as_ref().map_or_else(
432            || {
433                parent_cancellation
434                    .map(CancellationToken::child_token)
435                    .unwrap_or_default()
436            },
437            crate::capability::AgentCapabilitySubtask::cancellation,
438        );
439        let capability_runtime = capability_subtask
440            .as_ref()
441            .map(crate::capability::AgentCapabilitySubtask::runtime);
442        let execution = self
443            .execute_with_task_id_in_scope(
444                task_id,
445                params,
446                event_tx,
447                parent_session_id,
448                emit_start,
449                cancel_token,
450                capability_runtime,
451            )
452            .await;
453        let close = close_capability_subtask(capability_subtask.as_ref()).await;
454        match (execution, close) {
455            (Ok(result), Ok(())) => Ok(result),
456            (Ok(_), Err(close_error)) => Err(close_error),
457            (Err(error), Ok(())) => Err(error),
458            (Err(error), Err(close_error)) => {
459                tracing::warn!(
460                    error = %close_error,
461                    "Capability Subtask close also failed after delegated execution failure"
462                );
463                Err(error)
464            }
465        }
466    }
467
468    #[allow(clippy::too_many_arguments)]
469    async fn execute_with_task_id_in_scope(
470        &self,
471        task_id: String,
472        params: TaskParams,
473        event_tx: Option<broadcast::Sender<AgentEvent>>,
474        parent_session_id: Option<&str>,
475        emit_start: bool,
476        cancel_token: CancellationToken,
477        capability_runtime: Option<crate::capability::AgentCapabilityRuntime>,
478    ) -> Result<TaskResult> {
479        // Background callers receive the task id before this future starts.
480        // Register immediately so targeted cancellation also interrupts time
481        // spent waiting in the global scheduler.
482        if params.background {
483            if let Some(ref tracker) = self.subagent_tracker {
484                tracker
485                    .register_canceller(&task_id, cancel_token.clone())
486                    .await;
487            }
488        }
489        let _task_lease = if params.background || self.schedule_foreground {
490            match &self.task_scheduler {
491                Some(scheduler) => Some(
492                    scheduler
493                        .acquire(
494                            if params.background {
495                                crate::task_scheduler::TaskPriority::Background
496                            } else {
497                                crate::task_scheduler::TaskPriority::Foreground
498                            },
499                            format!(
500                                "{}:subagent:{}",
501                                parent_session_id.unwrap_or("host"),
502                                task_id
503                            ),
504                            &cancel_token,
505                        )
506                        .await
507                        .map_err(|error| anyhow::anyhow!(error))?,
508                ),
509                None => None,
510            }
511        } else {
512            None
513        };
514
515        let session_id = format!("task-run-{}", task_id);
516        let started_ms = epoch_ms();
517        let output_schema = params.output_schema.clone();
518
519        let agent = self
520            .registry
521            .get_arc(&params.agent)
522            .context(format!("Unknown agent type: '{}'", params.agent))?;
523        let tool_free = agent.tool_free;
524        let tool_free_system = agent.prompt.clone();
525        let inherited_security_provider = self
526            .parent_context
527            .as_ref()
528            .and_then(|context| context.security_provider.clone());
529
530        if emit_start {
531            let event = AgentEvent::SubagentStart {
532                task_id: task_id.clone(),
533                session_id: session_id.clone(),
534                parent_session_id: parent_session_id.unwrap_or_default().to_string(),
535                agent: params.agent.clone(),
536                description: params.description.clone(),
537                started_ms,
538            };
539            let event = inherited_security_provider
540                .as_deref()
541                .map(|provider| crate::security::sanitize_agent_event(provider, &event))
542                .unwrap_or(event);
543            if let Some(ref tracker) = self.subagent_tracker {
544                tracker.record_event(&event).await;
545            }
546            if let Some(ref tx) = event_tx {
547                let _ = tx.send(event);
548            }
549        }
550
551        // Build a child ToolExecutor. Task tools are intentionally omitted
552        // here to prevent unlimited delegation nesting.
553        let child_executor = if let Some(ref parent_ctx) = self.parent_context {
554            if let Some(ref services) = parent_ctx.workspace_services {
555                crate::tools::ToolExecutor::new_with_workspace_services_artifact_limits_and_immutable_content_adapter(
556                        self.workspace.clone(),
557                        Arc::clone(services),
558                        crate::tools::ArtifactStoreLimits::default(),
559                        parent_ctx.immutable_content_adapter.clone(),
560                    )
561            } else if let Some(adapter) = parent_ctx.immutable_content_adapter.clone() {
562                crate::tools::ToolExecutor::new_with_immutable_content_adapter(
563                    self.workspace.clone(),
564                    adapter,
565                )
566            } else {
567                crate::tools::ToolExecutor::new(self.workspace.clone())
568            }
569        } else {
570            crate::tools::ToolExecutor::new(self.workspace.clone())
571        };
572
573        // Register MCP tools so child agents can access MCP servers.
574        for mcp in &self.mcp_managers {
575            let all_tools = tokio::select! {
576                biased;
577                _ = cancel_token.cancelled() => {
578                    anyhow::bail!("Operation cancelled before child execution");
579                }
580                tools = mcp.get_all_tools() => tools,
581            };
582            let mut by_server: std::collections::HashMap<
583                String,
584                Vec<crate::mcp::protocol::McpTool>,
585            > = std::collections::HashMap::new();
586            for (server, tool) in all_tools {
587                by_server.entry(server).or_default().push(tool);
588            }
589            for (server_name, tools) in by_server {
590                let wrappers =
591                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
592                for wrapper in wrappers {
593                    child_executor.register_dynamic_tool(wrapper);
594                }
595            }
596        }
597        // Projected bindings are registered after compatibility sources. Run
598        // admission already proved their names conflict-free against its
599        // frozen parent executor. Registering the exact binding last prevents
600        // a later mutable manager refresh from replacing that generation in a
601        // delegated child.
602        for binding in &self.mcp_bindings {
603            if cancel_token.is_cancelled() {
604                anyhow::bail!("Operation cancelled before child execution");
605            }
606            for wrapper in binding.projected_tools() {
607                child_executor.register_dynamic_tool(wrapper);
608            }
609        }
610
611        // These exact Arc values belong only to this TaskExecutor. Install them
612        // after all inherited sources so no scoped tool can shadow a built-in,
613        // compatibility MCP tool, or Run-frozen projected binding.
614        for tool in &self.scoped_tools {
615            if !child_executor.register_dynamic_tool_if_absent(Arc::clone(tool)) {
616                anyhow::bail!(
617                    "Workflow-scoped tool '{}' conflicts with another child capability",
618                    tool.name()
619                );
620            }
621        }
622
623        let child_executor = Arc::new(child_executor);
624
625        let mut child_config = AgentConfig {
626            tools: child_executor.definitions(),
627            ..AgentConfig::default()
628        };
629        agent.apply_to(&mut child_config);
630        if let Some(ref parent_ctx) = self.parent_context {
631            parent_ctx.apply_to(&mut child_config);
632        }
633        // A delegated task is already the output of a parent planning
634        // decision. Running the generic pre-analysis/planning classifier again
635        // adds an unrelated LLM round to every child and can consume the whole
636        // fan-out deadline before any task tool runs.
637        child_config.planning_mode = crate::prompts::PlanningMode::Disabled;
638        if let Some(max_steps) = params.max_steps {
639            child_config.max_tool_rounds = max_steps;
640        }
641        let child_security_provider = child_config.security_provider.clone();
642        let source_security_provider = child_security_provider.clone();
643
644        let mut tool_context = self.child_tool_context(session_id.clone(), cancel_token.clone());
645        if let Some(ref parent_ctx) = self.parent_context {
646            if let Some(ref services) = parent_ctx.workspace_services {
647                tool_context = tool_context.with_workspace_services(Arc::clone(services));
648            }
649            if let Some(ref sandbox) = parent_ctx.sandbox_handle {
650                child_executor.registry().set_sandbox(Arc::clone(sandbox));
651                tool_context = tool_context.with_sandbox(Arc::clone(sandbox));
652            }
653        }
654
655        let source_context = tool_context.clone();
656        let mut agent_loop = AgentLoop::new(
657            Arc::clone(&self.llm_client),
658            child_executor,
659            tool_context,
660            child_config,
661        );
662        if let Some(runtime) = capability_runtime {
663            agent_loop = agent_loop.with_capability_runtime(runtime);
664        }
665
666        // Always observe the child event stream so successful source tool calls
667        // survive in TaskResult metadata even when nobody subscribed to live
668        // progress. Forward the same events when a parent broadcast exists.
669        let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
670        let broadcast_tx = event_tx.clone();
671        let progress_task_id = task_id.clone();
672        let progress_session_id = session_id.clone();
673        let child_event_forwarder = tokio::spawn(async move {
674            let mut source_anchors = Vec::new();
675            let mut seen_source_anchors = std::collections::HashSet::new();
676            let mut scanned_source_candidates = 0usize;
677            while let Some(event) = mpsc_rx.recv().await {
678                let event = source_security_provider
679                    .as_deref()
680                    .map(|provider| crate::security::sanitize_agent_event(provider, &event))
681                    .unwrap_or(event);
682                collect_tool_source_anchors(
683                    &event,
684                    &source_context,
685                    &mut source_anchors,
686                    &mut seen_source_anchors,
687                    &mut scanned_source_candidates,
688                );
689                if let Some(ref broadcast_tx) = broadcast_tx {
690                    if let Some(progress) = synthesize_subagent_progress(
691                        &event,
692                        &progress_task_id,
693                        &progress_session_id,
694                    ) {
695                        let _ = broadcast_tx.send(progress);
696                    }
697                    let _ = broadcast_tx.send(event);
698                }
699            }
700            source_anchors
701        });
702        let child_event_tx = Some(mpsc_tx);
703        let child_llm_event_tx = child_event_tx.clone();
704
705        // Register a CancellationToken with the tracker (if shared) so the
706        // parent session's `cancel_subagent_task` can interrupt this run.
707        if !params.background {
708            if let Some(ref tracker) = self.subagent_tracker {
709                tracker
710                    .register_canceller(&task_id, cancel_token.clone())
711                    .await;
712            }
713        }
714
715        let structured_prompt = output_schema
716            .as_ref()
717            .filter(|_| !tool_free)
718            .map(|schema| structured_task_prompt(&params.prompt, schema));
719        let execution_prompt = structured_prompt.as_deref().unwrap_or(&params.prompt);
720
721        let mut structured = None;
722        let (mut output, mut success, raw_output) = if tool_free && output_schema.is_some() {
723            let operation = agent_loop.begin_capability_operation(
724                0,
725                &cancel_token,
726                "structured task generation",
727            )?;
728            let llm_client = agent_loop.scoped_llm_client_for_parts(
729                Some(&session_id),
730                &child_llm_event_tx,
731                operation.cancellation(),
732            );
733            let generation = Self::generate_structured_task(
734                &*llm_client,
735                &params.prompt,
736                tool_free_system.as_deref(),
737                output_schema.clone().expect("schema checked above"),
738                operation.cancellation(),
739            )
740            .await;
741            let generation = settle_task_capability_operation(
742                generation,
743                operation.close().await,
744                "structured task generation",
745            );
746            match generation {
747                Ok(object) => {
748                    let output = serde_json::to_string_pretty(&object)
749                        .unwrap_or_else(|_| object.to_string());
750                    structured = Some(object);
751                    (output, true, None)
752                }
753                Err(error) if cancel_token.is_cancelled() => {
754                    (format!("Task cancelled by caller: {error}"), false, None)
755                }
756                Err(error) => (format!("Task failed: {error}"), false, None),
757            }
758        } else {
759            match agent_loop
760                .execute_with_session(
761                    &[],
762                    execution_prompt,
763                    Some(&session_id),
764                    child_event_tx.clone(),
765                    Some(&cancel_token),
766                )
767                .await
768            {
769                Ok(_) if cancel_token.is_cancelled() => {
770                    ("Task cancelled by caller".to_string(), false, None)
771                }
772                Ok(result) if result.text.trim().is_empty() => (
773                    "Task failed: child agent returned no final output".to_string(),
774                    false,
775                    None,
776                ),
777                Ok(result) if AgentLoop::is_synthetic_failure_output(&result.text) => {
778                    (format!("Task failed: {}", result.text), false, None)
779                }
780                Ok(result) => {
781                    let raw_output = result
782                        .messages
783                        .last()
784                        .filter(|message| message.role == "assistant")
785                        .map(crate::llm::Message::text)
786                        .filter(|text| !text.trim().is_empty());
787                    (result.text, true, raw_output)
788                }
789                Err(e) if cancel_token.is_cancelled() => {
790                    (format!("Task cancelled by caller: {}", e), false, None)
791                }
792                Err(e) => (format!("Task failed: {}", e), false, None),
793            }
794        };
795
796        if success && !tool_free {
797            if let Some(schema) = output_schema.as_ref() {
798                if let Some(object) = raw_output
799                    .as_deref()
800                    .and_then(|raw| parse_validated_output(raw, schema))
801                    .or_else(|| parse_validated_output(&output, schema))
802                {
803                    structured = Some(object);
804                } else {
805                    let operation = agent_loop.begin_capability_operation(
806                        0,
807                        &cancel_token,
808                        "structured task coercion",
809                    )?;
810                    let llm_client = agent_loop.scoped_llm_client_for_parts(
811                        Some(&session_id),
812                        &child_llm_event_tx,
813                        operation.cancellation(),
814                    );
815                    let coercion = Self::coerce_to_schema(
816                        &*llm_client,
817                        &output,
818                        schema.clone(),
819                        operation.cancellation(),
820                    )
821                    .await;
822                    let coercion = settle_task_capability_operation(
823                        coercion,
824                        operation.close().await,
825                        "structured task coercion",
826                    );
827                    match coercion {
828                        Ok(object) => structured = Some(object),
829                        Err(error) => {
830                            success = false;
831                            output = format!("{output}\n\n[structured output failed: {error}]");
832                        }
833                    }
834                }
835            }
836        }
837        if let Some(provider) = child_security_provider.as_deref() {
838            output = provider.sanitize_output(&output);
839            if let Some(value) = structured.take() {
840                let sanitized = output_schema.as_ref().map_or_else(
841                    || sanitize_task_json(provider, &value),
842                    |schema| sanitize_task_json_with_schema(provider, &value, schema),
843                );
844                if output_schema
845                    .as_ref()
846                    .is_none_or(|schema| value_matches_schema(&sanitized, schema))
847                {
848                    structured = Some(sanitized);
849                } else {
850                    success = false;
851                }
852            }
853        }
854
855        // The child loop and optional structured-output pass are the only
856        // producers. Close their sender and drain the bridge before emitting
857        // SubagentEnd so callers never observe a terminal event followed by
858        // stale child deltas or progress events.
859        drop(child_event_tx);
860        drop(child_llm_event_tx);
861        let source_anchors = match child_event_forwarder.await {
862            Ok(source_anchors) => source_anchors,
863            Err(error) => {
864                tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
865                Vec::new()
866            }
867        };
868
869        let end_event = AgentEvent::SubagentEnd {
870            task_id: task_id.clone(),
871            session_id: session_id.clone(),
872            agent: params.agent.clone(),
873            output: output.clone(),
874            success,
875            finished_ms: epoch_ms(),
876        };
877        if let Some(ref tracker) = self.subagent_tracker {
878            // The tracker is authoritative even when a background child
879            // finishes after the parent run's event forwarder has closed.
880            if success {
881                tracker
882                    .record_source_anchors(&task_id, &source_anchors)
883                    .await;
884            }
885            tracker.record_event(&end_event).await;
886            tracker.clear_canceller(&task_id).await;
887        }
888        if let Some(ref tx) = event_tx {
889            let _ = tx.send(end_event);
890        }
891
892        Ok(TaskResult {
893            output,
894            session_id,
895            agent: params.agent,
896            success,
897            task_id,
898            structured,
899            source_anchors,
900        })
901    }
902
903    /// Execute a task in the background.
904    ///
905    /// Returns immediately with the task ID; the same id is used in the emitted
906    /// `SubagentStart`/`SubagentEnd` events so callers can correlate. Pre-emits
907    /// `SubagentStart` synchronously when an event channel is available so a
908    /// caller that queries the subagent task tracker right after this call
909    /// observes the task in `Running` state without a race window.
910    pub fn execute_background(
911        self: Arc<Self>,
912        params: TaskParams,
913        event_tx: Option<broadcast::Sender<AgentEvent>>,
914        parent_session_id: Option<String>,
915    ) -> String {
916        let parent_cancellation = self.parent_cancellation.clone();
917        self.execute_background_with_parent_cancellation(
918            params,
919            event_tx,
920            parent_session_id,
921            parent_cancellation,
922        )
923    }
924
925    fn execute_background_with_parent_cancellation(
926        self: Arc<Self>,
927        params: TaskParams,
928        event_tx: Option<broadcast::Sender<AgentEvent>>,
929        parent_session_id: Option<String>,
930        parent_cancellation: Option<CancellationToken>,
931    ) -> String {
932        let task_id = format!("task-{}", uuid::Uuid::new_v4());
933        let session_id = format!("task-run-{}", task_id);
934        let failure_session_id = session_id.clone();
935        let failure_agent = params.agent.clone();
936        let start_event = AgentEvent::SubagentStart {
937            task_id: task_id.clone(),
938            session_id,
939            parent_session_id: parent_session_id.clone().unwrap_or_default(),
940            agent: params.agent.clone(),
941            description: params.description.clone(),
942            started_ms: epoch_ms(),
943        };
944        let security_provider = self
945            .parent_context
946            .as_ref()
947            .and_then(|context| context.security_provider.clone());
948        let start_event = security_provider
949            .as_deref()
950            .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
951            .unwrap_or(start_event);
952
953        if let Some(ref tx) = event_tx {
954            let _ = tx.send(start_event.clone());
955        }
956
957        let capability_admission = self
958            .capability_context
959            .as_ref()
960            .map(|context| {
961                context
962                    .admit_subtask(task_id.clone(), true)
963                    .map(|subtask| (context.background_scope().clone(), subtask))
964            })
965            .transpose();
966        let (capability_run, admitted_capability_subtask) = match capability_admission {
967            Ok(Some((run, subtask))) => (Some(run), Some(subtask)),
968            Ok(None) => (None, None),
969            Err(error) => {
970                let message = format!("Background task capability admission failed: {error}");
971                let end_event = AgentEvent::SubagentEnd {
972                    task_id: task_id.clone(),
973                    session_id: failure_session_id,
974                    agent: failure_agent,
975                    output: message.clone(),
976                    success: false,
977                    finished_ms: epoch_ms(),
978                };
979                let end_event = security_provider
980                    .as_deref()
981                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
982                    .unwrap_or(end_event);
983                if let Some(tx) = event_tx {
984                    let _ = tx.send(end_event);
985                }
986                tracing::error!(task_id = %task_id, "{message}");
987                return task_id;
988            }
989        };
990
991        let task_id_for_spawn = task_id.clone();
992        let task_id_for_log = task_id.clone();
993        let admission_failure_task_id = task_id.clone();
994        let admission_failure_session_id = failure_session_id.clone();
995        let admission_failure_agent = failure_agent.clone();
996        let admission_failure_events = event_tx.clone();
997        let admission_failure_security = security_provider.clone();
998        let background = async move {
999            if let Some(ref tracker) = self.subagent_tracker {
1000                tracker.record_event(&start_event).await;
1001            }
1002            let failure_event_tx = event_tx.clone();
1003            if let Err(error) = self
1004                .execute_with_task_id_scoped(
1005                    task_id_for_spawn,
1006                    params,
1007                    ScopedTaskExecution {
1008                        event_tx,
1009                        parent_session_id: parent_session_id.as_deref(),
1010                        emit_start: false,
1011                        parent_cancellation: parent_cancellation.as_ref(),
1012                        admitted_capability_subtask,
1013                    },
1014                )
1015                .await
1016            {
1017                let end_event = AgentEvent::SubagentEnd {
1018                    task_id: task_id_for_log.clone(),
1019                    session_id: failure_session_id,
1020                    agent: failure_agent,
1021                    output: format!("Task failed before child execution started: {error}"),
1022                    success: false,
1023                    finished_ms: epoch_ms(),
1024                };
1025                let end_event = security_provider
1026                    .as_deref()
1027                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
1028                    .unwrap_or(end_event);
1029                if let Some(ref tracker) = self.subagent_tracker {
1030                    tracker.record_event(&end_event).await;
1031                    tracker.clear_canceller(&task_id_for_log).await;
1032                }
1033                if let Some(tx) = failure_event_tx {
1034                    let _ = tx.send(end_event);
1035                }
1036                tracing::error!("Background task {} failed: {}", task_id_for_log, error);
1037            }
1038        };
1039        if let Some(run) = capability_run {
1040            let task_name = format!("subagent.{task_id}");
1041            if let Err(error) = run.spawn_task(task_name, async move {
1042                background.await;
1043                Ok(())
1044            }) {
1045                // Admission races with Run close fail closed: no detached
1046                // child work may escape after the exact generation lease is
1047                // released.
1048                let message = format!("Background task capability admission failed: {error}");
1049                let end_event = AgentEvent::SubagentEnd {
1050                    task_id: admission_failure_task_id.clone(),
1051                    session_id: admission_failure_session_id,
1052                    agent: admission_failure_agent,
1053                    output: message.clone(),
1054                    success: false,
1055                    finished_ms: epoch_ms(),
1056                };
1057                let end_event = admission_failure_security
1058                    .as_deref()
1059                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
1060                    .unwrap_or(end_event);
1061                if let Some(tx) = admission_failure_events {
1062                    let _ = tx.send(end_event);
1063                }
1064                tracing::error!(task_id = %admission_failure_task_id, "{message}");
1065            }
1066        } else {
1067            tokio::spawn(background);
1068        }
1069
1070        task_id
1071    }
1072}
1073
1074async fn close_capability_subtask(
1075    subtask: Option<&crate::capability::AgentCapabilitySubtask>,
1076) -> Result<()> {
1077    let Some(subtask) = subtask else {
1078        return Ok(());
1079    };
1080    let report = subtask.close().await?;
1081    if !report.is_clean() {
1082        anyhow::bail!(
1083            "Capability Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
1084            report.tasks_failed,
1085            report.tasks_timed_out,
1086            report.child_scopes_failed,
1087            report.child_scopes_timed_out,
1088            report.effects_failed,
1089            report.effects_timed_out,
1090        );
1091    }
1092    Ok(())
1093}
1094
1095fn settle_task_capability_operation<T>(
1096    execution: Result<T>,
1097    close: Result<()>,
1098    label: &str,
1099) -> Result<T> {
1100    match (execution, close) {
1101        (Ok(result), Ok(())) => Ok(result),
1102        (Ok(_), Err(close_error)) => Err(close_error),
1103        (Err(error), Ok(())) => Err(error),
1104        (Err(error), Err(close_error)) => {
1105            tracing::warn!(
1106                error = %close_error,
1107                operation = label,
1108                "Capability orchestration Turn close also failed after model failure"
1109            );
1110            Err(error)
1111        }
1112    }
1113}
1114
1115fn structured_task_prompt(prompt: &str, schema: &serde_json::Value) -> String {
1116    let schema = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
1117    format!(
1118        "{prompt}\n\n\
1119         FINAL OUTPUT CONTRACT\n\
1120         Complete the requested investigation before answering. Your final response must contain \
1121         exactly one JSON value matching the JSON Schema below, with no Markdown fence or prose \
1122         outside the JSON. This contract applies to the final response only; use the available \
1123         tools as needed before finalizing.\n\n\
1124         {schema}"
1125    )
1126}
1127
1128fn value_matches_schema(value: &serde_json::Value, schema: &serde_json::Value) -> bool {
1129    serde_json::to_string(value)
1130        .ok()
1131        .and_then(|encoded| parse_validated_output(&encoded, schema))
1132        .is_some()
1133}
1134
1135#[derive(Debug, Clone)]
1136struct AgentCatalogEntry {
1137    name: String,
1138    description: String,
1139}
1140
1141fn agent_catalog_entries(agents: &[AgentDefinition]) -> Vec<AgentCatalogEntry> {
1142    let mut entries = agents
1143        .iter()
1144        .map(|agent| AgentCatalogEntry {
1145            name: agent.name.clone(),
1146            description: agent
1147                .description
1148                .split_whitespace()
1149                .collect::<Vec<_>>()
1150                .join(" "),
1151        })
1152        .collect::<Vec<_>>();
1153    entries.sort_by(|left, right| left.name.cmp(&right.name));
1154    entries
1155}
1156
1157fn agent_catalog_text(agents: &[AgentDefinition]) -> String {
1158    agent_catalog_entries(agents)
1159        .into_iter()
1160        .map(|entry| format!("{}: {}", entry.name, entry.description))
1161        .collect::<Vec<_>>()
1162        .join("\n")
1163}
1164
1165fn delegation_tool_description(base: &str, agents: &[AgentDefinition]) -> String {
1166    format!(
1167        "{base}\n\nAvailable agents (live catalog; use canonical names):\n{}",
1168        agent_catalog_text(agents)
1169    )
1170}
1171
1172pub(super) fn task_agent_parameter_schema(agents: &[AgentDefinition]) -> serde_json::Value {
1173    let entries = agent_catalog_entries(agents);
1174    let examples = entries
1175        .iter()
1176        .map(|entry| serde_json::Value::String(entry.name.clone()))
1177        .collect::<Vec<_>>();
1178    let catalog = entries
1179        .into_iter()
1180        .map(|entry| format!("{}: {}", entry.name, entry.description))
1181        .collect::<Vec<_>>()
1182        .join("\n");
1183    serde_json::json!({
1184        "type": "string",
1185        "description": format!(
1186            "Required. Canonical agent type to use. Always provide this exact field name: 'agent'. Live agent catalog:\n{catalog}"
1187        ),
1188        "examples": examples
1189    })
1190}
1191
1192/// Get the compatibility JSON schema accepted by the `task` executor.
1193///
1194/// The model sees the more compact array-only schema. This public schema also
1195/// accepts the pre-6.8 single-task object so persisted and host-direct calls do
1196/// not break during the tool-surface migration.
1197pub fn task_params_schema() -> serde_json::Value {
1198    task_params_schema_for_agents(&AgentRegistry::new().list_visible())
1199}
1200
1201fn task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1202    serde_json::json!({
1203        "oneOf": [
1204            legacy_task_params_schema_for_agents(agents),
1205            task_model_params_schema_for_agents(agents)
1206        ]
1207    })
1208}
1209
1210fn legacy_task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1211    let mut schema = task_item_params_schema_for_agents(agents, true);
1212    schema["examples"] = serde_json::json!([
1213        {
1214            "agent": "explore",
1215            "description": "Find Rust files",
1216            "prompt": "Search the workspace for Rust files and summarize the layout."
1217        },
1218        {
1219            "agent": "general",
1220            "description": "Investigate test failure",
1221            "prompt": "Inspect the failing tests and explain the root cause.",
1222            "max_steps": 6
1223        }
1224    ]);
1225    schema
1226}
1227
1228fn task_model_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1229    parallel_params::task_tool_params_schema_for_agents(agents)
1230}
1231
1232pub(super) fn task_item_params_schema_for_agents(
1233    agents: &[AgentDefinition],
1234    include_background: bool,
1235) -> serde_json::Value {
1236    let mut properties = serde_json::Map::from_iter([
1237        ("agent".to_string(), task_agent_parameter_schema(agents)),
1238        (
1239            "description".to_string(),
1240            serde_json::json!({
1241                "type": "string",
1242                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
1243            }),
1244        ),
1245        (
1246            "prompt".to_string(),
1247            serde_json::json!({
1248                "type": "string",
1249                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
1250            }),
1251        ),
1252        (
1253            "max_steps".to_string(),
1254            serde_json::json!({
1255                "type": "integer",
1256                "description": "Optional. Maximum number of steps for this task."
1257            }),
1258        ),
1259        (
1260            "output_schema".to_string(),
1261            serde_json::json!({
1262                "type": "object",
1263                "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."
1264            }),
1265        ),
1266    ]);
1267    if include_background {
1268        properties.insert(
1269            "background".to_string(),
1270            serde_json::json!({
1271                "type": "boolean",
1272                "description": "Optional. Run this task in the background. Only valid when the outer tasks array contains one item. Default: false.",
1273                "default": false
1274            }),
1275        );
1276    }
1277
1278    serde_json::json!({
1279        "type": "object",
1280        "additionalProperties": false,
1281        "properties": properties,
1282        "required": ["agent", "description", "prompt"]
1283    })
1284}
1285
1286/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
1287/// This allows the LLM to delegate tasks through the standard tool interface.
1288pub struct TaskTool {
1289    executor: Arc<TaskExecutor>,
1290}
1291
1292impl TaskTool {
1293    /// Create a new TaskTool
1294    pub fn new(executor: Arc<TaskExecutor>) -> Self {
1295        Self { executor }
1296    }
1297
1298    async fn execute_single(&self, params: TaskParams, ctx: &ToolContext) -> Result<ToolOutput> {
1299        let parent_cancellation = ctx.cancellation_token();
1300        let executor = self.executor.scoped_for_invocation(ctx);
1301
1302        if params.background {
1303            let task_id = executor.execute_background_with_parent_cancellation(
1304                params,
1305                ctx.agent_event_tx.clone(),
1306                ctx.session_id.clone(),
1307                Some(parent_cancellation),
1308            );
1309            return Ok(ToolOutput::success(format!(
1310                "Task started in background. Task ID: {}",
1311                task_id
1312            )));
1313        }
1314
1315        let result = executor
1316            .execute_with_parent_cancellation(
1317                params,
1318                ctx.agent_event_tx.clone(),
1319                ctx.session_id.as_deref(),
1320                Some(&parent_cancellation),
1321            )
1322            .await?;
1323        let (content, truncated) = format_task_result_for_context(&result);
1324        let metadata = serde_json::json!({
1325            "task_id": result.task_id,
1326            "session_id": result.session_id,
1327            "agent": result.agent,
1328            "success": result.success,
1329            "output_bytes": result.output.len(),
1330            "truncated_for_context": truncated,
1331            "artifact_id": task_artifact_id(&result),
1332            "artifact_uri": task_artifact_uri(&result),
1333            "structured": result.structured,
1334            "source_anchors": result.source_anchors,
1335        });
1336
1337        if result.success {
1338            Ok(ToolOutput::success(content).with_metadata(metadata))
1339        } else {
1340            Ok(ToolOutput::error(content).with_metadata(metadata))
1341        }
1342    }
1343}
1344
1345#[async_trait]
1346impl Tool for TaskTool {
1347    fn name(&self) -> &str {
1348        "task"
1349    }
1350
1351    fn description(&self) -> &str {
1352        TASK_TOOL_DESCRIPTION
1353    }
1354
1355    fn parameters(&self) -> serde_json::Value {
1356        task_params_schema_for_agents(&self.executor.visible_agents())
1357    }
1358
1359    fn definition(&self) -> ToolDefinition {
1360        let agents = self.executor.visible_agents();
1361        ToolDefinition {
1362            name: self.name().to_string(),
1363            description: delegation_tool_description(self.description(), &agents),
1364            parameters: task_model_params_schema_for_agents(&agents),
1365        }
1366    }
1367
1368    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1369        if args.get("tasks").is_some() {
1370            let mut params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1371                Ok(params) => params,
1372                Err(error) => {
1373                    return Ok(invalid_delegation_argument(format!(
1374                        "Invalid task parameters: {error}"
1375                    )));
1376                }
1377            };
1378            if params.tasks.is_empty() {
1379                return Ok(invalid_delegation_argument(
1380                    "task requires at least 1 task".to_string(),
1381                ));
1382            }
1383
1384            let has_fanout_options = params.allow_partial_failure
1385                || params.timeout_ms.is_some()
1386                || params.min_success_count.is_some();
1387            if params.tasks.len() == 1 && !has_fanout_options {
1388                return self.execute_single(params.tasks.remove(0), ctx).await;
1389            }
1390
1391            return ParallelTaskTool::new(Arc::clone(&self.executor))
1392                .execute_params(params, ctx, "task", 1)
1393                .await;
1394        }
1395
1396        let params: TaskParams = match serde_json::from_value(args.clone()) {
1397            Ok(params) => params,
1398            Err(error) => {
1399                return Ok(invalid_delegation_argument(format!(
1400                    "Invalid task parameters: {error}"
1401                )));
1402            }
1403        };
1404        self.execute_single(params, ctx).await
1405    }
1406}
1407
1408mod parallel_params;
1409pub use parallel_params::{parallel_task_params_schema, ParallelTaskParams};
1410
1411/// ParallelTaskTool allows the LLM to fan out multiple delegated tasks concurrently.
1412///
1413/// All tasks execute in parallel and the tool returns when all complete.
1414pub struct ParallelTaskTool {
1415    executor: Arc<TaskExecutor>,
1416}
1417
1418impl ParallelTaskTool {
1419    /// Create a new ParallelTaskTool
1420    pub fn new(executor: Arc<TaskExecutor>) -> Self {
1421        Self { executor }
1422    }
1423
1424    async fn execute_params(
1425        &self,
1426        params: ParallelTaskParams,
1427        ctx: &ToolContext,
1428        tool_name: &str,
1429        min_tasks: usize,
1430    ) -> Result<ToolOutput> {
1431        let started_at = std::time::Instant::now();
1432        let parent_cancellation = ctx.cancellation_token();
1433        let executor = self.executor.scoped_for_invocation(ctx);
1434
1435        if params.tasks.len() < min_tasks {
1436            return Ok(invalid_delegation_argument(format!(
1437                "{tool_name} requires at least {min_tasks} task{}",
1438                if min_tasks == 1 { "" } else { "s" }
1439            )));
1440        }
1441        if params.tasks.len() > MAX_PARALLEL_TASKS_PER_CALL {
1442            return Ok(invalid_delegation_argument(format!(
1443                "{tool_name} accepts at most {MAX_PARALLEL_TASKS_PER_CALL} tasks"
1444            )));
1445        }
1446        if let Some((index, _)) = params
1447            .tasks
1448            .iter()
1449            .enumerate()
1450            .find(|(_, task)| task.background)
1451        {
1452            return Ok(invalid_delegation_argument(format!(
1453                "{tool_name} task {} cannot set background=true when fan-out options are used or multiple tasks are submitted; every branch is collected by the parent call",
1454                index + 1
1455            )));
1456        }
1457        if params.timeout_ms == Some(0) {
1458            return Ok(invalid_delegation_argument(format!(
1459                "{tool_name} timeout_ms must be at least 1"
1460            )));
1461        }
1462        if let Some(min_success_count) = params.min_success_count {
1463            if !params.allow_partial_failure {
1464                return Ok(invalid_delegation_argument(format!(
1465                    "{tool_name} min_success_count requires allow_partial_failure=true"
1466                )));
1467            }
1468            if min_success_count == 0 || min_success_count > params.tasks.len() {
1469                return Ok(invalid_delegation_argument(format!(
1470                    "{tool_name} min_success_count must be between 1 and the task count ({})",
1471                    params.tasks.len()
1472                )));
1473            }
1474        }
1475
1476        let task_count = params.tasks.len();
1477        let run = executor
1478            .execute_parallel_for_tool(
1479                params.tasks.clone(),
1480                ctx.agent_event_tx.clone(),
1481                parallel_execution::ParallelToolOptions {
1482                    parent_session_id: ctx.session_id.as_deref(),
1483                    timeout_ms: params.timeout_ms,
1484                    min_success_count: params.min_success_count,
1485                    allow_partial_failure: params.allow_partial_failure,
1486                    parent_cancellation: Some(&parent_cancellation),
1487                },
1488            )
1489            .await;
1490        let results = run.results;
1491
1492        let mut output = format!("Executed {} tasks concurrently:\n\n", task_count);
1493        let mut metadata_results = Vec::new();
1494        let source_anchor_counts = parallel_source_anchor_counts(&results);
1495        for (i, result) in results.iter().enumerate() {
1496            let status = if result.success { "[OK]" } else { "[ERR]" };
1497            let (formatted, truncated) = format_task_result_for_context(result);
1498            let (output_excerpt, _) = compact_task_output(&result.output);
1499            let source_anchors = &result.source_anchors[..source_anchor_counts[i]];
1500            metadata_results.push(serde_json::json!({
1501                "task_id": result.task_id,
1502                "session_id": result.session_id,
1503                "agent": result.agent,
1504                "success": result.success,
1505                "error_message": (!result.success).then(|| {
1506                    crate::text::truncate_utf8(&result.output, 1024).to_string()
1507                }),
1508                "output_excerpt": output_excerpt,
1509                "structured": result.structured,
1510                "source_anchors": source_anchors,
1511                "output_bytes": result.output.len(),
1512                "truncated_for_context": truncated,
1513                "artifact_id": task_artifact_id(result),
1514                "artifact_uri": task_artifact_uri(result),
1515            }));
1516            output.push_str(&format!(
1517                "--- Task {} ({}) {} ---\n{}\n\n",
1518                i + 1,
1519                result.agent,
1520                status,
1521                formatted
1522            ));
1523        }
1524
1525        let success_count = results.iter().filter(|result| result.success).count();
1526        let failed_count = results.len().saturating_sub(success_count);
1527        let all_success = failed_count == 0;
1528        let partial_failure = failed_count > 0 && success_count > 0;
1529        if params.allow_partial_failure && partial_failure {
1530            output.push_str(&format!(
1531                "Partial failure tolerated: {success_count} succeeded, {failed_count} failed.\n"
1532            ));
1533        }
1534        if run.timed_out {
1535            output.push_str(&format!(
1536                "Task fan-out timed out after {} ms; returned completed child results and marked unfinished children failed.\n",
1537                run.timeout_ms.unwrap_or_default()
1538            ));
1539        } else if run.returned_early {
1540            output.push_str(&format!(
1541                "Task fan-out returned after reaching min_success_count={}; unfinished children were marked failed.\n",
1542                run.min_success_count.unwrap_or_default()
1543            ));
1544        }
1545
1546        let tool_success = all_success || (params.allow_partial_failure && success_count > 0);
1547        let mut output = if tool_success {
1548            ToolOutput::success(output)
1549        } else {
1550            ToolOutput::error(output)
1551        };
1552        if !tool_success && failed_count > 0 {
1553            output.error_kind = Some(crate::tools::ToolErrorKind::PartialFailure {
1554                failed: failed_count,
1555                total: results.len(),
1556            });
1557        }
1558
1559        Ok(output.with_metadata(serde_json::json!({
1560            "task_count": task_count,
1561            "result_count": results.len(),
1562            "success_count": success_count,
1563            "failed_count": failed_count,
1564            "all_success": all_success,
1565            "partial_failure": partial_failure,
1566            "allow_partial_failure": params.allow_partial_failure,
1567            "timeout_ms": params.timeout_ms,
1568            "timed_out": run.timed_out,
1569            "min_success_count": params.min_success_count,
1570            "returned_early": run.returned_early,
1571            "duration_ms": started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1572            "results": metadata_results,
1573        })))
1574    }
1575}
1576
1577#[async_trait]
1578impl Tool for ParallelTaskTool {
1579    fn name(&self) -> &str {
1580        "parallel_task"
1581    }
1582
1583    fn description(&self) -> &str {
1584        PARALLEL_TASK_TOOL_DESCRIPTION
1585    }
1586
1587    fn parameters(&self) -> serde_json::Value {
1588        parallel_params::parallel_task_params_schema_for_agents(&self.executor.visible_agents())
1589    }
1590
1591    fn definition(&self) -> ToolDefinition {
1592        let agents = self.executor.visible_agents();
1593        ToolDefinition {
1594            name: self.name().to_string(),
1595            description: delegation_tool_description(self.description(), &agents),
1596            parameters: parallel_params::parallel_task_params_schema_for_agents(&agents),
1597        }
1598    }
1599
1600    fn is_model_visible(&self) -> bool {
1601        false
1602    }
1603
1604    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1605        let params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1606            Ok(params) => params,
1607            Err(error) => {
1608                return Ok(invalid_delegation_argument(format!(
1609                    "Invalid parallel_task parameters: {error}"
1610                )));
1611            }
1612        };
1613        self.execute_params(params, ctx, "parallel_task", 2).await
1614    }
1615}
1616
1617fn invalid_delegation_argument(message: String) -> ToolOutput {
1618    ToolOutput::error(&message)
1619        .with_error_kind(crate::tools::ToolErrorKind::InvalidArgument { message })
1620}
1621
1622#[cfg(test)]
1623mod tests;