PicoBot/src/skills/mod.rs

817 lines
24 KiB
Rust

use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use crate::config::SkillsConfig;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub body: String,
pub source: SkillSource,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource {
User,
UserAgent,
Project,
ProjectAgent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillScope {
User,
Project,
}
impl SkillScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
impl From<SkillScope> for SkillSource {
fn from(value: SkillScope) -> Self {
match value {
SkillScope::User => SkillSource::User,
SkillScope::Project => SkillSource::Project,
}
}
}
#[derive(Debug)]
pub struct SkillRuntime {
config: SkillsConfig,
catalog: RwLock<SkillCatalog>,
}
impl Default for SkillRuntime {
fn default() -> Self {
Self {
config: SkillsConfig::default(),
catalog: RwLock::new(SkillCatalog::default()),
}
}
}
impl SkillRuntime {
pub fn from_config(config: SkillsConfig) -> Self {
let catalog = SkillCatalog::discover(&config);
Self {
config,
catalog: RwLock::new(catalog),
}
}
pub fn reload(&self) -> Result<SkillCatalog, String> {
let catalog = SkillCatalog::discover(&self.config);
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.expect("skills rwlock poisoned")
.is_empty()
}
pub fn len(&self) -> usize {
self.catalog.read().expect("skills rwlock poisoned").len()
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt()
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.discovery_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.offered_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_payload(name)
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_event_payload(name)
}
pub fn list_skills(&self) -> Vec<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.skills
.clone()
}
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.find_skill(name)
.cloned()
}
pub fn create_skill(
&self,
scope: SkillScope,
name: &str,
description: &str,
body: &str,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if path.exists() {
return Err(format!(
"skill '{}' already exists at {}",
name,
path.display()
));
}
write_skill_file(&path, name, description, body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn update_skill(
&self,
scope: SkillScope,
name: &str,
description: Option<&str>,
body: Option<&str>,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if !path.exists() {
return Err(format!("skill '{}' not found at {}", name, path.display()));
}
let existing = parse_skill_file(&path, scope.into())?;
let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body);
write_skill_file(&path, name, next_description, next_body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn delete_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_skill_name(name)?;
let dir = skill_dir_path(scope, name)?;
if !dir.exists() {
return Err(format!("skill '{}' not found at {}", name, dir.display()));
}
fs::remove_dir_all(&dir)
.map_err(|err| format!("failed to delete skill directory: {}", err))?;
if reload {
let _ = self.reload()?;
}
Ok(dir)
}
}
impl crate::agent::SkillProvider for SkillRuntime {
fn system_index_prompt(&self) -> Option<String> {
SkillRuntime::system_index_prompt(self)
}
}
impl SkillSource {
fn as_str(&self) -> &'static str {
match self {
SkillSource::User => "user",
SkillSource::UserAgent => "user_agent",
SkillSource::Project => "project",
SkillSource::ProjectAgent => "project_agent",
}
}
}
#[derive(Debug, Clone)]
pub struct SkillCatalog {
skills: Vec<Skill>,
max_index_chars: usize,
max_listed_skills: usize,
}
impl Default for SkillCatalog {
fn default() -> Self {
Self {
skills: Vec::new(),
max_index_chars: 4_000,
max_listed_skills: 32,
}
}
}
impl SkillCatalog {
pub fn discover(config: &SkillsConfig) -> Self {
if !config.enabled {
return Self {
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
..Self::default()
};
}
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let mut merged: HashMap<String, Skill> = HashMap::new();
let mut sources_seen = 0usize;
// Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) {
sources_seen += 1;
let root = source_root(source, &cwd);
let Some(root) = root else { continue };
for skill in load_skills_from_root(&root, source) {
if let Some(existing) = merged.get(&skill.name) {
tracing::warn!(
skill = %skill.name,
old_source = %existing.source.as_str(),
new_source = %skill.source.as_str(),
"Duplicate skill name found; overriding with later source"
);
}
merged.insert(skill.name.clone(), skill);
}
}
let mut skills: Vec<Skill> = merged.into_values().collect();
skills.sort_by(|a, b| a.name.cmp(&b.name));
tracing::info!(
sources_seen,
discovered = skills.len(),
"Skills discovery completed"
);
Self {
skills,
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
}
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn system_index_prompt(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let mut prompt = String::from(
"You have access to skills discovered from local skill directories. Use a skill only when the user's request clearly matches the skill description.\nIf a skill is needed, call tool skill_activate with {\"name\": \"<skill-name>\"}.\nAvailable skills:\n",
);
for skill in self.skills.iter().take(self.max_listed_skills) {
let line = format!("- {}: {}\n", skill.name, skill.description);
if prompt.len() + line.len() > self.max_index_chars {
prompt.push_str("- ... (truncated)\n");
break;
}
prompt.push_str(&line);
}
Some(prompt)
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
if skill.body.is_empty() {
return Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.",
skill.name, skill.description
));
}
Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}",
skill.name,
skill.description,
skill.source.as_str(),
skill.path.display(),
skill.body
))
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
Ok(json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
"body_chars": skill.body.len(),
}))
}
fn find_skill(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name == name)
}
fn catalog_event_payload(&self) -> serde_json::Value {
json!({
"count": self.skills.len(),
"skills": self.skills.iter().map(|skill| json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
})).collect::<Vec<_>>()
})
}
}
fn source_order(sources: &[String]) -> Vec<SkillSource> {
let mut result = Vec::new();
for source in sources {
match source.as_str() {
"user" => {
if !result.contains(&SkillSource::User) {
result.push(SkillSource::User);
}
}
"user_agent" => {
if !result.contains(&SkillSource::UserAgent) {
result.push(SkillSource::UserAgent);
}
}
"project" => {
if !result.contains(&SkillSource::Project) {
result.push(SkillSource::Project);
}
}
"project_agent" => {
if !result.contains(&SkillSource::ProjectAgent) {
result.push(SkillSource::ProjectAgent);
}
}
unknown => {
tracing::warn!(source = %unknown, "Unknown skills source ignored");
}
}
}
if result.is_empty() {
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::Project,
SkillSource::ProjectAgent,
]
} else {
result
}
}
fn validate_skill_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("skill name cannot be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("skill name must not contain path separators or '..'".to_string());
}
Ok(())
}
pub fn project_skills_root() -> Result<PathBuf, String> {
let cwd =
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(cwd.join(".picobot").join("skills"))
}
fn project_agent_skills_root(cwd: &Path) -> PathBuf {
cwd.join(".agents").join("skills")
}
fn user_skills_root() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".picobot").join("skills"))
}
fn user_agent_skills_root() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".agents").join("skills"))
}
fn source_root(source: SkillSource, cwd: &Path) -> Option<PathBuf> {
match source {
SkillSource::User => user_skills_root(),
SkillSource::UserAgent => user_agent_skills_root(),
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)),
}
}
fn root_for_scope(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => {
user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string())
}
SkillScope::Project => project_skills_root(),
}
}
fn skill_dir_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(root_for_scope(scope)?.join(name))
}
fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(skill_dir_path(scope, name)?.join("SKILL.md"))
}
fn render_skill_file(name: &str, description: &str, body: &str) -> Result<String, String> {
if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string());
}
#[derive(serde::Serialize)]
struct SkillFrontmatterOwned {
name: String,
description: String,
}
let yaml = serde_yaml::to_string(&SkillFrontmatterOwned {
name: name.to_string(),
description: description.to_string(),
})
.map_err(|err| format!("failed to render skill frontmatter: {}", err))?;
let yaml = yaml.trim_start_matches("---\n");
let body = body.trim();
if body.is_empty() {
Ok(format!("---\n{}---\n", yaml))
} else {
Ok(format!("---\n{}---\n{}\n", yaml, body))
}
}
fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
let content = render_skill_file(name, description, body)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create skill directory: {}", err))?;
}
fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err))
}
fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec<Skill> {
let mut out = Vec::new();
if !root.exists() {
return out;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(err) => {
tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory");
return out;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill_file(&skill_md, source) {
Ok(skill) => out.push(skill),
Err(err) => {
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
}
}
}
out
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
description: String,
#[serde(default)]
name: Option<String>,
}
fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter_raw, body) =
split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?;
let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw)
.map_err(|e| format!("invalid YAML frontmatter: {}", e))?;
let description = frontmatter.description.trim();
if description.is_empty() {
return Err("description is required and cannot be empty".to_string());
}
let dir_name = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown-skill".to_string());
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
Ok(Skill {
name,
description: description.to_string(),
body: body.trim().to_string(),
source,
path: path.to_path_buf(),
})
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let rest = content.strip_prefix("---\n")?;
let marker = "\n---\n";
let idx = rest.find(marker)?;
let frontmatter = &rest[..idx];
let body = &rest[idx + marker.len()..];
Some((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static CWD_TEST_LOCK: Mutex<()> = Mutex::new(());
struct CurrentDirGuard {
previous: PathBuf,
}
impl CurrentDirGuard {
fn enter(path: &Path) -> Self {
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self { previous }
}
}
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.previous);
}
}
#[test]
fn test_split_frontmatter() {
let input = "---\ndescription: demo\n---\nhello";
let (fm, body) = split_frontmatter(input).unwrap();
assert!(fm.contains("description"));
assert_eq!(body, "hello");
}
#[test]
fn test_parse_skill_file_requires_description() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap();
let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_activation_payload_contains_body() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(
&skill_md,
"---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B",
)
.unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
let catalog = SkillCatalog {
skills: vec![skill],
max_index_chars: 1000,
max_listed_skills: 10,
};
let payload = catalog.activation_payload("demo").unwrap();
assert!(payload.contains("SKILL LOADED: demo"));
assert!(payload.contains("Step A"));
}
#[test]
fn test_runtime_create_update_delete_reload() {
let _lock = CWD_TEST_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _guard = CurrentDirGuard::enter(temp_dir.path());
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(runtime.len(), 0);
let created = runtime
.create_skill(
SkillScope::Project,
"demo-skill",
"demo desc",
"line 1",
true,
)
.unwrap();
assert_eq!(created.name, "demo-skill");
assert_eq!(runtime.len(), 1);
let updated = runtime
.update_skill(
SkillScope::Project,
"demo-skill",
Some("updated desc"),
Some("line 2"),
true,
)
.unwrap();
assert_eq!(updated.description, "updated desc");
assert!(
runtime
.activation_payload("demo-skill")
.unwrap()
.contains("line 2")
);
let deleted_path = runtime
.delete_skill(SkillScope::Project, "demo-skill", true)
.unwrap();
assert!(!deleted_path.exists());
assert_eq!(runtime.len(), 0);
}
#[test]
fn test_source_order_supports_agent_sources() {
let ordered = source_order(&[
"user".to_string(),
"user_agent".to_string(),
"project".to_string(),
"project_agent".to_string(),
"project".to_string(),
"unknown".to_string(),
]);
assert_eq!(
ordered,
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::Project,
SkillSource::ProjectAgent,
]
);
}
#[test]
fn test_discover_loads_project_agent_skills() {
let _lock = CWD_TEST_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _guard = CurrentDirGuard::enter(temp_dir.path());
let agent_skill_dir = temp_dir
.path()
.join(".agents")
.join("skills")
.join("demo-agent");
fs::create_dir_all(&agent_skill_dir).unwrap();
fs::write(
agent_skill_dir.join("SKILL.md"),
"---\ndescription: agent skill\n---\nUse agent",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project_agent".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-agent").unwrap();
assert_eq!(payload["source"], "project_agent");
}
#[test]
fn test_discover_prefers_project_agent_on_conflict() {
let _lock = CWD_TEST_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let _guard = CurrentDirGuard::enter(temp_dir.path());
let project_skill_dir = temp_dir.path().join(".picobot").join("skills").join("demo");
fs::create_dir_all(&project_skill_dir).unwrap();
fs::write(
project_skill_dir.join("SKILL.md"),
"---\ndescription: project version\n---\nProject body",
)
.unwrap();
let agent_skill_dir = temp_dir.path().join(".agents").join("skills").join("demo");
fs::create_dir_all(&agent_skill_dir).unwrap();
fs::write(
agent_skill_dir.join("SKILL.md"),
"---\ndescription: agent version\n---\nAgent body",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project".to_string(), "project_agent".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let payload = catalog.activation_payload("demo").unwrap();
assert!(payload.contains("Source: project_agent"));
assert!(payload.contains("Agent body"));
}
}