Coverage for agentos/tools/event_bus.py: 0%
112 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 23:53 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 23:53 +0800
1"""
2Event Bus / Pub-Sub for AgentOS.
4EventBus — in-process pub/sub with topic wildcards, async dispatch, and replay.
5supports: exact topic match, `*` single-level, `**` multi-level wildcards.
6"""
8import fnmatch
9import threading
10import time
11from collections import defaultdict
12from collections.abc import Callable
13from dataclasses import dataclass, field
14from typing import Any
16# ============================================================================
17# Event
18# ============================================================================
21@dataclass
22class Event:
23 """A published event with topic, payload, and metadata."""
25 topic: str
26 data: Any = None
27 timestamp: float = field(default_factory=time.time)
28 source: str = ""
31Subscriber = Callable[[Event], None]
32UnsubscribeHandle = Callable[[], None]
35# ============================================================================
36# EventBus
37# ============================================================================
40class EventBus:
41 """Thread-safe in-process pub/sub event bus.
43 Topics use dot-separated paths: 'agent.tool.call', 'system.shutdown'.
44 Wildcards: 'agent.*.call' (single level), 'agent.**' (multi-level).
45 """
47 def __init__(self):
48 self._subscribers: dict[str, list[Subscriber]] = defaultdict(list)
49 self._lock = threading.RLock()
50 self._history: list[Event] = []
51 self._max_history: int = 1000
52 self._total_published: int = 0
53 self._total_delivered: int = 0
54 self._running: bool = True
56 def subscribe(self, topic: str, callback: Subscriber) -> UnsubscribeHandle:
57 """Subscribe to a topic (supports wildcards). Returns unsubscribe handle."""
58 with self._lock:
59 self._subscribers[topic].append(callback)
60 # Return a closure that removes this specific callback
61 sub_list = self._subscribers[topic]
63 def unsubscribe():
64 with self._lock:
65 if callback in sub_list:
66 sub_list.remove(callback)
68 return unsubscribe
70 def unsubscribe(self, topic: str, callback: Subscriber) -> bool:
71 """Remove a specific subscriber. Returns True if found and removed."""
72 with self._lock:
73 subs = self._subscribers.get(topic)
74 if subs and callback in subs:
75 subs.remove(callback)
76 return True
77 return False
79 def publish(self, topic: str, data: Any = None, source: str = "") -> None:
80 """Publish an event to all matching subscribers synchronously."""
81 event = Event(topic=topic, data=data, source=source)
82 with self._lock:
83 self._history.append(event)
84 if len(self._history) > self._max_history:
85 self._history = self._history[-self._max_history :]
86 self._total_published += 1
88 if not self._running:
89 return
91 for sub_topic, callbacks in list(self._subscribers.items()):
92 if self._topic_match(sub_topic, topic):
93 for cb in callbacks[:]: # copy for safe iteration
94 try:
95 cb(event)
96 self._total_delivered += 1
97 except Exception:
98 pass
100 def _topic_match(self, pattern: str, topic: str) -> bool:
101 """Match a topic against a pattern with wildcard support.
102 - `*` matches a single level (dot-delimited).
103 - `**` matches zero or more levels.
104 """
105 if "**" in pattern or "*" in pattern:
106 return self._wildcard_match(pattern, topic)
107 return pattern == topic
109 def _wildcard_match(self, pattern: str, topic: str) -> bool:
110 """fnmatch-style glob matching on dot-delimited topic paths."""
111 return fnmatch.fnmatch(topic, pattern)
113 def get_history(self, limit: int = 100) -> list[Event]:
114 """Get recent published events."""
115 with self._lock:
116 return list(self._history[-limit:])
118 def clear_history(self) -> None:
119 with self._lock:
120 self._history.clear()
122 def stop(self) -> None:
123 """Stop delivering events (still records history)."""
124 with self._lock:
125 self._running = False
127 def start(self) -> None:
128 with self._lock:
129 self._running = True
131 def subscriber_count(self, topic: str | None = None) -> int:
132 """Count subscribers. If topic given, counts for that pattern only."""
133 with self._lock:
134 if topic:
135 return len(self._subscribers.get(topic, []))
136 return sum(len(v) for v in self._subscribers.values())
138 @property
139 def stats(self) -> dict[str, Any]:
140 with self._lock:
141 return {
142 "total_published": self._total_published,
143 "total_delivered": self._total_delivered,
144 "subscriber_count": self.subscriber_count(),
145 "history_size": len(self._history),
146 "topics": list(self._subscribers.keys()),
147 }
150# ============================================================================
151# TopicFilter
152# ============================================================================
155class TopicFilter:
156 """Pre-compiled topic filter chain for high-throughput event routing."""
158 def __init__(self):
159 self._filters: dict[str, Callable[[Event], bool]] = {}
160 self._lock = threading.Lock()
162 def add(self, name: str, predicate: Callable[[Event], bool]) -> None:
163 with self._lock:
164 self._filters[name] = predicate
166 def remove(self, name: str) -> bool:
167 with self._lock:
168 return self._filters.pop(name, None) is not None
170 def evaluate(self, event: Event) -> list[str]:
171 """Return names of all matching filters for this event."""
172 matches = []
173 with self._lock:
174 for name, pred in self._filters.items():
175 try:
176 if pred(event):
177 matches.append(name)
178 except Exception:
179 pass
180 return matches
183# ============================================================================
184# Global singleton
185# ============================================================================
187_default_bus: EventBus | None = None
188_default_lock = threading.Lock()
191def get_event_bus() -> EventBus:
192 """Get or create the global default EventBus."""
193 global _default_bus
194 if _default_bus is None:
195 with _default_lock:
196 if _default_bus is None:
197 _default_bus = EventBus()
198 return _default_bus