feat(phase4): complete zclaw-skills, zclaw-hands, zclaw-channels, zclaw-protocols 模块实现

This commit is contained in:
iven
2026-03-22 08:57:37 +08:00
parent 7abfca9d5c
commit 0ab2f7afda
24 changed files with 2060 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
[package]
name = "zclaw-skills"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "ZCLAW skill system"
[dependencies]
zclaw-types = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }

View File

@@ -0,0 +1,13 @@
//! ZCLAW Skills System
//!
//! Skill loading, execution, and management.
mod skill;
mod runner;
mod loader;
mod registry;
pub use skill::*;
pub use runner::*;
pub use loader::*;
pub use registry::*;

View File

@@ -0,0 +1,256 @@
//! Skill loader - parses SKILL.md and TOML manifests
use std::path::{Path, PathBuf};
use zclaw_types::{Result, SkillId, ZclawError};
use super::{SkillManifest, SkillMode};
/// Load a skill from a directory
pub fn load_skill_from_dir(dir: &Path) -> Result<SkillManifest> {
// Try SKILL.md first
let skill_md = dir.join("SKILL.md");
if skill_md.exists() {
return load_skill_md(&skill_md);
}
// Try skill.toml
let skill_toml = dir.join("skill.toml");
if skill_toml.exists() {
return load_skill_toml(&skill_toml);
}
Err(ZclawError::NotFound(format!(
"No SKILL.md or skill.toml found in {}",
dir.display()
)))
}
/// Parse SKILL.md file
pub fn load_skill_md(path: &Path) -> Result<SkillManifest> {
let content = std::fs::read_to_string(path)
.map_err(|e| ZclawError::StorageError(format!("Failed to read SKILL.md: {}", e)))?;
parse_skill_md(&content)
}
/// Parse SKILL.md content
pub fn parse_skill_md(content: &str) -> Result<SkillManifest> {
let mut name = String::new();
let mut description = String::new();
let mut version = "1.0.0".to_string();
let mut mode = SkillMode::PromptOnly;
let mut capabilities = Vec::new();
let mut tags = Vec::new();
// Parse frontmatter if present
if content.starts_with("---") {
if let Some(end) = content[3..].find("---") {
let frontmatter = &content[3..end + 3];
for line in frontmatter.lines() {
let line = line.trim();
if line.is_empty() || line == "---" {
continue;
}
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"name" => name = value.to_string(),
"description" => description = value.to_string(),
"version" => version = value.to_string(),
"mode" => mode = parse_mode(value),
"capabilities" => {
capabilities = value.split(',')
.map(|s| s.trim().to_string())
.collect();
}
"tags" => {
tags = value.split(',')
.map(|s| s.trim().to_string())
.collect();
}
_ => {}
}
}
}
}
}
// If no frontmatter, try to extract from content
if name.is_empty() {
// Try to extract from first heading
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("# ") {
name = trimmed[2..].to_string();
break;
}
}
}
// Use filename as fallback name
if name.is_empty() {
name = "unnamed-skill".to_string();
}
// Extract description from first paragraph
if description.is_empty() {
let mut in_paragraph = false;
let mut desc_lines = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
if in_paragraph && !desc_lines.is_empty() {
break;
}
continue;
}
if trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("---") {
continue;
}
in_paragraph = true;
desc_lines.push(trimmed);
}
if !desc_lines.is_empty() {
description = desc_lines.join(" ");
if description.len() > 200 {
description = description[..200].to_string();
}
}
}
let id = name.to_lowercase()
.replace(' ', "-")
.replace(|c: char| !c.is_alphanumeric() && c != '-', "");
Ok(SkillManifest {
id: SkillId::new(&id),
name,
description,
version,
author: None,
mode,
capabilities,
input_schema: None,
output_schema: None,
tags,
enabled: true,
})
}
/// Parse skill.toml file
pub fn load_skill_toml(path: &Path) -> Result<SkillManifest> {
let content = std::fs::read_to_string(path)
.map_err(|e| ZclawError::StorageError(format!("Failed to read skill.toml: {}", e)))?;
parse_skill_toml(&content)
}
/// Parse skill.toml content
pub fn parse_skill_toml(content: &str) -> Result<SkillManifest> {
// Simple TOML parser for basic structure
let mut id = String::new();
let mut name = String::new();
let mut description = String::new();
let mut version = "1.0.0".to_string();
let mut mode = "prompt_only".to_string();
let mut capabilities = Vec::new();
let mut tags = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"id" => id = value.to_string(),
"name" => name = value.to_string(),
"description" => description = value.to_string(),
"version" => version = value.to_string(),
"mode" => mode = value.to_string(),
"capabilities" => {
// Simple array parsing
let value = value.trim_start_matches('[').trim_end_matches(']');
capabilities = value.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
}
"tags" => {
let value = value.trim_start_matches('[').trim_end_matches(']');
tags = value.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.filter(|s| !s.is_empty())
.collect();
}
_ => {}
}
}
}
if name.is_empty() {
return Err(ZclawError::InvalidInput("Skill name is required".into()));
}
let skill_id = if id.is_empty() {
SkillId::new(&name.to_lowercase().replace(' ', "-"))
} else {
SkillId::new(&id)
};
Ok(SkillManifest {
id: skill_id,
name,
description,
version,
author: None,
mode: parse_mode(&mode),
capabilities,
input_schema: None,
output_schema: None,
tags,
enabled: true,
})
}
fn parse_mode(s: &str) -> SkillMode {
match s.to_lowercase().replace('_', "-").as_str() {
"prompt-only" | "promptonly" | "prompt_only" => SkillMode::PromptOnly,
"python" => SkillMode::Python,
"shell" => SkillMode::Shell,
"wasm" => SkillMode::Wasm,
"native" => SkillMode::Native,
_ => SkillMode::PromptOnly,
}
}
/// Discover skills in a directory
pub fn discover_skills(dir: &Path) -> Result<Vec<PathBuf>> {
let mut skills = Vec::new();
if !dir.exists() {
return Ok(skills);
}
for entry in std::fs::read_dir(dir)
.map_err(|e| ZclawError::StorageError(format!("Failed to read directory: {}", e)))?
{
let entry = entry.map_err(|e| ZclawError::StorageError(e.to_string()))?;
let path = entry.path();
if path.is_dir() {
// Check for SKILL.md or skill.toml
if path.join("SKILL.md").exists() || path.join("skill.toml").exists() {
skills.push(path);
}
}
}
Ok(skills)
}

