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.
- ironshell/__init__.py +682 -0
- ironshell/cloud.py +967 -0
- ironshell/distributed.py +691 -0
- ironshell/langchain.py +1074 -0
- ironshell/metrics.py +603 -0
- ironshell/py.typed +0 -0
- ironshell/workflows.py +539 -0
- ironshell-1.5.1.dist-info/METADATA +349 -0
- ironshell-1.5.1.dist-info/RECORD +11 -0
- ironshell-1.5.1.dist-info/WHEEL +5 -0
- ironshell-1.5.1.dist-info/top_level.txt +1 -0
ironshell/distributed.py
ADDED
|
@@ -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
|
+
}
|