tycode_core/skills/
tool.rs1use std::sync::Arc;
2
3use anyhow::{bail, Result};
4use serde_json::{json, Value};
5
6use crate::chat::events::{ToolExecutionResult, ToolRequest as ToolRequestEvent, ToolRequestType};
7use crate::tools::r#trait::{
8 ContinuationPreference, ToolCallHandle, ToolCategory, ToolExecutor, ToolOutput, ToolRequest,
9};
10use crate::tools::ToolName;
11
12use super::context::InvokedSkillsState;
13use super::discovery::SkillsManager;
14
15pub struct InvokeSkillTool {
17 manager: SkillsManager,
18 state: Arc<InvokedSkillsState>,
19}
20
21impl InvokeSkillTool {
22 pub fn new(manager: SkillsManager, state: Arc<InvokedSkillsState>) -> Self {
23 Self { manager, state }
24 }
25
26 pub fn tool_name() -> ToolName {
27 ToolName::new("invoke_skill")
28 }
29}
30
31struct InvokeSkillHandle {
32 skill_name: String,
33 tool_use_id: String,
34 manager: SkillsManager,
35 state: Arc<InvokedSkillsState>,
36}
37
38#[async_trait::async_trait(?Send)]
39impl ToolCallHandle for InvokeSkillHandle {
40 fn tool_request(&self) -> ToolRequestEvent {
41 ToolRequestEvent {
42 tool_call_id: self.tool_use_id.clone(),
43 tool_name: "invoke_skill".to_string(),
44 tool_type: ToolRequestType::Other {
45 args: json!({ "skill_name": self.skill_name }),
46 },
47 }
48 }
49
50 async fn execute(self: Box<Self>) -> ToolOutput {
51 match self.manager.load_instructions(&self.skill_name) {
53 Ok(skill) => {
54 self.state
56 .add_invoked(skill.metadata.name.clone(), skill.instructions.clone());
57
58 let mut response = format!(
60 "Skill '{}' loaded successfully.\n\n## Instructions\n\n{}",
61 skill.metadata.name, skill.instructions
62 );
63
64 if !skill.reference_files.is_empty() {
66 response.push_str("\n\n## Reference Files\n\n");
67 response.push_str(
68 "The following reference files are available. Use the read_file tool to access them:\n",
69 );
70 for file in &skill.reference_files {
71 response.push_str(&format!("- {}\n", file.display()));
72 }
73 }
74
75 if !skill.scripts.is_empty() {
77 response.push_str("\n\n## Scripts\n\n");
78 response
79 .push_str("The following scripts are available for use with this skill:\n");
80 for script in &skill.scripts {
81 response.push_str(&format!("- {}\n", script.display()));
82 }
83 }
84
85 ToolOutput::Result {
86 content: response,
87 is_error: false,
88 continuation: ContinuationPreference::Continue,
89 ui_result: ToolExecutionResult::Other {
90 result: json!({
91 "skill_name": skill.metadata.name,
92 "source": format!("{}", skill.metadata.source),
93 }),
94 },
95 }
96 }
97 Err(e) => ToolOutput::Result {
98 content: format!("Failed to load skill '{}': {}", self.skill_name, e),
99 is_error: true,
100 continuation: ContinuationPreference::Continue,
101 ui_result: ToolExecutionResult::Error {
102 short_message: format!("Skill '{}' not found", self.skill_name),
103 detailed_message: e.to_string(),
104 },
105 },
106 }
107 }
108}
109
110#[async_trait::async_trait(?Send)]
111impl ToolExecutor for InvokeSkillTool {
112 fn name(&self) -> String {
113 "invoke_skill".to_string()
114 }
115
116 fn description(&self) -> String {
117 "Load and activate a skill's instructions. Use this when a user's request matches \
118 a skill's description from the Available Skills list. The skill will provide \
119 detailed instructions for how to proceed with the task."
120 .to_string()
121 }
122
123 fn input_schema(&self) -> Value {
124 json!({
125 "type": "object",
126 "properties": {
127 "skill_name": {
128 "type": "string",
129 "description": "The name of the skill to invoke (from the Available Skills list)"
130 }
131 },
132 "required": ["skill_name"]
133 })
134 }
135
136 fn category(&self) -> ToolCategory {
137 ToolCategory::Meta
138 }
139
140 async fn process(&self, request: &ToolRequest) -> Result<Box<dyn ToolCallHandle>> {
141 let Some(skill_name) = request.arguments["skill_name"].as_str() else {
142 bail!("Missing required argument \"skill_name\"");
143 };
144
145 if !self.manager.is_enabled(skill_name) {
147 if self.manager.get_skill(skill_name).is_some() {
148 bail!("Skill '{}' is disabled", skill_name);
149 } else {
150 bail!(
151 "Skill '{}' not found. Use /skills to list available skills.",
152 skill_name
153 );
154 }
155 }
156
157 Ok(Box::new(InvokeSkillHandle {
158 skill_name: skill_name.to_string(),
159 tool_use_id: request.tool_use_id.clone(),
160 manager: self.manager.clone(),
161 state: self.state.clone(),
162 }))
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::settings::config::SkillsConfig;
170 use std::fs;
171 use tempfile::TempDir;
172
173 fn create_test_skill(dir: &std::path::Path, name: &str, description: &str, instructions: &str) {
174 let skill_dir = dir.join(name);
175 fs::create_dir_all(&skill_dir).unwrap();
176
177 let content = format!(
178 r#"---
179name: {}
180description: {}
181---
182
183{}
184"#,
185 name, description, instructions
186 );
187
188 fs::write(skill_dir.join("SKILL.md"), content).unwrap();
189 }
190
191 #[tokio::test]
192 async fn test_invoke_skill_success() {
193 let temp = TempDir::new().unwrap();
194 let skills_dir = temp.path().join(".tycode").join("skills");
195 fs::create_dir_all(&skills_dir).unwrap();
196
197 create_test_skill(
198 &skills_dir,
199 "test-skill",
200 "A test skill",
201 "# Test Instructions\n\nFollow these steps.",
202 );
203
204 let config = SkillsConfig::default();
205 let manager = SkillsManager::discover(&[], temp.path(), &config);
206 let state = Arc::new(InvokedSkillsState::new());
207 let tool = InvokeSkillTool::new(manager, state.clone());
208
209 let request = ToolRequest::new(json!({"skill_name": "test-skill"}), "test-id".to_string());
210
211 let handle = tool.process(&request).await.unwrap();
212 let output = handle.execute().await;
213
214 if let ToolOutput::Result {
215 content, is_error, ..
216 } = output
217 {
218 assert!(!is_error);
219 assert!(content.contains("Test Instructions"));
220 assert!(state.is_invoked("test-skill"));
221 } else {
222 panic!("Expected ToolOutput::Result");
223 }
224 }
225
226 #[tokio::test]
227 async fn test_invoke_skill_not_found() {
228 let temp = TempDir::new().unwrap();
229
230 let config = SkillsConfig::default();
231 let manager = SkillsManager::discover(&[], temp.path(), &config);
232 let state = Arc::new(InvokedSkillsState::new());
233 let tool = InvokeSkillTool::new(manager, state);
234
235 let request = ToolRequest::new(json!({"skill_name": "nonexistent"}), "test-id".to_string());
236
237 let result = tool.process(&request).await;
238 assert!(result.is_err());
239 }
240}