Skip to main content

a3s_code_core/tools/
task.rs

1//! Task Tool for Spawning Subagents
2//!
3//! The Task tool allows the main agent to delegate specialized tasks to
4//! focused child agents (subagents). Each subagent runs in an isolated
5//! child session with restricted permissions.
6//!
7//! ## Usage
8//!
9//! ```json
10//! {
11//!   "agent": "explore",
12//!   "description": "Find authentication code",
13//!   "prompt": "Search for files related to user authentication..."
14//! }
15//! ```
16
17use crate::agent::{AgentConfig, AgentEvent, AgentLoop};
18use crate::llm::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/// Task tool parameters
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct TaskParams {
38    /// Agent type to use (explore, general, plan, verification, review, etc.)
39    pub agent: String,
40    /// Short description of the task (for display)
41    pub description: String,
42    /// Detailed prompt for the agent
43    pub prompt: String,
44    /// Optional: run in background (default: false)
45    #[serde(default)]
46    pub background: bool,
47    /// Optional: maximum steps for this task
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub max_steps: Option<usize>,
50}
51
52/// Task tool result
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TaskResult {
55    /// Task output from the subagent
56    pub output: String,
57    /// Child session ID
58    pub session_id: String,
59    /// Agent type used
60    pub agent: String,
61    /// Whether the task succeeded
62    pub success: bool,
63    /// Task ID for tracking
64    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 subagent 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!("subagent-output:{}", result.task_id)
98}
99
100fn task_artifact_uri(result: &TaskResult) -> String {
101    format!(
102        "a3s://subagent/{}/tasks/{}/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 subagent 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
131/// Task executor for running subagent tasks
132pub struct TaskExecutor {
133    /// Agent registry for looking up agent definitions
134    registry: Arc<AgentRegistry>,
135    /// LLM client used to power child agent loops
136    llm_client: Arc<dyn LlmClient>,
137    /// Workspace path shared with child agents
138    workspace: String,
139    /// Optional MCP manager for registering MCP tools in child sessions
140    mcp_manager: Option<Arc<McpManager>>,
141}
142
143impl TaskExecutor {
144    /// Create a new task executor
145    pub fn new(
146        registry: Arc<AgentRegistry>,
147        llm_client: Arc<dyn LlmClient>,
148        workspace: String,
149    ) -> Self {
150        Self {
151            registry,
152            llm_client,
153            workspace,
154            mcp_manager: None,
155        }
156    }
157
158    /// Create a new task executor with MCP manager for tool inheritance
159    pub fn with_mcp(
160        registry: Arc<AgentRegistry>,
161        llm_client: Arc<dyn LlmClient>,
162        workspace: String,
163        mcp_manager: Arc<McpManager>,
164    ) -> Self {
165        Self {
166            registry,
167            llm_client,
168            workspace,
169            mcp_manager: Some(mcp_manager),
170        }
171    }
172
173    /// Execute a task by spawning an isolated child AgentLoop.
174    pub async fn execute(
175        &self,
176        params: TaskParams,
177        event_tx: Option<broadcast::Sender<AgentEvent>>,
178    ) -> Result<TaskResult> {
179        let task_id = format!("task-{}", uuid::Uuid::new_v4());
180        let session_id = format!("subagent-{}", task_id);
181
182        let agent = self
183            .registry
184            .get(&params.agent)
185            .context(format!("Unknown agent type: '{}'", params.agent))?;
186
187        if let Some(ref tx) = event_tx {
188            let _ = tx.send(AgentEvent::SubagentStart {
189                task_id: task_id.clone(),
190                session_id: session_id.clone(),
191                parent_session_id: String::new(),
192                agent: params.agent.clone(),
193                description: params.description.clone(),
194            });
195        }
196
197        // Build a child ToolExecutor. Task tools are intentionally omitted
198        // here to prevent unlimited subagent nesting.
199        let mut child_executor = crate::tools::ToolExecutor::new(self.workspace.clone());
200
201        // Register MCP tools so child agents can access MCP servers.
202        if let Some(ref mcp) = self.mcp_manager {
203            let all_tools = mcp.get_all_tools().await;
204            let mut by_server: std::collections::HashMap<
205                String,
206                Vec<crate::mcp::protocol::McpTool>,
207            > = std::collections::HashMap::new();
208            for (server, tool) in all_tools {
209                by_server.entry(server).or_default().push(tool);
210            }
211            for (server_name, tools) in by_server {
212                let wrappers =
213                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
214                for wrapper in wrappers {
215                    child_executor.register_dynamic_tool(wrapper);
216                }
217            }
218        }
219
220        if !agent.permissions.allow.is_empty() || !agent.permissions.deny.is_empty() {
221            child_executor.set_guard_policy(Arc::new(agent.permissions.clone())
222                as Arc<dyn crate::permissions::PermissionChecker>);
223        }
224        let child_executor = Arc::new(child_executor);
225
226        // Inject the agent system prompt via the extra slot.
227        let mut prompt_slots = crate::prompts::SystemPromptSlots::default();
228        if let Some(ref p) = agent.prompt {
229            prompt_slots.extra = Some(p.clone());
230        }
231
232        let child_config = AgentConfig {
233            prompt_slots,
234            tools: child_executor.definitions(),
235            max_tool_rounds: params
236                .max_steps
237                .unwrap_or_else(|| agent.max_steps.unwrap_or(20)),
238            ..AgentConfig::default()
239        };
240
241        let tool_context =
242            ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
243
244        let agent_loop = AgentLoop::new(
245            Arc::clone(&self.llm_client),
246            child_executor,
247            tool_context,
248            child_config,
249        );
250
251        // Create an mpsc channel for the child agent and forward events to broadcast
252        let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
253            let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
254            let broadcast_tx_clone = broadcast_tx.clone();
255
256            // Spawn a task to forward events from mpsc to broadcast
257            tokio::spawn(async move {
258                while let Some(event) = mpsc_rx.recv().await {
259                    let _ = broadcast_tx_clone.send(event);
260                }
261            });
262
263            Some(mpsc_tx)
264        } else {
265            None
266        };
267
268        let (output, success) = match agent_loop
269            .execute(&[], &params.prompt, child_event_tx)
270            .await
271        {
272            Ok(result) => (result.text, true),
273            Err(e) => (format!("Task failed: {}", e), false),
274        };
275
276        if let Some(ref tx) = event_tx {
277            let _ = tx.send(AgentEvent::SubagentEnd {
278                task_id: task_id.clone(),
279                session_id: session_id.clone(),
280                agent: params.agent.clone(),
281                output: output.clone(),
282                success,
283            });
284        }
285
286        Ok(TaskResult {
287            output,
288            session_id,
289            agent: params.agent,
290            success,
291            task_id,
292        })
293    }
294
295    /// Execute a task in the background.
296    ///
297    /// Returns immediately with the task ID. Use events to track progress.
298    pub fn execute_background(
299        self: Arc<Self>,
300        params: TaskParams,
301        event_tx: Option<broadcast::Sender<AgentEvent>>,
302    ) -> String {
303        let task_id = format!("task-{}", uuid::Uuid::new_v4());
304        let task_id_clone = task_id.clone();
305
306        tokio::spawn(async move {
307            if let Err(e) = self.execute(params, event_tx).await {
308                tracing::error!("Background task {} failed: {}", task_id_clone, e);
309            }
310        });
311
312        task_id
313    }
314
315    /// Execute multiple tasks in parallel.
316    ///
317    /// Spawns all tasks concurrently and waits for all to complete.
318    /// Returns results in the same order as the input tasks.
319    pub async fn execute_parallel(
320        self: &Arc<Self>,
321        tasks: Vec<TaskParams>,
322        event_tx: Option<broadcast::Sender<AgentEvent>>,
323    ) -> Vec<TaskResult> {
324        let mut join_set: JoinSet<(usize, TaskResult)> = JoinSet::new();
325
326        for (idx, params) in tasks.into_iter().enumerate() {
327            let executor = Arc::clone(self);
328            let tx = event_tx.clone();
329
330            join_set.spawn(async move {
331                let result = match executor.execute(params.clone(), tx).await {
332                    Ok(result) => result,
333                    Err(e) => TaskResult {
334                        output: format!("Task failed: {}", e),
335                        session_id: String::new(),
336                        agent: params.agent,
337                        success: false,
338                        task_id: format!("task-{}", uuid::Uuid::new_v4()),
339                    },
340                };
341                (idx, result)
342            });
343        }
344
345        let mut indexed_results = Vec::new();
346        while let Some(result) = join_set.join_next().await {
347            match result {
348                Ok((idx, task_result)) => indexed_results.push((idx, task_result)),
349                Err(e) => {
350                    tracing::error!("Parallel task panicked: {}", e);
351                    indexed_results.push((
352                        usize::MAX,
353                        TaskResult {
354                            output: format!("Task panicked: {}", e),
355                            session_id: String::new(),
356                            agent: "unknown".to_string(),
357                            success: false,
358                            task_id: format!("task-{}", uuid::Uuid::new_v4()),
359                        },
360                    ));
361                }
362            }
363        }
364
365        indexed_results.sort_by_key(|(idx, _)| *idx);
366        indexed_results.into_iter().map(|(_, r)| r).collect()
367    }
368}
369
370/// Get the JSON schema for TaskParams
371pub fn task_params_schema() -> serde_json::Value {
372    serde_json::json!({
373        "type": "object",
374        "additionalProperties": false,
375        "properties": {
376            "agent": {
377                "type": "string",
378                "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
379            },
380            "description": {
381                "type": "string",
382                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
383            },
384            "prompt": {
385                "type": "string",
386                "description": "Required. Detailed instruction for the delegated subagent. Always provide this exact field name: 'prompt'."
387            },
388            "background": {
389                "type": "boolean",
390                "description": "Optional. Run the task in the background. Default: false.",
391                "default": false
392            },
393            "max_steps": {
394                "type": "integer",
395                "description": "Optional. Maximum number of steps for this task."
396            }
397        },
398        "required": ["agent", "description", "prompt"],
399        "examples": [
400            {
401                "agent": "explore",
402                "description": "Find Rust files",
403                "prompt": "Search the workspace for Rust files and summarize the layout."
404            },
405            {
406                "agent": "general",
407                "description": "Investigate test failure",
408                "prompt": "Inspect the failing tests and explain the root cause.",
409                "max_steps": 6
410            }
411        ]
412    })
413}
414
415/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
416/// This allows the LLM to delegate tasks to subagents via the standard tool interface.
417pub struct TaskTool {
418    executor: Arc<TaskExecutor>,
419}
420
421impl TaskTool {
422    /// Create a new TaskTool
423    pub fn new(executor: Arc<TaskExecutor>) -> Self {
424        Self { executor }
425    }
426}
427
428#[async_trait]
429impl Tool for TaskTool {
430    fn name(&self) -> &str {
431        "task"
432    }
433
434    fn description(&self) -> &str {
435        "Delegate a task to a specialized subagent. 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."
436    }
437
438    fn parameters(&self) -> serde_json::Value {
439        task_params_schema()
440    }
441
442    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
443        let params: TaskParams =
444            serde_json::from_value(args.clone()).context("Invalid task parameters")?;
445
446        if params.background {
447            let task_id =
448                Arc::clone(&self.executor).execute_background(params, ctx.agent_event_tx.clone());
449            return Ok(ToolOutput::success(format!(
450                "Task started in background. Task ID: {}",
451                task_id
452            )));
453        }
454
455        let result = self
456            .executor
457            .execute(params, ctx.agent_event_tx.clone())
458            .await?;
459        let (content, truncated) = format_task_result_for_context(&result);
460        let metadata = serde_json::json!({
461            "task_id": result.task_id,
462            "session_id": result.session_id,
463            "agent": result.agent,
464            "success": result.success,
465            "output_bytes": result.output.len(),
466            "truncated_for_context": truncated,
467            "artifact_id": task_artifact_id(&result),
468            "artifact_uri": task_artifact_uri(&result),
469        });
470
471        if result.success {
472            Ok(ToolOutput::success(content).with_metadata(metadata))
473        } else {
474            Ok(ToolOutput::error(content).with_metadata(metadata))
475        }
476    }
477}
478
479/// Parameters for parallel task execution
480#[derive(Debug, Clone, Serialize, Deserialize)]
481#[serde(deny_unknown_fields)]
482pub struct ParallelTaskParams {
483    /// List of tasks to execute concurrently
484    pub tasks: Vec<TaskParams>,
485}
486
487/// Get the JSON schema for ParallelTaskParams
488pub fn parallel_task_params_schema() -> serde_json::Value {
489    serde_json::json!({
490        "type": "object",
491        "additionalProperties": false,
492        "properties": {
493            "tasks": {
494                "type": "array",
495                "description": "List of tasks to execute in parallel. Each task runs as an independent subagent concurrently.",
496                "items": {
497                    "type": "object",
498                    "additionalProperties": false,
499                    "properties": {
500                        "agent": {
501                            "type": "string",
502                            "description": "Required. Canonical agent type for this task."
503                        },
504                        "description": {
505                            "type": "string",
506                            "description": "Required. Short task label for display and tracking."
507                        },
508                        "prompt": {
509                            "type": "string",
510                            "description": "Required. Detailed instruction for the delegated subagent."
511                        }
512                    },
513                    "required": ["agent", "description", "prompt"]
514                },
515                "minItems": 1
516            }
517        },
518        "required": ["tasks"],
519        "examples": [
520            {
521                "tasks": [
522                    {
523                        "agent": "explore",
524                        "description": "Find Rust files",
525                        "prompt": "List Rust files under src/."
526                    },
527                    {
528                        "agent": "explore",
529                        "description": "Find tests",
530                        "prompt": "List test files and summarize their purpose."
531                    }
532                ]
533            }
534        ]
535    })
536}
537
538/// ParallelTaskTool allows the LLM to fan-out multiple subagent tasks concurrently.
539///
540/// All tasks execute in parallel and the tool returns when all complete.
541pub struct ParallelTaskTool {
542    executor: Arc<TaskExecutor>,
543}
544
545impl ParallelTaskTool {
546    /// Create a new ParallelTaskTool
547    pub fn new(executor: Arc<TaskExecutor>) -> Self {
548        Self { executor }
549    }
550}
551
552#[async_trait]
553impl Tool for ParallelTaskTool {
554    fn name(&self) -> &str {
555        "parallel_task"
556    }
557
558    fn description(&self) -> &str {
559        "Execute multiple subagent tasks 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."
560    }
561
562    fn parameters(&self) -> serde_json::Value {
563        parallel_task_params_schema()
564    }
565
566    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
567        let params: ParallelTaskParams =
568            serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
569
570        if params.tasks.is_empty() {
571            return Ok(ToolOutput::error("No tasks provided".to_string()));
572        }
573
574        let task_count = params.tasks.len();
575
576        let results = self
577            .executor
578            .execute_parallel(params.tasks, ctx.agent_event_tx.clone())
579            .await;
580
581        // Format results with compact per-task excerpts for parent context.
582        let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
583        let mut metadata_results = Vec::new();
584        for (i, result) in results.iter().enumerate() {
585            let status = if result.success { "[OK]" } else { "[ERR]" };
586            let (formatted, truncated) = format_task_result_for_context(result);
587            metadata_results.push(serde_json::json!({
588                "task_id": result.task_id,
589                "session_id": result.session_id,
590                "agent": result.agent,
591                "success": result.success,
592                "output_bytes": result.output.len(),
593                "truncated_for_context": truncated,
594                "artifact_id": task_artifact_id(result),
595                "artifact_uri": task_artifact_uri(result),
596            }));
597            output.push_str(&format!(
598                "--- Task {} ({}) {} ---\n{}\n\n",
599                i + 1,
600                result.agent,
601                status,
602                formatted
603            ));
604        }
605
606        Ok(
607            ToolOutput::success(output).with_metadata(serde_json::json!({
608                "task_count": task_count,
609                "results": metadata_results,
610            })),
611        )
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn test_task_params_deserialize() {
621        let json = r#"{
622            "agent": "explore",
623            "description": "Find auth code",
624            "prompt": "Search for authentication files"
625        }"#;
626
627        let params: TaskParams = serde_json::from_str(json).unwrap();
628        assert_eq!(params.agent, "explore");
629        assert_eq!(params.description, "Find auth code");
630        assert!(!params.background);
631    }
632
633    #[test]
634    fn test_task_params_with_background() {
635        let json = r#"{
636            "agent": "general",
637            "description": "Long task",
638            "prompt": "Do something complex",
639            "background": true
640        }"#;
641
642        let params: TaskParams = serde_json::from_str(json).unwrap();
643        assert!(params.background);
644    }
645
646    #[test]
647    fn test_task_params_with_max_steps() {
648        let json = r#"{
649            "agent": "plan",
650            "description": "Planning task",
651            "prompt": "Create a plan",
652            "max_steps": 10
653        }"#;
654
655        let params: TaskParams = serde_json::from_str(json).unwrap();
656        assert_eq!(params.agent, "plan");
657        assert_eq!(params.max_steps, Some(10));
658        assert!(!params.background);
659    }
660
661    #[test]
662    fn test_task_params_all_fields() {
663        let json = r#"{
664            "agent": "general",
665            "description": "Complex task",
666            "prompt": "Do everything",
667            "background": true,
668            "max_steps": 20
669        }"#;
670
671        let params: TaskParams = serde_json::from_str(json).unwrap();
672        assert_eq!(params.agent, "general");
673        assert_eq!(params.description, "Complex task");
674        assert_eq!(params.prompt, "Do everything");
675        assert!(params.background);
676        assert_eq!(params.max_steps, Some(20));
677    }
678
679    #[test]
680    fn test_task_params_missing_required_field() {
681        let json = r#"{
682            "agent": "explore",
683            "description": "Missing prompt"
684        }"#;
685
686        let result: Result<TaskParams, _> = serde_json::from_str(json);
687        assert!(result.is_err());
688    }
689
690    #[test]
691    fn test_task_params_serialize() {
692        let params = TaskParams {
693            agent: "explore".to_string(),
694            description: "Test task".to_string(),
695            prompt: "Test prompt".to_string(),
696            background: false,
697            max_steps: Some(5),
698        };
699
700        let json = serde_json::to_string(&params).unwrap();
701        assert!(json.contains("explore"));
702        assert!(json.contains("Test task"));
703        assert!(json.contains("Test prompt"));
704    }
705
706    #[test]
707    fn test_task_params_clone() {
708        let params = TaskParams {
709            agent: "explore".to_string(),
710            description: "Test".to_string(),
711            prompt: "Prompt".to_string(),
712            background: true,
713            max_steps: None,
714        };
715
716        let cloned = params.clone();
717        assert_eq!(params.agent, cloned.agent);
718        assert_eq!(params.description, cloned.description);
719        assert_eq!(params.background, cloned.background);
720    }
721
722    #[test]
723    fn test_task_result_serialize() {
724        let result = TaskResult {
725            output: "Found 5 files".to_string(),
726            session_id: "session-123".to_string(),
727            agent: "explore".to_string(),
728            success: true,
729            task_id: "task-456".to_string(),
730        };
731
732        let json = serde_json::to_string(&result).unwrap();
733        assert!(json.contains("Found 5 files"));
734        assert!(json.contains("explore"));
735    }
736
737    #[test]
738    fn test_task_result_deserialize() {
739        let json = r#"{
740            "output": "Task completed",
741            "session_id": "sess-789",
742            "agent": "general",
743            "success": false,
744            "task_id": "task-123"
745        }"#;
746
747        let result: TaskResult = serde_json::from_str(json).unwrap();
748        assert_eq!(result.output, "Task completed");
749        assert_eq!(result.session_id, "sess-789");
750        assert_eq!(result.agent, "general");
751        assert!(!result.success);
752        assert_eq!(result.task_id, "task-123");
753    }
754
755    #[test]
756    fn test_task_result_clone() {
757        let result = TaskResult {
758            output: "Output".to_string(),
759            session_id: "session-1".to_string(),
760            agent: "explore".to_string(),
761            success: true,
762            task_id: "task-1".to_string(),
763        };
764
765        let cloned = result.clone();
766        assert_eq!(result.output, cloned.output);
767        assert_eq!(result.success, cloned.success);
768    }
769
770    #[test]
771    fn test_compact_task_output_preserves_small_output() {
772        let (output, truncated) = compact_task_output("short result");
773        assert_eq!(output, "short result");
774        assert!(!truncated);
775    }
776
777    #[test]
778    fn test_format_task_result_for_context_truncates_large_output() {
779        let result = TaskResult {
780            output: format!("{}TAIL", "x".repeat(TASK_OUTPUT_CONTEXT_LIMIT + 500)),
781            session_id: "session-1".to_string(),
782            agent: "explore".to_string(),
783            success: true,
784            task_id: "task-1".to_string(),
785        };
786
787        let (formatted, truncated) = format_task_result_for_context(&result);
788        assert!(truncated);
789        assert!(formatted.contains("Output excerpt"));
790        assert!(formatted.contains("bytes omitted"));
791        assert!(formatted.contains("Artifact ID: subagent-output:task-1"));
792        assert!(formatted.contains("Artifact URI: a3s://subagent/session-1/tasks/task-1/output"));
793        assert!(formatted.contains("TAIL"));
794        assert!(formatted.len() < result.output.len());
795    }
796
797    #[test]
798    fn test_task_artifact_reference_is_stable() {
799        let result = TaskResult {
800            output: "done".to_string(),
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        assert_eq!(task_artifact_id(&result), "subagent-output:task-1");
808        assert_eq!(
809            task_artifact_uri(&result),
810            "a3s://subagent/session-1/tasks/task-1/output"
811        );
812
813        let (formatted, truncated) = format_task_result_for_context(&result);
814        assert!(!truncated);
815        assert!(formatted.contains("Artifact URI: a3s://subagent/session-1/tasks/task-1/output"));
816    }
817
818    #[test]
819    fn test_task_params_schema() {
820        let schema = task_params_schema();
821        assert_eq!(schema["type"], "object");
822        assert_eq!(schema["additionalProperties"], false);
823        assert!(schema["properties"]["agent"].is_object());
824        assert!(schema["properties"]["prompt"].is_object());
825    }
826
827    #[test]
828    fn test_task_params_schema_required_fields() {
829        let schema = task_params_schema();
830        let required = schema["required"].as_array().unwrap();
831        assert!(required.contains(&serde_json::json!("agent")));
832        assert!(required.contains(&serde_json::json!("description")));
833        assert!(required.contains(&serde_json::json!("prompt")));
834    }
835
836    #[test]
837    fn test_task_params_schema_properties() {
838        let schema = task_params_schema();
839        let props = &schema["properties"];
840
841        assert_eq!(props["agent"]["type"], "string");
842        assert_eq!(props["description"]["type"], "string");
843        assert_eq!(props["prompt"]["type"], "string");
844        assert_eq!(props["background"]["type"], "boolean");
845        assert_eq!(props["background"]["default"], false);
846        assert_eq!(props["max_steps"]["type"], "integer");
847    }
848
849    #[test]
850    fn test_task_params_schema_descriptions() {
851        let schema = task_params_schema();
852        let props = &schema["properties"];
853
854        assert!(props["agent"]["description"].is_string());
855        assert!(props["description"]["description"].is_string());
856        assert!(props["prompt"]["description"].is_string());
857        assert!(props["background"]["description"].is_string());
858        assert!(props["max_steps"]["description"].is_string());
859    }
860
861    #[test]
862    fn test_task_params_default_background() {
863        let params = TaskParams {
864            agent: "explore".to_string(),
865            description: "Test".to_string(),
866            prompt: "Test prompt".to_string(),
867            background: false,
868            max_steps: None,
869        };
870        assert!(!params.background);
871    }
872
873    #[test]
874    fn test_task_params_serialize_skip_none() {
875        let params = TaskParams {
876            agent: "explore".to_string(),
877            description: "Test".to_string(),
878            prompt: "Test prompt".to_string(),
879            background: false,
880            max_steps: None,
881        };
882        let json = serde_json::to_string(&params).unwrap();
883        // max_steps should not appear when None
884        assert!(!json.contains("max_steps"));
885    }
886
887    #[test]
888    fn test_task_params_serialize_with_max_steps() {
889        let params = TaskParams {
890            agent: "explore".to_string(),
891            description: "Test".to_string(),
892            prompt: "Test prompt".to_string(),
893            background: false,
894            max_steps: Some(15),
895        };
896        let json = serde_json::to_string(&params).unwrap();
897        assert!(json.contains("max_steps"));
898        assert!(json.contains("15"));
899    }
900
901    #[test]
902    fn test_task_result_success_true() {
903        let result = TaskResult {
904            output: "Success".to_string(),
905            session_id: "sess-1".to_string(),
906            agent: "explore".to_string(),
907            success: true,
908            task_id: "task-1".to_string(),
909        };
910        assert!(result.success);
911    }
912
913    #[test]
914    fn test_task_result_success_false() {
915        let result = TaskResult {
916            output: "Failed".to_string(),
917            session_id: "sess-1".to_string(),
918            agent: "explore".to_string(),
919            success: false,
920            task_id: "task-1".to_string(),
921        };
922        assert!(!result.success);
923    }
924
925    #[test]
926    fn test_task_params_empty_strings() {
927        let params = TaskParams {
928            agent: "".to_string(),
929            description: "".to_string(),
930            prompt: "".to_string(),
931            background: false,
932            max_steps: None,
933        };
934        let json = serde_json::to_string(&params).unwrap();
935        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
936        assert_eq!(deserialized.agent, "");
937        assert_eq!(deserialized.description, "");
938        assert_eq!(deserialized.prompt, "");
939    }
940
941    #[test]
942    fn test_task_result_empty_output() {
943        let result = TaskResult {
944            output: "".to_string(),
945            session_id: "sess-1".to_string(),
946            agent: "explore".to_string(),
947            success: true,
948            task_id: "task-1".to_string(),
949        };
950        assert_eq!(result.output, "");
951    }
952
953    #[test]
954    fn test_task_params_debug_format() {
955        let params = TaskParams {
956            agent: "explore".to_string(),
957            description: "Test".to_string(),
958            prompt: "Test prompt".to_string(),
959            background: false,
960            max_steps: None,
961        };
962        let debug_str = format!("{:?}", params);
963        assert!(debug_str.contains("explore"));
964        assert!(debug_str.contains("Test"));
965    }
966
967    #[test]
968    fn test_task_result_debug_format() {
969        let result = TaskResult {
970            output: "Output".to_string(),
971            session_id: "sess-1".to_string(),
972            agent: "explore".to_string(),
973            success: true,
974            task_id: "task-1".to_string(),
975        };
976        let debug_str = format!("{:?}", result);
977        assert!(debug_str.contains("Output"));
978        assert!(debug_str.contains("explore"));
979    }
980
981    #[test]
982    fn test_task_params_roundtrip() {
983        let original = TaskParams {
984            agent: "general".to_string(),
985            description: "Roundtrip test".to_string(),
986            prompt: "Test roundtrip serialization".to_string(),
987            background: true,
988            max_steps: Some(42),
989        };
990        let json = serde_json::to_string(&original).unwrap();
991        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
992        assert_eq!(original.agent, deserialized.agent);
993        assert_eq!(original.description, deserialized.description);
994        assert_eq!(original.prompt, deserialized.prompt);
995        assert_eq!(original.background, deserialized.background);
996        assert_eq!(original.max_steps, deserialized.max_steps);
997    }
998
999    #[test]
1000    fn test_task_result_roundtrip() {
1001        let original = TaskResult {
1002            output: "Roundtrip output".to_string(),
1003            session_id: "sess-roundtrip".to_string(),
1004            agent: "plan".to_string(),
1005            success: false,
1006            task_id: "task-roundtrip".to_string(),
1007        };
1008        let json = serde_json::to_string(&original).unwrap();
1009        let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
1010        assert_eq!(original.output, deserialized.output);
1011        assert_eq!(original.session_id, deserialized.session_id);
1012        assert_eq!(original.agent, deserialized.agent);
1013        assert_eq!(original.success, deserialized.success);
1014        assert_eq!(original.task_id, deserialized.task_id);
1015    }
1016
1017    #[test]
1018    fn test_parallel_task_params_deserialize() {
1019        let json = r#"{
1020            "tasks": [
1021                { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
1022                { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
1023            ]
1024        }"#;
1025
1026        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1027        assert_eq!(params.tasks.len(), 2);
1028        assert_eq!(params.tasks[0].agent, "explore");
1029        assert_eq!(params.tasks[1].agent, "general");
1030    }
1031
1032    #[test]
1033    fn test_parallel_task_params_single_task() {
1034        let json = r#"{
1035            "tasks": [
1036                { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
1037            ]
1038        }"#;
1039
1040        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1041        assert_eq!(params.tasks.len(), 1);
1042    }
1043
1044    #[test]
1045    fn test_parallel_task_params_empty_tasks() {
1046        let json = r#"{ "tasks": [] }"#;
1047        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1048        assert!(params.tasks.is_empty());
1049    }
1050
1051    #[test]
1052    fn test_parallel_task_params_missing_tasks() {
1053        let json = r#"{}"#;
1054        let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
1055        assert!(result.is_err());
1056    }
1057
1058    #[test]
1059    fn test_parallel_task_params_serialize() {
1060        let params = ParallelTaskParams {
1061            tasks: vec![
1062                TaskParams {
1063                    agent: "explore".to_string(),
1064                    description: "Task 1".to_string(),
1065                    prompt: "Prompt 1".to_string(),
1066                    background: false,
1067                    max_steps: None,
1068                },
1069                TaskParams {
1070                    agent: "general".to_string(),
1071                    description: "Task 2".to_string(),
1072                    prompt: "Prompt 2".to_string(),
1073                    background: false,
1074                    max_steps: Some(10),
1075                },
1076            ],
1077        };
1078        let json = serde_json::to_string(&params).unwrap();
1079        assert!(json.contains("explore"));
1080        assert!(json.contains("general"));
1081        assert!(json.contains("Prompt 1"));
1082        assert!(json.contains("Prompt 2"));
1083    }
1084
1085    #[test]
1086    fn test_parallel_task_params_roundtrip() {
1087        let original = ParallelTaskParams {
1088            tasks: vec![
1089                TaskParams {
1090                    agent: "explore".to_string(),
1091                    description: "Explore".to_string(),
1092                    prompt: "Find files".to_string(),
1093                    background: false,
1094                    max_steps: None,
1095                },
1096                TaskParams {
1097                    agent: "plan".to_string(),
1098                    description: "Plan".to_string(),
1099                    prompt: "Make plan".to_string(),
1100                    background: false,
1101                    max_steps: Some(5),
1102                },
1103            ],
1104        };
1105        let json = serde_json::to_string(&original).unwrap();
1106        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1107        assert_eq!(original.tasks.len(), deserialized.tasks.len());
1108        assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
1109        assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
1110        assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
1111    }
1112
1113    #[test]
1114    fn test_parallel_task_params_clone() {
1115        let params = ParallelTaskParams {
1116            tasks: vec![TaskParams {
1117                agent: "explore".to_string(),
1118                description: "Test".to_string(),
1119                prompt: "Prompt".to_string(),
1120                background: false,
1121                max_steps: None,
1122            }],
1123        };
1124        let cloned = params.clone();
1125        assert_eq!(params.tasks.len(), cloned.tasks.len());
1126        assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
1127    }
1128
1129    #[test]
1130    fn test_parallel_task_params_schema() {
1131        let schema = parallel_task_params_schema();
1132        assert_eq!(schema["type"], "object");
1133        assert_eq!(schema["additionalProperties"], false);
1134        assert!(schema["properties"]["tasks"].is_object());
1135        assert_eq!(schema["properties"]["tasks"]["type"], "array");
1136        assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
1137    }
1138
1139    #[test]
1140    fn test_parallel_task_params_schema_required() {
1141        let schema = parallel_task_params_schema();
1142        let required = schema["required"].as_array().unwrap();
1143        assert!(required.contains(&serde_json::json!("tasks")));
1144    }
1145
1146    #[test]
1147    fn test_parallel_task_params_schema_items() {
1148        let schema = parallel_task_params_schema();
1149        let items = &schema["properties"]["tasks"]["items"];
1150        assert_eq!(items["type"], "object");
1151        assert_eq!(items["additionalProperties"], false);
1152        let item_required = items["required"].as_array().unwrap();
1153        assert!(item_required.contains(&serde_json::json!("agent")));
1154        assert!(item_required.contains(&serde_json::json!("description")));
1155        assert!(item_required.contains(&serde_json::json!("prompt")));
1156    }
1157
1158    #[test]
1159    fn test_task_schema_examples_use_delegation_core() {
1160        let task = task_params_schema();
1161        let task_examples = task["examples"].as_array().unwrap();
1162        assert_eq!(task_examples[0]["agent"], "explore");
1163        assert!(task_examples[0].get("task").is_none());
1164
1165        let parallel = parallel_task_params_schema();
1166        let parallel_examples = parallel["examples"].as_array().unwrap();
1167        assert!(!parallel_examples[0]["tasks"].as_array().unwrap().is_empty());
1168    }
1169
1170    #[test]
1171    fn test_parallel_task_params_debug() {
1172        let params = ParallelTaskParams {
1173            tasks: vec![TaskParams {
1174                agent: "explore".to_string(),
1175                description: "Debug test".to_string(),
1176                prompt: "Test".to_string(),
1177                background: false,
1178                max_steps: None,
1179            }],
1180        };
1181        let debug_str = format!("{:?}", params);
1182        assert!(debug_str.contains("explore"));
1183        assert!(debug_str.contains("Debug test"));
1184    }
1185
1186    #[test]
1187    fn test_parallel_task_params_large_count() {
1188        // Validate that ParallelTaskParams can hold 150 tasks without truncation
1189        let tasks: Vec<TaskParams> = (0..150)
1190            .map(|i| TaskParams {
1191                agent: "explore".to_string(),
1192                description: format!("Task {}", i),
1193                prompt: format!("Prompt for task {}", i),
1194                background: false,
1195                max_steps: Some(10),
1196            })
1197            .collect();
1198
1199        let params = ParallelTaskParams { tasks };
1200        let json = serde_json::to_string(&params).unwrap();
1201        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1202        assert_eq!(deserialized.tasks.len(), 150);
1203        assert_eq!(deserialized.tasks[0].description, "Task 0");
1204        assert_eq!(deserialized.tasks[149].description, "Task 149");
1205    }
1206
1207    #[test]
1208    fn test_task_params_max_steps_zero() {
1209        // max_steps = 0 is a valid edge case (callers decide enforcement)
1210        let params = TaskParams {
1211            agent: "explore".to_string(),
1212            description: "Edge case".to_string(),
1213            prompt: "Zero steps".to_string(),
1214            background: false,
1215            max_steps: Some(0),
1216        };
1217        let json = serde_json::to_string(&params).unwrap();
1218        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1219        assert_eq!(deserialized.max_steps, Some(0));
1220    }
1221
1222    #[test]
1223    fn test_parallel_task_params_all_background() {
1224        let tasks: Vec<TaskParams> = (0..5)
1225            .map(|i| TaskParams {
1226                agent: "general".to_string(),
1227                description: format!("BG task {}", i),
1228                prompt: "Run in background".to_string(),
1229                background: true,
1230                max_steps: None,
1231            })
1232            .collect();
1233        let params = ParallelTaskParams { tasks };
1234        for task in &params.tasks {
1235            assert!(task.background);
1236        }
1237    }
1238
1239    #[test]
1240    fn test_task_params_rejects_permissive_field() {
1241        let json = r#"{
1242            "agent": "general",
1243            "description": "Legacy field rejection",
1244            "prompt": "Verify legacy fields are rejected",
1245            "permissive": true
1246        }"#;
1247
1248        let result: Result<TaskParams, _> = serde_json::from_str(json);
1249        assert!(result.is_err());
1250    }
1251
1252    #[test]
1253    fn test_task_params_schema_hides_permissive_field() {
1254        let schema = task_params_schema();
1255        let props = &schema["properties"];
1256
1257        assert!(props.get("permissive").is_none());
1258    }
1259}