View File

@@ -0,0 +1,149 @@
//! Skill registry
//!
//! Manage loaded skills and their execution.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::{Result, SkillId};
use super::{Skill, SkillContext, SkillManifest, SkillMode, SkillResult};
use crate::loader;
use crate::runner::{PromptOnlySkill, ShellSkill};
/// Skill registry
pub struct SkillRegistry {
skills: RwLock<HashMap<SkillId, Arc<dyn Skill>>>,
manifests: RwLock<HashMap<SkillId, SkillManifest>>,
skill_dirs: RwLock<Vec<PathBuf>>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: RwLock::new(HashMap::new()),
manifests: RwLock::new(HashMap::new()),
skill_dirs: RwLock::new(Vec::new()),
}
}
/// Add a skill directory to scan
pub async fn add_skill_dir(&self, dir: PathBuf) -> Result<()> {
if !dir.exists() {
return Err(zclaw_types::ZclawError::NotFound(format!("Directory not found: {}", dir.display())));
}
{
let mut dirs = self.skill_dirs.write().await;
if !dirs.contains(&dir) {
dirs.push(dir.clone());
}
}
// Scan for skills
let skill_paths = loader::discover_skills(&dir)?;
for skill_path in skill_paths {
self.load_skill_from_dir(&skill_path)?;
}
Ok(())
}
/// Load a skill from directory
fn load_skill_from_dir(&self, dir: &PathBuf) -> Result<()> {
let md_path = dir.join("SKILL.md");
let toml_path = dir.join("skill.toml");
let manifest = if md_path.exists() {
loader::load_skill_md(&md_path)?
} else if toml_path.exists() {
loader::load_skill_toml(&toml_path)?
} else {
return Err(zclaw_types::ZclawError::NotFound(
format!("No SKILL.md or skill.toml found in {}", dir.display())
));
};
// Create skill instance
let skill: Arc<dyn Skill> = match &manifest.mode {
SkillMode::PromptOnly => {
let prompt = std::fs::read_to_string(&md_path).unwrap_or_default();
Arc::new(PromptOnlySkill::new(manifest.clone(), prompt))
}
SkillMode::Shell => {
let cmd = std::fs::read_to_string(dir.join("command.sh"))
.unwrap_or_else(|_| "echo 'Shell skill not configured'".to_string());
Arc::new(ShellSkill::new(manifest.clone(), cmd))
}
_ => {
let prompt = std::fs::read_to_string(&md_path).unwrap_or_default();
Arc::new(PromptOnlySkill::new(manifest.clone(), prompt))
}
};
// Register
let mut skills = self.skills.blocking_write();
let mut manifests = self.manifests.blocking_write();
skills.insert(manifest.id.clone(), skill);
manifests.insert(manifest.id.clone(), manifest);
Ok(())
}
/// Get a skill by ID
pub async fn get(&self, id: &SkillId) -> Option<Arc<dyn Skill>> {
let skills = self.skills.read().await;
skills.get(id).cloned()
}
/// Get skill manifest
pub async fn get_manifest(&self, id: &SkillId) -> Option<SkillManifest> {
let manifests = self.manifests.read().await;
manifests.get(id).cloned()
}
/// List all skills
pub async fn list(&self) -> Vec<SkillManifest> {
let manifests = self.manifests.read().await;
manifests.values().cloned().collect()
}
/// Execute a skill
pub async fn execute(
&self,
id: &SkillId,
context: &SkillContext,
input: serde_json::Value,
) -> Result<SkillResult> {
let skill = self.get(id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Skill not found: {}", id)))?;
skill.execute(context, input).await
}
/// Remove a skill
pub async fn remove(&self, id: &SkillId) {
let mut skills = self.skills.write().await;
let mut manifests = self.manifests.write().await;
skills.remove(id);
manifests.remove(id);
}
/// Register a skill directly
pub async fn register(&self, skill: Arc<dyn Skill>, manifest: SkillManifest) {
let mut skills = self.skills.write().await;
let mut manifests = self.manifests.write().await;
skills.insert(manifest.id.clone(), skill);
manifests.insert(manifest.id.clone(), manifest);
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,152 @@
//! Skill runners for different execution modes
use async_trait::async_trait;
use serde_json::Value;
use std::process::Command;
use std::time::Instant;
use zclaw_types::Result;
use super::{Skill, SkillContext, SkillManifest, SkillResult};
/// Prompt-only skill execution
pub struct PromptOnlySkill {
manifest: SkillManifest,
prompt_template: String,
}
impl PromptOnlySkill {
pub fn new(manifest: SkillManifest, prompt_template: String) -> Self {
Self { manifest, prompt_template }
}
fn format_prompt(&self, input: &Value) -> String {
let mut prompt = self.prompt_template.clone();
if let Value::String(s) = input {
prompt = prompt.replace("{{input}}", s);
} else {
prompt = prompt.replace("{{input}}", &serde_json::to_string_pretty(input).unwrap_or_default());
}
prompt
}
}
#[async_trait]
impl Skill for PromptOnlySkill {
fn manifest(&self) -> &SkillManifest {
&self.manifest
}
async fn execute(&self, _context: &SkillContext, input: Value) -> Result<SkillResult> {
let prompt = self.format_prompt(&input);
Ok(SkillResult::success(Value::String(prompt)))
}
}
/// Python script skill execution
pub struct PythonSkill {
manifest: SkillManifest,
script_path: std::path::PathBuf,
}
impl PythonSkill {
pub fn new(manifest: SkillManifest, script_path: std::path::PathBuf) -> Self {
Self { manifest, script_path }
}
}
#[async_trait]
impl Skill for PythonSkill {
fn manifest(&self) -> &SkillManifest {
&self.manifest
}
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
let start = Instant::now();
let input_json = serde_json::to_string(&input).unwrap_or_default();
let output = Command::new("python3")
.arg(&self.script_path)
.env("SKILL_INPUT", &input_json)
.env("AGENT_ID", &context.agent_id)
.env("SESSION_ID", &context.session_id)
.output()
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute Python: {}", e)))?;
let duration_ms = start.elapsed().as_millis() as u64;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let result = serde_json::from_str(&stdout)
.map(|v| SkillResult {
success: true,
output: v,
error: None,
duration_ms: Some(duration_ms),
tokens_used: None,
})
.unwrap_or_else(|_| SkillResult::success(Value::String(stdout.to_string())));
Ok(result)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(SkillResult::error(stderr))
}
}
}
/// Shell command skill execution
pub struct ShellSkill {
manifest: SkillManifest,
command: String,
}
impl ShellSkill {
pub fn new(manifest: SkillManifest, command: String) -> Self {
Self { manifest, command }
}
}
#[async_trait]
impl Skill for ShellSkill {
fn manifest(&self) -> &SkillManifest {
&self.manifest
}
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult> {
let start = Instant::now();
let mut cmd = self.command.clone();
if let Value::String(s) = input {
cmd = cmd.replace("{{input}}", &s);
}
#[cfg(target_os = "windows")]
let output = {
Command::new("cmd")
.args(["/C", &cmd])
.current_dir(context.working_dir.as_ref().unwrap_or(&std::path::PathBuf::from(".")))
.output()
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute shell: {}", e)))?
};
#[cfg(not(target_os = "windows"))]
let output = {
Command::new("sh")
.args(["-c", &cmd])
.current_dir(context.working_dir.as_ref().unwrap_or(&std::path::PathBuf::from(".")))
.output()
.map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute shell: {}", e)))?
};
let duration_ms = start.elapsed().as_millis() as u64;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(SkillResult::success(Value::String(stdout.to_string())))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Ok(SkillResult::error(stderr))
}
}
}

