Skip to main content

agentzero_core/
delegation.rs

1use crate::common::privacy_helpers::boundary_allows_provider;
2use anyhow::bail;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5
6/// Configuration for a delegate sub-agent.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct DelegateConfig {
9    pub name: String,
10    /// Provider kind string (e.g. `"openrouter"`, `"anthropic"`). Used to
11    /// dispatch to the correct provider implementation via `build_provider`.
12    pub provider_kind: String,
13    /// Resolved base URL for the provider API (e.g. `"https://openrouter.ai/api/v1"`).
14    pub provider: String,
15    pub model: String,
16    pub system_prompt: Option<String>,
17    pub api_key: Option<String>,
18    pub temperature: Option<f64>,
19    pub max_depth: usize,
20    pub agentic: bool,
21    pub allowed_tools: HashSet<String>,
22    pub max_iterations: usize,
23    /// Privacy boundary for this delegate agent (e.g. "local_only", "encrypted_only", "any").
24    /// Empty string means inherit from parent.
25    #[serde(default)]
26    pub privacy_boundary: String,
27}
28
29impl Default for DelegateConfig {
30    fn default() -> Self {
31        Self {
32            name: String::new(),
33            provider_kind: String::new(),
34            provider: String::new(),
35            model: String::new(),
36            system_prompt: None,
37            api_key: None,
38            temperature: None,
39            max_depth: 3,
40            agentic: false,
41            allowed_tools: HashSet::new(),
42            max_iterations: 10,
43            privacy_boundary: String::new(),
44        }
45    }
46}
47
48/// A delegation request from the parent agent.
49#[derive(Debug, Clone)]
50pub struct DelegateRequest {
51    pub agent_name: String,
52    pub prompt: String,
53    pub current_depth: usize,
54}
55
56/// Result of a delegation.
57#[derive(Debug, Clone)]
58pub struct DelegateResult {
59    pub agent_name: String,
60    pub output: String,
61    pub iterations_used: usize,
62}
63
64/// Validate delegation parameters before execution.
65pub fn validate_delegation(
66    request: &DelegateRequest,
67    config: &DelegateConfig,
68) -> anyhow::Result<()> {
69    if request.current_depth >= config.max_depth {
70        bail!(
71            "delegation depth limit reached: current={}, max={}",
72            request.current_depth,
73            config.max_depth
74        );
75    }
76
77    if config.provider.is_empty() {
78        bail!(
79            "delegate agent `{}` has no provider configured",
80            request.agent_name
81        );
82    }
83
84    if config.model.is_empty() {
85        bail!(
86            "delegate agent `{}` has no model configured",
87            request.agent_name
88        );
89    }
90
91    // The delegate tool itself must never appear in sub-agent tool lists
92    // to prevent infinite delegation chains.
93    if config.allowed_tools.contains("delegate") {
94        bail!(
95            "delegate agent `{}` must not have `delegate` in allowed_tools",
96            request.agent_name
97        );
98    }
99
100    // Privacy boundary enforcement: if the delegate has a boundary set,
101    // verify the provider kind is allowed.
102    if !config.privacy_boundary.is_empty()
103        && !boundary_allows_provider(&config.privacy_boundary, &config.provider_kind)
104    {
105        bail!(
106            "delegate agent `{}` has privacy_boundary '{}' which does not allow \
107             provider kind '{}' — use a local provider or change the boundary",
108            request.agent_name,
109            config.privacy_boundary,
110            config.provider_kind,
111        );
112    }
113
114    Ok(())
115}
116
117/// Filter a tool list to only include allowed tools for a sub-agent.
118pub fn filter_tools(all_tools: &[String], allowed: &HashSet<String>) -> Vec<String> {
119    if allowed.is_empty() {
120        // Empty allowlist means all tools (except delegate).
121        all_tools
122            .iter()
123            .filter(|t| *t != "delegate")
124            .cloned()
125            .collect()
126    } else {
127        all_tools
128            .iter()
129            .filter(|t| allowed.contains(*t) && *t != "delegate")
130            .cloned()
131            .collect()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn config() -> DelegateConfig {
140        DelegateConfig {
141            name: "researcher".into(),
142            provider_kind: "openrouter".into(),
143            provider: "https://openrouter.ai/api/v1".into(),
144            model: "anthropic/claude-sonnet-4-6".into(),
145            max_depth: 3,
146            agentic: true,
147            max_iterations: 10,
148            ..Default::default()
149        }
150    }
151
152    #[test]
153    fn validate_rejects_depth_exceeded() {
154        let req = DelegateRequest {
155            agent_name: "researcher".into(),
156            prompt: "find docs".into(),
157            current_depth: 3,
158        };
159        let result = validate_delegation(&req, &config());
160        assert!(result.is_err());
161        assert!(result.unwrap_err().to_string().contains("depth limit"));
162    }
163
164    #[test]
165    fn validate_rejects_delegate_in_allowed_tools() {
166        let mut cfg = config();
167        cfg.allowed_tools.insert("delegate".into());
168        let req = DelegateRequest {
169            agent_name: "researcher".into(),
170            prompt: "search".into(),
171            current_depth: 0,
172        };
173        assert!(validate_delegation(&req, &cfg).is_err());
174    }
175
176    #[test]
177    fn validate_accepts_valid_request() {
178        let req = DelegateRequest {
179            agent_name: "researcher".into(),
180            prompt: "search".into(),
181            current_depth: 0,
182        };
183        assert!(validate_delegation(&req, &config()).is_ok());
184    }
185
186    #[test]
187    fn filter_tools_excludes_delegate() {
188        let tools = vec!["shell".into(), "file_read".into(), "delegate".into()];
189        let result = filter_tools(&tools, &HashSet::new());
190        assert!(!result.contains(&"delegate".to_string()));
191        assert!(result.contains(&"shell".to_string()));
192    }
193
194    #[test]
195    fn filter_tools_respects_allowlist() {
196        let tools = vec!["shell".into(), "file_read".into(), "web_search".into()];
197        let mut allowed = HashSet::new();
198        allowed.insert("file_read".into());
199        let result = filter_tools(&tools, &allowed);
200        assert_eq!(result, vec!["file_read".to_string()]);
201    }
202
203    #[test]
204    fn validate_rejects_cloud_provider_with_local_only_boundary() {
205        let mut cfg = config();
206        cfg.privacy_boundary = "local_only".into();
207        // openrouter is a cloud provider → should be rejected
208        let req = DelegateRequest {
209            agent_name: "researcher".into(),
210            prompt: "search".into(),
211            current_depth: 0,
212        };
213        let err = validate_delegation(&req, &cfg).unwrap_err();
214        assert!(err.to_string().contains("local_only"));
215        assert!(err.to_string().contains("openrouter"));
216    }
217
218    #[test]
219    fn validate_allows_local_provider_with_local_only_boundary() {
220        let mut cfg = config();
221        cfg.privacy_boundary = "local_only".into();
222        cfg.provider_kind = "ollama".into();
223        cfg.provider = "http://localhost:11434".into();
224        let req = DelegateRequest {
225            agent_name: "local-agent".into(),
226            prompt: "draft".into(),
227            current_depth: 0,
228        };
229        assert!(validate_delegation(&req, &cfg).is_ok());
230    }
231
232    #[test]
233    fn validate_allows_cloud_provider_with_encrypted_boundary() {
234        let mut cfg = config();
235        cfg.privacy_boundary = "encrypted_only".into();
236        let req = DelegateRequest {
237            agent_name: "researcher".into(),
238            prompt: "search".into(),
239            current_depth: 0,
240        };
241        assert!(validate_delegation(&req, &cfg).is_ok());
242    }
243
244    #[test]
245    fn validate_allows_any_provider_with_empty_boundary() {
246        // Empty boundary = inherit = no restriction
247        let cfg = config();
248        assert!(cfg.privacy_boundary.is_empty());
249        let req = DelegateRequest {
250            agent_name: "researcher".into(),
251            prompt: "search".into(),
252            current_depth: 0,
253        };
254        assert!(validate_delegation(&req, &cfg).is_ok());
255    }
256}