Skip to main content

agentd/context/
skills.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Skills** (RFC 0028 §7): named instruction bundles — the SKILL.md idiom —
3//! discovered from MCP servers as **prompts** (`prompts/list` = catalogue,
4//! `prompts/get` = body) or **resources** (`skill://<name>` URIs or
5//! `mimeType: text/x-skill+markdown`), referenced as `@skill:<name>` in the
6//! instruction, a step, or a chat message, and **preloaded** into the calling
7//! context (progressive disclosure: the catalogue is always visible, a body
8//! only when referenced or `skills.load`ed). Bodies are cached by hash, never
9//! stored; the loaded set `[{name, hash}]` is part of the context record.
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Value, json};
13use std::collections::BTreeMap;
14
15/// The default reference prefix (`skills.reference_prefix`).
16pub const DEFAULT_PREFIX: &str = "@skill:";
17/// The URI scheme skills-as-resources use.
18pub const SKILL_SCHEME: &str = "skill://";
19/// The mime type that marks a resource as a skill.
20pub const SKILL_MIME: &str = "text/x-skill+markdown";
21
22/// A catalogue entry (no body).
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
24pub struct SkillMeta {
25    pub name: String,
26    #[serde(default)]
27    pub description: String,
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub when_to_use: Option<String>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub arguments: Vec<Value>,
32    pub source: SkillSourceRef,
33}
34
35/// Where a skill comes from.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct SkillSourceRef {
38    pub server: String,
39    #[serde(rename = "kind")]
40    pub kind: SkillSourceKind,
41    /// The prompt name or the resource URI.
42    #[serde(rename = "ref")]
43    pub reference: String,
44    /// The body, for [`SkillSourceKind::Inline`] only.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub body: Option<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "lowercase")]
51pub enum SkillSourceKind {
52    Prompt,
53    Resource,
54    /// Defined by a `:::skill` directive in the instruction; the body lives on
55    /// the meta — no server round trip, no server at all.
56    Inline,
57}
58
59/// A loaded skill body (cached by hash).
60#[derive(Debug, Clone, PartialEq)]
61pub struct SkillBody {
62    pub name: String,
63    pub hash: String,
64    pub body: String,
65}
66
67/// The MCP surface skill discovery needs — implemented for the MCP client and
68/// by test fakes.
69pub trait SkillServer {
70    fn server_name(&self) -> String;
71    fn supports_prompts(&self) -> bool;
72    fn supports_resources(&self) -> bool;
73    fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String>;
74    fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String>;
75    fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String>;
76    fn read_resource(&self, uri: &str) -> Result<String, String>;
77}
78
79impl SkillServer for crate::mcp::client::McpClient {
80    fn server_name(&self) -> String {
81        self.name().to_string()
82    }
83    fn supports_prompts(&self) -> bool {
84        self.capabilities().supports_prompts()
85    }
86    fn supports_resources(&self) -> bool {
87        self.capabilities().supports_resources()
88    }
89    fn list_prompts(&self) -> Result<Vec<::mcp::wire::Prompt>, String> {
90        crate::mcp::client::McpClient::list_prompts(self).map_err(|e| e.to_string())
91    }
92    fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
93        crate::mcp::client::McpClient::get_prompt(self, name, arguments)
94            .map(|r| r.messages)
95            .map_err(|e| e.to_string())
96    }
97    fn list_resources(&self) -> Result<Vec<::mcp::wire::Resource>, String> {
98        crate::mcp::client::McpClient::list_resources(self).map_err(|e| e.to_string())
99    }
100    fn read_resource(&self, uri: &str) -> Result<String, String> {
101        crate::mcp::client::McpClient::read_resource(self, uri)
102            .map(|r| r.text())
103            .map_err(|e| e.to_string())
104    }
105}
106
107/// How to discover on one source (`skills.sources[].discover`).
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Discover {
110    Prompts,
111    Resources,
112    Auto,
113}
114
115/// The catalogue + body cache.
116#[derive(Debug, Default)]
117pub struct Catalogue {
118    skills: BTreeMap<String, SkillMeta>,
119    bodies: BTreeMap<String, SkillBody>, // by hash
120    max_bytes: usize,
121    pub prefix: String,
122    /// Discovery errors per server (surfaced in status, never fatal).
123    pub errors: BTreeMap<String, String>,
124}
125
126impl Catalogue {
127    pub fn new(prefix: &str, max_bytes: usize) -> Catalogue {
128        Catalogue {
129            prefix: prefix.to_string(),
130            max_bytes,
131            ..Default::default()
132        }
133    }
134
135    /// (Re)discover the skills of one server. Later sources do not override
136    /// an existing name (first source wins; a collision is logged by the caller).
137    /// Returns the names discovered on this server.
138    pub fn discover(
139        &mut self,
140        server: &dyn SkillServer,
141        mode: Discover,
142        filter: Option<&str>,
143    ) -> Vec<String> {
144        let name = server.server_name();
145        let mut found = Vec::new();
146        let want_prompts =
147            matches!(mode, Discover::Prompts | Discover::Auto) && server.supports_prompts();
148        let want_resources =
149            matches!(mode, Discover::Resources | Discover::Auto) && server.supports_resources();
150        if want_prompts {
151            match server.list_prompts() {
152                Ok(prompts) => {
153                    for p in prompts {
154                        if !passes(filter, &p.name) {
155                            continue;
156                        }
157                        let (description, when) =
158                            split_when(p.description.as_deref().unwrap_or(""));
159                        let meta = SkillMeta {
160                            name: p.name.clone(),
161                            description,
162                            when_to_use: when,
163                            arguments: p
164                                .arguments
165                                .iter()
166                                .map(|a| serde_json::to_value(a).unwrap_or(Value::Null))
167                                .collect(),
168                            source: SkillSourceRef {
169                                server: name.clone(),
170                                kind: SkillSourceKind::Prompt,
171                                reference: p.name.clone(),
172                                body: None,
173                            },
174                        };
175                        if self.insert(meta) {
176                            found.push(p.name);
177                        }
178                    }
179                }
180                Err(e) => {
181                    self.errors
182                        .insert(name.clone(), format!("prompts/list: {e}"));
183                }
184            }
185        }
186        if want_resources {
187            match server.list_resources() {
188                Ok(resources) => {
189                    for r in resources {
190                        let is_skill = r.uri.starts_with(SKILL_SCHEME)
191                            || r.mime_type.as_deref() == Some(SKILL_MIME);
192                        if !is_skill {
193                            continue;
194                        }
195                        let skill_name = r
196                            .uri
197                            .strip_prefix(SKILL_SCHEME)
198                            .map(|s| s.trim_matches('/').to_string())
199                            .filter(|s| !s.is_empty())
200                            .or_else(|| r.name.clone())
201                            .unwrap_or_else(|| r.uri.clone());
202                        // The optional index resource `skill://` itself is not a skill.
203                        if skill_name.is_empty() {
204                            continue;
205                        }
206                        if !passes(filter, &skill_name) {
207                            continue;
208                        }
209                        let (description, when) =
210                            split_when(r.description.as_deref().unwrap_or(""));
211                        let meta = SkillMeta {
212                            name: skill_name.clone(),
213                            description,
214                            when_to_use: when,
215                            arguments: Vec::new(),
216                            source: SkillSourceRef {
217                                server: name.clone(),
218                                kind: SkillSourceKind::Resource,
219                                reference: r.uri.clone(),
220                                body: None,
221                            },
222                        };
223                        if self.insert(meta) {
224                            found.push(skill_name);
225                        }
226                    }
227                }
228                Err(e) => {
229                    self.errors
230                        .entry(name.clone())
231                        .and_modify(|m| m.push_str(&format!("; resources/list: {e}")))
232                        .or_insert(format!("resources/list: {e}"));
233                }
234            }
235        }
236        found
237    }
238
239    fn insert(&mut self, meta: SkillMeta) -> bool {
240        if let Some(existing) = self.skills.get(&meta.name) {
241            // Same source refreshed: replace; a different source: first wins.
242            if existing.source == meta.source {
243                self.skills.insert(meta.name.clone(), meta);
244                return true;
245            }
246            return false;
247        }
248        self.skills.insert(meta.name.clone(), meta);
249        true
250    }
251
252    /// Forget every skill of `server` (before a re-discovery).
253    pub fn forget_server(&mut self, server: &str) {
254        self.skills.retain(|_, m| m.source.server != server);
255        self.errors.remove(server);
256    }
257
258    pub fn get(&self, name: &str) -> Option<&SkillMeta> {
259        self.skills.get(name)
260    }
261    pub fn names(&self) -> Vec<String> {
262        self.skills.keys().cloned().collect()
263    }
264    pub fn len(&self) -> usize {
265        self.skills.len()
266    }
267    pub fn is_empty(&self) -> bool {
268        self.skills.is_empty()
269    }
270
271    /// `skills.list` output.
272    pub fn list_value(&self) -> Value {
273        json!({
274            "skills": self.skills.values().map(|m| json!({
275                "name": m.name, "description": m.description, "when_to_use": m.when_to_use,
276                "arguments": m.arguments, "source": {"server": m.source.server, "kind": m.source.kind}
277            })).collect::<Vec<_>>(),
278            "errors": self.errors,
279        })
280    }
281
282    /// The catalogue block for a prompt (names + descriptions), or `None`
283    /// when empty.
284    pub fn render_catalogue(&self) -> Option<String> {
285        if self.skills.is_empty() {
286            return None;
287        }
288        let mut out = format!(
289            "Available skills (reference one as {}<name> or call skills.load to read its full instructions):\n",
290            self.prefix
291        );
292        for m in self.skills.values() {
293            out.push_str(&format!("- {}: {}", m.name, m.description));
294            if let Some(w) = &m.when_to_use {
295                out.push_str(&format!(" (use when: {w})"));
296            }
297            out.push('\n');
298        }
299        Some(out)
300    }
301
302    /// Fetch (or serve from cache) a skill body. `servers` resolves the source
303    /// server by name.
304    pub fn load(
305        &mut self,
306        name: &str,
307        arguments: Option<Value>,
308        servers: &dyn Fn(&str) -> Option<std::sync::Arc<dyn SkillServer>>,
309    ) -> Result<SkillBody, String> {
310        let meta = self
311            .skills
312            .get(name)
313            .cloned()
314            .ok_or_else(|| format!("unknown skill {name:?}"))?;
315        let text = if meta.source.kind == SkillSourceKind::Inline {
316            meta.source
317                .body
318                .clone()
319                .ok_or_else(|| format!("inline skill {name:?} lost its body"))?
320        } else {
321            let server = servers(&meta.source.server).ok_or_else(|| {
322                format!(
323                    "skill {name:?}: server {:?} is not connected",
324                    meta.source.server
325                )
326            })?;
327            match meta.source.kind {
328                SkillSourceKind::Prompt => {
329                    let messages = server.get_prompt(&meta.source.reference, arguments)?;
330                    prompt_messages_text(&messages)
331                }
332                SkillSourceKind::Resource => server.read_resource(&meta.source.reference)?,
333                SkillSourceKind::Inline => unreachable!("handled above"),
334            }
335        };
336        if text.trim().is_empty() {
337            return Err(format!("skill {name:?} has an empty body"));
338        }
339        let text = if text.len() > self.max_bytes {
340            let mut cut = self.max_bytes;
341            while !text.is_char_boundary(cut) {
342                cut -= 1;
343            }
344            format!(
345                "{}\n\n[skill body truncated to skills.max_bytes = {} bytes]",
346                &text[..cut],
347                self.max_bytes
348            )
349        } else {
350            text
351        };
352        let hash = crate::sha::sha256_hex(text.as_bytes());
353        let body = SkillBody {
354            name: name.to_string(),
355            hash: hash.clone(),
356            body: text,
357        };
358        self.bodies.insert(hash, body.clone());
359        Ok(body)
360    }
361
362    /// A cached body by hash.
363    /// Register the instruction's `:::skill` definitions. Inline skills win a
364    /// name collision with discovered ones — the operator wrote them CLOSER to
365    /// this agent than any server did.
366    pub fn add_inline(&mut self, skills: &[crate::config::directives::InlineSkill]) -> Vec<String> {
367        let mut names = Vec::new();
368        for sk in skills {
369            self.skills.insert(
370                sk.name.clone(),
371                SkillMeta {
372                    name: sk.name.clone(),
373                    description: sk.description.clone(),
374                    when_to_use: sk.when_to_use.clone(),
375                    arguments: Vec::new(),
376                    source: SkillSourceRef {
377                        server: "instruction".into(),
378                        kind: SkillSourceKind::Inline,
379                        reference: sk.name.clone(),
380                        body: Some(sk.body.clone()),
381                    },
382                },
383            );
384            names.push(sk.name.clone());
385        }
386        names
387    }
388
389    pub fn body(&self, hash: &str) -> Option<&SkillBody> {
390        self.bodies.get(hash)
391    }
392
393    /// Drop cached bodies whose hash is not in `keep`.
394    pub fn evict_except(&mut self, keep: &[String]) {
395        self.bodies.retain(|h, _| keep.iter().any(|k| k == h));
396    }
397
398    /// The `@skill:<name>` references in a text (deduplicated, in order).
399    pub fn references(&self, text: &str) -> Vec<String> {
400        find_references(text, &self.prefix)
401    }
402}
403
404/// `@skill:<name>` references in `text` — a name is `[A-Za-z0-9_.:-]+`
405/// (trailing punctuation stripped), deduplicated in order of appearance.
406pub fn find_references(text: &str, prefix: &str) -> Vec<String> {
407    let mut out: Vec<String> = Vec::new();
408    if prefix.is_empty() {
409        return out;
410    }
411    let mut rest = text;
412    while let Some(pos) = rest.find(prefix) {
413        let after = &rest[pos + prefix.len()..];
414        let name: String = after
415            .chars()
416            .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))
417            .collect();
418        let name = name.trim_end_matches(['.', '/']).to_string();
419        let consumed = name.len().min(after.len());
420        if !name.is_empty() && !out.contains(&name) {
421            out.push(name);
422        }
423        rest = &after[consumed..];
424    }
425    out
426}
427
428/// A `prompts/get` result's text: the concatenated text parts of its messages.
429pub fn prompt_messages_text(messages: &[Value]) -> String {
430    let mut out = String::new();
431    for m in messages {
432        let content = m.get("content").unwrap_or(&Value::Null);
433        let text = match content {
434            Value::String(s) => s.clone(),
435            Value::Object(o) => o
436                .get("text")
437                .and_then(Value::as_str)
438                .unwrap_or("")
439                .to_string(),
440            Value::Array(parts) => parts
441                .iter()
442                .filter_map(|p| p.get("text").and_then(Value::as_str))
443                .collect::<Vec<_>>()
444                .join("\n"),
445            _ => String::new(),
446        };
447        if !text.is_empty() {
448            if !out.is_empty() {
449                out.push_str("\n\n");
450            }
451            out.push_str(&text);
452        }
453    }
454    out
455}
456
457/// Render the loaded skill bodies as one system block.
458pub fn render_bodies(bodies: &[&SkillBody]) -> Option<String> {
459    if bodies.is_empty() {
460        return None;
461    }
462    let mut out = String::from("Loaded skills — follow these instructions when relevant:\n");
463    for b in bodies {
464        out.push_str(&format!("\n### Skill: {}\n{}\n", b.name, b.body.trim()));
465    }
466    Some(out)
467}
468
469fn passes(filter: Option<&str>, name: &str) -> bool {
470    match filter {
471        None => true,
472        Some(f) => {
473            let f = f.trim();
474            if let Some(prefix) = f.strip_suffix('*') {
475                name.starts_with(prefix)
476            } else {
477                f == name || f.is_empty()
478            }
479        }
480    }
481}
482
483/// Split a description of the form `"… When to use: …"` (or `"… Use when …"`).
484fn split_when(desc: &str) -> (String, Option<String>) {
485    for marker in ["When to use:", "when to use:", "Use when:", "use when:"] {
486        if let Some((a, b)) = desc.split_once(marker) {
487            let w = b.trim();
488            return (
489                a.trim().trim_end_matches('.').to_string(),
490                (!w.is_empty()).then(|| w.to_string()),
491            );
492        }
493    }
494    (desc.trim().to_string(), None)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use ::mcp::wire::{Prompt, PromptArgument, Resource};
501
502    struct Fake {
503        name: String,
504        prompts: Vec<Prompt>,
505        resources: Vec<Resource>,
506        bodies: BTreeMap<String, String>,
507    }
508    impl SkillServer for Fake {
509        fn server_name(&self) -> String {
510            self.name.clone()
511        }
512        fn supports_prompts(&self) -> bool {
513            !self.prompts.is_empty()
514        }
515        fn supports_resources(&self) -> bool {
516            !self.resources.is_empty()
517        }
518        fn list_prompts(&self) -> Result<Vec<Prompt>, String> {
519            Ok(self.prompts.clone())
520        }
521        fn get_prompt(&self, name: &str, arguments: Option<Value>) -> Result<Vec<Value>, String> {
522            let body = self.bodies.get(name).cloned().ok_or("no such prompt")?;
523            let body = match arguments
524                .and_then(|a| a.get("target").and_then(Value::as_str).map(str::to_string))
525            {
526                Some(t) => body.replace("{target}", &t),
527                None => body,
528            };
529            Ok(vec![
530                json!({"role": "user", "content": {"type": "text", "text": body}}),
531            ])
532        }
533        fn list_resources(&self) -> Result<Vec<Resource>, String> {
534            Ok(self.resources.clone())
535        }
536        fn read_resource(&self, uri: &str) -> Result<String, String> {
537            self.bodies
538                .get(uri)
539                .cloned()
540                .ok_or("no such resource".into())
541        }
542    }
543
544    fn fake() -> Fake {
545        Fake {
546            name: "skills".into(),
547            prompts: vec![
548                Prompt {
549                    name: "review-pr".into(),
550                    title: None,
551                    description: Some(
552                        "Review a pull request. When to use: any code review request".into(),
553                    ),
554                    arguments: vec![PromptArgument {
555                        name: "target".into(),
556                        title: None,
557                        description: None,
558                        required: Some(false),
559                    }],
560                },
561                Prompt {
562                    name: "internal-tool".into(),
563                    title: None,
564                    description: None,
565                    arguments: vec![],
566                },
567            ],
568            resources: vec![
569                Resource {
570                    uri: "skill://deploy".into(),
571                    name: Some("deploy".into()),
572                    title: None,
573                    description: Some("Deploy safely".into()),
574                    mime_type: Some(SKILL_MIME.into()),
575                },
576                Resource {
577                    uri: "file:///readme.md".into(),
578                    name: None,
579                    title: None,
580                    description: None,
581                    mime_type: Some("text/markdown".into()),
582                },
583                Resource {
584                    uri: "notes://x".into(),
585                    name: Some("x".into()),
586                    title: None,
587                    description: None,
588                    mime_type: Some(SKILL_MIME.into()),
589                },
590            ],
591            bodies: [
592                (
593                    "review-pr".to_string(),
594                    "# Review PR\nLook at {target} carefully.".to_string(),
595                ),
596                ("internal-tool".to_string(), "internal".to_string()),
597                (
598                    "skill://deploy".to_string(),
599                    "# Deploy\n1. plan 2. apply".to_string(),
600                ),
601                ("notes://x".to_string(), "x body".to_string()),
602            ]
603            .into_iter()
604            .collect(),
605        }
606    }
607
608    #[test]
609    fn discovery_over_prompts_and_resources_with_filters() {
610        let f = fake();
611        let mut c = Catalogue::new(DEFAULT_PREFIX, 1024);
612        let found = c.discover(&f, Discover::Auto, None);
613        assert_eq!(found, vec!["review-pr", "internal-tool", "deploy", "x"]);
614        let m = c.get("review-pr").unwrap();
615        assert_eq!(m.description, "Review a pull request");
616        assert_eq!(m.when_to_use.as_deref(), Some("any code review request"));
617        assert_eq!(m.arguments.len(), 1);
618        assert_eq!(
619            c.get("deploy").unwrap().source.kind,
620            SkillSourceKind::Resource
621        );
622        assert!(
623            c.get("readme.md").is_none(),
624            "a plain markdown resource is not a skill"
625        );
626        let cat = c.render_catalogue().unwrap();
627        assert!(
628            cat.contains("- review-pr: Review a pull request (use when: any code review request)"),
629            "{cat}"
630        );
631        // Filter + prompts-only.
632        let mut c2 = Catalogue::new(DEFAULT_PREFIX, 1024);
633        assert_eq!(
634            c2.discover(&f, Discover::Prompts, Some("review-*")),
635            vec!["review-pr"]
636        );
637        // A second source does not steal an existing name.
638        let mut other = fake();
639        other.name = "other".into();
640        assert!(c.discover(&other, Discover::Auto, None).is_empty());
641        assert_eq!(c.get("deploy").unwrap().source.server, "skills");
642        c.forget_server("skills");
643        assert!(c.is_empty());
644    }
645
646    #[test]
647    fn load_caches_by_hash_truncates_and_renders() {
648        let f = std::sync::Arc::new(fake());
649        let mut c = Catalogue::new(DEFAULT_PREFIX, 30);
650        c.discover(&*f, Discover::Auto, None);
651        let f2 = f.clone();
652        let servers = move |n: &str| -> Option<std::sync::Arc<dyn SkillServer>> {
653            (n == "skills").then(|| f2.clone() as std::sync::Arc<dyn SkillServer>)
654        };
655        let b = c
656            .load("review-pr", Some(json!({"target": "PR #7"})), &servers)
657            .unwrap();
658        assert!(b.body.contains("PR #7"));
659        assert!(b.body.contains("truncated to skills.max_bytes"));
660        assert!(c.body(&b.hash).is_some());
661        let d = c.load("deploy", None, &servers).unwrap();
662        assert!(d.body.starts_with("# Deploy"));
663        assert!(c.load("nope", None, &servers).is_err());
664        assert!(
665            c.load("deploy", None, &|_| None).is_err(),
666            "server not connected"
667        );
668        let block = render_bodies(&[&b, &d]).unwrap();
669        assert!(block.contains("### Skill: review-pr") && block.contains("### Skill: deploy"));
670        c.evict_except(std::slice::from_ref(&d.hash));
671        assert!(c.body(&b.hash).is_none());
672        assert!(c.body(&d.hash).is_some());
673    }
674
675    #[test]
676    fn references_are_found_and_deduped() {
677        let refs = find_references(
678            "please @skill:review-pr this, then @skill:deploy. Also @skill:review-pr again and @skill:",
679            "@skill:",
680        );
681        assert_eq!(refs, vec!["review-pr", "deploy"]);
682        assert!(find_references("nothing here", "@skill:").is_empty());
683        assert_eq!(find_references("use +s:x/y.", "+s:"), vec!["x/y"]);
684        assert_eq!(
685            prompt_messages_text(&[
686                json!({"content": "a"}),
687                json!({"content": [{"type": "text", "text": "b"}, {"type": "image"}]})
688            ]),
689            "a\n\nb"
690        );
691    }
692}