Skip to main content

aegis_tools/
skill.rs

1//! `skill` tool — on-demand skill discovery for scale (M-S2b).
2//!
3//! The agent auto-injects the top few skills matching each request. This tool
4//! lets the model reach the *rest* of the library on demand: `search` finds
5//! relevant skills (id + description, no body — a lightweight menu), `open`
6//! returns one skill's full body. This is how hundreds/thousands of skills stay
7//! cheap: the model retrieves a couple of files instead of pre-loading all.
8//!
9//! Read-only: it never writes or executes anything.
10
11use crate::registry::{Tool, ToolContext};
12use aegis_feedback::StrategyManager;
13use anyhow::Result;
14use async_trait::async_trait;
15use serde_json::{json, Value};
16
17/// Read-only skill discovery (search the library / open one skill body).
18pub struct SkillTool {
19    mgr: StrategyManager,
20}
21
22impl SkillTool {
23    /// Create a tool over the default skills/strategies directory.
24    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}