Skip to main content

microclaw_engine/tools/
activate_skill.rs

1use 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    /// Attach the storage handle so successful activations get logged to
33    /// `skill_activation_logs`. Optional: tests / standalone uses can
34    /// skip the DB and the tool just won't track usage.
35    pub fn with_db(mut self, db: Arc<Database>) -> Self {
36        self.db = Some(db);
37        self
38    }
39
40    /// Apply the configured ClawHub load-time verification policy so
41    /// tampered managed skills cannot be activated.
42    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}