codetether 1.2.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2a_server/__init__.py +29 -0
- a2a_server/a2a_agent_card.py +365 -0
- a2a_server/a2a_errors.py +1133 -0
- a2a_server/a2a_executor.py +926 -0
- a2a_server/a2a_router.py +1033 -0
- a2a_server/a2a_types.py +344 -0
- a2a_server/agent_card.py +408 -0
- a2a_server/agents_server.py +271 -0
- a2a_server/auth_api.py +349 -0
- a2a_server/billing_api.py +638 -0
- a2a_server/billing_service.py +712 -0
- a2a_server/billing_webhooks.py +501 -0
- a2a_server/config.py +96 -0
- a2a_server/database.py +2165 -0
- a2a_server/email_inbound.py +398 -0
- a2a_server/email_notifications.py +486 -0
- a2a_server/enhanced_agents.py +919 -0
- a2a_server/enhanced_server.py +160 -0
- a2a_server/hosted_worker.py +1049 -0
- a2a_server/integrated_agents_server.py +347 -0
- a2a_server/keycloak_auth.py +750 -0
- a2a_server/livekit_bridge.py +439 -0
- a2a_server/marketing_tools.py +1364 -0
- a2a_server/mcp_client.py +196 -0
- a2a_server/mcp_http_server.py +2256 -0
- a2a_server/mcp_server.py +191 -0
- a2a_server/message_broker.py +725 -0
- a2a_server/mock_mcp.py +273 -0
- a2a_server/models.py +494 -0
- a2a_server/monitor_api.py +5904 -0
- a2a_server/opencode_bridge.py +1594 -0
- a2a_server/redis_task_manager.py +518 -0
- a2a_server/server.py +726 -0
- a2a_server/task_manager.py +668 -0
- a2a_server/task_queue.py +742 -0
- a2a_server/tenant_api.py +333 -0
- a2a_server/tenant_middleware.py +219 -0
- a2a_server/tenant_service.py +760 -0
- a2a_server/user_auth.py +721 -0
- a2a_server/vault_client.py +576 -0
- a2a_server/worker_sse.py +873 -0
- agent_worker/__init__.py +8 -0
- agent_worker/worker.py +4877 -0
- codetether/__init__.py +10 -0
- codetether/__main__.py +4 -0
- codetether/cli.py +112 -0
- codetether/worker_cli.py +57 -0
- codetether-1.2.2.dist-info/METADATA +570 -0
- codetether-1.2.2.dist-info/RECORD +66 -0
- codetether-1.2.2.dist-info/WHEEL +5 -0
- codetether-1.2.2.dist-info/entry_points.txt +4 -0
- codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
- codetether-1.2.2.dist-info/top_level.txt +5 -0
- codetether_voice_agent/__init__.py +6 -0
- codetether_voice_agent/agent.py +445 -0
- codetether_voice_agent/codetether_mcp.py +345 -0
- codetether_voice_agent/config.py +16 -0
- codetether_voice_agent/functiongemma_caller.py +380 -0
- codetether_voice_agent/session_playback.py +247 -0
- codetether_voice_agent/tools/__init__.py +21 -0
- codetether_voice_agent/tools/definitions.py +135 -0
- codetether_voice_agent/tools/handlers.py +380 -0
- run_server.py +314 -0
- ui/monitor-tailwind.html +1790 -0
- ui/monitor.html +1775 -0
- ui/monitor.js +2662 -0
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message broker for real-time inter-agent communication.
|
|
3
|
+
|
|
4
|
+
Provides pub/sub messaging capabilities using Redis for agent discovery,
|
|
5
|
+
event streaming, and push notifications.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Dict, List, Optional, Callable, Any, Set
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
import redis.asyncio as redis_async
|
|
15
|
+
from redis.asyncio import Redis
|
|
16
|
+
|
|
17
|
+
from .models import AgentCard, Message, TaskStatusUpdateEvent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MessageBroker:
|
|
24
|
+
"""Redis-based message broker for A2A agent communication."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, redis_url: str = 'redis://localhost:6379'):
|
|
27
|
+
self.redis_url = redis_url
|
|
28
|
+
self.redis: Optional[Redis] = None
|
|
29
|
+
self.pub_redis: Optional[Redis] = None
|
|
30
|
+
self._subscribers: Dict[str, Set[Callable[[str, Any], None]]] = {}
|
|
31
|
+
self._subscription_tasks: Dict[str, asyncio.Task] = {}
|
|
32
|
+
self._running = False
|
|
33
|
+
|
|
34
|
+
async def start(self) -> None:
|
|
35
|
+
"""Start the message broker and connect to Redis."""
|
|
36
|
+
try:
|
|
37
|
+
# Create separate connections for pub/sub operations
|
|
38
|
+
self.redis = redis_async.from_url(self.redis_url)
|
|
39
|
+
self.pub_redis = redis_async.from_url(self.redis_url)
|
|
40
|
+
self._running = True
|
|
41
|
+
logger.info(
|
|
42
|
+
f'Message broker connected to Redis at {self.redis_url}'
|
|
43
|
+
)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
logger.error(f'Failed to connect to Redis: {e}')
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
async def stop(self) -> None:
|
|
49
|
+
"""Stop the message broker and close connections."""
|
|
50
|
+
self._running = False
|
|
51
|
+
|
|
52
|
+
# Cancel all subscription tasks
|
|
53
|
+
for task in self._subscription_tasks.values():
|
|
54
|
+
task.cancel()
|
|
55
|
+
|
|
56
|
+
# Wait for tasks to complete
|
|
57
|
+
if self._subscription_tasks:
|
|
58
|
+
await asyncio.gather(
|
|
59
|
+
*self._subscription_tasks.values(), return_exceptions=True
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Close Redis connections
|
|
63
|
+
if self.redis:
|
|
64
|
+
await self.redis.close()
|
|
65
|
+
if self.pub_redis:
|
|
66
|
+
await self.pub_redis.close()
|
|
67
|
+
|
|
68
|
+
logger.info('Message broker stopped')
|
|
69
|
+
|
|
70
|
+
async def register_agent(
|
|
71
|
+
self,
|
|
72
|
+
agent_card: AgentCard,
|
|
73
|
+
role: Optional[str] = None,
|
|
74
|
+
instance_id: Optional[str] = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Register an agent in the discovery registry.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
agent_card: The agent's card with name, description, url, capabilities
|
|
81
|
+
role: Optional routing role (e.g., "code-reviewer"). If the agent name
|
|
82
|
+
follows the pattern "role:instance", role is extracted automatically.
|
|
83
|
+
instance_id: Optional unique instance identifier for this agent.
|
|
84
|
+
"""
|
|
85
|
+
if not self.redis:
|
|
86
|
+
raise RuntimeError('Message broker not started')
|
|
87
|
+
|
|
88
|
+
# Extract role from name if not provided (pattern: "role:instance")
|
|
89
|
+
# This allows send_to_agent(role="code-reviewer") to route correctly
|
|
90
|
+
extracted_role = role
|
|
91
|
+
extracted_instance = instance_id
|
|
92
|
+
if ':' in agent_card.name and not role:
|
|
93
|
+
parts = agent_card.name.split(':', 1)
|
|
94
|
+
extracted_role = parts[0]
|
|
95
|
+
extracted_instance = parts[1] if len(parts) > 1 else instance_id
|
|
96
|
+
|
|
97
|
+
# Store agent card in registry
|
|
98
|
+
agent_key = f'agents:{agent_card.name}'
|
|
99
|
+
agent_data = agent_card.model_dump_json()
|
|
100
|
+
|
|
101
|
+
mapping = {
|
|
102
|
+
'card': agent_data,
|
|
103
|
+
'last_seen': datetime.utcnow().isoformat(),
|
|
104
|
+
'status': 'active',
|
|
105
|
+
}
|
|
106
|
+
# Store role and instance_id for discovery enrichment
|
|
107
|
+
if extracted_role:
|
|
108
|
+
mapping['role'] = extracted_role
|
|
109
|
+
if extracted_instance:
|
|
110
|
+
mapping['instance_id'] = extracted_instance
|
|
111
|
+
|
|
112
|
+
await self.redis.hset(agent_key, mapping=mapping)
|
|
113
|
+
|
|
114
|
+
# Add to agents set for discovery
|
|
115
|
+
await self.redis.sadd('agents:registry', agent_card.name)
|
|
116
|
+
|
|
117
|
+
# Also index by role for faster role-based lookups
|
|
118
|
+
if extracted_role:
|
|
119
|
+
await self.redis.sadd(
|
|
120
|
+
f'agents:role:{extracted_role}', agent_card.name
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Publish agent registration event
|
|
124
|
+
await self.publish_event(
|
|
125
|
+
'agent.registered',
|
|
126
|
+
{
|
|
127
|
+
'agent_name': agent_card.name,
|
|
128
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
logger.info(f'Registered agent: {agent_card.name}')
|
|
133
|
+
|
|
134
|
+
async def unregister_agent(self, agent_name: str) -> None:
|
|
135
|
+
"""Unregister an agent from the discovery registry."""
|
|
136
|
+
if not self.redis:
|
|
137
|
+
raise RuntimeError('Message broker not started')
|
|
138
|
+
|
|
139
|
+
agent_key = f'agents:{agent_name}'
|
|
140
|
+
|
|
141
|
+
# Mark as inactive
|
|
142
|
+
await self.redis.hset(agent_key, 'status', 'inactive')
|
|
143
|
+
|
|
144
|
+
# Remove from active agents set
|
|
145
|
+
await self.redis.srem('agents:registry', agent_name)
|
|
146
|
+
|
|
147
|
+
# Publish agent unregistration event
|
|
148
|
+
await self.publish_event(
|
|
149
|
+
'agent.unregistered',
|
|
150
|
+
{
|
|
151
|
+
'agent_name': agent_name,
|
|
152
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
logger.info(f'Unregistered agent: {agent_name}')
|
|
157
|
+
|
|
158
|
+
async def discover_agents(
|
|
159
|
+
self,
|
|
160
|
+
max_age_seconds: int = 120,
|
|
161
|
+
cleanup_stale: bool = True,
|
|
162
|
+
) -> List[Dict[str, Any]]:
|
|
163
|
+
"""
|
|
164
|
+
Discover all registered agents that are still alive.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
max_age_seconds: Filter out agents not seen within this many seconds.
|
|
168
|
+
Default 120s (2 minutes). Set to 0 to disable filtering.
|
|
169
|
+
cleanup_stale: If True, remove stale agents from registry (lazy cleanup).
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of dicts with agent info including:
|
|
173
|
+
- name: Full unique name (e.g., "code-reviewer:dev-vm:abc123")
|
|
174
|
+
- role: Routing role for send_to_agent (e.g., "code-reviewer")
|
|
175
|
+
- instance_id: Unique instance identifier (e.g., "dev-vm:abc123")
|
|
176
|
+
- description, url, capabilities: From AgentCard
|
|
177
|
+
- last_seen: ISO timestamp of last heartbeat
|
|
178
|
+
|
|
179
|
+
Note: send_to_agent expects the 'role' field for routing, not 'name'.
|
|
180
|
+
The 'name' field is the unique discovery identity for debugging.
|
|
181
|
+
"""
|
|
182
|
+
if not self.redis:
|
|
183
|
+
raise RuntimeError('Message broker not started')
|
|
184
|
+
|
|
185
|
+
agent_names = await self.redis.smembers('agents:registry')
|
|
186
|
+
agents = []
|
|
187
|
+
now = datetime.utcnow()
|
|
188
|
+
stale_agents = []
|
|
189
|
+
|
|
190
|
+
for agent_name in agent_names:
|
|
191
|
+
name_str = (
|
|
192
|
+
agent_name.decode()
|
|
193
|
+
if isinstance(agent_name, bytes)
|
|
194
|
+
else agent_name
|
|
195
|
+
)
|
|
196
|
+
agent_key = f'agents:{name_str}'
|
|
197
|
+
agent_hash = await self.redis.hgetall(agent_key)
|
|
198
|
+
|
|
199
|
+
if not agent_hash:
|
|
200
|
+
stale_agents.append(
|
|
201
|
+
name_str
|
|
202
|
+
) # Key exists in set but hash is gone
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Check TTL - filter out stale agents
|
|
206
|
+
last_seen_str = agent_hash.get(b'last_seen')
|
|
207
|
+
last_seen_iso = None
|
|
208
|
+
if last_seen_str:
|
|
209
|
+
try:
|
|
210
|
+
last_seen_decoded = (
|
|
211
|
+
last_seen_str.decode()
|
|
212
|
+
if isinstance(last_seen_str, bytes)
|
|
213
|
+
else last_seen_str
|
|
214
|
+
)
|
|
215
|
+
last_seen = datetime.fromisoformat(last_seen_decoded)
|
|
216
|
+
last_seen_iso = last_seen_decoded
|
|
217
|
+
age_seconds = (now - last_seen).total_seconds()
|
|
218
|
+
# Clock skew tolerance: treat future timestamps as "just seen"
|
|
219
|
+
# This handles worker clocks slightly ahead of server clock
|
|
220
|
+
if age_seconds < 0:
|
|
221
|
+
age_seconds = 0
|
|
222
|
+
if max_age_seconds > 0 and age_seconds > max_age_seconds:
|
|
223
|
+
stale_agents.append(name_str)
|
|
224
|
+
logger.debug(
|
|
225
|
+
f"Filtering stale agent '{name_str}' "
|
|
226
|
+
f'(last seen {age_seconds:.0f}s ago, max={max_age_seconds}s)'
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
except (ValueError, TypeError) as e:
|
|
230
|
+
logger.debug(
|
|
231
|
+
f'Could not parse last_seen for {name_str}: {e}'
|
|
232
|
+
)
|
|
233
|
+
# If we can't parse last_seen, include it (benefit of doubt)
|
|
234
|
+
|
|
235
|
+
# Extract role and instance_id
|
|
236
|
+
role = agent_hash.get(b'role')
|
|
237
|
+
instance_id = agent_hash.get(b'instance_id')
|
|
238
|
+
role_str = role.decode() if isinstance(role, bytes) else role
|
|
239
|
+
instance_str = (
|
|
240
|
+
instance_id.decode()
|
|
241
|
+
if isinstance(instance_id, bytes)
|
|
242
|
+
else instance_id
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
# Parse agent card
|
|
246
|
+
agent_data = agent_hash.get(b'card')
|
|
247
|
+
if agent_data:
|
|
248
|
+
try:
|
|
249
|
+
agent_card = AgentCard.model_validate_json(agent_data)
|
|
250
|
+
# Build enriched discovery response
|
|
251
|
+
agent_info = {
|
|
252
|
+
'name': name_str, # Unique discovery identity
|
|
253
|
+
'role': role_str or name_str.split(':')[0]
|
|
254
|
+
if ':' in name_str
|
|
255
|
+
else name_str,
|
|
256
|
+
'instance_id': instance_str,
|
|
257
|
+
'description': agent_card.description,
|
|
258
|
+
'url': agent_card.url,
|
|
259
|
+
'capabilities': agent_card.capabilities.model_dump()
|
|
260
|
+
if agent_card.capabilities
|
|
261
|
+
else {},
|
|
262
|
+
'last_seen': last_seen_iso,
|
|
263
|
+
}
|
|
264
|
+
agents.append(agent_info)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
logger.warning(
|
|
267
|
+
f'Failed to parse agent card for {name_str}: {e}'
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Lazy cleanup: remove stale agents from ALL indexes to prevent accumulation
|
|
271
|
+
if stale_agents and cleanup_stale:
|
|
272
|
+
logger.info(
|
|
273
|
+
f'Cleaning up {len(stale_agents)} stale agents from registry: {stale_agents}'
|
|
274
|
+
)
|
|
275
|
+
for stale_name in stale_agents:
|
|
276
|
+
try:
|
|
277
|
+
# 1. Remove from main registry set
|
|
278
|
+
await self.redis.srem('agents:registry', stale_name)
|
|
279
|
+
|
|
280
|
+
# 2. Get role from stored hash (more reliable than parsing name)
|
|
281
|
+
agent_key = f'agents:{stale_name}'
|
|
282
|
+
stored_role = await self.redis.hget(agent_key, 'role')
|
|
283
|
+
if stored_role:
|
|
284
|
+
role_str = (
|
|
285
|
+
stored_role.decode()
|
|
286
|
+
if isinstance(stored_role, bytes)
|
|
287
|
+
else stored_role
|
|
288
|
+
)
|
|
289
|
+
await self.redis.srem(
|
|
290
|
+
f'agents:role:{role_str}', stale_name
|
|
291
|
+
)
|
|
292
|
+
elif ':' in stale_name:
|
|
293
|
+
# Fallback: infer role from name pattern
|
|
294
|
+
role = stale_name.split(':')[0]
|
|
295
|
+
await self.redis.srem(f'agents:role:{role}', stale_name)
|
|
296
|
+
|
|
297
|
+
# 3. Mark hash as stale (keep for forensics, but mark inactive)
|
|
298
|
+
await self.redis.hset(
|
|
299
|
+
agent_key,
|
|
300
|
+
mapping={
|
|
301
|
+
'status': 'stale',
|
|
302
|
+
'stale_at': datetime.utcnow().isoformat(),
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
logger.debug(f'Cleaned up stale agent: {stale_name}')
|
|
307
|
+
except Exception as e:
|
|
308
|
+
logger.debug(
|
|
309
|
+
f'Failed to cleanup stale agent {stale_name}: {e}'
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
return agents
|
|
313
|
+
|
|
314
|
+
async def discover_agents_by_role(self, role: str) -> List[Dict[str, Any]]:
|
|
315
|
+
"""
|
|
316
|
+
Discover agents by their routing role.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
role: The routing role (e.g., "code-reviewer")
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
List of active agents with that role.
|
|
323
|
+
"""
|
|
324
|
+
if not self.redis:
|
|
325
|
+
raise RuntimeError('Message broker not started')
|
|
326
|
+
|
|
327
|
+
# Get agents indexed by role
|
|
328
|
+
role_members = await self.redis.smembers(f'agents:role:{role}')
|
|
329
|
+
if not role_members:
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
# Filter to only active ones
|
|
333
|
+
all_agents = await self.discover_agents()
|
|
334
|
+
return [a for a in all_agents if a.get('role') == role]
|
|
335
|
+
|
|
336
|
+
async def refresh_agent_heartbeat(self, agent_name: str) -> bool:
|
|
337
|
+
"""
|
|
338
|
+
Refresh the last_seen timestamp for an agent.
|
|
339
|
+
|
|
340
|
+
Call this periodically (e.g., every 30s) to keep the agent
|
|
341
|
+
visible in discovery. Agents not refreshed within max_age_seconds
|
|
342
|
+
will be filtered out of discover_agents results.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
agent_name: The agent's registered name
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
True if the agent exists and was refreshed, False otherwise.
|
|
349
|
+
"""
|
|
350
|
+
if not self.redis:
|
|
351
|
+
raise RuntimeError('Message broker not started')
|
|
352
|
+
|
|
353
|
+
agent_key = f'agents:{agent_name}'
|
|
354
|
+
|
|
355
|
+
# Check if agent exists
|
|
356
|
+
exists = await self.redis.exists(agent_key)
|
|
357
|
+
if not exists:
|
|
358
|
+
logger.debug(
|
|
359
|
+
f'Cannot refresh heartbeat for unknown agent: {agent_name}'
|
|
360
|
+
)
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
# Update last_seen
|
|
364
|
+
await self.redis.hset(
|
|
365
|
+
agent_key, 'last_seen', datetime.utcnow().isoformat()
|
|
366
|
+
)
|
|
367
|
+
logger.debug(f'Refreshed heartbeat for agent: {agent_name}')
|
|
368
|
+
return True
|
|
369
|
+
|
|
370
|
+
async def get_agent(self, agent_name: str) -> Optional[AgentCard]:
|
|
371
|
+
"""Get a specific agent's card."""
|
|
372
|
+
if not self.redis:
|
|
373
|
+
raise RuntimeError('Message broker not started')
|
|
374
|
+
|
|
375
|
+
agent_key = f'agents:{agent_name}'
|
|
376
|
+
agent_data = await self.redis.hget(agent_key, 'card')
|
|
377
|
+
|
|
378
|
+
if agent_data:
|
|
379
|
+
try:
|
|
380
|
+
return AgentCard.model_validate_json(agent_data)
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.warning(
|
|
383
|
+
f'Failed to parse agent card for {agent_name}: {e}'
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
async def publish_event(self, event_type: str, data: Any) -> None:
|
|
389
|
+
"""Publish an event to all subscribers."""
|
|
390
|
+
if not self.pub_redis:
|
|
391
|
+
raise RuntimeError('Message broker not started')
|
|
392
|
+
|
|
393
|
+
event_data = {
|
|
394
|
+
'type': event_type,
|
|
395
|
+
'data': data,
|
|
396
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# Publish to global events channel
|
|
400
|
+
await self.pub_redis.publish('events', json.dumps(event_data))
|
|
401
|
+
|
|
402
|
+
# Publish to event-specific channel
|
|
403
|
+
await self.pub_redis.publish(
|
|
404
|
+
f'events:{event_type}', json.dumps(event_data)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
async def publish(self, event_type: str, data: Any) -> None:
|
|
408
|
+
"""Alias for publish_event for compatibility."""
|
|
409
|
+
await self.publish_event(event_type, data)
|
|
410
|
+
|
|
411
|
+
async def publish_task_update(
|
|
412
|
+
self, agent_name: str, event: TaskStatusUpdateEvent
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Publish a task status update event."""
|
|
415
|
+
await self.publish_event(
|
|
416
|
+
'task.updated',
|
|
417
|
+
{
|
|
418
|
+
'agent_name': agent_name,
|
|
419
|
+
'task_id': event.task.id,
|
|
420
|
+
'status': event.task.status.value,
|
|
421
|
+
'final': event.final,
|
|
422
|
+
'timestamp': event.task.updated_at.isoformat(),
|
|
423
|
+
},
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
async def publish_message(
|
|
427
|
+
self, from_agent: str, to_agent: str, message: Message
|
|
428
|
+
) -> None:
|
|
429
|
+
"""Publish a message between agents."""
|
|
430
|
+
await self.publish_event(
|
|
431
|
+
'message.sent',
|
|
432
|
+
{
|
|
433
|
+
'from_agent': from_agent,
|
|
434
|
+
'to_agent': to_agent,
|
|
435
|
+
'message': message.model_dump(),
|
|
436
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
437
|
+
},
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
async def subscribe_to_events(
|
|
441
|
+
self, event_type: str, handler: Callable[[str, Any], None]
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Subscribe to events of a specific type."""
|
|
444
|
+
if not self._running:
|
|
445
|
+
raise RuntimeError('Message broker not started')
|
|
446
|
+
|
|
447
|
+
channel = f'events:{event_type}'
|
|
448
|
+
|
|
449
|
+
if channel not in self._subscribers:
|
|
450
|
+
self._subscribers[channel] = set()
|
|
451
|
+
# Start subscription task for this channel
|
|
452
|
+
task = asyncio.create_task(self._subscription_loop(channel))
|
|
453
|
+
self._subscription_tasks[channel] = task
|
|
454
|
+
|
|
455
|
+
self._subscribers[channel].add(handler)
|
|
456
|
+
logger.info(f'Subscribed to events: {event_type}')
|
|
457
|
+
|
|
458
|
+
async def unsubscribe_from_events(
|
|
459
|
+
self, event_type: str, handler: Callable[[str, Any], None]
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Unsubscribe from events of a specific type."""
|
|
462
|
+
channel = f'events:{event_type}'
|
|
463
|
+
|
|
464
|
+
if channel in self._subscribers:
|
|
465
|
+
self._subscribers[channel].discard(handler)
|
|
466
|
+
|
|
467
|
+
# If no more subscribers, cancel the subscription task
|
|
468
|
+
if not self._subscribers[channel]:
|
|
469
|
+
del self._subscribers[channel]
|
|
470
|
+
if channel in self._subscription_tasks:
|
|
471
|
+
self._subscription_tasks[channel].cancel()
|
|
472
|
+
del self._subscription_tasks[channel]
|
|
473
|
+
|
|
474
|
+
logger.info(f'Unsubscribed from events: {event_type}')
|
|
475
|
+
|
|
476
|
+
async def _subscription_loop(self, channel: str) -> None:
|
|
477
|
+
"""Handle subscriptions for a specific channel."""
|
|
478
|
+
if not self.redis:
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
pubsub = self.redis.pubsub()
|
|
483
|
+
await pubsub.subscribe(channel)
|
|
484
|
+
|
|
485
|
+
async for message in pubsub.listen():
|
|
486
|
+
if not self._running:
|
|
487
|
+
break
|
|
488
|
+
|
|
489
|
+
if message['type'] == 'message':
|
|
490
|
+
try:
|
|
491
|
+
event_data = json.loads(message['data'])
|
|
492
|
+
event_type = event_data.get('type', '')
|
|
493
|
+
data = event_data.get('data', {})
|
|
494
|
+
|
|
495
|
+
# Notify all handlers for this channel
|
|
496
|
+
handlers = self._subscribers.get(channel, set()).copy()
|
|
497
|
+
for handler in handlers:
|
|
498
|
+
try:
|
|
499
|
+
if asyncio.iscoroutinefunction(handler):
|
|
500
|
+
await handler(event_type, data)
|
|
501
|
+
else:
|
|
502
|
+
handler(event_type, data)
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logger.error(f'Error in event handler: {e}')
|
|
505
|
+
|
|
506
|
+
except json.JSONDecodeError as e:
|
|
507
|
+
logger.warning(f'Failed to decode event data: {e}')
|
|
508
|
+
|
|
509
|
+
except asyncio.CancelledError:
|
|
510
|
+
pass
|
|
511
|
+
except Exception as e:
|
|
512
|
+
logger.error(f'Error in subscription loop for {channel}: {e}')
|
|
513
|
+
finally:
|
|
514
|
+
try:
|
|
515
|
+
await pubsub.close()
|
|
516
|
+
except:
|
|
517
|
+
pass
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
class InMemoryMessageBroker:
|
|
521
|
+
"""In-memory message broker for testing and development."""
|
|
522
|
+
|
|
523
|
+
def __init__(self):
|
|
524
|
+
self._agents: Dict[str, AgentCard] = {}
|
|
525
|
+
self._agent_last_seen: Dict[
|
|
526
|
+
str, datetime
|
|
527
|
+
] = {} # Track last_seen for TTL filtering
|
|
528
|
+
self._agent_roles: Dict[str, str] = {} # name -> role mapping
|
|
529
|
+
self._agent_instance_ids: Dict[
|
|
530
|
+
str, str
|
|
531
|
+
] = {} # name -> instance_id mapping
|
|
532
|
+
self._subscribers: Dict[str, List[Callable[[str, Any], None]]] = {}
|
|
533
|
+
self._running = False
|
|
534
|
+
|
|
535
|
+
async def start(self) -> None:
|
|
536
|
+
"""Start the in-memory broker."""
|
|
537
|
+
self._running = True
|
|
538
|
+
logger.info('In-memory message broker started')
|
|
539
|
+
|
|
540
|
+
async def stop(self) -> None:
|
|
541
|
+
"""Stop the in-memory broker."""
|
|
542
|
+
self._running = False
|
|
543
|
+
self._subscribers.clear()
|
|
544
|
+
logger.info('In-memory message broker stopped')
|
|
545
|
+
|
|
546
|
+
async def register_agent(
|
|
547
|
+
self,
|
|
548
|
+
agent_card: AgentCard,
|
|
549
|
+
role: Optional[str] = None,
|
|
550
|
+
instance_id: Optional[str] = None,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Register an agent."""
|
|
553
|
+
self._agents[agent_card.name] = agent_card
|
|
554
|
+
self._agent_last_seen[agent_card.name] = datetime.utcnow()
|
|
555
|
+
|
|
556
|
+
# Extract role from name if not provided
|
|
557
|
+
if role:
|
|
558
|
+
self._agent_roles[agent_card.name] = role
|
|
559
|
+
elif ':' in agent_card.name:
|
|
560
|
+
self._agent_roles[agent_card.name] = agent_card.name.split(':')[0]
|
|
561
|
+
|
|
562
|
+
if instance_id:
|
|
563
|
+
self._agent_instance_ids[agent_card.name] = instance_id
|
|
564
|
+
elif ':' in agent_card.name:
|
|
565
|
+
parts = agent_card.name.split(':', 1)
|
|
566
|
+
if len(parts) > 1:
|
|
567
|
+
self._agent_instance_ids[agent_card.name] = parts[1]
|
|
568
|
+
|
|
569
|
+
await self.publish_event(
|
|
570
|
+
'agent.registered',
|
|
571
|
+
{
|
|
572
|
+
'agent_name': agent_card.name,
|
|
573
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
574
|
+
},
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
async def unregister_agent(self, agent_name: str) -> None:
|
|
578
|
+
"""Unregister an agent."""
|
|
579
|
+
self._agents.pop(agent_name, None)
|
|
580
|
+
self._agent_last_seen.pop(agent_name, None)
|
|
581
|
+
self._agent_roles.pop(agent_name, None)
|
|
582
|
+
self._agent_instance_ids.pop(agent_name, None)
|
|
583
|
+
await self.publish_event(
|
|
584
|
+
'agent.unregistered',
|
|
585
|
+
{
|
|
586
|
+
'agent_name': agent_name,
|
|
587
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
588
|
+
},
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
async def discover_agents(
|
|
592
|
+
self,
|
|
593
|
+
max_age_seconds: int = 120,
|
|
594
|
+
cleanup_stale: bool = True,
|
|
595
|
+
) -> List[Dict[str, Any]]:
|
|
596
|
+
"""
|
|
597
|
+
Discover all registered agents that are still alive.
|
|
598
|
+
|
|
599
|
+
Returns enriched agent info with role and instance_id.
|
|
600
|
+
"""
|
|
601
|
+
now = datetime.utcnow()
|
|
602
|
+
active_agents = []
|
|
603
|
+
stale_names = []
|
|
604
|
+
|
|
605
|
+
for name, card in list(self._agents.items()):
|
|
606
|
+
last_seen = self._agent_last_seen.get(name)
|
|
607
|
+
last_seen_iso = last_seen.isoformat() if last_seen else None
|
|
608
|
+
|
|
609
|
+
if max_age_seconds > 0 and last_seen:
|
|
610
|
+
age = (now - last_seen).total_seconds()
|
|
611
|
+
# Clock skew tolerance: treat future timestamps as "just seen"
|
|
612
|
+
if age < 0:
|
|
613
|
+
age = 0
|
|
614
|
+
if age > max_age_seconds:
|
|
615
|
+
stale_names.append(name)
|
|
616
|
+
continue
|
|
617
|
+
|
|
618
|
+
role = self._agent_roles.get(name)
|
|
619
|
+
if not role and ':' in name:
|
|
620
|
+
role = name.split(':')[0]
|
|
621
|
+
|
|
622
|
+
agent_info = {
|
|
623
|
+
'name': name,
|
|
624
|
+
'role': role or name,
|
|
625
|
+
'instance_id': self._agent_instance_ids.get(name),
|
|
626
|
+
'description': card.description,
|
|
627
|
+
'url': card.url,
|
|
628
|
+
'capabilities': card.capabilities.model_dump()
|
|
629
|
+
if card.capabilities
|
|
630
|
+
else {},
|
|
631
|
+
'last_seen': last_seen_iso,
|
|
632
|
+
}
|
|
633
|
+
active_agents.append(agent_info)
|
|
634
|
+
|
|
635
|
+
# Lazy cleanup
|
|
636
|
+
if stale_names and cleanup_stale:
|
|
637
|
+
for name in stale_names:
|
|
638
|
+
self._agents.pop(name, None)
|
|
639
|
+
self._agent_last_seen.pop(name, None)
|
|
640
|
+
self._agent_roles.pop(name, None)
|
|
641
|
+
self._agent_instance_ids.pop(name, None)
|
|
642
|
+
|
|
643
|
+
return active_agents
|
|
644
|
+
|
|
645
|
+
async def discover_agents_by_role(self, role: str) -> List[Dict[str, Any]]:
|
|
646
|
+
"""Discover agents by their routing role."""
|
|
647
|
+
all_agents = await self.discover_agents()
|
|
648
|
+
return [a for a in all_agents if a.get('role') == role]
|
|
649
|
+
|
|
650
|
+
async def refresh_agent_heartbeat(self, agent_name: str) -> bool:
|
|
651
|
+
"""Refresh the last_seen timestamp for an agent."""
|
|
652
|
+
if agent_name not in self._agents:
|
|
653
|
+
return False
|
|
654
|
+
self._agent_last_seen[agent_name] = datetime.utcnow()
|
|
655
|
+
return True
|
|
656
|
+
|
|
657
|
+
async def get_agent(self, agent_name: str) -> Optional[AgentCard]:
|
|
658
|
+
"""Get a specific agent's card."""
|
|
659
|
+
return self._agents.get(agent_name)
|
|
660
|
+
|
|
661
|
+
async def publish_event(self, event_type: str, data: Any) -> None:
|
|
662
|
+
"""Publish an event."""
|
|
663
|
+
if not self._running:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
# Notify subscribers
|
|
667
|
+
for handler in self._subscribers.get(event_type, []):
|
|
668
|
+
try:
|
|
669
|
+
if asyncio.iscoroutinefunction(handler):
|
|
670
|
+
await handler(event_type, data)
|
|
671
|
+
else:
|
|
672
|
+
handler(event_type, data)
|
|
673
|
+
except Exception as e:
|
|
674
|
+
logger.error(f'Error in event handler: {e}')
|
|
675
|
+
|
|
676
|
+
async def publish(self, event_type: str, data: Any) -> None:
|
|
677
|
+
"""Alias for publish_event for compatibility."""
|
|
678
|
+
await self.publish_event(event_type, data)
|
|
679
|
+
|
|
680
|
+
async def publish_task_update(
|
|
681
|
+
self, agent_name: str, event: TaskStatusUpdateEvent
|
|
682
|
+
) -> None:
|
|
683
|
+
"""Publish a task status update event."""
|
|
684
|
+
await self.publish_event(
|
|
685
|
+
'task.updated',
|
|
686
|
+
{
|
|
687
|
+
'agent_name': agent_name,
|
|
688
|
+
'task_id': event.task.id,
|
|
689
|
+
'status': event.task.status.value,
|
|
690
|
+
'final': event.final,
|
|
691
|
+
'timestamp': event.task.updated_at.isoformat(),
|
|
692
|
+
},
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
async def publish_message(
|
|
696
|
+
self, from_agent: str, to_agent: str, message: Message
|
|
697
|
+
) -> None:
|
|
698
|
+
"""Publish a message between agents."""
|
|
699
|
+
await self.publish_event(
|
|
700
|
+
'message.sent',
|
|
701
|
+
{
|
|
702
|
+
'from_agent': from_agent,
|
|
703
|
+
'to_agent': to_agent,
|
|
704
|
+
'message': message.model_dump(),
|
|
705
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
706
|
+
},
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
async def subscribe_to_events(
|
|
710
|
+
self, event_type: str, handler: Callable[[str, Any], None]
|
|
711
|
+
) -> None:
|
|
712
|
+
"""Subscribe to events."""
|
|
713
|
+
if event_type not in self._subscribers:
|
|
714
|
+
self._subscribers[event_type] = []
|
|
715
|
+
self._subscribers[event_type].append(handler)
|
|
716
|
+
|
|
717
|
+
async def unsubscribe_from_events(
|
|
718
|
+
self, event_type: str, handler: Callable[[str, Any], None]
|
|
719
|
+
) -> None:
|
|
720
|
+
"""Unsubscribe from events."""
|
|
721
|
+
if event_type in self._subscribers:
|
|
722
|
+
try:
|
|
723
|
+
self._subscribers[event_type].remove(handler)
|
|
724
|
+
except ValueError:
|
|
725
|
+
pass
|