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::manager::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
91mod result_projection;
92use result_projection::*;
93
94mod parallel_execution;
95
96const MAX_PARALLEL_TASKS_PER_CALL: usize = 32;
97
98/// Task executor for delegated child runs.
99#[derive(Clone)]
100pub struct TaskExecutor {
101    /// Agent registry for looking up agent definitions
102    registry: Arc<AgentRegistry>,
103    /// LLM client used to power child agent loops
104    llm_client: Arc<dyn LlmClient>,
105    /// Workspace path shared with child agents
106    workspace: String,
107    /// Ordered MCP managers for registering inherited tools in child sessions.
108    mcp_managers: Vec<Arc<McpManager>>,
109    /// Parent capabilities to inherit into child runs.
110    parent_context: Option<crate::child_run::ChildRunContext>,
111    /// Search configuration captured from the invoking parent context.
112    search_config: Option<Arc<crate::config::SearchConfig>>,
113    /// Agent-scoped search admission shared with delegated child runs.
114    search_bulkhead: Option<a3s_search::Bulkhead>,
115    /// Agent-scoped headless retry allowance shared with delegated child runs.
116    search_retry_budget: Option<a3s_search::RetryBudget>,
117    /// Parent-session request flights shared with delegated child runs.
118    search_request_coalescer: Option<a3s_search::SearchCoalescer>,
119    /// Optional lifetime boundary inherited from the session that created this
120    /// executor. Keeping it on the executor prevents cached workflow/executor
121    /// handles from starting new child runs after their session is closed.
122    parent_cancellation: Option<CancellationToken>,
123    max_parallel_tasks: usize,
124    /// Shared across every fan-out started by this executor. Per-call wave
125    /// limits alone are insufficient when a dynamic workflow launches several
126    /// `parallel_task` host steps concurrently.
127    parallel_permits: Arc<tokio::sync::Semaphore>,
128    /// Optional shared tracker — when present each task registers a
129    /// `CancellationToken` so callers can cancel by `task_id`.
130    subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
131    /// Agent-wide scheduler for independent delegated work.
132    task_scheduler: Option<Arc<crate::task_scheduler::TaskScheduler>>,
133    /// Host-started workflow executors have no parent lease, so their
134    /// foreground steps must be admitted independently. Model-invoked task
135    /// tools inherit the enclosing run's lease and leave this false.
136    schedule_foreground: bool,
137}
138
139impl TaskExecutor {
140    /// Create a new task executor
141    pub fn new(
142        registry: Arc<AgentRegistry>,
143        llm_client: Arc<dyn LlmClient>,
144        workspace: String,
145    ) -> Self {
146        Self {
147            registry,
148            llm_client,
149            workspace,
150            mcp_managers: Vec::new(),
151            parent_context: None,
152            search_config: None,
153            search_bulkhead: None,
154            search_retry_budget: None,
155            search_request_coalescer: None,
156            parent_cancellation: None,
157            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
158            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
159                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
160            )),
161            subagent_tracker: None,
162            task_scheduler: None,
163            schedule_foreground: false,
164        }
165    }
166
167    /// Create a new task executor with MCP manager for tool inheritance
168    pub fn with_mcp(
169        registry: Arc<AgentRegistry>,
170        llm_client: Arc<dyn LlmClient>,
171        workspace: String,
172        mcp_manager: Arc<McpManager>,
173    ) -> Self {
174        Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
175    }
176
177    /// Create a task executor with ordered MCP capability sources.
178    pub fn with_mcp_managers(
179        registry: Arc<AgentRegistry>,
180        llm_client: Arc<dyn LlmClient>,
181        workspace: String,
182        mcp_managers: Vec<Arc<McpManager>>,
183    ) -> Self {
184        Self {
185            registry,
186            llm_client,
187            workspace,
188            mcp_managers,
189            parent_context: None,
190            search_config: None,
191            search_bulkhead: None,
192            search_retry_budget: None,
193            search_request_coalescer: None,
194            parent_cancellation: None,
195            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
196            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
197                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
198            )),
199            subagent_tracker: None,
200            task_scheduler: None,
201            schedule_foreground: false,
202        }
203    }
204
205    /// Set parent session capabilities to inherit into child runs.
206    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
207        if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
208            let max_parallel_tasks = max_parallel_tasks.max(1);
209            self.max_parallel_tasks = max_parallel_tasks;
210            self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
211        }
212        self.parent_context = Some(ctx);
213        self
214    }
215
216    fn scoped_for_invocation(self: &Arc<Self>, ctx: &ToolContext) -> Arc<Self> {
217        let mut scoped = self.as_ref().clone();
218        scoped.search_config = ctx.search_config.clone();
219        scoped.search_bulkhead = Some(ctx.search_bulkhead());
220        scoped.search_retry_budget = Some(ctx.search_retry_budget());
221        scoped.search_request_coalescer = Some(ctx.search_request_coalescer());
222        if ctx.has_run_governance() {
223            scoped.parent_context = scoped.parent_context.take().map(|parent| {
224                parent.with_run_governance(
225                    ctx.run_permission_checker(),
226                    ctx.run_confirmation_manager(),
227                )
228            });
229        }
230        Arc::new(scoped)
231    }
232
233    fn child_tool_context(
234        &self,
235        session_id: String,
236        cancellation: CancellationToken,
237    ) -> ToolContext {
238        let mut context = ToolContext::new(PathBuf::from(&self.workspace))
239            .with_session_id(session_id)
240            .with_cancellation(cancellation);
241        if let (Some(bulkhead), Some(retry_budget)) =
242            (&self.search_bulkhead, &self.search_retry_budget)
243        {
244            context = context.with_search_runtime(bulkhead.clone(), retry_budget.clone());
245        }
246        if let Some(search_config) = &self.search_config {
247            context = context.with_search_config(search_config.as_ref().clone());
248        }
249        if let Some(coalescer) = &self.search_request_coalescer {
250            context = context.with_search_request_coalescer(coalescer.clone());
251        }
252        context
253    }
254
255    /// Bind every run started by this executor to a parent lifetime.
256    ///
257    /// A token that is already cancelled makes execution fail before emitting
258    /// `SubagentStart` or performing MCP/LLM work. In-flight children derive
259    /// their own token so cancellation still cascades without granting them the
260    /// ability to cancel the parent.
261    pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
262        self.parent_cancellation = Some(cancellation);
263        self
264    }
265
266    pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
267        let max_parallel_tasks = max_parallel_tasks.max(1);
268        self.max_parallel_tasks = max_parallel_tasks;
269        self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
270        self
271    }
272
273    /// Share a tracker with this executor. When set, each task registers
274    /// a `CancellationToken` against the tracker so the parent session
275    /// can cancel by `task_id`.
276    pub fn with_subagent_tracker(
277        mut self,
278        tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
279    ) -> Self {
280        self.subagent_tracker = Some(tracker);
281        self
282    }
283
284    /// Admit independent delegated work through the owning agent's scheduler.
285    pub fn with_task_scheduler(
286        mut self,
287        scheduler: Arc<crate::task_scheduler::TaskScheduler>,
288        schedule_foreground: bool,
289    ) -> Self {
290        self.task_scheduler = Some(scheduler);
291        self.schedule_foreground = schedule_foreground;
292        self
293    }
294
295    fn visible_agents(&self) -> Vec<AgentDefinition> {
296        self.registry.list_visible()
297    }
298
299    /// Execute a task by spawning an isolated child AgentLoop.
300    ///
301    /// `parent_session_id` flows into the emitted `SubagentStart`/`SubagentEnd`
302    /// events so dashboards can associate child runs with the parent session.
303    pub async fn execute(
304        &self,
305        params: TaskParams,
306        event_tx: Option<broadcast::Sender<AgentEvent>>,
307        parent_session_id: Option<&str>,
308    ) -> Result<TaskResult> {
309        self.execute_with_parent_cancellation(
310            params,
311            event_tx,
312            parent_session_id,
313            self.parent_cancellation.as_ref(),
314        )
315        .await
316    }
317
318    async fn execute_with_parent_cancellation(
319        &self,
320        params: TaskParams,
321        event_tx: Option<broadcast::Sender<AgentEvent>>,
322        parent_session_id: Option<&str>,
323        parent_cancellation: Option<&CancellationToken>,
324    ) -> Result<TaskResult> {
325        let task_id = format!("task-{}", uuid::Uuid::new_v4());
326        self.execute_with_task_id_scoped(
327            task_id,
328            params,
329            event_tx,
330            parent_session_id,
331            true,
332            parent_cancellation,
333        )
334        .await
335    }
336
337    /// Execute a task using a caller-supplied task id. Used by `execute_background`
338    /// so the synchronously-returned task id matches the one in lifecycle events.
339    /// When `emit_start` is `false` the caller is responsible for emitting
340    /// `SubagentStart` themselves (e.g. to avoid a race against a tracker query).
341    pub async fn execute_with_task_id(
342        &self,
343        task_id: String,
344        params: TaskParams,
345        event_tx: Option<broadcast::Sender<AgentEvent>>,
346        parent_session_id: Option<&str>,
347        emit_start: bool,
348    ) -> Result<TaskResult> {
349        self.execute_with_task_id_scoped(
350            task_id,
351            params,
352            event_tx,
353            parent_session_id,
354            emit_start,
355            self.parent_cancellation.as_ref(),
356        )
357        .await
358    }
359
360    async fn execute_with_task_id_scoped(
361        &self,
362        task_id: String,
363        params: TaskParams,
364        event_tx: Option<broadcast::Sender<AgentEvent>>,
365        parent_session_id: Option<&str>,
366        emit_start: bool,
367        parent_cancellation: Option<&CancellationToken>,
368    ) -> Result<TaskResult> {
369        if parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
370            anyhow::bail!("Operation cancelled by parent session");
371        }
372
373        let cancel_token = parent_cancellation
374            .map(CancellationToken::child_token)
375            .unwrap_or_default();
376        // Background callers receive the task id before this future starts.
377        // Register immediately so targeted cancellation also interrupts time
378        // spent waiting in the global scheduler.
379        if params.background {
380            if let Some(ref tracker) = self.subagent_tracker {
381                tracker
382                    .register_canceller(&task_id, cancel_token.clone())
383                    .await;
384            }
385        }
386        let _task_lease = if params.background || self.schedule_foreground {
387            match &self.task_scheduler {
388                Some(scheduler) => Some(
389                    scheduler
390                        .acquire(
391                            if params.background {
392                                crate::task_scheduler::TaskPriority::Background
393                            } else {
394                                crate::task_scheduler::TaskPriority::Foreground
395                            },
396                            format!(
397                                "{}:subagent:{}",
398                                parent_session_id.unwrap_or("host"),
399                                task_id
400                            ),
401                            &cancel_token,
402                        )
403                        .await
404                        .map_err(|error| anyhow::anyhow!(error))?,
405                ),
406                None => None,
407            }
408        } else {
409            None
410        };
411
412        let session_id = format!("task-run-{}", task_id);
413        let started_ms = epoch_ms();
414        let output_schema = params.output_schema.clone();
415
416        let agent = self
417            .registry
418            .get(&params.agent)
419            .context(format!("Unknown agent type: '{}'", params.agent))?;
420        let tool_free = agent.tool_free;
421        let tool_free_system = agent.prompt.clone();
422        let inherited_security_provider = self
423            .parent_context
424            .as_ref()
425            .and_then(|context| context.security_provider.clone());
426
427        if emit_start {
428            let event = AgentEvent::SubagentStart {
429                task_id: task_id.clone(),
430                session_id: session_id.clone(),
431                parent_session_id: parent_session_id.unwrap_or_default().to_string(),
432                agent: params.agent.clone(),
433                description: params.description.clone(),
434                started_ms,
435            };
436            let event = inherited_security_provider
437                .as_deref()
438                .map(|provider| crate::security::sanitize_agent_event(provider, &event))
439                .unwrap_or(event);
440            if let Some(ref tracker) = self.subagent_tracker {
441                tracker.record_event(&event).await;
442            }
443            if let Some(ref tx) = event_tx {
444                let _ = tx.send(event);
445            }
446        }
447
448        // Build a child ToolExecutor. Task tools are intentionally omitted
449        // here to prevent unlimited delegation nesting.
450        let child_executor = if let Some(ref parent_ctx) = self.parent_context {
451            if let Some(ref services) = parent_ctx.workspace_services {
452                crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
453                    self.workspace.clone(),
454                    Arc::clone(services),
455                    crate::tools::ArtifactStoreLimits::default(),
456                )
457            } else {
458                crate::tools::ToolExecutor::new(self.workspace.clone())
459            }
460        } else {
461            crate::tools::ToolExecutor::new(self.workspace.clone())
462        };
463
464        // Register MCP tools so child agents can access MCP servers.
465        for mcp in &self.mcp_managers {
466            let all_tools = tokio::select! {
467                biased;
468                _ = cancel_token.cancelled() => {
469                    anyhow::bail!("Operation cancelled before child execution");
470                }
471                tools = mcp.get_all_tools() => tools,
472            };
473            let mut by_server: std::collections::HashMap<
474                String,
475                Vec<crate::mcp::protocol::McpTool>,
476            > = std::collections::HashMap::new();
477            for (server, tool) in all_tools {
478                by_server.entry(server).or_default().push(tool);
479            }
480            for (server_name, tools) in by_server {
481                let wrappers =
482                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
483                for wrapper in wrappers {
484                    child_executor.register_dynamic_tool(wrapper);
485                }
486            }
487        }
488
489        let child_executor = Arc::new(child_executor);
490
491        let mut child_config = AgentConfig {
492            tools: child_executor.definitions(),
493            ..AgentConfig::default()
494        };
495        agent.apply_to(&mut child_config);
496        if let Some(ref parent_ctx) = self.parent_context {
497            parent_ctx.apply_to(&mut child_config);
498        }
499        // A delegated task is already the output of a parent planning
500        // decision. Running the generic pre-analysis/planning classifier again
501        // adds an unrelated LLM round to every child and can consume the whole
502        // fan-out deadline before any task tool runs.
503        child_config.planning_mode = crate::prompts::PlanningMode::Disabled;
504        if let Some(max_steps) = params.max_steps {
505            child_config.max_tool_rounds = max_steps;
506        }
507        let child_security_provider = child_config.security_provider.clone();
508        let source_security_provider = child_security_provider.clone();
509
510        let mut tool_context = self.child_tool_context(session_id.clone(), cancel_token.clone());
511        if let Some(ref parent_ctx) = self.parent_context {
512            if let Some(ref services) = parent_ctx.workspace_services {
513                tool_context = tool_context.with_workspace_services(Arc::clone(services));
514            }
515            if let Some(ref sandbox) = parent_ctx.sandbox_handle {
516                child_executor.registry().set_sandbox(Arc::clone(sandbox));
517                tool_context = tool_context.with_sandbox(Arc::clone(sandbox));
518            }
519        }
520
521        let source_context = tool_context.clone();
522        let agent_loop = AgentLoop::new(
523            Arc::clone(&self.llm_client),
524            child_executor,
525            tool_context,
526            child_config,
527        );
528
529        // Always observe the child event stream so successful source tool calls
530        // survive in TaskResult metadata even when nobody subscribed to live
531        // progress. Forward the same events when a parent broadcast exists.
532        let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
533        let broadcast_tx = event_tx.clone();
534        let progress_task_id = task_id.clone();
535        let progress_session_id = session_id.clone();
536        let child_event_forwarder = tokio::spawn(async move {
537            let mut source_anchors = Vec::new();
538            let mut seen_source_anchors = std::collections::HashSet::new();
539            let mut scanned_source_candidates = 0usize;
540            while let Some(event) = mpsc_rx.recv().await {
541                let event = source_security_provider
542                    .as_deref()
543                    .map(|provider| crate::security::sanitize_agent_event(provider, &event))
544                    .unwrap_or(event);
545                collect_tool_source_anchors(
546                    &event,
547                    &source_context,
548                    &mut source_anchors,
549                    &mut seen_source_anchors,
550                    &mut scanned_source_candidates,
551                );
552                if let Some(ref broadcast_tx) = broadcast_tx {
553                    if let Some(progress) = synthesize_subagent_progress(
554                        &event,
555                        &progress_task_id,
556                        &progress_session_id,
557                    ) {
558                        let _ = broadcast_tx.send(progress);
559                    }
560                    let _ = broadcast_tx.send(event);
561                }
562            }
563            source_anchors
564        });
565        let child_event_tx = Some(mpsc_tx);
566        let child_llm_event_tx = child_event_tx.clone();
567
568        // Register a CancellationToken with the tracker (if shared) so the
569        // parent session's `cancel_subagent_task` can interrupt this run.
570        if !params.background {
571            if let Some(ref tracker) = self.subagent_tracker {
572                tracker
573                    .register_canceller(&task_id, cancel_token.clone())
574                    .await;
575            }
576        }
577
578        let structured_prompt = output_schema
579            .as_ref()
580            .filter(|_| !tool_free)
581            .map(|schema| structured_task_prompt(&params.prompt, schema));
582        let execution_prompt = structured_prompt.as_deref().unwrap_or(&params.prompt);
583
584        let mut structured = None;
585        let (mut output, mut success) = if tool_free && output_schema.is_some() {
586            let llm_client = agent_loop.scoped_llm_client_for_parts(
587                Some(&session_id),
588                &child_llm_event_tx,
589                &cancel_token,
590            );
591            match Self::generate_structured_task(
592                &*llm_client,
593                &params.prompt,
594                tool_free_system.as_deref(),
595                output_schema.clone().expect("schema checked above"),
596                &cancel_token,
597            )
598            .await
599            {
600                Ok(object) => {
601                    let output = serde_json::to_string_pretty(&object)
602                        .unwrap_or_else(|_| object.to_string());
603                    structured = Some(object);
604                    (output, true)
605                }
606                Err(error) if cancel_token.is_cancelled() => {
607                    (format!("Task cancelled by caller: {error}"), false)
608                }
609                Err(error) => (format!("Task failed: {error}"), false),
610            }
611        } else {
612            match agent_loop
613                .execute_with_session(
614                    &[],
615                    execution_prompt,
616                    Some(&session_id),
617                    child_event_tx.clone(),
618                    Some(&cancel_token),
619                )
620                .await
621            {
622                Ok(_) if cancel_token.is_cancelled() => {
623                    ("Task cancelled by caller".to_string(), false)
624                }
625                Ok(result) if result.text.trim().is_empty() => (
626                    "Task failed: child agent returned no final output".to_string(),
627                    false,
628                ),
629                Ok(result) if AgentLoop::is_synthetic_failure_output(&result.text) => {
630                    (format!("Task failed: {}", result.text), false)
631                }
632                Ok(result) => (result.text, true),
633                Err(e) if cancel_token.is_cancelled() => {
634                    (format!("Task cancelled by caller: {}", e), false)
635                }
636                Err(e) => (format!("Task failed: {}", e), false),
637            }
638        };
639
640        if success && !tool_free {
641            if let Some(schema) = output_schema {
642                if let Some(object) = parse_validated_output(&output, &schema) {
643                    structured = Some(object);
644                } else {
645                    let llm_client = agent_loop.scoped_llm_client_for_parts(
646                        Some(&session_id),
647                        &child_llm_event_tx,
648                        &cancel_token,
649                    );
650                    match Self::coerce_to_schema(&*llm_client, &output, schema, &cancel_token).await
651                    {
652                        Ok(object) => structured = Some(object),
653                        Err(error) => {
654                            success = false;
655                            output = format!("{output}\n\n[structured output failed: {error}]");
656                        }
657                    }
658                }
659            }
660        }
661        if let Some(provider) = child_security_provider.as_deref() {
662            output = provider.sanitize_output(&output);
663            if let Some(value) = &mut structured {
664                *value = sanitize_task_json(provider, value);
665            }
666        }
667
668        // The child loop and optional structured-output pass are the only
669        // producers. Close their sender and drain the bridge before emitting
670        // SubagentEnd so callers never observe a terminal event followed by
671        // stale child deltas or progress events.
672        drop(child_event_tx);
673        drop(child_llm_event_tx);
674        let source_anchors = match child_event_forwarder.await {
675            Ok(source_anchors) => source_anchors,
676            Err(error) => {
677                tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
678                Vec::new()
679            }
680        };
681
682        let end_event = AgentEvent::SubagentEnd {
683            task_id: task_id.clone(),
684            session_id: session_id.clone(),
685            agent: params.agent.clone(),
686            output: output.clone(),
687            success,
688            finished_ms: epoch_ms(),
689        };
690        if let Some(ref tracker) = self.subagent_tracker {
691            // The tracker is authoritative even when a background child
692            // finishes after the parent run's event forwarder has closed.
693            if success {
694                tracker
695                    .record_source_anchors(&task_id, &source_anchors)
696                    .await;
697            }
698            tracker.record_event(&end_event).await;
699            tracker.clear_canceller(&task_id).await;
700        }
701        if let Some(ref tx) = event_tx {
702            let _ = tx.send(end_event);
703        }
704
705        Ok(TaskResult {
706            output,
707            session_id,
708            agent: params.agent,
709            success,
710            task_id,
711            structured,
712            source_anchors,
713        })
714    }
715
716    /// Execute a task in the background.
717    ///
718    /// Returns immediately with the task ID; the same id is used in the emitted
719    /// `SubagentStart`/`SubagentEnd` events so callers can correlate. Pre-emits
720    /// `SubagentStart` synchronously when an event channel is available so a
721    /// caller that queries the subagent task tracker right after this call
722    /// observes the task in `Running` state without a race window.
723    pub fn execute_background(
724        self: Arc<Self>,
725        params: TaskParams,
726        event_tx: Option<broadcast::Sender<AgentEvent>>,
727        parent_session_id: Option<String>,
728    ) -> String {
729        let parent_cancellation = self.parent_cancellation.clone();
730        self.execute_background_with_parent_cancellation(
731            params,
732            event_tx,
733            parent_session_id,
734            parent_cancellation,
735        )
736    }
737
738    fn execute_background_with_parent_cancellation(
739        self: Arc<Self>,
740        params: TaskParams,
741        event_tx: Option<broadcast::Sender<AgentEvent>>,
742        parent_session_id: Option<String>,
743        parent_cancellation: Option<CancellationToken>,
744    ) -> String {
745        let task_id = format!("task-{}", uuid::Uuid::new_v4());
746        let session_id = format!("task-run-{}", task_id);
747        let failure_session_id = session_id.clone();
748        let failure_agent = params.agent.clone();
749        let start_event = AgentEvent::SubagentStart {
750            task_id: task_id.clone(),
751            session_id,
752            parent_session_id: parent_session_id.clone().unwrap_or_default(),
753            agent: params.agent.clone(),
754            description: params.description.clone(),
755            started_ms: epoch_ms(),
756        };
757        let security_provider = self
758            .parent_context
759            .as_ref()
760            .and_then(|context| context.security_provider.clone());
761        let start_event = security_provider
762            .as_deref()
763            .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
764            .unwrap_or(start_event);
765
766        if let Some(ref tx) = event_tx {
767            let _ = tx.send(start_event.clone());
768        }
769
770        let task_id_for_spawn = task_id.clone();
771        let task_id_for_log = task_id.clone();
772        tokio::spawn(async move {
773            if let Some(ref tracker) = self.subagent_tracker {
774                tracker.record_event(&start_event).await;
775            }
776            let failure_event_tx = event_tx.clone();
777            if let Err(error) = self
778                .execute_with_task_id_scoped(
779                    task_id_for_spawn,
780                    params,
781                    event_tx,
782                    parent_session_id.as_deref(),
783                    false,
784                    parent_cancellation.as_ref(),
785                )
786                .await
787            {
788                let end_event = AgentEvent::SubagentEnd {
789                    task_id: task_id_for_log.clone(),
790                    session_id: failure_session_id,
791                    agent: failure_agent,
792                    output: format!("Task failed before child execution started: {error}"),
793                    success: false,
794                    finished_ms: epoch_ms(),
795                };
796                let end_event = security_provider
797                    .as_deref()
798                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
799                    .unwrap_or(end_event);
800                if let Some(ref tracker) = self.subagent_tracker {
801                    tracker.record_event(&end_event).await;
802                    tracker.clear_canceller(&task_id_for_log).await;
803                }
804                if let Some(tx) = failure_event_tx {
805                    let _ = tx.send(end_event);
806                }
807                tracing::error!("Background task {} failed: {}", task_id_for_log, error);
808            }
809        });
810
811        task_id
812    }
813}
814
815fn structured_task_prompt(prompt: &str, schema: &serde_json::Value) -> String {
816    let schema = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
817    format!(
818        "{prompt}\n\n\
819         FINAL OUTPUT CONTRACT\n\
820         Complete the requested investigation before answering. Your final response must contain \
821         exactly one JSON value matching the JSON Schema below, with no Markdown fence or prose \
822         outside the JSON. This contract applies to the final response only; use the available \
823         tools as needed before finalizing.\n\n\
824         {schema}"
825    )
826}
827
828#[derive(Debug, Clone)]
829struct AgentCatalogEntry {
830    name: String,
831    description: String,
832}
833
834fn agent_catalog_entries(agents: &[AgentDefinition]) -> Vec<AgentCatalogEntry> {
835    let mut entries = agents
836        .iter()
837        .map(|agent| AgentCatalogEntry {
838            name: agent.name.clone(),
839            description: agent
840                .description
841                .split_whitespace()
842                .collect::<Vec<_>>()
843                .join(" "),
844        })
845        .collect::<Vec<_>>();
846    entries.sort_by(|left, right| left.name.cmp(&right.name));
847    entries
848}
849
850fn agent_catalog_text(agents: &[AgentDefinition]) -> String {
851    agent_catalog_entries(agents)
852        .into_iter()
853        .map(|entry| format!("{}: {}", entry.name, entry.description))
854        .collect::<Vec<_>>()
855        .join("\n")
856}
857
858fn delegation_tool_description(base: &str, agents: &[AgentDefinition]) -> String {
859    format!(
860        "{base}\n\nAvailable agents (live catalog; use canonical names):\n{}",
861        agent_catalog_text(agents)
862    )
863}
864
865pub(super) fn task_agent_parameter_schema(agents: &[AgentDefinition]) -> serde_json::Value {
866    let entries = agent_catalog_entries(agents);
867    let examples = entries
868        .iter()
869        .map(|entry| serde_json::Value::String(entry.name.clone()))
870        .collect::<Vec<_>>();
871    let catalog = entries
872        .into_iter()
873        .map(|entry| format!("{}: {}", entry.name, entry.description))
874        .collect::<Vec<_>>()
875        .join("\n");
876    serde_json::json!({
877        "type": "string",
878        "description": format!(
879            "Required. Canonical agent type to use. Always provide this exact field name: 'agent'. Live agent catalog:\n{catalog}"
880        ),
881        "examples": examples
882    })
883}
884
885/// Get the compatibility JSON schema accepted by the `task` executor.
886///
887/// The model sees the more compact array-only schema. This public schema also
888/// accepts the pre-6.8 single-task object so persisted and host-direct calls do
889/// not break during the tool-surface migration.
890pub fn task_params_schema() -> serde_json::Value {
891    task_params_schema_for_agents(&AgentRegistry::new().list_visible())
892}
893
894fn task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
895    serde_json::json!({
896        "oneOf": [
897            legacy_task_params_schema_for_agents(agents),
898            task_model_params_schema_for_agents(agents)
899        ]
900    })
901}
902
903fn legacy_task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
904    let mut schema = task_item_params_schema_for_agents(agents, true);
905    schema["examples"] = serde_json::json!([
906        {
907            "agent": "explore",
908            "description": "Find Rust files",
909            "prompt": "Search the workspace for Rust files and summarize the layout."
910        },
911        {
912            "agent": "general",
913            "description": "Investigate test failure",
914            "prompt": "Inspect the failing tests and explain the root cause.",
915            "max_steps": 6
916        }
917    ]);
918    schema
919}
920
921fn task_model_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
922    parallel_params::task_tool_params_schema_for_agents(agents)
923}
924
925pub(super) fn task_item_params_schema_for_agents(
926    agents: &[AgentDefinition],
927    include_background: bool,
928) -> serde_json::Value {
929    let mut properties = serde_json::Map::from_iter([
930        ("agent".to_string(), task_agent_parameter_schema(agents)),
931        (
932            "description".to_string(),
933            serde_json::json!({
934                "type": "string",
935                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
936            }),
937        ),
938        (
939            "prompt".to_string(),
940            serde_json::json!({
941                "type": "string",
942                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
943            }),
944        ),
945        (
946            "max_steps".to_string(),
947            serde_json::json!({
948                "type": "integer",
949                "description": "Optional. Maximum number of steps for this task."
950            }),
951        ),
952        (
953            "output_schema".to_string(),
954            serde_json::json!({
955                "type": "object",
956                "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."
957            }),
958        ),
959    ]);
960    if include_background {
961        properties.insert(
962            "background".to_string(),
963            serde_json::json!({
964                "type": "boolean",
965                "description": "Optional. Run this task in the background. Only valid when the outer tasks array contains one item. Default: false.",
966                "default": false
967            }),
968        );
969    }
970
971    serde_json::json!({
972        "type": "object",
973        "additionalProperties": false,
974        "properties": properties,
975        "required": ["agent", "description", "prompt"]
976    })
977}
978
979/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
980/// This allows the LLM to delegate tasks through the standard tool interface.
981pub struct TaskTool {
982    executor: Arc<TaskExecutor>,
983}
984
985impl TaskTool {
986    /// Create a new TaskTool
987    pub fn new(executor: Arc<TaskExecutor>) -> Self {
988        Self { executor }
989    }
990
991    async fn execute_single(&self, params: TaskParams, ctx: &ToolContext) -> Result<ToolOutput> {
992        let parent_cancellation = ctx.cancellation_token();
993        let executor = self.executor.scoped_for_invocation(ctx);
994
995        if params.background {
996            let task_id = executor.execute_background_with_parent_cancellation(
997                params,
998                ctx.agent_event_tx.clone(),
999                ctx.session_id.clone(),
1000                Some(parent_cancellation),
1001            );
1002            return Ok(ToolOutput::success(format!(
1003                "Task started in background. Task ID: {}",
1004                task_id
1005            )));
1006        }
1007
1008        let result = executor
1009            .execute_with_parent_cancellation(
1010                params,
1011                ctx.agent_event_tx.clone(),
1012                ctx.session_id.as_deref(),
1013                Some(&parent_cancellation),
1014            )
1015            .await?;
1016        let (content, truncated) = format_task_result_for_context(&result);
1017        let metadata = serde_json::json!({
1018            "task_id": result.task_id,
1019            "session_id": result.session_id,
1020            "agent": result.agent,
1021            "success": result.success,
1022            "output_bytes": result.output.len(),
1023            "truncated_for_context": truncated,
1024            "artifact_id": task_artifact_id(&result),
1025            "artifact_uri": task_artifact_uri(&result),
1026            "structured": result.structured,
1027            "source_anchors": result.source_anchors,
1028        });
1029
1030        if result.success {
1031            Ok(ToolOutput::success(content).with_metadata(metadata))
1032        } else {
1033            Ok(ToolOutput::error(content).with_metadata(metadata))
1034        }
1035    }
1036}
1037
1038#[async_trait]
1039impl Tool for TaskTool {
1040    fn name(&self) -> &str {
1041        "task"
1042    }
1043
1044    fn description(&self) -> &str {
1045        TASK_TOOL_DESCRIPTION
1046    }
1047
1048    fn parameters(&self) -> serde_json::Value {
1049        task_params_schema_for_agents(&self.executor.visible_agents())
1050    }
1051
1052    fn definition(&self) -> ToolDefinition {
1053        let agents = self.executor.visible_agents();
1054        ToolDefinition {
1055            name: self.name().to_string(),
1056            description: delegation_tool_description(self.description(), &agents),
1057            parameters: task_model_params_schema_for_agents(&agents),
1058        }
1059    }
1060
1061    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1062        if args.get("tasks").is_some() {
1063            let mut params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1064                Ok(params) => params,
1065                Err(error) => {
1066                    return Ok(invalid_delegation_argument(format!(
1067                        "Invalid task parameters: {error}"
1068                    )));
1069                }
1070            };
1071            if params.tasks.is_empty() {
1072                return Ok(invalid_delegation_argument(
1073                    "task requires at least 1 task".to_string(),
1074                ));
1075            }
1076
1077            let has_fanout_options = params.allow_partial_failure
1078                || params.timeout_ms.is_some()
1079                || params.min_success_count.is_some();
1080            if params.tasks.len() == 1 && !has_fanout_options {
1081                return self.execute_single(params.tasks.remove(0), ctx).await;
1082            }
1083
1084            return ParallelTaskTool::new(Arc::clone(&self.executor))
1085                .execute_params(params, ctx, "task", 1)
1086                .await;
1087        }
1088
1089        let params: TaskParams = match serde_json::from_value(args.clone()) {
1090            Ok(params) => params,
1091            Err(error) => {
1092                return Ok(invalid_delegation_argument(format!(
1093                    "Invalid task parameters: {error}"
1094                )));
1095            }
1096        };
1097        self.execute_single(params, ctx).await
1098    }
1099}
1100
1101mod parallel_params;
1102pub use parallel_params::{parallel_task_params_schema, ParallelTaskParams};
1103
1104/// ParallelTaskTool allows the LLM to fan out multiple delegated tasks concurrently.
1105///
1106/// All tasks execute in parallel and the tool returns when all complete.
1107pub struct ParallelTaskTool {
1108    executor: Arc<TaskExecutor>,
1109}
1110
1111impl ParallelTaskTool {
1112    /// Create a new ParallelTaskTool
1113    pub fn new(executor: Arc<TaskExecutor>) -> Self {
1114        Self { executor }
1115    }
1116
1117    async fn execute_params(
1118        &self,
1119        params: ParallelTaskParams,
1120        ctx: &ToolContext,
1121        tool_name: &str,
1122        min_tasks: usize,
1123    ) -> Result<ToolOutput> {
1124        let started_at = std::time::Instant::now();
1125        let parent_cancellation = ctx.cancellation_token();
1126        let executor = self.executor.scoped_for_invocation(ctx);
1127
1128        if params.tasks.len() < min_tasks {
1129            return Ok(invalid_delegation_argument(format!(
1130                "{tool_name} requires at least {min_tasks} task{}",
1131                if min_tasks == 1 { "" } else { "s" }
1132            )));
1133        }
1134        if params.tasks.len() > MAX_PARALLEL_TASKS_PER_CALL {
1135            return Ok(invalid_delegation_argument(format!(
1136                "{tool_name} accepts at most {MAX_PARALLEL_TASKS_PER_CALL} tasks"
1137            )));
1138        }
1139        if let Some((index, _)) = params
1140            .tasks
1141            .iter()
1142            .enumerate()
1143            .find(|(_, task)| task.background)
1144        {
1145            return Ok(invalid_delegation_argument(format!(
1146                "{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",
1147                index + 1
1148            )));
1149        }
1150        if params.timeout_ms == Some(0) {
1151            return Ok(invalid_delegation_argument(format!(
1152                "{tool_name} timeout_ms must be at least 1"
1153            )));
1154        }
1155        if let Some(min_success_count) = params.min_success_count {
1156            if !params.allow_partial_failure {
1157                return Ok(invalid_delegation_argument(format!(
1158                    "{tool_name} min_success_count requires allow_partial_failure=true"
1159                )));
1160            }
1161            if min_success_count == 0 || min_success_count > params.tasks.len() {
1162                return Ok(invalid_delegation_argument(format!(
1163                    "{tool_name} min_success_count must be between 1 and the task count ({})",
1164                    params.tasks.len()
1165                )));
1166            }
1167        }
1168
1169        let task_count = params.tasks.len();
1170        let run = executor
1171            .execute_parallel_for_tool(
1172                params.tasks.clone(),
1173                ctx.agent_event_tx.clone(),
1174                parallel_execution::ParallelToolOptions {
1175                    parent_session_id: ctx.session_id.as_deref(),
1176                    timeout_ms: params.timeout_ms,
1177                    min_success_count: params.min_success_count,
1178                    allow_partial_failure: params.allow_partial_failure,
1179                    parent_cancellation: Some(&parent_cancellation),
1180                },
1181            )
1182            .await;
1183        let results = run.results;
1184
1185        let mut output = format!("Executed {} tasks concurrently:\n\n", task_count);
1186        let mut metadata_results = Vec::new();
1187        let source_anchor_counts = parallel_source_anchor_counts(&results);
1188        for (i, result) in results.iter().enumerate() {
1189            let status = if result.success { "[OK]" } else { "[ERR]" };
1190            let (formatted, truncated) = format_task_result_for_context(result);
1191            let (output_excerpt, _) = compact_task_output(&result.output);
1192            let source_anchors = &result.source_anchors[..source_anchor_counts[i]];
1193            metadata_results.push(serde_json::json!({
1194                "task_id": result.task_id,
1195                "session_id": result.session_id,
1196                "agent": result.agent,
1197                "success": result.success,
1198                "error_message": (!result.success).then(|| {
1199                    crate::text::truncate_utf8(&result.output, 1024).to_string()
1200                }),
1201                "output_excerpt": output_excerpt,
1202                "structured": result.structured,
1203                "source_anchors": source_anchors,
1204                "output_bytes": result.output.len(),
1205                "truncated_for_context": truncated,
1206                "artifact_id": task_artifact_id(result),
1207                "artifact_uri": task_artifact_uri(result),
1208            }));
1209            output.push_str(&format!(
1210                "--- Task {} ({}) {} ---\n{}\n\n",
1211                i + 1,
1212                result.agent,
1213                status,
1214                formatted
1215            ));
1216        }
1217
1218        let success_count = results.iter().filter(|result| result.success).count();
1219        let failed_count = results.len().saturating_sub(success_count);
1220        let all_success = failed_count == 0;
1221        let partial_failure = failed_count > 0 && success_count > 0;
1222        if params.allow_partial_failure && partial_failure {
1223            output.push_str(&format!(
1224                "Partial failure tolerated: {success_count} succeeded, {failed_count} failed.\n"
1225            ));
1226        }
1227        if run.timed_out {
1228            output.push_str(&format!(
1229                "Task fan-out timed out after {} ms; returned completed child results and marked unfinished children failed.\n",
1230                run.timeout_ms.unwrap_or_default()
1231            ));
1232        } else if run.returned_early {
1233            output.push_str(&format!(
1234                "Task fan-out returned after reaching min_success_count={}; unfinished children were marked failed.\n",
1235                run.min_success_count.unwrap_or_default()
1236            ));
1237        }
1238
1239        let tool_success = all_success || (params.allow_partial_failure && success_count > 0);
1240        let mut output = if tool_success {
1241            ToolOutput::success(output)
1242        } else {
1243            ToolOutput::error(output)
1244        };
1245        if !tool_success && failed_count > 0 {
1246            output.error_kind = Some(crate::tools::ToolErrorKind::PartialFailure {
1247                failed: failed_count,
1248                total: results.len(),
1249            });
1250        }
1251
1252        Ok(output.with_metadata(serde_json::json!({
1253            "task_count": task_count,
1254            "result_count": results.len(),
1255            "success_count": success_count,
1256            "failed_count": failed_count,
1257            "all_success": all_success,
1258            "partial_failure": partial_failure,
1259            "allow_partial_failure": params.allow_partial_failure,
1260            "timeout_ms": params.timeout_ms,
1261            "timed_out": run.timed_out,
1262            "min_success_count": params.min_success_count,
1263            "returned_early": run.returned_early,
1264            "duration_ms": started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1265            "results": metadata_results,
1266        })))
1267    }
1268}
1269
1270#[async_trait]
1271impl Tool for ParallelTaskTool {
1272    fn name(&self) -> &str {
1273        "parallel_task"
1274    }
1275
1276    fn description(&self) -> &str {
1277        PARALLEL_TASK_TOOL_DESCRIPTION
1278    }
1279
1280    fn parameters(&self) -> serde_json::Value {
1281        parallel_params::parallel_task_params_schema_for_agents(&self.executor.visible_agents())
1282    }
1283
1284    fn definition(&self) -> ToolDefinition {
1285        let agents = self.executor.visible_agents();
1286        ToolDefinition {
1287            name: self.name().to_string(),
1288            description: delegation_tool_description(self.description(), &agents),
1289            parameters: parallel_params::parallel_task_params_schema_for_agents(&agents),
1290        }
1291    }
1292
1293    fn is_model_visible(&self) -> bool {
1294        false
1295    }
1296
1297    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1298        let params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1299            Ok(params) => params,
1300            Err(error) => {
1301                return Ok(invalid_delegation_argument(format!(
1302                    "Invalid parallel_task parameters: {error}"
1303                )));
1304            }
1305        };
1306        self.execute_params(params, ctx, "parallel_task", 2).await
1307    }
1308}
1309
1310fn invalid_delegation_argument(message: String) -> ToolOutput {
1311    ToolOutput::error(&message)
1312        .with_error_kind(crate::tools::ToolErrorKind::InvalidArgument { message })
1313}
1314
1315#[cfg(test)]
1316mod tests;