agent_framework_core/
skills.rs1use 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#[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 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 pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
71 self.instructions = instructions.into();
72 self
73 }
74
75 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#[derive(Clone)]
92pub struct SkillsProvider {
93 skills: Arc<HashMap<String, Skill>>,
94 loaded: Arc<Mutex<HashSet<String>>>,
95}
96
97impl SkillsProvider {
98 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 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 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 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 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 }
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 }
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 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 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 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}