use crate::error::{PromptError, ToolError};
use crate::prompt::{PromptContext, PromptFragment, PromptLayer};
use crate::tool::{
Concurrency, PreparedToolCall, Tool, ToolContext, ToolExecutionContext, ToolOutput, ToolSpec,
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::{hash_map::DefaultHasher, BTreeMap, HashMap};
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub instructions: String,
pub source: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SkillSummary {
pub name: String,
pub description: String,
}
pub struct SkillRegistry {
skills: BTreeMap<String, Skill>,
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: BTreeMap::new(),
}
}
pub fn register(&mut self, skill: Skill) {
self.skills.insert(skill.name.clone(), skill);
}
pub fn get(&self, name: &str) -> Option<Skill> {
self.skills.get(name).cloned()
}
pub fn summaries(&self) -> Vec<SkillSummary> {
self.skills
.values()
.map(|skill| SkillSummary {
name: skill.name.clone(),
description: skill.description.clone(),
})
.collect()
}
pub fn from_directory(path: &Path) -> std::io::Result<Self> {
let mut registry = Self::new();
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(registry),
Err(error) => return Err(error),
};
for entry in entries {
let entry = entry?;
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let skill_md = dir.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
let instructions = std::fs::read_to_string(&skill_md)?;
let description = instructions
.lines()
.find(|line| !line.trim().is_empty())
.map(|line| line.trim().trim_start_matches('#').trim().to_string())
.unwrap_or_default();
let name = entry.file_name().to_string_lossy().to_string();
registry.register(Skill {
name: name.clone(),
description,
instructions,
source: dir.to_string_lossy().to_string(),
});
}
Ok(registry)
}
}
pub struct SkillActivationState {
activated: Mutex<HashMap<String, u64>>,
}
impl Default for SkillActivationState {
fn default() -> Self {
Self::new()
}
}
impl SkillActivationState {
pub fn new() -> Self {
Self {
activated: Mutex::new(HashMap::new()),
}
}
fn is_activated(&self, name: &str, hash: u64) -> bool {
self.activated
.lock()
.expect("skill activation mutex poisoned")
.get(name)
.is_some_and(|&h| h == hash)
}
fn mark(&self, name: &str, hash: u64) {
self.activated
.lock()
.expect("skill activation mutex poisoned")
.insert(name.to_string(), hash);
}
}
pub struct ActivateSkillTool {
registry: Arc<SkillRegistry>,
state: Arc<SkillActivationState>,
}
pub fn activate_skill_tool(
registry: Arc<SkillRegistry>,
state: Arc<SkillActivationState>,
) -> Arc<ActivateSkillTool> {
Arc::new(ActivateSkillTool { registry, state })
}
fn instructions_hash(instructions: &str) -> u64 {
let mut hasher = DefaultHasher::new();
instructions.hash(&mut hasher);
hasher.finish()
}
#[async_trait]
impl Tool for ActivateSkillTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "activate_skill".into(),
description: "读取一个 Skill 的完整指令".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}),
concurrency: Concurrency::Sequential,
}
}
async fn prepare(
&self,
arguments: serde_json::Value,
context: &ToolContext,
) -> Result<PreparedToolCall, ToolError> {
let name = arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidArguments("missing name".into()))?
.to_string();
if self.registry.get(&name).is_none() {
return Err(ToolError::InvalidArguments(format!(
"unknown skill: {name}"
)));
}
Ok(PreparedToolCall {
call_id: context.call_id.clone(),
name: self.spec().name,
arguments,
capabilities: Vec::new(),
})
}
async fn execute(
&self,
call: PreparedToolCall,
_context: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let name = call
.arguments
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidArguments("missing name".into()))?;
let skill = self
.registry
.get(name)
.ok_or_else(|| ToolError::InvalidArguments(format!("unknown skill: {name}")))?;
let hash = instructions_hash(&skill.instructions);
if self.state.is_activated(name, hash) {
return Ok(ToolOutput {
is_error: false,
text: format!("已激活: {name}"),
});
}
self.state.mark(name, hash);
Ok(ToolOutput {
is_error: false,
text: format!("Skill instructions for {name}:\n\n{}", skill.instructions),
})
}
}
pub struct SkillCatalogLayer {
registry: Arc<SkillRegistry>,
}
impl SkillCatalogLayer {
pub fn new(registry: Arc<SkillRegistry>) -> Self {
Self { registry }
}
}
#[async_trait]
impl PromptLayer for SkillCatalogLayer {
fn id(&self) -> &str {
"skill-catalog"
}
fn priority(&self) -> i32 {
400
}
async fn render(
&self,
_context: &PromptContext,
) -> Result<Option<PromptFragment>, PromptError> {
let summaries = self.registry.summaries();
if summaries.is_empty() {
return Ok(None);
}
let mut lines = vec!["可用 Skills(使用 activate_skill 工具获取完整指令):".to_string()];
for summary in summaries {
lines.push(format!("- {}: {}", summary.name, summary.description));
}
Ok(Some(PromptFragment {
content: lines.join("\n"),
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tool_context() -> ToolContext {
ToolContext {
session_id: crate::ids::SessionId::from("s"),
run_id: crate::ids::RunId::from("r"),
turn_id: crate::ids::TurnId::from("t"),
call_id: crate::ids::ToolCallId::from("c1"),
}
}
fn exec_context() -> ToolExecutionContext {
let (tx, _rx) = tokio::sync::mpsc::channel(4);
ToolExecutionContext {
cancel: tokio_util::sync::CancellationToken::new(),
progress: tx,
}
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("kaynine-skill-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).unwrap();
Self(path)
}
fn write(&self, skill: &str, markdown: &str) {
let dir = self.0.join(skill);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("SKILL.md"), markdown).unwrap();
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn from_directory_parses_skills_and_skips_missing_skill_md() {
let dir = TempDir::new();
dir.write("alpha", "# Alpha skill\nDo alpha things.");
dir.write("beta", "# Beta skill\nDo beta things.");
std::fs::create_dir_all(dir.0.join("empty")).unwrap();
let registry = SkillRegistry::from_directory(&dir.0).unwrap();
let summaries = registry.summaries();
assert_eq!(summaries.len(), 2);
let alpha = registry.get("alpha").unwrap();
assert_eq!(alpha.description, "Alpha skill");
assert_eq!(alpha.instructions, "# Alpha skill\nDo alpha things.");
assert!(alpha.source.contains("alpha"));
}
#[test]
fn summaries_are_in_name_order() {
let mut registry = SkillRegistry::new();
for name in ["zeta", "alpha", "mid"] {
registry.register(Skill {
name: name.into(),
description: format!("{name} desc"),
instructions: "x".into(),
source: "test".into(),
});
}
let names: Vec<String> = registry.summaries().into_iter().map(|s| s.name).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[tokio::test]
async fn activate_returns_full_then_short_confirmation() {
let mut registry = SkillRegistry::new();
registry.register(Skill {
name: "alpha".into(),
description: "d".into(),
instructions: "full instructions".into(),
source: "test".into(),
});
let registry = Arc::new(registry);
let state = Arc::new(SkillActivationState::new());
let tool = activate_skill_tool(registry, state);
let args = serde_json::json!({"name": "alpha"});
let first = tool
.execute(
tool.prepare(args.clone(), &tool_context()).await.unwrap(),
exec_context(),
)
.await
.unwrap();
assert!(!first.is_error);
assert_eq!(
first.text,
"Skill instructions for alpha:\n\nfull instructions"
);
let second = tool
.execute(
tool.prepare(args, &tool_context()).await.unwrap(),
exec_context(),
)
.await
.unwrap();
assert_eq!(second.text, "已激活: alpha");
}
#[tokio::test]
async fn version_change_rereturns_full_instructions() {
let mut registry = SkillRegistry::new();
registry.register(Skill {
name: "alpha".into(),
description: "d".into(),
instructions: "v1".into(),
source: "test".into(),
});
let registry = Arc::new(registry);
let state = Arc::new(SkillActivationState::new());
let tool = activate_skill_tool(registry.clone(), state.clone());
let args = serde_json::json!({"name": "alpha"});
let first = tool
.execute(
tool.prepare(args.clone(), &tool_context()).await.unwrap(),
exec_context(),
)
.await
.unwrap();
assert!(first.text.contains("v1"));
let mut updated = SkillRegistry::new();
updated.register(Skill {
name: "alpha".into(),
description: "d".into(),
instructions: "v2".into(),
source: "test".into(),
});
let tool = activate_skill_tool(Arc::new(updated), state);
let second = tool
.execute(
tool.prepare(args, &tool_context()).await.unwrap(),
exec_context(),
)
.await
.unwrap();
assert!(second.text.contains("v2"));
}
#[tokio::test]
async fn unknown_skill_is_invalid_arguments() {
let registry = Arc::new(SkillRegistry::new());
let tool = activate_skill_tool(registry, Arc::new(SkillActivationState::new()));
let result = tool
.prepare(serde_json::json!({"name": "nope"}), &tool_context())
.await;
assert!(matches!(
result,
Err(ToolError::InvalidArguments(msg)) if msg.contains("unknown skill")
));
let missing = tool.prepare(serde_json::json!({}), &tool_context()).await;
assert!(matches!(missing, Err(ToolError::InvalidArguments(_))));
}
}