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