mail-swarms 1.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. mail/__init__.py +35 -0
  2. mail/api.py +1964 -0
  3. mail/cli.py +432 -0
  4. mail/client.py +1657 -0
  5. mail/config/__init__.py +8 -0
  6. mail/config/client.py +87 -0
  7. mail/config/server.py +165 -0
  8. mail/core/__init__.py +72 -0
  9. mail/core/actions.py +69 -0
  10. mail/core/agents.py +73 -0
  11. mail/core/message.py +366 -0
  12. mail/core/runtime.py +3537 -0
  13. mail/core/tasks.py +311 -0
  14. mail/core/tools.py +1206 -0
  15. mail/db/__init__.py +0 -0
  16. mail/db/init.py +182 -0
  17. mail/db/types.py +65 -0
  18. mail/db/utils.py +523 -0
  19. mail/examples/__init__.py +27 -0
  20. mail/examples/analyst_dummy/__init__.py +15 -0
  21. mail/examples/analyst_dummy/agent.py +136 -0
  22. mail/examples/analyst_dummy/prompts.py +44 -0
  23. mail/examples/consultant_dummy/__init__.py +15 -0
  24. mail/examples/consultant_dummy/agent.py +136 -0
  25. mail/examples/consultant_dummy/prompts.py +42 -0
  26. mail/examples/data_analysis/__init__.py +40 -0
  27. mail/examples/data_analysis/analyst/__init__.py +9 -0
  28. mail/examples/data_analysis/analyst/agent.py +67 -0
  29. mail/examples/data_analysis/analyst/prompts.py +53 -0
  30. mail/examples/data_analysis/processor/__init__.py +13 -0
  31. mail/examples/data_analysis/processor/actions.py +293 -0
  32. mail/examples/data_analysis/processor/agent.py +67 -0
  33. mail/examples/data_analysis/processor/prompts.py +48 -0
  34. mail/examples/data_analysis/reporter/__init__.py +10 -0
  35. mail/examples/data_analysis/reporter/actions.py +187 -0
  36. mail/examples/data_analysis/reporter/agent.py +67 -0
  37. mail/examples/data_analysis/reporter/prompts.py +49 -0
  38. mail/examples/data_analysis/statistics/__init__.py +18 -0
  39. mail/examples/data_analysis/statistics/actions.py +343 -0
  40. mail/examples/data_analysis/statistics/agent.py +67 -0
  41. mail/examples/data_analysis/statistics/prompts.py +60 -0
  42. mail/examples/mafia/__init__.py +0 -0
  43. mail/examples/mafia/game.py +1537 -0
  44. mail/examples/mafia/narrator_tools.py +396 -0
  45. mail/examples/mafia/personas.py +240 -0
  46. mail/examples/mafia/prompts.py +489 -0
  47. mail/examples/mafia/roles.py +147 -0
  48. mail/examples/mafia/spec.md +350 -0
  49. mail/examples/math_dummy/__init__.py +23 -0
  50. mail/examples/math_dummy/actions.py +252 -0
  51. mail/examples/math_dummy/agent.py +136 -0
  52. mail/examples/math_dummy/prompts.py +46 -0
  53. mail/examples/math_dummy/types.py +5 -0
  54. mail/examples/research/__init__.py +39 -0
  55. mail/examples/research/researcher/__init__.py +9 -0
  56. mail/examples/research/researcher/agent.py +67 -0
  57. mail/examples/research/researcher/prompts.py +54 -0
  58. mail/examples/research/searcher/__init__.py +10 -0
  59. mail/examples/research/searcher/actions.py +324 -0
  60. mail/examples/research/searcher/agent.py +67 -0
  61. mail/examples/research/searcher/prompts.py +53 -0
  62. mail/examples/research/summarizer/__init__.py +18 -0
  63. mail/examples/research/summarizer/actions.py +255 -0
  64. mail/examples/research/summarizer/agent.py +67 -0
  65. mail/examples/research/summarizer/prompts.py +55 -0
  66. mail/examples/research/verifier/__init__.py +10 -0
  67. mail/examples/research/verifier/actions.py +337 -0
  68. mail/examples/research/verifier/agent.py +67 -0
  69. mail/examples/research/verifier/prompts.py +52 -0
  70. mail/examples/supervisor/__init__.py +11 -0
  71. mail/examples/supervisor/agent.py +4 -0
  72. mail/examples/supervisor/prompts.py +93 -0
  73. mail/examples/support/__init__.py +33 -0
  74. mail/examples/support/classifier/__init__.py +10 -0
  75. mail/examples/support/classifier/actions.py +307 -0
  76. mail/examples/support/classifier/agent.py +68 -0
  77. mail/examples/support/classifier/prompts.py +56 -0
  78. mail/examples/support/coordinator/__init__.py +9 -0
  79. mail/examples/support/coordinator/agent.py +67 -0
  80. mail/examples/support/coordinator/prompts.py +48 -0
  81. mail/examples/support/faq/__init__.py +10 -0
  82. mail/examples/support/faq/actions.py +182 -0
  83. mail/examples/support/faq/agent.py +67 -0
  84. mail/examples/support/faq/prompts.py +42 -0
  85. mail/examples/support/sentiment/__init__.py +15 -0
  86. mail/examples/support/sentiment/actions.py +341 -0
  87. mail/examples/support/sentiment/agent.py +67 -0
  88. mail/examples/support/sentiment/prompts.py +54 -0
  89. mail/examples/weather_dummy/__init__.py +23 -0
  90. mail/examples/weather_dummy/actions.py +75 -0
  91. mail/examples/weather_dummy/agent.py +136 -0
  92. mail/examples/weather_dummy/prompts.py +35 -0
  93. mail/examples/weather_dummy/types.py +5 -0
  94. mail/factories/__init__.py +27 -0
  95. mail/factories/action.py +223 -0
  96. mail/factories/base.py +1531 -0
  97. mail/factories/supervisor.py +241 -0
  98. mail/net/__init__.py +7 -0
  99. mail/net/registry.py +712 -0
  100. mail/net/router.py +728 -0
  101. mail/net/server_utils.py +114 -0
  102. mail/net/types.py +247 -0
  103. mail/server.py +1605 -0
  104. mail/stdlib/__init__.py +0 -0
  105. mail/stdlib/anthropic/__init__.py +0 -0
  106. mail/stdlib/fs/__init__.py +15 -0
  107. mail/stdlib/fs/actions.py +209 -0
  108. mail/stdlib/http/__init__.py +19 -0
  109. mail/stdlib/http/actions.py +333 -0
  110. mail/stdlib/interswarm/__init__.py +11 -0
  111. mail/stdlib/interswarm/actions.py +208 -0
  112. mail/stdlib/mcp/__init__.py +19 -0
  113. mail/stdlib/mcp/actions.py +294 -0
  114. mail/stdlib/openai/__init__.py +13 -0
  115. mail/stdlib/openai/agents.py +451 -0
  116. mail/summarizer.py +234 -0
  117. mail/swarms_json/__init__.py +27 -0
  118. mail/swarms_json/types.py +87 -0
  119. mail/swarms_json/utils.py +255 -0
  120. mail/url_scheme.py +51 -0
  121. mail/utils/__init__.py +53 -0
  122. mail/utils/auth.py +194 -0
  123. mail/utils/context.py +17 -0
  124. mail/utils/logger.py +73 -0
  125. mail/utils/openai.py +212 -0
  126. mail/utils/parsing.py +89 -0
  127. mail/utils/serialize.py +292 -0
  128. mail/utils/store.py +49 -0
  129. mail/utils/string_builder.py +119 -0
  130. mail/utils/version.py +20 -0
  131. mail_swarms-1.3.2.dist-info/METADATA +237 -0
  132. mail_swarms-1.3.2.dist-info/RECORD +137 -0
  133. mail_swarms-1.3.2.dist-info/WHEEL +4 -0
  134. mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
  135. mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
  136. mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
  137. mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
