初步实现skill
This commit is contained in:
parent
98259a7770
commit
401a7b6473
@ -10,4 +10,5 @@ pub mod channels;
|
||||
pub mod logging;
|
||||
pub mod observability;
|
||||
pub mod storage;
|
||||
pub mod skills;
|
||||
pub mod tools;
|
||||
|
||||
@ -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
350
src/skills/mod.rs
Normal 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("&"),
|
||||
'<' => 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>"), "<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());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user