release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements

## Major Features

### Streaming Response System
- Implement LlmDriver trait with `stream()` method returning async Stream
- Add SSE parsing for Anthropic and OpenAI API streaming
- Integrate Tauri event system for frontend streaming (`stream:chunk` events)
- Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error

### MCP Protocol Implementation
- Add MCP JSON-RPC 2.0 types (mcp_types.rs)
- Implement stdio-based MCP transport (mcp_transport.rs)
- Support tool discovery, execution, and resource operations

### Browser Hand Implementation
- Complete browser automation with Playwright-style actions
- Support Navigate, Click, Type, Scrape, Screenshot, Wait actions
- Add educational Hands: Whiteboard, Slideshow, Speech, Quiz

### Security Enhancements
- Implement command whitelist/blacklist for shell_exec tool
- Add SSRF protection with private IP blocking
- Create security.toml configuration file

## Test Improvements
- Fix test import paths (security-utils, setup)
- Fix vi.mock hoisting issues with vi.hoisted()
- Update test expectations for validateUrl and sanitizeFilename
- Add getUnsupportedLocalGatewayStatus mock

## Documentation Updates
- Update architecture documentation
- Improve configuration reference
- Add quick-start guide updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-24 03:24:24 +08:00
parent e49ba4460b
commit 3ff08faa56
78 changed files with 29575 additions and 1682 deletions

View File

