claude_agent/skills/
executor.rs

1//! Skill executor - runs skills with lazy content loading.
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use super::{SkillIndex, SkillResult};
7use crate::common::{IndexRegistry, Named};
8
9const DEFAULT_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
10
11pub type SkillExecutionCallback = Arc<
12    dyn Fn(
13            String,
14        )
15            -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<String, String>> + Send>>
16        + Send
17        + Sync,
18>;
19
20/// Skill executor using IndexRegistry for progressive disclosure.
21///
22/// Skills are stored as lightweight indices (metadata only). Full content
23/// is loaded on-demand only when the skill is executed.
24pub struct SkillExecutor {
25    registry: IndexRegistry<SkillIndex>,
26    execution_callback: Option<SkillExecutionCallback>,
27    callback_timeout: Duration,
28    mode: ExecutionMode,
29}
30
31#[derive(Clone, Copy, Debug, Default)]
32pub enum ExecutionMode {
33    /// Return skill content as inline prompt (default)
34    #[default]
35    InlinePrompt,
36    /// Execute via callback function
37    Callback,
38    /// Return what would be executed without running
39    DryRun,
40}
41
42impl SkillExecutor {
43    /// Create a new executor with an IndexRegistry.
44    pub fn new(registry: IndexRegistry<SkillIndex>) -> Self {
45        Self {
46            registry,
47            execution_callback: None,
48            callback_timeout: DEFAULT_CALLBACK_TIMEOUT,
49            mode: ExecutionMode::InlinePrompt,
50        }
51    }
52
53    /// Create an executor with an empty registry.
54    pub fn with_defaults() -> Self {
55        Self::new(IndexRegistry::new())
56    }
57
58    /// Set execution callback.
59    pub fn with_callback(mut self, callback: SkillExecutionCallback) -> Self {
60        self.execution_callback = Some(callback);
61        self.mode = ExecutionMode::Callback;
62        self
63    }
64
65    /// Set callback timeout.
66    pub fn with_callback_timeout(mut self, timeout: Duration) -> Self {
67        self.callback_timeout = timeout;
68        self
69    }
70
71    /// Set execution mode.
72    pub fn with_mode(mut self, mode: ExecutionMode) -> Self {
73        self.mode = mode;
74        self
75    }
76
77    /// Get the skill registry.
78    pub fn registry(&self) -> &IndexRegistry<SkillIndex> {
79        &self.registry
80    }
81
82    /// Get mutable access to the registry.
83    pub fn registry_mut(&mut self) -> &mut IndexRegistry<SkillIndex> {
84        &mut self.registry
85    }
86
87    /// Consume the executor and return the registry.
88    pub fn into_registry(self) -> IndexRegistry<SkillIndex> {
89        self.registry
90    }
91
92    /// Execute a skill by name.
93    ///
94    /// This triggers lazy loading of the skill content.
95    pub async fn execute(&self, name: &str, args: Option<&str>) -> SkillResult {
96        let skill = match self.registry.get(name) {
97            Some(s) => s.clone(),
98            None => {
99                return SkillResult::error(format!("Skill '{}' not found", name));
100            }
101        };
102
103        self.execute_skill(&skill, args).await
104    }
105
106    /// Execute by trigger matching.
107    pub async fn execute_by_trigger(&self, input: &str) -> Option<SkillResult> {
108        // Find matching skill
109        let skill = self.registry.iter().find(|s| s.matches_triggers(input))?;
110        let skill = skill.clone();
111
112        let args = self.extract_args(input, &skill);
113        Some(self.execute_skill(&skill, args.as_deref()).await)
114    }
115
116    /// Execute a skill directly.
117    async fn execute_skill(&self, skill: &SkillIndex, args: Option<&str>) -> SkillResult {
118        // Load content using registry cache (fixes cache bypass bug)
119        let content = match self.registry.load_content(skill.name()).await {
120            Ok(c) => c,
121            Err(e) => {
122                return SkillResult::error(format!("Failed to load skill '{}': {}", skill.name, e));
123            }
124        };
125
126        // Use the new execute() method for full processing pipeline
127        let prompt = skill.execute(args.unwrap_or(""), &content).await;
128
129        let base_result = match self.mode {
130            ExecutionMode::DryRun => SkillResult::success(format!(
131                "[DRY RUN] Skill '{}' prompt:\n\n{}",
132                skill.name, prompt
133            )),
134            ExecutionMode::Callback => {
135                if let Some(ref callback) = self.execution_callback {
136                    match tokio::time::timeout(self.callback_timeout, callback(prompt)).await {
137                        Ok(Ok(result)) => SkillResult::success(result),
138                        Ok(Err(e)) => SkillResult::error(e),
139                        Err(_) => SkillResult::error(format!(
140                            "Skill callback timed out after {:?}",
141                            self.callback_timeout
142                        )),
143                    }
144                } else {
145                    SkillResult::error("No execution callback configured")
146                }
147            }
148            ExecutionMode::InlinePrompt => SkillResult::success(format!(
149                "Execute the following skill instructions:\n\n---\n{}\n---\n\nSkill: {}\nArguments: {}",
150                prompt,
151                skill.name,
152                args.unwrap_or("(none)")
153            )),
154        };
155
156        base_result
157            .with_allowed_tools(skill.allowed_tools.clone())
158            .with_model(skill.model.clone())
159            .with_base_dir(skill.base_dir())
160    }
161
162    fn extract_args(&self, input: &str, skill: &SkillIndex) -> Option<String> {
163        for trigger in &skill.triggers {
164            if let Some(pos) = input.to_lowercase().find(&trigger.to_lowercase()) {
165                let after_trigger = &input[pos + trigger.len()..].trim();
166                if !after_trigger.is_empty() {
167                    return Some(after_trigger.to_string());
168                }
169            }
170        }
171        None
172    }
173
174    /// List all skill names.
175    pub fn list_skills(&self) -> Vec<&str> {
176        self.registry.list()
177    }
178
179    /// Check if a skill exists.
180    pub fn has_skill(&self, name: &str) -> bool {
181        self.registry.contains(name)
182    }
183
184    /// Get a skill by name.
185    pub fn get_skill(&self, name: &str) -> Option<&SkillIndex> {
186        self.registry.get(name)
187    }
188
189    /// Find skill by trigger match.
190    pub fn get_by_trigger(&self, input: &str) -> Option<&SkillIndex> {
191        self.registry.iter().find(|s| s.matches_triggers(input))
192    }
193
194    /// Build summary for system prompt.
195    pub fn build_summary(&self) -> String {
196        self.registry.build_summary()
197    }
198}
199
200impl Default for SkillExecutor {
201    fn default() -> Self {
202        Self::with_defaults()
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use crate::common::ContentSource;
209
210    use super::*;
211
212    fn test_skill(name: &str, content: &str) -> SkillIndex {
213        SkillIndex::new(name, format!("Test skill: {}", name))
214            .with_source(ContentSource::in_memory(content))
215    }
216
217    #[test]
218    fn test_substitute_args() {
219        let content = "Do something with $ARGUMENTS and ${ARGUMENTS}";
220        let result = SkillIndex::substitute_args(content, Some("test args"));
221        assert_eq!(result, "Do something with test args and test args");
222    }
223
224    #[test]
225    fn test_substitute_args_empty() {
226        let content = "Run with: $ARGUMENTS";
227        let result = SkillIndex::substitute_args(content, None);
228        assert_eq!(result, "Run with: ");
229    }
230
231    #[tokio::test]
232    async fn test_execute_not_found() {
233        let executor = SkillExecutor::with_defaults();
234        let result = executor.execute("nonexistent", None).await;
235
236        assert!(!result.success);
237        assert!(result.error.is_some());
238    }
239
240    #[tokio::test]
241    async fn test_execute_skill() {
242        let mut registry = IndexRegistry::new();
243        registry.register(test_skill("test-skill", "Execute: $ARGUMENTS"));
244
245        let executor = SkillExecutor::new(registry);
246        let result = executor.execute("test-skill", Some("my args")).await;
247
248        assert!(result.success);
249        assert!(result.output.contains("my args"));
250    }
251
252    #[tokio::test]
253    async fn test_execute_by_trigger() {
254        let mut registry = IndexRegistry::new();
255        registry.register(
256            SkillIndex::new("commit", "Create commit")
257                .with_source(ContentSource::in_memory("Create commit: $ARGUMENTS"))
258                .with_triggers(["/commit"]),
259        );
260
261        let executor = SkillExecutor::new(registry);
262        let result = executor.execute_by_trigger("/commit fix bug").await;
263
264        assert!(result.is_some());
265        let result = result.unwrap();
266        assert!(result.success);
267        assert!(result.output.contains("fix bug"));
268    }
269
270    #[tokio::test]
271    async fn test_dry_run_mode() {
272        let mut registry = IndexRegistry::new();
273        registry.register(test_skill("test", "Test content"));
274
275        let executor = SkillExecutor::new(registry).with_mode(ExecutionMode::DryRun);
276        let result = executor.execute("test", None).await;
277
278        assert!(result.success);
279        assert!(result.output.contains("[DRY RUN]"));
280    }
281
282    #[test]
283    fn test_list_skills() {
284        let mut registry = IndexRegistry::new();
285        registry.register(test_skill("a", "A"));
286        registry.register(test_skill("b", "B"));
287
288        let executor = SkillExecutor::new(registry);
289        let names = executor.list_skills();
290
291        assert_eq!(names.len(), 2);
292        assert!(names.contains(&"a"));
293        assert!(names.contains(&"b"));
294    }
295
296    #[test]
297    fn test_has_skill() {
298        let mut registry = IndexRegistry::new();
299        registry.register(test_skill("exists", "Content"));
300
301        let executor = SkillExecutor::new(registry);
302        assert!(executor.has_skill("exists"));
303        assert!(!executor.has_skill("missing"));
304    }
305
306    #[tokio::test]
307    async fn test_skill_with_allowed_tools() {
308        let mut registry = IndexRegistry::new();
309        registry.register(
310            SkillIndex::new("reader", "Read files")
311                .with_source(ContentSource::in_memory("Read: $ARGUMENTS"))
312                .with_allowed_tools(["Read", "Grep"]),
313        );
314
315        let executor = SkillExecutor::new(registry);
316        let result = executor.execute("reader", None).await;
317
318        assert!(result.success);
319        assert_eq!(result.allowed_tools, vec!["Read", "Grep"]);
320    }
321
322    #[tokio::test]
323    async fn test_skill_with_model() {
324        let mut registry = IndexRegistry::new();
325        registry.register(
326            SkillIndex::new("fast", "Fast task")
327                .with_source(ContentSource::in_memory("Do: $ARGUMENTS"))
328                .with_model("claude-haiku-4-5-20251001"),
329        );
330
331        let executor = SkillExecutor::new(registry);
332        let result = executor.execute("fast", None).await;
333
334        assert!(result.success);
335        assert_eq!(result.model, Some("claude-haiku-4-5-20251001".to_string()));
336    }
337
338    #[test]
339    fn test_build_summary() {
340        let mut registry = IndexRegistry::new();
341        registry.register(SkillIndex::new("commit", "Create commits"));
342        registry.register(SkillIndex::new("review", "Review code"));
343
344        let executor = SkillExecutor::new(registry);
345        let summary = executor.build_summary();
346
347        assert!(summary.contains("commit"));
348        assert!(summary.contains("review"));
349    }
350}