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/utils/logger.py ADDED
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ import datetime
5
+ import logging
6
+ import os
7
+
8
+ from rich.logging import RichHandler
9
+
10
+
11
+ def get_loggers():
12
+ """
13
+ Get the loggers for the application.
14
+ """
15
+ return list(logging.root.manager.loggerDict.keys())
16
+
17
+
18
+ def init_logger():
19
+ """
20
+ Initialize the logger for the application.
21
+ """
22
+ # Create logs directory if it doesn't exist
23
+ os.makedirs("logs", exist_ok=True)
24
+
25
+ # File handler
26
+ file_handler = logging.FileHandler(
27
+ f"logs/mail_{datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d')}.log"
28
+ )
29
+ file_handler.setLevel(logging.INFO)
30
+ file_handler.setFormatter(
31
+ logging.Formatter("%(asctime)s [%(levelname)s] [%(name)s] :: %(message)s")
32
+ )
33
+
34
+ # for all loggers that are not mail, clear all handlers
35
+ # and then add only the file handler above
36
+ for logger in get_loggers():
37
+ if not logger.startswith("mail"):
38
+ logging.getLogger(logger).propagate = False
39
+ logging.getLogger(logger).handlers.clear()
40
+ logging.getLogger(logger).addHandler(file_handler)
41
+
42
+ # Rich handler for colored console output
43
+ # All `mail.*` loggers should use this handler
44
+ # Use `mailquiet.*` for loggers that should not be verbose
45
+ console_handler = RichHandler(
46
+ rich_tracebacks=True,
47
+ show_time=True,
48
+ show_level=True,
49
+ show_path=True,
50
+ markup=True,
51
+ tracebacks_show_locals=True,
52
+ )
53
+ console_handler.setLevel(logging.DEBUG)
54
+
55
+ # Configure the mail logger (using the actual module name)
56
+ mail_logger = logging.getLogger("mail")
57
+ mail_logger.setLevel(logging.DEBUG)
58
+ mail_logger.propagate = False # Prevent double logging
59
+
60
+ # Clear any existing handlers
61
+ mail_logger.handlers.clear()
62
+
63
+ # Add our handlers
64
+ mail_logger.addHandler(console_handler)
65
+ mail_logger.addHandler(file_handler)
66
+
67
+ # Configure root logger to avoid conflicts with Uvicorn
68
+ root_logger = logging.getLogger()
69
+ root_logger.setLevel(logging.INFO)
70
+
71
+ # Only add file handler to root if it doesn't already have handlers
72
+ if not root_logger.handlers:
73
+ root_logger.addHandler(file_handler)
mail/utils/openai.py ADDED
@@ -0,0 +1,212 @@
1
+ import asyncio
2
+ import uuid
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ import ujson
7
+ from openai.types.responses import (
8
+ Response,
9
+ ResponseFunctionToolCall,
10
+ ResponseOutputMessage,
11
+ ResponseOutputText,
12
+ )
13
+ from pydantic import BaseModel, ValidationError
14
+
15
+ from mail.api import MAILAction, MAILSwarm, MAILSwarmTemplate
16
+ from mail.utils.serialize import dump_mail_result
17
+
18
+
19
+ async def async_lambda(x: Any) -> Any:
20
+ return x
21
+
22
+
23
+ def build_oai_clients_dict() -> dict[str, "SwarmOAIClient"]:
24
+ """
25
+ Build a dictionary of SwarmOAIClient instances for each API key in the environment.
26
+ """
27
+ return {}
28
+
29
+
30
+ class SwarmOAIClient:
31
+ def __init__(
32
+ self,
33
+ template: MAILSwarmTemplate,
34
+ instance: MAILSwarm | None = None,
35
+ validate_responses: bool = True,
36
+ ):
37
+ self.responses = self.Responses(self)
38
+ self.template = template
39
+ self.result_dumps: dict[str, list[Any]] = {}
40
+ self.swarm = instance
41
+ self.validate_responses = validate_responses
42
+
43
+ class Responses:
44
+ def __init__(self, owner: "SwarmOAIClient"):
45
+ self.owner = owner
46
+
47
+ async def create(
48
+ self,
49
+ input: list[dict[str, Any]],
50
+ tools: list[dict[str, Any]],
51
+ instructions: str | None = None,
52
+ previous_response_id: str | None = None,
53
+ tool_choice: str | dict[str, str] = "auto",
54
+ parallel_tool_calls: bool = True,
55
+ api_key: str | None = None,
56
+ **kwargs: Any,
57
+ ) -> Response:
58
+ if self.owner.swarm is None:
59
+ new_swarm = self.owner.template
60
+ complete_agent = next(
61
+ (a for a in new_swarm.agents if a.can_complete_tasks), None
62
+ )
63
+ assert complete_agent is not None
64
+ if instructions is not None:
65
+ raw_sys_msg = {"content": instructions}
66
+ else:
67
+ raw_sys_msg = next(
68
+ (
69
+ input_item # type: ignore
70
+ for input_item in input
71
+ if (
72
+ "role" in input_item
73
+ and input_item["role"] == "system"
74
+ or input_item["role"] == "developer"
75
+ )
76
+ ),
77
+ {"content": ""},
78
+ )
79
+ complete_agent.agent_params["system"] = (
80
+ complete_agent.agent_params["system"] + raw_sys_msg["content"]
81
+ )
82
+ if len(tools) > 0:
83
+ new_actions: list[MAILAction] = []
84
+ for tool in tools:
85
+ name = tool["name"]
86
+ description = tool["description"]
87
+ parameters = tool["parameters"]
88
+ new_actions.append(
89
+ MAILAction(
90
+ name=name,
91
+ description=description,
92
+ parameters=parameters,
93
+ function=async_lambda,
94
+ )
95
+ )
96
+ complete_agent.actions += new_actions
97
+ new_swarm.actions += new_actions
98
+ complete_agent.agent_params["system"] = (
99
+ complete_agent.agent_params["system"]
100
+ + f"\n\nYou can perform actions in the environment by calling one of the following tools: {', '.join([a.name for a in new_actions])}"
101
+ )
102
+ new_swarm.breakpoint_tools = [a.name for a in new_actions]
103
+
104
+ self.owner.swarm = new_swarm.instantiate({"user_token": ""})
105
+ asyncio.create_task(self.owner.swarm.run_continuous())
106
+ swarm = self.owner.swarm
107
+ body = ""
108
+ if "type" in input[-1] and input[-1]["type"] == "function_call_output":
109
+ tool_responses: list[dict[str, Any]] = []
110
+ for input_item in reversed(input):
111
+ if (
112
+ "type" not in input_item
113
+ or input_item["type"] == "function_call"
114
+ ):
115
+ break
116
+ if input_item["type"] == "function_call_output":
117
+ tool_responses.append(
118
+ {
119
+ "call_id": input_item["call_id"],
120
+ "content": input_item["output"],
121
+ }
122
+ )
123
+ out, events = await swarm.post_message(
124
+ body="",
125
+ subject="Tool Response",
126
+ task_id=previous_response_id,
127
+ show_events=True,
128
+ resume_from="breakpoint_tool_call",
129
+ breakpoint_tool_call_result=tool_responses,
130
+ )
131
+ else:
132
+ for input_item in reversed(input):
133
+ if isinstance(input_item, BaseModel):
134
+ input_item = input_item.model_dump()
135
+ if (
136
+ ("role" in input_item and input_item["role"] == "assistant")
137
+ or "type" in input_item
138
+ and (
139
+ input_item["type"]
140
+ in ["function_call_output", "function_call"]
141
+ )
142
+ ):
143
+ break
144
+ body = f"{input_item['content']}\n\n{body}"
145
+ out, events = await swarm.post_message(
146
+ body=body,
147
+ subject="Task Request",
148
+ task_id=previous_response_id,
149
+ show_events=True,
150
+ )
151
+ response_id = out["message"]["task_id"]
152
+ dump = dump_mail_result(result=out, events=events, verbose=True)
153
+ if response_id not in self.owner.result_dumps:
154
+ self.owner.result_dumps[response_id] = []
155
+ self.owner.result_dumps[response_id].append(dump)
156
+ has_called_tools = out["message"]["subject"] == "::breakpoint_tool_call::"
157
+ if not has_called_tools:
158
+ response = Response(
159
+ id=response_id,
160
+ created_at=float(datetime.now().timestamp()),
161
+ model=f"{swarm.name}",
162
+ object="response",
163
+ tools=tools, # type: ignore
164
+ output=[
165
+ ResponseOutputMessage(
166
+ type="message",
167
+ id=str(uuid.uuid4()),
168
+ status="completed",
169
+ role="assistant",
170
+ content=[
171
+ ResponseOutputText(
172
+ type="output_text",
173
+ text=out["message"]["body"],
174
+ annotations=[],
175
+ )
176
+ ],
177
+ )
178
+ ],
179
+ parallel_tool_calls=parallel_tool_calls,
180
+ tool_choice=tool_choice, # type: ignore
181
+ )
182
+ return response
183
+
184
+ tool_calls: list[ResponseFunctionToolCall] = []
185
+ body = ujson.loads(out["message"]["body"])
186
+ for tool_call in body:
187
+ tool_calls.append(
188
+ ResponseFunctionToolCall(
189
+ call_id=tool_call["call_id"],
190
+ name=tool_call["name"],
191
+ arguments=tool_call["arguments"],
192
+ type="function_call",
193
+ id=tool_call["id"],
194
+ status=tool_call["status"],
195
+ )
196
+ )
197
+ try:
198
+ return Response(
199
+ id=response_id,
200
+ created_at=float(datetime.now().timestamp()),
201
+ model=f"{swarm.name}",
202
+ object="response",
203
+ tools=tools, # type: ignore
204
+ output=tool_calls, # type: ignore
205
+ parallel_tool_calls=parallel_tool_calls,
206
+ tool_choice=tool_choice, # type: ignore
207
+ )
208
+ except ValidationError as e:
209
+ if self.owner.validate_responses:
210
+ raise e
211
+ else:
212
+ return response
mail/utils/parsing.py ADDED
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2025 Addison Kline
3
+
4
+ import importlib
5
+ import json
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from mail.core import parse_agent_address
11
+
12
+ PYTHON_STRING_PREFIX = "python::"
13
+ URL_STRING_PREFIX = "url::"
14
+
15
+
16
+ def read_python_string(string: str) -> Any:
17
+ """
18
+ Resolve an import string to a Python object.
19
+
20
+ Accepts strings in the format ``module:variable`` or with the explicit
21
+ ``python::`` prefix used in swarm configuration files, e.g.
22
+ ``python::package.module:object``.
23
+ """
24
+
25
+ if string.startswith(PYTHON_STRING_PREFIX):
26
+ string = string[len(PYTHON_STRING_PREFIX) :]
27
+
28
+ try:
29
+ module_str, attribute_path = string.split(":", 1)
30
+ except ValueError as err: # pragma: no cover - defensive guard
31
+ raise ValueError(
32
+ f"Invalid python reference '{string}'. Expected 'module:object' format."
33
+ ) from err
34
+
35
+ module = importlib.import_module(module_str)
36
+ obj: Any = module
37
+ for attr in attribute_path.split("."):
38
+ obj = getattr(obj, attr)
39
+
40
+ return obj
41
+
42
+
43
+ def resolve_prefixed_string_references(value: Any) -> Any:
44
+ """
45
+ Recursively resolve strings prefixed with ``python::`` or ``url::`` to Python objects or strings, respectively.
46
+ """
47
+
48
+ if isinstance(value, dict):
49
+ return {
50
+ key: resolve_prefixed_string_references(item) for key, item in value.items()
51
+ }
52
+ if isinstance(value, list):
53
+ return [resolve_prefixed_string_references(item) for item in value]
54
+ if isinstance(value, str):
55
+ if value.startswith(PYTHON_STRING_PREFIX):
56
+ return read_python_string(value)
57
+ if value.startswith(URL_STRING_PREFIX):
58
+ return read_url_string(value)
59
+ return value
60
+
61
+ return value
62
+
63
+
64
+ def read_url_string(string: str, raise_on_error: bool = False) -> str:
65
+ """
66
+ Resolve a URL to a string.
67
+
68
+ Accepts strings in the format ``url::`` prefix used in swarm configuration files, e.g.
69
+ ``url::https://example.com``.
70
+ """
71
+
72
+ if string.startswith(URL_STRING_PREFIX):
73
+ string = string[len(URL_STRING_PREFIX) :]
74
+
75
+ try:
76
+ response = httpx.get(string)
77
+ return json.dumps(response.json())
78
+ except Exception as e:
79
+ if raise_on_error:
80
+ raise RuntimeError(f"error reading URL string: '{str(e)}'")
81
+ return string
82
+
83
+
84
+ def target_address_is_interswarm(address: str) -> bool:
85
+ """
86
+ Check if a target address is an interswarm address.
87
+ """
88
+
89
+ return parse_agent_address(address)[1] is not None
@@ -0,0 +1,292 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import asdict, is_dataclass
3
+ from typing import Any
4
+
5
+ import ujson
6
+ from sse_starlette import ServerSentEvent
7
+
8
+ from mail.core.message import MAILMessage
9
+ from mail.utils.version import get_version
10
+
11
+ _REDACT_KEYS = {
12
+ "id",
13
+ "task_id",
14
+ "request_id",
15
+ "broadcast_id",
16
+ "message_id",
17
+ "event_id",
18
+ "timestamp",
19
+ "created_at",
20
+ "updated_at",
21
+ "sent_at",
22
+ "received_at",
23
+ }
24
+
25
+
26
+ def dump_mail_result(
27
+ result: MAILMessage,
28
+ events: list[ServerSentEvent],
29
+ verbose: bool = False,
30
+ ) -> str:
31
+ """
32
+ For a completed MAIL task, create an LLM-friendly string dump of the result and events.
33
+ """
34
+ serialized_result = serialize_mail_value(result, exclude_keys=_REDACT_KEYS)
35
+ if not verbose:
36
+ return str(serialized_result["message"]["body"])
37
+
38
+ serialized_events = []
39
+ for event in events:
40
+ serialized = _serialize_event(event, exclude_keys=_REDACT_KEYS)
41
+ if serialized is not None:
42
+ serialized_events.append(serialized)
43
+
44
+ event_sections = _format_event_sections(serialized_events)
45
+ result_section = str(serialized_result["message"]["body"])
46
+
47
+ sections: list[str] = [
48
+ "=== MAIL EVENTS ===",
49
+ event_sections if event_sections else "(no events)",
50
+ "=== FINAL ANSWER ===",
51
+ result_section,
52
+ ]
53
+ return "\n".join(sections)
54
+
55
+
56
+ def serialize_mail_value(
57
+ value: Any, *, exclude_keys: frozenset[str] | set[str] | None = None
58
+ ) -> Any:
59
+ """
60
+ Convert MAIL runtime objects into JSON-friendly primitives.
61
+ """
62
+ exclude_keys = frozenset() if exclude_keys is None else frozenset(exclude_keys)
63
+
64
+ if value is None or isinstance(value, str | int | float | bool):
65
+ return value
66
+ if isinstance(value, bytes):
67
+ return value.decode("utf-8", errors="replace")
68
+ if isinstance(value, Mapping):
69
+ return {
70
+ k: serialize_mail_value(v, exclude_keys=exclude_keys)
71
+ for k, v in value.items()
72
+ if k not in exclude_keys
73
+ }
74
+ if isinstance(value, list | tuple | set | frozenset):
75
+ return [serialize_mail_value(v, exclude_keys=exclude_keys) for v in value]
76
+ if is_dataclass(value):
77
+ return serialize_mail_value(asdict(value), exclude_keys=exclude_keys) # type: ignore[arg-type]
78
+ if hasattr(value, "model_dump"):
79
+ try:
80
+ dumped = value.model_dump()
81
+ except TypeError:
82
+ dumped = value.model_dump(mode="python")
83
+ return serialize_mail_value(dumped, exclude_keys=exclude_keys)
84
+ if hasattr(value, "dict"):
85
+ try:
86
+ dumped = value.dict()
87
+ except TypeError:
88
+ dumped = value.dict()
89
+ return serialize_mail_value(dumped, exclude_keys=exclude_keys)
90
+ if hasattr(value, "__dict__"):
91
+ return {
92
+ k: serialize_mail_value(v, exclude_keys=exclude_keys)
93
+ for k, v in vars(value).items()
94
+ if not k.startswith("_")
95
+ }
96
+ return str(value)
97
+
98
+
99
+ def _normalise_event_payload(payload: Any) -> Any:
100
+ if isinstance(payload, str):
101
+ try:
102
+ return ujson.loads(payload)
103
+ except ValueError:
104
+ return payload
105
+ return payload
106
+
107
+
108
+ def _serialize_event(
109
+ event: ServerSentEvent, *, exclude_keys: frozenset[str] | set[str] | None
110
+ ) -> dict[str, Any] | None:
111
+ payload = _normalise_event_payload(event.data)
112
+ if _should_skip_event(payload):
113
+ return None
114
+ event_type = _standardise_event_type(getattr(event, "event", None))
115
+
116
+ description = ""
117
+ if isinstance(payload, Mapping):
118
+ description = str(dict(payload).get("description", ""))
119
+
120
+ result = {
121
+ "event": event_type,
122
+ "description": description,
123
+ }
124
+ return result
125
+
126
+
127
+ def _standardise_event_type(event_name: Any) -> str:
128
+ if not isinstance(event_name, str):
129
+ return ""
130
+ normalised = event_name.strip()
131
+ normalised_key = normalised.casefold().replace(" ", "_")
132
+ if normalised_key == "action_complete":
133
+ return "action_output"
134
+ return normalised
135
+
136
+
137
+ def _should_skip_event(payload: Any) -> bool:
138
+ return _is_action_complete_broadcast(payload)
139
+
140
+
141
+ def _is_action_complete_broadcast(payload: Any) -> bool:
142
+ """
143
+ Check if the payload is an `action_complete_broadcast` message.
144
+ """
145
+ if not isinstance(payload, Mapping):
146
+ return False
147
+ description = payload.get("description")
148
+ if not isinstance(description, str):
149
+ return False
150
+ if "<subject>::action_complete_broadcast::</subject>" in description:
151
+ return True
152
+ return False
153
+
154
+
155
+ def _format_event_sections(events: list[Any]) -> str:
156
+ sections: list[str] = []
157
+ for idx, event in enumerate(events, start=1):
158
+ sections.append(f"--- Event {idx} ---\n{_format_json(event)}")
159
+ return "\n\n".join(sections)
160
+
161
+
162
+ def _format_json(payload: Any) -> str:
163
+ if isinstance(payload, str | int | float | bool) or payload is None:
164
+ return str(payload)
165
+ return ujson.dumps(payload, indent=2)
166
+
167
+
168
+ def extract_task_body(raw_result: Any, serialized_result: Any | None = None) -> Any:
169
+ """
170
+ Extract the body from a MAIL result.
171
+ """
172
+ for candidate in (raw_result, serialized_result):
173
+ if candidate is None:
174
+ continue
175
+ if isinstance(candidate, Mapping):
176
+ if "body" in candidate:
177
+ return candidate["body"]
178
+ message = candidate.get("message")
179
+ if isinstance(message, Mapping) and "body" in message:
180
+ return message["body"]
181
+ if hasattr(candidate, "body"):
182
+ body_attr = getattr(candidate, "body")
183
+ if body_attr is not None:
184
+ return body_attr
185
+ if hasattr(candidate, "message"):
186
+ message_obj = getattr(candidate, "message")
187
+ if isinstance(message_obj, Mapping) and "body" in message_obj:
188
+ return message_obj["body"]
189
+ if hasattr(message_obj, "body"):
190
+ body_attr = getattr(message_obj, "body")
191
+ if body_attr is not None:
192
+ return body_attr
193
+ return None
194
+
195
+
196
+ def export(swarms: list[Any]) -> str:
197
+ """
198
+ Export a `MAILSwarm` or `MAILSwarmTemplate` to a JSON string compatible
199
+ with the client-side `Generation`/`Swarm` interfaces.
200
+ """
201
+
202
+ def _format_python_reference(reference: Any) -> str | None:
203
+ if reference is None:
204
+ return None
205
+ if isinstance(reference, str):
206
+ return reference
207
+ if callable(reference):
208
+ module = getattr(reference, "__module__", "")
209
+ qualname = getattr(
210
+ reference, "__qualname__", getattr(reference, "__name__", "")
211
+ )
212
+ if module and qualname:
213
+ return f"python::{module}:{qualname}"
214
+ if qualname:
215
+ return qualname
216
+ return None
217
+
218
+ def _serialize_agent(agent: Any) -> dict[str, Any]:
219
+ agent_dict: dict[str, Any] = {
220
+ "name": getattr(agent, "name", ""),
221
+ "comm_targets": list(getattr(agent, "comm_targets", [])),
222
+ "enable_entrypoint": bool(getattr(agent, "enable_entrypoint", False)),
223
+ "enable_interswarm": bool(getattr(agent, "enable_interswarm", False)),
224
+ "can_complete_tasks": bool(getattr(agent, "can_complete_tasks", False)),
225
+ "tool_format": getattr(agent, "tool_format", "responses"),
226
+ "agent_params": serialize_mail_value(getattr(agent, "agent_params", {})),
227
+ }
228
+
229
+ factory_ref = _format_python_reference(getattr(agent, "factory", None))
230
+ if factory_ref:
231
+ agent_dict["factory"] = factory_ref
232
+
233
+ actions = getattr(agent, "actions", [])
234
+ if actions:
235
+ agent_dict["actions"] = [
236
+ getattr(action, "name", serialize_mail_value(action))
237
+ for action in actions
238
+ ]
239
+
240
+ breakpoint_tools = getattr(agent, "breakpoint_tools", None)
241
+ if breakpoint_tools:
242
+ agent_dict["breakpoint_tools"] = list(breakpoint_tools)
243
+
244
+ return agent_dict
245
+
246
+ def _serialize_action(action: Any) -> dict[str, Any]:
247
+ action_dict: dict[str, Any] = {
248
+ "name": getattr(action, "name", ""),
249
+ "description": getattr(action, "description", ""),
250
+ "parameters": serialize_mail_value(getattr(action, "parameters", {})),
251
+ }
252
+ function_ref = _format_python_reference(getattr(action, "function", None))
253
+ if function_ref:
254
+ action_dict["function"] = function_ref
255
+ return action_dict
256
+
257
+ swarm_payloads: list[dict[str, Any]] = []
258
+ for swarm in swarms:
259
+ agent_payloads = [
260
+ _serialize_agent(agent) for agent in getattr(swarm, "agents", [])
261
+ ]
262
+ action_payloads = [
263
+ _serialize_action(action) for action in getattr(swarm, "actions", [])
264
+ ]
265
+
266
+ swarm_payload: dict[str, Any] = {
267
+ "name": getattr(swarm, "name", ""),
268
+ "version": get_version(),
269
+ "entrypoint": getattr(swarm, "entrypoint", ""),
270
+ "enable_interswarm": bool(getattr(swarm, "enable_interswarm", False)),
271
+ "agents": agent_payloads,
272
+ }
273
+
274
+ breakpoint_tools = getattr(swarm, "breakpoint_tools", None)
275
+ if breakpoint_tools is not None:
276
+ swarm_payload["breakpoint_tools"] = list(breakpoint_tools)
277
+
278
+ if action_payloads:
279
+ swarm_payload["actions"] = action_payloads
280
+
281
+ user_id = getattr(swarm, "user_id", None)
282
+ if user_id is not None:
283
+ swarm_payload["user_id"] = user_id
284
+
285
+ swarm_payloads.append(swarm_payload)
286
+
287
+ swarms_payload = {
288
+ "swarms": swarm_payloads,
289
+ "n": len(swarm_payloads),
290
+ }
291
+
292
+ return ujson.dumps(swarms_payload)