microclaw_engine/tools/
activate_skill.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4use tracing::{info, warn};
5
6use crate::internal::storage::db::Database;
7use crate::skills::SkillManager;
8use microclaw_core::llm_types::ToolDefinition;
9
10use super::{auth_context_from_input, schema_object, Tool, ToolResult};
11
12pub struct ActivateSkillTool {
13 skill_manager: SkillManager,
14 db: Option<Arc<Database>>,
15}
16
17impl ActivateSkillTool {
18 pub fn new(skills_dir: &str) -> Self {
19 ActivateSkillTool {
20 skill_manager: SkillManager::from_skills_dir(skills_dir),
21 db: None,
22 }
23 }
24
25 pub fn new_with_runtime(skills_dir: &str, runtime_dir: &str) -> Self {
26 ActivateSkillTool {
27 skill_manager: SkillManager::from_skills_and_runtime(skills_dir, runtime_dir),
28 db: None,
29 }
30 }
31
32 pub fn with_db(mut self, db: Arc<Database>) -> Self {
36 self.db = Some(db);
37 self
38 }
39
40 pub fn with_config_verification(mut self, config: &crate::config::Config) -> Self {
43 self.skill_manager = self.skill_manager.with_config_verification(config);
44 self
45 }
46}
47
48#[async_trait]
49impl Tool for ActivateSkillTool {
50 fn name(&self) -> &str {
51 "activate_skill"
52 }
53
54 fn definition(&self) -> ToolDefinition {
55 ToolDefinition {
56 name: "activate_skill".into(),
57 description: "Activate an agent skill to load its full instructions. IMPORTANT: You must CALL this tool (not write it as text) to activate a skill. Use this when you see a relevant skill in the available skills list and need its detailed instructions to complete a task. Skills are filtered by platform/dependencies before they are listed.".into(),
58 input_schema: schema_object(
59 json!({
60 "skill_name": {
61 "type": "string",
62 "description": "The name of the skill to activate"
63 }
64 }),
65 &["skill_name"],
66 ),
67 }
68 }
69
70 async fn execute(&self, input: serde_json::Value) -> ToolResult {
71 let skill_name = match input.get("skill_name").and_then(|v| v.as_str()) {
72 Some(n) => n,
73 None => return ToolResult::error("Missing required parameter: skill_name".into()),
74 };
75
76 info!("Activating skill: {}", skill_name);
77
78 match self.skill_manager.load_skill_checked(skill_name) {
79 Ok((meta, body)) => {
80 let mut lifecycle_state = None;
81 if let Some(db) = &self.db {
82 let version = meta
83 .version
84 .as_deref()
85 .and_then(|value| value.parse::<i64>().ok())
86 .unwrap_or(1);
87 if db.get_skill_lifecycle(&meta.name).ok().flatten().is_none() {
88 let skill_file = meta.dir_path.join("SKILL.md");
89 if let Ok(content) = std::fs::read_to_string(&skill_file) {
90 if let Err(e) =
91 db.register_skill_version(&meta.name, version, &content, &meta.source)
92 {
93 warn!(skill = %meta.name, "Failed to register skill version: {e}");
94 }
95 }
96 }
97 lifecycle_state = db
98 .get_skill_lifecycle(&meta.name)
99 .ok()
100 .flatten()
101 .map(|lifecycle| lifecycle.state);
102 if lifecycle_state
103 .as_deref()
104 .is_some_and(|state| matches!(state, "degraded" | "archived"))
105 {
106 return ToolResult::error(format!(
107 "Skill '{}' is {} and cannot be activated. Roll it back or publish a corrected version first.",
108 meta.name,
109 lifecycle_state.as_deref().unwrap_or("unavailable")
110 ));
111 }
112 let chat_id = auth_context_from_input(&input)
113 .map(|a| a.caller_chat_id)
114 .unwrap_or(0);
115 match db.evaluate_skill_applicability(&meta.name, chat_id) {
116 Ok(applicability) if !applicability.allowed => {
117 return ToolResult::error(format!(
118 "Skill '{}' is contraindicated for the current environment: {} \
119 ({} verified outcomes, {:.0}% pass rate). Publish a corrected \
120 version or use a different skill.",
121 meta.name,
122 applicability.reason,
123 applicability.matching_outcomes,
124 applicability.success_rate.unwrap_or(0.0) * 100.0
125 ));
126 }
127 Ok(_) => {}
128 Err(e) => {
129 warn!(skill = %meta.name, "Failed to evaluate skill applicability: {e}");
130 }
131 }
132 if let Err(e) = db.log_skill_activation(&meta.name, chat_id) {
133 warn!(skill = %meta.name, "Failed to log skill activation: {e}");
134 }
135 }
136 let mut result = format!("# Skill: {}\n\n", meta.name);
137 result.push_str(&format!("Description: {}\n", meta.description));
138 result.push_str(&format!("Skill directory: {}\n", meta.dir_path.display()));
139 result.push_str(&format!("Source: {}\n", meta.source));
140 if let Some(version) = &meta.version {
141 result.push_str(&format!("Version: {}\n", version));
142 }
143 if let Some(updated_at) = &meta.updated_at {
144 result.push_str(&format!("Updated at: {}\n", updated_at));
145 }
146 if let Some(state) = lifecycle_state {
147 result.push_str(&format!("Learning lifecycle: {state}\n"));
148 if matches!(state.as_str(), "candidate" | "trial") {
149 result.push_str(
150 "Evaluation note: this skill is still under verified trial; validate the task outcome carefully.\n",
151 );
152 }
153 }
154 if !meta.platforms.is_empty() {
155 result.push_str(&format!("Platforms: {}\n", meta.platforms.join(", ")));
156 }
157 if !meta.deps.is_empty() {
158 result.push_str(&format!("Dependencies: {}\n", meta.deps.join(", ")));
159 }
160 result.push_str("\n## Instructions\n\n");
161 result.push_str(&body);
162 let mut tool_result = ToolResult::success(result);
163 if let Some(env_file_name) = &meta.env_file {
164 let env_path = meta.dir_path.join(env_file_name);
165 if env_path.exists() {
166 tool_result = tool_result.with_metadata(json!({
167 "skill_env_file": env_path.to_string_lossy(),
168 }));
169 }
170 }
171 tool_result
172 }
173 Err(e) => ToolResult::error(format!(
174 "{}\nHint: Run `/skills` to list available skills, or `microclaw skill available --all` for unavailable-skill diagnostics.",
175 e
176 )),
177 }
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use serde_json::json;
185 use std::path::PathBuf;
186
187 fn test_dir() -> PathBuf {
188 std::env::temp_dir().join(format!(
189 "microclaw_activate_skill_test_{}",
190 uuid::Uuid::new_v4()
191 ))
192 }
193
194 fn create_skill(base_dir: &std::path::Path, name: &str, desc: &str, body: &str) {
195 let skill_dir = base_dir.join(name);
196 std::fs::create_dir_all(&skill_dir).unwrap();
197 let content = format!("---\nname: {name}\ndescription: {desc}\n---\n{body}\n");
198 std::fs::write(skill_dir.join("SKILL.md"), content).unwrap();
199 }
200
201 fn cleanup(dir: &std::path::Path) {
202 let _ = std::fs::remove_dir_all(dir);
203 }
204
205 #[test]
206 fn test_activate_skill_tool_name_and_definition() {
207 let dir = test_dir();
208 let tool = ActivateSkillTool::new(dir.to_str().unwrap());
209 assert_eq!(tool.name(), "activate_skill");
210 let def = tool.definition();
211 assert_eq!(def.name, "activate_skill");
212 assert!(!def.description.is_empty());
213 assert!(def.input_schema["properties"]["skill_name"].is_object());
214 let required = def.input_schema["required"].as_array().unwrap();
215 assert_eq!(required.len(), 1);
216 assert_eq!(required[0], "skill_name");
217 cleanup(&dir);
218 }
219
220 #[tokio::test]
221 async fn test_activate_skill_found() {
222 let dir = test_dir();
223 create_skill(
224 &dir,
225 "pdf",
226 "Convert to PDF",
227 "Use pdflatex to convert documents.",
228 );
229
230 let tool = ActivateSkillTool::new(dir.to_str().unwrap());
231 let result = tool.execute(json!({"skill_name": "pdf"})).await;
232 assert!(!result.is_error);
233 assert!(result.content.contains("# Skill: pdf"));
234 assert!(result.content.contains("Convert to PDF"));
235 assert!(result
236 .content
237 .contains("Use pdflatex to convert documents."));
238 assert!(result.content.contains("Skill directory:"));
239 cleanup(&dir);
240 }
241
242 #[tokio::test]
243 async fn test_activate_skill_not_found() {
244 let dir = test_dir();
245 create_skill(&dir, "pdf", "Convert to PDF", "Instructions");
246
247 let tool = ActivateSkillTool::new(dir.to_str().unwrap());
248 let result = tool.execute(json!({"skill_name": "nonexistent"})).await;
249 assert!(result.is_error);
250 assert!(result.content.contains("not found"));
251 assert!(result.content.contains("pdf"));
252 cleanup(&dir);
253 }
254
255 #[tokio::test]
256 async fn test_activate_skill_not_found_no_skills() {
257 let dir = test_dir();
258 let tool = ActivateSkillTool::new(dir.to_str().unwrap());
259 let result = tool.execute(json!({"skill_name": "anything"})).await;
260 assert!(result.is_error);
261 assert!(result.content.contains("No skills are currently available"));
262 cleanup(&dir);
263 }
264
265 #[tokio::test]
266 async fn test_activate_skill_missing_param() {
267 let dir = test_dir();
268 let tool = ActivateSkillTool::new(dir.to_str().unwrap());
269 let result = tool.execute(json!({})).await;
270 assert!(result.is_error);
271 assert!(result.content.contains("Missing required parameter"));
272 cleanup(&dir);
273 }
274}