Skip to main content

opendev_tools_impl/invoke_skill/
mod.rs

1//! invoke_skill tool — loads skill content into conversation context on demand.
2//!
3//! Supports listing available skills, loading by name (with namespace),
4//! and session-scoped deduplication to avoid re-loading the same skill.
5
6mod arguments;
7mod mcp;
8
9use std::collections::{HashMap, HashSet};
10use std::sync::{Arc, Mutex};
11
12use opendev_mcp::McpManager;
13use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
14
15use opendev_agents::skills::SkillLoader;
16
17use arguments::expand_skill_arguments;
18
19/// Tool that loads skill content into the conversation context.
20///
21/// Skills are markdown files with YAML frontmatter discovered from:
22/// - `<project>/.opendev/skills/` (highest priority)
23/// - `~/.opendev/skills/`
24/// - Built-in skills embedded in the binary
25///
26/// Also surfaces MCP prompts as invokable commands using `server:prompt` syntax.
27pub struct InvokeSkillTool {
28    skill_loader: Arc<Mutex<SkillLoader>>,
29    /// Tracks which skills have been invoked this session to avoid re-loading.
30    pub(crate) invoked_skills: Mutex<HashSet<String>>,
31    /// Optional MCP manager for surfacing MCP prompts.
32    mcp_manager: Option<Arc<McpManager>>,
33}
34
35impl std::fmt::Debug for InvokeSkillTool {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("InvokeSkillTool")
38            .field("skill_loader", &"<SkillLoader>")
39            .field("invoked_skills", &self.invoked_skills)
40            .field(
41                "mcp_manager",
42                &self.mcp_manager.as_ref().map(|_| "<McpManager>"),
43            )
44            .finish()
45    }
46}
47
48impl InvokeSkillTool {
49    /// Create a new invoke_skill tool with a shared skill loader.
50    pub fn new(skill_loader: Arc<Mutex<SkillLoader>>) -> Self {
51        Self {
52            skill_loader,
53            invoked_skills: Mutex::new(HashSet::new()),
54            mcp_manager: None,
55        }
56    }
57
58    /// Create a new invoke_skill tool with MCP prompt support.
59    pub fn with_mcp(skill_loader: Arc<Mutex<SkillLoader>>, mcp_manager: Arc<McpManager>) -> Self {
60        Self {
61            skill_loader,
62            invoked_skills: Mutex::new(HashSet::new()),
63            mcp_manager: Some(mcp_manager),
64        }
65    }
66}
67
68#[async_trait::async_trait]
69impl BaseTool for InvokeSkillTool {
70    fn name(&self) -> &str {
71        "invoke_skill"
72    }
73
74    fn description(&self) -> &str {
75        "Load a predefined skill that the user explicitly mentioned by name (e.g. /commit, review-pr). \
76         Do NOT use for general tasks like code exploration, summarization, or analysis — \
77         use spawn_subagent for those instead. Only call when the user's message contains a skill name. \
78         Call without skill_name to list available skills."
79    }
80
81    fn parameter_schema(&self) -> serde_json::Value {
82        serde_json::json!({
83            "type": "object",
84            "properties": {
85                "skill_name": {
86                    "type": "string",
87                    "description": "Name of the skill to load (e.g. 'commit', 'git:rebase'). Omit to list all available skills."
88                },
89                "arguments": {
90                    "type": "string",
91                    "description": "Optional arguments to pass to the skill template. \
92                                    Skills can use $ARGUMENTS for the full string, or $1, $2, etc. for positional args."
93                }
94            },
95            "required": []
96        })
97    }
98
99    async fn execute(
100        &self,
101        args: HashMap<String, serde_json::Value>,
102        _ctx: &ToolContext,
103    ) -> ToolResult {
104        let skill_name = args
105            .get("skill_name")
106            .and_then(|v| v.as_str())
107            .unwrap_or("")
108            .trim();
109
110        enum SkillLookup {
111            ListOnly(Vec<String>),
112            SubagentRedirect(String),
113            Found(Box<opendev_agents::skills::LoadedSkill>),
114            NotFound(Vec<String>),
115        }
116
117        let lookup = {
118            let mut loader = match self.skill_loader.lock() {
119                Ok(l) => l,
120                Err(_) => return ToolResult::fail("Failed to acquire skill loader lock"),
121            };
122
123            if skill_name.is_empty() {
124                SkillLookup::ListOnly(loader.get_skill_names())
125            } else {
126                let subagent_types = [
127                    "explore",
128                    "code-explorer",
129                    "code_explorer",
130                    "planner",
131                    "general",
132                    "build",
133                    "ask-user",
134                    "ask_user",
135                ];
136                let normalized = skill_name.to_lowercase();
137                if subagent_types.iter().any(|t| normalized == *t) {
138                    SkillLookup::SubagentRedirect(normalized.replace('-', "_"))
139                } else {
140                    match loader.load_skill(skill_name) {
141                        Some(s) => SkillLookup::Found(Box::new(s)),
142                        None => SkillLookup::NotFound(loader.get_skill_names()),
143                    }
144                }
145            }
146        };
147
148        match lookup {
149            SkillLookup::ListOnly(names) => {
150                let mut sorted = names;
151                sorted.sort();
152
153                let mcp_prompts = if let Some(ref mgr) = self.mcp_manager {
154                    mgr.list_prompts().await
155                } else {
156                    vec![]
157                };
158
159                if sorted.is_empty() && mcp_prompts.is_empty() {
160                    return ToolResult::ok("No skills available.");
161                }
162
163                let mut output = String::new();
164                if !sorted.is_empty() {
165                    output.push_str(&format!("Available skills: {}", sorted.join(", ")));
166                }
167                if !mcp_prompts.is_empty() {
168                    if !output.is_empty() {
169                        output.push_str("\n\n");
170                    }
171                    output.push_str("MCP prompts:\n");
172                    for p in &mcp_prompts {
173                        let args_str = if p.arguments.is_empty() {
174                            String::new()
175                        } else {
176                            format!(" (args: {})", p.arguments.join(", "))
177                        };
178                        output.push_str(&format!(
179                            "  {} — {}{}\n",
180                            p.command, p.description, args_str
181                        ));
182                    }
183                }
184                return ToolResult::ok(output.trim_end().to_string());
185            }
186            SkillLookup::SubagentRedirect(agent_type) => {
187                return ToolResult::fail(format!(
188                    "'{skill_name}' is a subagent type, not a skill. \
189                     Use spawn_subagent with agent_type: \"{agent_type}\" instead. \
190                     invoke_skill is only for loading predefined skills the user mentioned by name \
191                     (e.g. /commit, /review-pr)."
192                ));
193            }
194            SkillLookup::NotFound(skill_names) => {
195                if let Some(ref mgr) = self.mcp_manager
196                    && let Some(result) = self.try_mcp_prompt(mgr, skill_name, &args).await
197                {
198                    return result;
199                }
200
201                let available = if skill_names.is_empty() {
202                    "None".to_string()
203                } else {
204                    let mut sorted = skill_names;
205                    sorted.sort();
206                    sorted.join(", ")
207                };
208                return ToolResult::fail(format!(
209                    "Skill not found: '{skill_name}'. \
210                     invoke_skill is only for predefined skills the user mentioned by name \
211                     (e.g. /commit, /review-pr). For general tasks like code exploration or \
212                     summarization, use spawn_subagent instead. Available skills: {available}"
213                ));
214            }
215            SkillLookup::Found(_) => {}
216        }
217
218        let SkillLookup::Found(skill) = lookup else {
219            unreachable!()
220        };
221
222        // Dedup: if already invoked this session, return a short reminder.
223        if let Ok(mut invoked) = self.invoked_skills.lock() {
224            if invoked.contains(skill_name) {
225                let mut meta = HashMap::new();
226                meta.insert(
227                    "skill_name".to_string(),
228                    serde_json::json!(skill.metadata.name),
229                );
230                meta.insert(
231                    "skill_namespace".to_string(),
232                    serde_json::json!(skill.metadata.namespace),
233                );
234                return ToolResult::ok_with_metadata(
235                    format!(
236                        "Skill '{}' is already loaded in this conversation. \
237                         Refer to the skill content above and proceed with the next action step — \
238                         do not invoke this skill again.",
239                        skill.metadata.name
240                    ),
241                    meta,
242                );
243            }
244            invoked.insert(skill_name.to_string());
245        }
246
247        // Apply argument substitution if provided.
248        let arguments = args
249            .get("arguments")
250            .and_then(|v| v.as_str())
251            .unwrap_or("")
252            .trim();
253        let skill_content = if !arguments.is_empty() {
254            expand_skill_arguments(&skill.content, arguments)
255        } else {
256            skill.content.clone()
257        };
258
259        // Return the full skill content with metadata.
260        let mut meta = HashMap::new();
261        meta.insert(
262            "skill_name".to_string(),
263            serde_json::json!(skill.metadata.name),
264        );
265        meta.insert(
266            "skill_namespace".to_string(),
267            serde_json::json!(skill.metadata.namespace),
268        );
269        if let Some(ref model) = skill.metadata.model {
270            meta.insert("skill_model".to_string(), serde_json::json!(model));
271        }
272        if let Some(ref agent) = skill.metadata.agent {
273            meta.insert("skill_agent".to_string(), serde_json::json!(agent));
274        }
275
276        let token_estimate = skill_content.len() / 4;
277        meta.insert("token_estimate".into(), serde_json::json!(token_estimate));
278
279        let mut output = format!(
280            "Loaded skill: {} (~{} tokens)\n\n<skill_content name=\"{}\">\n{}\n</skill_content>",
281            skill.metadata.name, token_estimate, skill.metadata.name, skill_content
282        );
283
284        if !skill.companion_files.is_empty() {
285            let base_dir = skill
286                .metadata
287                .path
288                .as_ref()
289                .and_then(|p| p.parent())
290                .map(|p| p.display().to_string())
291                .unwrap_or_default();
292
293            output.push_str("\n\n<skill_files>\n");
294            for cf in &skill.companion_files {
295                output.push_str(&format!("<file>{}</file>\n", cf.path.display()));
296            }
297            output.push_str("</skill_files>\n");
298            output.push_str(&format!(
299                "\nBase directory for this skill: {}\n\
300                 Relative paths in this skill are relative to this base directory.\n\
301                 Note: file list is sampled.",
302                base_dir
303            ));
304        }
305
306        ToolResult::ok_with_metadata(output, meta)
307    }
308}
309
310#[cfg(test)]
311mod tests;