1use crate::common::privacy_helpers::boundary_allows_provider;
2use anyhow::bail;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct DelegateConfig {
9 pub name: String,
10 pub provider_kind: String,
13 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 #[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#[derive(Debug, Clone)]
50pub struct DelegateRequest {
51 pub agent_name: String,
52 pub prompt: String,
53 pub current_depth: usize,
54}
55
56#[derive(Debug, Clone)]
58pub struct DelegateResult {
59 pub agent_name: String,
60 pub output: String,
61 pub iterations_used: usize,
62}
63
64pub 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 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 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
117pub fn filter_tools(all_tools: &[String], allowed: &HashSet<String>) -> Vec<String> {
119 if allowed.is_empty() {
120 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 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 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}