feat: 扩展技能源支持,添加用户代理和项目代理,优化技能管理工具描述

This commit is contained in:
ooodc 2026-04-28 09:32:37 +08:00
parent 137a62f1cc
commit bca86abe67
3 changed files with 172 additions and 14 deletions

View File

@ -69,7 +69,12 @@ fn default_skills_enabled() -> bool {
} }
fn default_skills_sources() -> Vec<String> { fn default_skills_sources() -> Vec<String> {
vec!["project".to_string(), "user".to_string()] vec![
"user".to_string(),
"user_agent".to_string(),
"project".to_string(),
"project_agent".to_string(),
]
} }
fn default_skills_max_index_chars() -> usize { fn default_skills_max_index_chars() -> usize {
@ -778,6 +783,21 @@ mod tests {
assert_eq!(provider_config.llm_timeout_secs, 400); assert_eq!(provider_config.llm_timeout_secs, 400);
} }
#[test]
fn test_default_skills_sources_include_agent_directories() {
let config = SkillsConfig::default();
assert_eq!(
config.sources,
vec![
"user".to_string(),
"user_agent".to_string(),
"project".to_string(),
"project_agent".to_string(),
]
);
}
#[test] #[test]
fn test_default_gateway_config() { fn test_default_gateway_config() {
let file = write_test_config(); let file = write_test_config();

View File

@ -20,7 +20,9 @@ pub struct Skill {
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkillSource { pub enum SkillSource {
User, User,
UserAgent,
Project, Project,
ProjectAgent,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -197,7 +199,9 @@ impl SkillSource {
fn as_str(&self) -> &'static str { fn as_str(&self) -> &'static str {
match self { match self {
SkillSource::User => "user", SkillSource::User => "user",
SkillSource::UserAgent => "user_agent",
SkillSource::Project => "project", SkillSource::Project => "project",
SkillSource::ProjectAgent => "project_agent",
} }
} }
} }
@ -233,13 +237,10 @@ impl SkillCatalog {
let mut merged: HashMap<String, Skill> = HashMap::new(); let mut merged: HashMap<String, Skill> = HashMap::new();
let mut sources_seen = 0usize; let mut sources_seen = 0usize;
// Load user first, then project. Project wins on conflicts. // Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) { for source in source_order(&config.sources) {
sources_seen += 1; sources_seen += 1;
let root = match source { let root = source_root(source, &cwd);
SkillSource::User => user_skills_root(),
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
};
let Some(root) = root else { continue }; let Some(root) = root else { continue };
for skill in load_skills_from_root(&root, source) { for skill in load_skills_from_root(&root, source) {
@ -285,7 +286,7 @@ impl SkillCatalog {
} }
let mut prompt = String::from( let mut prompt = String::from(
"You have access to project skills. 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", "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) { for skill in self.skills.iter().take(self.max_listed_skills) {
@ -394,11 +395,21 @@ fn source_order(sources: &[String]) -> Vec<SkillSource> {
result.push(SkillSource::User); result.push(SkillSource::User);
} }
} }
"user_agent" => {
if !result.contains(&SkillSource::UserAgent) {
result.push(SkillSource::UserAgent);
}
}
"project" => { "project" => {
if !result.contains(&SkillSource::Project) { if !result.contains(&SkillSource::Project) {
result.push(SkillSource::Project); result.push(SkillSource::Project);
} }
} }
"project_agent" => {
if !result.contains(&SkillSource::ProjectAgent) {
result.push(SkillSource::ProjectAgent);
}
}
unknown => { unknown => {
tracing::warn!(source = %unknown, "Unknown skills source ignored"); tracing::warn!(source = %unknown, "Unknown skills source ignored");
} }
@ -406,7 +417,12 @@ fn source_order(sources: &[String]) -> Vec<SkillSource> {
} }
if result.is_empty() { if result.is_empty() {
vec![SkillSource::User, SkillSource::Project] vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::Project,
SkillSource::ProjectAgent,
]
} else { } else {
result result
} }
@ -427,10 +443,27 @@ pub fn project_skills_root() -> Result<PathBuf, String> {
Ok(cwd.join(".picobot").join("skills")) 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> { fn user_skills_root() -> Option<PathBuf> {
dirs::home_dir().map(|p| p.join(".picobot").join("skills")) 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> { fn root_for_scope(scope: SkillScope) -> Result<PathBuf, String> {
match scope { match scope {
SkillScope::User => user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()), SkillScope::User => user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()),
@ -570,6 +603,27 @@ fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn test_split_frontmatter() { fn test_split_frontmatter() {
@ -639,9 +693,9 @@ mod tests {
#[test] #[test]
fn test_runtime_create_update_delete_reload() { fn test_runtime_create_update_delete_reload() {
let _lock = CWD_TEST_LOCK.lock().unwrap();
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
let previous = std::env::current_dir().unwrap(); let _guard = CurrentDirGuard::enter(temp_dir.path());
std::env::set_current_dir(temp_dir.path()).unwrap();
let runtime = SkillRuntime::from_config(SkillsConfig { let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true, enabled: true,
@ -675,7 +729,87 @@ mod tests {
.unwrap(); .unwrap();
assert!(!deleted_path.exists()); assert!(!deleted_path.exists());
assert_eq!(runtime.len(), 0); assert_eq!(runtime.len(), 0);
}
std::env::set_current_dir(previous).unwrap(); #[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"));
} }
} }

View File

@ -32,7 +32,7 @@ impl Tool for SkillManageTool {
} }
fn description(&self) -> &str { fn description(&self) -> &str {
"Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills. Supports actions: list, get, create, update, delete, reload." "Manage PicoBot skills stored under .picobot/skills or ~/.picobot/skills, while discovery also reads .agents/skills and ~/.agents/skills. Supports actions: list, get, create, update, delete, reload."
} }
fn parameters_schema(&self) -> serde_json::Value { fn parameters_schema(&self) -> serde_json::Value {
@ -47,7 +47,7 @@ impl Tool for SkillManageTool {
"scope": { "scope": {
"type": "string", "type": "string",
"enum": ["project", "user"], "enum": ["project", "user"],
"description": "Skill scope for create/update/delete. Defaults to project." "description": "Writable skill scope for create/update/delete. Defaults to project. .agents discovery sources are read-only here."
}, },
"name": { "name": {
"type": "string", "type": "string",
@ -83,7 +83,7 @@ impl Tool for SkillManageTool {
let scope = match args.get("scope").and_then(|v| v.as_str()) { let scope = match args.get("scope").and_then(|v| v.as_str()) {
Some(value) => match SkillScope::parse(value) { Some(value) => match SkillScope::parse(value) {
Some(scope) => scope, Some(scope) => scope,
None => return Ok(error_result("scope must be 'project' or 'user'")), None => return Ok(error_result("scope must be 'project' or 'user'; .agents sources are discovery-only")),
}, },
None => SkillScope::Project, None => SkillScope::Project,
}; };
@ -107,7 +107,9 @@ impl Tool for SkillManageTool {
"body": skill.body, "body": skill.body,
"source": match skill.source { "source": match skill.source {
crate::skills::SkillSource::User => "user", crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::UserAgent => "user_agent",
crate::skills::SkillSource::Project => "project", crate::skills::SkillSource::Project => "project",
crate::skills::SkillSource::ProjectAgent => "project_agent",
}, },
"path": skill.path.display().to_string(), "path": skill.path.display().to_string(),
}), }),
@ -241,7 +243,9 @@ fn list_skills_payload(skills: &Arc<SkillRuntime>) -> serde_json::Value {
"description": skill.description, "description": skill.description,
"source": match skill.source { "source": match skill.source {
crate::skills::SkillSource::User => "user", crate::skills::SkillSource::User => "user",
crate::skills::SkillSource::UserAgent => "user_agent",
crate::skills::SkillSource::Project => "project", crate::skills::SkillSource::Project => "project",
crate::skills::SkillSource::ProjectAgent => "project_agent",
}, },
"path": skill.path.display().to_string(), "path": skill.path.display().to_string(),
})).collect::<Vec<_>>() })).collect::<Vec<_>>()