1use 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
40static 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#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(deny_unknown_fields)]
61pub struct TaskParams {
62 pub agent: String,
64 pub description: String,
66 pub prompt: String,
68 #[serde(default)]
70 pub background: bool,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub max_steps: Option<usize>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub output_schema: Option<serde_json::Value>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TaskResult {
82 pub output: String,
84 pub session_id: String,
86 pub agent: String,
88 pub success: bool,
90 pub task_id: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub structured: Option<serde_json::Value>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub source_anchors: Vec<ToolSourceAnchor>,
98 #[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#[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 Err(poisoned) => poisoned.into_inner(),
143 }
144 }
145
146 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 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#[derive(Clone)]
200pub struct TaskExecutor {
201 registry: Arc<AgentRegistry>,
203 llm_client: Arc<dyn LlmClient>,
205 workspace: String,
207 mcp_managers: Vec<Arc<McpManager>>,
209 mcp_bindings: Vec<Arc<McpBinding>>,
211 child_tool_presentation: Option<crate::tools::ToolPresentationProfileV1>,
215 scoped_tools: Vec<Arc<dyn Tool>>,
219 parent_context: Option<crate::child_run::ChildRunContext>,
221 search_config: Option<Arc<crate::config::SearchConfig>>,
223 search_bulkhead: Option<a3s_search::Bulkhead>,
225 search_retry_budget: Option<a3s_search::RetryBudget>,
227 search_request_coalescer: Option<a3s_search::SearchCoalescer>,
229 capability_context: Option<crate::capability::AgentToolCapabilityContext>,
231 parent_cancellation: Option<CancellationToken>,
235 max_parallel_tasks: usize,
236 parallel_permits: Arc<tokio::sync::Semaphore>,
240 subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
243 task_scheduler: Option<Arc<crate::task_scheduler::TaskScheduler>>,
245 schedule_foreground: bool,
249 admission_scope: Option<String>,
252 provider_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
256 provider_admission: Option<crate::llm::ModelGenerationAdmission>,
260}
261
262impl TaskExecutor {
263 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 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 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 pub(crate) fn with_projected_mcp_bindings(mut self, bindings: Vec<Arc<McpBinding>>) -> Self {
347 self.mcp_bindings = bindings;
348 self
349 }
350
351 #[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 pub fn with_scoped_tools(mut self, tools: Vec<Arc<dyn Tool>>) -> Self {
366 self.scoped_tools = tools;
367 self
368 }
369
370 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 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 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 pub fn with_task_scheduler(
456 mut self,
457 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
458 schedule_foreground: bool,
459 ) -> Self {
460 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 #[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 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 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 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 "as,
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(¶ms.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) = ¶llel_lifecycle {
809 lifecycle.mark_started(&task_id);
813 }
814 }
815
816 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 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 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 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 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 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 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(¶ms.prompt, schema));
998 let execution_prompt = structured_prompt.as_deref().unwrap_or(¶ms.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 ¶ms.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 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 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) = ¶llel_lifecycle {
1169 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 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 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
1541pub 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
1635pub struct TaskTool {
1638 executor: Arc<TaskExecutor>,
1639}
1640
1641impl TaskTool {
1642 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;