claude_agent/skills/
skill_tool.rs

1//! SkillTool - tool wrapper for skill execution with progressive disclosure.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::Deserialize;
8use tokio::sync::RwLock;
9
10use super::{SkillExecutor, SkillIndex};
11use crate::common::IndexRegistry;
12use crate::tools::{ExecutionContext, SchemaTool};
13use crate::types::ToolResult;
14
15/// Tool for executing skills with progressive disclosure.
16///
17/// Skills are stored as lightweight indices (metadata only). Full content
18/// is loaded on-demand only when the skill is executed.
19pub struct SkillTool {
20    executor: Arc<RwLock<SkillExecutor>>,
21}
22
23impl SkillTool {
24    /// Create a new SkillTool with the given executor.
25    pub fn new(executor: SkillExecutor) -> Self {
26        Self {
27            executor: Arc::new(RwLock::new(executor)),
28        }
29    }
30
31    /// Create a SkillTool with an empty registry.
32    pub fn with_defaults() -> Self {
33        Self::new(SkillExecutor::with_defaults())
34    }
35
36    /// Create a SkillTool with a pre-populated registry.
37    pub fn with_registry(registry: IndexRegistry<SkillIndex>) -> Self {
38        Self::new(SkillExecutor::new(registry))
39    }
40
41    /// Get a reference to the executor.
42    pub fn executor(&self) -> &Arc<RwLock<SkillExecutor>> {
43        &self.executor
44    }
45
46    /// Generate description with available skills list.
47    ///
48    /// This method generates a complete description including the dynamic
49    /// `<available_skills>` section. Use this when building system prompts
50    /// to give the LLM visibility into registered skills.
51    ///
52    /// The skill list includes only metadata (name, description, tools),
53    /// NOT the full content - this is the progressive disclosure pattern.
54    pub async fn description_with_skills(&self) -> String {
55        let executor = self.executor.read().await;
56        let registry = executor.registry();
57
58        let skills_section = if registry.is_empty() {
59            "<skill>\n<name>No skills registered</name>\n<description>Register skills using IndexRegistry</description>\n</skill>".to_string()
60        } else {
61            registry
62                .iter()
63                .map(|skill| {
64                    let tools_hint = if skill.allowed_tools.is_empty() {
65                        String::new()
66                    } else {
67                        format!("\n<tools>{}</tools>", skill.allowed_tools.join(", "))
68                    };
69
70                    let args_hint = skill
71                        .argument_hint
72                        .as_ref()
73                        .map(|h| format!("\n<args>{}</args>", h))
74                        .unwrap_or_default();
75
76                    format!(
77                        "<skill>\n<name>{}</name>\n<description>{}</description>{}{}\n</skill>",
78                        skill.name, skill.description, tools_hint, args_hint
79                    )
80                })
81                .collect::<Vec<_>>()
82                .join("\n")
83        };
84
85        format!(
86            r#"Execute a skill within the main conversation
87
88<skills_instructions>
89When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
90
91When users ask you to run a "slash command" or reference "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke the corresponding skill.
92
93<example>
94User: "run /commit"
95Assistant: [Calls Skill tool with skill: "commit"]
96</example>
97
98How to invoke:
99- Use this tool with the skill name and optional arguments
100- Examples:
101  - `skill: "pdf"` - invoke the pdf skill
102  - `skill: "commit", args: "-m 'Fix bug'"` - invoke with arguments
103  - `skill: "review-pr", args: "123"` - invoke with arguments
104  - `skill: "ms-office-suite:pdf"` - invoke using fully qualified name
105
106Important:
107- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action
108- NEVER just announce or mention a skill in your text response without actually calling this tool
109- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
110- Only use skills listed in <available_skills> below
111- Do not invoke a skill that is already running
112</skills_instructions>
113
114<available_skills>
115{}
116</available_skills>"#,
117            skills_section
118        )
119    }
120
121    /// Register a skill in the executor's registry.
122    pub async fn register_skill(&self, skill: SkillIndex) {
123        let mut executor = self.executor.write().await;
124        executor.registry_mut().register(skill);
125    }
126
127    /// Register multiple skills.
128    pub async fn register_skills(&self, skills: impl IntoIterator<Item = SkillIndex>) {
129        let mut executor = self.executor.write().await;
130        executor.registry_mut().register_all(skills);
131    }
132}
133
134impl Default for SkillTool {
135    fn default() -> Self {
136        Self::with_defaults()
137    }
138}
139
140#[derive(Debug, Clone, Deserialize, JsonSchema)]
141#[schemars(deny_unknown_fields)]
142pub struct SkillInput {
143    /// The skill name. E.g., "commit", "review-pr", or "pdf"
144    pub skill: String,
145    /// Optional arguments for the skill
146    #[serde(default)]
147    pub args: Option<String>,
148}
149
150#[async_trait]
151impl SchemaTool for SkillTool {
152    type Input = SkillInput;
153
154    const NAME: &'static str = "Skill";
155    const DESCRIPTION: &'static str = r#"Execute a skill within the main conversation
156
157<skills_instructions>
158When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
159
160When users ask you to run a "slash command" or reference "/<something>" (e.g., "/commit", "/review-pr"), they are referring to a skill. Use this tool to invoke the corresponding skill.
161
162<example>
163User: "run /commit"
164Assistant: [Calls Skill tool with skill: "commit"]
165</example>
166
167How to invoke:
168- Use this tool with the skill name and optional arguments
169- Examples:
170  - `skill: "pdf"` - invoke the pdf skill
171  - `skill: "commit", args: "-m 'Fix bug'"` - invoke with arguments
172  - `skill: "review-pr", args: "123"` - invoke with arguments
173  - `skill: "ms-office-suite:pdf"` - invoke using fully qualified name
174
175Important:
176- When a skill is relevant, you must invoke this tool IMMEDIATELY as your first action
177- NEVER just announce or mention a skill in your text response without actually calling this tool
178- This is a BLOCKING REQUIREMENT: invoke the relevant Skill tool BEFORE generating any other response about the task
179- Only use skills that are registered and available
180- Do not invoke a skill that is already running
181</skills_instructions>
182
183Note: For the full list of available skills, use description_with_skills() method when building system prompts."#;
184
185    async fn handle(&self, input: SkillInput, _context: &ExecutionContext) -> ToolResult {
186        let executor = self.executor.read().await;
187        let result = executor.execute(&input.skill, input.args.as_deref()).await;
188
189        if result.success {
190            ToolResult::success(result.output)
191        } else {
192            ToolResult::error(
193                result
194                    .error
195                    .unwrap_or_else(|| "Skill execution failed".to_string()),
196            )
197        }
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::common::ContentSource;
205    use crate::tools::{ExecutionContext, Tool};
206    use crate::types::ToolOutput;
207
208    fn test_context() -> ExecutionContext {
209        ExecutionContext::default()
210    }
211
212    fn test_skill(name: &str, description: &str, content: &str) -> SkillIndex {
213        SkillIndex::new(name, description).with_source(ContentSource::in_memory(content))
214    }
215
216    #[tokio::test]
217    async fn test_skill_tool_execute() {
218        let mut registry = IndexRegistry::new();
219        registry.register(test_skill("test", "Test skill", "Execute with: $ARGUMENTS"));
220
221        let tool = SkillTool::with_registry(registry);
222        let context = test_context();
223
224        let result = tool
225            .execute(
226                serde_json::json!({
227                    "skill": "test",
228                    "args": "my args"
229                }),
230                &context,
231            )
232            .await;
233
234        assert!(!result.is_error());
235        if let ToolOutput::Success(content) = &result.output {
236            assert!(content.contains("my args"));
237        }
238    }
239
240    #[tokio::test]
241    async fn test_skill_tool_not_found() {
242        let tool = SkillTool::with_defaults();
243        let context = test_context();
244
245        let result = tool
246            .execute(
247                serde_json::json!({
248                    "skill": "nonexistent"
249                }),
250                &context,
251            )
252            .await;
253
254        assert!(result.is_error());
255    }
256
257    #[tokio::test]
258    async fn test_skill_tool_with_defaults() {
259        let tool = SkillTool::with_defaults();
260        let executor = tool.executor.read().await;
261        assert!(!executor.has_skill("nonexistent"));
262    }
263
264    #[tokio::test]
265    async fn test_description_with_skills() {
266        let mut registry = IndexRegistry::new();
267        registry.register(test_skill("test-skill", "A test skill", "template"));
268        registry.register(test_skill("another-skill", "Another skill", "template"));
269
270        let tool = SkillTool::with_registry(registry);
271        let desc = tool.description_with_skills().await;
272
273        assert!(desc.contains("<skills_instructions>"));
274        assert!(desc.contains("</skills_instructions>"));
275        assert!(desc.contains("<available_skills>"));
276        assert!(desc.contains("</available_skills>"));
277        assert!(desc.contains("test-skill"));
278        assert!(desc.contains("another-skill"));
279    }
280
281    #[tokio::test]
282    async fn test_description_with_tools_and_args() {
283        let mut registry = IndexRegistry::new();
284        registry.register(
285            SkillIndex::new("reader", "Read files")
286                .with_source(ContentSource::in_memory("content"))
287                .with_allowed_tools(["Read", "Grep"])
288                .with_argument_hint("<file_path>"),
289        );
290
291        let tool = SkillTool::with_registry(registry);
292        let desc = tool.description_with_skills().await;
293
294        assert!(desc.contains("<tools>Read, Grep</tools>"));
295        assert!(desc.contains("<args><file_path></args>"));
296    }
297
298    #[tokio::test]
299    async fn test_register_skill() {
300        let tool = SkillTool::with_defaults();
301
302        tool.register_skill(test_skill("dynamic", "Dynamic skill", "content"))
303            .await;
304
305        let executor = tool.executor.read().await;
306        assert!(executor.has_skill("dynamic"));
307    }
308
309    #[test]
310    fn test_description_has_skills_instructions_tag() {
311        use crate::tools::Tool;
312
313        let tool = SkillTool::with_defaults();
314        let desc = tool.description();
315
316        assert!(desc.contains("<skills_instructions>"));
317        assert!(desc.contains("</skills_instructions>"));
318    }
319}