feat: 扩展技能源支持,添加用户代理和项目代理,优化技能管理工具描述
This commit is contained in:
parent
137a62f1cc
commit
bca86abe67
@ -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();
|
||||||
|
|||||||
@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<_>>()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user