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