agent_circles.rs
1 //! Agent Circles: Lightweight coordination layer for agent collectives. 2 //! 3 //! This module provides a slimmed-down Circle interface designed specifically 4 //! for agent-to-agent coordination, enabling: 5 //! 6 //! - **Resource pooling**: Agents join circles to share inference capacity 7 //! - **Easy onboarding**: Minimal ceremony for new agents to join 8 //! - **Serialization-first**: All types designed for wire transmission 9 //! 10 //! ## Architecture 11 //! 12 //! ```text 13 //! ┌───────────────────────────────────────────────────────────────┐ 14 //! │ Agent Circle │ 15 //! ├───────────────────────────────────────────────────────────────┤ 16 //! │ Members: [Agent A, Agent B, Agent C] │ 17 //! │ Capabilities: [inference, embedding, image_gen] │ 18 //! │ Epoch: 3 (increments on membership change) │ 19 //! └───────────────────────────────────────────────────────────────┘ 20 //! │ │ │ 21 //! ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ 22 //! │ Agent A │ │ Agent B │ │ Agent C │ 23 //! │ llama7b │ │ mistral │ │ embed │ 24 //! └─────────┘ └─────────┘ └─────────┘ 25 //! ``` 26 //! 27 //! ## Joining a Circle 28 //! 29 //! ```ignore 30 //! use abzu_inference::agent_circles::{AgentCircle, AgentProfile, Capability}; 31 //! 32 //! // Create your agent profile 33 //! let profile = AgentProfile::new("my-agent") 34 //! .with_capability(Capability::Inference { model: "llama-7b".into() }) 35 //! .with_pubkey(my_pubkey); 36 //! 37 //! // Join via invite link 38 //! let invite = "abzu://circle/abc123/join?token=xyz"; 39 //! circle.join(profile, invite).await?; 40 //! ``` 41 42 use serde::{Deserialize, Serialize}; 43 use std::collections::HashSet; 44 45 /// Capability that an agent offers to the circle. 46 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] 47 pub enum Capability { 48 /// LLM inference with specific model 49 Inference { 50 model: String, 51 max_context: Option<usize>, 52 }, 53 54 /// Text embedding generation 55 Embedding { 56 model: String, 57 dimensions: usize, 58 }, 59 60 /// Image generation 61 ImageGeneration { 62 model: String, 63 }, 64 65 /// Speech-to-text transcription 66 Transcription { 67 model: String, 68 }, 69 70 /// Text-to-speech synthesis 71 Speech { 72 model: String, 73 }, 74 75 /// Code execution sandbox 76 CodeExecution { 77 runtime: String, 78 }, 79 80 /// Web browsing 81 WebBrowsing, 82 83 /// File storage (with capacity in bytes) 84 Storage { 85 capacity_bytes: u64, 86 }, 87 88 /// Custom capability 89 Custom { 90 name: String, 91 metadata: String, 92 }, 93 } 94 95 impl Capability { 96 /// Create an inference capability 97 pub fn inference(model: impl Into<String>) -> Self { 98 Self::Inference { 99 model: model.into(), 100 max_context: None, 101 } 102 } 103 104 /// Create an embedding capability 105 pub fn embedding(model: impl Into<String>, dimensions: usize) -> Self { 106 Self::Embedding { 107 model: model.into(), 108 dimensions, 109 } 110 } 111 } 112 113 /// Agent status within a circle. 114 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] 115 pub enum AgentStatus { 116 /// Available for work 117 Available, 118 /// Currently processing a request 119 Busy, 120 /// Temporarily offline 121 Offline, 122 /// Leaving the circle 123 Departing, 124 } 125 126 /// Profile of an agent participating in a circle. 127 #[derive(Debug, Clone, Serialize, Deserialize)] 128 pub struct AgentProfile { 129 /// Unique agent identifier (derived from pubkey or chosen) 130 pub id: String, 131 132 /// Agent's Ed25519 public key (32 bytes) 133 pub pubkey: [u8; 32], 134 135 /// Human-readable name (optional) 136 pub name: Option<String>, 137 138 /// Capabilities this agent offers 139 pub capabilities: Vec<Capability>, 140 141 /// Current status 142 pub status: AgentStatus, 143 144 /// When the agent joined (Unix ms) 145 pub joined_at: u64, 146 147 /// Last heartbeat (Unix ms) 148 pub last_seen: u64, 149 150 /// Reputation score (0.0 - 1.0, based on successful tasks) 151 pub reputation: f32, 152 } 153 154 impl AgentProfile { 155 /// Create a new agent profile with minimal info 156 pub fn new(id: impl Into<String>) -> Self { 157 let now = std::time::SystemTime::now() 158 .duration_since(std::time::UNIX_EPOCH) 159 .unwrap() 160 .as_millis() as u64; 161 162 Self { 163 id: id.into(), 164 pubkey: [0u8; 32], // Must be set via with_pubkey 165 name: None, 166 capabilities: vec![], 167 status: AgentStatus::Available, 168 joined_at: now, 169 last_seen: now, 170 reputation: 0.5, // Neutral starting reputation 171 } 172 } 173 174 /// Set the agent's public key 175 pub fn with_pubkey(mut self, pubkey: [u8; 32]) -> Self { 176 self.pubkey = pubkey; 177 self 178 } 179 180 /// Set the agent's display name 181 pub fn with_name(mut self, name: impl Into<String>) -> Self { 182 self.name = Some(name.into()); 183 self 184 } 185 186 /// Add a capability 187 pub fn with_capability(mut self, cap: Capability) -> Self { 188 self.capabilities.push(cap); 189 self 190 } 191 192 /// Set initial reputation 193 pub fn with_reputation(mut self, rep: f32) -> Self { 194 self.reputation = rep.clamp(0.0, 1.0); 195 self 196 } 197 198 /// Check if this agent has a specific capability 199 pub fn has_capability(&self, cap: &Capability) -> bool { 200 self.capabilities.contains(cap) 201 } 202 203 /// Check if this agent can handle inference for a model 204 pub fn can_infer(&self, model: &str) -> bool { 205 self.capabilities.iter().any(|c| { 206 matches!(c, Capability::Inference { model: m, .. } if m == model) 207 }) 208 } 209 210 /// Update last_seen to now 211 pub fn heartbeat(&mut self) { 212 self.last_seen = std::time::SystemTime::now() 213 .duration_since(std::time::UNIX_EPOCH) 214 .unwrap() 215 .as_millis() as u64; 216 } 217 218 /// Check if agent is stale (no heartbeat for duration_ms) 219 pub fn is_stale(&self, duration_ms: u64) -> bool { 220 let now = std::time::SystemTime::now() 221 .duration_since(std::time::UNIX_EPOCH) 222 .unwrap() 223 .as_millis() as u64; 224 225 now.saturating_sub(self.last_seen) > duration_ms 226 } 227 } 228 229 /// A lightweight agent circle for coordination. 230 /// 231 /// Unlike full Circle (which handles messaging, encryption, trust), 232 /// AgentCircle focuses purely on resource coordination. 233 #[derive(Debug, Clone, Serialize, Deserialize)] 234 pub struct AgentCircle { 235 /// Circle ID (32 bytes) 236 pub id: [u8; 32], 237 238 /// Circle name 239 pub name: String, 240 241 /// Description of the circle's purpose 242 pub description: Option<String>, 243 244 /// Member agents 245 pub members: Vec<AgentProfile>, 246 247 /// Current epoch (increments on membership change) 248 pub epoch: u64, 249 250 /// Aggregated capabilities available in this circle 251 pub capabilities: HashSet<String>, 252 253 /// Creation timestamp (Unix ms) 254 pub created_at: u64, 255 256 /// Maximum members allowed (0 = unlimited) 257 pub max_members: usize, 258 259 /// Whether new agents can join freely or need invite 260 pub open_membership: bool, 261 } 262 263 impl AgentCircle { 264 /// Create a new agent circle 265 pub fn new(name: impl Into<String>, founder: AgentProfile) -> Self { 266 let mut hasher = blake3::Hasher::new(); 267 hasher.update(b"abzu:agent-circle:"); 268 hasher.update(&founder.pubkey); 269 hasher.update(&founder.joined_at.to_le_bytes()); 270 let nonce: [u8; 16] = rand::random(); 271 hasher.update(&nonce); 272 let id = *hasher.finalize().as_bytes(); 273 274 let mut capabilities = HashSet::new(); 275 for cap in &founder.capabilities { 276 capabilities.insert(capability_name(cap)); 277 } 278 279 Self { 280 id, 281 name: name.into(), 282 description: None, 283 members: vec![founder], 284 epoch: 1, 285 capabilities, 286 created_at: std::time::SystemTime::now() 287 .duration_since(std::time::UNIX_EPOCH) 288 .unwrap() 289 .as_millis() as u64, 290 max_members: 0, 291 open_membership: false, 292 } 293 } 294 295 /// Set description 296 pub fn with_description(mut self, desc: impl Into<String>) -> Self { 297 self.description = Some(desc.into()); 298 self 299 } 300 301 /// Set max members 302 pub fn with_max_members(mut self, max: usize) -> Self { 303 self.max_members = max; 304 self 305 } 306 307 /// Enable open membership 308 pub fn with_open_membership(mut self) -> Self { 309 self.open_membership = true; 310 self 311 } 312 313 /// Add a member to the circle 314 pub fn add_member(&mut self, agent: AgentProfile) -> Result<(), String> { 315 // Check capacity 316 if self.max_members > 0 && self.members.len() >= self.max_members { 317 return Err("Circle is full".into()); 318 } 319 320 // Check if already a member 321 if self.members.iter().any(|m| m.pubkey == agent.pubkey) { 322 return Err("Agent is already a member".into()); 323 } 324 325 // Add capabilities 326 for cap in &agent.capabilities { 327 self.capabilities.insert(capability_name(cap)); 328 } 329 330 self.members.push(agent); 331 self.epoch += 1; 332 333 Ok(()) 334 } 335 336 /// Remove a member from the circle 337 pub fn remove_member(&mut self, pubkey: &[u8; 32]) -> bool { 338 let before = self.members.len(); 339 self.members.retain(|m| &m.pubkey != pubkey); 340 341 if self.members.len() < before { 342 // Recalculate capabilities 343 self.capabilities.clear(); 344 for member in &self.members { 345 for cap in &member.capabilities { 346 self.capabilities.insert(capability_name(cap)); 347 } 348 } 349 self.epoch += 1; 350 true 351 } else { 352 false 353 } 354 } 355 356 /// Get available members (status == Available) 357 pub fn available_members(&self) -> Vec<&AgentProfile> { 358 self.members 359 .iter() 360 .filter(|m| m.status == AgentStatus::Available) 361 .collect() 362 } 363 364 /// Find members that can handle a specific capability 365 pub fn find_capable(&self, cap: &Capability) -> Vec<&AgentProfile> { 366 self.members 367 .iter() 368 .filter(|m| m.status == AgentStatus::Available && m.has_capability(cap)) 369 .collect() 370 } 371 372 /// Find members that can handle inference for a model 373 pub fn find_inference_capable(&self, model: &str) -> Vec<&AgentProfile> { 374 self.members 375 .iter() 376 .filter(|m| m.status == AgentStatus::Available && m.can_infer(model)) 377 .collect() 378 } 379 380 /// Prune stale members (no heartbeat for duration) 381 pub fn prune_stale(&mut self, stale_duration_ms: u64) -> usize { 382 let before = self.members.len(); 383 self.members.retain(|m| !m.is_stale(stale_duration_ms)); 384 let pruned = before - self.members.len(); 385 386 if pruned > 0 { 387 // Recalculate capabilities 388 self.capabilities.clear(); 389 for member in &self.members { 390 for cap in &member.capabilities { 391 self.capabilities.insert(capability_name(cap)); 392 } 393 } 394 self.epoch += 1; 395 } 396 397 pruned 398 } 399 400 /// Generate a compact invite link 401 pub fn invite_link(&self) -> String { 402 let id_hex = hex::encode(&self.id[..8]); 403 format!("abzu://circle/{}/join", id_hex) 404 } 405 406 /// Serialize to compact bytes for transmission 407 pub fn to_bytes(&self) -> Result<Vec<u8>, String> { 408 serde_json::to_vec(self).map_err(|e| e.to_string()) 409 } 410 411 /// Deserialize from bytes 412 pub fn from_bytes(bytes: &[u8]) -> Result<Self, String> { 413 serde_json::from_slice(bytes).map_err(|e| e.to_string()) 414 } 415 } 416 417 /// Extract a capability name for aggregation 418 fn capability_name(cap: &Capability) -> String { 419 match cap { 420 Capability::Inference { model, .. } => format!("inference:{}", model), 421 Capability::Embedding { model, .. } => format!("embedding:{}", model), 422 Capability::ImageGeneration { model } => format!("image:{}", model), 423 Capability::Transcription { model } => format!("transcription:{}", model), 424 Capability::Speech { model } => format!("speech:{}", model), 425 Capability::CodeExecution { runtime } => format!("code:{}", runtime), 426 Capability::WebBrowsing => "web".into(), 427 Capability::Storage { .. } => "storage".into(), 428 Capability::Custom { name, .. } => format!("custom:{}", name), 429 } 430 } 431 432 /// Invite token for joining a circle. 433 #[derive(Debug, Clone, Serialize, Deserialize)] 434 pub struct CircleInvite { 435 /// Circle ID 436 pub circle_id: [u8; 32], 437 438 /// Circle name (for display) 439 pub circle_name: String, 440 441 /// Who created the invite 442 pub inviter_pubkey: [u8; 32], 443 444 /// Expiration timestamp (Unix ms, 0 = never) 445 pub expires_at: u64, 446 447 /// Maximum uses (0 = unlimited) 448 pub max_uses: u32, 449 450 /// Signature from inviter 451 pub signature: Vec<u8>, 452 } 453 454 impl CircleInvite { 455 /// Create a new invite 456 pub fn new( 457 circle_id: [u8; 32], 458 circle_name: String, 459 inviter_pubkey: [u8; 32], 460 ) -> Self { 461 Self { 462 circle_id, 463 circle_name, 464 inviter_pubkey, 465 expires_at: 0, 466 max_uses: 0, 467 signature: vec![], 468 } 469 } 470 471 /// Set expiration (duration from now) 472 pub fn expires_in(mut self, duration: std::time::Duration) -> Self { 473 let now = std::time::SystemTime::now() 474 .duration_since(std::time::UNIX_EPOCH) 475 .unwrap() 476 .as_millis() as u64; 477 self.expires_at = now + duration.as_millis() as u64; 478 self 479 } 480 481 /// Set max uses 482 pub fn with_max_uses(mut self, uses: u32) -> Self { 483 self.max_uses = uses; 484 self 485 } 486 487 /// Check if invite is still valid 488 pub fn is_valid(&self) -> bool { 489 if self.expires_at == 0 { 490 return true; 491 } 492 493 let now = std::time::SystemTime::now() 494 .duration_since(std::time::UNIX_EPOCH) 495 .unwrap() 496 .as_millis() as u64; 497 498 now < self.expires_at 499 } 500 501 /// Serialize to URL-safe string 502 pub fn to_token(&self) -> String { 503 let bytes = serde_json::to_vec(self).unwrap_or_default(); 504 base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, &bytes) 505 } 506 507 /// Deserialize from URL-safe string 508 pub fn from_token(token: &str) -> Result<Self, String> { 509 let bytes = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, token) 510 .map_err(|e| e.to_string())?; 511 serde_json::from_slice(&bytes).map_err(|e| e.to_string()) 512 } 513 } 514 515 #[cfg(test)] 516 mod tests { 517 use super::*; 518 519 #[test] 520 fn test_agent_profile_builder() { 521 let profile = AgentProfile::new("test-agent") 522 .with_name("Test Agent") 523 .with_pubkey([1u8; 32]) 524 .with_capability(Capability::inference("llama-7b")) 525 .with_reputation(0.8); 526 527 assert_eq!(profile.id, "test-agent"); 528 assert_eq!(profile.name, Some("Test Agent".into())); 529 assert!(profile.can_infer("llama-7b")); 530 assert!(!profile.can_infer("mistral")); 531 assert!((profile.reputation - 0.8).abs() < 0.01); 532 } 533 534 #[test] 535 fn test_agent_circle_creation() { 536 let founder = AgentProfile::new("founder") 537 .with_pubkey([1u8; 32]) 538 .with_capability(Capability::inference("llama-7b")); 539 540 let circle = AgentCircle::new("Test Circle", founder) 541 .with_description("A test circle for agents") 542 .with_max_members(10); 543 544 assert_eq!(circle.name, "Test Circle"); 545 assert_eq!(circle.members.len(), 1); 546 assert!(circle.capabilities.contains("inference:llama-7b")); 547 } 548 549 #[test] 550 fn test_add_member() { 551 let founder = AgentProfile::new("founder") 552 .with_pubkey([1u8; 32]) 553 .with_capability(Capability::inference("llama-7b")); 554 555 let mut circle = AgentCircle::new("Test Circle", founder); 556 557 let member = AgentProfile::new("member1") 558 .with_pubkey([2u8; 32]) 559 .with_capability(Capability::embedding("bge-small", 384)); 560 561 circle.add_member(member).unwrap(); 562 563 assert_eq!(circle.members.len(), 2); 564 assert_eq!(circle.epoch, 2); 565 assert!(circle.capabilities.contains("embedding:bge-small")); 566 } 567 568 #[test] 569 fn test_find_capable() { 570 let founder = AgentProfile::new("founder") 571 .with_pubkey([1u8; 32]) 572 .with_capability(Capability::inference("llama-7b")); 573 574 let mut circle = AgentCircle::new("Test Circle", founder); 575 576 let member = AgentProfile::new("member1") 577 .with_pubkey([2u8; 32]) 578 .with_capability(Capability::inference("mistral-7b")); 579 580 circle.add_member(member).unwrap(); 581 582 let llama_capable = circle.find_inference_capable("llama-7b"); 583 assert_eq!(llama_capable.len(), 1); 584 assert_eq!(llama_capable[0].id, "founder"); 585 586 let mistral_capable = circle.find_inference_capable("mistral-7b"); 587 assert_eq!(mistral_capable.len(), 1); 588 assert_eq!(mistral_capable[0].id, "member1"); 589 } 590 591 #[test] 592 fn test_invite_link() { 593 let founder = AgentProfile::new("founder").with_pubkey([1u8; 32]); 594 let circle = AgentCircle::new("Test", founder); 595 596 let link = circle.invite_link(); 597 assert!(link.starts_with("abzu://circle/")); 598 assert!(link.ends_with("/join")); 599 } 600 601 #[test] 602 fn test_circle_serialization() { 603 let founder = AgentProfile::new("founder") 604 .with_pubkey([1u8; 32]) 605 .with_capability(Capability::inference("test")); 606 607 let circle = AgentCircle::new("Test", founder); 608 609 let bytes = circle.to_bytes().unwrap(); 610 let restored = AgentCircle::from_bytes(&bytes).unwrap(); 611 612 assert_eq!(restored.name, circle.name); 613 assert_eq!(restored.id, circle.id); 614 assert_eq!(restored.members.len(), 1); 615 } 616 617 #[test] 618 fn test_invite_token() { 619 let invite = CircleInvite::new([1u8; 32], "Test Circle".into(), [2u8; 32]) 620 .expires_in(std::time::Duration::from_secs(3600)) 621 .with_max_uses(5); 622 623 let token = invite.to_token(); 624 let restored = CircleInvite::from_token(&token).unwrap(); 625 626 assert_eq!(restored.circle_name, "Test Circle"); 627 assert_eq!(restored.max_uses, 5); 628 assert!(restored.is_valid()); 629 } 630 }