@@ -17,3 +17,4 @@ thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,50 +1,122 @@
//! A2A (Agent-to-Agent) protocol support
//!
//! Implements communication between AI agents.
//! Implements communication between AI agents with support for:
//! - Direct messaging (point-to-point)
//! - Group messaging (multicast)
//! - Broadcast messaging (all agents)
//! - Capability discovery and advertisement
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use zclaw_types::{Result, AgentId};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
use uuid::Uuid;
use zclaw_types::{AgentId, Result, ZclawError};
/// Default channel buffer size
const DEFAULT_CHANNEL_SIZE: usize = 256;
/// A2A message envelope
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aEnvelope {
/// Message ID
/// Message ID (UUID recommended)
pub id: String,
/// Sender agent ID
pub from: AgentId,
/// Recipient agent ID (or broadcast)
/// Recipient specification
pub to: A2aRecipient,
/// Message type
pub message_type: A2aMessageType,
/// Message payload
/// Message payload (JSON)
pub payload: serde_json::Value,
/// Timestamp
/// Timestamp (Unix epoch milliseconds)
pub timestamp: i64,
/// Conversation/thread ID
/// Conversation/thread ID for grouping related messages
pub conversation_id: Option<String>,
/// Reply-to message ID
/// Reply-to message ID for threading
pub reply_to: Option<String>,
/// Priority (0 = normal, higher = more urgent)
#[serde(default)]
pub priority: u8,
/// Time-to-live in seconds (0 = no expiry)
#[serde(default)]
pub ttl: u32,
}
impl A2aEnvelope {
/// Create a new envelope with auto-generated ID and timestamp
pub fn new(from: AgentId, to: A2aRecipient, message_type: A2aMessageType, payload: serde_json::Value) -> Self {
Self {
id: uuid_v4(),
from,
to,
message_type,
payload,
timestamp: current_timestamp(),
conversation_id: None,
reply_to: None,
priority: 0,
ttl: 0,
}
}
/// Set conversation ID
pub fn with_conversation(mut self, conversation_id: impl Into<String>) -> Self {
self.conversation_id = Some(conversation_id.into());
self
}
/// Set reply-to message ID
pub fn with_reply_to(mut self, reply_to: impl Into<String>) -> Self {
self.reply_to = Some(reply_to.into());
self
}
/// Set priority
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
/// Check if message has expired
pub fn is_expired(&self) -> bool {
if self.ttl == 0 {
return false;
}
let now = current_timestamp();
let expiry = self.timestamp + (self.ttl as i64 * 1000);
now > expiry
}
}
/// Recipient specification
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[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
/// Message to all agents in a group
Group { group_id: String },
/// Broadcast to all agents
Broadcast,
}
impl std::fmt::Display for A2aRecipient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
A2aRecipient::Direct { agent_id } => write!(f, "direct:{}", agent_id),
A2aRecipient::Group { group_id } => write!(f, "group:{}", group_id),
A2aRecipient::Broadcast => write!(f, "broadcast"),
}
}
}
/// A2A message types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum A2aMessageType {
/// Request for information or action
/// Request for information or action (expects response)
Request,
/// Response to a request
Response,
@@ -56,21 +128,31 @@ pub enum A2aMessageType {
Heartbeat,
/// Capability advertisement
Capability,
/// Task delegation
Task,
/// Task status update
TaskStatus,
}
/// Agent capability advertisement
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct A2aCapability {
/// Capability name
/// Capability name (e.g., "code-generation", "web-search")
pub name: String,
/// Capability description
/// Human-readable description
pub description: String,
/// Input schema
/// JSON Schema for input validation
pub input_schema: Option<serde_json::Value>,
/// Output schema
/// JSON Schema for output validation
pub output_schema: Option<serde_json::Value>,
/// Whether this capability requires approval
/// Whether this capability requires human approval
pub requires_approval: bool,
/// Capability version
#[serde(default)]
pub version: String,
/// Tags for categorization
#[serde(default)]
pub tags: Vec<String>,
}
/// Agent profile for A2A
@@ -78,16 +160,41 @@ pub struct A2aCapability {
pub struct A2aAgentProfile {
/// Agent ID
pub id: AgentId,
/// Agent name
/// Display name
pub name: String,
/// Agent description
pub description: String,
/// Agent capabilities
/// Advertised capabilities
pub capabilities: Vec<A2aCapability>,
/// Supported protocols
pub protocols: Vec<String>,
/// Agent metadata
/// Agent role (e.g., "teacher", "assistant", "worker")
#[serde(default)]
pub role: String,
/// Priority for task assignment (higher = more priority)
#[serde(default)]
pub priority: u8,
/// Additional metadata
#[serde(default)]
pub metadata: HashMap<String, String>,
/// Groups this agent belongs to
#[serde(default)]
pub groups: Vec<String>,
/// Last seen timestamp
#[serde(default)]
pub last_seen: i64,
}
impl A2aAgentProfile {
/// Check if agent has a specific capability
pub fn has_capability(&self, name: &str) -> bool {
self.capabilities.iter().any(|c| c.name == name)
}
/// Get capability by name
pub fn get_capability(&self, name: &str) -> Option<&A2aCapability> {
self.capabilities.iter().find(|c| c.name == name)
}
}
/// A2A client trait
@@ -96,61 +203,487 @@ 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>>;
/// Receive the next message (blocking)
async fn recv(&self) -> Option<A2aEnvelope>;
/// Get agent profile
/// Try to receive a message without blocking
fn try_recv(&self) -> Result<A2aEnvelope>;
/// Get agent profile by ID
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>>;
/// Discover agents with specific capabilities
/// Discover agents with specific capability
async fn discover(&self, capability: &str) -> Result<Vec<A2aAgentProfile>>;
/// Advertise own capabilities
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()>;
/// Join a group
async fn join_group(&self, group_id: &str) -> Result<()>;
/// Leave a group
async fn leave_group(&self, group_id: &str) -> Result<()>;
/// Get all agents in a group
async fn get_group_members(&self, group_id: &str) -> Result<Vec<AgentId>>;
/// Get all online agents
async fn get_online_agents(&self) -> Result<Vec<A2aAgentProfile>>;
}
/// A2A Router - manages message routing between agents
pub struct A2aRouter {
/// Agent ID for this router instance
agent_id: AgentId,
/// Agent profiles registry
profiles: Arc<RwLock<HashMap<AgentId, A2aAgentProfile>>>,
/// Agent message queues (inbox for each agent) - using broadcast for multiple subscribers
queues: Arc<RwLock<HashMap<AgentId, mpsc::Sender<A2aEnvelope>>>>,
/// Group membership mapping (group_id -> agent_ids)
groups: Arc<RwLock<HashMap<String, Vec<AgentId>>>>,
/// Capability index (capability_name -> agent_ids)
capability_index: Arc<RwLock<HashMap<String, Vec<AgentId>>>>,
/// Channel size for message queues
channel_size: usize,
}
/// Handle for receiving A2A messages
///
/// This struct provides a way to receive messages from the A2A router.
/// It stores the receiver internally and provides methods to receive messages.
pub struct A2aReceiver {
receiver: Option<mpsc::Receiver<A2aEnvelope>>,
}
impl A2aReceiver {
fn new(rx: mpsc::Receiver<A2aEnvelope>) -> Self {
Self { receiver: Some(rx) }
}
/// Receive the next message (async)
pub async fn recv(&mut self) -> Option<A2aEnvelope> {
if let Some(ref mut rx) = self.receiver {
rx.recv().await
} else {
None
}
}
/// Try to receive a message without blocking
pub fn try_recv(&mut self) -> Result<A2aEnvelope> {
if let Some(ref mut rx) = self.receiver {
rx.try_recv()
.map_err(|e| ZclawError::Internal(format!("Receive error: {}", e)))
} else {
Err(ZclawError::Internal("No receiver available".into()))
}
}
/// Check if receiver is still active
pub fn is_active(&self) -> bool {
self.receiver.is_some()
}
}
impl A2aRouter {
/// Create a new A2A router
pub fn new(agent_id: AgentId) -> Self {
Self {
agent_id,
profiles: Arc::new(RwLock::new(HashMap::new())),
queues: Arc::new(RwLock::new(HashMap::new())),
groups: Arc::new(RwLock::new(HashMap::new())),
capability_index: Arc::new(RwLock::new(HashMap::new())),
channel_size: DEFAULT_CHANNEL_SIZE,
}
}
/// Create router with custom channel size
pub fn with_channel_size(agent_id: AgentId, channel_size: usize) -> Self {
Self {
agent_id,
profiles: Arc::new(RwLock::new(HashMap::new())),
queues: Arc::new(RwLock::new(HashMap::new())),
groups: Arc::new(RwLock::new(HashMap::new())),
capability_index: Arc::new(RwLock::new(HashMap::new())),
channel_size,
}
}
/// Register an agent with the router
pub async fn register_agent(&self, profile: A2aAgentProfile) -> mpsc::Receiver<A2aEnvelope> {
let agent_id = profile.id.clone();
// Create inbox for this agent
let (tx, rx) = mpsc::channel(self.channel_size);
// Update capability index
{
let mut cap_index = self.capability_index.write().await;
for cap in &profile.capabilities {
cap_index
.entry(cap.name.clone())
.or_insert_with(Vec::new)
.push(agent_id.clone());
}
}
// Update last seen
let mut profile = profile;
profile.last_seen = current_timestamp();
// Store profile and queue
{
let mut profiles = self.profiles.write().await;
profiles.insert(agent_id.clone(), profile);
}
{
let mut queues = self.queues.write().await;
queues.insert(agent_id, tx);
}
rx
}
/// Unregister an agent
pub async fn unregister_agent(&self, agent_id: &AgentId) {
// Remove from profiles
let profile = {
let mut profiles = self.profiles.write().await;
profiles.remove(agent_id)
};
// Remove from capability index
if let Some(profile) = profile {
let mut cap_index = self.capability_index.write().await;
for cap in &profile.capabilities {
if let Some(agents) = cap_index.get_mut(&cap.name) {
agents.retain(|id| id != agent_id);
}
}
}
// Remove from all groups
{
let mut groups = self.groups.write().await;
for members in groups.values_mut() {
members.retain(|id| id != agent_id);
}
}
// Remove queue
{
let mut queues = self.queues.write().await;
queues.remove(agent_id);
}
}
/// Route a message to recipient(s)
pub async fn route(&self, envelope: A2aEnvelope) -> Result<()> {
// Check if message has expired
if envelope.is_expired() {
return Err(ZclawError::InvalidInput("Message has expired".into()));
}
let queues = self.queues.read().await;
match &envelope.to {
A2aRecipient::Direct { agent_id } => {
// Direct message to single agent
if let Some(tx) = queues.get(agent_id) {
tx.send(envelope.clone())
.await
.map_err(|e| ZclawError::Internal(format!("Failed to send message: {}", e)))?;
} else {
tracing::warn!("Agent {} not found for direct message", agent_id);
}
}
A2aRecipient::Group { group_id } => {
// Message to all agents in group
let groups = self.groups.read().await;
if let Some(members) = groups.get(group_id) {
for agent_id in members {
if let Some(tx) = queues.get(agent_id) {
let _ = tx.send(envelope.clone()).await;
}
}
}
}
A2aRecipient::Broadcast => {
// Broadcast to all registered agents
for (agent_id, tx) in queues.iter() {
if agent_id != &envelope.from {
let _ = tx.send(envelope.clone()).await;
}
}
}
}
Ok(())
}
/// Get router's agent ID
pub fn agent_id(&self) -> &AgentId {
&self.agent_id
}
}
/// Basic A2A client implementation
pub struct BasicA2aClient {
/// Agent ID
agent_id: AgentId,
profiles: std::sync::Arc<tokio::sync::RwLock<HashMap<AgentId, A2aAgentProfile>>>,
/// Shared router reference
router: Arc<A2aRouter>,
/// Receiver for incoming messages
receiver: Arc<tokio::sync::Mutex<Option<mpsc::Receiver<A2aEnvelope>>>>,
}
impl BasicA2aClient {
pub fn new(agent_id: AgentId) -> Self {
/// Create a new A2A client with shared router
pub fn new(agent_id: AgentId, router: Arc<A2aRouter>) -> Self {
Self {
agent_id,
profiles: std::sync::Arc::new(tokio::sync::RwLock::new(HashMap::new())),
router,
receiver: Arc::new(tokio::sync::Mutex::new(None)),
}
}
/// Initialize the client (register with router)
pub async fn initialize(&self, profile: A2aAgentProfile) -> Result<()> {
let rx = self.router.register_agent(profile).await;
let mut receiver = self.receiver.lock().await;
*receiver = Some(rx);
Ok(())
}
/// Shutdown the client
pub async fn shutdown(&self) {
self.router.unregister_agent(&self.agent_id).await;
}
}
#[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 send(&self, envelope: A2aEnvelope) -> Result<()> {
tracing::debug!(
from = %envelope.from,
to = %envelope.to,
type = ?envelope.message_type,
"A2A send"
);
self.router.route(envelope).await
}
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 recv(&self) -> Option<A2aEnvelope> {
let mut receiver = self.receiver.lock().await;
if let Some(ref mut rx) = *receiver {
rx.recv().await
} else {
// Wait a bit and return None if no receiver
None
}
}
fn try_recv(&self) -> Result<A2aEnvelope> {
// Use blocking lock for try_recv
let mut receiver = self.receiver
.try_lock()
.map_err(|_| ZclawError::Internal("Receiver locked".into()))?;
if let Some(ref mut rx) = *receiver {
rx.try_recv()
.map_err(|e| ZclawError::Internal(format!("Receive error: {}", e)))
} else {
Err(ZclawError::Internal("No receiver available".into()))
}
}
async fn get_profile(&self, agent_id: &AgentId) -> Result<Option<A2aAgentProfile>> {
let profiles = self.profiles.read().await;
let profiles = self.router.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 discover(&self, capability: &str) -> Result<Vec<A2aAgentProfile>> {
let cap_index = self.router.capability_index.read().await;
let profiles = self.router.profiles.read().await;
if let Some(agent_ids) = cap_index.get(capability) {
let result: Vec<A2aAgentProfile> = agent_ids
.iter()
.filter_map(|id| profiles.get(id).cloned())
.collect();
Ok(result)
} else {
Ok(Vec::new())
}
}
async fn advertise(&self, profile: A2aAgentProfile) -> Result<()> {
let mut profiles = self.profiles.write().await;
profiles.insert(profile.id.clone(), profile);
tracing::info!(agent_id = %profile.id, capabilities = ?profile.capabilities.len(), "A2A advertise");
self.router.register_agent(profile).await;
Ok(())
}
async fn join_group(&self, group_id: &str) -> Result<()> {
let mut groups = self.router.groups.write().await;
groups
.entry(group_id.to_string())
.or_insert_with(Vec::new)
.push(self.agent_id.clone());
tracing::info!(agent_id = %self.agent_id, group = %group_id, "A2A join group");
Ok(())
}
async fn leave_group(&self, group_id: &str) -> Result<()> {
let mut groups = self.router.groups.write().await;
if let Some(members) = groups.get_mut(group_id) {
members.retain(|id| id != &self.agent_id);
}
tracing::info!(agent_id = %self.agent_id, group = %group_id, "A2A leave group");
Ok(())
}
async fn get_group_members(&self, group_id: &str) -> Result<Vec<AgentId>> {
let groups = self.router.groups.read().await;
Ok(groups.get(group_id).cloned().unwrap_or_default())
}
async fn get_online_agents(&self) -> Result<Vec<A2aAgentProfile>> {
let profiles = self.router.profiles.read().await;
Ok(profiles.values().cloned().collect())
}
}
// Helper functions
/// Generate a UUID v4 string using cryptographically secure random
fn uuid_v4() -> String {
Uuid::new_v4().to_string()
}
/// Get current timestamp in milliseconds
fn current_timestamp() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_envelope_creation() {
let from = AgentId::new();
let to = A2aRecipient::Direct { agent_id: AgentId::new() };
let envelope = A2aEnvelope::new(
from,
to,
A2aMessageType::Request,
serde_json::json!({"action": "test"}),
);
assert!(!envelope.id.is_empty());
assert!(envelope.timestamp > 0);
assert!(envelope.conversation_id.is_none());
}
#[test]
fn test_envelope_expiry() {
let from = AgentId::new();
let to = A2aRecipient::Broadcast;
let mut envelope = A2aEnvelope::new(
from,
to,
A2aMessageType::Notification,
serde_json::json!({}),
);
envelope.ttl = 1; // 1 second
assert!(!envelope.is_expired());
// After TTL should be expired (in practice, this test might be flaky)
// We just verify the logic exists
}
#[test]
fn test_recipient_display() {
let agent_id = AgentId::new();
let direct = A2aRecipient::Direct { agent_id };
assert!(format!("{}", direct).starts_with("direct:"));
let group = A2aRecipient::Group { group_id: "teachers".to_string() };
assert_eq!(format!("{}", group), "group:teachers");
let broadcast = A2aRecipient::Broadcast;
assert_eq!(format!("{}", broadcast), "broadcast");
}
#[tokio::test]
async fn test_router_registration() {
let router = A2aRouter::new(AgentId::new());
let agent_id = AgentId::new();
let profile = A2aAgentProfile {
id: agent_id,
name: "Test Agent".to_string(),
description: "A test agent".to_string(),
capabilities: vec![A2aCapability {
name: "test".to_string(),
description: "Test capability".to_string(),
input_schema: None,
output_schema: None,
requires_approval: false,
version: "1.0.0".to_string(),
tags: vec![],
}],
protocols: vec!["a2a".to_string()],
role: "worker".to_string(),
priority: 5,
metadata: HashMap::new(),
groups: vec![],
last_seen: 0,
};
let _rx = router.register_agent(profile.clone()).await;
// Verify registration
let profiles = router.profiles.read().await;
assert!(profiles.contains_key(&agent_id));
}
#[tokio::test]
async fn test_capability_discovery() {
let router = A2aRouter::new(AgentId::new());
let agent_id = AgentId::new();
let profile = A2aAgentProfile {
id: agent_id,
name: "Test Agent".to_string(),
description: "A test agent".to_string(),
capabilities: vec![A2aCapability {
name: "code-generation".to_string(),
description: "Generate code".to_string(),
input_schema: None,
output_schema: None,
requires_approval: false,
version: "1.0.0".to_string(),
tags: vec!["coding".to_string()],
}],
protocols: vec!["a2a".to_string()],
role: "worker".to_string(),
priority: 5,
metadata: HashMap::new(),
groups: vec![],
last_seen: 0,
};
router.register_agent(profile).await;
// Check capability index
let cap_index = router.capability_index.read().await;
assert!(cap_index.contains_key("code-generation"));
}
}