use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use crate::error::Result;
use crate::memory::{ContextProvider, SessionContext};
use crate::tools::FunctionTool;
#[derive(Debug, Clone, Default)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
pub resources: HashMap<String, String>,
}
impl Skill {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
instructions: String::new(),
resources: HashMap::new(),
}
}
pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = instructions.into();
self
}
pub fn with_resource(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
self.resources.insert(name.into(), content.into());
self
}
}
#[derive(Clone)]
pub struct SkillsProvider {
skills: Arc<HashMap<String, Skill>>,
loaded: Arc<Mutex<HashSet<String>>>,
}
impl SkillsProvider {
pub fn new(skills: Vec<Skill>) -> Self {
let skills = skills.into_iter().map(|s| (s.name.clone(), s)).collect();
Self {
skills: Arc::new(skills),
loaded: Arc::new(Mutex::new(HashSet::new())),
}
}
fn catalog(&self) -> String {
let mut names: Vec<&String> = self.skills.keys().collect();
names.sort();
let mut lines = vec![
"Available skills (progressive disclosure — each skill below is only \
summarized; call the `load_skill` tool with a skill's name to reveal \
its full instructions, and `read_skill_resource` to read one of its \
named resources):"
.to_string(),
];
for name in names {
let skill = &self.skills[name];
lines.push(format!("- {}: {}", skill.name, skill.description));
}
lines.join("\n")
}
fn loaded_instructions(&self) -> Vec<String> {
let loaded = self.loaded.lock().unwrap();
let mut names: Vec<&String> = loaded.iter().collect();
names.sort();
names
.into_iter()
.filter_map(|name| self.skills.get(name))
.map(|skill| {
format!(
"Full instructions for skill '{}':\n{}",
skill.name, skill.instructions
)
})
.collect()
}
fn load_skill_tool(&self) -> FunctionTool {
let skills = Arc::clone(&self.skills);
let loaded = Arc::clone(&self.loaded);
FunctionTool::new(
"load_skill",
"Load a skill by name to reveal its full instructions. Use this once \
a skill from the catalog looks relevant to the current task.",
serde_json::json!({
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "The name of the skill to load, exactly as listed in the catalog."
}
},
"required": ["skill_name"]
}),
move |args: Value| {
let skills = Arc::clone(&skills);
let loaded = Arc::clone(&loaded);
async move {
let skill_name = args
.get("skill_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let response = match skills.get(&skill_name) {
Some(skill) => {
loaded.lock().unwrap().insert(skill_name);
skill.instructions.clone()
}
None => format!("No skill named '{skill_name}' is available."),
};
Ok(Value::String(response))
}
},
)
}
fn read_skill_resource_tool(&self) -> FunctionTool {
let skills = Arc::clone(&self.skills);
FunctionTool::new(
"read_skill_resource",
"Read a named resource belonging to a skill (e.g. reference docs, \
examples, or schemas the skill's instructions point to).",
serde_json::json!({
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "The name of the skill that owns the resource."
},
"resource_name": {
"type": "string",
"description": "The name of the resource to read."
}
},
"required": ["skill_name", "resource_name"]
}),
move |args: Value| {
let skills = Arc::clone(&skills);
async move {
let skill_name = args
.get("skill_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let resource_name = args
.get("resource_name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let response = match skills.get(&skill_name) {
Some(skill) => match skill.resources.get(&resource_name) {
Some(content) => content.clone(),
None => format!(
"Skill '{skill_name}' has no resource named '{resource_name}'."
),
},
None => format!("No skill named '{skill_name}' is available."),
};
Ok(Value::String(response))
}
},
)
}
}
#[async_trait]
impl ContextProvider for SkillsProvider {
async fn before_run(&self, ctx: &mut SessionContext) -> Result<()> {
ctx.add_instructions(self.catalog());
for instructions in self.loaded_instructions() {
ctx.add_instructions(instructions);
}
ctx.tools.push(self.load_skill_tool().into_definition());
ctx.tools
.push(self.read_skill_resource_tool().into_definition());
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Message;
fn two_skills() -> Vec<Skill> {
vec![
Skill::new("weather", "Get current weather for a city")
.with_instructions("Call the weather API with a city name and units.")
.with_resource("api_reference", "GET /weather?city={city}"),
Skill::new("translate", "Translate text between languages").with_instructions(
"Detect the source language, then translate to the target language.",
),
]
}
#[tokio::test]
async fn before_run_injects_catalog_and_adds_tools() {
let provider = SkillsProvider::new(two_skills());
let mut ctx = SessionContext::new(vec![Message::user("hi")]);
provider.before_run(&mut ctx).await.unwrap();
let instructions = ctx.instructions.clone().unwrap_or_default();
assert!(instructions.contains("weather: Get current weather for a city"));
assert!(instructions.contains("translate: Translate text between languages"));
assert!(instructions.contains("load_skill"));
assert!(instructions.contains("read_skill_resource"));
assert!(!instructions.contains("Call the weather API"));
assert_eq!(ctx.tools.len(), 2);
assert!(ctx.tools.iter().any(|t| t.name == "load_skill"));
assert!(ctx.tools.iter().any(|t| t.name == "read_skill_resource"));
assert!(ctx.tools.iter().all(|t| t.is_executable()));
}
#[tokio::test]
async fn load_skill_returns_instructions_and_persists_across_runs() {
let provider = SkillsProvider::new(two_skills());
let mut ctx = SessionContext::new(vec![]);
provider.before_run(&mut ctx).await.unwrap();
let load_tool = ctx.tools.iter().find(|t| t.name == "load_skill").unwrap();
let executor = load_tool.executor.clone().unwrap();
let result = executor
.invoke(serde_json::json!({"skill_name": "weather"}))
.await
.unwrap();
assert_eq!(
result.as_str().unwrap(),
"Call the weather API with a city name and units."
);
let mut ctx2 = SessionContext::new(vec![]);
provider.before_run(&mut ctx2).await.unwrap();
let instructions2 = ctx2.instructions.unwrap_or_default();
assert!(instructions2.contains("Full instructions for skill 'weather':"));
assert!(instructions2.contains("Call the weather API with a city name and units."));
assert!(!instructions2.contains("Detect the source language"));
}
#[tokio::test]
async fn load_skill_reports_unknown_skill_with_a_clear_message() {
let provider = SkillsProvider::new(two_skills());
let mut ctx = SessionContext::new(vec![]);
provider.before_run(&mut ctx).await.unwrap();
let load_tool = ctx.tools.iter().find(|t| t.name == "load_skill").unwrap();
let executor = load_tool.executor.clone().unwrap();
let result = executor
.invoke(serde_json::json!({"skill_name": "nonexistent"}))
.await
.unwrap();
assert_eq!(
result.as_str().unwrap(),
"No skill named 'nonexistent' is available."
);
}
#[tokio::test]
async fn read_skill_resource_returns_content_or_clear_not_found_messages() {
let provider = SkillsProvider::new(two_skills());
let mut ctx = SessionContext::new(vec![]);
provider.before_run(&mut ctx).await.unwrap();
let read_tool = ctx
.tools
.iter()
.find(|t| t.name == "read_skill_resource")
.unwrap();
let executor = read_tool.executor.clone().unwrap();
let found = executor
.invoke(serde_json::json!({"skill_name": "weather", "resource_name": "api_reference"}))
.await
.unwrap();
assert_eq!(found.as_str().unwrap(), "GET /weather?city={city}");
let missing_resource = executor
.invoke(serde_json::json!({"skill_name": "weather", "resource_name": "nope"}))
.await
.unwrap();
assert_eq!(
missing_resource.as_str().unwrap(),
"Skill 'weather' has no resource named 'nope'."
);
let missing_skill = executor
.invoke(serde_json::json!({"skill_name": "nope", "resource_name": "nope"}))
.await
.unwrap();
assert_eq!(
missing_skill.as_str().unwrap(),
"No skill named 'nope' is available."
);
}
}