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/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.")