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