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:
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user