1use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::LlmClient;
19use crate::mcp::manager::McpManager;
20use crate::subagent::AgentRegistry;
21use crate::tools::types::{Tool, ToolContext, ToolOutput};
22use anyhow::{Context, Result};
23use async_trait::async_trait;
24use serde::{Deserialize, Serialize};
25use std::path::PathBuf;
26use std::sync::Arc;
27use tokio::sync::broadcast;
28use tokio::task::JoinSet;
29
30const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
31const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
32const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct TaskParams {
38 pub agent: String,
40 pub description: String,
42 pub prompt: String,
44 #[serde(default)]
46 pub background: bool,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub max_steps: Option<usize>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TaskResult {
55 pub output: String,
57 pub session_id: String,
59 pub agent: String,
61 pub success: bool,
63 pub task_id: String,
65}
66
67fn compact_task_output(output: &str) -> (String, bool) {
68 if output.len() <= TASK_OUTPUT_CONTEXT_LIMIT {
69 return (output.to_string(), false);
70 }
71
72 let head = crate::text::truncate_utf8(output, TASK_OUTPUT_CONTEXT_HEAD);
73 let tail_start = output
74 .char_indices()
75 .find_map(|(idx, _)| {
76 if output.len().saturating_sub(idx) <= TASK_OUTPUT_CONTEXT_TAIL {
77 Some(idx)
78 } else {
79 None
80 }
81 })
82 .unwrap_or(output.len());
83 let tail = &output[tail_start..];
84
85 (
86 format!(
87 "{}\n\n[{} bytes omitted from delegated task output]\n\n{}",
88 head,
89 output.len().saturating_sub(head.len() + tail.len()),
90 tail
91 ),
92 true,
93 )
94}
95
96fn task_artifact_id(result: &TaskResult) -> String {
97 format!("task-output:{}", result.task_id)
98}
99
100fn task_artifact_uri(result: &TaskResult) -> String {
101 format!(
102 "a3s://tasks/{}/runs/{}/output",
103 result.session_id, result.task_id
104 )
105}
106
107fn format_task_result_for_context(result: &TaskResult) -> (String, bool) {
108 let (output, truncated) = compact_task_output(&result.output);
109 let status = if result.success {
110 "completed"
111 } else {
112 "failed"
113 };
114 let artifact_id = task_artifact_id(result);
115 let artifact_uri = task_artifact_uri(result);
116 let mut formatted = format!(
117 "Task {status}: {}\nAgent: {}\nSession: {}\nTask ID: {}\nArtifact ID: {}\nArtifact URI: {}\n",
118 result.task_id, result.agent, result.session_id, result.task_id, artifact_id, artifact_uri
119 );
120 if truncated {
121 formatted.push_str(
122 "Output excerpt: truncated for parent context. Use the artifact URI or child run session/events if exact omitted content is needed.\n",
123 );
124 } else {
125 formatted.push_str("Output:\n");
126 }
127 formatted.push_str(&output);
128 (formatted, truncated)
129}
130
131pub struct TaskExecutor {
133 registry: Arc<AgentRegistry>,
135 llm_client: Arc<dyn LlmClient>,
137 workspace: String,
139 mcp_manager: Option<Arc<McpManager>>,
141 parent_context: Option<crate::child_run::ChildRunContext>,
143}
144
145impl TaskExecutor {
146 pub fn new(
148 registry: Arc<AgentRegistry>,
149 llm_client: Arc<dyn LlmClient>,
150 workspace: String,
151 ) -> Self {
152 Self {
153 registry,
154 llm_client,
155 workspace,
156 mcp_manager: None,
157 parent_context: None,
158 }
159 }
160
161 pub fn with_mcp(
163 registry: Arc<AgentRegistry>,
164 llm_client: Arc<dyn LlmClient>,
165 workspace: String,
166 mcp_manager: Arc<McpManager>,
167 ) -> Self {
168 Self {
169 registry,
170 llm_client,
171 workspace,
172 mcp_manager: Some(mcp_manager),
173 parent_context: None,
174 }
175 }
176
177 pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
179 self.parent_context = Some(ctx);
180 self
181 }
182
183 pub async fn execute(
185 &self,
186 params: TaskParams,
187 event_tx: Option<broadcast::Sender<AgentEvent>>,
188 ) -> Result<TaskResult> {
189 let task_id = format!("task-{}", uuid::Uuid::new_v4());
190 let session_id = format!("task-run-{}", task_id);
191
192 let agent = self
193 .registry
194 .get(¶ms.agent)
195 .context(format!("Unknown agent type: '{}'", params.agent))?;
196
197 if let Some(ref tx) = event_tx {
198 let _ = tx.send(AgentEvent::SubagentStart {
199 task_id: task_id.clone(),
200 session_id: session_id.clone(),
201 parent_session_id: String::new(),
202 agent: params.agent.clone(),
203 description: params.description.clone(),
204 });
205 }
206
207 let child_executor = if let Some(ref parent_ctx) = self.parent_context {
210 if let Some(ref services) = parent_ctx.workspace_services {
211 crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
212 self.workspace.clone(),
213 Arc::clone(services),
214 crate::tools::ArtifactStoreLimits::default(),
215 )
216 } else {
217 crate::tools::ToolExecutor::new(self.workspace.clone())
218 }
219 } else {
220 crate::tools::ToolExecutor::new(self.workspace.clone())
221 };
222
223 if let Some(ref mcp) = self.mcp_manager {
225 let all_tools = mcp.get_all_tools().await;
226 let mut by_server: std::collections::HashMap<
227 String,
228 Vec<crate::mcp::protocol::McpTool>,
229 > = std::collections::HashMap::new();
230 for (server, tool) in all_tools {
231 by_server.entry(server).or_default().push(tool);
232 }
233 for (server_name, tools) in by_server {
234 let wrappers =
235 crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
236 for wrapper in wrappers {
237 child_executor.register_dynamic_tool(wrapper);
238 }
239 }
240 }
241
242 let child_executor = Arc::new(child_executor);
243
244 let mut child_config = AgentConfig {
245 tools: child_executor.definitions(),
246 ..AgentConfig::default()
247 };
248 agent.apply_to(&mut child_config);
249 if let Some(ref parent_ctx) = self.parent_context {
250 parent_ctx.apply_to(&mut child_config);
251 }
252 if let Some(max_steps) = params.max_steps {
253 child_config.max_tool_rounds = max_steps;
254 }
255
256 let mut tool_context =
257 ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
258 if let Some(ref parent_ctx) = self.parent_context {
259 if let Some(ref services) = parent_ctx.workspace_services {
260 tool_context = tool_context.with_workspace_services(Arc::clone(services));
261 }
262 }
263
264 let agent_loop = AgentLoop::new(
265 Arc::clone(&self.llm_client),
266 child_executor,
267 tool_context,
268 child_config,
269 );
270
271 let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
273 let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
274 let broadcast_tx_clone = broadcast_tx.clone();
275
276 tokio::spawn(async move {
278 while let Some(event) = mpsc_rx.recv().await {
279 let _ = broadcast_tx_clone.send(event);
280 }
281 });
282
283 Some(mpsc_tx)
284 } else {
285 None
286 };
287
288 let (output, success) = match agent_loop
289 .execute(&[], ¶ms.prompt, child_event_tx)
290 .await
291 {
292 Ok(result) => (result.text, true),
293 Err(e) => (format!("Task failed: {}", e), false),
294 };
295
296 if let Some(ref tx) = event_tx {
297 let _ = tx.send(AgentEvent::SubagentEnd {
298 task_id: task_id.clone(),
299 session_id: session_id.clone(),
300 agent: params.agent.clone(),
301 output: output.clone(),
302 success,
303 });
304 }
305
306 Ok(TaskResult {
307 output,
308 session_id,
309 agent: params.agent,
310 success,
311 task_id,
312 })
313 }
314
315 pub fn execute_background(
319 self: Arc<Self>,
320 params: TaskParams,
321 event_tx: Option<broadcast::Sender<AgentEvent>>,
322 ) -> String {
323 let task_id = format!("task-{}", uuid::Uuid::new_v4());
324 let task_id_clone = task_id.clone();
325
326 tokio::spawn(async move {
327 if let Err(e) = self.execute(params, event_tx).await {
328 tracing::error!("Background task {} failed: {}", task_id_clone, e);
329 }
330 });
331
332 task_id
333 }
334
335 pub async fn execute_parallel(
340 self: &Arc<Self>,
341 tasks: Vec<TaskParams>,
342 event_tx: Option<broadcast::Sender<AgentEvent>>,
343 ) -> Vec<TaskResult> {
344 let mut join_set: JoinSet<(usize, TaskResult)> = JoinSet::new();
345
346 for (idx, params) in tasks.into_iter().enumerate() {
347 let executor = Arc::clone(self);
348 let tx = event_tx.clone();
349
350 join_set.spawn(async move {
351 let result = match executor.execute(params.clone(), tx).await {
352 Ok(result) => result,
353 Err(e) => TaskResult {
354 output: format!("Task failed: {}", e),
355 session_id: String::new(),
356 agent: params.agent,
357 success: false,
358 task_id: format!("task-{}", uuid::Uuid::new_v4()),
359 },
360 };
361 (idx, result)
362 });
363 }
364
365 let mut indexed_results = Vec::new();
366 while let Some(result) = join_set.join_next().await {
367 match result {
368 Ok((idx, task_result)) => indexed_results.push((idx, task_result)),
369 Err(e) => {
370 tracing::error!("Parallel task panicked: {}", e);
371 indexed_results.push((
372 usize::MAX,
373 TaskResult {
374 output: format!("Task panicked: {}", e),
375 session_id: String::new(),
376 agent: "unknown".to_string(),
377 success: false,
378 task_id: format!("task-{}", uuid::Uuid::new_v4()),
379 },
380 ));
381 }
382 }
383 }
384
385 indexed_results.sort_by_key(|(idx, _)| *idx);
386 indexed_results.into_iter().map(|(_, r)| r).collect()
387 }
388}
389
390pub fn task_params_schema() -> serde_json::Value {
392 serde_json::json!({
393 "type": "object",
394 "additionalProperties": false,
395 "properties": {
396 "agent": {
397 "type": "string",
398 "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
399 },
400 "description": {
401 "type": "string",
402 "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
403 },
404 "prompt": {
405 "type": "string",
406 "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
407 },
408 "background": {
409 "type": "boolean",
410 "description": "Optional. Run the task in the background. Default: false.",
411 "default": false
412 },
413 "max_steps": {
414 "type": "integer",
415 "description": "Optional. Maximum number of steps for this task."
416 }
417 },
418 "required": ["agent", "description", "prompt"],
419 "examples": [
420 {
421 "agent": "explore",
422 "description": "Find Rust files",
423 "prompt": "Search the workspace for Rust files and summarize the layout."
424 },
425 {
426 "agent": "general",
427 "description": "Investigate test failure",
428 "prompt": "Inspect the failing tests and explain the root cause.",
429 "max_steps": 6
430 }
431 ]
432 })
433}
434
435pub struct TaskTool {
438 executor: Arc<TaskExecutor>,
439}
440
441impl TaskTool {
442 pub fn new(executor: Arc<TaskExecutor>) -> Self {
444 Self { executor }
445 }
446}
447
448#[async_trait]
449impl Tool for TaskTool {
450 fn name(&self) -> &str {
451 "task"
452 }
453
454 fn description(&self) -> &str {
455 "Delegate a bounded task to a specialized child run. Built-in agents: explore (read-only codebase search), general (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs are also available."
456 }
457
458 fn parameters(&self) -> serde_json::Value {
459 task_params_schema()
460 }
461
462 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
463 let params: TaskParams =
464 serde_json::from_value(args.clone()).context("Invalid task parameters")?;
465
466 if params.background {
467 let task_id =
468 Arc::clone(&self.executor).execute_background(params, ctx.agent_event_tx.clone());
469 return Ok(ToolOutput::success(format!(
470 "Task started in background. Task ID: {}",
471 task_id
472 )));
473 }
474
475 let result = self
476 .executor
477 .execute(params, ctx.agent_event_tx.clone())
478 .await?;
479 let (content, truncated) = format_task_result_for_context(&result);
480 let metadata = serde_json::json!({
481 "task_id": result.task_id,
482 "session_id": result.session_id,
483 "agent": result.agent,
484 "success": result.success,
485 "output_bytes": result.output.len(),
486 "truncated_for_context": truncated,
487 "artifact_id": task_artifact_id(&result),
488 "artifact_uri": task_artifact_uri(&result),
489 });
490
491 if result.success {
492 Ok(ToolOutput::success(content).with_metadata(metadata))
493 } else {
494 Ok(ToolOutput::error(content).with_metadata(metadata))
495 }
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(deny_unknown_fields)]
502pub struct ParallelTaskParams {
503 pub tasks: Vec<TaskParams>,
505}
506
507pub fn parallel_task_params_schema() -> serde_json::Value {
509 serde_json::json!({
510 "type": "object",
511 "additionalProperties": false,
512 "properties": {
513 "tasks": {
514 "type": "array",
515 "description": "List of tasks to execute in parallel. Each task runs as an independent delegated child run concurrently.",
516 "items": {
517 "type": "object",
518 "additionalProperties": false,
519 "properties": {
520 "agent": {
521 "type": "string",
522 "description": "Required. Canonical agent type for this task."
523 },
524 "description": {
525 "type": "string",
526 "description": "Required. Short task label for display and tracking."
527 },
528 "prompt": {
529 "type": "string",
530 "description": "Required. Detailed instruction for the delegated child run."
531 }
532 },
533 "required": ["agent", "description", "prompt"]
534 },
535 "minItems": 1
536 }
537 },
538 "required": ["tasks"],
539 "examples": [
540 {
541 "tasks": [
542 {
543 "agent": "explore",
544 "description": "Find Rust files",
545 "prompt": "List Rust files under src/."
546 },
547 {
548 "agent": "explore",
549 "description": "Find tests",
550 "prompt": "List test files and summarize their purpose."
551 }
552 ]
553 }
554 ]
555 })
556}
557
558pub struct ParallelTaskTool {
562 executor: Arc<TaskExecutor>,
563}
564
565impl ParallelTaskTool {
566 pub fn new(executor: Arc<TaskExecutor>) -> Self {
568 Self { executor }
569 }
570}
571
572#[async_trait]
573impl Tool for ParallelTaskTool {
574 fn name(&self) -> &str {
575 "parallel_task"
576 }
577
578 fn description(&self) -> &str {
579 "Execute multiple delegated child runs in parallel. All tasks run concurrently and results are returned when all complete. Built-in agents: explore (read-only codebase search), general (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs are also available."
580 }
581
582 fn parameters(&self) -> serde_json::Value {
583 parallel_task_params_schema()
584 }
585
586 async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
587 let params: ParallelTaskParams =
588 serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
589
590 if params.tasks.is_empty() {
591 return Ok(ToolOutput::error("No tasks provided".to_string()));
592 }
593
594 let task_count = params.tasks.len();
595
596 let results = self
597 .executor
598 .execute_parallel(params.tasks, ctx.agent_event_tx.clone())
599 .await;
600
601 let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
603 let mut metadata_results = Vec::new();
604 for (i, result) in results.iter().enumerate() {
605 let status = if result.success { "[OK]" } else { "[ERR]" };
606 let (formatted, truncated) = format_task_result_for_context(result);
607 metadata_results.push(serde_json::json!({
608 "task_id": result.task_id,
609 "session_id": result.session_id,
610 "agent": result.agent,
611 "success": result.success,
612 "output_bytes": result.output.len(),
613 "truncated_for_context": truncated,
614 "artifact_id": task_artifact_id(result),
615 "artifact_uri": task_artifact_uri(result),
616 }));
617 output.push_str(&format!(
618 "--- Task {} ({}) {} ---\n{}\n\n",
619 i + 1,
620 result.agent,
621 status,
622 formatted
623 ));
624 }
625
626 Ok(
627 ToolOutput::success(output).with_metadata(serde_json::json!({
628 "task_count": task_count,
629 "results": metadata_results,
630 })),
631 )
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[test]
640 fn test_task_params_deserialize() {
641 let json = r#"{
642 "agent": "explore",
643 "description": "Find auth code",
644 "prompt": "Search for authentication files"
645 }"#;
646
647 let params: TaskParams = serde_json::from_str(json).unwrap();
648 assert_eq!(params.agent, "explore");
649 assert_eq!(params.description, "Find auth code");
650 assert!(!params.background);
651 }
652
653 #[test]
654 fn test_task_params_with_background() {
655 let json = r#"{
656 "agent": "general",
657 "description": "Long task",
658 "prompt": "Do something complex",
659 "background": true
660 }"#;
661
662 let params: TaskParams = serde_json::from_str(json).unwrap();
663 assert!(params.background);
664 }
665
666 #[test]
667 fn test_task_params_with_max_steps() {
668 let json = r#"{
669 "agent": "plan",
670 "description": "Planning task",
671 "prompt": "Create a plan",
672 "max_steps": 10
673 }"#;
674
675 let params: TaskParams = serde_json::from_str(json).unwrap();
676 assert_eq!(params.agent, "plan");
677 assert_eq!(params.max_steps, Some(10));
678 assert!(!params.background);
679 }
680
681 #[test]
682 fn test_task_params_all_fields() {
683 let json = r#"{
684 "agent": "general",
685 "description": "Complex task",
686 "prompt": "Do everything",
687 "background": true,
688 "max_steps": 20
689 }"#;
690
691 let params: TaskParams = serde_json::from_str(json).unwrap();
692 assert_eq!(params.agent, "general");
693 assert_eq!(params.description, "Complex task");
694 assert_eq!(params.prompt, "Do everything");
695 assert!(params.background);
696 assert_eq!(params.max_steps, Some(20));
697 }
698
699 #[test]
700 fn test_task_params_missing_required_field() {
701 let json = r#"{
702 "agent": "explore",
703 "description": "Missing prompt"
704 }"#;
705
706 let result: Result<TaskParams, _> = serde_json::from_str(json);
707 assert!(result.is_err());
708 }
709
710 #[test]
711 fn test_task_params_serialize() {
712 let params = TaskParams {
713 agent: "explore".to_string(),
714 description: "Test task".to_string(),
715 prompt: "Test prompt".to_string(),
716 background: false,
717 max_steps: Some(5),
718 };
719
720 let json = serde_json::to_string(¶ms).unwrap();
721 assert!(json.contains("explore"));
722 assert!(json.contains("Test task"));
723 assert!(json.contains("Test prompt"));
724 }
725
726 #[test]
727 fn test_task_params_clone() {
728 let params = TaskParams {
729 agent: "explore".to_string(),
730 description: "Test".to_string(),
731 prompt: "Prompt".to_string(),
732 background: true,
733 max_steps: None,
734 };
735
736 let cloned = params.clone();
737 assert_eq!(params.agent, cloned.agent);
738 assert_eq!(params.description, cloned.description);
739 assert_eq!(params.background, cloned.background);
740 }
741
742 #[test]
743 fn test_task_result_serialize() {
744 let result = TaskResult {
745 output: "Found 5 files".to_string(),
746 session_id: "session-123".to_string(),
747 agent: "explore".to_string(),
748 success: true,
749 task_id: "task-456".to_string(),
750 };
751
752 let json = serde_json::to_string(&result).unwrap();
753 assert!(json.contains("Found 5 files"));
754 assert!(json.contains("explore"));
755 }
756
757 #[test]
758 fn test_task_result_deserialize() {
759 let json = r#"{
760 "output": "Task completed",
761 "session_id": "sess-789",
762 "agent": "general",
763 "success": false,
764 "task_id": "task-123"
765 }"#;
766
767 let result: TaskResult = serde_json::from_str(json).unwrap();
768 assert_eq!(result.output, "Task completed");
769 assert_eq!(result.session_id, "sess-789");
770 assert_eq!(result.agent, "general");
771 assert!(!result.success);
772 assert_eq!(result.task_id, "task-123");
773 }
774
775 #[test]
776 fn test_task_result_clone() {
777 let result = TaskResult {
778 output: "Output".to_string(),
779 session_id: "session-1".to_string(),
780 agent: "explore".to_string(),
781 success: true,
782 task_id: "task-1".to_string(),
783 };
784
785 let cloned = result.clone();
786 assert_eq!(result.output, cloned.output);
787 assert_eq!(result.success, cloned.success);
788 }
789
790 #[test]
791 fn test_compact_task_output_preserves_small_output() {
792 let (output, truncated) = compact_task_output("short result");
793 assert_eq!(output, "short result");
794 assert!(!truncated);
795 }
796
797 #[test]
798 fn test_format_task_result_for_context_truncates_large_output() {
799 let result = TaskResult {
800 output: format!("{}TAIL", "x".repeat(TASK_OUTPUT_CONTEXT_LIMIT + 500)),
801 session_id: "session-1".to_string(),
802 agent: "explore".to_string(),
803 success: true,
804 task_id: "task-1".to_string(),
805 };
806
807 let (formatted, truncated) = format_task_result_for_context(&result);
808 assert!(truncated);
809 assert!(formatted.contains("Output excerpt"));
810 assert!(formatted.contains("bytes omitted"));
811 assert!(formatted.contains("Artifact ID: task-output:task-1"));
812 assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
813 assert!(formatted.contains("TAIL"));
814 assert!(formatted.len() < result.output.len());
815 }
816
817 #[test]
818 fn test_task_artifact_reference_is_stable() {
819 let result = TaskResult {
820 output: "done".to_string(),
821 session_id: "session-1".to_string(),
822 agent: "explore".to_string(),
823 success: true,
824 task_id: "task-1".to_string(),
825 };
826
827 assert_eq!(task_artifact_id(&result), "task-output:task-1");
828 assert_eq!(
829 task_artifact_uri(&result),
830 "a3s://tasks/session-1/runs/task-1/output"
831 );
832
833 let (formatted, truncated) = format_task_result_for_context(&result);
834 assert!(!truncated);
835 assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
836 }
837
838 #[test]
839 fn test_task_params_schema() {
840 let schema = task_params_schema();
841 assert_eq!(schema["type"], "object");
842 assert_eq!(schema["additionalProperties"], false);
843 assert!(schema["properties"]["agent"].is_object());
844 assert!(schema["properties"]["prompt"].is_object());
845 }
846
847 #[test]
848 fn test_task_params_schema_required_fields() {
849 let schema = task_params_schema();
850 let required = schema["required"].as_array().unwrap();
851 assert!(required.contains(&serde_json::json!("agent")));
852 assert!(required.contains(&serde_json::json!("description")));
853 assert!(required.contains(&serde_json::json!("prompt")));
854 }
855
856 #[test]
857 fn test_task_params_schema_properties() {
858 let schema = task_params_schema();
859 let props = &schema["properties"];
860
861 assert_eq!(props["agent"]["type"], "string");
862 assert_eq!(props["description"]["type"], "string");
863 assert_eq!(props["prompt"]["type"], "string");
864 assert_eq!(props["background"]["type"], "boolean");
865 assert_eq!(props["background"]["default"], false);
866 assert_eq!(props["max_steps"]["type"], "integer");
867 }
868
869 #[test]
870 fn test_task_params_schema_descriptions() {
871 let schema = task_params_schema();
872 let props = &schema["properties"];
873
874 assert!(props["agent"]["description"].is_string());
875 assert!(props["description"]["description"].is_string());
876 assert!(props["prompt"]["description"].is_string());
877 assert!(props["background"]["description"].is_string());
878 assert!(props["max_steps"]["description"].is_string());
879 }
880
881 #[test]
882 fn test_task_params_default_background() {
883 let params = TaskParams {
884 agent: "explore".to_string(),
885 description: "Test".to_string(),
886 prompt: "Test prompt".to_string(),
887 background: false,
888 max_steps: None,
889 };
890 assert!(!params.background);
891 }
892
893 #[test]
894 fn test_task_params_serialize_skip_none() {
895 let params = TaskParams {
896 agent: "explore".to_string(),
897 description: "Test".to_string(),
898 prompt: "Test prompt".to_string(),
899 background: false,
900 max_steps: None,
901 };
902 let json = serde_json::to_string(¶ms).unwrap();
903 assert!(!json.contains("max_steps"));
905 }
906
907 #[test]
908 fn test_task_params_serialize_with_max_steps() {
909 let params = TaskParams {
910 agent: "explore".to_string(),
911 description: "Test".to_string(),
912 prompt: "Test prompt".to_string(),
913 background: false,
914 max_steps: Some(15),
915 };
916 let json = serde_json::to_string(¶ms).unwrap();
917 assert!(json.contains("max_steps"));
918 assert!(json.contains("15"));
919 }
920
921 #[test]
922 fn test_task_result_success_true() {
923 let result = TaskResult {
924 output: "Success".to_string(),
925 session_id: "sess-1".to_string(),
926 agent: "explore".to_string(),
927 success: true,
928 task_id: "task-1".to_string(),
929 };
930 assert!(result.success);
931 }
932
933 #[test]
934 fn test_task_result_success_false() {
935 let result = TaskResult {
936 output: "Failed".to_string(),
937 session_id: "sess-1".to_string(),
938 agent: "explore".to_string(),
939 success: false,
940 task_id: "task-1".to_string(),
941 };
942 assert!(!result.success);
943 }
944
945 #[test]
946 fn test_task_params_empty_strings() {
947 let params = TaskParams {
948 agent: "".to_string(),
949 description: "".to_string(),
950 prompt: "".to_string(),
951 background: false,
952 max_steps: None,
953 };
954 let json = serde_json::to_string(¶ms).unwrap();
955 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
956 assert_eq!(deserialized.agent, "");
957 assert_eq!(deserialized.description, "");
958 assert_eq!(deserialized.prompt, "");
959 }
960
961 #[test]
962 fn test_task_result_empty_output() {
963 let result = TaskResult {
964 output: "".to_string(),
965 session_id: "sess-1".to_string(),
966 agent: "explore".to_string(),
967 success: true,
968 task_id: "task-1".to_string(),
969 };
970 assert_eq!(result.output, "");
971 }
972
973 #[test]
974 fn test_task_params_debug_format() {
975 let params = TaskParams {
976 agent: "explore".to_string(),
977 description: "Test".to_string(),
978 prompt: "Test prompt".to_string(),
979 background: false,
980 max_steps: None,
981 };
982 let debug_str = format!("{:?}", params);
983 assert!(debug_str.contains("explore"));
984 assert!(debug_str.contains("Test"));
985 }
986
987 #[test]
988 fn test_task_result_debug_format() {
989 let result = TaskResult {
990 output: "Output".to_string(),
991 session_id: "sess-1".to_string(),
992 agent: "explore".to_string(),
993 success: true,
994 task_id: "task-1".to_string(),
995 };
996 let debug_str = format!("{:?}", result);
997 assert!(debug_str.contains("Output"));
998 assert!(debug_str.contains("explore"));
999 }
1000
1001 #[test]
1002 fn test_task_params_roundtrip() {
1003 let original = TaskParams {
1004 agent: "general".to_string(),
1005 description: "Roundtrip test".to_string(),
1006 prompt: "Test roundtrip serialization".to_string(),
1007 background: true,
1008 max_steps: Some(42),
1009 };
1010 let json = serde_json::to_string(&original).unwrap();
1011 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1012 assert_eq!(original.agent, deserialized.agent);
1013 assert_eq!(original.description, deserialized.description);
1014 assert_eq!(original.prompt, deserialized.prompt);
1015 assert_eq!(original.background, deserialized.background);
1016 assert_eq!(original.max_steps, deserialized.max_steps);
1017 }
1018
1019 #[test]
1020 fn test_task_result_roundtrip() {
1021 let original = TaskResult {
1022 output: "Roundtrip output".to_string(),
1023 session_id: "sess-roundtrip".to_string(),
1024 agent: "plan".to_string(),
1025 success: false,
1026 task_id: "task-roundtrip".to_string(),
1027 };
1028 let json = serde_json::to_string(&original).unwrap();
1029 let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
1030 assert_eq!(original.output, deserialized.output);
1031 assert_eq!(original.session_id, deserialized.session_id);
1032 assert_eq!(original.agent, deserialized.agent);
1033 assert_eq!(original.success, deserialized.success);
1034 assert_eq!(original.task_id, deserialized.task_id);
1035 }
1036
1037 #[test]
1038 fn test_parallel_task_params_deserialize() {
1039 let json = r#"{
1040 "tasks": [
1041 { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
1042 { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
1043 ]
1044 }"#;
1045
1046 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1047 assert_eq!(params.tasks.len(), 2);
1048 assert_eq!(params.tasks[0].agent, "explore");
1049 assert_eq!(params.tasks[1].agent, "general");
1050 }
1051
1052 #[test]
1053 fn test_parallel_task_params_single_task() {
1054 let json = r#"{
1055 "tasks": [
1056 { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
1057 ]
1058 }"#;
1059
1060 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1061 assert_eq!(params.tasks.len(), 1);
1062 }
1063
1064 #[test]
1065 fn test_parallel_task_params_empty_tasks() {
1066 let json = r#"{ "tasks": [] }"#;
1067 let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1068 assert!(params.tasks.is_empty());
1069 }
1070
1071 #[test]
1072 fn test_parallel_task_params_missing_tasks() {
1073 let json = r#"{}"#;
1074 let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
1075 assert!(result.is_err());
1076 }
1077
1078 #[test]
1079 fn test_parallel_task_params_serialize() {
1080 let params = ParallelTaskParams {
1081 tasks: vec![
1082 TaskParams {
1083 agent: "explore".to_string(),
1084 description: "Task 1".to_string(),
1085 prompt: "Prompt 1".to_string(),
1086 background: false,
1087 max_steps: None,
1088 },
1089 TaskParams {
1090 agent: "general".to_string(),
1091 description: "Task 2".to_string(),
1092 prompt: "Prompt 2".to_string(),
1093 background: false,
1094 max_steps: Some(10),
1095 },
1096 ],
1097 };
1098 let json = serde_json::to_string(¶ms).unwrap();
1099 assert!(json.contains("explore"));
1100 assert!(json.contains("general"));
1101 assert!(json.contains("Prompt 1"));
1102 assert!(json.contains("Prompt 2"));
1103 }
1104
1105 #[test]
1106 fn test_parallel_task_params_roundtrip() {
1107 let original = ParallelTaskParams {
1108 tasks: vec![
1109 TaskParams {
1110 agent: "explore".to_string(),
1111 description: "Explore".to_string(),
1112 prompt: "Find files".to_string(),
1113 background: false,
1114 max_steps: None,
1115 },
1116 TaskParams {
1117 agent: "plan".to_string(),
1118 description: "Plan".to_string(),
1119 prompt: "Make plan".to_string(),
1120 background: false,
1121 max_steps: Some(5),
1122 },
1123 ],
1124 };
1125 let json = serde_json::to_string(&original).unwrap();
1126 let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1127 assert_eq!(original.tasks.len(), deserialized.tasks.len());
1128 assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
1129 assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
1130 assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
1131 }
1132
1133 #[test]
1134 fn test_parallel_task_params_clone() {
1135 let params = ParallelTaskParams {
1136 tasks: vec![TaskParams {
1137 agent: "explore".to_string(),
1138 description: "Test".to_string(),
1139 prompt: "Prompt".to_string(),
1140 background: false,
1141 max_steps: None,
1142 }],
1143 };
1144 let cloned = params.clone();
1145 assert_eq!(params.tasks.len(), cloned.tasks.len());
1146 assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
1147 }
1148
1149 #[test]
1150 fn test_parallel_task_params_schema() {
1151 let schema = parallel_task_params_schema();
1152 assert_eq!(schema["type"], "object");
1153 assert_eq!(schema["additionalProperties"], false);
1154 assert!(schema["properties"]["tasks"].is_object());
1155 assert_eq!(schema["properties"]["tasks"]["type"], "array");
1156 assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
1157 }
1158
1159 #[test]
1160 fn test_parallel_task_params_schema_required() {
1161 let schema = parallel_task_params_schema();
1162 let required = schema["required"].as_array().unwrap();
1163 assert!(required.contains(&serde_json::json!("tasks")));
1164 }
1165
1166 #[test]
1167 fn test_parallel_task_params_schema_items() {
1168 let schema = parallel_task_params_schema();
1169 let items = &schema["properties"]["tasks"]["items"];
1170 assert_eq!(items["type"], "object");
1171 assert_eq!(items["additionalProperties"], false);
1172 let item_required = items["required"].as_array().unwrap();
1173 assert!(item_required.contains(&serde_json::json!("agent")));
1174 assert!(item_required.contains(&serde_json::json!("description")));
1175 assert!(item_required.contains(&serde_json::json!("prompt")));
1176 }
1177
1178 #[test]
1179 fn test_task_schema_examples_use_delegation_core() {
1180 let task = task_params_schema();
1181 let task_examples = task["examples"].as_array().unwrap();
1182 assert_eq!(task_examples[0]["agent"], "explore");
1183 assert!(task_examples[0].get("task").is_none());
1184
1185 let parallel = parallel_task_params_schema();
1186 let parallel_examples = parallel["examples"].as_array().unwrap();
1187 assert!(!parallel_examples[0]["tasks"].as_array().unwrap().is_empty());
1188 }
1189
1190 #[test]
1191 fn test_parallel_task_params_debug() {
1192 let params = ParallelTaskParams {
1193 tasks: vec![TaskParams {
1194 agent: "explore".to_string(),
1195 description: "Debug test".to_string(),
1196 prompt: "Test".to_string(),
1197 background: false,
1198 max_steps: None,
1199 }],
1200 };
1201 let debug_str = format!("{:?}", params);
1202 assert!(debug_str.contains("explore"));
1203 assert!(debug_str.contains("Debug test"));
1204 }
1205
1206 #[test]
1207 fn test_parallel_task_params_large_count() {
1208 let tasks: Vec<TaskParams> = (0..150)
1210 .map(|i| TaskParams {
1211 agent: "explore".to_string(),
1212 description: format!("Task {}", i),
1213 prompt: format!("Prompt for task {}", i),
1214 background: false,
1215 max_steps: Some(10),
1216 })
1217 .collect();
1218
1219 let params = ParallelTaskParams { tasks };
1220 let json = serde_json::to_string(¶ms).unwrap();
1221 let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1222 assert_eq!(deserialized.tasks.len(), 150);
1223 assert_eq!(deserialized.tasks[0].description, "Task 0");
1224 assert_eq!(deserialized.tasks[149].description, "Task 149");
1225 }
1226
1227 #[test]
1228 fn test_task_params_max_steps_zero() {
1229 let params = TaskParams {
1231 agent: "explore".to_string(),
1232 description: "Edge case".to_string(),
1233 prompt: "Zero steps".to_string(),
1234 background: false,
1235 max_steps: Some(0),
1236 };
1237 let json = serde_json::to_string(¶ms).unwrap();
1238 let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1239 assert_eq!(deserialized.max_steps, Some(0));
1240 }
1241
1242 #[test]
1243 fn test_parallel_task_params_all_background() {
1244 let tasks: Vec<TaskParams> = (0..5)
1245 .map(|i| TaskParams {
1246 agent: "general".to_string(),
1247 description: format!("BG task {}", i),
1248 prompt: "Run in background".to_string(),
1249 background: true,
1250 max_steps: None,
1251 })
1252 .collect();
1253 let params = ParallelTaskParams { tasks };
1254 for task in ¶ms.tasks {
1255 assert!(task.background);
1256 }
1257 }
1258
1259 #[test]
1260 fn test_task_params_rejects_permissive_field() {
1261 let json = r#"{
1262 "agent": "general",
1263 "description": "Legacy field rejection",
1264 "prompt": "Verify legacy fields are rejected",
1265 "permissive": true
1266 }"#;
1267
1268 let result: Result<TaskParams, _> = serde_json::from_str(json);
1269 assert!(result.is_err());
1270 }
1271
1272 #[test]
1273 fn test_task_params_schema_hides_permissive_field() {
1274 let schema = task_params_schema();
1275 let props = &schema["properties"];
1276
1277 assert!(props.get("permissive").is_none());
1278 }
1279
1280 use crate::agent::tests::MockLlmClient;
1285 use crate::permissions::PermissionPolicy;
1286 use crate::subagent::AgentRegistry;
1287
1288 fn test_registry_with_writer() -> Arc<AgentRegistry> {
1289 let registry = AgentRegistry::new();
1290 let spec = crate::subagent::WorkerAgentSpec::custom("writer", "Write files")
1291 .with_permissions(PermissionPolicy::new().allow("write(*)").allow("read(*)"))
1292 .with_prompt("Write files when asked.")
1293 .with_max_steps(3);
1294 registry.register(spec.into_agent_definition());
1295 Arc::new(registry)
1296 }
1297
1298 #[tokio::test]
1299 async fn task_child_run_permission_allow() {
1300 let workspace = tempfile::tempdir().unwrap();
1301 let mock = Arc::new(MockLlmClient::new(vec![
1302 MockLlmClient::tool_call_response(
1303 "t1",
1304 "write",
1305 serde_json::json!({
1306 "file_path": workspace.path().join("out.txt").to_string_lossy(),
1307 "content": "WRITTEN"
1308 }),
1309 ),
1310 MockLlmClient::text_response("Done."),
1311 ]));
1312
1313 let executor = TaskExecutor::new(
1314 test_registry_with_writer(),
1315 mock,
1316 workspace.path().to_string_lossy().to_string(),
1317 );
1318
1319 let result = executor
1320 .execute(
1321 TaskParams {
1322 agent: "writer".to_string(),
1323 description: "Write file".to_string(),
1324 prompt: "Write out.txt".to_string(),
1325 background: false,
1326 max_steps: Some(3),
1327 },
1328 None,
1329 )
1330 .await
1331 .unwrap();
1332
1333 assert!(
1334 result.success,
1335 "child run should succeed: {}",
1336 result.output
1337 );
1338 assert!(
1339 !result.output.contains("Permission denied"),
1340 "no permission denial: {}",
1341 result.output
1342 );
1343 let content = std::fs::read_to_string(workspace.path().join("out.txt")).unwrap();
1344 assert_eq!(content, "WRITTEN");
1345 }
1346
1347 #[tokio::test]
1348 async fn task_child_run_permission_deny() {
1349 let workspace = tempfile::tempdir().unwrap();
1350 let registry = AgentRegistry::new();
1351 let spec = crate::subagent::WorkerAgentSpec::custom("restricted", "Restricted agent")
1352 .with_permissions(PermissionPolicy::new().allow("read(*)").deny("bash(*)"))
1353 .with_max_steps(3);
1354 registry.register(spec.into_agent_definition());
1355
1356 let mock = Arc::new(MockLlmClient::new(vec![
1357 MockLlmClient::tool_call_response(
1358 "t1",
1359 "bash",
1360 serde_json::json!({"command": "echo hello"}),
1361 ),
1362 MockLlmClient::text_response("Could not run bash."),
1363 ]));
1364
1365 let executor = TaskExecutor::new(
1366 Arc::new(registry),
1367 mock,
1368 workspace.path().to_string_lossy().to_string(),
1369 );
1370
1371 let result = executor
1372 .execute(
1373 TaskParams {
1374 agent: "restricted".to_string(),
1375 description: "Try bash".to_string(),
1376 prompt: "Run echo hello".to_string(),
1377 background: false,
1378 max_steps: Some(3),
1379 },
1380 None,
1381 )
1382 .await
1383 .unwrap();
1384
1385 assert!(result.success, "agent should complete: {}", result.output);
1388 }
1389
1390 #[tokio::test]
1391 async fn task_child_run_confirmation_auto_approve() {
1392 let workspace = tempfile::tempdir().unwrap();
1393 let registry = AgentRegistry::new();
1394 let spec = crate::subagent::WorkerAgentSpec::custom("reader-writer", "Read and write")
1397 .with_permissions(PermissionPolicy::new().allow("read(*)"))
1398 .with_max_steps(3);
1399 registry.register(spec.into_agent_definition());
1400
1401 let mock = Arc::new(MockLlmClient::new(vec![
1402 MockLlmClient::tool_call_response(
1403 "t1",
1404 "write",
1405 serde_json::json!({
1406 "file_path": workspace.path().join("auto.txt").to_string_lossy(),
1407 "content": "AUTO_APPROVED"
1408 }),
1409 ),
1410 MockLlmClient::text_response("Written."),
1411 ]));
1412
1413 let executor = TaskExecutor::new(
1414 Arc::new(registry),
1415 mock,
1416 workspace.path().to_string_lossy().to_string(),
1417 );
1418
1419 let result = executor
1420 .execute(
1421 TaskParams {
1422 agent: "reader-writer".to_string(),
1423 description: "Write via auto-approve".to_string(),
1424 prompt: "Write auto.txt".to_string(),
1425 background: false,
1426 max_steps: Some(3),
1427 },
1428 None,
1429 )
1430 .await
1431 .unwrap();
1432
1433 assert!(
1434 result.success,
1435 "Ask should be auto-approved: {}",
1436 result.output
1437 );
1438 assert!(
1439 !result.output.contains("MissingConfirmationManager"),
1440 "no MissingConfirmationManager: {}",
1441 result.output
1442 );
1443 }
1444
1445 #[tokio::test]
1446 async fn task_child_run_step_budget_enforced() {
1447 let workspace = tempfile::tempdir().unwrap();
1448 let mock = Arc::new(MockLlmClient::new(vec![
1449 MockLlmClient::tool_call_response(
1450 "t1",
1451 "read",
1452 serde_json::json!({"file_path": "/tmp/a.txt"}),
1453 ),
1454 MockLlmClient::tool_call_response(
1455 "t2",
1456 "read",
1457 serde_json::json!({"file_path": "/tmp/b.txt"}),
1458 ),
1459 MockLlmClient::tool_call_response(
1460 "t3",
1461 "read",
1462 serde_json::json!({"file_path": "/tmp/c.txt"}),
1463 ),
1464 MockLlmClient::text_response("Should not reach here."),
1465 ]));
1466
1467 let executor = TaskExecutor::new(
1468 test_registry_with_writer(),
1469 mock,
1470 workspace.path().to_string_lossy().to_string(),
1471 );
1472
1473 let result = executor
1474 .execute(
1475 TaskParams {
1476 agent: "writer".to_string(),
1477 description: "Exceed budget".to_string(),
1478 prompt: "Read many files".to_string(),
1479 background: false,
1480 max_steps: Some(2),
1481 },
1482 None,
1483 )
1484 .await
1485 .unwrap();
1486
1487 assert!(
1489 !result.success,
1490 "should fail when exceeding step budget: {}",
1491 result.output
1492 );
1493 assert!(
1494 result.output.contains("Max tool rounds") || result.output.contains("max tool rounds"),
1495 "error should mention tool rounds: {}",
1496 result.output
1497 );
1498 }
1499
1500 #[tokio::test]
1501 async fn parallel_task_both_inherit_permissions() {
1502 let workspace = tempfile::tempdir().unwrap();
1503 let mock = Arc::new(MockLlmClient::new(vec![
1504 MockLlmClient::tool_call_response(
1506 "t1",
1507 "write",
1508 serde_json::json!({
1509 "file_path": workspace.path().join("p1.txt").to_string_lossy(),
1510 "content": "P1"
1511 }),
1512 ),
1513 MockLlmClient::text_response("Done 1."),
1514 MockLlmClient::tool_call_response(
1516 "t2",
1517 "write",
1518 serde_json::json!({
1519 "file_path": workspace.path().join("p2.txt").to_string_lossy(),
1520 "content": "P2"
1521 }),
1522 ),
1523 MockLlmClient::text_response("Done 2."),
1524 ]));
1525
1526 let executor = Arc::new(TaskExecutor::new(
1527 test_registry_with_writer(),
1528 mock,
1529 workspace.path().to_string_lossy().to_string(),
1530 ));
1531
1532 let tasks = vec![
1533 TaskParams {
1534 agent: "writer".to_string(),
1535 description: "Write p1".to_string(),
1536 prompt: "Write p1.txt".to_string(),
1537 background: false,
1538 max_steps: Some(3),
1539 },
1540 TaskParams {
1541 agent: "writer".to_string(),
1542 description: "Write p2".to_string(),
1543 prompt: "Write p2.txt".to_string(),
1544 background: false,
1545 max_steps: Some(3),
1546 },
1547 ];
1548
1549 let results = executor.execute_parallel(tasks, None).await;
1550 assert_eq!(results.len(), 2);
1551
1552 for result in &results {
1553 assert!(
1554 result.success,
1555 "parallel child should succeed: {}",
1556 result.output
1557 );
1558 }
1559 }
1560}