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