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