mail-swarms 1.3.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mail/__init__.py +35 -0
- mail/api.py +1964 -0
- mail/cli.py +432 -0
- mail/client.py +1657 -0
- mail/config/__init__.py +8 -0
- mail/config/client.py +87 -0
- mail/config/server.py +165 -0
- mail/core/__init__.py +72 -0
- mail/core/actions.py +69 -0
- mail/core/agents.py +73 -0
- mail/core/message.py +366 -0
- mail/core/runtime.py +3537 -0
- mail/core/tasks.py +311 -0
- mail/core/tools.py +1206 -0
- mail/db/__init__.py +0 -0
- mail/db/init.py +182 -0
- mail/db/types.py +65 -0
- mail/db/utils.py +523 -0
- mail/examples/__init__.py +27 -0
- mail/examples/analyst_dummy/__init__.py +15 -0
- mail/examples/analyst_dummy/agent.py +136 -0
- mail/examples/analyst_dummy/prompts.py +44 -0
- mail/examples/consultant_dummy/__init__.py +15 -0
- mail/examples/consultant_dummy/agent.py +136 -0
- mail/examples/consultant_dummy/prompts.py +42 -0
- mail/examples/data_analysis/__init__.py +40 -0
- mail/examples/data_analysis/analyst/__init__.py +9 -0
- mail/examples/data_analysis/analyst/agent.py +67 -0
- mail/examples/data_analysis/analyst/prompts.py +53 -0
- mail/examples/data_analysis/processor/__init__.py +13 -0
- mail/examples/data_analysis/processor/actions.py +293 -0
- mail/examples/data_analysis/processor/agent.py +67 -0
- mail/examples/data_analysis/processor/prompts.py +48 -0
- mail/examples/data_analysis/reporter/__init__.py +10 -0
- mail/examples/data_analysis/reporter/actions.py +187 -0
- mail/examples/data_analysis/reporter/agent.py +67 -0
- mail/examples/data_analysis/reporter/prompts.py +49 -0
- mail/examples/data_analysis/statistics/__init__.py +18 -0
- mail/examples/data_analysis/statistics/actions.py +343 -0
- mail/examples/data_analysis/statistics/agent.py +67 -0
- mail/examples/data_analysis/statistics/prompts.py +60 -0
- mail/examples/mafia/__init__.py +0 -0
- mail/examples/mafia/game.py +1537 -0
- mail/examples/mafia/narrator_tools.py +396 -0
- mail/examples/mafia/personas.py +240 -0
- mail/examples/mafia/prompts.py +489 -0
- mail/examples/mafia/roles.py +147 -0
- mail/examples/mafia/spec.md +350 -0
- mail/examples/math_dummy/__init__.py +23 -0
- mail/examples/math_dummy/actions.py +252 -0
- mail/examples/math_dummy/agent.py +136 -0
- mail/examples/math_dummy/prompts.py +46 -0
- mail/examples/math_dummy/types.py +5 -0
- mail/examples/research/__init__.py +39 -0
- mail/examples/research/researcher/__init__.py +9 -0
- mail/examples/research/researcher/agent.py +67 -0
- mail/examples/research/researcher/prompts.py +54 -0
- mail/examples/research/searcher/__init__.py +10 -0
- mail/examples/research/searcher/actions.py +324 -0
- mail/examples/research/searcher/agent.py +67 -0
- mail/examples/research/searcher/prompts.py +53 -0
- mail/examples/research/summarizer/__init__.py +18 -0
- mail/examples/research/summarizer/actions.py +255 -0
- mail/examples/research/summarizer/agent.py +67 -0
- mail/examples/research/summarizer/prompts.py +55 -0
- mail/examples/research/verifier/__init__.py +10 -0
- mail/examples/research/verifier/actions.py +337 -0
- mail/examples/research/verifier/agent.py +67 -0
- mail/examples/research/verifier/prompts.py +52 -0
- mail/examples/supervisor/__init__.py +11 -0
- mail/examples/supervisor/agent.py +4 -0
- mail/examples/supervisor/prompts.py +93 -0
- mail/examples/support/__init__.py +33 -0
- mail/examples/support/classifier/__init__.py +10 -0
- mail/examples/support/classifier/actions.py +307 -0
- mail/examples/support/classifier/agent.py +68 -0
- mail/examples/support/classifier/prompts.py +56 -0
- mail/examples/support/coordinator/__init__.py +9 -0
- mail/examples/support/coordinator/agent.py +67 -0
- mail/examples/support/coordinator/prompts.py +48 -0
- mail/examples/support/faq/__init__.py +10 -0
- mail/examples/support/faq/actions.py +182 -0
- mail/examples/support/faq/agent.py +67 -0
- mail/examples/support/faq/prompts.py +42 -0
- mail/examples/support/sentiment/__init__.py +15 -0
- mail/examples/support/sentiment/actions.py +341 -0
- mail/examples/support/sentiment/agent.py +67 -0
- mail/examples/support/sentiment/prompts.py +54 -0
- mail/examples/weather_dummy/__init__.py +23 -0
- mail/examples/weather_dummy/actions.py +75 -0
- mail/examples/weather_dummy/agent.py +136 -0
- mail/examples/weather_dummy/prompts.py +35 -0
- mail/examples/weather_dummy/types.py +5 -0
- mail/factories/__init__.py +27 -0
- mail/factories/action.py +223 -0
- mail/factories/base.py +1531 -0
- mail/factories/supervisor.py +241 -0
- mail/net/__init__.py +7 -0
- mail/net/registry.py +712 -0
- mail/net/router.py +728 -0
- mail/net/server_utils.py +114 -0
- mail/net/types.py +247 -0
- mail/server.py +1605 -0
- mail/stdlib/__init__.py +0 -0
- mail/stdlib/anthropic/__init__.py +0 -0
- mail/stdlib/fs/__init__.py +15 -0
- mail/stdlib/fs/actions.py +209 -0
- mail/stdlib/http/__init__.py +19 -0
- mail/stdlib/http/actions.py +333 -0
- mail/stdlib/interswarm/__init__.py +11 -0
- mail/stdlib/interswarm/actions.py +208 -0
- mail/stdlib/mcp/__init__.py +19 -0
- mail/stdlib/mcp/actions.py +294 -0
- mail/stdlib/openai/__init__.py +13 -0
- mail/stdlib/openai/agents.py +451 -0
- mail/summarizer.py +234 -0
- mail/swarms_json/__init__.py +27 -0
- mail/swarms_json/types.py +87 -0
- mail/swarms_json/utils.py +255 -0
- mail/url_scheme.py +51 -0
- mail/utils/__init__.py +53 -0
- mail/utils/auth.py +194 -0
- mail/utils/context.py +17 -0
- mail/utils/logger.py +73 -0
- mail/utils/openai.py +212 -0
- mail/utils/parsing.py +89 -0
- mail/utils/serialize.py +292 -0
- mail/utils/store.py +49 -0
- mail/utils/string_builder.py +119 -0
- mail/utils/version.py +20 -0
- mail_swarms-1.3.2.dist-info/METADATA +237 -0
- mail_swarms-1.3.2.dist-info/RECORD +137 -0
- mail_swarms-1.3.2.dist-info/WHEEL +4 -0
- mail_swarms-1.3.2.dist-info/entry_points.txt +2 -0
- mail_swarms-1.3.2.dist-info/licenses/LICENSE +202 -0
- mail_swarms-1.3.2.dist-info/licenses/NOTICE +10 -0
- mail_swarms-1.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +12334 -0
mail/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
|
mail/utils/serialize.py
ADDED
|
@@ -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)
|