1use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::structured::{
19 generate_blocking, parse_validated_output, StructuredMode, StructuredRequest,
20};
21use crate::llm::{LlmClient, ToolDefinition};
22use crate::mcp::manager::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::panic::AssertUnwindSafe;
32use std::path::PathBuf;
33use std::sync::Arc;
34use tokio::sync::broadcast;
35use tokio::task::JoinSet;
36use tokio_util::sync::CancellationToken;
37
38const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
39const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
40const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
41const MAX_TASK_SOURCE_ANCHORS: usize = 64;
42const MAX_TASK_SOURCE_CANDIDATES: usize = MAX_TASK_SOURCE_ANCHORS * 4;
43const MAX_TASK_SOURCE_TOOL_BYTES: usize = 64;
44const MAX_TASK_SOURCE_VALUE_BYTES: usize = 4 * 1024;
45const MAX_PARALLEL_TASK_SOURCE_ANCHORS: usize = MAX_TASK_SOURCE_ANCHORS;
46const 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.";
47const PARALLEL_TASK_TOOL_DESCRIPTION: &str = "Fan out 2 or more INDEPENDENT subtasks as delegated child runs that execute concurrently; results are returned when all complete. By default any failed child makes the tool fail; evidence-gathering callers may set allow_partial_failure=true to continue when at least one child succeeds. Child output never authorizes branch replay; provider and child runtimes own any typed retry policy below this boundary. Use this only when the work genuinely splits into branches that can be investigated or implemented separately. Do not use it for trivial, conversational, single-step, or dependent work. Choose canonical worker names from the live agent catalog.";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TaskParams {
53 pub agent: String,
55 pub description: String,
57 pub prompt: String,
59 #[serde(default)]
61 pub background: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub max_steps: Option<usize>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub output_schema: Option<serde_json::Value>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct TaskResult {
73 pub output: String,
75 pub session_id: String,
77 pub agent: String,
79 pub success: bool,
81 pub task_id: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub structured: Option<serde_json::Value>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
88 pub source_anchors: Vec<ToolSourceAnchor>,
89}
90
91mod result_projection;
92use result_projection::*;
93
94mod parallel_execution;
95
96const MAX_PARALLEL_TASKS_PER_CALL: usize = 32;
97
98#[derive(Clone)]
100pub struct TaskExecutor {
101 registry: Arc<AgentRegistry>,
103 llm_client: Arc<dyn LlmClient>,
105 workspace: String,
107 mcp_managers: Vec<Arc<McpManager>>,
109 parent_context: Option<crate::child_run::ChildRunContext>,
111 search_config: Option<Arc<crate::config::SearchConfig>>,
113 search_bulkhead: Option<a3s_search::Bulkhead>,
115 search_retry_budget: Option<a3s_search::RetryBudget>,
117 search_request_coalescer: Option<a3s_search::SearchCoalescer>,
119 parent_cancellation: Option<CancellationToken>,
123 max_parallel_tasks: usize,
124 parallel_permits: Arc<tokio::sync::Semaphore>,
128 subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
131 task_scheduler: Option<Arc<crate::task_scheduler::TaskScheduler>>,
133 schedule_foreground: bool,
137}
138
139impl TaskExecutor {
140 pub fn new(
142 registry: Arc<AgentRegistry>,
143 llm_client: Arc<dyn LlmClient>,
144 workspace: String,
145 ) -> Self {
146 Self {
147 registry,
148 llm_client,
149 workspace,
150 mcp_managers: Vec::new(),
151 parent_context: None,
152 search_config: None,
153 search_bulkhead: None,
154 search_retry_budget: None,
155 search_request_coalescer: None,
156 parent_cancellation: None,
157 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
158 parallel_permits: Arc::new(tokio::sync::Semaphore::new(
159 crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
160 )),
161 subagent_tracker: None,
162 task_scheduler: None,
163 schedule_foreground: false,
164 }
165 }
166
167 pub fn with_mcp(
169 registry: Arc<AgentRegistry>,
170 llm_client: Arc<dyn LlmClient>,
171 workspace: String,
172 mcp_manager: Arc<McpManager>,
173 ) -> Self {
174 Self::with_mcp_managers(registry, llm_client, workspace, vec![mcp_manager])
175 }
176
177 pub fn with_mcp_managers(
179 registry: Arc<AgentRegistry>,
180 llm_client: Arc<dyn LlmClient>,
181 workspace: String,
182 mcp_managers: Vec<Arc<McpManager>>,
183 ) -> Self {
184 Self {
185 registry,
186 llm_client,
187 workspace,
188 mcp_managers,
189 parent_context: None,
190 search_config: None,
191 search_bulkhead: None,
192 search_retry_budget: None,
193 search_request_coalescer: None,
194 parent_cancellation: None,
195 max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
196 parallel_permits: Arc::new(tokio::sync::Semaphore::new(
197 crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
198 )),
199 subagent_tracker: None,
200 task_scheduler: None,
201 schedule_foreground: false,
202 }
203 }
204
205 pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
207 if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
208 let max_parallel_tasks = max_parallel_tasks.max(1);
209 self.max_parallel_tasks = max_parallel_tasks;
210 self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
211 }
212 self.parent_context = Some(ctx);
213 self
214 }
215
216 fn scoped_for_invocation(self: &Arc<Self>, ctx: &ToolContext) -> Arc<Self> {
217 let mut scoped = self.as_ref().clone();
218 scoped.search_config = ctx.search_config.clone();
219 scoped.search_bulkhead = Some(ctx.search_bulkhead());
220 scoped.search_retry_budget = Some(ctx.search_retry_budget());
221 scoped.search_request_coalescer = Some(ctx.search_request_coalescer());
222 if ctx.has_run_governance() {
223 scoped.parent_context = scoped.parent_context.take().map(|parent| {
224 parent.with_run_governance(
225 ctx.run_permission_checker(),
226 ctx.run_confirmation_manager(),
227 )
228 });
229 }
230 Arc::new(scoped)
231 }
232
233 fn child_tool_context(
234 &self,
235 session_id: String,
236 cancellation: CancellationToken,
237 ) -> ToolContext {
238 let mut context = ToolContext::new(PathBuf::from(&self.workspace))
239 .with_session_id(session_id)
240 .with_cancellation(cancellation);
241 if let (Some(bulkhead), Some(retry_budget)) =
242 (&self.search_bulkhead, &self.search_retry_budget)
243 {
244 context = context.with_search_runtime(bulkhead.clone(), retry_budget.clone());
245 }
246 if let Some(search_config) = &self.search_config {
247 context = context.with_search_config(search_config.as_ref().clone());
248 }
249 if let Some(coalescer) = &self.search_request_coalescer {
250 context = context.with_search_request_coalescer(coalescer.clone());
251 }
252 context
253 }
254
255 pub fn with_parent_cancellation(mut self, cancellation: CancellationToken) -> Self {
262 self.parent_cancellation = Some(cancellation);
263 self
264 }
265
266 pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
267 let max_parallel_tasks = max_parallel_tasks.max(1);
268 self.max_parallel_tasks = max_parallel_tasks;
269 self.parallel_permits = Arc::new(tokio::sync::Semaphore::new(max_parallel_tasks));
270 self
271 }
272
273 pub fn with_subagent_tracker(
277 mut self,
278 tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
279 ) -> Self {
280 self.subagent_tracker = Some(tracker);
281 self
282 }
283
284 pub fn with_task_scheduler(
286 mut self,
287 scheduler: Arc<crate::task_scheduler::TaskScheduler>,
288 schedule_foreground: bool,
289 ) -> Self {
290 self.task_scheduler = Some(scheduler);
291 self.schedule_foreground = schedule_foreground;
292 self
293 }
294
295 fn visible_agents(&self) -> Vec<AgentDefinition> {
296 self.registry.list_visible()
297 }
298
299 pub async fn execute(
304 &self,
305 params: TaskParams,
306 event_tx: Option<broadcast::Sender<AgentEvent>>,
307 parent_session_id: Option<&str>,
308 ) -> Result<TaskResult> {
309 self.execute_with_parent_cancellation(
310 params,
311 event_tx,
312 parent_session_id,
313 self.parent_cancellation.as_ref(),
314 )
315 .await
316 }
317
318 async fn execute_with_parent_cancellation(
319 &self,
320 params: TaskParams,
321 event_tx: Option<broadcast::Sender<AgentEvent>>,
322 parent_session_id: Option<&str>,
323 parent_cancellation: Option<&CancellationToken>,
324 ) -> Result<TaskResult> {
325 let task_id = format!("task-{}", uuid::Uuid::new_v4());
326 self.execute_with_task_id_scoped(
327 task_id,
328 params,
329 event_tx,
330 parent_session_id,
331 true,
332 parent_cancellation,
333 )
334 .await
335 }
336
337 pub async fn execute_with_task_id(
342 &self,
343 task_id: String,
344 params: TaskParams,
345 event_tx: Option<broadcast::Sender<AgentEvent>>,
346 parent_session_id: Option<&str>,
347 emit_start: bool,
348 ) -> Result<TaskResult> {
349 self.execute_with_task_id_scoped(
350 task_id,
351 params,
352 event_tx,
353 parent_session_id,
354 emit_start,
355 self.parent_cancellation.as_ref(),
356 )
357 .await
358 }
359
360 async fn execute_with_task_id_scoped(
361 &self,
362 task_id: String,
363 params: TaskParams,
364 event_tx: Option<broadcast::Sender<AgentEvent>>,
365 parent_session_id: Option<&str>,
366 emit_start: bool,
367 parent_cancellation: Option<&CancellationToken>,
368 ) -> Result<TaskResult> {
369 if parent_cancellation.is_some_and(CancellationToken::is_cancelled) {
370 anyhow::bail!("Operation cancelled by parent session");
371 }
372
373 let cancel_token = parent_cancellation
374 .map(CancellationToken::child_token)
375 .unwrap_or_default();
376 if params.background {
380 if let Some(ref tracker) = self.subagent_tracker {
381 tracker
382 .register_canceller(&task_id, cancel_token.clone())
383 .await;
384 }
385 }
386 let _task_lease = if params.background || self.schedule_foreground {
387 match &self.task_scheduler {
388 Some(scheduler) => Some(
389 scheduler
390 .acquire(
391 if params.background {
392 crate::task_scheduler::TaskPriority::Background
393 } else {
394 crate::task_scheduler::TaskPriority::Foreground
395 },
396 format!(
397 "{}:subagent:{}",
398 parent_session_id.unwrap_or("host"),
399 task_id
400 ),
401 &cancel_token,
402 )
403 .await
404 .map_err(|error| anyhow::anyhow!(error))?,
405 ),
406 None => None,
407 }
408 } else {
409 None
410 };
411
412 let session_id = format!("task-run-{}", task_id);
413 let started_ms = epoch_ms();
414 let output_schema = params.output_schema.clone();
415
416 let agent = self
417 .registry
418 .get(¶ms.agent)
419 .context(format!("Unknown agent type: '{}'", params.agent))?;
420 let tool_free = agent.tool_free;
421 let tool_free_system = agent.prompt.clone();
422 let inherited_security_provider = self
423 .parent_context
424 .as_ref()
425 .and_then(|context| context.security_provider.clone());
426
427 if emit_start {
428 let event = AgentEvent::SubagentStart {
429 task_id: task_id.clone(),
430 session_id: session_id.clone(),
431 parent_session_id: parent_session_id.unwrap_or_default().to_string(),
432 agent: params.agent.clone(),
433 description: params.description.clone(),
434 started_ms,
435 };
436 let event = inherited_security_provider
437 .as_deref()
438 .map(|provider| crate::security::sanitize_agent_event(provider, &event))
439 .unwrap_or(event);
440 if let Some(ref tracker) = self.subagent_tracker {
441 tracker.record_event(&event).await;
442 }
443 if let Some(ref tx) = event_tx {
444 let _ = tx.send(event);
445 }
446 }
447
448 let child_executor = if let Some(ref parent_ctx) = self.parent_context {
451 if let Some(ref services) = parent_ctx.workspace_services {
452 crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
453 self.workspace.clone(),
454 Arc::clone(services),
455 crate::tools::ArtifactStoreLimits::default(),
456 )
457 } else {
458 crate::tools::ToolExecutor::new(self.workspace.clone())
459 }
460 } else {
461 crate::tools::ToolExecutor::new(self.workspace.clone())
462 };
463
464 for mcp in &self.mcp_managers {
466 let all_tools = tokio::select! {
467 biased;
468 _ = cancel_token.cancelled() => {
469 anyhow::bail!("Operation cancelled before child execution");
470 }
471 tools = mcp.get_all_tools() => tools,
472 };
473 let mut by_server: std::collections::HashMap<
474 String,
475 Vec<crate::mcp::protocol::McpTool>,
476 > = std::collections::HashMap::new();
477 for (server, tool) in all_tools {
478 by_server.entry(server).or_default().push(tool);
479 }
480 for (server_name, tools) in by_server {
481 let wrappers =
482 crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
483 for wrapper in wrappers {
484 child_executor.register_dynamic_tool(wrapper);
485 }
486 }
487 }
488
489 let child_executor = Arc::new(child_executor);
490
491 let mut child_config = AgentConfig {
492 tools: child_executor.definitions(),
493 ..AgentConfig::default()
494 };
495 agent.apply_to(&mut child_config);
496 if let Some(ref parent_ctx) = self.parent_context {
497 parent_ctx.apply_to(&mut child_config);
498 }
499 child_config.planning_mode = crate::prompts::PlanningMode::Disabled;
504 if let Some(max_steps) = params.max_steps {
505 child_config.max_tool_rounds = max_steps;
506 }
507 let child_security_provider = child_config.security_provider.clone();
508 let source_security_provider = child_security_provider.clone();
509
510 let mut tool_context = self.child_tool_context(session_id.clone(), cancel_token.clone());
511 if let Some(ref parent_ctx) = self.parent_context {
512 if let Some(ref services) = parent_ctx.workspace_services {
513 tool_context = tool_context.with_workspace_services(Arc::clone(services));
514 }
515 if let Some(ref sandbox) = parent_ctx.sandbox_handle {
516 child_executor.registry().set_sandbox(Arc::clone(sandbox));
517 tool_context = tool_context.with_sandbox(Arc::clone(sandbox));
518 }
519 }
520
521 let source_context = tool_context.clone();
522 let agent_loop = AgentLoop::new(
523 Arc::clone(&self.llm_client),
524 child_executor,
525 tool_context,
526 child_config,
527 );
528
529 let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
533 let broadcast_tx = event_tx.clone();
534 let progress_task_id = task_id.clone();
535 let progress_session_id = session_id.clone();
536 let child_event_forwarder = tokio::spawn(async move {
537 let mut source_anchors = Vec::new();
538 let mut seen_source_anchors = std::collections::HashSet::new();
539 let mut scanned_source_candidates = 0usize;
540 while let Some(event) = mpsc_rx.recv().await {
541 let event = source_security_provider
542 .as_deref()
543 .map(|provider| crate::security::sanitize_agent_event(provider, &event))
544 .unwrap_or(event);
545 collect_tool_source_anchors(
546 &event,
547 &source_context,
548 &mut source_anchors,
549 &mut seen_source_anchors,
550 &mut scanned_source_candidates,
551 );
552 if let Some(ref broadcast_tx) = broadcast_tx {
553 if let Some(progress) = synthesize_subagent_progress(
554 &event,
555 &progress_task_id,
556 &progress_session_id,
557 ) {
558 let _ = broadcast_tx.send(progress);
559 }
560 let _ = broadcast_tx.send(event);
561 }
562 }
563 source_anchors
564 });
565 let child_event_tx = Some(mpsc_tx);
566 let child_llm_event_tx = child_event_tx.clone();
567
568 if !params.background {
571 if let Some(ref tracker) = self.subagent_tracker {
572 tracker
573 .register_canceller(&task_id, cancel_token.clone())
574 .await;
575 }
576 }
577
578 let structured_prompt = output_schema
579 .as_ref()
580 .filter(|_| !tool_free)
581 .map(|schema| structured_task_prompt(¶ms.prompt, schema));
582 let execution_prompt = structured_prompt.as_deref().unwrap_or(¶ms.prompt);
583
584 let mut structured = None;
585 let (mut output, mut success) = if tool_free && output_schema.is_some() {
586 let llm_client = agent_loop.scoped_llm_client_for_parts(
587 Some(&session_id),
588 &child_llm_event_tx,
589 &cancel_token,
590 );
591 match Self::generate_structured_task(
592 &*llm_client,
593 ¶ms.prompt,
594 tool_free_system.as_deref(),
595 output_schema.clone().expect("schema checked above"),
596 &cancel_token,
597 )
598 .await
599 {
600 Ok(object) => {
601 let output = serde_json::to_string_pretty(&object)
602 .unwrap_or_else(|_| object.to_string());
603 structured = Some(object);
604 (output, true)
605 }
606 Err(error) if cancel_token.is_cancelled() => {
607 (format!("Task cancelled by caller: {error}"), false)
608 }
609 Err(error) => (format!("Task failed: {error}"), false),
610 }
611 } else {
612 match agent_loop
613 .execute_with_session(
614 &[],
615 execution_prompt,
616 Some(&session_id),
617 child_event_tx.clone(),
618 Some(&cancel_token),
619 )
620 .await
621 {
622 Ok(_) if cancel_token.is_cancelled() => {
623 ("Task cancelled by caller".to_string(), false)
624 }
625 Ok(result) if result.text.trim().is_empty() => (
626 "Task failed: child agent returned no final output".to_string(),
627 false,
628 ),
629 Ok(result) if AgentLoop::is_synthetic_failure_output(&result.text) => {
630 (format!("Task failed: {}", result.text), false)
631 }
632 Ok(result) => (result.text, true),
633 Err(e) if cancel_token.is_cancelled() => {
634 (format!("Task cancelled by caller: {}", e), false)
635 }
636 Err(e) => (format!("Task failed: {}", e), false),
637 }
638 };
639
640 if success && !tool_free {
641 if let Some(schema) = output_schema {
642 if let Some(object) = parse_validated_output(&output, &schema) {
643 structured = Some(object);
644 } else {
645 let llm_client = agent_loop.scoped_llm_client_for_parts(
646 Some(&session_id),
647 &child_llm_event_tx,
648 &cancel_token,
649 );
650 match Self::coerce_to_schema(&*llm_client, &output, schema, &cancel_token).await
651 {
652 Ok(object) => structured = Some(object),
653 Err(error) => {
654 success = false;
655 output = format!("{output}\n\n[structured output failed: {error}]");
656 }
657 }
658 }
659 }
660 }
661 if let Some(provider) = child_security_provider.as_deref() {
662 output = provider.sanitize_output(&output);
663 if let Some(value) = &mut structured {
664 *value = sanitize_task_json(provider, value);
665 }
666 }
667
668 drop(child_event_tx);
673 drop(child_llm_event_tx);
674 let source_anchors = match child_event_forwarder.await {
675 Ok(source_anchors) => source_anchors,
676 Err(error) => {
677 tracing::warn!(%error, task_id = %task_id, "subagent event bridge failed");
678 Vec::new()
679 }
680 };
681
682 let end_event = AgentEvent::SubagentEnd {
683 task_id: task_id.clone(),
684 session_id: session_id.clone(),
685 agent: params.agent.clone(),
686 output: output.clone(),
687 success,
688 finished_ms: epoch_ms(),
689 };
690 if let Some(ref tracker) = self.subagent_tracker {
691 if success {
694 tracker
695 .record_source_anchors(&task_id, &source_anchors)
696 .await;
697 }
698 tracker.record_event(&end_event).await;
699 tracker.clear_canceller(&task_id).await;
700 }
701 if let Some(ref tx) = event_tx {
702 let _ = tx.send(end_event);
703 }
704
705 Ok(TaskResult {
706 output,
707 session_id,
708 agent: params.agent,
709 success,
710 task_id,
711 structured,
712 source_anchors,
713 })
714 }
715
716 pub fn execute_background(
724 self: Arc<Self>,
725 params: TaskParams,
726 event_tx: Option<broadcast::Sender<AgentEvent>>,
727 parent_session_id: Option<String>,
728 ) -> String {
729 let parent_cancellation = self.parent_cancellation.clone();
730 self.execute_background_with_parent_cancellation(
731 params,
732 event_tx,
733 parent_session_id,
734 parent_cancellation,
735 )
736 }
737
738 fn execute_background_with_parent_cancellation(
739 self: Arc<Self>,
740 params: TaskParams,
741 event_tx: Option<broadcast::Sender<AgentEvent>>,
742 parent_session_id: Option<String>,
743 parent_cancellation: Option<CancellationToken>,
744 ) -> String {
745 let task_id = format!("task-{}", uuid::Uuid::new_v4());
746 let session_id = format!("task-run-{}", task_id);
747 let failure_session_id = session_id.clone();
748 let failure_agent = params.agent.clone();
749 let start_event = AgentEvent::SubagentStart {
750 task_id: task_id.clone(),
751 session_id,
752 parent_session_id: parent_session_id.clone().unwrap_or_default(),
753 agent: params.agent.clone(),
754 description: params.description.clone(),
755 started_ms: epoch_ms(),
756 };
757 let security_provider = self
758 .parent_context
759 .as_ref()
760 .and_then(|context| context.security_provider.clone());
761 let start_event = security_provider
762 .as_deref()
763 .map(|provider| crate::security::sanitize_agent_event(provider, &start_event))
764 .unwrap_or(start_event);
765
766 if let Some(ref tx) = event_tx {
767 let _ = tx.send(start_event.clone());
768 }
769
770 let task_id_for_spawn = task_id.clone();
771 let task_id_for_log = task_id.clone();
772 tokio::spawn(async move {
773 if let Some(ref tracker) = self.subagent_tracker {
774 tracker.record_event(&start_event).await;
775 }
776 let failure_event_tx = event_tx.clone();
777 if let Err(error) = self
778 .execute_with_task_id_scoped(
779 task_id_for_spawn,
780 params,
781 event_tx,
782 parent_session_id.as_deref(),
783 false,
784 parent_cancellation.as_ref(),
785 )
786 .await
787 {
788 let end_event = AgentEvent::SubagentEnd {
789 task_id: task_id_for_log.clone(),
790 session_id: failure_session_id,
791 agent: failure_agent,
792 output: format!("Task failed before child execution started: {error}"),
793 success: false,
794 finished_ms: epoch_ms(),
795 };
796 let end_event = security_provider
797 .as_deref()
798 .map(|provider| crate::security::sanitize_agent_event(provider, &end_event))
799 .unwrap_or(end_event);
800 if let Some(ref tracker) = self.subagent_tracker {
801 tracker.record_event(&end_event).await;
802 tracker.clear_canceller(&task_id_for_log).await;
803 }
804 if let Some(tx) = failure_event_tx {
805 let _ = tx.send(end_event);
806 }
807 tracing::error!("Background task {} failed: {}", task_id_for_log, error);
808 }
809 });
810
811 task_id
812 }
813}
814
815fn structured_task_prompt(prompt: &str, schema: &serde_json::Value) -> String {
816 let schema = serde_json::to_string_pretty(schema).unwrap_or_else(|_| schema.to_string());
817 format!(
818 "{prompt}\n\n\
819 FINAL OUTPUT CONTRACT\n\
820 Complete the requested investigation before answering. Your final response must contain \
821 exactly one JSON value matching the JSON Schema below, with no Markdown fence or prose \
822 outside the JSON. This contract applies to the final response only; use the available \
823 tools as needed before finalizing.\n\n\
824 {schema}"
825 )
826}
827
828#[derive(Debug, Clone)]
829struct AgentCatalogEntry {
830 name: String,
831 description: String,
832}
833
834fn agent_catalog_entries(agents: &[AgentDefinition]) -> Vec<AgentCatalogEntry> {
835 let mut entries = agents
836 .iter()
837 .map(|agent| AgentCatalogEntry {
838 name: agent.name.clone(),
839 description: agent
840 .description
841 .split_whitespace()
842 .collect::<Vec<_>>()
843 .join(" "),
844 })
845 .collect::<Vec<_>>();
846 entries.sort_by(|left, right| left.name.cmp(&right.name));
847 entries
848}
849
850fn agent_catalog_text(agents: &[AgentDefinition]) -> String {
851 agent_catalog_entries(agents)
852 .into_iter()
853 .map(|entry| format!("{}: {}", entry.name, entry.description))
854 .collect::<Vec<_>>()
855 .join("\n")
856}
857
858fn delegation_tool_description(base: &str, agents: &[AgentDefinition]) -> String {
859 format!(
860 "{base}\n\nAvailable agents (live catalog; use canonical names):\n{}",
861 agent_catalog_text(agents)
862 )
863}
864
865pub(super) fn task_agent_parameter_schema(agents: &[AgentDefinition]) -> serde_json::Value {
866 let entries = agent_catalog_entries(agents);
867 let examples = entries
868 .iter()
869 .map(|entry| serde_json::Value::String(entry.name.clone()))
870 .collect::<Vec<_>>();
871 let catalog = entries
872 .into_iter()
873 .map(|entry| format!("{}: {}", entry.name, entry.description))
874 .collect::<Vec<_>>()
875 .join("\n");
876 serde_json::json!({
877 "type": "string",
878 "description": format!(
879 "Required. Canonical agent type to use. Always provide this exact field name: 'agent'. Live agent catalog:\n{catalog}"
880 ),
881 "examples": examples
882 })
883}
884
885pub fn task_params_schema() -> serde_json::Value {
891 task_params_schema_for_agents(&AgentRegistry::new().list_visible())
892}
893
894fn task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
895 serde_json::json!({
896 "oneOf": [
897 legacy_task_params_schema_for_agents(agents),
898 task_model_params_schema_for_agents(agents)
899 ]
900 })
901}
902
903fn legacy_task_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
904 let mut schema = task_item_params_schema_for_agents(agents, true);
905 schema["examples"] = serde_json::json!([
906 {
907 "agent": "explore",
908 "description": "Find Rust files",
909 "prompt": "Search the workspace for Rust files and summarize the layout."
910 },
911 {
912 "agent": "general",
913 "description": "Investigate test failure",
914 "prompt": "Inspect the failing tests and explain the root cause.",
915 "max_steps": 6
916 }
917 ]);
918 schema
919}
920
921fn task_model_params_schema_for_agents(agents: &[AgentDefinition]) -> serde_json::Value {
922 parallel_params::task_tool_params_schema_for_agents(agents)
923}
924
925pub(super) fn task_item_params_schema_for_agents(
926 agents: &[AgentDefinition],
927 include_background: bool,
928) -> serde_json::Value {
929 let mut properties = serde_json::Map::from_iter([
930 ("agent".to_string(), task_agent_parameter_schema(agents)),
931 (
932 "description".to_string(),
933 serde_json::json!({
934 "type": "string",
935 "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
936 }),
937 ),
938 (
939 "prompt".to_string(),
940 serde_json::json!({
941 "type": "string",
942 "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
943 }),
944 ),
945 (
946 "max_steps".to_string(),
947 serde_json::json!({
948 "type": "integer",
949 "description": "Optional. Maximum number of steps for this task."
950 }),
951 ),
952 (
953 "output_schema".to_string(),
954 serde_json::json!({
955 "type": "object",
956 "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."
957 }),
958 ),
959 ]);
960 if include_background {
961 properties.insert(
962 "background".to_string(),
963 serde_json::json!({
964 "type": "boolean",
965 "description": "Optional. Run this task in the background. Only valid when the outer tasks array contains one item. Default: false.",
966 "default": false
967 }),
968 );
969 }
970
971 serde_json::json!({
972 "type": "object",
973 "additionalProperties": false,
974 "properties": properties,
975 "required": ["agent", "description", "prompt"]
976 })
977}
978
979pub struct TaskTool {
982 executor: Arc<TaskExecutor>,
983}
984
985impl TaskTool {
986 pub fn new(executor: Arc<TaskExecutor>) -> Self {
988 Self { executor }
989 }
990
991 async fn execute_single(&self, params: TaskParams, ctx: &ToolContext) -> Result<ToolOutput> {
992 let parent_cancellation = ctx.cancellation_token();
993 let executor = self.executor.scoped_for_invocation(ctx);
994
995 if params.background {
996 let task_id = executor.execute_background_with_parent_cancellation(
997 params,
998 ctx.agent_event_tx.clone(),
999 ctx.session_id.clone(),
1000 Some(parent_cancellation),
1001 );
1002 return Ok(ToolOutput::success(format!(
1003 "Task started in background. Task ID: {}",
1004 task_id
1005 )));
1006 }
1007
1008 let result = executor
1009 .execute_with_parent_cancellation(
1010 params,
1011 ctx.agent_event_tx.clone(),
1012 ctx.session_id.as_deref(),
1013 Some(&parent_cancellation),
1014 )
1015 .await?;
1016 let (content, truncated) = format_task_result_for_context(&result);
1017 let metadata = serde_json::json!({
1018 "task_id": result.task_id,
1019 "session_id": result.session_id,
1020 "agent": result.agent,
1021 "success": result.success,
1022 "output_bytes": result.output.len(),
1023 "truncated_for_context": truncated,
1024 "artifact_id": task_artifact_id(&result),
1025 "artifact_uri": task_artifact_uri(&result),
1026 "structured": result.structured,
1027 "source_anchors": result.source_anchors,
1028 });
1029
1030 if result.success {
1031 Ok(ToolOutput::success(content).with_metadata(metadata))
1032 } else {
1033 Ok(ToolOutput::error(content).with_metadata(metadata))
1034 }
1035 }
1036}
1037
1038#[async_trait]
1039impl Tool for TaskTool {
1040 fn name(&self) -> &str {
1041 "task"
1042 }
1043
1044 fn description(&self) -> &str {
1045 TASK_TOOL_DESCRIPTION
1046 }
1047
1048 fn parameters(&self) -> serde_json::Value {
1049 task_params_schema_for_agents(&self.executor.visible_agents())
1050 }
1051
1052 fn definition(&self) -> ToolDefinition {
1053 let agents = self.executor.visible_agents();
1054 ToolDefinition {
1055 name: self.name().to_string(),
1056 description: delegation_tool_description(self.description(), &agents),
1057 parameters: task_model_params_schema_for_agents(&agents),
1058 }
1059 }
1060
1061 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1062 if args.get("tasks").is_some() {
1063 let mut params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1064 Ok(params) => params,
1065 Err(error) => {
1066 return Ok(invalid_delegation_argument(format!(
1067 "Invalid task parameters: {error}"
1068 )));
1069 }
1070 };
1071 if params.tasks.is_empty() {
1072 return Ok(invalid_delegation_argument(
1073 "task requires at least 1 task".to_string(),
1074 ));
1075 }
1076
1077 let has_fanout_options = params.allow_partial_failure
1078 || params.timeout_ms.is_some()
1079 || params.min_success_count.is_some();
1080 if params.tasks.len() == 1 && !has_fanout_options {
1081 return self.execute_single(params.tasks.remove(0), ctx).await;
1082 }
1083
1084 return ParallelTaskTool::new(Arc::clone(&self.executor))
1085 .execute_params(params, ctx, "task", 1)
1086 .await;
1087 }
1088
1089 let params: TaskParams = match serde_json::from_value(args.clone()) {
1090 Ok(params) => params,
1091 Err(error) => {
1092 return Ok(invalid_delegation_argument(format!(
1093 "Invalid task parameters: {error}"
1094 )));
1095 }
1096 };
1097 self.execute_single(params, ctx).await
1098 }
1099}
1100
1101mod parallel_params;
1102pub use parallel_params::{parallel_task_params_schema, ParallelTaskParams};
1103
1104pub struct ParallelTaskTool {
1108 executor: Arc<TaskExecutor>,
1109}
1110
1111impl ParallelTaskTool {
1112 pub fn new(executor: Arc<TaskExecutor>) -> Self {
1114 Self { executor }
1115 }
1116
1117 async fn execute_params(
1118 &self,
1119 params: ParallelTaskParams,
1120 ctx: &ToolContext,
1121 tool_name: &str,
1122 min_tasks: usize,
1123 ) -> Result<ToolOutput> {
1124 let started_at = std::time::Instant::now();
1125 let parent_cancellation = ctx.cancellation_token();
1126 let executor = self.executor.scoped_for_invocation(ctx);
1127
1128 if params.tasks.len() < min_tasks {
1129 return Ok(invalid_delegation_argument(format!(
1130 "{tool_name} requires at least {min_tasks} task{}",
1131 if min_tasks == 1 { "" } else { "s" }
1132 )));
1133 }
1134 if params.tasks.len() > MAX_PARALLEL_TASKS_PER_CALL {
1135 return Ok(invalid_delegation_argument(format!(
1136 "{tool_name} accepts at most {MAX_PARALLEL_TASKS_PER_CALL} tasks"
1137 )));
1138 }
1139 if let Some((index, _)) = params
1140 .tasks
1141 .iter()
1142 .enumerate()
1143 .find(|(_, task)| task.background)
1144 {
1145 return Ok(invalid_delegation_argument(format!(
1146 "{tool_name} task {} cannot set background=true when fan-out options are used or multiple tasks are submitted; every branch is collected by the parent call",
1147 index + 1
1148 )));
1149 }
1150 if params.timeout_ms == Some(0) {
1151 return Ok(invalid_delegation_argument(format!(
1152 "{tool_name} timeout_ms must be at least 1"
1153 )));
1154 }
1155 if let Some(min_success_count) = params.min_success_count {
1156 if !params.allow_partial_failure {
1157 return Ok(invalid_delegation_argument(format!(
1158 "{tool_name} min_success_count requires allow_partial_failure=true"
1159 )));
1160 }
1161 if min_success_count == 0 || min_success_count > params.tasks.len() {
1162 return Ok(invalid_delegation_argument(format!(
1163 "{tool_name} min_success_count must be between 1 and the task count ({})",
1164 params.tasks.len()
1165 )));
1166 }
1167 }
1168
1169 let task_count = params.tasks.len();
1170 let run = executor
1171 .execute_parallel_for_tool(
1172 params.tasks.clone(),
1173 ctx.agent_event_tx.clone(),
1174 parallel_execution::ParallelToolOptions {
1175 parent_session_id: ctx.session_id.as_deref(),
1176 timeout_ms: params.timeout_ms,
1177 min_success_count: params.min_success_count,
1178 allow_partial_failure: params.allow_partial_failure,
1179 parent_cancellation: Some(&parent_cancellation),
1180 },
1181 )
1182 .await;
1183 let results = run.results;
1184
1185 let mut output = format!("Executed {} tasks concurrently:\n\n", task_count);
1186 let mut metadata_results = Vec::new();
1187 let source_anchor_counts = parallel_source_anchor_counts(&results);
1188 for (i, result) in results.iter().enumerate() {
1189 let status = if result.success { "[OK]" } else { "[ERR]" };
1190 let (formatted, truncated) = format_task_result_for_context(result);
1191 let (output_excerpt, _) = compact_task_output(&result.output);
1192 let source_anchors = &result.source_anchors[..source_anchor_counts[i]];
1193 metadata_results.push(serde_json::json!({
1194 "task_id": result.task_id,
1195 "session_id": result.session_id,
1196 "agent": result.agent,
1197 "success": result.success,
1198 "error_message": (!result.success).then(|| {
1199 crate::text::truncate_utf8(&result.output, 1024).to_string()
1200 }),
1201 "output_excerpt": output_excerpt,
1202 "structured": result.structured,
1203 "source_anchors": source_anchors,
1204 "output_bytes": result.output.len(),
1205 "truncated_for_context": truncated,
1206 "artifact_id": task_artifact_id(result),
1207 "artifact_uri": task_artifact_uri(result),
1208 }));
1209 output.push_str(&format!(
1210 "--- Task {} ({}) {} ---\n{}\n\n",
1211 i + 1,
1212 result.agent,
1213 status,
1214 formatted
1215 ));
1216 }
1217
1218 let success_count = results.iter().filter(|result| result.success).count();
1219 let failed_count = results.len().saturating_sub(success_count);
1220 let all_success = failed_count == 0;
1221 let partial_failure = failed_count > 0 && success_count > 0;
1222 if params.allow_partial_failure && partial_failure {
1223 output.push_str(&format!(
1224 "Partial failure tolerated: {success_count} succeeded, {failed_count} failed.\n"
1225 ));
1226 }
1227 if run.timed_out {
1228 output.push_str(&format!(
1229 "Task fan-out timed out after {} ms; returned completed child results and marked unfinished children failed.\n",
1230 run.timeout_ms.unwrap_or_default()
1231 ));
1232 } else if run.returned_early {
1233 output.push_str(&format!(
1234 "Task fan-out returned after reaching min_success_count={}; unfinished children were marked failed.\n",
1235 run.min_success_count.unwrap_or_default()
1236 ));
1237 }
1238
1239 let tool_success = all_success || (params.allow_partial_failure && success_count > 0);
1240 let mut output = if tool_success {
1241 ToolOutput::success(output)
1242 } else {
1243 ToolOutput::error(output)
1244 };
1245 if !tool_success && failed_count > 0 {
1246 output.error_kind = Some(crate::tools::ToolErrorKind::PartialFailure {
1247 failed: failed_count,
1248 total: results.len(),
1249 });
1250 }
1251
1252 Ok(output.with_metadata(serde_json::json!({
1253 "task_count": task_count,
1254 "result_count": results.len(),
1255 "success_count": success_count,
1256 "failed_count": failed_count,
1257 "all_success": all_success,
1258 "partial_failure": partial_failure,
1259 "allow_partial_failure": params.allow_partial_failure,
1260 "timeout_ms": params.timeout_ms,
1261 "timed_out": run.timed_out,
1262 "min_success_count": params.min_success_count,
1263 "returned_early": run.returned_early,
1264 "duration_ms": started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1265 "results": metadata_results,
1266 })))
1267 }
1268}
1269
1270#[async_trait]
1271impl Tool for ParallelTaskTool {
1272 fn name(&self) -> &str {
1273 "parallel_task"
1274 }
1275
1276 fn description(&self) -> &str {
1277 PARALLEL_TASK_TOOL_DESCRIPTION
1278 }
1279
1280 fn parameters(&self) -> serde_json::Value {
1281 parallel_params::parallel_task_params_schema_for_agents(&self.executor.visible_agents())
1282 }
1283
1284 fn definition(&self) -> ToolDefinition {
1285 let agents = self.executor.visible_agents();
1286 ToolDefinition {
1287 name: self.name().to_string(),
1288 description: delegation_tool_description(self.description(), &agents),
1289 parameters: parallel_params::parallel_task_params_schema_for_agents(&agents),
1290 }
1291 }
1292
1293 fn is_model_visible(&self) -> bool {
1294 false
1295 }
1296
1297 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
1298 let params: ParallelTaskParams = match serde_json::from_value(args.clone()) {
1299 Ok(params) => params,
1300 Err(error) => {
1301 return Ok(invalid_delegation_argument(format!(
1302 "Invalid parallel_task parameters: {error}"
1303 )));
1304 }
1305 };
1306 self.execute_params(params, ctx, "parallel_task", 2).await
1307 }
1308}
1309
1310fn invalid_delegation_argument(message: String) -> ToolOutput {
1311 ToolOutput::error(&message)
1312 .with_error_kind(crate::tools::ToolErrorKind::InvalidArgument { message })
1313}
1314
1315#[cfg(test)]
1316mod tests;