opendev_tools_impl/invoke_skill/
mod.rs1mod 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
19pub struct InvokeSkillTool {
28 skill_loader: Arc<Mutex<SkillLoader>>,
29 pub(crate) invoked_skills: Mutex<HashSet<String>>,
31 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 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 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 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 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 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;