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