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            identity_config: None,
1027            dispatcher_instructions: "",
1028            tool_descriptions: None,
1029            security_summary: None,
1030            autonomy_level: crate::security::AutonomyLevel::default(),
1031            operator_enabled: false,
1032            kumiho_enabled: false,
1033        };
1034
1035        let builder = SystemPromptBuilder::default()
1036            .add_section(Box::new(crate::agent::prompt::ToolsSection))
1037            .add_section(Box::new(crate::agent::prompt::SafetySection))
1038            .add_section(Box::new(crate::agent::prompt::SkillsSection))
1039            .add_section(Box::new(crate::agent::prompt::WorkspaceSection))
1040            .add_section(Box::new(crate::agent::prompt::DateTimeSection));
1041
1042        let mut enriched = builder.build(&ctx).unwrap_or_default();
1043
1044        if !shell_policy.is_empty() {
1045            enriched.push_str(&shell_policy);
1046            enriched.push_str("\n\n");
1047        }
1048
1049        // Append the operator-configured system_prompt as the identity/role block.
1050        if let Some(operator_prompt) = agent_config.system_prompt.as_ref() {
1051            enriched.push_str(operator_prompt);
1052            enriched.push('\n');
1053        }
1054
1055        let trimmed = enriched.trim().to_string();
1056        if trimmed.is_empty() {
1057            None
1058        } else {
1059            Some(trimmed)
1060        }
1061    }
1062
1063    async fn execute_agentic(
1064        &self,
1065        agent_name: &str,
1066        agent_config: &DelegateAgentConfig,
1067        provider: &dyn Provider,
1068        full_prompt: &str,
1069        temperature: f64,
1070    ) -> anyhow::Result<ToolResult> {
1071        if agent_config.allowed_tools.is_empty() {
1072            return Ok(ToolResult {
1073                success: false,
1074                output: String::new(),
1075                error: Some(format!(
1076                    "Agent '{agent_name}' has agentic=true but allowed_tools is empty"
1077                )),
1078            });
1079        }
1080
1081        let allowed = agent_config
1082            .allowed_tools
1083            .iter()
1084            .map(|name| name.trim())
1085            .filter(|name| !name.is_empty())
1086            .collect::<std::collections::HashSet<_>>();
1087
1088        let sub_tools: Vec<Box<dyn Tool>> = {
1089            let parent_tools = self.parent_tools.read();
1090            parent_tools
1091                .iter()
1092                .filter(|tool| allowed.contains(tool.name()))
1093                .filter(|tool| tool.name() != "delegate")
1094                .map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
1095                .collect()
1096        };
1097
1098        if sub_tools.is_empty() {
1099            return Ok(ToolResult {
1100                success: false,
1101                output: String::new(),
1102                error: Some(format!(
1103                    "Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
1104                    agent_config.allowed_tools.join(", ")
1105                )),
1106            });
1107        }
1108
1109        // Build enriched system prompt with tools, skills, workspace, datetime context.
1110        let enriched_system_prompt =
1111            self.build_enriched_system_prompt(agent_config, &sub_tools, &self.workspace_dir);
1112
1113        let mut history = Vec::new();
1114        if let Some(system_prompt) = enriched_system_prompt.as_ref() {
1115            history.push(ChatMessage::system(system_prompt.clone()));
1116        }
1117        history.push(ChatMessage::user(full_prompt.to_string()));
1118
1119        let noop_observer = NoopObserver;
1120
1121        let agentic_timeout_secs = agent_config
1122            .agentic_timeout_secs
1123            .unwrap_or(self.delegate_config.agentic_timeout_secs);
1124        let result = tokio::time::timeout(
1125            Duration::from_secs(agentic_timeout_secs),
1126            run_tool_call_loop(
1127                provider,
1128                &mut history,
1129                &sub_tools,
1130                &noop_observer,
1131                &agent_config.provider,
1132                &agent_config.model,
1133                temperature,
1134                true,
1135                None,
1136                "delegate",
1137                None,
1138                &self.multimodal_config,
1139                agent_config.max_iterations,
1140                None,
1141                None,
1142                None,
1143                &[],
1144                &[],
1145                None,
1146                None,
1147                &crate::config::PacingConfig::default(),
1148                0,    // max_tool_result_chars: inherit from parent config in future
1149                0,    // context_token_budget: 0 = disabled for subagents
1150                None, // shared_budget: TODO thread from parent in future
1151            ),
1152        )
1153        .await;
1154
1155        match result {
1156            Ok(Ok(response)) => {
1157                let rendered = if response.trim().is_empty() {
1158                    "[Empty response]".to_string()
1159                } else {
1160                    response
1161                };
1162
1163                Ok(ToolResult {
1164                    success: true,
1165                    output: format!(
1166                        "[Agent '{agent_name}' ({provider}/{model}, agentic)]\n{rendered}",
1167                        provider = agent_config.provider,
1168                        model = agent_config.model
1169                    ),
1170                    error: None,
1171                })
1172            }
1173            Ok(Err(e)) => Ok(ToolResult {
1174                success: false,
1175                output: String::new(),
1176                error: Some(format!("Agent '{agent_name}' failed: {e}")),
1177            }),
1178            Err(_) => Ok(ToolResult {
1179                success: false,
1180                output: String::new(),
1181                error: Some(format!(
1182                    "Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
1183                )),
1184            }),
1185        }
1186    }
1187}
1188
1189struct ToolArcRef {
1190    inner: Arc<dyn Tool>,
1191}
1192
1193impl ToolArcRef {
1194    fn new(inner: Arc<dyn Tool>) -> Self {
1195        Self { inner }
1196    }
1197}
1198
1199#[async_trait]
1200impl Tool for ToolArcRef {
1201    fn name(&self) -> &str {
1202        self.inner.name()
1203    }
1204
1205    fn description(&self) -> &str {
1206        self.inner.description()
1207    }
1208
1209    fn parameters_schema(&self) -> serde_json::Value {
1210        self.inner.parameters_schema()
1211    }
1212
1213    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1214        self.inner.execute(args).await
1215    }
1216}
1217
1218struct NoopObserver;
1219
1220impl Observer for NoopObserver {
1221    fn record_event(&self, _event: &ObserverEvent) {}
1222
1223    fn record_metric(&self, _metric: &ObserverMetric) {}
1224
1225    fn name(&self) -> &str {
1226        "noop"
1227    }
1228
1229    fn as_any(&self) -> &dyn std::any::Any {
1230        self
1231    }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237    use crate::config::schema::{
1238        DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
1239    };
1240    use crate::providers::{ChatRequest, ChatResponse, ToolCall};
1241    use crate::security::{AutonomyLevel, SecurityPolicy};
1242    use anyhow::anyhow;
1243
1244    fn test_security() -> Arc<SecurityPolicy> {
1245        Arc::new(SecurityPolicy::default())
1246    }
1247
1248    fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
1249        let mut agents = HashMap::new();
1250        agents.insert(
1251            "researcher".to_string(),
1252            DelegateAgentConfig {
1253                provider: "ollama".to_string(),
1254                model: "llama3".to_string(),
1255                system_prompt: Some("You are a research assistant.".to_string()),
1256                api_key: None,
1257                temperature: Some(0.3),
1258                max_depth: 3,
1259                agentic: false,
1260                allowed_tools: Vec::new(),
1261                max_iterations: 10,
1262                timeout_secs: None,
1263                agentic_timeout_secs: None,
1264                skills_directory: None,
1265            },
1266        );
1267        agents.insert(
1268            "coder".to_string(),
1269            DelegateAgentConfig {
1270                provider: "openrouter".to_string(),
1271                model: "anthropic/claude-sonnet-4-20250514".to_string(),
1272                system_prompt: None,
1273                api_key: Some("delegate-test-credential".to_string()),
1274                temperature: None,
1275                max_depth: 2,
1276                agentic: false,
1277                allowed_tools: Vec::new(),
1278                max_iterations: 10,
1279                timeout_secs: None,
1280                agentic_timeout_secs: None,
1281                skills_directory: None,
1282            },
1283        );
1284        agents
1285    }
1286
1287    #[derive(Default)]
1288    struct EchoTool;
1289
1290    #[async_trait]
1291    impl Tool for EchoTool {
1292        fn name(&self) -> &str {
1293            "echo_tool"
1294        }
1295
1296        fn description(&self) -> &str {
1297            "Echoes the `value` argument."
1298        }
1299
1300        fn parameters_schema(&self) -> serde_json::Value {
1301            serde_json::json!({
1302                "type": "object",
1303                "properties": {
1304                    "value": {"type": "string"}
1305                },
1306                "required": ["value"]
1307            })
1308        }
1309
1310        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1311            let value = args
1312                .get("value")
1313                .and_then(serde_json::Value::as_str)
1314                .unwrap_or_default()
1315                .to_string();
1316            Ok(ToolResult {
1317                success: true,
1318                output: format!("echo:{value}"),
1319                error: None,
1320            })
1321        }
1322    }
1323
1324    struct OneToolThenFinalProvider;
1325
1326    #[async_trait]
1327    impl Provider for OneToolThenFinalProvider {
1328        async fn chat_with_system(
1329            &self,
1330            _system_prompt: Option<&str>,
1331            _message: &str,
1332            _model: &str,
1333            _temperature: f64,
1334        ) -> anyhow::Result<String> {
1335            Ok("unused".to_string())
1336        }
1337
1338        async fn chat(
1339            &self,
1340            request: ChatRequest<'_>,
1341            _model: &str,
1342            _temperature: f64,
1343        ) -> anyhow::Result<ChatResponse> {
1344            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1345            if has_tool_message {
1346                Ok(ChatResponse {
1347                    text: Some("done".to_string()),
1348                    tool_calls: Vec::new(),
1349                    usage: None,
1350                    reasoning_content: None,
1351                })
1352            } else {
1353                Ok(ChatResponse {
1354                    text: None,
1355                    tool_calls: vec![ToolCall {
1356                        id: "call_1".to_string(),
1357                        name: "echo_tool".to_string(),
1358                        arguments: "{\"value\":\"ping\"}".to_string(),
1359                    }],
1360                    usage: None,
1361                    reasoning_content: None,
1362                })
1363            }
1364        }
1365    }
1366
1367    struct InfiniteToolCallProvider;
1368
1369    #[async_trait]
1370    impl Provider for InfiniteToolCallProvider {
1371        async fn chat_with_system(
1372            &self,
1373            _system_prompt: Option<&str>,
1374            _message: &str,
1375            _model: &str,
1376            _temperature: f64,
1377        ) -> anyhow::Result<String> {
1378            Ok("unused".to_string())
1379        }
1380
1381        async fn chat(
1382            &self,
1383            _request: ChatRequest<'_>,
1384            _model: &str,
1385            _temperature: f64,
1386        ) -> anyhow::Result<ChatResponse> {
1387            Ok(ChatResponse {
1388                text: None,
1389                tool_calls: vec![ToolCall {
1390                    id: "loop".to_string(),
1391                    name: "echo_tool".to_string(),
1392                    arguments: "{\"value\":\"x\"}".to_string(),
1393                }],
1394                usage: None,
1395                reasoning_content: None,
1396            })
1397        }
1398    }
1399
1400    struct FailingProvider;
1401
1402    #[async_trait]
1403    impl Provider for FailingProvider {
1404        async fn chat_with_system(
1405            &self,
1406            _system_prompt: Option<&str>,
1407            _message: &str,
1408            _model: &str,
1409            _temperature: f64,
1410        ) -> anyhow::Result<String> {
1411            Ok("unused".to_string())
1412        }
1413
1414        async fn chat(
1415            &self,
1416            _request: ChatRequest<'_>,
1417            _model: &str,
1418            _temperature: f64,
1419        ) -> anyhow::Result<ChatResponse> {
1420            Err(anyhow!("provider boom"))
1421        }
1422    }
1423
1424    fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
1425        DelegateAgentConfig {
1426            provider: "openrouter".to_string(),
1427            model: "model-test".to_string(),
1428            system_prompt: Some("You are agentic.".to_string()),
1429            api_key: Some("delegate-test-credential".to_string()),
1430            temperature: Some(0.2),
1431            max_depth: 3,
1432            agentic: true,
1433            allowed_tools,
1434            max_iterations,
1435            timeout_secs: None,
1436            agentic_timeout_secs: None,
1437            skills_directory: None,
1438        }
1439    }
1440
1441    #[test]
1442    fn name_and_schema() {
1443        let tool = DelegateTool::new(sample_agents(), None, test_security());
1444        assert_eq!(tool.name(), "delegate");
1445        let schema = tool.parameters_schema();
1446        assert!(schema["properties"]["agent"].is_object());
1447        assert!(schema["properties"]["prompt"].is_object());
1448        assert!(schema["properties"]["context"].is_object());
1449        assert!(schema["properties"]["background"].is_object());
1450        assert!(schema["properties"]["parallel"].is_object());
1451        assert!(schema["properties"]["action"].is_object());
1452        assert!(schema["properties"]["task_id"].is_object());
1453        // required is empty because different actions need different params
1454        let required = schema["required"].as_array().unwrap();
1455        assert!(required.is_empty());
1456        assert_eq!(schema["additionalProperties"], json!(false));
1457        assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
1458        assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
1459    }
1460
1461    #[test]
1462    fn description_not_empty() {
1463        let tool = DelegateTool::new(sample_agents(), None, test_security());
1464        assert!(!tool.description().is_empty());
1465    }
1466
1467    #[test]
1468    fn schema_lists_agent_names() {
1469        let tool = DelegateTool::new(sample_agents(), None, test_security());
1470        let schema = tool.parameters_schema();
1471        let desc = schema["properties"]["agent"]["description"]
1472            .as_str()
1473            .unwrap();
1474        assert!(desc.contains("researcher") || desc.contains("coder"));
1475    }
1476
1477    #[tokio::test]
1478    async fn missing_agent_param() {
1479        let tool = DelegateTool::new(sample_agents(), None, test_security());
1480        let result = tool.execute(json!({"prompt": "test"})).await;
1481        assert!(result.is_err());
1482    }
1483
1484    #[tokio::test]
1485    async fn missing_prompt_param() {
1486        let tool = DelegateTool::new(sample_agents(), None, test_security());
1487        let result = tool.execute(json!({"agent": "researcher"})).await;
1488        assert!(result.is_err());
1489    }
1490
1491    #[tokio::test]
1492    async fn unknown_agent_returns_error() {
1493        let tool = DelegateTool::new(sample_agents(), None, test_security());
1494        let result = tool
1495            .execute(json!({"agent": "nonexistent", "prompt": "test"}))
1496            .await
1497            .unwrap();
1498        assert!(!result.success);
1499        assert!(result.error.unwrap().contains("Unknown agent"));
1500    }
1501
1502    #[tokio::test]
1503    async fn depth_limit_enforced() {
1504        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
1505        let result = tool
1506            .execute(json!({"agent": "researcher", "prompt": "test"}))
1507            .await
1508            .unwrap();
1509        assert!(!result.success);
1510        assert!(result.error.unwrap().contains("depth limit"));
1511    }
1512
1513    #[tokio::test]
1514    async fn depth_limit_per_agent() {
1515        // coder has max_depth=2, so depth=2 should be blocked
1516        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
1517        let result = tool
1518            .execute(json!({"agent": "coder", "prompt": "test"}))
1519            .await
1520            .unwrap();
1521        assert!(!result.success);
1522        assert!(result.error.unwrap().contains("depth limit"));
1523    }
1524
1525    #[test]
1526    fn empty_agents_schema() {
1527        let tool = DelegateTool::new(HashMap::new(), None, test_security());
1528        let schema = tool.parameters_schema();
1529        let desc = schema["properties"]["agent"]["description"]
1530            .as_str()
1531            .unwrap();
1532        assert!(desc.contains("none configured"));
1533    }
1534
1535    #[tokio::test]
1536    async fn invalid_provider_returns_error() {
1537        let mut agents = HashMap::new();
1538        agents.insert(
1539            "broken".to_string(),
1540            DelegateAgentConfig {
1541                provider: "totally-invalid-provider".to_string(),
1542                model: "model".to_string(),
1543                system_prompt: None,
1544                api_key: None,
1545                temperature: None,
1546                max_depth: 3,
1547                agentic: false,
1548                allowed_tools: Vec::new(),
1549                max_iterations: 10,
1550                timeout_secs: None,
1551                agentic_timeout_secs: None,
1552                skills_directory: None,
1553            },
1554        );
1555        let tool = DelegateTool::new(agents, None, test_security());
1556        let result = tool
1557            .execute(json!({"agent": "broken", "prompt": "test"}))
1558            .await
1559            .unwrap();
1560        assert!(!result.success);
1561        assert!(result.error.unwrap().contains("Failed to create provider"));
1562    }
1563
1564    #[tokio::test]
1565    async fn blank_agent_rejected() {
1566        let tool = DelegateTool::new(sample_agents(), None, test_security());
1567        let result = tool
1568            .execute(json!({"agent": "  ", "prompt": "test"}))
1569            .await
1570            .unwrap();
1571        assert!(!result.success);
1572        assert!(result.error.unwrap().contains("must not be empty"));
1573    }
1574
1575    #[tokio::test]
1576    async fn blank_prompt_rejected() {
1577        let tool = DelegateTool::new(sample_agents(), None, test_security());
1578        let result = tool
1579            .execute(json!({"agent": "researcher", "prompt": "  \t  "}))
1580            .await
1581            .unwrap();
1582        assert!(!result.success);
1583        assert!(result.error.unwrap().contains("must not be empty"));
1584    }
1585
1586    #[tokio::test]
1587    async fn whitespace_agent_name_trimmed_and_found() {
1588        let tool = DelegateTool::new(sample_agents(), None, test_security());
1589        // " researcher " with surrounding whitespace — after trim becomes "researcher"
1590        let result = tool
1591            .execute(json!({"agent": " researcher ", "prompt": "test"}))
1592            .await
1593            .unwrap();
1594        // Should find "researcher" after trim — will fail at provider level
1595        // since ollama isn't running, but must NOT get "Unknown agent".
1596        assert!(
1597            result.error.is_none()
1598                || !result
1599                    .error
1600                    .as_deref()
1601                    .unwrap_or("")
1602                    .contains("Unknown agent")
1603        );
1604    }
1605
1606    #[tokio::test]
1607    async fn delegation_blocked_in_readonly_mode() {
1608        let readonly = Arc::new(SecurityPolicy {
1609            autonomy: AutonomyLevel::ReadOnly,
1610            ..SecurityPolicy::default()
1611        });
1612        let tool = DelegateTool::new(sample_agents(), None, readonly);
1613        let result = tool
1614            .execute(json!({"agent": "researcher", "prompt": "test"}))
1615            .await
1616            .unwrap();
1617        assert!(!result.success);
1618        assert!(
1619            result
1620                .error
1621                .as_deref()
1622                .unwrap_or("")
1623                .contains("read-only mode")
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn delegation_blocked_when_rate_limited() {
1629        let limited = Arc::new(SecurityPolicy {
1630            max_actions_per_hour: 0,
1631            ..SecurityPolicy::default()
1632        });
1633        let tool = DelegateTool::new(sample_agents(), None, limited);
1634        let result = tool
1635            .execute(json!({"agent": "researcher", "prompt": "test"}))
1636            .await
1637            .unwrap();
1638        assert!(!result.success);
1639        assert!(
1640            result
1641                .error
1642                .as_deref()
1643                .unwrap_or("")
1644                .contains("Rate limit exceeded")
1645        );
1646    }
1647
1648    #[tokio::test]
1649    async fn delegate_context_is_prepended_to_prompt() {
1650        let mut agents = HashMap::new();
1651        agents.insert(
1652            "tester".to_string(),
1653            DelegateAgentConfig {
1654                provider: "invalid-for-test".to_string(),
1655                model: "test-model".to_string(),
1656                system_prompt: None,
1657                api_key: None,
1658                temperature: None,
1659                max_depth: 3,
1660                agentic: false,
1661                allowed_tools: Vec::new(),
1662                max_iterations: 10,
1663                timeout_secs: None,
1664                agentic_timeout_secs: None,
1665                skills_directory: None,
1666            },
1667        );
1668        let tool = DelegateTool::new(agents, None, test_security());
1669        let result = tool
1670            .execute(json!({
1671                "agent": "tester",
1672                "prompt": "do something",
1673                "context": "some context data"
1674            }))
1675            .await
1676            .unwrap();
1677
1678        assert!(!result.success);
1679        assert!(
1680            result
1681                .error
1682                .as_deref()
1683                .unwrap_or("")
1684                .contains("Failed to create provider")
1685        );
1686    }
1687
1688    #[tokio::test]
1689    async fn delegate_empty_context_omits_prefix() {
1690        let mut agents = HashMap::new();
1691        agents.insert(
1692            "tester".to_string(),
1693            DelegateAgentConfig {
1694                provider: "invalid-for-test".to_string(),
1695                model: "test-model".to_string(),
1696                system_prompt: None,
1697                api_key: None,
1698                temperature: None,
1699                max_depth: 3,
1700                agentic: false,
1701                allowed_tools: Vec::new(),
1702                max_iterations: 10,
1703                timeout_secs: None,
1704                agentic_timeout_secs: None,
1705                skills_directory: None,
1706            },
1707        );
1708        let tool = DelegateTool::new(agents, None, test_security());
1709        let result = tool
1710            .execute(json!({
1711                "agent": "tester",
1712                "prompt": "do something",
1713                "context": ""
1714            }))
1715            .await
1716            .unwrap();
1717
1718        assert!(!result.success);
1719        assert!(
1720            result
1721                .error
1722                .as_deref()
1723                .unwrap_or("")
1724                .contains("Failed to create provider")
1725        );
1726    }
1727
1728    #[test]
1729    fn delegate_depth_construction() {
1730        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
1731        assert_eq!(tool.depth, 5);
1732    }
1733
1734    #[tokio::test]
1735    async fn delegate_no_agents_configured() {
1736        let tool = DelegateTool::new(HashMap::new(), None, test_security());
1737        let result = tool
1738            .execute(json!({"agent": "any", "prompt": "test"}))
1739            .await
1740            .unwrap();
1741        assert!(!result.success);
1742        assert!(result.error.unwrap().contains("none configured"));
1743    }
1744
1745    #[tokio::test]
1746    async fn agentic_mode_rejects_empty_allowed_tools() {
1747        let mut agents = HashMap::new();
1748        agents.insert("agentic".to_string(), agentic_config(Vec::new(), 10));
1749
1750        let tool = DelegateTool::new(agents, None, test_security());
1751        let result = tool
1752            .execute(json!({"agent": "agentic", "prompt": "test"}))
1753            .await
1754            .unwrap();
1755
1756        assert!(!result.success);
1757        assert!(
1758            result
1759                .error
1760                .as_deref()
1761                .unwrap_or("")
1762                .contains("allowed_tools is empty")
1763        );
1764    }
1765
1766    #[tokio::test]
1767    async fn agentic_mode_rejects_unmatched_allowed_tools() {
1768        let mut agents = HashMap::new();
1769        agents.insert(
1770            "agentic".to_string(),
1771            agentic_config(vec!["missing_tool".to_string()], 10),
1772        );
1773
1774        let tool = DelegateTool::new(agents, None, test_security())
1775            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1776        let result = tool
1777            .execute(json!({"agent": "agentic", "prompt": "test"}))
1778            .await
1779            .unwrap();
1780
1781        assert!(!result.success);
1782        assert!(
1783            result
1784                .error
1785                .as_deref()
1786                .unwrap_or("")
1787                .contains("no executable tools")
1788        );
1789    }
1790
1791    #[tokio::test]
1792    async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
1793        let config = agentic_config(vec!["echo_tool".to_string()], 10);
1794        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1795            Arc::new(RwLock::new(vec![
1796                Arc::new(EchoTool),
1797                Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
1798            ])),
1799        );
1800
1801        let provider = OneToolThenFinalProvider;
1802        let result = tool
1803            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1804            .await
1805            .unwrap();
1806
1807        assert!(result.success);
1808        assert!(result.output.contains("(openrouter/model-test, agentic)"));
1809        assert!(result.output.contains("done"));
1810    }
1811
1812    #[tokio::test]
1813    async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
1814        let config = agentic_config(vec!["delegate".to_string()], 10);
1815        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1816            Arc::new(RwLock::new(vec![Arc::new(DelegateTool::new(
1817                HashMap::new(),
1818                None,
1819                test_security(),
1820            ))])),
1821        );
1822
1823        let provider = OneToolThenFinalProvider;
1824        let result = tool
1825            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1826            .await
1827            .unwrap();
1828
1829        assert!(!result.success);
1830        assert!(
1831            result
1832                .error
1833                .as_deref()
1834                .unwrap_or("")
1835                .contains("no executable tools")
1836        );
1837    }
1838
1839    #[tokio::test]
1840    async fn execute_agentic_respects_max_iterations() {
1841        let config = agentic_config(vec!["echo_tool".to_string()], 2);
1842        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1843            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1844
1845        let provider = InfiniteToolCallProvider;
1846        let result = tool
1847            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1848            .await
1849            .unwrap();
1850
1851        assert!(!result.success);
1852        assert!(
1853            result
1854                .error
1855                .as_deref()
1856                .unwrap_or("")
1857                .contains("maximum tool iterations (2)")
1858        );
1859    }
1860
1861    #[tokio::test]
1862    async fn execute_agentic_propagates_provider_errors() {
1863        let config = agentic_config(vec!["echo_tool".to_string()], 10);
1864        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1865            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1866
1867        let provider = FailingProvider;
1868        let result = tool
1869            .execute_agentic("agentic", &config, &provider, "run", 0.2)
1870            .await
1871            .unwrap();
1872
1873        assert!(!result.success);
1874        assert!(
1875            result
1876                .error
1877                .as_deref()
1878                .unwrap_or("")
1879                .contains("provider boom")
1880        );
1881    }
1882
1883    /// MCP tools pushed into the shared parent_tools handle after DelegateTool
1884    /// construction must be visible to the sub-agent tool list.
1885    #[derive(Default)]
1886    struct FakeMcpTool;
1887
1888    #[async_trait]
1889    impl Tool for FakeMcpTool {
1890        fn name(&self) -> &str {
1891            "mcp_fake"
1892        }
1893
1894        fn description(&self) -> &str {
1895            "Fake MCP tool for testing."
1896        }
1897
1898        fn parameters_schema(&self) -> serde_json::Value {
1899            serde_json::json!({"type": "object", "properties": {}})
1900        }
1901
1902        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
1903            Ok(ToolResult {
1904                success: true,
1905                output: "mcp_fake_output".into(),
1906                error: None,
1907            })
1908        }
1909    }
1910
1911    struct McpToolThenFinalProvider;
1912
1913    #[async_trait]
1914    impl Provider for McpToolThenFinalProvider {
1915        async fn chat_with_system(
1916            &self,
1917            _system_prompt: Option<&str>,
1918            _message: &str,
1919            _model: &str,
1920            _temperature: f64,
1921        ) -> anyhow::Result<String> {
1922            Ok("unused".to_string())
1923        }
1924
1925        async fn chat(
1926            &self,
1927            request: ChatRequest<'_>,
1928            _model: &str,
1929            _temperature: f64,
1930        ) -> anyhow::Result<ChatResponse> {
1931            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1932            if has_tool_message {
1933                Ok(ChatResponse {
1934                    text: Some("mcp done".to_string()),
1935                    tool_calls: Vec::new(),
1936                    usage: None,
1937                    reasoning_content: None,
1938                })
1939            } else {
1940                Ok(ChatResponse {
1941                    text: None,
1942                    tool_calls: vec![ToolCall {
1943                        id: "call_mcp".to_string(),
1944                        name: "mcp_fake".to_string(),
1945                        arguments: "{}".to_string(),
1946                    }],
1947                    usage: None,
1948                    reasoning_content: None,
1949                })
1950            }
1951        }
1952    }
1953
1954    #[tokio::test]
1955    async fn mcp_tools_included_in_subagent_tool_list() {
1956        // Build DelegateTool with NO parent tools initially
1957        let config = agentic_config(vec!["mcp_fake".to_string()], 10);
1958        let tool = DelegateTool::new(HashMap::new(), None, test_security())
1959            .with_parent_tools(Arc::new(RwLock::new(Vec::new())));
1960
1961        // Simulate late MCP tool injection via the shared handle
1962        let handle = tool.parent_tools_handle();
1963        handle.write().push(Arc::new(FakeMcpTool));
1964
1965        let provider = McpToolThenFinalProvider;
1966        let result = tool
1967            .execute_agentic("agentic", &config, &provider, "run mcp", 0.2)
1968            .await
1969            .unwrap();
1970
1971        assert!(result.success, "Expected success, got: {:?}", result.error);
1972        assert!(
1973            result.output.contains("mcp done"),
1974            "Expected output containing 'mcp done', got: {}",
1975            result.output
1976        );
1977    }
1978
1979    #[test]
1980    fn enriched_prompt_includes_tools_workspace_datetime() {
1981        let config = DelegateAgentConfig {
1982            provider: "openrouter".to_string(),
1983            model: "test-model".to_string(),
1984            system_prompt: Some("You are a code reviewer.".to_string()),
1985            api_key: None,
1986            temperature: None,
1987            max_depth: 3,
1988            agentic: true,
1989            allowed_tools: vec!["echo_tool".to_string()],
1990            max_iterations: 10,
1991            timeout_secs: None,
1992            agentic_timeout_secs: None,
1993            skills_directory: None,
1994        };
1995
1996        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
1997        let workspace = std::env::temp_dir().join(format!(
1998            "construct_delegate_enrich_test_{}",
1999            uuid::Uuid::new_v4()
2000        ));
2001        std::fs::create_dir_all(&workspace).unwrap();
2002
2003        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2004            .with_workspace_dir(workspace.clone());
2005
2006        let prompt = tool
2007            .build_enriched_system_prompt(&config, &tools, &workspace)
2008            .unwrap();
2009
2010        assert!(prompt.contains("## Tools"), "should contain tools section");
2011        assert!(prompt.contains("echo_tool"), "should list allowed tools");
2012        assert!(
2013            prompt.contains("## Workspace"),
2014            "should contain workspace section"
2015        );
2016        assert!(
2017            prompt.contains(&workspace.display().to_string()),
2018            "should contain workspace path"
2019        );
2020        assert!(
2021            prompt.contains("## CRITICAL CONTEXT: CURRENT DATE & TIME"),
2022            "should contain datetime section"
2023        );
2024        assert!(
2025            prompt.contains("You are a code reviewer."),
2026            "should append operator system_prompt"
2027        );
2028
2029        let _ = std::fs::remove_dir_all(workspace);
2030    }
2031
2032    #[test]
2033    fn enriched_prompt_includes_shell_policy_when_shell_present() {
2034        let config = DelegateAgentConfig {
2035            provider: "openrouter".to_string(),
2036            model: "test-model".to_string(),
2037            system_prompt: None,
2038            api_key: None,
2039            temperature: None,
2040            max_depth: 3,
2041            agentic: true,
2042            allowed_tools: vec!["shell".to_string()],
2043            max_iterations: 10,
2044            timeout_secs: None,
2045            agentic_timeout_secs: None,
2046            skills_directory: None,
2047        };
2048
2049        struct MockShellTool;
2050        #[async_trait]
2051        impl Tool for MockShellTool {
2052            fn name(&self) -> &str {
2053                "shell"
2054            }
2055            fn description(&self) -> &str {
2056                "Execute shell commands"
2057            }
2058            fn parameters_schema(&self) -> serde_json::Value {
2059                json!({"type": "object"})
2060            }
2061            async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2062                Ok(ToolResult {
2063                    success: true,
2064                    output: String::new(),
2065                    error: None,
2066                })
2067            }
2068        }
2069
2070        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockShellTool)];
2071        let workspace = std::env::temp_dir();
2072
2073        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2074            .with_workspace_dir(workspace.to_path_buf());
2075
2076        let prompt = tool
2077            .build_enriched_system_prompt(&config, &tools, &workspace)
2078            .unwrap();
2079
2080        assert!(
2081            prompt.contains("## Shell Policy"),
2082            "should contain shell policy when shell tool is present"
2083        );
2084    }
2085
2086    #[test]
2087    fn parent_tools_handle_returns_shared_reference() {
2088        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
2089            Arc::new(RwLock::new(vec![Arc::new(EchoTool) as Arc<dyn Tool>])),
2090        );
2091
2092        let handle = tool.parent_tools_handle();
2093        assert_eq!(handle.read().len(), 1);
2094
2095        // Push a new tool via the handle
2096        handle.write().push(Arc::new(FakeMcpTool));
2097        assert_eq!(handle.read().len(), 2);
2098    }
2099
2100    // ── Configurable timeout tests ──────────────────────────────────
2101
2102    #[test]
2103    fn default_timeout_values_used_when_config_unset() {
2104        let config = DelegateAgentConfig {
2105            provider: "ollama".to_string(),
2106            model: "llama3".to_string(),
2107            system_prompt: None,
2108            api_key: None,
2109            temperature: None,
2110            max_depth: 3,
2111            agentic: false,
2112            allowed_tools: Vec::new(),
2113            max_iterations: 10,
2114            timeout_secs: None,
2115            agentic_timeout_secs: None,
2116            skills_directory: None,
2117        };
2118        assert_eq!(
2119            config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2120            120
2121        );
2122        assert_eq!(
2123            config
2124                .agentic_timeout_secs
2125                .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2126            300
2127        );
2128    }
2129
2130    #[test]
2131    fn enriched_prompt_omits_shell_policy_without_shell_tool() {
2132        let config = DelegateAgentConfig {
2133            provider: "openrouter".to_string(),
2134            model: "test-model".to_string(),
2135            system_prompt: None,
2136            api_key: None,
2137            temperature: None,
2138            max_depth: 3,
2139            agentic: true,
2140            allowed_tools: vec!["echo_tool".to_string()],
2141            max_iterations: 10,
2142            timeout_secs: None,
2143            agentic_timeout_secs: None,
2144            skills_directory: None,
2145        };
2146
2147        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2148        let workspace = std::env::temp_dir();
2149
2150        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2151            .with_workspace_dir(workspace.to_path_buf());
2152
2153        let prompt = tool
2154            .build_enriched_system_prompt(&config, &tools, &workspace)
2155            .unwrap();
2156
2157        assert!(
2158            !prompt.contains("## Shell Policy"),
2159            "should not contain shell policy when shell tool is absent"
2160        );
2161    }
2162
2163    #[test]
2164    fn custom_timeout_values_are_respected() {
2165        let config = DelegateAgentConfig {
2166            provider: "ollama".to_string(),
2167            model: "llama3".to_string(),
2168            system_prompt: None,
2169            api_key: None,
2170            temperature: None,
2171            max_depth: 3,
2172            agentic: false,
2173            allowed_tools: Vec::new(),
2174            max_iterations: 10,
2175            timeout_secs: Some(60),
2176            agentic_timeout_secs: Some(600),
2177            skills_directory: None,
2178        };
2179        assert_eq!(
2180            config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2181            60
2182        );
2183        assert_eq!(
2184            config
2185                .agentic_timeout_secs
2186                .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2187            600
2188        );
2189    }
2190
2191    #[test]
2192    fn timeout_deserialization_defaults_to_none() {
2193        let toml_str = r#"
2194            provider = "ollama"
2195            model = "llama3"
2196        "#;
2197        let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2198        assert!(config.timeout_secs.is_none());
2199        assert!(config.agentic_timeout_secs.is_none());
2200    }
2201
2202    #[test]
2203    fn timeout_deserialization_with_custom_values() {
2204        let toml_str = r#"
2205            provider = "ollama"
2206            model = "llama3"
2207            timeout_secs = 45
2208            agentic_timeout_secs = 900
2209        "#;
2210        let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2211        assert_eq!(config.timeout_secs, Some(45));
2212        assert_eq!(config.agentic_timeout_secs, Some(900));
2213    }
2214
2215    #[test]
2216    fn config_validation_rejects_zero_timeout() {
2217        let mut config = crate::config::Config::default();
2218        config.agents.insert(
2219            "bad".into(),
2220            DelegateAgentConfig {
2221                provider: "ollama".into(),
2222                model: "llama3".into(),
2223                system_prompt: None,
2224                api_key: None,
2225                temperature: None,
2226                max_depth: 3,
2227                agentic: false,
2228                allowed_tools: Vec::new(),
2229                max_iterations: 10,
2230                timeout_secs: Some(0),
2231                agentic_timeout_secs: None,
2232                skills_directory: None,
2233            },
2234        );
2235        let err = config.validate().unwrap_err();
2236        assert!(
2237            format!("{err}").contains("timeout_secs must be greater than 0"),
2238            "unexpected error: {err}"
2239        );
2240    }
2241
2242    #[test]
2243    fn config_validation_rejects_zero_agentic_timeout() {
2244        let mut config = crate::config::Config::default();
2245        config.agents.insert(
2246            "bad".into(),
2247            DelegateAgentConfig {
2248                provider: "ollama".into(),
2249                model: "llama3".into(),
2250                system_prompt: None,
2251                api_key: None,
2252                temperature: None,
2253                max_depth: 3,
2254                agentic: false,
2255                allowed_tools: Vec::new(),
2256                max_iterations: 10,
2257                timeout_secs: None,
2258                agentic_timeout_secs: Some(0),
2259                skills_directory: None,
2260            },
2261        );
2262        let err = config.validate().unwrap_err();
2263        assert!(
2264            format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
2265            "unexpected error: {err}"
2266        );
2267    }
2268
2269    #[test]
2270    fn config_validation_rejects_excessive_timeout() {
2271        let mut config = crate::config::Config::default();
2272        config.agents.insert(
2273            "bad".into(),
2274            DelegateAgentConfig {
2275                provider: "ollama".into(),
2276                model: "llama3".into(),
2277                system_prompt: None,
2278                api_key: None,
2279                temperature: None,
2280                max_depth: 3,
2281                agentic: false,
2282                allowed_tools: Vec::new(),
2283                max_iterations: 10,
2284                timeout_secs: Some(7200),
2285                agentic_timeout_secs: None,
2286                skills_directory: None,
2287            },
2288        );
2289        let err = config.validate().unwrap_err();
2290        assert!(
2291            format!("{err}").contains("exceeds max 3600"),
2292            "unexpected error: {err}"
2293        );
2294    }
2295
2296    #[test]
2297    fn config_validation_rejects_excessive_agentic_timeout() {
2298        let mut config = crate::config::Config::default();
2299        config.agents.insert(
2300            "bad".into(),
2301            DelegateAgentConfig {
2302                provider: "ollama".into(),
2303                model: "llama3".into(),
2304                system_prompt: None,
2305                api_key: None,
2306                temperature: None,
2307                max_depth: 3,
2308                agentic: false,
2309                allowed_tools: Vec::new(),
2310                max_iterations: 10,
2311                timeout_secs: None,
2312                agentic_timeout_secs: Some(5000),
2313                skills_directory: None,
2314            },
2315        );
2316        let err = config.validate().unwrap_err();
2317        assert!(
2318            format!("{err}").contains("exceeds max 3600"),
2319            "unexpected error: {err}"
2320        );
2321    }
2322
2323    #[test]
2324    fn config_validation_accepts_max_boundary_timeout() {
2325        let mut config = crate::config::Config::default();
2326        config.agents.insert(
2327            "ok".into(),
2328            DelegateAgentConfig {
2329                provider: "ollama".into(),
2330                model: "llama3".into(),
2331                system_prompt: None,
2332                api_key: None,
2333                temperature: None,
2334                max_depth: 3,
2335                agentic: false,
2336                allowed_tools: Vec::new(),
2337                max_iterations: 10,
2338                timeout_secs: Some(3600),
2339                agentic_timeout_secs: Some(3600),
2340                skills_directory: None,
2341            },
2342        );
2343        assert!(config.validate().is_ok());
2344    }
2345
2346    #[test]
2347    fn config_validation_accepts_none_timeouts() {
2348        let mut config = crate::config::Config::default();
2349        config.agents.insert(
2350            "ok".into(),
2351            DelegateAgentConfig {
2352                provider: "ollama".into(),
2353                model: "llama3".into(),
2354                system_prompt: None,
2355                api_key: None,
2356                temperature: None,
2357                max_depth: 3,
2358                agentic: false,
2359                allowed_tools: Vec::new(),
2360                max_iterations: 10,
2361                timeout_secs: None,
2362                agentic_timeout_secs: None,
2363                skills_directory: None,
2364            },
2365        );
2366        assert!(config.validate().is_ok());
2367    }
2368
2369    #[test]
2370    fn enriched_prompt_loads_skills_from_scoped_directory() {
2371        let workspace = std::env::temp_dir().join(format!(
2372            "construct_delegate_skills_test_{}",
2373            uuid::Uuid::new_v4()
2374        ));
2375        let scoped_skills_dir = workspace.join("skills/code-review");
2376        std::fs::create_dir_all(scoped_skills_dir.join("lint-check")).unwrap();
2377        std::fs::write(
2378            scoped_skills_dir.join("lint-check/SKILL.toml"),
2379            "[skill]\nname = \"lint-check\"\ndescription = \"Run lint checks\"\nversion = \"1.0.0\"\n",
2380        )
2381        .unwrap();
2382
2383        let config = DelegateAgentConfig {
2384            provider: "openrouter".to_string(),
2385            model: "test-model".to_string(),
2386            system_prompt: None,
2387            api_key: None,
2388            temperature: None,
2389            max_depth: 3,
2390            agentic: true,
2391            allowed_tools: vec!["echo_tool".to_string()],
2392            max_iterations: 10,
2393            timeout_secs: None,
2394            agentic_timeout_secs: None,
2395            skills_directory: Some("skills/code-review".to_string()),
2396        };
2397
2398        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2399
2400        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2401            .with_workspace_dir(workspace.clone());
2402
2403        let prompt = tool
2404            .build_enriched_system_prompt(&config, &tools, &workspace)
2405            .unwrap();
2406
2407        assert!(
2408            prompt.contains("lint-check"),
2409            "should contain skills from scoped directory"
2410        );
2411
2412        let _ = std::fs::remove_dir_all(workspace);
2413    }
2414
2415    #[test]
2416    fn enriched_prompt_falls_back_to_default_skills_dir() {
2417        let workspace = std::env::temp_dir().join(format!(
2418            "construct_delegate_fallback_test_{}",
2419            uuid::Uuid::new_v4()
2420        ));
2421        let default_skills_dir = workspace.join("skills");
2422        std::fs::create_dir_all(default_skills_dir.join("deploy")).unwrap();
2423        std::fs::write(
2424            default_skills_dir.join("deploy/SKILL.toml"),
2425            "[skill]\nname = \"deploy\"\ndescription = \"Deploy safely\"\nversion = \"1.0.0\"\n",
2426        )
2427        .unwrap();
2428
2429        let config = DelegateAgentConfig {
2430            provider: "openrouter".to_string(),
2431            model: "test-model".to_string(),
2432            system_prompt: None,
2433            api_key: None,
2434            temperature: None,
2435            max_depth: 3,
2436            agentic: true,
2437            allowed_tools: vec!["echo_tool".to_string()],
2438            max_iterations: 10,
2439            timeout_secs: None,
2440            agentic_timeout_secs: None,
2441            skills_directory: None,
2442        };
2443
2444        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2445
2446        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2447            .with_workspace_dir(workspace.clone());
2448
2449        let prompt = tool
2450            .build_enriched_system_prompt(&config, &tools, &workspace)
2451            .unwrap();
2452
2453        assert!(
2454            prompt.contains("deploy"),
2455            "should contain skills from default workspace skills/ directory"
2456        );
2457
2458        let _ = std::fs::remove_dir_all(workspace);
2459    }
2460
2461    // ── Background and Parallel execution tests ─────────────────────
2462
2463    #[tokio::test]
2464    async fn background_delegation_returns_task_id() {
2465        let workspace = std::env::temp_dir().join(format!(
2466            "construct_delegate_bg_test_{}",
2467            uuid::Uuid::new_v4()
2468        ));
2469        std::fs::create_dir_all(&workspace).unwrap();
2470
2471        let tool = DelegateTool::new(sample_agents(), None, test_security())
2472            .with_workspace_dir(workspace.clone());
2473        let result = tool
2474            .execute(json!({
2475                "agent": "researcher",
2476                "prompt": "test background",
2477                "background": true
2478            }))
2479            .await
2480            .unwrap();
2481
2482        // The agent will fail at provider level (ollama not running),
2483        // but the background task should be spawned and return a task_id.
2484        assert!(result.success);
2485        assert!(result.output.contains("task_id:"));
2486        assert!(result.output.contains("Background task started"));
2487
2488        // Wait a moment for the background task to write its result
2489        tokio::time::sleep(Duration::from_millis(200)).await;
2490
2491        // The results directory should exist
2492        assert!(workspace.join("delegate_results").exists());
2493
2494        let _ = std::fs::remove_dir_all(workspace);
2495    }
2496
2497    #[tokio::test]
2498    async fn background_unknown_agent_rejected() {
2499        let workspace = std::env::temp_dir().join(format!(
2500            "construct_delegate_bg_unknown_{}",
2501            uuid::Uuid::new_v4()
2502        ));
2503        std::fs::create_dir_all(&workspace).unwrap();
2504
2505        let tool = DelegateTool::new(sample_agents(), None, test_security())
2506            .with_workspace_dir(workspace.clone());
2507        let result = tool
2508            .execute(json!({
2509                "agent": "nonexistent",
2510                "prompt": "test",
2511                "background": true
2512            }))
2513            .await
2514            .unwrap();
2515
2516        assert!(!result.success);
2517        assert!(result.error.unwrap().contains("Unknown agent"));
2518
2519        let _ = std::fs::remove_dir_all(workspace);
2520    }
2521
2522    #[tokio::test]
2523    async fn check_result_missing_task_id() {
2524        let workspace = std::env::temp_dir().join(format!(
2525            "construct_delegate_check_noid_{}",
2526            uuid::Uuid::new_v4()
2527        ));
2528        std::fs::create_dir_all(&workspace).unwrap();
2529
2530        let tool = DelegateTool::new(sample_agents(), None, test_security())
2531            .with_workspace_dir(workspace.clone());
2532        let result = tool.execute(json!({"action": "check_result"})).await;
2533
2534        assert!(result.is_err());
2535
2536        let _ = std::fs::remove_dir_all(workspace);
2537    }
2538
2539    #[tokio::test]
2540    async fn check_result_nonexistent_task() {
2541        let workspace = std::env::temp_dir().join(format!(
2542            "construct_delegate_check_miss_{}",
2543            uuid::Uuid::new_v4()
2544        ));
2545        std::fs::create_dir_all(&workspace).unwrap();
2546
2547        let tool = DelegateTool::new(sample_agents(), None, test_security())
2548            .with_workspace_dir(workspace.clone());
2549        // Use a valid UUID format that doesn't correspond to any real task
2550        let fake_uuid = uuid::Uuid::new_v4().to_string();
2551        let result = tool
2552            .execute(json!({
2553                "action": "check_result",
2554                "task_id": fake_uuid
2555            }))
2556            .await
2557            .unwrap();
2558
2559        assert!(!result.success);
2560        assert!(result.error.unwrap().contains("No result found"));
2561
2562        let _ = std::fs::remove_dir_all(workspace);
2563    }
2564
2565    #[tokio::test]
2566    async fn list_results_empty() {
2567        let workspace = std::env::temp_dir().join(format!(
2568            "construct_delegate_list_empty_{}",
2569            uuid::Uuid::new_v4()
2570        ));
2571        std::fs::create_dir_all(&workspace).unwrap();
2572
2573        let tool = DelegateTool::new(sample_agents(), None, test_security())
2574            .with_workspace_dir(workspace.clone());
2575        let result = tool
2576            .execute(json!({"action": "list_results"}))
2577            .await
2578            .unwrap();
2579
2580        assert!(result.success);
2581        assert!(result.output.contains("No background delegate results"));
2582
2583        let _ = std::fs::remove_dir_all(workspace);
2584    }
2585
2586    #[tokio::test]
2587    async fn parallel_empty_list_rejected() {
2588        let tool = DelegateTool::new(sample_agents(), None, test_security());
2589        let result = tool
2590            .execute(json!({
2591                "parallel": [],
2592                "prompt": "test"
2593            }))
2594            .await
2595            .unwrap();
2596
2597        assert!(!result.success);
2598        assert!(result.error.unwrap().contains("at least one agent"));
2599    }
2600
2601    #[tokio::test]
2602    async fn parallel_unknown_agent_rejected() {
2603        let tool = DelegateTool::new(sample_agents(), None, test_security());
2604        let result = tool
2605            .execute(json!({
2606                "parallel": ["researcher", "nonexistent"],
2607                "prompt": "test"
2608            }))
2609            .await
2610            .unwrap();
2611
2612        assert!(!result.success);
2613        assert!(result.error.unwrap().contains("Unknown agent"));
2614    }
2615
2616    #[tokio::test]
2617    async fn parallel_missing_prompt_rejected() {
2618        let tool = DelegateTool::new(sample_agents(), None, test_security());
2619        let result = tool
2620            .execute(json!({
2621                "parallel": ["researcher"]
2622            }))
2623            .await;
2624
2625        assert!(result.is_err());
2626    }
2627
2628    #[tokio::test]
2629    async fn unknown_action_rejected() {
2630        let tool = DelegateTool::new(sample_agents(), None, test_security());
2631        let result = tool
2632            .execute(json!({"action": "invalid_action"}))
2633            .await
2634            .unwrap();
2635
2636        assert!(!result.success);
2637        assert!(result.error.unwrap().contains("Unknown action"));
2638    }
2639
2640    #[tokio::test]
2641    async fn cancel_task_nonexistent() {
2642        let workspace = std::env::temp_dir().join(format!(
2643            "construct_delegate_cancel_miss_{}",
2644            uuid::Uuid::new_v4()
2645        ));
2646        std::fs::create_dir_all(&workspace).unwrap();
2647
2648        let tool = DelegateTool::new(sample_agents(), None, test_security())
2649            .with_workspace_dir(workspace.clone());
2650        // Use a valid UUID format that doesn't correspond to any real task
2651        let fake_uuid = uuid::Uuid::new_v4().to_string();
2652        let result = tool
2653            .execute(json!({
2654                "action": "cancel_task",
2655                "task_id": fake_uuid
2656            }))
2657            .await
2658            .unwrap();
2659
2660        assert!(!result.success);
2661        assert!(result.error.unwrap().contains("No task found"));
2662
2663        let _ = std::fs::remove_dir_all(workspace);
2664    }
2665
2666    #[test]
2667    fn cancellation_token_accessor() {
2668        let tool = DelegateTool::new(sample_agents(), None, test_security());
2669        let token = tool.cancellation_token();
2670        assert!(!token.is_cancelled());
2671
2672        tool.cancel_all_background_tasks();
2673        assert!(token.is_cancelled());
2674    }
2675
2676    #[test]
2677    fn with_cancellation_token_replaces_default() {
2678        let custom_token = CancellationToken::new();
2679        let tool = DelegateTool::new(sample_agents(), None, test_security())
2680            .with_cancellation_token(custom_token.clone());
2681
2682        assert!(!tool.cancellation_token().is_cancelled());
2683        custom_token.cancel();
2684        assert!(tool.cancellation_token().is_cancelled());
2685    }
2686
2687    #[tokio::test]
2688    async fn background_task_result_persisted_to_disk() {
2689        let workspace = std::env::temp_dir().join(format!(
2690            "construct_delegate_bg_persist_{}",
2691            uuid::Uuid::new_v4()
2692        ));
2693        std::fs::create_dir_all(&workspace).unwrap();
2694
2695        let tool = DelegateTool::new(sample_agents(), None, test_security())
2696            .with_workspace_dir(workspace.clone());
2697
2698        let result = tool
2699            .execute(json!({
2700                "agent": "researcher",
2701                "prompt": "persistence test",
2702                "background": true
2703            }))
2704            .await
2705            .unwrap();
2706
2707        assert!(result.success);
2708
2709        // Extract task_id from output
2710        let task_id = result
2711            .output
2712            .lines()
2713            .find(|l| l.starts_with("task_id:"))
2714            .unwrap()
2715            .trim_start_matches("task_id: ")
2716            .trim();
2717
2718        // Wait for the background task to finish
2719        tokio::time::sleep(Duration::from_millis(500)).await;
2720
2721        // Check that the result file exists
2722        let result_path = workspace
2723            .join("delegate_results")
2724            .join(format!("{task_id}.json"));
2725        assert!(
2726            result_path.exists(),
2727            "Result file should exist at {result_path:?}"
2728        );
2729
2730        // Read and parse the result
2731        let content = std::fs::read_to_string(&result_path).unwrap();
2732        let bg_result: BackgroundDelegateResult = serde_json::from_str(&content).unwrap();
2733        assert_eq!(bg_result.task_id, task_id);
2734        assert_eq!(bg_result.agent, "researcher");
2735        // The task will have failed because ollama isn't running, but it should be persisted
2736        assert!(
2737            bg_result.status == BackgroundTaskStatus::Completed
2738                || bg_result.status == BackgroundTaskStatus::Failed
2739        );
2740        assert!(bg_result.finished_at.is_some());
2741
2742        let _ = std::fs::remove_dir_all(workspace);
2743    }
2744
2745    #[tokio::test]
2746    async fn check_result_retrieves_persisted_background_result() {
2747        let workspace = std::env::temp_dir().join(format!(
2748            "construct_delegate_check_retrieve_{}",
2749            uuid::Uuid::new_v4()
2750        ));
2751        std::fs::create_dir_all(&workspace).unwrap();
2752
2753        let tool = DelegateTool::new(sample_agents(), None, test_security())
2754            .with_workspace_dir(workspace.clone());
2755
2756        // Start background task
2757        let result = tool
2758            .execute(json!({
2759                "agent": "researcher",
2760                "prompt": "retrieval test",
2761                "background": true
2762            }))
2763            .await
2764            .unwrap();
2765
2766        let task_id = result
2767            .output
2768            .lines()
2769            .find(|l| l.starts_with("task_id:"))
2770            .unwrap()
2771            .trim_start_matches("task_id: ")
2772            .trim()
2773            .to_string();
2774
2775        // Wait for background task
2776        tokio::time::sleep(Duration::from_millis(500)).await;
2777
2778        // Check result
2779        let check = tool
2780            .execute(json!({
2781                "action": "check_result",
2782                "task_id": task_id
2783            }))
2784            .await
2785            .unwrap();
2786
2787        // The output should contain the serialized result
2788        assert!(check.output.contains(&task_id));
2789        assert!(check.output.contains("researcher"));
2790
2791        let _ = std::fs::remove_dir_all(workspace);
2792    }
2793
2794    #[tokio::test]
2795    async fn list_results_includes_background_tasks() {
2796        let workspace = std::env::temp_dir().join(format!(
2797            "construct_delegate_list_tasks_{}",
2798            uuid::Uuid::new_v4()
2799        ));
2800        std::fs::create_dir_all(&workspace).unwrap();
2801
2802        let tool = DelegateTool::new(sample_agents(), None, test_security())
2803            .with_workspace_dir(workspace.clone());
2804
2805        // Start a background task
2806        let result = tool
2807            .execute(json!({
2808                "agent": "researcher",
2809                "prompt": "list test",
2810                "background": true
2811            }))
2812            .await
2813            .unwrap();
2814        assert!(result.success);
2815
2816        // Wait for task to complete
2817        tokio::time::sleep(Duration::from_millis(500)).await;
2818
2819        // List results
2820        let list = tool
2821            .execute(json!({"action": "list_results"}))
2822            .await
2823            .unwrap();
2824
2825        assert!(list.success);
2826        assert!(list.output.contains("researcher"));
2827
2828        let _ = std::fs::remove_dir_all(workspace);
2829    }
2830
2831    #[tokio::test]
2832    async fn default_action_is_delegate() {
2833        // Calling without action should behave like "delegate"
2834        let tool = DelegateTool::new(sample_agents(), None, test_security());
2835        let result = tool
2836            .execute(json!({"agent": "researcher", "prompt": "test"}))
2837            .await
2838            .unwrap();
2839        // Should proceed to delegation (will fail at provider since ollama isn't running)
2840        // but should NOT fail with "Unknown action" error
2841        assert!(
2842            result.error.is_none()
2843                || !result
2844                    .error
2845                    .as_deref()
2846                    .unwrap_or("")
2847                    .contains("Unknown action")
2848        );
2849    }
2850
2851    #[tokio::test]
2852    async fn check_result_rejects_path_traversal() {
2853        let workspace = std::env::temp_dir().join(format!(
2854            "construct_delegate_traversal_check_{}",
2855            uuid::Uuid::new_v4()
2856        ));
2857        std::fs::create_dir_all(&workspace).unwrap();
2858
2859        let tool = DelegateTool::new(sample_agents(), None, test_security())
2860            .with_workspace_dir(workspace.clone());
2861        let result = tool
2862            .execute(json!({
2863                "action": "check_result",
2864                "task_id": "../../etc/passwd"
2865            }))
2866            .await
2867            .unwrap();
2868
2869        assert!(!result.success);
2870        assert!(result.error.unwrap().contains("Invalid task_id"));
2871
2872        let _ = std::fs::remove_dir_all(workspace);
2873    }
2874
2875    #[tokio::test]
2876    async fn cancel_task_rejects_path_traversal() {
2877        let workspace = std::env::temp_dir().join(format!(
2878            "construct_delegate_traversal_cancel_{}",
2879            uuid::Uuid::new_v4()
2880        ));
2881        std::fs::create_dir_all(&workspace).unwrap();
2882
2883        let tool = DelegateTool::new(sample_agents(), None, test_security())
2884            .with_workspace_dir(workspace.clone());
2885        let result = tool
2886            .execute(json!({
2887                "action": "cancel_task",
2888                "task_id": "../../../etc/shadow"
2889            }))
2890            .await
2891            .unwrap();
2892
2893        assert!(!result.success);
2894        assert!(result.error.unwrap().contains("Invalid task_id"));
2895
2896        let _ = std::fs::remove_dir_all(workspace);
2897    }
2898}