| """ |
| Agent Event Bus - Pub/sub for agent-to-agent communication. |
| |
| OpenClaw Integration: Event-driven architecture for real-time agent feed. |
| Uses WebSocket for broadcasts (MVP <100 agents) or Redis Pub/Sub (enterprise). |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import os |
| from typing import Dict, Set, Any, List, Callable, TYPE_CHECKING, Optional |
| from datetime import datetime |
|
|
| if TYPE_CHECKING: |
| from starlette.websockets import WebSocket |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| try: |
| import redis.asyncio as redis |
| REDIS_AVAILABLE = True |
| except ImportError: |
| REDIS_AVAILABLE = False |
| logger.warning("Redis not available, using in-memory pub/sub only") |
|
|
|
|
| class AgentEventBus: |
| """ |
| Event bus for agent communication. |
| |
| Patterns: |
| - Publish-subscribe for WebSocket broadcasts |
| - Topic-based filtering (agent_id, post_type) |
| - Fan-out to multiple subscribers |
| |
| MVP: In-memory WebSocket connections (<100 agents) |
| Enterprise: Redis Pub/Sub for horizontal scaling |
| |
| **Redis Integration (Optional):** |
| - Set REDIS_URL environment variable to enable |
| - Falls back to in-memory if Redis unavailable |
| - Cross-instance message broadcasting for multi-instance deployments |
| """ |
|
|
| def __init__(self, redis_url: Optional[str] = None): |
| |
| self._subscribers: Dict[str, Set[Any]] = {} |
|
|
| |
| self._topics: Dict[str, Set[str]] = { |
| "global": set(), |
| } |
|
|
| |
| self._redis_url = redis_url or os.getenv("REDIS_URL") |
| self._redis: Optional[redis.Redis] = None |
| self._pubsub = None |
| self._redis_enabled = bool(self._redis_url) and REDIS_AVAILABLE |
| self._redis_listener_task = None |
|
|
| async def _ensure_redis(self): |
| """Initialize Redis connection if not already connected.""" |
| if self._redis_enabled and not self._redis: |
| try: |
| self._redis = await redis.from_url( |
| self._redis_url, |
| encoding="utf-8", |
| decode_responses=True |
| ) |
| self._pubsub = self._redis.pubsub() |
| logger.info(f"Redis pub/sub enabled: {self._redis_url}") |
| except Exception as e: |
| logger.warning(f"Redis connection failed, using in-memory only: {e}") |
| self._redis_enabled = False |
|
|
| async def subscribe(self, agent_id: str, websocket: Any, topics: List[str] = None): |
| """ |
| Subscribe agent to event bus. |
| |
| Args: |
| agent_id: Agent subscribing |
| websocket: WebSocket connection for broadcasts |
| topics: Topics to subscribe (default: ["global"]) |
| """ |
| if agent_id not in self._subscribers: |
| self._subscribers[agent_id] = set() |
|
|
| self._subscribers[agent_id].add(websocket) |
|
|
| |
| if topics: |
| for topic in topics: |
| if topic not in self._topics: |
| self._topics[topic] = set() |
| self._topics[topic].add(agent_id) |
|
|
| logger.info(f"Agent {agent_id} subscribed to event bus (topics: {topics})") |
|
|
| async def unsubscribe(self, agent_id: str, websocket: Any): |
| """Unsubscribe agent's WebSocket connection.""" |
| if agent_id in self._subscribers: |
| self._subscribers[agent_id].discard(websocket) |
|
|
| |
| if not self._subscribers[agent_id]: |
| del self._subscribers[agent_id] |
|
|
| |
| for topic_subscribers in self._topics.values(): |
| topic_subscribers.discard(agent_id) |
|
|
| logger.info(f"Agent {agent_id} unsubscribed from event bus") |
|
|
| async def publish(self, event: Dict[str, Any], topics: List[str] = None): |
| """ |
| Publish event to subscribers. |
| |
| Enhanced: Also publishes to Redis for horizontal scaling. |
| |
| Args: |
| event: Event data (agent_post, status_update, etc.) |
| topics: Topics to broadcast to (default: ["global"]) |
| """ |
| topics = topics if topics is not None else ["global"] |
|
|
| |
| if self._redis_enabled: |
| await self._ensure_redis() |
| if self._redis: |
| try: |
| event_json = json.dumps({"topics": topics, "event": event}) |
| for topic in topics: |
| await self._redis.publish(f"agent_events:{topic}", event_json) |
| logger.debug(f"Published to Redis topic: agent_events:{topic}") |
| except Exception as e: |
| logger.warning(f"Redis publish failed: {e}") |
|
|
| |
| subscriber_ids = set() |
| for topic in topics: |
| if topic in self._topics: |
| subscriber_ids.update(self._topics[topic]) |
|
|
| |
| |
| websockets_to_send = [] |
| for agent_id in subscriber_ids: |
| if agent_id in self._subscribers: |
| for websocket in self._subscribers[agent_id]: |
| websockets_to_send.append((agent_id, websocket)) |
|
|
| |
| for agent_id, websocket in websockets_to_send: |
| try: |
| await websocket.send_json(event) |
| except Exception as e: |
| logger.warning(f"Failed to send to agent {agent_id}: {e}") |
| |
| await self.unsubscribe(agent_id, websocket) |
|
|
| logger.info(f"Event published to {len(subscriber_ids)} subscribers (topics: {topics})") |
|
|
| async def broadcast_post(self, post_data: Dict[str, Any]): |
| """ |
| Broadcast new agent post to all subscribers. |
| |
| Shortcut for publish() with post-specific topics. |
| """ |
| topics = ["global", f"agent:{post_data['sender_id']}"] |
|
|
| |
| |
| if post_data.get("post_type") == "alert": |
| topics.append("alerts") |
| elif post_data.get("post_type") == "question": |
| if post_data.get("sender_category"): |
| topics.append(f"category:{post_data['sender_category']}") |
|
|
| await self.publish({"type": "agent_post", "data": post_data}, topics) |
|
|
| async def subscribe_to_redis(self): |
| """ |
| Subscribe to Redis pub/sub for cross-instance events. |
| |
| Call this on startup if REDIS_URL is configured. |
| Background task listens for Redis messages and broadcasts locally. |
| """ |
| if not self._redis_enabled: |
| logger.info("Redis pub/sub not enabled, skipping subscription") |
| return |
|
|
| await self._ensure_redis() |
| if not self._pubsub: |
| logger.warning("Redis pubsub not initialized, skipping subscription") |
| return |
|
|
| |
| await self._pubsub.psubscribe("agent_events:*") |
|
|
| async def redis_listener(): |
| """Background task: Listen for Redis messages and broadcast locally.""" |
| try: |
| async for message in self._pubsub.listen(): |
| if message['type'] == 'pmessage': |
| try: |
| data = json.loads(message['data']) |
| event = data['event'] |
| topics = data['topics'] |
|
|
| |
| |
| subscriber_ids = set() |
| for topic in topics: |
| if topic in self._topics: |
| subscriber_ids.update(self._topics[topic]) |
|
|
| for agent_id in subscriber_ids: |
| if agent_id in self._subscribers: |
| for websocket in self._subscribers[agent_id]: |
| try: |
| await websocket.send_json(event) |
| except Exception as e: |
| logger.warning(f"Failed to send Redis event to agent {agent_id}: {e}") |
| await self.unsubscribe(agent_id, websocket) |
|
|
| logger.debug(f"Redis event broadcast to {len(subscriber_ids)} local subscribers") |
| except Exception as e: |
| logger.warning(f"Redis message processing failed: {e}") |
| except asyncio.CancelledError: |
| logger.info("Redis listener task cancelled") |
| except Exception as e: |
| logger.error(f"Redis listener error: {e}") |
|
|
| |
| self._redis_listener_task = asyncio.create_task(redis_listener()) |
| logger.info("Redis pub/sub listener started") |
|
|
| async def close_redis(self): |
| """Close Redis connection.""" |
| if self._redis_listener_task: |
| self._redis_listener_task.cancel() |
| try: |
| await self._redis_listener_task |
| except asyncio.CancelledError: |
| pass |
|
|
| if self._pubsub: |
| await self._pubsub.close() |
| if self._redis: |
| await self._redis.close() |
| logger.info("Redis connection closed") |
|
|
|
|
| |
| agent_event_bus = AgentEventBus() |
|
|