feat(phase4): complete zclaw-skills, zclaw-hands, zclaw-channels, zclaw-protocols 模块实现
This commit is contained in:
21
crates/zclaw-channels/Cargo.toml
Normal file
21
crates/zclaw-channels/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "zclaw-channels"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW Channels - external platform adapters"
|
||||
|
||||
[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 }
|
||||
|
||||
reqwest = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
71
crates/zclaw-channels/src/adapters/console.rs
Normal file
71
crates/zclaw-channels/src/adapters/console.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! Console channel adapter for testing
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Console channel adapter (for testing)
|
||||
pub struct ConsoleChannel {
|
||||
config: ChannelConfig,
|
||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
||||
}
|
||||
|
||||
impl ConsoleChannel {
|
||||
pub fn new(config: ChannelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for ConsoleChannel {
|
||||
fn config(&self) -> &ChannelConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Connected;
|
||||
tracing::info!("Console channel connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Disconnected;
|
||||
tracing::info!("Console channel disconnected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(&self) -> ChannelStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, message: OutgoingMessage) -> Result<String> {
|
||||
// Print to console for testing
|
||||
let msg_id = format!("console_{}", chrono::Utc::now().timestamp());
|
||||
|
||||
match &message.content {
|
||||
crate::MessageContent::Text { text } => {
|
||||
tracing::info!("[Console] To {}: {}", message.conversation_id, text);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("[Console] To {}: {:?}", message.conversation_id, message.content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
// Console channel doesn't receive messages automatically
|
||||
// Messages would need to be injected via a separate method
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
57
crates/zclaw-channels/src/adapters/discord.rs
Normal file
57
crates/zclaw-channels/src/adapters/discord.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! Discord channel adapter
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Discord channel adapter
|
||||
pub struct DiscordChannel {
|
||||
config: ChannelConfig,
|
||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
||||
}
|
||||
|
||||
impl DiscordChannel {
|
||||
pub fn new(config: ChannelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for DiscordChannel {
|
||||
fn config(&self) -> &ChannelConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Connected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(&self) -> ChannelStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
||||
// TODO: Implement Discord API send
|
||||
Ok("discord_msg_id".to_string())
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
// TODO: Implement Discord gateway
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
11
crates/zclaw-channels/src/adapters/mod.rs
Normal file
11
crates/zclaw-channels/src/adapters/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Channel adapters
|
||||
|
||||
mod telegram;
|
||||
mod discord;
|
||||
mod slack;
|
||||
mod console;
|
||||
|
||||
pub use telegram::TelegramChannel;
|
||||
pub use discord::DiscordChannel;
|
||||
pub use slack::SlackChannel;
|
||||
pub use console::ConsoleChannel;
|
||||
57
crates/zclaw-channels/src/adapters/slack.rs
Normal file
57
crates/zclaw-channels/src/adapters/slack.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! Slack channel adapter
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Slack channel adapter
|
||||
pub struct SlackChannel {
|
||||
config: ChannelConfig,
|
||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
||||
}
|
||||
|
||||
impl SlackChannel {
|
||||
pub fn new(config: ChannelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for SlackChannel {
|
||||
fn config(&self) -> &ChannelConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Connected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(&self) -> ChannelStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
||||
// TODO: Implement Slack API send
|
||||
Ok("slack_msg_ts".to_string())
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
// TODO: Implement Slack RTM/events API
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
59
crates/zclaw-channels/src/adapters/telegram.rs
Normal file
59
crates/zclaw-channels/src/adapters/telegram.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! Telegram channel adapter
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Telegram channel adapter
|
||||
pub struct TelegramChannel {
|
||||
config: ChannelConfig,
|
||||
client: Option<reqwest::Client>,
|
||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
||||
}
|
||||
|
||||
impl TelegramChannel {
|
||||
pub fn new(config: ChannelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: None,
|
||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TelegramChannel {
|
||||
fn config(&self) -> &ChannelConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Connected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Disconnected;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(&self) -> ChannelStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, _message: OutgoingMessage) -> Result<String> {
|
||||
// TODO: Implement Telegram API send
|
||||
Ok("telegram_msg_id".to_string())
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||
let (tx, rx) = mpsc::channel(100);
|
||||
// TODO: Implement Telegram webhook/polling
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
94
crates/zclaw-channels/src/bridge.rs
Normal file
94
crates/zclaw-channels/src/bridge.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Channel bridge manager
|
||||
//!
|
||||
//! Coordinates multiple channel adapters and routes messages.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Channel bridge manager
|
||||
pub struct ChannelBridge {
|
||||
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
|
||||
configs: RwLock<HashMap<String, ChannelConfig>>,
|
||||
}
|
||||
|
||||
impl ChannelBridge {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a channel adapter
|
||||
pub async fn register(&self, channel: Arc<dyn Channel>) {
|
||||
let config = channel.config().clone();
|
||||
let mut channels = self.channels.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
channels.insert(config.id.clone(), channel);
|
||||
configs.insert(config.id.clone(), config);
|
||||
}
|
||||
|
||||
/// Get a channel by ID
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<dyn Channel>> {
|
||||
let channels = self.channels.read().await;
|
||||
channels.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get channel configuration
|
||||
pub async fn get_config(&self, id: &str) -> Option<ChannelConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all channels
|
||||
pub async fn list(&self) -> Vec<ChannelConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Connect all channels
|
||||
pub async fn connect_all(&self) -> Result<()> {
|
||||
let channels = self.channels.read().await;
|
||||
for channel in channels.values() {
|
||||
channel.connect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect all channels
|
||||
pub async fn disconnect_all(&self) -> Result<()> {
|
||||
let channels = self.channels.read().await;
|
||||
for channel in channels.values() {
|
||||
channel.disconnect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send message through a specific channel
|
||||
pub async fn send(&self, channel_id: &str, message: OutgoingMessage) -> Result<String> {
|
||||
let channel = self.get(channel_id).await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Channel not found: {}", channel_id)))?;
|
||||
|
||||
channel.send(message).await
|
||||
}
|
||||
|
||||
/// Remove a channel
|
||||
pub async fn remove(&self, id: &str) {
|
||||
let mut channels = self.channels.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
channels.remove(id);
|
||||
configs.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelBridge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
109
crates/zclaw-channels/src/channel.rs
Normal file
109
crates/zclaw-channels/src/channel.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Channel trait and types
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::{Result, AgentId};
|
||||
|
||||
/// Channel configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelConfig {
|
||||
/// Unique channel identifier
|
||||
pub id: String,
|
||||
/// Channel type (telegram, discord, slack, etc.)
|
||||
pub channel_type: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Whether the channel is enabled
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Channel-specific configuration
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
/// Associated agent for this channel
|
||||
pub agent_id: Option<AgentId>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool { true }
|
||||
|
||||
/// Incoming message from a channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IncomingMessage {
|
||||
/// Message ID from the platform
|
||||
pub platform_id: String,
|
||||
/// Channel/conversation ID
|
||||
pub conversation_id: String,
|
||||
/// Sender information
|
||||
pub sender: MessageSender,
|
||||
/// Message content
|
||||
pub content: MessageContent,
|
||||
/// Timestamp
|
||||
pub timestamp: i64,
|
||||
/// Reply-to message ID if any
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
/// Message sender information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageSender {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// Message content types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MessageContent {
|
||||
Text { text: String },
|
||||
Image { url: String, caption: Option<String> },
|
||||
File { url: String, filename: String },
|
||||
Audio { url: String },
|
||||
Video { url: String },
|
||||
Location { latitude: f64, longitude: f64 },
|
||||
Sticker { emoji: Option<String>, url: Option<String> },
|
||||
}
|
||||
|
||||
/// Outgoing message to a channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutgoingMessage {
|
||||
/// Conversation/channel ID to send to
|
||||
pub conversation_id: String,
|
||||
/// Message content
|
||||
pub content: MessageContent,
|
||||
/// Reply-to message ID if any
|
||||
pub reply_to: Option<String>,
|
||||
/// Whether to send silently (no notification)
|
||||
pub silent: bool,
|
||||
}
|
||||
|
||||
/// Channel connection status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ChannelStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Channel trait for platform adapters
|
||||
#[async_trait]
|
||||
pub trait Channel: Send + Sync {
|
||||
/// Get channel configuration
|
||||
fn config(&self) -> &ChannelConfig;
|
||||
|
||||
/// Connect to the platform
|
||||
async fn connect(&self) -> Result<()>;
|
||||
|
||||
/// Disconnect from the platform
|
||||
async fn disconnect(&self) -> Result<()>;
|
||||
|
||||
/// Get current connection status
|
||||
async fn status(&self) -> ChannelStatus;
|
||||
|
||||
/// Send a message
|
||||
async fn send(&self, message: OutgoingMessage) -> Result<String>;
|
||||
|
||||
/// Receive incoming messages (streaming)
|
||||
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
|
||||
}
|
||||
11
crates/zclaw-channels/src/lib.rs
Normal file
11
crates/zclaw-channels/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! ZCLAW Channels
|
||||
//!
|
||||
//! External platform adapters for unified message handling.
|
||||
|
||||
mod channel;
|
||||
mod bridge;
|
||||
mod adapters;
|
||||
|
||||
pub use channel::*;
|
||||
pub use bridge::*;
|
||||
pub use adapters::*;
|
||||
20
crates/zclaw-hands/Cargo.toml
Normal file
20
crates/zclaw-hands/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "zclaw-hands"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW Hands - autonomous capabilities"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
156
crates/zclaw-hands/src/hand.rs
Normal file
156
crates/zclaw-hands/src/hand.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! Hand definition and types
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use zclaw_types::{Result, AgentId};
|
||||
|
||||
/// Hand configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandConfig {
|
||||
/// Unique hand identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Hand description
|
||||
pub description: String,
|
||||
/// Whether this hand needs approval before execution
|
||||
#[serde(default)]
|
||||
pub needs_approval: bool,
|
||||
/// Required dependencies
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
/// Input schema
|
||||
#[serde(default)]
|
||||
pub input_schema: Option<Value>,
|
||||
/// Tags for categorization
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// Whether the hand is enabled
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool { true }
|
||||
|
||||
/// Hand execution context
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HandContext {
|
||||
/// Agent ID executing the hand
|
||||
pub agent_id: AgentId,
|
||||
/// Working directory
|
||||
pub working_dir: Option<std::path::PathBuf>,
|
||||
/// Environment variables
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// Timeout in seconds
|
||||
pub timeout_secs: u64,
|
||||
/// Callback URL for async results
|
||||
pub callback_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HandContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
agent_id: AgentId::new(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: 300,
|
||||
callback_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand execution result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HandResult {
|
||||
/// 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>,
|
||||
/// Status message
|
||||
#[serde(default)]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl HandResult {
|
||||
pub fn success(output: Value) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
output,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
status: "completed".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
output: Value::Null,
|
||||
error: Some(message.into()),
|
||||
duration_ms: None,
|
||||
status: "failed".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending(status: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
output: Value::Null,
|
||||
error: None,
|
||||
duration_ms: None,
|
||||
status: status.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand execution status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandStatus {
|
||||
Idle,
|
||||
Running,
|
||||
PendingApproval,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Hand trait - autonomous capability
|
||||
#[async_trait]
|
||||
pub trait Hand: Send + Sync {
|
||||
/// Get the hand configuration
|
||||
fn config(&self) -> &HandConfig;
|
||||
|
||||
/// Execute the hand
|
||||
async fn execute(&self, context: &HandContext, input: Value) -> Result<HandResult>;
|
||||
|
||||
/// Check if the hand needs approval
|
||||
fn needs_approval(&self) -> bool {
|
||||
self.config().needs_approval
|
||||
}
|
||||
|
||||
/// Check dependencies
|
||||
fn check_dependencies(&self) -> Result<Vec<String>> {
|
||||
let missing: Vec<String> = self.config().dependencies.iter()
|
||||
.filter(|dep| !self.is_dependency_available(dep))
|
||||
.cloned()
|
||||
.collect();
|
||||
Ok(missing)
|
||||
}
|
||||
|
||||
/// Check if a specific dependency is available
|
||||
fn is_dependency_available(&self, _dep: &str) -> bool {
|
||||
true // Default implementation
|
||||
}
|
||||
|
||||
/// Get current status
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
11
crates/zclaw-hands/src/lib.rs
Normal file
11
crates/zclaw-hands/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! ZCLAW Hands
|
||||
//!
|
||||
//! Autonomous capabilities for ZCLAW agents.
|
||||
|
||||
mod hand;
|
||||
mod registry;
|
||||
mod trigger;
|
||||
|
||||
pub use hand::*;
|
||||
pub use registry::*;
|
||||
pub use trigger::*;
|
||||
131
crates/zclaw-hands/src/registry.rs
Normal file
131
crates/zclaw-hands/src/registry.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
//! Hand and Trigger registries
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::{Hand, HandConfig, HandContext, HandResult, Trigger, TriggerConfig};
|
||||
|
||||
/// Hand registry
|
||||
pub struct HandRegistry {
|
||||
hands: RwLock<HashMap<String, Arc<dyn Hand>>>,
|
||||
configs: RwLock<HashMap<String, HandConfig>>,
|
||||
}
|
||||
|
||||
impl HandRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
hands: RwLock::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a hand
|
||||
pub async fn register(&self, hand: Arc<dyn Hand>) {
|
||||
let config = hand.config().clone();
|
||||
let mut hands = self.hands.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
hands.insert(config.id.clone(), hand);
|
||||
configs.insert(config.id.clone(), config);
|
||||
}
|
||||
|
||||
/// Get a hand by ID
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<dyn Hand>> {
|
||||
let hands = self.hands.read().await;
|
||||
hands.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get hand configuration
|
||||
pub async fn get_config(&self, id: &str) -> Option<HandConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all hands
|
||||
pub async fn list(&self) -> Vec<HandConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Execute a hand
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &HandContext,
|
||||
input: serde_json::Value,
|
||||
) -> Result<HandResult> {
|
||||
let hand = self.get(id).await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Hand not found: {}", id)))?;
|
||||
|
||||
hand.execute(context, input).await
|
||||
}
|
||||
|
||||
/// Remove a hand
|
||||
pub async fn remove(&self, id: &str) {
|
||||
let mut hands = self.hands.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
hands.remove(id);
|
||||
configs.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HandRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger registry
|
||||
pub struct TriggerRegistry {
|
||||
triggers: RwLock<HashMap<String, Arc<dyn Trigger>>>,
|
||||
configs: RwLock<HashMap<String, TriggerConfig>>,
|
||||
}
|
||||
|
||||
impl TriggerRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
triggers: RwLock::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a trigger
|
||||
pub async fn register(&self, trigger: Arc<dyn Trigger>) {
|
||||
let config = trigger.config().clone();
|
||||
let mut triggers = self.triggers.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
triggers.insert(config.id.clone(), trigger);
|
||||
configs.insert(config.id.clone(), config);
|
||||
}
|
||||
|
||||
/// Get a trigger by ID
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<dyn Trigger>> {
|
||||
let triggers = self.triggers.read().await;
|
||||
triggers.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all triggers
|
||||
pub async fn list(&self) -> Vec<TriggerConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Remove a trigger
|
||||
pub async fn remove(&self, id: &str) {
|
||||
let mut triggers = self.triggers.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
triggers.remove(id);
|
||||
configs.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TriggerRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
150
crates/zclaw-hands/src/trigger.rs
Normal file
150
crates/zclaw-hands/src/trigger.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
//! Hand trigger definitions
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Trigger configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriggerConfig {
|
||||
/// Unique trigger identifier
|
||||
pub id: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Hand ID to trigger
|
||||
pub hand_id: String,
|
||||
/// Trigger type
|
||||
pub trigger_type: TriggerType,
|
||||
/// Whether the trigger is enabled
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Maximum executions per hour (rate limiting)
|
||||
#[serde(default = "default_max_executions")]
|
||||
pub max_executions_per_hour: u32,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool { true }
|
||||
fn default_max_executions() -> u32 { 10 }
|
||||
|
||||
/// Trigger type
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum TriggerType {
|
||||
/// Time-based trigger
|
||||
Schedule {
|
||||
/// Cron expression
|
||||
cron: String,
|
||||
},
|
||||
/// Event-based trigger
|
||||
Event {
|
||||
/// Event pattern to match
|
||||
pattern: String,
|
||||
},
|
||||
/// Webhook trigger
|
||||
Webhook {
|
||||
/// Webhook path
|
||||
path: String,
|
||||
/// Secret for verification
|
||||
secret: Option<String>,
|
||||
},
|
||||
/// Message pattern trigger
|
||||
MessagePattern {
|
||||
/// Regex pattern
|
||||
pattern: String,
|
||||
},
|
||||
/// File system trigger
|
||||
FileSystem {
|
||||
/// Path to watch
|
||||
path: String,
|
||||
/// Events to watch for
|
||||
events: Vec<FileEvent>,
|
||||
},
|
||||
/// Manual trigger only
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// File system event types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FileEvent {
|
||||
Created,
|
||||
Modified,
|
||||
Deleted,
|
||||
Any,
|
||||
}
|
||||
|
||||
/// Trigger state
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriggerState {
|
||||
/// Trigger ID
|
||||
pub trigger_id: String,
|
||||
/// Last execution time
|
||||
pub last_execution: Option<DateTime<Utc>>,
|
||||
/// Execution count in current hour
|
||||
pub execution_count: u32,
|
||||
/// Last execution result
|
||||
pub last_result: Option<TriggerResult>,
|
||||
/// Whether the trigger is active
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
impl TriggerState {
|
||||
pub fn new(trigger_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
trigger_id: trigger_id.into(),
|
||||
last_execution: None,
|
||||
execution_count: 0,
|
||||
last_result: None,
|
||||
is_active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger execution result
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TriggerResult {
|
||||
/// Execution timestamp
|
||||
pub timestamp: DateTime<Utc>,
|
||||
/// Whether execution succeeded
|
||||
pub success: bool,
|
||||
/// Output from hand execution
|
||||
pub output: Option<Value>,
|
||||
/// Error message if failed
|
||||
pub error: Option<String>,
|
||||
/// Input that triggered execution
|
||||
pub trigger_input: Value,
|
||||
}
|
||||
|
||||
impl TriggerResult {
|
||||
pub fn success(trigger_input: Value, output: Value) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
success: true,
|
||||
output: Some(output),
|
||||
error: None,
|
||||
trigger_input,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(trigger_input: Value, error: impl Into<String>) -> Self {
|
||||
Self {
|
||||
timestamp: Utc::now(),
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(error.into()),
|
||||
trigger_input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger trait
|
||||
pub trait Trigger: Send + Sync {
|
||||
/// Get trigger configuration
|
||||
fn config(&self) -> &TriggerConfig;
|
||||
|
||||
/// Check if trigger should fire
|
||||
fn should_fire(&self, input: &Value) -> bool;
|
||||
|
||||
/// Update trigger state
|
||||
fn update_state(&mut self, result: TriggerResult);
|
||||
}
|
||||
19
crates/zclaw-protocols/Cargo.toml
Normal file
19
crates/zclaw-protocols/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "zclaw-protocols"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW protocol support (MCP, A2A)"
|
||||
|
||||
[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 }
|
||||
reqwest = { workspace = true }
|
||||
156
crates/zclaw-protocols/src/a2a.rs
Normal file
156
crates/zclaw-protocols/src/a2a.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
//! A2A (Agent-to-Agent) protocol support
|
||||
//!
|
||||
//! Implements communication between AI agents.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use zclaw_types::{Result, AgentId};
|
||||
|
||||
/// A2A message envelope
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aEnvelope {
|
||||
/// Message ID
|
||||
pub id: String,
|
||||
/// Sender agent ID
|
||||
pub from: AgentId,
|
||||
/// Recipient agent ID (or broadcast)
|
||||
pub to: A2aRecipient,
|
||||
/// Message type
|
||||
pub message_type: A2aMessageType,
|
||||
/// Message payload
|
||||
pub payload: serde_json::Value,
|
||||
/// Timestamp
|
||||
pub timestamp: i64,
|
||||
/// Conversation/thread ID
|
||||
pub conversation_id: Option<String>,
|
||||
/// Reply-to message ID
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
/// Recipient specification
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum A2aRecipient {
|
||||
/// Direct message to specific agent
|
||||
Direct { agent_id: AgentId },
|
||||
/// Broadcast to all agents in a group
|
||||
Group { group_id: String },
|
||||
/// Broadcast to all agents
|
||||
Broadcast,
|
||||
}
|
||||
|
||||
/// A2A message types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum A2aMessageType {
|
||||
/// Request for information or action
|
||||
Request,
|
||||
/// Response to a request
|
||||
Response,
|
||||
/// Notification (no response expected)
|
||||
Notification,
|
||||
/// Error message
|
||||
Error,
|
||||
/// Heartbeat/ping
|
||||
Heartbeat,
|
||||
/// Capability advertisement
|
||||
Capability,
|
||||
}
|
||||
|
||||
/// Agent capability advertisement
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aCapability {
|
||||
/// Capability name
|
||||
pub name: String,
|
||||
/// Capability description
|
||||
pub description: String,
|
||||
/// Input schema
|
||||
pub input_schema: Option<serde_json::Value>,
|
||||
/// Output schema
|
||||
pub output_schema: Option<serde_json::Value>,
|
||||
/// Whether this capability requires approval
|
||||
pub requires_approval: bool,
|
||||
}
|
||||
|
||||
/// Agent profile for A2A
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct A2aAgentProfile {
|
||||
/// Agent ID
|
||||
pub id: AgentId,
|
||||
/// Agent name
|
||||
pub name: String,
|
||||
/// Agent description
|
||||
pub description: String,
|
||||
/// Agent capabilities
|
||||
pub capabilities: Vec<A2aCapability>,
|
||||
/// Supported protocols
|
||||
pub protocols: Vec<String>,
|
||||
/// Agent metadata
|
||||
pub metadata: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// A2A client trait
|
||||
#[async_trait]
|
||||
pub trait A2aClient: Send + Sync {
|
||||
/// Send a message to another agent
|
||||
async fn send(&self, envelope: A2aEnvelope) -> Result<()>;
|
||||
|
||||
/// Receive messages (streaming)
|
||||
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<A2aEnvelope>>;
|
||||
|
||||
/// Get agent profile
|
||||
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>>;
|
||||
|
||||
/// Discover agents with specific capabilities
|
||||
async fn discover(&self, capability: &str) -> Result<Vec<A2aAgentProfile>>;
|
||||
|
||||
/// Advertise own capabilities
|
||||
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Basic A2A client implementation
|
||||
pub struct BasicA2aClient {
|
||||
agent_id: AgentId,
|
||||
profiles: std::sync::Arc<tokio::sync::RwLock<HashMap<AgentId, A2aAgentProfile>>>,
|
||||
}
|
||||
|
||||
impl BasicA2aClient {
|
||||
pub fn new(agent_id: AgentId) -> Self {
|
||||
Self {
|
||||
agent_id,
|
||||
profiles: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl A2aClient for BasicA2aClient {
|
||||
async fn send(&self, _envelope: A2aEnvelope) -> Result<()> {
|
||||
// TODO: Implement actual A2A protocol communication
|
||||
tracing::info!("A2A send called");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<A2aEnvelope>> {
|
||||
let (_tx, rx) = tokio::sync::mpsc::channel(100);
|
||||
// TODO: Implement actual A2A protocol communication
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>> {
|
||||
let profiles = self.profiles.read().await;
|
||||
Ok(profiles.get(agent_id).cloned())
|
||||
}
|
||||
|
||||
async fn discover(&self, _capability: &str) -> Result<Vec<A2aAgentProfile>> {
|
||||
let profiles = self.profiles.read().await;
|
||||
Ok(profiles.values().cloned().collect())
|
||||
}
|
||||
|
||||
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()> {
|
||||
let mut profiles = self.profiles.write().await;
|
||||
profiles.insert(profile.id.clone(), profile);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
9
crates/zclaw-protocols/src/lib.rs
Normal file
9
crates/zclaw-protocols/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! ZCLAW Protocols
|
||||
//!
|
||||
//! Protocol support for MCP (Model Context Protocol) and A2A (Agent-to-Agent).
|
||||
|
||||
mod mcp;
|
||||
mod a2a;
|
||||
|
||||
pub use mcp::*;
|
||||
pub use a2a::*;
|
||||
183
crates/zclaw-protocols/src/mcp.rs
Normal file
183
crates/zclaw-protocols/src/mcp.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! MCP (Model Context Protocol) support
|
||||
//!
|
||||
//! Implements MCP client and server for tool/resource integration.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use zclaw_types::Result;
|
||||
|
||||
/// MCP tool definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpTool {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
/// MCP resource definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResource {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP prompt definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPrompt {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub arguments: Vec<McpPromptArgument>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptArgument {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
/// MCP server info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub protocol_version: String,
|
||||
}
|
||||
|
||||
/// MCP client configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpClientConfig {
|
||||
pub server_url: String,
|
||||
pub server_info: McpServerInfo,
|
||||
pub capabilities: McpCapabilities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct McpCapabilities {
|
||||
pub tools: Option<McpToolCapabilities>,
|
||||
pub resources: Option<McpResourceCapabilities>,
|
||||
pub prompts: Option<McpPromptCapabilities>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCapabilities {
|
||||
pub list_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceCapabilities {
|
||||
pub subscribe: bool,
|
||||
pub list_changed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpPromptCapabilities {
|
||||
pub list_changed: bool,
|
||||
}
|
||||
|
||||
/// MCP tool call request
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCallRequest {
|
||||
pub name: String,
|
||||
pub arguments: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// MCP tool call response
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpToolCallResponse {
|
||||
pub content: Vec<McpContent>,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum McpContent {
|
||||
Text { text: String },
|
||||
Image { data: String, mime_type: String },
|
||||
Resource { resource: McpResourceContent },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpResourceContent {
|
||||
pub uri: String,
|
||||
pub mime_type: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub blob: Option<String>,
|
||||
}
|
||||
|
||||
/// MCP Client trait
|
||||
#[async_trait]
|
||||
pub trait McpClient: Send + Sync {
|
||||
/// List available tools
|
||||
async fn list_tools(&self) -> Result<Vec<McpTool>>;
|
||||
|
||||
/// Call a tool
|
||||
async fn call_tool(&self, request: McpToolCallRequest) -> Result<McpToolCallResponse>;
|
||||
|
||||
/// List available resources
|
||||
async fn list_resources(&self) -> Result<Vec<McpResource>>;
|
||||
|
||||
/// Read a resource
|
||||
async fn read_resource(&self, uri: &str) -> Result<McpResourceContent>;
|
||||
|
||||
/// List available prompts
|
||||
async fn list_prompts(&self) -> Result<Vec<McpPrompt>>;
|
||||
|
||||
/// Get a prompt
|
||||
async fn get_prompt(&self, name: &str, arguments: HashMap<String, String>) -> Result<String>;
|
||||
}
|
||||
|
||||
/// Basic MCP client implementation
|
||||
pub struct BasicMcpClient {
|
||||
config: McpClientConfig,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl BasicMcpClient {
|
||||
pub fn new(config: McpClientConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl McpClient for BasicMcpClient {
|
||||
async fn list_tools(&self) -> Result<Vec<McpTool>> {
|
||||
// TODO: Implement actual MCP protocol communication
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn call_tool(&self, _request: McpToolCallRequest) -> Result<McpToolCallResponse> {
|
||||
// TODO: Implement actual MCP protocol communication
|
||||
Ok(McpToolCallResponse {
|
||||
content: vec![McpContent::Text { text: "Not implemented".to_string() }],
|
||||
is_error: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_resources(&self) -> Result<Vec<McpResource>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn read_resource(&self, _uri: &str) -> Result<McpResourceContent> {
|
||||
Ok(McpResourceContent {
|
||||
uri: String::new(),
|
||||
mime_type: None,
|
||||
text: Some("Not implemented".to_string()),
|
||||
blob: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_prompts(&self) -> Result<Vec<McpPrompt>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn get_prompt(&self, _name: &str, _arguments: HashMap<String, String>) -> Result<String> {
|
||||
Ok("Not implemented".to_string())
|
||||
}
|
||||
}
|
||||
18
crates/zclaw-skills/Cargo.toml
Normal file
18
crates/zclaw-skills/Cargo.toml
Normal 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 }
|
||||
13
crates/zclaw-skills/src/lib.rs
Normal file
13
crates/zclaw-skills/src/lib.rs
Normal 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::*;
|
||||
256
crates/zclaw-skills/src/loader.rs
Normal file
256
crates/zclaw-skills/src/loader.rs
Normal 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)
|
||||
}
|
||||
149
crates/zclaw-skills/src/registry.rs
Normal file
149
crates/zclaw-skills/src/registry.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
152
crates/zclaw-skills/src/runner.rs
Normal file
152
crates/zclaw-skills/src/runner.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
147
crates/zclaw-skills/src/skill.rs
Normal file
147
crates/zclaw-skills/src/skill.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user