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