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