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.
Files changed (66) hide show
  1. a2a_server/__init__.py +29 -0
  2. a2a_server/a2a_agent_card.py +365 -0
  3. a2a_server/a2a_errors.py +1133 -0
  4. a2a_server/a2a_executor.py +926 -0
  5. a2a_server/a2a_router.py +1033 -0
  6. a2a_server/a2a_types.py +344 -0
  7. a2a_server/agent_card.py +408 -0
  8. a2a_server/agents_server.py +271 -0
  9. a2a_server/auth_api.py +349 -0
  10. a2a_server/billing_api.py +638 -0
  11. a2a_server/billing_service.py +712 -0
  12. a2a_server/billing_webhooks.py +501 -0
  13. a2a_server/config.py +96 -0
  14. a2a_server/database.py +2165 -0
  15. a2a_server/email_inbound.py +398 -0
  16. a2a_server/email_notifications.py +486 -0
  17. a2a_server/enhanced_agents.py +919 -0
  18. a2a_server/enhanced_server.py +160 -0
  19. a2a_server/hosted_worker.py +1049 -0
  20. a2a_server/integrated_agents_server.py +347 -0
  21. a2a_server/keycloak_auth.py +750 -0
  22. a2a_server/livekit_bridge.py +439 -0
  23. a2a_server/marketing_tools.py +1364 -0
  24. a2a_server/mcp_client.py +196 -0
  25. a2a_server/mcp_http_server.py +2256 -0
  26. a2a_server/mcp_server.py +191 -0
  27. a2a_server/message_broker.py +725 -0
  28. a2a_server/mock_mcp.py +273 -0
  29. a2a_server/models.py +494 -0
  30. a2a_server/monitor_api.py +5904 -0
  31. a2a_server/opencode_bridge.py +1594 -0
  32. a2a_server/redis_task_manager.py +518 -0
  33. a2a_server/server.py +726 -0
  34. a2a_server/task_manager.py +668 -0
  35. a2a_server/task_queue.py +742 -0
  36. a2a_server/tenant_api.py +333 -0
  37. a2a_server/tenant_middleware.py +219 -0
  38. a2a_server/tenant_service.py +760 -0
  39. a2a_server/user_auth.py +721 -0
  40. a2a_server/vault_client.py +576 -0
  41. a2a_server/worker_sse.py +873 -0
  42. agent_worker/__init__.py +8 -0
  43. agent_worker/worker.py +4877 -0
  44. codetether/__init__.py +10 -0
  45. codetether/__main__.py +4 -0
  46. codetether/cli.py +112 -0
  47. codetether/worker_cli.py +57 -0
  48. codetether-1.2.2.dist-info/METADATA +570 -0
  49. codetether-1.2.2.dist-info/RECORD +66 -0
  50. codetether-1.2.2.dist-info/WHEEL +5 -0
  51. codetether-1.2.2.dist-info/entry_points.txt +4 -0
  52. codetether-1.2.2.dist-info/licenses/LICENSE +202 -0
  53. codetether-1.2.2.dist-info/top_level.txt +5 -0
  54. codetether_voice_agent/__init__.py +6 -0
  55. codetether_voice_agent/agent.py +445 -0
  56. codetether_voice_agent/codetether_mcp.py +345 -0
  57. codetether_voice_agent/config.py +16 -0
  58. codetether_voice_agent/functiongemma_caller.py +380 -0
  59. codetether_voice_agent/session_playback.py +247 -0
  60. codetether_voice_agent/tools/__init__.py +21 -0
  61. codetether_voice_agent/tools/definitions.py +135 -0
  62. codetether_voice_agent/tools/handlers.py +380 -0
  63. run_server.py +314 -0
  64. ui/monitor-tailwind.html +1790 -0
  65. ui/monitor.html +1775 -0
  66. 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