mail/net/router.py ADDED
@@ -0,0 +1,728 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ import datetime
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from collections.abc import AsyncIterator, Awaitable, Callable
9
+ from typing import Any, cast
10
+
11
+ import aiohttp
12
+
13
+ from mail.core.message import (
14
+ MAILAddress,
15
+ MAILInterswarmMessage,
16
+ MAILMessage,
17
+ MAILResponse,
18
+ create_agent_address,
19
+ format_agent_address,
20
+ parse_agent_address,
21
+ )
22
+
23
+ from .registry import SwarmRegistry
24
+
25
+ logger = logging.getLogger("mail.router")
26
+
27
+
28
+ StreamHandler = Callable[[str, str | None], Awaitable[None]]
29
+
30
+
31
+ class InterswarmRouter:
32
+ """
33
+ Router for handling interswarm message routing via HTTP.
34
+ """
35
+
36
+ def __init__(self, swarm_registry: SwarmRegistry, local_swarm_name: str):
37
+ self.swarm_registry = swarm_registry
38
+ self.local_swarm_name = local_swarm_name
39
+ self.session: aiohttp.ClientSession | None = None
40
+ self.message_handlers: dict[
41
+ str, Callable[[MAILInterswarmMessage], Awaitable[None]]
42
+ ] = {}
43
+
44
+ def _log_prelude(self) -> str:
45
+ """
46
+ Get the log prelude for the router.
47
+ """
48
+ return f"[[green]{self.local_swarm_name}[/green]@{self.swarm_registry.local_base_url}]"
49
+
50
+ async def start(self) -> None:
51
+ """
52
+ Start the interswarm router.
53
+ """
54
+ if self.session is None:
55
+ self.session = aiohttp.ClientSession()
56
+ logger.info(f"{self._log_prelude()} started interswarm router")
57
+
58
+ async def stop(self) -> None:
59
+ """
60
+ Stop the interswarm router.
61
+ """
62
+ if self.session:
63
+ await self.session.close()
64
+ self.session = None
65
+ logger.info(f"{self._log_prelude()} stopped interswarm router")
66
+
67
+ async def is_running(self) -> bool:
68
+ """
69
+ Check if the interswarm router is running.
70
+ """
71
+ return self.session is not None
72
+
73
+ def register_message_handler(
74
+ self,
75
+ message_type: str,
76
+ handler: Callable[[MAILInterswarmMessage], Awaitable[None]],
77
+ ) -> None:
78
+ """
79
+ Register a handler for a specific message type.
80
+ """
81
+ self.message_handlers[message_type] = handler
82
+ logger.info(
83
+ f"{self._log_prelude()} registered handler for message type: '{message_type}'"
84
+ )
85
+
86
+ def _convert_interswarm_message_to_local(
87
+ self,
88
+ message: MAILInterswarmMessage,
89
+ ) -> MAILMessage:
90
+ """
91
+ Convert an interswarm message (`MAILInterswarmMessage`) to a local message (`MAILMessage`).
92
+ """
93
+ return MAILMessage(
94
+ id=message["message_id"],
95
+ timestamp=message["timestamp"],
96
+ message=message["payload"],
97
+ msg_type=message["msg_type"],
98
+ )
99
+
100
+ def _resolve_auth_token_ref(self, auth_token_ref: str | None) -> str | None:
101
+ """
102
+ Resolve an auth token reference to an actual token.
103
+ """
104
+ if auth_token_ref is None:
105
+ return None
106
+ return self.swarm_registry.get_resolved_auth_token(auth_token_ref)
107
+
108
+ async def receive_interswarm_message_forward(
109
+ self,
110
+ message: MAILInterswarmMessage,
111
+ ) -> None:
112
+ """
113
+ Receive an interswarm message in the case of a new task.
114
+ """
115
+ # ensure this is the right target swarm
116
+ if message["target_swarm"] != self.local_swarm_name:
117
+ logger.error(
118
+ f"{self._log_prelude()} received interswarm message for wrong swarm: '{message['target_swarm']}'"
119
+ )
120
+ raise ValueError(
121
+ f"received interswarm message for wrong swarm: '{message['target_swarm']}'"
122
+ )
123
+
124
+ # attempt to post this message to the local swarm
125
+ try:
126
+ handler = self.message_handlers.get("local_message_handler")
127
+ if handler:
128
+ await handler(message)
129
+ else:
130
+ logger.warning(
131
+ f"{self._log_prelude()} no local message handler registered"
132
+ )
133
+ raise ValueError("no local message handler registered")
134
+ except Exception as e:
135
+ logger.error(
136
+ f"{self._log_prelude()} router failed to receive interswarm message forward: {e}"
137
+ )
138
+ raise ValueError(
139
+ f"router failed to receive interswarm message forward: {e}"
140
+ )
141
+
142
+ async def receive_interswarm_message_back(
143
+ self,
144
+ message: MAILInterswarmMessage,
145
+ ) -> None:
146
+ """
147
+ Receive an interswarm message in the case of a task resolution.
148
+ """
149
+ # ensure this is the right target swarm
150
+ if message["target_swarm"] != self.local_swarm_name:
151
+ logger.error(
152
+ f"{self._log_prelude()} received interswarm message for wrong swarm: '{message['target_swarm']}'"
153
+ )
154
+ raise ValueError(
155
+ f"received interswarm message for wrong swarm: '{message['target_swarm']}'"
156
+ )
157
+
158
+ # attempt to post this message to the local swarm
159
+ try:
160
+ handler = self.message_handlers.get("local_message_handler")
161
+ if handler:
162
+ await handler(message)
163
+ else:
164
+ logger.warning(
165
+ f"{self._log_prelude()} no local message handler registered"
166
+ )
167
+ raise ValueError("no local message handler registered")
168
+ except Exception as e:
169
+ logger.error(
170
+ f"{self._log_prelude()} router failed to receive interswarm message back: {e}"
171
+ )
172
+ raise ValueError(f"router failed to receive interswarm message back: {e}")
173
+
174
+ async def send_interswarm_message_forward(
175
+ self,
176
+ message: MAILInterswarmMessage,
177
+ ) -> None:
178
+ """
179
+ Send a message to a remote swarm in the case of a new task.
180
+ """
181
+ # ensure target swarm is reachable
182
+ endpoint = self.swarm_registry.get_swarm_endpoint(message["target_swarm"])
183
+ if not endpoint:
184
+ logger.error(
185
+ f"{self._log_prelude()} unknown swarm endpoint: '{message['target_swarm']}'"
186
+ )
187
+ raise ValueError(f"unknown swarm endpoint: '{message['target_swarm']}'")
188
+
189
+ # ensure the target swarm is active
190
+ if not endpoint["is_active"]:
191
+ logger.error(
192
+ f"{self._log_prelude()} swarm '{message['target_swarm']}' is not active"
193
+ )
194
+ raise ValueError(f"swarm '{message['target_swarm']}' is not active")
195
+
196
+ # ensure this session is open
197
+ if self.session is None:
198
+ logger.error(f"{self._log_prelude()} HTTP client session is not open")
199
+ raise ValueError("HTTP client session is not open")
200
+
201
+ # attempt to send this message to the remote swarm
202
+ try:
203
+ token = self._resolve_auth_token_ref(endpoint.get("swarm_name"))
204
+ if not token:
205
+ token = message.get("auth_token")
206
+ if not token:
207
+ raise ValueError(
208
+ f"authentication token missing for swarm '{message['target_swarm']}'"
209
+ )
210
+ async with self.session.post(
211
+ endpoint["base_url"] + "/interswarm/forward",
212
+ json={
213
+ "message": self._prep_message_for_interswarm(message),
214
+ },
215
+ headers={
216
+ "Content-Type": "application/json",
217
+ "User-Agent": f"MAIL-Interswarm-Router/{self.local_swarm_name}",
218
+ "Authorization": f"Bearer {token}",
219
+ },
220
+ ) as response:
221
+ if response.status != 200:
222
+ logger.error(
223
+ f"{self._log_prelude()} router failed to send interswarm message forward to swarm '{message['target_swarm']}': {response.status}"
224
+ )
225
+ raise ValueError(
226
+ f"router failed to send interswarm message forward to swarm '{message['target_swarm']}': HTTP status code {response.status}, reason '{response.reason}'"
227
+ )
228
+ else:
229
+ logger.info(
230
+ f"{self._log_prelude()} router successfully sent interswarm message forward to swarm '{message['target_swarm']}'"
231
+ )
232
+ return
233
+ except Exception as e:
234
+ logger.error(
235
+ f"{self._log_prelude()} router failed to send interswarm message forward: {e}"
236
+ )
237
+ raise ValueError(f"router failed to send interswarm message forward: {e}")
238
+
239
+ async def send_interswarm_message_back(
240
+ self,
241
+ message: MAILInterswarmMessage,
242
+ ) -> None:
243
+ """
244
+ Send a message to a remote swarm in the case of a task resolution.
245
+ """
246
+ # ensure target swarm is reachable
247
+ endpoint = self.swarm_registry.get_swarm_endpoint(message["target_swarm"])
248
+ if not endpoint:
249
+ logger.error(
250
+ f"{self._log_prelude()} unknown swarm endpoint: '{message['target_swarm']}'"
251
+ )
252
+ raise ValueError(f"unknown swarm endpoint: '{message['target_swarm']}'")
253
+
254
+ # ensure the target swarm is active
255
+ if not endpoint["is_active"]:
256
+ logger.error(
257
+ f"{self._log_prelude()} swarm '{message['target_swarm']}' is not active"
258
+ )
259
+ raise ValueError(f"swarm '{message['target_swarm']}' is not active")
260
+
261
+ # ensure this session is open
262
+ if self.session is None:
263
+ logger.error(f"{self._log_prelude()} HTTP client session is not open")
264
+ raise ValueError("HTTP client session is not open")
265
+
266
+ # attempt to send this message to the remote swarm
267
+ try:
268
+ token = self._resolve_auth_token_ref(endpoint.get("swarm_name"))
269
+ if not token:
270
+ token = message.get("auth_token")
271
+ if not token:
272
+ raise ValueError(
273
+ f"authentication token missing for swarm '{message['target_swarm']}'"
274
+ )
275
+ async with self.session.post(
276
+ endpoint["base_url"] + "/interswarm/back",
277
+ json={
278
+ "message": self._prep_message_for_interswarm(message),
279
+ },
280
+ headers={
281
+ "Content-Type": "application/json",
282
+ "User-Agent": f"MAIL-Interswarm-Router/{self.local_swarm_name}",
283
+ "Authorization": f"Bearer {token}",
284
+ },
285
+ ) as response:
286
+ if response.status != 200:
287
+ logger.error(
288
+ f"{self._log_prelude()} router failed to send interswarm message back to swarm '{message['target_swarm']}': {response.status}"
289
+ )
290
+ raise ValueError(
291
+ f"router failed to send interswarm message back to swarm '{message['target_swarm']}': HTTP status code {response.status}, reason '{response.reason}'"
292
+ )
293
+ else:
294
+ logger.info(
295
+ f"{self._log_prelude()} successfully sent interswarm message back to swarm '{message['target_swarm']}'"
296
+ )
297
+ return
298
+ except Exception as e:
299
+ logger.error(
300
+ f"{self._log_prelude()} router failed to send interswarm message back: {e}"
301
+ )
302
+ raise ValueError(f"router failed to send interswarm message back: {e}")
303
+
304
+ async def post_interswarm_user_message(
305
+ self,
306
+ message: MAILInterswarmMessage,
307
+ ) -> MAILMessage:
308
+ """
309
+ Post a message (from an admin or user) to a remote swarm.
310
+ """
311
+ # ensure target swarm is reachable
312
+ endpoint = self.swarm_registry.get_swarm_endpoint(message["target_swarm"])
313
+ if not endpoint:
314
+ logger.error(
315
+ f"{self._log_prelude()} unknown swarm endpoint: '{message['target_swarm']}'"
316
+ )
317
+ raise ValueError(f"unknown swarm endpoint: '{message['target_swarm']}'")
318
+
319
+ # ensure the target swarm is active
320
+ if not endpoint["is_active"]:
321
+ logger.error(
322
+ f"{self._log_prelude()} swarm '{message['target_swarm']}' is not active"
323
+ )
324
+ raise ValueError(f"swarm '{message['target_swarm']}' is not active")
325
+
326
+ # ensure this session is open
327
+ if self.session is None:
328
+ logger.error(f"{self._log_prelude()} HTTP client session is not open")
329
+ raise ValueError("HTTP client session is not open")
330
+
331
+ # attempt to post this message to the remote swarm
332
+ try:
333
+ auth_token = message.get("auth_token")
334
+ if not auth_token:
335
+ raise ValueError("user token is required for interswarm user messages")
336
+
337
+ payload = message.get("payload", {})
338
+ if not isinstance(payload, dict):
339
+ raise ValueError("invalid interswarm payload")
340
+
341
+ msg_type = message.get("msg_type")
342
+ if msg_type not in {"request", "broadcast"}:
343
+ raise ValueError(
344
+ f"msg_type '{msg_type}' is not supported for interswarm user messages"
345
+ )
346
+
347
+ subject = payload.get("subject") if isinstance(payload.get("subject"), str) else None
348
+ body = payload.get("body") if isinstance(payload.get("body"), str) else None
349
+ task_id = payload.get("task_id") if isinstance(payload.get("task_id"), str) else None
350
+ routing_info = payload.get("routing_info") if isinstance(payload.get("routing_info"), dict) else {}
351
+
352
+ if body is None:
353
+ raise ValueError("body is required for interswarm user messages")
354
+
355
+ targets: list[str] = []
356
+ if msg_type == "request":
357
+ recipient = payload.get("recipient")
358
+ if isinstance(recipient, dict):
359
+ address = recipient.get("address")
360
+ if isinstance(address, str):
361
+ targets = [address]
362
+ elif msg_type == "broadcast":
363
+ recipients = payload.get("recipients")
364
+ if isinstance(recipients, list):
365
+ for recipient in recipients:
366
+ if isinstance(recipient, dict):
367
+ address = recipient.get("address")
368
+ if isinstance(address, str):
369
+ targets.append(address)
370
+
371
+ if not targets:
372
+ raise ValueError("targets are required for interswarm user messages")
373
+
374
+ request_body: dict[str, Any] = {
375
+ "user_token": auth_token,
376
+ "body": body,
377
+ "targets": targets,
378
+ "msg_type": msg_type,
379
+ }
380
+ if subject is not None:
381
+ request_body["subject"] = subject
382
+ if task_id is not None:
383
+ request_body["task_id"] = task_id
384
+ if routing_info:
385
+ request_body["routing_info"] = routing_info
386
+ if "stream" in routing_info:
387
+ request_body["stream"] = bool(routing_info.get("stream"))
388
+ if "ignore_stream_pings" in routing_info:
389
+ request_body["ignore_stream_pings"] = bool(
390
+ routing_info.get("ignore_stream_pings")
391
+ )
392
+
393
+ async with self.session.post(
394
+ endpoint["base_url"] + "/interswarm/message",
395
+ json=request_body,
396
+ headers={
397
+ "Content-Type": "application/json",
398
+ "User-Agent": f"MAIL-Interswarm-Router/{self.local_swarm_name}",
399
+ "Authorization": f"Bearer {auth_token}",
400
+ },
401
+ ) as response:
402
+ if response.status != 200:
403
+ logger.error(
404
+ f"{self._log_prelude()} failed to post interswarm user message to swarm '{message['target_swarm']}': '{response.status}'"
405
+ )
406
+ raise ValueError(
407
+ f"failed to post interswarm user message to swarm '{message['target_swarm']}': HTTP status code '{response.status}', reason '{response.reason}'"
408
+ )
409
+ else:
410
+ logger.info(
411
+ f"{self._log_prelude()} successfully posted interswarm user message to swarm '{message['target_swarm']}'"
412
+ )
413
+ return cast(MAILMessage, await response.json())
414
+ except Exception as e:
415
+ logger.error(
416
+ f"{self._log_prelude()} error posting interswarm user message: {e}"
417
+ )
418
+ raise ValueError(f"error posting interswarm user message: {e}")
419
+
420
+ def _prep_message_for_interswarm(
421
+ self, message: MAILInterswarmMessage
422
+ ) -> MAILInterswarmMessage:
423
+ """
424
+ Ensure the sender follows the interswarm address format (agent@swarm).
425
+ """
426
+ payload = message["payload"]
427
+ sender_agent, sender_swarm = parse_agent_address(payload["sender"]["address"])
428
+ if sender_swarm != self.local_swarm_name:
429
+ payload["sender"] = format_agent_address(
430
+ sender_agent, self.local_swarm_name
431
+ )
432
+
433
+ return MAILInterswarmMessage(
434
+ message_id=message["message_id"],
435
+ source_swarm=message["source_swarm"],
436
+ target_swarm=message["target_swarm"],
437
+ timestamp=message["timestamp"],
438
+ payload=payload,
439
+ msg_type=message["msg_type"],
440
+ auth_token=message["auth_token"],
441
+ task_owner=message["task_owner"],
442
+ task_contributors=message["task_contributors"],
443
+ metadata=message["metadata"],
444
+ )
445
+
446
+ def convert_local_message_to_interswarm(
447
+ self,
448
+ message: MAILMessage,
449
+ task_owner: str,
450
+ task_contributors: list[str],
451
+ metadata: dict[str, Any] | None = None,
452
+ ) -> MAILInterswarmMessage:
453
+ """
454
+ Convert a local message (`MAILMessage`) to an interswarm message (`MAILInterswarmMessage`).
455
+ """
456
+ all_targets = self._get_target_swarms(message)
457
+ target_swarm = all_targets[0]
458
+ return MAILInterswarmMessage(
459
+ message_id=message["id"],
460
+ source_swarm=self.local_swarm_name,
461
+ target_swarm=target_swarm,
462
+ timestamp=message["timestamp"],
463
+ payload=message["message"],
464
+ msg_type=message["msg_type"], # type: ignore
465
+ auth_token=self.swarm_registry.get_resolved_auth_token(target_swarm),
466
+ task_owner=task_owner,
467
+ task_contributors=task_contributors,
468
+ metadata=metadata or {},
469
+ )
470
+
471
+ def _get_target_swarms(self, message: MAILMessage) -> list[str]:
472
+ """
473
+ Build the list of target swarms for a message.
474
+ """
475
+ targets = message["message"].get("recipients") or [
476
+ message["message"].get("recipient")
477
+ ]
478
+ assert isinstance(targets, list)
479
+ return [
480
+ cast(str, parse_agent_address(target["address"])[1])
481
+ for target in targets
482
+ if parse_agent_address(target["address"])[1] is not None
483
+ ]
484
+
485
+ def _create_local_message(
486
+ self, original_message: MAILMessage, local_recipients: list[str]
487
+ ) -> MAILMessage:
488
+ """
489
+ Create a local message from an original message with local recipients only.
490
+ """
491
+ msg_content = original_message["message"].copy()
492
+
493
+ if "recipients" in msg_content:
494
+ msg_content["recipients"] = [ # type: ignore
495
+ create_agent_address(agent) for agent in local_recipients
496
+ ]
497
+ elif "recipient" in msg_content:
498
+ # Convert single recipient to list for local routing
499
+ msg_content["recipients"] = [ # type: ignore
500
+ create_agent_address(agent) for agent in local_recipients
501
+ ]
502
+ del msg_content["recipient"] # type: ignore
503
+
504
+ return MAILMessage(
505
+ id=str(uuid.uuid4()),
506
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
507
+ message=msg_content,
508
+ msg_type=original_message["msg_type"],
509
+ )
510
+
511
+ async def _consume_stream(
512
+ self,
513
+ response: aiohttp.ClientResponse,
514
+ original_message: MAILMessage,
515
+ swarm_name: str,
516
+ *,
517
+ stream_handler: StreamHandler | None = None,
518
+ ignore_stream_pings: bool = False,
519
+ ) -> MAILMessage:
520
+ """
521
+ Consume an SSE response from a remote swarm and return the final MAILMessage.
522
+ """
523
+
524
+ final_message: MAILMessage | None = None
525
+ task_failed = False
526
+ failure_reason: str | None = None
527
+
528
+ async for event_name, payload in self._iter_sse(response):
529
+ if event_name == "ping" and ignore_stream_pings:
530
+ continue
531
+
532
+ if stream_handler is not None:
533
+ await stream_handler(event_name, payload)
534
+
535
+ if event_name == "new_message" and payload:
536
+ try:
537
+ data = json.loads(payload)
538
+ except json.JSONDecodeError:
539
+ logger.debug(
540
+ f"{self._log_prelude()} unable to parse streaming 'new_message' payload from swarm '{swarm_name}'"
541
+ )
542
+ continue
543
+
544
+ message_data = (
545
+ data.get("extra_data", {}).get("full_message")
546
+ if isinstance(data, dict)
547
+ else None
548
+ )
549
+
550
+ if isinstance(message_data, dict):
551
+ try:
552
+ candidate = cast(MAILMessage, message_data)
553
+ except TypeError:
554
+ logger.debug(
555
+ f"{self._log_prelude()} received non-conforming message in stream from '{swarm_name}'"
556
+ )
557
+ continue
558
+
559
+ task_id = (
560
+ candidate["message"].get("task_id")
561
+ if isinstance(candidate.get("message"), dict)
562
+ else None
563
+ )
564
+ original_task_id = (
565
+ original_message["message"].get("task_id")
566
+ if isinstance(original_message.get("message"), dict)
567
+ else None
568
+ )
569
+ if task_id and task_id == original_task_id:
570
+ final_message = candidate
571
+
572
+ elif event_name == "task_error":
573
+ task_failed = True
574
+ if payload:
575
+ try:
576
+ data = json.loads(payload)
577
+ failure_reason = (
578
+ data.get("response") if isinstance(data, dict) else None
579
+ )
580
+ except json.JSONDecodeError:
581
+ failure_reason = payload
582
+ break
583
+ elif event_name == "task_complete":
584
+ break
585
+
586
+ if final_message is not None:
587
+ return final_message
588
+
589
+ if task_failed:
590
+ reason = failure_reason or "remote task reported an error"
591
+ return self._system_router_message(original_message, reason)
592
+
593
+ logger.error(
594
+ f"{self._log_prelude()} streamed interswarm response from '{swarm_name}' ended without delivering a final message",
595
+ )
596
+ return self._system_router_message(
597
+ original_message,
598
+ "stream ended before a final response was received",
599
+ )
600
+
601
+ async def _iter_sse(
602
+ self, response: aiohttp.ClientResponse
603
+ ) -> AsyncIterator[tuple[str, str | None]]:
604
+ """
605
+ Yield (event, data) tuples from an SSE response.
606
+ """
607
+
608
+ event_name = "message"
609
+ data_lines: list[str] = []
610
+
611
+ async for raw_line in response.content:
612
+ line = raw_line.decode("utf-8", errors="ignore").rstrip("\n")
613
+ if line.endswith("\r"):
614
+ line = line[:-1]
615
+
616
+ if line == "":
617
+ if data_lines or event_name != "message":
618
+ data = "\n".join(data_lines) if data_lines else None
619
+ yield event_name, data
620
+ event_name = "message"
621
+ data_lines = []
622
+ continue
623
+
624
+ if line.startswith(":"):
625
+ continue
626
+
627
+ if line.startswith("event:"):
628
+ event_name = line[len("event:") :].strip() or "message"
629
+ elif line.startswith("data:"):
630
+ data_lines.append(line[len("data:") :].lstrip())
631
+
632
+ if data_lines or event_name != "message":
633
+ data = "\n".join(data_lines) if data_lines else None
634
+ yield event_name, data
635
+
636
+ def _create_remote_message(
637
+ self, original_message: MAILMessage, remote_agents: list[str], swarm_name: str
638
+ ) -> MAILMessage:
639
+ """
640
+ Create a remote message for a specific swarm.
641
+ """
642
+ msg_content = original_message["message"].copy()
643
+
644
+ # Update recipients to use full interswarm addresses
645
+ if "recipients" in msg_content:
646
+ msg_content["recipients"] = [ # type: ignore
647
+ format_agent_address(agent, swarm_name) for agent in remote_agents
648
+ ]
649
+ msg_content["recipient_swarms"] = [swarm_name] # type: ignore
650
+ elif "recipient" in msg_content:
651
+ # Convert to recipients list for remote routing
652
+ msg_content["recipients"] = [ # type: ignore
653
+ format_agent_address(agent, swarm_name) for agent in remote_agents
654
+ ]
655
+ msg_content["recipient_swarm"] = swarm_name # type: ignore
656
+ del msg_content["recipient"] # type: ignore
657
+
658
+ # Add swarm routing information
659
+ msg_content["sender_swarm"] = self.local_swarm_name
660
+
661
+ return MAILMessage(
662
+ id=str(uuid.uuid4()),
663
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
664
+ message=msg_content,
665
+ msg_type=original_message["msg_type"],
666
+ )
667
+
668
+ def _determine_message_type(self, payload: dict[str, Any]) -> str:
669
+ """
670
+ Determine the message type from the payload.
671
+ """
672
+ if "request_id" in payload and "recipient" in payload:
673
+ return "request"
674
+ elif "request_id" in payload and "sender" in payload:
675
+ return "response"
676
+ elif "broadcast_id" in payload:
677
+ return "broadcast"
678
+ elif "interrupt_id" in payload:
679
+ return "interrupt"
680
+ else:
681
+ return "unknown"
682
+
683
+ def get_routing_stats(self) -> dict[str, Any]:
684
+ """
685
+ Get routing statistics.
686
+ """
687
+ active_endpoints = self.swarm_registry.get_active_endpoints()
688
+ return {
689
+ "local_swarm_name": self.local_swarm_name,
690
+ "total_endpoints": len(self.swarm_registry.get_all_endpoints()),
691
+ "active_endpoints": len(active_endpoints),
692
+ "registered_handlers": list(self.message_handlers.keys()),
693
+ }
694
+
695
+ def _system_router_message(self, message: MAILMessage, reason: str) -> MAILMessage:
696
+ """
697
+ Create a system router message.
698
+ """
699
+ match message["msg_type"]:
700
+ case "request":
701
+ request_id = message["message"]["request_id"] # type: ignore
702
+ case "response":
703
+ request_id = message["message"]["request_id"] # type: ignore
704
+ case "broadcast":
705
+ request_id = message["message"]["broadcast_id"] # type: ignore
706
+ case "interrupt":
707
+ request_id = message["message"]["interrupt_id"] # type: ignore
708
+ case _:
709
+ raise ValueError(f"invalid message type: {message['msg_type']}")
710
+ return MAILMessage(
711
+ id=str(uuid.uuid4()),
712
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
713
+ message=MAILResponse(
714
+ task_id=message["message"]["task_id"],
715
+ request_id=request_id,
716
+ sender=MAILAddress(
717
+ address_type="system",
718
+ address=self.local_swarm_name,
719
+ ),
720
+ recipient=message["message"]["sender"], # type: ignore
721
+ subject="Router Error",
722
+ body=reason,
723
+ sender_swarm=self.local_swarm_name,
724
+ recipient_swarm=self.local_swarm_name,
725
+ routing_info={},
726
+ ),
727
+ msg_type="response",
728
+ )