Skip to main content

zeroclaw_runtime/tools/
delegate.rs

1use crate::agent::loop_::{TOOL_LOOP_SESSION_KEY, run_tool_call_loop};
2use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
3use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
4use crate::security::SecurityPolicy;
5use crate::security::policy::ToolOperation;
6use async_trait::async_trait;
7use parking_lot::RwLock;
8use serde_json::json;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio_util::sync::CancellationToken;
14use zeroclaw_api::tool::{Tool, ToolResult};
15use zeroclaw_config::schema::{
16    AliasedAgentConfig, Config, DelegateToolConfig, ModelProviderConfig, RiskProfileConfig,
17    RuntimeProfileConfig, SkillBundleConfig,
18};
19use zeroclaw_memory::Memory;
20use zeroclaw_providers::{self, ChatMessage, ModelProvider};
21
22fn current_tool_loop_session_key() -> Option<String> {
23    TOOL_LOOP_SESSION_KEY.try_with(Clone::clone).ok().flatten()
24}
25
26async fn scope_delegate_session_key<F>(session_key: Option<String>, future: F) -> F::Output
27where
28    F: std::future::Future,
29{
30    TOOL_LOOP_SESSION_KEY.scope(session_key, future).await
31}
32
33/// Serializable result of a background delegate task.
34#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35pub struct BackgroundDelegateResult {
36    pub task_id: String,
37    pub agent: String,
38    pub status: BackgroundTaskStatus,
39    pub output: Option<String>,
40    pub error: Option<String>,
41    pub started_at: String,
42    pub finished_at: Option<String>,
43}
44
45/// Status of a background delegate task.
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "snake_case")]
48pub enum BackgroundTaskStatus {
49    Running,
50    Completed,
51    Failed,
52    Cancelled,
53}
54
55/// Tool that delegates a subtask to a named agent with a different
56/// model_provider/model configuration. Enables multi-agent workflows where
57/// a primary agent can hand off specialized work (research, coding,
58/// summarization) to purpose-built sub-agents.
59///
60/// Supports three execution modes:
61/// - **Synchronous** (default): blocks until the sub-agent completes.
62/// - **Background** (`background: true`): spawns the sub-agent in a tokio
63///   task and returns a `task_id` immediately.
64/// - **Parallel** (`parallel: [...]`): runs multiple agents concurrently
65///   and returns all results.
66///
67/// Background results are persisted to `workspace/delegate_results/{task_id}.json`
68/// and can be retrieved via `action: "check_result"`.
69pub struct DelegateTool {
70    agents: Arc<HashMap<String, AliasedAgentConfig>>,
71    security: Arc<SecurityPolicy>,
72    /// Global credential (from config.api_key) used when an agent has none set.
73    global_credential: Option<String>,
74    /// ModelProvider runtime options inherited from root config.
75    provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
76    /// Depth at which this tool instance lives in the delegation chain.
77    depth: u32,
78    /// Parent tool registry for agentic sub-agents.
79    parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
80    /// Inherited multimodal handling config for sub-agent loops.
81    multimodal_config: zeroclaw_config::schema::MultimodalConfig,
82    /// Global delegate tool config providing default timeout values.
83    delegate_config: DelegateToolConfig,
84    /// Workspace directory inherited from the root agent context.
85    workspace_dir: PathBuf,
86    /// Cancellation token for cascade control of background tasks.
87    cancellation_token: CancellationToken,
88    /// Optional memory instance for namespace isolation on delegate agents.
89    memory: Option<Arc<dyn Memory>>,
90    /// nested model provider map for brain resolution.
91    providers_models: Arc<HashMap<String, HashMap<String, ModelProviderConfig>>>,
92    /// named risk profiles for delegation depth and timeout resolution.
93    risk_profiles: Arc<HashMap<String, RiskProfileConfig>>,
94    /// named runtime profiles for agentic/tools/iteration resolution.
95    runtime_profiles: Arc<HashMap<String, RuntimeProfileConfig>>,
96    /// named skill bundles for skills-directory resolution.
97    skill_bundles: Arc<HashMap<String, SkillBundleConfig>>,
98    /// Optional handle to the loaded root config used to resolve a
99    /// per-target `SecurityPolicy` at delegate time. When set, every
100    /// delegation validates the target agent's policy as a subset of
101    /// the calling agent's via `ensure_no_escalation_beyond` and
102    /// inherits the caller's `PerSenderTracker` so action / cost
103    /// budgets are shared between caller and delegated runs. When
104    /// unset (legacy unit-test constructors), DelegateTool falls back
105    /// to using `self.security` for the spawned inner DelegateTool.
106    root_config: Option<Arc<Config>>,
107}
108
109impl DelegateTool {
110    pub fn new(
111        agents: HashMap<String, AliasedAgentConfig>,
112        global_credential: Option<String>,
113        security: Arc<SecurityPolicy>,
114    ) -> Self {
115        Self::new_with_options(
116            agents,
117            global_credential,
118            security,
119            zeroclaw_providers::ModelProviderRuntimeOptions::default(),
120        )
121    }
122
123    pub fn new_with_options(
124        agents: HashMap<String, AliasedAgentConfig>,
125        global_credential: Option<String>,
126        security: Arc<SecurityPolicy>,
127        provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
128    ) -> Self {
129        Self {
130            agents: Arc::new(agents),
131            security,
132            global_credential,
133            provider_runtime_options,
134            depth: 0,
135            parent_tools: Arc::new(RwLock::new(Vec::new())),
136            multimodal_config: zeroclaw_config::schema::MultimodalConfig::default(),
137            delegate_config: DelegateToolConfig::default(),
138            workspace_dir: PathBuf::new(),
139            cancellation_token: CancellationToken::new(),
140            memory: None,
141            providers_models: Arc::new(HashMap::new()),
142            risk_profiles: Arc::new(HashMap::new()),
143            runtime_profiles: Arc::new(HashMap::new()),
144            skill_bundles: Arc::new(HashMap::new()),
145            root_config: None,
146        }
147    }
148
149    /// Create a DelegateTool for a sub-agent (with incremented depth).
150    /// When sub-agents eventually get their own tool registry, construct
151    /// their DelegateTool via this method with `depth: parent.depth + 1`.
152    pub fn with_depth(
153        agents: HashMap<String, AliasedAgentConfig>,
154        global_credential: Option<String>,
155        security: Arc<SecurityPolicy>,
156        depth: u32,
157    ) -> Self {
158        Self::with_depth_and_options(
159            agents,
160            global_credential,
161            security,
162            depth,
163            zeroclaw_providers::ModelProviderRuntimeOptions::default(),
164        )
165    }
166
167    pub fn with_depth_and_options(
168        agents: HashMap<String, AliasedAgentConfig>,
169        global_credential: Option<String>,
170        security: Arc<SecurityPolicy>,
171        depth: u32,
172        provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
173    ) -> Self {
174        Self {
175            agents: Arc::new(agents),
176            security,
177            global_credential,
178            provider_runtime_options,
179            depth,
180            parent_tools: Arc::new(RwLock::new(Vec::new())),
181            multimodal_config: zeroclaw_config::schema::MultimodalConfig::default(),
182            delegate_config: DelegateToolConfig::default(),
183            workspace_dir: PathBuf::new(),
184            cancellation_token: CancellationToken::new(),
185            memory: None,
186            providers_models: Arc::new(HashMap::new()),
187            risk_profiles: Arc::new(HashMap::new()),
188            runtime_profiles: Arc::new(HashMap::new()),
189            skill_bundles: Arc::new(HashMap::new()),
190            root_config: None,
191        }
192    }
193
194    /// Attach parent tools used to build sub-agent allowlist registries.
195    pub fn with_parent_tools(mut self, parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>) -> Self {
196        self.parent_tools = parent_tools;
197        self
198    }
199
200    /// Attach multimodal configuration for sub-agent tool loops.
201    pub fn with_multimodal_config(
202        mut self,
203        config: zeroclaw_config::schema::MultimodalConfig,
204    ) -> Self {
205        self.multimodal_config = config;
206        self
207    }
208
209    /// Attach global delegate tool configuration for default timeout values.
210    pub fn with_delegate_config(mut self, config: DelegateToolConfig) -> Self {
211        self.delegate_config = config;
212        self
213    }
214
215    /// Return a shared handle to the parent tools list.
216    /// Callers can push additional tools (e.g. MCP wrappers) after construction.
217    pub fn parent_tools_handle(&self) -> Arc<RwLock<Vec<Arc<dyn Tool>>>> {
218        Arc::clone(&self.parent_tools)
219    }
220
221    /// Attach the workspace directory for system prompt enrichment.
222    pub fn with_workspace_dir(mut self, workspace_dir: PathBuf) -> Self {
223        self.workspace_dir = workspace_dir;
224        self
225    }
226
227    /// Resolve a target sub-agent's workspace dir for identity-file
228    /// loading. Delegates to `Config::agent_workspace_dir` so the
229    /// per-agent path lives in one place; returns `None` when no
230    /// `root_config` is attached (legacy unit-test constructors), which
231    /// callers treat as "no identity files to load".
232    fn agent_workspace(&self, agent_alias: &str) -> Option<PathBuf> {
233        self.root_config
234            .as_ref()
235            .map(|cfg| cfg.agent_workspace_dir(agent_alias))
236    }
237
238    /// Attach a cancellation token for cascade control of background tasks.
239    /// When the token is cancelled, all background sub-agents are aborted.
240    pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self {
241        self.cancellation_token = token;
242        self
243    }
244
245    /// Return the cancellation token for external cascade control.
246    pub fn cancellation_token(&self) -> &CancellationToken {
247        &self.cancellation_token
248    }
249
250    /// Attach memory for namespace isolation on delegate agents.
251    pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
252        self.memory = Some(memory);
253        self
254    }
255
256    /// Attach nested model provider map for brain resolution.
257    pub fn with_providers_models(
258        mut self,
259        m: HashMap<String, HashMap<String, ModelProviderConfig>>,
260    ) -> Self {
261        self.providers_models = Arc::new(m);
262        self
263    }
264
265    /// Attach risk profiles for depth/timeout resolution.
266    pub fn with_risk_profiles(mut self, m: HashMap<String, RiskProfileConfig>) -> Self {
267        self.risk_profiles = Arc::new(m);
268        self
269    }
270
271    /// Attach runtime profiles for agentic/tools/iteration resolution.
272    pub fn with_runtime_profiles(mut self, m: HashMap<String, RuntimeProfileConfig>) -> Self {
273        self.runtime_profiles = Arc::new(m);
274        self
275    }
276
277    /// Attach skill bundles for skills-directory resolution.
278    pub fn with_skill_bundles(mut self, m: HashMap<String, SkillBundleConfig>) -> Self {
279        self.skill_bundles = Arc::new(m);
280        self
281    }
282
283    /// Attach the loaded root config so DelegateTool can resolve a
284    /// per-target `SecurityPolicy` at delegate time, validate it as a
285    /// subset of the caller's policy, and share the caller's
286    /// `PerSenderTracker` with the delegated run.
287    pub fn with_root_config(mut self, config: Arc<Config>) -> Self {
288        self.root_config = Some(config);
289        self
290    }
291
292    /// Build a `SecurityPolicy` for the delegated target agent
293    /// validated as **mutually equivalent** to the caller's policy
294    /// (neither escalates nor narrows), with the caller's action /
295    /// cost tracker shared into the returned policy.
296    ///
297    /// Returns:
298    /// - `Ok(target_policy)` when `root_config` is set, the target
299    ///   resolves, and the target's policy is equivalent to the
300    ///   caller's under [`SecurityPolicy::ensure_no_escalation_beyond`]
301    ///   in both directions. The returned policy's `tracker` field is
302    ///   the caller's `Arc`-shared tracker so delegated actions count
303    ///   against the caller's `max_actions_per_hour` /
304    ///   `max_cost_per_day_cents`.
305    /// - `Err(_)` on escalation: the target's risk profile or
306    ///   workspace.access map would widen permissions beyond the
307    ///   caller. The originating `EscalationViolation` is chained.
308    /// - `Err(_)` on narrowing: the target's policy is strictly
309    ///   tighter than the caller's. `DelegateTool` reuses the
310    ///   caller's `parent_tools` registry whose tools each hold the
311    ///   caller's `Arc<SecurityPolicy>` from registration time, so a
312    ///   narrower target would silently inherit the caller's broader
313    ///   allowlist — an over-grant the validator catches loudly here
314    ///   instead of letting it ship as an enforcement gap. The error
315    ///   message names `spawn_subagent` as the supported path for
316    ///   narrowed runs (it re-enters `agent::run`, which rebuilds the
317    ///   tool registry under the validated child policy).
318    /// - `Ok(self.security)` (caller's policy) when `root_config`
319    ///   is `None`. This branch only fires for the legacy unit-test
320    ///   constructors that don't plumb root config.
321    fn policy_for_target(&self, target_alias: &str) -> anyhow::Result<Arc<SecurityPolicy>> {
322        let Some(config) = self.root_config.as_ref() else {
323            return Ok(Arc::clone(&self.security));
324        };
325        let mut target_policy = SecurityPolicy::for_agent(config, target_alias).map_err(|e| {
326            ::zeroclaw_log::record!(
327                WARN,
328                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
329                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
330                    .with_attrs(::serde_json::json!({
331                        "target_agent": target_alias,
332                        "error": format!("{}", e),
333                    })),
334                "delegate: could not resolve target's security policy"
335            );
336            anyhow::Error::msg(format!(
337                "could not resolve security policy for delegate target {target_alias:?}: {e}"
338            ))
339        })?;
340        target_policy
341            .ensure_no_escalation_beyond(&self.security)
342            .map_err(|violation| {
343                ::zeroclaw_log::record!(
344                    WARN,
345                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
346                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
347                        .with_attrs(::serde_json::json!({
348                            "target_agent": target_alias,
349                            "violation": violation.to_string(),
350                        })),
351                    "delegate refused: target policy escalates beyond caller"
352                );
353                anyhow::Error::msg(format!(
354                    "delegate target {target_alias:?} policy escalates beyond caller: {violation}"
355                ))
356            })?;
357        // Refuse strict narrowing. `DelegateTool::execute_agentic`
358        // reuses the caller's `parent_tools` registry (every tool
359        // there holds the caller's `Arc<SecurityPolicy>` from
360        // registration time); a narrower target policy would not
361        // reach those tools, so the target would silently inherit
362        // the caller's broader allowlist. Catching the narrowing
363        // here turns a silent over-grant into a loud refusal.
364        // Operators with truly narrowed sub-agents should use
365        // `spawn_subagent`, which re-enters `agent::run` with the
366        // validated child policy and rebuilds the tool registry
367        // under it.
368        self.security
369            .ensure_no_escalation_beyond(&target_policy)
370            .map_err(|violation| {
371                ::zeroclaw_log::record!(
372                    WARN,
373                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
374                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
375                        .with_attrs(::serde_json::json!({
376                            "target_agent": target_alias,
377                            "violation": violation.to_string(),
378                        })),
379                    "delegate refused: target policy narrows caller's (use spawn_subagent for narrowed runs)"
380                );
381                anyhow::Error::msg(format!(
382                    "delegate target {target_alias:?} policy narrows the caller's ({violation}); \
383                     DelegateTool reuses the caller's tool registry, so narrowing is not enforced \
384                     by the spawned tool calls. Either align caller and target risk_profile / \
385                     workspace.access so the policies are equivalent, or use `spawn_subagent` for \
386                     a narrowed run."
387                ))
388            })?;
389        target_policy.tracker = self.security.tracker.clone();
390        Ok(Arc::new(target_policy))
391    }
392
393    /// Resolve `model_provider` ("type.alias") → (provider_type, credential, model, temperature).
394    fn resolve_brain(&self, model_provider: &str) -> (String, Option<String>, String, Option<f64>) {
395        if let Some((type_key, alias_key)) = model_provider.split_once('.')
396            && let Some(alias_map) = self.providers_models.get(type_key)
397            && let Some(cfg) = alias_map.get(alias_key)
398        {
399            return (
400                type_key.to_string(),
401                cfg.api_key
402                    .clone()
403                    .or_else(|| self.global_credential.clone()),
404                cfg.model.clone().unwrap_or_default(),
405                cfg.temperature,
406            );
407        }
408        let type_key = model_provider
409            .split_once('.')
410            .map_or(model_provider, |(t, _)| t);
411        (
412            type_key.to_string(),
413            self.global_credential.clone(),
414            String::new(),
415            None,
416        )
417    }
418
419    /// Resolve max delegation depth from the named runtime profile (default: 3).
420    fn resolve_max_depth(&self, runtime_profile: &str) -> u32 {
421        if runtime_profile.is_empty() {
422            return 3;
423        }
424        self.runtime_profiles
425            .get(runtime_profile)
426            .map(|p| p.max_delegation_depth)
427            .filter(|&d| d > 0)
428            .unwrap_or(3)
429    }
430
431    /// Resolve per-call delegation timeout from the named runtime profile.
432    fn resolve_delegation_timeout(&self, runtime_profile: &str) -> Option<u64> {
433        if runtime_profile.is_empty() {
434            return None;
435        }
436        self.runtime_profiles
437            .get(runtime_profile)
438            .and_then(|p| p.delegation_timeout_secs)
439    }
440
441    /// Resolve agentic run timeout from the named runtime profile.
442    fn resolve_agentic_timeout_secs(&self, runtime_profile: &str) -> Option<u64> {
443        if runtime_profile.is_empty() {
444            return None;
445        }
446        self.runtime_profiles
447            .get(runtime_profile)
448            .and_then(|p| p.agentic_timeout_secs)
449    }
450
451    /// Resolve agentic mode flag from the named runtime profile (default: false).
452    fn resolve_agentic(&self, runtime_profile: &str) -> bool {
453        if runtime_profile.is_empty() {
454            return false;
455        }
456        self.runtime_profiles
457            .get(runtime_profile)
458            .map(|p| p.agentic)
459            .unwrap_or(false)
460    }
461
462    /// Resolve max tool iterations from the named runtime profile (default: 10).
463    fn resolve_max_iterations(&self, runtime_profile: &str) -> usize {
464        if runtime_profile.is_empty() {
465            return 10;
466        }
467        self.runtime_profiles
468            .get(runtime_profile)
469            .map(|p| p.max_tool_iterations)
470            .filter(|&i| i > 0)
471            .unwrap_or(10)
472    }
473
474    /// Resolve allowed tools list from the named risk profile (authorization).
475    fn resolve_allowed_tools(&self, risk_profile: &str) -> Vec<String> {
476        if risk_profile.is_empty() {
477            return Vec::new();
478        }
479        self.risk_profiles
480            .get(risk_profile)
481            .map(|p| p.allowed_tools.clone())
482            .unwrap_or_default()
483    }
484
485    /// Resolve every configured skill bundle alias to its directory.
486    /// Empty list / no matches → caller falls back to the workspace default.
487    fn resolve_skill_bundle_dirs(&self, bundle_aliases: &[String]) -> Vec<String> {
488        bundle_aliases
489            .iter()
490            .filter(|a| !a.is_empty())
491            .filter_map(|a| self.skill_bundles.get(a).and_then(|b| b.directory.clone()))
492            .collect()
493    }
494
495    /// Directory where background delegate results are stored.
496    fn results_dir(&self) -> PathBuf {
497        self.workspace_dir.join("delegate_results")
498    }
499
500    /// Validate that a user-provided task_id is a valid UUID to prevent
501    /// path traversal attacks (e.g. `../../etc/passwd`).
502    fn validate_task_id(task_id: &str) -> Result<(), String> {
503        if uuid::Uuid::parse_str(task_id).is_err() {
504            return Err(format!("Invalid task_id '{task_id}': must be a valid UUID"));
505        }
506        Ok(())
507    }
508}
509
510#[async_trait]
511impl Tool for DelegateTool {
512    fn name(&self) -> &str {
513        "delegate"
514    }
515
516    fn description(&self) -> &str {
517        "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
518         (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
519         prompt by default; with agentic=true it can iterate with a filtered tool-call loop. \
520         Supports background execution (returns a task_id immediately) and parallel execution \
521         (runs multiple agents concurrently). Use action='check_result' with a task_id to \
522         retrieve background results."
523    }
524
525    fn parameters_schema(&self) -> serde_json::Value {
526        let agent_names: Vec<&str> = self.agents.keys().map(|s: &String| s.as_str()).collect();
527        json!({
528            "type": "object",
529            "additionalProperties": false,
530            "properties": {
531                "action": {
532                    "type": "string",
533                    "enum": ["delegate", "check_result", "list_results", "cancel_task"],
534                    "description": "Action to perform. Default: 'delegate'. Use 'check_result' to \
535                                    retrieve a background task result, 'list_results' to list all \
536                                    background tasks, 'cancel_task' to cancel a running background task.",
537                    "default": "delegate"
538                },
539                "agent": {
540                    "type": "string",
541                    "minLength": 1,
542                    "description": format!(
543                        "Name of the agent to delegate to. Available: {}",
544                        if agent_names.is_empty() {
545                            "(none configured)".to_string()
546                        } else {
547                            agent_names.join(", ")
548                        }
549                    )
550                },
551                "prompt": {
552                    "type": "string",
553                    "minLength": 1,
554                    "description": "The task/prompt to send to the sub-agent"
555                },
556                "context": {
557                    "type": "string",
558                    "description": "Optional context to prepend (e.g. relevant code, prior findings)"
559                },
560                "background": {
561                    "type": "boolean",
562                    "description": "When true, the sub-agent runs in a background tokio task and \
563                                    returns a task_id immediately. Results are stored to \
564                                    workspace/delegate_results/{task_id}.json.",
565                    "default": false
566                },
567                "parallel": {
568                    "type": "array",
569                    "items": { "type": "string" },
570                    "description": "Array of agent names to run concurrently with the same prompt. \
571                                    Returns all results when all agents complete. Cannot be combined \
572                                    with 'background'."
573                },
574                "task_id": {
575                    "type": "string",
576                    "description": "Task ID for check_result/cancel_task actions (returned by \
577                                    background delegation)."
578                }
579            },
580            "required": []
581        })
582    }
583
584    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
585        let action = args
586            .get("action")
587            .and_then(|v| v.as_str())
588            .unwrap_or("delegate");
589
590        match action {
591            "check_result" => return self.handle_check_result(&args).await,
592            "list_results" => return self.handle_list_results().await,
593            "cancel_task" => return self.handle_cancel_task(&args).await,
594            "delegate" => {} // fall through to delegation logic
595            other => {
596                return Ok(ToolResult {
597                    success: false,
598                    output: String::new(),
599                    error: Some(format!(
600                        "Unknown action '{other}'. Use delegate/check_result/list_results/cancel_task."
601                    )),
602                });
603            }
604        }
605
606        // --- Parallel mode ---
607        if let Some(parallel_agents) = args.get("parallel").and_then(|v| v.as_array()) {
608            return self.execute_parallel(parallel_agents, &args).await;
609        }
610
611        // --- Single-agent delegation (synchronous or background) ---
612        let agent_name = args
613            .get("agent")
614            .and_then(|v| v.as_str())
615            .map(str::trim)
616            .ok_or_else(|| {
617                ::zeroclaw_log::record!(
618                    WARN,
619                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
620                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
621                        .with_attrs(::serde_json::json!({"param": "agent"})),
622                    "tool argument validation failed"
623                );
624
625                anyhow::Error::msg("Missing 'agent' parameter")
626            })?;
627
628        if agent_name.is_empty() {
629            return Ok(ToolResult {
630                success: false,
631                output: String::new(),
632                error: Some("'agent' parameter must not be empty".into()),
633            });
634        }
635
636        let prompt = args
637            .get("prompt")
638            .and_then(|v| v.as_str())
639            .map(str::trim)
640            .ok_or_else(|| {
641                ::zeroclaw_log::record!(
642                    WARN,
643                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
644                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
645                        .with_attrs(::serde_json::json!({"param": "prompt"})),
646                    "tool argument validation failed"
647                );
648
649                anyhow::Error::msg("Missing 'prompt' parameter")
650            })?;
651
652        if prompt.is_empty() {
653            return Ok(ToolResult {
654                success: false,
655                output: String::new(),
656                error: Some("'prompt' parameter must not be empty".into()),
657            });
658        }
659
660        let background = args
661            .get("background")
662            .and_then(|v| v.as_bool())
663            .unwrap_or(false);
664
665        if background {
666            return self.execute_background(agent_name, prompt, &args).await;
667        }
668
669        // --- Synchronous delegation (original path) ---
670        self.execute_sync(agent_name, prompt, &args).await
671    }
672}
673
674impl DelegateTool {
675    /// Original synchronous delegation path (extracted for reuse).
676    async fn execute_sync(
677        &self,
678        agent_name: &str,
679        prompt: &str,
680        args: &serde_json::Value,
681    ) -> anyhow::Result<ToolResult> {
682        let context = args
683            .get("context")
684            .and_then(|v| v.as_str())
685            .map(str::trim)
686            .unwrap_or("");
687
688        // Look up agent config
689        let agent_config = match self.agents.get(agent_name) {
690            Some(cfg) => cfg,
691            None => {
692                let available: Vec<&str> =
693                    self.agents.keys().map(|s: &String| s.as_str()).collect();
694                return Ok(ToolResult {
695                    success: false,
696                    output: String::new(),
697                    error: Some(format!(
698                        "Unknown agent '{agent_name}'. Available agents: {}",
699                        if available.is_empty() {
700                            "(none configured)".to_string()
701                        } else {
702                            available.join(", ")
703                        }
704                    )),
705                });
706            }
707        };
708
709        // Resolve profile references
710        let max_depth = self.resolve_max_depth(&agent_config.runtime_profile);
711        let (provider_type, credential, model, temperature) =
712            self.resolve_brain(&agent_config.model_provider);
713        let agentic = self.resolve_agentic(&agent_config.runtime_profile);
714
715        // Check recursion depth (immutable — set at construction, incremented for sub-agents)
716        if self.depth >= max_depth {
717            return Ok(ToolResult {
718                success: false,
719                output: String::new(),
720                error: Some(format!(
721                    "Delegation depth limit reached ({depth}/{max}). \
722                     Cannot delegate further to prevent infinite loops.",
723                    depth = self.depth,
724                    max = max_depth
725                )),
726            });
727        }
728
729        if let Err(error) = self
730            .security
731            .enforce_tool_operation(ToolOperation::Act, "delegate")
732        {
733            return Ok(ToolResult {
734                success: false,
735                output: String::new(),
736                error: Some(error),
737            });
738        }
739
740        if let Err(e) = self.policy_for_target(agent_name) {
741            return Ok(ToolResult {
742                success: false,
743                output: String::new(),
744                error: Some(format!("{e:#}")),
745            });
746        }
747
748        // Create model_provider for this agent
749        let model_provider: Box<dyn ModelProvider> =
750            match zeroclaw_providers::create_model_provider_with_options(
751                &provider_type,
752                credential.as_deref(),
753                &self.provider_runtime_options,
754            ) {
755                Ok(p) => p,
756                Err(e) => {
757                    return Ok(ToolResult {
758                        success: false,
759                        output: String::new(),
760                        error: Some(format!(
761                            "Failed to create model_provider '{provider_type}' for agent '{agent_name}': {e}"
762                        )),
763                    });
764                }
765            };
766
767        // Build the message
768        let full_prompt = if context.is_empty() {
769            prompt.to_string()
770        } else {
771            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
772        };
773
774        // Agentic mode: run full tool-call loop with allowlisted tools.
775        if agentic {
776            return self
777                .execute_agentic(
778                    agent_name,
779                    agent_config,
780                    &provider_type,
781                    &model,
782                    &*model_provider,
783                    &full_prompt,
784                    temperature,
785                )
786                .await;
787        }
788
789        // Build enriched system prompt for non-agentic sub-agent.
790        let enriched_system_prompt = self.build_enriched_system_prompt(
791            agent_name,
792            agent_config,
793            &model,
794            &[],
795            &self.workspace_dir,
796            false,
797        );
798        let system_prompt_ref = enriched_system_prompt.as_deref();
799
800        // Wrap the model_provider call in a timeout to prevent indefinite blocking
801        let timeout_secs = self
802            .resolve_delegation_timeout(&agent_config.runtime_profile)
803            .unwrap_or(self.delegate_config.timeout_secs);
804        let result = tokio::time::timeout(
805            Duration::from_secs(timeout_secs),
806            model_provider.chat_with_system(system_prompt_ref, &full_prompt, &model, temperature),
807        )
808        .await;
809
810        let result = match result {
811            Ok(inner) => inner,
812            Err(_elapsed) => {
813                return Ok(ToolResult {
814                    success: false,
815                    output: String::new(),
816                    error: Some(format!(
817                        "Agent '{agent_name}' timed out after {timeout_secs}s"
818                    )),
819                });
820            }
821        };
822
823        match result {
824            Ok(response) => {
825                let mut rendered = response;
826                if rendered.trim().is_empty() {
827                    rendered = "[Empty response]".to_string();
828                }
829
830                Ok(ToolResult {
831                    success: true,
832                    output: format!("[Agent '{agent_name}' ({provider_type}/{model})]\n{rendered}",),
833                    error: None,
834                })
835            }
836            Err(e) => Ok(ToolResult {
837                success: false,
838                output: String::new(),
839                error: Some(format!("Agent '{agent_name}' failed: {e}",)),
840            }),
841        }
842    }
843}
844
845impl DelegateTool {
846    // ── Background Execution ────────────────────────────────────────
847
848    /// Spawn a sub-agent in a background tokio task. Returns a task_id immediately.
849    /// The result is persisted to `workspace/delegate_results/{task_id}.json`.
850    async fn execute_background(
851        &self,
852        agent_name: &str,
853        prompt: &str,
854        args: &serde_json::Value,
855    ) -> anyhow::Result<ToolResult> {
856        // Validate agent exists and check depth/security before spawning
857        let agent_config = match self.agents.get(agent_name) {
858            Some(cfg) => cfg.clone(),
859            None => {
860                let available: Vec<&str> =
861                    self.agents.keys().map(|s: &String| s.as_str()).collect();
862                return Ok(ToolResult {
863                    success: false,
864                    output: String::new(),
865                    error: Some(format!(
866                        "Unknown agent '{agent_name}'. Available agents: {}",
867                        if available.is_empty() {
868                            "(none configured)".to_string()
869                        } else {
870                            available.join(", ")
871                        }
872                    )),
873                });
874            }
875        };
876
877        let max_depth = self.resolve_max_depth(&agent_config.runtime_profile);
878        if self.depth >= max_depth {
879            return Ok(ToolResult {
880                success: false,
881                output: String::new(),
882                error: Some(format!(
883                    "Delegation depth limit reached ({depth}/{max}).",
884                    depth = self.depth,
885                    max = max_depth
886                )),
887            });
888        }
889
890        if let Err(error) = self
891            .security
892            .enforce_tool_operation(ToolOperation::Act, "delegate")
893        {
894            return Ok(ToolResult {
895                success: false,
896                output: String::new(),
897                error: Some(error),
898            });
899        }
900
901        let target_policy = match self.policy_for_target(agent_name) {
902            Ok(p) => p,
903            Err(e) => {
904                return Ok(ToolResult {
905                    success: false,
906                    output: String::new(),
907                    error: Some(format!("{e:#}")),
908                });
909            }
910        };
911
912        let task_id = uuid::Uuid::new_v4().to_string();
913        let results_dir = self.results_dir();
914        tokio::fs::create_dir_all(&results_dir).await?;
915
916        let context = args
917            .get("context")
918            .and_then(|v| v.as_str())
919            .map(str::trim)
920            .unwrap_or("");
921        let full_prompt = if context.is_empty() {
922            prompt.to_string()
923        } else {
924            format!("[Context]\n{context}\n\n[Task]\n{prompt}")
925        };
926
927        let started_at = chrono::Utc::now().to_rfc3339();
928        let agent_name_owned = agent_name.to_string();
929
930        // Write initial "running" status
931        let initial_result = BackgroundDelegateResult {
932            task_id: task_id.clone(),
933            agent: agent_name_owned.clone(),
934            status: BackgroundTaskStatus::Running,
935            output: None,
936            error: None,
937            started_at: started_at.clone(),
938            finished_at: None,
939        };
940        let result_path = results_dir.join(format!("{task_id}.json"));
941        let json_bytes = serde_json::to_vec_pretty(&initial_result)?;
942        tokio::fs::write(&result_path, &json_bytes).await?;
943
944        let agents = Arc::clone(&self.agents);
945        let security = target_policy;
946        let global_credential = self.global_credential.clone();
947        let provider_runtime_options = self.provider_runtime_options.clone();
948        let depth = self.depth;
949        let parent_tools = Arc::clone(&self.parent_tools);
950        let multimodal_config = self.multimodal_config.clone();
951        let delegate_config = self.delegate_config.clone();
952        let workspace_dir = self.workspace_dir.clone();
953        let child_token = self.cancellation_token.child_token();
954        let task_id_clone = task_id.clone();
955        let providers_models = Arc::clone(&self.providers_models);
956        let risk_profiles = Arc::clone(&self.risk_profiles);
957        let runtime_profiles = Arc::clone(&self.runtime_profiles);
958        let skill_bundles = Arc::clone(&self.skill_bundles);
959        let root_config = self.root_config.clone();
960        // Capture the parent loop's session-key task-local so the
961        // detached background task scopes its tool calls under the
962        // same key — channel tools (sessions_send, etc.) need the
963        // session key in scope to attribute correctly. Without this
964        // wrap, the spawned task would lose the parent's task-local
965        // and channel-scoped tool calls would land unattributed.
966        let parent_session_key = current_tool_loop_session_key();
967
968        tokio::spawn(async move {
969            scope_delegate_session_key(parent_session_key, async move {
970                let inner = DelegateTool {
971                    agents,
972                    security,
973                    global_credential,
974                    provider_runtime_options,
975                    depth,
976                    parent_tools,
977                    multimodal_config,
978                    delegate_config,
979                    workspace_dir: workspace_dir.clone(),
980                    cancellation_token: child_token.clone(),
981                    memory: None,
982                    providers_models,
983                    risk_profiles,
984                    runtime_profiles,
985                    skill_bundles,
986                    root_config,
987                };
988
989                let args_inner = json!({
990                    "agent": agent_name_owned,
991                    "prompt": full_prompt,
992                });
993
994                // Race the delegation against cancellation
995                let outcome = tokio::select! {
996                    () = child_token.cancelled() => {
997                        Err("Cancelled by parent session".to_string())
998                    }
999                    result = Box::pin(inner.execute_sync(&agent_name_owned, &full_prompt, &args_inner)) => {
1000                        match result {
1001                            Ok(tool_result) => {
1002                                if tool_result.success {
1003                                    Ok(tool_result.output)
1004                                } else {
1005                                    Err(tool_result.error.unwrap_or_else(|| "Unknown error".into()))
1006                                }
1007                            }
1008                            Err(e) => Err(e.to_string()),
1009                        }
1010                    }
1011                };
1012
1013                let finished_at = chrono::Utc::now().to_rfc3339();
1014                let final_result = match outcome {
1015                    Ok(output) => BackgroundDelegateResult {
1016                        task_id: task_id_clone.clone(),
1017                        agent: agent_name_owned,
1018                        status: BackgroundTaskStatus::Completed,
1019                        output: Some(output),
1020                        error: None,
1021                        started_at,
1022                        finished_at: Some(finished_at),
1023                    },
1024                    Err(err) => {
1025                        let status = if err.contains("Cancelled") {
1026                            BackgroundTaskStatus::Cancelled
1027                        } else {
1028                            BackgroundTaskStatus::Failed
1029                        };
1030                        BackgroundDelegateResult {
1031                            task_id: task_id_clone.clone(),
1032                            agent: agent_name_owned,
1033                            status,
1034                            output: None,
1035                            error: Some(err),
1036                            started_at,
1037                            finished_at: Some(finished_at),
1038                        }
1039                    }
1040                };
1041
1042                let result_path = results_dir.join(format!("{}.json", task_id_clone));
1043                if let Ok(bytes) = serde_json::to_vec_pretty(&final_result) {
1044                    let _ = tokio::fs::write(&result_path, &bytes).await;
1045                }
1046            })
1047            .await;
1048        });
1049
1050        Ok(ToolResult {
1051            success: true,
1052            output: format!(
1053                "Background task started for agent '{agent_name}'.\n\
1054                 task_id: {task_id}\n\
1055                 Use action='check_result' with task_id='{task_id}' to retrieve the result."
1056            ),
1057            error: None,
1058        })
1059    }
1060
1061    // ── Parallel Execution ──────────────────────────────────────────
1062
1063    /// Run multiple agents concurrently with the same prompt.
1064    async fn execute_parallel(
1065        &self,
1066        parallel_agents: &[serde_json::Value],
1067        args: &serde_json::Value,
1068    ) -> anyhow::Result<ToolResult> {
1069        let prompt = args
1070            .get("prompt")
1071            .and_then(|v| v.as_str())
1072            .map(str::trim)
1073            .ok_or_else(|| {
1074                ::zeroclaw_log::record!(
1075                    WARN,
1076                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1077                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1078                        .with_attrs(::serde_json::json!({"param": "prompt"})),
1079                    "tool argument validation failed"
1080                );
1081
1082                anyhow::Error::msg("Missing 'prompt' parameter for parallel execution")
1083            })?;
1084
1085        if prompt.is_empty() {
1086            return Ok(ToolResult {
1087                success: false,
1088                output: String::new(),
1089                error: Some("'prompt' parameter must not be empty".into()),
1090            });
1091        }
1092
1093        let agent_names: Vec<String> = parallel_agents
1094            .iter()
1095            .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
1096            .filter(|s| !s.is_empty())
1097            .collect();
1098
1099        if agent_names.is_empty() {
1100            return Ok(ToolResult {
1101                success: false,
1102                output: String::new(),
1103                error: Some("'parallel' array must contain at least one agent name".into()),
1104            });
1105        }
1106
1107        // Validate all agents exist before starting any
1108        for name in &agent_names {
1109            if !self.agents.contains_key(name) {
1110                let available: Vec<&str> =
1111                    self.agents.keys().map(|s: &String| s.as_str()).collect();
1112                return Ok(ToolResult {
1113                    success: false,
1114                    output: String::new(),
1115                    error: Some(format!(
1116                        "Unknown agent '{name}' in parallel list. Available: {}",
1117                        if available.is_empty() {
1118                            "(none configured)".to_string()
1119                        } else {
1120                            available.join(", ")
1121                        }
1122                    )),
1123                });
1124            }
1125        }
1126
1127        let mut target_policies: HashMap<String, Arc<SecurityPolicy>> =
1128            HashMap::with_capacity(agent_names.len());
1129        for name in &agent_names {
1130            match self.policy_for_target(name) {
1131                Ok(p) => {
1132                    target_policies.insert(name.clone(), p);
1133                }
1134                Err(e) => {
1135                    return Ok(ToolResult {
1136                        success: false,
1137                        output: String::new(),
1138                        error: Some(format!("{e:#}")),
1139                    });
1140                }
1141            }
1142        }
1143
1144        // Capture the current receipt scope so each spawned sub-agent task
1145        // re-enters it. `tokio::spawn` does not propagate task-locals, so
1146        // without this `execute_sync`'s `try_with` would resolve to `None`
1147        // inside the spawn and the parallel agents would run unsigned even
1148        // when the parent turn has receipts enabled. The collector is `Arc`'d
1149        // inside `ReceiptScope`, so all parallel agents push into the same
1150        // per-turn collector the orchestrator renders after the loop returns.
1151        let parent_receipt_scope = crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
1152            .try_with(Clone::clone)
1153            .ok()
1154            .flatten();
1155        let parent_session_key = current_tool_loop_session_key();
1156
1157        // Spawn all agents concurrently
1158        let mut handles = Vec::with_capacity(agent_names.len());
1159        for agent_name in &agent_names {
1160            let agents = Arc::clone(&self.agents);
1161            let security = target_policies
1162                .get(agent_name)
1163                .cloned()
1164                .unwrap_or_else(|| Arc::clone(&self.security));
1165            let global_credential = self.global_credential.clone();
1166            let provider_runtime_options = self.provider_runtime_options.clone();
1167            let depth = self.depth;
1168            let parent_tools = Arc::clone(&self.parent_tools);
1169            let multimodal_config = self.multimodal_config.clone();
1170            let delegate_config = self.delegate_config.clone();
1171            let workspace_dir = self.workspace_dir.clone();
1172            let cancellation_token = self.cancellation_token.child_token();
1173            let agent_name = agent_name.clone();
1174            let prompt = prompt.to_string();
1175            let args_clone = args.clone();
1176            let providers_models = Arc::clone(&self.providers_models);
1177            let risk_profiles = Arc::clone(&self.risk_profiles);
1178            let runtime_profiles = Arc::clone(&self.runtime_profiles);
1179            let skill_bundles = Arc::clone(&self.skill_bundles);
1180            let receipt_scope = parent_receipt_scope.clone();
1181            let root_config = self.root_config.clone();
1182            let session_key = parent_session_key.clone();
1183
1184            handles.push(tokio::spawn(async move {
1185                let inner = DelegateTool {
1186                    agents,
1187                    security,
1188                    global_credential,
1189                    provider_runtime_options,
1190                    depth,
1191                    parent_tools,
1192                    multimodal_config,
1193                    delegate_config,
1194                    workspace_dir,
1195                    cancellation_token,
1196                    memory: None,
1197                    providers_models,
1198                    risk_profiles,
1199                    runtime_profiles,
1200                    skill_bundles,
1201                    root_config,
1202                };
1203                let agent_name_for_return = agent_name.clone();
1204                let result = scope_delegate_session_key(session_key, async move {
1205                    crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
1206                        .scope(receipt_scope, async move {
1207                            Box::pin(inner.execute_sync(&agent_name, &prompt, &args_clone)).await
1208                        })
1209                        .await
1210                })
1211                .await;
1212                (agent_name_for_return, result)
1213            }));
1214        }
1215
1216        // Collect all results
1217        let mut outputs = Vec::with_capacity(handles.len());
1218        let mut all_success = true;
1219
1220        for handle in handles {
1221            match handle.await {
1222                Ok((agent_name, Ok(tool_result))) => {
1223                    if !tool_result.success {
1224                        all_success = false;
1225                    }
1226                    outputs.push(format!(
1227                        "--- {agent_name} (success={}) ---\n{}{}",
1228                        tool_result.success,
1229                        tool_result.output,
1230                        tool_result
1231                            .error
1232                            .map(|e| format!("\nError: {e}"))
1233                            .unwrap_or_default()
1234                    ));
1235                }
1236                Ok((agent_name, Err(e))) => {
1237                    all_success = false;
1238                    outputs.push(format!("--- {agent_name} (success=false) ---\nError: {e}"));
1239                }
1240                Err(e) => {
1241                    all_success = false;
1242                    outputs.push(format!("--- [join error] ---\n{e}"));
1243                }
1244            }
1245        }
1246
1247        Ok(ToolResult {
1248            success: all_success,
1249            output: format!(
1250                "[Parallel delegation: {} agents]\n\n{}",
1251                agent_names.len(),
1252                outputs.join("\n\n")
1253            ),
1254            error: if all_success {
1255                None
1256            } else {
1257                Some("One or more parallel agents failed".into())
1258            },
1259        })
1260    }
1261
1262    // ── Result Retrieval ────────────────────────────────────────────
1263
1264    /// Retrieve the result of a background delegate task by task_id.
1265    async fn handle_check_result(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
1266        let task_id = args
1267            .get("task_id")
1268            .and_then(|v| v.as_str())
1269            .ok_or_else(|| {
1270                ::zeroclaw_log::record!(
1271                    WARN,
1272                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1273                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1274                        .with_attrs(::serde_json::json!({"param": "task_id"})),
1275                    "tool argument validation failed"
1276                );
1277
1278                anyhow::Error::msg("Missing 'task_id' parameter for check_result")
1279            })?;
1280
1281        if let Err(e) = Self::validate_task_id(task_id) {
1282            return Ok(ToolResult {
1283                success: false,
1284                output: String::new(),
1285                error: Some(e),
1286            });
1287        }
1288
1289        let result_path = self.results_dir().join(format!("{task_id}.json"));
1290        if !result_path.exists() {
1291            return Ok(ToolResult {
1292                success: false,
1293                output: String::new(),
1294                error: Some(format!("No result found for task_id '{task_id}'")),
1295            });
1296        }
1297
1298        let content = tokio::fs::read_to_string(&result_path).await?;
1299        let result: BackgroundDelegateResult = serde_json::from_str(&content)?;
1300
1301        Ok(ToolResult {
1302            success: result.status == BackgroundTaskStatus::Completed,
1303            output: serde_json::to_string_pretty(&result)?,
1304            error: if result.status == BackgroundTaskStatus::Completed {
1305                None
1306            } else {
1307                result.error
1308            },
1309        })
1310    }
1311
1312    /// List all background delegate task results.
1313    async fn handle_list_results(&self) -> anyhow::Result<ToolResult> {
1314        let results_dir = self.results_dir();
1315        if !results_dir.exists() {
1316            return Ok(ToolResult {
1317                success: true,
1318                output: "No background delegate results found.".into(),
1319                error: None,
1320            });
1321        }
1322
1323        let mut entries = tokio::fs::read_dir(&results_dir).await?;
1324        let mut results = Vec::new();
1325
1326        while let Some(entry) = entries.next_entry().await? {
1327            let path = entry.path();
1328            if path.extension().and_then(|e| e.to_str()) == Some("json")
1329                && let Ok(content) = tokio::fs::read_to_string(&path).await
1330                && let Ok(result) = serde_json::from_str::<BackgroundDelegateResult>(&content)
1331            {
1332                results.push(json!({
1333                    "task_id": result.task_id,
1334                    "agent": result.agent,
1335                    "status": result.status,
1336                    "started_at": result.started_at,
1337                    "finished_at": result.finished_at,
1338                }));
1339            }
1340        }
1341
1342        if results.is_empty() {
1343            return Ok(ToolResult {
1344                success: true,
1345                output: "No background delegate results found.".into(),
1346                error: None,
1347            });
1348        }
1349
1350        Ok(ToolResult {
1351            success: true,
1352            output: serde_json::to_string_pretty(&results)?,
1353            error: None,
1354        })
1355    }
1356
1357    /// Cancel a running background task by task_id.
1358    async fn handle_cancel_task(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
1359        let task_id = args
1360            .get("task_id")
1361            .and_then(|v| v.as_str())
1362            .ok_or_else(|| {
1363                ::zeroclaw_log::record!(
1364                    WARN,
1365                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
1366                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1367                        .with_attrs(::serde_json::json!({"param": "task_id"})),
1368                    "tool argument validation failed"
1369                );
1370
1371                anyhow::Error::msg("Missing 'task_id' parameter for cancel_task")
1372            })?;
1373
1374        if let Err(e) = Self::validate_task_id(task_id) {
1375            return Ok(ToolResult {
1376                success: false,
1377                output: String::new(),
1378                error: Some(e),
1379            });
1380        }
1381
1382        let result_path = self.results_dir().join(format!("{task_id}.json"));
1383        if !result_path.exists() {
1384            return Ok(ToolResult {
1385                success: false,
1386                output: String::new(),
1387                error: Some(format!("No task found for task_id '{task_id}'")),
1388            });
1389        }
1390
1391        // Read current status
1392        let content = tokio::fs::read_to_string(&result_path).await?;
1393        let mut result: BackgroundDelegateResult = serde_json::from_str(&content)?;
1394
1395        if result.status != BackgroundTaskStatus::Running {
1396            return Ok(ToolResult {
1397                success: false,
1398                output: String::new(),
1399                error: Some(format!(
1400                    "Task '{task_id}' is not running (status: {:?})",
1401                    result.status
1402                )),
1403            });
1404        }
1405
1406        // Cancel via the parent token — this will cascade to all child tokens
1407        // Note: individual task cancellation uses the shared parent token, which
1408        // cancels all background tasks. For per-task cancellation, each background
1409        // task uses a child token, and the parent token cancels all.
1410        // We update the result file to reflect the cancellation request.
1411        result.status = BackgroundTaskStatus::Cancelled;
1412        result.error = Some("Cancelled by user request".into());
1413        result.finished_at = Some(chrono::Utc::now().to_rfc3339());
1414        let bytes = serde_json::to_vec_pretty(&result)?;
1415        tokio::fs::write(&result_path, &bytes).await?;
1416
1417        Ok(ToolResult {
1418            success: true,
1419            output: format!("Task '{task_id}' cancellation requested."),
1420            error: None,
1421        })
1422    }
1423
1424    /// Cancel all background tasks (cascade control).
1425    /// Call this when the parent session ends.
1426    pub fn cancel_all_background_tasks(&self) {
1427        self.cancellation_token.cancel();
1428    }
1429
1430    /// Build an enriched system prompt for a sub-agent by composing structured
1431    /// operational sections (tools, skills, workspace, datetime, shell policy)
1432    /// with the per-agent identity files loaded from the target's own
1433    /// workspace dir (`<install>/agents/<alias>/workspace/AGENTS.md`,
1434    /// `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `BOOTSTRAP.md`,
1435    /// `MEMORY.md`).
1436    fn build_enriched_system_prompt(
1437        &self,
1438        agent_alias: &str,
1439        agent_config: &AliasedAgentConfig,
1440        model_name: &str,
1441        sub_tools: &[Box<dyn Tool>],
1442        workspace_dir: &Path,
1443        sends_native_tool_specs: bool,
1444    ) -> Option<String> {
1445        // Resolve skill bundle directories. With one or more configured
1446        // bundles, load + concat skills from each. With none, fall back to
1447        // the workspace default.
1448        let bundle_dirs = self.resolve_skill_bundle_dirs(&agent_config.skill_bundles);
1449        let skills = if bundle_dirs.is_empty() {
1450            let default_dir = crate::skills::skills_dir(workspace_dir);
1451            crate::skills::load_skills_from_directory(&default_dir, false)
1452        } else {
1453            bundle_dirs
1454                .into_iter()
1455                .flat_map(|dir| {
1456                    crate::skills::load_skills_from_directory(&workspace_dir.join(dir), false)
1457                })
1458                .collect()
1459        };
1460
1461        // Determine shell policy instructions when the `shell` tool is in the
1462        // effective tool list.
1463        let empty_tools: &[Box<dyn Tool>] = &[];
1464        let expose_text_tools = sends_native_tool_specs || !agent_config.strict_tool_parsing;
1465        let prompt_tools = if expose_text_tools {
1466            sub_tools
1467        } else {
1468            empty_tools
1469        };
1470        let has_shell = prompt_tools.iter().any(|t| t.name() == "shell");
1471        let shell_policy = if has_shell {
1472            "## Shell Policy\n\n\
1473             - Prefer non-destructive commands. Use `trash` over `rm` where possible.\n\
1474             - Do not run commands that exfiltrate data or modify system-critical paths.\n\
1475             - Avoid interactive commands that block on stdin.\n\
1476             - Quote paths that may contain spaces."
1477                .to_string()
1478        } else {
1479            String::new()
1480        };
1481
1482        // Build structured operational context using SystemPromptBuilder sections.
1483        let ctx = PromptContext {
1484            workspace_dir,
1485            agent_workspace_dir: workspace_dir,
1486            model_name,
1487            tools: prompt_tools,
1488            skills: &skills,
1489            skills_prompt_mode: zeroclaw_config::schema::SkillsPromptInjectionMode::Full,
1490            identity_config: None,
1491            dispatcher_instructions: "",
1492            sends_native_tool_specs: sends_native_tool_specs && !prompt_tools.is_empty(),
1493
1494            security_summary: None,
1495            autonomy_level: crate::security::AutonomyLevel::default(),
1496            channel_targets: None,
1497        };
1498
1499        let builder = SystemPromptBuilder::default()
1500            .add_section(Box::new(crate::agent::prompt::ToolsSection))
1501            .add_section(Box::new(crate::agent::prompt::SafetySection))
1502            .add_section(Box::new(crate::agent::prompt::SkillsSection))
1503            .add_section(Box::new(crate::agent::prompt::WorkspaceSection))
1504            .add_section(Box::new(crate::agent::prompt::DateTimeSection));
1505
1506        let mut enriched = builder.build(&ctx).unwrap_or_default();
1507
1508        if !shell_policy.is_empty() {
1509            enriched.push_str(&shell_policy);
1510            enriched.push_str("\n\n");
1511        }
1512
1513        // Append the per-agent identity files from the target
1514        // sub-agent's own workspace dir. Each missing file is silently
1515        // skipped — the operator may not have authored every file.
1516        // Skipped entirely when no `root_config` is attached (legacy
1517        // unit-test constructors); production paths always attach it.
1518        if let Some(target_workspace) = self.agent_workspace(agent_alias) {
1519            let identity_files = [
1520                "AGENTS.md",
1521                "SOUL.md",
1522                "IDENTITY.md",
1523                "USER.md",
1524                "BOOTSTRAP.md",
1525            ];
1526            for filename in identity_files {
1527                let path = target_workspace.join(filename);
1528                if let Ok(contents) = std::fs::read_to_string(&path) {
1529                    let trimmed = contents.trim();
1530                    if !trimmed.is_empty() {
1531                        enriched.push_str(trimmed);
1532                        enriched.push_str("\n\n");
1533                    }
1534                }
1535            }
1536        }
1537
1538        let trimmed = enriched.trim().to_string();
1539        if trimmed.is_empty() {
1540            None
1541        } else {
1542            Some(trimmed)
1543        }
1544    }
1545
1546    async fn execute_agentic(
1547        &self,
1548        agent_name: &str,
1549        agent_config: &AliasedAgentConfig,
1550        provider_type: &str,
1551        model: &str,
1552        model_provider: &dyn ModelProvider,
1553        full_prompt: &str,
1554        temperature: Option<f64>,
1555    ) -> anyhow::Result<ToolResult> {
1556        let allowed_tools = self.resolve_allowed_tools(&agent_config.risk_profile);
1557
1558        if allowed_tools.is_empty() {
1559            return Ok(ToolResult {
1560                success: false,
1561                output: String::new(),
1562                error: Some(format!(
1563                    "Agent '{agent_name}' is agentic but risk_profile '{}' has no allowed_tools",
1564                    agent_config.risk_profile
1565                )),
1566            });
1567        }
1568
1569        let allowed = allowed_tools
1570            .iter()
1571            .map(|name: &String| name.trim())
1572            .filter(|name| !name.is_empty())
1573            .collect::<std::collections::HashSet<_>>();
1574
1575        let sub_tools: Vec<Box<dyn Tool>> = {
1576            let parent_tools = self.parent_tools.read();
1577            parent_tools
1578                .iter()
1579                .filter(|tool| allowed.contains(tool.name()))
1580                .filter(|tool| tool.name() != "delegate")
1581                .map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
1582                .collect()
1583        };
1584
1585        if sub_tools.is_empty() {
1586            return Ok(ToolResult {
1587                success: false,
1588                output: String::new(),
1589                error: Some(format!(
1590                    "Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
1591                    allowed_tools.join(", ")
1592                )),
1593            });
1594        }
1595
1596        let max_iterations = self.resolve_max_iterations(&agent_config.runtime_profile);
1597
1598        // Build enriched system prompt with tools, skills, workspace, datetime context.
1599        let enriched_system_prompt = self.build_enriched_system_prompt(
1600            agent_name,
1601            agent_config,
1602            model,
1603            &sub_tools,
1604            &self.workspace_dir,
1605            model_provider.supports_native_tools(),
1606        );
1607
1608        let mut history = Vec::new();
1609        if let Some(system_prompt) = enriched_system_prompt.as_ref() {
1610            history.push(ChatMessage::system(system_prompt.clone()));
1611        }
1612        history.push(ChatMessage::user(full_prompt.to_string()));
1613
1614        let noop_observer = NoopObserver;
1615
1616        let agentic_timeout_secs = self
1617            .resolve_agentic_timeout_secs(&agent_config.runtime_profile)
1618            .unwrap_or(self.delegate_config.agentic_timeout_secs);
1619        // Forward the per-turn receipt scope from the parent loop so subagent
1620        // tool calls land in the same collector as the top-level turn. When
1621        // receipts are disabled (or no scope is set, e.g. CLI / background
1622        // delegate spawn) this resolves to `None` and the sub-loop runs
1623        // unsigned, matching the parent.
1624        let receipt_scope = crate::agent::tool_receipts::TOOL_LOOP_RECEIPT_CONTEXT
1625            .try_with(Clone::clone)
1626            .ok()
1627            .flatten();
1628        let receipt_generator = receipt_scope.as_ref().map(|s| &s.generator);
1629        let collected_receipts = receipt_scope.as_ref().map(|s| s.collector.as_ref());
1630        let result = tokio::time::timeout(
1631            Duration::from_secs(agentic_timeout_secs),
1632            run_tool_call_loop(
1633                model_provider,
1634                &mut history,
1635                &sub_tools,
1636                &noop_observer,
1637                provider_type,
1638                model,
1639                temperature,
1640                true,
1641                None,
1642                "delegate",
1643                None,
1644                &self.multimodal_config,
1645                max_iterations,
1646                Some(self.cancellation_token.child_token()),
1647                None,
1648                None,
1649                &[],
1650                &[],
1651                None,
1652                None,
1653                &zeroclaw_config::schema::PacingConfig::default(),
1654                agent_config.strict_tool_parsing,
1655                0,    // max_tool_result_chars: inherit from parent config in future
1656                0,    // context_token_budget: 0 = disabled for subagents
1657                None, // shared_budget: TODO thread from parent in future
1658                None, // channel: delegate subagents don't support approval
1659                receipt_generator,
1660                collected_receipts,
1661            ),
1662        )
1663        .await;
1664
1665        match result {
1666            Ok(Ok(response)) => {
1667                let rendered = if response.trim().is_empty() {
1668                    "[Empty response]".to_string()
1669                } else {
1670                    response
1671                };
1672
1673                Ok(ToolResult {
1674                    success: true,
1675                    output: format!(
1676                        "[Agent '{agent_name}' ({provider_type}/{model}, agentic)]\n{rendered}",
1677                    ),
1678                    error: None,
1679                })
1680            }
1681            Ok(Err(e)) => Ok(ToolResult {
1682                success: false,
1683                output: String::new(),
1684                error: Some(format!("Agent '{agent_name}' failed: {e}")),
1685            }),
1686            Err(_) => Ok(ToolResult {
1687                success: false,
1688                output: String::new(),
1689                error: Some(format!(
1690                    "Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
1691                )),
1692            }),
1693        }
1694    }
1695}
1696
1697struct ToolArcRef {
1698    inner: Arc<dyn Tool>,
1699}
1700
1701impl ToolArcRef {
1702    fn new(inner: Arc<dyn Tool>) -> Self {
1703        Self { inner }
1704    }
1705}
1706
1707impl ::zeroclaw_api::attribution::Attributable for ToolArcRef {
1708    fn role(&self) -> ::zeroclaw_api::attribution::Role {
1709        self.inner.role()
1710    }
1711    fn alias(&self) -> &str {
1712        self.inner.alias()
1713    }
1714}
1715
1716#[async_trait]
1717impl Tool for ToolArcRef {
1718    fn name(&self) -> &str {
1719        self.inner.name()
1720    }
1721
1722    fn description(&self) -> &str {
1723        self.inner.description()
1724    }
1725
1726    fn parameters_schema(&self) -> serde_json::Value {
1727        self.inner.parameters_schema()
1728    }
1729
1730    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1731        self.inner.execute(args).await
1732    }
1733}
1734
1735struct NoopObserver;
1736
1737impl Observer for NoopObserver {
1738    fn record_event(&self, _event: &ObserverEvent) {}
1739
1740    fn record_metric(&self, _metric: &ObserverMetric) {}
1741
1742    fn name(&self) -> &str {
1743        "noop"
1744    }
1745
1746    fn as_any(&self) -> &dyn std::any::Any {
1747        self
1748    }
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753    use super::*;
1754    use crate::security::{AutonomyLevel, SecurityPolicy};
1755    use std::path::Path;
1756    use tokio::time::{Instant, sleep};
1757    use zeroclaw_config::schema::{
1758        DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
1759    };
1760    use zeroclaw_providers::{ChatRequest, ChatResponse, ToolCall};
1761
1762    zeroclaw_api::mock_tool_attribution!(EchoTool, FakeMcpTool);
1763
1764    fn test_security() -> Arc<SecurityPolicy> {
1765        Arc::new(SecurityPolicy::default())
1766    }
1767
1768    fn sample_agents() -> HashMap<String, AliasedAgentConfig> {
1769        let mut agents = HashMap::new();
1770        agents.insert(
1771            "researcher".to_string(),
1772            AliasedAgentConfig {
1773                model_provider: "ollama.researcher".into(),
1774                ..Default::default()
1775            },
1776        );
1777        agents.insert(
1778            "coder".to_string(),
1779            AliasedAgentConfig {
1780                model_provider: "openrouter.coder".into(),
1781                ..Default::default()
1782            },
1783        );
1784        agents
1785    }
1786
1787    async fn wait_for_terminal_background_result(
1788        workspace: &Path,
1789        task_id: &str,
1790    ) -> BackgroundDelegateResult {
1791        let result_path = workspace
1792            .join("delegate_results")
1793            .join(format!("{task_id}.json"));
1794        let deadline = Instant::now() + Duration::from_secs(5);
1795        let mut last_result = None;
1796
1797        loop {
1798            if let Ok(content) = std::fs::read_to_string(&result_path) {
1799                let result: BackgroundDelegateResult = serde_json::from_str(&content).unwrap();
1800                if result.status != BackgroundTaskStatus::Running {
1801                    return result;
1802                }
1803                last_result = Some(result);
1804            }
1805
1806            if Instant::now() >= deadline {
1807                panic!(
1808                    "Background task {task_id} did not finish before timeout; last result: {last_result:?}"
1809                );
1810            }
1811
1812            sleep(Duration::from_millis(50)).await;
1813        }
1814    }
1815
1816    #[derive(Default)]
1817    struct EchoTool;
1818
1819    #[async_trait]
1820    impl Tool for EchoTool {
1821        fn name(&self) -> &str {
1822            "echo_tool"
1823        }
1824
1825        fn description(&self) -> &str {
1826            "Echoes the `value` argument."
1827        }
1828
1829        fn parameters_schema(&self) -> serde_json::Value {
1830            serde_json::json!({
1831                "type": "object",
1832                "properties": {
1833                    "value": {"type": "string"}
1834                },
1835                "required": ["value"]
1836            })
1837        }
1838
1839        async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1840            let value = args
1841                .get("value")
1842                .and_then(serde_json::Value::as_str)
1843                .unwrap_or_default()
1844                .to_string();
1845            Ok(ToolResult {
1846                success: true,
1847                output: format!("echo:{value}"),
1848                error: None,
1849            })
1850        }
1851    }
1852
1853    struct OneToolThenFinalModelProvider;
1854
1855    #[async_trait]
1856    impl ModelProvider for OneToolThenFinalModelProvider {
1857        async fn chat_with_system(
1858            &self,
1859            _system_prompt: Option<&str>,
1860            _message: &str,
1861            _model: &str,
1862            _temperature: Option<f64>,
1863        ) -> anyhow::Result<String> {
1864            Ok("unused".to_string())
1865        }
1866
1867        async fn chat(
1868            &self,
1869            request: ChatRequest<'_>,
1870            _model: &str,
1871            _temperature: Option<f64>,
1872        ) -> anyhow::Result<ChatResponse> {
1873            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1874            if has_tool_message {
1875                Ok(ChatResponse {
1876                    text: Some("done".to_string()),
1877                    tool_calls: Vec::new(),
1878                    usage: None,
1879                    reasoning_content: None,
1880                })
1881            } else {
1882                Ok(ChatResponse {
1883                    text: None,
1884                    tool_calls: vec![ToolCall {
1885                        id: "call_1".to_string(),
1886                        name: "echo_tool".to_string(),
1887                        arguments: "{\"value\":\"ping\"}".to_string(),
1888                        extra_content: None,
1889                    }],
1890                    usage: None,
1891                    reasoning_content: None,
1892                })
1893            }
1894        }
1895    }
1896    impl ::zeroclaw_api::attribution::Attributable for OneToolThenFinalModelProvider {
1897        fn role(&self) -> ::zeroclaw_api::attribution::Role {
1898            ::zeroclaw_api::attribution::Role::Provider(
1899                ::zeroclaw_api::attribution::ProviderKind::Model(
1900                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
1901                ),
1902            )
1903        }
1904        fn alias(&self) -> &str {
1905            "OneToolThenFinalModelProvider"
1906        }
1907    }
1908
1909    struct TextFallbackToolModelProvider;
1910
1911    #[async_trait]
1912    impl ModelProvider for TextFallbackToolModelProvider {
1913        async fn chat_with_system(
1914            &self,
1915            _system_prompt: Option<&str>,
1916            _message: &str,
1917            _model: &str,
1918            _temperature: Option<f64>,
1919        ) -> anyhow::Result<String> {
1920            Ok("unused".to_string())
1921        }
1922
1923        async fn chat(
1924            &self,
1925            _request: ChatRequest<'_>,
1926            _model: &str,
1927            _temperature: Option<f64>,
1928        ) -> anyhow::Result<ChatResponse> {
1929            Ok(ChatResponse {
1930                text: Some(
1931                    r#"<tool_call>{"name":"echo_tool","arguments":{"value":"ignored"}}</tool_call>"#
1932                        .to_string(),
1933                ),
1934                tool_calls: Vec::new(),
1935                usage: None,
1936                reasoning_content: None,
1937            })
1938        }
1939    }
1940    impl ::zeroclaw_api::attribution::Attributable for TextFallbackToolModelProvider {
1941        fn role(&self) -> ::zeroclaw_api::attribution::Role {
1942            ::zeroclaw_api::attribution::Role::Provider(
1943                ::zeroclaw_api::attribution::ProviderKind::Model(
1944                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
1945                ),
1946            )
1947        }
1948        fn alias(&self) -> &str {
1949            "TextFallbackToolModelProvider"
1950        }
1951    }
1952
1953    struct InfiniteToolCallModelProvider;
1954
1955    #[async_trait]
1956    impl ModelProvider for InfiniteToolCallModelProvider {
1957        async fn chat_with_system(
1958            &self,
1959            _system_prompt: Option<&str>,
1960            _message: &str,
1961            _model: &str,
1962            _temperature: Option<f64>,
1963        ) -> anyhow::Result<String> {
1964            Ok("unused".to_string())
1965        }
1966
1967        async fn chat(
1968            &self,
1969            _request: ChatRequest<'_>,
1970            _model: &str,
1971            _temperature: Option<f64>,
1972        ) -> anyhow::Result<ChatResponse> {
1973            Ok(ChatResponse {
1974                text: None,
1975                tool_calls: vec![ToolCall {
1976                    id: "loop".to_string(),
1977                    name: "echo_tool".to_string(),
1978                    arguments: "{\"value\":\"x\"}".to_string(),
1979                    extra_content: None,
1980                }],
1981                usage: None,
1982                reasoning_content: None,
1983            })
1984        }
1985    }
1986    impl ::zeroclaw_api::attribution::Attributable for InfiniteToolCallModelProvider {
1987        fn role(&self) -> ::zeroclaw_api::attribution::Role {
1988            ::zeroclaw_api::attribution::Role::Provider(
1989                ::zeroclaw_api::attribution::ProviderKind::Model(
1990                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
1991                ),
1992            )
1993        }
1994        fn alias(&self) -> &str {
1995            "InfiniteToolCallModelProvider"
1996        }
1997    }
1998
1999    struct FailingModelProvider;
2000
2001    #[async_trait]
2002    impl ModelProvider for FailingModelProvider {
2003        async fn chat_with_system(
2004            &self,
2005            _system_prompt: Option<&str>,
2006            _message: &str,
2007            _model: &str,
2008            _temperature: Option<f64>,
2009        ) -> anyhow::Result<String> {
2010            Ok("unused".to_string())
2011        }
2012
2013        async fn chat(
2014            &self,
2015            _request: ChatRequest<'_>,
2016            _model: &str,
2017            _temperature: Option<f64>,
2018        ) -> anyhow::Result<ChatResponse> {
2019            Err(anyhow::Error::msg("model_provider boom"))
2020        }
2021    }
2022    impl ::zeroclaw_api::attribution::Attributable for FailingModelProvider {
2023        fn role(&self) -> ::zeroclaw_api::attribution::Role {
2024            ::zeroclaw_api::attribution::Role::Provider(
2025                ::zeroclaw_api::attribution::ProviderKind::Model(
2026                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
2027                ),
2028            )
2029        }
2030        fn alias(&self) -> &str {
2031            "FailingModelProvider"
2032        }
2033    }
2034
2035    fn agentic_agent_config() -> AliasedAgentConfig {
2036        AliasedAgentConfig {
2037            model_provider: "openrouter.agentic".into(),
2038            risk_profile: "agentic_test".to_string(),
2039            runtime_profile: "agentic_test".to_string(),
2040            ..Default::default()
2041        }
2042    }
2043
2044    fn agentic_providers_models() -> HashMap<String, HashMap<String, ModelProviderConfig>> {
2045        let mut models: HashMap<String, HashMap<String, ModelProviderConfig>> = HashMap::new();
2046        models.entry("openrouter".to_string()).or_default().insert(
2047            "agentic".to_string(),
2048            ModelProviderConfig {
2049                model: Some("model-test".to_string()),
2050                temperature: Some(0.2),
2051                api_key: Some("delegate-test-credential".to_string()),
2052                ..Default::default()
2053            },
2054        );
2055        models
2056    }
2057
2058    fn agentic_runtime_profiles(max_iterations: usize) -> HashMap<String, RuntimeProfileConfig> {
2059        let mut profiles = HashMap::new();
2060        profiles.insert(
2061            "agentic_test".to_string(),
2062            RuntimeProfileConfig {
2063                agentic: true,
2064                max_tool_iterations: max_iterations,
2065                ..Default::default()
2066            },
2067        );
2068        profiles
2069    }
2070
2071    fn agentic_risk_profiles(allowed_tools: Vec<String>) -> HashMap<String, RiskProfileConfig> {
2072        let mut profiles = HashMap::new();
2073        profiles.insert(
2074            "agentic_test".to_string(),
2075            RiskProfileConfig {
2076                allowed_tools,
2077                ..Default::default()
2078            },
2079        );
2080        profiles
2081    }
2082
2083    #[test]
2084    fn name_and_schema() {
2085        let tool = DelegateTool::new(sample_agents(), None, test_security());
2086        assert_eq!(tool.name(), "delegate");
2087        let schema = tool.parameters_schema();
2088        assert!(schema["properties"]["agent"].is_object());
2089        assert!(schema["properties"]["prompt"].is_object());
2090        assert!(schema["properties"]["context"].is_object());
2091        assert!(schema["properties"]["background"].is_object());
2092        assert!(schema["properties"]["parallel"].is_object());
2093        assert!(schema["properties"]["action"].is_object());
2094        assert!(schema["properties"]["task_id"].is_object());
2095        // required is empty because different actions need different params
2096        let required = schema["required"].as_array().unwrap();
2097        assert!(required.is_empty());
2098        assert_eq!(schema["additionalProperties"], json!(false));
2099        assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
2100        assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
2101    }
2102
2103    #[test]
2104    fn description_not_empty() {
2105        let tool = DelegateTool::new(sample_agents(), None, test_security());
2106        assert!(!tool.description().is_empty());
2107    }
2108
2109    #[test]
2110    fn schema_lists_agent_names() {
2111        let tool = DelegateTool::new(sample_agents(), None, test_security());
2112        let schema = tool.parameters_schema();
2113        let desc = schema["properties"]["agent"]["description"]
2114            .as_str()
2115            .unwrap();
2116        assert!(desc.contains("researcher") || desc.contains("coder"));
2117    }
2118
2119    #[tokio::test]
2120    async fn missing_agent_param() {
2121        let tool = DelegateTool::new(sample_agents(), None, test_security());
2122        let result = tool.execute(json!({"prompt": "test"})).await;
2123        assert!(result.is_err());
2124    }
2125
2126    #[tokio::test]
2127    async fn missing_prompt_param() {
2128        let tool = DelegateTool::new(sample_agents(), None, test_security());
2129        let result = tool.execute(json!({"agent": "researcher"})).await;
2130        assert!(result.is_err());
2131    }
2132
2133    #[tokio::test]
2134    async fn unknown_agent_returns_error() {
2135        let tool = DelegateTool::new(sample_agents(), None, test_security());
2136        let result = tool
2137            .execute(json!({"agent": "nonexistent", "prompt": "test"}))
2138            .await
2139            .unwrap();
2140        assert!(!result.success);
2141        assert!(result.error.unwrap().contains("Unknown agent"));
2142    }
2143
2144    #[tokio::test]
2145    async fn depth_limit_enforced() {
2146        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
2147        let result = tool
2148            .execute(json!({"agent": "researcher", "prompt": "test"}))
2149            .await
2150            .unwrap();
2151        assert!(!result.success);
2152        assert!(result.error.unwrap().contains("depth limit"));
2153    }
2154
2155    #[tokio::test]
2156    async fn depth_limit_at_default_max() {
2157        // Default max_depth is 3; at depth=3 the agent should be blocked.
2158        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
2159        let result = tool
2160            .execute(json!({"agent": "coder", "prompt": "test"}))
2161            .await
2162            .unwrap();
2163        assert!(!result.success);
2164        assert!(result.error.unwrap().contains("depth limit"));
2165    }
2166
2167    #[test]
2168    fn empty_agents_schema() {
2169        let tool = DelegateTool::new(HashMap::new(), None, test_security());
2170        let schema = tool.parameters_schema();
2171        let desc = schema["properties"]["agent"]["description"]
2172            .as_str()
2173            .unwrap();
2174        assert!(desc.contains("none configured"));
2175    }
2176
2177    #[tokio::test]
2178    async fn invalid_provider_returns_error() {
2179        let mut agents = HashMap::new();
2180        agents.insert(
2181            "broken".to_string(),
2182            AliasedAgentConfig {
2183                model_provider: "totally-invalid-provider.default".into(),
2184                ..Default::default()
2185            },
2186        );
2187        let tool = DelegateTool::new(agents, None, test_security());
2188        let result = tool
2189            .execute(json!({"agent": "broken", "prompt": "test"}))
2190            .await
2191            .unwrap();
2192        assert!(!result.success);
2193        assert!(
2194            result
2195                .error
2196                .unwrap()
2197                .contains("Failed to create model_provider")
2198        );
2199    }
2200
2201    #[tokio::test]
2202    async fn blank_agent_rejected() {
2203        let tool = DelegateTool::new(sample_agents(), None, test_security());
2204        let result = tool
2205            .execute(json!({"agent": "  ", "prompt": "test"}))
2206            .await
2207            .unwrap();
2208        assert!(!result.success);
2209        assert!(result.error.unwrap().contains("must not be empty"));
2210    }
2211
2212    #[tokio::test]
2213    async fn blank_prompt_rejected() {
2214        let tool = DelegateTool::new(sample_agents(), None, test_security());
2215        let result = tool
2216            .execute(json!({"agent": "researcher", "prompt": "  \t  "}))
2217            .await
2218            .unwrap();
2219        assert!(!result.success);
2220        assert!(result.error.unwrap().contains("must not be empty"));
2221    }
2222
2223    #[tokio::test]
2224    async fn whitespace_agent_name_trimmed_and_found() {
2225        let tool = DelegateTool::new(sample_agents(), None, test_security());
2226        // " researcher " with surrounding whitespace — after trim becomes "researcher"
2227        let result = tool
2228            .execute(json!({"agent": " researcher ", "prompt": "test"}))
2229            .await
2230            .unwrap();
2231        // Should find "researcher" after trim — will fail at model_provider level
2232        // since ollama isn't running, but must NOT get "Unknown agent".
2233        assert!(
2234            result.error.is_none()
2235                || !result
2236                    .error
2237                    .as_deref()
2238                    .unwrap_or("")
2239                    .contains("Unknown agent")
2240        );
2241    }
2242
2243    #[tokio::test]
2244    async fn delegation_blocked_in_readonly_mode() {
2245        let readonly = Arc::new(SecurityPolicy {
2246            autonomy: AutonomyLevel::ReadOnly,
2247            ..SecurityPolicy::default()
2248        });
2249        let tool = DelegateTool::new(sample_agents(), None, readonly);
2250        let result = tool
2251            .execute(json!({"agent": "researcher", "prompt": "test"}))
2252            .await
2253            .unwrap();
2254        assert!(!result.success);
2255        assert!(
2256            result
2257                .error
2258                .as_deref()
2259                .unwrap_or("")
2260                .contains("read-only mode")
2261        );
2262    }
2263
2264    #[tokio::test]
2265    async fn delegation_blocked_when_rate_limited() {
2266        let limited = Arc::new(SecurityPolicy {
2267            max_actions_per_hour: 0,
2268            ..SecurityPolicy::default()
2269        });
2270        let tool = DelegateTool::new(sample_agents(), None, limited);
2271        let result = tool
2272            .execute(json!({"agent": "researcher", "prompt": "test"}))
2273            .await
2274            .unwrap();
2275        assert!(!result.success);
2276        assert!(
2277            result
2278                .error
2279                .as_deref()
2280                .unwrap_or("")
2281                .contains("Rate limit exceeded")
2282        );
2283    }
2284
2285    #[tokio::test]
2286    async fn delegate_context_is_prepended_to_prompt() {
2287        let mut agents = HashMap::new();
2288        agents.insert(
2289            "tester".to_string(),
2290            AliasedAgentConfig {
2291                model_provider: "invalid-for-test.default".into(),
2292                ..Default::default()
2293            },
2294        );
2295        let tool = DelegateTool::new(agents, None, test_security());
2296        let result = tool
2297            .execute(json!({
2298                "agent": "tester",
2299                "prompt": "do something",
2300                "context": "some context data"
2301            }))
2302            .await
2303            .unwrap();
2304
2305        assert!(!result.success);
2306        assert!(
2307            result
2308                .error
2309                .as_deref()
2310                .unwrap_or("")
2311                .contains("Failed to create model_provider")
2312        );
2313    }
2314
2315    #[tokio::test]
2316    async fn delegate_empty_context_omits_prefix() {
2317        let mut agents = HashMap::new();
2318        agents.insert(
2319            "tester".to_string(),
2320            AliasedAgentConfig {
2321                model_provider: "invalid-for-test.default".into(),
2322                ..Default::default()
2323            },
2324        );
2325        let tool = DelegateTool::new(agents, None, test_security());
2326        let result = tool
2327            .execute(json!({
2328                "agent": "tester",
2329                "prompt": "do something",
2330                "context": ""
2331            }))
2332            .await
2333            .unwrap();
2334
2335        assert!(!result.success);
2336        assert!(
2337            result
2338                .error
2339                .as_deref()
2340                .unwrap_or("")
2341                .contains("Failed to create model_provider")
2342        );
2343    }
2344
2345    #[test]
2346    fn delegate_depth_construction() {
2347        let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
2348        assert_eq!(tool.depth, 5);
2349    }
2350
2351    #[tokio::test]
2352    async fn delegate_no_agents_configured() {
2353        let tool = DelegateTool::new(HashMap::new(), None, test_security());
2354        let result = tool
2355            .execute(json!({"agent": "any", "prompt": "test"}))
2356            .await
2357            .unwrap();
2358        assert!(!result.success);
2359        assert!(result.error.unwrap().contains("none configured"));
2360    }
2361
2362    #[tokio::test]
2363    async fn agentic_mode_rejects_empty_allowed_tools() {
2364        let mut agents = HashMap::new();
2365        agents.insert("agentic".to_string(), agentic_agent_config());
2366
2367        let tool = DelegateTool::new(agents, None, test_security())
2368            .with_providers_models(agentic_providers_models())
2369            .with_runtime_profiles(agentic_runtime_profiles(10))
2370            .with_risk_profiles(agentic_risk_profiles(Vec::new()));
2371        let result = tool
2372            .execute(json!({"agent": "agentic", "prompt": "test"}))
2373            .await
2374            .unwrap();
2375
2376        assert!(!result.success);
2377        assert!(
2378            result
2379                .error
2380                .as_deref()
2381                .unwrap_or("")
2382                .contains("has no allowed_tools"),
2383            "got: {:?}",
2384            result.error
2385        );
2386    }
2387
2388    #[tokio::test]
2389    async fn agentic_mode_rejects_unmatched_allowed_tools() {
2390        let mut agents = HashMap::new();
2391        agents.insert("agentic".to_string(), agentic_agent_config());
2392
2393        let allowed = vec!["missing_tool".to_string()];
2394        let tool = DelegateTool::new(agents, None, test_security())
2395            .with_providers_models(agentic_providers_models())
2396            .with_runtime_profiles(agentic_runtime_profiles(10))
2397            .with_risk_profiles(agentic_risk_profiles(allowed))
2398            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2399        let result = tool
2400            .execute(json!({"agent": "agentic", "prompt": "test"}))
2401            .await
2402            .unwrap();
2403
2404        assert!(!result.success);
2405        assert!(
2406            result
2407                .error
2408                .as_deref()
2409                .unwrap_or("")
2410                .contains("no executable tools")
2411        );
2412    }
2413
2414    #[tokio::test]
2415    async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
2416        let config = agentic_agent_config();
2417        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2418            .with_runtime_profiles(agentic_runtime_profiles(10))
2419            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2420            .with_parent_tools(Arc::new(RwLock::new(vec![
2421                Arc::new(EchoTool),
2422                Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
2423            ])));
2424
2425        let model_provider = OneToolThenFinalModelProvider;
2426        let result = tool
2427            .execute_agentic(
2428                "agentic",
2429                &config,
2430                "openrouter",
2431                "model-test",
2432                &model_provider,
2433                "run",
2434                Some(0.2),
2435            )
2436            .await
2437            .unwrap();
2438
2439        assert!(result.success);
2440        assert!(result.output.contains("(openrouter/model-test, agentic)"));
2441        assert!(result.output.contains("done"));
2442    }
2443
2444    #[tokio::test]
2445    async fn execute_agentic_strict_tool_parsing_uses_target_agent_policy() {
2446        let mut config = agentic_agent_config();
2447        config.strict_tool_parsing = true;
2448        let prompt_tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2449        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2450            .with_runtime_profiles(agentic_runtime_profiles(10))
2451            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2452            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2453
2454        let prompt = tool
2455            .build_enriched_system_prompt(
2456                "agentic",
2457                &config,
2458                "model-test",
2459                &prompt_tools,
2460                Path::new("/tmp"),
2461                false,
2462            )
2463            .expect("prompt should render");
2464        assert!(
2465            !prompt.contains("## Tools"),
2466            "strict delegate prompt should not advertise text tool instructions"
2467        );
2468        assert!(
2469            !prompt.contains("echo_tool"),
2470            "strict delegate prompt should hide text-only tool schemas"
2471        );
2472
2473        let model_provider = TextFallbackToolModelProvider;
2474        let result = tool
2475            .execute_agentic(
2476                "agentic",
2477                &config,
2478                "openrouter",
2479                "model-test",
2480                &model_provider,
2481                "run",
2482                Some(0.2),
2483            )
2484            .await
2485            .unwrap();
2486
2487        assert!(result.success);
2488        assert!(
2489            result.output.contains("<tool_call>"),
2490            "strict subagent should return fallback-looking text unchanged"
2491        );
2492        assert!(
2493            !result.output.contains("echo:ignored"),
2494            "strict subagent must not execute text fallback tool calls"
2495        );
2496    }
2497
2498    #[tokio::test]
2499    async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
2500        let config = agentic_agent_config();
2501        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2502            .with_runtime_profiles(agentic_runtime_profiles(10))
2503            .with_risk_profiles(agentic_risk_profiles(vec!["delegate".to_string()]))
2504            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(DelegateTool::new(
2505                HashMap::new(),
2506                None,
2507                test_security(),
2508            ))])));
2509
2510        let model_provider = OneToolThenFinalModelProvider;
2511        let result = tool
2512            .execute_agentic(
2513                "agentic",
2514                &config,
2515                "openrouter",
2516                "model-test",
2517                &model_provider,
2518                "run",
2519                Some(0.2),
2520            )
2521            .await
2522            .unwrap();
2523
2524        assert!(!result.success);
2525        assert!(
2526            result
2527                .error
2528                .as_deref()
2529                .unwrap_or("")
2530                .contains("no executable tools")
2531        );
2532    }
2533
2534    #[tokio::test]
2535    async fn execute_agentic_respects_max_iterations() {
2536        let config = agentic_agent_config();
2537        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2538            .with_runtime_profiles(agentic_runtime_profiles(2))
2539            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2540            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2541
2542        let model_provider = InfiniteToolCallModelProvider;
2543        let result = tool
2544            .execute_agentic(
2545                "agentic",
2546                &config,
2547                "openrouter",
2548                "model-test",
2549                &model_provider,
2550                "run",
2551                Some(0.2),
2552            )
2553            .await
2554            .unwrap();
2555
2556        assert!(!result.success);
2557        assert!(
2558            result
2559                .error
2560                .as_deref()
2561                .unwrap_or("")
2562                .contains("maximum tool iterations (2)")
2563        );
2564    }
2565
2566    #[tokio::test]
2567    async fn execute_agentic_forwards_receipt_scope_into_subagent_loop() {
2568        // Receipt forwarding through the delegate sub-loop is the activation
2569        // pass for #6182's delegate.rs:1184 acceptance criterion. With
2570        // `TOOL_LOOP_RECEIPT_CONTEXT` scoped, every sub-tool call inside the
2571        // delegate must produce a receipt that lands in the same per-turn
2572        // collector the parent passed in. Without the task-local read in
2573        // `execute_sync` this test fails: the collector stays empty because
2574        // the sub-loop runs unsigned with `None, None` for the receipt args.
2575        use crate::agent::tool_receipts::{
2576            ReceiptGenerator, ReceiptScope, TOOL_LOOP_RECEIPT_CONTEXT,
2577        };
2578
2579        let config = agentic_agent_config();
2580        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2581            .with_runtime_profiles(agentic_runtime_profiles(10))
2582            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2583            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2584
2585        let collector: Arc<std::sync::Mutex<Vec<String>>> =
2586            Arc::new(std::sync::Mutex::new(Vec::new()));
2587        let scope = ReceiptScope {
2588            generator: ReceiptGenerator::new(),
2589            collector: Arc::clone(&collector),
2590        };
2591
2592        let model_provider = OneToolThenFinalModelProvider;
2593        let result = TOOL_LOOP_RECEIPT_CONTEXT
2594            .scope(Some(scope), async {
2595                tool.execute_agentic(
2596                    "agentic",
2597                    &config,
2598                    "test-provider",
2599                    "test-model",
2600                    &model_provider,
2601                    "run",
2602                    Some(0.2),
2603                )
2604                .await
2605            })
2606            .await
2607            .unwrap();
2608
2609        assert!(
2610            result.success,
2611            "delegate sub-loop must complete: {result:?}"
2612        );
2613        let receipts = collector.lock().unwrap();
2614        assert_eq!(
2615            receipts.len(),
2616            1,
2617            "expected exactly one receipt for the single echo_tool sub-call, got: {:?}",
2618            receipts.as_slice()
2619        );
2620        assert!(
2621            receipts[0].starts_with("echo_tool: zc-receipt-"),
2622            "sub-tool receipt must be tagged with the tool name and a zc-receipt- HMAC token, got: {}",
2623            receipts[0]
2624        );
2625    }
2626
2627    #[tokio::test]
2628    async fn delegate_spawn_helper_forwards_session_key() {
2629        let seen = TOOL_LOOP_SESSION_KEY
2630            .scope(Some("channel_session".to_string()), async {
2631                let session_key = current_tool_loop_session_key();
2632                tokio::spawn(async move {
2633                    scope_delegate_session_key(session_key, async {
2634                        current_tool_loop_session_key()
2635                    })
2636                    .await
2637                })
2638                .await
2639                .unwrap()
2640            })
2641            .await;
2642
2643        assert_eq!(seen.as_deref(), Some("channel_session"));
2644    }
2645
2646    #[tokio::test]
2647    async fn execute_agentic_emits_no_receipts_when_scope_absent() {
2648        // Backward-compat for callers without a scoped receipt context (CLI,
2649        // background spawn that does not forward scope, tests). The sub-loop
2650        // must run unsigned and the agent output must not carry a
2651        // `[receipt: ` trailer.
2652        let config = agentic_agent_config();
2653        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2654            .with_runtime_profiles(agentic_runtime_profiles(10))
2655            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2656            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2657
2658        let model_provider = OneToolThenFinalModelProvider;
2659        let result = tool
2660            .execute_agentic(
2661                "agentic",
2662                &config,
2663                "test-provider",
2664                "test-model",
2665                &model_provider,
2666                "run",
2667                Some(0.2),
2668            )
2669            .await
2670            .unwrap();
2671
2672        assert!(result.success);
2673        assert!(
2674            !result.output.contains("[receipt: "),
2675            "no receipt trailer must appear in agent output when receipts are disabled, got: {}",
2676            result.output
2677        );
2678    }
2679
2680    #[tokio::test]
2681    async fn execute_agentic_propagates_provider_errors() {
2682        let config = agentic_agent_config();
2683        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2684            .with_runtime_profiles(agentic_runtime_profiles(10))
2685            .with_risk_profiles(agentic_risk_profiles(vec!["echo_tool".to_string()]))
2686            .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
2687
2688        let model_provider = FailingModelProvider;
2689        let result = tool
2690            .execute_agentic(
2691                "agentic",
2692                &config,
2693                "openrouter",
2694                "model-test",
2695                &model_provider,
2696                "run",
2697                Some(0.2),
2698            )
2699            .await
2700            .unwrap();
2701
2702        assert!(!result.success);
2703        assert!(
2704            result
2705                .error
2706                .as_deref()
2707                .unwrap_or("")
2708                .contains("model_provider boom")
2709        );
2710    }
2711
2712    /// MCP tools pushed into the shared parent_tools handle after DelegateTool
2713    /// construction must be visible to the sub-agent tool list.
2714    #[derive(Default)]
2715    struct FakeMcpTool;
2716
2717    #[async_trait]
2718    impl Tool for FakeMcpTool {
2719        fn name(&self) -> &str {
2720            "mcp_fake"
2721        }
2722
2723        fn description(&self) -> &str {
2724            "Fake MCP tool for testing."
2725        }
2726
2727        fn parameters_schema(&self) -> serde_json::Value {
2728            serde_json::json!({"type": "object", "properties": {}})
2729        }
2730
2731        async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2732            Ok(ToolResult {
2733                success: true,
2734                output: "mcp_fake_output".into(),
2735                error: None,
2736            })
2737        }
2738    }
2739
2740    struct McpToolThenFinalModelProvider;
2741
2742    #[async_trait]
2743    impl ModelProvider for McpToolThenFinalModelProvider {
2744        async fn chat_with_system(
2745            &self,
2746            _system_prompt: Option<&str>,
2747            _message: &str,
2748            _model: &str,
2749            _temperature: Option<f64>,
2750        ) -> anyhow::Result<String> {
2751            Ok("unused".to_string())
2752        }
2753
2754        async fn chat(
2755            &self,
2756            request: ChatRequest<'_>,
2757            _model: &str,
2758            _temperature: Option<f64>,
2759        ) -> anyhow::Result<ChatResponse> {
2760            let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
2761            if has_tool_message {
2762                Ok(ChatResponse {
2763                    text: Some("mcp done".to_string()),
2764                    tool_calls: Vec::new(),
2765                    usage: None,
2766                    reasoning_content: None,
2767                })
2768            } else {
2769                Ok(ChatResponse {
2770                    text: None,
2771                    tool_calls: vec![ToolCall {
2772                        id: "call_mcp".to_string(),
2773                        name: "mcp_fake".to_string(),
2774                        arguments: "{}".to_string(),
2775                        extra_content: None,
2776                    }],
2777                    usage: None,
2778                    reasoning_content: None,
2779                })
2780            }
2781        }
2782    }
2783    impl ::zeroclaw_api::attribution::Attributable for McpToolThenFinalModelProvider {
2784        fn role(&self) -> ::zeroclaw_api::attribution::Role {
2785            ::zeroclaw_api::attribution::Role::Provider(
2786                ::zeroclaw_api::attribution::ProviderKind::Model(
2787                    ::zeroclaw_api::attribution::ModelProviderKind::Custom,
2788                ),
2789            )
2790        }
2791        fn alias(&self) -> &str {
2792            "McpToolThenFinalModelProvider"
2793        }
2794    }
2795
2796    #[tokio::test]
2797    async fn mcp_tools_included_in_subagent_tool_list() {
2798        // Build DelegateTool with NO parent tools initially
2799        let config = agentic_agent_config();
2800        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2801            .with_runtime_profiles(agentic_runtime_profiles(10))
2802            .with_risk_profiles(agentic_risk_profiles(vec!["mcp_fake".to_string()]))
2803            .with_parent_tools(Arc::new(RwLock::new(Vec::new())));
2804
2805        // Simulate late MCP tool injection via the shared handle
2806        let handle = tool.parent_tools_handle();
2807        handle.write().push(Arc::new(FakeMcpTool));
2808
2809        let model_provider = McpToolThenFinalModelProvider;
2810        let result = tool
2811            .execute_agentic(
2812                "agentic",
2813                &config,
2814                "openrouter",
2815                "model-test",
2816                &model_provider,
2817                "run mcp",
2818                Some(0.2),
2819            )
2820            .await
2821            .unwrap();
2822
2823        assert!(result.success, "Expected success, got: {:?}", result.error);
2824        assert!(
2825            result.output.contains("mcp done"),
2826            "Expected output containing 'mcp done', got: {}",
2827            result.output
2828        );
2829    }
2830
2831    #[test]
2832    fn enriched_prompt_includes_tools_workspace_datetime() {
2833        let config = AliasedAgentConfig {
2834            model_provider: "openrouter.test".into(),
2835            ..Default::default()
2836        };
2837
2838        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2839        let workspace = std::env::temp_dir().join(format!(
2840            "zeroclaw_delegate_enrich_test_{}",
2841            uuid::Uuid::new_v4()
2842        ));
2843        std::fs::create_dir_all(&workspace).unwrap();
2844
2845        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2846            .with_workspace_dir(workspace.clone());
2847
2848        let prompt = tool
2849            .build_enriched_system_prompt("alpha", &config, "test-model", &tools, &workspace, false)
2850            .unwrap();
2851
2852        assert!(prompt.contains("## Tools"), "should contain tools section");
2853        assert!(prompt.contains("echo_tool"), "should list allowed tools");
2854        assert!(
2855            prompt.contains("## Workspace"),
2856            "should contain workspace section"
2857        );
2858        assert!(
2859            prompt.contains(&workspace.display().to_string()),
2860            "should contain workspace path"
2861        );
2862        assert!(
2863            prompt.contains("## CRITICAL CONTEXT: CURRENT DATE & TIME"),
2864            "should contain datetime section"
2865        );
2866        // Identity files come from the target sub-agent's per-agent
2867        // workspace dir. The test's install_root is unset, so no
2868        // identity files exist for the dummy alias — the prompt still
2869        // contains the structural sections verified above, which is
2870        // the load-bearing assertion.
2871
2872        let _ = std::fs::remove_dir_all(workspace);
2873    }
2874
2875    #[test]
2876    fn enriched_prompt_includes_shell_policy_when_shell_present() {
2877        let config = AliasedAgentConfig::default();
2878
2879        struct MockShellTool;
2880        impl ::zeroclaw_api::attribution::Attributable for MockShellTool {
2881            fn role(&self) -> ::zeroclaw_api::attribution::Role {
2882                ::zeroclaw_api::attribution::Role::Tool(
2883                    ::zeroclaw_api::attribution::ToolKind::Shell,
2884                )
2885            }
2886            fn alias(&self) -> &str {
2887                <Self as Tool>::name(self)
2888            }
2889        }
2890        #[async_trait]
2891        impl Tool for MockShellTool {
2892            fn name(&self) -> &str {
2893                "shell"
2894            }
2895            fn description(&self) -> &str {
2896                "Execute shell commands"
2897            }
2898            fn parameters_schema(&self) -> serde_json::Value {
2899                json!({"type": "object"})
2900            }
2901            async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2902                Ok(ToolResult {
2903                    success: true,
2904                    output: String::new(),
2905                    error: None,
2906                })
2907            }
2908        }
2909
2910        let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockShellTool)];
2911        let workspace = std::env::temp_dir();
2912
2913        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2914            .with_workspace_dir(workspace.to_path_buf());
2915
2916        let prompt = tool
2917            .build_enriched_system_prompt("alpha", &config, "test-model", &tools, &workspace, false)
2918            .unwrap();
2919
2920        assert!(
2921            prompt.contains("## Shell Policy"),
2922            "should contain shell policy when shell tool is present"
2923        );
2924    }
2925
2926    #[test]
2927    fn parent_tools_handle_returns_shared_reference() {
2928        let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
2929            Arc::new(RwLock::new(vec![Arc::new(EchoTool) as Arc<dyn Tool>])),
2930        );
2931
2932        let handle = tool.parent_tools_handle();
2933        assert_eq!(handle.read().len(), 1);
2934
2935        // Push a new tool via the handle
2936        handle.write().push(Arc::new(FakeMcpTool));
2937        assert_eq!(handle.read().len(), 2);
2938    }
2939
2940    // ── Configurable timeout tests ──────────────────────────────────
2941
2942    #[test]
2943    fn delegate_timeout_defaults_come_from_delegate_config() {
2944        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2945            .with_delegate_config(DelegateToolConfig::default());
2946        assert_eq!(
2947            tool.delegate_config.timeout_secs,
2948            DEFAULT_DELEGATE_TIMEOUT_SECS
2949        );
2950        assert_eq!(
2951            tool.delegate_config.agentic_timeout_secs,
2952            DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
2953        );
2954    }
2955
2956    #[test]
2957    fn enriched_prompt_omits_shell_policy_without_shell_tool() {
2958        let config = AliasedAgentConfig::default();
2959
2960        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2961        let workspace = std::env::temp_dir();
2962
2963        let tool = DelegateTool::new(HashMap::new(), None, test_security())
2964            .with_workspace_dir(workspace.to_path_buf());
2965
2966        let prompt = tool
2967            .build_enriched_system_prompt("alpha", &config, "test-model", &tools, &workspace, false)
2968            .unwrap();
2969
2970        assert!(
2971            !prompt.contains("## Shell Policy"),
2972            "should not contain shell policy when shell tool is absent"
2973        );
2974    }
2975
2976    #[test]
2977    fn config_validation_accepts_minimal_agent() {
2978        let mut config = zeroclaw_config::schema::Config::default();
2979        // model_provider must reference a real entry under
2980        // providers.models — the validator (correctly) rejects dangling refs.
2981        config.providers.models.ollama.insert(
2982            "default".into(),
2983            zeroclaw_config::schema::OllamaModelProviderConfig::default(),
2984        );
2985        config.risk_profiles.insert(
2986            "default".into(),
2987            zeroclaw_config::schema::RiskProfileConfig::default(),
2988        );
2989        config.agents.insert(
2990            "ok".into(),
2991            AliasedAgentConfig {
2992                model_provider: "ollama.default".into(),
2993                risk_profile: "default".into(),
2994                ..Default::default()
2995            },
2996        );
2997        assert!(
2998            config.validate().is_ok(),
2999            "validate: {:?}",
3000            config.validate()
3001        );
3002    }
3003
3004    #[test]
3005    fn enriched_prompt_loads_skills_from_scoped_directory() {
3006        let workspace = std::env::temp_dir().join(format!(
3007            "zeroclaw_delegate_skills_test_{}",
3008            uuid::Uuid::new_v4()
3009        ));
3010        let scoped_skills_dir = workspace.join("skills/code-review");
3011        std::fs::create_dir_all(scoped_skills_dir.join("lint-check")).unwrap();
3012        std::fs::write(
3013            scoped_skills_dir.join("lint-check/SKILL.toml"),
3014            "[skill]\nname = \"lint-check\"\ndescription = \"Run lint checks\"\nversion = \"1.0.0\"\n",
3015        )
3016        .unwrap();
3017
3018        let config = AliasedAgentConfig {
3019            skill_bundles: vec!["code_review".to_string()],
3020            ..Default::default()
3021        };
3022
3023        let mut skill_bundles = HashMap::new();
3024        skill_bundles.insert(
3025            "code_review".to_string(),
3026            SkillBundleConfig {
3027                directory: Some("skills/code-review".to_string()),
3028                ..Default::default()
3029            },
3030        );
3031
3032        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
3033
3034        let tool = DelegateTool::new(HashMap::new(), None, test_security())
3035            .with_skill_bundles(skill_bundles)
3036            .with_workspace_dir(workspace.clone());
3037
3038        let prompt = tool
3039            .build_enriched_system_prompt("alpha", &config, "test-model", &tools, &workspace, false)
3040            .unwrap();
3041
3042        assert!(
3043            prompt.contains("lint-check"),
3044            "should contain skills from scoped directory"
3045        );
3046
3047        let _ = std::fs::remove_dir_all(workspace);
3048    }
3049
3050    #[test]
3051    fn enriched_prompt_falls_back_to_default_skills_dir() {
3052        let workspace = std::env::temp_dir().join(format!(
3053            "zeroclaw_delegate_fallback_test_{}",
3054            uuid::Uuid::new_v4()
3055        ));
3056        let default_skills_dir = workspace.join("skills");
3057        std::fs::create_dir_all(default_skills_dir.join("deploy")).unwrap();
3058        std::fs::write(
3059            default_skills_dir.join("deploy/SKILL.toml"),
3060            "[skill]\nname = \"deploy\"\ndescription = \"Deploy safely\"\nversion = \"1.0.0\"\n",
3061        )
3062        .unwrap();
3063
3064        let config = AliasedAgentConfig::default();
3065
3066        let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
3067
3068        let tool = DelegateTool::new(HashMap::new(), None, test_security())
3069            .with_workspace_dir(workspace.clone());
3070
3071        let prompt = tool
3072            .build_enriched_system_prompt("alpha", &config, "test-model", &tools, &workspace, false)
3073            .unwrap();
3074
3075        assert!(
3076            prompt.contains("deploy"),
3077            "should contain skills from default workspace skills/ directory"
3078        );
3079
3080        let _ = std::fs::remove_dir_all(workspace);
3081    }
3082
3083    // ── Background and Parallel execution tests ─────────────────────
3084
3085    #[tokio::test]
3086    async fn background_delegation_returns_task_id() {
3087        let workspace = std::env::temp_dir().join(format!(
3088            "zeroclaw_delegate_bg_test_{}",
3089            uuid::Uuid::new_v4()
3090        ));
3091        std::fs::create_dir_all(&workspace).unwrap();
3092
3093        let tool = DelegateTool::new(sample_agents(), None, test_security())
3094            .with_workspace_dir(workspace.clone());
3095        let result = tool
3096            .execute(json!({
3097                "agent": "researcher",
3098                "prompt": "test background",
3099                "background": true
3100            }))
3101            .await
3102            .unwrap();
3103
3104        // The agent will fail at model_provider level (ollama not running),
3105        // but the background task should be spawned and return a task_id.
3106        assert!(result.success);
3107        assert!(result.output.contains("task_id:"));
3108        assert!(result.output.contains("Background task started"));
3109
3110        // Wait a moment for the background task to write its result
3111        tokio::time::sleep(Duration::from_millis(200)).await;
3112
3113        // The results directory should exist
3114        assert!(workspace.join("delegate_results").exists());
3115
3116        let _ = std::fs::remove_dir_all(workspace);
3117    }
3118
3119    #[tokio::test]
3120    async fn background_unknown_agent_rejected() {
3121        let workspace = std::env::temp_dir().join(format!(
3122            "zeroclaw_delegate_bg_unknown_{}",
3123            uuid::Uuid::new_v4()
3124        ));
3125        std::fs::create_dir_all(&workspace).unwrap();
3126
3127        let tool = DelegateTool::new(sample_agents(), None, test_security())
3128            .with_workspace_dir(workspace.clone());
3129        let result = tool
3130            .execute(json!({
3131                "agent": "nonexistent",
3132                "prompt": "test",
3133                "background": true
3134            }))
3135            .await
3136            .unwrap();
3137
3138        assert!(!result.success);
3139        assert!(result.error.unwrap().contains("Unknown agent"));
3140
3141        let _ = std::fs::remove_dir_all(workspace);
3142    }
3143
3144    #[tokio::test]
3145    async fn check_result_missing_task_id() {
3146        let workspace = std::env::temp_dir().join(format!(
3147            "zeroclaw_delegate_check_noid_{}",
3148            uuid::Uuid::new_v4()
3149        ));
3150        std::fs::create_dir_all(&workspace).unwrap();
3151
3152        let tool = DelegateTool::new(sample_agents(), None, test_security())
3153            .with_workspace_dir(workspace.clone());
3154        let result = tool.execute(json!({"action": "check_result"})).await;
3155
3156        assert!(result.is_err());
3157
3158        let _ = std::fs::remove_dir_all(workspace);
3159    }
3160
3161    #[tokio::test]
3162    async fn check_result_nonexistent_task() {
3163        let workspace = std::env::temp_dir().join(format!(
3164            "zeroclaw_delegate_check_miss_{}",
3165            uuid::Uuid::new_v4()
3166        ));
3167        std::fs::create_dir_all(&workspace).unwrap();
3168
3169        let tool = DelegateTool::new(sample_agents(), None, test_security())
3170            .with_workspace_dir(workspace.clone());
3171        // Use a valid UUID format that doesn't correspond to any real task
3172        let fake_uuid = uuid::Uuid::new_v4().to_string();
3173        let result = tool
3174            .execute(json!({
3175                "action": "check_result",
3176                "task_id": fake_uuid
3177            }))
3178            .await
3179            .unwrap();
3180
3181        assert!(!result.success);
3182        assert!(result.error.unwrap().contains("No result found"));
3183
3184        let _ = std::fs::remove_dir_all(workspace);
3185    }
3186
3187    #[tokio::test]
3188    async fn list_results_empty() {
3189        let workspace = std::env::temp_dir().join(format!(
3190            "zeroclaw_delegate_list_empty_{}",
3191            uuid::Uuid::new_v4()
3192        ));
3193        std::fs::create_dir_all(&workspace).unwrap();
3194
3195        let tool = DelegateTool::new(sample_agents(), None, test_security())
3196            .with_workspace_dir(workspace.clone());
3197        let result = tool
3198            .execute(json!({"action": "list_results"}))
3199            .await
3200            .unwrap();
3201
3202        assert!(result.success);
3203        assert!(result.output.contains("No background delegate results"));
3204
3205        let _ = std::fs::remove_dir_all(workspace);
3206    }
3207
3208    #[tokio::test]
3209    async fn parallel_empty_list_rejected() {
3210        let tool = DelegateTool::new(sample_agents(), None, test_security());
3211        let result = tool
3212            .execute(json!({
3213                "parallel": [],
3214                "prompt": "test"
3215            }))
3216            .await
3217            .unwrap();
3218
3219        assert!(!result.success);
3220        assert!(result.error.unwrap().contains("at least one agent"));
3221    }
3222
3223    #[tokio::test]
3224    async fn parallel_unknown_agent_rejected() {
3225        let tool = DelegateTool::new(sample_agents(), None, test_security());
3226        let result = tool
3227            .execute(json!({
3228                "parallel": ["researcher", "nonexistent"],
3229                "prompt": "test"
3230            }))
3231            .await
3232            .unwrap();
3233
3234        assert!(!result.success);
3235        assert!(result.error.unwrap().contains("Unknown agent"));
3236    }
3237
3238    #[tokio::test]
3239    async fn parallel_missing_prompt_rejected() {
3240        let tool = DelegateTool::new(sample_agents(), None, test_security());
3241        let result = tool
3242            .execute(json!({
3243                "parallel": ["researcher"]
3244            }))
3245            .await;
3246
3247        assert!(result.is_err());
3248    }
3249
3250    #[tokio::test]
3251    async fn unknown_action_rejected() {
3252        let tool = DelegateTool::new(sample_agents(), None, test_security());
3253        let result = tool
3254            .execute(json!({"action": "invalid_action"}))
3255            .await
3256            .unwrap();
3257
3258        assert!(!result.success);
3259        assert!(result.error.unwrap().contains("Unknown action"));
3260    }
3261
3262    #[tokio::test]
3263    async fn cancel_task_nonexistent() {
3264        let workspace = std::env::temp_dir().join(format!(
3265            "zeroclaw_delegate_cancel_miss_{}",
3266            uuid::Uuid::new_v4()
3267        ));
3268        std::fs::create_dir_all(&workspace).unwrap();
3269
3270        let tool = DelegateTool::new(sample_agents(), None, test_security())
3271            .with_workspace_dir(workspace.clone());
3272        // Use a valid UUID format that doesn't correspond to any real task
3273        let fake_uuid = uuid::Uuid::new_v4().to_string();
3274        let result = tool
3275            .execute(json!({
3276                "action": "cancel_task",
3277                "task_id": fake_uuid
3278            }))
3279            .await
3280            .unwrap();
3281
3282        assert!(!result.success);
3283        assert!(result.error.unwrap().contains("No task found"));
3284
3285        let _ = std::fs::remove_dir_all(workspace);
3286    }
3287
3288    #[test]
3289    fn cancellation_token_accessor() {
3290        let tool = DelegateTool::new(sample_agents(), None, test_security());
3291        let token = tool.cancellation_token();
3292        assert!(!token.is_cancelled());
3293
3294        tool.cancel_all_background_tasks();
3295        assert!(token.is_cancelled());
3296    }
3297
3298    #[test]
3299    fn with_cancellation_token_replaces_default() {
3300        let custom_token = CancellationToken::new();
3301        let tool = DelegateTool::new(sample_agents(), None, test_security())
3302            .with_cancellation_token(custom_token.clone());
3303
3304        assert!(!tool.cancellation_token().is_cancelled());
3305        custom_token.cancel();
3306        assert!(tool.cancellation_token().is_cancelled());
3307    }
3308
3309    #[tokio::test]
3310    async fn background_task_result_persisted_to_disk() {
3311        let workspace = std::env::temp_dir().join(format!(
3312            "zeroclaw_delegate_bg_persist_{}",
3313            uuid::Uuid::new_v4()
3314        ));
3315        std::fs::create_dir_all(&workspace).unwrap();
3316
3317        let tool = DelegateTool::new(sample_agents(), None, test_security())
3318            .with_workspace_dir(workspace.clone());
3319
3320        let result = tool
3321            .execute(json!({
3322                "agent": "researcher",
3323                "prompt": "persistence test",
3324                "background": true
3325            }))
3326            .await
3327            .unwrap();
3328
3329        assert!(result.success);
3330
3331        // Extract task_id from output
3332        let task_id = result
3333            .output
3334            .lines()
3335            .find(|l| l.starts_with("task_id:"))
3336            .unwrap()
3337            .trim_start_matches("task_id: ")
3338            .trim();
3339
3340        // Check that the result file exists
3341        let result_path = workspace
3342            .join("delegate_results")
3343            .join(format!("{task_id}.json"));
3344        assert!(
3345            result_path.exists(),
3346            "Result file should exist at {result_path:?}"
3347        );
3348
3349        // Read and parse the result
3350        let bg_result = wait_for_terminal_background_result(&workspace, task_id).await;
3351        assert_eq!(bg_result.task_id, task_id);
3352        assert_eq!(bg_result.agent, "researcher");
3353        // The task will have failed because ollama isn't running, but it should be persisted
3354        assert!(
3355            bg_result.status == BackgroundTaskStatus::Completed
3356                || bg_result.status == BackgroundTaskStatus::Failed
3357        );
3358        assert!(bg_result.finished_at.is_some());
3359
3360        let _ = std::fs::remove_dir_all(workspace);
3361    }
3362
3363    #[tokio::test]
3364    async fn check_result_retrieves_persisted_background_result() {
3365        let workspace = std::env::temp_dir().join(format!(
3366            "zeroclaw_delegate_check_retrieve_{}",
3367            uuid::Uuid::new_v4()
3368        ));
3369        std::fs::create_dir_all(&workspace).unwrap();
3370
3371        let tool = DelegateTool::new(sample_agents(), None, test_security())
3372            .with_workspace_dir(workspace.clone());
3373
3374        // Start background task
3375        let result = tool
3376            .execute(json!({
3377                "agent": "researcher",
3378                "prompt": "retrieval test",
3379                "background": true
3380            }))
3381            .await
3382            .unwrap();
3383
3384        let task_id = result
3385            .output
3386            .lines()
3387            .find(|l| l.starts_with("task_id:"))
3388            .unwrap()
3389            .trim_start_matches("task_id: ")
3390            .trim()
3391            .to_string();
3392
3393        // Wait for background task
3394        let _ = wait_for_terminal_background_result(&workspace, &task_id).await;
3395
3396        // Check result
3397        let check = tool
3398            .execute(json!({
3399                "action": "check_result",
3400                "task_id": task_id
3401            }))
3402            .await
3403            .unwrap();
3404
3405        // The output should contain the serialized result
3406        assert!(check.output.contains(&task_id));
3407        assert!(check.output.contains("researcher"));
3408
3409        let _ = std::fs::remove_dir_all(workspace);
3410    }
3411
3412    #[tokio::test]
3413    async fn list_results_includes_background_tasks() {
3414        let workspace = std::env::temp_dir().join(format!(
3415            "zeroclaw_delegate_list_tasks_{}",
3416            uuid::Uuid::new_v4()
3417        ));
3418        std::fs::create_dir_all(&workspace).unwrap();
3419
3420        let tool = DelegateTool::new(sample_agents(), None, test_security())
3421            .with_workspace_dir(workspace.clone());
3422
3423        // Start a background task
3424        let result = tool
3425            .execute(json!({
3426                "agent": "researcher",
3427                "prompt": "list test",
3428                "background": true
3429            }))
3430            .await
3431            .unwrap();
3432        assert!(result.success);
3433        let task_id = result
3434            .output
3435            .lines()
3436            .find(|l| l.starts_with("task_id:"))
3437            .unwrap()
3438            .trim_start_matches("task_id: ")
3439            .trim();
3440
3441        // Wait for task to complete
3442        let _ = wait_for_terminal_background_result(&workspace, task_id).await;
3443
3444        // List results
3445        let list = tool
3446            .execute(json!({"action": "list_results"}))
3447            .await
3448            .unwrap();
3449
3450        assert!(list.success);
3451        assert!(list.output.contains("researcher"));
3452
3453        let _ = std::fs::remove_dir_all(workspace);
3454    }
3455
3456    #[tokio::test]
3457    async fn default_action_is_delegate() {
3458        // Calling without action should behave like "delegate"
3459        let tool = DelegateTool::new(sample_agents(), None, test_security());
3460        let result = tool
3461            .execute(json!({"agent": "researcher", "prompt": "test"}))
3462            .await
3463            .unwrap();
3464        // Should proceed to delegation (will fail at model_provider since ollama isn't running)
3465        // but should NOT fail with "Unknown action" error
3466        assert!(
3467            result.error.is_none()
3468                || !result
3469                    .error
3470                    .as_deref()
3471                    .unwrap_or("")
3472                    .contains("Unknown action")
3473        );
3474    }
3475
3476    #[tokio::test]
3477    async fn check_result_rejects_path_traversal() {
3478        let workspace = std::env::temp_dir().join(format!(
3479            "zeroclaw_delegate_traversal_check_{}",
3480            uuid::Uuid::new_v4()
3481        ));
3482        std::fs::create_dir_all(&workspace).unwrap();
3483
3484        let tool = DelegateTool::new(sample_agents(), None, test_security())
3485            .with_workspace_dir(workspace.clone());
3486        let result = tool
3487            .execute(json!({
3488                "action": "check_result",
3489                "task_id": "../../etc/passwd"
3490            }))
3491            .await
3492            .unwrap();
3493
3494        assert!(!result.success);
3495        assert!(result.error.unwrap().contains("Invalid task_id"));
3496
3497        let _ = std::fs::remove_dir_all(workspace);
3498    }
3499
3500    #[tokio::test]
3501    async fn cancel_task_rejects_path_traversal() {
3502        let workspace = std::env::temp_dir().join(format!(
3503            "zeroclaw_delegate_traversal_cancel_{}",
3504            uuid::Uuid::new_v4()
3505        ));
3506        std::fs::create_dir_all(&workspace).unwrap();
3507
3508        let tool = DelegateTool::new(sample_agents(), None, test_security())
3509            .with_workspace_dir(workspace.clone());
3510        let result = tool
3511            .execute(json!({
3512                "action": "cancel_task",
3513                "task_id": "../../../etc/shadow"
3514            }))
3515            .await
3516            .unwrap();
3517
3518        assert!(!result.success);
3519        assert!(result.error.unwrap().contains("Invalid task_id"));
3520
3521        let _ = std::fs::remove_dir_all(workspace);
3522    }
3523
3524    fn config_with_two_agents(
3525        caller_alias: &str,
3526        caller_max_actions: u32,
3527        target_alias: &str,
3528        target_max_actions: u32,
3529    ) -> Arc<zeroclaw_config::schema::Config> {
3530        use zeroclaw_config::schema::{
3531            AliasedAgentConfig, Config, RiskProfileConfig, RuntimeProfileConfig,
3532        };
3533        let mut config = Config::default();
3534        config
3535            .risk_profiles
3536            .insert("narrow".to_string(), RiskProfileConfig::default());
3537        config
3538            .risk_profiles
3539            .insert("wide".to_string(), RiskProfileConfig::default());
3540        config.runtime_profiles.insert(
3541            "narrow".to_string(),
3542            RuntimeProfileConfig {
3543                max_actions_per_hour: caller_max_actions,
3544                ..RuntimeProfileConfig::default()
3545            },
3546        );
3547        config.runtime_profiles.insert(
3548            "wide".to_string(),
3549            RuntimeProfileConfig {
3550                max_actions_per_hour: target_max_actions,
3551                ..RuntimeProfileConfig::default()
3552            },
3553        );
3554        let pick = |above: bool| if above { "wide" } else { "narrow" }.to_string();
3555        config.agents.insert(
3556            caller_alias.to_string(),
3557            AliasedAgentConfig {
3558                risk_profile: "narrow".to_string(),
3559                runtime_profile: "narrow".to_string(),
3560                model_provider: "ollama.caller".into(),
3561                ..AliasedAgentConfig::default()
3562            },
3563        );
3564        config.agents.insert(
3565            target_alias.to_string(),
3566            AliasedAgentConfig {
3567                risk_profile: pick(target_max_actions > caller_max_actions),
3568                runtime_profile: pick(target_max_actions > caller_max_actions),
3569                model_provider: "ollama.target".into(),
3570                ..AliasedAgentConfig::default()
3571            },
3572        );
3573        Arc::new(config)
3574    }
3575
3576    #[tokio::test]
3577    async fn delegate_rejects_target_whose_policy_escalates_caller() {
3578        let config = config_with_two_agents("caller", 5, "target", 50);
3579        let caller_policy =
3580            Arc::new(SecurityPolicy::for_agent(&config, "caller").expect("caller policy resolves"));
3581        let mut delegate_agents = HashMap::new();
3582        for (name, agent) in &config.agents {
3583            delegate_agents.insert(name.clone(), agent.clone());
3584        }
3585        let tool = DelegateTool::new(delegate_agents, None, caller_policy)
3586            .with_root_config(config.clone());
3587
3588        let err = tool
3589            .policy_for_target("target")
3590            .expect_err("escalating target must be rejected at delegate boundary");
3591        let chain = format!("{err:#}");
3592        assert!(
3593            chain.contains("escalates beyond caller"),
3594            "expected escalation error, got: {chain}"
3595        );
3596    }
3597
3598    #[tokio::test]
3599    async fn delegate_target_inherits_caller_action_tracker() {
3600        let config = config_with_two_agents("caller", 5, "target", 5);
3601        let caller_policy =
3602            Arc::new(SecurityPolicy::for_agent(&config, "caller").expect("caller policy resolves"));
3603        let mut delegate_agents = HashMap::new();
3604        for (name, agent) in &config.agents {
3605            delegate_agents.insert(name.clone(), agent.clone());
3606        }
3607        let tool = DelegateTool::new(delegate_agents, None, Arc::clone(&caller_policy))
3608            .with_root_config(config.clone());
3609
3610        let bucket_key = "shared-budget-test";
3611        let max = 2u32;
3612        for _ in 0..max {
3613            assert!(
3614                caller_policy.tracker.record_within(bucket_key, max),
3615                "caller's first {max} actions fit within the shared budget"
3616            );
3617        }
3618
3619        let target_policy = tool
3620            .policy_for_target("target")
3621            .expect("non-escalating target resolves");
3622        assert!(
3623            !target_policy.tracker.record_within(bucket_key, max),
3624            "delegated target must consume from the caller's bucket; spawning the target should not reset the budget"
3625        );
3626    }
3627
3628    #[tokio::test]
3629    async fn delegate_without_root_config_falls_back_to_caller_policy() {
3630        let tool = DelegateTool::new(sample_agents(), None, test_security());
3631        let resolved = tool
3632            .policy_for_target("researcher")
3633            .expect("fallback path returns caller policy unchanged");
3634        assert!(
3635            Arc::ptr_eq(&resolved, &tool.security),
3636            "without root_config the helper returns the caller's Arc verbatim"
3637        );
3638    }
3639
3640    /// Build a config where `caller` has a strictly broader policy
3641    /// than `target`. The caller-only command is the narrowing axis
3642    /// the validator should catch.
3643    fn config_with_narrowed_target() -> Arc<zeroclaw_config::schema::Config> {
3644        use zeroclaw_config::schema::{AliasedAgentConfig, Config, RiskProfileConfig};
3645        let mut config = Config::default();
3646        config.risk_profiles.insert(
3647            "broad".to_string(),
3648            RiskProfileConfig {
3649                allowed_commands: vec!["git".into(), "cargo".into()],
3650                ..RiskProfileConfig::default()
3651            },
3652        );
3653        config.risk_profiles.insert(
3654            "narrow".to_string(),
3655            RiskProfileConfig {
3656                allowed_commands: vec!["git".into()],
3657                ..RiskProfileConfig::default()
3658            },
3659        );
3660        config.agents.insert(
3661            "caller".to_string(),
3662            AliasedAgentConfig {
3663                risk_profile: "broad".to_string(),
3664                model_provider: "ollama.caller".into(),
3665                ..AliasedAgentConfig::default()
3666            },
3667        );
3668        config.agents.insert(
3669            "target".to_string(),
3670            AliasedAgentConfig {
3671                risk_profile: "narrow".to_string(),
3672                model_provider: "ollama.target".into(),
3673                ..AliasedAgentConfig::default()
3674            },
3675        );
3676        Arc::new(config)
3677    }
3678
3679    #[tokio::test]
3680    async fn delegate_rejects_target_whose_policy_narrows_caller() {
3681        // DelegateTool's spawned agentic loop reuses the caller's
3682        // parent_tools registry — a narrower target would silently
3683        // inherit the caller's broader allowlist. The validator
3684        // must catch the narrowing at the delegate boundary and
3685        // refuse to dispatch.
3686        let config = config_with_narrowed_target();
3687        let caller_policy =
3688            Arc::new(SecurityPolicy::for_agent(&config, "caller").expect("caller policy resolves"));
3689        let mut delegate_agents = HashMap::new();
3690        for (name, agent) in &config.agents {
3691            delegate_agents.insert(name.clone(), agent.clone());
3692        }
3693        let tool = DelegateTool::new(delegate_agents, None, caller_policy)
3694            .with_root_config(config.clone());
3695
3696        let err = tool
3697            .policy_for_target("target")
3698            .expect_err("narrowing target must be rejected at delegate boundary");
3699        let chain = format!("{err:#}");
3700        assert!(
3701            chain.contains("narrows the caller"),
3702            "expected narrowing error, got: {chain}"
3703        );
3704        assert!(
3705            chain.contains("spawn_subagent"),
3706            "error must point operators at spawn_subagent for narrowed runs, got: {chain}"
3707        );
3708    }
3709}