Skip to main content

brainwires_skills/
executor.rs

1//! Skill Executor
2//!
3//! Executes skills in one of three modes:
4//! - **Inline**: Instructions returned for injection into the conversation
5//! - **Subagent**: Execution info returned; caller spawns via AgentPool
6//! - **Script**: Script content returned; caller executes via OrchestratorTool
7//!
8//! Tool restrictions from `allowed-tools` are enforced in `prepare_*` methods.
9
10use anyhow::{Context, Result};
11use std::collections::HashMap;
12use std::sync::Arc;
13use tokio::sync::RwLock;
14
15use super::metadata::{Skill, SkillExecutionMode, SkillResult};
16use super::parser::render_template;
17use super::registry::SkillRegistry;
18
19/// Skill executor handles the execution of skills in various modes
20pub struct SkillExecutor {
21    /// Reference to skill registry for loading skills
22    registry: Arc<RwLock<SkillRegistry>>,
23}
24
25impl SkillExecutor {
26    /// Create a new skill executor
27    pub fn new(registry: Arc<RwLock<SkillRegistry>>) -> Self {
28        Self { registry }
29    }
30
31    /// Execute a skill by name
32    ///
33    /// Loads the skill from registry and executes it with the given arguments.
34    pub async fn execute_by_name(
35        &self,
36        skill_name: &str,
37        args: HashMap<String, String>,
38    ) -> Result<SkillResult> {
39        let mut registry = self.registry.write().await;
40        let skill = registry
41            .get_skill(skill_name)
42            .with_context(|| format!("Failed to load skill '{}'", skill_name))?
43            .clone();
44        drop(registry);
45
46        self.execute(&skill, args).await
47    }
48
49    /// Execute a skill
50    ///
51    /// Dispatches to the appropriate execution mode based on skill metadata.
52    pub async fn execute(
53        &self,
54        skill: &Skill,
55        args: HashMap<String, String>,
56    ) -> Result<SkillResult> {
57        let instructions = render_template(&skill.instructions, &args);
58
59        match skill.execution_mode {
60            SkillExecutionMode::Inline => self.execute_inline(skill, &instructions).await,
61            SkillExecutionMode::Subagent => self.execute_subagent(skill, &instructions).await,
62            SkillExecutionMode::Script => self.execute_script(skill, &instructions).await,
63        }
64    }
65
66    /// Execute skill inline — returns instructions for injection into the conversation.
67    async fn execute_inline(&self, skill: &Skill, instructions: &str) -> Result<SkillResult> {
68        tracing::info!("Executing skill '{}' inline", skill.name());
69
70        let full_instructions = format!(
71            "## Skill: {}\n\n{}\n\n---\n\n{}",
72            skill.name(),
73            skill.description(),
74            instructions
75        );
76
77        Ok(SkillResult::inline(
78            full_instructions,
79            skill.model().cloned(),
80        ))
81    }
82
83    /// Execute skill as a subagent — returns an agent ID; caller spawns via AgentPool.
84    async fn execute_subagent(&self, skill: &Skill, instructions: &str) -> Result<SkillResult> {
85        tracing::info!("Executing skill '{}' as subagent", skill.name());
86
87        let agent_id = format!("skill-{}-{}", skill.name(), uuid::Uuid::new_v4());
88
89        tracing::debug!(
90            "Prepared subagent task '{}' with {} instructions chars",
91            agent_id,
92            instructions.len()
93        );
94
95        Ok(SkillResult::Subagent { agent_id })
96    }
97
98    /// Execute skill as a Rhai script — returns script content; caller executes via OrchestratorTool.
99    async fn execute_script(&self, skill: &Skill, script: &str) -> Result<SkillResult> {
100        tracing::info!("Executing skill '{}' as script", skill.name());
101
102        if !script.contains("let ") && !script.contains("fn ") && !script.contains(";") {
103            tracing::warn!(
104                "Script for skill '{}' doesn't look like valid Rhai code",
105                skill.name()
106            );
107        }
108
109        Ok(SkillResult::Script {
110            output: script.to_string(),
111            is_error: false,
112        })
113    }
114
115    /// Filter available tool names to only those allowed by the skill.
116    fn filter_allowed_tools(&self, skill: &Skill, available: &[String]) -> Vec<String> {
117        if let Some(allowed_tools) = skill.allowed_tools() {
118            available
119                .iter()
120                .filter(|name| {
121                    allowed_tools.iter().any(|allowed| {
122                        // Match exact name or MCP-style prefix (server__tool)
123                        *name == allowed || name.ends_with(&format!("__{}", allowed))
124                    })
125                })
126                .cloned()
127                .collect()
128        } else {
129            available.to_vec()
130        }
131    }
132
133    /// Prepare a subagent execution context.
134    ///
135    /// Returns task description, filtered tool names, and system prompt.
136    /// Caller (who has AgentPool access) converts this into a Task + AgentContext.
137    pub async fn prepare_subagent(
138        &self,
139        skill: &Skill,
140        available_tool_names: &[String],
141        args: HashMap<String, String>,
142    ) -> Result<SubagentPrepared> {
143        let instructions = render_template(&skill.instructions, &args);
144        let allowed_tool_names = self.filter_allowed_tools(skill, available_tool_names);
145
146        let system_prompt = format!(
147            "You are executing the '{}' skill.\n\n\
148             **Description**: {}\n\n\
149             **Instructions**:\n{}",
150            skill.name(),
151            skill.description(),
152            instructions
153        );
154
155        Ok(SubagentPrepared {
156            task_description: instructions,
157            allowed_tool_names,
158            system_prompt,
159            model_override: skill.model().cloned(),
160        })
161    }
162
163    /// Prepare a script execution.
164    ///
165    /// Returns the rendered script and filtered tool names.
166    /// Caller (who has OrchestratorTool access) handles execution.
167    pub async fn prepare_script(
168        &self,
169        skill: &Skill,
170        available_tool_names: &[String],
171        args: HashMap<String, String>,
172    ) -> Result<ScriptPrepared> {
173        let script_content = render_template(&skill.instructions, &args);
174        let allowed_tool_names = self.filter_allowed_tools(skill, available_tool_names);
175
176        Ok(ScriptPrepared {
177            script_content,
178            allowed_tool_names,
179            model_override: skill.model().cloned(),
180            skill_name: skill.name().to_string(),
181        })
182    }
183
184    /// Get the execution mode for a skill
185    pub async fn get_execution_mode(&self, skill_name: &str) -> Result<SkillExecutionMode> {
186        let registry = self.registry.read().await;
187        let metadata = registry
188            .get_metadata(skill_name)
189            .ok_or_else(|| anyhow::anyhow!("Skill not found: {}", skill_name))?;
190        Ok(metadata.execution_mode())
191    }
192}
193
194/// Prepared subagent execution — caller converts into Task + AgentContext.
195#[derive(Debug, Clone)]
196pub struct SubagentPrepared {
197    /// Task description (rendered instructions)
198    pub task_description: String,
199    /// Tool names allowed for this skill (filtered from available tools)
200    pub allowed_tool_names: Vec<String>,
201    /// System prompt for the subagent
202    pub system_prompt: String,
203    /// Optional model override
204    pub model_override: Option<String>,
205}
206
207/// Prepared script execution — caller executes via OrchestratorTool.
208#[derive(Debug, Clone)]
209pub struct ScriptPrepared {
210    /// The rendered Rhai script content
211    pub script_content: String,
212    /// Tool names allowed for this skill (filtered from available tools)
213    pub allowed_tool_names: Vec<String>,
214    /// Optional model override
215    pub model_override: Option<String>,
216    /// Skill name for logging
217    pub skill_name: String,
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::metadata::SkillMetadata;
224
225    fn create_available_tools() -> Vec<String> {
226        vec![
227            "Read".to_string(),
228            "Write".to_string(),
229            "Grep".to_string(),
230            "git_diff".to_string(),
231        ]
232    }
233
234    fn create_test_skill() -> Skill {
235        let mut metadata = SkillMetadata::new("test-skill".to_string(), "A test skill".to_string());
236        metadata.allowed_tools = Some(vec!["Read".to_string(), "Grep".to_string()]);
237
238        Skill {
239            metadata,
240            instructions: "Do the test with {{arg1}}".to_string(),
241            execution_mode: SkillExecutionMode::Inline,
242        }
243    }
244
245    #[tokio::test]
246    async fn test_execute_inline() {
247        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
248        let executor = SkillExecutor::new(registry);
249
250        let skill = create_test_skill();
251        let mut args = HashMap::new();
252        args.insert("arg1".to_string(), "value1".to_string());
253
254        let result = executor.execute(&skill, args).await.unwrap();
255
256        match result {
257            SkillResult::Inline { instructions, .. } => {
258                assert!(instructions.contains("test-skill"));
259                assert!(instructions.contains("value1"));
260            }
261            _ => panic!("Expected inline result"),
262        }
263    }
264
265    #[tokio::test]
266    async fn test_execute_subagent() {
267        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
268        let executor = SkillExecutor::new(registry);
269
270        let mut skill = create_test_skill();
271        skill.execution_mode = SkillExecutionMode::Subagent;
272
273        let args = HashMap::new();
274        let result = executor.execute(&skill, args).await.unwrap();
275
276        match result {
277            SkillResult::Subagent { agent_id } => {
278                assert!(agent_id.starts_with("skill-test-skill-"));
279            }
280            _ => panic!("Expected subagent result"),
281        }
282    }
283
284    #[tokio::test]
285    async fn test_execute_script() {
286        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
287        let executor = SkillExecutor::new(registry);
288
289        let mut skill = create_test_skill();
290        skill.execution_mode = SkillExecutionMode::Script;
291        skill.instructions = "let x = 1; x + 1".to_string();
292
293        let args = HashMap::new();
294        let result = executor.execute(&skill, args).await.unwrap();
295
296        match result {
297            SkillResult::Script { output, is_error } => {
298                assert!(!is_error);
299                assert!(output.contains("let x = 1"));
300            }
301            _ => panic!("Expected script result"),
302        }
303    }
304
305    #[tokio::test]
306    async fn test_filter_allowed_tools() {
307        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
308        let executor = SkillExecutor::new(registry);
309
310        let skill = create_test_skill(); // allowed: Read, Grep
311        let available = create_available_tools();
312
313        let filtered = executor.filter_allowed_tools(&skill, &available);
314
315        assert_eq!(filtered.len(), 2);
316        assert!(filtered.contains(&"Read".to_string()));
317        assert!(filtered.contains(&"Grep".to_string()));
318        assert!(!filtered.contains(&"Write".to_string()));
319        assert!(!filtered.contains(&"git_diff".to_string()));
320    }
321
322    #[tokio::test]
323    async fn test_no_tool_restrictions() {
324        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
325        let executor = SkillExecutor::new(registry);
326
327        let mut skill = create_test_skill();
328        skill.metadata.allowed_tools = None; // No restrictions
329
330        let available = create_available_tools();
331        let filtered = executor.filter_allowed_tools(&skill, &available);
332
333        // Should have all tools
334        assert_eq!(filtered.len(), 4);
335    }
336
337    #[tokio::test]
338    async fn test_prepare_subagent() {
339        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
340        let executor = SkillExecutor::new(registry);
341
342        let mut skill = create_test_skill();
343        skill.execution_mode = SkillExecutionMode::Subagent;
344
345        let available = create_available_tools();
346        let mut args = HashMap::new();
347        args.insert("arg1".to_string(), "test_value".to_string());
348
349        let prepared = executor
350            .prepare_subagent(&skill, &available, args)
351            .await
352            .unwrap();
353
354        assert!(prepared.task_description.contains("test_value"));
355        assert!(prepared.system_prompt.contains("test-skill"));
356        // Context should be restricted to Read + Grep
357        assert_eq!(prepared.allowed_tool_names.len(), 2);
358        assert!(prepared.allowed_tool_names.contains(&"Read".to_string()));
359        assert!(prepared.allowed_tool_names.contains(&"Grep".to_string()));
360    }
361
362    #[tokio::test]
363    async fn test_prepare_script() {
364        let registry = Arc::new(RwLock::new(SkillRegistry::new()));
365        let executor = SkillExecutor::new(registry);
366
367        let mut skill = create_test_skill();
368        skill.execution_mode = SkillExecutionMode::Script;
369        skill.instructions = "let result = {{value}}; result".to_string();
370
371        let available = create_available_tools();
372        let mut args = HashMap::new();
373        args.insert("value".to_string(), "42".to_string());
374
375        let prepared = executor
376            .prepare_script(&skill, &available, args)
377            .await
378            .unwrap();
379
380        assert!(prepared.script_content.contains("let result = 42"));
381        assert_eq!(prepared.skill_name, "test-skill");
382        // Context should be restricted to Read + Grep
383        assert_eq!(prepared.allowed_tool_names.len(), 2);
384    }
385}