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/api.py ADDED
@@ -0,0 +1,1964 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline, Ryan Heaton
3
+
4
+ import asyncio
5
+ import datetime
6
+ import inspect
7
+ import logging
8
+ import uuid
9
+ from collections.abc import Awaitable, Callable
10
+ from copy import deepcopy
11
+ from functools import wraps
12
+ from typing import Any, Literal, TypeVar, get_type_hints
13
+
14
+ from pydantic import BaseModel, Field, create_model
15
+ from sse_starlette import EventSourceResponse, ServerSentEvent
16
+
17
+ from mail import utils
18
+ from mail.core import (
19
+ ActionFunction,
20
+ ActionOverrideFunction,
21
+ AgentFunction,
22
+ AgentToolCall,
23
+ MAILMessage,
24
+ MAILRequest,
25
+ MAILRuntime,
26
+ create_admin_address,
27
+ create_agent_address,
28
+ create_user_address,
29
+ pydantic_model_to_tool,
30
+ )
31
+ from mail.core.actions import ActionCore
32
+ from mail.core.agents import AgentCore
33
+ from mail.core.message import MAILBroadcast, MAILInterswarmMessage
34
+ from mail.core.tasks import MAILTask
35
+ from mail.core.tools import MAIL_TOOL_NAMES
36
+ from mail.factories.base import MAILAgentFunction
37
+ from mail.net import SwarmRegistry
38
+ from mail.swarms_json import (
39
+ SwarmsJSONAction,
40
+ SwarmsJSONAgent,
41
+ SwarmsJSONSwarm,
42
+ build_action_from_swarms_json,
43
+ build_agent_from_swarms_json,
44
+ build_swarm_from_swarms_json,
45
+ build_swarms_from_swarms_json,
46
+ load_swarms_json_from_file,
47
+ )
48
+ from mail.utils import read_python_string, resolve_prefixed_string_references
49
+
50
+ logger = logging.getLogger("mail.api")
51
+
52
+ ActionLike = TypeVar("ActionLike", bound=Callable[..., Awaitable[str] | str])
53
+
54
+
55
+ class MAILAgent:
56
+ """
57
+ Instance of an agent (including factory-built function) exposed via the MAIL API.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ name: str,
63
+ factory: str | Callable,
64
+ actions: list["MAILAction"],
65
+ function: AgentFunction,
66
+ comm_targets: list[str],
67
+ agent_params: dict[str, Any],
68
+ enable_entrypoint: bool = False,
69
+ enable_interswarm: bool = False,
70
+ can_complete_tasks: bool = False,
71
+ tool_format: Literal["completions", "responses"] = "responses",
72
+ exclude_tools: list[str] | None = None,
73
+ ) -> None:
74
+ self.name = name
75
+ self.factory = factory
76
+ self.actions = actions
77
+ self.function = function
78
+ self.comm_targets = comm_targets
79
+ self.enable_entrypoint = enable_entrypoint
80
+ self.enable_interswarm = enable_interswarm
81
+ self.agent_params = agent_params
82
+ self.tool_format = tool_format
83
+ self.can_complete_tasks = can_complete_tasks
84
+ self.exclude_tools = list(exclude_tools or [])
85
+ self._validate()
86
+
87
+ def _validate(self) -> None:
88
+ """
89
+ Validate an instance of the `MAILAgent` class.
90
+ """
91
+ if len(self.name) < 1:
92
+ raise ValueError(
93
+ f"agent name must be at least 1 character long, got {len(self.name)}"
94
+ )
95
+ if len(self.comm_targets) < 1 and (
96
+ self.can_complete_tasks is False or self.enable_entrypoint is False
97
+ ):
98
+ raise ValueError(
99
+ f"agent must have at least one communication target, got {len(self.comm_targets)}. If should be a solo agent, set can_complete_tasks and enable_entrypoint to True."
100
+ )
101
+
102
+ def _to_template(self, names: list[str]) -> "MAILAgentTemplate":
103
+ """
104
+ Convert the MAILAgent to a MAILAgentTemplate.
105
+ The names parameter is used to filter comm targets.
106
+ """
107
+ return MAILAgentTemplate(
108
+ name=self.name,
109
+ factory=self.factory,
110
+ comm_targets=[target for target in self.comm_targets if target in names],
111
+ actions=self.actions,
112
+ agent_params=self.agent_params,
113
+ enable_entrypoint=self.enable_entrypoint,
114
+ enable_interswarm=self.enable_interswarm,
115
+ tool_format=self.tool_format,
116
+ can_complete_tasks=self.can_complete_tasks,
117
+ exclude_tools=self.exclude_tools,
118
+ )
119
+
120
+ async def __call__(
121
+ self,
122
+ messages: list[dict[str, Any]],
123
+ tool_choice: str = "required",
124
+ ) -> tuple[str | None, list[AgentToolCall]]:
125
+ return await self.function(messages, tool_choice)
126
+
127
+ def to_core(self) -> AgentCore:
128
+ """
129
+ Convert the `MAILAgent` to an `AgentCore`.
130
+ """
131
+ return AgentCore(
132
+ function=self.function,
133
+ comm_targets=self.comm_targets,
134
+ actions={action.name: action.to_core() for action in self.actions},
135
+ enable_entrypoint=self.enable_entrypoint,
136
+ enable_interswarm=self.enable_interswarm,
137
+ can_complete_tasks=self.can_complete_tasks,
138
+ )
139
+
140
+
141
+ class MAILAgentTemplate:
142
+ """
143
+ Template class for an agent in the MAIL API.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ name: str,
149
+ factory: str | Callable,
150
+ comm_targets: list[str],
151
+ actions: list["MAILAction"],
152
+ agent_params: dict[str, Any],
153
+ enable_entrypoint: bool = False,
154
+ enable_interswarm: bool = False,
155
+ can_complete_tasks: bool = False,
156
+ tool_format: Literal["completions", "responses"] = "responses",
157
+ exclude_tools: list[str] | None = None,
158
+ ) -> None:
159
+ self.name = name
160
+ self.factory = factory
161
+ self.comm_targets = comm_targets
162
+ self.actions = actions
163
+ self.agent_params = agent_params
164
+ self.enable_entrypoint = enable_entrypoint
165
+ self.enable_interswarm = enable_interswarm
166
+ self.tool_format = tool_format
167
+ self.can_complete_tasks = can_complete_tasks
168
+ self.exclude_tools = list(exclude_tools or [])
169
+ self._validate()
170
+
171
+ def _validate(self) -> None:
172
+ if len(self.name) < 1:
173
+ raise ValueError(
174
+ f"agent name must be at least 1 character long, got {len(self.name)}"
175
+ )
176
+
177
+ def _top_level_params(
178
+ self, exclude_tools: list[str] | None = None
179
+ ) -> dict[str, Any]:
180
+ final_exclude = self.exclude_tools if exclude_tools is None else exclude_tools
181
+ return {
182
+ "name": self.name,
183
+ "comm_targets": self.comm_targets,
184
+ "tools": [
185
+ action.to_tool_dict(style=self.tool_format) for action in self.actions
186
+ ],
187
+ "enable_entrypoint": self.enable_entrypoint,
188
+ "enable_interswarm": self.enable_interswarm,
189
+ "tool_format": self.tool_format,
190
+ "can_complete_tasks": self.can_complete_tasks,
191
+ "exclude_tools": final_exclude,
192
+ }
193
+
194
+ def instantiate(
195
+ self,
196
+ instance_params: dict[str, Any],
197
+ additional_exclude_tools: list[str] | None = None,
198
+ ) -> MAILAgent:
199
+ combined_exclude = sorted(
200
+ set(self.exclude_tools + (additional_exclude_tools or []))
201
+ )
202
+
203
+ # Remove tool_format from agent_params if present - top-level is authoritative
204
+ agent_params = dict(self.agent_params)
205
+ if "tool_format" in agent_params:
206
+ logger.warning(
207
+ f"agent '{self.name}' has tool_format in agent_params; "
208
+ f"ignoring in favor of top-level tool_format='{self.tool_format}'"
209
+ )
210
+ agent_params.pop("tool_format")
211
+
212
+ full_params = {
213
+ **self._top_level_params(combined_exclude),
214
+ **agent_params,
215
+ **instance_params,
216
+ }
217
+ full_params["exclude_tools"] = combined_exclude
218
+ if isinstance(self.factory, str):
219
+ factory_func = read_python_string(self.factory)
220
+ else:
221
+ factory_func = self.factory
222
+ agent_function = factory_func(**full_params)
223
+
224
+ return MAILAgent(
225
+ name=self.name,
226
+ factory=self.factory,
227
+ actions=self.actions,
228
+ function=agent_function,
229
+ comm_targets=self.comm_targets,
230
+ agent_params=self.agent_params,
231
+ enable_entrypoint=self.enable_entrypoint,
232
+ enable_interswarm=self.enable_interswarm,
233
+ tool_format=self.tool_format,
234
+ can_complete_tasks=self.can_complete_tasks,
235
+ exclude_tools=combined_exclude,
236
+ )
237
+
238
+ @staticmethod
239
+ def from_swarms_json(
240
+ agent_data: SwarmsJSONAgent,
241
+ actions_by_name: dict[str, "MAILAction"] | None = None,
242
+ ) -> "MAILAgentTemplate":
243
+ """
244
+ Create a MAILAgentTemplate from a pre-parsed `SwarmsJSONAgent` definition.
245
+ """
246
+ actions: list[MAILAction] = []
247
+ action_names = agent_data.get("actions") or []
248
+ if action_names:
249
+ if not actions_by_name:
250
+ raise ValueError(
251
+ f"agent '{agent_data['name']}' declares actions but no action definitions were provided"
252
+ )
253
+ for action_name in action_names:
254
+ if action_name not in actions_by_name:
255
+ raise ValueError(
256
+ f"agent '{agent_data['name']}' references unknown action '{action_name}'"
257
+ )
258
+ actions.append(actions_by_name[action_name])
259
+
260
+ agent_params = resolve_prefixed_string_references(agent_data["agent_params"])
261
+ return MAILAgentTemplate(
262
+ name=agent_data["name"],
263
+ factory=agent_data["factory"],
264
+ comm_targets=agent_data["comm_targets"],
265
+ actions=actions,
266
+ agent_params=agent_params,
267
+ enable_entrypoint=agent_data["enable_entrypoint"],
268
+ enable_interswarm=agent_data["enable_interswarm"],
269
+ tool_format=agent_data["tool_format"],
270
+ can_complete_tasks=agent_data["can_complete_tasks"],
271
+ exclude_tools=agent_data["exclude_tools"],
272
+ )
273
+
274
+ @staticmethod
275
+ def from_swarm_json(
276
+ json_dump: str,
277
+ actions_by_name: dict[str, "MAILAction"] | None = None,
278
+ ) -> "MAILAgentTemplate":
279
+ """
280
+ Create a MAILAgentTemplate from a JSON dump following the `swarms.json` format.
281
+ """
282
+ import json as _json
283
+
284
+ agent_candidate = _json.loads(json_dump)
285
+ parsed_agent = build_agent_from_swarms_json(agent_candidate)
286
+ return MAILAgentTemplate.from_swarms_json(parsed_agent, actions_by_name)
287
+
288
+ @staticmethod
289
+ def from_example(
290
+ name: Literal["supervisor", "weather", "math", "consultant", "analyst"],
291
+ comm_targets: list[str],
292
+ ) -> "MAILAgentTemplate":
293
+ """
294
+ Create a MAILAgent from an example in `mail.examples`.
295
+ """
296
+ match name:
297
+ case "supervisor":
298
+ from mail.examples import supervisor
299
+ from mail.factories import supervisor_factory
300
+
301
+ agent_params = supervisor.supervisor_agent_params
302
+
303
+ return MAILAgentTemplate(
304
+ name=name,
305
+ factory=supervisor_factory.__name__,
306
+ comm_targets=comm_targets,
307
+ actions=[],
308
+ agent_params=agent_params,
309
+ enable_entrypoint=True,
310
+ enable_interswarm=False,
311
+ tool_format="responses",
312
+ can_complete_tasks=True,
313
+ exclude_tools=[],
314
+ )
315
+ case "weather":
316
+ from mail.examples import weather_dummy as weather
317
+
318
+ agent_params = weather.weather_agent_params
319
+ actions = [weather.action_get_weather_forecast]
320
+
321
+ return MAILAgentTemplate(
322
+ name=name,
323
+ factory=weather.factory_weather_dummy.__name__,
324
+ comm_targets=comm_targets,
325
+ actions=actions,
326
+ agent_params=agent_params,
327
+ enable_entrypoint=False,
328
+ enable_interswarm=False,
329
+ tool_format="responses",
330
+ can_complete_tasks=False,
331
+ exclude_tools=[],
332
+ )
333
+ case "math":
334
+ from mail.examples import math_dummy as math
335
+
336
+ agent_params = math.math_agent_params
337
+
338
+ return MAILAgentTemplate(
339
+ name=name,
340
+ factory=math.factory_math_dummy.__name__,
341
+ comm_targets=comm_targets,
342
+ actions=[],
343
+ agent_params=agent_params,
344
+ enable_entrypoint=False,
345
+ enable_interswarm=False,
346
+ tool_format="responses",
347
+ can_complete_tasks=False,
348
+ exclude_tools=[],
349
+ )
350
+ case "consultant":
351
+ from mail.examples import consultant_dummy as consultant
352
+
353
+ agent_params = consultant.consultant_agent_params
354
+
355
+ return MAILAgentTemplate(
356
+ name=name,
357
+ factory=consultant.factory_consultant_dummy.__name__,
358
+ comm_targets=comm_targets,
359
+ actions=[],
360
+ agent_params=agent_params,
361
+ enable_entrypoint=False,
362
+ enable_interswarm=False,
363
+ tool_format="responses",
364
+ can_complete_tasks=False,
365
+ exclude_tools=[],
366
+ )
367
+ case "analyst":
368
+ from mail.examples import analyst_dummy as analyst
369
+
370
+ agent_params = analyst.analyst_agent_params
371
+
372
+ return MAILAgentTemplate(
373
+ name=name,
374
+ factory=analyst.factory_analyst_dummy.__name__,
375
+ comm_targets=comm_targets,
376
+ actions=[],
377
+ agent_params=agent_params,
378
+ enable_entrypoint=False,
379
+ enable_interswarm=False,
380
+ tool_format="responses",
381
+ can_complete_tasks=False,
382
+ exclude_tools=[],
383
+ )
384
+ case _:
385
+ raise ValueError(f"invalid agent name: {name}")
386
+
387
+
388
+ def _json_schema_to_python_type(
389
+ schema: dict[str, Any],
390
+ model_name_prefix: str = "Nested",
391
+ _depth: int = 0,
392
+ ) -> Any:
393
+ """
394
+ Recursively convert a JSON schema type definition to a Python type annotation.
395
+ """
396
+ schema_type = schema.get("type")
397
+
398
+ if schema_type == "string":
399
+ return str
400
+ elif schema_type == "integer":
401
+ return int
402
+ elif schema_type == "number":
403
+ return float
404
+ elif schema_type == "boolean":
405
+ return bool
406
+ elif schema_type == "null":
407
+ return type(None)
408
+ elif schema_type == "array":
409
+ items_schema = schema.get("items", {})
410
+ if not items_schema:
411
+ return list[Any]
412
+ item_type = _json_schema_to_python_type(
413
+ items_schema,
414
+ f"{model_name_prefix}Item",
415
+ _depth + 1,
416
+ )
417
+ return list[item_type] # type: ignore[valid-type]
418
+ elif schema_type == "object":
419
+ properties = schema.get("properties")
420
+ if properties:
421
+ # Create a nested Pydantic model for structured objects
422
+ return _json_schema_to_pydantic_model(
423
+ f"{model_name_prefix}_{_depth}",
424
+ schema,
425
+ )
426
+ else:
427
+ # Generic dict - check additionalProperties for value type
428
+ additional = schema.get("additionalProperties", {})
429
+ if isinstance(additional, dict) and additional:
430
+ value_type = _json_schema_to_python_type(
431
+ additional,
432
+ f"{model_name_prefix}Value",
433
+ _depth + 1,
434
+ )
435
+ return dict[str, value_type] # type: ignore[valid-type]
436
+ else:
437
+ return dict[str, Any]
438
+ elif schema_type is None:
439
+ # Handle anyOf, oneOf, allOf
440
+ any_of = schema.get("anyOf") or schema.get("oneOf")
441
+ if any_of:
442
+ types = [
443
+ _json_schema_to_python_type(sub_schema, model_name_prefix, _depth + 1)
444
+ for sub_schema in any_of
445
+ ]
446
+ if len(types) == 1:
447
+ return types[0]
448
+ # Create Union type using | operator
449
+ result = types[0]
450
+ for t in types[1:]:
451
+ result = result | t
452
+ return result
453
+ all_of = schema.get("allOf")
454
+ if all_of and len(all_of) == 1:
455
+ return _json_schema_to_python_type(all_of[0], model_name_prefix, _depth + 1)
456
+ return Any
457
+ else:
458
+ raise ValueError(f"unsupported JSON schema type: {schema_type}")
459
+
460
+
461
+ def _json_schema_to_pydantic_model(
462
+ model_name: str,
463
+ schema: dict[str, Any],
464
+ ) -> type[BaseModel]:
465
+ """
466
+ Convert a JSON schema object definition to a Pydantic model.
467
+ """
468
+ properties = schema.get("properties", {})
469
+ required = set(schema.get("required", []))
470
+
471
+ field_definitions: dict[str, tuple[Any, Any]] = {}
472
+
473
+ for field_name, field_schema in properties.items():
474
+ python_type = _json_schema_to_python_type(
475
+ field_schema,
476
+ f"{model_name}_{field_name}",
477
+ )
478
+
479
+ description = field_schema.get("description")
480
+ default_value = field_schema.get("default", ...)
481
+
482
+ is_required = field_name in required
483
+
484
+ if not is_required and default_value is ...:
485
+ # Optional field with no default - make it Optional with None default
486
+ python_type = python_type | None
487
+ default_value = None
488
+
489
+ if description:
490
+ field_info = Field(default=default_value, description=description)
491
+ elif default_value is not ...:
492
+ field_info = Field(default=default_value)
493
+ else:
494
+ field_info = Field()
495
+
496
+ field_definitions[field_name] = (python_type, field_info)
497
+
498
+ return create_model(model_name, **field_definitions) # type: ignore[call-overload]
499
+
500
+
501
+ class MAILAction:
502
+ """
503
+ Action class exposed via the MAIL API.
504
+ """
505
+
506
+ def __init__(
507
+ self,
508
+ name: str,
509
+ description: str,
510
+ parameters: dict[str, Any],
511
+ function: str | ActionFunction,
512
+ ) -> None:
513
+ self.name = name
514
+ self.description = description
515
+ self.parameters = parameters
516
+ self.function = self._build_action_function(function)
517
+ self._validate()
518
+
519
+ def _validate(self) -> None:
520
+ """
521
+ Validate an instance of the `MAILAction` class.
522
+ """
523
+ if len(self.name) < 1:
524
+ raise ValueError(
525
+ f"action name must be at least 1 character long, got {len(self.name)}"
526
+ )
527
+ if len(self.description) < 1:
528
+ raise ValueError(
529
+ f"action description must be at least 1 character long, got {len(self.description)}"
530
+ )
531
+
532
+ def _build_action_function(
533
+ self,
534
+ function: str | ActionFunction,
535
+ ) -> ActionFunction:
536
+ resolved_function: Any
537
+ if isinstance(function, str):
538
+ resolved_function = read_python_string(function)
539
+ else:
540
+ resolved_function = function
541
+
542
+ if isinstance(resolved_function, MAILAction):
543
+ return resolved_function.function
544
+ if not callable(resolved_function):
545
+ raise TypeError(
546
+ f"action function must be callable, got {type(resolved_function)}"
547
+ )
548
+ return resolved_function # type: ignore[return-value]
549
+
550
+ @staticmethod
551
+ def from_pydantic_model(
552
+ model: type[BaseModel],
553
+ function: str | ActionFunction,
554
+ name: str | None = None,
555
+ description: str | None = None,
556
+ ) -> "MAILAction":
557
+ """
558
+ Create a MAILAction from a Pydantic model and function string.
559
+ """
560
+ tool = pydantic_model_to_tool(
561
+ model, name=name, description=description, style="responses"
562
+ )
563
+ return MAILAction(
564
+ name=tool["name"],
565
+ description=tool["description"],
566
+ parameters=tool["parameters"],
567
+ function=function,
568
+ )
569
+
570
+ def to_core(self) -> ActionCore:
571
+ """
572
+ Convert the MAILAction to an ActionCore.
573
+ """
574
+ return ActionCore(
575
+ function=self.function,
576
+ name=self.name,
577
+ parameters=self.parameters,
578
+ )
579
+
580
+ @staticmethod
581
+ def from_swarms_json(action_data: SwarmsJSONAction) -> "MAILAction":
582
+ """
583
+ Create a MAILAction from a pre-parsed `SwarmsJSONAction` definition.
584
+ """
585
+ return MAILAction(
586
+ name=action_data["name"],
587
+ description=action_data["description"],
588
+ parameters=action_data["parameters"],
589
+ function=action_data["function"],
590
+ )
591
+
592
+ @staticmethod
593
+ def from_swarm_json(json_dump: str) -> "MAILAction":
594
+ """
595
+ Create a MAILAction from a JSON dump following the `swarms.json` format.
596
+ """
597
+ import json as _json
598
+
599
+ action_candidate = _json.loads(json_dump)
600
+ parsed_action = build_action_from_swarms_json(action_candidate)
601
+ return MAILAction.from_swarms_json(parsed_action)
602
+
603
+ def to_tool_dict(
604
+ self,
605
+ style: Literal["completions", "responses"] = "responses",
606
+ ) -> dict[str, Any]:
607
+ """
608
+ Convert the MAILAction to a tool dictionary.
609
+ """
610
+ return pydantic_model_to_tool(
611
+ self.to_pydantic_model(for_tools=True),
612
+ name=self.name,
613
+ description=self.description,
614
+ style=style,
615
+ )
616
+
617
+ def to_pydantic_model(
618
+ self,
619
+ for_tools: bool = False,
620
+ ) -> type[BaseModel]:
621
+ """
622
+ Convert the MAILAction to a Pydantic model.
623
+ """
624
+ if for_tools:
625
+ return _json_schema_to_pydantic_model(self.name, self.parameters)
626
+ else:
627
+
628
+ class MAILActionBaseModel(BaseModel):
629
+ name: str = Field(description=self.name)
630
+ description: str = Field(description=self.description)
631
+ parameters: dict[str, Any] = Field()
632
+ function: str = Field(description=str(self.function))
633
+
634
+ return MAILActionBaseModel
635
+
636
+
637
+ def action(
638
+ *,
639
+ name: str | None = None,
640
+ description: str | None = None,
641
+ model: type[BaseModel] | None = None,
642
+ parameters: dict[str, Any] | None = None,
643
+ style: Literal["completions", "responses"] = "responses",
644
+ ) -> Callable[[ActionLike], MAILAction]:
645
+ """
646
+ Decorator that converts a Python callable into a MAILAction.
647
+ """
648
+
649
+ if model is not None and not issubclass(model, BaseModel):
650
+ msg = f"model must be a subclass of BaseModel, got {model}"
651
+ raise TypeError(msg)
652
+
653
+ def decorator(func: ActionLike) -> MAILAction:
654
+ action_name = name or func.__name__
655
+ docstring = description or inspect.getdoc(func) or ""
656
+ clean_description = inspect.cleandoc(docstring)
657
+ if len(clean_description) < 1:
658
+ raise ValueError(
659
+ f"Action '{action_name}' is missing a description. Provide one via "
660
+ "`description=` or add a docstring."
661
+ )
662
+
663
+ signature = inspect.signature(func)
664
+ if len(signature.parameters) != 1:
665
+ raise TypeError(
666
+ f"Action '{action_name}' must accept exactly one argument matching the "
667
+ "tool payload."
668
+ )
669
+
670
+ resolved_model = model
671
+ if resolved_model is None:
672
+ type_hints = get_type_hints(func)
673
+ first_param_name = next(iter(signature.parameters))
674
+ candidate = type_hints.get(first_param_name)
675
+ if isinstance(candidate, type) and issubclass(candidate, BaseModel):
676
+ resolved_model = candidate
677
+
678
+ if resolved_model and parameters:
679
+ raise ValueError(
680
+ f"Action '{action_name}' cannot specify both model= and parameters=."
681
+ )
682
+
683
+ if resolved_model:
684
+ tool_definition = pydantic_model_to_tool(
685
+ resolved_model,
686
+ name=action_name,
687
+ description=clean_description,
688
+ style=style,
689
+ )
690
+ action_parameters = tool_definition["parameters"]
691
+ elif parameters:
692
+ action_parameters = parameters
693
+ else:
694
+ raise ValueError(
695
+ f"Action '{action_name}' must provide either a model= or parameters=."
696
+ )
697
+
698
+ @wraps(func)
699
+ async def runner(payload: dict[str, Any]) -> str:
700
+ if resolved_model is not None:
701
+ parsed_payload = resolved_model.model_validate(payload)
702
+ else:
703
+ parsed_payload = payload # type: ignore
704
+
705
+ result = func(parsed_payload)
706
+ if inspect.isawaitable(result):
707
+ result = await result
708
+
709
+ if not isinstance(result, str):
710
+ raise TypeError(
711
+ f"Action '{action_name}' returned {type(result)}, expected str."
712
+ )
713
+
714
+ return result
715
+
716
+ mail_action = MAILAction(
717
+ name=action_name,
718
+ description=clean_description,
719
+ parameters=action_parameters,
720
+ function=runner,
721
+ )
722
+ mail_action.callback = func # type: ignore[attr-defined]
723
+ mail_action.parameters_model = resolved_model # type: ignore[attr-defined]
724
+ return mail_action
725
+
726
+ return decorator
727
+
728
+
729
+ class MAILSwarm:
730
+ """
731
+ Swarm instance class exposed via the MAIL API.
732
+ """
733
+
734
+ def __init__(
735
+ self,
736
+ name: str,
737
+ version: str,
738
+ agents: list[MAILAgent],
739
+ actions: list[MAILAction],
740
+ entrypoint: str,
741
+ user_id: str = "default",
742
+ user_role: Literal["admin", "agent", "user"] = "user",
743
+ swarm_registry: SwarmRegistry | None = None,
744
+ enable_interswarm: bool = False,
745
+ breakpoint_tools: list[str] = [],
746
+ exclude_tools: list[str] = [],
747
+ task_message_limit: int | None = None,
748
+ description: str = "",
749
+ keywords: list[str] = [],
750
+ enable_db_agent_histories: bool = False,
751
+ ) -> None:
752
+ self.name = name
753
+ self.version = version
754
+ self.agents = agents
755
+ self.actions = actions
756
+ self.entrypoint = entrypoint
757
+ self.user_id = user_id
758
+ self.swarm_registry = swarm_registry
759
+ self.user_role = user_role
760
+ self.enable_interswarm = enable_interswarm
761
+ self.breakpoint_tools = breakpoint_tools
762
+ self.exclude_tools = exclude_tools
763
+ self.task_message_limit = task_message_limit
764
+ self.description = description
765
+ self.keywords = keywords
766
+ self.adjacency_matrix, self.agent_names = self._build_adjacency_matrix()
767
+ self.supervisors = [agent for agent in agents if agent.can_complete_tasks]
768
+ self._agent_cores = {agent.name: agent.to_core() for agent in agents}
769
+ self._runtime = MAILRuntime(
770
+ agents=self._agent_cores,
771
+ actions={action.name: action.to_core() for action in actions},
772
+ user_id=user_id,
773
+ user_role=user_role,
774
+ swarm_name=name,
775
+ swarm_registry=swarm_registry,
776
+ enable_interswarm=enable_interswarm,
777
+ entrypoint=entrypoint,
778
+ breakpoint_tools=breakpoint_tools,
779
+ exclude_tools=exclude_tools,
780
+ enable_db_agent_histories=enable_db_agent_histories,
781
+ )
782
+ self._validate()
783
+
784
+ def _validate(self) -> None:
785
+ """
786
+ Validate an instance of the `MAILSwarm` class.
787
+ """
788
+ if len(self.name) < 1:
789
+ raise ValueError(
790
+ f"swarm name must be at least 1 character long, got {len(self.name)}"
791
+ )
792
+ if len(self.agents) < 1:
793
+ raise ValueError(
794
+ f"swarm must have at least one agent, got {len(self.agents)}"
795
+ )
796
+ if len(self.user_id) < 1:
797
+ raise ValueError(
798
+ f"user ID must be at least 1 character long, got {len(self.user_id)}"
799
+ )
800
+
801
+ # is the entrypoint valid?
802
+ entrypoints = [agent.name for agent in self.agents if agent.enable_entrypoint]
803
+ if len(entrypoints) < 1:
804
+ raise ValueError(
805
+ f"swarm must have at least one entrypoint agent, got {len(entrypoints)}"
806
+ )
807
+ if self.entrypoint not in entrypoints:
808
+ raise ValueError(f"entrypoint agent '{self.entrypoint}' not found in swarm")
809
+
810
+ # are agent comm targets valid?
811
+ for agent in self.agents:
812
+ for target in agent.comm_targets:
813
+ interswarm_target = utils.target_address_is_interswarm(target)
814
+ if interswarm_target and not self.enable_interswarm:
815
+ raise ValueError(
816
+ f"agent '{agent.name}' has interswarm communication target '{target}' but interswarm messaging is not enabled for this swarm"
817
+ )
818
+ if not interswarm_target and target not in [
819
+ agent.name for agent in self.agents
820
+ ]:
821
+ raise ValueError(
822
+ f"agent '{agent.name}' has invalid communication target '{target}'"
823
+ )
824
+
825
+ if self.swarm_registry is None and self.enable_interswarm:
826
+ raise ValueError(
827
+ "swarm registry must be provided if interswarm messaging is enabled"
828
+ )
829
+
830
+ # is there at least one supervisor?
831
+ if len(self.supervisors) < 1:
832
+ raise ValueError(
833
+ f"swarm must have at least one supervisor, got {len(self.supervisors)}"
834
+ )
835
+
836
+ # is each breakpoint tool valid?
837
+ for tool in self.breakpoint_tools:
838
+ if tool not in MAIL_TOOL_NAMES + [action.name for action in self.actions]:
839
+ raise ValueError(f"breakpoint tool '{tool}' not found in swarm")
840
+
841
+ # are the excluded tools valid?
842
+ for tool in self.exclude_tools:
843
+ if tool not in MAIL_TOOL_NAMES:
844
+ raise ValueError(f"excluded tool '{tool}' is not valid")
845
+
846
+ def _build_adjacency_matrix(self) -> tuple[list[list[int]], list[str]]:
847
+ """
848
+ Build an adjacency matrix for the swarm.
849
+ Returns a tuple of the adjacency matrix and the map of indices to agent names.
850
+ """
851
+ agent_names = [agent.name for agent in self.agents]
852
+ name_to_index = {name: idx for idx, name in enumerate(agent_names)}
853
+ adj = [[0 for _ in agent_names] for _ in agent_names]
854
+
855
+ for agent in self.agents:
856
+ row_idx = name_to_index[agent.name]
857
+ for target_name in agent.comm_targets:
858
+ target_idx = name_to_index.get(target_name)
859
+ if target_idx is not None:
860
+ adj[row_idx][target_idx] = 1
861
+
862
+ return adj, agent_names
863
+
864
+ def update_from_adjacency_matrix(self, adj: list[list[int]]) -> None:
865
+ """
866
+ Update `comm_targets` for all agents using an adjacency matrix.
867
+ """
868
+
869
+ if len(adj) != len(self.agents):
870
+ raise ValueError(
871
+ f"Length of adjacency matrix does not match number of agents. Expected: {len(self.agents)} Got: {len(adj)}"
872
+ )
873
+
874
+ idx_to_name = {idx: name for idx, name in enumerate(self.agent_names)}
875
+ for i, agent_adj in enumerate(adj):
876
+ if len(agent_adj) != len(adj):
877
+ raise ValueError(
878
+ f"Adjacency matrix is malformed. Expected number of agents: {len(adj)} Got: {len(agent_adj)}"
879
+ )
880
+
881
+ target_idx = [j for j, x in enumerate(agent_adj) if x]
882
+ new_targets = [idx_to_name[idx] for idx in target_idx]
883
+ self.agents[i].comm_targets = new_targets
884
+
885
+ async def post_message(
886
+ self,
887
+ body: str,
888
+ subject: str = "New Message",
889
+ msg_type: Literal["request", "response", "broadcast", "interrupt"] = "request",
890
+ entrypoint: str | None = None,
891
+ show_events: bool = False,
892
+ timeout: float = 3600.0,
893
+ task_id: str | None = None,
894
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
895
+ **kwargs: Any,
896
+ ) -> tuple[MAILMessage, list[ServerSentEvent]]:
897
+ """
898
+ Post a message to the swarm and return the task completion response.
899
+ This method is indented to be used when the swarm is running in continuous mode.
900
+ """
901
+ if entrypoint is None:
902
+ entrypoint = self.entrypoint
903
+
904
+ message = self.build_message(
905
+ subject=subject,
906
+ body=body,
907
+ targets=[entrypoint],
908
+ sender_type=self.user_role,
909
+ type=msg_type,
910
+ task_id=task_id,
911
+ )
912
+ task_id = message["message"]["task_id"]
913
+
914
+ runtime_kwargs = dict(kwargs)
915
+ if resume_from is not None:
916
+ runtime_kwargs["resume_from"] = resume_from
917
+
918
+ return await self.submit_message(
919
+ message,
920
+ timeout=timeout,
921
+ show_events=show_events,
922
+ **runtime_kwargs,
923
+ )
924
+
925
+ async def post_message_stream(
926
+ self,
927
+ body: str,
928
+ subject: str = "New Message",
929
+ msg_type: Literal["request", "response", "broadcast", "interrupt"] = "request",
930
+ entrypoint: str | None = None,
931
+ task_id: str | None = None,
932
+ timeout: float = 3600.0,
933
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
934
+ **kwargs: Any,
935
+ ) -> EventSourceResponse:
936
+ """
937
+ Post a message to the swarm and stream the response.
938
+ This method is indented to be used when the swarm is running in continuous mode.
939
+ """
940
+ if entrypoint is None:
941
+ entrypoint = self.entrypoint
942
+
943
+ message = self.build_message(
944
+ subject=subject,
945
+ body=body,
946
+ targets=[entrypoint],
947
+ sender_type=self.user_role,
948
+ type=msg_type,
949
+ task_id=task_id,
950
+ )
951
+
952
+ runtime_kwargs = dict(kwargs)
953
+ if resume_from is not None:
954
+ runtime_kwargs["resume_from"] = resume_from
955
+
956
+ return await self.submit_message_stream(
957
+ message,
958
+ timeout=timeout,
959
+ **runtime_kwargs,
960
+ )
961
+
962
+ async def post_message_and_run(
963
+ self,
964
+ body: str,
965
+ subject: str = "New Message",
966
+ msg_type: Literal["request", "response", "broadcast", "interrupt"] = "request",
967
+ entrypoint: str | None = None,
968
+ show_events: bool = False,
969
+ task_id: str | None = None,
970
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
971
+ max_steps: int | None = None,
972
+ **kwargs: Any,
973
+ ) -> tuple[MAILMessage, list[ServerSentEvent]]:
974
+ """
975
+ Post a message to the swarm and run until the task is complete.
976
+ This method cannot be used when the swarm is running in continuous mode.
977
+ """
978
+ if entrypoint is None:
979
+ entrypoint = self.entrypoint
980
+
981
+ message = self.build_message(
982
+ subject=subject,
983
+ body=body,
984
+ targets=[entrypoint],
985
+ sender_type=self.user_role,
986
+ type=msg_type,
987
+ task_id=task_id,
988
+ )
989
+ task_id = message["message"]["task_id"]
990
+ if not resume_from == "breakpoint_tool_call":
991
+ await self._runtime.submit(message)
992
+ task_response = await self._runtime.run_task(
993
+ task_id=task_id, resume_from=resume_from, max_steps=max_steps, **kwargs
994
+ )
995
+
996
+ if show_events:
997
+ return task_response, self._runtime.get_events_by_task_id(
998
+ task_response["message"]["task_id"]
999
+ )
1000
+ else:
1001
+ return task_response, []
1002
+
1003
+ def build_message(
1004
+ self,
1005
+ subject: str,
1006
+ body: str,
1007
+ targets: list[str],
1008
+ sender_type: Literal["admin", "agent", "user"] = "user",
1009
+ type: Literal["request", "response", "broadcast", "interrupt"] = "request",
1010
+ task_id: str | None = None,
1011
+ ) -> MAILMessage:
1012
+ """
1013
+ Build a MAIL message.
1014
+ """
1015
+ match sender_type:
1016
+ case "admin":
1017
+ sender = create_admin_address(self.user_id)
1018
+ case "agent":
1019
+ sender = create_agent_address(self.user_id)
1020
+ case "user":
1021
+ sender = create_user_address(self.user_id)
1022
+ case _:
1023
+ raise ValueError(f"invalid sender type: {sender_type}")
1024
+ match type:
1025
+ case "request":
1026
+ if not len(targets) == 1:
1027
+ raise ValueError("request messages must have exactly one target")
1028
+ target = targets[0]
1029
+ return MAILMessage(
1030
+ id=str(uuid.uuid4()),
1031
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
1032
+ message=MAILRequest(
1033
+ task_id=task_id or str(uuid.uuid4()),
1034
+ request_id=str(uuid.uuid4()),
1035
+ sender=sender,
1036
+ recipient=create_agent_address(target),
1037
+ subject=subject,
1038
+ body=body,
1039
+ sender_swarm=self.name,
1040
+ recipient_swarm=self.name,
1041
+ routing_info={},
1042
+ ),
1043
+ msg_type="request",
1044
+ )
1045
+ case "broadcast":
1046
+ return MAILMessage(
1047
+ id=str(uuid.uuid4()),
1048
+ timestamp=datetime.datetime.now(datetime.UTC).isoformat(),
1049
+ message=MAILBroadcast(
1050
+ task_id=task_id or str(uuid.uuid4()),
1051
+ broadcast_id=str(uuid.uuid4()),
1052
+ sender=sender,
1053
+ recipients=[create_agent_address(target) for target in targets],
1054
+ subject=subject,
1055
+ body=body,
1056
+ sender_swarm=None,
1057
+ recipient_swarms=None,
1058
+ routing_info={},
1059
+ ),
1060
+ msg_type="broadcast",
1061
+ )
1062
+ case _:
1063
+ raise NotImplementedError(
1064
+ f"type '{type}' not implemented for this method"
1065
+ )
1066
+
1067
+ async def shutdown(self) -> None:
1068
+ """
1069
+ Shut down the MAILSwarm.
1070
+ """
1071
+ await self._runtime.shutdown()
1072
+ if self.enable_interswarm and self.swarm_registry is not None:
1073
+ await self.swarm_registry.stop_health_checks()
1074
+
1075
+ async def start_interswarm(self) -> None:
1076
+ """
1077
+ Start interswarm messaging.
1078
+ """
1079
+ if not self.enable_interswarm:
1080
+ raise ValueError("interswarm messaging is not enabled for this swarm")
1081
+ if self.swarm_registry is None:
1082
+ raise ValueError(
1083
+ "swarm registry must be provided if interswarm messaging is enabled"
1084
+ )
1085
+
1086
+ await self.swarm_registry.start_health_checks()
1087
+ await self._runtime.start_interswarm()
1088
+
1089
+ async def stop_interswarm(self) -> None:
1090
+ """
1091
+ Stop interswarm messaging.
1092
+ """
1093
+ if not self.enable_interswarm:
1094
+ raise ValueError("interswarm messaging is not enabled for this swarm")
1095
+ if self.swarm_registry is None:
1096
+ raise ValueError(
1097
+ "swarm registry must be provided if interswarm messaging is enabled"
1098
+ )
1099
+
1100
+ await self._runtime.stop_interswarm()
1101
+
1102
+ async def is_interswarm_running(self) -> bool:
1103
+ """
1104
+ Check if interswarm messaging is running.
1105
+ """
1106
+ if not self.enable_interswarm:
1107
+ return False
1108
+ if self.swarm_registry is None:
1109
+ return False
1110
+
1111
+ return await self._runtime.is_interswarm_running()
1112
+
1113
+ async def load_agent_histories_from_db(self) -> None:
1114
+ """
1115
+ Load existing agent histories from the database.
1116
+ Only has effect when enable_db_agent_histories is True.
1117
+ """
1118
+ await self._runtime.load_agent_histories_from_db()
1119
+
1120
+ async def load_tasks_from_db(self) -> None:
1121
+ """
1122
+ Load existing tasks from the database.
1123
+ Only has effect when enable_db_agent_histories is True.
1124
+ """
1125
+ await self._runtime.load_tasks_from_db()
1126
+
1127
+ async def run_continuous(
1128
+ self,
1129
+ max_steps: int | None = None,
1130
+ action_override: ActionOverrideFunction | None = None,
1131
+ mode: Literal["continuous", "manual"] = "continuous",
1132
+ ) -> None:
1133
+ """
1134
+ Run the MAILSwarm in continuous mode.
1135
+ """
1136
+ await self._runtime.run_continuous(max_steps, action_override, mode)
1137
+
1138
+ async def manual_step(
1139
+ self,
1140
+ task_id: str,
1141
+ target: str,
1142
+ response_targets: list[str] | None = None,
1143
+ response_type: Literal["broadcast", "response", "request"] = "broadcast",
1144
+ payload: str | None = None,
1145
+ dynamic_ctx_ratio: float = 0.0,
1146
+ _llm: str | None = None,
1147
+ _system: str | None = None,
1148
+ ) -> MAILMessage:
1149
+ """
1150
+ Manually step a target agent.
1151
+ """
1152
+ return await self._runtime._manual_step(
1153
+ task_id=task_id,
1154
+ target=target,
1155
+ response_targets=response_targets,
1156
+ response_type=response_type,
1157
+ payload=payload,
1158
+ dynamic_ctx_ratio=dynamic_ctx_ratio,
1159
+ _llm=_llm,
1160
+ _system=_system,
1161
+ )
1162
+
1163
+ async def await_queue_empty(self) -> None:
1164
+ """
1165
+ Await for the message queue to be empty.
1166
+ """
1167
+ while not self._runtime.message_queue.empty():
1168
+ await asyncio.sleep(0.1)
1169
+
1170
+ async def submit_message(
1171
+ self,
1172
+ message: MAILMessage,
1173
+ timeout: float = 3600.0,
1174
+ show_events: bool = False,
1175
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
1176
+ **kwargs: Any,
1177
+ ) -> tuple[MAILMessage, list[ServerSentEvent]]:
1178
+ """
1179
+ Submit a fully-formed MAILMessage to the swarm and return the response.
1180
+ """
1181
+ response = await self._runtime.submit_and_wait(
1182
+ message, timeout, resume_from, **kwargs
1183
+ )
1184
+
1185
+ if show_events:
1186
+ return response, self._runtime.get_events_by_task_id(
1187
+ message["message"]["task_id"]
1188
+ )
1189
+ else:
1190
+ return response, []
1191
+
1192
+ async def submit_message_nowait(
1193
+ self,
1194
+ message: MAILMessage,
1195
+ **kwargs: Any,
1196
+ ) -> None:
1197
+ """
1198
+ Submit a fully-formed MAILMessage to the swarm and do not wait for the response.
1199
+ """
1200
+ await self._runtime.submit(message)
1201
+
1202
+ async def submit_message_stream(
1203
+ self,
1204
+ message: MAILMessage,
1205
+ timeout: float = 3600.0,
1206
+ resume_from: Literal["user_response", "breakpoint_tool_call"] | None = None,
1207
+ *,
1208
+ ping_interval: int | None = 15000,
1209
+ **kwargs: Any,
1210
+ ) -> EventSourceResponse:
1211
+ """
1212
+ Submit a fully-formed MAILMessage to the swarm and stream the response.
1213
+ """
1214
+ # Support runtimes that either return an async generator directly
1215
+ # or coroutines that resolve to an async generator.
1216
+ maybe_stream = self._runtime.submit_and_stream(
1217
+ message, timeout, resume_from, **kwargs
1218
+ )
1219
+ stream = (
1220
+ await maybe_stream # type: ignore[func-returns-value]
1221
+ if inspect.isawaitable(maybe_stream)
1222
+ else maybe_stream
1223
+ )
1224
+
1225
+ return EventSourceResponse(
1226
+ stream,
1227
+ ping=ping_interval,
1228
+ headers={
1229
+ "Cache-Control": "no-cache",
1230
+ "Connection": "keep-alive",
1231
+ "X-Accel-Buffering": "no",
1232
+ },
1233
+ )
1234
+
1235
+ def get_pending_requests(self) -> dict[str, asyncio.Future[MAILMessage]]:
1236
+ """
1237
+ Get the pending requests for the swarm.
1238
+ """
1239
+ return self._runtime.pending_requests
1240
+
1241
+ async def receive_interswarm_message(
1242
+ self,
1243
+ message: MAILInterswarmMessage,
1244
+ direction: Literal["forward", "back"] = "forward",
1245
+ ) -> None:
1246
+ """
1247
+ Receive an interswarm message from a remote swarm.
1248
+ """
1249
+ router = self._runtime.interswarm_router
1250
+ if router is None:
1251
+ raise ValueError("interswarm router not available")
1252
+
1253
+ try:
1254
+ if direction == "forward":
1255
+ await router.receive_interswarm_message_forward(message)
1256
+ elif direction == "back":
1257
+ await router.receive_interswarm_message_back(message)
1258
+ else:
1259
+ raise ValueError(f"invalid direction: {direction}")
1260
+ except Exception as e:
1261
+ raise ValueError(f"error routing interswarm message: {e}")
1262
+
1263
+ async def send_interswarm_message(
1264
+ self,
1265
+ message: MAILInterswarmMessage,
1266
+ direction: Literal["forward", "back"] = "forward",
1267
+ ) -> None:
1268
+ """
1269
+ Send an interswarm message to a remote swarm.
1270
+ """
1271
+ router = self._runtime.interswarm_router
1272
+ if router is None:
1273
+ raise ValueError("interswarm router not available")
1274
+
1275
+ try:
1276
+ if direction == "forward":
1277
+ await router.send_interswarm_message_forward(message)
1278
+ elif direction == "back":
1279
+ await router.send_interswarm_message_back(message)
1280
+ else:
1281
+ raise ValueError(f"invalid direction: {direction}")
1282
+ except Exception as e:
1283
+ raise ValueError(f"error sending interswarm message: {e}")
1284
+
1285
+ async def post_interswarm_user_message(
1286
+ self,
1287
+ message: MAILInterswarmMessage,
1288
+ ) -> MAILMessage:
1289
+ """
1290
+ Post a message (from an admin or user) to a remote swarm.
1291
+ """
1292
+ router = self._runtime.interswarm_router
1293
+ if router is None:
1294
+ raise ValueError("interswarm router not available")
1295
+
1296
+ try:
1297
+ result = await router.post_interswarm_user_message(message)
1298
+ return result
1299
+ except Exception as e:
1300
+ raise ValueError(f"error posting interswarm user message: {e}")
1301
+
1302
+ def get_subswarm(
1303
+ self, names: list[str], name_suffix: str, entrypoint: str | None = None
1304
+ ) -> "MAILSwarmTemplate":
1305
+ """
1306
+ Get a subswarm of the current swarm. Only agents with names in the `names` list will be included.
1307
+ Returns a `MAILSwarmTemplate`.
1308
+ """
1309
+ agent_lookup = {agent.name: agent for agent in self.agents}
1310
+ selected_agents: list[MAILAgentTemplate] = []
1311
+ for agent_name in names:
1312
+ if agent_name not in agent_lookup:
1313
+ raise ValueError(f"agent '{agent_name}' not found in swarm")
1314
+ agent = agent_lookup[agent_name]
1315
+ filtered_targets = [
1316
+ target for target in agent.comm_targets if target in names
1317
+ ]
1318
+ if agent.name in filtered_targets:
1319
+ filtered_targets.remove(agent.name)
1320
+ if not filtered_targets:
1321
+ fallback_candidates = [n for n in names if n != agent.name]
1322
+ if fallback_candidates:
1323
+ filtered_targets = [fallback_candidates[0]]
1324
+ else:
1325
+ filtered_targets = [agent.name]
1326
+ selected_agents.append(
1327
+ MAILAgentTemplate(
1328
+ name=agent.name,
1329
+ factory=agent.factory,
1330
+ comm_targets=filtered_targets,
1331
+ actions=agent.actions,
1332
+ agent_params=deepcopy(agent.agent_params),
1333
+ enable_entrypoint=agent.enable_entrypoint,
1334
+ enable_interswarm=agent.enable_interswarm,
1335
+ can_complete_tasks=agent.can_complete_tasks,
1336
+ tool_format=agent.tool_format,
1337
+ exclude_tools=agent.exclude_tools,
1338
+ )
1339
+ )
1340
+
1341
+ if entrypoint is None:
1342
+ entrypoint_agent = next(
1343
+ (agent for agent in selected_agents if agent.enable_entrypoint), None
1344
+ )
1345
+ if entrypoint_agent is None:
1346
+ raise ValueError("Subswarm must contain an entrypoint agent")
1347
+ else:
1348
+ entrypoint_agent = next(
1349
+ (agent for agent in selected_agents if agent.name == entrypoint), None
1350
+ )
1351
+ if entrypoint_agent is None:
1352
+ raise ValueError(f"entrypoint agent '{entrypoint}' not found in swarm")
1353
+ entrypoint_agent.enable_entrypoint = True
1354
+
1355
+ if not any(agent.can_complete_tasks for agent in selected_agents):
1356
+ raise ValueError("Subswarm must contain at least one supervisor")
1357
+
1358
+ actions: list[MAILAction] = []
1359
+ seen_actions: dict[str, MAILAction] = {}
1360
+ for agent_template in selected_agents:
1361
+ for action in agent_template.actions:
1362
+ if action.name not in seen_actions:
1363
+ seen_actions[action.name] = action
1364
+ actions = list(seen_actions.values())
1365
+
1366
+ return MAILSwarmTemplate(
1367
+ name=f"{self.name}-{name_suffix}",
1368
+ version=self.version,
1369
+ agents=selected_agents,
1370
+ actions=actions,
1371
+ entrypoint=entrypoint_agent.name,
1372
+ enable_interswarm=self.enable_interswarm,
1373
+ enable_db_agent_histories=self._runtime.enable_db_agent_histories,
1374
+ )
1375
+
1376
+ def get_response_message(self, task_id: str) -> MAILMessage | None:
1377
+ """
1378
+ Get the response message for a given task ID. Mostly used after streaming response events.
1379
+ """
1380
+ return self._runtime.get_response_message(task_id)
1381
+
1382
+ def get_events(self, task_id: str) -> list[ServerSentEvent]:
1383
+ """
1384
+ Get the events for a given task ID. Mostly used after streaming response events.
1385
+ """
1386
+ return self._runtime.get_events_by_task_id(task_id)
1387
+
1388
+ def get_all_tasks(self) -> dict[str, MAILTask]:
1389
+ """
1390
+ Get all tasks for the swarm.
1391
+ """
1392
+ return self._runtime.mail_tasks
1393
+
1394
+ def get_task_by_id(self, task_id: str) -> MAILTask | None:
1395
+ """
1396
+ Get a task by ID.
1397
+ """
1398
+ return self._runtime.mail_tasks.get(task_id)
1399
+
1400
+
1401
+ class MAILSwarmTemplate:
1402
+ """
1403
+ Swarm template class exposed via the MAIL API.
1404
+ This class is used to create a swarm from a JSON dump or file.
1405
+ Unlike MAILSwarm, this class does not have a runtime.
1406
+ `MAILSwarmTemplate.instantiate()` creates a MAILSwarm containing a runtime.
1407
+ """
1408
+
1409
+ def __init__(
1410
+ self,
1411
+ name: str,
1412
+ version: str,
1413
+ agents: list[MAILAgentTemplate],
1414
+ actions: list[MAILAction],
1415
+ entrypoint: str,
1416
+ enable_interswarm: bool = False,
1417
+ breakpoint_tools: list[str] = [],
1418
+ exclude_tools: list[str] = [],
1419
+ task_message_limit: int | None = None,
1420
+ description: str = "",
1421
+ keywords: list[str] = [],
1422
+ public: bool = False,
1423
+ enable_db_agent_histories: bool = False,
1424
+ ) -> None:
1425
+ self.name = name
1426
+ self.version = version
1427
+ self.agents = agents
1428
+ self.actions = actions
1429
+ self.entrypoint = entrypoint
1430
+ self.enable_interswarm = enable_interswarm
1431
+ self.breakpoint_tools = breakpoint_tools
1432
+ self.exclude_tools = exclude_tools
1433
+ self.task_message_limit = task_message_limit
1434
+ self.description = description
1435
+ self.keywords = keywords
1436
+ self.public = public
1437
+ self.enable_db_agent_histories = enable_db_agent_histories
1438
+ self.adjacency_matrix, self.agent_names = self._build_adjacency_matrix()
1439
+ self.supervisors = [agent for agent in agents if agent.can_complete_tasks]
1440
+ self._validate()
1441
+
1442
+ def _log_prelude(self) -> str:
1443
+ """
1444
+ Get the log prelude for the swarm template.
1445
+ """
1446
+ return f"[[green]{self.name}[/green] (template)]"
1447
+
1448
+ def _validate(self) -> None:
1449
+ """
1450
+ Validate an instance of the `MAILSwarmTemplate` class.
1451
+ """
1452
+ if len(self.name) < 1:
1453
+ raise ValueError(
1454
+ f"swarm name must be at least 1 character long, got {len(self.name)}"
1455
+ )
1456
+ if len(self.agents) < 1:
1457
+ raise ValueError(
1458
+ f"swarm must have at least one agent, got {len(self.agents)}"
1459
+ )
1460
+
1461
+ # is the entrypoint valid?
1462
+ entrypoints = [agent.name for agent in self.agents if agent.enable_entrypoint]
1463
+ if len(entrypoints) < 1:
1464
+ raise ValueError(
1465
+ f"swarm must have at least one entrypoint agent, got {len(entrypoints)}"
1466
+ )
1467
+ if self.entrypoint not in entrypoints:
1468
+ raise ValueError(f"entrypoint agent '{self.entrypoint}' not found in swarm")
1469
+
1470
+ # are agent comm targets valid?
1471
+ agent_names = [agent.name for agent in self.agents]
1472
+ for agent in self.agents:
1473
+ for target in agent.comm_targets:
1474
+ interswarm_target = utils.target_address_is_interswarm(target)
1475
+ if interswarm_target and not self.enable_interswarm:
1476
+ raise ValueError(
1477
+ f"agent '{agent.name}' has interswarm communication target '{target}' but interswarm messaging is not enabled for this swarm"
1478
+ )
1479
+ if not interswarm_target and target not in agent_names:
1480
+ raise ValueError(
1481
+ f"agent '{agent.name}' has invalid communication target '{target}'"
1482
+ )
1483
+
1484
+ # is there at least one supervisor?
1485
+ if len(self.supervisors) < 1:
1486
+ raise ValueError(
1487
+ f"swarm must have at least one supervisor, got {len(self.supervisors)}"
1488
+ )
1489
+
1490
+ # is each breakpoint tool valid?
1491
+ for tool in self.breakpoint_tools:
1492
+ if tool not in MAIL_TOOL_NAMES + [action.name for action in self.actions]:
1493
+ raise ValueError(f"breakpoint tool '{tool}' not found in swarm")
1494
+
1495
+ # are the excluded tools valid?
1496
+ for tool in self.exclude_tools:
1497
+ if tool not in MAIL_TOOL_NAMES:
1498
+ raise ValueError(f"excluded tool '{tool}' is not valid")
1499
+
1500
+ def _build_adjacency_matrix(self) -> tuple[list[list[int]], list[str]]:
1501
+ """
1502
+ Build an adjacency matrix for the swarm.
1503
+ Returns a tuple of the adjacency matrix and the map of agent names to indices.
1504
+ """
1505
+ agent_names = [agent.name for agent in self.agents]
1506
+ name_to_index = {name: idx for idx, name in enumerate(agent_names)}
1507
+ adj = [[0 for _ in agent_names] for _ in agent_names]
1508
+
1509
+ for agent in self.agents:
1510
+ row_idx = name_to_index[agent.name]
1511
+ for target_name in agent.comm_targets:
1512
+ target_idx = name_to_index.get(target_name)
1513
+ if target_idx is not None:
1514
+ adj[row_idx][target_idx] = 1
1515
+
1516
+ return adj, agent_names
1517
+
1518
+ def update_from_adjacency_matrix(self, adj: list[list[int]]) -> None:
1519
+ """
1520
+ Update comm_targets for all agents using an adjacency matrix.
1521
+ """
1522
+
1523
+ if len(adj) != len(self.agents):
1524
+ raise ValueError(
1525
+ f"Length of adjacency matrix does not match number of agents. Expected: {len(self.agents)} Got: {len(adj)}"
1526
+ )
1527
+
1528
+ idx_to_name = {idx: name for idx, name in enumerate(self.agent_names)}
1529
+ for i, agent_adj in enumerate(adj):
1530
+ if len(agent_adj) != len(adj):
1531
+ raise ValueError(
1532
+ f"Adjacency matrix is malformed. Expected number of agents: {len(adj)} Got: {len(agent_adj)}"
1533
+ )
1534
+
1535
+ target_idx = [j for j, x in enumerate(agent_adj) if x]
1536
+ new_targets = [idx_to_name[idx] for idx in target_idx]
1537
+ self.agents[i].comm_targets = new_targets
1538
+
1539
+ def instantiate(
1540
+ self,
1541
+ instance_params: dict[str, Any],
1542
+ user_id: str = "default_user",
1543
+ user_role: Literal["admin", "agent", "user"] = "user",
1544
+ base_url: str = "http://localhost:8000",
1545
+ registry_file: str | None = None,
1546
+ ) -> MAILSwarm:
1547
+ """
1548
+ Instantiate a MAILSwarm from a MAILSwarmTemplate.
1549
+ """
1550
+ if self.enable_interswarm:
1551
+ swarm_registry = SwarmRegistry(
1552
+ self.name,
1553
+ base_url,
1554
+ registry_file,
1555
+ local_swarm_description=self.description,
1556
+ local_swarm_keywords=self.keywords,
1557
+ local_swarm_public=self.public,
1558
+ )
1559
+ else:
1560
+ swarm_registry = None
1561
+
1562
+ agents = [
1563
+ agent.instantiate(
1564
+ instance_params, additional_exclude_tools=self.exclude_tools
1565
+ )
1566
+ for agent in self.agents
1567
+ ]
1568
+
1569
+ for agent in agents:
1570
+ if isinstance(agent.function, MAILAgentFunction):
1571
+ function = agent.function
1572
+ if hasattr(function, "supervisor_fn"):
1573
+ function = function.supervisor_fn # type: ignore
1574
+ if hasattr(function, "action_agent_fn"):
1575
+ function = function.action_agent_fn # type: ignore
1576
+ logger.debug(
1577
+ f"{self._log_prelude()} updating system prompt for agent '{agent.name}'"
1578
+ )
1579
+ delimiter = (
1580
+ "Here are details about the agents you can communicate with:"
1581
+ )
1582
+ prompt: str = function.system # type: ignore
1583
+ if delimiter in prompt:
1584
+ lines = prompt.splitlines()
1585
+ result_lines = []
1586
+ for line in lines:
1587
+ if delimiter in line:
1588
+ break
1589
+ result_lines.append(line)
1590
+ prompt = "\n".join(result_lines)
1591
+ prompt += f"\n\n{delimiter}\n\n"
1592
+ else:
1593
+ prompt += f"\n\n{delimiter}\n\n"
1594
+ targets_as_agents = [a for a in agents if a.name in agent.comm_targets]
1595
+ for t in targets_as_agents:
1596
+ prompt += f"Name: {t.name}\n"
1597
+ prompt += "Capabilities:\n"
1598
+ fn = t.function
1599
+ logger.debug(
1600
+ f"{self._log_prelude()} found target agent with fn of type '{type(fn)}'"
1601
+ )
1602
+ if isinstance(fn, MAILAgentFunction):
1603
+ logger.debug("found target agent with MAILAgentFunction")
1604
+ web_search = any(t["type"] == "web_search" for t in fn.tools)
1605
+ code_interpreter = any(
1606
+ t["type"] == "code_interpreter" for t in fn.tools
1607
+ )
1608
+ if web_search and code_interpreter:
1609
+ prompt += "- This agent can search the web\n- This agent can execute code. The code it writes cannot access the internet."
1610
+ if web_search and not code_interpreter:
1611
+ prompt += "- This agent can search the web\n- This agent cannot execute code"
1612
+ if not web_search and code_interpreter:
1613
+ prompt += "- This agent can execute code. The code it writes cannot access the internet.\n- This agent cannot search the web"
1614
+ if not web_search and not code_interpreter:
1615
+ prompt += "- This agent does not have access to tools, the internet, real-time data, etc."
1616
+ else:
1617
+ prompt += "- This agent does not have access to tools, the internet, real-time data, etc."
1618
+ prompt += "\n\n"
1619
+ prompt.strip()
1620
+ logger.debug(
1621
+ f"{self._log_prelude()} updated system prompt for agent '{agent.name}' to '{prompt[:25]}...'"
1622
+ )
1623
+ function.system = prompt # type: ignore
1624
+
1625
+ return MAILSwarm(
1626
+ name=self.name,
1627
+ version=self.version,
1628
+ agents=agents,
1629
+ actions=self.actions,
1630
+ entrypoint=self.entrypoint,
1631
+ user_id=user_id,
1632
+ user_role=user_role,
1633
+ swarm_registry=swarm_registry,
1634
+ enable_interswarm=self.enable_interswarm,
1635
+ breakpoint_tools=self.breakpoint_tools,
1636
+ exclude_tools=self.exclude_tools,
1637
+ task_message_limit=self.task_message_limit,
1638
+ description=self.description,
1639
+ keywords=self.keywords,
1640
+ enable_db_agent_histories=self.enable_db_agent_histories,
1641
+ )
1642
+
1643
+ def get_subswarm(
1644
+ self, names: list[str], name_suffix: str, entrypoint: str | None = None
1645
+ ) -> "MAILSwarmTemplate":
1646
+ """
1647
+ Get a subswarm of the current swarm. Only agents with names in the `names` list will be included.
1648
+ Returns a `MAILSwarmTemplate`.
1649
+ """
1650
+ agent_lookup = {agent.name: agent for agent in self.agents}
1651
+ selected_agents: list[MAILAgentTemplate] = []
1652
+ for agent_name in names:
1653
+ if agent_name not in agent_lookup:
1654
+ raise ValueError(f"agent '{agent_name}' not found in swarm")
1655
+ agent = agent_lookup[agent_name]
1656
+ filtered_targets = [
1657
+ target for target in agent.comm_targets if target in names
1658
+ ]
1659
+ if agent.name in filtered_targets:
1660
+ filtered_targets.remove(agent.name)
1661
+ if not filtered_targets:
1662
+ fallback_candidates = [n for n in names if n != agent.name]
1663
+ if fallback_candidates:
1664
+ filtered_targets = [fallback_candidates[0]]
1665
+ else:
1666
+ filtered_targets = [agent.name]
1667
+ selected_agents.append(
1668
+ MAILAgentTemplate(
1669
+ name=agent.name,
1670
+ factory=agent.factory,
1671
+ comm_targets=filtered_targets,
1672
+ actions=agent.actions,
1673
+ agent_params=deepcopy(agent.agent_params),
1674
+ enable_entrypoint=agent.enable_entrypoint,
1675
+ enable_interswarm=agent.enable_interswarm,
1676
+ can_complete_tasks=agent.can_complete_tasks,
1677
+ tool_format=agent.tool_format,
1678
+ exclude_tools=agent.exclude_tools,
1679
+ )
1680
+ )
1681
+
1682
+ if entrypoint is None:
1683
+ entrypoint_agent = next(
1684
+ (agent for agent in selected_agents if agent.enable_entrypoint), None
1685
+ )
1686
+ if entrypoint_agent is None:
1687
+ raise ValueError("Subswarm must contain an entrypoint agent")
1688
+ else:
1689
+ entrypoint_agent = next(
1690
+ (agent for agent in selected_agents if agent.name == entrypoint), None
1691
+ )
1692
+ if entrypoint_agent is None:
1693
+ raise ValueError(f"entrypoint agent '{entrypoint}' not found in swarm")
1694
+ entrypoint_agent.enable_entrypoint = True
1695
+
1696
+ if not any(agent.can_complete_tasks for agent in selected_agents):
1697
+ raise ValueError("Subswarm must contain at least one supervisor")
1698
+
1699
+ actions: list[MAILAction] = []
1700
+ seen_actions: dict[str, MAILAction] = {}
1701
+ for agent_template in selected_agents:
1702
+ for action in agent_template.actions:
1703
+ if action.name not in seen_actions:
1704
+ seen_actions[action.name] = action
1705
+ actions = list(seen_actions.values())
1706
+
1707
+ return MAILSwarmTemplate(
1708
+ name=f"{self.name}-{name_suffix}",
1709
+ version=self.version,
1710
+ agents=selected_agents,
1711
+ actions=actions,
1712
+ entrypoint=entrypoint_agent.name,
1713
+ enable_interswarm=self.enable_interswarm,
1714
+ breakpoint_tools=self.breakpoint_tools,
1715
+ exclude_tools=self.exclude_tools,
1716
+ enable_db_agent_histories=self.enable_db_agent_histories,
1717
+ )
1718
+
1719
+ @staticmethod
1720
+ def from_swarms_json(
1721
+ swarm_data: SwarmsJSONSwarm, task_message_limit: int | None = None
1722
+ ) -> "MAILSwarmTemplate":
1723
+ """
1724
+ Create a `MAILSwarmTemplate` from a pre-parsed `SwarmsJSONSwarm` definition.
1725
+ """
1726
+ inline_actions = [
1727
+ MAILAction.from_swarms_json(action) for action in swarm_data["actions"]
1728
+ ]
1729
+ imported_actions: list[MAILAction] = []
1730
+ for import_path in swarm_data.get("action_imports", []):
1731
+ resolved = read_python_string(import_path)
1732
+ if not isinstance(resolved, MAILAction):
1733
+ raise TypeError(
1734
+ f"action import '{import_path}' in swarm '{swarm_data['name']}' did not resolve to a MAILAction"
1735
+ )
1736
+ imported_actions.append(resolved)
1737
+
1738
+ combined_actions: dict[str, MAILAction] = {}
1739
+ for action in imported_actions + inline_actions:
1740
+ existing = combined_actions.get(action.name)
1741
+ if existing and existing is not action:
1742
+ raise ValueError(
1743
+ f"duplicate action definition for '{action.name}' in swarm '{swarm_data['name']}'"
1744
+ )
1745
+ combined_actions[action.name] = action
1746
+
1747
+ actions = list(combined_actions.values())
1748
+ actions_by_name = {action.name: action for action in actions}
1749
+ agents = [
1750
+ MAILAgentTemplate.from_swarms_json(agent, actions_by_name)
1751
+ for agent in swarm_data["agents"]
1752
+ ]
1753
+
1754
+ return MAILSwarmTemplate(
1755
+ name=swarm_data["name"],
1756
+ version=swarm_data["version"],
1757
+ agents=agents,
1758
+ actions=actions,
1759
+ entrypoint=swarm_data["entrypoint"],
1760
+ enable_interswarm=swarm_data["enable_interswarm"],
1761
+ breakpoint_tools=swarm_data["breakpoint_tools"],
1762
+ exclude_tools=swarm_data["exclude_tools"],
1763
+ task_message_limit=task_message_limit,
1764
+ description=swarm_data.get("description", ""),
1765
+ keywords=swarm_data.get("keywords", []),
1766
+ public=swarm_data.get("public", False),
1767
+ enable_db_agent_histories=swarm_data.get(
1768
+ "enable_db_agent_histories", False
1769
+ ),
1770
+ )
1771
+
1772
+ @staticmethod
1773
+ def from_swarm_json(
1774
+ json_dump: str, task_message_limit: int | None = None
1775
+ ) -> "MAILSwarmTemplate":
1776
+ """
1777
+ Create a `MAILSwarmTemplate` from a JSON dump following the `swarms.json` format.
1778
+ """
1779
+ import json as _json
1780
+
1781
+ swarm_candidate = _json.loads(json_dump)
1782
+ parsed_swarm = build_swarm_from_swarms_json(swarm_candidate)
1783
+ return MAILSwarmTemplate.from_swarms_json(parsed_swarm, task_message_limit)
1784
+
1785
+ @staticmethod
1786
+ def from_swarm_json_file(
1787
+ swarm_name: str,
1788
+ json_filepath: str = "swarms.json",
1789
+ task_message_limit: int | None = None,
1790
+ ) -> "MAILSwarmTemplate":
1791
+ """
1792
+ Create a `MAILSwarmTemplate` from a JSON file following the `swarms.json` format.
1793
+ """
1794
+ swarms_file = load_swarms_json_from_file(json_filepath)
1795
+ swarms = build_swarms_from_swarms_json(swarms_file["swarms"])
1796
+ for swarm in swarms:
1797
+ if swarm["name"] == swarm_name:
1798
+ return MAILSwarmTemplate.from_swarms_json(swarm, task_message_limit)
1799
+ raise ValueError(f"swarm '{swarm_name}' not found in {json_filepath}")
1800
+
1801
+ def start_server(
1802
+ self,
1803
+ port: int = 8000,
1804
+ host: str = "0.0.0.0",
1805
+ launch_ui: bool = True,
1806
+ ui_port: int = 3000,
1807
+ ui_path: str | None = None,
1808
+ open_browser: bool = True,
1809
+ server_url: str | None = None,
1810
+ ) -> None:
1811
+ """Start a MAIL server with this swarm template.
1812
+
1813
+ Blocks until Ctrl+C. Runs in single-process mode only.
1814
+ Debug mode is always enabled for /ui/message endpoint.
1815
+
1816
+ Args:
1817
+ port: Server port (default 8000)
1818
+ host: Server host (default 0.0.0.0)
1819
+ launch_ui: Start Next.js UI dev server (default True)
1820
+ ui_port: UI dev server port (default 3000)
1821
+ ui_path: Path to UI directory (auto-detected if None)
1822
+ open_browser: Open browser to UI URL on startup (default True)
1823
+ server_url: URL the UI uses to connect to server (default: http://localhost:{port})
1824
+
1825
+ Raises:
1826
+ ValueError: If template validation fails
1827
+ FileNotFoundError: If UI directory not found or node_modules missing
1828
+ RuntimeError: If UI process fails to start
1829
+ OSError: If port is already in use
1830
+ """
1831
+ import os
1832
+ import socket
1833
+ import subprocess
1834
+ import time
1835
+ import webbrowser
1836
+
1837
+ from mail.server import run_server_with_template
1838
+
1839
+ # Validate template first (already validated in __init__, but re-check)
1840
+ self._validate()
1841
+
1842
+ # Resolve UI path
1843
+ if ui_path is None:
1844
+ package_dir = os.path.dirname(os.path.abspath(__file__))
1845
+ ui_path = os.path.normpath(os.path.join(package_dir, "..", "..", "ui"))
1846
+
1847
+ # Compute server URL for UI to connect to
1848
+ # Smart default: localhost for wildcard bindings, else use the actual host
1849
+ if server_url is None:
1850
+ # Wildcard addresses (IPv4 0.0.0.0, IPv6 ::) and localhost variants -> localhost
1851
+ if host in ("0.0.0.0", "127.0.0.1", "localhost", "::", "::1"):
1852
+ server_url = f"http://localhost:{port}"
1853
+ else:
1854
+ server_url = f"http://{host}:{port}"
1855
+
1856
+ ui_proc = None
1857
+
1858
+ if launch_ui:
1859
+ # Validate UI directory exists
1860
+ if not os.path.isdir(ui_path):
1861
+ raise FileNotFoundError(
1862
+ f"UI directory not found at {ui_path}. "
1863
+ f"Set ui_path parameter or use launch_ui=False."
1864
+ )
1865
+
1866
+ # Check node_modules exists
1867
+ node_modules = os.path.join(ui_path, "node_modules")
1868
+ if not os.path.isdir(node_modules):
1869
+ raise FileNotFoundError(
1870
+ f"node_modules not found in {ui_path}. "
1871
+ f"Run 'pnpm install' in the UI directory first."
1872
+ )
1873
+
1874
+ # Check if UI port is available before launching
1875
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
1876
+ if s.connect_ex(("localhost", ui_port)) == 0:
1877
+ raise OSError(f"UI port {ui_port} is already in use. Try a different ui_port.")
1878
+
1879
+ # Print startup banner
1880
+ print(f"\n{'='*60}")
1881
+ print(f" MAIL Swarm Viewer")
1882
+ print(f" Swarm: {self.name}")
1883
+ print(f" Agents: {', '.join(self.agent_names)}")
1884
+ print(f"{'='*60}")
1885
+ print(f" Server: http://{host}:{port}")
1886
+ print(f" UI: http://localhost:{ui_port}")
1887
+ print(f"{'='*60}")
1888
+ print(f" Press Ctrl+C to stop\n")
1889
+
1890
+ # Set up environment for UI to connect to server
1891
+ ui_env = os.environ.copy()
1892
+ ui_env["NEXT_PUBLIC_MAIL_SERVER_URL"] = server_url
1893
+
1894
+ # Start UI dev server in background
1895
+ # Note: stdout/stderr suppressed for cleaner output. If UI fails to start,
1896
+ # the error message directs users to run pnpm dev manually.
1897
+ ui_proc = subprocess.Popen(
1898
+ ["pnpm", "dev", "--port", str(ui_port)],
1899
+ cwd=ui_path,
1900
+ env=ui_env,
1901
+ stdout=subprocess.DEVNULL,
1902
+ stderr=subprocess.DEVNULL,
1903
+ )
1904
+
1905
+ # Poll to verify UI process started successfully
1906
+ time.sleep(2)
1907
+ if ui_proc.poll() is not None:
1908
+ # Process exited - UI failed to start
1909
+ raise RuntimeError(
1910
+ f"UI dev server failed to start (exit code {ui_proc.returncode}).\n"
1911
+ f"To see the actual error, run manually:\n"
1912
+ f" cd {ui_path} && pnpm dev --port {ui_port}"
1913
+ )
1914
+
1915
+ # Open browser (with WSL2 support)
1916
+ if open_browser:
1917
+ url = f"http://localhost:{ui_port}"
1918
+ # Detect WSL2 and use Windows browser
1919
+ # os.uname() doesn't exist on native Windows, so guard with try/except
1920
+ is_wsl = False
1921
+ try:
1922
+ is_wsl = "microsoft" in os.uname().release.lower()
1923
+ except AttributeError:
1924
+ pass # Native Windows - os.uname() doesn't exist
1925
+
1926
+ if is_wsl:
1927
+ # WSL2: use cmd.exe to open Windows default browser
1928
+ subprocess.Popen(
1929
+ ["cmd.exe", "/c", "start", "", url],
1930
+ stdout=subprocess.DEVNULL,
1931
+ stderr=subprocess.DEVNULL,
1932
+ )
1933
+ else:
1934
+ webbrowser.open(url)
1935
+ else:
1936
+ # Server-only banner
1937
+ print(f"\n{'='*60}")
1938
+ print(f" MAIL Server")
1939
+ print(f" Swarm: {self.name}")
1940
+ print(f" Agents: {', '.join(self.agent_names)}")
1941
+ print(f"{'='*60}")
1942
+ print(f" Server: http://{host}:{port}")
1943
+ print(f"{'='*60}")
1944
+ print(f" Press Ctrl+C to stop\n")
1945
+
1946
+ try:
1947
+ run_server_with_template(
1948
+ template=self,
1949
+ port=port,
1950
+ host=host,
1951
+ task_message_limit=None,
1952
+ )
1953
+ except OSError as e:
1954
+ if "Address already in use" in str(e) or getattr(e, 'errno', None) == 98:
1955
+ raise OSError(f"Port {port} is already in use. Try a different port.") from e
1956
+ raise
1957
+ finally:
1958
+ # Graceful shutdown of UI process
1959
+ if ui_proc is not None:
1960
+ ui_proc.terminate()
1961
+ try:
1962
+ ui_proc.wait(timeout=5)
1963
+ except subprocess.TimeoutExpired:
1964
+ ui_proc.kill()