Skip to main content

agent_framework_core/
skills.rs

1//! Skills: progressive-disclosure capability packages.
2//!
3//! Rust equivalent of `agent_framework._skills` (upstream `_skills.py`,
4//! ~4,370 lines). A [`Skill`] is a named capability package: a short
5//! `description` (always visible to the model, so it can decide whether the
6//! skill is relevant), a longer `instructions` body (revealed on demand), and
7//! zero or more named `resources` (revealed on demand, individually).
8//!
9//! [`SkillsProvider`] is a [`ContextProvider`] that surfaces a set of skills
10//! to an agent run with **progressive disclosure**: on every
11//! [`ContextProvider::before_run`] it injects a compact catalog (name +
12//! description for every skill) into the run's instructions, plus two
13//! framework-generated [`FunctionTool`]s — `load_skill` and
14//! `read_skill_resource` — that let the model pull in a skill's full
15//! `instructions` or a specific resource only when it decides the skill is
16//! relevant, instead of paying the token cost of every skill's full detail
17//! on every turn. Once a skill has been loaded (via a `load_skill` call),
18//! its full instructions are also injected on every subsequent `before_run`
19//! for the lifetime of the provider, so the model does not have to re-load it
20//! each turn.
21//!
22//! This is a deliberate **subset** of upstream: no MCP-backed skills (an
23//! `@experimental` upstream feature) and no `run_skill_script` /
24//! sandboxed script execution (upstream's third framework-generated tool) —
25//! both are out of scope here. Only `load_skill` and `read_skill_resource`
26//! are implemented.
27
28use std::collections::{HashMap, HashSet};
29use std::sync::{Arc, Mutex};
30
31use async_trait::async_trait;
32use serde_json::Value;
33
34use crate::error::Result;
35use crate::memory::{ContextProvider, SessionContext};
36use crate::tools::FunctionTool;
37
38/// A named, progressive-disclosure capability package.
39///
40/// * `description` is short and always visible to the model (via
41///   [`SkillsProvider`]'s catalog), so the model can judge relevance without
42///   paying for the full detail.
43/// * `instructions` is the full guidance for actually using the skill;
44///   revealed only after the model calls `load_skill` (or if the skill was
45///   already loaded on a previous turn of the same [`SkillsProvider`]).
46/// * `resources` are additional named text blobs (reference docs, examples,
47///   schemas, ...) revealed individually via `read_skill_resource`, so a
48///   skill can carry more detail than is worth inlining into
49///   `instructions` up front.
50#[derive(Debug, Clone, Default)]
51pub struct Skill {
52    pub name: String,
53    pub description: String,
54    pub instructions: String,
55    pub resources: HashMap<String, String>,
56}
57
58impl Skill {
59    /// A new skill with an empty `instructions` body and no resources.
60    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
61        Self {
62            name: name.into(),
63            description: description.into(),
64            instructions: String::new(),
65            resources: HashMap::new(),
66        }
67    }
68
69    /// Builder: set the skill's full instructions (revealed on `load_skill`).
70    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
71        self.instructions = instructions.into();
72        self
73    }
74
75    /// Builder: attach a named resource (revealed on `read_skill_resource`).
76    /// Calling this again with the same `name` replaces the prior content.
77    pub fn with_resource(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
78        self.resources.insert(name.into(), content.into());
79        self
80    }
81}
82
83/// A [`ContextProvider`] that attaches a set of [`Skill`]s to an agent run
84/// with progressive disclosure.
85///
86/// Holds its skills behind an `Arc` (so `SkillsProvider` itself is cheap to
87/// `Clone`, e.g. into a session's `context_providers`) and its set of
88/// currently-loaded skill names behind an `Arc<Mutex<_>>`, shared with the
89/// `load_skill` tool closure so that a call to the tool during a run is
90/// visible to `before_run` on every subsequent run.
91#[derive(Clone)]
92pub struct SkillsProvider {
93    skills: Arc<HashMap<String, Skill>>,
94    loaded: Arc<Mutex<HashSet<String>>>,
95}
96
97impl SkillsProvider {
98    /// Build a provider from a list of skills, keyed by [`Skill::name`]. If
99    /// two skills share a name, the later one in `skills` wins.
100    pub fn new(skills: Vec<Skill>) -> Self {
101        let skills = skills.into_iter().map(|s| (s.name.clone(), s)).collect();
102        Self {
103            skills: Arc::new(skills),
104            loaded: Arc::new(Mutex::new(HashSet::new())),
105        }
106    }
107
108    /// The short catalog injected into every run's instructions: one bullet
109    /// per skill (name + description), plus a note describing how the model
110    /// can pull in more detail.
111    fn catalog(&self) -> String {
112        let mut names: Vec<&String> = self.skills.keys().collect();
113        names.sort();
114
115        let mut lines = vec![
116            "Available skills (progressive disclosure — each skill below is only \
117             summarized; call the `load_skill` tool with a skill's name to reveal \
118             its full instructions, and `read_skill_resource` to read one of its \
119             named resources):"
120                .to_string(),
121        ];
122        for name in names {
123            let skill = &self.skills[name];
124            lines.push(format!("- {}: {}", skill.name, skill.description));
125        }
126        lines.join("\n")
127    }
128
129    /// Full instructions for every skill currently marked as loaded, sorted
130    /// by name for determinism. Empty when no skill has been loaded yet.
131    fn loaded_instructions(&self) -> Vec<String> {
132        let loaded = self.loaded.lock().unwrap();
133        let mut names: Vec<&String> = loaded.iter().collect();
134        names.sort();
135        names
136            .into_iter()
137            .filter_map(|name| self.skills.get(name))
138            .map(|skill| {
139                format!(
140                    "Full instructions for skill '{}':\n{}",
141                    skill.name, skill.instructions
142                )
143            })
144            .collect()
145    }
146
147    /// The `load_skill(skill_name: String) -> String` tool: marks the named
148    /// skill as loaded (so its full instructions are injected on every
149    /// subsequent `before_run`) and returns those instructions directly, so
150    /// the model can act on them immediately without waiting for the next
151    /// turn. An unknown skill name is not an error — it returns a clear
152    /// message the model can read and recover from.
153    fn load_skill_tool(&self) -> FunctionTool {
154        let skills = Arc::clone(&self.skills);
155        let loaded = Arc::clone(&self.loaded);
156        FunctionTool::new(
157            "load_skill",
158            "Load a skill by name to reveal its full instructions. Use this once \
159             a skill from the catalog looks relevant to the current task.",
160            serde_json::json!({
161                "type": "object",
162                "properties": {
163                    "skill_name": {
164                        "type": "string",
165                        "description": "The name of the skill to load, exactly as listed in the catalog."
166                    }
167                },
168                "required": ["skill_name"]
169            }),
170            move |args: Value| {
171                let skills = Arc::clone(&skills);
172                let loaded = Arc::clone(&loaded);
173                async move {
174                    let skill_name = args
175                        .get("skill_name")
176                        .and_then(Value::as_str)
177                        .unwrap_or_default()
178                        .to_string();
179                    let response = match skills.get(&skill_name) {
180                        Some(skill) => {
181                            loaded.lock().unwrap().insert(skill_name);
182                            skill.instructions.clone()
183                        }
184                        None => format!("No skill named '{skill_name}' is available."),
185                    };
186                    Ok(Value::String(response))
187                }
188            },
189        )
190    }
191
192    /// The `read_skill_resource(skill_name: String, resource_name: String) ->
193    /// String` tool: returns a named resource's content, or a clear
194    /// not-found message for an unknown skill or resource name.
195    fn read_skill_resource_tool(&self) -> FunctionTool {
196        let skills = Arc::clone(&self.skills);
197        FunctionTool::new(
198            "read_skill_resource",
199            "Read a named resource belonging to a skill (e.g. reference docs, \
200             examples, or schemas the skill's instructions point to).",
201            serde_json::json!({
202                "type": "object",
203                "properties": {
204                    "skill_name": {
205                        "type": "string",
206                        "description": "The name of the skill that owns the resource."
207                    },
208                    "resource_name": {
209                        "type": "string",
210                        "description": "The name of the resource to read."
211                    }
212                },
213                "required": ["skill_name", "resource_name"]
214            }),
215            move |args: Value| {
216                let skills = Arc::clone(&skills);
217                async move {
218                    let skill_name = args
219                        .get("skill_name")
220                        .and_then(Value::as_str)
221                        .unwrap_or_default()
222                        .to_string();
223                    let resource_name = args
224                        .get("resource_name")
225                        .and_then(Value::as_str)
226                        .unwrap_or_default()
227                        .to_string();
228                    let response = match skills.get(&skill_name) {
229                        Some(skill) => match skill.resources.get(&resource_name) {
230                            Some(content) => content.clone(),
231                            None => format!(
232                                "Skill '{skill_name}' has no resource named '{resource_name}'."
233                            ),
234                        },
235                        None => format!("No skill named '{skill_name}' is available."),
236                    };
237                    Ok(Value::String(response))
238                }
239            },
240        )
241    }
242
243    // Note: upstream also generates a third tool, `run_skill_script`, backed
244    // by sandboxed script execution. That — like MCP-backed skills — is out
245    // of scope for this subset; only `load_skill` and `read_skill_resource`
246    // are provided.
247}
248
249#[async_trait]
250impl ContextProvider for SkillsProvider {
251    async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
252        ctx.add_instructions(self.catalog());
253        for instructions in self.loaded_instructions() {
254            ctx.add_instructions(instructions);
255        }
256
257        ctx.tools.push(self.load_skill_tool().into_definition());
258        ctx.tools
259            .push(self.read_skill_resource_tool().into_definition());
260
261        Ok(())
262    }
263
264    // after_run is a no-op: skills carry no per-run state to reconcile once
265    // a run completes (unlike a HistoryProvider, which records messages
266    // here). Uses the trait's default implementation.
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use crate::types::Message;
273
274    fn two_skills() -> Vec<Skill> {
275        vec![
276            Skill::new("weather", "Get current weather for a city")
277                .with_instructions("Call the weather API with a city name and units.")
278                .with_resource("api_reference", "GET /weather?city={city}"),
279            Skill::new("translate", "Translate text between languages").with_instructions(
280                "Detect the source language, then translate to the target language.",
281            ),
282        ]
283    }
284
285    #[tokio::test]
286    async fn before_run_injects_catalog_and_adds_tools() {
287        let provider = SkillsProvider::new(two_skills());
288        let mut ctx = SessionContext::new(vec![Message::user("hi")]);
289
290        provider.before_run(&mut ctx).await.unwrap();
291
292        let instructions = ctx.instructions.clone().unwrap_or_default();
293        assert!(instructions.contains("weather: Get current weather for a city"));
294        assert!(instructions.contains("translate: Translate text between languages"));
295        assert!(instructions.contains("load_skill"));
296        assert!(instructions.contains("read_skill_resource"));
297        // Neither skill has been loaded yet, so no full instructions leak in.
298        assert!(!instructions.contains("Call the weather API"));
299
300        assert_eq!(ctx.tools.len(), 2);
301        assert!(ctx.tools.iter().any(|t| t.name == "load_skill"));
302        assert!(ctx.tools.iter().any(|t| t.name == "read_skill_resource"));
303        assert!(ctx.tools.iter().all(|t| t.is_executable()));
304    }
305
306    #[tokio::test]
307    async fn load_skill_returns_instructions_and_persists_across_runs() {
308        let provider = SkillsProvider::new(two_skills());
309        let mut ctx = SessionContext::new(vec![]);
310        provider.before_run(&mut ctx).await.unwrap();
311
312        let load_tool = ctx.tools.iter().find(|t| t.name == "load_skill").unwrap();
313        let executor = load_tool.executor.clone().unwrap();
314        let result = executor
315            .invoke(serde_json::json!({"skill_name": "weather"}))
316            .await
317            .unwrap();
318        assert_eq!(
319            result.as_str().unwrap(),
320            "Call the weather API with a city name and units."
321        );
322
323        // A later before_run (e.g. next turn) must now also inject the full
324        // instructions for the loaded skill, alongside the catalog.
325        let mut ctx2 = SessionContext::new(vec![]);
326        provider.before_run(&mut ctx2).await.unwrap();
327        let instructions2 = ctx2.instructions.unwrap_or_default();
328        assert!(instructions2.contains("Full instructions for skill 'weather':"));
329        assert!(instructions2.contains("Call the weather API with a city name and units."));
330        // The un-loaded skill's full instructions still must not appear.
331        assert!(!instructions2.contains("Detect the source language"));
332    }
333
334    #[tokio::test]
335    async fn load_skill_reports_unknown_skill_with_a_clear_message() {
336        let provider = SkillsProvider::new(two_skills());
337        let mut ctx = SessionContext::new(vec![]);
338        provider.before_run(&mut ctx).await.unwrap();
339
340        let load_tool = ctx.tools.iter().find(|t| t.name == "load_skill").unwrap();
341        let executor = load_tool.executor.clone().unwrap();
342        let result = executor
343            .invoke(serde_json::json!({"skill_name": "nonexistent"}))
344            .await
345            .unwrap();
346        assert_eq!(
347            result.as_str().unwrap(),
348            "No skill named 'nonexistent' is available."
349        );
350    }
351
352    #[tokio::test]
353    async fn read_skill_resource_returns_content_or_clear_not_found_messages() {
354        let provider = SkillsProvider::new(two_skills());
355        let mut ctx = SessionContext::new(vec![]);
356        provider.before_run(&mut ctx).await.unwrap();
357
358        let read_tool = ctx
359            .tools
360            .iter()
361            .find(|t| t.name == "read_skill_resource")
362            .unwrap();
363        let executor = read_tool.executor.clone().unwrap();
364
365        let found = executor
366            .invoke(serde_json::json!({"skill_name": "weather", "resource_name": "api_reference"}))
367            .await
368            .unwrap();
369        assert_eq!(found.as_str().unwrap(), "GET /weather?city={city}");
370
371        let missing_resource = executor
372            .invoke(serde_json::json!({"skill_name": "weather", "resource_name": "nope"}))
373            .await
374            .unwrap();
375        assert_eq!(
376            missing_resource.as_str().unwrap(),
377            "Skill 'weather' has no resource named 'nope'."
378        );
379
380        let missing_skill = executor
381            .invoke(serde_json::json!({"skill_name": "nope", "resource_name": "nope"}))
382            .await
383            .unwrap();
384        assert_eq!(
385            missing_skill.as_str().unwrap(),
386            "No skill named 'nope' is available."
387        );
388    }
389}