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 = "Fan out 2 or more INDEPENDENT subtasks as delegated child runs that execute concurrently; results are returned when all complete. By default any failed child makes the tool fail; evidence-gathering callers may set allow_partial_failure=true to continue when at least one child succeeds. Child output never authorizes branch replay; provider and child runtimes own any typed retry policy below this boundary. Use this only when the work genuinely splits into branches that can be investigated or implemented separately. Do not use it for trivial, conversational, single-step, or dependent work. Choose canonical worker names from the live agent catalog.";
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;