From 401a7b6473c165618ebfae56993590037ff55965 Mon Sep 17 00:00:00 2001 From: xiaoxixi Date: Sun, 26 Apr 2026 23:18:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=AE=9E=E7=8E=B0skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib.rs | 1 + src/session/session.rs | 21 ++- src/skills/mod.rs | 350 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/skills/mod.rs diff --git a/src/lib.rs b/src/lib.rs index 2e371d5..5b270ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,4 +10,5 @@ pub mod channels; pub mod logging; pub mod observability; pub mod storage; +pub mod skills; pub mod tools; diff --git a/src/session/session.rs b/src/session/session.rs index fa4ba01..d47faaa 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -10,6 +10,7 @@ use crate::protocol::WsOutbound; use crate::providers::{create_provider, LLMProvider}; use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID}; use crate::session::events::DialogInfo; +use crate::skills::{Skill, SkillsLoader}; use crate::storage::{SessionRecord, SessionStore}; use crate::tools::{ BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, @@ -178,6 +179,7 @@ pub struct SessionManager { provider_config: LLMProviderConfig, tools: Arc, store: Arc, + skills: Vec, } struct SessionManagerInner { @@ -239,6 +241,10 @@ impl SessionManager { .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, ); + // Load skills from standard locations + let skills_loader = SkillsLoader::new(); + let skills = skills_loader.load_skills(); + Ok(Self { inner: Arc::new(Mutex::new(SessionManagerInner { sessions: HashMap::new(), @@ -248,6 +254,7 @@ impl SessionManager { provider_config, tools: Arc::new(default_tools()), store, + skills, }) } @@ -607,8 +614,20 @@ impl SessionManager { // 加载历史 session_guard.load_history()?; + // 构建历史消息 + let mut history = session_guard.get_history().to_vec(); + + // Prepend skills as a system message if skills are available + if !self.skills.is_empty() { + let skills_prompt = SkillsLoader::build_skills_prompt_from_skills(&self.skills); + if !skills_prompt.is_empty() { + let skills_message = ChatMessage::system(skills_prompt); + history.insert(0, skills_message); + tracing::debug!(skill_count = self.skills.len(), "Injected skills into context"); + } + } + // 压缩历史(如果需要) - let history = session_guard.get_history().to_vec(); let history = session_guard.compressor .compress_if_needed(history) .await?; diff --git a/src/skills/mod.rs b/src/skills/mod.rs new file mode 100644 index 0000000..6ffe844 --- /dev/null +++ b/src/skills/mod.rs @@ -0,0 +1,350 @@ +use std::path::{Path, PathBuf}; + +/// Skill definition +#[derive(Debug, Clone)] +pub struct Skill { + pub name: String, + pub description: String, + pub content: String, +} + +struct SkillMarkdownMeta { + name: Option, + description: Option, +} + +/// Skills loader - loads skills from multiple directories +pub struct SkillsLoader { + picobot_skills_dir: PathBuf, + agent_skills_dir: PathBuf, +} + +impl SkillsLoader { + /// Create a new loader with default paths + pub fn new() -> Self { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + Self { + picobot_skills_dir: home.join(".picobot/skills"), + agent_skills_dir: home.join(".agent/skills"), + } + } + + /// Load all skills from both directories + pub fn load_skills(&self) -> Vec { + let mut skills = Vec::new(); + + // Load from ~/.picobot/skills + if self.picobot_skills_dir.exists() { + let loaded = self.load_skills_from_dir(&self.picobot_skills_dir); + tracing::debug!( + dir = %self.picobot_skills_dir.display(), + count = loaded.len(), + "Loaded skills from picobot directory" + ); + skills.extend(loaded); + } + + // Load from ~/.agent/skills + if self.agent_skills_dir.exists() { + let loaded = self.load_skills_from_dir(&self.agent_skills_dir); + tracing::debug!( + dir = %self.agent_skills_dir.display(), + count = loaded.len(), + "Loaded skills from agent directory" + ); + skills.extend(loaded); + } + + if skills.is_empty() { + tracing::debug!("No skills found in any skills directory"); + } else { + tracing::info!(count = skills.len(), "Loaded {} skills total", skills.len()); + } + + skills + } + + /// Load skills from a specific directory + fn load_skills_from_dir(&self, dir: &Path) -> Vec { + let mut skills = Vec::new(); + + let Ok(entries) = std::fs::read_dir(dir) else { + tracing::warn!(dir = %dir.display(), "Failed to read skills directory"); + return skills; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let skill_file = path.join("SKILL.md"); + if !skill_file.exists() { + continue; + } + + match std::fs::read_to_string(&skill_file) { + Ok(content) => { + match self.parse_skill(&path, &content) { + Some(skill) => { + tracing::debug!( + skill = %skill.name, + path = %skill_file.display(), + "Loaded skill" + ); + skills.push(skill); + } + None => { + tracing::warn!( + path = %skill_file.display(), + "Failed to parse skill" + ); + } + } + } + Err(e) => { + tracing::warn!( + path = %skill_file.display(), + error = %e, + "Failed to read skill file" + ); + } + } + } + + skills + } + + /// List all skills (name + description) + pub fn list_skills(&self) -> Vec<(String, String)> { + self.load_skills() + .into_iter() + .map(|s| (s.name, s.description)) + .collect() + } + + /// Get a specific skill by name + pub fn get_skill(&self, name: &str) -> Option { + // Check picobot_skills first + let picobot_path = self.picobot_skills_dir.join(name).join("SKILL.md"); + if picobot_path.exists() { + if let Ok(content) = std::fs::read_to_string(&picobot_path) { + let dir = self.picobot_skills_dir.join(name); + return self.parse_skill(&dir, &content); + } + } + + // Check agent_skills + let agent_path = self.agent_skills_dir.join(name).join("SKILL.md"); + if agent_path.exists() { + if let Ok(content) = std::fs::read_to_string(&agent_path) { + let dir = self.agent_skills_dir.join(name); + return self.parse_skill(&dir, &content); + } + } + + None + } + + /// Build skills prompt for agent context (reloads from disk) + pub fn build_skills_prompt(&self) -> String { + let skills = self.load_skills(); + Self::format_skills_prompt(&skills) + } + + /// Build skills prompt from already-loaded skills (no disk I/O) + pub fn build_skills_prompt_from_skills(skills: &[Skill]) -> String { + Self::format_skills_prompt(skills) + } + + /// Format skills into a prompt string + fn format_skills_prompt(skills: &[Skill]) -> String { + if skills.is_empty() { + return String::new(); + } + + let mut prompt = String::from("## Available Skills\n\n"); + prompt.push_str("Skills teach the agent how to use specific capabilities.\n\n"); + prompt.push_str("\n"); + + for skill in skills { + prompt.push_str(" \n"); + prompt.push_str(&format!(" {}\n", escape_xml(&skill.name))); + prompt.push_str(&format!( + " {}\n", + escape_xml(&skill.description) + )); + prompt.push_str(" \n"); + prompt.push_str(&format!( + " {}\n", + escape_xml(&skill.content) + )); + prompt.push_str(" \n"); + prompt.push_str(" \n"); + } + + prompt.push_str("\n"); + prompt + } + + /// Parse a skill from markdown content + fn parse_skill(&self, dir: &Path, content: &str) -> Option { + let (meta, body) = self.parse_skill_markdown(content); + + let name = meta.name.or_else(|| { + dir.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + })?; + + let description = meta + .description + .unwrap_or_else(|| extract_description(&body)); + + Some(Skill { + name, + description, + content: body, + }) + } + + /// Parse skill markdown, extracting frontmatter and body + fn parse_skill_markdown(&self, content: &str) -> (SkillMarkdownMeta, String) { + let normalized = content.replace("\r\n", "\n"); + + if let Some(stripped) = normalized.strip_prefix("---\n") { + if let Some(idx) = stripped.find("\n---\n") { + let frontmatter = stripped[..idx].to_string(); + let body = stripped[idx + 5..].trim().to_string(); + let meta = self.parse_frontmatter(&frontmatter); + return (meta, body); + } + if let Some(frontmatter) = stripped.strip_suffix("\n---") { + return (self.parse_frontmatter(frontmatter), String::new()); + } + } + + (SkillMarkdownMeta::default(), normalized) + } + + /// Parse simple YAML-like frontmatter + fn parse_frontmatter(&self, content: &str) -> SkillMarkdownMeta { + let mut meta = SkillMarkdownMeta::default(); + + for line in content.lines() { + let Some((key, val)) = line.split_once(':') else { + continue; + }; + let key = key.trim(); + let val = val.trim().trim_matches('"').trim_matches('\''); + + match key { + "name" => meta.name = Some(val.to_string()), + "description" => meta.description = Some(val.to_string()), + _ => {} + } + } + + meta + } +} + +impl Default for SkillsLoader { + fn default() -> Self { + Self::new() + } +} + +impl Default for SkillMarkdownMeta { + fn default() -> Self { + Self { + name: None, + description: None, + } + } +} + +/// Extract first non-empty, non-heading line as description +fn extract_description(content: &str) -> String { + content + .lines() + .find(|line| !line.starts_with('#') && !line.trim().is_empty()) + .map(|l| l.trim().to_string()) + .unwrap_or_else(|| "No description".to_string()) +} + +/// Escape XML special characters +fn escape_xml(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => result.push_str("&"), + '<' => result.push_str("<"), + '>' => result.push_str(">"), + '"' => result.push_str("""), + '\'' => result.push_str("'"), + _ => result.push(c), + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_skill_without_frontmatter() { + let loader = SkillsLoader::new(); + let content = "# My Skill\n\nThis is the content."; + let (meta, body) = loader.parse_skill_markdown(content); + + assert!(meta.name.is_none()); + assert!(meta.description.is_none()); + assert!(body.contains("My Skill")); + } + + #[test] + fn test_parse_skill_with_frontmatter() { + let loader = SkillsLoader::new(); + let content = r#"--- +name: test-skill +description: A test skill +--- +# Test Skill + +This is the content. +"#; + + let (meta, body) = loader.parse_skill_markdown(content); + + assert_eq!(meta.name, Some("test-skill".to_string())); + assert_eq!(meta.description, Some("A test skill".to_string())); + assert!(body.contains("Test Skill")); + } + + #[test] + fn test_escape_xml() { + assert_eq!(escape_xml("a & b"), "a & b"); + assert_eq!(escape_xml(""), "<tag>"); + assert_eq!(escape_xml("\"quote\""), ""quote""); + } + + #[test] + fn test_extract_description() { + assert_eq!( + extract_description("# Title\n\nFirst line of content."), + "First line of content." + ); + assert_eq!(extract_description("# Title"), "No description"); + } + + #[test] + fn test_load_skills_from_empty_dir() { + let loader = SkillsLoader::new(); + let temp_dir = tempfile::tempdir().unwrap(); + let skills = loader.load_skills_from_dir(temp_dir.path()); + assert!(skills.is_empty()); + } +}