Skip to main content

agent_sdk/
skills.rs

1//! Skills system for loading agent behavior from markdown files.
2//!
3//! Skills allow you to define agent behavior, system prompts, and tool configurations
4//! in markdown files with YAML frontmatter.
5//!
6//! # Skill File Format
7//!
8//! ```markdown
9//! ---
10//! name: code-review
11//! description: Review code for quality and security
12//! tools: [read, grep, glob]
13//! denied_tools: [bash, write]
14//! ---
15//!
16//! # Code Review Skill
17//!
18//! You are an expert code reviewer...
19//! ```
20//!
21//! # Example
22//!
23//! ```ignore
24//! use agent_sdk::skills::{FileSkillLoader, SkillLoader};
25//!
26//! let loader = FileSkillLoader::new("./skills");
27//! let skill = loader.load("code-review").await?;
28//!
29//! let agent = builder()
30//!     .provider(provider)
31//!     .with_skill(skill)
32//!     .build();
33//! ```
34
35pub mod loader;
36pub mod parser;
37
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40
41/// A loaded skill definition.
42///
43/// Skills contain:
44/// - A system prompt that defines agent behavior
45/// - Tool configurations (which tools are available/denied)
46/// - Optional metadata for custom extensions
47#[derive(Clone, Debug, Serialize, Deserialize)]
48pub struct Skill {
49    /// Unique identifier for the skill.
50    pub name: String,
51
52    /// Human-readable description of what the skill does.
53    pub description: String,
54
55    /// The system prompt content (markdown body after frontmatter).
56    pub system_prompt: String,
57
58    /// List of tool names that should be enabled for this skill.
59    ///
60    /// If empty, all registered tools are available. If non-empty, it acts as a
61    /// whitelist: only the listed tools (unioned with `allowed_tools`, and minus
62    /// `denied_tools`) are available. See [`Skill::is_tool_allowed`].
63    pub tools: Vec<String>,
64
65    /// Optional list of tools explicitly allowed (whitelist).
66    /// If set, only these tools are available.
67    pub allowed_tools: Option<Vec<String>>,
68
69    /// Optional list of tools explicitly denied (blacklist).
70    /// These tools will be filtered out even if in `tools` list.
71    pub denied_tools: Option<Vec<String>>,
72
73    /// Additional metadata from frontmatter.
74    #[serde(default)]
75    pub metadata: HashMap<String, serde_json::Value>,
76}
77
78impl Skill {
79    /// Create a new skill with the given name and system prompt.
80    #[must_use]
81    pub fn new(name: impl Into<String>, system_prompt: impl Into<String>) -> Self {
82        Self {
83            name: name.into(),
84            description: String::new(),
85            system_prompt: system_prompt.into(),
86            tools: Vec::new(),
87            allowed_tools: None,
88            denied_tools: None,
89            metadata: HashMap::new(),
90        }
91    }
92
93    /// Set the description.
94    #[must_use]
95    pub fn with_description(mut self, description: impl Into<String>) -> Self {
96        self.description = description.into();
97        self
98    }
99
100    /// Set the list of tools.
101    #[must_use]
102    pub fn with_tools(mut self, tools: Vec<String>) -> Self {
103        self.tools = tools;
104        self
105    }
106
107    /// Set the allowed tools whitelist.
108    #[must_use]
109    pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
110        self.allowed_tools = Some(tools);
111        self
112    }
113
114    /// Set the denied tools blacklist.
115    #[must_use]
116    pub fn with_denied_tools(mut self, tools: Vec<String>) -> Self {
117        self.denied_tools = Some(tools);
118        self
119    }
120
121    /// Check if a tool is allowed by this skill.
122    ///
123    /// Resolution order:
124    /// - If the tool is in `denied_tools`, it is denied (highest precedence).
125    /// - Otherwise, if any whitelist is configured — either `allowed_tools` or
126    ///   a non-empty `tools` list — the tool must appear in the union of those
127    ///   lists. A non-empty `tools` list therefore restricts access, matching
128    ///   the documented skill-file format (`tools: [read, grep]`).
129    /// - If no whitelist is configured at all, the tool is allowed.
130    #[must_use]
131    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
132        // Check denied list first — it always wins.
133        if let Some(ref denied) = self.denied_tools
134            && denied.iter().any(|t| t == tool_name)
135        {
136            return false;
137        }
138
139        // A non-empty `tools` list acts as a whitelist, unioned with any
140        // explicit `allowed_tools` whitelist.
141        let has_allowed_tools = self.allowed_tools.is_some();
142        let has_tools_whitelist = !self.tools.is_empty();
143
144        if has_allowed_tools || has_tools_whitelist {
145            let in_allowed = self
146                .allowed_tools
147                .as_ref()
148                .is_some_and(|allowed| allowed.iter().any(|t| t == tool_name));
149            let in_tools = self.tools.iter().any(|t| t == tool_name);
150            return in_allowed || in_tools;
151        }
152
153        // No whitelist of any kind, tool is allowed.
154        true
155    }
156}
157
158pub use loader::{FileSkillLoader, SkillLoader};
159pub use parser::parse_skill_file;
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_skill_builder() {
167        let skill = Skill::new("test", "You are a test assistant.")
168            .with_description("A test skill")
169            .with_tools(vec!["read".into(), "write".into()])
170            .with_denied_tools(vec!["bash".into()]);
171
172        assert_eq!(skill.name, "test");
173        assert_eq!(skill.description, "A test skill");
174        assert_eq!(skill.system_prompt, "You are a test assistant.");
175        assert_eq!(skill.tools, vec!["read", "write"]);
176        assert_eq!(skill.denied_tools, Some(vec!["bash".into()]));
177    }
178
179    #[test]
180    fn test_is_tool_allowed_no_restrictions() {
181        let skill = Skill::new("test", "prompt");
182
183        assert!(skill.is_tool_allowed("read"));
184        assert!(skill.is_tool_allowed("write"));
185        assert!(skill.is_tool_allowed("bash"));
186    }
187
188    #[test]
189    fn test_is_tool_allowed_with_denied() {
190        let skill = Skill::new("test", "prompt").with_denied_tools(vec!["bash".into()]);
191
192        assert!(skill.is_tool_allowed("read"));
193        assert!(skill.is_tool_allowed("write"));
194        assert!(!skill.is_tool_allowed("bash"));
195    }
196
197    #[test]
198    fn test_is_tool_allowed_with_whitelist() {
199        let skill =
200            Skill::new("test", "prompt").with_allowed_tools(vec!["read".into(), "grep".into()]);
201
202        assert!(skill.is_tool_allowed("read"));
203        assert!(skill.is_tool_allowed("grep"));
204        assert!(!skill.is_tool_allowed("write"));
205        assert!(!skill.is_tool_allowed("bash"));
206    }
207
208    #[test]
209    fn test_is_tool_allowed_denied_takes_precedence() {
210        let skill = Skill::new("test", "prompt")
211            .with_allowed_tools(vec!["read".into(), "bash".into()])
212            .with_denied_tools(vec!["bash".into()]);
213
214        assert!(skill.is_tool_allowed("read"));
215        assert!(!skill.is_tool_allowed("bash")); // Denied takes precedence
216    }
217
218    #[test]
219    fn test_is_tool_allowed_tools_acts_as_whitelist() {
220        // A skill declaring only `tools: [read]` must NOT grant every
221        // registered tool. `with_skill` filters via `is_tool_allowed`, so this
222        // restriction flows through to the agent's tool set.
223        let skill = Skill::new("test", "prompt").with_tools(vec!["read".into()]);
224
225        assert!(skill.is_tool_allowed("read"));
226        assert!(!skill.is_tool_allowed("write"));
227        assert!(!skill.is_tool_allowed("bash"));
228    }
229
230    #[test]
231    fn test_is_tool_allowed_tools_unions_with_allowed_tools() {
232        let skill = Skill::new("test", "prompt")
233            .with_tools(vec!["read".into()])
234            .with_allowed_tools(vec!["grep".into()]);
235
236        assert!(skill.is_tool_allowed("read")); // from tools
237        assert!(skill.is_tool_allowed("grep")); // from allowed_tools
238        assert!(!skill.is_tool_allowed("bash"));
239    }
240
241    #[test]
242    fn test_is_tool_allowed_denied_overrides_tools_whitelist() {
243        let skill = Skill::new("test", "prompt")
244            .with_tools(vec!["read".into(), "bash".into()])
245            .with_denied_tools(vec!["bash".into()]);
246
247        assert!(skill.is_tool_allowed("read"));
248        assert!(!skill.is_tool_allowed("bash")); // denied wins over tools whitelist
249    }
250}