use std::sync::Arc; use async_trait::async_trait; use serde_json::json; use crate::skills::SkillRuntime; use crate::storage::SkillEventRepository; use crate::tools::check_null_args; use crate::tools::traits::{Tool, ToolContext, ToolResult}; pub struct SkillActivateTool { skills: Arc, events: Arc, } impl SkillActivateTool { pub fn new(skills: Arc, events: Arc) -> Self { Self { skills, events } } fn record_event( &self, context: &ToolContext, event_type: &str, skill_name: Option<&str>, payload: &serde_json::Value, ) { if let Err(err) = self.events.append_skill_event( context.session_id.as_deref(), event_type, skill_name, payload, ) { tracing::warn!(error = %err, event_type, skill_name, "Failed to record skill activation event"); } } } #[async_trait] impl Tool for SkillActivateTool { fn name(&self) -> &str { "skill_activate" } fn description(&self) -> &str { "Load detailed instructions for a named skill discovered from SKILL.md files. Use when a task matches a listed skill description." } fn parameters_schema(&self) -> serde_json::Value { json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Skill name from the available skills list" } }, "required": ["name"] }) } async fn execute(&self, args: serde_json::Value) -> anyhow::Result { self.execute_with_context(&ToolContext::default(), args) .await } async fn execute_with_context( &self, context: &ToolContext, args: serde_json::Value, ) -> anyhow::Result { if let Some(result) = check_null_args(&args, "skill_activate") { return Ok(result); } let skill_name = match args.get("name").and_then(|value| value.as_str()) { Some(name) if !name.trim().is_empty() => name, _ => { self.record_event( context, "activation_failed", None, &json!({ "reason": "missing_name", "arguments": args, }), ); return Ok(error_result("Missing required parameter: name")); } }; match self.skills.activation_payload(skill_name) { Ok(output) => { if let Ok(payload) = self.skills.activation_event_payload(skill_name) { self.record_event(context, "activated", Some(skill_name), &payload); } Ok(ToolResult { success: true, output, error: None, }) } Err(err) => { self.record_event( context, "activation_failed", Some(skill_name), &json!({ "reason": err, "arguments": args, }), ); Ok(error_result(&err)) } } } } fn error_result(message: &str) -> ToolResult { ToolResult { success: false, output: String::new(), error: Some(message.to_string()), } } #[cfg(test)] mod tests { use super::*; use crate::storage::SessionStore; const TEST_CHANNEL: &str = "test-channel"; #[tokio::test] async fn test_skill_activate_records_failed_activation_event() { let skills = Arc::new(SkillRuntime::default()); let store = Arc::new(SessionStore::in_memory().unwrap()); store .ensure_channel_session(TEST_CHANNEL, "chat-1") .unwrap(); let tool = SkillActivateTool::new(skills, store.clone()); let context = ToolContext { session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), ..ToolContext::default() }; let result = tool .execute_with_context(&context, json!({ "name": "demo" })) .await .unwrap(); assert!(!result.success); assert!(result.error.unwrap().contains("not found")); let events = store .list_skill_events(Some(&format!("{}:chat-1", TEST_CHANNEL))) .unwrap(); assert_eq!(events.len(), 1); assert_eq!(events[0].event_type, "activation_failed"); assert_eq!(events[0].skill_name.as_deref(), Some("demo")); } #[tokio::test] async fn test_skill_activate_handles_null_args() { let skills = Arc::new(SkillRuntime::default()); let store = Arc::new(SessionStore::in_memory().unwrap()); store .ensure_channel_session(TEST_CHANNEL, "chat-1") .unwrap(); let tool = SkillActivateTool::new(skills, store.clone()); let context = ToolContext { session_id: Some(format!("{}:chat-1", TEST_CHANNEL)), ..ToolContext::default() }; let result = tool .execute_with_context(&context, serde_json::Value::Null) .await .unwrap(); assert!(!result.success); assert!( result .error .unwrap() .contains("Missing required parameters") ); } }