Skip to main content

a3s_code_core/tools/
task.rs

1//! Task tools for delegated child runs.
2//!
3//! The Task tool allows the main agent to delegate specialized work to focused
4//! child runs. Each child run gets bounded context and the permissions declared
5//! by its agent definition.
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::structured::{generate_blocking, StructuredMode, StructuredRequest};
19use crate::llm::LlmClient;
20use crate::mcp::manager::McpManager;
21use crate::orchestration::{AgentExecutor, AgentStepSpec, StepOutcome};
22use crate::subagent::AgentRegistry;
23use crate::tools::types::{Tool, ToolContext, ToolOutput};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use std::path::PathBuf;
28use std::sync::Arc;
29use tokio::sync::broadcast;
30
31const TASK_OUTPUT_CONTEXT_LIMIT: usize = 4_000;
32const TASK_OUTPUT_CONTEXT_HEAD: usize = 3_000;
33const TASK_OUTPUT_CONTEXT_TAIL: usize = 800;
34
35/// Task tool parameters
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct TaskParams {
39    /// Agent type to use (explore, general, plan, verification, review, etc.)
40    pub agent: String,
41    /// Short description of the task (for display)
42    pub description: String,
43    /// Detailed prompt for the agent
44    pub prompt: String,
45    /// Optional: run in background (default: false)
46    #[serde(default)]
47    pub background: bool,
48    /// Optional: maximum steps for this task
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub max_steps: Option<usize>,
51}
52
53/// Task tool result
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct TaskResult {
56    /// Task output from the delegated child run.
57    pub output: String,
58    /// Child session ID
59    pub session_id: String,
60    /// Agent type used
61    pub agent: String,
62    /// Whether the task succeeded
63    pub success: bool,
64    /// Task ID for tracking
65    pub task_id: String,
66}
67
68fn compact_task_output(output: &str) -> (String, bool) {
69    if output.len() <= TASK_OUTPUT_CONTEXT_LIMIT {
70        return (output.to_string(), false);
71    }
72
73    let head = crate::text::truncate_utf8(output, TASK_OUTPUT_CONTEXT_HEAD);
74    let tail_start = output
75        .char_indices()
76        .find_map(|(idx, _)| {
77            if output.len().saturating_sub(idx) <= TASK_OUTPUT_CONTEXT_TAIL {
78                Some(idx)
79            } else {
80                None
81            }
82        })
83        .unwrap_or(output.len());
84    let tail = &output[tail_start..];
85
86    (
87        format!(
88            "{}\n\n[{} bytes omitted from delegated task output]\n\n{}",
89            head,
90            output.len().saturating_sub(head.len() + tail.len()),
91            tail
92        ),
93        true,
94    )
95}
96
97/// Translate selected child-loop events into a `SubagentProgress` milestone
98/// for the parent broadcast. Returns `None` for events that aren't worth
99/// surfacing as progress (text deltas, tool starts, subagent events from
100/// nested delegation, etc.).
101///
102/// Currently emits progress for:
103/// - `ToolEnd`   → `status = "tool_completed"`,
104///   `metadata = { tool, exit_code, output_bytes, error_kind? }`
105/// - `TurnEnd`   → `status = "turn_completed"`,
106///   `metadata = { turn, total_tokens, prompt_tokens, completion_tokens }`
107fn synthesize_subagent_progress(
108    event: &AgentEvent,
109    task_id: &str,
110    session_id: &str,
111) -> Option<AgentEvent> {
112    match event {
113        AgentEvent::ToolEnd {
114            name,
115            output,
116            exit_code,
117            error_kind,
118            ..
119        } => {
120            let mut metadata = serde_json::json!({
121                "tool": name,
122                "exit_code": exit_code,
123                "output_bytes": output.len(),
124            });
125            if let Some(kind) = error_kind {
126                metadata["error_kind"] =
127                    serde_json::to_value(kind).unwrap_or(serde_json::Value::Null);
128            }
129            Some(AgentEvent::SubagentProgress {
130                task_id: task_id.to_string(),
131                session_id: session_id.to_string(),
132                status: "tool_completed".to_string(),
133                metadata,
134            })
135        }
136        AgentEvent::TurnEnd { turn, usage } => Some(AgentEvent::SubagentProgress {
137            task_id: task_id.to_string(),
138            session_id: session_id.to_string(),
139            status: "turn_completed".to_string(),
140            metadata: serde_json::json!({
141                "turn": turn,
142                "total_tokens": usage.total_tokens,
143                "prompt_tokens": usage.prompt_tokens,
144                "completion_tokens": usage.completion_tokens,
145            }),
146        }),
147        _ => None,
148    }
149}
150
151fn task_artifact_id(result: &TaskResult) -> String {
152    format!("task-output:{}", result.task_id)
153}
154
155fn task_artifact_uri(result: &TaskResult) -> String {
156    format!(
157        "a3s://tasks/{}/runs/{}/output",
158        result.session_id, result.task_id
159    )
160}
161
162fn epoch_ms() -> u64 {
163    use std::time::{SystemTime, UNIX_EPOCH};
164    SystemTime::now()
165        .duration_since(UNIX_EPOCH)
166        .map(|duration| duration.as_millis() as u64)
167        .unwrap_or(0)
168}
169
170fn format_task_result_for_context(result: &TaskResult) -> (String, bool) {
171    let (output, truncated) = compact_task_output(&result.output);
172    let status = if result.success {
173        "completed"
174    } else {
175        "failed"
176    };
177    let artifact_id = task_artifact_id(result);
178    let artifact_uri = task_artifact_uri(result);
179    let mut formatted = format!(
180        "Task {status}: {}\nAgent: {}\nSession: {}\nTask ID: {}\nArtifact ID: {}\nArtifact URI: {}\n",
181        result.task_id, result.agent, result.session_id, result.task_id, artifact_id, artifact_uri
182    );
183    if truncated {
184        formatted.push_str(
185            "Output excerpt: truncated for parent context. Use the artifact URI or child run session/events if exact omitted content is needed.\n",
186        );
187    } else {
188        formatted.push_str("Output:\n");
189    }
190    formatted.push_str(&output);
191    (formatted, truncated)
192}
193
194/// Task executor for delegated child runs.
195pub struct TaskExecutor {
196    /// Agent registry for looking up agent definitions
197    registry: Arc<AgentRegistry>,
198    /// LLM client used to power child agent loops
199    llm_client: Arc<dyn LlmClient>,
200    /// Workspace path shared with child agents
201    workspace: String,
202    /// Optional MCP manager for registering MCP tools in child sessions
203    mcp_manager: Option<Arc<McpManager>>,
204    /// Parent capabilities to inherit into child runs.
205    parent_context: Option<crate::child_run::ChildRunContext>,
206    max_parallel_tasks: usize,
207    /// Optional shared tracker — when present each task registers a
208    /// `CancellationToken` so callers can cancel by `task_id`.
209    subagent_tracker: Option<Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>>,
210}
211
212impl TaskExecutor {
213    /// Create a new task executor
214    pub fn new(
215        registry: Arc<AgentRegistry>,
216        llm_client: Arc<dyn LlmClient>,
217        workspace: String,
218    ) -> Self {
219        Self {
220            registry,
221            llm_client,
222            workspace,
223            mcp_manager: None,
224            parent_context: None,
225            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
226            subagent_tracker: None,
227        }
228    }
229
230    /// Create a new task executor with MCP manager for tool inheritance
231    pub fn with_mcp(
232        registry: Arc<AgentRegistry>,
233        llm_client: Arc<dyn LlmClient>,
234        workspace: String,
235        mcp_manager: Arc<McpManager>,
236    ) -> Self {
237        Self {
238            registry,
239            llm_client,
240            workspace,
241            mcp_manager: Some(mcp_manager),
242            parent_context: None,
243            max_parallel_tasks: crate::agent::DEFAULT_MAX_PARALLEL_TASKS,
244            subagent_tracker: None,
245        }
246    }
247
248    /// Set parent session capabilities to inherit into child runs.
249    pub fn with_parent_context(mut self, ctx: crate::child_run::ChildRunContext) -> Self {
250        if let Some(max_parallel_tasks) = ctx.max_parallel_tasks {
251            self.max_parallel_tasks = max_parallel_tasks.max(1);
252        }
253        self.parent_context = Some(ctx);
254        self
255    }
256
257    pub fn with_max_parallel_tasks(mut self, max_parallel_tasks: usize) -> Self {
258        self.max_parallel_tasks = max_parallel_tasks.max(1);
259        self
260    }
261
262    /// Share a tracker with this executor. When set, each task registers
263    /// a `CancellationToken` against the tracker so the parent session
264    /// can cancel by `task_id`.
265    pub fn with_subagent_tracker(
266        mut self,
267        tracker: Arc<crate::subagent_task_tracker::InMemorySubagentTaskTracker>,
268    ) -> Self {
269        self.subagent_tracker = Some(tracker);
270        self
271    }
272
273    /// Execute a task by spawning an isolated child AgentLoop.
274    ///
275    /// `parent_session_id` flows into the emitted `SubagentStart`/`SubagentEnd`
276    /// events so dashboards can associate child runs with the parent session.
277    pub async fn execute(
278        &self,
279        params: TaskParams,
280        event_tx: Option<broadcast::Sender<AgentEvent>>,
281        parent_session_id: Option<&str>,
282    ) -> Result<TaskResult> {
283        let task_id = format!("task-{}", uuid::Uuid::new_v4());
284        self.execute_with_task_id(task_id, params, event_tx, parent_session_id, true)
285            .await
286    }
287
288    /// Execute a task using a caller-supplied task id. Used by `execute_background`
289    /// so the synchronously-returned task id matches the one in lifecycle events.
290    /// When `emit_start` is `false` the caller is responsible for emitting
291    /// `SubagentStart` themselves (e.g. to avoid a race against a tracker query).
292    pub async fn execute_with_task_id(
293        &self,
294        task_id: String,
295        params: TaskParams,
296        event_tx: Option<broadcast::Sender<AgentEvent>>,
297        parent_session_id: Option<&str>,
298        emit_start: bool,
299    ) -> Result<TaskResult> {
300        let session_id = format!("task-run-{}", task_id);
301        let started_ms = epoch_ms();
302
303        let agent = self
304            .registry
305            .get(&params.agent)
306            .context(format!("Unknown agent type: '{}'", params.agent))?;
307
308        if emit_start {
309            if let Some(ref tx) = event_tx {
310                let _ = tx.send(AgentEvent::SubagentStart {
311                    task_id: task_id.clone(),
312                    session_id: session_id.clone(),
313                    parent_session_id: parent_session_id.unwrap_or_default().to_string(),
314                    agent: params.agent.clone(),
315                    description: params.description.clone(),
316                    started_ms,
317                });
318            }
319        }
320
321        // Build a child ToolExecutor. Task tools are intentionally omitted
322        // here to prevent unlimited delegation nesting.
323        let child_executor = if let Some(ref parent_ctx) = self.parent_context {
324            if let Some(ref services) = parent_ctx.workspace_services {
325                crate::tools::ToolExecutor::new_with_workspace_services_and_artifact_limits(
326                    self.workspace.clone(),
327                    Arc::clone(services),
328                    crate::tools::ArtifactStoreLimits::default(),
329                )
330            } else {
331                crate::tools::ToolExecutor::new(self.workspace.clone())
332            }
333        } else {
334            crate::tools::ToolExecutor::new(self.workspace.clone())
335        };
336
337        // Register MCP tools so child agents can access MCP servers.
338        if let Some(ref mcp) = self.mcp_manager {
339            let all_tools = mcp.get_all_tools().await;
340            let mut by_server: std::collections::HashMap<
341                String,
342                Vec<crate::mcp::protocol::McpTool>,
343            > = std::collections::HashMap::new();
344            for (server, tool) in all_tools {
345                by_server.entry(server).or_default().push(tool);
346            }
347            for (server_name, tools) in by_server {
348                let wrappers =
349                    crate::mcp::tools::create_mcp_tools(&server_name, tools, Arc::clone(mcp));
350                for wrapper in wrappers {
351                    child_executor.register_dynamic_tool(wrapper);
352                }
353            }
354        }
355
356        let child_executor = Arc::new(child_executor);
357
358        let mut child_config = AgentConfig {
359            tools: child_executor.definitions(),
360            ..AgentConfig::default()
361        };
362        agent.apply_to(&mut child_config);
363        if let Some(ref parent_ctx) = self.parent_context {
364            parent_ctx.apply_to(&mut child_config);
365        }
366        if let Some(max_steps) = params.max_steps {
367            child_config.max_tool_rounds = max_steps;
368        }
369
370        let mut tool_context =
371            ToolContext::new(PathBuf::from(&self.workspace)).with_session_id(session_id.clone());
372        if let Some(ref parent_ctx) = self.parent_context {
373            if let Some(ref services) = parent_ctx.workspace_services {
374                tool_context = tool_context.with_workspace_services(Arc::clone(services));
375            }
376        }
377
378        let agent_loop = AgentLoop::new(
379            Arc::clone(&self.llm_client),
380            child_executor,
381            tool_context,
382            child_config,
383        );
384
385        // Create an mpsc channel for the child agent and forward events to broadcast.
386        // Selected child events (ToolEnd, TurnEnd) are also surfaced to the parent
387        // broadcast as synthetic `SubagentProgress` events so dashboards can observe
388        // mid-task milestones without subscribing to the raw event stream.
389        let child_event_tx = if let Some(ref broadcast_tx) = event_tx {
390            let (mpsc_tx, mut mpsc_rx) = tokio::sync::mpsc::channel(100);
391            let broadcast_tx_clone = broadcast_tx.clone();
392            let progress_task_id = task_id.clone();
393            let progress_session_id = session_id.clone();
394
395            tokio::spawn(async move {
396                while let Some(event) = mpsc_rx.recv().await {
397                    if let Some(progress) = synthesize_subagent_progress(
398                        &event,
399                        &progress_task_id,
400                        &progress_session_id,
401                    ) {
402                        let _ = broadcast_tx_clone.send(progress);
403                    }
404                    let _ = broadcast_tx_clone.send(event);
405                }
406            });
407
408            Some(mpsc_tx)
409        } else {
410            None
411        };
412
413        // Register a CancellationToken with the tracker (if shared) so the
414        // parent session's `cancel_subagent_task` can interrupt this run.
415        let cancel_token = tokio_util::sync::CancellationToken::new();
416        if let Some(ref tracker) = self.subagent_tracker {
417            tracker
418                .register_canceller(&task_id, cancel_token.clone())
419                .await;
420        }
421
422        let (output, success) = match agent_loop
423            .execute_with_session(
424                &[],
425                &params.prompt,
426                Some(&session_id),
427                child_event_tx,
428                Some(&cancel_token),
429            )
430            .await
431        {
432            Ok(result) => (result.text, true),
433            Err(e) if cancel_token.is_cancelled() => {
434                (format!("Task cancelled by caller: {}", e), false)
435            }
436            Err(e) => (format!("Task failed: {}", e), false),
437        };
438
439        if let Some(ref tracker) = self.subagent_tracker {
440            tracker.clear_canceller(&task_id).await;
441        }
442
443        if let Some(ref tx) = event_tx {
444            let _ = tx.send(AgentEvent::SubagentEnd {
445                task_id: task_id.clone(),
446                session_id: session_id.clone(),
447                agent: params.agent.clone(),
448                output: output.clone(),
449                success,
450                finished_ms: epoch_ms(),
451            });
452        }
453
454        Ok(TaskResult {
455            output,
456            session_id,
457            agent: params.agent,
458            success,
459            task_id,
460        })
461    }
462
463    /// Execute a task in the background.
464    ///
465    /// Returns immediately with the task ID; the same id is used in the emitted
466    /// `SubagentStart`/`SubagentEnd` events so callers can correlate. Pre-emits
467    /// `SubagentStart` synchronously when an event channel is available so a
468    /// caller that queries the subagent task tracker right after this call
469    /// observes the task in `Running` state without a race window.
470    pub fn execute_background(
471        self: Arc<Self>,
472        params: TaskParams,
473        event_tx: Option<broadcast::Sender<AgentEvent>>,
474        parent_session_id: Option<String>,
475    ) -> String {
476        let task_id = format!("task-{}", uuid::Uuid::new_v4());
477        let session_id = format!("task-run-{}", task_id);
478
479        if let Some(ref tx) = event_tx {
480            let _ = tx.send(AgentEvent::SubagentStart {
481                task_id: task_id.clone(),
482                session_id,
483                parent_session_id: parent_session_id.clone().unwrap_or_default(),
484                agent: params.agent.clone(),
485                description: params.description.clone(),
486                started_ms: epoch_ms(),
487            });
488        }
489
490        let task_id_for_spawn = task_id.clone();
491        let task_id_for_log = task_id.clone();
492        tokio::spawn(async move {
493            if let Err(e) = self
494                .execute_with_task_id(
495                    task_id_for_spawn,
496                    params,
497                    event_tx,
498                    parent_session_id.as_deref(),
499                    false,
500                )
501                .await
502            {
503                tracing::error!("Background task {} failed: {}", task_id_for_log, e);
504            }
505        });
506
507        task_id
508    }
509
510    /// Execute multiple tasks in parallel.
511    ///
512    /// Spawns all tasks concurrently and waits for all to complete.
513    /// Returns results in the same order as the input tasks. Routed through
514    /// the [`AgentExecutor`](crate::orchestration::AgentExecutor) seam so the
515    /// same fan-out works whether steps run locally (default) or are placed
516    /// on remote nodes by a host.
517    pub async fn execute_parallel(
518        self: &Arc<Self>,
519        tasks: Vec<TaskParams>,
520        event_tx: Option<broadcast::Sender<AgentEvent>>,
521        parent_session_id: Option<&str>,
522    ) -> Vec<TaskResult> {
523        let parent = parent_session_id.map(|s| s.to_string());
524        let specs = tasks
525            .into_iter()
526            .map(|params| AgentStepSpec {
527                task_id: format!("task-{}", uuid::Uuid::new_v4()),
528                agent: params.agent,
529                description: params.description,
530                prompt: params.prompt,
531                max_steps: params.max_steps,
532                parent_session_id: parent.clone(),
533                output_schema: None,
534            })
535            .collect();
536
537        let executor: Arc<dyn AgentExecutor> = Arc::<Self>::clone(self);
538        crate::orchestration::execute_steps_parallel(executor, specs, event_tx)
539            .await
540            .into_iter()
541            .map(TaskResult::from)
542            .collect()
543    }
544}
545
546impl From<TaskResult> for StepOutcome {
547    fn from(r: TaskResult) -> Self {
548        StepOutcome {
549            task_id: r.task_id,
550            session_id: r.session_id,
551            agent: r.agent,
552            output: r.output,
553            success: r.success,
554            structured: None,
555        }
556    }
557}
558
559impl From<StepOutcome> for TaskResult {
560    fn from(o: StepOutcome) -> Self {
561        TaskResult {
562            output: o.output,
563            session_id: o.session_id,
564            agent: o.agent,
565            success: o.success,
566            task_id: o.task_id,
567        }
568    }
569}
570
571/// The local, in-process executor: every step runs as a child `AgentLoop` on
572/// this node's tokio runtime. This is the default; a host substitutes
573/// its own [`AgentExecutor`] to place steps across a cluster.
574#[async_trait]
575impl AgentExecutor for TaskExecutor {
576    async fn execute_step(
577        &self,
578        spec: AgentStepSpec,
579        event_tx: Option<broadcast::Sender<AgentEvent>>,
580    ) -> StepOutcome {
581        let agent = spec.agent.clone();
582        let task_id = spec.task_id.clone();
583        let output_schema = spec.output_schema.clone();
584        let params = TaskParams {
585            agent: spec.agent,
586            description: spec.description,
587            prompt: spec.prompt,
588            background: false,
589            max_steps: spec.max_steps,
590        };
591        let mut outcome: StepOutcome = match self
592            .execute_with_task_id(
593                task_id.clone(),
594                params,
595                event_tx,
596                spec.parent_session_id.as_deref(),
597                true,
598            )
599            .await
600        {
601            Ok(result) => result.into(),
602            Err(e) => return StepOutcome::failed(task_id, agent, format!("Task failed: {e}")),
603        };
604
605        // When the step requested structured output, coerce the (succeeded)
606        // free-text result to the schema. A coercion failure demotes the step
607        // to unsuccessful so callers never treat unvalidated text as the
608        // promised object.
609        if outcome.success {
610            if let Some(schema) = output_schema {
611                match self.coerce_to_schema(&outcome.output, schema).await {
612                    Ok(object) => outcome.structured = Some(object),
613                    Err(e) => {
614                        outcome.success = false;
615                        outcome.output =
616                            format!("{}\n\n[structured output failed: {e}]", outcome.output);
617                    }
618                }
619            }
620        }
621        outcome
622    }
623
624    fn concurrency_hint(&self) -> usize {
625        self.max_parallel_tasks
626    }
627}
628
629impl TaskExecutor {
630    /// Coerce a step's free-text output into a JSON object validated against
631    /// `schema`, reusing the structured-output machinery (Tool mode — the most
632    /// portable across providers, with built-in repair). This is one extra LLM
633    /// call beyond the step's own run.
634    async fn coerce_to_schema(
635        &self,
636        output: &str,
637        schema: serde_json::Value,
638    ) -> Result<serde_json::Value> {
639        let req = StructuredRequest {
640            prompt: format!(
641                "Convert the following task result into a single JSON object that conforms to \
642                 the required schema. Use only information present in the result.\n\n\
643                 --- TASK RESULT ---\n{output}"
644            ),
645            system: Some(
646                "You output exactly one JSON object matching the provided schema.".to_string(),
647            ),
648            schema,
649            schema_name: "step_output".to_string(),
650            schema_description: None,
651            // Tool mode works on every provider that supports tool use and
652            // does not depend on response_format wiring.
653            mode: StructuredMode::Tool,
654            max_repair_attempts: 2,
655        };
656        let result = generate_blocking(&*self.llm_client, &req).await?;
657        Ok(result.object)
658    }
659}
660
661/// Get the JSON schema for TaskParams
662pub fn task_params_schema() -> serde_json::Value {
663    serde_json::json!({
664        "type": "object",
665        "additionalProperties": false,
666        "properties": {
667            "agent": {
668                "type": "string",
669                "description": "Required. Canonical agent type to use (for example: explore, general, plan, verification, review). Always provide this exact field name: 'agent'."
670            },
671            "description": {
672                "type": "string",
673                "description": "Required. Short task label for display and tracking. Always provide this exact field name: 'description'."
674            },
675            "prompt": {
676                "type": "string",
677                "description": "Required. Detailed instruction for the delegated child run. Always provide this exact field name: 'prompt'."
678            },
679            "background": {
680                "type": "boolean",
681                "description": "Optional. Run the task in the background. Default: false.",
682                "default": false
683            },
684            "max_steps": {
685                "type": "integer",
686                "description": "Optional. Maximum number of steps for this task."
687            }
688        },
689        "required": ["agent", "description", "prompt"],
690        "examples": [
691            {
692                "agent": "explore",
693                "description": "Find Rust files",
694                "prompt": "Search the workspace for Rust files and summarize the layout."
695            },
696            {
697                "agent": "general",
698                "description": "Investigate test failure",
699                "prompt": "Inspect the failing tests and explain the root cause.",
700                "max_steps": 6
701            }
702        ]
703    })
704}
705
706/// TaskTool wraps TaskExecutor as a Tool for registration in ToolExecutor.
707/// This allows the LLM to delegate tasks through the standard tool interface.
708pub struct TaskTool {
709    executor: Arc<TaskExecutor>,
710}
711
712impl TaskTool {
713    /// Create a new TaskTool
714    pub fn new(executor: Arc<TaskExecutor>) -> Self {
715        Self { executor }
716    }
717}
718
719#[async_trait]
720impl Tool for TaskTool {
721    fn name(&self) -> &str {
722        "task"
723    }
724
725    fn description(&self) -> &str {
726        "Delegate a bounded task to a specialized child run. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
727    }
728
729    fn parameters(&self) -> serde_json::Value {
730        task_params_schema()
731    }
732
733    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
734        let params: TaskParams =
735            serde_json::from_value(args.clone()).context("Invalid task parameters")?;
736
737        if params.background {
738            let task_id = Arc::clone(&self.executor).execute_background(
739                params,
740                ctx.agent_event_tx.clone(),
741                ctx.session_id.clone(),
742            );
743            return Ok(ToolOutput::success(format!(
744                "Task started in background. Task ID: {}",
745                task_id
746            )));
747        }
748
749        let result = self
750            .executor
751            .execute(
752                params,
753                ctx.agent_event_tx.clone(),
754                ctx.session_id.as_deref(),
755            )
756            .await?;
757        let (content, truncated) = format_task_result_for_context(&result);
758        let metadata = serde_json::json!({
759            "task_id": result.task_id,
760            "session_id": result.session_id,
761            "agent": result.agent,
762            "success": result.success,
763            "output_bytes": result.output.len(),
764            "truncated_for_context": truncated,
765            "artifact_id": task_artifact_id(&result),
766            "artifact_uri": task_artifact_uri(&result),
767        });
768
769        if result.success {
770            Ok(ToolOutput::success(content).with_metadata(metadata))
771        } else {
772            Ok(ToolOutput::error(content).with_metadata(metadata))
773        }
774    }
775}
776
777/// Parameters for parallel task execution
778#[derive(Debug, Clone, Serialize, Deserialize)]
779#[serde(deny_unknown_fields)]
780pub struct ParallelTaskParams {
781    /// List of tasks to execute concurrently
782    pub tasks: Vec<TaskParams>,
783}
784
785/// Get the JSON schema for ParallelTaskParams
786pub fn parallel_task_params_schema() -> serde_json::Value {
787    serde_json::json!({
788        "type": "object",
789        "additionalProperties": false,
790        "properties": {
791            "tasks": {
792                "type": "array",
793                "description": "List of tasks to execute in parallel. Each task runs as an independent delegated child run concurrently.",
794                "items": {
795                    "type": "object",
796                    "additionalProperties": false,
797                    "properties": {
798                        "agent": {
799                            "type": "string",
800                            "description": "Required. Canonical agent type for this task."
801                        },
802                        "description": {
803                            "type": "string",
804                            "description": "Required. Short task label for display and tracking."
805                        },
806                        "prompt": {
807                            "type": "string",
808                            "description": "Required. Detailed instruction for the delegated child run."
809                        }
810                    },
811                    "required": ["agent", "description", "prompt"]
812                },
813                "minItems": 1
814            }
815        },
816        "required": ["tasks"],
817        "examples": [
818            {
819                "tasks": [
820                    {
821                        "agent": "explore",
822                        "description": "Find Rust files",
823                        "prompt": "List Rust files under src/."
824                    },
825                    {
826                        "agent": "explore",
827                        "description": "Find tests",
828                        "prompt": "List test files and summarize their purpose."
829                    }
830                ]
831            }
832        ]
833    })
834}
835
836/// ParallelTaskTool allows the LLM to fan out multiple delegated tasks concurrently.
837///
838/// All tasks execute in parallel and the tool returns when all complete.
839pub struct ParallelTaskTool {
840    executor: Arc<TaskExecutor>,
841}
842
843impl ParallelTaskTool {
844    /// Create a new ParallelTaskTool
845    pub fn new(executor: Arc<TaskExecutor>) -> Self {
846        Self { executor }
847    }
848}
849
850#[async_trait]
851impl Tool for ParallelTaskTool {
852    fn name(&self) -> &str {
853        "parallel_task"
854    }
855
856    fn description(&self) -> &str {
857        "Fan out 2 or more INDEPENDENT subtasks as delegated child runs that execute concurrently; results are returned when all complete. Use this only when the work genuinely splits into branches that can be investigated or implemented separately (e.g. inspect several unrelated modules at once, or run review and verification in parallel). Do NOT use it for trivial, conversational, or single-step requests, or for steps that depend on one another — handle those directly. Built-in agents: explore (read-only codebase and web evidence search), general/general-purpose (full access multi-step), plan (read-only planning), verification (adversarial validation), review (code review). Custom agents from agent_dirs and .a3s/agents are also available; .claude/agents is read for compatibility."
858    }
859
860    fn parameters(&self) -> serde_json::Value {
861        parallel_task_params_schema()
862    }
863
864    async fn execute(&self, args: &serde_json::Value, ctx: &ToolContext) -> Result<ToolOutput> {
865        let params: ParallelTaskParams =
866            serde_json::from_value(args.clone()).context("Invalid parallel task parameters")?;
867
868        if params.tasks.is_empty() {
869            return Ok(ToolOutput::error("No tasks provided".to_string()));
870        }
871
872        let task_count = params.tasks.len();
873
874        let results = self
875            .executor
876            .execute_parallel(
877                params.tasks,
878                ctx.agent_event_tx.clone(),
879                ctx.session_id.as_deref(),
880            )
881            .await;
882
883        // Format results with compact per-task excerpts for parent context.
884        let mut output = format!("Executed {} tasks in parallel:\n\n", task_count);
885        let mut metadata_results = Vec::new();
886        for (i, result) in results.iter().enumerate() {
887            let status = if result.success { "[OK]" } else { "[ERR]" };
888            let (formatted, truncated) = format_task_result_for_context(result);
889            metadata_results.push(serde_json::json!({
890                "task_id": result.task_id,
891                "session_id": result.session_id,
892                "agent": result.agent,
893                "success": result.success,
894                "output": formatted.clone(),
895                "output_bytes": result.output.len(),
896                "truncated_for_context": truncated,
897                "artifact_id": task_artifact_id(result),
898                "artifact_uri": task_artifact_uri(result),
899            }));
900            output.push_str(&format!(
901                "--- Task {} ({}) {} ---\n{}\n\n",
902                i + 1,
903                result.agent,
904                status,
905                formatted
906            ));
907        }
908
909        let all_success = results.iter().all(|result| result.success);
910        let output = if all_success {
911            ToolOutput::success(output)
912        } else {
913            ToolOutput::error(output)
914        };
915
916        Ok(output.with_metadata(serde_json::json!({
917            "task_count": task_count,
918            "results": metadata_results,
919        })))
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    #[test]
928    fn test_task_params_deserialize() {
929        let json = r#"{
930            "agent": "explore",
931            "description": "Find auth code",
932            "prompt": "Search for authentication files"
933        }"#;
934
935        let params: TaskParams = serde_json::from_str(json).unwrap();
936        assert_eq!(params.agent, "explore");
937        assert_eq!(params.description, "Find auth code");
938        assert!(!params.background);
939    }
940
941    #[test]
942    fn test_task_params_with_background() {
943        let json = r#"{
944            "agent": "general",
945            "description": "Long task",
946            "prompt": "Do something complex",
947            "background": true
948        }"#;
949
950        let params: TaskParams = serde_json::from_str(json).unwrap();
951        assert!(params.background);
952    }
953
954    #[test]
955    fn test_task_params_with_max_steps() {
956        let json = r#"{
957            "agent": "plan",
958            "description": "Planning task",
959            "prompt": "Create a plan",
960            "max_steps": 10
961        }"#;
962
963        let params: TaskParams = serde_json::from_str(json).unwrap();
964        assert_eq!(params.agent, "plan");
965        assert_eq!(params.max_steps, Some(10));
966        assert!(!params.background);
967    }
968
969    #[test]
970    fn test_task_params_all_fields() {
971        let json = r#"{
972            "agent": "general",
973            "description": "Complex task",
974            "prompt": "Do everything",
975            "background": true,
976            "max_steps": 20
977        }"#;
978
979        let params: TaskParams = serde_json::from_str(json).unwrap();
980        assert_eq!(params.agent, "general");
981        assert_eq!(params.description, "Complex task");
982        assert_eq!(params.prompt, "Do everything");
983        assert!(params.background);
984        assert_eq!(params.max_steps, Some(20));
985    }
986
987    #[test]
988    fn test_task_params_missing_required_field() {
989        let json = r#"{
990            "agent": "explore",
991            "description": "Missing prompt"
992        }"#;
993
994        let result: Result<TaskParams, _> = serde_json::from_str(json);
995        assert!(result.is_err());
996    }
997
998    #[test]
999    fn test_task_params_serialize() {
1000        let params = TaskParams {
1001            agent: "explore".to_string(),
1002            description: "Test task".to_string(),
1003            prompt: "Test prompt".to_string(),
1004            background: false,
1005            max_steps: Some(5),
1006        };
1007
1008        let json = serde_json::to_string(&params).unwrap();
1009        assert!(json.contains("explore"));
1010        assert!(json.contains("Test task"));
1011        assert!(json.contains("Test prompt"));
1012    }
1013
1014    #[test]
1015    fn test_task_params_clone() {
1016        let params = TaskParams {
1017            agent: "explore".to_string(),
1018            description: "Test".to_string(),
1019            prompt: "Prompt".to_string(),
1020            background: true,
1021            max_steps: None,
1022        };
1023
1024        let cloned = params.clone();
1025        assert_eq!(params.agent, cloned.agent);
1026        assert_eq!(params.description, cloned.description);
1027        assert_eq!(params.background, cloned.background);
1028    }
1029
1030    #[test]
1031    fn test_task_result_serialize() {
1032        let result = TaskResult {
1033            output: "Found 5 files".to_string(),
1034            session_id: "session-123".to_string(),
1035            agent: "explore".to_string(),
1036            success: true,
1037            task_id: "task-456".to_string(),
1038        };
1039
1040        let json = serde_json::to_string(&result).unwrap();
1041        assert!(json.contains("Found 5 files"));
1042        assert!(json.contains("explore"));
1043    }
1044
1045    #[test]
1046    fn test_task_result_deserialize() {
1047        let json = r#"{
1048            "output": "Task completed",
1049            "session_id": "sess-789",
1050            "agent": "general",
1051            "success": false,
1052            "task_id": "task-123"
1053        }"#;
1054
1055        let result: TaskResult = serde_json::from_str(json).unwrap();
1056        assert_eq!(result.output, "Task completed");
1057        assert_eq!(result.session_id, "sess-789");
1058        assert_eq!(result.agent, "general");
1059        assert!(!result.success);
1060        assert_eq!(result.task_id, "task-123");
1061    }
1062
1063    #[test]
1064    fn test_task_result_clone() {
1065        let result = TaskResult {
1066            output: "Output".to_string(),
1067            session_id: "session-1".to_string(),
1068            agent: "explore".to_string(),
1069            success: true,
1070            task_id: "task-1".to_string(),
1071        };
1072
1073        let cloned = result.clone();
1074        assert_eq!(result.output, cloned.output);
1075        assert_eq!(result.success, cloned.success);
1076    }
1077
1078    #[test]
1079    fn test_compact_task_output_preserves_small_output() {
1080        let (output, truncated) = compact_task_output("short result");
1081        assert_eq!(output, "short result");
1082        assert!(!truncated);
1083    }
1084
1085    #[test]
1086    fn test_format_task_result_for_context_truncates_large_output() {
1087        let result = TaskResult {
1088            output: format!("{}TAIL", "x".repeat(TASK_OUTPUT_CONTEXT_LIMIT + 500)),
1089            session_id: "session-1".to_string(),
1090            agent: "explore".to_string(),
1091            success: true,
1092            task_id: "task-1".to_string(),
1093        };
1094
1095        let (formatted, truncated) = format_task_result_for_context(&result);
1096        assert!(truncated);
1097        assert!(formatted.contains("Output excerpt"));
1098        assert!(formatted.contains("bytes omitted"));
1099        assert!(formatted.contains("Artifact ID: task-output:task-1"));
1100        assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
1101        assert!(formatted.contains("TAIL"));
1102        assert!(formatted.len() < result.output.len());
1103    }
1104
1105    #[test]
1106    fn test_task_artifact_reference_is_stable() {
1107        let result = TaskResult {
1108            output: "done".to_string(),
1109            session_id: "session-1".to_string(),
1110            agent: "explore".to_string(),
1111            success: true,
1112            task_id: "task-1".to_string(),
1113        };
1114
1115        assert_eq!(task_artifact_id(&result), "task-output:task-1");
1116        assert_eq!(
1117            task_artifact_uri(&result),
1118            "a3s://tasks/session-1/runs/task-1/output"
1119        );
1120
1121        let (formatted, truncated) = format_task_result_for_context(&result);
1122        assert!(!truncated);
1123        assert!(formatted.contains("Artifact URI: a3s://tasks/session-1/runs/task-1/output"));
1124    }
1125
1126    #[test]
1127    fn test_task_params_schema() {
1128        let schema = task_params_schema();
1129        assert_eq!(schema["type"], "object");
1130        assert_eq!(schema["additionalProperties"], false);
1131        assert!(schema["properties"]["agent"].is_object());
1132        assert!(schema["properties"]["prompt"].is_object());
1133    }
1134
1135    #[test]
1136    fn test_task_params_schema_required_fields() {
1137        let schema = task_params_schema();
1138        let required = schema["required"].as_array().unwrap();
1139        assert!(required.contains(&serde_json::json!("agent")));
1140        assert!(required.contains(&serde_json::json!("description")));
1141        assert!(required.contains(&serde_json::json!("prompt")));
1142    }
1143
1144    #[test]
1145    fn test_task_params_schema_properties() {
1146        let schema = task_params_schema();
1147        let props = &schema["properties"];
1148
1149        assert_eq!(props["agent"]["type"], "string");
1150        assert_eq!(props["description"]["type"], "string");
1151        assert_eq!(props["prompt"]["type"], "string");
1152        assert_eq!(props["background"]["type"], "boolean");
1153        assert_eq!(props["background"]["default"], false);
1154        assert_eq!(props["max_steps"]["type"], "integer");
1155    }
1156
1157    #[test]
1158    fn test_task_params_schema_descriptions() {
1159        let schema = task_params_schema();
1160        let props = &schema["properties"];
1161
1162        assert!(props["agent"]["description"].is_string());
1163        assert!(props["description"]["description"].is_string());
1164        assert!(props["prompt"]["description"].is_string());
1165        assert!(props["background"]["description"].is_string());
1166        assert!(props["max_steps"]["description"].is_string());
1167    }
1168
1169    #[test]
1170    fn test_task_params_default_background() {
1171        let params = TaskParams {
1172            agent: "explore".to_string(),
1173            description: "Test".to_string(),
1174            prompt: "Test prompt".to_string(),
1175            background: false,
1176            max_steps: None,
1177        };
1178        assert!(!params.background);
1179    }
1180
1181    #[test]
1182    fn test_task_params_serialize_skip_none() {
1183        let params = TaskParams {
1184            agent: "explore".to_string(),
1185            description: "Test".to_string(),
1186            prompt: "Test prompt".to_string(),
1187            background: false,
1188            max_steps: None,
1189        };
1190        let json = serde_json::to_string(&params).unwrap();
1191        // max_steps should not appear when None
1192        assert!(!json.contains("max_steps"));
1193    }
1194
1195    #[test]
1196    fn test_task_params_serialize_with_max_steps() {
1197        let params = TaskParams {
1198            agent: "explore".to_string(),
1199            description: "Test".to_string(),
1200            prompt: "Test prompt".to_string(),
1201            background: false,
1202            max_steps: Some(15),
1203        };
1204        let json = serde_json::to_string(&params).unwrap();
1205        assert!(json.contains("max_steps"));
1206        assert!(json.contains("15"));
1207    }
1208
1209    #[test]
1210    fn test_task_result_success_true() {
1211        let result = TaskResult {
1212            output: "Success".to_string(),
1213            session_id: "sess-1".to_string(),
1214            agent: "explore".to_string(),
1215            success: true,
1216            task_id: "task-1".to_string(),
1217        };
1218        assert!(result.success);
1219    }
1220
1221    #[test]
1222    fn test_task_result_success_false() {
1223        let result = TaskResult {
1224            output: "Failed".to_string(),
1225            session_id: "sess-1".to_string(),
1226            agent: "explore".to_string(),
1227            success: false,
1228            task_id: "task-1".to_string(),
1229        };
1230        assert!(!result.success);
1231    }
1232
1233    #[test]
1234    fn test_task_params_empty_strings() {
1235        let params = TaskParams {
1236            agent: "".to_string(),
1237            description: "".to_string(),
1238            prompt: "".to_string(),
1239            background: false,
1240            max_steps: None,
1241        };
1242        let json = serde_json::to_string(&params).unwrap();
1243        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1244        assert_eq!(deserialized.agent, "");
1245        assert_eq!(deserialized.description, "");
1246        assert_eq!(deserialized.prompt, "");
1247    }
1248
1249    #[test]
1250    fn test_task_result_empty_output() {
1251        let result = TaskResult {
1252            output: "".to_string(),
1253            session_id: "sess-1".to_string(),
1254            agent: "explore".to_string(),
1255            success: true,
1256            task_id: "task-1".to_string(),
1257        };
1258        assert_eq!(result.output, "");
1259    }
1260
1261    #[test]
1262    fn test_task_params_debug_format() {
1263        let params = TaskParams {
1264            agent: "explore".to_string(),
1265            description: "Test".to_string(),
1266            prompt: "Test prompt".to_string(),
1267            background: false,
1268            max_steps: None,
1269        };
1270        let debug_str = format!("{:?}", params);
1271        assert!(debug_str.contains("explore"));
1272        assert!(debug_str.contains("Test"));
1273    }
1274
1275    #[test]
1276    fn test_task_result_debug_format() {
1277        let result = TaskResult {
1278            output: "Output".to_string(),
1279            session_id: "sess-1".to_string(),
1280            agent: "explore".to_string(),
1281            success: true,
1282            task_id: "task-1".to_string(),
1283        };
1284        let debug_str = format!("{:?}", result);
1285        assert!(debug_str.contains("Output"));
1286        assert!(debug_str.contains("explore"));
1287    }
1288
1289    #[test]
1290    fn test_task_params_roundtrip() {
1291        let original = TaskParams {
1292            agent: "general".to_string(),
1293            description: "Roundtrip test".to_string(),
1294            prompt: "Test roundtrip serialization".to_string(),
1295            background: true,
1296            max_steps: Some(42),
1297        };
1298        let json = serde_json::to_string(&original).unwrap();
1299        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1300        assert_eq!(original.agent, deserialized.agent);
1301        assert_eq!(original.description, deserialized.description);
1302        assert_eq!(original.prompt, deserialized.prompt);
1303        assert_eq!(original.background, deserialized.background);
1304        assert_eq!(original.max_steps, deserialized.max_steps);
1305    }
1306
1307    #[test]
1308    fn test_task_result_roundtrip() {
1309        let original = TaskResult {
1310            output: "Roundtrip output".to_string(),
1311            session_id: "sess-roundtrip".to_string(),
1312            agent: "plan".to_string(),
1313            success: false,
1314            task_id: "task-roundtrip".to_string(),
1315        };
1316        let json = serde_json::to_string(&original).unwrap();
1317        let deserialized: TaskResult = serde_json::from_str(&json).unwrap();
1318        assert_eq!(original.output, deserialized.output);
1319        assert_eq!(original.session_id, deserialized.session_id);
1320        assert_eq!(original.agent, deserialized.agent);
1321        assert_eq!(original.success, deserialized.success);
1322        assert_eq!(original.task_id, deserialized.task_id);
1323    }
1324
1325    #[test]
1326    fn test_parallel_task_params_deserialize() {
1327        let json = r#"{
1328            "tasks": [
1329                { "agent": "explore", "description": "Find auth", "prompt": "Search auth files" },
1330                { "agent": "general", "description": "Fix bug", "prompt": "Fix the login bug" }
1331            ]
1332        }"#;
1333
1334        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1335        assert_eq!(params.tasks.len(), 2);
1336        assert_eq!(params.tasks[0].agent, "explore");
1337        assert_eq!(params.tasks[1].agent, "general");
1338    }
1339
1340    #[test]
1341    fn test_parallel_task_params_single_task() {
1342        let json = r#"{
1343            "tasks": [
1344                { "agent": "plan", "description": "Plan work", "prompt": "Create a plan" }
1345            ]
1346        }"#;
1347
1348        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1349        assert_eq!(params.tasks.len(), 1);
1350    }
1351
1352    #[test]
1353    fn test_parallel_task_params_empty_tasks() {
1354        let json = r#"{ "tasks": [] }"#;
1355        let params: ParallelTaskParams = serde_json::from_str(json).unwrap();
1356        assert!(params.tasks.is_empty());
1357    }
1358
1359    #[test]
1360    fn test_parallel_task_params_missing_tasks() {
1361        let json = r#"{}"#;
1362        let result: Result<ParallelTaskParams, _> = serde_json::from_str(json);
1363        assert!(result.is_err());
1364    }
1365
1366    #[test]
1367    fn test_parallel_task_params_serialize() {
1368        let params = ParallelTaskParams {
1369            tasks: vec![
1370                TaskParams {
1371                    agent: "explore".to_string(),
1372                    description: "Task 1".to_string(),
1373                    prompt: "Prompt 1".to_string(),
1374                    background: false,
1375                    max_steps: None,
1376                },
1377                TaskParams {
1378                    agent: "general".to_string(),
1379                    description: "Task 2".to_string(),
1380                    prompt: "Prompt 2".to_string(),
1381                    background: false,
1382                    max_steps: Some(10),
1383                },
1384            ],
1385        };
1386        let json = serde_json::to_string(&params).unwrap();
1387        assert!(json.contains("explore"));
1388        assert!(json.contains("general"));
1389        assert!(json.contains("Prompt 1"));
1390        assert!(json.contains("Prompt 2"));
1391    }
1392
1393    #[test]
1394    fn test_parallel_task_params_roundtrip() {
1395        let original = ParallelTaskParams {
1396            tasks: vec![
1397                TaskParams {
1398                    agent: "explore".to_string(),
1399                    description: "Explore".to_string(),
1400                    prompt: "Find files".to_string(),
1401                    background: false,
1402                    max_steps: None,
1403                },
1404                TaskParams {
1405                    agent: "plan".to_string(),
1406                    description: "Plan".to_string(),
1407                    prompt: "Make plan".to_string(),
1408                    background: false,
1409                    max_steps: Some(5),
1410                },
1411            ],
1412        };
1413        let json = serde_json::to_string(&original).unwrap();
1414        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1415        assert_eq!(original.tasks.len(), deserialized.tasks.len());
1416        assert_eq!(original.tasks[0].agent, deserialized.tasks[0].agent);
1417        assert_eq!(original.tasks[1].agent, deserialized.tasks[1].agent);
1418        assert_eq!(original.tasks[1].max_steps, deserialized.tasks[1].max_steps);
1419    }
1420
1421    #[test]
1422    fn test_parallel_task_params_clone() {
1423        let params = ParallelTaskParams {
1424            tasks: vec![TaskParams {
1425                agent: "explore".to_string(),
1426                description: "Test".to_string(),
1427                prompt: "Prompt".to_string(),
1428                background: false,
1429                max_steps: None,
1430            }],
1431        };
1432        let cloned = params.clone();
1433        assert_eq!(params.tasks.len(), cloned.tasks.len());
1434        assert_eq!(params.tasks[0].agent, cloned.tasks[0].agent);
1435    }
1436
1437    #[test]
1438    fn test_parallel_task_params_schema() {
1439        let schema = parallel_task_params_schema();
1440        assert_eq!(schema["type"], "object");
1441        assert_eq!(schema["additionalProperties"], false);
1442        assert!(schema["properties"]["tasks"].is_object());
1443        assert_eq!(schema["properties"]["tasks"]["type"], "array");
1444        assert_eq!(schema["properties"]["tasks"]["minItems"], 1);
1445    }
1446
1447    #[test]
1448    fn test_parallel_task_params_schema_required() {
1449        let schema = parallel_task_params_schema();
1450        let required = schema["required"].as_array().unwrap();
1451        assert!(required.contains(&serde_json::json!("tasks")));
1452    }
1453
1454    #[test]
1455    fn test_parallel_task_params_schema_items() {
1456        let schema = parallel_task_params_schema();
1457        let items = &schema["properties"]["tasks"]["items"];
1458        assert_eq!(items["type"], "object");
1459        assert_eq!(items["additionalProperties"], false);
1460        let item_required = items["required"].as_array().unwrap();
1461        assert!(item_required.contains(&serde_json::json!("agent")));
1462        assert!(item_required.contains(&serde_json::json!("description")));
1463        assert!(item_required.contains(&serde_json::json!("prompt")));
1464    }
1465
1466    #[test]
1467    fn test_task_schema_examples_use_delegation_core() {
1468        let task = task_params_schema();
1469        let task_examples = task["examples"].as_array().unwrap();
1470        assert_eq!(task_examples[0]["agent"], "explore");
1471        assert!(task_examples[0].get("task").is_none());
1472
1473        let parallel = parallel_task_params_schema();
1474        let parallel_examples = parallel["examples"].as_array().unwrap();
1475        assert!(!parallel_examples[0]["tasks"].as_array().unwrap().is_empty());
1476    }
1477
1478    #[test]
1479    fn test_parallel_task_params_debug() {
1480        let params = ParallelTaskParams {
1481            tasks: vec![TaskParams {
1482                agent: "explore".to_string(),
1483                description: "Debug test".to_string(),
1484                prompt: "Test".to_string(),
1485                background: false,
1486                max_steps: None,
1487            }],
1488        };
1489        let debug_str = format!("{:?}", params);
1490        assert!(debug_str.contains("explore"));
1491        assert!(debug_str.contains("Debug test"));
1492    }
1493
1494    #[test]
1495    fn test_parallel_task_params_large_count() {
1496        // Validate that ParallelTaskParams can hold 150 tasks without truncation
1497        let tasks: Vec<TaskParams> = (0..150)
1498            .map(|i| TaskParams {
1499                agent: "explore".to_string(),
1500                description: format!("Task {}", i),
1501                prompt: format!("Prompt for task {}", i),
1502                background: false,
1503                max_steps: Some(10),
1504            })
1505            .collect();
1506
1507        let params = ParallelTaskParams { tasks };
1508        let json = serde_json::to_string(&params).unwrap();
1509        let deserialized: ParallelTaskParams = serde_json::from_str(&json).unwrap();
1510        assert_eq!(deserialized.tasks.len(), 150);
1511        assert_eq!(deserialized.tasks[0].description, "Task 0");
1512        assert_eq!(deserialized.tasks[149].description, "Task 149");
1513    }
1514
1515    #[test]
1516    fn test_task_params_max_steps_zero() {
1517        // max_steps = 0 is a valid edge case (callers decide enforcement)
1518        let params = TaskParams {
1519            agent: "explore".to_string(),
1520            description: "Edge case".to_string(),
1521            prompt: "Zero steps".to_string(),
1522            background: false,
1523            max_steps: Some(0),
1524        };
1525        let json = serde_json::to_string(&params).unwrap();
1526        let deserialized: TaskParams = serde_json::from_str(&json).unwrap();
1527        assert_eq!(deserialized.max_steps, Some(0));
1528    }
1529
1530    #[test]
1531    fn test_parallel_task_params_all_background() {
1532        let tasks: Vec<TaskParams> = (0..5)
1533            .map(|i| TaskParams {
1534                agent: "general".to_string(),
1535                description: format!("BG task {}", i),
1536                prompt: "Run in background".to_string(),
1537                background: true,
1538                max_steps: None,
1539            })
1540            .collect();
1541        let params = ParallelTaskParams { tasks };
1542        for task in &params.tasks {
1543            assert!(task.background);
1544        }
1545    }
1546
1547    #[test]
1548    fn test_task_params_rejects_permissive_field() {
1549        let json = r#"{
1550            "agent": "general",
1551            "description": "Legacy field rejection",
1552            "prompt": "Verify legacy fields are rejected",
1553            "permissive": true
1554        }"#;
1555
1556        let result: Result<TaskParams, _> = serde_json::from_str(json);
1557        assert!(result.is_err());
1558    }
1559
1560    #[test]
1561    fn test_task_params_schema_hides_permissive_field() {
1562        let schema = task_params_schema();
1563        let props = &schema["properties"];
1564
1565        assert!(props.get("permissive").is_none());
1566    }
1567
1568    // ========================================================================
1569    // Contract tests — verify task delegation with MockLlmClient (no network)
1570    // ========================================================================
1571
1572    use crate::agent::tests::MockLlmClient;
1573    use crate::llm::{ContentBlock, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition};
1574    use crate::permissions::PermissionPolicy;
1575    use crate::subagent::AgentRegistry;
1576    use std::sync::atomic::{AtomicUsize, Ordering};
1577    use std::time::Duration;
1578    use tokio::sync::{mpsc, Barrier};
1579
1580    fn text_response(text: impl Into<String>) -> LlmResponse {
1581        LlmResponse {
1582            message: Message {
1583                role: "assistant".to_string(),
1584                content: vec![ContentBlock::Text { text: text.into() }],
1585                reasoning_content: None,
1586            },
1587            usage: TokenUsage {
1588                prompt_tokens: 10,
1589                completion_tokens: 5,
1590                total_tokens: 15,
1591                cache_read_tokens: None,
1592                cache_write_tokens: None,
1593            },
1594            stop_reason: Some("end_turn".to_string()),
1595            token_logprobs: Vec::new(),
1596            meta: None,
1597        }
1598    }
1599
1600    fn pre_analysis_response(messages: &[Message]) -> LlmResponse {
1601        let prompt = last_text(messages);
1602        let response = serde_json::json!({
1603            "intent": "GeneralPurpose",
1604            "requires_planning": false,
1605            "goal": {
1606                "description": prompt,
1607                "success_criteria": []
1608            },
1609            "execution_plan": {
1610                "complexity": "Simple",
1611                "steps": [{
1612                    "id": "step-1",
1613                    "description": prompt,
1614                    "tool": null,
1615                    "dependencies": [],
1616                    "success_criteria": "Complete the request"
1617                }],
1618                "required_tools": []
1619            },
1620            "optimized_input": prompt
1621        });
1622        text_response(response.to_string())
1623    }
1624
1625    fn last_text(messages: &[Message]) -> String {
1626        messages
1627            .last()
1628            .and_then(|message| {
1629                message.content.iter().find_map(|block| {
1630                    if let ContentBlock::Text { text } = block {
1631                        Some(text.clone())
1632                    } else {
1633                        None
1634                    }
1635                })
1636            })
1637            .unwrap_or_default()
1638    }
1639
1640    /// Client for the schema-coercion tests. The agent's own turn returns
1641    /// plain text (which ends the loop); the structured-output coercion call
1642    /// — recognizable by the injected `step_output` tool — returns a tool call
1643    /// carrying the object.
1644    struct SchemaCoercionClient;
1645
1646    #[async_trait::async_trait]
1647    impl LlmClient for SchemaCoercionClient {
1648        async fn complete(
1649            &self,
1650            messages: &[Message],
1651            system: Option<&str>,
1652            tools: &[ToolDefinition],
1653        ) -> Result<LlmResponse> {
1654            if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1655                return Ok(pre_analysis_response(messages));
1656            }
1657            // The structured-output coercion injects a synthetic tool named
1658            // `emit_<schema_name>` (here `emit_step_output`).
1659            if tools.iter().any(|t| t.name == "emit_step_output") {
1660                return Ok(MockLlmClient::tool_call_response(
1661                    "coerce-1",
1662                    "emit_step_output",
1663                    serde_json::json!({ "verdict": "ok" }),
1664                ));
1665            }
1666            Ok(text_response("The verdict is ok."))
1667        }
1668
1669        async fn complete_streaming(
1670            &self,
1671            _messages: &[Message],
1672            _system: Option<&str>,
1673            _tools: &[ToolDefinition],
1674            _cancel_token: tokio_util::sync::CancellationToken,
1675        ) -> Result<mpsc::Receiver<StreamEvent>> {
1676            anyhow::bail!("streaming is not used by schema coercion tests")
1677        }
1678    }
1679
1680    fn verdict_schema() -> serde_json::Value {
1681        serde_json::json!({
1682            "type": "object",
1683            "properties": { "verdict": { "type": "string" } },
1684            "required": ["verdict"]
1685        })
1686    }
1687
1688    #[tokio::test]
1689    async fn execute_step_with_schema_coerces_structured_output() {
1690        let workspace = tempfile::tempdir().unwrap();
1691        let executor = TaskExecutor::new(
1692            Arc::new(AgentRegistry::new()),
1693            Arc::new(SchemaCoercionClient),
1694            workspace.path().to_string_lossy().to_string(),
1695        );
1696        let spec = AgentStepSpec::new("step-1", "general", "assess", "Assess the thing.")
1697            .with_output_schema(verdict_schema());
1698
1699        let outcome = executor.execute_step(spec, None).await;
1700
1701        assert!(outcome.success, "step should succeed: {}", outcome.output);
1702        assert_eq!(
1703            outcome.structured,
1704            Some(serde_json::json!({ "verdict": "ok" })),
1705            "a schema'd step returns the validated object in `structured`"
1706        );
1707    }
1708
1709    #[tokio::test]
1710    async fn execute_step_without_schema_has_no_structured_output() {
1711        let workspace = tempfile::tempdir().unwrap();
1712        let executor = TaskExecutor::new(
1713            Arc::new(AgentRegistry::new()),
1714            Arc::new(SchemaCoercionClient),
1715            workspace.path().to_string_lossy().to_string(),
1716        );
1717        let spec = AgentStepSpec::new("step-2", "general", "assess", "Assess the thing.");
1718
1719        let outcome = executor.execute_step(spec, None).await;
1720
1721        assert!(outcome.success, "step should succeed: {}", outcome.output);
1722        assert_eq!(
1723            outcome.structured, None,
1724            "no schema requested → no structured output, no coercion call"
1725        );
1726    }
1727
1728    /// The agent's turn returns text; the coercion call (`emit_step_output`)
1729    /// always returns an object that VIOLATES the schema, so `generate_blocking`
1730    /// exhausts its repairs and bails.
1731    struct SchemaFailClient;
1732
1733    #[async_trait::async_trait]
1734    impl LlmClient for SchemaFailClient {
1735        async fn complete(
1736            &self,
1737            messages: &[Message],
1738            system: Option<&str>,
1739            tools: &[ToolDefinition],
1740        ) -> Result<LlmResponse> {
1741            if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1742                return Ok(pre_analysis_response(messages));
1743            }
1744            if tools.iter().any(|t| t.name == "emit_step_output") {
1745                // `{}` is missing the required `verdict` field → schema invalid.
1746                return Ok(MockLlmClient::tool_call_response(
1747                    "coerce-fail",
1748                    "emit_step_output",
1749                    serde_json::json!({}),
1750                ));
1751            }
1752            Ok(text_response("some answer"))
1753        }
1754
1755        async fn complete_streaming(
1756            &self,
1757            _messages: &[Message],
1758            _system: Option<&str>,
1759            _tools: &[ToolDefinition],
1760            _cancel_token: tokio_util::sync::CancellationToken,
1761        ) -> Result<mpsc::Receiver<StreamEvent>> {
1762            anyhow::bail!("streaming unused")
1763        }
1764    }
1765
1766    #[tokio::test]
1767    async fn execute_step_with_schema_demotes_step_on_coercion_failure() {
1768        let workspace = tempfile::tempdir().unwrap();
1769        let executor = TaskExecutor::new(
1770            Arc::new(AgentRegistry::new()),
1771            Arc::new(SchemaFailClient),
1772            workspace.path().to_string_lossy().to_string(),
1773        );
1774        let spec = AgentStepSpec::new("step-x", "general", "assess", "Assess the thing.")
1775            .with_output_schema(verdict_schema());
1776
1777        let outcome = executor.execute_step(spec, None).await;
1778
1779        assert!(
1780            !outcome.success,
1781            "a step whose output can't satisfy the schema is demoted to failure"
1782        );
1783        assert_eq!(outcome.structured, None, "no validated object on failure");
1784        assert!(
1785            outcome.output.contains("[structured output failed"),
1786            "the demotion marker is appended: {}",
1787            outcome.output
1788        );
1789    }
1790
1791    #[tokio::test]
1792    async fn parallel_isolates_schema_coercion_failure_from_sibling() {
1793        let workspace = tempfile::tempdir().unwrap();
1794        let executor: Arc<dyn AgentExecutor> = Arc::new(TaskExecutor::new(
1795            Arc::new(AgentRegistry::new()),
1796            Arc::new(SchemaFailClient),
1797            workspace.path().to_string_lossy().to_string(),
1798        ));
1799        // A plain step (no schema → succeeds) alongside a schema'd step whose
1800        // coercion fails. The failure must not drop or fail the sibling.
1801        let specs = vec![
1802            AgentStepSpec::new("plain", "general", "d", "p"),
1803            AgentStepSpec::new("schemad", "general", "d", "p").with_output_schema(verdict_schema()),
1804        ];
1805        let out = crate::orchestration::execute_steps_parallel(executor, specs, None).await;
1806
1807        assert_eq!(out.len(), 2);
1808        assert_eq!(out[0].task_id, "plain");
1809        assert!(out[0].success, "no-schema sibling unaffected");
1810        assert_eq!(out[0].structured, None);
1811        assert_eq!(out[1].task_id, "schemad");
1812        assert!(!out[1].success, "schema-failing step surfaces as failure");
1813        assert_eq!(out[1].structured, None);
1814        assert!(out[1].output.contains("[structured output failed"));
1815    }
1816
1817    #[tokio::test]
1818    async fn failed_step_with_schema_skips_coercion() {
1819        let workspace = tempfile::tempdir().unwrap();
1820        let executor = TaskExecutor::new(
1821            Arc::new(AgentRegistry::new()),
1822            Arc::new(SchemaCoercionClient),
1823            workspace.path().to_string_lossy().to_string(),
1824        );
1825        // Unknown agent → the run fails BEFORE coercion. The failure is the
1826        // run error, not a coercion failure — coercion must not run.
1827        let spec = AgentStepSpec::new("step-y", "no-such-agent", "d", "p")
1828            .with_output_schema(verdict_schema());
1829
1830        let outcome = executor.execute_step(spec, None).await;
1831
1832        assert!(!outcome.success);
1833        assert_eq!(outcome.structured, None);
1834        assert!(
1835            !outcome.output.contains("[structured output failed"),
1836            "coercion never ran — failure is the run error, not a coercion failure: {}",
1837            outcome.output
1838        );
1839    }
1840
1841    struct StaticLlmClient {
1842        text: String,
1843    }
1844
1845    impl StaticLlmClient {
1846        fn new(text: impl Into<String>) -> Self {
1847            Self { text: text.into() }
1848        }
1849    }
1850
1851    #[async_trait::async_trait]
1852    impl LlmClient for StaticLlmClient {
1853        async fn complete(
1854            &self,
1855            messages: &[Message],
1856            system: Option<&str>,
1857            _tools: &[ToolDefinition],
1858        ) -> Result<LlmResponse> {
1859            if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1860                return Ok(pre_analysis_response(messages));
1861            }
1862            Ok(text_response(self.text.clone()))
1863        }
1864
1865        async fn complete_streaming(
1866            &self,
1867            _messages: &[Message],
1868            _system: Option<&str>,
1869            _tools: &[ToolDefinition],
1870            _cancel_token: tokio_util::sync::CancellationToken,
1871        ) -> Result<mpsc::Receiver<StreamEvent>> {
1872            anyhow::bail!("streaming is not used by task executor tests")
1873        }
1874    }
1875
1876    struct ConcurrentLlmClient {
1877        barrier: Arc<Barrier>,
1878        active: AtomicUsize,
1879        max_active: AtomicUsize,
1880    }
1881
1882    impl ConcurrentLlmClient {
1883        fn new(task_count: usize) -> Self {
1884            Self {
1885                barrier: Arc::new(Barrier::new(task_count)),
1886                active: AtomicUsize::new(0),
1887                max_active: AtomicUsize::new(0),
1888            }
1889        }
1890
1891        fn max_active(&self) -> usize {
1892            self.max_active.load(Ordering::SeqCst)
1893        }
1894
1895        fn record_active(&self) {
1896            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1897            let mut observed = self.max_active.load(Ordering::SeqCst);
1898            while active > observed {
1899                match self.max_active.compare_exchange(
1900                    observed,
1901                    active,
1902                    Ordering::SeqCst,
1903                    Ordering::SeqCst,
1904                ) {
1905                    Ok(_) => break,
1906                    Err(next) => observed = next,
1907                }
1908            }
1909        }
1910    }
1911
1912    struct LimitedConcurrencyLlmClient {
1913        active: AtomicUsize,
1914        max_active: AtomicUsize,
1915    }
1916
1917    impl LimitedConcurrencyLlmClient {
1918        fn new() -> Self {
1919            Self {
1920                active: AtomicUsize::new(0),
1921                max_active: AtomicUsize::new(0),
1922            }
1923        }
1924
1925        fn max_active(&self) -> usize {
1926            self.max_active.load(Ordering::SeqCst)
1927        }
1928
1929        fn record_active(&self) {
1930            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
1931            self.max_active.fetch_max(active, Ordering::SeqCst);
1932        }
1933    }
1934
1935    #[async_trait::async_trait]
1936    impl LlmClient for LimitedConcurrencyLlmClient {
1937        async fn complete(
1938            &self,
1939            messages: &[Message],
1940            system: Option<&str>,
1941            _tools: &[ToolDefinition],
1942        ) -> Result<LlmResponse> {
1943            if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1944                return Ok(pre_analysis_response(messages));
1945            }
1946
1947            let prompt = last_text(messages);
1948            self.record_active();
1949            tokio::time::sleep(Duration::from_millis(40)).await;
1950            self.active.fetch_sub(1, Ordering::SeqCst);
1951            Ok(text_response(format!("completed: {prompt}")))
1952        }
1953
1954        async fn complete_streaming(
1955            &self,
1956            _messages: &[Message],
1957            _system: Option<&str>,
1958            _tools: &[ToolDefinition],
1959            _cancel_token: tokio_util::sync::CancellationToken,
1960        ) -> Result<mpsc::Receiver<StreamEvent>> {
1961            anyhow::bail!("streaming is not used by task executor tests")
1962        }
1963    }
1964
1965    #[async_trait::async_trait]
1966    impl LlmClient for ConcurrentLlmClient {
1967        async fn complete(
1968            &self,
1969            messages: &[Message],
1970            system: Option<&str>,
1971            _tools: &[ToolDefinition],
1972        ) -> Result<LlmResponse> {
1973            if system == Some(crate::prompts::PRE_ANALYSIS_SYSTEM) {
1974                return Ok(pre_analysis_response(messages));
1975            }
1976
1977            let prompt = last_text(messages);
1978            self.record_active();
1979            self.barrier.wait().await;
1980            if prompt.contains("slow") {
1981                tokio::time::sleep(Duration::from_millis(120)).await;
1982            } else {
1983                tokio::time::sleep(Duration::from_millis(10)).await;
1984            }
1985            self.active.fetch_sub(1, Ordering::SeqCst);
1986            Ok(text_response(format!("completed: {prompt}")))
1987        }
1988
1989        async fn complete_streaming(
1990            &self,
1991            _messages: &[Message],
1992            _system: Option<&str>,
1993            _tools: &[ToolDefinition],
1994            _cancel_token: tokio_util::sync::CancellationToken,
1995        ) -> Result<mpsc::Receiver<StreamEvent>> {
1996            anyhow::bail!("streaming is not used by task executor tests")
1997        }
1998    }
1999
2000    fn test_registry_with_writer() -> Arc<AgentRegistry> {
2001        let registry = AgentRegistry::new();
2002        let spec = crate::subagent::WorkerAgentSpec::custom("writer", "Write files")
2003            .with_permissions(PermissionPolicy::new().allow("write(*)").allow("read(*)"))
2004            .with_prompt("Write files when asked.")
2005            .with_max_steps(3);
2006        registry.register(spec.into_agent_definition());
2007        Arc::new(registry)
2008    }
2009
2010    fn test_registry_with_text_worker() -> Arc<AgentRegistry> {
2011        let registry = AgentRegistry::new();
2012        let spec = crate::subagent::WorkerAgentSpec::custom("worker", "Text worker")
2013            .with_prompt("Return a concise result.")
2014            .with_max_steps(1);
2015        registry.register(spec.into_agent_definition());
2016        Arc::new(registry)
2017    }
2018
2019    #[tokio::test]
2020    async fn task_child_run_permission_allow() {
2021        let workspace = tempfile::tempdir().unwrap();
2022        let mock = Arc::new(MockLlmClient::new(vec![
2023            MockLlmClient::tool_call_response(
2024                "t1",
2025                "write",
2026                serde_json::json!({
2027                    "file_path": workspace.path().join("out.txt").to_string_lossy(),
2028                    "content": "WRITTEN"
2029                }),
2030            ),
2031            MockLlmClient::text_response("Done."),
2032        ]));
2033
2034        let executor = TaskExecutor::new(
2035            test_registry_with_writer(),
2036            mock,
2037            workspace.path().to_string_lossy().to_string(),
2038        );
2039
2040        let result = executor
2041            .execute(
2042                TaskParams {
2043                    agent: "writer".to_string(),
2044                    description: "Write file".to_string(),
2045                    prompt: "Write out.txt".to_string(),
2046                    background: false,
2047                    max_steps: Some(3),
2048                },
2049                None,
2050                None,
2051            )
2052            .await
2053            .unwrap();
2054
2055        assert!(
2056            result.success,
2057            "child run should succeed: {}",
2058            result.output
2059        );
2060        assert!(
2061            !result.output.contains("Permission denied"),
2062            "no permission denial: {}",
2063            result.output
2064        );
2065        let content = std::fs::read_to_string(workspace.path().join("out.txt")).unwrap();
2066        assert_eq!(content, "WRITTEN");
2067    }
2068
2069    #[tokio::test]
2070    async fn task_child_run_permission_deny() {
2071        let workspace = tempfile::tempdir().unwrap();
2072        let registry = AgentRegistry::new();
2073        let spec = crate::subagent::WorkerAgentSpec::custom("restricted", "Restricted agent")
2074            .with_permissions(PermissionPolicy::new().allow("read(*)").deny("bash(*)"))
2075            .with_max_steps(3);
2076        registry.register(spec.into_agent_definition());
2077
2078        let mock = Arc::new(MockLlmClient::new(vec![
2079            MockLlmClient::tool_call_response(
2080                "t1",
2081                "bash",
2082                serde_json::json!({"command": "echo hello"}),
2083            ),
2084            MockLlmClient::text_response("Could not run bash."),
2085        ]));
2086
2087        let executor = TaskExecutor::new(
2088            Arc::new(registry),
2089            mock,
2090            workspace.path().to_string_lossy().to_string(),
2091        );
2092
2093        let result = executor
2094            .execute(
2095                TaskParams {
2096                    agent: "restricted".to_string(),
2097                    description: "Try bash".to_string(),
2098                    prompt: "Run echo hello".to_string(),
2099                    background: false,
2100                    max_steps: Some(3),
2101                },
2102                None,
2103                None,
2104            )
2105            .await
2106            .unwrap();
2107
2108        // The agent completes (LLM responds after denial), but bash was denied.
2109        // The denial is sent as a tool result to the LLM, which then responds.
2110        assert!(result.success, "agent should complete: {}", result.output);
2111    }
2112
2113    #[tokio::test]
2114    async fn task_child_run_confirmation_auto_approve() {
2115        let workspace = tempfile::tempdir().unwrap();
2116        let registry = AgentRegistry::new();
2117        // Agent with allow("read(*)") — write is not in allow list, so it returns Ask.
2118        // With AutoApproveConfirmation (default for agents with permissions), Ask → approve.
2119        let spec = crate::subagent::WorkerAgentSpec::custom("reader-writer", "Read and write")
2120            .with_permissions(PermissionPolicy::new().allow("read(*)"))
2121            .with_max_steps(3);
2122        registry.register(spec.into_agent_definition());
2123
2124        let mock = Arc::new(MockLlmClient::new(vec![
2125            MockLlmClient::tool_call_response(
2126                "t1",
2127                "write",
2128                serde_json::json!({
2129                    "file_path": workspace.path().join("auto.txt").to_string_lossy(),
2130                    "content": "AUTO_APPROVED"
2131                }),
2132            ),
2133            MockLlmClient::text_response("Written."),
2134        ]));
2135
2136        let executor = TaskExecutor::new(
2137            Arc::new(registry),
2138            mock,
2139            workspace.path().to_string_lossy().to_string(),
2140        );
2141
2142        let result = executor
2143            .execute(
2144                TaskParams {
2145                    agent: "reader-writer".to_string(),
2146                    description: "Write via auto-approve".to_string(),
2147                    prompt: "Write auto.txt".to_string(),
2148                    background: false,
2149                    max_steps: Some(3),
2150                },
2151                None,
2152                None,
2153            )
2154            .await
2155            .unwrap();
2156
2157        assert!(
2158            result.success,
2159            "Ask should be auto-approved: {}",
2160            result.output
2161        );
2162        assert!(
2163            !result.output.contains("MissingConfirmationManager"),
2164            "no MissingConfirmationManager: {}",
2165            result.output
2166        );
2167    }
2168
2169    #[tokio::test]
2170    async fn task_child_run_step_budget_enforced() {
2171        let workspace = tempfile::tempdir().unwrap();
2172        let mock = Arc::new(MockLlmClient::new(vec![
2173            MockLlmClient::tool_call_response(
2174                "t1",
2175                "read",
2176                serde_json::json!({"file_path": "/tmp/a.txt"}),
2177            ),
2178            MockLlmClient::tool_call_response(
2179                "t2",
2180                "read",
2181                serde_json::json!({"file_path": "/tmp/b.txt"}),
2182            ),
2183            MockLlmClient::tool_call_response(
2184                "t3",
2185                "read",
2186                serde_json::json!({"file_path": "/tmp/c.txt"}),
2187            ),
2188            MockLlmClient::text_response("Should not reach here."),
2189        ]));
2190
2191        let executor = TaskExecutor::new(
2192            test_registry_with_writer(),
2193            mock,
2194            workspace.path().to_string_lossy().to_string(),
2195        );
2196
2197        let result = executor
2198            .execute(
2199                TaskParams {
2200                    agent: "writer".to_string(),
2201                    description: "Exceed budget".to_string(),
2202                    prompt: "Read many files".to_string(),
2203                    background: false,
2204                    max_steps: Some(2),
2205                },
2206                None,
2207                None,
2208            )
2209            .await
2210            .unwrap();
2211
2212        // The agent should fail after exceeding 2 tool rounds
2213        assert!(
2214            !result.success,
2215            "should fail when exceeding step budget: {}",
2216            result.output
2217        );
2218        assert!(
2219            result.output.contains("Max tool rounds") || result.output.contains("max tool rounds"),
2220            "error should mention tool rounds: {}",
2221            result.output
2222        );
2223    }
2224
2225    #[tokio::test]
2226    async fn parallel_task_executor_runs_children_concurrently_and_preserves_input_order() {
2227        let workspace = tempfile::tempdir().unwrap();
2228        let client = Arc::new(ConcurrentLlmClient::new(2));
2229        let executor = Arc::new(TaskExecutor::new(
2230            test_registry_with_text_worker(),
2231            client.clone(),
2232            workspace.path().to_string_lossy().to_string(),
2233        ));
2234
2235        let tasks = vec![
2236            TaskParams {
2237                agent: "worker".to_string(),
2238                description: "Slow task".to_string(),
2239                prompt: "slow branch".to_string(),
2240                background: false,
2241                max_steps: Some(1),
2242            },
2243            TaskParams {
2244                agent: "worker".to_string(),
2245                description: "Fast task".to_string(),
2246                prompt: "fast branch".to_string(),
2247                background: false,
2248                max_steps: Some(1),
2249            },
2250        ];
2251
2252        let results = tokio::time::timeout(
2253            Duration::from_secs(2),
2254            executor.execute_parallel(tasks, None, None),
2255        )
2256        .await
2257        .expect("parallel children should reach the barrier and complete");
2258
2259        assert_eq!(results.len(), 2);
2260        assert!(
2261            client.max_active() >= 2,
2262            "expected concurrent child execution, max_active={}",
2263            client.max_active()
2264        );
2265        assert!(results[0].success);
2266        assert!(results[0].output.contains("slow branch"));
2267        assert!(results[1].success);
2268        assert!(results[1].output.contains("fast branch"));
2269    }
2270
2271    #[tokio::test]
2272    async fn parallel_task_executor_respects_configured_concurrency_limit() {
2273        let workspace = tempfile::tempdir().unwrap();
2274        let client = Arc::new(LimitedConcurrencyLlmClient::new());
2275        let executor = Arc::new(
2276            TaskExecutor::new(
2277                test_registry_with_text_worker(),
2278                client.clone(),
2279                workspace.path().to_string_lossy().to_string(),
2280            )
2281            .with_max_parallel_tasks(2),
2282        );
2283
2284        let tasks = (0..5)
2285            .map(|idx| TaskParams {
2286                agent: "worker".to_string(),
2287                description: format!("Task {idx}"),
2288                prompt: format!("branch {idx}"),
2289                background: false,
2290                max_steps: Some(1),
2291            })
2292            .collect::<Vec<_>>();
2293
2294        let results = executor.execute_parallel(tasks, None, None).await;
2295
2296        assert_eq!(results.len(), 5);
2297        assert!(results.iter().all(|result| result.success));
2298        assert_eq!(client.max_active(), 2);
2299    }
2300
2301    #[tokio::test]
2302    async fn parallel_task_executor_isolates_unknown_agent_failure() {
2303        let workspace = tempfile::tempdir().unwrap();
2304        let executor = Arc::new(TaskExecutor::new(
2305            test_registry_with_text_worker(),
2306            Arc::new(StaticLlmClient::new("valid branch done")),
2307            workspace.path().to_string_lossy().to_string(),
2308        ));
2309
2310        let tasks = vec![
2311            TaskParams {
2312                agent: "missing-agent".to_string(),
2313                description: "Missing".to_string(),
2314                prompt: "should fail".to_string(),
2315                background: false,
2316                max_steps: Some(1),
2317            },
2318            TaskParams {
2319                agent: "worker".to_string(),
2320                description: "Valid".to_string(),
2321                prompt: "should succeed".to_string(),
2322                background: false,
2323                max_steps: Some(1),
2324            },
2325        ];
2326
2327        let results = executor.execute_parallel(tasks, None, None).await;
2328
2329        assert_eq!(results.len(), 2);
2330        assert!(!results[0].success);
2331        assert_eq!(results[0].agent, "missing-agent");
2332        assert!(results[0].output.contains("Unknown agent type"));
2333        assert!(results[1].success);
2334        assert_eq!(results[1].agent, "worker");
2335        assert!(results[1].output.contains("valid branch done"));
2336    }
2337
2338    #[tokio::test]
2339    async fn parallel_task_executor_emits_subagent_events_for_each_child() {
2340        let workspace = tempfile::tempdir().unwrap();
2341        let executor = Arc::new(TaskExecutor::new(
2342            test_registry_with_text_worker(),
2343            Arc::new(StaticLlmClient::new("done")),
2344            workspace.path().to_string_lossy().to_string(),
2345        ));
2346        let (tx, mut rx) = broadcast::channel(64);
2347
2348        let tasks = vec![
2349            TaskParams {
2350                agent: "worker".to_string(),
2351                description: "One".to_string(),
2352                prompt: "first".to_string(),
2353                background: false,
2354                max_steps: Some(1),
2355            },
2356            TaskParams {
2357                agent: "worker".to_string(),
2358                description: "Two".to_string(),
2359                prompt: "second".to_string(),
2360                background: false,
2361                max_steps: Some(1),
2362            },
2363        ];
2364
2365        let results = executor.execute_parallel(tasks, Some(tx), None).await;
2366        assert_eq!(results.len(), 2);
2367        tokio::time::sleep(Duration::from_millis(20)).await;
2368
2369        let mut starts = Vec::new();
2370        let mut ends = Vec::new();
2371        let mut progress_statuses: Vec<String> = Vec::new();
2372        while let Ok(event) = rx.try_recv() {
2373            match event {
2374                AgentEvent::SubagentStart { description, .. } => starts.push(description),
2375                AgentEvent::SubagentEnd { agent, success, .. } => ends.push((agent, success)),
2376                AgentEvent::SubagentProgress { status, .. } => progress_statuses.push(status),
2377                _ => {}
2378            }
2379        }
2380
2381        starts.sort();
2382        assert_eq!(starts, vec!["One".to_string(), "Two".to_string()]);
2383        assert_eq!(ends.len(), 2);
2384        assert!(ends
2385            .iter()
2386            .all(|(agent, success)| agent == "worker" && *success));
2387        // Each child loop emits at least one TurnEnd, so we expect at least
2388        // two synthesized turn_completed progress events across the run.
2389        assert!(
2390            progress_statuses
2391                .iter()
2392                .filter(|s| s == &"turn_completed")
2393                .count()
2394                >= 2,
2395            "expected at least two turn_completed progress events, got {:?}",
2396            progress_statuses
2397        );
2398    }
2399
2400    #[tokio::test]
2401    async fn parallel_task_tool_reports_error_when_any_child_fails() {
2402        let workspace = tempfile::tempdir().unwrap();
2403        let executor = Arc::new(TaskExecutor::new(
2404            test_registry_with_text_worker(),
2405            Arc::new(StaticLlmClient::new("valid branch done")),
2406            workspace.path().to_string_lossy().to_string(),
2407        ));
2408        let tool = ParallelTaskTool::new(executor);
2409        let ctx = ToolContext::new(workspace.path().to_path_buf());
2410
2411        let output = tool
2412            .execute(
2413                &serde_json::json!({
2414                    "tasks": [
2415                        {
2416                            "agent": "missing-agent",
2417                            "description": "Missing",
2418                            "prompt": "should fail"
2419                        },
2420                        {
2421                            "agent": "worker",
2422                            "description": "Valid",
2423                            "prompt": "should succeed"
2424                        }
2425                    ]
2426                }),
2427                &ctx,
2428            )
2429            .await
2430            .unwrap();
2431
2432        assert!(
2433            !output.success,
2434            "parallel_task should fail when any child result fails"
2435        );
2436        assert!(output.content.contains("[ERR]"));
2437        assert!(output.content.contains("[OK]"));
2438        let metadata = output.metadata.expect("metadata");
2439        assert_eq!(metadata["task_count"], 2);
2440        assert_eq!(metadata["results"][0]["success"], false);
2441        assert_eq!(metadata["results"][1]["success"], true);
2442    }
2443
2444    #[tokio::test]
2445    async fn parallel_task_both_inherit_permissions() {
2446        let workspace = tempfile::tempdir().unwrap();
2447        let mock = Arc::new(MockLlmClient::new(vec![
2448            // Task 1 responses
2449            MockLlmClient::tool_call_response(
2450                "t1",
2451                "write",
2452                serde_json::json!({
2453                    "file_path": workspace.path().join("p1.txt").to_string_lossy(),
2454                    "content": "P1"
2455                }),
2456            ),
2457            MockLlmClient::text_response("Done 1."),
2458            // Task 2 responses
2459            MockLlmClient::tool_call_response(
2460                "t2",
2461                "write",
2462                serde_json::json!({
2463                    "file_path": workspace.path().join("p2.txt").to_string_lossy(),
2464                    "content": "P2"
2465                }),
2466            ),
2467            MockLlmClient::text_response("Done 2."),
2468        ]));
2469
2470        let executor = Arc::new(TaskExecutor::new(
2471            test_registry_with_writer(),
2472            mock,
2473            workspace.path().to_string_lossy().to_string(),
2474        ));
2475
2476        let tasks = vec![
2477            TaskParams {
2478                agent: "writer".to_string(),
2479                description: "Write p1".to_string(),
2480                prompt: "Write p1.txt".to_string(),
2481                background: false,
2482                max_steps: Some(3),
2483            },
2484            TaskParams {
2485                agent: "writer".to_string(),
2486                description: "Write p2".to_string(),
2487                prompt: "Write p2.txt".to_string(),
2488                background: false,
2489                max_steps: Some(3),
2490            },
2491        ];
2492
2493        let results = executor.execute_parallel(tasks, None, None).await;
2494        assert_eq!(results.len(), 2);
2495
2496        for result in &results {
2497            assert!(
2498                result.success,
2499                "parallel child should succeed: {}",
2500                result.output
2501            );
2502        }
2503    }
2504
2505    #[test]
2506    fn synthesize_progress_emits_tool_completed_for_tool_end() {
2507        let event = AgentEvent::ToolEnd {
2508            id: "call-1".to_string(),
2509            name: "bash".to_string(),
2510            output: "hello".to_string(),
2511            exit_code: 0,
2512            metadata: None,
2513            error_kind: None,
2514        };
2515        let progress =
2516            synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2517        match progress {
2518            AgentEvent::SubagentProgress {
2519                task_id,
2520                session_id,
2521                status,
2522                metadata,
2523            } => {
2524                assert_eq!(task_id, "task-1");
2525                assert_eq!(session_id, "task-run-task-1");
2526                assert_eq!(status, "tool_completed");
2527                assert_eq!(metadata["tool"], "bash");
2528                assert_eq!(metadata["exit_code"], 0);
2529                assert_eq!(metadata["output_bytes"], 5);
2530                assert!(metadata.get("error_kind").is_none());
2531            }
2532            other => panic!("expected SubagentProgress, got {:?}", other),
2533        }
2534    }
2535
2536    #[test]
2537    fn synthesize_progress_includes_error_kind_when_present() {
2538        let event = AgentEvent::ToolEnd {
2539            id: "call-2".to_string(),
2540            name: "edit".to_string(),
2541            output: "boom".to_string(),
2542            exit_code: 1,
2543            metadata: None,
2544            error_kind: Some(crate::tools::ToolErrorKind::NotFound {
2545                path: "missing.txt".to_string(),
2546            }),
2547        };
2548        let progress =
2549            synthesize_subagent_progress(&event, "task-x", "task-run-task-x").expect("some");
2550        if let AgentEvent::SubagentProgress { metadata, .. } = progress {
2551            assert!(
2552                metadata.get("error_kind").is_some(),
2553                "error_kind should propagate into metadata"
2554            );
2555        } else {
2556            panic!("expected SubagentProgress");
2557        }
2558    }
2559
2560    #[test]
2561    fn synthesize_progress_emits_turn_completed_for_turn_end() {
2562        let event = AgentEvent::TurnEnd {
2563            turn: 3,
2564            usage: crate::llm::TokenUsage {
2565                prompt_tokens: 100,
2566                completion_tokens: 25,
2567                total_tokens: 125,
2568                cache_read_tokens: None,
2569                cache_write_tokens: None,
2570            },
2571        };
2572        let progress =
2573            synthesize_subagent_progress(&event, "task-1", "task-run-task-1").expect("some");
2574        if let AgentEvent::SubagentProgress {
2575            status, metadata, ..
2576        } = progress
2577        {
2578            assert_eq!(status, "turn_completed");
2579            assert_eq!(metadata["turn"], 3);
2580            assert_eq!(metadata["total_tokens"], 125);
2581            assert_eq!(metadata["prompt_tokens"], 100);
2582            assert_eq!(metadata["completion_tokens"], 25);
2583        } else {
2584            panic!("expected SubagentProgress");
2585        }
2586    }
2587
2588    #[test]
2589    fn synthesize_progress_ignores_unrelated_events() {
2590        let ignored = [
2591            AgentEvent::TextDelta {
2592                text: "hi".to_string(),
2593            },
2594            AgentEvent::ToolStart {
2595                id: "x".to_string(),
2596                name: "bash".to_string(),
2597            },
2598            AgentEvent::TurnStart { turn: 1 },
2599            AgentEvent::SubagentStart {
2600                task_id: "nested".to_string(),
2601                session_id: "nested-run".to_string(),
2602                parent_session_id: "parent".to_string(),
2603                agent: "explore".to_string(),
2604                description: "nested".to_string(),
2605                started_ms: 0,
2606            },
2607        ];
2608        for event in &ignored {
2609            assert!(
2610                synthesize_subagent_progress(event, "task", "session").is_none(),
2611                "{:?} should not emit progress",
2612                event
2613            );
2614        }
2615    }
2616}