use anda_core::{
Agent, BoxError, FunctionDefinition, Resource, Tool, ToolOutput, select_resources,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
any::Any,
collections::BTreeMap,
ffi::OsStr,
path::{Path, PathBuf},
sync::Arc,
};
use crate::{
context::BaseCtx,
extension::fs::{ensure_file_size_within_limit, ensure_regular_file},
subagent::{SubAgent, SubAgentSet},
};
mod types;
pub use types::*;
const MAX_SKILL_FILE_BYTES: u64 = 512 * 1024;
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SkillArgs {
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SkillContentOutput {
pub name: String,
pub agent_name: String,
pub description: String,
pub path: String,
pub content: String,
}
pub struct SkillManager {
skills_dir: PathBuf,
skills: RwLock<BTreeMap<String, Skill>>,
description: String,
default_skill_tools: Vec<String>,
}
static DEFAULT_SKILL_TOOLS: &[&str] = &[
"shell",
"read_file",
"search_file",
"write_file",
"edit_file",
"todo",
"tools_search",
"tools_select",
];
impl SkillManager {
pub const NAME: &'static str = "skills_manager";
pub fn new(skills_dir: PathBuf) -> Self {
Self {
skills: RwLock::new(BTreeMap::new()),
description: format!(
"Load reusable skills following the Agent Skills specification and read \
a skill's SKILL.md content by name. Agent Skills are folders of instructions, \
scripts, and resources that agents can follow directly or invoke as subagents. Skills: {}",
skills_dir.display()
),
skills_dir,
default_skill_tools: DEFAULT_SKILL_TOOLS.iter().map(|s| s.to_string()).collect(),
}
}
pub fn with_description(mut self, description: String) -> Self {
self.description = description;
self
}
pub fn with_default_skill_tools(mut self, tools: Vec<String>) -> Self {
self.default_skill_tools = tools;
self
}
fn with_default_tools(&self, agent: SubAgent) -> SubAgent {
let mut tools = self.default_skill_tools.clone();
for tool in agent.tools {
if !tools.contains(&tool) {
tools.push(tool);
}
}
SubAgent { tools, ..agent }
}
async fn read_text_file(&self, path: &Path, max_size: u64) -> Result<String, BoxError> {
let meta = tokio::fs::symlink_metadata(path).await.map_err(|err| {
format!(
"Failed to inspect file metadata (path: {}): {err}",
path.display()
)
})?;
ensure_regular_file(&meta, path, "Reading multiply-linked files is not allowed")?;
ensure_file_size_within_limit(&meta, path, max_size)?;
let data = tokio::fs::read(path)
.await
.map_err(|err| format!("Failed to read file (path: {}): {err}", path.display()))?;
String::from_utf8(data).map_err(|_| {
format!(
"Only UTF-8 skill files are supported by skills_manager (path: {})",
path.display()
)
.into()
})
}
async fn find_skill_dir(&self, name: &str) -> Result<Option<PathBuf>, BoxError> {
validate_skill_name(name)?;
let mut matches = Vec::new();
{
let skills = self.skills.read();
for skill in skills.values() {
let dir_name_matches = skill.base_dir.file_name() == Some(OsStr::new(name));
if (skill.frontmatter.name == name || dir_name_matches)
&& !matches.iter().any(|path| path == &skill.base_dir)
{
matches.push(skill.base_dir.clone());
}
}
}
if self.skills_dir.is_dir() {
for path in find_skill_files(&self.skills_dir).await? {
let Some(base_dir) = path.parent() else {
continue;
};
let base_dir = base_dir.to_path_buf();
let dir_name_matches = base_dir.file_name() == Some(OsStr::new(name));
let frontmatter_name_matches = if dir_name_matches {
true
} else if let Ok(content) = tokio::fs::read_to_string(&path).await {
parse_skill_md(base_dir.clone(), &content)
.map(|skill| skill.frontmatter.name == name)
.unwrap_or(false)
} else {
false
};
if frontmatter_name_matches
&& !matches.iter().any(|candidate| candidate == &base_dir)
{
matches.push(base_dir);
}
}
}
match matches.len() {
0 => Ok(None),
1 => Ok(matches.pop()),
_ => Err(format!(
"multiple skills named {:?} exist under {}",
name,
self.skills_dir.display()
)
.into()),
}
}
fn display_path(&self, path: &Path) -> String {
if let Ok(stripped) = path.strip_prefix(&self.skills_dir) {
return stripped.display().to_string();
}
if let Ok(root) = std::fs::canonicalize(&self.skills_dir)
&& let Ok(stripped) = path.strip_prefix(&root)
{
return stripped.display().to_string();
}
path.display().to_string()
}
async fn read_skill_action(&self, args: SkillArgs) -> Result<SkillContentOutput, BoxError> {
validate_skill_name(&args.name)?;
let skill_dir = self
.find_skill_dir(&args.name)
.await?
.ok_or_else(|| format!("skill {:?} not found", args.name))?;
let target = skill_dir.join("SKILL.md");
let content = self.read_text_file(&target, MAX_SKILL_FILE_BYTES).await?;
let skill = parse_skill_md(skill_dir, &content)?;
if skill.frontmatter.name != args.name {
return Err(format!(
"SKILL.md frontmatter name {:?} must match requested skill name {:?}",
skill.frontmatter.name, args.name
)
.into());
}
self.skills
.write()
.entry(skill.agent_name.clone())
.insert_entry(skill.clone());
Ok(SkillContentOutput {
name: skill.frontmatter.name,
agent_name: skill.agent_name,
description: skill.frontmatter.description,
path: self.display_path(&target),
content,
})
}
pub async fn load(&self) -> Result<(), BoxError> {
if !self.skills_dir.is_dir() {
log::error!(
"skills directory {} does not exist, skipping load",
self.skills_dir.display()
);
return Ok(());
}
let skills = load_skills_from_dir(&self.skills_dir).await?;
log::info!(
"loaded {} skill(s) from {}",
skills.len(),
self.skills_dir.display()
);
*self.skills.write() = skills;
Ok(())
}
pub fn get_skill(&self, lowercase_name: &str) -> Option<Skill> {
self.skills.read().get(lowercase_name).cloned()
}
pub fn subagents(&self) -> Vec<SubAgent> {
self.skills
.read()
.values()
.map(SubAgent::from)
.map(|agent| self.with_default_tools(agent))
.collect::<Vec<_>>()
}
pub fn list(&self) -> BTreeMap<String, Skill> {
self.skills.read().clone()
}
}
impl SubAgentSet for SkillManager {
fn into_any(self: Arc<Self>) -> Arc<dyn Any + Send + Sync> {
self
}
fn contains_lowercase(&self, lowercase_name: &str) -> bool {
self.skills.read().contains_key(lowercase_name)
}
fn get_lowercase(&self, lowercase_name: &str) -> Option<SubAgent> {
self.skills
.read()
.get(lowercase_name)
.map(SubAgent::from)
.map(|agent| self.with_default_tools(agent))
}
fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
match names {
None => self
.skills
.read()
.values()
.map(SubAgent::from)
.map(|agent| agent.definition())
.collect(),
Some(names) => {
let skills = self.skills.read();
names
.iter()
.filter_map(|name| {
skills
.get(&name.to_ascii_lowercase())
.map(SubAgent::from)
.map(|agent| agent.definition())
})
.collect()
}
}
}
fn select_resources(&self, name: &str, resources: &mut Vec<Resource>) -> Vec<Resource> {
if resources.is_empty() {
return Vec::new();
}
self.skills
.read()
.get(&name.to_ascii_lowercase())
.map(SubAgent::from)
.map(|agent| {
let supported_tags = agent.supported_resource_tags();
select_resources(resources, &supported_tags)
})
.unwrap_or_default()
}
}
impl Tool<BaseCtx> for SkillManager {
type Args = SkillArgs;
type Output = SkillContentOutput;
fn name(&self) -> String {
Self::NAME.to_string()
}
fn description(&self) -> String {
self.description.clone()
}
fn definition(&self) -> FunctionDefinition {
FunctionDefinition {
name: self.name(),
description: self.description(),
parameters: json!({
"type": "object",
"description": "Read a reusable skill's SKILL.md file content by skill name. Create or update skills by editing files directly with shell or file tools, then reload the manager.",
"properties": {
"name": {
"type": "string",
"description": "Skill name in kebab-case (e.g. 'pdf-processing'). Returns the matching SKILL.md content so the agent can follow it directly.",
"pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$",
"maxLength": 64
}
},
"required": ["name"],
"additionalProperties": false
}),
strict: Some(true),
}
}
async fn call(
&self,
_ctx: BaseCtx,
args: Self::Args,
_resources: Vec<Resource>,
) -> Result<ToolOutput<Self::Output>, BoxError> {
Ok(ToolOutput::new(self.read_skill_action(args).await?))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{context::BaseCtx, engine::EngineBuilder, subagent::SubAgentSet};
use std::sync::Arc;
fn mock_ctx() -> BaseCtx {
EngineBuilder::new().mock_ctx().base
}
fn skill_md(name: &str, description: &str, body: &str, allowed_tools: Option<&str>) -> String {
let mut content = format!("---\nname: {name}\ndescription: {description}\n");
if let Some(allowed_tools) = allowed_tools {
content.push_str(&format!("allowed-tools: {allowed_tools}\n"));
}
content.push_str("---\n\n");
content.push_str(body);
if !body.ends_with('\n') {
content.push('\n');
}
content
}
#[test]
fn skill_manager_tool_definition_schema() {
let mgr = SkillManager::new(PathBuf::from("/tmp/skills"));
let def = mgr.definition();
assert_eq!(def.name, "skills_manager");
assert!(def.description.contains("Agent Skills specification"));
assert_eq!(def.parameters["additionalProperties"], json!(false));
assert_eq!(def.parameters["required"], json!(["name"]));
assert!(def.parameters["properties"].get("action").is_none());
assert_eq!(def.parameters["properties"]["name"]["maxLength"], json!(64));
}
#[tokio::test]
async fn load_and_read_from_temp_dir() {
let tmp =
std::env::temp_dir().join(format!("anda-skills-test-{:016x}", rand::random::<u64>()));
tokio::fs::create_dir_all(tmp.join("alpha")).await.unwrap();
tokio::fs::create_dir_all(tmp.join("beta-skill"))
.await
.unwrap();
tokio::fs::write(
tmp.join("alpha/SKILL.md"),
"\
---
name: alpha
description: Alpha skill for testing.
---
Alpha instructions.
",
)
.await
.unwrap();
tokio::fs::write(
tmp.join("beta-skill/SKILL.md"),
"\
---
name: beta-skill
description: Beta skill for testing.
license: MIT
allowed-tools: shell fetch
---
Beta instructions.
",
)
.await
.unwrap();
let mgr = SkillManager::new(tmp.clone());
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_alpha"));
assert!(mgr.contains_lowercase("skill_beta_skill"));
assert!(!mgr.contains_lowercase("skill_gamma"));
let alpha = mgr.get_lowercase("skill_alpha").unwrap();
assert_eq!(alpha.description, "Alpha skill for testing.");
assert_eq!(alpha.tools, DEFAULT_SKILL_TOOLS);
let beta = mgr.get_lowercase("skill_beta_skill").unwrap();
assert_eq!(
beta.tools,
vec![
"shell",
"read_file",
"search_file",
"write_file",
"edit_file",
"todo",
"tools_search",
"tools_select",
"fetch"
]
);
assert!(beta.instructions.contains("Beta instructions."));
let beta_skill = mgr.get_skill("skill_beta_skill").unwrap();
assert_eq!(beta_skill.frontmatter.license.as_deref(), Some("MIT"));
let beta_content = mgr
.call_raw(mock_ctx(), json!({ "name": "beta-skill" }), Vec::new())
.await
.unwrap();
assert_eq!(beta_content.output["name"], json!("beta-skill"));
assert_eq!(beta_content.output["agent_name"], json!("skill_beta_skill"));
assert_eq!(beta_content.output["path"], json!("beta-skill/SKILL.md"));
assert!(
beta_content.output["content"]
.as_str()
.unwrap()
.contains("Beta instructions.")
);
tokio::fs::create_dir_all(tmp.join("gamma")).await.unwrap();
tokio::fs::write(
tmp.join("gamma/SKILL.md"),
skill_md(
"gamma",
"Gamma skill for testing.",
"Gamma instructions.",
None,
),
)
.await
.unwrap();
mgr.load().await.unwrap();
assert!(mgr.contains_lowercase("skill_gamma"));
assert!(tmp.join("gamma/SKILL.md").exists());
let on_disk = tokio::fs::read_to_string(tmp.join("gamma/SKILL.md"))
.await
.unwrap();
let reparsed = parse_skill_md(tmp.to_path_buf(), &on_disk).unwrap();
assert_eq!(reparsed.frontmatter.name, "gamma");
let defs = mgr.definitions(None);
assert_eq!(defs.len(), 3);
let defs_filtered = mgr.definitions(Some(&["skill_gamma".to_string()]));
assert_eq!(defs_filtered.len(), 1);
assert_eq!(defs_filtered[0].name, "skill_gamma");
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test]
#[ignore = "skip"]
async fn load_skips_mismatched_dir_name() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-mismatch-{:016x}",
rand::random::<u64>()
));
tokio::fs::create_dir_all(tmp.join("wrong-dir"))
.await
.unwrap();
tokio::fs::write(
tmp.join("wrong-dir/SKILL.md"),
"\
---
name: correct-name
description: Name does not match directory.
---
Body.
",
)
.await
.unwrap();
let mgr = SkillManager::new(tmp.clone());
mgr.load().await.unwrap();
assert!(!mgr.contains_lowercase("skill_correct_name"));
let _ = tokio::fs::remove_dir_all(&tmp).await;
}
#[tokio::test(flavor = "current_thread")]
async fn tool_requires_name() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-requires-name-{:016x}",
rand::random::<u64>()
));
let mgr = SkillManager::new(tmp.clone());
let err = mgr
.call_raw(mock_ctx(), json!({}), Vec::new())
.await
.unwrap_err();
assert!(err.to_string().contains("missing field `name`"));
}
#[tokio::test(flavor = "current_thread")]
async fn tool_rejects_mutation_fields() {
let tmp = std::env::temp_dir().join(format!(
"anda-skills-rejects-action-{:016x}",
rand::random::<u64>()
));
let mgr = SkillManager::new(tmp.clone());
let err = mgr
.call_raw(
mock_ctx(),
json!({
"action": "create",
"name": "golf"
}),
Vec::new(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("unknown field `action`"));
}
#[tokio::test(flavor = "current_thread")]
async fn sub_agents_manager_register_skills_manager() {
let tmp =
std::env::temp_dir().join(format!("anda-skills-val-{:016x}", rand::random::<u64>()));
let tool = SkillManager::new(tmp.clone());
let engine = EngineBuilder::new().empty().await.unwrap();
assert!(engine.sub_agents_manager().insert(Arc::new(tool)).is_none());
}
}