Skip to main content

a3s_code_core/tools/
task.rs

1//! Task tools for delegated child runs.
2//!
3//! The Task tool allows the main agent to delegate one or more specialized
4//! tasks to focused child runs. Each child run gets bounded context and the
5//! permissions declared by its agent definition.
6//!
7//! ## Usage
8//!
9//! ```json
10//! {"tasks": [{
11//!   "agent": "explore",
12//!   "description": "Find authentication code",
13//!   "prompt": "Search for files related to user authentication..."
14//! }]}
15//! ```
16
17use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::structured::{
19    generate_blocking_with_cancellation, parse_validated_output, StructuredMode, StructuredRequest,
20};
21use crate::llm::{LlmClient, ToolDefinition};
22use crate::mcp::{McpBinding, 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::collections::HashSet;
32use std::panic::AssertUnwindSafe;
33use std::path::PathBuf;
34use std::sync::{Arc, Mutex, MutexGuard};
35use tokio::sync::broadcast;
36use tokio::task::JoinSet;
37use tokio_util::sync::CancellationToken;
38
39const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
40const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
41const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
42const MAX_TASK_SOURCE_ANCHORS: usize = 64;
43const MAX_TASK_SOURCE_CANDIDATES: usize = MAX_TASK_SOURCE_ANCHORS * 4;
44const MAX_TASK_SOURCE_TOOL_BYTES: usize = 64;
45const MAX_TASK_SOURCE_VALUE_BYTES: usize = 4 * 1024;
46const MAX_PARALLEL_TASK_SOURCE_ANCHORS: usize = MAX_TASK_SOURCE_ANCHORS;
47const TASK_TOOL_DESCRIPTION: &str = "Delegate one or more bounded tasks to specialized child runs. Pass one item for a focused child run or multiple INDEPENDENT items for concurrent fan-out. A single item may run in the background; multi-item calls are collected by the parent. By default any failed child makes a multi-item call fail; evidence-gathering callers may set allow_partial_failure=true. Choose canonical worker names from the live agent catalog. Custom agents from agent_dirs and .a3s/agents are supported; .claude/agents is read for compatibility.";
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
91struct ScopedTaskExecution<'a> {
92    event_tx: Option<broadcast::Sender<AgentEvent>>,
93    parent_session_id: Option<&'a str>,
94    emit_start: bool,
95    parent_cancellation: Option<&'a CancellationToken>,
96    admitted_capability_subtask: Option<crate::capability::AgentCapabilitySubtask>,
97    parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
98}
99
100/// Coordinates terminal lifecycle events for one bounded parallel fan-out.
101///
102/// A child normally emits its own `SubagentEnd`, but an outer `JoinSet` may
103/// have to abort a child that is stuck in a non-cooperative provider or
104/// subprocess. The fan-out then emits a synthetic terminal event. Keeping
105/// this state separate from the public task tracker lets the natural and
106/// synthetic paths race safely while still guaranteeing exactly one end event
107/// for every emitted start event.
108#[derive(Default)]
109pub(super) struct ParallelTaskLifecycle {
110    state: Mutex<ParallelTaskLifecycleState>,
111}
112
113#[derive(Default)]
114struct ParallelTaskLifecycleState {
115    started: HashSet<String>,
116    ended: HashSet<String>,
117}
118
119impl ParallelTaskLifecycle {
120    fn lock_state(&self) -> MutexGuard<'_, ParallelTaskLifecycleState> {
121        match self.state.lock() {
122            Ok(guard) => guard,
123            // A poisoned lifecycle state still contains the authoritative
124            // event history. Recover it instead of turning cancellation
125            // cleanup into a process panic.
126            Err(poisoned) => poisoned.into_inner(),
127        }
128    }
129
130    /// Record that the start event has been written to the tracker/stream.
131    /// This method deliberately has no await point: once a start is observable
132    /// the enclosing task cannot be aborted between the write and this mark.
133    fn mark_started(&self, task_id: &str) {
134        self.lock_state().started.insert(task_id.to_string());
135    }
136
137    fn is_started(&self, task_id: &str) -> bool {
138        self.lock_state().started.contains(task_id)
139    }
140
141    /// Mark a started task as terminal after its terminal event has been
142    /// written. The parallel fan-out drains every child join before synthetic
143    /// cleanup, so natural and synthetic terminal emitters cannot overlap.
144    fn mark_ended(&self, task_id: &str) {
145        let mut state = self.lock_state();
146        if state.started.contains(task_id) {
147            state.ended.insert(task_id.to_string());
148        }
149    }
150
151    fn is_ended(&self, task_id: &str) -> bool {
152        self.lock_state().ended.contains(task_id)
153    }
154}
155
156mod result_projection;
157use result_projection::*;
158
159mod parallel_execution;
160
161const MAX_PARALLEL_TASKS_PER_CALL: usize = 32;
162
163fn provider_quota_for_client(
164    client: &dyn LlmClient,
165) -> Option<crate::task_scheduler::TaskSchedulerQuota> {
166    let pool = client.model_generation_pool()?;
167    crate::task_scheduler::TaskSchedulerQuota::new(
168        pool.identity.clone(),
169        pool.max_concurrency().get(),
170    )
171    .ok()
172}
173
174/// Task executor for delegated child runs.
175#[derive(Clone)]
176pub struct TaskExecutor {
177    /// Agent registry for looking up agent definitions
178    registry: Arc<AgentRegistry>,
179    /// LLM client used to power child agent loops
180    llm_client: Arc<dyn LlmClient>,
181    /// Workspace path shared with child agents
182    workspace: String,
183    /// Ordered MCP managers for registering inherited tools in child sessions.
184    mcp_managers: Vec<Arc<McpManager>>,
185    /// Exact projected MCP bindings inherited from the admitted parent Run.
186    mcp_bindings: Vec<Arc<McpBinding>>,
187    /// Optional Tool presentation profile forced onto delegated children.
188    /// Tests use Direct so Adaptive selection cannot hide manager-injected
189    /// MCP tools and mask OPT-MCP1 regressions.
190    child_tool_presentation: Option<crate::tools::ToolPresentationProfileV1>,
191    /// Exact host tools owned by this executor. They are installed only in
192    /// child executors and remain bounded by the composed parent/child
193    /// governance context.
194    scoped_tools: Vec<Arc<dyn Tool>>,
195    /// Parent capabilities to inherit into child runs.
196    parent_context: Option<crate::child_run::ChildRunContext>,
197    /// Search configuration captured from the invoking parent context.
198    search_config: Option<Arc<crate::config::SearchConfig>>,
199    /// Agent-scoped search admission shared with delegated child runs.
200    search_bulkhead: Option<a3s_search::Bulkhead>,
201    /// Agent-scoped headless retry allowance shared with delegated child runs.
202    search_retry_budget: Option<a3s_search::RetryBudget>,
203    /// Parent-session request flights shared with delegated child runs.
204    search_request_coalescer: Option<a3s_search::SearchCoalescer>,
205    /// Weak capability parents captured from the invoking Tool Turn.
206    capability_context: Option<crate::capability::AgentToolCapabilityContext>,
207    /// Optional lifetime boundary inherited from the session that created this
208    /// executor. Keeping it on the executor prevents cached workflow/executor
209    /// handles from starting new child runs after their session is closed.
210    parent_cancellation: Option<CancellationToken>,
211    max_parallel_tasks: usize,
212    /// Shared across every fan-out started by this executor. Per-call wave
213    /// limits alone are insufficient when a dynamic workflow launches several
214    /// `parallel_task` host steps concurrently.
215    parallel_permits: Arc<tokio::sync::Semaphore>,
216    /// Optional shared tracker — when present each task registers a
217    /// `CancellationToken` so callers can cancel by `task_id`.
218    subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
219    /// Agent-wide scheduler for independent delegated work.
220    task_scheduler: Option<Arc<crate::task_scheduler::TaskScheduler>>,
221    /// Host-started workflow executors have no parent lease, so their
222    /// foreground steps must be admitted independently. Model-invoked task
223    /// tools inherit the enclosing run's lease and leave this false.
224    schedule_foreground: bool,
225    /// Transient run/host scope used to derive the owner quota. Only the
226    /// digest-derived identity crosses into the scheduler actor.
227    admission_scope: Option<String>,
228    /// Provider/model capacity projected into the same scheduler actor as
229    /// owner admission. This is metadata only; the scheduler remains the
230    /// single live reservation authority.
231    provider_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
232    /// Shared provider admission for foreground child runs. Background runs
233    /// already hold `provider_quota` on their outer scheduler lease and use a
234    /// local child gate to avoid recursively reserving that same dimension.
235    provider_admission: Option<crate::llm::ModelGenerationAdmission>,
236}
237
238impl TaskExecutor {
239    /// Create a new task executor
240    pub fn new(
241        registry: Arc<AgentRegistry>,
242        llm_client: Arc<dyn LlmClient>,
243        workspace: String,
244    ) -> Self {
245        let provider_quota = provider_quota_for_client(llm_client.as_ref());
246        Self {
247            registry,
248            llm_client,
249            workspace,
250            mcp_managers: Vec::new(),
251            mcp_bindings: Vec::new(),
252            child_tool_presentation: None,
253            scoped_tools: Vec::new(),
254            parent_context: None,
255            search_config: None,
256            search_bulkhead: None,
257            search_retry_budget: None,
258            search_request_coalescer: None,
259            capability_context: None,
260            parent_cancellation: None,
261            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
262            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
263                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
264            )),
265            subagent_tracker: None,
266            task_scheduler: None,
267            schedule_foreground: false,
268            admission_scope: None,
269            provider_quota,
270            provider_admission: None,
271        }
272    }
273
274    /// Create a new task executor with MCP manager for tool inheritance
275    pub fn with_mcp(
276        registry: Arc<AgentRegistry>,
277        llm_client: Arc<dyn LlmClient>,
278        workspace: String,
279        mcp_manager: Arc<McpManager>,
280    ) -> Self {
281        Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
282    }
283
284    /// Create a task executor with ordered MCP capability sources.
285    pub fn with_mcp_managers(
286        registry: Arc<AgentRegistry>,
287        llm_client: Arc<dyn LlmClient>,
288        workspace: String,
289        mcp_managers: Vec<Arc<McpManager>>,
290    ) -> Self {
291        let provider_quota = provider_quota_for_client(llm_client.as_ref());
292        Self {
293            registry,
294            llm_client,
295            workspace,
296            mcp_managers,
297            mcp_bindings: Vec::new(),
298            child_tool_presentation: None,
299            scoped_tools: Vec::new(),
300            parent_context: None,
301            search_config: None,
302            search_bulkhead: None,
303            search_retry_budget: None,
304            search_request_coalescer: None,
305            capability_context: None,
306            parent_cancellation: None,
307            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
308            parallel_permits: Arc::new(tokio::sync::Semaphore::new(
309                crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
310            )),
311            subagent_tracker: None,
312            task_scheduler: None,
313            schedule_foreground: false,
314            admission_scope: None,
315            provider_quota,
316            provider_admission: None,
317        }
318    }
319
320    /// Add immutable MCP bindings already admitted by the parent capability
321    /// Run. These bindings are never rediscovered through a manager.
322    pub(crate) fn with_projected_mcp_bindings(mut self, bindings: Vec<Arc<McpBinding>>) -> Self {
323        self.mcp_bindings = bindings;
324        self
325    }
326
327    /// Force a Tool presentation profile on delegated children.
328    #[cfg(test)]
329    pub(crate) fn with_child_tool_presentation(
330        mut self,
331        profile: crate::tools::ToolPresentationProfileV1,
332    ) -> Self {
333        self.child_tool_presentation = Some(profile);
334        self
335    }
336
337    /// Install exact host-provided tools only in child runs created by this
338    /// executor. Registration does not grant invocation authority: every call
339    /// still crosses composed parent and child governance. A name collision
340    /// with another child capability fails before model execution.
341    pub fn with_scoped_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
342        self.scoped_tools = tools;
343        self
344    }
345
346    /// Set parent session capabilities to inherit into child runs.
347    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
348        if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
349            let max_parallel_tasks = max_parallel_tasks.max(1);
350            self.max_parallel_tasks = max_parallel_tasks;
351            self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
352        }
353        self.parent_context = Some(ctx);
354        self
355    }
356
357    fn scoped_for_invocation(self: &Arc<Self>, ctx: &ToolContext) -> Arc<Self> {
358        let mut scoped = self.as_ref().clone();
359        scoped.search_config = ctx.search_config.clone();
360        scoped.search_bulkhead = Some(ctx.search_bulkhead());
361        scoped.search_retry_budget = Some(ctx.search_retry_budget());
362        scoped.search_request_coalescer = Some(ctx.search_request_coalescer());
363        scoped.capability_context = ctx.capability_context();
364        scoped.admission_scope = ctx
365            .run_id()
366            .map(|run_id| format!("run:{run_id}"))
367            .or_else(|| ctx.session_id.as_deref().map(|id| format!("session:{id}")));
368        if ctx.has_run_governance() {
369            scoped.parent_context = scoped.parent_context.take().map(|parent| {
370                parent.with_run_governance(
371                    ctx.run_permission_checker(),
372                    ctx.run_confirmation_manager(),
373                )
374            });
375        }
376        Arc::new(scoped)
377    }
378
379    fn child_tool_context(
380        &self,
381        session_id: String,
382        cancellation: CancellationToken,
383    ) -> ToolContext {
384        let mut context = ToolContext::new(PathBuf::from(&self.workspace))
385            .with_session_id(session_id)
386            .with_cancellation(cancellation);
387        if let (Some(bulkhead), Some(retry_budget)) =
388            (&self.search_bulkhead, &self.search_retry_budget)
389        {
390            context = context.with_search_runtime(bulkhead.clone(), retry_budget.clone());
391        }
392        if let Some(search_config) = &self.search_config {
393            context = context.with_search_config(search_config.as_ref().clone());
394        }
395        if let Some(coalescer) = &self.search_request_coalescer {
396            context = context.with_search_request_coalescer(coalescer.clone());
397        }
398        context
399    }
400
401    /// Bind every run started by this executor to a parent lifetime.
402    ///
403    /// A token that is already cancelled makes execution fail before emitting
404    /// `SubagentStart` or performing MCP/LLM work. In-flight children derive
405    /// their own token so cancellation still cascades without granting them the
406    /// ability to cancel the parent.
407    pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
408        self.parent_cancellation = Some(cancellation);
409        self
410    }
411
412    pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
413        let max_parallel_tasks = max_parallel_tasks.max(1);
414        self.max_parallel_tasks = max_parallel_tasks;
415        self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
416        self
417    }
418
419    /// Share a tracker with this executor. When set, each task registers
420    /// a `CancellationToken` against the tracker so the parent session
421    /// can cancel by `task_id`.
422    pub fn with_subagent_tracker(
423        mut self,
424        tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
425    ) -> Self {
426        self.subagent_tracker = Some(tracker);
427        self
428    }
429
430    /// Admit independent delegated work through the owning agent's scheduler.
431    pub fn with_task_scheduler(
432        mut self,
433        scheduler: Arc<crate::task_scheduler::TaskScheduler>,
434        schedule_foreground: bool,
435    ) -> Self {
436        // OPT-POOL1: shared provider capacity requires a typed
437        // `ModelGenerationPool`. Without one, children keep a local-only gate
438        // and must not attach a scheduler quota that would look like a product
439        // shared pool.
440        self.provider_admission = self.llm_client.model_generation_pool().and_then(|pool| {
441            crate::llm::ModelGenerationAdmission::new(
442                self.llm_client.model_generation_concurrency(),
443            )
444            .with_model_generation_pool(
445                Arc::clone(&scheduler),
446                pool,
447                crate::task_scheduler::TaskPriority::Foreground,
448                "task-child-model-generation",
449            )
450            .ok()
451        });
452        self.task_scheduler = Some(scheduler);
453        self.schedule_foreground = schedule_foreground;
454        self
455    }
456
457    #[cfg(test)]
458    pub(crate) fn has_provider_model_generation_admission(&self) -> bool {
459        self.provider_admission.is_some()
460    }
461
462    #[cfg(test)]
463    pub(crate) fn provider_admission_publishes_typed_pool(&self) -> bool {
464        self.provider_admission
465            .as_ref()
466            .is_some_and(|admission| admission.publishes_model_generation_pool())
467    }
468
469    fn visible_agents(&self) -> Vec<AgentDefinition> {
470        self.registry.list_visible()
471    }
472
473    /// Execute a task by spawning an isolated child AgentLoop.
474    ///
475    /// `parent_session_id` flows into the emitted `SubagentStart`/`SubagentEnd`
476    /// events so dashboards can associate child runs with the parent session.
477    pub async fn execute(
478        &self,
479        params: TaskParams,
480        event_tx: Option<broadcast::Sender<AgentEvent>>,
481        parent_session_id: Option<&str>,
482    ) -> Result<TaskResult> {
483        self.execute_with_parent_cancellation(
484            params,
485            event_tx,
486            parent_session_id,
487            self.parent_cancellation.as_ref(),
488        )
489        .await
490    }
491
492    async fn execute_with_parent_cancellation(
493        &self,
494        params: TaskParams,
495        event_tx: Option<broadcast::Sender<AgentEvent>>,
496        parent_session_id: Option<&str>,
497        parent_cancellation: Option<&CancellationToken>,
498    ) -> Result<TaskResult> {
499        let task_id = format!("task-{}", uuid::Uuid::new_v4());
500        self.execute_with_task_id_scoped(
501            task_id,
502            params,
503            ScopedTaskExecution {
504                event_tx,
505                parent_session_id,
506                emit_start: true,
507                parent_cancellation,
508                admitted_capability_subtask: None,
509                parallel_lifecycle: None,
510            },
511        )
512        .await
513    }
514
515    /// Execute a task using a caller-supplied task id. Used by `execute_background`
516    /// so the synchronously-returned task id matches the one in lifecycle events.
517    /// When `emit_start` is `false` the caller is responsible for emitting
518    /// `SubagentStart` themselves (e.g. to avoid a race against a tracker query).
519    pub async fn execute_with_task_id(
520        &self,
521        task_id: String,
522        params: TaskParams,
523        event_tx: Option<broadcast::Sender<AgentEvent>>,
524        parent_session_id: Option<&str>,
525        emit_start: bool,
526    ) -> Result<TaskResult> {
527        self.execute_with_task_id_scoped(
528            task_id,
529            params,
530            ScopedTaskExecution {
531                event_tx,
532                parent_session_id,
533                emit_start,
534                parent_cancellation: self.parent_cancellation.as_ref(),
535                admitted_capability_subtask: None,
536                parallel_lifecycle: None,
537            },
538        )
539        .await
540    }
541
542    async fn execute_with_task_id_scoped(
543        &self,
544        task_id: String,
545        params: TaskParams,
546        execution: ScopedTaskExecution<'_>,
547    ) -> Result<TaskResult> {
548        let ScopedTaskExecution {
549            event_tx,
550            parent_session_id,
551            emit_start,
552            parent_cancellation,
553            admitted_capability_subtask,
554            parallel_lifecycle,
555        } = execution;
556        let was_promoted = admitted_capability_subtask.is_some();
557        if !was_promoted && parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
558            anyhow::bail!("Operation cancelled by parent session");
559        }
560
561        let capability_subtask = match admitted_capability_subtask {
562            Some(subtask) => Some(subtask),
563            None => self
564                .capability_context
565                .as_ref()
566                .map(|context| context.admit_subtask(task_id.clone(), params.background))
567                .transpose()?,
568        };
569        let cancel_token = capability_subtask.as_ref().map_or_else(
570            || {
571                parent_cancellation
572                    .map(CancellationToken::child_token)
573                    .unwrap_or_default()
574            },
575            crate::capability::AgentCapabilitySubtask::cancellation,
576        );
577        let capability_runtime = capability_subtask
578            .as_ref()
579            .map(crate::capability::AgentCapabilitySubtask::runtime);
580        let execution = self
581            .execute_with_task_id_in_scope(
582                task_id,
583                params,
584                event_tx,
585                parent_session_id,
586                emit_start,
587                cancel_token,
588                capability_runtime,
589                parallel_lifecycle,
590            )
591            .await;
592        let close = close_capability_subtask(capability_subtask.as_ref()).await;
593        match (execution, close) {
594            (Ok(result), Ok(())) => Ok(result),
595            (Ok(_), Err(close_error)) => Err(close_error),
596            (Err(error), Ok(())) => Err(error),
597            (Err(error), Err(close_error)) => {
598                tracing::warn!(
599                    error = %close_error,
600                    "Capability Subtask close also failed after delegated execution failure"
601                );
602                Err(error)
603            }
604        }
605    }
606
607    #[allow(clippy::too_many_arguments)]
608    async fn execute_with_task_id_in_scope(
609        &self,
610        task_id: String,
611        params: TaskParams,
612        event_tx: Option<broadcast::Sender<AgentEvent>>,
613        parent_session_id: Option<&str>,
614        emit_start: bool,
615        cancel_token: CancellationToken,
616        capability_runtime: Option<crate::capability::AgentCapabilityRuntime>,
617        parallel_lifecycle: Option<Arc<ParallelTaskLifecycle>>,
618    ) -> Result<TaskResult> {
619        // Background callers receive the task id before this future starts.
620        // Register immediately so targeted cancellation also interrupts time
621        // spent waiting in the global scheduler.
622        if params.background {
623            if let Some(ref tracker) = self.subagent_tracker {
624                tracker
625                    .register_canceller(&task_id, cancel_token.clone())
626                    .await;
627            }
628        }
629        let execution_identity =
630            if (params.background || self.schedule_foreground) && self.task_scheduler.is_some() {
631                let mut identity_spec = AgentStepSpec::new(
632                    task_id.clone(),
633                    params.agent.clone(),
634                    params.description.clone(),
635                    params.prompt.clone(),
636                );
637                if let Some(max_steps) = params.max_steps {
638                    identity_spec = identity_spec.with_max_steps(max_steps);
639                }
640                if let Some(output_schema) = params.output_schema.clone() {
641                    identity_spec = identity_spec.with_output_schema(output_schema);
642                }
643                if let Some(parent_session_id) = parent_session_id {
644                    identity_spec = identity_spec.with_parent_session_id(parent_session_id);
645                }
646                Some(
647                    crate::orchestration::workflow_step_execution_identity(
648                        parent_session_id.unwrap_or("host"),
649                        &identity_spec,
650                    )
651                    .map_err(|error| {
652                        anyhow::anyhow!("derive delegated task execution identity: {error}")
653                    })?,
654                )
655            } else {
656                None
657            };
658        let admission_quota =
659            if (params.background || self.schedule_foreground) && self.task_scheduler.is_some() {
660                let scope = self.admission_scope.clone().unwrap_or_else(|| {
661                    parent_session_id
662                        .map(|session_id| format!("session:{session_id}"))
663                        .unwrap_or_else(|| "host".to_string())
664                });
665                Some(
666                    crate::task_scheduler::TaskSchedulerQuota::for_scope(
667                        &scope,
668                        self.max_parallel_tasks,
669                    )
670                    .map_err(|error| anyhow::anyhow!(error))?,
671                )
672            } else {
673                None
674            };
675        let _task_lease = if params.background || self.schedule_foreground {
676            match &self.task_scheduler {
677                Some(scheduler) => {
678                    let mut quotas = Vec::with_capacity(2);
679                    if let Some(quota) = admission_quota.as_ref() {
680                        quotas.push(quota.clone());
681                    }
682                    if let Some(quota) = self.provider_quota.as_ref() {
683                        if !quotas
684                            .iter()
685                            .any(|candidate| candidate.identity == quota.identity)
686                        {
687                            quotas.push(quota.clone());
688                        }
689                    }
690                    let priority = if params.background {
691                        crate::task_scheduler::TaskPriority::Background
692                    } else {
693                        crate::task_scheduler::TaskPriority::Foreground
694                    };
695                    let label = format!(
696                        "{}:subagent:{}",
697                        parent_session_id.unwrap_or("host"),
698                        task_id
699                    );
700                    Some(if quotas.is_empty() {
701                        scheduler
702                            .acquire_with_identity(
703                                priority,
704                                label,
705                                execution_identity.clone(),
706                                &cancel_token,
707                            )
708                            .await
709                            .map_err(|error| anyhow::anyhow!(error))?
710                    } else {
711                        scheduler
712                            .acquire_with_quotas(
713                                priority,
714                                label,
715                                &quotas,
716                                execution_identity.clone(),
717                                &cancel_token,
718                            )
719                            .await
720                            .map_err(|error| anyhow::anyhow!(error))?
721                    })
722                }
723                None => None,
724            }
725        } else {
726            None
727        };
728
729        let session_id = format!("task-run-{}", task_id);
730        let started_ms = epoch_ms();
731        let output_schema = params.output_schema.clone();
732
733        let agent = self
734            .registry
735            .get_arc(&params.agent)
736            .context(format!("Unknown agent type: '{}'", params.agent))?;
737        let tool_free = agent.tool_free;
738        let tool_free_system = agent.prompt.clone();
739        let inherited_security_provider = self
740            .parent_context
741            .as_ref()
742            .and_then(|context| context.security_provider.clone());
743
744        if emit_start {
745            let event = AgentEvent::SubagentStart {
746                task_id: task_id.clone(),
747                session_id: session_id.clone(),
748                parent_session_id: parent_session_id.unwrap_or_default().to_string(),
749                agent: params.agent.clone(),
750                description: params.description.clone(),
751                started_ms,
752            };
753            let event = inherited_security_provider
754                .as_deref()
755                .map(|provider| crate::security::sanitize_agent_event(provider, &event))
756                .unwrap_or(event);
757            if let Some(ref tracker) = self.subagent_tracker {
758                tracker.record_event(&event).await;
759            }
760            if let Some(ref tx) = event_tx {
761                let _ = tx.send(event);
762            }
763            if let Some(lifecycle) = &parallel_lifecycle {
764                // Keep this after the last await and after the broadcast so an
765                // abort can never suppress a start that the lifecycle thinks
766                // was emitted.
767                lifecycle.mark_started(&task_id);
768            }
769        }
770
771        // Build a child ToolExecutor. Task tools are intentionally omitted
772        // here to prevent unlimited delegation nesting.
773        let child_executor = if let Some(ref parent_ctx) = self.parent_context {
774            if let Some(ref services) = parent_ctx.workspace_services {
775                crate::tools::ToolExecutor::new_with_workspace_services_artifact_limits_and_immutable_content_adapter(
776                        self.workspace.clone(),
777                        Arc::clone(services),
778                        crate::tools::ArtifactStoreLimits::default(),
779                        parent_ctx.immutable_content_adapter.clone(),
780                    )
781            } else if let Some(adapter) = parent_ctx.immutable_content_adapter.clone() {
782                crate::tools::ToolExecutor::new_with_immutable_content_adapter(
783                    self.workspace.clone(),
784                    adapter,
785                )
786            } else {
787                crate::tools::ToolExecutor::new(self.workspace.clone())
788            }
789        } else {
790            crate::tools::ToolExecutor::new(self.workspace.clone())
791        };
792
793        // Register MCP tools so child agents can access MCP servers.
794        // When the parent Run already projected exact McpBindings, those
795        // bindings are the routing authority (OPT-MCP1). Manager snapshots are
796        // mutable refresh caches and must not inject unbound tools into the
797        // delegated child.
798        // When the parent Run already projected exact McpBindings, those
799        // bindings are the routing authority (OPT-MCP1). Manager snapshots are
800        // mutable refresh caches and must not inject unbound tools into the
801        // delegated child.
802        if self.mcp_bindings.is_empty() {
803            for mcp in &self.mcp_managers {
804                let all_tools = tokio::select! {
805                    biased;
806                    _ = cancel_token.cancelled() => {
807                        anyhow::bail!("Operation cancelled before child execution");
808                    }
809                    tools = mcp.get_all_tools() => tools,
810                };
811                let mut by_server: std::collections::HashMap<
812                    String,
813                    Vec<crate::mcp::protocol::McpTool>,
814                > = std::collections::HashMap::new();
815                for (server, tool) in all_tools {
816                    by_server.entry(server).or_default().push(tool);
817                }
818                for (server_name, tools) in by_server {
819                    let wrappers =
820                        crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
821                    for wrapper in wrappers {
822                        child_executor.register_dynamic_tool(wrapper);
823                    }
824                }
825            }
826        }
827        // Projected bindings are the Run-frozen MCP generation.
828        for binding in &self.mcp_bindings {
829            if cancel_token.is_cancelled() {
830                anyhow::bail!("Operation cancelled before child execution");
831            }
832            for wrapper in binding.projected_tools() {
833                child_executor.register_dynamic_tool(wrapper);
834            }
835        }
836
837        // These exact Arc values belong only to this TaskExecutor. Install them
838        // after all inherited sources so no scoped tool can shadow a built-in,
839        // compatibility MCP tool, or Run-frozen projected binding.
840        for tool in &self.scoped_tools {
841            if !child_executor.register_dynamic_tool_if_absent(Arc::clone(tool)) {
842                anyhow::bail!(
843                    "Workflow-scoped tool '{}' conflicts with another child capability",
844                    tool.name()
845                );
846            }
847        }
848
849        let child_executor = Arc::new(child_executor);
850
851        let mut child_config = AgentConfig {
852            tools: child_executor.definitions(),
853            ..AgentConfig::default()
854        };
855        agent.apply_to(&mut child_config);
856        if let Some(ref parent_ctx) = self.parent_context {
857            parent_ctx.apply_to(&mut child_config);
858        }
859        if let Some(profile) = self.child_tool_presentation.clone() {
860            child_config.tool_presentation_profile = profile;
861        }
862        // A delegated task is already the output of a parent planning
863        // decision. Running the generic pre-analysis/planning classifier again
864        // adds an unrelated LLM round to every child and can consume the whole
865        // fan-out deadline before any task tool runs.
866        child_config.planning_mode = crate::prompts::PlanningMode::Disabled;
867        if let Some(max_steps) = params.max_steps {
868            child_config.max_tool_rounds = max_steps;
869        }
870        let child_security_provider = child_config.security_provider.clone();
871        let source_security_provider = child_security_provider.clone();
872
873        let mut tool_context = self.child_tool_context(session_id.clone(), cancel_token.clone());
874        if let Some(ref parent_ctx) = self.parent_context {
875            if let Some(ref services) = parent_ctx.workspace_services {
876                tool_context = tool_context.with_workspace_services(Arc::clone(services));
877            }
878            if let Some(ref sandbox) = parent_ctx.sandbox_handle {
879                child_executor.registry().set_sandbox(Arc::clone(sandbox));
880                tool_context = tool_context.with_sandbox(Arc::clone(sandbox));
881            }
882        }
883
884        let source_context = tool_context.clone();
885        let mut agent_loop = AgentLoop::new(
886            Arc::clone(&self.llm_client),
887            child_executor,
888            tool_context,
889            child_config,
890        );
891        if !params.background && !self.schedule_foreground {
892            if let Some(admission) = &self.provider_admission {
893                agent_loop = agent_loop.with_model_generation_admission(admission.clone());
894            }
895        }
896        if let Some(runtime) = capability_runtime {
897            agent_loop = agent_loop.with_capability_runtime(runtime);
898        }
899
900        // Always observe the child event stream so successful source tool calls
901        // survive in TaskResult metadata even when nobody subscribed to live
902        // progress. Forward the same events when a parent broadcast exists.
903        let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
904        let broadcast_tx = event_tx.clone();
905        let progress_task_id = task_id.clone();
906        let progress_session_id = session_id.clone();
907        let child_event_forwarder = tokio::spawn(async move {
908            let mut source_anchors = Vec::new();
909            let mut seen_source_anchors = std::collections::HashSet::new();
910            let mut scanned_source_candidates = 0usize;
911            while let Some(event) = mpsc_rx.recv().await {
912                let event = source_security_provider
913                    .as_deref()
914                    .map(|provider| crate::security::sanitize_agent_event(provider, &event))
915                    .unwrap_or(event);
916                collect_tool_source_anchors(
917                    &event,
918                    &source_context,
919                    &mut source_anchors,
920                    &mut seen_source_anchors,
921                    &mut scanned_source_candidates,
922                );
923                if let Some(ref broadcast_tx) = broadcast_tx {
924                    if let Some(progress) = synthesize_subagent_progress(
925                        &event,
926                        &progress_task_id,
927                        &progress_session_id,
928                    ) {
929                        let _ = broadcast_tx.send(progress);
930                    }
931                    let _ = broadcast_tx.send(event);
932                }
933            }
934            source_anchors
935        });
936        let child_event_tx = Some(mpsc_tx);
937        let child_llm_event_tx = child_event_tx.clone();
938
939        // Register a CancellationToken with the tracker (if shared) so the
940        // parent session's `cancel_subagent_task` can interrupt this run.
941        if !params.background {
942            if let Some(ref tracker) = self.subagent_tracker {
943                tracker
944                    .register_canceller(&task_id, cancel_token.clone())
945                    .await;
946            }
947        }
948
949        let structured_prompt = output_schema
950            .as_ref()
951            .filter(|_| !tool_free)
952            .map(|schema| structured_task_prompt(&params.prompt, schema));
953        let execution_prompt = structured_prompt.as_deref().unwrap_or(&params.prompt);
954
955        let mut structured = None;
956        let (mut output, mut success, raw_output) = if tool_free && output_schema.is_some() {
957            let operation = agent_loop.begin_capability_operation(
958                0,
959                &cancel_token,
960                "structured task generation",
961            )?;
962            let llm_client = agent_loop.scoped_llm_client_for_parts(
963                Some(&session_id),
964                &child_llm_event_tx,
965                operation.cancellation(),
966            );
967            let generation = Self::generate_structured_task(
968                &*llm_client,
969                &params.prompt,
970                tool_free_system.as_deref(),
971                output_schema.clone().expect("schema checked above"),
972                operation.cancellation(),
973            )
974            .await;
975            let generation = settle_task_capability_operation(
976                generation,
977                operation.close().await,
978                "structured task generation",
979            );
980            match generation {
981                Ok(object) => {
982                    let output = serde_json::to_string_pretty(&object)
983                        .unwrap_or_else(|_| object.to_string());
984                    structured = Some(object);
985                    (output, true, None)
986                }
987                Err(error) if cancel_token.is_cancelled() => {
988                    (format!("Task cancelled by caller: {error}"), false, None)
989                }
990                Err(error) => (format!("Task failed: {error}"), false, None),
991            }
992        } else {
993            match agent_loop
994                .execute_with_session(
995                    &[],
996                    execution_prompt,
997                    Some(&session_id),
998                    child_event_tx.clone(),
999                    Some(&cancel_token),
1000                )
1001                .await
1002            {
1003                Ok(_) if cancel_token.is_cancelled() => {
1004                    ("Task cancelled by caller".to_string(), false, None)
1005                }
1006                Ok(result) if result.text.trim().is_empty() => (
1007                    "Task failed: child agent returned no final output".to_string(),
1008                    false,
1009                    None,
1010                ),
1011                Ok(result) if AgentLoop::is_synthetic_failure_output(&result.text) => {
1012                    (format!("Task failed: {}", result.text), false, None)
1013                }
1014                Ok(result) => {
1015                    let raw_output = result
1016                        .messages
1017                        .last()
1018                        .filter(|message| message.role == "assistant")
1019                        .map(crate::llm::Message::text)
1020                        .filter(|text| !text.trim().is_empty());
1021                    (result.text, true, raw_output)
1022                }
1023                Err(e) if cancel_token.is_cancelled() => {
1024                    (format!("Task cancelled by caller: {}", e), false, None)
1025                }
1026                Err(e) => (format!("Task failed: {}", e), false, None),
1027            }
1028        };
1029
1030        if success && !tool_free {
1031            if let Some(schema) = output_schema.as_ref() {
1032                if let Some(object) = raw_output
1033                    .as_deref()
1034                    .and_then(|raw| parse_validated_output(raw, schema))
1035                    .or_else(|| parse_validated_output(&output, schema))
1036                {
1037                    structured = Some(object);
1038                } else {
1039                    let operation = agent_loop.begin_capability_operation(
1040                        0,
1041                        &cancel_token,
1042                        "structured task coercion",
1043                    )?;
1044                    let llm_client = agent_loop.scoped_llm_client_for_parts(
1045                        Some(&session_id),
1046                        &child_llm_event_tx,
1047                        operation.cancellation(),
1048                    );
1049                    let coercion = Self::coerce_to_schema(
1050                        &*llm_client,
1051                        &output,
1052                        schema.clone(),
1053                        operation.cancellation(),
1054                    )
1055                    .await;
1056                    let coercion = settle_task_capability_operation(
1057                        coercion,
1058                        operation.close().await,
1059                        "structured task coercion",
1060                    );
1061                    match coercion {
1062                        Ok(object) => structured = Some(object),
1063                        Err(error) => {
1064                            success = false;
1065                            output = format!("{output}\n\n[structured output failed: {error}]");
1066                        }
1067                    }
1068                }
1069            }
1070        }
1071        if let Some(provider) = child_security_provider.as_deref() {
1072            output = crate::security::sanitize_text(provider, &output);
1073            if let Some(value) = structured.take() {
1074                let sanitized = output_schema.as_ref().map_or_else(
1075                    || sanitize_task_json(provider, &value),
1076                    |schema| sanitize_task_json_with_schema(provider, &value, schema),
1077                );
1078                if output_schema
1079                    .as_ref()
1080                    .is_none_or(|schema| value_matches_schema(&sanitized, schema))
1081                {
1082                    structured = Some(sanitized);
1083                } else {
1084                    success = false;
1085                }
1086            }
1087        }
1088
1089        // The child loop and optional structured-output pass are the only
1090        // producers. Close their sender and drain the bridge before emitting
1091        // SubagentEnd so callers never observe a terminal event followed by
1092        // stale child deltas or progress events.
1093        drop(child_event_tx);
1094        drop(child_llm_event_tx);
1095        let source_anchors = match child_event_forwarder.await {
1096            Ok(source_anchors) => source_anchors,
1097            Err(error) => {
1098                tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
1099                Vec::new()
1100            }
1101        };
1102
1103        let end_event = AgentEvent::SubagentEnd {
1104            task_id: task_id.clone(),
1105            session_id: session_id.clone(),
1106            agent: params.agent.clone(),
1107            output: output.clone(),
1108            success,
1109            finished_ms: epoch_ms(),
1110        };
1111        if let Some(ref tracker) = self.subagent_tracker {
1112            // The tracker is authoritative even when a background child
1113            // finishes after the parent run's event forwarder has closed.
1114            if success {
1115                tracker
1116                    .record_source_anchors(&task_id, &source_anchors)
1117                    .await;
1118            }
1119            tracker.record_event(&end_event).await;
1120            tracker.clear_canceller(&task_id).await;
1121        }
1122        if let Some(ref tx) = event_tx {
1123            let _ = tx.send(end_event);
1124        }
1125        if let Some(lifecycle) = &parallel_lifecycle {
1126            // Keep this as the final synchronous operation. If the child was
1127            // aborted at an earlier await, synthetic cleanup can still fill in
1128            // the missing terminal event.
1129            lifecycle.mark_ended(&task_id);
1130        }
1131
1132        Ok(TaskResult {
1133            output,
1134            session_id,
1135            agent: params.agent,
1136            success,
1137            task_id,
1138            structured,
1139            source_anchors,
1140        })
1141    }
1142
1143    /// Execute a task in the background.
1144    ///
1145    /// Returns immediately with the task ID; the same id is used in the emitted
1146    /// `SubagentStart`/`SubagentEnd` events so callers can correlate. Pre-emits
1147    /// `SubagentStart` synchronously when an event channel is available so a
1148    /// caller that queries the subagent task tracker right after this call
1149    /// observes the task in `Running` state without a race window.
1150    pub fn execute_background(
1151        self: Arc<Self>,
1152        params: TaskParams,
1153        event_tx: Option<broadcast::Sender<AgentEvent>>,
1154        parent_session_id: Option<String>,
1155    ) -> String {
1156        let parent_cancellation = self.parent_cancellation.clone();
1157        self.execute_background_with_parent_cancellation(
1158            params,
1159            event_tx,
1160            parent_session_id,
1161            parent_cancellation,
1162        )
1163    }
1164
1165    fn execute_background_with_parent_cancellation(
1166        self: Arc<Self>,
1167        params: TaskParams,
1168        event_tx: Option<broadcast::Sender<AgentEvent>>,
1169        parent_session_id: Option<String>,
1170        parent_cancellation: Option<CancellationToken>,
1171    ) -> String {
1172        let task_id = format!("task-{}", uuid::Uuid::new_v4());
1173        let session_id = format!("task-run-{}", task_id);
1174        let failure_session_id = session_id.clone();
1175        let failure_agent = params.agent.clone();
1176        let start_event = AgentEvent::SubagentStart {
1177            task_id: task_id.clone(),
1178            session_id,
1179            parent_session_id: parent_session_id.clone().unwrap_or_default(),
1180            agent: params.agent.clone(),
1181            description: params.description.clone(),
1182            started_ms: epoch_ms(),
1183        };
1184        let security_provider = self
1185            .parent_context
1186            .as_ref()
1187            .and_then(|context| context.security_provider.clone());
1188        let start_event = security_provider
1189            .as_deref()
1190            .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
1191            .unwrap_or(start_event);
1192
1193        if let Some(ref tx) = event_tx {
1194            let _ = tx.send(start_event.clone());
1195        }
1196
1197        let capability_admission = self
1198            .capability_context
1199            .as_ref()
1200            .map(|context| {
1201                context
1202                    .admit_subtask(task_id.clone(), true)
1203                    .map(|subtask| (context.background_scope().clone(), subtask))
1204            })
1205            .transpose();
1206        let (capability_run, admitted_capability_subtask) = match capability_admission {
1207            Ok(Some((run, subtask))) => (Some(run), Some(subtask)),
1208            Ok(None) => (None, None),
1209            Err(error) => {
1210                let message = format!("Background task capability admission failed: {error}");
1211                let end_event = AgentEvent::SubagentEnd {
1212                    task_id: task_id.clone(),
1213                    session_id: failure_session_id,
1214                    agent: failure_agent,
1215                    output: message.clone(),
1216                    success: false,
1217                    finished_ms: epoch_ms(),
1218                };
1219                let end_event = security_provider
1220                    .as_deref()
1221                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
1222                    .unwrap_or(end_event);
1223                if let Some(tx) = event_tx {
1224                    let _ = tx.send(end_event);
1225                }
1226                tracing::error!(task_id = %task_id, "{message}");
1227                return task_id;
1228            }
1229        };
1230
1231        let task_id_for_spawn = task_id.clone();
1232        let task_id_for_log = task_id.clone();
1233        let admission_failure_task_id = task_id.clone();
1234        let admission_failure_session_id = failure_session_id.clone();
1235        let admission_failure_agent = failure_agent.clone();
1236        let admission_failure_events = event_tx.clone();
1237        let admission_failure_security = security_provider.clone();
1238        let background = async move {
1239            if let Some(ref tracker) = self.subagent_tracker {
1240                tracker.record_event(&start_event).await;
1241            }
1242            let failure_event_tx = event_tx.clone();
1243            if let Err(error) = self
1244                .execute_with_task_id_scoped(
1245                    task_id_for_spawn,
1246                    params,
1247                    ScopedTaskExecution {
1248                        event_tx,
1249                        parent_session_id: parent_session_id.as_deref(),
1250                        emit_start: false,
1251                        parent_cancellation: parent_cancellation.as_ref(),
1252                        admitted_capability_subtask,
1253                        parallel_lifecycle: None,
1254                    },
1255                )
1256                .await
1257            {
1258                let end_event = AgentEvent::SubagentEnd {
1259                    task_id: task_id_for_log.clone(),
1260                    session_id: failure_session_id,
1261                    agent: failure_agent,
1262                    output: format!("Task failed before child execution started: {error}"),
1263                    success: false,
1264                    finished_ms: epoch_ms(),
1265                };
1266                let end_event = security_provider
1267                    .as_deref()
1268                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
1269                    .unwrap_or(end_event);
1270                if let Some(ref tracker) = self.subagent_tracker {
1271                    tracker.record_event(&end_event).await;
1272                    tracker.clear_canceller(&task_id_for_log).await;
1273                }
1274                if let Some(tx) = failure_event_tx {
1275                    let _ = tx.send(end_event);
1276                }
1277                tracing::error!("Background task {} failed: {}", task_id_for_log, error);
1278            }
1279        };
1280        if let Some(run) = capability_run {
1281            let task_name = format!("subagent.{task_id}");
1282            if let Err(error) = run.spawn_task(task_name, async move {
1283                background.await;
1284                Ok(())
1285            }) {
1286                // Admission races with Run close fail closed: no detached
1287                // child work may escape after the exact generation lease is
1288                // released.
1289                let message = format!("Background task capability admission failed: {error}");
1290                let end_event = AgentEvent::SubagentEnd {
1291                    task_id: admission_failure_task_id.clone(),
1292                    session_id: admission_failure_session_id,
1293                    agent: admission_failure_agent,
1294                    output: message.clone(),
1295                    success: false,
1296                    finished_ms: epoch_ms(),
1297                };
1298                let end_event = admission_failure_security
1299                    .as_deref()
1300                    .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
1301                    .unwrap_or(end_event);
1302                if let Some(tx) = admission_failure_events {
1303                    let _ = tx.send(end_event);
1304                }
1305                tracing::error!(task_id = %admission_failure_task_id, "{message}");
1306            }
1307        } else {
1308            tokio::spawn(background);
1309        }
1310
1311        task_id
1312    }
1313}
1314
1315async fn close_capability_subtask(
1316    subtask: Option<&crate::capability::AgentCapabilitySubtask>,
1317) -> Result<()> {
1318    let Some(subtask) = subtask else {
1319        return Ok(());
1320    };
1321    let report = subtask.close().await?;
1322    if !report.is_clean() {
1323        anyhow::bail!(
1324            "Capability Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
1325            report.tasks_failed,
1326            report.tasks_timed_out,
1327            report.child_scopes_failed,
1328            report.child_scopes_timed_out,
1329            report.effects_failed,
1330            report.effects_timed_out,
1331        );
1332    }
1333    Ok(())
1334}
1335
1336fn settle_task_capability_operation<T>(
1337    execution: Result<T>,
1338    close: Result<()>,
1339    label: &str,
1340) -> Result<T> {
1341    match (execution, close) {
1342        (Ok(result), Ok(())) => Ok(result),
1343        (Ok(_), Err(close_error)) => Err(close_error),
1344        (Err(error), Ok(())) => Err(error),
1345        (Err(error), Err(close_error)) => {
1346            tracing::warn!(
1347                error = %close_error,
1348                operation = label,
1349                "Capability orchestration Turn close also failed after model failure"
1350            );
1351            Err(error)
1352        }
1353    }
1354}
1355
1356fn structured_task_prompt(prompt: &str, schema: &serde_json::Value) -> String {
1357    let schema = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
1358    format!(
1359        "{prompt}\n\n\
1360         FINAL OUTPUT CONTRACT\n\
1361         Complete the requested investigation before answering. Your final response must contain \
1362         exactly one JSON value matching the JSON Schema below, with no Markdown fence or prose \
1363         outside the JSON. This contract applies to the final response only; use the available \
1364         tools as needed before finalizing.\n\n\
1365         {schema}"
1366    )
1367}
1368
1369fn value_matches_schema(value: &serde_json::Value, schema: &serde_json::Value) -> bool {
1370    serde_json::to_string(value)
1371        .ok()
1372        .and_then(|encoded| parse_validated_output(&encoded, schema))
1373        .is_some()
1374}
1375
1376#[derive(Debug, Clone)]
1377struct AgentCatalogEntry {
1378    name: String,
1379    description: String,
1380}
1381
1382fn agent_catalog_entries(agents: &[AgentDefinition]) -> Vec<AgentCatalogEntry> {
1383    let mut entries = agents
1384        .iter()
1385        .map(|agent| AgentCatalogEntry {
1386            name: agent.name.clone(),
1387            description: agent
1388                .description
1389                .split_whitespace()
1390                .collect::<Vec<_>>()
1391                .join(" "),
1392        })
1393        .collect::<Vec<_>>();
1394    entries.sort_by(|left, right| left.name.cmp(&right.name));
1395    entries
1396}
1397
1398fn agent_catalog_text(agents: &[AgentDefinition]) -> String {
1399    agent_catalog_entries(agents)
1400        .into_iter()
1401        .map(|entry| format!("{}: {}", entry.name, entry.description))
1402        .collect::<Vec<_>>()
1403        .join("\n")
1404}
1405
1406fn delegation_tool_description(base: &str, agents: &[AgentDefinition]) -> String {
1407    format!(
1408        "{base}\n\nAvailable agents (live catalog; use canonical names):\n{}",
1409        agent_catalog_text(agents)
1410    )
1411}
1412
1413pub(super) fn task_agent_parameter_schema(agents: &[AgentDefinition]) -> serde_json::Value {
1414    let entries = agent_catalog_entries(agents);
1415    let examples = entries
1416        .iter()
1417        .map(|entry| serde_json::Value::String(entry.name.clone()))
1418        .collect::<Vec<_>>();
1419    let catalog = entries
1420        .into_iter()
1421        .map(|entry| format!("{}: {}", entry.name, entry.description))
1422        .collect::<Vec<_>>()
1423        .join("\n");
1424    serde_json::json!({
1425        "type": "string",
1426        "description": format!(
1427            "Required. Canonical agent type to use. Always provide this exact field name: 'agent'. Live agent catalog:\n{catalog}"
1428        ),
1429        "examples": examples
1430    })
1431}
1432
1433/// Get the compatibility JSON schema accepted by the `task` executor.
1434///
1435/// The model sees the more compact array-only schema. This public schema also
1436/// accepts the pre-6.8 single-task object so persisted and host-direct calls do
1437/// not break during the tool-surface migration.
1438pub fn task_params_schema() -> serde_json::Value {
1439    task_params_schema_for_agents(&AgentRegistry::new().list_visible())
1440}
1441
1442fn task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1443    serde_json::json!({
1444        "oneOf": [
1445            legacy_task_params_schema_for_agents(agents),
1446            task_model_params_schema_for_agents(agents)
1447        ]
1448    })
1449}
1450
1451fn legacy_task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1452    let mut schema = task_item_params_schema_for_agents(agents, true);
1453    schema["examples"] = serde_json::json!([
1454        {
1455            "agent": "explore",
1456            "description": "Find Rust files",
1457            "prompt": "Search the workspace for Rust files and summarize the layout."
1458        },
1459        {
1460            "agent": "general",
1461            "description": "Investigate test failure",
1462            "prompt": "Inspect the failing tests and explain the root cause.",
1463            "max_steps": 6
1464        }
1465    ]);
1466    schema
1467}
1468
1469fn task_model_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
1470    parallel_params::task_tool_params_schema_for_agents(agents)
1471}
1472
1473pub(super) fn task_item_params_schema_for_agents(
1474    agents: &[AgentDefinition],
1475    include_background: bool,
1476) -> serde_json::Value {
1477    let mut properties = serde_json::Map::from_iter([
1478        ("agent".to_string(), task_agent_parameter_schema(agents)),
1479        (
1480            "description".to_string(),
1481            serde_json::json!({
1482                "type": "string",
1483                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
1484            }),
1485        ),
1486        (
1487            "prompt".to_string(),
1488            serde_json::json!({
1489                "type": "string",
1490                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
1491            }),
1492        ),
1493        (
1494            "max_steps".to_string(),
1495            serde_json::json!({
1496                "type": "integer",
1497                "description": "Optional. Maximum number of steps for this task."
1498            }),
1499        ),
1500        (
1501            "output_schema".to_string(),
1502            serde_json::json!({
1503                "type": "object",
1504                "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."
1505            }),
1506        ),
1507    ]);
1508    if include_background {
1509        properties.insert(
1510            "background".to_string(),
1511            serde_json::json!({
1512                "type": "boolean",
1513                "description": "Optional. Run this task in the background. Only valid when the outer tasks array contains one item. Default: false.",
1514                "default": false
1515            }),
1516        );
1517    }
1518
1519    serde_json::json!({
1520        "type": "object",
1521        "additionalProperties": false,
1522        "properties": properties,
1523        "required": ["agent", "description", "prompt"]
1524    })
1525}
1526
1527/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
1528/// This allows the LLM to delegate tasks through the standard tool interface.
1529pub struct TaskTool {
1530    executor: Arc<TaskExecutor>,
1531}
1532
1533impl TaskTool {
1534    /// Create a new TaskTool
1535    pub fn new(executor: Arc<TaskExecutor>) -> Self {
1536        Self { executor }
1537    }
1538
1539    async fn execute_single(&self, params: TaskParams, ctx: &ToolContext) -> Result<ToolOutput> {
1540        let parent_cancellation = ctx.cancellation_token();
1541        let executor = self.executor.scoped_for_invocation(ctx);
1542
1543        if params.background {
1544            let task_id = executor.execute_background_with_parent_cancellation(
1545                params,
1546                ctx.agent_event_tx.clone(),
1547                ctx.session_id.clone(),
1548                Some(parent_cancellation),
1549            );
1550            return Ok(ToolOutput::success(format!(
1551                "Task started in background. Task ID: {}",
1552                task_id
1553            )));
1554        }
1555
1556        let result = executor
1557            .execute_with_parent_cancellation(
1558                params,
1559                ctx.agent_event_tx.clone(),
1560                ctx.session_id.as_deref(),
1561                Some(&parent_cancellation),
1562            )
1563            .await?;
1564        let (content, truncated) = format_task_result_for_context(&result);
1565        let metadata = serde_json::json!({
1566            "task_id": result.task_id,
1567            "session_id": result.session_id,
1568            "agent": result.agent,
1569            "success": result.success,
1570            "output_bytes": result.output.len(),
1571            "truncated_for_context": truncated,
1572            "artifact_id": task_artifact_id(&result),
1573            "artifact_uri": task_artifact_uri(&result),
1574            "structured": result.structured,
1575            "source_anchors": result.source_anchors,
1576        });
1577
1578        if result.success {
1579            Ok(ToolOutput::success(content).with_metadata(metadata))
1580        } else {
1581            Ok(ToolOutput::error(content).with_metadata(metadata))
1582        }
1583    }
1584}
1585
1586#[async_trait]
1587impl Tool for TaskTool {
1588    fn name(&self) -> &str {
1589        "task"
1590    }
1591
1592    fn description(&self) -> &str {
1593        TASK_TOOL_DESCRIPTION
1594    }
1595
1596    fn parameters(&self) -> serde_json::Value {
1597        task_params_schema_for_agents(&self.executor.visible_agents())
1598    }
1599
1600    fn definition(&self) -> ToolDefinition {
1601        let agents = self.executor.visible_agents();
1602        ToolDefinition {
1603            name: self.name().to_string(),
1604            description: delegation_tool_description(self.description(), &agents),
1605            parameters: task_model_params_schema_for_agents(&agents),
1606        }
1607    }
1608
1609    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1610        if args.get("tasks").is_some() {
1611            let mut params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1612                Ok(params) => params,
1613                Err(error) => {
1614                    return Ok(invalid_delegation_argument(format!(
1615                        "Invalid task parameters: {error}"
1616                    )));
1617                }
1618            };
1619            if params.tasks.is_empty() {
1620                return Ok(invalid_delegation_argument(
1621                    "task requires at least 1 task".to_string(),
1622                ));
1623            }
1624
1625            let has_fanout_options = params.allow_partial_failure
1626                || params.timeout_ms.is_some()
1627                || params.min_success_count.is_some();
1628            if params.tasks.len() == 1 && !has_fanout_options {
1629                return self.execute_single(params.tasks.remove(0), ctx).await;
1630            }
1631
1632            return ParallelTaskTool::new(Arc::clone(&self.executor))
1633                .execute_params(params, ctx, "task", 1)
1634                .await;
1635        }
1636
1637        let params: TaskParams = match serde_json::from_value(args.clone()) {
1638            Ok(params) => params,
1639            Err(error) => {
1640                return Ok(invalid_delegation_argument(format!(
1641                    "Invalid task parameters: {error}"
1642                )));
1643            }
1644        };
1645        self.execute_single(params, ctx).await
1646    }
1647}
1648
1649mod parallel_params;
1650pub use parallel_params::{parallel_task_params_schema, ParallelTaskParams};
1651
1652mod parallel_task;
1653pub use parallel_task::ParallelTaskTool;
1654
1655fn invalid_delegation_argument(message: String) -> ToolOutput {
1656    ToolOutput::error(&message)
1657        .with_error_kind(crate::tools::ToolErrorKind::InvalidArgument { message })
1658}
1659
1660#[cfg(test)]
1661mod tests;