1use crate::modes::OperatingMode;
8use serde::Deserialize;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum IntelligenceLevel {
17 Light,
19 Medium,
21 Hard,
23}
24
25impl Default for IntelligenceLevel {
26 fn default() -> Self {
27 Self::Medium
28 }
29}
30
31impl IntelligenceLevel {
32 pub const fn compression_percentage(self) -> u8 {
34 match self {
35 Self::Light => 70,
36 Self::Medium => 85,
37 Self::Hard => 95,
38 }
39 }
40
41 pub const fn chunk_size(self) -> usize {
43 match self {
44 Self::Light => 256,
45 Self::Medium => 512,
46 Self::Hard => 1024,
47 }
48 }
49
50 pub const fn max_chunks(self) -> usize {
52 match self {
53 Self::Light => 1_000,
54 Self::Medium => 10_000,
55 Self::Hard => 100_000,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum ToolPermission {
64 Allow,
66 Deny,
68 Restricted(HashMap<String, String>),
70}
71
72impl Default for ToolPermission {
73 fn default() -> Self {
74 Self::Allow
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ParameterDefinition {
81 pub name: String,
83 pub description: String,
85 #[serde(default)]
87 pub required: bool,
88 pub default: Option<String>,
90 pub valid_values: Option<Vec<String>>,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SubagentConfig {
97 pub name: String,
99
100 pub description: String,
102
103 pub mode_override: Option<OperatingMode>,
105
106 #[serde(default)]
108 pub intelligence: IntelligenceLevel,
109
110 #[serde(default)]
112 pub tools: HashMap<String, ToolPermission>,
113
114 pub prompt: String,
116
117 #[serde(default)]
119 pub parameters: Vec<ParameterDefinition>,
120
121 pub template: Option<String>,
123
124 #[serde(default = "default_timeout")]
126 pub timeout_seconds: u64,
127
128 #[serde(default = "default_true")]
130 pub chainable: bool,
131
132 #[serde(default = "default_true")]
134 pub parallelizable: bool,
135
136 #[serde(default)]
138 pub metadata: HashMap<String, serde_json::Value>,
139
140 #[serde(default)]
142 pub file_patterns: Vec<String>,
143
144 #[serde(default)]
146 pub tags: Vec<String>,
147}
148
149const fn default_timeout() -> u64 {
150 300 }
152
153const fn default_true() -> bool {
154 true
155}
156
157impl SubagentConfig {
158 pub fn from_file(path: &PathBuf) -> Result<Self, super::SubagentError> {
160 let content = std::fs::read_to_string(path)?;
161 let config: Self = toml::from_str(&content)?;
162 config.validate()?;
163 Ok(config)
164 }
165
166 pub fn to_file(&self, path: &PathBuf) -> Result<(), super::SubagentError> {
168 let content = toml::to_string_pretty(self)
169 .map_err(|e| super::SubagentError::InvalidConfig(e.to_string()))?;
170 std::fs::write(path, content)?;
171 Ok(())
172 }
173
174 pub fn validate(&self) -> Result<(), super::SubagentError> {
176 if self.name.is_empty() {
177 return Err(super::SubagentError::InvalidConfig(
178 "agent name cannot be empty".to_string(),
179 ));
180 }
181
182 if self.template.is_none() {
184 if self.description.is_empty() {
185 return Err(super::SubagentError::InvalidConfig(
186 "agent description cannot be empty (unless using a template)".to_string(),
187 ));
188 }
189
190 if self.prompt.is_empty() {
191 return Err(super::SubagentError::InvalidConfig(
192 "agent prompt cannot be empty (unless using a template)".to_string(),
193 ));
194 }
195 }
196
197 if self.timeout_seconds == 0 {
198 return Err(super::SubagentError::InvalidConfig(
199 "timeout must be greater than 0".to_string(),
200 ));
201 }
202
203 let mut param_names = std::collections::HashSet::new();
205 for param in &self.parameters {
206 if !param_names.insert(¶m.name) {
207 return Err(super::SubagentError::InvalidConfig(format!(
208 "duplicate parameter name: {}",
209 param.name
210 )));
211 }
212 }
213
214 Ok(())
215 }
216
217 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
219 match self.tools.get(tool_name) {
220 Some(ToolPermission::Allow) => true,
221 Some(ToolPermission::Deny) => false,
222 Some(ToolPermission::Restricted(_)) => true, None => true, }
225 }
226
227 pub fn get_tool_restrictions(&self, tool_name: &str) -> Option<&HashMap<String, String>> {
229 match self.tools.get(tool_name) {
230 Some(ToolPermission::Restricted(restrictions)) => Some(restrictions),
231 _ => None,
232 }
233 }
234
235 pub fn effective_mode(&self, current_mode: OperatingMode) -> OperatingMode {
237 self.mode_override.unwrap_or(current_mode)
238 }
239
240 pub fn matches_file(&self, file_path: &std::path::Path) -> bool {
242 if self.file_patterns.is_empty() {
243 return true; }
245
246 let path_str = file_path.to_string_lossy();
247 self.file_patterns.iter().any(|pattern| {
248 if pattern.contains('*') {
250 let pattern = pattern.replace('*', ".*");
252 regex_lite::Regex::new(&pattern)
253 .map(|re| re.is_match(&path_str))
254 .unwrap_or(false)
255 } else {
256 path_str.contains(pattern)
257 }
258 })
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct SubagentTemplate {
265 pub name: String,
267
268 pub description: String,
270
271 pub config: SubagentConfig,
273
274 #[serde(default)]
276 pub placeholders: Vec<String>,
277}
278
279impl SubagentTemplate {
280 pub fn from_file(path: &PathBuf) -> Result<Self, super::SubagentError> {
282 let content = std::fs::read_to_string(path)?;
283 let template: Self = toml::from_str(&content)?;
284 Ok(template)
285 }
286
287 pub fn instantiate(
289 &self,
290 name: String,
291 substitutions: HashMap<String, String>,
292 ) -> Result<SubagentConfig, super::SubagentError> {
293 let mut config = self.config.clone();
294 config.name = name;
295
296 let mut prompt = config.prompt.clone();
298 for (placeholder, value) in substitutions {
299 let placeholder_pattern = format!("{{{{{}}}}}", placeholder);
300 prompt = prompt.replace(&placeholder_pattern, &value);
301 }
302 config.prompt = prompt;
303
304 config.validate()?;
305 Ok(config)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn test_intelligence_level_properties() {
315 assert_eq!(IntelligenceLevel::Light.compression_percentage(), 70);
316 assert_eq!(IntelligenceLevel::Medium.compression_percentage(), 85);
317 assert_eq!(IntelligenceLevel::Hard.compression_percentage(), 95);
318
319 assert_eq!(IntelligenceLevel::Light.chunk_size(), 256);
320 assert_eq!(IntelligenceLevel::Medium.chunk_size(), 512);
321 assert_eq!(IntelligenceLevel::Hard.chunk_size(), 1024);
322 }
323
324 #[test]
325 fn test_subagent_config_validation() {
326 let mut config = SubagentConfig {
327 name: "test-agent".to_string(),
328 description: "Test agent".to_string(),
329 mode_override: None,
330 intelligence: IntelligenceLevel::Medium,
331 tools: HashMap::new(),
332 prompt: "You are a test agent.".to_string(),
333 parameters: vec![],
334 template: None,
335 timeout_seconds: 300,
336 chainable: true,
337 parallelizable: true,
338 metadata: HashMap::new(),
339 file_patterns: vec![],
340 tags: vec![],
341 };
342
343 assert!(config.validate().is_ok());
345
346 config.name = String::new();
348 assert!(config.validate().is_err());
349 }
350
351 #[test]
352 fn test_tool_permissions() {
353 let mut tools = HashMap::new();
354 tools.insert("allowed_tool".to_string(), ToolPermission::Allow);
355 tools.insert("denied_tool".to_string(), ToolPermission::Deny);
356 tools.insert(
357 "restricted_tool".to_string(),
358 ToolPermission::Restricted(HashMap::from([(
359 "max_files".to_string(),
360 "10".to_string(),
361 )])),
362 );
363
364 let config = SubagentConfig {
365 name: "test-agent".to_string(),
366 description: "Test agent".to_string(),
367 mode_override: None,
368 intelligence: IntelligenceLevel::Medium,
369 tools,
370 prompt: "You are a test agent.".to_string(),
371 parameters: vec![],
372 template: None,
373 timeout_seconds: 300,
374 chainable: true,
375 parallelizable: true,
376 metadata: HashMap::new(),
377 file_patterns: vec![],
378 tags: vec![],
379 };
380
381 assert!(config.is_tool_allowed("allowed_tool"));
382 assert!(!config.is_tool_allowed("denied_tool"));
383 assert!(config.is_tool_allowed("restricted_tool"));
384 assert!(config.is_tool_allowed("unknown_tool")); assert!(config.get_tool_restrictions("restricted_tool").is_some());
387 assert!(config.get_tool_restrictions("allowed_tool").is_none());
388 }
389
390 #[test]
391 fn test_config_serialization() {
392 let config = SubagentConfig {
393 name: "test-agent".to_string(),
394 description: "Test agent".to_string(),
395 mode_override: Some(OperatingMode::Review),
396 intelligence: IntelligenceLevel::Hard,
397 tools: HashMap::new(),
398 prompt: "You are a test agent.".to_string(),
399 parameters: vec![ParameterDefinition {
400 name: "target".to_string(),
401 description: "Target file".to_string(),
402 required: true,
403 default: None,
404 valid_values: None,
405 }],
406 template: None,
407 timeout_seconds: 600,
408 chainable: false,
409 parallelizable: true,
410 metadata: HashMap::new(),
411 file_patterns: vec!["*.rs".to_string()],
412 tags: vec!["rust".to_string(), "review".to_string()],
413 };
414
415 let toml_str = toml::to_string(&config).unwrap();
417 let deserialized: SubagentConfig = toml::from_str(&toml_str).unwrap();
418 assert_eq!(config, deserialized);
419 }
420
421 #[test]
422 fn test_file_pattern_matching() {
423 let config = SubagentConfig {
424 name: "rust-agent".to_string(),
425 description: "Rust-specific agent".to_string(),
426 mode_override: None,
427 intelligence: IntelligenceLevel::Medium,
428 tools: HashMap::new(),
429 prompt: "You are a Rust agent.".to_string(),
430 parameters: vec![],
431 template: None,
432 timeout_seconds: 300,
433 chainable: true,
434 parallelizable: true,
435 metadata: HashMap::new(),
436 file_patterns: vec!["*.rs".to_string(), "Cargo.toml".to_string()],
437 tags: vec![],
438 };
439
440 assert!(config.matches_file(&PathBuf::from("src/main.rs")));
441 assert!(config.matches_file(&PathBuf::from("Cargo.toml")));
442 assert!(!config.matches_file(&PathBuf::from("src/main.py")));
443 }
444}