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,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 }

View 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)
}
}

View 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)
}
}

View 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;

View 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)
}
}

View 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)
}
}

View 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()
}
}

View 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>>;
}

View 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::*;

View 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 }

View 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
}
}

View 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::*;

View 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()
}
}

View 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);
}

View 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 }

View 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(())
}
}

View 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::*;

View 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())
}
}

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(())
}
}