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#[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#[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
56pub struct DelegateTool {
71 agents: Arc<HashMap<String, AliasedAgentConfig>>,
72 security: Arc<SecurityPolicy>,
73 global_credential: Option<String>,
75 provider_runtime_options: zeroclaw_providers::ModelProviderRuntimeOptions,
77 depth: u32,
79 parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
81 multimodal_config: zeroclaw_config::schema::MultimodalConfig,
83 delegate_config: DelegateToolConfig,
85 workspace_dir: PathBuf,
87 cancellation_token: CancellationToken,
89 memory: Option<Arc<dyn Memory>>,
91 providers_models: Arc<HashMap<String, HashMap<String, ModelProviderConfig>>>,
93 risk_profiles: Arc<HashMap<String, RiskProfileConfig>>,
95 runtime_profiles: Arc<HashMap<String, RuntimeProfileConfig>>,
97 skill_bundles: Arc<HashMap<String, SkillBundleConfig>>,
99 root_config: Option<Arc<Config>>,
108 caller_alias: String,
112}
113
114impl DelegateTool {
115 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 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 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 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 pub fn with_delegate_config(mut self, config: DelegateToolConfig) -> Self {
222 self.delegate_config = config;
223 self
224 }
225
226 pub fn parent_tools_handle(&self) -> Arc<RwLock<Vec<Arc<dyn Tool>>>> {
229 Arc::clone(&self.parent_tools)
230 }
231
232 pub fn with_workspace_dir(mut self, workspace_dir: PathBuf) -> Self {
234 self.workspace_dir = workspace_dir;
235 self
236 }
237
238 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 pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self {
252 self.cancellation_token = token;
253 self
254 }
255
256 pub fn cancellation_token(&self) -> &CancellationToken {
258 &self.cancellation_token
259 }
260
261 pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
263 self.memory = Some(memory);
264 self
265 }
266
267 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 pub fn with_risk_profiles(mut self, m: HashMap<String, RiskProfileConfig>) -> Self {
278 self.risk_profiles = Arc::new(m);
279 self
280 }
281
282 pub fn with_runtime_profiles(mut self, m: HashMap<String, RuntimeProfileConfig>) -> Self {
284 self.runtime_profiles = Arc::new(m);
285 self
286 }
287
288 pub fn with_skill_bundles(mut self, m: HashMap<String, SkillBundleConfig>) -> Self {
290 self.skill_bundles = Arc::new(m);
291 self
292 }
293
294 pub fn with_root_config(mut self, config: Arc<Config>) -> Self {
299 self.root_config = Some(config);
300 self
301 }
302
303 pub fn with_caller_alias(mut self, alias: impl Into<String>) -> Self {
307 self.caller_alias = alias.into();
308 self
309 }
310
311 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 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 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 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 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 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 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 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 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 fn results_dir(&self) -> PathBuf {
552 self.workspace_dir.join("delegate_results")
553 }
554
555 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 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 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" => {} 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 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 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 self.execute_sync(agent_name, prompt, &args).await
753 }
754}
755
756impl DelegateTool {
757 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 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 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 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 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 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 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 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 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 async fn execute_background(
932 &self,
933 agent_name: &str,
934 prompt: &str,
935 args: &serde_json::Value,
936 ) -> anyhow::Result<ToolResult> {
937 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn cancel_all_background_tasks(&self) {
1518 self.cancellation_token.cancel();
1519 }
1520
1521 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 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 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 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 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 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 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 loop_runtime.max_context_tokens,
1753 None, None, 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 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 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 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 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 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 assert!(desc.contains("alpha_peer"), "{desc}");
2366 assert!(!desc.contains("alpha_self"), "{desc}");
2368 assert!(!desc.contains("beta_outsider"), "{desc}");
2370 }
2371
2372 #[test]
2373 fn schema_excludes_caller_alias_from_roster() {
2374 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 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 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 let result = tool
2507 .execute(json!({"agent": " researcher ", "prompt": "test"}))
2508 .await
2509 .unwrap();
2510 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 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 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 #[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 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 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 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 handle.write().push(Arc::new(FakeMcpTool));
3263 assert_eq!(handle.read().len(), 2);
3264 }
3265
3266 #[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 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 #[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 assert!(result.success);
3433 assert!(result.output.contains("task_id:"));
3434 assert!(result.output.contains("Background task started"));
3435
3436 tokio::time::sleep(Duration::from_millis(200)).await;
3438
3439 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 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 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 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 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 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 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 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 let _ = wait_for_terminal_background_result(&workspace, &task_id).await;
3721
3722 let check = tool
3724 .execute(json!({
3725 "action": "check_result",
3726 "task_id": task_id
3727 }))
3728 .await
3729 .unwrap();
3730
3731 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 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 let _ = wait_for_terminal_background_result(&workspace, task_id).await;
3769
3770 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 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 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 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 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 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 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 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 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}