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