diff --git a/crates/zclaw-channels/Cargo.toml b/crates/zclaw-channels/Cargo.toml new file mode 100644 index 0000000..9c19941 --- /dev/null +++ b/crates/zclaw-channels/Cargo.toml @@ -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 } diff --git a/crates/zclaw-channels/src/adapters/console.rs b/crates/zclaw-channels/src/adapters/console.rs new file mode 100644 index 0000000..6f3bd93 --- /dev/null +++ b/crates/zclaw-channels/src/adapters/console.rs @@ -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>, +} + +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 { + // 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> { + 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) + } +} diff --git a/crates/zclaw-channels/src/adapters/discord.rs b/crates/zclaw-channels/src/adapters/discord.rs new file mode 100644 index 0000000..5b8c84d --- /dev/null +++ b/crates/zclaw-channels/src/adapters/discord.rs @@ -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>, +} + +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 { + // TODO: Implement Discord API send + Ok("discord_msg_id".to_string()) + } + + async fn receive(&self) -> Result> { + let (tx, rx) = mpsc::channel(100); + // TODO: Implement Discord gateway + Ok(rx) + } +} diff --git a/crates/zclaw-channels/src/adapters/mod.rs b/crates/zclaw-channels/src/adapters/mod.rs new file mode 100644 index 0000000..ccbc718 --- /dev/null +++ b/crates/zclaw-channels/src/adapters/mod.rs @@ -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; diff --git a/crates/zclaw-channels/src/adapters/slack.rs b/crates/zclaw-channels/src/adapters/slack.rs new file mode 100644 index 0000000..e10f228 --- /dev/null +++ b/crates/zclaw-channels/src/adapters/slack.rs @@ -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>, +} + +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 { + // TODO: Implement Slack API send + Ok("slack_msg_ts".to_string()) + } + + async fn receive(&self) -> Result> { + let (tx, rx) = mpsc::channel(100); + // TODO: Implement Slack RTM/events API + Ok(rx) + } +} diff --git a/crates/zclaw-channels/src/adapters/telegram.rs b/crates/zclaw-channels/src/adapters/telegram.rs new file mode 100644 index 0000000..2ce71fe --- /dev/null +++ b/crates/zclaw-channels/src/adapters/telegram.rs @@ -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, + status: Arc>, +} + +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 { + // TODO: Implement Telegram API send + Ok("telegram_msg_id".to_string()) + } + + async fn receive(&self) -> Result> { + let (tx, rx) = mpsc::channel(100); + // TODO: Implement Telegram webhook/polling + Ok(rx) + } +} diff --git a/crates/zclaw-channels/src/bridge.rs b/crates/zclaw-channels/src/bridge.rs new file mode 100644 index 0000000..22df1c7 --- /dev/null +++ b/crates/zclaw-channels/src/bridge.rs @@ -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>>, + configs: RwLock>, +} + +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) { + 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> { + let channels = self.channels.read().await; + channels.get(id).cloned() + } + + /// Get channel configuration + pub async fn get_config(&self, id: &str) -> Option { + let configs = self.configs.read().await; + configs.get(id).cloned() + } + + /// List all channels + pub async fn list(&self) -> Vec { + 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 { + 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() + } +} diff --git a/crates/zclaw-channels/src/channel.rs b/crates/zclaw-channels/src/channel.rs new file mode 100644 index 0000000..3420eb7 --- /dev/null +++ b/crates/zclaw-channels/src/channel.rs @@ -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, +} + +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, +} + +/// Message sender information +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageSender { + pub id: String, + pub name: Option, + pub username: Option, + 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 }, + File { url: String, filename: String }, + Audio { url: String }, + Video { url: String }, + Location { latitude: f64, longitude: f64 }, + Sticker { emoji: Option, url: Option }, +} + +/// 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, + /// 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; + + /// Receive incoming messages (streaming) + async fn receive(&self) -> Result>; +} diff --git a/crates/zclaw-channels/src/lib.rs b/crates/zclaw-channels/src/lib.rs new file mode 100644 index 0000000..3f897c6 --- /dev/null +++ b/crates/zclaw-channels/src/lib.rs @@ -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::*; diff --git a/crates/zclaw-hands/Cargo.toml b/crates/zclaw-hands/Cargo.toml new file mode 100644 index 0000000..8be9485 --- /dev/null +++ b/crates/zclaw-hands/Cargo.toml @@ -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 } diff --git a/crates/zclaw-hands/src/hand.rs b/crates/zclaw-hands/src/hand.rs new file mode 100644 index 0000000..2399413 --- /dev/null +++ b/crates/zclaw-hands/src/hand.rs @@ -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, + /// Input schema + #[serde(default)] + pub input_schema: Option, + /// Tags for categorization + #[serde(default)] + pub tags: Vec, + /// 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, + /// Environment variables + pub env: std::collections::HashMap, + /// Timeout in seconds + pub timeout_secs: u64, + /// Callback URL for async results + pub callback_url: Option, +} + +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, + /// Execution duration in milliseconds + #[serde(default)] + pub duration_ms: Option, + /// 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) -> Self { + Self { + success: false, + output: Value::Null, + error: Some(message.into()), + duration_ms: None, + status: "failed".to_string(), + } + } + + pub fn pending(status: impl Into) -> 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; + + /// Check if the hand needs approval + fn needs_approval(&self) -> bool { + self.config().needs_approval + } + + /// Check dependencies + fn check_dependencies(&self) -> Result> { + let missing: Vec = 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 + } +} diff --git a/crates/zclaw-hands/src/lib.rs b/crates/zclaw-hands/src/lib.rs new file mode 100644 index 0000000..a963ce6 --- /dev/null +++ b/crates/zclaw-hands/src/lib.rs @@ -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::*; diff --git a/crates/zclaw-hands/src/registry.rs b/crates/zclaw-hands/src/registry.rs new file mode 100644 index 0000000..bf2be8d --- /dev/null +++ b/crates/zclaw-hands/src/registry.rs @@ -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>>, + configs: RwLock>, +} + +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) { + 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> { + let hands = self.hands.read().await; + hands.get(id).cloned() + } + + /// Get hand configuration + pub async fn get_config(&self, id: &str) -> Option { + let configs = self.configs.read().await; + configs.get(id).cloned() + } + + /// List all hands + pub async fn list(&self) -> Vec { + 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 { + 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>>, + configs: RwLock>, +} + +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) { + 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> { + let triggers = self.triggers.read().await; + triggers.get(id).cloned() + } + + /// List all triggers + pub async fn list(&self) -> Vec { + 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() + } +} diff --git a/crates/zclaw-hands/src/trigger.rs b/crates/zclaw-hands/src/trigger.rs new file mode 100644 index 0000000..5c44469 --- /dev/null +++ b/crates/zclaw-hands/src/trigger.rs @@ -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, + }, + /// Message pattern trigger + MessagePattern { + /// Regex pattern + pattern: String, + }, + /// File system trigger + FileSystem { + /// Path to watch + path: String, + /// Events to watch for + events: Vec, + }, + /// 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>, + /// Execution count in current hour + pub execution_count: u32, + /// Last execution result + pub last_result: Option, + /// Whether the trigger is active + pub is_active: bool, +} + +impl TriggerState { + pub fn new(trigger_id: impl Into) -> 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, + /// Whether execution succeeded + pub success: bool, + /// Output from hand execution + pub output: Option, + /// Error message if failed + pub error: Option, + /// 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) -> 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); +} diff --git a/crates/zclaw-protocols/Cargo.toml b/crates/zclaw-protocols/Cargo.toml new file mode 100644 index 0000000..e49cc69 --- /dev/null +++ b/crates/zclaw-protocols/Cargo.toml @@ -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 } diff --git a/crates/zclaw-protocols/src/a2a.rs b/crates/zclaw-protocols/src/a2a.rs new file mode 100644 index 0000000..73ef8be --- /dev/null +++ b/crates/zclaw-protocols/src/a2a.rs @@ -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, + /// Reply-to message ID + pub reply_to: Option, +} + +/// 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, + /// Output schema + pub output_schema: Option, + /// 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, + /// Supported protocols + pub protocols: Vec, + /// Agent metadata + pub metadata: HashMap, +} + +/// 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>; + + /// Get agent profile + async fn get_profile(&self, agent_id: &AgentId) -> Result>; + + /// Discover agents with specific capabilities + async fn discover(&self, capability: &str) -> Result>; + + /// Advertise own capabilities + async fn advertise(&self, profile: A2aAgentProfile) -> Result<()>; +} + +/// Basic A2A client implementation +pub struct BasicA2aClient { + agent_id: AgentId, + profiles: std::sync::Arc>>, +} + +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> { + 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> { + let profiles = self.profiles.read().await; + Ok(profiles.get(agent_id).cloned()) + } + + async fn discover(&self, _capability: &str) -> Result> { + 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(()) + } +} diff --git a/crates/zclaw-protocols/src/lib.rs b/crates/zclaw-protocols/src/lib.rs new file mode 100644 index 0000000..d1a6704 --- /dev/null +++ b/crates/zclaw-protocols/src/lib.rs @@ -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::*; diff --git a/crates/zclaw-protocols/src/mcp.rs b/crates/zclaw-protocols/src/mcp.rs new file mode 100644 index 0000000..b7ce96a --- /dev/null +++ b/crates/zclaw-protocols/src/mcp.rs @@ -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, + pub mime_type: Option, +} + +/// MCP prompt definition +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpPrompt { + pub name: String, + pub description: String, + pub arguments: Vec, +} + +#[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, + pub resources: Option, + pub prompts: Option, +} + +#[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, +} + +/// MCP tool call response +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpToolCallResponse { + pub content: Vec, + 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, + pub text: Option, + pub blob: Option, +} + +/// MCP Client trait +#[async_trait] +pub trait McpClient: Send + Sync { + /// List available tools + async fn list_tools(&self) -> Result>; + + /// Call a tool + async fn call_tool(&self, request: McpToolCallRequest) -> Result; + + /// List available resources + async fn list_resources(&self) -> Result>; + + /// Read a resource + async fn read_resource(&self, uri: &str) -> Result; + + /// List available prompts + async fn list_prompts(&self) -> Result>; + + /// Get a prompt + async fn get_prompt(&self, name: &str, arguments: HashMap) -> Result; +} + +/// 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> { + // TODO: Implement actual MCP protocol communication + Ok(Vec::new()) + } + + async fn call_tool(&self, _request: McpToolCallRequest) -> Result { + // 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> { + Ok(Vec::new()) + } + + async fn read_resource(&self, _uri: &str) -> Result { + Ok(McpResourceContent { + uri: String::new(), + mime_type: None, + text: Some("Not implemented".to_string()), + blob: None, + }) + } + + async fn list_prompts(&self) -> Result> { + Ok(Vec::new()) + } + + async fn get_prompt(&self, _name: &str, _arguments: HashMap) -> Result { + Ok("Not implemented".to_string()) + } +} diff --git a/crates/zclaw-skills/Cargo.toml b/crates/zclaw-skills/Cargo.toml new file mode 100644 index 0000000..08cdf2c --- /dev/null +++ b/crates/zclaw-skills/Cargo.toml @@ -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 } diff --git a/crates/zclaw-skills/src/lib.rs b/crates/zclaw-skills/src/lib.rs new file mode 100644 index 0000000..a602b02 --- /dev/null +++ b/crates/zclaw-skills/src/lib.rs @@ -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::*; diff --git a/crates/zclaw-skills/src/loader.rs b/crates/zclaw-skills/src/loader.rs new file mode 100644 index 0000000..cecde8b --- /dev/null +++ b/crates/zclaw-skills/src/loader.rs @@ -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 { + // 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 { + 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 { + 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 { + 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 { + // 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> { + 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) +} diff --git a/crates/zclaw-skills/src/registry.rs b/crates/zclaw-skills/src/registry.rs new file mode 100644 index 0000000..4dac9e3 --- /dev/null +++ b/crates/zclaw-skills/src/registry.rs @@ -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>>, + manifests: RwLock>, + skill_dirs: RwLock>, +} + +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 = 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> { + let skills = self.skills.read().await; + skills.get(id).cloned() + } + + /// Get skill manifest + pub async fn get_manifest(&self, id: &SkillId) -> Option { + let manifests = self.manifests.read().await; + manifests.get(id).cloned() + } + + /// List all skills + pub async fn list(&self) -> Vec { + 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 { + 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, 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() + } +} diff --git a/crates/zclaw-skills/src/runner.rs b/crates/zclaw-skills/src/runner.rs new file mode 100644 index 0000000..29f82a2 --- /dev/null +++ b/crates/zclaw-skills/src/runner.rs @@ -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 { + 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 { + 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 { + 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)) + } + } +} diff --git a/crates/zclaw-skills/src/skill.rs b/crates/zclaw-skills/src/skill.rs new file mode 100644 index 0000000..4884864 --- /dev/null +++ b/crates/zclaw-skills/src/skill.rs @@ -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, + /// Execution mode + pub mode: SkillMode, + /// Required capabilities + #[serde(default)] + pub capabilities: Vec, + /// Input schema (JSON Schema) + #[serde(default)] + pub input_schema: Option, + /// Output schema (JSON Schema) + #[serde(default)] + pub output_schema: Option, + /// Tags for categorization + #[serde(default)] + pub tags: Vec, + /// 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, + /// Environment variables + pub env: std::collections::HashMap, + /// 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, + /// Execution duration in milliseconds + #[serde(default)] + pub duration_ms: Option, + /// Token usage if LLM was #[serde(default)] + pub tokens_used: Option, +} + +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) -> 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; + + /// 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(()) + } +}