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 skill_effectiveness: None,
1027 identity_config: None,
1028 dispatcher_instructions: "",
1029 tool_descriptions: None,
1030 security_summary: None,
1031 autonomy_level: crate::security::AutonomyLevel::default(),
1032 operator_enabled: false,
1033 kumiho_enabled: false,
1034 };
1035
1036 let builder = SystemPromptBuilder::default()
1037 .add_section(Box::new(crate::agent::prompt::ToolsSection))
1038 .add_section(Box::new(crate::agent::prompt::SafetySection))
1039 .add_section(Box::new(crate::agent::prompt::SkillsSection))
1040 .add_section(Box::new(crate::agent::prompt::WorkspaceSection))
1041 .add_section(Box::new(crate::agent::prompt::DateTimeSection));
1042
1043 let mut enriched = builder.build(&ctx).unwrap_or_default();
1044
1045 if !shell_policy.is_empty() {
1046 enriched.push_str(&shell_policy);
1047 enriched.push_str("\n\n");
1048 }
1049
1050 if let Some(operator_prompt) = agent_config.system_prompt.as_ref() {
1052 enriched.push_str(operator_prompt);
1053 enriched.push('\n');
1054 }
1055
1056 let trimmed = enriched.trim().to_string();
1057 if trimmed.is_empty() {
1058 None
1059 } else {
1060 Some(trimmed)
1061 }
1062 }
1063
1064 async fn execute_agentic(
1065 &self,
1066 agent_name: &str,
1067 agent_config: &DelegateAgentConfig,
1068 provider: &dyn Provider,
1069 full_prompt: &str,
1070 temperature: f64,
1071 ) -> anyhow::Result<ToolResult> {
1072 if agent_config.allowed_tools.is_empty() {
1073 return Ok(ToolResult {
1074 success: false,
1075 output: String::new(),
1076 error: Some(format!(
1077 "Agent '{agent_name}' has agentic=true but allowed_tools is empty"
1078 )),
1079 });
1080 }
1081
1082 let allowed = agent_config
1083 .allowed_tools
1084 .iter()
1085 .map(|name| name.trim())
1086 .filter(|name| !name.is_empty())
1087 .collect::<std::collections::HashSet<_>>();
1088
1089 let sub_tools: Vec<Box<dyn Tool>> = {
1090 let parent_tools = self.parent_tools.read();
1091 parent_tools
1092 .iter()
1093 .filter(|tool| allowed.contains(tool.name()))
1094 .filter(|tool| tool.name() != "delegate")
1095 .map(|tool| Box::new(ToolArcRef::new(tool.clone())) as Box<dyn Tool>)
1096 .collect()
1097 };
1098
1099 if sub_tools.is_empty() {
1100 return Ok(ToolResult {
1101 success: false,
1102 output: String::new(),
1103 error: Some(format!(
1104 "Agent '{agent_name}' has no executable tools after filtering allowlist ({})",
1105 agent_config.allowed_tools.join(", ")
1106 )),
1107 });
1108 }
1109
1110 let enriched_system_prompt =
1112 self.build_enriched_system_prompt(agent_config, &sub_tools, &self.workspace_dir);
1113
1114 let mut history = Vec::new();
1115 if let Some(system_prompt) = enriched_system_prompt.as_ref() {
1116 history.push(ChatMessage::system(system_prompt.clone()));
1117 }
1118 history.push(ChatMessage::user(full_prompt.to_string()));
1119
1120 let noop_observer = NoopObserver;
1121
1122 let agentic_timeout_secs = agent_config
1123 .agentic_timeout_secs
1124 .unwrap_or(self.delegate_config.agentic_timeout_secs);
1125 let result = tokio::time::timeout(
1126 Duration::from_secs(agentic_timeout_secs),
1127 run_tool_call_loop(
1128 provider,
1129 &mut history,
1130 &sub_tools,
1131 &noop_observer,
1132 &agent_config.provider,
1133 &agent_config.model,
1134 temperature,
1135 true,
1136 None,
1137 "delegate",
1138 None,
1139 &self.multimodal_config,
1140 agent_config.max_iterations,
1141 None,
1142 None,
1143 None,
1144 &[],
1145 &[],
1146 None,
1147 None,
1148 &crate::config::PacingConfig::default(),
1149 0, 0, None, ),
1153 )
1154 .await;
1155
1156 match result {
1157 Ok(Ok(response)) => {
1158 let rendered = if response.trim().is_empty() {
1159 "[Empty response]".to_string()
1160 } else {
1161 response
1162 };
1163
1164 Ok(ToolResult {
1165 success: true,
1166 output: format!(
1167 "[Agent '{agent_name}' ({provider}/{model}, agentic)]\n{rendered}",
1168 provider = agent_config.provider,
1169 model = agent_config.model
1170 ),
1171 error: None,
1172 })
1173 }
1174 Ok(Err(e)) => Ok(ToolResult {
1175 success: false,
1176 output: String::new(),
1177 error: Some(format!("Agent '{agent_name}' failed: {e}")),
1178 }),
1179 Err(_) => Ok(ToolResult {
1180 success: false,
1181 output: String::new(),
1182 error: Some(format!(
1183 "Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
1184 )),
1185 }),
1186 }
1187 }
1188}
1189
1190struct ToolArcRef {
1191 inner: Arc<dyn Tool>,
1192}
1193
1194impl ToolArcRef {
1195 fn new(inner: Arc<dyn Tool>) -> Self {
1196 Self { inner }
1197 }
1198}
1199
1200#[async_trait]
1201impl Tool for ToolArcRef {
1202 fn name(&self) -> &str {
1203 self.inner.name()
1204 }
1205
1206 fn description(&self) -> &str {
1207 self.inner.description()
1208 }
1209
1210 fn parameters_schema(&self) -> serde_json::Value {
1211 self.inner.parameters_schema()
1212 }
1213
1214 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1215 self.inner.execute(args).await
1216 }
1217}
1218
1219struct NoopObserver;
1220
1221impl Observer for NoopObserver {
1222 fn record_event(&self, _event: &ObserverEvent) {}
1223
1224 fn record_metric(&self, _metric: &ObserverMetric) {}
1225
1226 fn name(&self) -> &str {
1227 "noop"
1228 }
1229
1230 fn as_any(&self) -> &dyn std::any::Any {
1231 self
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238 use crate::config::schema::{
1239 DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
1240 };
1241 use crate::providers::{ChatRequest, ChatResponse, ToolCall};
1242 use crate::security::{AutonomyLevel, SecurityPolicy};
1243 use anyhow::anyhow;
1244
1245 fn test_security() -> Arc<SecurityPolicy> {
1246 Arc::new(SecurityPolicy::default())
1247 }
1248
1249 fn sample_agents() -> HashMap<String, DelegateAgentConfig> {
1250 let mut agents = HashMap::new();
1251 agents.insert(
1252 "researcher".to_string(),
1253 DelegateAgentConfig {
1254 provider: "ollama".to_string(),
1255 model: "llama3".to_string(),
1256 system_prompt: Some("You are a research assistant.".to_string()),
1257 api_key: None,
1258 temperature: Some(0.3),
1259 max_depth: 3,
1260 agentic: false,
1261 allowed_tools: Vec::new(),
1262 max_iterations: 10,
1263 timeout_secs: None,
1264 agentic_timeout_secs: None,
1265 skills_directory: None,
1266 },
1267 );
1268 agents.insert(
1269 "coder".to_string(),
1270 DelegateAgentConfig {
1271 provider: "openrouter".to_string(),
1272 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1273 system_prompt: None,
1274 api_key: Some("delegate-test-credential".to_string()),
1275 temperature: None,
1276 max_depth: 2,
1277 agentic: false,
1278 allowed_tools: Vec::new(),
1279 max_iterations: 10,
1280 timeout_secs: None,
1281 agentic_timeout_secs: None,
1282 skills_directory: None,
1283 },
1284 );
1285 agents
1286 }
1287
1288 #[derive(Default)]
1289 struct EchoTool;
1290
1291 #[async_trait]
1292 impl Tool for EchoTool {
1293 fn name(&self) -> &str {
1294 "echo_tool"
1295 }
1296
1297 fn description(&self) -> &str {
1298 "Echoes the `value` argument."
1299 }
1300
1301 fn parameters_schema(&self) -> serde_json::Value {
1302 serde_json::json!({
1303 "type": "object",
1304 "properties": {
1305 "value": {"type": "string"}
1306 },
1307 "required": ["value"]
1308 })
1309 }
1310
1311 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
1312 let value = args
1313 .get("value")
1314 .and_then(serde_json::Value::as_str)
1315 .unwrap_or_default()
1316 .to_string();
1317 Ok(ToolResult {
1318 success: true,
1319 output: format!("echo:{value}"),
1320 error: None,
1321 })
1322 }
1323 }
1324
1325 struct OneToolThenFinalProvider;
1326
1327 #[async_trait]
1328 impl Provider for OneToolThenFinalProvider {
1329 async fn chat_with_system(
1330 &self,
1331 _system_prompt: Option<&str>,
1332 _message: &str,
1333 _model: &str,
1334 _temperature: f64,
1335 ) -> anyhow::Result<String> {
1336 Ok("unused".to_string())
1337 }
1338
1339 async fn chat(
1340 &self,
1341 request: ChatRequest<'_>,
1342 _model: &str,
1343 _temperature: f64,
1344 ) -> anyhow::Result<ChatResponse> {
1345 let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1346 if has_tool_message {
1347 Ok(ChatResponse {
1348 text: Some("done".to_string()),
1349 tool_calls: Vec::new(),
1350 usage: None,
1351 reasoning_content: None,
1352 })
1353 } else {
1354 Ok(ChatResponse {
1355 text: None,
1356 tool_calls: vec![ToolCall {
1357 id: "call_1".to_string(),
1358 name: "echo_tool".to_string(),
1359 arguments: "{\"value\":\"ping\"}".to_string(),
1360 }],
1361 usage: None,
1362 reasoning_content: None,
1363 })
1364 }
1365 }
1366 }
1367
1368 struct InfiniteToolCallProvider;
1369
1370 #[async_trait]
1371 impl Provider for InfiniteToolCallProvider {
1372 async fn chat_with_system(
1373 &self,
1374 _system_prompt: Option<&str>,
1375 _message: &str,
1376 _model: &str,
1377 _temperature: f64,
1378 ) -> anyhow::Result<String> {
1379 Ok("unused".to_string())
1380 }
1381
1382 async fn chat(
1383 &self,
1384 _request: ChatRequest<'_>,
1385 _model: &str,
1386 _temperature: f64,
1387 ) -> anyhow::Result<ChatResponse> {
1388 Ok(ChatResponse {
1389 text: None,
1390 tool_calls: vec![ToolCall {
1391 id: "loop".to_string(),
1392 name: "echo_tool".to_string(),
1393 arguments: "{\"value\":\"x\"}".to_string(),
1394 }],
1395 usage: None,
1396 reasoning_content: None,
1397 })
1398 }
1399 }
1400
1401 struct FailingProvider;
1402
1403 #[async_trait]
1404 impl Provider for FailingProvider {
1405 async fn chat_with_system(
1406 &self,
1407 _system_prompt: Option<&str>,
1408 _message: &str,
1409 _model: &str,
1410 _temperature: f64,
1411 ) -> anyhow::Result<String> {
1412 Ok("unused".to_string())
1413 }
1414
1415 async fn chat(
1416 &self,
1417 _request: ChatRequest<'_>,
1418 _model: &str,
1419 _temperature: f64,
1420 ) -> anyhow::Result<ChatResponse> {
1421 Err(anyhow!("provider boom"))
1422 }
1423 }
1424
1425 fn agentic_config(allowed_tools: Vec<String>, max_iterations: usize) -> DelegateAgentConfig {
1426 DelegateAgentConfig {
1427 provider: "openrouter".to_string(),
1428 model: "model-test".to_string(),
1429 system_prompt: Some("You are agentic.".to_string()),
1430 api_key: Some("delegate-test-credential".to_string()),
1431 temperature: Some(0.2),
1432 max_depth: 3,
1433 agentic: true,
1434 allowed_tools,
1435 max_iterations,
1436 timeout_secs: None,
1437 agentic_timeout_secs: None,
1438 skills_directory: None,
1439 }
1440 }
1441
1442 #[test]
1443 fn name_and_schema() {
1444 let tool = DelegateTool::new(sample_agents(), None, test_security());
1445 assert_eq!(tool.name(), "delegate");
1446 let schema = tool.parameters_schema();
1447 assert!(schema["properties"]["agent"].is_object());
1448 assert!(schema["properties"]["prompt"].is_object());
1449 assert!(schema["properties"]["context"].is_object());
1450 assert!(schema["properties"]["background"].is_object());
1451 assert!(schema["properties"]["parallel"].is_object());
1452 assert!(schema["properties"]["action"].is_object());
1453 assert!(schema["properties"]["task_id"].is_object());
1454 let required = schema["required"].as_array().unwrap();
1456 assert!(required.is_empty());
1457 assert_eq!(schema["additionalProperties"], json!(false));
1458 assert_eq!(schema["properties"]["agent"]["minLength"], json!(1));
1459 assert_eq!(schema["properties"]["prompt"]["minLength"], json!(1));
1460 }
1461
1462 #[test]
1463 fn description_not_empty() {
1464 let tool = DelegateTool::new(sample_agents(), None, test_security());
1465 assert!(!tool.description().is_empty());
1466 }
1467
1468 #[test]
1469 fn schema_lists_agent_names() {
1470 let tool = DelegateTool::new(sample_agents(), None, test_security());
1471 let schema = tool.parameters_schema();
1472 let desc = schema["properties"]["agent"]["description"]
1473 .as_str()
1474 .unwrap();
1475 assert!(desc.contains("researcher") || desc.contains("coder"));
1476 }
1477
1478 #[tokio::test]
1479 async fn missing_agent_param() {
1480 let tool = DelegateTool::new(sample_agents(), None, test_security());
1481 let result = tool.execute(json!({"prompt": "test"})).await;
1482 assert!(result.is_err());
1483 }
1484
1485 #[tokio::test]
1486 async fn missing_prompt_param() {
1487 let tool = DelegateTool::new(sample_agents(), None, test_security());
1488 let result = tool.execute(json!({"agent": "researcher"})).await;
1489 assert!(result.is_err());
1490 }
1491
1492 #[tokio::test]
1493 async fn unknown_agent_returns_error() {
1494 let tool = DelegateTool::new(sample_agents(), None, test_security());
1495 let result = tool
1496 .execute(json!({"agent": "nonexistent", "prompt": "test"}))
1497 .await
1498 .unwrap();
1499 assert!(!result.success);
1500 assert!(result.error.unwrap().contains("Unknown agent"));
1501 }
1502
1503 #[tokio::test]
1504 async fn depth_limit_enforced() {
1505 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
1506 let result = tool
1507 .execute(json!({"agent": "researcher", "prompt": "test"}))
1508 .await
1509 .unwrap();
1510 assert!(!result.success);
1511 assert!(result.error.unwrap().contains("depth limit"));
1512 }
1513
1514 #[tokio::test]
1515 async fn depth_limit_per_agent() {
1516 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
1518 let result = tool
1519 .execute(json!({"agent": "coder", "prompt": "test"}))
1520 .await
1521 .unwrap();
1522 assert!(!result.success);
1523 assert!(result.error.unwrap().contains("depth limit"));
1524 }
1525
1526 #[test]
1527 fn empty_agents_schema() {
1528 let tool = DelegateTool::new(HashMap::new(), None, test_security());
1529 let schema = tool.parameters_schema();
1530 let desc = schema["properties"]["agent"]["description"]
1531 .as_str()
1532 .unwrap();
1533 assert!(desc.contains("none configured"));
1534 }
1535
1536 #[tokio::test]
1537 async fn invalid_provider_returns_error() {
1538 let mut agents = HashMap::new();
1539 agents.insert(
1540 "broken".to_string(),
1541 DelegateAgentConfig {
1542 provider: "totally-invalid-provider".to_string(),
1543 model: "model".to_string(),
1544 system_prompt: None,
1545 api_key: None,
1546 temperature: None,
1547 max_depth: 3,
1548 agentic: false,
1549 allowed_tools: Vec::new(),
1550 max_iterations: 10,
1551 timeout_secs: None,
1552 agentic_timeout_secs: None,
1553 skills_directory: None,
1554 },
1555 );
1556 let tool = DelegateTool::new(agents, None, test_security());
1557 let result = tool
1558 .execute(json!({"agent": "broken", "prompt": "test"}))
1559 .await
1560 .unwrap();
1561 assert!(!result.success);
1562 assert!(result.error.unwrap().contains("Failed to create provider"));
1563 }
1564
1565 #[tokio::test]
1566 async fn blank_agent_rejected() {
1567 let tool = DelegateTool::new(sample_agents(), None, test_security());
1568 let result = tool
1569 .execute(json!({"agent": " ", "prompt": "test"}))
1570 .await
1571 .unwrap();
1572 assert!(!result.success);
1573 assert!(result.error.unwrap().contains("must not be empty"));
1574 }
1575
1576 #[tokio::test]
1577 async fn blank_prompt_rejected() {
1578 let tool = DelegateTool::new(sample_agents(), None, test_security());
1579 let result = tool
1580 .execute(json!({"agent": "researcher", "prompt": " \t "}))
1581 .await
1582 .unwrap();
1583 assert!(!result.success);
1584 assert!(result.error.unwrap().contains("must not be empty"));
1585 }
1586
1587 #[tokio::test]
1588 async fn whitespace_agent_name_trimmed_and_found() {
1589 let tool = DelegateTool::new(sample_agents(), None, test_security());
1590 let result = tool
1592 .execute(json!({"agent": " researcher ", "prompt": "test"}))
1593 .await
1594 .unwrap();
1595 assert!(
1598 result.error.is_none()
1599 || !result
1600 .error
1601 .as_deref()
1602 .unwrap_or("")
1603 .contains("Unknown agent")
1604 );
1605 }
1606
1607 #[tokio::test]
1608 async fn delegation_blocked_in_readonly_mode() {
1609 let readonly = Arc::new(SecurityPolicy {
1610 autonomy: AutonomyLevel::ReadOnly,
1611 ..SecurityPolicy::default()
1612 });
1613 let tool = DelegateTool::new(sample_agents(), None, readonly);
1614 let result = tool
1615 .execute(json!({"agent": "researcher", "prompt": "test"}))
1616 .await
1617 .unwrap();
1618 assert!(!result.success);
1619 assert!(
1620 result
1621 .error
1622 .as_deref()
1623 .unwrap_or("")
1624 .contains("read-only mode")
1625 );
1626 }
1627
1628 #[tokio::test]
1629 async fn delegation_blocked_when_rate_limited() {
1630 let limited = Arc::new(SecurityPolicy {
1631 max_actions_per_hour: 0,
1632 ..SecurityPolicy::default()
1633 });
1634 let tool = DelegateTool::new(sample_agents(), None, limited);
1635 let result = tool
1636 .execute(json!({"agent": "researcher", "prompt": "test"}))
1637 .await
1638 .unwrap();
1639 assert!(!result.success);
1640 assert!(
1641 result
1642 .error
1643 .as_deref()
1644 .unwrap_or("")
1645 .contains("Rate limit exceeded")
1646 );
1647 }
1648
1649 #[tokio::test]
1650 async fn delegate_context_is_prepended_to_prompt() {
1651 let mut agents = HashMap::new();
1652 agents.insert(
1653 "tester".to_string(),
1654 DelegateAgentConfig {
1655 provider: "invalid-for-test".to_string(),
1656 model: "test-model".to_string(),
1657 system_prompt: None,
1658 api_key: None,
1659 temperature: None,
1660 max_depth: 3,
1661 agentic: false,
1662 allowed_tools: Vec::new(),
1663 max_iterations: 10,
1664 timeout_secs: None,
1665 agentic_timeout_secs: None,
1666 skills_directory: None,
1667 },
1668 );
1669 let tool = DelegateTool::new(agents, None, test_security());
1670 let result = tool
1671 .execute(json!({
1672 "agent": "tester",
1673 "prompt": "do something",
1674 "context": "some context data"
1675 }))
1676 .await
1677 .unwrap();
1678
1679 assert!(!result.success);
1680 assert!(
1681 result
1682 .error
1683 .as_deref()
1684 .unwrap_or("")
1685 .contains("Failed to create provider")
1686 );
1687 }
1688
1689 #[tokio::test]
1690 async fn delegate_empty_context_omits_prefix() {
1691 let mut agents = HashMap::new();
1692 agents.insert(
1693 "tester".to_string(),
1694 DelegateAgentConfig {
1695 provider: "invalid-for-test".to_string(),
1696 model: "test-model".to_string(),
1697 system_prompt: None,
1698 api_key: None,
1699 temperature: None,
1700 max_depth: 3,
1701 agentic: false,
1702 allowed_tools: Vec::new(),
1703 max_iterations: 10,
1704 timeout_secs: None,
1705 agentic_timeout_secs: None,
1706 skills_directory: None,
1707 },
1708 );
1709 let tool = DelegateTool::new(agents, None, test_security());
1710 let result = tool
1711 .execute(json!({
1712 "agent": "tester",
1713 "prompt": "do something",
1714 "context": ""
1715 }))
1716 .await
1717 .unwrap();
1718
1719 assert!(!result.success);
1720 assert!(
1721 result
1722 .error
1723 .as_deref()
1724 .unwrap_or("")
1725 .contains("Failed to create provider")
1726 );
1727 }
1728
1729 #[test]
1730 fn delegate_depth_construction() {
1731 let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
1732 assert_eq!(tool.depth, 5);
1733 }
1734
1735 #[tokio::test]
1736 async fn delegate_no_agents_configured() {
1737 let tool = DelegateTool::new(HashMap::new(), None, test_security());
1738 let result = tool
1739 .execute(json!({"agent": "any", "prompt": "test"}))
1740 .await
1741 .unwrap();
1742 assert!(!result.success);
1743 assert!(result.error.unwrap().contains("none configured"));
1744 }
1745
1746 #[tokio::test]
1747 async fn agentic_mode_rejects_empty_allowed_tools() {
1748 let mut agents = HashMap::new();
1749 agents.insert("agentic".to_string(), agentic_config(Vec::new(), 10));
1750
1751 let tool = DelegateTool::new(agents, None, test_security());
1752 let result = tool
1753 .execute(json!({"agent": "agentic", "prompt": "test"}))
1754 .await
1755 .unwrap();
1756
1757 assert!(!result.success);
1758 assert!(
1759 result
1760 .error
1761 .as_deref()
1762 .unwrap_or("")
1763 .contains("allowed_tools is empty")
1764 );
1765 }
1766
1767 #[tokio::test]
1768 async fn agentic_mode_rejects_unmatched_allowed_tools() {
1769 let mut agents = HashMap::new();
1770 agents.insert(
1771 "agentic".to_string(),
1772 agentic_config(vec!["missing_tool".to_string()], 10),
1773 );
1774
1775 let tool = DelegateTool::new(agents, None, test_security())
1776 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1777 let result = tool
1778 .execute(json!({"agent": "agentic", "prompt": "test"}))
1779 .await
1780 .unwrap();
1781
1782 assert!(!result.success);
1783 assert!(
1784 result
1785 .error
1786 .as_deref()
1787 .unwrap_or("")
1788 .contains("no executable tools")
1789 );
1790 }
1791
1792 #[tokio::test]
1793 async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
1794 let config = agentic_config(vec!["echo_tool".to_string()], 10);
1795 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1796 Arc::new(RwLock::new(vec![
1797 Arc::new(EchoTool),
1798 Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
1799 ])),
1800 );
1801
1802 let provider = OneToolThenFinalProvider;
1803 let result = tool
1804 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1805 .await
1806 .unwrap();
1807
1808 assert!(result.success);
1809 assert!(result.output.contains("(openrouter/model-test, agentic)"));
1810 assert!(result.output.contains("done"));
1811 }
1812
1813 #[tokio::test]
1814 async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
1815 let config = agentic_config(vec!["delegate".to_string()], 10);
1816 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
1817 Arc::new(RwLock::new(vec![Arc::new(DelegateTool::new(
1818 HashMap::new(),
1819 None,
1820 test_security(),
1821 ))])),
1822 );
1823
1824 let provider = OneToolThenFinalProvider;
1825 let result = tool
1826 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1827 .await
1828 .unwrap();
1829
1830 assert!(!result.success);
1831 assert!(
1832 result
1833 .error
1834 .as_deref()
1835 .unwrap_or("")
1836 .contains("no executable tools")
1837 );
1838 }
1839
1840 #[tokio::test]
1841 async fn execute_agentic_respects_max_iterations() {
1842 let config = agentic_config(vec!["echo_tool".to_string()], 2);
1843 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1844 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1845
1846 let provider = InfiniteToolCallProvider;
1847 let result = tool
1848 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1849 .await
1850 .unwrap();
1851
1852 assert!(!result.success);
1853 assert!(
1854 result
1855 .error
1856 .as_deref()
1857 .unwrap_or("")
1858 .contains("maximum tool iterations (2)")
1859 );
1860 }
1861
1862 #[tokio::test]
1863 async fn execute_agentic_propagates_provider_errors() {
1864 let config = agentic_config(vec!["echo_tool".to_string()], 10);
1865 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1866 .with_parent_tools(Arc::new(RwLock::new(vec![Arc::new(EchoTool)])));
1867
1868 let provider = FailingProvider;
1869 let result = tool
1870 .execute_agentic("agentic", &config, &provider, "run", 0.2)
1871 .await
1872 .unwrap();
1873
1874 assert!(!result.success);
1875 assert!(
1876 result
1877 .error
1878 .as_deref()
1879 .unwrap_or("")
1880 .contains("provider boom")
1881 );
1882 }
1883
1884 #[derive(Default)]
1887 struct FakeMcpTool;
1888
1889 #[async_trait]
1890 impl Tool for FakeMcpTool {
1891 fn name(&self) -> &str {
1892 "mcp_fake"
1893 }
1894
1895 fn description(&self) -> &str {
1896 "Fake MCP tool for testing."
1897 }
1898
1899 fn parameters_schema(&self) -> serde_json::Value {
1900 serde_json::json!({"type": "object", "properties": {}})
1901 }
1902
1903 async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
1904 Ok(ToolResult {
1905 success: true,
1906 output: "mcp_fake_output".into(),
1907 error: None,
1908 })
1909 }
1910 }
1911
1912 struct McpToolThenFinalProvider;
1913
1914 #[async_trait]
1915 impl Provider for McpToolThenFinalProvider {
1916 async fn chat_with_system(
1917 &self,
1918 _system_prompt: Option<&str>,
1919 _message: &str,
1920 _model: &str,
1921 _temperature: f64,
1922 ) -> anyhow::Result<String> {
1923 Ok("unused".to_string())
1924 }
1925
1926 async fn chat(
1927 &self,
1928 request: ChatRequest<'_>,
1929 _model: &str,
1930 _temperature: f64,
1931 ) -> anyhow::Result<ChatResponse> {
1932 let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
1933 if has_tool_message {
1934 Ok(ChatResponse {
1935 text: Some("mcp done".to_string()),
1936 tool_calls: Vec::new(),
1937 usage: None,
1938 reasoning_content: None,
1939 })
1940 } else {
1941 Ok(ChatResponse {
1942 text: None,
1943 tool_calls: vec![ToolCall {
1944 id: "call_mcp".to_string(),
1945 name: "mcp_fake".to_string(),
1946 arguments: "{}".to_string(),
1947 }],
1948 usage: None,
1949 reasoning_content: None,
1950 })
1951 }
1952 }
1953 }
1954
1955 #[tokio::test]
1956 async fn mcp_tools_included_in_subagent_tool_list() {
1957 let config = agentic_config(vec!["mcp_fake".to_string()], 10);
1959 let tool = DelegateTool::new(HashMap::new(), None, test_security())
1960 .with_parent_tools(Arc::new(RwLock::new(Vec::new())));
1961
1962 let handle = tool.parent_tools_handle();
1964 handle.write().push(Arc::new(FakeMcpTool));
1965
1966 let provider = McpToolThenFinalProvider;
1967 let result = tool
1968 .execute_agentic("agentic", &config, &provider, "run mcp", 0.2)
1969 .await
1970 .unwrap();
1971
1972 assert!(result.success, "Expected success, got: {:?}", result.error);
1973 assert!(
1974 result.output.contains("mcp done"),
1975 "Expected output containing 'mcp done', got: {}",
1976 result.output
1977 );
1978 }
1979
1980 #[test]
1981 fn enriched_prompt_includes_tools_workspace_datetime() {
1982 let config = DelegateAgentConfig {
1983 provider: "openrouter".to_string(),
1984 model: "test-model".to_string(),
1985 system_prompt: Some("You are a code reviewer.".to_string()),
1986 api_key: None,
1987 temperature: None,
1988 max_depth: 3,
1989 agentic: true,
1990 allowed_tools: vec!["echo_tool".to_string()],
1991 max_iterations: 10,
1992 timeout_secs: None,
1993 agentic_timeout_secs: None,
1994 skills_directory: None,
1995 };
1996
1997 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
1998 let workspace = std::env::temp_dir().join(format!(
1999 "construct_delegate_enrich_test_{}",
2000 uuid::Uuid::new_v4()
2001 ));
2002 std::fs::create_dir_all(&workspace).unwrap();
2003
2004 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2005 .with_workspace_dir(workspace.clone());
2006
2007 let prompt = tool
2008 .build_enriched_system_prompt(&config, &tools, &workspace)
2009 .unwrap();
2010
2011 assert!(prompt.contains("## Tools"), "should contain tools section");
2012 assert!(prompt.contains("echo_tool"), "should list allowed tools");
2013 assert!(
2014 prompt.contains("## Workspace"),
2015 "should contain workspace section"
2016 );
2017 assert!(
2018 prompt.contains(&workspace.display().to_string()),
2019 "should contain workspace path"
2020 );
2021 assert!(
2022 prompt.contains("## CRITICAL CONTEXT: CURRENT DATE & TIME"),
2023 "should contain datetime section"
2024 );
2025 assert!(
2026 prompt.contains("You are a code reviewer."),
2027 "should append operator system_prompt"
2028 );
2029
2030 let _ = std::fs::remove_dir_all(workspace);
2031 }
2032
2033 #[test]
2034 fn enriched_prompt_includes_shell_policy_when_shell_present() {
2035 let config = DelegateAgentConfig {
2036 provider: "openrouter".to_string(),
2037 model: "test-model".to_string(),
2038 system_prompt: None,
2039 api_key: None,
2040 temperature: None,
2041 max_depth: 3,
2042 agentic: true,
2043 allowed_tools: vec!["shell".to_string()],
2044 max_iterations: 10,
2045 timeout_secs: None,
2046 agentic_timeout_secs: None,
2047 skills_directory: None,
2048 };
2049
2050 struct MockShellTool;
2051 #[async_trait]
2052 impl Tool for MockShellTool {
2053 fn name(&self) -> &str {
2054 "shell"
2055 }
2056 fn description(&self) -> &str {
2057 "Execute shell commands"
2058 }
2059 fn parameters_schema(&self) -> serde_json::Value {
2060 json!({"type": "object"})
2061 }
2062 async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
2063 Ok(ToolResult {
2064 success: true,
2065 output: String::new(),
2066 error: None,
2067 })
2068 }
2069 }
2070
2071 let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockShellTool)];
2072 let workspace = std::env::temp_dir();
2073
2074 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2075 .with_workspace_dir(workspace.to_path_buf());
2076
2077 let prompt = tool
2078 .build_enriched_system_prompt(&config, &tools, &workspace)
2079 .unwrap();
2080
2081 assert!(
2082 prompt.contains("## Shell Policy"),
2083 "should contain shell policy when shell tool is present"
2084 );
2085 }
2086
2087 #[test]
2088 fn parent_tools_handle_returns_shared_reference() {
2089 let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
2090 Arc::new(RwLock::new(vec![Arc::new(EchoTool) as Arc<dyn Tool>])),
2091 );
2092
2093 let handle = tool.parent_tools_handle();
2094 assert_eq!(handle.read().len(), 1);
2095
2096 handle.write().push(Arc::new(FakeMcpTool));
2098 assert_eq!(handle.read().len(), 2);
2099 }
2100
2101 #[test]
2104 fn default_timeout_values_used_when_config_unset() {
2105 let config = DelegateAgentConfig {
2106 provider: "ollama".to_string(),
2107 model: "llama3".to_string(),
2108 system_prompt: None,
2109 api_key: None,
2110 temperature: None,
2111 max_depth: 3,
2112 agentic: false,
2113 allowed_tools: Vec::new(),
2114 max_iterations: 10,
2115 timeout_secs: None,
2116 agentic_timeout_secs: None,
2117 skills_directory: None,
2118 };
2119 assert_eq!(
2120 config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2121 120
2122 );
2123 assert_eq!(
2124 config
2125 .agentic_timeout_secs
2126 .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2127 300
2128 );
2129 }
2130
2131 #[test]
2132 fn enriched_prompt_omits_shell_policy_without_shell_tool() {
2133 let config = DelegateAgentConfig {
2134 provider: "openrouter".to_string(),
2135 model: "test-model".to_string(),
2136 system_prompt: None,
2137 api_key: None,
2138 temperature: None,
2139 max_depth: 3,
2140 agentic: true,
2141 allowed_tools: vec!["echo_tool".to_string()],
2142 max_iterations: 10,
2143 timeout_secs: None,
2144 agentic_timeout_secs: None,
2145 skills_directory: None,
2146 };
2147
2148 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2149 let workspace = std::env::temp_dir();
2150
2151 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2152 .with_workspace_dir(workspace.to_path_buf());
2153
2154 let prompt = tool
2155 .build_enriched_system_prompt(&config, &tools, &workspace)
2156 .unwrap();
2157
2158 assert!(
2159 !prompt.contains("## Shell Policy"),
2160 "should not contain shell policy when shell tool is absent"
2161 );
2162 }
2163
2164 #[test]
2165 fn custom_timeout_values_are_respected() {
2166 let config = DelegateAgentConfig {
2167 provider: "ollama".to_string(),
2168 model: "llama3".to_string(),
2169 system_prompt: None,
2170 api_key: None,
2171 temperature: None,
2172 max_depth: 3,
2173 agentic: false,
2174 allowed_tools: Vec::new(),
2175 max_iterations: 10,
2176 timeout_secs: Some(60),
2177 agentic_timeout_secs: Some(600),
2178 skills_directory: None,
2179 };
2180 assert_eq!(
2181 config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
2182 60
2183 );
2184 assert_eq!(
2185 config
2186 .agentic_timeout_secs
2187 .unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
2188 600
2189 );
2190 }
2191
2192 #[test]
2193 fn timeout_deserialization_defaults_to_none() {
2194 let toml_str = r#"
2195 provider = "ollama"
2196 model = "llama3"
2197 "#;
2198 let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2199 assert!(config.timeout_secs.is_none());
2200 assert!(config.agentic_timeout_secs.is_none());
2201 }
2202
2203 #[test]
2204 fn timeout_deserialization_with_custom_values() {
2205 let toml_str = r#"
2206 provider = "ollama"
2207 model = "llama3"
2208 timeout_secs = 45
2209 agentic_timeout_secs = 900
2210 "#;
2211 let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
2212 assert_eq!(config.timeout_secs, Some(45));
2213 assert_eq!(config.agentic_timeout_secs, Some(900));
2214 }
2215
2216 #[test]
2217 fn config_validation_rejects_zero_timeout() {
2218 let mut config = crate::config::Config::default();
2219 config.agents.insert(
2220 "bad".into(),
2221 DelegateAgentConfig {
2222 provider: "ollama".into(),
2223 model: "llama3".into(),
2224 system_prompt: None,
2225 api_key: None,
2226 temperature: None,
2227 max_depth: 3,
2228 agentic: false,
2229 allowed_tools: Vec::new(),
2230 max_iterations: 10,
2231 timeout_secs: Some(0),
2232 agentic_timeout_secs: None,
2233 skills_directory: None,
2234 },
2235 );
2236 let err = config.validate().unwrap_err();
2237 assert!(
2238 format!("{err}").contains("timeout_secs must be greater than 0"),
2239 "unexpected error: {err}"
2240 );
2241 }
2242
2243 #[test]
2244 fn config_validation_rejects_zero_agentic_timeout() {
2245 let mut config = crate::config::Config::default();
2246 config.agents.insert(
2247 "bad".into(),
2248 DelegateAgentConfig {
2249 provider: "ollama".into(),
2250 model: "llama3".into(),
2251 system_prompt: None,
2252 api_key: None,
2253 temperature: None,
2254 max_depth: 3,
2255 agentic: false,
2256 allowed_tools: Vec::new(),
2257 max_iterations: 10,
2258 timeout_secs: None,
2259 agentic_timeout_secs: Some(0),
2260 skills_directory: None,
2261 },
2262 );
2263 let err = config.validate().unwrap_err();
2264 assert!(
2265 format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
2266 "unexpected error: {err}"
2267 );
2268 }
2269
2270 #[test]
2271 fn config_validation_rejects_excessive_timeout() {
2272 let mut config = crate::config::Config::default();
2273 config.agents.insert(
2274 "bad".into(),
2275 DelegateAgentConfig {
2276 provider: "ollama".into(),
2277 model: "llama3".into(),
2278 system_prompt: None,
2279 api_key: None,
2280 temperature: None,
2281 max_depth: 3,
2282 agentic: false,
2283 allowed_tools: Vec::new(),
2284 max_iterations: 10,
2285 timeout_secs: Some(7200),
2286 agentic_timeout_secs: None,
2287 skills_directory: None,
2288 },
2289 );
2290 let err = config.validate().unwrap_err();
2291 assert!(
2292 format!("{err}").contains("exceeds max 3600"),
2293 "unexpected error: {err}"
2294 );
2295 }
2296
2297 #[test]
2298 fn config_validation_rejects_excessive_agentic_timeout() {
2299 let mut config = crate::config::Config::default();
2300 config.agents.insert(
2301 "bad".into(),
2302 DelegateAgentConfig {
2303 provider: "ollama".into(),
2304 model: "llama3".into(),
2305 system_prompt: None,
2306 api_key: None,
2307 temperature: None,
2308 max_depth: 3,
2309 agentic: false,
2310 allowed_tools: Vec::new(),
2311 max_iterations: 10,
2312 timeout_secs: None,
2313 agentic_timeout_secs: Some(5000),
2314 skills_directory: None,
2315 },
2316 );
2317 let err = config.validate().unwrap_err();
2318 assert!(
2319 format!("{err}").contains("exceeds max 3600"),
2320 "unexpected error: {err}"
2321 );
2322 }
2323
2324 #[test]
2325 fn config_validation_accepts_max_boundary_timeout() {
2326 let mut config = crate::config::Config::default();
2327 config.agents.insert(
2328 "ok".into(),
2329 DelegateAgentConfig {
2330 provider: "ollama".into(),
2331 model: "llama3".into(),
2332 system_prompt: None,
2333 api_key: None,
2334 temperature: None,
2335 max_depth: 3,
2336 agentic: false,
2337 allowed_tools: Vec::new(),
2338 max_iterations: 10,
2339 timeout_secs: Some(3600),
2340 agentic_timeout_secs: Some(3600),
2341 skills_directory: None,
2342 },
2343 );
2344 assert!(config.validate().is_ok());
2345 }
2346
2347 #[test]
2348 fn config_validation_accepts_none_timeouts() {
2349 let mut config = crate::config::Config::default();
2350 config.agents.insert(
2351 "ok".into(),
2352 DelegateAgentConfig {
2353 provider: "ollama".into(),
2354 model: "llama3".into(),
2355 system_prompt: None,
2356 api_key: None,
2357 temperature: None,
2358 max_depth: 3,
2359 agentic: false,
2360 allowed_tools: Vec::new(),
2361 max_iterations: 10,
2362 timeout_secs: None,
2363 agentic_timeout_secs: None,
2364 skills_directory: None,
2365 },
2366 );
2367 assert!(config.validate().is_ok());
2368 }
2369
2370 #[test]
2371 fn enriched_prompt_loads_skills_from_scoped_directory() {
2372 let workspace = std::env::temp_dir().join(format!(
2373 "construct_delegate_skills_test_{}",
2374 uuid::Uuid::new_v4()
2375 ));
2376 let scoped_skills_dir = workspace.join("skills/code-review");
2377 std::fs::create_dir_all(scoped_skills_dir.join("lint-check")).unwrap();
2378 std::fs::write(
2379 scoped_skills_dir.join("lint-check/SKILL.toml"),
2380 "[skill]\nname = \"lint-check\"\ndescription = \"Run lint checks\"\nversion = \"1.0.0\"\n",
2381 )
2382 .unwrap();
2383
2384 let config = DelegateAgentConfig {
2385 provider: "openrouter".to_string(),
2386 model: "test-model".to_string(),
2387 system_prompt: None,
2388 api_key: None,
2389 temperature: None,
2390 max_depth: 3,
2391 agentic: true,
2392 allowed_tools: vec!["echo_tool".to_string()],
2393 max_iterations: 10,
2394 timeout_secs: None,
2395 agentic_timeout_secs: None,
2396 skills_directory: Some("skills/code-review".to_string()),
2397 };
2398
2399 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2400
2401 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2402 .with_workspace_dir(workspace.clone());
2403
2404 let prompt = tool
2405 .build_enriched_system_prompt(&config, &tools, &workspace)
2406 .unwrap();
2407
2408 assert!(
2409 prompt.contains("lint-check"),
2410 "should contain skills from scoped directory"
2411 );
2412
2413 let _ = std::fs::remove_dir_all(workspace);
2414 }
2415
2416 #[test]
2417 fn enriched_prompt_falls_back_to_default_skills_dir() {
2418 let workspace = std::env::temp_dir().join(format!(
2419 "construct_delegate_fallback_test_{}",
2420 uuid::Uuid::new_v4()
2421 ));
2422 let default_skills_dir = workspace.join("skills");
2423 std::fs::create_dir_all(default_skills_dir.join("deploy")).unwrap();
2424 std::fs::write(
2425 default_skills_dir.join("deploy/SKILL.toml"),
2426 "[skill]\nname = \"deploy\"\ndescription = \"Deploy safely\"\nversion = \"1.0.0\"\n",
2427 )
2428 .unwrap();
2429
2430 let config = DelegateAgentConfig {
2431 provider: "openrouter".to_string(),
2432 model: "test-model".to_string(),
2433 system_prompt: None,
2434 api_key: None,
2435 temperature: None,
2436 max_depth: 3,
2437 agentic: true,
2438 allowed_tools: vec!["echo_tool".to_string()],
2439 max_iterations: 10,
2440 timeout_secs: None,
2441 agentic_timeout_secs: None,
2442 skills_directory: None,
2443 };
2444
2445 let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
2446
2447 let tool = DelegateTool::new(HashMap::new(), None, test_security())
2448 .with_workspace_dir(workspace.clone());
2449
2450 let prompt = tool
2451 .build_enriched_system_prompt(&config, &tools, &workspace)
2452 .unwrap();
2453
2454 assert!(
2455 prompt.contains("deploy"),
2456 "should contain skills from default workspace skills/ directory"
2457 );
2458
2459 let _ = std::fs::remove_dir_all(workspace);
2460 }
2461
2462 #[tokio::test]
2465 async fn background_delegation_returns_task_id() {
2466 let workspace = std::env::temp_dir().join(format!(
2467 "construct_delegate_bg_test_{}",
2468 uuid::Uuid::new_v4()
2469 ));
2470 std::fs::create_dir_all(&workspace).unwrap();
2471
2472 let tool = DelegateTool::new(sample_agents(), None, test_security())
2473 .with_workspace_dir(workspace.clone());
2474 let result = tool
2475 .execute(json!({
2476 "agent": "researcher",
2477 "prompt": "test background",
2478 "background": true
2479 }))
2480 .await
2481 .unwrap();
2482
2483 assert!(result.success);
2486 assert!(result.output.contains("task_id:"));
2487 assert!(result.output.contains("Background task started"));
2488
2489 tokio::time::sleep(Duration::from_millis(200)).await;
2491
2492 assert!(workspace.join("delegate_results").exists());
2494
2495 let _ = std::fs::remove_dir_all(workspace);
2496 }
2497
2498 #[tokio::test]
2499 async fn background_unknown_agent_rejected() {
2500 let workspace = std::env::temp_dir().join(format!(
2501 "construct_delegate_bg_unknown_{}",
2502 uuid::Uuid::new_v4()
2503 ));
2504 std::fs::create_dir_all(&workspace).unwrap();
2505
2506 let tool = DelegateTool::new(sample_agents(), None, test_security())
2507 .with_workspace_dir(workspace.clone());
2508 let result = tool
2509 .execute(json!({
2510 "agent": "nonexistent",
2511 "prompt": "test",
2512 "background": true
2513 }))
2514 .await
2515 .unwrap();
2516
2517 assert!(!result.success);
2518 assert!(result.error.unwrap().contains("Unknown agent"));
2519
2520 let _ = std::fs::remove_dir_all(workspace);
2521 }
2522
2523 #[tokio::test]
2524 async fn check_result_missing_task_id() {
2525 let workspace = std::env::temp_dir().join(format!(
2526 "construct_delegate_check_noid_{}",
2527 uuid::Uuid::new_v4()
2528 ));
2529 std::fs::create_dir_all(&workspace).unwrap();
2530
2531 let tool = DelegateTool::new(sample_agents(), None, test_security())
2532 .with_workspace_dir(workspace.clone());
2533 let result = tool.execute(json!({"action": "check_result"})).await;
2534
2535 assert!(result.is_err());
2536
2537 let _ = std::fs::remove_dir_all(workspace);
2538 }
2539
2540 #[tokio::test]
2541 async fn check_result_nonexistent_task() {
2542 let workspace = std::env::temp_dir().join(format!(
2543 "construct_delegate_check_miss_{}",
2544 uuid::Uuid::new_v4()
2545 ));
2546 std::fs::create_dir_all(&workspace).unwrap();
2547
2548 let tool = DelegateTool::new(sample_agents(), None, test_security())
2549 .with_workspace_dir(workspace.clone());
2550 let fake_uuid = uuid::Uuid::new_v4().to_string();
2552 let result = tool
2553 .execute(json!({
2554 "action": "check_result",
2555 "task_id": fake_uuid
2556 }))
2557 .await
2558 .unwrap();
2559
2560 assert!(!result.success);
2561 assert!(result.error.unwrap().contains("No result found"));
2562
2563 let _ = std::fs::remove_dir_all(workspace);
2564 }
2565
2566 #[tokio::test]
2567 async fn list_results_empty() {
2568 let workspace = std::env::temp_dir().join(format!(
2569 "construct_delegate_list_empty_{}",
2570 uuid::Uuid::new_v4()
2571 ));
2572 std::fs::create_dir_all(&workspace).unwrap();
2573
2574 let tool = DelegateTool::new(sample_agents(), None, test_security())
2575 .with_workspace_dir(workspace.clone());
2576 let result = tool
2577 .execute(json!({"action": "list_results"}))
2578 .await
2579 .unwrap();
2580
2581 assert!(result.success);
2582 assert!(result.output.contains("No background delegate results"));
2583
2584 let _ = std::fs::remove_dir_all(workspace);
2585 }
2586
2587 #[tokio::test]
2588 async fn parallel_empty_list_rejected() {
2589 let tool = DelegateTool::new(sample_agents(), None, test_security());
2590 let result = tool
2591 .execute(json!({
2592 "parallel": [],
2593 "prompt": "test"
2594 }))
2595 .await
2596 .unwrap();
2597
2598 assert!(!result.success);
2599 assert!(result.error.unwrap().contains("at least one agent"));
2600 }
2601
2602 #[tokio::test]
2603 async fn parallel_unknown_agent_rejected() {
2604 let tool = DelegateTool::new(sample_agents(), None, test_security());
2605 let result = tool
2606 .execute(json!({
2607 "parallel": ["researcher", "nonexistent"],
2608 "prompt": "test"
2609 }))
2610 .await
2611 .unwrap();
2612
2613 assert!(!result.success);
2614 assert!(result.error.unwrap().contains("Unknown agent"));
2615 }
2616
2617 #[tokio::test]
2618 async fn parallel_missing_prompt_rejected() {
2619 let tool = DelegateTool::new(sample_agents(), None, test_security());
2620 let result = tool
2621 .execute(json!({
2622 "parallel": ["researcher"]
2623 }))
2624 .await;
2625
2626 assert!(result.is_err());
2627 }
2628
2629 #[tokio::test]
2630 async fn unknown_action_rejected() {
2631 let tool = DelegateTool::new(sample_agents(), None, test_security());
2632 let result = tool
2633 .execute(json!({"action": "invalid_action"}))
2634 .await
2635 .unwrap();
2636
2637 assert!(!result.success);
2638 assert!(result.error.unwrap().contains("Unknown action"));
2639 }
2640
2641 #[tokio::test]
2642 async fn cancel_task_nonexistent() {
2643 let workspace = std::env::temp_dir().join(format!(
2644 "construct_delegate_cancel_miss_{}",
2645 uuid::Uuid::new_v4()
2646 ));
2647 std::fs::create_dir_all(&workspace).unwrap();
2648
2649 let tool = DelegateTool::new(sample_agents(), None, test_security())
2650 .with_workspace_dir(workspace.clone());
2651 let fake_uuid = uuid::Uuid::new_v4().to_string();
2653 let result = tool
2654 .execute(json!({
2655 "action": "cancel_task",
2656 "task_id": fake_uuid
2657 }))
2658 .await
2659 .unwrap();
2660
2661 assert!(!result.success);
2662 assert!(result.error.unwrap().contains("No task found"));
2663
2664 let _ = std::fs::remove_dir_all(workspace);
2665 }
2666
2667 #[test]
2668 fn cancellation_token_accessor() {
2669 let tool = DelegateTool::new(sample_agents(), None, test_security());
2670 let token = tool.cancellation_token();
2671 assert!(!token.is_cancelled());
2672
2673 tool.cancel_all_background_tasks();
2674 assert!(token.is_cancelled());
2675 }
2676
2677 #[test]
2678 fn with_cancellation_token_replaces_default() {
2679 let custom_token = CancellationToken::new();
2680 let tool = DelegateTool::new(sample_agents(), None, test_security())
2681 .with_cancellation_token(custom_token.clone());
2682
2683 assert!(!tool.cancellation_token().is_cancelled());
2684 custom_token.cancel();
2685 assert!(tool.cancellation_token().is_cancelled());
2686 }
2687
2688 #[tokio::test]
2689 async fn background_task_result_persisted_to_disk() {
2690 let workspace = std::env::temp_dir().join(format!(
2691 "construct_delegate_bg_persist_{}",
2692 uuid::Uuid::new_v4()
2693 ));
2694 std::fs::create_dir_all(&workspace).unwrap();
2695
2696 let tool = DelegateTool::new(sample_agents(), None, test_security())
2697 .with_workspace_dir(workspace.clone());
2698
2699 let result = tool
2700 .execute(json!({
2701 "agent": "researcher",
2702 "prompt": "persistence test",
2703 "background": true
2704 }))
2705 .await
2706 .unwrap();
2707
2708 assert!(result.success);
2709
2710 let task_id = result
2712 .output
2713 .lines()
2714 .find(|l| l.starts_with("task_id:"))
2715 .unwrap()
2716 .trim_start_matches("task_id: ")
2717 .trim();
2718
2719 tokio::time::sleep(Duration::from_millis(500)).await;
2721
2722 let result_path = workspace
2724 .join("delegate_results")
2725 .join(format!("{task_id}.json"));
2726 assert!(
2727 result_path.exists(),
2728 "Result file should exist at {result_path:?}"
2729 );
2730
2731 let content = std::fs::read_to_string(&result_path).unwrap();
2733 let bg_result: BackgroundDelegateResult = serde_json::from_str(&content).unwrap();
2734 assert_eq!(bg_result.task_id, task_id);
2735 assert_eq!(bg_result.agent, "researcher");
2736 assert!(
2738 bg_result.status == BackgroundTaskStatus::Completed
2739 || bg_result.status == BackgroundTaskStatus::Failed
2740 );
2741 assert!(bg_result.finished_at.is_some());
2742
2743 let _ = std::fs::remove_dir_all(workspace);
2744 }
2745
2746 #[tokio::test]
2747 async fn check_result_retrieves_persisted_background_result() {
2748 let workspace = std::env::temp_dir().join(format!(
2749 "construct_delegate_check_retrieve_{}",
2750 uuid::Uuid::new_v4()
2751 ));
2752 std::fs::create_dir_all(&workspace).unwrap();
2753
2754 let tool = DelegateTool::new(sample_agents(), None, test_security())
2755 .with_workspace_dir(workspace.clone());
2756
2757 let result = tool
2759 .execute(json!({
2760 "agent": "researcher",
2761 "prompt": "retrieval test",
2762 "background": true
2763 }))
2764 .await
2765 .unwrap();
2766
2767 let task_id = result
2768 .output
2769 .lines()
2770 .find(|l| l.starts_with("task_id:"))
2771 .unwrap()
2772 .trim_start_matches("task_id: ")
2773 .trim()
2774 .to_string();
2775
2776 tokio::time::sleep(Duration::from_millis(500)).await;
2778
2779 let check = tool
2781 .execute(json!({
2782 "action": "check_result",
2783 "task_id": task_id
2784 }))
2785 .await
2786 .unwrap();
2787
2788 assert!(check.output.contains(&task_id));
2790 assert!(check.output.contains("researcher"));
2791
2792 let _ = std::fs::remove_dir_all(workspace);
2793 }
2794
2795 #[tokio::test]
2796 async fn list_results_includes_background_tasks() {
2797 let workspace = std::env::temp_dir().join(format!(
2798 "construct_delegate_list_tasks_{}",
2799 uuid::Uuid::new_v4()
2800 ));
2801 std::fs::create_dir_all(&workspace).unwrap();
2802
2803 let tool = DelegateTool::new(sample_agents(), None, test_security())
2804 .with_workspace_dir(workspace.clone());
2805
2806 let result = tool
2808 .execute(json!({
2809 "agent": "researcher",
2810 "prompt": "list test",
2811 "background": true
2812 }))
2813 .await
2814 .unwrap();
2815 assert!(result.success);
2816
2817 tokio::time::sleep(Duration::from_millis(500)).await;
2819
2820 let list = tool
2822 .execute(json!({"action": "list_results"}))
2823 .await
2824 .unwrap();
2825
2826 assert!(list.success);
2827 assert!(list.output.contains("researcher"));
2828
2829 let _ = std::fs::remove_dir_all(workspace);
2830 }
2831
2832 #[tokio::test]
2833 async fn default_action_is_delegate() {
2834 let tool = DelegateTool::new(sample_agents(), None, test_security());
2836 let result = tool
2837 .execute(json!({"agent": "researcher", "prompt": "test"}))
2838 .await
2839 .unwrap();
2840 assert!(
2843 result.error.is_none()
2844 || !result
2845 .error
2846 .as_deref()
2847 .unwrap_or("")
2848 .contains("Unknown action")
2849 );
2850 }
2851
2852 #[tokio::test]
2853 async fn check_result_rejects_path_traversal() {
2854 let workspace = std::env::temp_dir().join(format!(
2855 "construct_delegate_traversal_check_{}",
2856 uuid::Uuid::new_v4()
2857 ));
2858 std::fs::create_dir_all(&workspace).unwrap();
2859
2860 let tool = DelegateTool::new(sample_agents(), None, test_security())
2861 .with_workspace_dir(workspace.clone());
2862 let result = tool
2863 .execute(json!({
2864 "action": "check_result",
2865 "task_id": "../../etc/passwd"
2866 }))
2867 .await
2868 .unwrap();
2869
2870 assert!(!result.success);
2871 assert!(result.error.unwrap().contains("Invalid task_id"));
2872
2873 let _ = std::fs::remove_dir_all(workspace);
2874 }
2875
2876 #[tokio::test]
2877 async fn cancel_task_rejects_path_traversal() {
2878 let workspace = std::env::temp_dir().join(format!(
2879 "construct_delegate_traversal_cancel_{}",
2880 uuid::Uuid::new_v4()
2881 ));
2882 std::fs::create_dir_all(&workspace).unwrap();
2883
2884 let tool = DelegateTool::new(sample_agents(), None, test_security())
2885 .with_workspace_dir(workspace.clone());
2886 let result = tool
2887 .execute(json!({
2888 "action": "cancel_task",
2889 "task_id": "../../../etc/shadow"
2890 }))
2891 .await
2892 .unwrap();
2893
2894 assert!(!result.success);
2895 assert!(result.error.unwrap().contains("Invalid task_id"));
2896
2897 let _ = std::fs::remove_dir_all(workspace);
2898 }
2899}