Skip to main content

harness/
skills.rs

1//! Skill progressive disclosure for the agent loop.
2//!
3//! A *skill* is a directory holding a `SKILL.md` (with YAML frontmatter
4//! carrying `name` + `description`) plus optional `scripts/` and assets.
5//! Instead of dumping every `SKILL.md` body into the system prompt, this
6//! module renders a compact catalogue — `name` / `description` / `path` per
7//! skill — and lets the model pull the full instructions on demand with its
8//! existing `read` tool and run any scripts with `bash`. No bespoke
9//! "execute skill" tool is involved.
10//!
11//! # Local vs remote
12//!
13//! Where the skill files physically live (local FS vs a remote sandbox) is
14//! abstracted behind [`SkillSource`]. This crate ships [`LocalSkillSource`]
15//! (`std::fs`) as the default; an embedder running tools in a sandbox injects
16//! its own [`SkillSource`] (e.g. one backed by a sandbox process client) so
17//! the catalogue is read from wherever the agent's other tools operate.
18
19use std::path::{Path, PathBuf};
20
21use async_trait::async_trait;
22
23#[derive(Debug, thiserror::Error)]
24pub enum SkillError {
25    #[error("skill source error: {0}")]
26    Source(String),
27}
28
29/// One skill's catalogue entry. `path` is the skill directory as seen by the
30/// agent's tools (i.e. inside the sandbox when running remotely); the model
31/// reads `{path}/SKILL.md` for the full instructions.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SkillMetadata {
34    pub name: String,
35    pub description: String,
36    pub path: String,
37}
38
39/// Abstracts *where* skill files live. Implementors enumerate skill
40/// directories and read each `SKILL.md`. The harness only ever talks to this
41/// trait — it never knows whether the bytes come from the local disk or a
42/// remote sandbox.
43#[async_trait]
44pub trait SkillSource: Send + Sync {
45    /// List skill directories (absolute paths) under the skills root.
46    async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError>;
47
48    /// Read `{dir}/SKILL.md`. `Ok(None)` when the file is absent (the dir is
49    /// then skipped, not treated as an error).
50    async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError>;
51}
52
53/// Default [`SkillSource`] reading from the local filesystem. Used in local
54/// mode and by standalone library users.
55pub struct LocalSkillSource {
56    root: PathBuf,
57}
58
59impl LocalSkillSource {
60    /// `root` is the directory that *contains* the per-skill subdirectories
61    /// (e.g. `<cwd>/.harness/skills`).
62    pub fn new(root: impl Into<PathBuf>) -> Self {
63        Self { root: root.into() }
64    }
65}
66
67#[async_trait]
68impl SkillSource for LocalSkillSource {
69    async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError> {
70        let mut entries = match tokio::fs::read_dir(&self.root).await {
71            Ok(e) => e,
72            // Missing root ⇒ no skills, not an error.
73            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
74            Err(e) => return Err(SkillError::Source(e.to_string())),
75        };
76        let mut dirs = Vec::new();
77        while let Some(entry) = entries
78            .next_entry()
79            .await
80            .map_err(|e| SkillError::Source(e.to_string()))?
81        {
82            if entry
83                .file_type()
84                .await
85                .map(|t| t.is_dir())
86                .unwrap_or(false)
87            {
88                dirs.push(entry.path().to_string_lossy().into_owned());
89            }
90        }
91        dirs.sort();
92        Ok(dirs)
93    }
94
95    async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError> {
96        let path = Path::new(dir).join("SKILL.md");
97        match tokio::fs::read_to_string(&path).await {
98            Ok(s) => Ok(Some(s)),
99            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
100            Err(e) => Err(SkillError::Source(e.to_string())),
101        }
102    }
103}
104
105/// Parse `name` and `description` out of a `SKILL.md` YAML frontmatter block
106/// (`---` … `---`). Only single-line scalar values are supported; surrounding
107/// quotes are stripped. Either field may be `None`.
108pub fn parse_skill_frontmatter(content: &str) -> (Option<String>, Option<String>) {
109    let Some(after_open) = content.strip_prefix("---") else {
110        return (None, None);
111    };
112    let Some(after_open) = after_open.trim_start_matches(' ').strip_prefix('\n') else {
113        return (None, None);
114    };
115    let Some(close_pos) = after_open.find("\n---") else {
116        return (None, None);
117    };
118    let front_matter = &after_open[..close_pos];
119
120    let mut name = None;
121    let mut description = None;
122    for line in front_matter.lines() {
123        if let Some(rest) = line.strip_prefix("name:") {
124            name = unquote_nonempty(rest);
125        } else if let Some(rest) = line.strip_prefix("description:") {
126            description = unquote_nonempty(rest);
127        }
128    }
129    (name, description)
130}
131
132fn unquote_nonempty(raw: &str) -> Option<String> {
133    let raw = raw.trim();
134    let v = raw
135        .strip_prefix('"')
136        .and_then(|s| s.strip_suffix('"'))
137        .or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
138        .unwrap_or(raw)
139        .trim();
140    (!v.is_empty()).then(|| v.to_string())
141}
142
143fn dir_basename(dir: &str) -> String {
144    dir.trim_end_matches('/')
145        .rsplit('/')
146        .next()
147        .unwrap_or(dir)
148        .to_string()
149}
150
151/// Reads every skill via a [`SkillSource`] and produces [`SkillMetadata`].
152/// A directory without a `SKILL.md` is skipped. When the frontmatter omits
153/// `name`, the directory basename is used.
154#[derive(Default)]
155pub struct SkillLoader;
156
157impl SkillLoader {
158    pub fn new() -> Self {
159        Self
160    }
161
162    pub async fn load(&self, src: &dyn SkillSource) -> Result<Vec<SkillMetadata>, SkillError> {
163        let mut out = Vec::new();
164        for dir in src.list_skill_dirs().await? {
165            let Some(content) = src.read_skill_md(&dir).await? else {
166                continue;
167            };
168            let (name, description) = parse_skill_frontmatter(&content);
169            out.push(SkillMetadata {
170                name: name.unwrap_or_else(|| dir_basename(&dir)),
171                description: description.unwrap_or_default(),
172                path: dir,
173            });
174        }
175        Ok(out)
176    }
177}
178
179/// Renders a compact skill catalogue for injection into the system prompt.
180#[derive(Default)]
181pub struct SkillPromptRenderer;
182
183impl SkillPromptRenderer {
184    pub fn new() -> Self {
185        Self
186    }
187
188    /// `None` when there are no skills (so the caller can omit the section).
189    pub fn render(&self, skills: &[SkillMetadata]) -> Option<String> {
190        if skills.is_empty() {
191            return None;
192        }
193        let mut s = String::from(
194            "You have access to the following skills. When a task matches one, read its \
195             SKILL.md at the given path for the full instructions, then follow them (run any \
196             bundled scripts with bash). Available skills:",
197        );
198        for skill in skills {
199            s.push_str("\n- ");
200            s.push_str(&skill.name);
201            if !skill.description.is_empty() {
202                s.push_str(": ");
203                s.push_str(&skill.description);
204            }
205            s.push_str(" (path: ");
206            s.push_str(&skill.path);
207            s.push_str("/SKILL.md)");
208        }
209        Some(s)
210    }
211}
212
213/// Orchestrates load + render. The single entry point an embedder calls at
214/// turn/boot setup: hand it a [`SkillSource`], get back the system-prompt
215/// fragment (or `None`).
216#[derive(Default)]
217pub struct SkillsManager {
218    loader: SkillLoader,
219    renderer: SkillPromptRenderer,
220}
221
222impl SkillsManager {
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    pub async fn load_and_render(
228        &self,
229        src: &dyn SkillSource,
230    ) -> Result<Option<String>, SkillError> {
231        let skills = self.loader.load(src).await?;
232        Ok(self.renderer.render(&skills))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn frontmatter_parses_name_and_description() {
242        let md = "---\nname: pdf-tools\ndescription: \"Work with PDFs\"\n---\n# body\n";
243        let (name, desc) = parse_skill_frontmatter(md);
244        assert_eq!(name.as_deref(), Some("pdf-tools"));
245        assert_eq!(desc.as_deref(), Some("Work with PDFs"));
246    }
247
248    #[test]
249    fn frontmatter_missing_fields_are_none() {
250        assert_eq!(parse_skill_frontmatter("no frontmatter"), (None, None));
251        let (name, desc) = parse_skill_frontmatter("---\nname: x\n---\n");
252        assert_eq!(name.as_deref(), Some("x"));
253        assert_eq!(desc, None);
254    }
255
256    #[test]
257    fn renderer_emits_name_desc_path_and_no_body() {
258        let skills = vec![SkillMetadata {
259            name: "pdf-tools".into(),
260            description: "Work with PDFs".into(),
261            path: "/cwd/.harness/skills/pdf-tools".into(),
262        }];
263        let out = SkillPromptRenderer::new().render(&skills).unwrap();
264        assert!(out.contains("pdf-tools"));
265        assert!(out.contains("Work with PDFs"));
266        assert!(out.contains("/cwd/.harness/skills/pdf-tools/SKILL.md"));
267        // Progressive disclosure: the catalogue must not embed body text.
268        assert!(!out.contains("# body"));
269    }
270
271    #[test]
272    fn renderer_empty_is_none() {
273        assert!(SkillPromptRenderer::new().render(&[]).is_none());
274    }
275
276    /// In-memory `SkillSource` for end-to-end loader/manager tests.
277    struct FakeSource(Vec<(String, Option<String>)>);
278
279    #[async_trait]
280    impl SkillSource for FakeSource {
281        async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError> {
282            Ok(self.0.iter().map(|(d, _)| d.clone()).collect())
283        }
284        async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError> {
285            Ok(self
286                .0
287                .iter()
288                .find(|(d, _)| d == dir)
289                .and_then(|(_, md)| md.clone()))
290        }
291    }
292
293    #[tokio::test]
294    async fn loader_skips_dirs_without_skill_md_and_falls_back_to_basename() {
295        let src = FakeSource(vec![
296            (
297                "/s/alpha".into(),
298                Some("---\nname: alpha-skill\ndescription: A\n---\nbody".into()),
299            ),
300            ("/s/no-md".into(), None),
301            // No frontmatter name → basename fallback.
302            ("/s/beta".into(), Some("just text, no frontmatter".into())),
303        ]);
304        let skills = SkillLoader::new().load(&src).await.unwrap();
305        assert_eq!(skills.len(), 2);
306        assert_eq!(skills[0].name, "alpha-skill");
307        assert_eq!(skills[0].description, "A");
308        assert_eq!(skills[1].name, "beta");
309        assert_eq!(skills[1].description, "");
310    }
311
312    #[tokio::test]
313    async fn manager_load_and_render_end_to_end() {
314        let src = FakeSource(vec![(
315            "/s/alpha".into(),
316            Some("---\nname: alpha\ndescription: Do alpha\n---\nbody".into()),
317        )]);
318        let fragment = SkillsManager::new().load_and_render(&src).await.unwrap();
319        let fragment = fragment.expect("one skill ⇒ Some");
320        assert!(fragment.contains("alpha"));
321        assert!(fragment.contains("Do alpha"));
322        assert!(fragment.contains("/s/alpha/SKILL.md"));
323    }
324
325    #[tokio::test]
326    async fn local_source_lists_and_reads(/* uses a temp dir */) {
327        let base = std::env::temp_dir().join(format!("harness_skills_test_{}", std::process::id()));
328        let skill_dir = base.join("demo");
329        tokio::fs::create_dir_all(&skill_dir).await.unwrap();
330        tokio::fs::write(skill_dir.join("SKILL.md"), "---\nname: demo\n---\nx")
331            .await
332            .unwrap();
333
334        let src = LocalSkillSource::new(&base);
335        let dirs = src.list_skill_dirs().await.unwrap();
336        assert_eq!(dirs.len(), 1);
337        assert!(src.read_skill_md(&dirs[0]).await.unwrap().is_some());
338
339        let _ = tokio::fs::remove_dir_all(&base).await;
340    }
341}