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    /// If empty, all registered tools are available.
60    pub tools: Vec<String>,
61
62    /// Optional list of tools explicitly allowed (whitelist).
63    /// If set, only these tools are available.
64    pub allowed_tools: Option<Vec<String>>,
65
66    /// Optional list of tools explicitly denied (blacklist).
67    /// These tools will be filtered out even if in `tools` list.
68    pub denied_tools: Option<Vec<String>>,
69
70    /// Additional metadata from frontmatter.
71    #[serde(default)]
72    pub metadata: HashMap<String, serde_json::Value>,
73}
74
75impl Skill {
76    /// Create a new skill with the given name and system prompt.
77    #[must_use]
78    pub fn new(name: impl Into<String>, system_prompt: impl Into<String>) -> Self {
79        Self {
80            name: name.into(),
81            description: String::new(),
82            system_prompt: system_prompt.into(),
83            tools: Vec::new(),
84            allowed_tools: None,
85            denied_tools: None,
86            metadata: HashMap::new(),
87        }
88    }
89
90    /// Set the description.
91    #[must_use]
92    pub fn with_description(mut self, description: impl Into<String>) -> Self {
93        self.description = description.into();
94        self
95    }
96
97    /// Set the list of tools.
98    #[must_use]
99    pub fn with_tools(mut self, tools: Vec<String>) -> Self {
100        self.tools = tools;
101        self
102    }
103
104    /// Set the allowed tools whitelist.
105    #[must_use]
106    pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
107        self.allowed_tools = Some(tools);
108        self
109    }
110
111    /// Set the denied tools blacklist.
112    #[must_use]
113    pub fn with_denied_tools(mut self, tools: Vec<String>) -> Self {
114        self.denied_tools = Some(tools);
115        self
116    }
117
118    /// Check if a tool is allowed by this skill.
119    ///
120    /// Returns true if:
121    /// - The tool is not in `denied_tools`, AND
122    /// - Either `allowed_tools` is None, or the tool is in `allowed_tools`
123    #[must_use]
124    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
125        // Check denied list first
126        if let Some(ref denied) = self.denied_tools
127            && denied.iter().any(|t| t == tool_name)
128        {
129            return false;
130        }
131
132        // Check allowed list if set
133        if let Some(ref allowed) = self.allowed_tools {
134            return allowed.iter().any(|t| t == tool_name);
135        }
136
137        // No whitelist, tool is allowed
138        true
139    }
140}
141
142pub use loader::{FileSkillLoader, SkillLoader};
143pub use parser::parse_skill_file;
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_skill_builder() {
151        let skill = Skill::new("test", "You are a test assistant.")
152            .with_description("A test skill")
153            .with_tools(vec!["read".into(), "write".into()])
154            .with_denied_tools(vec!["bash".into()]);
155
156        assert_eq!(skill.name, "test");
157        assert_eq!(skill.description, "A test skill");
158        assert_eq!(skill.system_prompt, "You are a test assistant.");
159        assert_eq!(skill.tools, vec!["read", "write"]);
160        assert_eq!(skill.denied_tools, Some(vec!["bash".into()]));
161    }
162
163    #[test]
164    fn test_is_tool_allowed_no_restrictions() {
165        let skill = Skill::new("test", "prompt");
166
167        assert!(skill.is_tool_allowed("read"));
168        assert!(skill.is_tool_allowed("write"));
169        assert!(skill.is_tool_allowed("bash"));
170    }
171
172    #[test]
173    fn test_is_tool_allowed_with_denied() {
174        let skill = Skill::new("test", "prompt").with_denied_tools(vec!["bash".into()]);
175
176        assert!(skill.is_tool_allowed("read"));
177        assert!(skill.is_tool_allowed("write"));
178        assert!(!skill.is_tool_allowed("bash"));
179    }
180
181    #[test]
182    fn test_is_tool_allowed_with_whitelist() {
183        let skill =
184            Skill::new("test", "prompt").with_allowed_tools(vec!["read".into(), "grep".into()]);
185
186        assert!(skill.is_tool_allowed("read"));
187        assert!(skill.is_tool_allowed("grep"));
188        assert!(!skill.is_tool_allowed("write"));
189        assert!(!skill.is_tool_allowed("bash"));
190    }
191
192    #[test]
193    fn test_is_tool_allowed_denied_takes_precedence() {
194        let skill = Skill::new("test", "prompt")
195            .with_allowed_tools(vec!["read".into(), "bash".into()])
196            .with_denied_tools(vec!["bash".into()]);
197
198        assert!(skill.is_tool_allowed("read"));
199        assert!(!skill.is_tool_allowed("bash")); // Denied takes precedence
200    }
201}