PicoBot/src/skills/mod.rs
oudecheng 12f1094426 feat(capability): 新增 CapabilityPolicy 统一策略模型并集成至专家/子代理/技能
- domain: 定义 CapabilityPolicy(allowed/denied skills/tools)统一策略结构

- experts: 专家支持 capability 字段,新增 update_expert 写回 EXPERT.md

- skills: SkillRuntime 暴露 capability 过滤接口

- tools/task: SubagentDef 增加 capability 字段,SubagentRuntime 新增 update_subagent 写回 SUBAGENT.md,工具集按 capability 过滤

- agent_factory: 按 capability 构建专家/子代理工具集(白名单交集 + 黑名单扣除)
2026-07-30 16:53:30 +08:00

1573 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::platform::{atomic_rename, home_dir as platform_home_dir, path_to_uri, xml_escape as platform_xml_escape};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[cfg(test)]
static SKILL_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn acquire_skill_test_env_lock() -> std::sync::MutexGuard<'static, ()> {
SKILL_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner())
}
use crate::config::SkillsConfig;
#[derive(Debug, Clone)]
pub struct Skill {
pub name: String,
pub description: String,
pub body: String,
pub source: SkillSource,
pub path: PathBuf,
}
/// 渲染技能索引提示词(`<available_skills>` XML 块)。
/// 仅输出技能索引列表skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
fn render_skill_index(skills: &[&Skill]) -> String {
let mut prompt = String::from("# 可用技能Skills\n\n<available_skills>\n");
for skill in skills {
let entry = format!(
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\n",
platform_xml_escape(&skill.name),
platform_xml_escape(&skill.description),
platform_xml_escape(&path_to_uri(&skill.path)),
);
prompt.push_str(&entry);
}
prompt.push_str("</available_skills>\n");
prompt
}
/// A skill entry with its disabled status across scopes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillWithStatus {
pub name: String,
pub description: String,
pub source: String,
pub path: String,
/// Which scopes have this skill disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SkillSource {
User,
UserAgent,
UserOpenclaw,
Project,
ProjectAgent,
ProjectOpenclaw,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SkillScope {
User,
Project,
}
impl SkillScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
impl From<SkillScope> for SkillSource {
fn from(value: SkillScope) -> Self {
match value {
SkillScope::User => SkillSource::User,
SkillScope::Project => SkillSource::Project,
}
}
}
#[derive(Debug)]
pub struct SkillRuntime {
config: SkillsConfig,
catalog: RwLock<SkillCatalog>,
}
#[derive(Debug, Clone)]
pub struct SkillAvailabilityChange {
pub name: String,
pub scope: SkillScope,
pub state_path: PathBuf,
pub changed: bool,
pub disabled_in_scopes: Vec<SkillScope>,
pub available: bool,
}
impl Default for SkillRuntime {
fn default() -> Self {
Self {
config: SkillsConfig::default(),
catalog: RwLock::new(SkillCatalog::default()),
}
}
}
impl SkillRuntime {
pub fn from_config(config: SkillsConfig) -> Self {
let catalog = SkillCatalog::discover(&config);
Self {
config,
catalog: RwLock::new(catalog),
}
}
pub fn reload(&self) -> Result<SkillCatalog, String> {
let catalog = SkillCatalog::discover(&self.config);
let mut guard = self.catalog.write().expect("skills rwlock poisoned");
*guard = catalog.clone();
Ok(catalog)
}
pub fn is_empty(&self) -> bool {
self.catalog
.read()
.expect("skills rwlock poisoned")
.is_empty()
}
pub fn len(&self) -> usize {
self.catalog.read().expect("skills rwlock poisoned").len()
}
pub fn system_index_prompt(&self) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt()
}
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
pub fn system_index_prompt_filtered(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt_filtered(allowed, denied)
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.discovery_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog
.read()
.expect("skills rwlock poisoned")
.offered_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_payload(name)
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.activation_event_payload(name)
}
pub fn list_skills(&self) -> Vec<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.skills
.clone()
}
/// List all discovered skills including disabled ones, with their disabled scopes.
pub fn list_skills_with_status(&self) -> Vec<SkillWithStatus> {
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let catalog = SkillCatalog::discover_without_state(&self.config, &cwd);
let disable_state = load_skill_disable_state(&cwd);
catalog.skills.iter().map(|skill| {
let disabled_scopes = disable_state.disabled_scopes_for(&skill.name);
SkillWithStatus {
name: skill.name.clone(),
description: skill.description.clone(),
source: skill.source.as_str().to_string(),
path: skill.path.display().to_string(),
disabled_in_scopes: disabled_scopes.iter().map(|s| s.as_str().to_string()).collect(),
}
}).collect()
}
pub fn get_skill(&self, name: &str) -> Option<Skill> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.find_skill(name)
.cloned()
}
pub fn create_skill(
&self,
scope: SkillScope,
name: &str,
description: &str,
body: &str,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if path.exists() {
return Err(format!(
"skill '{}' already exists at {}",
name,
path.display()
));
}
write_skill_file(&path, name, description, body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn update_skill(
&self,
scope: SkillScope,
name: &str,
description: Option<&str>,
body: Option<&str>,
reload: bool,
) -> Result<Skill, String> {
validate_skill_name(name)?;
let path = skill_file_path(scope, name)?;
if !path.exists() {
return Err(format!("skill '{}' not found at {}", name, path.display()));
}
let existing = parse_skill_file(&path, scope.into())?;
let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body);
write_skill_file(&path, name, next_description, next_body)?;
let skill = parse_skill_file(&path, scope.into())?;
if reload {
let _ = self.reload()?;
}
Ok(skill)
}
pub fn delete_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<PathBuf, String> {
validate_skill_name(name)?;
let dir = skill_dir_path(scope, name)?;
if !dir.exists() {
return Err(format!("skill '{}' not found at {}", name, dir.display()));
}
fs::remove_dir_all(&dir)
.map_err(|err| format!("failed to delete skill directory: {}", err))?;
if reload {
let _ = self.reload()?;
}
Ok(dir)
}
pub fn disable_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
self.set_skill_enabled(scope, name, false, reload)
}
pub fn enable_skill(
&self,
scope: SkillScope,
name: &str,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
self.set_skill_enabled(scope, name, true, reload)
}
pub fn has_skill_definition(&self, name: &str) -> Result<bool, String> {
validate_skill_name(name)?;
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(SkillCatalog::discover_without_state(&self.config, &cwd)
.find_skill(name)
.is_some())
}
fn set_skill_enabled(
&self,
scope: SkillScope,
name: &str,
enabled: bool,
reload: bool,
) -> Result<SkillAvailabilityChange, String> {
validate_skill_name(name)?;
if !self.has_skill_definition(name)? {
return Err(format!("skill '{}' not found", name));
}
let state_path = skill_state_path(scope)?;
let mut state = load_skill_state_file(&state_path)?;
let mut disabled: HashSet<String> = state.disabled_skills.into_iter().collect();
let changed = if enabled {
disabled.remove(name)
} else {
disabled.insert(name.to_string())
};
let mut disabled_skills: Vec<String> = disabled.into_iter().collect();
disabled_skills.sort();
state.disabled_skills = disabled_skills;
save_skill_state_file(&state_path, &state)?;
if reload {
let _ = self.reload()?;
}
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
let effective_state = load_skill_disable_state(&cwd);
let disabled_in_scopes = effective_state.disabled_scopes_for(name);
Ok(SkillAvailabilityChange {
name: name.to_string(),
scope,
state_path,
changed,
available: disabled_in_scopes.is_empty(),
disabled_in_scopes,
})
}
}
impl crate::agent::SkillProvider for SkillRuntime {
fn system_index_prompt(&self) -> Option<String> {
SkillRuntime::system_index_prompt(self)
}
fn matching_skill_summary(&self, name: &str) -> Option<String> {
self.get_skill(name).map(|skill| skill.description)
}
}
impl SkillSource {
pub fn as_str(&self) -> &str {
match self {
SkillSource::User => "user",
SkillSource::UserAgent => "user_agent",
SkillSource::UserOpenclaw => "user_openclaw",
SkillSource::Project => "project",
SkillSource::ProjectAgent => "project_agent",
SkillSource::ProjectOpenclaw => "project_openclaw",
SkillSource::Custom(path) => path.as_str(),
}
}
}
#[derive(Debug, Clone)]
pub struct SkillCatalog {
skills: Vec<Skill>,
#[allow(dead_code)]
max_index_chars: usize,
#[allow(dead_code)]
max_listed_skills: usize,
}
impl Default for SkillCatalog {
fn default() -> Self {
Self {
skills: Vec::new(),
max_index_chars: 4_000,
max_listed_skills: 32,
}
}
}
impl SkillCatalog {
pub fn discover(config: &SkillsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let disable_state = load_skill_disable_state(&cwd);
Self::discover_with_state(config, &cwd, Some(&disable_state))
}
fn discover_without_state(config: &SkillsConfig, cwd: &Path) -> Self {
Self::discover_with_state(config, cwd, None)
}
fn discover_with_state(
config: &SkillsConfig,
cwd: &Path,
disable_state: Option<&SkillDisableState>,
) -> Self {
if !config.enabled {
return Self {
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
..Self::default()
};
}
let mut merged: HashMap<String, Skill> = HashMap::new();
let mut sources_seen = 0usize;
// Load from least specific to most specific so later sources win on conflicts.
for source in source_order(&config.sources) {
sources_seen += 1;
let root = source_root(&source, &cwd);
let Some(root) = root else { continue };
for skill in load_skills_from_root(&root, source.clone()) {
if let Some(existing) = merged.get(&skill.name) {
tracing::warn!(
skill = %skill.name,
old_source = %existing.source.as_str(),
new_source = %skill.source.as_str(),
"Duplicate skill name found; overriding with later source"
);
}
merged.insert(skill.name.clone(), skill);
}
}
let mut skills: Vec<Skill> = merged.into_values().collect();
if let Some(disable_state) = disable_state {
skills.retain(|skill| !disable_state.is_disabled(&skill.name));
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
tracing::info!(
sources_seen,
discovered = skills.len(),
"Skills discovery completed"
);
Self {
skills,
max_index_chars: config.max_index_chars,
max_listed_skills: config.max_listed_skills,
}
}
pub fn is_empty(&self) -> bool {
self.skills.is_empty()
}
pub fn len(&self) -> usize {
self.skills.len()
}
pub fn system_index_prompt(&self) -> Option<String> {
if self.skills.is_empty() {
return None;
}
let refs: Vec<&Skill> = self.skills.iter().collect();
Some(render_skill_index(&refs))
}
/// 按白/黑名单过滤后的技能索引。`allowed` 为 `None` 表示不限(白名单关闭),
/// `Some` 表示仅这些技能可见(空切片 = 全禁)。`denied` 为黑名单。
pub fn system_index_prompt_filtered(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
let denied_set: HashSet<&str> = denied.iter().map(|s| s.as_str()).collect();
let allowed_set: Option<HashSet<&str>> =
allowed.map(|a| a.iter().map(|s| s.as_str()).collect());
let filtered: Vec<&Skill> = self
.skills
.iter()
.filter(|s| !denied_set.contains(s.name.as_str()))
.filter(|s| {
allowed_set
.as_ref()
.map_or(true, |set| set.contains(s.name.as_str()))
})
.collect();
if filtered.is_empty() {
return None;
}
Some(render_skill_index(&filtered))
}
pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn offered_event_payload(&self) -> serde_json::Value {
self.catalog_event_payload()
}
pub fn activation_payload(&self, name: &str) -> Result<String, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
if skill.body.is_empty() {
return Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.",
skill.name, skill.description
));
}
Ok(format!(
"SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}",
skill.name,
skill.description,
skill.source.as_str(),
skill.path.display(),
skill.body
))
}
pub fn activation_event_payload(&self, name: &str) -> Result<serde_json::Value, String> {
let skill = self
.find_skill(name)
.ok_or_else(|| format!("skill '{}' not found", name))?;
Ok(json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
"body_chars": skill.body.len(),
}))
}
fn find_skill(&self, name: &str) -> Option<&Skill> {
self.skills.iter().find(|s| s.name == name)
}
fn catalog_event_payload(&self) -> serde_json::Value {
json!({
"count": self.skills.len(),
"skills": self.skills.iter().map(|skill| json!({
"name": skill.name,
"description": skill.description,
"source": skill.source.as_str(),
"path": skill.path.display().to_string(),
})).collect::<Vec<_>>()
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
struct SkillStateFile {
#[serde(default)]
disabled_skills: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct SkillDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
impl SkillDisableState {
fn is_disabled(&self, name: &str) -> bool {
self.user_disabled.contains(name) || self.project_disabled.contains(name)
}
fn disabled_scopes_for(&self, name: &str) -> Vec<SkillScope> {
let mut scopes = Vec::new();
if self.user_disabled.contains(name) {
scopes.push(SkillScope::User);
}
if self.project_disabled.contains(name) {
scopes.push(SkillScope::Project);
}
scopes
}
}
fn source_order(sources: &[String]) -> Vec<SkillSource> {
let mut result = Vec::new();
for source in sources {
match source.as_str() {
"user" => {
if !result.contains(&SkillSource::User) {
result.push(SkillSource::User);
}
}
"user_agent" => {
if !result.contains(&SkillSource::UserAgent) {
result.push(SkillSource::UserAgent);
}
}
"user_openclaw" => {
if !result.contains(&SkillSource::UserOpenclaw) {
result.push(SkillSource::UserOpenclaw);
}
}
"project" => {
if !result.contains(&SkillSource::Project) {
result.push(SkillSource::Project);
}
}
"project_agent" => {
if !result.contains(&SkillSource::ProjectAgent) {
result.push(SkillSource::ProjectAgent);
}
}
"project_openclaw" => {
if !result.contains(&SkillSource::ProjectOpenclaw) {
result.push(SkillSource::ProjectOpenclaw);
}
}
unknown => {
let custom = SkillSource::Custom(unknown.to_string());
if !result.contains(&custom) {
result.push(custom);
}
}
}
}
if result.is_empty() {
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::UserOpenclaw,
SkillSource::Project,
SkillSource::ProjectAgent,
SkillSource::ProjectOpenclaw,
]
} else {
result
}
}
fn validate_skill_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("skill name cannot be empty".to_string());
}
if name.contains('/') || name.contains('\\') || name.contains("..") {
return Err("skill name must not contain path separators or '..'".to_string());
}
Ok(())
}
pub fn project_skills_root() -> Result<PathBuf, String> {
let cwd =
std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(cwd.join(".picobot").join("skills"))
}
fn project_agent_skills_root(cwd: &Path) -> PathBuf {
cwd.join(".agents").join("skills")
}
fn project_openclaw_skills_root(cwd: &Path) -> PathBuf {
cwd.join(".openclaw").join("skills")
}
fn project_skill_state_path(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("skill-state.json")
}
fn user_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("skills"))
}
fn user_skill_state_path() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".picobot").join("skill-state.json"))
}
fn user_agent_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".agents").join("skills"))
}
fn user_openclaw_skills_root() -> Option<PathBuf> {
platform_home_dir().map(|p| p.join(".openclaw").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::UserOpenclaw => user_openclaw_skills_root(),
SkillSource::Project => Some(cwd.join(".picobot").join("skills")),
SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)),
SkillSource::ProjectOpenclaw => Some(project_openclaw_skills_root(cwd)),
SkillSource::Custom(path) => {
let p = std::path::PathBuf::from(path);
if p.is_absolute() {
Some(p)
} else {
tracing::warn!(path = %path, "Custom skills source must be an absolute path, skipping");
None
}
}
}
}
fn root_for_scope(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => {
user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string())
}
SkillScope::Project => project_skills_root(),
}
}
fn skill_dir_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(root_for_scope(scope)?.join(name))
}
fn skill_file_path(scope: SkillScope, name: &str) -> Result<PathBuf, String> {
Ok(skill_dir_path(scope, name)?.join("SKILL.md"))
}
fn skill_state_path(scope: SkillScope) -> Result<PathBuf, String> {
match scope {
SkillScope::User => user_skill_state_path()
.ok_or_else(|| "failed to resolve home directory".to_string()),
SkillScope::Project => {
let cwd = std::env::current_dir()
.map_err(|err| format!("failed to get current dir: {}", err))?;
Ok(project_skill_state_path(&cwd))
}
}
}
fn load_skill_disable_state(cwd: &Path) -> SkillDisableState {
SkillDisableState {
user_disabled: user_skill_state_path()
.map(|path| load_disabled_skill_names(&path))
.unwrap_or_default(),
project_disabled: load_disabled_skill_names(&project_skill_state_path(cwd)),
}
}
fn load_disabled_skill_names(path: &Path) -> HashSet<String> {
match load_skill_state_file(path) {
Ok(state) => state
.disabled_skills
.into_iter()
.filter_map(|name| normalize_skill_name(name, path))
.collect(),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load skill state file");
HashSet::new()
}
}
}
fn normalize_skill_name(name: String, path: &Path) -> Option<String> {
let trimmed = name.trim();
match validate_skill_name(trimmed) {
Ok(()) => Some(trimmed.to_string()),
Err(err) => {
tracing::warn!(path = %path.display(), skill = %name, error = %err, "Ignoring invalid disabled skill entry");
None
}
}
}
fn load_skill_state_file(path: &Path) -> Result<SkillStateFile, String> {
if !path.exists() {
return Ok(SkillStateFile::default());
}
let content = fs::read_to_string(path)
.map_err(|err| format!("failed to read skill state file: {}", err))?;
serde_json::from_str(&content)
.map_err(|err| format!("failed to parse skill state file: {}", err))
}
fn save_skill_state_file(path: &Path, state: &SkillStateFile) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create skill state directory: {}", err))?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|err| format!("failed to render skill state file: {}", err))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, format!("{}\n", content))
.map_err(|err| format!("failed to write temporary skill state file: {}", err))?;
// 使用平台抽象的原子重命名
atomic_rename(&tmp_path, path)
.map_err(|err| format!("failed to persist skill state file: {}", err))
}
fn render_skill_file(name: &str, description: &str, body: &str) -> Result<String, String> {
if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string());
}
#[derive(serde::Serialize)]
struct SkillFrontmatterOwned {
name: String,
description: String,
}
let yaml = serde_yaml::to_string(&SkillFrontmatterOwned {
name: name.to_string(),
description: description.to_string(),
})
.map_err(|err| format!("failed to render skill frontmatter: {}", err))?;
let yaml = yaml.trim_start_matches("---\n");
let body = body.trim();
if body.is_empty() {
Ok(format!("---\n{}---\n", yaml))
} else {
Ok(format!("---\n{}---\n{}\n", yaml, body))
}
}
fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> {
let content = render_skill_file(name, description, body)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create skill directory: {}", err))?;
}
fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err))
}
fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec<Skill> {
let mut out = Vec::new();
if !root.exists() {
return out;
}
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
Err(err) => {
tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory");
return out;
}
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.exists() {
continue;
}
match parse_skill_file(&skill_md, source.clone()) {
Ok(skill) => out.push(skill),
Err(err) => {
tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file");
}
}
}
out
}
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
description: String,
#[serde(default)]
name: Option<String>,
}
fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<SkillFrontmatter>(&content) {
Ok(v) => v,
Err(err) => {
let bytes = content.len();
let crlf = content.contains('\r');
return Err(format!("{} (bytes={}, crlf={})", err, bytes, crlf));
}
};
let description = frontmatter.description.trim();
if description.is_empty() {
return Err("description is required and cannot be empty".to_string());
}
let dir_name = path
.parent()
.and_then(|p| p.file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "unknown-skill".to_string());
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
Ok(Skill {
name,
description: description.to_string(),
body: body.trim().to_string(),
source,
path: path.to_path_buf(),
})
}
// 使用 platform 模块提供的 xml_escape 和 path_to_uri 函数
// SkillPromptProvider 实现
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::experts::ExpertRuntime;
/// Skill 提示词提供者
///
/// 负责提供技能的系统索引提示词system_index_prompt
/// 当会话选中了带技能策略的专家时,按专家 `CapabilityPolicy` 过滤技能索引。
pub struct SkillPromptProvider {
skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
}
impl SkillPromptProvider {
/// 创建新的 Skill 提示词提供者
pub fn new(skills: Arc<SkillRuntime>, experts: Arc<ExpertRuntime>) -> Self {
Self { skills, experts }
}
}
impl SystemPromptProvider for SkillPromptProvider {
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
let content = match context.session_id.as_deref() {
Some(sid) => {
let policy = self
.experts
.selected_expert_for(sid)
.map(|e| e.capability);
match policy {
Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered(
p.allowed_skills.as_deref(),
&p.denied_skills,
),
_ => self.skills.system_index_prompt(),
}
}
None => self.skills.system_index_prompt(),
};
content.map(|c| SystemPrompt {
content: c,
context: Some("skill_index".to_string()),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
struct CurrentDirGuard {
previous: PathBuf,
}
struct HomeDirGuard {
previous: Option<OsString>,
previous_userprofile: Option<OsString>,
}
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);
}
}
impl HomeDirGuard {
fn enter(path: &Path) -> Self {
let home_backup = std::env::var_os("HOME");
let userprofile_backup = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", path);
// Windows 环境下同时设置 USERPROFILE
std::env::set_var("USERPROFILE", path);
}
Self {
previous: home_backup,
previous_userprofile: userprofile_backup,
}
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.previous_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> {
acquire_skill_test_env_lock()
}
#[test]
fn test_parse_skill_file_handles_crlf_endings() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\r\ndescription: demo skill\r\n---\r\nStep A\r\nStep B").unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
assert_eq!(skill.name, "demo");
assert_eq!(skill.description, "demo skill");
assert_eq!(skill.body, "Step A\nStep B");
}
#[test]
fn test_parse_skill_file_requires_description() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap();
let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err();
assert!(err.contains("description"));
}
#[test]
fn test_activation_payload_contains_body() {
let dir = tempfile::tempdir().unwrap();
let skill_dir = dir.path().join("demo");
fs::create_dir_all(&skill_dir).unwrap();
let skill_md = skill_dir.join("SKILL.md");
fs::write(
&skill_md,
"---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B",
)
.unwrap();
let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap();
let catalog = SkillCatalog {
skills: vec![skill],
max_index_chars: 1000,
max_listed_skills: 10,
};
let payload = catalog.activation_payload("demo").unwrap();
assert!(payload.contains("SKILL LOADED: demo"));
assert!(payload.contains("Step A"));
}
#[test]
fn test_system_index_prompt_uses_available_skills_markup() {
// 使用临时目录创建测试路径,确保跨平台兼容
let temp_dir = tempfile::tempdir().unwrap();
let skill_path = temp_dir.path().join("demo-skill").join("SKILL.md");
let catalog = SkillCatalog {
skills: vec![Skill {
name: "demo-skill".to_string(),
description: "demo <skill> & usage".to_string(),
body: String::new(),
source: SkillSource::Project,
path: skill_path.clone(),
}],
max_index_chars: 4000,
max_listed_skills: 32,
};
let prompt = catalog.system_index_prompt().unwrap();
assert!(prompt.contains("<available_skills>"));
assert!(prompt.contains("# 可用技能"));
assert!(prompt.contains("<name>demo-skill</name>"));
assert!(prompt.contains("<description>demo &lt;skill&gt; &amp; usage</description>"));
// 验证 location 包含正确的 file:// URI 格式
let expected_uri = path_to_uri(&skill_path);
assert!(prompt.contains(&format!("<location>{}</location>", platform_xml_escape(&expected_uri))));
assert!(prompt.contains("</available_skills>"));
}
#[test]
fn test_path_to_uri() {
// Unix 路径
let unix_path = PathBuf::from("/tmp/demo-skill/SKILL.md");
let unix_uri = path_to_uri(&unix_path);
if cfg!(target_os = "windows") {
// Windows 上运行时,路径可能被转换
assert!(unix_uri.contains("file://"));
} else {
assert_eq!(unix_uri, "file:///tmp/demo-skill/SKILL.md");
}
// Windows 路径格式测试(仅在 Windows 上)
if cfg!(target_os = "windows") {
let win_path = PathBuf::from("C:\\Users\\test\\.picobot\\skills\\demo\\SKILL.md");
let win_uri = path_to_uri(&win_path);
assert!(win_uri.starts_with("file:///C:/"));
assert!(win_uri.contains("/SKILL.md"));
assert!(!win_uri.contains('\\')); // 不应包含反斜杠
}
}
#[test]
fn test_runtime_create_update_delete_reload() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(runtime.len(), 0);
let created = runtime
.create_skill(
SkillScope::Project,
"demo-skill",
"demo desc",
"line 1",
true,
)
.unwrap();
assert_eq!(created.name, "demo-skill");
assert_eq!(runtime.len(), 1);
let updated = runtime
.update_skill(
SkillScope::Project,
"demo-skill",
Some("updated desc"),
Some("line 2"),
true,
)
.unwrap();
assert_eq!(updated.description, "updated desc");
assert!(
runtime
.activation_payload("demo-skill")
.unwrap()
.contains("line 2")
);
let deleted_path = runtime
.delete_skill(SkillScope::Project, "demo-skill", true)
.unwrap();
assert!(!deleted_path.exists());
assert_eq!(runtime.len(), 0);
}
#[test]
fn test_source_order_supports_agent_sources() {
let ordered = source_order(&[
"user".to_string(),
"user_agent".to_string(),
"user_openclaw".to_string(),
"project".to_string(),
"project_agent".to_string(),
"project_openclaw".to_string(),
"project".to_string(),
"unknown".to_string(),
]);
assert_eq!(
ordered,
vec![
SkillSource::User,
SkillSource::UserAgent,
SkillSource::UserOpenclaw,
SkillSource::Project,
SkillSource::ProjectAgent,
SkillSource::ProjectOpenclaw,
]
);
}
#[test]
fn test_discover_loads_project_agent_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let agent_skill_dir = project_dir
.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 = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.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 = project_dir.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"));
}
#[test]
fn test_skill_state_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("skill-state.json");
let state = SkillStateFile {
disabled_skills: vec!["demo".to_string(), "other".to_string()],
};
save_skill_state_file(&path, &state).unwrap();
let loaded = load_skill_state_file(&path).unwrap();
assert_eq!(loaded, state);
}
#[test]
fn test_discover_filters_disabled_skills_from_sidecar() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.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();
save_skill_state_file(
&project_dir.join(".picobot").join("skill-state.json"),
&SkillStateFile {
disabled_skills: vec!["demo".to_string()],
},
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 0);
assert!(catalog.activation_payload("demo").is_err());
}
#[test]
fn test_runtime_disable_and_enable_skill_updates_visibility() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.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 runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let disabled = runtime.disable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(disabled.changed);
assert_eq!(disabled.disabled_in_scopes, vec![SkillScope::Project]);
assert!(!disabled.available);
assert!(runtime.get_skill("demo").is_none());
let enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(enabled.changed);
assert!(enabled.disabled_in_scopes.is_empty());
assert!(enabled.available);
assert!(runtime.get_skill("demo").is_some());
}
#[test]
fn test_user_scope_disable_overrides_project_scope_enable() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let project_skill_dir = project_dir.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 runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
let user_disabled = runtime.disable_skill(SkillScope::User, "demo", true).unwrap();
assert_eq!(user_disabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let project_enabled = runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
assert!(!project_enabled.available);
assert_eq!(project_enabled.disabled_in_scopes, vec![SkillScope::User]);
assert!(runtime.get_skill("demo").is_none());
let user_enabled = runtime.enable_skill(SkillScope::User, "demo", true).unwrap();
assert!(user_enabled.available);
assert!(user_enabled.disabled_in_scopes.is_empty());
assert!(runtime.get_skill("demo").is_some());
}
#[test]
fn test_discover_loads_project_openclaw_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let openclaw_skill_dir = project_dir
.join(".openclaw")
.join("skills")
.join("demo-openclaw");
fs::create_dir_all(&openclaw_skill_dir).unwrap();
fs::write(
openclaw_skill_dir.join("SKILL.md"),
"---\ndescription: openclaw skill\n---\nUse openclaw",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["project_openclaw".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-openclaw").unwrap();
assert_eq!(payload["source"], "project_openclaw");
}
#[test]
fn test_discover_loads_user_openclaw_skills() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let user_openclaw_skill_dir = home_dir
.join(".openclaw")
.join("skills")
.join("demo-user-openclaw");
fs::create_dir_all(&user_openclaw_skill_dir).unwrap();
fs::write(
user_openclaw_skill_dir.join("SKILL.md"),
"---\ndescription: user openclaw skill\n---\nUse user openclaw",
)
.unwrap();
let catalog = SkillCatalog::discover(&SkillsConfig {
enabled: true,
sources: vec!["user_openclaw".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
assert_eq!(catalog.len(), 1);
let payload = catalog.activation_event_payload("demo-user-openclaw").unwrap();
assert_eq!(payload["source"], "user_openclaw");
}
#[test]
fn test_list_skills_with_status_includes_disabled() {
let _lock = acquire_test_lock();
let temp_dir = tempfile::tempdir().unwrap();
let home_dir = temp_dir.path().join("home");
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&home_dir).unwrap();
fs::create_dir_all(&project_dir).unwrap();
let _home = HomeDirGuard::enter(&home_dir);
let _guard = CurrentDirGuard::enter(&project_dir);
let skill_dir = project_dir.join(".picobot").join("skills").join("demo");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: A demo skill\n---\nBody here",
)
.unwrap();
save_skill_state_file(
&project_dir.join(".picobot").join("skill-state.json"),
&SkillStateFile {
disabled_skills: vec!["demo".to_string()],
},
)
.unwrap();
let runtime = SkillRuntime::from_config(SkillsConfig {
enabled: true,
sources: vec!["project".to_string()],
max_index_chars: 4000,
max_listed_skills: 32,
});
// list_skills_with_status should include the disabled skill with its disabled scope
let skills = runtime.list_skills_with_status();
assert_eq!(
skills.len(),
1,
"list_skills_with_status should include disabled skills"
);
assert_eq!(skills[0].name, "demo");
assert_eq!(skills[0].description, "A demo skill");
assert_eq!(skills[0].source, "project");
assert_eq!(skills[0].disabled_in_scopes, vec!["project".to_string()]);
// list_skills (normal) should filter out disabled skills
let active = runtime.list_skills();
assert_eq!(
active.len(),
0,
"list_skills should filter out disabled skills"
);
// After enabling, list_skills_with_status should report no disabled scopes
runtime.enable_skill(SkillScope::Project, "demo", true).unwrap();
let skills_after = runtime.list_skills_with_status();
assert_eq!(skills_after.len(), 1);
assert!(skills_after[0].disabled_in_scopes.is_empty());
}
}