初步实现skill
This commit is contained in:
parent
98259a7770
commit
401a7b6473
@ -10,4 +10,5 @@ pub mod channels;
|
|||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
pub mod skills;
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ use crate::protocol::WsOutbound;
|
|||||||
use crate::providers::{create_provider, LLMProvider};
|
use crate::providers::{create_provider, LLMProvider};
|
||||||
use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID};
|
use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID};
|
||||||
use crate::session::events::DialogInfo;
|
use crate::session::events::DialogInfo;
|
||||||
|
use crate::skills::{Skill, SkillsLoader};
|
||||||
use crate::storage::{SessionRecord, SessionStore};
|
use crate::storage::{SessionRecord, SessionStore};
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
||||||
@ -178,6 +179,7 @@ pub struct SessionManager {
|
|||||||
provider_config: LLMProviderConfig,
|
provider_config: LLMProviderConfig,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
store: Arc<SessionStore>,
|
store: Arc<SessionStore>,
|
||||||
|
skills: Vec<Skill>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionManagerInner {
|
struct SessionManagerInner {
|
||||||
@ -239,6 +241,10 @@ impl SessionManager {
|
|||||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
.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 {
|
Ok(Self {
|
||||||
inner: Arc::new(Mutex::new(SessionManagerInner {
|
inner: Arc::new(Mutex::new(SessionManagerInner {
|
||||||
sessions: HashMap::new(),
|
sessions: HashMap::new(),
|
||||||
@ -248,6 +254,7 @@ impl SessionManager {
|
|||||||
provider_config,
|
provider_config,
|
||||||
tools: Arc::new(default_tools()),
|
tools: Arc::new(default_tools()),
|
||||||
store,
|
store,
|
||||||
|
skills,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -607,8 +614,20 @@ impl SessionManager {
|
|||||||
// 加载历史
|
// 加载历史
|
||||||
session_guard.load_history()?;
|
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
|
let history = session_guard.compressor
|
||||||
.compress_if_needed(history)
|
.compress_if_needed(history)
|
||||||
.await?;
|
.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