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}