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