Skip to main content

mecha_core/tool/
skill.rs

1//! The `skill` tool: level 2 of progressive disclosure.
2//!
3//! Every enabled skill's name and description already ride in the system
4//! prompt. This is how the model gets the *body* — the actual procedure — and
5//! it is a tool call rather than a `cat` for four reasons, all of them mecha's
6//! rather than the standard's:
7//!
8//! - `shell` may be sandboxed, or withheld entirely. A loading mechanism that
9//!   depends on it stops working in exactly the configurations that were
10//!   locked down on purpose.
11//! - A tool call passes the `pre_tool` gate, so a policy hook can decide which
12//!   skills may load. A `cat` is invisible to hooks.
13//! - It lands in the trace, so an eval case can assert on it and
14//!   `sessions health` can count it. A silent context injection is the thing
15//!   Datadog named as defeating every downstream defence.
16//! - The model does not have to know where the filesystem keeps things.
17//!
18//! ## It arms no taint, and that is the point
19//!
20//! A skill body is user-authored — there is no install verb, no remote fetch,
21//! and nothing here is ever written by a model. So it is the user's own words,
22//! exactly like the system prompt, and this tool declares
23//! [`Capabilities::default`] and returns [`ToolOutput::ok`] rather than
24//! `from_outside`. Marking it untrusted would be a category error in the
25//! direction that makes a model invent explanations for its own harness — the
26//! same mistake as labelling a harness refusal as third-party content. See
27//! [`crate::skill`] for the provenance argument this rests on.
28
29use super::{Capabilities, CarriedState, Tool, ToolCtx, ToolOutput};
30use crate::skill::Skill;
31use anyhow::Result;
32use async_trait::async_trait;
33use serde_json::{json, Value};
34use std::path::PathBuf;
35use std::sync::Mutex;
36
37/// Loads skill bodies, and remembers which it has loaded.
38pub struct SkillTool {
39    /// The enabled set, sorted, fixed for the life of the agent.
40    ///
41    /// **Never model input**, in the way `recall`'s transcript path is never
42    /// model input: the model names a skill, and the name is looked up in this
43    /// list. There is no argument that reaches the filesystem, so no call can
44    /// read a file the user did not put in the store.
45    available: Vec<Skill>,
46    /// Names loaded so far, in load order.
47    loaded: Mutex<Vec<String>>,
48}
49
50impl SkillTool {
51    pub fn new(available: Vec<Skill>) -> Self {
52        SkillTool {
53            available,
54            loaded: Mutex::new(Vec::new()),
55        }
56    }
57
58    /// What has been loaded, for a UI or a test.
59    pub fn loaded(&self) -> Vec<String> {
60        self.loaded.lock().unwrap().clone()
61    }
62
63    /// What this run actually carries — the level-1 set, after config
64    /// selection and `--skill` narrowing.
65    ///
66    /// For a UI answering "what does this agent know how to do". It has to
67    /// come from here rather than from re-reading the store beside the
68    /// config, because `--skill` narrows the run without touching either:
69    /// `mecha skills` shipped with exactly that bug, marking every
70    /// config-selected skill as carried while the run carried one.
71    pub fn available(&self) -> &[Skill] {
72        &self.available
73    }
74
75    /// Forget what is loaded, because the conversation that loaded it ended.
76    ///
77    /// A loaded skill is the agent's state and a **conversation** is the scope
78    /// it belongs to. Where one agent serves one conversation nothing needs to
79    /// call this; where a front-end starts a fresh one — `/clear`, the next
80    /// batch item — it has to, or a `tools:` narrowing outlives the task that
81    /// asked for it and silently constrains the next one. There is no unload
82    /// *within* a conversation on purpose: a procedure that has been read
83    /// cannot be un-read, and the narrowing is the fail-closed direction.
84    pub fn clear(&self) {
85        self.loaded.lock().unwrap().clear();
86    }
87
88    fn skill(&self, name: &str) -> Option<&Skill> {
89        self.available.iter().find(|s| s.name == name)
90    }
91
92    /// The body as the model receives it.
93    ///
94    /// Level 3 is routed back through this tool rather than pointed at the
95    /// filesystem, and that is not a stylistic choice: a skill lives in
96    /// `~/.mecha/skills/`, which is **outside the run's workspace**, so the
97    /// path jail refuses it — correctly. Telling the model to `fs_read` a
98    /// bundled file produced a call that could not succeed, found by running
99    /// it. Serving the file here keeps the jail intact and gives the bundled
100    /// files their own containment proof, rooted at the skill's own directory.
101    fn render(skill: &Skill) -> String {
102        format!(
103            "# Skill: {}\n\
104             If this procedure points at a file bundled with it, call `skill` \
105             again with `file` set to that name — the ordinary file tools \
106             cannot reach it, since a skill lives outside the workspace.\n\n{}",
107            skill.name, skill.body
108        )
109    }
110
111    /// Resolve a bundled file inside one skill's directory.
112    ///
113    /// The path jail's rule applied to a second root: canonicalize, then prove
114    /// containment. `file` is the only argument on this tool that a model can
115    /// point at the filesystem, so it gets the treatment every model-supplied
116    /// path gets — `..` cannot climb out, and a symlink cannot either, because
117    /// containment is checked after canonicalization rather than on the string.
118    fn resolve_bundled(skill: &Skill, file: &str) -> Result<PathBuf, String> {
119        let root = skill
120            .dir
121            .canonicalize()
122            .map_err(|e| format!("cannot read the skill's directory: {e}"))?;
123        let candidate = root.join(file);
124        let resolved = candidate
125            .canonicalize()
126            .map_err(|_| format!("no file `{file}` bundled with skill `{}`", skill.name))?;
127        if !resolved.starts_with(&root) {
128            return Err(format!(
129                "`{file}` resolves outside skill `{}` — a bundled file has to be \
130                 inside the skill's own directory",
131                skill.name
132            ));
133        }
134        if !resolved.is_file() {
135            return Err(format!("`{file}` is not a file"));
136        }
137        Ok(resolved)
138    }
139}
140
141/// Ceiling on one bundled file.
142///
143/// Level 3's whole promise is "zero cost until read", which stops being true
144/// if one read can swallow the window. Generous enough for a reference
145/// document and small enough that a stray binary cannot end a run — and the
146/// message says what was cut, because a silently truncated reference is a
147/// procedure with steps missing.
148const MAX_BUNDLED_BYTES: usize = 60_000;
149
150/// Ceiling on everything carried across one compaction.
151///
152/// A compaction exists to make the prompt smaller, so what it reinstalls has
153/// to be bounded or the mechanism fights itself. Comfortably larger than the
154/// standard's own level-2 guidance (a body under ~5k tokens), so a run
155/// working through two ordinary procedures never notices it.
156const CARRIED_BUDGET: usize = 24_000;
157
158/// The largest index at or below `max` that a string may be split at.
159///
160/// `str::floor_char_boundary` is still unstable, and slicing mid-character
161/// panics — in a tool whose whole job is reading files somebody else wrote.
162fn floor_char_boundary(s: &str, max: usize) -> usize {
163    if max >= s.len() {
164        return s.len();
165    }
166    let mut end = max;
167    while end > 0 && !s.is_char_boundary(end) {
168        end -= 1;
169    }
170    end
171}
172
173#[async_trait]
174impl Tool for SkillTool {
175    fn name(&self) -> &str {
176        "skill"
177    }
178
179    fn description(&self) -> &str {
180        "Load the full instructions for one of the skills listed in your system \
181         prompt. Call this before starting work the skill covers, then follow what \
182         it says. The skills are procedures the user wrote for you, so they are more \
183         specific than your general judgement about how to do the task."
184    }
185
186    fn input_schema(&self) -> Value {
187        json!({
188            "type": "object",
189            "properties": {
190                "name": {
191                    "type": "string",
192                    "description": "The skill's name, exactly as listed in the system prompt."
193                },
194                "file": {
195                    "type": "string",
196                    "description": "Optional: a file bundled with the skill, named by its                                     procedure. Omit to load the procedure itself."
197                }
198            },
199            "required": ["name"]
200        })
201    }
202
203    /// Reading a local file the user wrote, with no side effect anyone can
204    /// observe. Skipping the approval gate matters more than it looks: a
205    /// procedure the user authored should not need a click to be *read*, or
206    /// every run that follows instructions costs an extra interruption.
207    fn read_only(&self) -> bool {
208        true
209    }
210
211    fn capabilities(&self) -> Capabilities {
212        Capabilities::default()
213    }
214
215    async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
216        let Some(name) = input.get("name").and_then(Value::as_str) else {
217            return Ok(ToolOutput::err("`name` is required, and must be a string"));
218        };
219        let name = name.trim();
220
221        let Some(skill) = self.skill(name) else {
222            let known: Vec<&str> = self.available.iter().map(|s| s.name.as_str()).collect();
223            return Ok(ToolOutput::err(if known.is_empty() {
224                "no skills are enabled for this run".to_string()
225            } else {
226                format!("no skill named `{name}`. Enabled: {}", known.join(", "))
227            }));
228        };
229
230        // Level 3: a file the procedure pointed at. Deliberately does not
231        // count as loading the skill — the body is what carries the
232        // instructions, and a run that read a reference without the procedure
233        // has not adopted it.
234        if let Some(file) = input.get("file").and_then(Value::as_str) {
235            return Ok(match Self::resolve_bundled(skill, file.trim()) {
236                Err(why) => ToolOutput::err(why),
237                Ok(path) => match std::fs::read_to_string(&path) {
238                    Err(e) => ToolOutput::err(format!("cannot read `{file}`: {e}")),
239                    Ok(text) if text.len() > MAX_BUNDLED_BYTES => {
240                        // The check counts bytes, so the cut must too. Taking
241                        // *characters* here made the ceiling a lie in both
242                        // directions: a 90 KB file of three-byte characters
243                        // tripped the check and then came back whole with a
244                        // message claiming it had been cut.
245                        let end = floor_char_boundary(&text, MAX_BUNDLED_BYTES);
246                        ToolOutput::ok(format!(
247                            "{}\n\n[cut: `{file}` is {} bytes, over the {MAX_BUNDLED_BYTES}-byte \
248                             ceiling for one bundled file]",
249                            &text[..end],
250                            text.len()
251                        ))
252                    }
253                    Ok(text) => ToolOutput::ok(text),
254                },
255            });
256        }
257
258        let mut loaded = self.loaded.lock().unwrap();
259        if !loaded.iter().any(|n| n == name) {
260            loaded.push(name.to_string());
261        }
262        drop(loaded);
263
264        // Re-loading returns the body again rather than "already loaded":
265        // after a compaction the model may genuinely no longer hold it, and a
266        // tool that answers a request for instructions by declining to give
267        // them is the shape that makes a run go in circles.
268        Ok(ToolOutput::ok(Self::render(skill)))
269    }
270
271    /// Loaded skills cross a compaction verbatim.
272    ///
273    /// A summariser preserves what is true and drops how far you got — and for
274    /// a procedure it does something worse, because a *paraphrased* procedure
275    /// is a different procedure. The steps would survive as a plausible
276    /// gist with the specifics gone, which is exactly the failure the user
277    /// wrote the skill to prevent. `rebuild` places carried state after the
278    /// summary, as the part of the rebuilt head known to be current rather
279    /// than paraphrased.
280    fn carried_state(&self) -> Option<CarriedState> {
281        let loaded = self.loaded.lock().unwrap();
282        if loaded.is_empty() {
283            return None;
284        }
285        // Bounded, because this is re-inserted at *every* compaction and
286        // nothing bounds a `SKILL.md` body — the parser caps `name` and
287        // `description`, not the procedure. Two long skills carried unbounded
288        // could land the rebuilt transcript back at the threshold, spending a
289        // summary per turn and arming the loop guard.
290        //
291        // Newest first, on `collapse_repeated_failures`' reasoning: the most
292        // recently loaded procedure is the one the run is most likely working
293        // through. What does not fit is *named* rather than dropped silently,
294        // so the model can reload it deliberately.
295        let mut kept: Vec<String> = Vec::new();
296        let mut dropped: Vec<&str> = Vec::new();
297        let mut budget = CARRIED_BUDGET;
298        for skill in loaded.iter().rev().filter_map(|n| self.skill(n)) {
299            let rendered = Self::render(skill);
300            if rendered.len() <= budget {
301                budget -= rendered.len();
302                kept.push(rendered);
303            } else {
304                dropped.push(skill.name.as_str());
305            }
306        }
307        if kept.is_empty() && dropped.is_empty() {
308            return None;
309        }
310        kept.reverse();
311        dropped.reverse();
312
313        let mut body = format!(
314            "Skills loaded in this session, reproduced in full because a \
315             summary of a procedure is a different procedure:\n\n{}",
316            kept.join("\n\n---\n\n")
317        );
318        if !dropped.is_empty() {
319            body.push_str(&format!(
320                "\n\n[also loaded earlier, too long to carry: {}. Call `skill` again if \
321                 you need one of them.]",
322                dropped.join(", ")
323            ));
324        }
325        Some(CarriedState {
326            label: "skill".to_string(),
327            body,
328        })
329    }
330
331    /// The union of what the loaded skills declared, or `None` if none did.
332    ///
333    /// A skill with no `tools` key is an opinion-free skill and must not drag
334    /// the surface down to whatever its neighbour declared, so the union is
335    /// taken over declaring skills only — and if none declares, nothing
336    /// narrows. See [`Tool::narrows_surface_to`] for the composition rule.
337    /// A conversation ending is the one thing that unloads a skill: loaded
338    /// skills are its state, and a `tools:` narrowing that outlived it would
339    /// silently constrain the next task.
340    fn forget_conversation_state(&self) {
341        self.clear();
342    }
343
344    fn narrows_surface_to(&self) -> Option<Vec<String>> {
345        let loaded = self.loaded.lock().unwrap();
346        let mut names: Vec<String> = Vec::new();
347        let mut any = false;
348        for skill in loaded.iter().filter_map(|n| self.skill(n)) {
349            if let Some(tools) = &skill.tools {
350                any = true;
351                names.extend(tools.iter().cloned());
352            }
353        }
354        any.then_some(names)
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use std::path::PathBuf;
362
363    fn skill(name: &str, tools: Option<Vec<&str>>) -> Skill {
364        Skill {
365            name: name.to_string(),
366            description: "d".into(),
367            triggers: Vec::new(),
368            tools: tools.map(|t| t.into_iter().map(String::from).collect()),
369            body: format!("the {name} procedure"),
370            dir: PathBuf::from("/tmp/skills").join(name),
371        }
372    }
373
374    async fn load(tool: &SkillTool, name: &str) -> ToolOutput {
375        tool.call(json!({ "name": name }), &ToolCtx::default())
376            .await
377            .unwrap()
378    }
379
380    #[tokio::test]
381    async fn loading_returns_the_body_verbatim_and_names_the_directory() {
382        let tool = SkillTool::new(vec![skill("audit", None)]);
383        let out = load(&tool, "audit").await;
384        assert!(!out.is_error);
385        assert!(
386            out.content.contains("the audit procedure"),
387            "{}",
388            out.content
389        );
390        assert!(
391            out.content.contains("call `skill` again with `file`"),
392            "level 3 has to be reachable, and only through this tool: {}",
393            out.content
394        );
395        assert_eq!(tool.loaded(), vec!["audit"]);
396    }
397
398    #[tokio::test]
399    async fn a_loaded_skill_is_never_third_party_content() {
400        // The provenance decision, asserted rather than left to a comment: a
401        // skill is user-authored, so loading one must not arm the interlock.
402        let tool = SkillTool::new(vec![skill("audit", None)]);
403        let out = load(&tool, "audit").await;
404        assert!(
405            !out.external,
406            "a user-authored procedure is not outside input"
407        );
408        assert_eq!(tool.capabilities(), Capabilities::default());
409    }
410
411    #[tokio::test]
412    async fn an_unknown_name_lists_what_is_enabled_rather_than_failing_blind() {
413        let tool = SkillTool::new(vec![skill("audit", None), skill("brief", None)]);
414        let out = load(&tool, "audi").await;
415        assert!(out.is_error);
416        assert!(out.content.contains("audit") && out.content.contains("brief"));
417        assert!(tool.loaded().is_empty(), "a failed load is not a load");
418    }
419
420    #[tokio::test]
421    async fn re_loading_hands_the_body_back_rather_than_declining() {
422        // After a compaction the model may genuinely no longer hold it.
423        let tool = SkillTool::new(vec![skill("audit", None)]);
424        let first = load(&tool, "audit").await;
425        let again = load(&tool, "audit").await;
426        assert_eq!(first.content, again.content);
427        assert_eq!(tool.loaded(), vec!["audit"], "and it is not counted twice");
428    }
429
430    #[tokio::test]
431    async fn nothing_is_carried_across_a_compaction_until_something_is_loaded() {
432        let tool = SkillTool::new(vec![skill("audit", None)]);
433        assert!(tool.carried_state().is_none());
434        load(&tool, "audit").await;
435        let carried = tool.carried_state().unwrap();
436        assert!(
437            carried.body.contains("the audit procedure"),
438            "{}",
439            carried.body
440        );
441    }
442
443    #[tokio::test]
444    async fn a_bundled_file_is_served_by_the_tool_itself() {
445        // Level 3. It cannot go through `fs_read`: a skill lives outside the
446        // run's workspace, so the path jail refuses it — which is how this
447        // was found, by a real run whose read was correctly denied.
448        let dir = std::env::temp_dir().join(format!("mecha-skill-l3-{}", std::process::id()));
449        std::fs::create_dir_all(&dir).unwrap();
450        std::fs::write(dir.join("reference.md"), "the long reference").unwrap();
451        let mut s = skill("bundled", None);
452        s.dir = dir.clone();
453        let tool = SkillTool::new(vec![s]);
454
455        let out = tool
456            .call(
457                json!({"name": "bundled", "file": "reference.md"}),
458                &ToolCtx::default(),
459            )
460            .await
461            .unwrap();
462        assert!(!out.is_error, "{}", out.content);
463        assert_eq!(out.content, "the long reference");
464        assert!(
465            tool.loaded().is_empty(),
466            "reading a reference is not adopting the procedure"
467        );
468
469        let _ = std::fs::remove_dir_all(&dir);
470    }
471
472    #[tokio::test]
473    async fn a_bundled_path_cannot_climb_out_of_its_skill() {
474        // `file` is the one argument here a model can point at the filesystem,
475        // so it gets the path jail's rule against a second root.
476        let dir = std::env::temp_dir().join(format!("mecha-skill-esc-{}", std::process::id()));
477        std::fs::create_dir_all(&dir).unwrap();
478        let mut s = skill("escape", None);
479        s.dir = dir.clone();
480        let tool = SkillTool::new(vec![s]);
481
482        for bad in ["../../../etc/passwd", "/etc/passwd"] {
483            let out = tool
484                .call(json!({"name": "escape", "file": bad}), &ToolCtx::default())
485                .await
486                .unwrap();
487            assert!(out.is_error, "should have refused {bad}: {}", out.content);
488        }
489
490        let _ = std::fs::remove_dir_all(&dir);
491    }
492
493    #[tokio::test]
494    async fn a_multibyte_reference_is_cut_on_a_character_boundary() {
495        // The check counts bytes and the cut has to as well. Taking characters
496        // meant a 3-byte-per-char file tripped the ceiling and then came back
497        // whole under a message claiming it had been cut.
498        let dir = std::env::temp_dir().join(format!("mecha-skill-utf8-{}", std::process::id()));
499        std::fs::create_dir_all(&dir).unwrap();
500        let big = "é".repeat(MAX_BUNDLED_BYTES); // 2 bytes each, so twice the ceiling
501        std::fs::write(dir.join("ref.md"), &big).unwrap();
502        let mut s = skill("utf8", None);
503        s.dir = dir.clone();
504        let tool = SkillTool::new(vec![s]);
505
506        let out = tool
507            .call(
508                json!({"name": "utf8", "file": "ref.md"}),
509                &ToolCtx::default(),
510            )
511            .await
512            .unwrap();
513        assert!(!out.is_error, "{}", out.content);
514        assert!(
515            out.content.contains("[cut:"),
516            "it really was over the ceiling"
517        );
518        // The claim and the deed agree: what came back is genuinely shorter.
519        assert!(
520            out.content.len() < big.len(),
521            "content {} vs original {}",
522            out.content.len(),
523            big.len()
524        );
525
526        let _ = std::fs::remove_dir_all(&dir);
527    }
528
529    #[tokio::test]
530    async fn the_carried_block_is_bounded_and_names_what_would_not_fit() {
531        // Re-inserted at every compaction, and nothing bounds a SKILL.md body.
532        // Unbounded, two long procedures could land the rebuilt transcript
533        // back at the threshold and spend a summary per turn.
534        // Two thirds of the budget each: either fits alone, both cannot.
535        let long = "x".repeat(CARRIED_BUDGET * 2 / 3);
536        let mut a = skill("older", None);
537        a.body = long.clone();
538        let mut b = skill("newer", None);
539        b.body = long;
540        let tool = SkillTool::new(vec![a, b]);
541        load(&tool, "older").await;
542        load(&tool, "newer").await;
543
544        let carried = tool.carried_state().unwrap();
545        assert!(
546            carried.body.len() < 2 * CARRIED_BUDGET,
547            "bounded: {}",
548            carried.body.len()
549        );
550        // Newest kept, oldest named rather than dropped in silence, so the
551        // model can reload it deliberately.
552        assert!(carried.body.contains("# Skill: newer"), "newest survives");
553        assert!(
554            carried.body.contains("too long to carry: older"),
555            "and the drop is named: {}",
556            carried.body
557        );
558    }
559
560    #[tokio::test]
561    async fn a_procedure_too_long_to_carry_is_named_rather_than_truncated() {
562        // The one case where nothing is carried. Cutting a procedure in half
563        // is the failure this whole mechanism exists to avoid, so an
564        // oversized one is named and left to be reloaded on purpose.
565        let mut huge = skill("huge", None);
566        huge.body = "x".repeat(CARRIED_BUDGET * 2);
567        let tool = SkillTool::new(vec![huge]);
568        load(&tool, "huge").await;
569
570        let carried = tool.carried_state().unwrap();
571        assert!(
572            carried.body.contains("too long to carry: huge"),
573            "{}",
574            carried.body
575        );
576        assert!(
577            !carried.body.contains(&"x".repeat(100)),
578            "no half a procedure"
579        );
580    }
581
582    #[tokio::test]
583    async fn a_conversation_ending_unloads_everything() {
584        // One agent can outlive a conversation — a batch item, a `/clear` —
585        // and a narrowing that survived would constrain the next task.
586        let tool = SkillTool::new(vec![skill("audit", Some(vec!["fs_read"]))]);
587        load(&tool, "audit").await;
588        assert!(tool.narrows_surface_to().is_some());
589        assert!(tool.carried_state().is_some());
590
591        tool.forget_conversation_state();
592        assert!(tool.loaded().is_empty());
593        assert_eq!(
594            tool.narrows_surface_to(),
595            None,
596            "the surface has to come back, or the next task starts constrained"
597        );
598        assert!(tool.carried_state().is_none());
599    }
600
601    #[tokio::test]
602    async fn a_skill_that_declares_no_tools_narrows_nothing() {
603        let tool = SkillTool::new(vec![skill("audit", None)]);
604        load(&tool, "audit").await;
605        assert_eq!(tool.narrows_surface_to(), None);
606    }
607
608    #[tokio::test]
609    async fn declared_tools_narrow_and_two_skills_union() {
610        let tool = SkillTool::new(vec![
611            skill("audit", Some(vec!["fs_read"])),
612            skill("brief", Some(vec!["mail_send"])),
613        ]);
614        load(&tool, "audit").await;
615        assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
616
617        // Union, not intersection: each skill names what its own procedure
618        // needs, and intersecting would strand a run that loaded both.
619        load(&tool, "brief").await;
620        let both = tool.narrows_surface_to().unwrap();
621        assert!(both.contains(&"fs_read".to_string()));
622        assert!(both.contains(&"mail_send".to_string()));
623    }
624
625    #[tokio::test]
626    async fn an_opinion_free_skill_does_not_widen_a_restriction_its_neighbour_set() {
627        // The asymmetry that matters: loading a skill with no `tools` key
628        // alongside one that has it must not lift the restriction.
629        let tool = SkillTool::new(vec![
630            skill("audit", Some(vec!["fs_read"])),
631            skill("plain", None),
632        ]);
633        load(&tool, "audit").await;
634        load(&tool, "plain").await;
635        assert_eq!(tool.narrows_surface_to().unwrap(), vec!["fs_read"]);
636    }
637}