View File

@@ -0,0 +1,147 @@
//! Skill definition and types
use serde::{Deserialize, Serialize};
use serde_json::Value;
use zclaw_types::{SkillId, Result};
/// Skill manifest definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillManifest {
/// Unique skill identifier
pub id: SkillId,
/// Human-readable name
pub name: String,
/// Skill description
pub description: String,
/// Skill version
pub version: String,
/// Skill author
#[serde(default)]
pub author: Option<String>,
/// Execution mode
pub mode: SkillMode,
/// Required capabilities
#[serde(default)]
pub capabilities: Vec<String>,
/// Input schema (JSON Schema)
#[serde(default)]
pub input_schema: Option<Value>,
/// Output schema (JSON Schema)
#[serde(default)]
pub output_schema: Option<Value>,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
/// Whether the skill is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool { true }
/// Skill execution mode
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SkillMode {
/// Prompt-only skill (no code execution)
PromptOnly,
/// Python script execution
Python,
/// Shell command execution
Shell,
/// WebAssembly execution
Wasm,
/// Native Rust execution
Native,
}
/// Skill execution context
#[derive(Debug, Clone)]
pub struct SkillContext {
/// Agent ID executing the skill
pub agent_id: String,
/// Session ID for the execution
pub session_id: String,
/// Working directory for execution
pub working_dir: Option<std::path::PathBuf>,
/// Environment variables
pub env: std::collections::HashMap<String, String>,
/// Timeout in seconds
pub timeout_secs: u64,
/// Whether to allow network access
pub network_allowed: bool,
/// Whether to allow file system access
pub file_access_allowed: bool,
}
impl Default for SkillContext {
fn default() -> Self {
Self {
agent_id: String::new(),
session_id: String::new(),
working_dir: None,
env: std::collections::HashMap::new(),
timeout_secs: 60,
network_allowed: false,
file_access_allowed: false,
}
}
}
/// Skill execution result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillResult {
/// Whether execution succeeded
pub success: bool,
/// Output data
pub output: Value,
/// Error message if failed
#[serde(default)]
pub error: Option<String>,
/// Execution duration in milliseconds
#[serde(default)]
pub duration_ms: Option<u64>,
/// Token usage if LLM was #[serde(default)]
pub tokens_used: Option<u32>,
}
impl SkillResult {
pub fn success(output: Value) -> Self {
Self {
success: true,
output,
error: None,
duration_ms: None,
tokens_used: None,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
output: Value::Null,
error: Some(message.into()),
duration_ms: None,
tokens_used: None,
}
}
}
/// Skill definition with execution logic
#[async_trait::async_trait]
pub trait Skill: Send + Sync {
/// Get the skill manifest
fn manifest(&self) -> &SkillManifest;
/// Execute the skill with given input
async fn execute(&self, context: &SkillContext, input: Value) -> Result<SkillResult>;
/// Validate input against schema
fn validate_input(&self, input: &Value) -> Result<()> {
// Basic validation - can be overridden
if input.is_null() {
return Err(zclaw_types::ZclawError::InvalidInput("Input cannot be null".into()));
}
Ok(())
}
}