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.
- mail/__init__.py +35 -0
- mail/api.py +1964 -0
- mail/cli.py +432 -0
- mail/client.py +1657 -0
- mail/config/__init__.py +8 -0
- mail/config/client.py +87 -0
- mail/config/server.py +165 -0
- mail/core/__init__.py +72 -0
- mail/core/actions.py +69 -0
- mail/core/agents.py +73 -0
- mail/core/message.py +366 -0
- mail/core/runtime.py +3537 -0
- mail/core/tasks.py +311 -0
- mail/core/tools.py +1206 -0
- mail/db/__init__.py +0 -0
- mail/db/init.py +182 -0
- mail/db/types.py +65 -0
- mail/db/utils.py +523 -0
- mail/examples/__init__.py +27 -0
- mail/examples/analyst_dummy/__init__.py +15 -0
- mail/examples/analyst_dummy/agent.py +136 -0
- mail/examples/analyst_dummy/prompts.py +44 -0
- mail/examples/consultant_dummy/__init__.py +15 -0
- mail/examples/consultant_dummy/agent.py +136 -0
- mail/examples/consultant_dummy/prompts.py +42 -0
- mail/examples/data_analysis/__init__.py +40 -0
- mail/examples/data_analysis/analyst/__init__.py +9 -0
- mail/examples/data_analysis/analyst/agent.py +67 -0
- mail/examples/data_analysis/analyst/prompts.py +53 -0
- mail/examples/data_analysis/processor/__init__.py +13 -0
- mail/examples/data_analysis/processor/actions.py +293 -0
- mail/examples/data_analysis/processor/agent.py +67 -0
- mail/examples/data_analysis/processor/prompts.py +48 -0
- mail/examples/data_analysis/reporter/__init__.py +10 -0
- mail/examples/data_analysis/reporter/actions.py +187 -0
- mail/examples/data_analysis/reporter/agent.py +67 -0
- mail/examples/data_analysis/reporter/prompts.py +49 -0
- mail/examples/data_analysis/statistics/__init__.py +18 -0
- mail/examples/data_analysis/statistics/actions.py +343 -0
- mail/examples/data_analysis/statistics/agent.py +67 -0
- mail/examples/data_analysis/statistics/prompts.py +60 -0
- mail/examples/mafia/__init__.py +0 -0
- mail/examples/mafia/game.py +1537 -0
- mail/examples/mafia/narrator_tools.py +396 -0
- mail/examples/mafia/personas.py +240 -0
- mail/examples/mafia/prompts.py +489 -0
- mail/examples/mafia/roles.py +147 -0
- mail/examples/mafia/spec.md +350 -0
- mail/examples/math_dummy/__init__.py +23 -0
- mail/examples/math_dummy/actions.py +252 -0
- mail/examples/math_dummy/agent.py +136 -0
- mail/examples/math_dummy/prompts.py +46 -0
- mail/examples/math_dummy/types.py +5 -0
- mail/examples/research/__init__.py +39 -0
- mail/examples/research/researcher/__init__.py +9 -0
- mail/examples/research/researcher/agent.py +67 -0
- mail/examples/research/researcher/prompts.py +54 -0
- mail/examples/research/searcher/__init__.py +10 -0
- mail/examples/research/searcher/actions.py +324 -0
- mail/examples/research/searcher/agent.py +67 -0
- mail/examples/research/searcher/prompts.py +53 -0
- mail/examples/research/summarizer/__init__.py +18 -0
- mail/examples/research/summarizer/actions.py +255 -0
- mail/examples/research/summarizer/agent.py +67 -0
- mail/examples/research/summarizer/prompts.py +55 -0
- mail/examples/research/verifier/__init__.py +10 -0
- mail/examples/research/verifier/actions.py +337 -0
- mail/examples/research/verifier/agent.py +67 -0
- mail/examples/research/verifier/prompts.py +52 -0
- mail/examples/supervisor/__init__.py +11 -0
- mail/examples/supervisor/agent.py +4 -0
- mail/examples/supervisor/prompts.py +93 -0
- mail/examples/support/__init__.py +33 -0
- mail/examples/support/classifier/__init__.py +10 -0
- mail/examples/support/classifier/actions.py +307 -0
- mail/examples/support/classifier/agent.py +68 -0
- mail/examples/support/classifier/prompts.py +56 -0
- mail/examples/support/coordinator/__init__.py +9 -0
- mail/examples/support/coordinator/agent.py +67 -0
- mail/examples/support/coordinator/prompts.py +48 -0
- mail/examples/support/faq/__init__.py +10 -0
- mail/examples/support/faq/actions.py +182 -0
- mail/examples/support/faq/agent.py +67 -0
- mail/examples/support/faq/prompts.py +42 -0
- mail/examples/support/sentiment/__init__.py +15 -0
- mail/examples/support/sentiment/actions.py +341 -0
- mail/examples/support/sentiment/agent.py +67 -0
- mail/examples/support/sentiment/prompts.py +54 -0
- mail/examples/weather_dummy/__init__.py +23 -0
- mail/examples/weather_dummy/actions.py +75 -0
- mail/examples/weather_dummy/agent.py +136 -0
- mail/examples/weather_dummy/prompts.py +35 -0
- mail/examples/weather_dummy/types.py +5 -0
- mail/factories/__init__.py +27 -0
- mail/factories/action.py +223 -0
- mail/factories/base.py +1531 -0
- mail/factories/supervisor.py +241 -0
- mail/net/__init__.py +7 -0
- mail/net/registry.py +712 -0
- mail/net/router.py +728 -0
- mail/net/server_utils.py +114 -0
- mail/net/types.py +247 -0
- mail/server.py +1605 -0
- mail/stdlib/__init__.py +0 -0
- mail/stdlib/anthropic/__init__.py +0 -0
- mail/stdlib/fs/__init__.py +15 -0
- mail/stdlib/fs/actions.py +209 -0
- mail/stdlib/http/__init__.py +19 -0
- mail/stdlib/http/actions.py +333 -0
- mail/stdlib/interswarm/__init__.py +11 -0
- mail/stdlib/interswarm/actions.py +208 -0
- mail/stdlib/mcp/__init__.py +19 -0
- mail/stdlib/mcp/actions.py +294 -0
- mail/stdlib/openai/__init__.py +13 -0
- mail/stdlib/openai/agents.py +451 -0
- mail/summarizer.py +234 -0
- mail/swarms_json/__init__.py +27 -0
- mail/swarms_json/types.py +87 -0
- mail/swarms_json/utils.py +255 -0
- mail/url_scheme.py +51 -0
- mail/utils/__init__.py +53 -0
- mail/utils/auth.py +194 -0
- mail/utils/context.py +17 -0
- mail/utils/logger.py +73 -0
- mail/utils/openai.py +212 -0
- mail/utils/parsing.py +89 -0
- mail/utils/serialize.py +292 -0
- mail/utils/store.py +49 -0
- mail/utils/string_builder.py +119 -0
- mail/utils/version.py +20 -0
- mail_swarms-1.3.2.dist-info/METADATA +237 -0
- mail_swarms-1.3.2.dist-info/RECORD +137 -0
- mail_swarms-1.3.2.dist-info/WHEEL +4 -0
- mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
- mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
- mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
- 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
|
+
)
|