1use super::traits::{Tool, ToolResult};
2use crate::agent::loop_::run_tool_call_loop;
3use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
4use crate::config::{DelegateAgentConfig, DelegateToolConfig};
5use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
6use crate::providers::{self, ChatMessage, Provider};
7use crate::security::SecurityPolicy;
8use crate::security::policy::ToolOperation;
9use async_trait::async_trait;
10use parking_lot::RwLock;
11use serde_json::json;
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use std::time::Duration;
16use tokio_util::sync::CancellationToken;
17
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
20pub struct BackgroundDelegateResult {
21 pub task_id: String,
22 pub agent: String,
23 pub status: BackgroundTaskStatus,
24 pub output: Option<String>,
25 pub error: Option<String>,
26 pub started_at: String,
27 pub finished_at: Option<String>,
28}
29
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum BackgroundTaskStatus {
34 Running,
35 Completed,
36 Failed,
37 Cancelled,
38}
39
40pub struct DelegateTool {
55 agents: Arc<HashMap<String, DelegateAgentConfig>>,
56 security: Arc<SecurityPolicy>,
57 fallback_credential: Option<String>,
59 provider_runtime_options: providers::ProviderRuntimeOptions,
61 depth: u32,
63 parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
65 multimodal_config: crate::config::MultimodalConfig,
67 delegate_config: DelegateToolConfig,
69 workspace_dir: PathBuf,
71 cancellation_token: CancellationToken,
73}
74
75impl DelegateTool {
76 pub fn new(
77 agents: HashMap<String, DelegateAgentConfig>,
78 fallback_credential: Option<String>,
79 security: Arc<SecurityPolicy>,
80 ) -> Self {
81 Self::new_with_options(
82 agents,
83 fallback_credential,
84 security,
85 providers::ProviderRuntimeOptions::default(),
86 )
87 }
88
89 pub fn new_with_options(
90 agents: HashMap<String, DelegateAgentConfig>,
91 fallback_credential: Option<String>,
92 security: Arc<SecurityPolicy>,
93 provider_runtime_options: providers::ProviderRuntimeOptions,
94 ) -> Self {
95 Self {
96 agents: Arc::new(agents),
97 security,
98 fallback_credential,
99 provider_runtime_options,
100 depth: 0,
101 parent_tools: Arc::new(RwLock::new(Vec::new())),
102 multimodal_config: crate::config::MultimodalConfig::default(),
103 delegate_config: DelegateToolConfig::default(),
104 workspace_dir: PathBuf::new(),
105 cancellation_token: CancellationToken::new(),
106 }
107 }
108
109 pub fn with_depth(
113 agents: HashMap<String, DelegateAgentConfig>,
114 fallback_credential: Option<String>,
115 security: Arc<SecurityPolicy>,
116 depth: u32,
117 ) -> Self {
118 Self::with_depth_and_options(
119 agents,
120 fallback_credential,
121 security,
122 depth,
123 providers::ProviderRuntimeOptions::default(),
124 )
125 }
126
127 pub fn with_depth_and_options(
128 agents: HashMap<String, DelegateAgentConfig>,
129 fallback_credential: Option<String>,
130 security: Arc<SecurityPolicy>,
131 depth: u32,
132 provider_runtime_options: providers::ProviderRuntimeOptions,
133 ) -> Self {
134 Self {
135 agents: Arc::new(agents),
136 security,
137 fallback_credential,
138 provider_runtime_options,
139 depth,
140 parent_tools: Arc::new(RwLock::new(Vec::new())),
141 multimodal_config: crate::config::MultimodalConfig::default(),
142 delegate_config: DelegateToolConfig::default(),
143 workspace_dir: PathBuf::new(),
144 cancellation_token: CancellationToken::new(),
145 }
146 }
147
148 pub fn with_parent_tools(mut self, parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>) -> Self {
150 self.parent_tools = parent_tools;
151 self
152 }
153
154 pub fn with_multimodal_config(mut self, config: crate::config::MultimodalConfig) -> Self {
156 self.multimodal_config = config;
157 self
158 }
159
160 pub fn with_delegate_config(mut self, config: DelegateToolConfig) -> Self {
162 self.delegate_config = config;
163 self
164 }
165
166 pub fn parent_tools_handle(&self) -> Arc<RwLock<Vec<Arc<dyn Tool>>>> {
169 Arc::clone(&self.parent_tools)
170 }
171
172 pub fn with_workspace_dir(mut self, workspace_dir: PathBuf) -> Self {
174 self.workspace_dir = workspace_dir;
175 self
176 }
177
178 pub fn with_cancellation_token(mut self, token: CancellationToken) -> Self {
181 self.cancellation_token = token;
182 self
183 }
184
185 pub fn cancellation_token(&self) -> &CancellationToken {
187 &self.cancellation_token
188 }
189
190 fn results_dir(&self) -> PathBuf {
192 self.workspace_dir.join("delegate_results")
193 }
194
195 fn validate_task_id(task_id: &str) -> Result<(), String> {
198 if uuid::Uuid::parse_str(task_id).is_err() {
199 return Err(format!("Invalid task_id '{task_id}': must be a valid UUID"));
200 }
201 Ok(())
202 }
203}
204
205#[async_trait]
206impl Tool for DelegateTool {
207 fn name(&self) -> &str {
208 "delegate"
209 }
210
211 fn description(&self) -> &str {
212 "Delegate a subtask to a specialized agent. Use when: a task benefits from a different model \
213 (e.g. fast summarization, deep reasoning, code generation). The sub-agent runs a single \
214 prompt by default; with agentic=true it can iterate with a filtered tool-call loop. \
215 Supports background execution (returns a task_id immediately) and parallel execution \
216 (runs multiple agents concurrently). Use action='check_result' with a task_id to \
217 retrieve background results."
218 }
219
220 fn parameters_schema(&self) -> serde_json::Value {
221 let agent_names: Vec<&str> = self.agents.keys().map(|s: &String| s.as_str()).collect();
222 json!({
223 "type": "object",
224 "additionalProperties": false,
225 "properties": {
226 "action": {
227 "type": "string",
228 "enum": ["delegate", "check_result", "list_results", "cancel_task"],
229 "description": "Action to perform. Default: 'delegate'. Use 'check_result' to \
230 retrieve a background task result, 'list_results' to list all \
231 background tasks, 'cancel_task' to cancel a running background task.",
232 "default": "delegate"
233 },
234 "agent": {
235 "type": "string",
236 "minLength": 1,
237 "description": format!(
238 "Name of the agent to delegate to. Available: {}",
239 if agent_names.is_empty() {
240 "(none configured)".to_string()
241 } else {
242 agent_names.join(", ")
243 }
244 )
245 },
246 "prompt": {
247 "type": "string",
248 "minLength": 1,
249 "description": "The task/prompt to send to the sub-agent"
250 },
251 "context": {
252 "type": "string",
253 "description": "Optional context to prepend (e.g. relevant code, prior findings)"
254 },
255 "background": {
256 "type": "boolean",
257 "description": "When true, the sub-agent runs in a background tokio task and \
258 returns a task_id immediately. Results are stored to \
259 workspace/delegate_results/{task_id}.json.",
260 "default": false
261 },
262 "parallel": {
263 "type": "array",
264 "items": { "type": "string" },
265 "description": "Array of agent names to run concurrently with the same prompt. \
266 Returns all results when all agents complete. Cannot be combined \
267 with 'background'."
268 },
269 "task_id": {
270 "type": "string",
271 "description": "Task ID for check_result/cancel_task actions (returned by \
272 background delegation)."
273 }
274 },
275 "required": []
276 })
277 }
278
279 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
280 let action = args
281 .get("action")
282 .and_then(|v| v.as_str())
283 .unwrap_or("delegate");
284
285 match action {
286 "check_result" => return self.handle_check_result(&args).await,
287 "list_results" => return self.handle_list_results().await,
288 "cancel_task" => return self.handle_cancel_task(&args).await,
289 "delegate" => {} other => {
291 return Ok(ToolResult {
292 success: false,
293 output: String::new(),
294 error: Some(format!(
295 "Unknown action '{other}'. Use delegate/check_result/list_results/cancel_task."
296 )),
297 });
298 }
299 }
300
301 if let Some(parallel_agents) = args.get("parallel").and_then(|v| v.as_array()) {
303 return self.execute_parallel(parallel_agents, &args).await;
304 }
305
306 let agent_name = args
308 .get("agent")
309 .and_then(|v| v.as_str())
310 .map(str::trim)
311 .ok_or_else(|| anyhow::anyhow!("Missing 'agent' parameter"))?;
312
313 if agent_name.is_empty() {
314 return Ok(ToolResult {
315 success: false,
316 output: String::new(),
317 error: Some("'agent' parameter must not be empty".into()),
318 });
319 }
320
321 let prompt = args
322 .get("prompt")
323 .and_then(|v| v.as_str())
324 .map(str::trim)
325 .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter"))?;
326
327 if prompt.is_empty() {
328 return Ok(ToolResult {
329 success: false,
330 output: String::new(),
331 error: Some("'prompt' parameter must not be empty".into()),
332 });
333 }
334
335 let background = args
336 .get("background")
337 .and_then(|v| v.as_bool())
338 .unwrap_or(false);
339
340 if background {
341 return self.execute_background(agent_name, prompt, &args).await;
342 }
343
344 self.execute_sync(agent_name, prompt, &args).await
346 }
347}
348
349impl DelegateTool {
350 async fn execute_sync(
352 &self,
353 agent_name: &str,
354 prompt: &str,
355 args: &serde_json::Value,
356 ) -> anyhow::Result<ToolResult> {
357 let context = args
358 .get("context")
359 .and_then(|v| v.as_str())
360 .map(str::trim)
361 .unwrap_or("");
362
363 let agent_config = match self.agents.get(agent_name) {
365 Some(cfg) => cfg,
366 None => {
367 let available: Vec<&str> =
368 self.agents.keys().map(|s: &String| s.as_str()).collect();
369 return Ok(ToolResult {
370 success: false,
371 output: String::new(),
372 error: Some(format!(
373 "Unknown agent '{agent_name}'. Available agents: {}",
374 if available.is_empty() {
375 "(none configured)".to_string()
376 } else {
377 available.join(", ")
378 }
379 )),
380 });
381 }
382 };
383
384 if self.depth >= agent_config.max_depth {
386 return Ok(ToolResult {
387 success: false,
388 output: String::new(),
389 error: Some(format!(
390 "Delegation depth limit reached ({depth}/{max}). \
391 Cannot delegate further to prevent infinite loops.",
392 depth = self.depth,
393 max = agent_config.max_depth
394 )),
395 });
396 }
397
398 if let Err(error) = self
399 .security
400 .enforce_tool_operation(ToolOperation::Act, "delegate")
401 {
402 return Ok(ToolResult {
403 success: false,
404 output: String::new(),
405 error: Some(error),
406 });
407 }
408
409 let provider_credential_owned = agent_config
411 .api_key
412 .clone()
413 .or_else(|| self.fallback_credential.clone());
414 #[allow(clippy::option_as_ref_deref)]
415 let provider_credential = provider_credential_owned.as_ref().map(String::as_str);
416
417 let provider: Box<dyn Provider> = match providers::create_provider_with_options(
418 &agent_config.provider,
419 provider_credential,
420 &self.provider_runtime_options,
421 ) {
422 Ok(p) => p,
423 Err(e) => {
424 return Ok(ToolResult {
425 success: false,
426 output: String::new(),
427 error: Some(format!(
428 "Failed to create provider '{}' for agent '{agent_name}': {e}",
429 agent_config.provider
430 )),
431 });
432 }
433 };
434
435 let full_prompt = if context.is_empty() {
437 prompt.to_string()
438 } else {
439 format!("[Context]\n{context}\n\n[Task]\n{prompt}")
440 };
441
442 let temperature = agent_config.temperature.unwrap_or(0.7);
443
444 if agent_config.agentic {
446 return self
447 .execute_agentic(
448 agent_name,
449 agent_config,
450 &*provider,
451 &full_prompt,
452 temperature,
453 )
454 .await;
455 }
456
457 let enriched_system_prompt =
459 self.build_enriched_system_prompt(agent_config, &[], &self.workspace_dir);
460 let system_prompt_ref = enriched_system_prompt.as_deref();
461
462 let timeout_secs = agent_config
464 .timeout_secs
465 .unwrap_or(self.delegate_config.timeout_secs);
466 let result = tokio::time::timeout(
467 Duration::from_secs(timeout_secs),
468 provider.chat_with_system(
469 system_prompt_ref,
470 &full_prompt,
471 &agent_config.model,
472 temperature,
473 ),
474 )
475 .await;
476
477 let result = match result {
478 Ok(inner) => inner,
479 Err(_elapsed) => {
480 return Ok(ToolResult {
481 success: false,
482 output: String::new(),
483 error: Some(format!(
484 "Agent '{agent_name}' timed out after {timeout_secs}s"
485 )),
486 });
487 }
488 };
489
490 match result {
491 Ok(response) => {
492 let mut rendered = response;
493 if rendered.trim().is_empty() {
494 rendered = "[Empty response]".to_string();
495 }
496
497 Ok(ToolResult {
498 success: true,
499 output: format!(
500 "[Agent '{agent_name}' ({provider}/{model})]\n{rendered}",
501 provider = agent_config.provider,
502 model = agent_config.model
503 ),
504 error: None,
505 })
506 }
507 Err(e) => Ok(ToolResult {
508 success: false,
509 output: String::new(),
510 error: Some(format!("Agent '{agent_name}' failed: {e}",)),
511 }),
512 }
513 }
514}
515
516impl DelegateTool {
517 async fn execute_background(
522 &self,
523 agent_name: &str,
524 prompt: &str,
525 args: &serde_json::Value,
526 ) -> anyhow::Result<ToolResult> {
527 let agent_config = match self.agents.get(agent_name) {
529 Some(cfg) => cfg.clone(),
530 None => {
531 let available: Vec<&str> =
532 self.agents.keys().map(|s: &String| s.as_str()).collect();
533 return Ok(ToolResult {
534 success: false,
535 output: String::new(),
536 error: Some(format!(
537 "Unknown agent '{agent_name}'. Available agents: {}",
538 if available.is_empty() {
539 "(none configured)".to_string()
540 } else {
541 available.join(", ")
542 }
543 )),
544 });
545 }
546 };
547
548 if self.depth >= agent_config.max_depth {
549 return Ok(ToolResult {
550 success: false,
551 output: String::new(),
552 error: Some(format!(
553 "Delegation depth limit reached ({depth}/{max}).",
554 depth = self.depth,
555 max = agent_config.max_depth
556 )),
557 });
558 }
559
560 if let Err(error) = self
561 .security
562 .enforce_tool_operation(ToolOperation::Act, "delegate")
563 {
564 return Ok(ToolResult {
565 success: false,
566 output: String::new(),
567 error: Some(error),
568 });
569 }
570
571 let task_id = uuid::Uuid::new_v4().to_string();
572 let results_dir = self.results_dir();
573 tokio::fs::create_dir_all(&results_dir).await?;
574
575 let context = args
576 .get("context")
577 .and_then(|v| v.as_str())
578 .map(str::trim)
579 .unwrap_or("");
580 let full_prompt = if context.is_empty() {
581 prompt.to_string()
582 } else {
583 format!("[Context]\n{context}\n\n[Task]\n{prompt}")
584 };
585
586 let started_at = chrono::Utc::now().to_rfc3339();
587 let agent_name_owned = agent_name.to_string();
588
589 let initial_result = BackgroundDelegateResult {
591 task_id: task_id.clone(),
592 agent: agent_name_owned.clone(),
593 status: BackgroundTaskStatus::Running,
594 output: None,
595 error: None,
596 started_at: started_at.clone(),
597 finished_at: None,
598 };
599 let result_path = results_dir.join(format!("{task_id}.json"));
600 let json_bytes = serde_json::to_vec_pretty(&initial_result)?;
601 tokio::fs::write(&result_path, &json_bytes).await?;
602
603 let agents = Arc::clone(&self.agents);
605 let security = Arc::clone(&self.security);
606 let fallback_credential = self.fallback_credential.clone();
607 let provider_runtime_options = self.provider_runtime_options.clone();
608 let depth = self.depth;
609 let parent_tools = Arc::clone(&self.parent_tools);
610 let multimodal_config = self.multimodal_config.clone();
611 let delegate_config = self.delegate_config.clone();
612 let workspace_dir = self.workspace_dir.clone();
613 let child_token = self.cancellation_token.child_token();
614 let task_id_clone = task_id.clone();
615
616 tokio::spawn(async move {
617 let inner = DelegateTool {
619 agents,
620 security,
621 fallback_credential,
622 provider_runtime_options,
623 depth,
624 parent_tools,
625 multimodal_config,
626 delegate_config,
627 workspace_dir: workspace_dir.clone(),
628 cancellation_token: child_token.clone(),
629 };
630
631 let args_inner = json!({
632 "agent": agent_name_owned,
633 "prompt": full_prompt,
634 });
635
636 let outcome = tokio::select! {
638 () = child_token.cancelled() => {
639 Err("Cancelled by parent session".to_string())
640 }
641 result = Box::pin(inner.execute_sync(&agent_name_owned, &full_prompt, &args_inner)) => {
642 match result {
643 Ok(tool_result) => {
644 if tool_result.success {
645 Ok(tool_result.output)
646 } else {
647 Err(tool_result.error.unwrap_or_else(|| "Unknown error".into()))
648 }
649 }
650 Err(e) => Err(e.to_string()),
651 }
652 }
653 };
654
655 let finished_at = chrono::Utc::now().to_rfc3339();
656 let final_result = match outcome {
657 Ok(output) => BackgroundDelegateResult {
658 task_id: task_id_clone.clone(),
659 agent: agent_name_owned,
660 status: BackgroundTaskStatus::Completed,
661 output: Some(output),
662 error: None,
663 started_at,
664 finished_at: Some(finished_at),
665 },
666 Err(err) => {
667 let status = if err.contains("Cancelled") {
668 BackgroundTaskStatus::Cancelled
669 } else {
670 BackgroundTaskStatus::Failed
671 };
672 BackgroundDelegateResult {
673 task_id: task_id_clone.clone(),
674 agent: agent_name_owned,
675 status,
676 output: None,
677 error: Some(err),
678 started_at,
679 finished_at: Some(finished_at),
680 }
681 }
682 };
683
684 let result_path = results_dir.join(format!("{}.json", task_id_clone));
685 if let Ok(bytes) = serde_json::to_vec_pretty(&final_result) {
686 let _ = tokio::fs::write(&result_path, &bytes).await;
687 }
688 });
689
690 Ok(ToolResult {
691 success: true,
692 output: format!(
693 "Background task started for agent '{agent_name}'.\n\
694 task_id: {task_id}\n\
695 Use action='check_result' with task_id='{task_id}' to retrieve the result."
696 ),
697 error: None,
698 })
699 }
700
701 async fn execute_parallel(
705 &self,
706 parallel_agents: &[serde_json::Value],
707 args: &serde_json::Value,
708 ) -> anyhow::Result<ToolResult> {
709 let prompt = args
710 .get("prompt")
711 .and_then(|v| v.as_str())
712 .map(str::trim)
713 .ok_or_else(|| anyhow::anyhow!("Missing 'prompt' parameter for parallel execution"))?;
714
715 if prompt.is_empty() {
716 return Ok(ToolResult {
717 success: false,
718 output: String::new(),
719 error: Some("'prompt' parameter must not be empty".into()),
720 });
721 }
722
723 let agent_names: Vec<String> = parallel_agents
724 .iter()
725 .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
726 .filter(|s| !s.is_empty())
727 .collect();
728
729 if agent_names.is_empty() {
730 return Ok(ToolResult {
731 success: false,
732 output: String::new(),
733 error: Some("'parallel' array must contain at least one agent name".into()),
734 });
735 }
736
737 for name in &agent_names {
739 if !self.agents.contains_key(name) {
740 let available: Vec<&str> =
741 self.agents.keys().map(|s: &String| s.as_str()).collect();
742 return Ok(ToolResult {
743 success: false,
744 output: String::new(),
745 error: Some(format!(
746 "Unknown agent '{name}' in parallel list. Available: {}",
747 if available.is_empty() {
748 "(none configured)".to_string()
749 } else {
750 available.join(", ")
751 }
752 )),
753 });
754 }
755 }
756
757 let mut handles = Vec::with_capacity(agent_names.len());
759 for agent_name in &agent_names {
760 let agents = Arc::clone(&self.agents);
761 let security = Arc::clone(&self.security);
762 let fallback_credential = self.fallback_credential.clone();
763 let provider_runtime_options = self.provider_runtime_options.clone();
764 let depth = self.depth;
765 let parent_tools = Arc::clone(&self.parent_tools);
766 let multimodal_config = self.multimodal_config.clone();
767 let delegate_config = self.delegate_config.clone();
768 let workspace_dir = self.workspace_dir.clone();
769 let cancellation_token = self.cancellation_token.child_token();
770 let agent_name = agent_name.clone();
771 let prompt = prompt.to_string();
772 let args_clone = args.clone();
773
774 handles.push(tokio::spawn(async move {
775 let inner = DelegateTool {
776 agents,
777 security,
778 fallback_credential,
779 provider_runtime_options,
780 depth,
781 parent_tools,
782 multimodal_config,
783 delegate_config,
784 workspace_dir,
785 cancellation_token,
786 };
787 let result = Box::pin(inner.execute_sync(&agent_name, &prompt, &args_clone)).await;
788 (agent_name, result)
789 }));
790 }
791
792 let mut outputs = Vec::with_capacity(handles.len());
794 let mut all_success = true;
795
796 for handle in handles {
797 match handle.await {
798 Ok((agent_name, Ok(tool_result))) => {
799 if !tool_result.success {
800 all_success = false;
801 }
802 outputs.push(format!(
803 "--- {agent_name} (success={}) ---\n{}{}",
804 tool_result.success,
805 tool_result.output,
806 tool_result
807 .error
808 .map(|e| format!("\nError: {e}"))
809 .unwrap_or_default()
810 ));
811 }
812 Ok((agent_name, Err(e))) => {
813 all_success = false;
814 outputs.push(format!("--- {agent_name} (success=false) ---\nError: {e}"));
815 }
816 Err(e) => {
817 all_success = false;
818 outputs.push(format!("--- [join error] ---\n{e}"));
819 }
820 }
821 }
822
823 Ok(ToolResult {
824 success: all_success,
825 output: format!(
826 "[Parallel delegation: {} agents]\n\n{}",
827 agent_names.len(),
828 outputs.join("\n\n")
829 ),
830 error: if all_success {
831 None
832 } else {
833 Some("One or more parallel agents failed".into())
834 },
835 })
836 }
837
838 async fn handle_check_result(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
842 let task_id = args
843 .get("task_id")
844 .and_then(|v| v.as_str())
845 .ok_or_else(|| anyhow::anyhow!("Missing 'task_id' parameter for check_result"))?;
846
847 if let Err(e) = Self::validate_task_id(task_id) {
848 return Ok(ToolResult {
849 success: false,
850 output: String::new(),
851 error: Some(e),
852 });
853 }
854
855 let result_path = self.results_dir().join(format!("{task_id}.json"));
856 if !result_path.exists() {
857 return Ok(ToolResult {
858 success: false,
859 output: String::new(),
860 error: Some(format!("No result found for task_id '{task_id}'")),
861 });
862 }
863
864 let content = tokio::fs::read_to_string(&result_path).await?;
865 let result: BackgroundDelegateResult = serde_json::from_str(&content)?;
866
867 Ok(ToolResult {
868 success: result.status == BackgroundTaskStatus::Completed,
869 output: serde_json::to_string_pretty(&result)?,
870 error: if result.status == BackgroundTaskStatus::Completed {
871 None
872 } else {
873 result.error
874 },
875 })
876 }
877
878 async fn handle_list_results(&self) -> anyhow::Result<ToolResult> {
880 let results_dir = self.results_dir();
881 if !results_dir.exists() {
882 return Ok(ToolResult {
883 success: true,
884 output: "No background delegate results found.".into(),
885 error: None,
886 });
887 }
888
889 let mut entries = tokio::fs::read_dir(&results_dir).await?;
890 let mut results = Vec::new();
891
892 while let Some(entry) = entries.next_entry().await? {
893 let path = entry.path();
894 if path.extension().and_then(|e| e.to_str()) == Some("json") {
895 if let Ok(content) = tokio::fs::read_to_string(&path).await {
896 if let Ok(result) = serde_json::from_str::<BackgroundDelegateResult>(&content) {
897 results.push(json!({
898 "task_id": result.task_id,
899 "agent": result.agent,
900 "status": result.status,
901 "started_at": result.started_at,
902 "finished_at": result.finished_at,
903 }));
904 }
905 }
906 }
907 }
908
909 if results.is_empty() {
910 return Ok(ToolResult {
911 success: true,
912 output: "No background delegate results found.".into(),
913 error: None,
914 });
915 }
916
917 Ok(ToolResult {
918 success: true,
919 output: serde_json::to_string_pretty(&results)?,
920 error: None,
921 })
922 }
923
924 async fn handle_cancel_task(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
926 let task_id = args
927 .get("task_id")
928 .and_then(|v| v.as_str())
929 .ok_or_else(|| anyhow::anyhow!("Missing 'task_id' parameter for cancel_task"))?;
930
931 if let Err(e) = Self::validate_task_id(task_id) {
932 return Ok(ToolResult {
933 success: false,
934 output: String::new(),
935 error: Some(e),
936 });
937 }
938
939 let result_path = self.results_dir().join(format!("{task_id}.json"));
940 if !result_path.exists() {
941 return Ok(ToolResult {
942 success: false,
943 output: String::new(),
944 error: Some(format!("No task found for task_id '{task_id}'")),
945 });
946 }
947
948 let content = tokio::fs::read_to_string(&result_path).await?;
950 let mut result: BackgroundDelegateResult = serde_json::from_str(&content)?;
951
952 if result.status != BackgroundTaskStatus::Running {
953 return Ok(ToolResult {
954 success: false,
955 output: String::new(),
956 error: Some(format!(
957 "Task '{task_id}' is not running (status: {:?})",
958 result.status
959 )),
960 });
961 }
962
963 result.status = BackgroundTaskStatus::Cancelled;
969 result.error = Some("Cancelled by user request".into());
970 result.finished_at = Some(chrono::Utc::now().to_rfc3339());
971 let bytes = serde_json::to_vec_pretty(&result)?;
972 tokio::fs::write(&result_path, &bytes).await?;
973
974 Ok(ToolResult {
975 success: true,
976 output: format!("Task '{task_id}' cancellation requested."),
977 error: None,
978 })
979 }
980
981 pub fn cancel_all_background_tasks(&self) {
984 self.cancellation_token.cancel();
985 }
986
987 fn build_enriched_system_prompt(
991 &self,
992 agent_config: &DelegateAgentConfig,
993 sub_tools: &[Box<dyn Tool>],
994 workspace_dir: &Path,
995 ) -> Option<String> {
996 let skills_dir = agent_config
998 .skills_directory
999 .as_ref()
1000 .filter(|s| !s.trim().is_empty())
1001 .map(|dir| workspace_dir.join(dir))
1002 .unwrap_or_else(|| crate::skills::skills_dir(workspace_dir));
1003 let skills = crate::skills::load_skills_from_directory(&skills_dir, false);
1004
1005 let has_shell = sub_tools.iter().any(|t| t.name() == "shell");
1008 let shell_policy = if has_shell {
1009 "## Shell Policy\n\n\
1010 - Prefer non-destructive commands. Use `trash` over `rm` where possible.\n\
1011 - Do not run commands that exfiltrate data or modify system-critical paths.\n\
1012 - Avoid interactive commands that block on stdin.\n\
1013 - Quote paths that may contain spaces."
1014 .to_string()
1015 } else {
1016 String::new()
1017 };
1018
1019 let ctx = PromptContext {
1021 workspace_dir,
1022 model_name: &agent_config.model,
1023 tools: sub_tools,
1024 skills: &skills,
1025 skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
1026 identity_config: None,
1027 dispatcher_instructions: "",
1028 tool_descriptions: None,
1029 security_summary: None,
1030 autonomy_level: crate::security::AutonomyLevel::default(),
1031 operator_enabled: false,
1032 kumiho_enabled: false,
1033 };
1034
1035 let builder = SystemPromptBuilder::default()
1036 .add_section(Box::new(crate::agent::prompt::ToolsSection))
1037 .add_section(Box::new(crate::agent::prompt::SafetySection))
1038 .add_section(Box::new(crate::agent::prompt::SkillsSection))
1039 .add_section(Box::new(crate::agent::prompt::WorkspaceSection))
1040 .add_section(Box::new(crate::agent::prompt::DateTimeSection));
1041
1042 let mut enriched = builder.build(&ctx).unwrap_or_default();
1043
1044 if !shell_policy.is_empty() {
1045 enriched.push_str(&shell_policy);
1046 enriched.push_str("\n\n");
1047 }
1048
1049 if let Some(operator_prompt) = agent_config.system_prompt.as_ref() {
1051 enriched.push_str(operator_prompt);
1052 enriched.push('\n');
1053 }
1054
1055 let trimmed = enriched.trim().to_string();
1056 if trimmed.is_empty() {
1057 None
1058 } else {
1059 Some(trimmed)
1060 }
1061 }
1062
1063 async fn execute_agentic(
1064 &self,
1065 agent_name: &str,
1066 agent_config: &DelegateAgentConfig,
1067 provider: &dyn Provider,
1068 full_prompt: &str,
1069 temperature: f64,
1070 ) -> anyhow::Result<ToolResult> {
1071 if agent_config.allowed_tools.is_empty() {
1072 return Ok(ToolResult {
1073 success: false,
1074 output: String::new(),
1075 error: Some(format!(
1076 "Agent '{agent_name}' has agentic=true but allowed_tools is empty"
1077 )),
1078 });
1079 }
1080
1081 let allowed = agent_config
1082 .allowed_tools
1083 .iter()
1084 .map(|name| name.trim())
1085 .filter(|name| !name.is_empty())
1086 .collect::<std::collections::HashSet<_>>();
1087
1088 let sub_tools: Vec<Box<dyn Tool>> = {
1089 let parent_tools = self.parent_tools.read();
1090 parent_tools
1091 .iter()
1092 .filter(|tool| allowed.contains(tool.name()))
1093 .filter(|tool| tool.name() != "delegate")
1094 .map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
1095 .collect()
1096 };
1097
1098 if sub_tools.is_empty() {
1099 return Ok(ToolResult {
1100 success: false,
1101 output: String::new(),
1102 error: Some(format!(
1103 "Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
1104 agent_config.allowed_tools.join(", ")
1105 )),
1106 });
1107 }
1108
1109 let enriched_system_prompt =
1111 self.build_enriched_system_prompt(agent_config, &sub_tools, &self.workspace_dir);
1112
1113 let mut history = Vec::new();
1114 if let Some(system_prompt) = enriched_system_prompt.as_ref() {
1115 history.push(ChatMessage::system(system_prompt.clone()));
1116 }
1117 history.push(ChatMessage::user(full_prompt.to_string()));
1118
1119 let noop_observer = NoopObserver;
1120
1121 let agentic_timeout_secs = agent_config
1122 .agentic_timeout_secs
1123 .unwrap_or(self.delegate_config.agentic_timeout_secs);
1124 let result = tokio::time::timeout(
1125 Duration::from_secs(agentic_timeout_secs),
1126 run_tool_call_loop(
1127 provider,
1128 &mut history,
1129 &sub_tools,
1130 &noop_observer,
1131 &agent_config.provider,
1132 &agent_config.model,
1133 temperature,
1134 true,
1135 None,
1136 "delegate",
1137 None,
1138 &self.multimodal_config,
1139 agent_config.max_iterations,
1140 None,
1141 None,
1142 None,
1143 &[],
1144 &[],
1145 None,
1146 None,
1147 &crate::config::PacingConfig::default(),
1148 0, 0, None, ),
1152 )
1153 .await;
1154
1155 match result {
1156 Ok(Ok(response)) => {
1157 let rendered = if response.trim().is_empty() {
1158 "[Empty response]".to_string()
1159 } else {
1160 response
1161 };
1162
1163 Ok(ToolResult {
1164 success: true,
1165 output: format!(
1166 "[Agent '{agent_name}' ({provider}/{model}, agentic)]\n{rendered}",
1167 provider = agent_config.provider,
1168 model = agent_config.model
1169 ),
1170 error: None,
1171 })
1172 }
1173 Ok(Err(e)) => Ok(ToolResult {
1174 success: false,
1175 output: String::new(),
1176 error: Some(format!("Agent '{agent_name}' failed: {e}")),
1177 }),
1178 Err(_) => Ok(ToolResult {
1179 success: false,
1180 output: String::new(),
1181 error: Some(format!(
1182 "Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
1183 )),
1184 }),
1185 }
1186 }
1187}
1188
1189struct ToolArcRef {
1190 inner: Arc<dyn Tool>,
1191}
1192
1193impl ToolArcRef {
1194 fn new(inner: Arc<dyn Tool>) -> Self {
1195 Self { inner }
1196 }
1197}
1198
1199#[async_trait]
1200impl Tool for ToolArcRef {
1201 fn name(&self) -> &str {
1202 self.inner.name()
1203 }
1204
1205 fn description(&self) -> &str {
1206 self.inner.description()
1207 }
1208
1209 fn parameters_schema(&self) -> serde_json::Value {
1210 self.inner.parameters_schema()
1211 }
1212
1213 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1214 self.inner.execute(args).await
1215 }
1216}
1217
1218struct NoopObserver;
1219
1220impl Observer for NoopObserver {
1221 fn record_event(&self, _event: &ObserverEvent) {}
1222
1223 fn record_metric(&self, _metric: &ObserverMetric) {}
1224
1225 fn name(&self) -> &str {
1226 "noop"
1227 }
1228
1229 fn as_any(&self) -> &dyn std::any::Any {
1230 self
1231 }
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237 use crate::config::schema::{
1238 DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
1239 };
1240 use crate::providers::{ChatRequest, ChatResponse, ToolCall};
1241 use crate::security::{AutonomyLevel, SecurityPolicy};
1242 use anyhow::anyhow;
1243
1244 fn test_security() -> Arc<SecurityPolicy> {
1245 Arc::new(SecurityPolicy::default())
1246 }
1247
1248 fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
1249 let mut agents = HashMap::new();
1250 agents.insert(
1251 "researcher".to_string(),
1252 DelegateAgentConfig {
1253 provider: "ollama".to_string(),
1254 model: "llama3".to_string(),
1255 system_prompt: Some("You are a research assistant.".to_string()),
1256 api_key: None,
1257 temperature: Some(0.3),
1258 max_depth: 3,
1259 agentic: false,
1260 allowed_tools: Vec::new(),
1261 max_iterations: 10,
1262 timeout_secs: None,
1263 agentic_timeout_secs: None,
1264 skills_directory: None,
1265 },
1266 );
1267 agents.insert(
1268 "coder".to_string(),
1269 DelegateAgentConfig {
1270 provider: "openrouter".to_string(),
1271 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1272 system_prompt: None,
1273 api_key: Some("delegate-test-credential".to_string()),
1274 temperature: None,
1275 max_depth: 2,
1276 agentic: false,
1277 allowed_tools: Vec::new(),
1278 max_iterations: 10,
1279 timeout_secs: None,
1280 agentic_timeout_secs: None,
1281 skills_directory: None,
1282 },
1283 );
1284 agents
1285 }
1286
1287 #[derive(Default)]
1288 struct EchoTool;
1289
1290 #[async_trait]
1291 impl Tool for EchoTool {
1292 fn name(&self) -> &str {
1293 "echo_tool"
1294 }
1295
1296 fn description(&self) -> &str {
1297 "Echoes the `value` argument."
1298 }
1299
1300 fn parameters_schema(&self) -> serde_json::Value {
1301 serde_json::json!({
1302 "type": "object",
1303 "properties": {
1304 "value": {"type": "string"}
1305 },
1306 "required": ["value"]
1307 })
1308 }
1309
1310 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1311 let value = args
1312 .get("value")
1313 .and_then(serde_json::Value::as_str)
1314 .unwrap_or_default()
1315 .to_string();
1316 Ok(ToolResult {
1317 success: true,
1318 output: format!("echo:{value}"),
1319 error: None,
1320 })
1321 }
1322 }
1323
1324 struct OneToolThenFinalProvider;
1325
1326 #[async_trait]
1327 impl Provider for OneToolThenFinalProvider {
1328 async fn chat_with_system(
1329 &self,
1330 _system_prompt: Option<&str>,
1331 _message: &str,
1332 _model: &str,
1333 _temperature: f64,
1334 ) -> anyhow::Result<String> {
1335 Ok("unused".to_string())
1336 }
1337
1338 async fn chat(
1339 &self,
1340 request: ChatRequest<'_>,
1341 _model: &str,
1342 _temperature: f64,
1343 ) -> anyhow::Result<ChatResponse> {
1344 let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1345 if has_tool_message {
1346 Ok(ChatResponse {
1347 text: Some("done".to_string()),
1348 tool_calls: Vec::new(),
1349 usage: None,
1350 reasoning_content: None,
1351 })
1352 } else {
1353 Ok(ChatResponse {
1354 text: None,
1355 tool_calls: vec![ToolCall {
1356 id: "call_1".to_string(),
1357 name: "echo_tool".to_string(),
1358 arguments: "{\"value\":\"ping\"}".to_string(),
1359 }],
1360 usage: None,
1361 reasoning_content: None,
1362 })
1363 }
1364 }
1365 }
1366
1367 struct InfiniteToolCallProvider;
1368
1369 #[async_trait]
1370 impl Provider for InfiniteToolCallProvider {
1371 async fn chat_with_system(
1372 &self,
1373 _system_prompt: Option<&str>,
1374 _message: &str,
1375 _model: &str,
1376 _temperature: f64,
1377 ) -> anyhow::Result<String> {
1378 Ok("unused".to_string())
1379 }
1380
1381 async fn chat(
1382 &self,
1383 _request: ChatRequest<'_>,
1384 _model: &str,
1385 _temperature: f64,
1386 ) -> anyhow::Result<ChatResponse> {
1387 Ok(ChatResponse {
1388 text: None,
1389 tool_calls: vec![ToolCall {
1390 id: "loop".to_string(),
1391 name: "echo_tool".to_string(),
1392 arguments: "{\"value\":\"x\"}".to_string(),
1393 }],
1394 usage: None,
1395 reasoning_content: None,
1396 })
1397 }
1398 }
1399
1400 struct FailingProvider;
1401
1402 #[async_trait]
1403 impl Provider for FailingProvider {
1404 async fn chat_with_system(
1405 &self,
1406 _system_prompt: Option<&str>,
1407 _message: &str,
1408 _model: &str,
1409 _temperature: f64,
1410 ) -> anyhow::Result<String> {
1411 Ok("unused".to_string())
1412 }
1413
1414 async fn chat(
1415 &self,
1416 _request: ChatRequest<'_>,
1417 _model: &str,
1418 _temperature: f64,
1419 ) -> anyhow::Result<ChatResponse> {
1420 Err(anyhow!("provider boom"))
1421 }
1422 }
1423
1424 fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
1425 DelegateAgentConfig {
1426 provider: "openrouter".to_string(),
1427 model: "model-test".to_string(),
1428 system_prompt: Some("You are agentic.".to_string()),
1429 api_key: Some("delegate-test-credential".to_string()),
1430 temperature: Some(0.2),
1431 max_depth: 3,
1432 agentic: true,
1433 allowed_tools,
1434 max_iterations,
1435 timeout_secs: None,
1436 agentic_timeout_secs: None,
1437 skills_directory: None,
1438 }
1439 }
1440
1441 #[test]
1442 fn name_and_schema() {
1443 let tool = DelegateTool::new(sample_agents(), None, test_security());
1444 assert_eq!(tool.name(), "delegate");
1445 let schema = tool.parameters_schema();
1446 assert!(schema["properties"]["agent"].is_object());
1447 assert!(schema["properties"]["prompt"].is_object());
1448 assert!(schema["properties"]["context"].is_object());
1449 assert!(schema["properties"]["background"].is_object());
1450 assert!(schema["properties"]["parallel"].is_object());
1451 assert!(schema["properties"]["action"].is_object());
1452 assert!(schema["properties"]["task_id"].is_object());
1453 let required = schema["required"].as_array().unwrap();
1455 assert!(required.is_empty());
1456 assert_eq!(schema["additionalProperties"], json!(false));
1457 assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
1458 assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
1459 }
1460
1461 #[test]
1462 fn description_not_empty() {
1463 let tool = DelegateTool::new(sample_agents(), None, test_security());
1464 assert!(!tool.description().is_empty());
1465 }
1466
1467 #[test]
1468 fn schema_lists_agent_names() {
1469 let tool = DelegateTool::new(sample_agents(), None, test_security());
1470 let schema = tool.parameters_schema();
1471 let desc = schema["properties"]["agent"]["description"]
1472 .as_str()
1473 .unwrap();
1474 assert!(desc.contains("researcher") || desc.contains("coder"));
1475 }
1476
1477 #[tokio::test]
1478 async fn missing_agent_param() {
1479 let tool = DelegateTool::new(sample_agents(), None, test_security());
1480 let result = tool.execute(json!({"prompt": "test"})).await;
1481 assert!(result.is_err());
1482 }
1483
1484 #[tokio::test]
1485 async fn missing_prompt_param() {
1486 let tool = DelegateTool::new(sample_agents(), None, test_security());
1487 let result = tool.execute(json!({"agent": "researcher"})).await;
1488 assert!(result.is_err());
1489 }
1490
1491 #[tokio::test]
1492 async fn unknown_agent_returns_error() {
1493 let tool = DelegateTool::new(sample_agents(), None, test_security());
1494 let result = tool
1495 .execute(json!({"agent": "nonexistent", "prompt": "test"}))
1496 .await
1497 .unwrap();
1498 assert!(!result.success);
1499 assert!(result.error.unwrap().contains("Unknown agent"));
1500 }
1501
1502 #[tokio::test]
1503 async fn depth_limit_enforced() {
1504 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
1505 let result = tool
1506 .execute(json!({"agent": "researcher", "prompt": "test"}))
1507 .await
1508 .unwrap();
1509 assert!(!result.success);
1510 assert!(result.error.unwrap().contains("depth limit"));
1511 }
1512
1513 #[tokio::test]
1514 async fn depth_limit_per_agent() {
1515 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
1517 let result = tool
1518 .execute(json!({"agent": "coder", "prompt": "test"}))
1519 .await
1520 .unwrap();
1521 assert!(!result.success);
1522 assert!(result.error.unwrap().contains("depth limit"));
1523 }
1524
1525 #[test]
1526 fn empty_agents_schema() {
1527 let tool = DelegateTool::new(HashMap::new(), None, test_security());
1528 let schema = tool.parameters_schema();
1529 let desc = schema["properties"]["agent"]["description"]
1530 .as_str()
1531 .unwrap();
1532 assert!(desc.contains("none configured"));
1533 }
1534
1535 #[tokio::test]
1536 async fn invalid_provider_returns_error() {
1537 let mut agents = HashMap::new();
1538 agents.insert(
1539 "broken".to_string(),
1540 DelegateAgentConfig {
1541 provider: "totally-invalid-provider".to_string(),
1542 model: "model".to_string(),
1543 system_prompt: None,
1544 api_key: None,
1545 temperature: None,
1546 max_depth: 3,
1547 agentic: false,
1548 allowed_tools: Vec::new(),
1549 max_iterations: 10,
1550 timeout_secs: None,
1551 agentic_timeout_secs: None,
1552 skills_directory: None,
1553 },
1554 );
1555 let tool = DelegateTool::new(agents, None, test_security());
1556 let result = tool
1557 .execute(json!({"agent": "broken", "prompt": "test"}))
1558 .await
1559 .unwrap();
1560 assert!(!result.success);
1561 assert!(result.error.unwrap().contains("Failed to create provider"));
1562 }
1563
1564 #[tokio::test]
1565 async fn blank_agent_rejected() {
1566 let tool = DelegateTool::new(sample_agents(), None, test_security());
1567 let result = tool
1568 .execute(json!({"agent": " ", "prompt": "test"}))
1569 .await
1570 .unwrap();
1571 assert!(!result.success);
1572 assert!(result.error.unwrap().contains("must not be empty"));
1573 }
1574
1575 #[tokio::test]
1576 async fn blank_prompt_rejected() {
1577 let tool = DelegateTool::new(sample_agents(), None, test_security());
1578 let result = tool
1579 .execute(json!({"agent": "researcher", "prompt": " \t "}))
1580 .await
1581 .unwrap();
1582 assert!(!result.success);
1583 assert!(result.error.unwrap().contains("must not be empty"));
1584 }
1585
1586 #[tokio::test]
1587 async fn whitespace_agent_name_trimmed_and_found() {
1588 let tool = DelegateTool::new(sample_agents(), None, test_security());
1589 let result = tool
1591 .execute(json!({"agent": " researcher ", "prompt": "test"}))
1592 .await
1593 .unwrap();
1594 assert!(
1597 result.error.is_none()
1598 || !result
1599 .error
1600 .as_deref()
1601 .unwrap_or("")
1602 .contains("Unknown agent")
1603 );
1604 }
1605
1606 #[tokio::test]
1607 async fn delegation_blocked_in_readonly_mode() {
1608 let readonly = Arc::new(SecurityPolicy {
1609 autonomy: AutonomyLevel::ReadOnly,
1610 ..SecurityPolicy::default()
1611 });
1612 let tool = DelegateTool::new(sample_agents(), None, readonly);
1613 let result = tool
1614 .execute(json!({"agent": "researcher", "prompt": "test"}))
1615 .await
1616 .unwrap();
1617 assert!(!result.success);
1618 assert!(
1619 result
1620 .error
1621 .as_deref()
1622 .unwrap_or("")
1623 .contains("read-only mode")
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn delegation_blocked_when_rate_limited() {
1629 let limited = Arc::new(SecurityPolicy {
1630 max_actions_per_hour: 0,
1631 ..SecurityPolicy::default()
1632 });
1633 let tool = DelegateTool::new(sample_agents(), None, limited);
1634 let result = tool
1635 .execute(json!({"agent": "researcher", "prompt": "test"}))
1636 .await
1637 .unwrap();
1638 assert!(!result.success);
1639 assert!(
1640 result
1641 .error
1642 .as_deref()
1643 .unwrap_or("")
1644 .contains("Rate limit exceeded")
1645 );
1646 }
1647
1648 #[tokio::test]
1649 async fn delegate_context_is_prepended_to_prompt() {
1650 let mut agents = HashMap::new();
1651 agents.insert(
1652 "tester".to_string(),
1653 DelegateAgentConfig {
1654 provider: "invalid-for-test".to_string(),
1655 model: "test-model".to_string(),
1656 system_prompt: None,
1657 api_key: None,
1658 temperature: None,
1659 max_depth: 3,
1660 agentic: false,
1661 allowed_tools: Vec::new(),
1662 max_iterations: 10,
1663 timeout_secs: None,
1664 agentic_timeout_secs: None,
1665 skills_directory: None,
1666 },
1667 );
1668 let tool = DelegateTool::new(agents, None, test_security());
1669 let result = tool
1670 .execute(json!({
1671 "agent": "tester",
1672 "prompt": "do something",
1673 "context": "some context data"
1674 }))
1675 .await
1676 .unwrap();
1677
1678 assert!(!result.success);
1679 assert!(
1680 result
1681 .error
1682 .as_deref()
1683 .unwrap_or("")
1684 .contains("Failed to create provider")
1685 );
1686 }
1687
1688 #[tokio::test]
1689 async fn delegate_empty_context_omits_prefix() {
1690 let mut agents = HashMap::new();
1691 agents.insert(
1692 "tester".to_string(),
1693 DelegateAgentConfig {
1694 provider: "invalid-for-test".to_string(),
1695 model: "test-model".to_string(),
1696 system_prompt: None,
1697 api_key: None,
1698 temperature: None,
1699 max_depth: 3,
1700 agentic: false,
1701 allowed_tools: Vec::new(),
1702 max_iterations: 10,
1703 timeout_secs: None,
1704 agentic_timeout_secs: None,
1705 skills_directory: None,
1706 },
1707 );
1708 let tool = DelegateTool::new(agents, None, test_security());
1709 let result = tool
1710 .execute(json!({
1711 "agent": "tester",
1712 "prompt": "do something",
1713 "context": ""
1714 }))
1715 .await
1716 .unwrap();
1717
1718 assert!(!result.success);
1719 assert!(
1720 result
1721 .error
1722 .as_deref()
1723 .unwrap_or("")
1724 .contains("Failed to create provider")
1725 );
1726 }
1727
1728 #[test]
1729 fn delegate_depth_construction() {
1730 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
1731 assert_eq!(tool.depth, 5);
1732 }
1733
1734 #[tokio::test]
1735 async fn delegate_no_agents_configured() {
1736 let tool = DelegateTool::new(HashMap::new(), None, test_security());
1737 let result = tool
1738 .execute(json!({"agent": "any", "prompt": "test"}))
1739 .await
1740 .unwrap();
1741 assert!(!result.success);
1742 assert!(result.error.unwrap().contains("none configured"));
1743 }
1744
1745 #[tokio::test]
1746 async fn agentic_mode_rejects_empty_allowed_tools() {
1747 let mut agents = HashMap::new();
1748 agents.insert("agentic".to_string(), agentic_config(Vec::new(), 10));
1749
1750 let tool = DelegateTool::new(agents, None, test_security());
1751 let result = tool
1752 .execute(json!({"agent": "agentic", "prompt": "test"}))
1753 .await
1754 .unwrap();
1755
1756 assert!(!result.success);
1757 assert!(
1758 result
1759 .error
1760 .as_deref()
1761 .unwrap_or("")
1762 .contains("allowed_tools is empty")
1763 );
1764 }
1765
1766 #[tokio::test]
1767 async fn agentic_mode_rejects_unmatched_allowed_tools() {
1768 let mut agents = HashMap::new();
1769 agents.insert(
1770 "agentic".to_string(),
1771 agentic_config(vec!["missing_tool".to_string()], 10),
1772 );
1773
1774 let tool = DelegateTool::new(agents, None, test_security())
1775 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1776 let result = tool
1777 .execute(json!({"agent": "agentic", "prompt": "test"}))
1778 .await
1779 .unwrap();
1780
1781 assert!(!result.success);
1782 assert!(
1783 result
1784 .error
1785 .as_deref()
1786 .unwrap_or("")
1787 .contains("no executable tools")
1788 );
1789 }
1790
1791 #[tokio::test]
1792 async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
1793 let config = agentic_config(vec!["echo_tool".to_string()], 10);
1794 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1795 Arc::new(RwLock::new(vec![
1796 Arc::new(EchoTool),
1797 Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
1798 ])),
1799 );
1800
1801 let provider = OneToolThenFinalProvider;
1802 let result = tool
1803 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1804 .await
1805 .unwrap();
1806
1807 assert!(result.success);
1808 assert!(result.output.contains("(openrouter/model-test, agentic)"));
1809 assert!(result.output.contains("done"));
1810 }
1811
1812 #[tokio::test]
1813 async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
1814 let config = agentic_config(vec!["delegate".to_string()], 10);
1815 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1816 Arc::new(RwLock::new(vec![Arc::new(DelegateTool::new(
1817 HashMap::new(),
1818 None,
1819 test_security(),
1820 ))])),
1821 );
1822
1823 let provider = OneToolThenFinalProvider;
1824 let result = tool
1825 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1826 .await
1827 .unwrap();
1828
1829 assert!(!result.success);
1830 assert!(
1831 result
1832 .error
1833 .as_deref()
1834 .unwrap_or("")
1835 .contains("no executable tools")
1836 );
1837 }
1838
1839 #[tokio::test]
1840 async fn execute_agentic_respects_max_iterations() {
1841 let config = agentic_config(vec!["echo_tool".to_string()], 2);
1842 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1843 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1844
1845 let provider = InfiniteToolCallProvider;
1846 let result = tool
1847 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1848 .await
1849 .unwrap();
1850
1851 assert!(!result.success);
1852 assert!(
1853 result
1854 .error
1855 .as_deref()
1856 .unwrap_or("")
1857 .contains("maximum tool iterations (2)")
1858 );
1859 }
1860
1861 #[tokio::test]
1862 async fn execute_agentic_propagates_provider_errors() {
1863 let config = agentic_config(vec!["echo_tool".to_string()], 10);
1864 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1865 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1866
1867 let provider = FailingProvider;
1868 let result = tool
1869 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1870 .await
1871 .unwrap();
1872
1873 assert!(!result.success);
1874 assert!(
1875 result
1876 .error
1877 .as_deref()
1878 .unwrap_or("")
1879 .contains("provider boom")
1880 );
1881 }
1882
1883 #[derive(Default)]
1886 struct FakeMcpTool;
1887
1888 #[async_trait]
1889 impl Tool for FakeMcpTool {
1890 fn name(&self) -> &str {
1891 "mcp_fake"
1892 }
1893
1894 fn description(&self) -> &str {
1895 "Fake MCP tool for testing."
1896 }
1897
1898 fn parameters_schema(&self) -> serde_json::Value {
1899 serde_json::json!({"type": "object", "properties": {}})
1900 }
1901
1902 async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
1903 Ok(ToolResult {
1904 success: true,
1905 output: "mcp_fake_output".into(),
1906 error: None,
1907 })
1908 }
1909 }
1910
1911 struct McpToolThenFinalProvider;
1912
1913 #[async_trait]
1914 impl Provider for McpToolThenFinalProvider {
1915 async fn chat_with_system(
1916 &self,
1917 _system_prompt: Option<&str>,
1918 _message: &str,
1919 _model: &str,
1920 _temperature: f64,
1921 ) -> anyhow::Result<String> {
1922 Ok("unused".to_string())
1923 }
1924
1925 async fn chat(
1926 &self,
1927 request: ChatRequest<'_>,
1928 _model: &str,
1929 _temperature: f64,
1930 ) -> anyhow::Result<ChatResponse> {
1931 let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1932 if has_tool_message {
1933 Ok(ChatResponse {
1934 text: Some("mcp done".to_string()),
1935 tool_calls: Vec::new(),
1936 usage: None,
1937 reasoning_content: None,
1938 })
1939 } else {
1940 Ok(ChatResponse {
1941 text: None,
1942 tool_calls: vec![ToolCall {
1943 id: "call_mcp".to_string(),
1944 name: "mcp_fake".to_string(),
1945 arguments: "{}".to_string(),
1946 }],
1947 usage: None,
1948 reasoning_content: None,
1949 })
1950 }
1951 }
1952 }
1953
1954 #[tokio::test]
1955 async fn mcp_tools_included_in_subagent_tool_list() {
1956 let config = agentic_config(vec!["mcp_fake".to_string()], 10);
1958 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1959 .with_parent_tools(Arc::new(RwLock::new(Vec::new())));
1960
1961 let handle = tool.parent_tools_handle();
1963 handle.write().push(Arc::new(FakeMcpTool));
1964
1965 let provider = McpToolThenFinalProvider;
1966 let result = tool
1967 .execute_agentic("agentic", &config, &provider, "run mcp", 0.2)
1968 .await
1969 .unwrap();
1970
1971 assert!(result.success, "Expected success, got: {:?}", result.error);
1972 assert!(
1973 result.output.contains("mcp done"),
1974 "Expected output containing 'mcp done', got: {}",
1975 result.output
1976 );
1977 }
1978
1979 #[test]
1980 fn enriched_prompt_includes_tools_workspace_datetime() {
1981 let config = DelegateAgentConfig {
1982 provider: "openrouter".to_string(),
1983 model: "test-model".to_string(),
1984 system_prompt: Some("You are a code reviewer.".to_string()),
1985 api_key: None,
1986 temperature: None,
1987 max_depth: 3,
1988 agentic: true,
1989 allowed_tools: vec!["echo_tool".to_string()],
1990 max_iterations: 10,
1991 timeout_secs: None,
1992 agentic_timeout_secs: None,
1993 skills_directory: None,
1994 };
1995
1996 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
1997 let workspace = std::env::temp_dir().join(format!(
1998 "construct_delegate_enrich_test_{}",
1999 uuid::Uuid::new_v4()
2000 ));
2001 std::fs::create_dir_all(&workspace).unwrap();
2002
2003 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2004 .with_workspace_dir(workspace.clone());
2005
2006 let prompt = tool
2007 .build_enriched_system_prompt(&config, &tools, &workspace)
2008 .unwrap();
2009
2010 assert!(prompt.contains("## Tools"), "should contain tools section");
2011 assert!(prompt.contains("echo_tool"), "should list allowed tools");
2012 assert!(
2013 prompt.contains("## Workspace"),
2014 "should contain workspace section"
2015 );
2016 assert!(
2017 prompt.contains(&workspace.display().to_string()),
2018 "should contain workspace path"
2019 );
2020 assert!(
2021 prompt.contains("## CRITICAL CONTEXT: CURRENT DATE & TIME"),
2022 "should contain datetime section"
2023 );
2024 assert!(
2025 prompt.contains("You are a code reviewer."),
2026 "should append operator system_prompt"
2027 );
2028
2029 let _ = std::fs::remove_dir_all(workspace);
2030 }
2031
2032 #[test]
2033 fn enriched_prompt_includes_shell_policy_when_shell_present() {
2034 let config = DelegateAgentConfig {
2035 provider: "openrouter".to_string(),
2036 model: "test-model".to_string(),
2037 system_prompt: None,
2038 api_key: None,
2039 temperature: None,
2040 max_depth: 3,
2041 agentic: true,
2042 allowed_tools: vec!["shell".to_string()],
2043 max_iterations: 10,
2044 timeout_secs: None,
2045 agentic_timeout_secs: None,
2046 skills_directory: None,
2047 };
2048
2049 struct MockShellTool;
2050 #[async_trait]
2051 impl Tool for MockShellTool {
2052 fn name(&self) -> &str {
2053 "shell"
2054 }
2055 fn description(&self) -> &str {
2056 "Execute shell commands"
2057 }
2058 fn parameters_schema(&self) -> serde_json::Value {
2059 json!({"type": "object"})
2060 }
2061 async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2062 Ok(ToolResult {
2063 success: true,
2064 output: String::new(),
2065 error: None,
2066 })
2067 }
2068 }
2069
2070 let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockShellTool)];
2071 let workspace = std::env::temp_dir();
2072
2073 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2074 .with_workspace_dir(workspace.to_path_buf());
2075
2076 let prompt = tool
2077 .build_enriched_system_prompt(&config, &tools, &workspace)
2078 .unwrap();
2079
2080 assert!(
2081 prompt.contains("## Shell Policy"),
2082 "should contain shell policy when shell tool is present"
2083 );
2084 }
2085
2086 #[test]
2087 fn parent_tools_handle_returns_shared_reference() {
2088 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
2089 Arc::new(RwLock::new(vec![Arc::new(EchoTool) as Arc<dyn Tool>])),
2090 );
2091
2092 let handle = tool.parent_tools_handle();
2093 assert_eq!(handle.read().len(), 1);
2094
2095 handle.write().push(Arc::new(FakeMcpTool));
2097 assert_eq!(handle.read().len(), 2);
2098 }
2099
2100 #[test]
2103 fn default_timeout_values_used_when_config_unset() {
2104 let config = DelegateAgentConfig {
2105 provider: "ollama".to_string(),
2106 model: "llama3".to_string(),
2107 system_prompt: None,
2108 api_key: None,
2109 temperature: None,
2110 max_depth: 3,
2111 agentic: false,
2112 allowed_tools: Vec::new(),
2113 max_iterations: 10,
2114 timeout_secs: None,
2115 agentic_timeout_secs: None,
2116 skills_directory: None,
2117 };
2118 assert_eq!(
2119 config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2120 120
2121 );
2122 assert_eq!(
2123 config
2124 .agentic_timeout_secs
2125 .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2126 300
2127 );
2128 }
2129
2130 #[test]
2131 fn enriched_prompt_omits_shell_policy_without_shell_tool() {
2132 let config = DelegateAgentConfig {
2133 provider: "openrouter".to_string(),
2134 model: "test-model".to_string(),
2135 system_prompt: None,
2136 api_key: None,
2137 temperature: None,
2138 max_depth: 3,
2139 agentic: true,
2140 allowed_tools: vec!["echo_tool".to_string()],
2141 max_iterations: 10,
2142 timeout_secs: None,
2143 agentic_timeout_secs: None,
2144 skills_directory: None,
2145 };
2146
2147 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2148 let workspace = std::env::temp_dir();
2149
2150 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2151 .with_workspace_dir(workspace.to_path_buf());
2152
2153 let prompt = tool
2154 .build_enriched_system_prompt(&config, &tools, &workspace)
2155 .unwrap();
2156
2157 assert!(
2158 !prompt.contains("## Shell Policy"),
2159 "should not contain shell policy when shell tool is absent"
2160 );
2161 }
2162
2163 #[test]
2164 fn custom_timeout_values_are_respected() {
2165 let config = DelegateAgentConfig {
2166 provider: "ollama".to_string(),
2167 model: "llama3".to_string(),
2168 system_prompt: None,
2169 api_key: None,
2170 temperature: None,
2171 max_depth: 3,
2172 agentic: false,
2173 allowed_tools: Vec::new(),
2174 max_iterations: 10,
2175 timeout_secs: Some(60),
2176 agentic_timeout_secs: Some(600),
2177 skills_directory: None,
2178 };
2179 assert_eq!(
2180 config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2181 60
2182 );
2183 assert_eq!(
2184 config
2185 .agentic_timeout_secs
2186 .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2187 600
2188 );
2189 }
2190
2191 #[test]
2192 fn timeout_deserialization_defaults_to_none() {
2193 let toml_str = r#"
2194 provider = "ollama"
2195 model = "llama3"
2196 "#;
2197 let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2198 assert!(config.timeout_secs.is_none());
2199 assert!(config.agentic_timeout_secs.is_none());
2200 }
2201
2202 #[test]
2203 fn timeout_deserialization_with_custom_values() {
2204 let toml_str = r#"
2205 provider = "ollama"
2206 model = "llama3"
2207 timeout_secs = 45
2208 agentic_timeout_secs = 900
2209 "#;
2210 let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2211 assert_eq!(config.timeout_secs, Some(45));
2212 assert_eq!(config.agentic_timeout_secs, Some(900));
2213 }
2214
2215 #[test]
2216 fn config_validation_rejects_zero_timeout() {
2217 let mut config = crate::config::Config::default();
2218 config.agents.insert(
2219 "bad".into(),
2220 DelegateAgentConfig {
2221 provider: "ollama".into(),
2222 model: "llama3".into(),
2223 system_prompt: None,
2224 api_key: None,
2225 temperature: None,
2226 max_depth: 3,
2227 agentic: false,
2228 allowed_tools: Vec::new(),
2229 max_iterations: 10,
2230 timeout_secs: Some(0),
2231 agentic_timeout_secs: None,
2232 skills_directory: None,
2233 },
2234 );
2235 let err = config.validate().unwrap_err();
2236 assert!(
2237 format!("{err}").contains("timeout_secs must be greater than 0"),
2238 "unexpected error: {err}"
2239 );
2240 }
2241
2242 #[test]
2243 fn config_validation_rejects_zero_agentic_timeout() {
2244 let mut config = crate::config::Config::default();
2245 config.agents.insert(
2246 "bad".into(),
2247 DelegateAgentConfig {
2248 provider: "ollama".into(),
2249 model: "llama3".into(),
2250 system_prompt: None,
2251 api_key: None,
2252 temperature: None,
2253 max_depth: 3,
2254 agentic: false,
2255 allowed_tools: Vec::new(),
2256 max_iterations: 10,
2257 timeout_secs: None,
2258 agentic_timeout_secs: Some(0),
2259 skills_directory: None,
2260 },
2261 );
2262 let err = config.validate().unwrap_err();
2263 assert!(
2264 format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
2265 "unexpected error: {err}"
2266 );
2267 }
2268
2269 #[test]
2270 fn config_validation_rejects_excessive_timeout() {
2271 let mut config = crate::config::Config::default();
2272 config.agents.insert(
2273 "bad".into(),
2274 DelegateAgentConfig {
2275 provider: "ollama".into(),
2276 model: "llama3".into(),
2277 system_prompt: None,
2278 api_key: None,
2279 temperature: None,
2280 max_depth: 3,
2281 agentic: false,
2282 allowed_tools: Vec::new(),
2283 max_iterations: 10,
2284 timeout_secs: Some(7200),
2285 agentic_timeout_secs: None,
2286 skills_directory: None,
2287 },
2288 );
2289 let err = config.validate().unwrap_err();
2290 assert!(
2291 format!("{err}").contains("exceeds max 3600"),
2292 "unexpected error: {err}"
2293 );
2294 }
2295
2296 #[test]
2297 fn config_validation_rejects_excessive_agentic_timeout() {
2298 let mut config = crate::config::Config::default();
2299 config.agents.insert(
2300 "bad".into(),
2301 DelegateAgentConfig {
2302 provider: "ollama".into(),
2303 model: "llama3".into(),
2304 system_prompt: None,
2305 api_key: None,
2306 temperature: None,
2307 max_depth: 3,
2308 agentic: false,
2309 allowed_tools: Vec::new(),
2310 max_iterations: 10,
2311 timeout_secs: None,
2312 agentic_timeout_secs: Some(5000),
2313 skills_directory: None,
2314 },
2315 );
2316 let err = config.validate().unwrap_err();
2317 assert!(
2318 format!("{err}").contains("exceeds max 3600"),
2319 "unexpected error: {err}"
2320 );
2321 }
2322
2323 #[test]
2324 fn config_validation_accepts_max_boundary_timeout() {
2325 let mut config = crate::config::Config::default();
2326 config.agents.insert(
2327 "ok".into(),
2328 DelegateAgentConfig {
2329 provider: "ollama".into(),
2330 model: "llama3".into(),
2331 system_prompt: None,
2332 api_key: None,
2333 temperature: None,
2334 max_depth: 3,
2335 agentic: false,
2336 allowed_tools: Vec::new(),
2337 max_iterations: 10,
2338 timeout_secs: Some(3600),
2339 agentic_timeout_secs: Some(3600),
2340 skills_directory: None,
2341 },
2342 );
2343 assert!(config.validate().is_ok());
2344 }
2345
2346 #[test]
2347 fn config_validation_accepts_none_timeouts() {
2348 let mut config = crate::config::Config::default();
2349 config.agents.insert(
2350 "ok".into(),
2351 DelegateAgentConfig {
2352 provider: "ollama".into(),
2353 model: "llama3".into(),
2354 system_prompt: None,
2355 api_key: None,
2356 temperature: None,
2357 max_depth: 3,
2358 agentic: false,
2359 allowed_tools: Vec::new(),
2360 max_iterations: 10,
2361 timeout_secs: None,
2362 agentic_timeout_secs: None,
2363 skills_directory: None,
2364 },
2365 );
2366 assert!(config.validate().is_ok());
2367 }
2368
2369 #[test]
2370 fn enriched_prompt_loads_skills_from_scoped_directory() {
2371 let workspace = std::env::temp_dir().join(format!(
2372 "construct_delegate_skills_test_{}",
2373 uuid::Uuid::new_v4()
2374 ));
2375 let scoped_skills_dir = workspace.join("skills/code-review");
2376 std::fs::create_dir_all(scoped_skills_dir.join("lint-check")).unwrap();
2377 std::fs::write(
2378 scoped_skills_dir.join("lint-check/SKILL.toml"),
2379 "[skill]\nname = \"lint-check\"\ndescription = \"Run lint checks\"\nversion = \"1.0.0\"\n",
2380 )
2381 .unwrap();
2382
2383 let config = DelegateAgentConfig {
2384 provider: "openrouter".to_string(),
2385 model: "test-model".to_string(),
2386 system_prompt: None,
2387 api_key: None,
2388 temperature: None,
2389 max_depth: 3,
2390 agentic: true,
2391 allowed_tools: vec!["echo_tool".to_string()],
2392 max_iterations: 10,
2393 timeout_secs: None,
2394 agentic_timeout_secs: None,
2395 skills_directory: Some("skills/code-review".to_string()),
2396 };
2397
2398 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2399
2400 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2401 .with_workspace_dir(workspace.clone());
2402
2403 let prompt = tool
2404 .build_enriched_system_prompt(&config, &tools, &workspace)
2405 .unwrap();
2406
2407 assert!(
2408 prompt.contains("lint-check"),
2409 "should contain skills from scoped directory"
2410 );
2411
2412 let _ = std::fs::remove_dir_all(workspace);
2413 }
2414
2415 #[test]
2416 fn enriched_prompt_falls_back_to_default_skills_dir() {
2417 let workspace = std::env::temp_dir().join(format!(
2418 "construct_delegate_fallback_test_{}",
2419 uuid::Uuid::new_v4()
2420 ));
2421 let default_skills_dir = workspace.join("skills");
2422 std::fs::create_dir_all(default_skills_dir.join("deploy")).unwrap();
2423 std::fs::write(
2424 default_skills_dir.join("deploy/SKILL.toml"),
2425 "[skill]\nname = \"deploy\"\ndescription = \"Deploy safely\"\nversion = \"1.0.0\"\n",
2426 )
2427 .unwrap();
2428
2429 let config = DelegateAgentConfig {
2430 provider: "openrouter".to_string(),
2431 model: "test-model".to_string(),
2432 system_prompt: None,
2433 api_key: None,
2434 temperature: None,
2435 max_depth: 3,
2436 agentic: true,
2437 allowed_tools: vec!["echo_tool".to_string()],
2438 max_iterations: 10,
2439 timeout_secs: None,
2440 agentic_timeout_secs: None,
2441 skills_directory: None,
2442 };
2443
2444 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2445
2446 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2447 .with_workspace_dir(workspace.clone());
2448
2449 let prompt = tool
2450 .build_enriched_system_prompt(&config, &tools, &workspace)
2451 .unwrap();
2452
2453 assert!(
2454 prompt.contains("deploy"),
2455 "should contain skills from default workspace skills/ directory"
2456 );
2457
2458 let _ = std::fs::remove_dir_all(workspace);
2459 }
2460
2461 #[tokio::test]
2464 async fn background_delegation_returns_task_id() {
2465 let workspace = std::env::temp_dir().join(format!(
2466 "construct_delegate_bg_test_{}",
2467 uuid::Uuid::new_v4()
2468 ));
2469 std::fs::create_dir_all(&workspace).unwrap();
2470
2471 let tool = DelegateTool::new(sample_agents(), None, test_security())
2472 .with_workspace_dir(workspace.clone());
2473 let result = tool
2474 .execute(json!({
2475 "agent": "researcher",
2476 "prompt": "test background",
2477 "background": true
2478 }))
2479 .await
2480 .unwrap();
2481
2482 assert!(result.success);
2485 assert!(result.output.contains("task_id:"));
2486 assert!(result.output.contains("Background task started"));
2487
2488 tokio::time::sleep(Duration::from_millis(200)).await;
2490
2491 assert!(workspace.join("delegate_results").exists());
2493
2494 let _ = std::fs::remove_dir_all(workspace);
2495 }
2496
2497 #[tokio::test]
2498 async fn background_unknown_agent_rejected() {
2499 let workspace = std::env::temp_dir().join(format!(
2500 "construct_delegate_bg_unknown_{}",
2501 uuid::Uuid::new_v4()
2502 ));
2503 std::fs::create_dir_all(&workspace).unwrap();
2504
2505 let tool = DelegateTool::new(sample_agents(), None, test_security())
2506 .with_workspace_dir(workspace.clone());
2507 let result = tool
2508 .execute(json!({
2509 "agent": "nonexistent",
2510 "prompt": "test",
2511 "background": true
2512 }))
2513 .await
2514 .unwrap();
2515
2516 assert!(!result.success);
2517 assert!(result.error.unwrap().contains("Unknown agent"));
2518
2519 let _ = std::fs::remove_dir_all(workspace);
2520 }
2521
2522 #[tokio::test]
2523 async fn check_result_missing_task_id() {
2524 let workspace = std::env::temp_dir().join(format!(
2525 "construct_delegate_check_noid_{}",
2526 uuid::Uuid::new_v4()
2527 ));
2528 std::fs::create_dir_all(&workspace).unwrap();
2529
2530 let tool = DelegateTool::new(sample_agents(), None, test_security())
2531 .with_workspace_dir(workspace.clone());
2532 let result = tool.execute(json!({"action": "check_result"})).await;
2533
2534 assert!(result.is_err());
2535
2536 let _ = std::fs::remove_dir_all(workspace);
2537 }
2538
2539 #[tokio::test]
2540 async fn check_result_nonexistent_task() {
2541 let workspace = std::env::temp_dir().join(format!(
2542 "construct_delegate_check_miss_{}",
2543 uuid::Uuid::new_v4()
2544 ));
2545 std::fs::create_dir_all(&workspace).unwrap();
2546
2547 let tool = DelegateTool::new(sample_agents(), None, test_security())
2548 .with_workspace_dir(workspace.clone());
2549 let fake_uuid = uuid::Uuid::new_v4().to_string();
2551 let result = tool
2552 .execute(json!({
2553 "action": "check_result",
2554 "task_id": fake_uuid
2555 }))
2556 .await
2557 .unwrap();
2558
2559 assert!(!result.success);
2560 assert!(result.error.unwrap().contains("No result found"));
2561
2562 let _ = std::fs::remove_dir_all(workspace);
2563 }
2564
2565 #[tokio::test]
2566 async fn list_results_empty() {
2567 let workspace = std::env::temp_dir().join(format!(
2568 "construct_delegate_list_empty_{}",
2569 uuid::Uuid::new_v4()
2570 ));
2571 std::fs::create_dir_all(&workspace).unwrap();
2572
2573 let tool = DelegateTool::new(sample_agents(), None, test_security())
2574 .with_workspace_dir(workspace.clone());
2575 let result = tool
2576 .execute(json!({"action": "list_results"}))
2577 .await
2578 .unwrap();
2579
2580 assert!(result.success);
2581 assert!(result.output.contains("No background delegate results"));
2582
2583 let _ = std::fs::remove_dir_all(workspace);
2584 }
2585
2586 #[tokio::test]
2587 async fn parallel_empty_list_rejected() {
2588 let tool = DelegateTool::new(sample_agents(), None, test_security());
2589 let result = tool
2590 .execute(json!({
2591 "parallel": [],
2592 "prompt": "test"
2593 }))
2594 .await
2595 .unwrap();
2596
2597 assert!(!result.success);
2598 assert!(result.error.unwrap().contains("at least one agent"));
2599 }
2600
2601 #[tokio::test]
2602 async fn parallel_unknown_agent_rejected() {
2603 let tool = DelegateTool::new(sample_agents(), None, test_security());
2604 let result = tool
2605 .execute(json!({
2606 "parallel": ["researcher", "nonexistent"],
2607 "prompt": "test"
2608 }))
2609 .await
2610 .unwrap();
2611
2612 assert!(!result.success);
2613 assert!(result.error.unwrap().contains("Unknown agent"));
2614 }
2615
2616 #[tokio::test]
2617 async fn parallel_missing_prompt_rejected() {
2618 let tool = DelegateTool::new(sample_agents(), None, test_security());
2619 let result = tool
2620 .execute(json!({
2621 "parallel": ["researcher"]
2622 }))
2623 .await;
2624
2625 assert!(result.is_err());
2626 }
2627
2628 #[tokio::test]
2629 async fn unknown_action_rejected() {
2630 let tool = DelegateTool::new(sample_agents(), None, test_security());
2631 let result = tool
2632 .execute(json!({"action": "invalid_action"}))
2633 .await
2634 .unwrap();
2635
2636 assert!(!result.success);
2637 assert!(result.error.unwrap().contains("Unknown action"));
2638 }
2639
2640 #[tokio::test]
2641 async fn cancel_task_nonexistent() {
2642 let workspace = std::env::temp_dir().join(format!(
2643 "construct_delegate_cancel_miss_{}",
2644 uuid::Uuid::new_v4()
2645 ));
2646 std::fs::create_dir_all(&workspace).unwrap();
2647
2648 let tool = DelegateTool::new(sample_agents(), None, test_security())
2649 .with_workspace_dir(workspace.clone());
2650 let fake_uuid = uuid::Uuid::new_v4().to_string();
2652 let result = tool
2653 .execute(json!({
2654 "action": "cancel_task",
2655 "task_id": fake_uuid
2656 }))
2657 .await
2658 .unwrap();
2659
2660 assert!(!result.success);
2661 assert!(result.error.unwrap().contains("No task found"));
2662
2663 let _ = std::fs::remove_dir_all(workspace);
2664 }
2665
2666 #[test]
2667 fn cancellation_token_accessor() {
2668 let tool = DelegateTool::new(sample_agents(), None, test_security());
2669 let token = tool.cancellation_token();
2670 assert!(!token.is_cancelled());
2671
2672 tool.cancel_all_background_tasks();
2673 assert!(token.is_cancelled());
2674 }
2675
2676 #[test]
2677 fn with_cancellation_token_replaces_default() {
2678 let custom_token = CancellationToken::new();
2679 let tool = DelegateTool::new(sample_agents(), None, test_security())
2680 .with_cancellation_token(custom_token.clone());
2681
2682 assert!(!tool.cancellation_token().is_cancelled());
2683 custom_token.cancel();
2684 assert!(tool.cancellation_token().is_cancelled());
2685 }
2686
2687 #[tokio::test]
2688 async fn background_task_result_persisted_to_disk() {
2689 let workspace = std::env::temp_dir().join(format!(
2690 "construct_delegate_bg_persist_{}",
2691 uuid::Uuid::new_v4()
2692 ));
2693 std::fs::create_dir_all(&workspace).unwrap();
2694
2695 let tool = DelegateTool::new(sample_agents(), None, test_security())
2696 .with_workspace_dir(workspace.clone());
2697
2698 let result = tool
2699 .execute(json!({
2700 "agent": "researcher",
2701 "prompt": "persistence test",
2702 "background": true
2703 }))
2704 .await
2705 .unwrap();
2706
2707 assert!(result.success);
2708
2709 let task_id = result
2711 .output
2712 .lines()
2713 .find(|l| l.starts_with("task_id:"))
2714 .unwrap()
2715 .trim_start_matches("task_id: ")
2716 .trim();
2717
2718 tokio::time::sleep(Duration::from_millis(500)).await;
2720
2721 let result_path = workspace
2723 .join("delegate_results")
2724 .join(format!("{task_id}.json"));
2725 assert!(
2726 result_path.exists(),
2727 "Result file should exist at {result_path:?}"
2728 );
2729
2730 let content = std::fs::read_to_string(&result_path).unwrap();
2732 let bg_result: BackgroundDelegateResult = serde_json::from_str(&content).unwrap();
2733 assert_eq!(bg_result.task_id, task_id);
2734 assert_eq!(bg_result.agent, "researcher");
2735 assert!(
2737 bg_result.status == BackgroundTaskStatus::Completed
2738 || bg_result.status == BackgroundTaskStatus::Failed
2739 );
2740 assert!(bg_result.finished_at.is_some());
2741
2742 let _ = std::fs::remove_dir_all(workspace);
2743 }
2744
2745 #[tokio::test]
2746 async fn check_result_retrieves_persisted_background_result() {
2747 let workspace = std::env::temp_dir().join(format!(
2748 "construct_delegate_check_retrieve_{}",
2749 uuid::Uuid::new_v4()
2750 ));
2751 std::fs::create_dir_all(&workspace).unwrap();
2752
2753 let tool = DelegateTool::new(sample_agents(), None, test_security())
2754 .with_workspace_dir(workspace.clone());
2755
2756 let result = tool
2758 .execute(json!({
2759 "agent": "researcher",
2760 "prompt": "retrieval test",
2761 "background": true
2762 }))
2763 .await
2764 .unwrap();
2765
2766 let task_id = result
2767 .output
2768 .lines()
2769 .find(|l| l.starts_with("task_id:"))
2770 .unwrap()
2771 .trim_start_matches("task_id: ")
2772 .trim()
2773 .to_string();
2774
2775 tokio::time::sleep(Duration::from_millis(500)).await;
2777
2778 let check = tool
2780 .execute(json!({
2781 "action": "check_result",
2782 "task_id": task_id
2783 }))
2784 .await
2785 .unwrap();
2786
2787 assert!(check.output.contains(&task_id));
2789 assert!(check.output.contains("researcher"));
2790
2791 let _ = std::fs::remove_dir_all(workspace);
2792 }
2793
2794 #[tokio::test]
2795 async fn list_results_includes_background_tasks() {
2796 let workspace = std::env::temp_dir().join(format!(
2797 "construct_delegate_list_tasks_{}",
2798 uuid::Uuid::new_v4()
2799 ));
2800 std::fs::create_dir_all(&workspace).unwrap();
2801
2802 let tool = DelegateTool::new(sample_agents(), None, test_security())
2803 .with_workspace_dir(workspace.clone());
2804
2805 let result = tool
2807 .execute(json!({
2808 "agent": "researcher",
2809 "prompt": "list test",
2810 "background": true
2811 }))
2812 .await
2813 .unwrap();
2814 assert!(result.success);
2815
2816 tokio::time::sleep(Duration::from_millis(500)).await;
2818
2819 let list = tool
2821 .execute(json!({"action": "list_results"}))
2822 .await
2823 .unwrap();
2824
2825 assert!(list.success);
2826 assert!(list.output.contains("researcher"));
2827
2828 let _ = std::fs::remove_dir_all(workspace);
2829 }
2830
2831 #[tokio::test]
2832 async fn default_action_is_delegate() {
2833 let tool = DelegateTool::new(sample_agents(), None, test_security());
2835 let result = tool
2836 .execute(json!({"agent": "researcher", "prompt": "test"}))
2837 .await
2838 .unwrap();
2839 assert!(
2842 result.error.is_none()
2843 || !result
2844 .error
2845 .as_deref()
2846 .unwrap_or("")
2847 .contains("Unknown action")
2848 );
2849 }
2850
2851 #[tokio::test]
2852 async fn check_result_rejects_path_traversal() {
2853 let workspace = std::env::temp_dir().join(format!(
2854 "construct_delegate_traversal_check_{}",
2855 uuid::Uuid::new_v4()
2856 ));
2857 std::fs::create_dir_all(&workspace).unwrap();
2858
2859 let tool = DelegateTool::new(sample_agents(), None, test_security())
2860 .with_workspace_dir(workspace.clone());
2861 let result = tool
2862 .execute(json!({
2863 "action": "check_result",
2864 "task_id": "../../etc/passwd"
2865 }))
2866 .await
2867 .unwrap();
2868
2869 assert!(!result.success);
2870 assert!(result.error.unwrap().contains("Invalid task_id"));
2871
2872 let _ = std::fs::remove_dir_all(workspace);
2873 }
2874
2875 #[tokio::test]
2876 async fn cancel_task_rejects_path_traversal() {
2877 let workspace = std::env::temp_dir().join(format!(
2878 "construct_delegate_traversal_cancel_{}",
2879 uuid::Uuid::new_v4()
2880 ));
2881 std::fs::create_dir_all(&workspace).unwrap();
2882
2883 let tool = DelegateTool::new(sample_agents(), None, test_security())
2884 .with_workspace_dir(workspace.clone());
2885 let result = tool
2886 .execute(json!({
2887 "action": "cancel_task",
2888 "task_id": "../../../etc/shadow"
2889 }))
2890 .await
2891 .unwrap();
2892
2893 assert!(!result.success);
2894 assert!(result.error.unwrap().contains("Invalid task_id"));
2895
2896 let _ = std::fs::remove_dir_all(workspace);
2897 }
2898}