intermesh 0.3.0__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.
- intermesh/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
intermesh/agent.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from ssl import SSLContext
|
|
6
|
+
from typing import Callable, Optional, List, Any, Union
|
|
7
|
+
|
|
8
|
+
import websockets
|
|
9
|
+
|
|
10
|
+
from intermesh.message import MessageType, InterMeshMessage
|
|
11
|
+
from intermesh.identity import AgentIdentity
|
|
12
|
+
from intermesh.task import InterMeshTask, TaskStatus
|
|
13
|
+
from intermesh.crypto import generate_keypair, get_public_key_pem, encrypt_for, decrypt_with
|
|
14
|
+
from intermesh.egress import EgressPolicy, apply_egress
|
|
15
|
+
from intermesh.schema import SchemaRegistry, default_registry
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InterMeshAgent:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
name: str,
|
|
22
|
+
org_id: str = "default",
|
|
23
|
+
api_key: Optional[str] = None,
|
|
24
|
+
capabilities: Optional[List[str]] = None,
|
|
25
|
+
roles: Optional[List[str]] = None,
|
|
26
|
+
permissions: Optional[List[str]] = None,
|
|
27
|
+
metadata: Optional[dict] = None,
|
|
28
|
+
hub_url: Union[str, List[str]] = "ws://localhost:8765",
|
|
29
|
+
encrypt: bool = True,
|
|
30
|
+
auto_reconnect: bool = False,
|
|
31
|
+
reconnect_backoff: float = 0.5,
|
|
32
|
+
reconnect_max_backoff: float = 15.0,
|
|
33
|
+
schema: Optional[str] = None,
|
|
34
|
+
schema_registry: Optional[SchemaRegistry] = None,
|
|
35
|
+
ssl: Optional[SSLContext] = None,
|
|
36
|
+
egress_policy: Optional[EgressPolicy] = None,
|
|
37
|
+
):
|
|
38
|
+
self.org_id = org_id
|
|
39
|
+
self.name = name
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self._hub_candidates: List[str] = [hub_url] if isinstance(hub_url, str) else list(hub_url)
|
|
42
|
+
self.hub_url = self._hub_candidates[0]
|
|
43
|
+
self.ws = None
|
|
44
|
+
self.token: Optional[str] = None
|
|
45
|
+
self.encrypt = encrypt
|
|
46
|
+
# Contexte TLS pour joindre un Hub en wss://. Utile lorsque le Hub
|
|
47
|
+
# présente un certificat d'une autorité privée, absente du magasin
|
|
48
|
+
# système : voir intermesh.peering.build_peer_ssl_context.
|
|
49
|
+
self._ssl = ssl
|
|
50
|
+
# Politique de sortie appliquée avant chiffrement : c'est le seul
|
|
51
|
+
# point du chemin nominal où le contenu est encore en clair.
|
|
52
|
+
self.egress_policy = egress_policy
|
|
53
|
+
self.auto_reconnect = auto_reconnect
|
|
54
|
+
self._reconnect_backoff = reconnect_backoff
|
|
55
|
+
self._reconnect_max_backoff = reconnect_max_backoff
|
|
56
|
+
self._closing = False
|
|
57
|
+
self._on_failover_handler: Optional[Callable] = None
|
|
58
|
+
self._message_handler: Optional[Callable] = None
|
|
59
|
+
self._request_handler: Optional[Callable] = None
|
|
60
|
+
self._task_handler: Optional[Callable] = None
|
|
61
|
+
self._pending_requests: dict[str, asyncio.Future] = {}
|
|
62
|
+
self._pending_tasks: dict[str, asyncio.Future] = {}
|
|
63
|
+
self._public_key_cache: dict[str, str] = {}
|
|
64
|
+
self.schema_registry = schema_registry or default_registry()
|
|
65
|
+
self._target_schema_cache: dict[str, Optional[str]] = {}
|
|
66
|
+
|
|
67
|
+
self._private_key = generate_keypair()
|
|
68
|
+
self._public_key_pem = get_public_key_pem(self._private_key)
|
|
69
|
+
|
|
70
|
+
self.identity = AgentIdentity(
|
|
71
|
+
name=name,
|
|
72
|
+
org_id=org_id,
|
|
73
|
+
capabilities=capabilities or [],
|
|
74
|
+
roles=roles or ["standard"],
|
|
75
|
+
permissions=permissions or [],
|
|
76
|
+
metadata=metadata or {},
|
|
77
|
+
public_key=self._public_key_pem,
|
|
78
|
+
schema=schema
|
|
79
|
+
)
|
|
80
|
+
self.qualified_name = self.identity.qualified_name
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_callable(cls, fn: Callable[[Any], Any], name: str, capabilities: Optional[List[str]] = None, **kwargs):
|
|
84
|
+
"""1-LINE INTEGRATION: Transforme n'importe quelle fonction Python en Agent InterMesh."""
|
|
85
|
+
from intermesh.adapters import from_callable as _from_callable
|
|
86
|
+
return _from_callable(fn=fn, name=name, capabilities=capabilities, **kwargs)
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def from_langchain(cls, chain_or_runnable: Any, name: str, capabilities: Optional[List[str]] = None, **kwargs):
|
|
90
|
+
"""1-LINE INTEGRATION: Transforme un Runnable LangChain / CrewAI en Agent InterMesh."""
|
|
91
|
+
from intermesh.adapters import from_langchain as _from_langchain
|
|
92
|
+
return _from_langchain(chain_or_runnable=chain_or_runnable, name=name, capabilities=capabilities, **kwargs)
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def from_command(cls, command, name: str, capabilities: Optional[List[str]] = None, **kwargs):
|
|
96
|
+
"""1-LINE INTEGRATION: n'importe quel exécutable, dans n'importe quel langage."""
|
|
97
|
+
from intermesh.bridge import from_command as _from_command
|
|
98
|
+
return _from_command(command=command, name=name, capabilities=capabilities, **kwargs)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_http(cls, url: str, name: str, capabilities: Optional[List[str]] = None, **kwargs):
|
|
102
|
+
"""1-LINE INTEGRATION: n'importe quel service HTTP déjà en ligne."""
|
|
103
|
+
from intermesh.bridge import from_http as _from_http
|
|
104
|
+
return _from_http(url=url, name=name, capabilities=capabilities, **kwargs)
|
|
105
|
+
|
|
106
|
+
def on_message(self, handler: Callable): self._message_handler = handler
|
|
107
|
+
def on_request(self, handler: Callable): self._request_handler = handler
|
|
108
|
+
def on_task(self, handler: Callable): self._task_handler = handler
|
|
109
|
+
|
|
110
|
+
def on_failover(self, handler: Callable):
|
|
111
|
+
"""
|
|
112
|
+
Appelé après une reconnexion automatique réussie sur un Hub différent
|
|
113
|
+
du précédent : `handler(old_url, new_url)`. N'a d'effet qu'avec
|
|
114
|
+
`auto_reconnect=True`.
|
|
115
|
+
"""
|
|
116
|
+
self._on_failover_handler = handler
|
|
117
|
+
|
|
118
|
+
async def _register_on(self, url: str) -> None:
|
|
119
|
+
"""Connexion + REGISTER sur une URL donnée. Lève sur refus ou échec réseau."""
|
|
120
|
+
ws = await websockets.connect(url, ssl=self._ssl)
|
|
121
|
+
reg_payload = self.identity.to_dict()
|
|
122
|
+
if self.api_key:
|
|
123
|
+
reg_payload["api_key"] = self.api_key
|
|
124
|
+
|
|
125
|
+
reg_msg = InterMeshMessage(type=MessageType.REGISTER, sender=self.name, content=reg_payload)
|
|
126
|
+
await ws.send(reg_msg.to_json())
|
|
127
|
+
res = InterMeshMessage.from_json(await ws.recv())
|
|
128
|
+
|
|
129
|
+
if res.type == MessageType.REGISTERED:
|
|
130
|
+
self.ws = ws
|
|
131
|
+
self.hub_url = url
|
|
132
|
+
self.token = res.content.get("token")
|
|
133
|
+
self.qualified_name = res.content.get("qualified_name", self.name)
|
|
134
|
+
if "roles" in res.content: self.identity.roles = res.content["roles"]
|
|
135
|
+
if "permissions" in res.content: self.identity.permissions = res.content["permissions"]
|
|
136
|
+
if "org_id" in res.content:
|
|
137
|
+
self.identity.org_id = res.content["org_id"]
|
|
138
|
+
self.org_id = res.content["org_id"]
|
|
139
|
+
print(f"✅ [{self.qualified_name}] Connecté sur {url} | E2E: {'🔒 ON' if self.encrypt else '🔓 OFF'}")
|
|
140
|
+
elif res.type == MessageType.ERROR:
|
|
141
|
+
print(f"❌ [{self.name}] Refusé : {res.content}")
|
|
142
|
+
await ws.close()
|
|
143
|
+
raise PermissionError(res.content)
|
|
144
|
+
|
|
145
|
+
async def connect(self):
|
|
146
|
+
"""
|
|
147
|
+
Connexion initiale. Avec plusieurs URLs de Hub, essaie chacune dans
|
|
148
|
+
l'ordre — un Hub injoignable (réseau) passe au suivant, un refus
|
|
149
|
+
explicite (`PermissionError`) est propagé tel quel : ce n'est pas un
|
|
150
|
+
problème de disponibilité, réessayer ailleurs ne changerait rien.
|
|
151
|
+
"""
|
|
152
|
+
last_exc: Optional[Exception] = None
|
|
153
|
+
for url in self._hub_candidates:
|
|
154
|
+
try:
|
|
155
|
+
await self._register_on(url)
|
|
156
|
+
break
|
|
157
|
+
except PermissionError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as exc:
|
|
160
|
+
last_exc = exc
|
|
161
|
+
continue
|
|
162
|
+
else:
|
|
163
|
+
raise ConnectionError(
|
|
164
|
+
f"Impossible de joindre un Hub parmi {self._hub_candidates} : {last_exc}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
asyncio.create_task(self._listen_loop())
|
|
168
|
+
|
|
169
|
+
async def serve_forever(self):
|
|
170
|
+
"""Se connecte et reste en service jusqu'à interruption.
|
|
171
|
+
|
|
172
|
+
Sans ça, une intégration « en une ligne » n'en est pas une : il
|
|
173
|
+
fallait encore écrire la boucle asyncio qui maintient l'agent en vie.
|
|
174
|
+
"""
|
|
175
|
+
await self.connect()
|
|
176
|
+
try:
|
|
177
|
+
await asyncio.Event().wait()
|
|
178
|
+
except asyncio.CancelledError:
|
|
179
|
+
pass
|
|
180
|
+
finally:
|
|
181
|
+
await self.close()
|
|
182
|
+
|
|
183
|
+
def run(self):
|
|
184
|
+
"""Version bloquante de `serve_forever`, pour un script sans asyncio.
|
|
185
|
+
|
|
186
|
+
InterMeshAgent.from_callable(ma_fonction, name="bot").run()
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
asyncio.run(self.serve_forever())
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
print(f"\n[{self.qualified_name}] arrêt demandé.")
|
|
192
|
+
|
|
193
|
+
async def close(self):
|
|
194
|
+
"""Déconnexion volontaire : n'entraîne jamais de reconnexion automatique."""
|
|
195
|
+
self._closing = True
|
|
196
|
+
if self.ws is not None:
|
|
197
|
+
await self.ws.close()
|
|
198
|
+
|
|
199
|
+
def _fail_pending(self, reason: str) -> None:
|
|
200
|
+
error = ConnectionError(reason)
|
|
201
|
+
for future in list(self._pending_requests.values()):
|
|
202
|
+
if not future.done():
|
|
203
|
+
future.set_exception(error)
|
|
204
|
+
self._pending_requests.clear()
|
|
205
|
+
for future in list(self._pending_tasks.values()):
|
|
206
|
+
if not future.done():
|
|
207
|
+
future.set_exception(error)
|
|
208
|
+
self._pending_tasks.clear()
|
|
209
|
+
|
|
210
|
+
async def _reconnect_loop(self):
|
|
211
|
+
"""
|
|
212
|
+
Tente de rejoindre un Hub (le même, ou un autre candidat de la liste)
|
|
213
|
+
avec un recul exponentiel, jusqu'à réussite ou fermeture volontaire.
|
|
214
|
+
|
|
215
|
+
Ceci suppose que les Hubs candidats sont des répliques opérationnelles
|
|
216
|
+
de la même organisation (même backend d'état partagé côté Hub) — ce
|
|
217
|
+
module ne fait pas d'élection de leader ni de promotion automatique
|
|
218
|
+
d'un Hub de secours : c'est un mécanisme de reconnexion côté client,
|
|
219
|
+
pas un orchestrateur de cluster.
|
|
220
|
+
"""
|
|
221
|
+
old_url = self.hub_url
|
|
222
|
+
attempt = 0
|
|
223
|
+
while not self._closing:
|
|
224
|
+
for url in self._hub_candidates:
|
|
225
|
+
try:
|
|
226
|
+
await self._register_on(url)
|
|
227
|
+
if self._on_failover_handler and url != old_url:
|
|
228
|
+
result = self._on_failover_handler(old_url, url)
|
|
229
|
+
if inspect.isawaitable(result):
|
|
230
|
+
await result
|
|
231
|
+
asyncio.create_task(self._listen_loop())
|
|
232
|
+
return
|
|
233
|
+
except PermissionError:
|
|
234
|
+
raise
|
|
235
|
+
except Exception:
|
|
236
|
+
continue
|
|
237
|
+
attempt += 1
|
|
238
|
+
backoff = min(self._reconnect_max_backoff, self._reconnect_backoff * (2 ** min(attempt, 5)))
|
|
239
|
+
await asyncio.sleep(backoff)
|
|
240
|
+
|
|
241
|
+
async def _fetch_public_key(self, agent_name: str) -> Optional[str]:
|
|
242
|
+
if agent_name in self._public_key_cache:
|
|
243
|
+
return self._public_key_cache[agent_name]
|
|
244
|
+
try:
|
|
245
|
+
data = await self.who_is(agent_name, timeout=3.0)
|
|
246
|
+
pk = data.get("public_key")
|
|
247
|
+
if pk:
|
|
248
|
+
self._public_key_cache[agent_name] = pk
|
|
249
|
+
return pk
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
async def _fetch_target_schema(self, agent_name: str) -> Optional[str]:
|
|
255
|
+
if agent_name in self._target_schema_cache:
|
|
256
|
+
return self._target_schema_cache[agent_name]
|
|
257
|
+
try:
|
|
258
|
+
data = await self.who_is(agent_name, timeout=3.0)
|
|
259
|
+
target_schema = data.get("schema") if isinstance(data, dict) else None
|
|
260
|
+
except Exception:
|
|
261
|
+
target_schema = None
|
|
262
|
+
self._target_schema_cache[agent_name] = target_schema
|
|
263
|
+
return target_schema
|
|
264
|
+
|
|
265
|
+
def _apply_egress(self, target: str, payload):
|
|
266
|
+
"""Filtre un contenu sortant s'il quitte l'organisation.
|
|
267
|
+
|
|
268
|
+
Les échanges internes ne sont pas filtrés : la politique décrit ce
|
|
269
|
+
qui ne doit pas franchir la frontière, pas ce que les départements
|
|
270
|
+
d'une même entreprise ont le droit de se dire.
|
|
271
|
+
"""
|
|
272
|
+
if self.egress_policy is None or self.egress_policy.is_empty:
|
|
273
|
+
return payload
|
|
274
|
+
target_org = target.split("/")[0] if target and "/" in target else "default"
|
|
275
|
+
if target_org == self.org_id:
|
|
276
|
+
return payload
|
|
277
|
+
|
|
278
|
+
filtered, triggered = apply_egress(payload, target_org, self.egress_policy)
|
|
279
|
+
if triggered:
|
|
280
|
+
print(f"🛡️ [{self.qualified_name}] Egress vers '{target_org}' : "
|
|
281
|
+
f"{', '.join(triggered)}")
|
|
282
|
+
return filtered
|
|
283
|
+
|
|
284
|
+
async def _translate_for(self, target: str, payload):
|
|
285
|
+
"""
|
|
286
|
+
Traduit `payload` vers le schéma déclaré par `target`, si on connaît
|
|
287
|
+
le nôtre et le sien et qu'ils diffèrent. Sans schéma déclaré des deux
|
|
288
|
+
côtés, ou si le payload n'est pas un dict, rien n'est modifié — la
|
|
289
|
+
traduction n'invente jamais de structure.
|
|
290
|
+
"""
|
|
291
|
+
if not self.identity.schema or not isinstance(payload, dict):
|
|
292
|
+
return payload
|
|
293
|
+
target_schema = await self._fetch_target_schema(target)
|
|
294
|
+
if not target_schema or target_schema == self.identity.schema:
|
|
295
|
+
return payload
|
|
296
|
+
try:
|
|
297
|
+
return self.schema_registry.translate(payload, target_schema, self.identity.schema)
|
|
298
|
+
except Exception:
|
|
299
|
+
return payload
|
|
300
|
+
|
|
301
|
+
def _encrypt_content(self, recipient_pk: str, content) -> str:
|
|
302
|
+
plaintext = json.dumps(content) if not isinstance(content, str) else content
|
|
303
|
+
return encrypt_for(recipient_pk, plaintext)
|
|
304
|
+
|
|
305
|
+
def _decrypt_content(self, encrypted_content: str):
|
|
306
|
+
try:
|
|
307
|
+
decrypted = decrypt_with(self._private_key, encrypted_content)
|
|
308
|
+
try: return json.loads(decrypted)
|
|
309
|
+
except json.JSONDecodeError: return decrypted
|
|
310
|
+
except Exception:
|
|
311
|
+
try: return json.loads(encrypted_content)
|
|
312
|
+
except json.JSONDecodeError: return encrypted_content
|
|
313
|
+
|
|
314
|
+
async def _listen_loop(self):
|
|
315
|
+
try:
|
|
316
|
+
async for raw in self.ws:
|
|
317
|
+
msg = InterMeshMessage.from_json(raw)
|
|
318
|
+
|
|
319
|
+
if msg.type in (MessageType.RESPONSE, MessageType.IDENTITY, MessageType.DISCOVER_RESULT, MessageType.ADMIN_RESULT) and msg.reply_to in self._pending_requests:
|
|
320
|
+
future = self._pending_requests.pop(msg.reply_to)
|
|
321
|
+
if not future.done():
|
|
322
|
+
content = msg.content
|
|
323
|
+
if self.encrypt and msg.type == MessageType.RESPONSE and isinstance(content, str):
|
|
324
|
+
content = self._decrypt_content(content)
|
|
325
|
+
future.set_result(content)
|
|
326
|
+
|
|
327
|
+
elif msg.type == MessageType.TASK_ASSIGN:
|
|
328
|
+
task = InterMeshTask.from_dict(msg.content)
|
|
329
|
+
asyncio.create_task(self._execute_assigned_task(task))
|
|
330
|
+
|
|
331
|
+
elif msg.type == MessageType.TASK_UPDATE:
|
|
332
|
+
task = InterMeshTask.from_dict(msg.content)
|
|
333
|
+
if task.task_id in self._pending_tasks:
|
|
334
|
+
future = self._pending_tasks[task.task_id]
|
|
335
|
+
if task.status == TaskStatus.COMPLETED:
|
|
336
|
+
self._pending_tasks.pop(task.task_id)
|
|
337
|
+
output = task.output_data
|
|
338
|
+
if self.encrypt and isinstance(output, str):
|
|
339
|
+
output = self._decrypt_content(output)
|
|
340
|
+
if not future.done(): future.set_result(output)
|
|
341
|
+
elif task.status == TaskStatus.FAILED:
|
|
342
|
+
self._pending_tasks.pop(task.task_id)
|
|
343
|
+
if not future.done(): future.set_exception(RuntimeError(task.error_message))
|
|
344
|
+
|
|
345
|
+
elif msg.type == MessageType.REQUEST:
|
|
346
|
+
content = msg.content
|
|
347
|
+
if self.encrypt and isinstance(content, str):
|
|
348
|
+
content = self._decrypt_content(content)
|
|
349
|
+
msg.content = content
|
|
350
|
+
if self._request_handler:
|
|
351
|
+
reply = await self._request_handler(msg) if inspect.iscoroutinefunction(self._request_handler) else self._request_handler(msg)
|
|
352
|
+
reply = self._apply_egress(msg.sender, reply)
|
|
353
|
+
if self.encrypt:
|
|
354
|
+
pk = await self._fetch_public_key(msg.sender)
|
|
355
|
+
if pk: reply = self._encrypt_content(pk, reply)
|
|
356
|
+
await self.ws.send(InterMeshMessage(type=MessageType.RESPONSE, sender=self.qualified_name, to=msg.sender, reply_to=msg.id, content=reply, token=self.token).to_json())
|
|
357
|
+
|
|
358
|
+
elif msg.type == MessageType.MESSAGE:
|
|
359
|
+
content = msg.content
|
|
360
|
+
if self.encrypt and isinstance(content, str): content = self._decrypt_content(content)
|
|
361
|
+
msg.content = content
|
|
362
|
+
if self._message_handler:
|
|
363
|
+
if inspect.iscoroutinefunction(self._message_handler): await self._message_handler(msg)
|
|
364
|
+
else: self._message_handler(msg)
|
|
365
|
+
|
|
366
|
+
elif msg.type == MessageType.ERROR:
|
|
367
|
+
print(f"❌ [{self.qualified_name}] {msg.content}")
|
|
368
|
+
if msg.reply_to and msg.reply_to in self._pending_requests:
|
|
369
|
+
future = self._pending_requests.pop(msg.reply_to)
|
|
370
|
+
if not future.done():
|
|
371
|
+
if isinstance(msg.content, str) and msg.content.startswith("ADMIN_DENIED"):
|
|
372
|
+
future.set_exception(PermissionError(msg.content))
|
|
373
|
+
else:
|
|
374
|
+
future.set_exception(RuntimeError(msg.content))
|
|
375
|
+
for tid, fut in list(self._pending_tasks.items()):
|
|
376
|
+
if not fut.done():
|
|
377
|
+
fut.set_exception(RuntimeError(msg.content))
|
|
378
|
+
self._pending_tasks.pop(tid)
|
|
379
|
+
|
|
380
|
+
except websockets.ConnectionClosed:
|
|
381
|
+
pass
|
|
382
|
+
finally:
|
|
383
|
+
# La boucle `async for` sort SANS exception sur une fermeture
|
|
384
|
+
# normale (code 1000/1001) — le nettoyage doit donc tourner ici,
|
|
385
|
+
# pas seulement dans le except, sinon une déconnexion propre
|
|
386
|
+
# laisse les appels en attente bloqués jusqu'à leur propre timeout.
|
|
387
|
+
print(f"⚠️ [{self.qualified_name}] Déconnecté.")
|
|
388
|
+
self._fail_pending("Connexion au Hub perdue.")
|
|
389
|
+
if self.auto_reconnect and not self._closing:
|
|
390
|
+
asyncio.create_task(self._reconnect_loop())
|
|
391
|
+
|
|
392
|
+
async def _execute_assigned_task(self, task: InterMeshTask):
|
|
393
|
+
print(f"⚙️ [{self.qualified_name}] Tâche: '{task.title}' de {task.orchestrator}")
|
|
394
|
+
task.update_status(TaskStatus.RUNNING)
|
|
395
|
+
await self.ws.send(InterMeshMessage(type=MessageType.TASK_UPDATE, sender=self.qualified_name, content=task.to_dict(), token=self.token).to_json())
|
|
396
|
+
|
|
397
|
+
decrypted_input = task.input_data
|
|
398
|
+
if self.encrypt and isinstance(decrypted_input, str):
|
|
399
|
+
decrypted_input = self._decrypt_content(decrypted_input)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
if self._task_handler:
|
|
403
|
+
output = await self._task_handler(decrypted_input, task) if inspect.iscoroutinefunction(self._task_handler) else self._task_handler(decrypted_input, task)
|
|
404
|
+
else:
|
|
405
|
+
raise NotImplementedError("Aucun handler.")
|
|
406
|
+
encrypted_output = await self._translate_for(task.orchestrator, output)
|
|
407
|
+
encrypted_output = self._apply_egress(task.orchestrator, encrypted_output)
|
|
408
|
+
if self.encrypt:
|
|
409
|
+
pk = await self._fetch_public_key(task.orchestrator)
|
|
410
|
+
if pk: encrypted_output = self._encrypt_content(pk, encrypted_output)
|
|
411
|
+
task.update_status(TaskStatus.COMPLETED, output_data=encrypted_output)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
task.update_status(TaskStatus.FAILED, error_message=str(e))
|
|
414
|
+
|
|
415
|
+
await self.ws.send(InterMeshMessage(type=MessageType.TASK_UPDATE, sender=self.qualified_name, content=task.to_dict(), token=self.token).to_json())
|
|
416
|
+
print(f"✅ [{self.qualified_name}] Tâche terminée: {task.status.value.upper()}")
|
|
417
|
+
|
|
418
|
+
async def send(self, to: str, content):
|
|
419
|
+
ec = await self._translate_for(to, content)
|
|
420
|
+
ec = self._apply_egress(to, ec)
|
|
421
|
+
if self.encrypt:
|
|
422
|
+
pk = await self._fetch_public_key(to)
|
|
423
|
+
if pk: ec = self._encrypt_content(pk, ec)
|
|
424
|
+
await self.ws.send(InterMeshMessage(type=MessageType.MESSAGE, sender=self.qualified_name, to=to, content=ec, token=self.token).to_json())
|
|
425
|
+
|
|
426
|
+
async def ask(self, to: str, content, timeout: float = 10.0):
|
|
427
|
+
ec = await self._translate_for(to, content)
|
|
428
|
+
ec = self._apply_egress(to, ec)
|
|
429
|
+
if self.encrypt:
|
|
430
|
+
pk = await self._fetch_public_key(to)
|
|
431
|
+
if pk: ec = self._encrypt_content(pk, ec)
|
|
432
|
+
msg = InterMeshMessage(type=MessageType.REQUEST, sender=self.qualified_name, to=to, content=ec, token=self.token)
|
|
433
|
+
loop = asyncio.get_running_loop()
|
|
434
|
+
future = loop.create_future()
|
|
435
|
+
self._pending_requests[msg.id] = future
|
|
436
|
+
await self.ws.send(msg.to_json())
|
|
437
|
+
try:
|
|
438
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
439
|
+
except asyncio.TimeoutError:
|
|
440
|
+
self._pending_requests.pop(msg.id, None)
|
|
441
|
+
raise TimeoutError(f"Timeout {timeout}s.")
|
|
442
|
+
|
|
443
|
+
async def submit_task(self, title: str, assignee: str, input_data, timeout: float = 15.0,
|
|
444
|
+
estimated_cost: float = 0.0, parent_task_id: Optional[str] = None,
|
|
445
|
+
escrow: Optional[dict] = None):
|
|
446
|
+
ei = await self._translate_for(assignee, input_data)
|
|
447
|
+
ei = self._apply_egress(assignee, ei)
|
|
448
|
+
if self.encrypt:
|
|
449
|
+
pk = await self._fetch_public_key(assignee)
|
|
450
|
+
if pk: ei = self._encrypt_content(pk, ei)
|
|
451
|
+
task = InterMeshTask(title=title, orchestrator=self.qualified_name, assignee=assignee, input_data=ei)
|
|
452
|
+
loop = asyncio.get_running_loop()
|
|
453
|
+
future = loop.create_future()
|
|
454
|
+
self._pending_tasks[task.task_id] = future
|
|
455
|
+
content = task.to_dict()
|
|
456
|
+
if estimated_cost:
|
|
457
|
+
content["estimated_cost"] = estimated_cost
|
|
458
|
+
if parent_task_id:
|
|
459
|
+
content["parent_task_id"] = parent_task_id
|
|
460
|
+
if escrow:
|
|
461
|
+
content["escrow"] = escrow
|
|
462
|
+
await self.ws.send(InterMeshMessage(type=MessageType.TASK_SUBMIT, sender=self.qualified_name, to=assignee, content=content, token=self.token).to_json())
|
|
463
|
+
print(f"📝 [{self.qualified_name}] Tâche ➜ {assignee}")
|
|
464
|
+
try:
|
|
465
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
466
|
+
except asyncio.TimeoutError:
|
|
467
|
+
self._pending_tasks.pop(task.task_id, None)
|
|
468
|
+
raise TimeoutError(f"Tâche '{title}' expirée ({timeout}s).")
|
|
469
|
+
|
|
470
|
+
async def who_is(self, agent_name: str, timeout: float = 5.0) -> dict:
|
|
471
|
+
msg = InterMeshMessage(type=MessageType.WHO_IS, sender=self.qualified_name, content=agent_name, token=self.token)
|
|
472
|
+
loop = asyncio.get_running_loop()
|
|
473
|
+
future = loop.create_future()
|
|
474
|
+
self._pending_requests[msg.id] = future
|
|
475
|
+
await self.ws.send(msg.to_json())
|
|
476
|
+
try:
|
|
477
|
+
result = await asyncio.wait_for(future, timeout=timeout)
|
|
478
|
+
if isinstance(result, dict) and result.get("public_key"):
|
|
479
|
+
self._public_key_cache[agent_name] = result["public_key"]
|
|
480
|
+
return result
|
|
481
|
+
except asyncio.TimeoutError:
|
|
482
|
+
self._pending_requests.pop(msg.id, None)
|
|
483
|
+
raise TimeoutError("Who_is timeout.")
|
|
484
|
+
|
|
485
|
+
async def discover(self, timeout: float = 5.0, **query) -> dict:
|
|
486
|
+
msg = InterMeshMessage(type=MessageType.DISCOVER, sender=self.qualified_name, content=query, token=self.token)
|
|
487
|
+
loop = asyncio.get_running_loop()
|
|
488
|
+
future = loop.create_future()
|
|
489
|
+
self._pending_requests[msg.id] = future
|
|
490
|
+
await self.ws.send(msg.to_json())
|
|
491
|
+
try:
|
|
492
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
493
|
+
except asyncio.TimeoutError:
|
|
494
|
+
self._pending_requests.pop(msg.id, None)
|
|
495
|
+
raise TimeoutError("Discover timeout.")
|
|
496
|
+
|
|
497
|
+
async def admin(self, command: str, **params) -> dict:
|
|
498
|
+
"""
|
|
499
|
+
Envoie une commande à la console d'administration du Hub.
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
PermissionError: la commande a été refusée par `authorize()`
|
|
503
|
+
(identité non authentifiée par clé d'API, ou rôle
|
|
504
|
+
insuffisant) — message préfixé par `ADMIN_DENIED:`.
|
|
505
|
+
RuntimeError: la commande a été exécutée mais refusée par le
|
|
506
|
+
gestionnaire lui-même (ex: hors du périmètre de l'org).
|
|
507
|
+
"""
|
|
508
|
+
msg = InterMeshMessage(
|
|
509
|
+
type=MessageType.ADMIN_REQUEST, sender=self.qualified_name,
|
|
510
|
+
content={"command": command, "params": params}, token=self.token,
|
|
511
|
+
)
|
|
512
|
+
loop = asyncio.get_running_loop()
|
|
513
|
+
future = loop.create_future()
|
|
514
|
+
self._pending_requests[msg.id] = future
|
|
515
|
+
await self.ws.send(msg.to_json())
|
|
516
|
+
try:
|
|
517
|
+
return await asyncio.wait_for(future, timeout=15.0)
|
|
518
|
+
except asyncio.TimeoutError:
|
|
519
|
+
self._pending_requests.pop(msg.id, None)
|
|
520
|
+
raise TimeoutError(f"Commande admin '{command}' expirée.")
|