Skip to main content

mecha_core/
skill.rs

1//! Skills: named procedures the user writes and the model loads on demand.
2//!
3//! A skill is a directory holding a `SKILL.md` — frontmatter naming it and
4//! saying when to use it, then a markdown body that *is* the procedure. The
5//! shape is the Agent Skills standard, and the reason to take a standard here
6//! rather than invent a format is that the procedures worth writing are
7//! portable: this repository already carries two of them, written for the
8//! other side of it.
9//!
10//! ## Progressive disclosure is the whole point
11//!
12//! | Level | Loaded | Cost |
13//! |---|---|---|
14//! | 1 · metadata | always, in the system prompt | ~100 tokens per skill |
15//! | 2 · body | when the model calls `skill` | the body, once |
16//! | 3 · bundled files | when the body points at one | nothing until read |
17//!
18//! So a mailbox full of skills costs almost nothing until one is relevant,
19//! which is what makes this the pressure valve for the learned-rule cap:
20//! `MAX_ACTIVE_RULES_PER_DOMAIN` is small because the always-on prefix is
21//! finite, and a procedure like *how to answer a rec-letter request* is too
22//! long for a rule, too specific to be worth a slot, and irrelevant on almost
23//! every run. Skills do not loosen that cap — they make it affordable.
24//!
25//! ## Why this is allowed to be liberal where learning is strict
26//!
27//! **A skill is user-authored, and there is deliberately no way for it not to
28//! be.** No `mecha skill install`, no registry client, no remote body, and
29//! nothing here is ever written by a model or derived from a session. That is
30//! the whole safety argument, and it is why loading a skill arms no taint: a
31//! skill body is the user's own words, exactly like the system prompt and the
32//! `*.user.toml` rules, and treating it as third-party content would be a
33//! category error in the direction that makes the model invent explanations
34//! for its own harness.
35//!
36//! The absence of an install verb is the feature rather than an omission.
37//! Snyk scanned 3,984 published skills and found 36.8% carrying at least one
38//! security flaw, 13.4% a critical one, and 76 confirmed malicious payloads —
39//! and Datadog's finding is the sharper one for a harness: *a cloned
40//! repository can bring skills into a trusted session even if the developer
41//! never installed one from a marketplace*. mecha already refuses that shape
42//! for triggers, in writing. It refuses it here for the same reason: the
43//! store is **global only**, and a project's `mecha.toml` may narrow the set
44//! by name but can never author a skill or add one. See
45//! [`crate::config::SkillsConfig`].
46//!
47//! ## The frontmatter is YAML, and that is not ours to change
48//!
49//! Every other file mecha reads is TOML, and this one is not, because the
50//! Agent Skills standard fixes YAML and roughly forty implementations read it.
51//! A skill written here should load in any of them and one written for any of
52//! them should load here; inventing a dialect would spend that for internal
53//! consistency, which is the trade `docs/SKILLS-RESEARCH.md` §9 lists under
54//! *what not to build*.
55//!
56//! Unknown keys are **ignored rather than refused**, for the same reason: a
57//! skill carrying a field some other harness understands must not fail to
58//! load here. What is refused is a key mecha knows and cannot use — a
59//! `description` that is a list, a `tools` that is empty — because that is an
60//! authoring mistake rather than a portability one.
61
62use anyhow::{bail, Context, Result};
63use serde::Deserialize;
64use std::collections::BTreeSet;
65use std::path::{Path, PathBuf};
66
67/// `name`'s constraints, from the standard.
68///
69/// The vendor-name exclusion is theirs and is kept rather than dropped: a
70/// skill called `claude-notes` authored for mecha would stop loading the day
71/// somebody moved it to the harness it was named after, which is exactly the
72/// portability this format is being adopted for.
73const MAX_NAME: usize = 64;
74const MAX_DESCRIPTION: usize = 1024;
75
76/// One skill, as read off the disk.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Skill {
79    pub name: String,
80    /// What it does *and when to use it* — this carries the entire discovery
81    /// burden, because it is all the model sees until it loads the body.
82    ///
83    /// The same sentence [`crate::subagent::SubagentProfile`] already carries:
84    /// say when to use it, not just what it is. Two independent designs
85    /// arriving at the same instruction is a good sign it is load-bearing.
86    pub description: String,
87    /// Optional keywords, a cheap deterministic complement to the model
88    /// inferring relevance from prose. Costs nothing at level 1 because they
89    /// ride in the same line the description already needed.
90    pub triggers: Vec<String>,
91    /// If present, the tool surface this skill narrows to while loaded.
92    ///
93    /// Narrow only, never widen — the capability-override rule in a second
94    /// setting. Enforcement is not here: it is
95    /// [`crate::tool::Tool::narrows_surface_to`], so the loop learns that
96    /// some tool may restrict the surface and never that skills exist.
97    pub tools: Option<Vec<String>>,
98    /// The procedure. Reproduced verbatim when loaded — a paraphrased
99    /// procedure is a different procedure.
100    pub body: String,
101    /// Where it lives, so a body may point at a file beside it.
102    pub dir: PathBuf,
103}
104
105impl Skill {
106    /// The level-1 line: everything the model knows before it loads anything.
107    pub fn summary_line(&self) -> String {
108        let mut line = format!("- `{}` — {}", self.name, self.description);
109        if !self.triggers.is_empty() {
110            line.push_str(&format!(" (keywords: {})", self.triggers.join(", ")));
111        }
112        line
113    }
114
115    /// Read and validate one `<dir>/SKILL.md`.
116    pub fn load(dir: &Path) -> Result<Skill> {
117        let path = dir.join("SKILL.md");
118        let raw = std::fs::read_to_string(&path)
119            .with_context(|| format!("reading {}", path.display()))?;
120        let mut skill = Skill::parse(&raw, dir)?;
121        skill.dir = dir.to_path_buf();
122
123        // The directory name is how a person finds a skill and how the model
124        // names it; a mismatch means one of the two is a lie. Refused rather
125        // than resolved in either direction, because guessing which the author
126        // meant is how a rename half-lands.
127        let folder = dir.file_name().and_then(|n| n.to_str()).unwrap_or_default();
128        if folder != skill.name {
129            bail!(
130                "{}: frontmatter says `name = {}` but the directory is `{folder}` — \
131                 they have to match, since the directory is how the skill is found \
132                 and the name is how it is called",
133                path.display(),
134                skill.name
135            );
136        }
137        Ok(skill)
138    }
139
140    /// Split frontmatter from body, parse it, and validate.
141    pub fn parse(raw: &str, dir: &Path) -> Result<Skill> {
142        let (fm, body) = split_frontmatter(raw)?;
143        // Budgets rather than defaults: this parser reads files that may have
144        // arrived with a repository, and an unbounded one is a denial of
145        // service against a startup path. Frontmatter is a handful of short
146        // lines, so the ceilings are far above anything honest.
147        let options = serde_saphyr::options!(
148            // A repeated key is an authoring mistake with two plausible
149            // readings, and silently taking one of them is how a skill ends
150            // up doing something its author did not write.
151            duplicate_keys: serde_saphyr::DuplicateKeyPolicy::Error,
152            budget: serde_saphyr::budget!(max_depth: 8, max_documents: 1)
153        );
154        let fm: Frontmatter = serde_saphyr::from_str_with_options(&fm, options)
155            .map_err(|e| anyhow::anyhow!("{e}"))
156            .context("parsing the YAML frontmatter")?;
157
158        validate_name(&fm.name)?;
159        validate_description(&fm.description)?;
160
161        if fm.tools.as_ref().is_some_and(|t| t.is_empty()) {
162            // An empty list reads as "no tools at all", which would strand the
163            // run; an absent key is what "do not narrow" is spelled as. The
164            // difference is invisible enough to be worth refusing.
165            bail!(
166                "`tools` is present but empty — omit the key to leave the surface \
167                 alone, or name the tools this skill needs"
168            );
169        }
170
171        Ok(Skill {
172            name: fm.name,
173            description: fm.description,
174            triggers: fm.triggers.unwrap_or_default(),
175            tools: fm.tools,
176            body: body.trim().to_string(),
177            dir: dir.to_path_buf(),
178        })
179    }
180}
181
182/// The frontmatter fields mecha uses.
183///
184/// Not `deny_unknown_fields`, deliberately: a skill written for another
185/// harness may carry keys this one has never heard of, and refusing it would
186/// give up the portability that is the whole argument for the format.
187#[derive(Debug, Deserialize)]
188struct Frontmatter {
189    name: String,
190    description: String,
191    /// Optional keywords. `Option` rather than `#[serde(default)]` so an
192    /// explicitly empty list stays distinguishable from an absent key, which
193    /// `tools` needs and this shares for symmetry.
194    triggers: Option<Vec<String>>,
195    tools: Option<Vec<String>>,
196}
197
198/// Frontmatter and body.
199///
200/// `---` opens it and `---` closes it, per the standard. The body is
201/// everything after, kept as written.
202fn split_frontmatter(raw: &str) -> Result<(String, String)> {
203    let text = raw.strip_prefix('\u{feff}').unwrap_or(raw);
204    let mut lines = text.lines();
205    if lines.next().map(str::trim_end) != Some("---") {
206        bail!("no frontmatter — a SKILL.md opens with a `---` line");
207    }
208
209    let mut fm = String::new();
210    let mut body = String::new();
211    let mut closed = false;
212    for line in lines {
213        if !closed && line.trim_end() == "---" {
214            closed = true;
215            continue;
216        }
217        if closed {
218            body.push_str(line);
219            body.push('\n');
220        } else {
221            fm.push_str(line);
222            fm.push('\n');
223        }
224    }
225    if !closed {
226        bail!("frontmatter opened with `---` and was never closed");
227    }
228    Ok((fm, body))
229}
230
231fn validate_name(name: &str) -> Result<()> {
232    if name.is_empty() {
233        bail!("`name` is empty");
234    }
235    if name.chars().count() > MAX_NAME {
236        bail!("`name` is longer than {MAX_NAME} characters");
237    }
238    if !name
239        .chars()
240        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
241    {
242        bail!("`name` may hold only lowercase letters, digits and hyphens: got `{name}`");
243    }
244    let lower = name.to_ascii_lowercase();
245    if lower.contains("anthropic") || lower.contains("claude") {
246        bail!("`name` may not contain a vendor name (`{name}`) — the standard reserves those");
247    }
248    Ok(())
249}
250
251fn validate_description(description: &str) -> Result<()> {
252    if description.trim().is_empty() {
253        bail!("`description` is empty — it is the only thing the model sees before loading");
254    }
255    if description.chars().count() > MAX_DESCRIPTION {
256        bail!("`description` is longer than {MAX_DESCRIPTION} characters");
257    }
258    // Angle brackets in the one field that rides in every prompt are refused
259    // outright: the standard forbids XML tags there, and the reason is that
260    // the block is assembled into a prompt where a closing tag could end a
261    // section the harness opened.
262    if description.contains('<') || description.contains('>') {
263        bail!("`description` may not contain `<` or `>`");
264    }
265    Ok(())
266}
267
268/// Every skill on the machine, in a stable order.
269#[derive(Debug, Clone, Default)]
270pub struct SkillStore {
271    /// Sorted by name. **Sorted rather than in directory order**, because the
272    /// level-1 block rides at the front of the cached prefix and filesystem
273    /// order is not an order — the same reason the tool registry is a
274    /// `BTreeMap`.
275    skills: Vec<Skill>,
276}
277
278/// A skill directory that would not load, kept so startup can say so.
279///
280/// A skill that silently fails to load looks exactly like a skill the model
281/// chose not to use, which is the shape of the unrouted-domain warning and is
282/// reported for the same reason.
283#[derive(Debug, Clone)]
284pub struct SkillError {
285    pub dir: PathBuf,
286    pub why: String,
287}
288
289impl SkillStore {
290    /// `~/.mecha/skills`.
291    pub fn default_dir() -> Result<PathBuf> {
292        Ok(crate::work::mecha_home()?.join("skills"))
293    }
294
295    /// Read every `<dir>/*/SKILL.md`.
296    ///
297    /// Best-effort per skill, like every other reader over a store here: one
298    /// unparseable skill is a finding, not a crash, and never suppresses the
299    /// ones beside it. **Read-only** — a missing directory is an empty store,
300    /// because an agent that has been given no skills must not create state by
301    /// starting.
302    pub fn load(dir: &Path) -> (SkillStore, Vec<SkillError>) {
303        let mut skills = Vec::new();
304        let mut errors = Vec::new();
305        let Ok(entries) = std::fs::read_dir(dir) else {
306            return (SkillStore::default(), errors);
307        };
308        for entry in entries.flatten() {
309            let path = entry.path();
310            if !path.is_dir() {
311                continue;
312            }
313            if !path.join("SKILL.md").is_file() {
314                continue;
315            }
316            match Skill::load(&path) {
317                Ok(skill) => skills.push(skill),
318                Err(e) => errors.push(SkillError {
319                    dir: path,
320                    why: format!("{e:#}"),
321                }),
322            }
323        }
324        skills.sort_by(|a, b| a.name.cmp(&b.name));
325        // Two directories cannot produce one name — `Skill::load` pins the
326        // name to the directory — so a duplicate is impossible rather than
327        // resolved by a rule nobody would remember.
328        (SkillStore { skills }, errors)
329    }
330
331    pub fn all(&self) -> &[Skill] {
332        &self.skills
333    }
334
335    pub fn get(&self, name: &str) -> Option<&Skill> {
336        self.skills.iter().find(|s| s.name == name)
337    }
338
339    pub fn is_empty(&self) -> bool {
340        self.skills.is_empty()
341    }
342
343    /// The skills a run actually carries.
344    ///
345    /// `enabled` empty means all of them; `disabled` is applied after, so it
346    /// wins. Order is preserved, which is to say still sorted.
347    pub fn select(&self, enabled: &[String], disabled: &[String]) -> Vec<Skill> {
348        let disabled: BTreeSet<&str> = disabled.iter().map(String::as_str).collect();
349        self.skills
350            .iter()
351            .filter(|s| enabled.is_empty() || enabled.iter().any(|e| e == &s.name))
352            .filter(|s| !disabled.contains(s.name.as_str()))
353            .cloned()
354            .collect()
355    }
356
357    /// Names in `enabled`/`disabled` that match no skill on disk.
358    ///
359    /// Worth saying at startup for the reason a routed outbox name matching no
360    /// tool is: a typo'd enable is indistinguishable from a skill the model
361    /// never chose, and both look like nothing happening.
362    pub fn unknown_names<'a>(&self, names: &'a [String]) -> Vec<&'a str> {
363        names
364            .iter()
365            .map(String::as_str)
366            .filter(|n| self.get(n).is_none())
367            .collect()
368    }
369}
370
371/// The level-1 block: what every run carries about skills it has not loaded.
372///
373/// `None` when there are none, so a machine with no skills sends no block at
374/// all rather than a header explaining an empty list.
375pub fn prompt_block(skills: &[Skill]) -> Option<String> {
376    if skills.is_empty() {
377        return None;
378    }
379    let mut out = String::from(
380        "## Skills\n\n\
381         Procedures the user has written for you. Each is a name and when to use it; \
382         the steps arrive only when you ask for them. Call the `skill` tool with the \
383         name to load one *before* starting work it covers, and then follow it — it is \
384         the user's own instruction, more specific than your general judgement.\n\n",
385    );
386    for skill in skills {
387        out.push_str(&skill.summary_line());
388        out.push('\n');
389    }
390    Some(out.trim_end().to_string())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn dir() -> PathBuf {
398        PathBuf::from("/tmp/skills/x")
399    }
400
401    #[test]
402    fn the_standard_spelling_parses() {
403        // Byte-for-byte the shape of the two SKILL.md files already in this
404        // repository, and of every published skill.
405        let raw = "---\nname: handoff\ndescription: Update the handoff docs. Use at the end of a session.\n---\n\n# Closing out\n\nStep one.\n";
406        let s = Skill::parse(raw, &dir()).unwrap();
407        assert_eq!(s.name, "handoff");
408        assert!(s.description.starts_with("Update the handoff"));
409        assert_eq!(s.body, "# Closing out\n\nStep one.");
410        assert!(s.triggers.is_empty());
411        assert_eq!(s.tools, None);
412    }
413
414    #[test]
415    fn the_optional_fields_parse_in_both_list_spellings() {
416        let flow = "---\nname: a\ndescription: d\ntriggers: [one, \"two\"]\n---\nbody\n";
417        assert_eq!(
418            Skill::parse(flow, &dir()).unwrap().triggers,
419            vec!["one", "two"]
420        );
421        let block = "---\nname: a\ndescription: d\ntools:\n  - fs_read\n  - fs_list\n---\nbody\n";
422        assert_eq!(
423            Skill::parse(block, &dir()).unwrap().tools.unwrap(),
424            vec!["fs_read", "fs_list"]
425        );
426    }
427
428    #[test]
429    fn real_yaml_means_folded_scalars_work() {
430        // Why this is a YAML parser and not a subset reader: a long
431        // description is exactly what a folded scalar is for, and every other
432        // harness reading this file accepts one.
433        let raw = "---\nname: a\ndescription: >-\n  a long description\n  folded over two lines\n---\nbody\n";
434        let s = Skill::parse(raw, &dir()).unwrap();
435        assert_eq!(s.description, "a long description folded over two lines");
436    }
437
438    #[test]
439    fn a_key_another_harness_understands_does_not_stop_it_loading_here() {
440        // Portability is the whole argument for the format, so an unknown key
441        // is ignored rather than refused.
442        let raw = "---\nname: a\ndescription: d\nlicense: MIT\nallowed-tools: [Bash]\n---\nbody\n";
443        assert_eq!(Skill::parse(raw, &dir()).unwrap().name, "a");
444    }
445
446    #[test]
447    fn a_field_mecha_knows_and_cannot_use_is_refused() {
448        // The other half of that rule: a wrong type on a known key, or a
449        // missing required one, is an authoring mistake rather than a
450        // portability one, and silence there loses a field nobody notices.
451        for bad in [
452            "---\nname: a\ndescription:\n  nested: map\n---\nbody\n",
453            "---\nname: [a, b]\ndescription: d\n---\nbody\n",
454            "---\nname: a\ndescription: d\ntools: fs_read\n---\nbody\n",
455            "---\ndescription: d\n---\nbody\n",
456            "---\nname: a\n---\nbody\n",
457        ] {
458            assert!(
459                Skill::parse(bad, &dir()).is_err(),
460                "should have refused: {bad:?}"
461            );
462        }
463    }
464
465    #[test]
466    fn a_repeated_key_is_refused_rather_than_silently_resolved() {
467        // Two plausible readings, and taking one quietly is how a skill ends
468        // up doing something its author did not write.
469        let raw = "---\nname: a\ndescription: first\ndescription: second\n---\nbody\n";
470        assert!(Skill::parse(raw, &dir()).is_err());
471    }
472
473    #[test]
474    fn frontmatter_that_never_closes_is_refused() {
475        let raw = "---\nname: a\ndescription: d\n\n# body with no close\n";
476        let e = Skill::parse(raw, &dir()).unwrap_err().to_string();
477        assert!(e.contains("never closed"), "{e}");
478    }
479
480    #[test]
481    fn a_file_with_no_frontmatter_says_so() {
482        let e = Skill::parse("# just a document\n", &dir())
483            .unwrap_err()
484            .to_string();
485        assert!(e.contains("no frontmatter"), "{e}");
486    }
487
488    #[test]
489    fn the_names_the_standard_reserves_are_refused() {
490        assert!(validate_name("claude-helper").is_err());
491        assert!(validate_name("my-anthropic-thing").is_err());
492        assert!(validate_name("Rec-Letter").is_err(), "uppercase");
493        assert!(validate_name("rec letter").is_err(), "space");
494        assert!(validate_name(&"a".repeat(65)).is_err(), "too long");
495        assert!(validate_name("rec-letter-2").is_ok());
496    }
497
498    #[test]
499    fn a_description_that_could_close_a_prompt_section_is_refused() {
500        // It rides in every run's system prompt, so a stray tag is not a
501        // cosmetic problem.
502        assert!(validate_description("does <thing>").is_err());
503        assert!(validate_description("  ").is_err());
504        assert!(validate_description(&"d".repeat(1025)).is_err());
505    }
506
507    #[test]
508    fn an_empty_tool_list_is_refused_rather_than_read_as_no_tools() {
509        let raw = "---\nname: a\ndescription: d\ntools: []\n---\nbody\n";
510        let e = Skill::parse(raw, &dir()).unwrap_err().to_string();
511        assert!(e.contains("omit the key"), "{e}");
512    }
513
514    #[test]
515    fn selection_is_all_by_default_and_disabled_wins() {
516        let store = SkillStore {
517            skills: vec![skill("a"), skill("b"), skill("c")],
518        };
519        let names = |v: Vec<Skill>| v.into_iter().map(|s| s.name).collect::<Vec<_>>();
520        assert_eq!(names(store.select(&[], &[])), vec!["a", "b", "c"]);
521        assert_eq!(
522            names(store.select(&["a".into(), "b".into()], &[])),
523            vec!["a", "b"]
524        );
525        assert_eq!(
526            names(store.select(&["a".into(), "b".into()], &["b".into()])),
527            vec!["a"],
528            "disabled is applied after enabled, so it wins"
529        );
530    }
531
532    #[test]
533    fn a_name_nothing_on_disk_matches_is_reported() {
534        let store = SkillStore {
535            skills: vec![skill("a")],
536        };
537        assert_eq!(
538            store.unknown_names(&["a".into(), "typo".into()]),
539            vec!["typo"]
540        );
541    }
542
543    #[test]
544    fn an_empty_store_contributes_no_block_at_all() {
545        assert_eq!(prompt_block(&[]), None);
546    }
547
548    #[test]
549    fn the_block_lists_skills_in_the_order_it_was_given() {
550        // Sorted upstream, in `load`, because this block is the front of the
551        // cached prefix.
552        let block = prompt_block(&[skill("alpha"), skill("beta")]).unwrap();
553        let a = block.find("alpha").unwrap();
554        let b = block.find("beta").unwrap();
555        assert!(a < b, "{block}");
556    }
557
558    fn skill(name: &str) -> Skill {
559        Skill {
560            name: name.to_string(),
561            description: "does a thing. Use when a thing is needed.".into(),
562            triggers: Vec::new(),
563            tools: None,
564            body: "step one".into(),
565            dir: dir(),
566        }
567    }
568}