ironshell 1.5.1__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.
@@ -0,0 +1,691 @@
1
+ """
2
+ IronShell Distributed Agents for Python
3
+
4
+ Provides distributed agent coordination:
5
+ - Service discovery
6
+ - Leader election
7
+ - Agent routing
8
+ - Load balancing
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import json
16
+ import random
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timedelta
20
+ from enum import Enum
21
+ from typing import Any, Callable, Dict, List, Optional, Set
22
+ import threading
23
+
24
+ from . import IronShellRuntime, Agent
25
+
26
+ __all__ = [
27
+ "ServiceRegistry",
28
+ "ServiceInfo",
29
+ "LeaderElection",
30
+ "AgentRouter",
31
+ "LoadBalancer",
32
+ "LoadBalancerStrategy",
33
+ "DistributedSwarm",
34
+ ]
35
+
36
+
37
+ class LoadBalancerStrategy(Enum):
38
+ """Load balancing strategies"""
39
+ ROUND_ROBIN = "round_robin"
40
+ RANDOM = "random"
41
+ LEAST_CONNECTIONS = "least_connections"
42
+ CONSISTENT_HASH = "consistent_hash"
43
+ WEIGHTED = "weighted"
44
+
45
+
46
+ @dataclass
47
+ class ServiceInfo:
48
+ """Information about a registered service"""
49
+ service_id: str
50
+ name: str
51
+ host: str
52
+ port: int
53
+ metadata: Dict[str, Any] = field(default_factory=dict)
54
+ health_check_url: Optional[str] = None
55
+ last_heartbeat: Optional[datetime] = None
56
+ weight: int = 1
57
+ active_connections: int = 0
58
+
59
+ @property
60
+ def is_healthy(self) -> bool:
61
+ """Check if service is healthy (had heartbeat within 30s)"""
62
+ if self.last_heartbeat is None:
63
+ return False
64
+ return datetime.now() - self.last_heartbeat < timedelta(seconds=30)
65
+
66
+ @property
67
+ def address(self) -> str:
68
+ """Get service address"""
69
+ return f"{self.host}:{self.port}"
70
+
71
+ def to_dict(self) -> Dict[str, Any]:
72
+ """Convert to dictionary"""
73
+ return {
74
+ "service_id": self.service_id,
75
+ "name": self.name,
76
+ "host": self.host,
77
+ "port": self.port,
78
+ "metadata": self.metadata,
79
+ "health_check_url": self.health_check_url,
80
+ "last_heartbeat": self.last_heartbeat.isoformat() if self.last_heartbeat else None,
81
+ "weight": self.weight,
82
+ "active_connections": self.active_connections,
83
+ "is_healthy": self.is_healthy,
84
+ }
85
+
86
+
87
+ class ServiceRegistry:
88
+ """
89
+ Service registry for agent discovery.
90
+
91
+ Maintains a registry of available agents and their locations.
92
+ """
93
+
94
+ def __init__(self):
95
+ self._services: Dict[str, ServiceInfo] = {}
96
+ self._by_name: Dict[str, List[str]] = {}
97
+ self._lock = threading.Lock()
98
+ self._watchers: Dict[str, List[Callable[[ServiceInfo], None]]] = {}
99
+
100
+ def register(
101
+ self,
102
+ name: str,
103
+ host: str,
104
+ port: int,
105
+ service_id: Optional[str] = None,
106
+ metadata: Optional[Dict[str, Any]] = None,
107
+ health_check_url: Optional[str] = None,
108
+ weight: int = 1,
109
+ ) -> ServiceInfo:
110
+ """
111
+ Register a service.
112
+
113
+ Args:
114
+ name: Service name
115
+ host: Service host
116
+ port: Service port
117
+ service_id: Unique service ID (generated if not provided)
118
+ metadata: Additional metadata
119
+ health_check_url: Health check endpoint
120
+ weight: Load balancing weight
121
+
122
+ Returns:
123
+ ServiceInfo for the registered service
124
+ """
125
+ if service_id is None:
126
+ service_id = f"{name}-{host}-{port}-{int(time.time() * 1000)}"
127
+
128
+ service = ServiceInfo(
129
+ service_id=service_id,
130
+ name=name,
131
+ host=host,
132
+ port=port,
133
+ metadata=metadata or {},
134
+ health_check_url=health_check_url,
135
+ last_heartbeat=datetime.now(),
136
+ weight=weight,
137
+ )
138
+
139
+ with self._lock:
140
+ self._services[service_id] = service
141
+
142
+ if name not in self._by_name:
143
+ self._by_name[name] = []
144
+ if service_id not in self._by_name[name]:
145
+ self._by_name[name].append(service_id)
146
+
147
+ # Notify watchers
148
+ if name in self._watchers:
149
+ for callback in self._watchers[name]:
150
+ try:
151
+ callback(service)
152
+ except Exception:
153
+ pass
154
+
155
+ return service
156
+
157
+ def deregister(self, service_id: str) -> bool:
158
+ """
159
+ Deregister a service.
160
+
161
+ Args:
162
+ service_id: Service ID to remove
163
+
164
+ Returns:
165
+ True if service was removed
166
+ """
167
+ with self._lock:
168
+ if service_id not in self._services:
169
+ return False
170
+
171
+ service = self._services.pop(service_id)
172
+
173
+ if service.name in self._by_name:
174
+ self._by_name[service.name] = [
175
+ sid for sid in self._by_name[service.name]
176
+ if sid != service_id
177
+ ]
178
+
179
+ return True
180
+
181
+ def heartbeat(self, service_id: str) -> bool:
182
+ """
183
+ Update service heartbeat.
184
+
185
+ Args:
186
+ service_id: Service ID
187
+
188
+ Returns:
189
+ True if service exists
190
+ """
191
+ with self._lock:
192
+ if service_id in self._services:
193
+ self._services[service_id].last_heartbeat = datetime.now()
194
+ return True
195
+ return False
196
+
197
+ def get_service(self, service_id: str) -> Optional[ServiceInfo]:
198
+ """Get service by ID"""
199
+ return self._services.get(service_id)
200
+
201
+ def get_services_by_name(self, name: str, healthy_only: bool = True) -> List[ServiceInfo]:
202
+ """
203
+ Get all services with a given name.
204
+
205
+ Args:
206
+ name: Service name
207
+ healthy_only: Only return healthy services
208
+
209
+ Returns:
210
+ List of matching services
211
+ """
212
+ with self._lock:
213
+ if name not in self._by_name:
214
+ return []
215
+
216
+ services = [
217
+ self._services[sid]
218
+ for sid in self._by_name[name]
219
+ if sid in self._services
220
+ ]
221
+
222
+ if healthy_only:
223
+ services = [s for s in services if s.is_healthy]
224
+
225
+ return services
226
+
227
+ def get_all_services(self, healthy_only: bool = True) -> List[ServiceInfo]:
228
+ """Get all registered services"""
229
+ with self._lock:
230
+ services = list(self._services.values())
231
+ if healthy_only:
232
+ services = [s for s in services if s.is_healthy]
233
+ return services
234
+
235
+ def watch(self, name: str, callback: Callable[[ServiceInfo], None]) -> None:
236
+ """
237
+ Watch for changes to services with a given name.
238
+
239
+ Args:
240
+ name: Service name to watch
241
+ callback: Function called when service changes
242
+ """
243
+ with self._lock:
244
+ if name not in self._watchers:
245
+ self._watchers[name] = []
246
+ self._watchers[name].append(callback)
247
+
248
+ def unwatch(self, name: str, callback: Callable[[ServiceInfo], None]) -> None:
249
+ """Remove a watcher"""
250
+ with self._lock:
251
+ if name in self._watchers:
252
+ self._watchers[name] = [
253
+ cb for cb in self._watchers[name]
254
+ if cb != callback
255
+ ]
256
+
257
+ async def cleanup_stale_services(self, max_age_seconds: int = 60) -> int:
258
+ """
259
+ Remove services that haven't sent heartbeats.
260
+
261
+ Args:
262
+ max_age_seconds: Maximum age without heartbeat
263
+
264
+ Returns:
265
+ Number of services removed
266
+ """
267
+ cutoff = datetime.now() - timedelta(seconds=max_age_seconds)
268
+ removed = 0
269
+
270
+ with self._lock:
271
+ stale_ids = [
272
+ sid for sid, service in self._services.items()
273
+ if service.last_heartbeat and service.last_heartbeat < cutoff
274
+ ]
275
+
276
+ for sid in stale_ids:
277
+ if self.deregister(sid):
278
+ removed += 1
279
+
280
+ return removed
281
+
282
+
283
+ class LeaderElection:
284
+ """
285
+ Simple leader election for distributed coordination.
286
+
287
+ Uses a basic election algorithm where the lowest ID wins.
288
+ """
289
+
290
+ def __init__(
291
+ self,
292
+ node_id: str,
293
+ registry: ServiceRegistry,
294
+ election_group: str = "default",
295
+ ):
296
+ self.node_id = node_id
297
+ self.registry = registry
298
+ self.election_group = election_group
299
+ self._is_leader = False
300
+ self._current_leader: Optional[str] = None
301
+ self._lock = threading.Lock()
302
+ self._callbacks: List[Callable[[bool], None]] = []
303
+
304
+ @property
305
+ def is_leader(self) -> bool:
306
+ """Check if this node is the leader"""
307
+ return self._is_leader
308
+
309
+ @property
310
+ def current_leader(self) -> Optional[str]:
311
+ """Get current leader ID"""
312
+ return self._current_leader
313
+
314
+ def on_leadership_change(self, callback: Callable[[bool], None]) -> None:
315
+ """
316
+ Register callback for leadership changes.
317
+
318
+ Args:
319
+ callback: Function called with True when becoming leader
320
+ """
321
+ self._callbacks.append(callback)
322
+
323
+ async def run_election(self) -> str:
324
+ """
325
+ Run leader election.
326
+
327
+ Returns:
328
+ ID of the elected leader
329
+ """
330
+ # Get all nodes in election group
331
+ services = self.registry.get_services_by_name(self.election_group)
332
+
333
+ if not services:
334
+ # No other nodes, we're the leader
335
+ with self._lock:
336
+ was_leader = self._is_leader
337
+ self._is_leader = True
338
+ self._current_leader = self.node_id
339
+
340
+ if not was_leader:
341
+ for callback in self._callbacks:
342
+ try:
343
+ callback(True)
344
+ except Exception:
345
+ pass
346
+
347
+ return self.node_id
348
+
349
+ # Find lowest ID (simple election)
350
+ all_ids = [s.service_id for s in services]
351
+ all_ids.append(self.node_id)
352
+ leader_id = min(all_ids)
353
+
354
+ with self._lock:
355
+ was_leader = self._is_leader
356
+ self._is_leader = leader_id == self.node_id
357
+ self._current_leader = leader_id
358
+
359
+ if was_leader != self._is_leader:
360
+ for callback in self._callbacks:
361
+ try:
362
+ callback(self._is_leader)
363
+ except Exception:
364
+ pass
365
+
366
+ return leader_id
367
+
368
+ async def step_down(self) -> None:
369
+ """Voluntarily step down as leader"""
370
+ with self._lock:
371
+ was_leader = self._is_leader
372
+ self._is_leader = False
373
+
374
+ if was_leader:
375
+ for callback in self._callbacks:
376
+ try:
377
+ callback(False)
378
+ except Exception:
379
+ pass
380
+
381
+
382
+ class LoadBalancer:
383
+ """
384
+ Load balancer for distributing requests across services.
385
+ """
386
+
387
+ def __init__(
388
+ self,
389
+ registry: ServiceRegistry,
390
+ strategy: LoadBalancerStrategy = LoadBalancerStrategy.ROUND_ROBIN,
391
+ ):
392
+ self.registry = registry
393
+ self.strategy = strategy
394
+ self._round_robin_index: Dict[str, int] = {}
395
+ self._lock = threading.Lock()
396
+
397
+ def select_service(self, name: str, key: Optional[str] = None) -> Optional[ServiceInfo]:
398
+ """
399
+ Select a service instance using the configured strategy.
400
+
401
+ Args:
402
+ name: Service name
403
+ key: Optional key for consistent hashing
404
+
405
+ Returns:
406
+ Selected service or None
407
+ """
408
+ services = self.registry.get_services_by_name(name)
409
+
410
+ if not services:
411
+ return None
412
+
413
+ if len(services) == 1:
414
+ return services[0]
415
+
416
+ if self.strategy == LoadBalancerStrategy.ROUND_ROBIN:
417
+ return self._select_round_robin(name, services)
418
+ elif self.strategy == LoadBalancerStrategy.RANDOM:
419
+ return self._select_random(services)
420
+ elif self.strategy == LoadBalancerStrategy.LEAST_CONNECTIONS:
421
+ return self._select_least_connections(services)
422
+ elif self.strategy == LoadBalancerStrategy.CONSISTENT_HASH:
423
+ return self._select_consistent_hash(services, key or name)
424
+ elif self.strategy == LoadBalancerStrategy.WEIGHTED:
425
+ return self._select_weighted(services)
426
+
427
+ return services[0]
428
+
429
+ def _select_round_robin(self, name: str, services: List[ServiceInfo]) -> ServiceInfo:
430
+ """Round-robin selection"""
431
+ with self._lock:
432
+ idx = self._round_robin_index.get(name, 0)
433
+ self._round_robin_index[name] = (idx + 1) % len(services)
434
+ return services[idx % len(services)]
435
+
436
+ def _select_random(self, services: List[ServiceInfo]) -> ServiceInfo:
437
+ """Random selection"""
438
+ return random.choice(services)
439
+
440
+ def _select_least_connections(self, services: List[ServiceInfo]) -> ServiceInfo:
441
+ """Select service with fewest active connections"""
442
+ return min(services, key=lambda s: s.active_connections)
443
+
444
+ def _select_consistent_hash(self, services: List[ServiceInfo], key: str) -> ServiceInfo:
445
+ """Consistent hash selection"""
446
+ hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
447
+ idx = hash_val % len(services)
448
+ return services[idx]
449
+
450
+ def _select_weighted(self, services: List[ServiceInfo]) -> ServiceInfo:
451
+ """Weighted random selection"""
452
+ total_weight = sum(s.weight for s in services)
453
+ target = random.randint(1, total_weight)
454
+
455
+ cumulative = 0
456
+ for service in services:
457
+ cumulative += service.weight
458
+ if cumulative >= target:
459
+ return service
460
+
461
+ return services[-1]
462
+
463
+
464
+ class AgentRouter:
465
+ """
466
+ Routes agent requests to appropriate handlers.
467
+
468
+ Supports capability-based routing and load balancing.
469
+ """
470
+
471
+ def __init__(
472
+ self,
473
+ registry: ServiceRegistry,
474
+ load_balancer: Optional[LoadBalancer] = None,
475
+ ):
476
+ self.registry = registry
477
+ self.load_balancer = load_balancer or LoadBalancer(registry)
478
+ self._capability_map: Dict[str, Set[str]] = {}
479
+ self._lock = threading.Lock()
480
+
481
+ def register_capability(self, service_name: str, capability: str) -> None:
482
+ """
483
+ Register a capability for a service.
484
+
485
+ Args:
486
+ service_name: Name of the service
487
+ capability: Capability identifier
488
+ """
489
+ with self._lock:
490
+ if capability not in self._capability_map:
491
+ self._capability_map[capability] = set()
492
+ self._capability_map[capability].add(service_name)
493
+
494
+ def route_by_capability(self, capability: str) -> Optional[ServiceInfo]:
495
+ """
496
+ Find a service that provides a capability.
497
+
498
+ Args:
499
+ capability: Required capability
500
+
501
+ Returns:
502
+ Service that provides the capability
503
+ """
504
+ with self._lock:
505
+ service_names = self._capability_map.get(capability, set())
506
+
507
+ for name in service_names:
508
+ service = self.load_balancer.select_service(name)
509
+ if service:
510
+ return service
511
+
512
+ return None
513
+
514
+ def route_by_name(self, name: str, key: Optional[str] = None) -> Optional[ServiceInfo]:
515
+ """
516
+ Route to a service by name.
517
+
518
+ Args:
519
+ name: Service name
520
+ key: Optional routing key for consistent hashing
521
+
522
+ Returns:
523
+ Selected service instance
524
+ """
525
+ return self.load_balancer.select_service(name, key)
526
+
527
+ def get_capabilities(self, service_name: str) -> Set[str]:
528
+ """Get all capabilities for a service"""
529
+ with self._lock:
530
+ return {
531
+ cap for cap, services in self._capability_map.items()
532
+ if service_name in services
533
+ }
534
+
535
+
536
+ class DistributedSwarm:
537
+ """
538
+ Distributed swarm of agents across multiple nodes.
539
+
540
+ Coordinates agent execution across a cluster.
541
+ """
542
+
543
+ def __init__(
544
+ self,
545
+ name: str,
546
+ registry: ServiceRegistry,
547
+ runtime: Optional[IronShellRuntime] = None,
548
+ ):
549
+ self.name = name
550
+ self.registry = registry
551
+ self._runtime = runtime or IronShellRuntime()
552
+ self.router = AgentRouter(registry)
553
+ self._local_agents: Dict[str, Agent] = {}
554
+ self._lock = threading.Lock()
555
+
556
+ def add_local_agent(self, agent: Agent) -> None:
557
+ """
558
+ Add a locally-running agent.
559
+
560
+ Args:
561
+ agent: Agent instance
562
+ """
563
+ with self._lock:
564
+ self._local_agents[agent.name] = agent
565
+
566
+ def register_local_agents(
567
+ self,
568
+ host: str,
569
+ port: int,
570
+ capabilities: Optional[List[str]] = None,
571
+ ) -> List[ServiceInfo]:
572
+ """
573
+ Register local agents with the service registry.
574
+
575
+ Args:
576
+ host: Host address
577
+ port: Service port
578
+ capabilities: Agent capabilities
579
+
580
+ Returns:
581
+ List of registered services
582
+ """
583
+ services = []
584
+
585
+ with self._lock:
586
+ for agent_name, agent in self._local_agents.items():
587
+ service = self.registry.register(
588
+ name=f"{self.name}/{agent_name}",
589
+ host=host,
590
+ port=port,
591
+ metadata={"agent_name": agent_name, "model": agent.model},
592
+ )
593
+ services.append(service)
594
+
595
+ # Register capabilities
596
+ for cap in (capabilities or []):
597
+ self.router.register_capability(service.name, cap)
598
+
599
+ return services
600
+
601
+ async def dispatch(
602
+ self,
603
+ goal: str,
604
+ agent_name: Optional[str] = None,
605
+ capability: Optional[str] = None,
606
+ prefer_local: bool = True,
607
+ ) -> Dict[str, Any]:
608
+ """
609
+ Dispatch a task to an agent.
610
+
611
+ Args:
612
+ goal: Task goal
613
+ agent_name: Specific agent to use
614
+ capability: Required capability
615
+ prefer_local: Prefer local agents
616
+
617
+ Returns:
618
+ Agent response
619
+ """
620
+ # Try local agent first if preferred
621
+ if prefer_local and agent_name:
622
+ with self._lock:
623
+ if agent_name in self._local_agents:
624
+ return await self._local_agents[agent_name].run(goal)
625
+
626
+ # Find remote service
627
+ service: Optional[ServiceInfo] = None
628
+
629
+ if capability:
630
+ service = self.router.route_by_capability(capability)
631
+ elif agent_name:
632
+ service = self.router.route_by_name(f"{self.name}/{agent_name}")
633
+
634
+ if service:
635
+ # Would call remote service here
636
+ # For now, return a placeholder
637
+ return {
638
+ "status": "routed",
639
+ "service": service.to_dict(),
640
+ "goal": goal,
641
+ }
642
+
643
+ # Fallback to any local agent
644
+ with self._lock:
645
+ if self._local_agents:
646
+ agent = next(iter(self._local_agents.values()))
647
+ return await agent.run(goal)
648
+
649
+ return {"error": "No available agents"}
650
+
651
+ async def broadcast(self, goal: str) -> List[Dict[str, Any]]:
652
+ """
653
+ Broadcast a task to all agents.
654
+
655
+ Args:
656
+ goal: Task goal
657
+
658
+ Returns:
659
+ List of responses from all agents
660
+ """
661
+ tasks = []
662
+
663
+ # Local agents
664
+ with self._lock:
665
+ for agent in self._local_agents.values():
666
+ tasks.append(asyncio.create_task(agent.run(goal)))
667
+
668
+ # Would also dispatch to remote agents here
669
+
670
+ if tasks:
671
+ results = await asyncio.gather(*tasks, return_exceptions=True)
672
+ return [
673
+ r if not isinstance(r, Exception) else {"error": str(r)}
674
+ for r in results
675
+ ]
676
+
677
+ return []
678
+
679
+ def get_stats(self) -> Dict[str, Any]:
680
+ """Get swarm statistics"""
681
+ with self._lock:
682
+ local_count = len(self._local_agents)
683
+
684
+ remote_services = self.registry.get_services_by_name(self.name)
685
+
686
+ return {
687
+ "name": self.name,
688
+ "local_agents": local_count,
689
+ "remote_services": len(remote_services),
690
+ "total_agents": local_count + len(remote_services),
691
+ }