1use crate::registry::{Tool, ToolContext};
12use aegis_feedback::StrategyManager;
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::{json, Value};
16
17pub struct SkillTool {
19 mgr: StrategyManager,
20}
21
22impl SkillTool {
23 pub fn new() -> Self {
25 Self {
26 mgr: StrategyManager::new(),
27 }
28 }
29}
30
31impl Default for SkillTool {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37#[async_trait]
38impl Tool for SkillTool {
39 fn name(&self) -> &str {
40 "skill"
41 }
42
43 fn description(&self) -> &str {
44 "Discover skills on demand (read-only). action=search lists skills relevant to a query (id + description + score, no body); action=open returns one skill's full body by id. Use this to find capabilities beyond the few auto-loaded for the current request."
45 }
46
47 fn parameters(&self) -> Value {
48 json!({
49 "type": "object",
50 "properties": {
51 "action": { "type": "string", "enum": ["search", "open"], "description": "search the library or open one skill" },
52 "query": { "type": "string", "description": "Search keywords (for action=search)" },
53 "id": { "type": "string", "description": "Skill id to open (for action=open)" },
54 "limit": { "type": "integer", "description": "Max search results (default 8)" }
55 },
56 "required": ["action"]
57 })
58 }
59
60 async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
61 let action = args["action"].as_str().unwrap_or("").trim();
62 match action {
63 "open" => {
64 let id = args["id"].as_str().unwrap_or("").trim();
65 if id.is_empty() {
66 return Ok("Error: 'id' is required for action=open".to_string());
67 }
68 match self.mgr.get_skill(id) {
69 Some(s) => Ok(format!("# skill {}\n{}", s.id, s.body)),
70 None => Ok(format!("No skill with id '{id}'.")),
71 }
72 }
73 "search" | "" => {
74 let query = args["query"].as_str().unwrap_or("").trim();
75 if query.is_empty() {
76 return Ok("Error: 'query' is required for action=search".to_string());
77 }
78 let limit = args["limit"].as_u64().unwrap_or(8).clamp(1, 25) as usize;
79 let hits = self.mgr.match_skills(query, limit);
80 if hits.is_empty() {
81 return Ok(format!("No skills match '{query}'."));
82 }
83 let mut out = String::new();
84 for s in &hits {
85 let desc = if s.description.is_empty() {
86 s.body.lines().next().unwrap_or("").trim().to_string()
87 } else {
88 s.description.clone()
89 };
90 out.push_str(&format!(
91 "- {} (score {:.2}): {}\n",
92 s.id, s.metrics.score, desc
93 ));
94 }
95 out.push_str("\nUse action=open with an id to load a skill's full instructions.");
96 Ok(out.trim_end().to_string())
97 }
98 other => Ok(format!("Unknown action '{other}' (use search|open)")),
99 }
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn test_skill_tool_metadata() {
109 let t = SkillTool::new();
110 assert_eq!(t.name(), "skill");
111 assert!(t.parameters().get("properties").is_some());
112 }
113}