Skip to main content

a3s_code_core/tools/
task.rs

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