初步实现skill

This commit is contained in:
xiaoxixi 2026-04-26 23:18:23 +08:00
parent 98259a7770
commit 401a7b6473
3 changed files with 371 additions and 1 deletions

View File

@ -10,4 +10,5 @@ pub mod channels;
pub mod logging;
pub mod observability;
pub mod storage;
pub mod skills;
pub mod tools;

View File

@ -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<ToolRegistry>,
store: Arc<SessionStore>,
skills: Vec<Skill>,
}
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?;

350
src/skills/mod.rs Normal file
View File

@ -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<String>,
description: Option<String>,
}
/// 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<Skill> {
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<Skill> {
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<Skill> {
// 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("<skills>\n");
for skill in skills {
prompt.push_str(" <skill>\n");
prompt.push_str(&format!(" <name>{}</name>\n", escape_xml(&skill.name)));
prompt.push_str(&format!(
" <description>{}</description>\n",
escape_xml(&skill.description)
));
prompt.push_str(" <instructions>\n");
prompt.push_str(&format!(
" <instruction>{}</instruction>\n",
escape_xml(&skill.content)
));
prompt.push_str(" </instructions>\n");
prompt.push_str(" </skill>\n");
}
prompt.push_str("</skills>\n");
prompt
}
/// Parse a skill from markdown content
fn parse_skill(&self, dir: &Path, content: &str) -> Option<Skill> {
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("&amp;"),
'<' => result.push_str("&lt;"),
'>' => result.push_str("&gt;"),
'"' => result.push_str("&quot;"),
'\'' => result.push_str("&apos;"),
_ => 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 &amp; b");
assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
assert_eq!(escape_xml("\"quote\""), "&quot;quote&quot;");
}
#[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());
}
}