use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use tracing::{info, warn};
use crate::internal::storage::db::Database;
use crate::skills::SkillManager;
use microclaw_core::llm_types::ToolDefinition;
use super::{auth_context_from_input, schema_object, Tool, ToolResult};
pub struct ActivateSkillTool {
skill_manager: SkillManager,
db: Option<Arc<Database>>,
}
impl ActivateSkillTool {
pub fn new(skills_dir: &str) -> Self {
ActivateSkillTool {
skill_manager: SkillManager::from_skills_dir(skills_dir),
db: None,
}
}
pub fn new_with_runtime(skills_dir: &str, runtime_dir: &str) -> Self {
ActivateSkillTool {
skill_manager: SkillManager::from_skills_and_runtime(skills_dir, runtime_dir),
db: None,
}
}
pub fn with_db(mut self, db: Arc<Database>) -> Self {
self.db = Some(db);
self
}
pub fn with_config_verification(mut self, config: &crate::config::Config) -> Self {
self.skill_manager = self.skill_manager.with_config_verification(config);
self
}
}
#[async_trait]
impl Tool for ActivateSkillTool {
fn name(&self) -> &str {
"activate_skill"
}
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "activate_skill".into(),
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(),
input_schema: schema_object(
json!({
"skill_name": {
"type": "string",
"description": "The name of the skill to activate"
}
}),
&["skill_name"],
),
}
}
async fn execute(&self, input: serde_json::Value) -> ToolResult {
let skill_name = match input.get("skill_name").and_then(|v| v.as_str()) {
Some(n) => n,
None => return ToolResult::error("Missing required parameter: skill_name".into()),
};
info!("Activating skill: {}", skill_name);
match self.skill_manager.load_skill_checked(skill_name) {
Ok((meta, body)) => {
let mut lifecycle_state = None;
if let Some(db) = &self.db {
let version = meta
.version
.as_deref()
.and_then(|value| value.parse::<i64>().ok())
.unwrap_or(1);
if db.get_skill_lifecycle(&meta.name).ok().flatten().is_none() {
let skill_file = meta.dir_path.join("SKILL.md");
if let Ok(content) = std::fs::read_to_string(&skill_file) {
if let Err(e) =
db.register_skill_version(&meta.name, version, &content, &meta.source)
{
warn!(skill = %meta.name, "Failed to register skill version: {e}");
}
}
}
lifecycle_state = db
.get_skill_lifecycle(&meta.name)
.ok()
.flatten()
.map(|lifecycle| lifecycle.state);
if lifecycle_state
.as_deref()
.is_some_and(|state| matches!(state, "degraded" | "archived"))
{
return ToolResult::error(format!(
"Skill '{}' is {} and cannot be activated. Roll it back or publish a corrected version first.",
meta.name,
lifecycle_state.as_deref().unwrap_or("unavailable")
));
}
let chat_id = auth_context_from_input(&input)
.map(|a| a.caller_chat_id)
.unwrap_or(0);
match db.evaluate_skill_applicability(&meta.name, chat_id) {
Ok(applicability) if !applicability.allowed => {
return ToolResult::error(format!(
"Skill '{}' is contraindicated for the current environment: {} \
({} verified outcomes, {:.0}% pass rate). Publish a corrected \
version or use a different skill.",
meta.name,
applicability.reason,
applicability.matching_outcomes,
applicability.success_rate.unwrap_or(0.0) * 100.0
));
}
Ok(_) => {}
Err(e) => {
warn!(skill = %meta.name, "Failed to evaluate skill applicability: {e}");
}
}
if let Err(e) = db.log_skill_activation(&meta.name, chat_id) {
warn!(skill = %meta.name, "Failed to log skill activation: {e}");
}
}
let mut result = format!("# Skill: {}\n\n", meta.name);
result.push_str(&format!("Description: {}\n", meta.description));
result.push_str(&format!("Skill directory: {}\n", meta.dir_path.display()));
result.push_str(&format!("Source: {}\n", meta.source));
if let Some(version) = &meta.version {
result.push_str(&format!("Version: {}\n", version));
}
if let Some(updated_at) = &meta.updated_at {
result.push_str(&format!("Updated at: {}\n", updated_at));
}
if let Some(state) = lifecycle_state {
result.push_str(&format!("Learning lifecycle: {state}\n"));
if matches!(state.as_str(), "candidate" | "trial") {
result.push_str(
"Evaluation note: this skill is still under verified trial; validate the task outcome carefully.\n",
);
}
}
if !meta.platforms.is_empty() {
result.push_str(&format!("Platforms: {}\n", meta.platforms.join(", ")));
}
if !meta.deps.is_empty() {
result.push_str(&format!("Dependencies: {}\n", meta.deps.join(", ")));
}
result.push_str("\n## Instructions\n\n");
result.push_str(&body);
let mut tool_result = ToolResult::success(result);
if let Some(env_file_name) = &meta.env_file {
let env_path = meta.dir_path.join(env_file_name);
if env_path.exists() {
tool_result = tool_result.with_metadata(json!({
"skill_env_file": env_path.to_string_lossy(),
}));
}
}
tool_result
}
Err(e) => ToolResult::error(format!(
"{}\nHint: Run `/skills` to list available skills, or `microclaw skill available --all` for unavailable-skill diagnostics.",
e
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::path::PathBuf;
fn test_dir() -> PathBuf {
std::env::temp_dir().join(format!(
"microclaw_activate_skill_test_{}",
uuid::Uuid::new_v4()
))
}
fn create_skill(base_dir: &std::path::Path, name: &str, desc: &str, body: &str) {
let skill_dir = base_dir.join(name);
std::fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {desc}\n---\n{body}\n");
std::fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
fn cleanup(dir: &std::path::Path) {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn test_activate_skill_tool_name_and_definition() {
let dir = test_dir();
let tool = ActivateSkillTool::new(dir.to_str().unwrap());
assert_eq!(tool.name(), "activate_skill");
let def = tool.definition();
assert_eq!(def.name, "activate_skill");
assert!(!def.description.is_empty());
assert!(def.input_schema["properties"]["skill_name"].is_object());
let required = def.input_schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "skill_name");
cleanup(&dir);
}
#[tokio::test]
async fn test_activate_skill_found() {
let dir = test_dir();
create_skill(
&dir,
"pdf",
"Convert to PDF",
"Use pdflatex to convert documents.",
);
let tool = ActivateSkillTool::new(dir.to_str().unwrap());
let result = tool.execute(json!({"skill_name": "pdf"})).await;
assert!(!result.is_error);
assert!(result.content.contains("# Skill: pdf"));
assert!(result.content.contains("Convert to PDF"));
assert!(result
.content
.contains("Use pdflatex to convert documents."));
assert!(result.content.contains("Skill directory:"));
cleanup(&dir);
}
#[tokio::test]
async fn test_activate_skill_not_found() {
let dir = test_dir();
create_skill(&dir, "pdf", "Convert to PDF", "Instructions");
let tool = ActivateSkillTool::new(dir.to_str().unwrap());
let result = tool.execute(json!({"skill_name": "nonexistent"})).await;
assert!(result.is_error);
assert!(result.content.contains("not found"));
assert!(result.content.contains("pdf"));
cleanup(&dir);
}
#[tokio::test]
async fn test_activate_skill_not_found_no_skills() {
let dir = test_dir();
let tool = ActivateSkillTool::new(dir.to_str().unwrap());
let result = tool.execute(json!({"skill_name": "anything"})).await;
assert!(result.is_error);
assert!(result.content.contains("No skills are currently available"));
cleanup(&dir);
}
#[tokio::test]
async fn test_activate_skill_missing_param() {
let dir = test_dir();
let tool = ActivateSkillTool::new(dir.to_str().unwrap());
let result = tool.execute(json!({})).await;
assert!(result.is_error);
assert!(result.content.contains("Missing required parameter"));
cleanup(&dir);
}
}