Skip to main content

construct/tools/
delegate.rs

1use super::traits::{Tool, ToolResult};
2use crate::agent::loop_::run_tool_call_loop;
3use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
4use crate::config::{DelegateAgentConfig, DelegateToolConfig};
5use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
6use crate::providers::{self, ChatMessage, Provider};
7use crate::security::SecurityPolicy;
8use crate::security::policy::ToolOperation;
9use async_trait::async_trait;
10use parking_lot::RwLock;
11use serde_json::json;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16use tokio_util::sync::CancellationToken;
17
18/// Serializable result of a background delegate task.
19#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct BackgroundDelegateResult {
21    pub task_id: String,
22    pub agent: String,
23    pub status: BackgroundTaskStatus,
24    pub output: Option<String>,
25    pub error: Option<String>,
26    pub started_at: String,
27    pub finished_at: Option<String>,
28}
29
30/// Status of a background delegate task.
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum BackgroundTaskStatus {
34    Running,
35    Completed,
36    Failed,
37    Cancelled,
38}
39
40/// Tool that delegates a subtask to a named agent with a different
41/// provider/model configuration. Enables multi-agent workflows where
42/// a primary agent can hand off specialized work (research, coding,
43/// summarization) to purpose-built sub-agents.
44///
45/// Supports three execution modes:
46/// - **Synchronous** (default): blocks until the sub-agent completes.
47/// - **Background** (`background: true`): spawns the sub-agent in a tokio
48///   task and returns a `task_id` immediately.
49/// - **Parallel** (`parallel: [...]`): runs multiple agents concurrently
50///   and returns all results.
51///
52/// Background results are persisted to `workspace/delegate_results/{task_id}.json`
53/// and can be retrieved via `action: "check_result"`.
54pub struct DelegateTool {
55    agents: Arc<HashMap<String, DelegateAgentConfig>>,
56    security: Arc<SecurityPolicy>,
57    /// Global credential fallback (from config.api_key)
58    fallback_credential: Option<String>,
59    /// Provider runtime options inherited from root config.
60    provider_runtime_options: providers::ProviderRuntimeOptions,
61    /// Depth at which this tool instance lives in the delegation chain.
62    depth: u32,
63    /// Parent tool registry for agentic sub-agents.
64    parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
65    /// Inherited multimodal handling config for sub-agent loops.
66    multimodal_config: crate::config::MultimodalConfig,
67    /// Global delegate tool config providing default timeout values.
68    delegate_config: DelegateToolConfig,
69    /// Workspace directory inherited from the root agent context.
70    workspace_dir: PathBuf,
71    /// Cancellation token for cascade control of background tasks.
72    cancellation_token: CancellationToken,
73}
74
75impl DelegateTool {
76    pub fn new(
77        agents: HashMap<String, DelegateAgentConfig>,
78        fallback_credential: Option<String>,
79        security: Arc<SecurityPolicy>,
80    ) -> Self {
81        Self::new_with_options(
82            agents,
83            fallback_credential,
84            security,
85            providers::ProviderRuntimeOptions::default(),
86        )
87    }
88
89    pub fn new_with_options(
90        agents: HashMap<String, DelegateAgentConfig>,
91        fallback_credential: Option<String>,
92        security: Arc<SecurityPolicy>,
93        provider_runtime_options: providers::ProviderRuntimeOptions,
94    ) -> Self {
95        Self {
96            agents: Arc::new(agents),
97            security,
98            fallback_credential,
99            provider_runtime_options,
100            depth: 0,
101            parent_tools: Arc::new(RwLock::new(Vec::new())),
102            multimodal_config: crate::config::MultimodalConfig::default(),
103            delegate_config: DelegateToolConfig::default(),
104            workspace_dir: PathBuf::new(),
105            cancellation_token: CancellationToken::new(),
106        }
107    }
108
109    /// Create a DelegateTool for a sub-agent (with incremented depth).
110    /// When sub-agents eventually get their own tool registry, construct
111    /// their DelegateTool via this method with `depth: parent.depth + 1`.
112    pub fn with_depth(
113        agents: HashMap<String, DelegateAgentConfig>,
114        fallback_credential: Option<String>,
115        security: Arc<SecurityPolicy>,
116        depth: u32,
117    ) -> Self {
118        Self::with_depth_and_options(
119            agents,
120            fallback_credential,
121            security,
122            depth,
123            providers::ProviderRuntimeOptions::default(),
124        )
125    }
126
127    pub fn with_depth_and_options(
128        agents: HashMap<String, DelegateAgentConfig>,
129        fallback_credential: Option<String>,
130        security: Arc<SecurityPolicy>,
131        depth: u32,
132        provider_runtime_options: providers::ProviderRuntimeOptions,
133    ) -> Self {
134        Self {
135            agents: Arc::new(agents),
136            security,
137            fallback_credential,
138            provider_runtime_options,
139            depth,
140            parent_tools: Arc::new(RwLock::new(Vec::new())),
141            multimodal_config: crate::config::MultimodalConfig::default(),
142            delegate_config: DelegateToolConfig::default(),
143            workspace_dir: PathBuf::new(),
144            cancellation_token: CancellationToken::new(),
145        }
146    }
147
148    /// Attach parent tools used to build sub-agent allowlist registries.
149    pub fn with_parent_tools(mut self, parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>) -> Self {
150        self.parent_tools = parent_tools;
151        self
152    }
153
154    /// Attach multimodal configuration for sub-agent tool loops.
155    pub fn with_multimodal_config(mut self, config: crate::config::MultimodalConfig) -> Self {
156        self.multimodal_config = config;
157        self
158    }
159
160    /// Attach global delegate tool configuration for default timeout values.
161    pub fn with_delegate_config(mut self, config: DelegateToolConfig) -> Self {
162        self.delegate_config = config;
163        self
164    }
165
166    /// Return a shared handle to the parent tools list.
167    /// Callers can push additional tools (e.g. MCP wrappers) after construction.
168    pub fn parent_tools_handle(&self) -> Arc<RwLock<Vec<Arc<dyn Tool>>>> {
169        Arc::clone(&self.parent_tools)
170    }
171
172    /// Attach the workspace directory for system prompt enrichment.
173    pub fn with_workspace_dir(mut self, workspace_dir: PathBuf) -> Self {
174        self.workspace_dir = workspace_dir;
175        self
176    }
177
178    /// Attach a cancellation token for cascade control of background tasks.
179    /// When the token is cancelled, all background sub-agents are aborted.
180    pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self {
181        self.cancellation_token = token;
182        self
183    }
184
185    /// Return the cancellation token for external cascade control.
186    pub fn cancellation_token(&self) -> &CancellationToken {
187        &self.cancellation_token
188    }
189
190    /// Directory where background delegate results are stored.
191    fn results_dir(&self) -> PathBuf {
192        self.workspace_dir.join("delegate_results")
193    }
194
195    /// Validate that a user-provided task_id is a valid UUID to prevent
196    /// path traversal attacks (e.g. `../../etc/passwd`).
197    fn validate_task_id(task_id: &str) -> Result<(), String> {
198        if uuid::Uuid::parse_str(task_id).is_err() {
199            return Err(format!("Invalid task_id '{task_id}': must be a valid UUID"));
200        }
201        Ok(())
202    }
203}
204
205#[async_trait]
206impl Tool for DelegateTool {
207    fn name(&self) -> &str {
208        "delegate"
209    }
210
211    fn description(&self) -> &str {
212        "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
213         (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
214         prompt by default; with agentic=true it can iterate with a filtered tool-call loop. \
215         Supports background execution (returns a task_id immediately) and parallel execution \
216         (runs multiple agents concurrently). Use action='check_result' with a task_id to \
217         retrieve background results."
218    }
219
220    fn parameters_schema(&self) -> serde_json::Value {
221        let agent_names: Vec<&str> = self.agents.keys().map(|s: &String| s.as_str()).collect();
222        json!({
223            "type": "object",
224            "additionalProperties": false,
225            "properties": {
226                "action": {
227                    "type": "string",
228                    "enum": ["delegate", "check_result", "list_results", "cancel_task"],
229                    "description": "Action to perform. Default: 'delegate'. Use 'check_result' to \
230                                    retrieve a background task result, 'list_results' to list all \
231                                    background tasks, 'cancel_task' to cancel a running background task.",
232                    "default": "delegate"
233                },
234                "agent": {
235                    "type": "string",
236                    "minLength": 1,
237                    "description": format!(
238                        "Name of the agent to delegate to. Available: {}",
239                        if agent_names.is_empty() {
240                            "(none configured)".to_string()
241                        } else {
242                            agent_names.join(", ")
243                        }
244                    )
245                },
246                "prompt": {
247                    "type": "string",
248                    "minLength": 1,
249                    "description": "The task/prompt to send to the sub-agent"
250                },
251                "context": {
252                    "type": "string",
253                    "description": "Optional context to prepend (e.g. relevant code, prior findings)"
254                },
255                "background": {
256                    "type": "boolean",
257                    "description": "When true, the sub-agent runs in a background tokio task and \
258                                    returns a task_id immediately. Results are stored to \
259                                    workspace/delegate_results/{task_id}.json.",
260                    "default": false
261                },
262                "parallel": {
263                    "type": "array",
264                    "items": { "type": "string" },
265                    "description": "Array of agent names to run concurrently with the same prompt. \
266                                    Returns all results when all agents complete. Cannot be combined \
267                                    with 'background'."
268                },
269                "task_id": {
270                    "type": "string",
271                    "description": "Task ID for check_result/cancel_task actions (returned by \
272                                    background delegation)."
273                }
274            },
275            "required": []
276        })
277    }
278
279    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
280        let action = args
281            .get("action")
282            .and_then(|v| v.as_str())
283            .unwrap_or("delegate");
284
285        match action {
286            "check_result" => return self.handle_check_result(&args).await,
287            "list_results" => return self.handle_list_results().await,
288            "cancel_task" => return self.handle_cancel_task(&args).await,
289            "delegate" => {} // fall through to delegation logic
290            other => {
291                return Ok(ToolResult {
292                    success: false,
293                    output: String::new(),
294                    error: Some(format!(
295                        "Unknown action '{other}'. Use delegate/check_result/list_results/cancel_task."
296                    )),
297                });
298            }
299        }
300
301        // --- Parallel mode ---
302        if let Some(parallel_agents) = args.get("parallel").and_then(|v| v.as_array()) {
303            return self.execute_parallel(parallel_agents, &args).await;
304        }
305
306        // --- Single-agent delegation (synchronous or background) ---
307        let agent_name = args
308            .get("agent")
309            .and_then(|v| v.as_str())
310            .map(str::trim)
311            .ok_or_else(|| anyhow::anyhow!("Missing 'agent' parameter"))?;
312
313        if agent_name.is_empty() {
314            return Ok(ToolResult {
315                success: false,
316                output: String::new(),
317                error: Some("'agent' parameter must not be empty".into()),
318            });
319        }
320
321        let prompt = args
322            .get("prompt")
323            .and_then(|v| v.as_str())
324            .map(str::trim)
325            .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
326
327        if prompt.is_empty() {
328            return Ok(ToolResult {
329                success: false,
330                output: String::new(),
331                error: Some("'prompt' parameter must not be empty".into()),
332            });
333        }
334
335        let background = args
336            .get("background")
337            .and_then(|v| v.as_bool())
338            .unwrap_or(false);
339
340        if background {
341            return self.execute_background(agent_name, prompt, &args).await;
342        }
343
344        // --- Synchronous delegation (original path) ---
345        self.execute_sync(agent_name, prompt, &args).await
346    }
347}
348
349impl DelegateTool {
350    /// Original synchronous delegation path (extracted for reuse).
351    async fn execute_sync(
352        &self,
353        agent_name: &str,
354        prompt: &str,
355        args: &serde_json::Value,
356    ) -> anyhow::Result<ToolResult> {
357        let context = args
358            .get("context")
359            .and_then(|v| v.as_str())
360            .map(str::trim)
361            .unwrap_or("");
362
363        // Look up agent config
364        let agent_config = match self.agents.get(agent_name) {
365            Some(cfg) => cfg,
366            None => {
367                let available: Vec<&str> =
368                    self.agents.keys().map(|s: &String| s.as_str()).collect();
369                return Ok(ToolResult {
370                    success: false,
371                    output: String::new(),
372                    error: Some(format!(
373                        "Unknown agent '{agent_name}'. Available agents: {}",
374                        if available.is_empty() {
375                            "(none configured)".to_string()
376                        } else {
377                            available.join(", ")
378                        }
379                    )),
380                });
381            }
382        };
383
384        // Check recursion depth (immutable — set at construction, incremented for sub-agents)
385        if self.depth >= agent_config.max_depth {
386            return Ok(ToolResult {
387                success: false,
388                output: String::new(),
389                error: Some(format!(
390                    "Delegation depth limit reached ({depth}/{max}). \
391                     Cannot delegate further to prevent infinite loops.",
392                    depth = self.depth,
393                    max = agent_config.max_depth
394                )),
395            });
396        }
397
398        if let Err(error) = self
399            .security
400            .enforce_tool_operation(ToolOperation::Act, "delegate")
401        {
402            return Ok(ToolResult {
403                success: false,
404                output: String::new(),
405                error: Some(error),
406            });
407        }
408
409        // Create provider for this agent
410        let provider_credential_owned = agent_config
411            .api_key
412            .clone()
413            .or_else(|| self.fallback_credential.clone());
414        #[allow(clippy::option_as_ref_deref)]
415        let provider_credential = provider_credential_owned.as_ref().map(String::as_str);
416
417        let provider: Box<dyn Provider> = match providers::create_provider_with_options(
418            &agent_config.provider,
419            provider_credential,
420            &self.provider_runtime_options,
421        ) {
422            Ok(p) => p,
423            Err(e) => {
424                return Ok(ToolResult {
425                    success: false,
426                    output: String::new(),
427                    error: Some(format!(
428                        "Failed to create provider '{}' for agent '{agent_name}': {e}",
429                        agent_config.provider
430                    )),
431                });
432            }
433        };
434
435        // Build the message
436        let full_prompt = if context.is_empty() {
437            prompt.to_string()
438        } else {
439            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
440        };
441
442        let temperature = agent_config.temperature.unwrap_or(0.7);
443
444        // Agentic mode: run full tool-call loop with allowlisted tools.
445        if agent_config.agentic {
446            return self
447                .execute_agentic(
448                    agent_name,
449                    agent_config,
450                    &*provider,
451                    &full_prompt,
452                    temperature,
453                )
454                .await;
455        }
456
457        // Build enriched system prompt for non-agentic sub-agent.
458        let enriched_system_prompt =
459            self.build_enriched_system_prompt(agent_config, &[], &self.workspace_dir);
460        let system_prompt_ref = enriched_system_prompt.as_deref();
461
462        // Wrap the provider call in a timeout to prevent indefinite blocking
463        let timeout_secs = agent_config
464            .timeout_secs
465            .unwrap_or(self.delegate_config.timeout_secs);
466        let result = tokio::time::timeout(
467            Duration::from_secs(timeout_secs),
468            provider.chat_with_system(
469                system_prompt_ref,
470                &full_prompt,
471                &agent_config.model,
472                temperature,
473            ),
474        )
475        .await;
476
477        let result = match result {
478            Ok(inner) => inner,
479            Err(_elapsed) => {
480                return Ok(ToolResult {
481                    success: false,
482                    output: String::new(),
483                    error: Some(format!(
484                        "Agent '{agent_name}' timed out after {timeout_secs}s"
485                    )),
486                });
487            }
488        };
489
490        match result {
491            Ok(response) => {
492                let mut rendered = response;
493                if rendered.trim().is_empty() {
494                    rendered = "[Empty response]".to_string();
495                }
496
497                Ok(ToolResult {
498                    success: true,
499                    output: format!(
500                        "[Agent '{agent_name}' ({provider}/{model})]\n{rendered}",
501                        provider = agent_config.provider,
502                        model = agent_config.model
503                    ),
504                    error: None,
505                })
506            }
507            Err(e) => Ok(ToolResult {
508                success: false,
509                output: String::new(),
510                error: Some(format!("Agent '{agent_name}' failed: {e}",)),
511            }),
512        }
513    }
514}
515
516impl DelegateTool {
517    // ── Background Execution ────────────────────────────────────────
518
519    /// Spawn a sub-agent in a background tokio task. Returns a task_id immediately.
520    /// The result is persisted to `workspace/delegate_results/{task_id}.json`.
521    async fn execute_background(
522        &self,
523        agent_name: &str,
524        prompt: &str,
525        args: &serde_json::Value,
526    ) -> anyhow::Result<ToolResult> {
527        // Validate agent exists and check depth/security before spawning
528        let agent_config = match self.agents.get(agent_name) {
529            Some(cfg) => cfg.clone(),
530            None => {
531                let available: Vec<&str> =
532                    self.agents.keys().map(|s: &String| s.as_str()).collect();
533                return Ok(ToolResult {
534                    success: false,
535                    output: String::new(),
536                    error: Some(format!(
537                        "Unknown agent '{agent_name}'. Available agents: {}",
538                        if available.is_empty() {
539                            "(none configured)".to_string()
540                        } else {
541                            available.join(", ")
542                        }
543                    )),
544                });
545            }
546        };
547
548        if self.depth >= agent_config.max_depth {
549            return Ok(ToolResult {
550                success: false,
551                output: String::new(),
552                error: Some(format!(
553                    "Delegation depth limit reached ({depth}/{max}).",
554                    depth = self.depth,
555                    max = agent_config.max_depth
556                )),
557            });
558        }
559
560        if let Err(error) = self
561            .security
562            .enforce_tool_operation(ToolOperation::Act, "delegate")
563        {
564            return Ok(ToolResult {
565                success: false,
566                output: String::new(),
567                error: Some(error),
568            });
569        }
570
571        let task_id = uuid::Uuid::new_v4().to_string();
572        let results_dir = self.results_dir();
573        tokio::fs::create_dir_all(&results_dir).await?;
574
575        let context = args
576            .get("context")
577            .and_then(|v| v.as_str())
578            .map(str::trim)
579            .unwrap_or("");
580        let full_prompt = if context.is_empty() {
581            prompt.to_string()
582        } else {
583            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
584        };
585
586        let started_at = chrono::Utc::now().to_rfc3339();
587        let agent_name_owned = agent_name.to_string();
588
589        // Write initial "running" status
590        let initial_result = BackgroundDelegateResult {
591            task_id: task_id.clone(),
592            agent: agent_name_owned.clone(),
593            status: BackgroundTaskStatus::Running,
594            output: None,
595            error: None,
596            started_at: started_at.clone(),
597            finished_at: None,
598        };
599        let result_path = results_dir.join(format!("{task_id}.json"));
600        let json_bytes = serde_json::to_vec_pretty(&initial_result)?;
601        tokio::fs::write(&result_path, &json_bytes).await?;
602
603        // Clone everything needed for the spawned task
604        let agents = Arc::clone(&self.agents);
605        let security = Arc::clone(&self.security);
606        let fallback_credential = self.fallback_credential.clone();
607        let provider_runtime_options = self.provider_runtime_options.clone();
608        let depth = self.depth;
609        let parent_tools = Arc::clone(&self.parent_tools);
610        let multimodal_config = self.multimodal_config.clone();
611        let delegate_config = self.delegate_config.clone();
612        let workspace_dir = self.workspace_dir.clone();
613        let child_token = self.cancellation_token.child_token();
614        let task_id_clone = task_id.clone();
615
616        tokio::spawn(async move {
617            // Build an inner DelegateTool for the spawned context
618            let inner = DelegateTool {
619                agents,
620                security,
621                fallback_credential,
622                provider_runtime_options,
623                depth,
624                parent_tools,
625                multimodal_config,
626                delegate_config,
627                workspace_dir: workspace_dir.clone(),
628                cancellation_token: child_token.clone(),
629            };
630
631            let args_inner = json!({
632                "agent": agent_name_owned,
633                "prompt": full_prompt,
634            });
635
636            // Race the delegation against cancellation
637            let outcome = tokio::select! {
638                () = child_token.cancelled() => {
639                    Err("Cancelled by parent session".to_string())
640                }
641                result = Box::pin(inner.execute_sync(&agent_name_owned, &full_prompt, &args_inner)) => {
642                    match result {
643                        Ok(tool_result) => {
644                            if tool_result.success {
645                                Ok(tool_result.output)
646                            } else {
647                                Err(tool_result.error.unwrap_or_else(|| "Unknown error".into()))
648                            }
649                        }
650                        Err(e) => Err(e.to_string()),
651                    }
652                }
653            };
654
655            let finished_at = chrono::Utc::now().to_rfc3339();
656            let final_result = match outcome {
657                Ok(output) => BackgroundDelegateResult {
658                    task_id: task_id_clone.clone(),
659                    agent: agent_name_owned,
660                    status: BackgroundTaskStatus::Completed,
661                    output: Some(output),
662                    error: None,
663                    started_at,
664                    finished_at: Some(finished_at),
665                },
666                Err(err) => {
667                    let status = if err.contains("Cancelled") {
668                        BackgroundTaskStatus::Cancelled
669                    } else {
670                        BackgroundTaskStatus::Failed
671                    };
672                    BackgroundDelegateResult {
673                        task_id: task_id_clone.clone(),
674                        agent: agent_name_owned,
675                        status,
676                        output: None,
677                        error: Some(err),
678                        started_at,
679                        finished_at: Some(finished_at),
680                    }
681                }
682            };
683
684            let result_path = results_dir.join(format!("{}.json", task_id_clone));
685            if let Ok(bytes) = serde_json::to_vec_pretty(&final_result) {
686                let _ = tokio::fs::write(&result_path, &bytes).await;
687            }
688        });
689
690        Ok(ToolResult {
691            success: true,
692            output: format!(
693                "Background task started for agent '{agent_name}'.\n\
694                 task_id: {task_id}\n\
695                 Use action='check_result' with task_id='{task_id}' to retrieve the result."
696            ),
697            error: None,
698        })
699    }
700
701    // ── Parallel Execution ──────────────────────────────────────────
702
703    /// Run multiple agents concurrently with the same prompt.
704    async fn execute_parallel(
705        &self,
706        parallel_agents: &[serde_json::Value],
707        args: &serde_json::Value,
708    ) -> anyhow::Result<ToolResult> {
709        let prompt = args
710            .get("prompt")
711            .and_then(|v| v.as_str())
712            .map(str::trim)
713            .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter for parallel execution"))?;
714
715        if prompt.is_empty() {
716            return Ok(ToolResult {
717                success: false,
718                output: String::new(),
719                error: Some("'prompt' parameter must not be empty".into()),
720            });
721        }
722
723        let agent_names: Vec<String> = parallel_agents
724            .iter()
725            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
726            .filter(|s| !s.is_empty())
727            .collect();
728
729        if agent_names.is_empty() {
730            return Ok(ToolResult {
731                success: false,
732                output: String::new(),
733                error: Some("'parallel' array must contain at least one agent name".into()),
734            });
735        }
736
737        // Validate all agents exist before starting any
738        for name in &agent_names {
739            if !self.agents.contains_key(name) {
740                let available: Vec<&str> =
741                    self.agents.keys().map(|s: &String| s.as_str()).collect();
742                return Ok(ToolResult {
743                    success: false,
744                    output: String::new(),
745                    error: Some(format!(
746                        "Unknown agent '{name}' in parallel list. Available: {}",
747                        if available.is_empty() {
748                            "(none configured)".to_string()
749                        } else {
750                            available.join(", ")
751                        }
752                    )),
753                });
754            }
755        }
756
757        // Spawn all agents concurrently
758        let mut handles = Vec::with_capacity(agent_names.len());
759        for agent_name in &agent_names {
760            let agents = Arc::clone(&self.agents);
761            let security = Arc::clone(&self.security);
762            let fallback_credential = self.fallback_credential.clone();
763            let provider_runtime_options = self.provider_runtime_options.clone();
764            let depth = self.depth;
765            let parent_tools = Arc::clone(&self.parent_tools);
766            let multimodal_config = self.multimodal_config.clone();
767            let delegate_config = self.delegate_config.clone();
768            let workspace_dir = self.workspace_dir.clone();
769            let cancellation_token = self.cancellation_token.child_token();
770            let agent_name = agent_name.clone();
771            let prompt = prompt.to_string();
772            let args_clone = args.clone();
773
774            handles.push(tokio::spawn(async move {
775                let inner = DelegateTool {
776                    agents,
777                    security,
778                    fallback_credential,
779                    provider_runtime_options,
780                    depth,
781                    parent_tools,
782                    multimodal_config,
783                    delegate_config,
784                    workspace_dir,
785                    cancellation_token,
786                };
787                let result = Box::pin(inner.execute_sync(&agent_name, &prompt, &args_clone)).await;
788                (agent_name, result)
789            }));
790        }
791
792        // Collect all results
793        let mut outputs = Vec::with_capacity(handles.len());
794        let mut all_success = true;
795
796        for handle in handles {
797            match handle.await {
798                Ok((agent_name, Ok(tool_result))) => {
799                    if !tool_result.success {
800                        all_success = false;
801                    }
802                    outputs.push(format!(
803                        "--- {agent_name} (success={}) ---\n{}{}",
804                        tool_result.success,
805                        tool_result.output,
806                        tool_result
807                            .error
808                            .map(|e| format!("\nError: {e}"))
809                            .unwrap_or_default()
810                    ));
811                }
812                Ok((agent_name, Err(e))) => {
813                    all_success = false;
814                    outputs.push(format!("--- {agent_name} (success=false) ---\nError: {e}"));
815                }
816                Err(e) => {
817                    all_success = false;
818                    outputs.push(format!("--- [join error] ---\n{e}"));
819                }
820            }
821        }
822
823        Ok(ToolResult {
824            success: all_success,
825            output: format!(
826                "[Parallel delegation: {} agents]\n\n{}",
827                agent_names.len(),
828                outputs.join("\n\n")
829            ),
830            error: if all_success {
831                None
832            } else {
833                Some("One or more parallel agents failed".into())
834            },
835        })
836    }
837
838    // ── Result Retrieval ────────────────────────────────────────────
839
840    /// Retrieve the result of a background delegate task by task_id.
841    async fn handle_check_result(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
842        let task_id = args
843            .get("task_id")
844            .and_then(|v| v.as_str())
845            .ok_or_else(|| anyhow::anyhow!("Missing 'task_id' parameter for check_result"))?;
846
847        if let Err(e) = Self::validate_task_id(task_id) {
848            return Ok(ToolResult {
849                success: false,
850                output: String::new(),
851                error: Some(e),
852            });
853        }
854
855        let result_path = self.results_dir().join(format!("{task_id}.json"));
856        if !result_path.exists() {
857            return Ok(ToolResult {
858                success: false,
859                output: String::new(),
860                error: Some(format!("No result found for task_id '{task_id}'")),
861            });
862        }
863
864        let content = tokio::fs::read_to_string(&result_path).await?;
865        let result: BackgroundDelegateResult = serde_json::from_str(&content)?;
866
867        Ok(ToolResult {
868            success: result.status == BackgroundTaskStatus::Completed,
869            output: serde_json::to_string_pretty(&result)?,
870            error: if result.status == BackgroundTaskStatus::Completed {
871                None
872            } else {
873                result.error
874            },
875        })
876    }
877
878    /// List all background delegate task results.
879    async fn handle_list_results(&self) -> anyhow::Result<ToolResult> {
880        let results_dir = self.results_dir();
881        if !results_dir.exists() {
882            return Ok(ToolResult {
883                success: true,
884                output: "No background delegate results found.".into(),
885                error: None,
886            });
887        }
888
889        let mut entries = tokio::fs::read_dir(&results_dir).await?;
890        let mut results = Vec::new();
891
892        while let Some(entry) = entries.next_entry().await? {
893            let path = entry.path();
894            if path.extension().and_then(|e| e.to_str()) == Some("json") {
895                if let Ok(content) = tokio::fs::read_to_string(&path).await {
896                    if let Ok(result) = serde_json::from_str::<BackgroundDelegateResult>(&content) {
897                        results.push(json!({
898                            "task_id": result.task_id,
899                            "agent": result.agent,
900                            "status": result.status,
901                            "started_at": result.started_at,
902                            "finished_at": result.finished_at,
903                        }));
904                    }
905                }
906            }
907        }
908
909        if results.is_empty() {
910            return Ok(ToolResult {
911                success: true,
912                output: "No background delegate results found.".into(),
913                error: None,
914            });
915        }
916
917        Ok(ToolResult {
918            success: true,
919            output: serde_json::to_string_pretty(&results)?,
920            error: None,
921        })
922    }
923
924    /// Cancel a running background task by task_id.
925    async fn handle_cancel_task(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
926        let task_id = args
927            .get("task_id")
928            .and_then(|v| v.as_str())
929            .ok_or_else(|| anyhow::anyhow!("Missing 'task_id' parameter for cancel_task"))?;
930
931        if let Err(e) = Self::validate_task_id(task_id) {
932            return Ok(ToolResult {
933                success: false,
934                output: String::new(),
935                error: Some(e),
936            });
937        }
938
939        let result_path = self.results_dir().join(format!("{task_id}.json"));
940        if !result_path.exists() {
941            return Ok(ToolResult {
942                success: false,
943                output: String::new(),
944                error: Some(format!("No task found for task_id '{task_id}'")),
945            });
946        }
947
948        // Read current status
949        let content = tokio::fs::read_to_string(&result_path).await?;
950        let mut result: BackgroundDelegateResult = serde_json::from_str(&content)?;
951
952        if result.status != BackgroundTaskStatus::Running {
953            return Ok(ToolResult {
954                success: false,
955                output: String::new(),
956                error: Some(format!(
957                    "Task '{task_id}' is not running (status: {:?})",
958                    result.status
959                )),
960            });
961        }
962
963        // Cancel via the parent token — this will cascade to all child tokens
964        // Note: individual task cancellation uses the shared parent token, which
965        // cancels all background tasks. For per-task cancellation, each background
966        // task uses a child token, and the parent token cancels all.
967        // We update the result file to reflect the cancellation request.
968        result.status = BackgroundTaskStatus::Cancelled;
969        result.error = Some("Cancelled by user request".into());
970        result.finished_at = Some(chrono::Utc::now().to_rfc3339());
971        let bytes = serde_json::to_vec_pretty(&result)?;
972        tokio::fs::write(&result_path, &bytes).await?;
973
974        Ok(ToolResult {
975            success: true,
976            output: format!("Task '{task_id}' cancellation requested."),
977            error: None,
978        })
979    }
980
981    /// Cancel all background tasks (cascade control).
982    /// Call this when the parent session ends.
983    pub fn cancel_all_background_tasks(&self) {
984        self.cancellation_token.cancel();
985    }
986
987    /// Build an enriched system prompt for a sub-agent by composing structured
988    /// operational sections (tools, skills, workspace, datetime, shell policy)
989    /// with the operator-configured `system_prompt` string.
990    fn build_enriched_system_prompt(
991        &self,
992        agent_config: &DelegateAgentConfig,
993        sub_tools: &[Box<dyn Tool>],
994        workspace_dir: &Path,
995    ) -> Option<String> {
996        // Resolve skills directory: scoped if configured, otherwise workspace default.
997        let skills_dir = agent_config
998            .skills_directory
999            .as_ref()
1000            .filter(|s| !s.trim().is_empty())
1001            .map(|dir| workspace_dir.join(dir))
1002            .unwrap_or_else(|| crate::skills::skills_dir(workspace_dir));
1003        let skills = crate::skills::load_skills_from_directory(&skills_dir, false);
1004
1005        // Determine shell policy instructions when the `shell` tool is in the
1006        // effective tool list.
1007        let has_shell = sub_tools.iter().any(|t| t.name() == "shell");
1008        let shell_policy = if has_shell {
1009            "## Shell Policy\n\n\
1010             - Prefer non-destructive commands. Use `trash` over `rm` where possible.\n\
1011             - Do not run commands that exfiltrate data or modify system-critical paths.\n\
1012             - Avoid interactive commands that block on stdin.\n\
1013             - Quote paths that may contain spaces."
1014                .to_string()
1015        } else {
1016            String::new()
1017        };
1018
1019        // Build structured operational context using SystemPromptBuilder sections.
1020        let ctx = PromptContext {
1021            workspace_dir,
1022            model_name: &agent_config.model,
1023            tools: sub_tools,
1024            skills: &skills,
1025            skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
1026            skill_effectiveness: None,
1027            identity_config: None,
1028            dispatcher_instructions: "",
1029            tool_descriptions: None,
1030            security_summary: None,
1031            autonomy_level: crate::security::AutonomyLevel::default(),
1032            operator_enabled: false,
1033            kumiho_enabled: false,
1034        };
1035
1036        let builder = SystemPromptBuilder::default()
1037            .add_section(Box::new(crate::agent::prompt::ToolsSection))
1038            .add_section(Box::new(crate::agent::prompt::SafetySection))
1039            .add_section(Box::new(crate::agent::prompt::SkillsSection))
1040            .add_section(Box::new(crate::agent::prompt::WorkspaceSection))
1041            .add_section(Box::new(crate::agent::prompt::DateTimeSection));
1042
1043        let mut enriched = builder.build(&ctx).unwrap_or_default();
1044
1045        if !shell_policy.is_empty() {
1046            enriched.push_str(&shell_policy);
1047            enriched.push_str("\n\n");
1048        }
1049
1050        // Append the operator-configured system_prompt as the identity/role block.
1051        if let Some(operator_prompt) = agent_config.system_prompt.as_ref() {
1052            enriched.push_str(operator_prompt);
1053            enriched.push('\n');
1054        }
1055
1056        let trimmed = enriched.trim().to_string();
1057        if trimmed.is_empty() {
1058            None
1059        } else {
1060            Some(trimmed)
1061        }
1062    }
1063
1064    async fn execute_agentic(
1065        &self,
1066        agent_name: &str,
1067        agent_config: &DelegateAgentConfig,
1068        provider: &dyn Provider,
1069        full_prompt: &str,
1070        temperature: f64,
1071    ) -> anyhow::Result<ToolResult> {
1072        if agent_config.allowed_tools.is_empty() {
1073            return Ok(ToolResult {
1074                success: false,
1075                output: String::new(),
1076                error: Some(format!(
1077                    "Agent '{agent_name}' has agentic=true but allowed_tools is empty"
1078                )),
1079            });
1080        }
1081
1082        let allowed = agent_config
1083            .allowed_tools
1084            .iter()
1085            .map(|name| name.trim())
1086            .filter(|name| !name.is_empty())
1087            .collect::<std::collections::HashSet<_>>();
1088
1089        let sub_tools: Vec<Box<dyn Tool>> = {
1090            let parent_tools = self.parent_tools.read();
1091            parent_tools
1092                .iter()
1093                .filter(|tool| allowed.contains(tool.name()))
1094                .filter(|tool| tool.name() != "delegate")
1095                .map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
1096                .collect()
1097        };
1098
1099        if sub_tools.is_empty() {
1100            return Ok(ToolResult {
1101                success: false,
1102                output: String::new(),
1103                error: Some(format!(
1104                    "Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
1105                    agent_config.allowed_tools.join(", ")
1106                )),
1107            });
1108        }
1109
1110        // Build enriched system prompt with tools, skills, workspace, datetime context.
1111        let enriched_system_prompt =
1112            self.build_enriched_system_prompt(agent_config, &sub_tools, &self.workspace_dir);
1113
1114        let mut history = Vec::new();
1115        if let Some(system_prompt) = enriched_system_prompt.as_ref() {
1116            history.push(ChatMessage::system(system_prompt.clone()));
1117        }
1118        history.push(ChatMessage::user(full_prompt.to_string()));
1119
1120        let noop_observer = NoopObserver;
1121
1122        let agentic_timeout_secs = agent_config
1123            .agentic_timeout_secs
1124            .unwrap_or(self.delegate_config.agentic_timeout_secs);
1125        let result = tokio::time::timeout(
1126            Duration::from_secs(agentic_timeout_secs),
1127            run_tool_call_loop(
1128                provider,
1129                &mut history,
1130                &sub_tools,
1131                &noop_observer,
1132                &agent_config.provider,
1133                &agent_config.model,
1134                temperature,
1135                true,
1136                None,
1137                "delegate",
1138                None,
1139                &self.multimodal_config,
1140                agent_config.max_iterations,
1141                None,
1142                None,
1143                None,
1144                &[],
1145                &[],
1146                None,
1147                None,
1148                &crate::config::PacingConfig::default(),
1149                0,    // max_tool_result_chars: inherit from parent config in future
1150                0,    // context_token_budget: 0 = disabled for subagents
1151                None, // shared_budget: TODO thread from parent in future
1152            ),
1153        )
1154        .await;
1155
1156        match result {
1157            Ok(Ok(response)) => {
1158                let rendered = if response.trim().is_empty() {
1159                    "[Empty response]".to_string()
1160                } else {
1161                    response
1162                };
1163
1164                Ok(ToolResult {
1165                    success: true,
1166                    output: format!(
1167                        "[Agent '{agent_name}' ({provider}/{model}, agentic)]\n{rendered}",
1168                        provider = agent_config.provider,
1169                        model = agent_config.model
1170                    ),
1171                    error: None,
1172                })
1173            }
1174            Ok(Err(e)) => Ok(ToolResult {
1175                success: false,
1176                output: String::new(),
1177                error: Some(format!("Agent '{agent_name}' failed: {e}")),
1178            }),
1179            Err(_) => Ok(ToolResult {
1180                success: false,
1181                output: String::new(),
1182                error: Some(format!(
1183                    "Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
1184                )),
1185            }),
1186        }
1187    }
1188}
1189
1190struct ToolArcRef {
1191    inner: Arc<dyn Tool>,
1192}
1193
1194impl ToolArcRef {
1195    fn new(inner: Arc<dyn Tool>) -> Self {
1196        Self { inner }
1197    }
1198}
1199
1200#[async_trait]
1201impl Tool for ToolArcRef {
1202    fn name(&self) -> &str {
1203        self.inner.name()
1204    }
1205
1206    fn description(&self) -> &str {
1207        self.inner.description()
1208    }
1209
1210    fn parameters_schema(&self) -> serde_json::Value {
1211        self.inner.parameters_schema()
1212    }
1213
1214    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1215        self.inner.execute(args).await
1216    }
1217}
1218
1219struct NoopObserver;
1220
1221impl Observer for NoopObserver {
1222    fn record_event(&self, _event: &ObserverEvent) {}
1223
1224    fn record_metric(&self, _metric: &ObserverMetric) {}
1225
1226    fn name(&self) -> &str {
1227        "noop"
1228    }
1229
1230    fn as_any(&self) -> &dyn std::any::Any {
1231        self
1232    }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238    use crate::config::schema::{
1239        DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
1240    };
1241    use crate::providers::{ChatRequest, ChatResponse, ToolCall};
1242    use crate::security::{AutonomyLevel, SecurityPolicy};
1243    use anyhow::anyhow;
1244
1245    fn test_security() -> Arc<SecurityPolicy> {
1246        Arc::new(SecurityPolicy::default())
1247    }
1248
1249    fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
1250        let mut agents = HashMap::new();
1251        agents.insert(
1252            "researcher".to_string(),
1253            DelegateAgentConfig {
1254                provider: "ollama".to_string(),
1255                model: "llama3".to_string(),
1256                system_prompt: Some("You are a research assistant.".to_string()),
1257                api_key: None,
1258                temperature: Some(0.3),
1259                max_depth: 3,
1260                agentic: false,
1261                allowed_tools: Vec::new(),
1262                max_iterations: 10,
1263                timeout_secs: None,
1264                agentic_timeout_secs: None,
1265                skills_directory: None,
1266            },
1267        );
1268        agents.insert(
1269            "coder".to_string(),
1270            DelegateAgentConfig {
1271                provider: "openrouter".to_string(),
1272                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1273                system_prompt: None,
1274                api_key: Some("delegate-test-credential".to_string()),
1275                temperature: None,
1276                max_depth: 2,
1277                agentic: false,
1278                allowed_tools: Vec::new(),
1279                max_iterations: 10,
1280                timeout_secs: None,
1281                agentic_timeout_secs: None,
1282                skills_directory: None,
1283            },
1284        );
1285        agents
1286    }
1287
1288    #[derive(Default)]
1289    struct EchoTool;
1290
1291    #[async_trait]
1292    impl Tool for EchoTool {
1293        fn name(&self) -> &str {
1294            "echo_tool"
1295        }
1296
1297        fn description(&self) -> &str {
1298            "Echoes the `value` argument."
1299        }
1300
1301        fn parameters_schema(&self) -> serde_json::Value {
1302            serde_json::json!({
1303                "type": "object",
1304                "properties": {
1305                    "value": {"type": "string"}
1306                },
1307                "required": ["value"]
1308            })
1309        }
1310
1311        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1312            let value = args
1313                .get("value")
1314                .and_then(serde_json::Value::as_str)
1315                .unwrap_or_default()
1316                .to_string();
1317            Ok(ToolResult {
1318                success: true,
1319                output: format!("echo:{value}"),
1320                error: None,
1321            })
1322        }
1323    }
1324
1325    struct OneToolThenFinalProvider;
1326
1327    #[async_trait]
1328    impl Provider for OneToolThenFinalProvider {
1329        async fn chat_with_system(
1330            &self,
1331            _system_prompt: Option<&str>,
1332            _message: &str,
1333            _model: &str,
1334            _temperature: f64,
1335        ) -> anyhow::Result<String> {
1336            Ok("unused".to_string())
1337        }
1338
1339        async fn chat(
1340            &self,
1341            request: ChatRequest<'_>,
1342            _model: &str,
1343            _temperature: f64,
1344        ) -> anyhow::Result<ChatResponse> {
1345            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1346            if has_tool_message {
1347                Ok(ChatResponse {
1348                    text: Some("done".to_string()),
1349                    tool_calls: Vec::new(),
1350                    usage: None,
1351                    reasoning_content: None,
1352                })
1353            } else {
1354                Ok(ChatResponse {
1355                    text: None,
1356                    tool_calls: vec![ToolCall {
1357                        id: "call_1".to_string(),
1358                        name: "echo_tool".to_string(),
1359                        arguments: "{\"value\":\"ping\"}".to_string(),
1360                    }],
1361                    usage: None,
1362                    reasoning_content: None,
1363                })
1364            }
1365        }
1366    }
1367
1368    struct InfiniteToolCallProvider;
1369
1370    #[async_trait]
1371    impl Provider for InfiniteToolCallProvider {
1372        async fn chat_with_system(
1373            &self,
1374            _system_prompt: Option<&str>,
1375            _message: &str,
1376            _model: &str,
1377            _temperature: f64,
1378        ) -> anyhow::Result<String> {
1379            Ok("unused".to_string())
1380        }
1381
1382        async fn chat(
1383            &self,
1384            _request: ChatRequest<'_>,
1385            _model: &str,
1386            _temperature: f64,
1387        ) -> anyhow::Result<ChatResponse> {
1388            Ok(ChatResponse {
1389                text: None,
1390                tool_calls: vec![ToolCall {
1391                    id: "loop".to_string(),
1392                    name: "echo_tool".to_string(),
1393                    arguments: "{\"value\":\"x\"}".to_string(),
1394                }],
1395                usage: None,
1396                reasoning_content: None,
1397            })
1398        }
1399    }
1400
1401    struct FailingProvider;
1402
1403    #[async_trait]
1404    impl Provider for FailingProvider {
1405        async fn chat_with_system(
1406            &self,
1407            _system_prompt: Option<&str>,
1408            _message: &str,
1409            _model: &str,
1410            _temperature: f64,
1411        ) -> anyhow::Result<String> {
1412            Ok("unused".to_string())
1413        }
1414
1415        async fn chat(
1416            &self,
1417            _request: ChatRequest<'_>,
1418            _model: &str,
1419            _temperature: f64,
1420        ) -> anyhow::Result<ChatResponse> {
1421            Err(anyhow!("provider boom"))
1422        }
1423    }
1424
1425    fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
1426        DelegateAgentConfig {
1427            provider: "openrouter".to_string(),
1428            model: "model-test".to_string(),
1429            system_prompt: Some("You are agentic.".to_string()),
1430            api_key: Some("delegate-test-credential".to_string()),
1431            temperature: Some(0.2),
1432            max_depth: 3,
1433            agentic: true,
1434            allowed_tools,
1435            max_iterations,
1436            timeout_secs: None,
1437            agentic_timeout_secs: None,
1438            skills_directory: None,
1439        }
1440    }
1441
1442    #[test]
1443    fn name_and_schema() {
1444        let tool = DelegateTool::new(sample_agents(), None, test_security());
1445        assert_eq!(tool.name(), "delegate");
1446        let schema = tool.parameters_schema();
1447        assert!(schema["properties"]["agent"].is_object());
1448        assert!(schema["properties"]["prompt"].is_object());
1449        assert!(schema["properties"]["context"].is_object());
1450        assert!(schema["properties"]["background"].is_object());
1451        assert!(schema["properties"]["parallel"].is_object());
1452        assert!(schema["properties"]["action"].is_object());
1453        assert!(schema["properties"]["task_id"].is_object());
1454        // required is empty because different actions need different params
1455        let required = schema["required"].as_array().unwrap();
1456        assert!(required.is_empty());
1457        assert_eq!(schema["additionalProperties"], json!(false));
1458        assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
1459        assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
1460    }
1461
1462    #[test]
1463    fn description_not_empty() {
1464        let tool = DelegateTool::new(sample_agents(), None, test_security());
1465        assert!(!tool.description().is_empty());
1466    }
1467
1468    #[test]
1469    fn schema_lists_agent_names() {
1470        let tool = DelegateTool::new(sample_agents(), None, test_security());
1471        let schema = tool.parameters_schema();
1472        let desc = schema["properties"]["agent"]["description"]
1473            .as_str()
1474            .unwrap();
1475        assert!(desc.contains("researcher") || desc.contains("coder"));
1476    }
1477
1478    #[tokio::test]
1479    async fn missing_agent_param() {
1480        let tool = DelegateTool::new(sample_agents(), None, test_security());
1481        let result = tool.execute(json!({"prompt": "test"})).await;
1482        assert!(result.is_err());
1483    }
1484
1485    #[tokio::test]
1486    async fn missing_prompt_param() {
1487        let tool = DelegateTool::new(sample_agents(), None, test_security());
1488        let result = tool.execute(json!({"agent": "researcher"})).await;
1489        assert!(result.is_err());
1490    }
1491
1492    #[tokio::test]
1493    async fn unknown_agent_returns_error() {
1494        let tool = DelegateTool::new(sample_agents(), None, test_security());
1495        let result = tool
1496            .execute(json!({"agent": "nonexistent", "prompt": "test"}))
1497            .await
1498            .unwrap();
1499        assert!(!result.success);
1500        assert!(result.error.unwrap().contains("Unknown agent"));
1501    }
1502
1503    #[tokio::test]
1504    async fn depth_limit_enforced() {
1505        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
1506        let result = tool
1507            .execute(json!({"agent": "researcher", "prompt": "test"}))
1508            .await
1509            .unwrap();
1510        assert!(!result.success);
1511        assert!(result.error.unwrap().contains("depth limit"));
1512    }
1513
1514    #[tokio::test]
1515    async fn depth_limit_per_agent() {
1516        // coder has max_depth=2, so depth=2 should be blocked
1517        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
1518        let result = tool
1519            .execute(json!({"agent": "coder", "prompt": "test"}))
1520            .await
1521            .unwrap();
1522        assert!(!result.success);
1523        assert!(result.error.unwrap().contains("depth limit"));
1524    }
1525
1526    #[test]
1527    fn empty_agents_schema() {
1528        let tool = DelegateTool::new(HashMap::new(), None, test_security());
1529        let schema = tool.parameters_schema();
1530        let desc = schema["properties"]["agent"]["description"]
1531            .as_str()
1532            .unwrap();
1533        assert!(desc.contains("none configured"));
1534    }
1535
1536    #[tokio::test]
1537    async fn invalid_provider_returns_error() {
1538        let mut agents = HashMap::new();
1539        agents.insert(
1540            "broken".to_string(),
1541            DelegateAgentConfig {
1542                provider: "totally-invalid-provider".to_string(),
1543                model: "model".to_string(),
1544                system_prompt: None,
1545                api_key: None,
1546                temperature: None,
1547                max_depth: 3,
1548                agentic: false,
1549                allowed_tools: Vec::new(),
1550                max_iterations: 10,
1551                timeout_secs: None,
1552                agentic_timeout_secs: None,
1553                skills_directory: None,
1554            },
1555        );
1556        let tool = DelegateTool::new(agents, None, test_security());
1557        let result = tool
1558            .execute(json!({"agent": "broken", "prompt": "test"}))
1559            .await
1560            .unwrap();
1561        assert!(!result.success);
1562        assert!(result.error.unwrap().contains("Failed to create provider"));
1563    }
1564
1565    #[tokio::test]
1566    async fn blank_agent_rejected() {
1567        let tool = DelegateTool::new(sample_agents(), None, test_security());
1568        let result = tool
1569            .execute(json!({"agent": "  ", "prompt": "test"}))
1570            .await
1571            .unwrap();
1572        assert!(!result.success);
1573        assert!(result.error.unwrap().contains("must not be empty"));
1574    }
1575
1576    #[tokio::test]
1577    async fn blank_prompt_rejected() {
1578        let tool = DelegateTool::new(sample_agents(), None, test_security());
1579        let result = tool
1580            .execute(json!({"agent": "researcher", "prompt": "  \t  "}))
1581            .await
1582            .unwrap();
1583        assert!(!result.success);
1584        assert!(result.error.unwrap().contains("must not be empty"));
1585    }
1586
1587    #[tokio::test]
1588    async fn whitespace_agent_name_trimmed_and_found() {
1589        let tool = DelegateTool::new(sample_agents(), None, test_security());
1590        // " researcher " with surrounding whitespace — after trim becomes "researcher"
1591        let result = tool
1592            .execute(json!({"agent": " researcher ", "prompt": "test"}))
1593            .await
1594            .unwrap();
1595        // Should find "researcher" after trim — will fail at provider level
1596        // since ollama isn't running, but must NOT get "Unknown agent".
1597        assert!(
1598            result.error.is_none()
1599                || !result
1600                    .error
1601                    .as_deref()
1602                    .unwrap_or("")
1603                    .contains("Unknown agent")
1604        );
1605    }
1606
1607    #[tokio::test]
1608    async fn delegation_blocked_in_readonly_mode() {
1609        let readonly = Arc::new(SecurityPolicy {
1610            autonomy: AutonomyLevel::ReadOnly,
1611            ..SecurityPolicy::default()
1612        });
1613        let tool = DelegateTool::new(sample_agents(), None, readonly);
1614        let result = tool
1615            .execute(json!({"agent": "researcher", "prompt": "test"}))
1616            .await
1617            .unwrap();
1618        assert!(!result.success);
1619        assert!(
1620            result
1621                .error
1622                .as_deref()
1623                .unwrap_or("")
1624                .contains("read-only mode")
1625        );
1626    }
1627
1628    #[tokio::test]
1629    async fn delegation_blocked_when_rate_limited() {
1630        let limited = Arc::new(SecurityPolicy {
1631            max_actions_per_hour: 0,
1632            ..SecurityPolicy::default()
1633        });
1634        let tool = DelegateTool::new(sample_agents(), None, limited);
1635        let result = tool
1636            .execute(json!({"agent": "researcher", "prompt": "test"}))
1637            .await
1638            .unwrap();
1639        assert!(!result.success);
1640        assert!(
1641            result
1642                .error
1643                .as_deref()
1644                .unwrap_or("")
1645                .contains("Rate limit exceeded")
1646        );
1647    }
1648
1649    #[tokio::test]
1650    async fn delegate_context_is_prepended_to_prompt() {
1651        let mut agents = HashMap::new();
1652        agents.insert(
1653            "tester".to_string(),
1654            DelegateAgentConfig {
1655                provider: "invalid-for-test".to_string(),
1656                model: "test-model".to_string(),
1657                system_prompt: None,
1658                api_key: None,
1659                temperature: None,
1660                max_depth: 3,
1661                agentic: false,
1662                allowed_tools: Vec::new(),
1663                max_iterations: 10,
1664                timeout_secs: None,
1665                agentic_timeout_secs: None,
1666                skills_directory: None,
1667            },
1668        );
1669        let tool = DelegateTool::new(agents, None, test_security());
1670        let result = tool
1671            .execute(json!({
1672                "agent": "tester",
1673                "prompt": "do something",
1674                "context": "some context data"
1675            }))
1676            .await
1677            .unwrap();
1678
1679        assert!(!result.success);
1680        assert!(
1681            result
1682                .error
1683                .as_deref()
1684                .unwrap_or("")
1685                .contains("Failed to create provider")
1686        );
1687    }
1688
1689    #[tokio::test]
1690    async fn delegate_empty_context_omits_prefix() {
1691        let mut agents = HashMap::new();
1692        agents.insert(
1693            "tester".to_string(),
1694            DelegateAgentConfig {
1695                provider: "invalid-for-test".to_string(),
1696                model: "test-model".to_string(),
1697                system_prompt: None,
1698                api_key: None,
1699                temperature: None,
1700                max_depth: 3,
1701                agentic: false,
1702                allowed_tools: Vec::new(),
1703                max_iterations: 10,
1704                timeout_secs: None,
1705                agentic_timeout_secs: None,
1706                skills_directory: None,
1707            },
1708        );
1709        let tool = DelegateTool::new(agents, None, test_security());
1710        let result = tool
1711            .execute(json!({
1712                "agent": "tester",
1713                "prompt": "do something",
1714                "context": ""
1715            }))
1716            .await
1717            .unwrap();
1718
1719        assert!(!result.success);
1720        assert!(
1721            result
1722                .error
1723                .as_deref()
1724                .unwrap_or("")
1725                .contains("Failed to create provider")
1726        );
1727    }
1728
1729    #[test]
1730    fn delegate_depth_construction() {
1731        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
1732        assert_eq!(tool.depth, 5);
1733    }
1734
1735    #[tokio::test]
1736    async fn delegate_no_agents_configured() {
1737        let tool = DelegateTool::new(HashMap::new(), None, test_security());
1738        let result = tool
1739            .execute(json!({"agent": "any", "prompt": "test"}))
1740            .await
1741            .unwrap();
1742        assert!(!result.success);
1743        assert!(result.error.unwrap().contains("none configured"));
1744    }
1745
1746    #[tokio::test]
1747    async fn agentic_mode_rejects_empty_allowed_tools() {
1748        let mut agents = HashMap::new();
1749        agents.insert("agentic".to_string(), agentic_config(Vec::new(), 10));
1750
1751        let tool = DelegateTool::new(agents, None, test_security());
1752        let result = tool
1753            .execute(json!({"agent": "agentic", "prompt": "test"}))
1754            .await
1755            .unwrap();
1756
1757        assert!(!result.success);
1758        assert!(
1759            result
1760                .error
1761                .as_deref()
1762                .unwrap_or("")
1763                .contains("allowed_tools is empty")
1764        );
1765    }
1766
1767    #[tokio::test]
1768    async fn agentic_mode_rejects_unmatched_allowed_tools() {
1769        let mut agents = HashMap::new();
1770        agents.insert(
1771            "agentic".to_string(),
1772            agentic_config(vec!["missing_tool".to_string()], 10),
1773        );
1774
1775        let tool = DelegateTool::new(agents, None, test_security())
1776            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1777        let result = tool
1778            .execute(json!({"agent": "agentic", "prompt": "test"}))
1779            .await
1780            .unwrap();
1781
1782        assert!(!result.success);
1783        assert!(
1784            result
1785                .error
1786                .as_deref()
1787                .unwrap_or("")
1788                .contains("no executable tools")
1789        );
1790    }
1791
1792    #[tokio::test]
1793    async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
1794        let config = agentic_config(vec!["echo_tool".to_string()], 10);
1795        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1796            Arc::new(RwLock::new(vec![
1797                Arc::new(EchoTool),
1798                Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
1799            ])),
1800        );
1801
1802        let provider = OneToolThenFinalProvider;
1803        let result = tool
1804            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1805            .await
1806            .unwrap();
1807
1808        assert!(result.success);
1809        assert!(result.output.contains("(openrouter/model-test, agentic)"));
1810        assert!(result.output.contains("done"));
1811    }
1812
1813    #[tokio::test]
1814    async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
1815        let config = agentic_config(vec!["delegate".to_string()], 10);
1816        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1817            Arc::new(RwLock::new(vec![Arc::new(DelegateTool::new(
1818                HashMap::new(),
1819                None,
1820                test_security(),
1821            ))])),
1822        );
1823
1824        let provider = OneToolThenFinalProvider;
1825        let result = tool
1826            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1827            .await
1828            .unwrap();
1829
1830        assert!(!result.success);
1831        assert!(
1832            result
1833                .error
1834                .as_deref()
1835                .unwrap_or("")
1836                .contains("no executable tools")
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn execute_agentic_respects_max_iterations() {
1842        let config = agentic_config(vec!["echo_tool".to_string()], 2);
1843        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1844            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1845
1846        let provider = InfiniteToolCallProvider;
1847        let result = tool
1848            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1849            .await
1850            .unwrap();
1851
1852        assert!(!result.success);
1853        assert!(
1854            result
1855                .error
1856                .as_deref()
1857                .unwrap_or("")
1858                .contains("maximum tool iterations (2)")
1859        );
1860    }
1861
1862    #[tokio::test]
1863    async fn execute_agentic_propagates_provider_errors() {
1864        let config = agentic_config(vec!["echo_tool".to_string()], 10);
1865        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1866            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1867
1868        let provider = FailingProvider;
1869        let result = tool
1870            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1871            .await
1872            .unwrap();
1873
1874        assert!(!result.success);
1875        assert!(
1876            result
1877                .error
1878                .as_deref()
1879                .unwrap_or("")
1880                .contains("provider boom")
1881        );
1882    }
1883
1884    /// MCP tools pushed into the shared parent_tools handle after DelegateTool
1885    /// construction must be visible to the sub-agent tool list.
1886    #[derive(Default)]
1887    struct FakeMcpTool;
1888
1889    #[async_trait]
1890    impl Tool for FakeMcpTool {
1891        fn name(&self) -> &str {
1892            "mcp_fake"
1893        }
1894
1895        fn description(&self) -> &str {
1896            "Fake MCP tool for testing."
1897        }
1898
1899        fn parameters_schema(&self) -> serde_json::Value {
1900            serde_json::json!({"type": "object", "properties": {}})
1901        }
1902
1903        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
1904            Ok(ToolResult {
1905                success: true,
1906                output: "mcp_fake_output".into(),
1907                error: None,
1908            })
1909        }
1910    }
1911
1912    struct McpToolThenFinalProvider;
1913
1914    #[async_trait]
1915    impl Provider for McpToolThenFinalProvider {
1916        async fn chat_with_system(
1917            &self,
1918            _system_prompt: Option<&str>,
1919            _message: &str,
1920            _model: &str,
1921            _temperature: f64,
1922        ) -> anyhow::Result<String> {
1923            Ok("unused".to_string())
1924        }
1925
1926        async fn chat(
1927            &self,
1928            request: ChatRequest<'_>,
1929            _model: &str,
1930            _temperature: f64,
1931        ) -> anyhow::Result<ChatResponse> {
1932            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1933            if has_tool_message {
1934                Ok(ChatResponse {
1935                    text: Some("mcp done".to_string()),
1936                    tool_calls: Vec::new(),
1937                    usage: None,
1938                    reasoning_content: None,
1939                })
1940            } else {
1941                Ok(ChatResponse {
1942                    text: None,
1943                    tool_calls: vec![ToolCall {
1944                        id: "call_mcp".to_string(),
1945                        name: "mcp_fake".to_string(),
1946                        arguments: "{}".to_string(),
1947                    }],
1948                    usage: None,
1949                    reasoning_content: None,
1950                })
1951            }
1952        }
1953    }
1954
1955    #[tokio::test]
1956    async fn mcp_tools_included_in_subagent_tool_list() {
1957        // Build DelegateTool with NO parent tools initially
1958        let config = agentic_config(vec!["mcp_fake".to_string()], 10);
1959        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1960            .with_parent_tools(Arc::new(RwLock::new(Vec::new())));
1961
1962        // Simulate late MCP tool injection via the shared handle
1963        let handle = tool.parent_tools_handle();
1964        handle.write().push(Arc::new(FakeMcpTool));
1965
1966        let provider = McpToolThenFinalProvider;
1967        let result = tool
1968            .execute_agentic("agentic", &config, &provider, "run mcp", 0.2)
1969            .await
1970            .unwrap();
1971
1972        assert!(result.success, "Expected success, got: {:?}", result.error);
1973        assert!(
1974            result.output.contains("mcp done"),
1975            "Expected output containing 'mcp done', got: {}",
1976            result.output
1977        );
1978    }
1979
1980    #[test]
1981    fn enriched_prompt_includes_tools_workspace_datetime() {
1982        let config = DelegateAgentConfig {
1983            provider: "openrouter".to_string(),
1984            model: "test-model".to_string(),
1985            system_prompt: Some("You are a code reviewer.".to_string()),
1986            api_key: None,
1987            temperature: None,
1988            max_depth: 3,
1989            agentic: true,
1990            allowed_tools: vec!["echo_tool".to_string()],
1991            max_iterations: 10,
1992            timeout_secs: None,
1993            agentic_timeout_secs: None,
1994            skills_directory: None,
1995        };
1996
1997        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
1998        let workspace = std::env::temp_dir().join(format!(
1999            "construct_delegate_enrich_test_{}",
2000            uuid::Uuid::new_v4()
2001        ));
2002        std::fs::create_dir_all(&workspace).unwrap();
2003
2004        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2005            .with_workspace_dir(workspace.clone());
2006
2007        let prompt = tool
2008            .build_enriched_system_prompt(&config, &tools, &workspace)
2009            .unwrap();
2010
2011        assert!(prompt.contains("## Tools"), "should contain tools section");
2012        assert!(prompt.contains("echo_tool"), "should list allowed tools");
2013        assert!(
2014            prompt.contains("## Workspace"),
2015            "should contain workspace section"
2016        );
2017        assert!(
2018            prompt.contains(&workspace.display().to_string()),
2019            "should contain workspace path"
2020        );
2021        assert!(
2022            prompt.contains("## CRITICAL CONTEXT: CURRENT DATE & TIME"),
2023            "should contain datetime section"
2024        );
2025        assert!(
2026            prompt.contains("You are a code reviewer."),
2027            "should append operator system_prompt"
2028        );
2029
2030        let _ = std::fs::remove_dir_all(workspace);
2031    }
2032
2033    #[test]
2034    fn enriched_prompt_includes_shell_policy_when_shell_present() {
2035        let config = DelegateAgentConfig {
2036            provider: "openrouter".to_string(),
2037            model: "test-model".to_string(),
2038            system_prompt: None,
2039            api_key: None,
2040            temperature: None,
2041            max_depth: 3,
2042            agentic: true,
2043            allowed_tools: vec!["shell".to_string()],
2044            max_iterations: 10,
2045            timeout_secs: None,
2046            agentic_timeout_secs: None,
2047            skills_directory: None,
2048        };
2049
2050        struct MockShellTool;
2051        #[async_trait]
2052        impl Tool for MockShellTool {
2053            fn name(&self) -> &str {
2054                "shell"
2055            }
2056            fn description(&self) -> &str {
2057                "Execute shell commands"
2058            }
2059            fn parameters_schema(&self) -> serde_json::Value {
2060                json!({"type": "object"})
2061            }
2062            async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2063                Ok(ToolResult {
2064                    success: true,
2065                    output: String::new(),
2066                    error: None,
2067                })
2068            }
2069        }
2070
2071        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockShellTool)];
2072        let workspace = std::env::temp_dir();
2073
2074        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2075            .with_workspace_dir(workspace.to_path_buf());
2076
2077        let prompt = tool
2078            .build_enriched_system_prompt(&config, &tools, &workspace)
2079            .unwrap();
2080
2081        assert!(
2082            prompt.contains("## Shell Policy"),
2083            "should contain shell policy when shell tool is present"
2084        );
2085    }
2086
2087    #[test]
2088    fn parent_tools_handle_returns_shared_reference() {
2089        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
2090            Arc::new(RwLock::new(vec![Arc::new(EchoTool) as Arc<dyn Tool>])),
2091        );
2092
2093        let handle = tool.parent_tools_handle();
2094        assert_eq!(handle.read().len(), 1);
2095
2096        // Push a new tool via the handle
2097        handle.write().push(Arc::new(FakeMcpTool));
2098        assert_eq!(handle.read().len(), 2);
2099    }
2100
2101    // ── Configurable timeout tests ──────────────────────────────────
2102
2103    #[test]
2104    fn default_timeout_values_used_when_config_unset() {
2105        let config = DelegateAgentConfig {
2106            provider: "ollama".to_string(),
2107            model: "llama3".to_string(),
2108            system_prompt: None,
2109            api_key: None,
2110            temperature: None,
2111            max_depth: 3,
2112            agentic: false,
2113            allowed_tools: Vec::new(),
2114            max_iterations: 10,
2115            timeout_secs: None,
2116            agentic_timeout_secs: None,
2117            skills_directory: None,
2118        };
2119        assert_eq!(
2120            config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2121            120
2122        );
2123        assert_eq!(
2124            config
2125                .agentic_timeout_secs
2126                .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2127            300
2128        );
2129    }
2130
2131    #[test]
2132    fn enriched_prompt_omits_shell_policy_without_shell_tool() {
2133        let config = DelegateAgentConfig {
2134            provider: "openrouter".to_string(),
2135            model: "test-model".to_string(),
2136            system_prompt: None,
2137            api_key: None,
2138            temperature: None,
2139            max_depth: 3,
2140            agentic: true,
2141            allowed_tools: vec!["echo_tool".to_string()],
2142            max_iterations: 10,
2143            timeout_secs: None,
2144            agentic_timeout_secs: None,
2145            skills_directory: None,
2146        };
2147
2148        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2149        let workspace = std::env::temp_dir();
2150
2151        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2152            .with_workspace_dir(workspace.to_path_buf());
2153
2154        let prompt = tool
2155            .build_enriched_system_prompt(&config, &tools, &workspace)
2156            .unwrap();
2157
2158        assert!(
2159            !prompt.contains("## Shell Policy"),
2160            "should not contain shell policy when shell tool is absent"
2161        );
2162    }
2163
2164    #[test]
2165    fn custom_timeout_values_are_respected() {
2166        let config = DelegateAgentConfig {
2167            provider: "ollama".to_string(),
2168            model: "llama3".to_string(),
2169            system_prompt: None,
2170            api_key: None,
2171            temperature: None,
2172            max_depth: 3,
2173            agentic: false,
2174            allowed_tools: Vec::new(),
2175            max_iterations: 10,
2176            timeout_secs: Some(60),
2177            agentic_timeout_secs: Some(600),
2178            skills_directory: None,
2179        };
2180        assert_eq!(
2181            config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2182            60
2183        );
2184        assert_eq!(
2185            config
2186                .agentic_timeout_secs
2187                .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2188            600
2189        );
2190    }
2191
2192    #[test]
2193    fn timeout_deserialization_defaults_to_none() {
2194        let toml_str = r#"
2195            provider = "ollama"
2196            model = "llama3"
2197        "#;
2198        let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2199        assert!(config.timeout_secs.is_none());
2200        assert!(config.agentic_timeout_secs.is_none());
2201    }
2202
2203    #[test]
2204    fn timeout_deserialization_with_custom_values() {
2205        let toml_str = r#"
2206            provider = "ollama"
2207            model = "llama3"
2208            timeout_secs = 45
2209            agentic_timeout_secs = 900
2210        "#;
2211        let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2212        assert_eq!(config.timeout_secs, Some(45));
2213        assert_eq!(config.agentic_timeout_secs, Some(900));
2214    }
2215
2216    #[test]
2217    fn config_validation_rejects_zero_timeout() {
2218        let mut config = crate::config::Config::default();
2219        config.agents.insert(
2220            "bad".into(),
2221            DelegateAgentConfig {
2222                provider: "ollama".into(),
2223                model: "llama3".into(),
2224                system_prompt: None,
2225                api_key: None,
2226                temperature: None,
2227                max_depth: 3,
2228                agentic: false,
2229                allowed_tools: Vec::new(),
2230                max_iterations: 10,
2231                timeout_secs: Some(0),
2232                agentic_timeout_secs: None,
2233                skills_directory: None,
2234            },
2235        );
2236        let err = config.validate().unwrap_err();
2237        assert!(
2238            format!("{err}").contains("timeout_secs must be greater than 0"),
2239            "unexpected error: {err}"
2240        );
2241    }
2242
2243    #[test]
2244    fn config_validation_rejects_zero_agentic_timeout() {
2245        let mut config = crate::config::Config::default();
2246        config.agents.insert(
2247            "bad".into(),
2248            DelegateAgentConfig {
2249                provider: "ollama".into(),
2250                model: "llama3".into(),
2251                system_prompt: None,
2252                api_key: None,
2253                temperature: None,
2254                max_depth: 3,
2255                agentic: false,
2256                allowed_tools: Vec::new(),
2257                max_iterations: 10,
2258                timeout_secs: None,
2259                agentic_timeout_secs: Some(0),
2260                skills_directory: None,
2261            },
2262        );
2263        let err = config.validate().unwrap_err();
2264        assert!(
2265            format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
2266            "unexpected error: {err}"
2267        );
2268    }
2269
2270    #[test]
2271    fn config_validation_rejects_excessive_timeout() {
2272        let mut config = crate::config::Config::default();
2273        config.agents.insert(
2274            "bad".into(),
2275            DelegateAgentConfig {
2276                provider: "ollama".into(),
2277                model: "llama3".into(),
2278                system_prompt: None,
2279                api_key: None,
2280                temperature: None,
2281                max_depth: 3,
2282                agentic: false,
2283                allowed_tools: Vec::new(),
2284                max_iterations: 10,
2285                timeout_secs: Some(7200),
2286                agentic_timeout_secs: None,
2287                skills_directory: None,
2288            },
2289        );
2290        let err = config.validate().unwrap_err();
2291        assert!(
2292            format!("{err}").contains("exceeds max 3600"),
2293            "unexpected error: {err}"
2294        );
2295    }
2296
2297    #[test]
2298    fn config_validation_rejects_excessive_agentic_timeout() {
2299        let mut config = crate::config::Config::default();
2300        config.agents.insert(
2301            "bad".into(),
2302            DelegateAgentConfig {
2303                provider: "ollama".into(),
2304                model: "llama3".into(),
2305                system_prompt: None,
2306                api_key: None,
2307                temperature: None,
2308                max_depth: 3,
2309                agentic: false,
2310                allowed_tools: Vec::new(),
2311                max_iterations: 10,
2312                timeout_secs: None,
2313                agentic_timeout_secs: Some(5000),
2314                skills_directory: None,
2315            },
2316        );
2317        let err = config.validate().unwrap_err();
2318        assert!(
2319            format!("{err}").contains("exceeds max 3600"),
2320            "unexpected error: {err}"
2321        );
2322    }
2323
2324    #[test]
2325    fn config_validation_accepts_max_boundary_timeout() {
2326        let mut config = crate::config::Config::default();
2327        config.agents.insert(
2328            "ok".into(),
2329            DelegateAgentConfig {
2330                provider: "ollama".into(),
2331                model: "llama3".into(),
2332                system_prompt: None,
2333                api_key: None,
2334                temperature: None,
2335                max_depth: 3,
2336                agentic: false,
2337                allowed_tools: Vec::new(),
2338                max_iterations: 10,
2339                timeout_secs: Some(3600),
2340                agentic_timeout_secs: Some(3600),
2341                skills_directory: None,
2342            },
2343        );
2344        assert!(config.validate().is_ok());
2345    }
2346
2347    #[test]
2348    fn config_validation_accepts_none_timeouts() {
2349        let mut config = crate::config::Config::default();
2350        config.agents.insert(
2351            "ok".into(),
2352            DelegateAgentConfig {
2353                provider: "ollama".into(),
2354                model: "llama3".into(),
2355                system_prompt: None,
2356                api_key: None,
2357                temperature: None,
2358                max_depth: 3,
2359                agentic: false,
2360                allowed_tools: Vec::new(),
2361                max_iterations: 10,
2362                timeout_secs: None,
2363                agentic_timeout_secs: None,
2364                skills_directory: None,
2365            },
2366        );
2367        assert!(config.validate().is_ok());
2368    }
2369
2370    #[test]
2371    fn enriched_prompt_loads_skills_from_scoped_directory() {
2372        let workspace = std::env::temp_dir().join(format!(
2373            "construct_delegate_skills_test_{}",
2374            uuid::Uuid::new_v4()
2375        ));
2376        let scoped_skills_dir = workspace.join("skills/code-review");
2377        std::fs::create_dir_all(scoped_skills_dir.join("lint-check")).unwrap();
2378        std::fs::write(
2379            scoped_skills_dir.join("lint-check/SKILL.toml"),
2380            "[skill]\nname = \"lint-check\"\ndescription = \"Run lint checks\"\nversion = \"1.0.0\"\n",
2381        )
2382        .unwrap();
2383
2384        let config = DelegateAgentConfig {
2385            provider: "openrouter".to_string(),
2386            model: "test-model".to_string(),
2387            system_prompt: None,
2388            api_key: None,
2389            temperature: None,
2390            max_depth: 3,
2391            agentic: true,
2392            allowed_tools: vec!["echo_tool".to_string()],
2393            max_iterations: 10,
2394            timeout_secs: None,
2395            agentic_timeout_secs: None,
2396            skills_directory: Some("skills/code-review".to_string()),
2397        };
2398
2399        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2400
2401        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2402            .with_workspace_dir(workspace.clone());
2403
2404        let prompt = tool
2405            .build_enriched_system_prompt(&config, &tools, &workspace)
2406            .unwrap();
2407
2408        assert!(
2409            prompt.contains("lint-check"),
2410            "should contain skills from scoped directory"
2411        );
2412
2413        let _ = std::fs::remove_dir_all(workspace);
2414    }
2415
2416    #[test]
2417    fn enriched_prompt_falls_back_to_default_skills_dir() {
2418        let workspace = std::env::temp_dir().join(format!(
2419            "construct_delegate_fallback_test_{}",
2420            uuid::Uuid::new_v4()
2421        ));
2422        let default_skills_dir = workspace.join("skills");
2423        std::fs::create_dir_all(default_skills_dir.join("deploy")).unwrap();
2424        std::fs::write(
2425            default_skills_dir.join("deploy/SKILL.toml"),
2426            "[skill]\nname = \"deploy\"\ndescription = \"Deploy safely\"\nversion = \"1.0.0\"\n",
2427        )
2428        .unwrap();
2429
2430        let config = DelegateAgentConfig {
2431            provider: "openrouter".to_string(),
2432            model: "test-model".to_string(),
2433            system_prompt: None,
2434            api_key: None,
2435            temperature: None,
2436            max_depth: 3,
2437            agentic: true,
2438            allowed_tools: vec!["echo_tool".to_string()],
2439            max_iterations: 10,
2440            timeout_secs: None,
2441            agentic_timeout_secs: None,
2442            skills_directory: None,
2443        };
2444
2445        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2446
2447        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2448            .with_workspace_dir(workspace.clone());
2449
2450        let prompt = tool
2451            .build_enriched_system_prompt(&config, &tools, &workspace)
2452            .unwrap();
2453
2454        assert!(
2455            prompt.contains("deploy"),
2456            "should contain skills from default workspace skills/ directory"
2457        );
2458
2459        let _ = std::fs::remove_dir_all(workspace);
2460    }
2461
2462    // ── Background and Parallel execution tests ─────────────────────
2463
2464    #[tokio::test]
2465    async fn background_delegation_returns_task_id() {
2466        let workspace = std::env::temp_dir().join(format!(
2467            "construct_delegate_bg_test_{}",
2468            uuid::Uuid::new_v4()
2469        ));
2470        std::fs::create_dir_all(&workspace).unwrap();
2471
2472        let tool = DelegateTool::new(sample_agents(), None, test_security())
2473            .with_workspace_dir(workspace.clone());
2474        let result = tool
2475            .execute(json!({
2476                "agent": "researcher",
2477                "prompt": "test background",
2478                "background": true
2479            }))
2480            .await
2481            .unwrap();
2482
2483        // The agent will fail at provider level (ollama not running),
2484        // but the background task should be spawned and return a task_id.
2485        assert!(result.success);
2486        assert!(result.output.contains("task_id:"));
2487        assert!(result.output.contains("Background task started"));
2488
2489        // Wait a moment for the background task to write its result
2490        tokio::time::sleep(Duration::from_millis(200)).await;
2491
2492        // The results directory should exist
2493        assert!(workspace.join("delegate_results").exists());
2494
2495        let _ = std::fs::remove_dir_all(workspace);
2496    }
2497
2498    #[tokio::test]
2499    async fn background_unknown_agent_rejected() {
2500        let workspace = std::env::temp_dir().join(format!(
2501            "construct_delegate_bg_unknown_{}",
2502            uuid::Uuid::new_v4()
2503        ));
2504        std::fs::create_dir_all(&workspace).unwrap();
2505
2506        let tool = DelegateTool::new(sample_agents(), None, test_security())
2507            .with_workspace_dir(workspace.clone());
2508        let result = tool
2509            .execute(json!({
2510                "agent": "nonexistent",
2511                "prompt": "test",
2512                "background": true
2513            }))
2514            .await
2515            .unwrap();
2516
2517        assert!(!result.success);
2518        assert!(result.error.unwrap().contains("Unknown agent"));
2519
2520        let _ = std::fs::remove_dir_all(workspace);
2521    }
2522
2523    #[tokio::test]
2524    async fn check_result_missing_task_id() {
2525        let workspace = std::env::temp_dir().join(format!(
2526            "construct_delegate_check_noid_{}",
2527            uuid::Uuid::new_v4()
2528        ));
2529        std::fs::create_dir_all(&workspace).unwrap();
2530
2531        let tool = DelegateTool::new(sample_agents(), None, test_security())
2532            .with_workspace_dir(workspace.clone());
2533        let result = tool.execute(json!({"action": "check_result"})).await;
2534
2535        assert!(result.is_err());
2536
2537        let _ = std::fs::remove_dir_all(workspace);
2538    }
2539
2540    #[tokio::test]
2541    async fn check_result_nonexistent_task() {
2542        let workspace = std::env::temp_dir().join(format!(
2543            "construct_delegate_check_miss_{}",
2544            uuid::Uuid::new_v4()
2545        ));
2546        std::fs::create_dir_all(&workspace).unwrap();
2547
2548        let tool = DelegateTool::new(sample_agents(), None, test_security())
2549            .with_workspace_dir(workspace.clone());
2550        // Use a valid UUID format that doesn't correspond to any real task
2551        let fake_uuid = uuid::Uuid::new_v4().to_string();
2552        let result = tool
2553            .execute(json!({
2554                "action": "check_result",
2555                "task_id": fake_uuid
2556            }))
2557            .await
2558            .unwrap();
2559
2560        assert!(!result.success);
2561        assert!(result.error.unwrap().contains("No result found"));
2562
2563        let _ = std::fs::remove_dir_all(workspace);
2564    }
2565
2566    #[tokio::test]
2567    async fn list_results_empty() {
2568        let workspace = std::env::temp_dir().join(format!(
2569            "construct_delegate_list_empty_{}",
2570            uuid::Uuid::new_v4()
2571        ));
2572        std::fs::create_dir_all(&workspace).unwrap();
2573
2574        let tool = DelegateTool::new(sample_agents(), None, test_security())
2575            .with_workspace_dir(workspace.clone());
2576        let result = tool
2577            .execute(json!({"action": "list_results"}))
2578            .await
2579            .unwrap();
2580
2581        assert!(result.success);
2582        assert!(result.output.contains("No background delegate results"));
2583
2584        let _ = std::fs::remove_dir_all(workspace);
2585    }
2586
2587    #[tokio::test]
2588    async fn parallel_empty_list_rejected() {
2589        let tool = DelegateTool::new(sample_agents(), None, test_security());
2590        let result = tool
2591            .execute(json!({
2592                "parallel": [],
2593                "prompt": "test"
2594            }))
2595            .await
2596            .unwrap();
2597
2598        assert!(!result.success);
2599        assert!(result.error.unwrap().contains("at least one agent"));
2600    }
2601
2602    #[tokio::test]
2603    async fn parallel_unknown_agent_rejected() {
2604        let tool = DelegateTool::new(sample_agents(), None, test_security());
2605        let result = tool
2606            .execute(json!({
2607                "parallel": ["researcher", "nonexistent"],
2608                "prompt": "test"
2609            }))
2610            .await
2611            .unwrap();
2612
2613        assert!(!result.success);
2614        assert!(result.error.unwrap().contains("Unknown agent"));
2615    }
2616
2617    #[tokio::test]
2618    async fn parallel_missing_prompt_rejected() {
2619        let tool = DelegateTool::new(sample_agents(), None, test_security());
2620        let result = tool
2621            .execute(json!({
2622                "parallel": ["researcher"]
2623            }))
2624            .await;
2625
2626        assert!(result.is_err());
2627    }
2628
2629    #[tokio::test]
2630    async fn unknown_action_rejected() {
2631        let tool = DelegateTool::new(sample_agents(), None, test_security());
2632        let result = tool
2633            .execute(json!({"action": "invalid_action"}))
2634            .await
2635            .unwrap();
2636
2637        assert!(!result.success);
2638        assert!(result.error.unwrap().contains("Unknown action"));
2639    }
2640
2641    #[tokio::test]
2642    async fn cancel_task_nonexistent() {
2643        let workspace = std::env::temp_dir().join(format!(
2644            "construct_delegate_cancel_miss_{}",
2645            uuid::Uuid::new_v4()
2646        ));
2647        std::fs::create_dir_all(&workspace).unwrap();
2648
2649        let tool = DelegateTool::new(sample_agents(), None, test_security())
2650            .with_workspace_dir(workspace.clone());
2651        // Use a valid UUID format that doesn't correspond to any real task
2652        let fake_uuid = uuid::Uuid::new_v4().to_string();
2653        let result = tool
2654            .execute(json!({
2655                "action": "cancel_task",
2656                "task_id": fake_uuid
2657            }))
2658            .await
2659            .unwrap();
2660
2661        assert!(!result.success);
2662        assert!(result.error.unwrap().contains("No task found"));
2663
2664        let _ = std::fs::remove_dir_all(workspace);
2665    }
2666
2667    #[test]
2668    fn cancellation_token_accessor() {
2669        let tool = DelegateTool::new(sample_agents(), None, test_security());
2670        let token = tool.cancellation_token();
2671        assert!(!token.is_cancelled());
2672
2673        tool.cancel_all_background_tasks();
2674        assert!(token.is_cancelled());
2675    }
2676
2677    #[test]
2678    fn with_cancellation_token_replaces_default() {
2679        let custom_token = CancellationToken::new();
2680        let tool = DelegateTool::new(sample_agents(), None, test_security())
2681            .with_cancellation_token(custom_token.clone());
2682
2683        assert!(!tool.cancellation_token().is_cancelled());
2684        custom_token.cancel();
2685        assert!(tool.cancellation_token().is_cancelled());
2686    }
2687
2688    #[tokio::test]
2689    async fn background_task_result_persisted_to_disk() {
2690        let workspace = std::env::temp_dir().join(format!(
2691            "construct_delegate_bg_persist_{}",
2692            uuid::Uuid::new_v4()
2693        ));
2694        std::fs::create_dir_all(&workspace).unwrap();
2695
2696        let tool = DelegateTool::new(sample_agents(), None, test_security())
2697            .with_workspace_dir(workspace.clone());
2698
2699        let result = tool
2700            .execute(json!({
2701                "agent": "researcher",
2702                "prompt": "persistence test",
2703                "background": true
2704            }))
2705            .await
2706            .unwrap();
2707
2708        assert!(result.success);
2709
2710        // Extract task_id from output
2711        let task_id = result
2712            .output
2713            .lines()
2714            .find(|l| l.starts_with("task_id:"))
2715            .unwrap()
2716            .trim_start_matches("task_id: ")
2717            .trim();
2718
2719        // Wait for the background task to finish
2720        tokio::time::sleep(Duration::from_millis(500)).await;
2721
2722        // Check that the result file exists
2723        let result_path = workspace
2724            .join("delegate_results")
2725            .join(format!("{task_id}.json"));
2726        assert!(
2727            result_path.exists(),
2728            "Result file should exist at {result_path:?}"
2729        );
2730
2731        // Read and parse the result
2732        let content = std::fs::read_to_string(&result_path).unwrap();
2733        let bg_result: BackgroundDelegateResult = serde_json::from_str(&content).unwrap();
2734        assert_eq!(bg_result.task_id, task_id);
2735        assert_eq!(bg_result.agent, "researcher");
2736        // The task will have failed because ollama isn't running, but it should be persisted
2737        assert!(
2738            bg_result.status == BackgroundTaskStatus::Completed
2739                || bg_result.status == BackgroundTaskStatus::Failed
2740        );
2741        assert!(bg_result.finished_at.is_some());
2742
2743        let _ = std::fs::remove_dir_all(workspace);
2744    }
2745
2746    #[tokio::test]
2747    async fn check_result_retrieves_persisted_background_result() {
2748        let workspace = std::env::temp_dir().join(format!(
2749            "construct_delegate_check_retrieve_{}",
2750            uuid::Uuid::new_v4()
2751        ));
2752        std::fs::create_dir_all(&workspace).unwrap();
2753
2754        let tool = DelegateTool::new(sample_agents(), None, test_security())
2755            .with_workspace_dir(workspace.clone());
2756
2757        // Start background task
2758        let result = tool
2759            .execute(json!({
2760                "agent": "researcher",
2761                "prompt": "retrieval test",
2762                "background": true
2763            }))
2764            .await
2765            .unwrap();
2766
2767        let task_id = result
2768            .output
2769            .lines()
2770            .find(|l| l.starts_with("task_id:"))
2771            .unwrap()
2772            .trim_start_matches("task_id: ")
2773            .trim()
2774            .to_string();
2775
2776        // Wait for background task
2777        tokio::time::sleep(Duration::from_millis(500)).await;
2778
2779        // Check result
2780        let check = tool
2781            .execute(json!({
2782                "action": "check_result",
2783                "task_id": task_id
2784            }))
2785            .await
2786            .unwrap();
2787
2788        // The output should contain the serialized result
2789        assert!(check.output.contains(&task_id));
2790        assert!(check.output.contains("researcher"));
2791
2792        let _ = std::fs::remove_dir_all(workspace);
2793    }
2794
2795    #[tokio::test]
2796    async fn list_results_includes_background_tasks() {
2797        let workspace = std::env::temp_dir().join(format!(
2798            "construct_delegate_list_tasks_{}",
2799            uuid::Uuid::new_v4()
2800        ));
2801        std::fs::create_dir_all(&workspace).unwrap();
2802
2803        let tool = DelegateTool::new(sample_agents(), None, test_security())
2804            .with_workspace_dir(workspace.clone());
2805
2806        // Start a background task
2807        let result = tool
2808            .execute(json!({
2809                "agent": "researcher",
2810                "prompt": "list test",
2811                "background": true
2812            }))
2813            .await
2814            .unwrap();
2815        assert!(result.success);
2816
2817        // Wait for task to complete
2818        tokio::time::sleep(Duration::from_millis(500)).await;
2819
2820        // List results
2821        let list = tool
2822            .execute(json!({"action": "list_results"}))
2823            .await
2824            .unwrap();
2825
2826        assert!(list.success);
2827        assert!(list.output.contains("researcher"));
2828
2829        let _ = std::fs::remove_dir_all(workspace);
2830    }
2831
2832    #[tokio::test]
2833    async fn default_action_is_delegate() {
2834        // Calling without action should behave like "delegate"
2835        let tool = DelegateTool::new(sample_agents(), None, test_security());
2836        let result = tool
2837            .execute(json!({"agent": "researcher", "prompt": "test"}))
2838            .await
2839            .unwrap();
2840        // Should proceed to delegation (will fail at provider since ollama isn't running)
2841        // but should NOT fail with "Unknown action" error
2842        assert!(
2843            result.error.is_none()
2844                || !result
2845                    .error
2846                    .as_deref()
2847                    .unwrap_or("")
2848                    .contains("Unknown action")
2849        );
2850    }
2851
2852    #[tokio::test]
2853    async fn check_result_rejects_path_traversal() {
2854        let workspace = std::env::temp_dir().join(format!(
2855            "construct_delegate_traversal_check_{}",
2856            uuid::Uuid::new_v4()
2857        ));
2858        std::fs::create_dir_all(&workspace).unwrap();
2859
2860        let tool = DelegateTool::new(sample_agents(), None, test_security())
2861            .with_workspace_dir(workspace.clone());
2862        let result = tool
2863            .execute(json!({
2864                "action": "check_result",
2865                "task_id": "../../etc/passwd"
2866            }))
2867            .await
2868            .unwrap();
2869
2870        assert!(!result.success);
2871        assert!(result.error.unwrap().contains("Invalid task_id"));
2872
2873        let _ = std::fs::remove_dir_all(workspace);
2874    }
2875
2876    #[tokio::test]
2877    async fn cancel_task_rejects_path_traversal() {
2878        let workspace = std::env::temp_dir().join(format!(
2879            "construct_delegate_traversal_cancel_{}",
2880            uuid::Uuid::new_v4()
2881        ));
2882        std::fs::create_dir_all(&workspace).unwrap();
2883
2884        let tool = DelegateTool::new(sample_agents(), None, test_security())
2885            .with_workspace_dir(workspace.clone());
2886        let result = tool
2887            .execute(json!({
2888                "action": "cancel_task",
2889                "task_id": "../../../etc/shadow"
2890            }))
2891            .await
2892            .unwrap();
2893
2894        assert!(!result.success);
2895        assert!(result.error.unwrap().contains("Invalid task_id"));
2896
2897        let _ = std::fs::remove_dir_all(workspace);
2898    }
2899}