agcodex_core/subagents/
config.rs

1//! Configuration structures for subagents
2//!
3//! This module defines how subagents are configured through TOML files.
4//! Each agent has its own configuration file that defines its capabilities,
5//! permissions, and behavior.
6
7use crate::modes::OperatingMode;
8use serde::Deserialize;
9use serde::Serialize;
10use std::collections::HashMap;
11use std::path::PathBuf;
12
13/// Intelligence level for subagent operations
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum IntelligenceLevel {
17    /// Fast, minimal resources (70% compression)
18    Light,
19    /// Balanced, default (85% compression)
20    Medium,
21    /// Maximum intelligence (95% compression)
22    Hard,
23}
24
25impl Default for IntelligenceLevel {
26    fn default() -> Self {
27        Self::Medium
28    }
29}
30
31impl IntelligenceLevel {
32    /// Get the compression level as a percentage
33    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    /// Get the maximum chunk size for this intelligence level
42    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    /// Get the maximum number of chunks for this intelligence level
51    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/// Tool permission for subagents
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum ToolPermission {
64    /// Tool is allowed
65    Allow,
66    /// Tool is denied
67    Deny,
68    /// Tool is allowed with restrictions
69    Restricted(HashMap<String, String>),
70}
71
72impl Default for ToolPermission {
73    fn default() -> Self {
74        Self::Allow
75    }
76}
77
78/// Parameter definition for subagent
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ParameterDefinition {
81    /// Parameter name
82    pub name: String,
83    /// Parameter description
84    pub description: String,
85    /// Whether the parameter is required
86    #[serde(default)]
87    pub required: bool,
88    /// Default value if not provided
89    pub default: Option<String>,
90    /// Valid values (if restricted)
91    pub valid_values: Option<Vec<String>>,
92}
93
94/// Complete configuration for a subagent
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct SubagentConfig {
97    /// Agent name (must be unique)
98    pub name: String,
99
100    /// Human-readable description
101    pub description: String,
102
103    /// Override the operating mode when this agent is active
104    pub mode_override: Option<OperatingMode>,
105
106    /// Intelligence level for AST processing and embeddings
107    #[serde(default)]
108    pub intelligence: IntelligenceLevel,
109
110    /// Tools and their permissions
111    #[serde(default)]
112    pub tools: HashMap<String, ToolPermission>,
113
114    /// Custom prompt template for this agent
115    pub prompt: String,
116
117    /// Parameter definitions
118    #[serde(default)]
119    pub parameters: Vec<ParameterDefinition>,
120
121    /// Template this agent inherits from (optional)
122    pub template: Option<String>,
123
124    /// Maximum execution time in seconds
125    #[serde(default = "default_timeout")]
126    pub timeout_seconds: u64,
127
128    /// Whether this agent can be chained with others
129    #[serde(default = "default_true")]
130    pub chainable: bool,
131
132    /// Whether this agent can run in parallel with others
133    #[serde(default = "default_true")]
134    pub parallelizable: bool,
135
136    /// Custom metadata for the agent
137    #[serde(default)]
138    pub metadata: HashMap<String, serde_json::Value>,
139
140    /// File patterns this agent is specialized for
141    #[serde(default)]
142    pub file_patterns: Vec<String>,
143
144    /// Tags for categorizing agents
145    #[serde(default)]
146    pub tags: Vec<String>,
147}
148
149const fn default_timeout() -> u64 {
150    300 // 5 minutes
151}
152
153const fn default_true() -> bool {
154    true
155}
156
157impl SubagentConfig {
158    /// Load a subagent configuration from a TOML file
159    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    /// Save this configuration to a TOML file
167    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    /// Validate the configuration
175    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 using a template, description and prompt can be empty (will be inherited)
183        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        // Validate parameter names are unique
204        let mut param_names = std::collections::HashSet::new();
205        for param in &self.parameters {
206            if !param_names.insert(&param.name) {
207                return Err(super::SubagentError::InvalidConfig(format!(
208                    "duplicate parameter name: {}",
209                    param.name
210                )));
211            }
212        }
213
214        Ok(())
215    }
216
217    /// Check if a tool is allowed for this agent
218    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, // Allowed with restrictions
223            None => true,                                // Default allow
224        }
225    }
226
227    /// Get tool restrictions for a specific tool
228    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    /// Get the effective operating mode (considering override)
236    pub fn effective_mode(&self, current_mode: OperatingMode) -> OperatingMode {
237        self.mode_override.unwrap_or(current_mode)
238    }
239
240    /// Check if this agent matches the given file patterns
241    pub fn matches_file(&self, file_path: &std::path::Path) -> bool {
242        if self.file_patterns.is_empty() {
243            return true; // No restrictions
244        }
245
246        let path_str = file_path.to_string_lossy();
247        self.file_patterns.iter().any(|pattern| {
248            // Simple glob matching - could be enhanced with a proper glob library
249            if pattern.contains('*') {
250                // Basic wildcard matching
251                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/// Template for creating subagent configurations
263#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
264pub struct SubagentTemplate {
265    /// Template name
266    pub name: String,
267
268    /// Template description
269    pub description: String,
270
271    /// Base configuration
272    pub config: SubagentConfig,
273
274    /// Placeholders that can be customized
275    #[serde(default)]
276    pub placeholders: Vec<String>,
277}
278
279impl SubagentTemplate {
280    /// Load a template from a TOML file
281    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    /// Create a configuration from this template with substitutions
288    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        // Apply substitutions to the prompt
297        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        // Valid config should pass
344        assert!(config.validate().is_ok());
345
346        // Empty name should fail
347        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")); // Default allow
385
386        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        // Test TOML serialization
416        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}