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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .types import (
|
|
2
|
+
SwarmsJSONAction,
|
|
3
|
+
SwarmsJSONAgent,
|
|
4
|
+
SwarmsJSONFile,
|
|
5
|
+
SwarmsJSONSwarm,
|
|
6
|
+
)
|
|
7
|
+
from .utils import (
|
|
8
|
+
build_action_from_swarms_json,
|
|
9
|
+
build_agent_from_swarms_json,
|
|
10
|
+
build_swarm_from_swarms_json,
|
|
11
|
+
build_swarms_from_swarms_json,
|
|
12
|
+
load_swarms_json_from_file,
|
|
13
|
+
load_swarms_json_from_string,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SwarmsJSONAction",
|
|
18
|
+
"SwarmsJSONAgent",
|
|
19
|
+
"SwarmsJSONFile",
|
|
20
|
+
"SwarmsJSONSwarm",
|
|
21
|
+
"build_action_from_swarms_json",
|
|
22
|
+
"build_agent_from_swarms_json",
|
|
23
|
+
"build_swarm_from_swarms_json",
|
|
24
|
+
"build_swarms_from_swarms_json",
|
|
25
|
+
"load_swarms_json_from_file",
|
|
26
|
+
"load_swarms_json_from_string",
|
|
27
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Addison Kline
|
|
3
|
+
|
|
4
|
+
from typing import Any, Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SwarmsJSONFile(TypedDict):
|
|
8
|
+
"""
|
|
9
|
+
A standardized container for MAIL swarms and their configuration.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
swarms: list["SwarmsJSONSwarm"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SwarmsJSONSwarm(TypedDict):
|
|
16
|
+
"""
|
|
17
|
+
A MAIL swarm and its configuration, following the `swarms.json` format.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
"""The swarm's name."""
|
|
22
|
+
version: str
|
|
23
|
+
"""The version of `mail` to build this swarm with."""
|
|
24
|
+
description: str # default: ""
|
|
25
|
+
"""The description of the swarm."""
|
|
26
|
+
keywords: list[str] # default: []
|
|
27
|
+
"""The keywords of the swarm."""
|
|
28
|
+
public: bool # default: False
|
|
29
|
+
"""Whether this swarm is publicly accessible."""
|
|
30
|
+
entrypoint: str
|
|
31
|
+
"""The name of the swarm's default entrypoint agent."""
|
|
32
|
+
enable_interswarm: bool # default: False
|
|
33
|
+
"""Whether to enable interswarm communication for this swarm."""
|
|
34
|
+
action_imports: list[str] # default: []
|
|
35
|
+
"""Python import strings that resolve to pre-built MAILAction instances."""
|
|
36
|
+
agents: list["SwarmsJSONAgent"]
|
|
37
|
+
"""The agents in this swarm."""
|
|
38
|
+
actions: list["SwarmsJSONAction"]
|
|
39
|
+
"""The actions in this swarm."""
|
|
40
|
+
breakpoint_tools: list[str] # default: []
|
|
41
|
+
"""The tools that can be used to breakpoint the swarm."""
|
|
42
|
+
exclude_tools: list[str] # default: []
|
|
43
|
+
"""The names of MAIL tools that should not be available to the swarm."""
|
|
44
|
+
enable_db_agent_histories: bool # default: False
|
|
45
|
+
"""Whether to enable database persistence for agent histories."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SwarmsJSONAgent(TypedDict):
|
|
49
|
+
"""
|
|
50
|
+
A MAIL agent and its configuration, following the `swarms.json` format.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
"""The agent's name."""
|
|
55
|
+
factory: str
|
|
56
|
+
"""The agent's factory function as a Python import string."""
|
|
57
|
+
comm_targets: list[str]
|
|
58
|
+
"""The names of the agents this agent can communicate with."""
|
|
59
|
+
enable_entrypoint: bool # default: False
|
|
60
|
+
"""Whether this agent can be used as a swarm entrypoint."""
|
|
61
|
+
enable_interswarm: bool # default: False
|
|
62
|
+
"""Whether this agent can communicate with other swarms."""
|
|
63
|
+
can_complete_tasks: bool # default: False
|
|
64
|
+
"""Whether this agent can complete tasks."""
|
|
65
|
+
tool_format: Literal["completions", "responses"] # default: "responses"
|
|
66
|
+
"""The format of the tools this agent can use."""
|
|
67
|
+
actions: list[str] # default: []
|
|
68
|
+
"""The names of the actions this agent can use."""
|
|
69
|
+
agent_params: dict[str, Any]
|
|
70
|
+
"""The parameters for this agent."""
|
|
71
|
+
exclude_tools: list[str] # default: []
|
|
72
|
+
"""The names ofMAIL tools that should not be available to this agent."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SwarmsJSONAction(TypedDict):
|
|
76
|
+
"""
|
|
77
|
+
A MAIL action and its configuration, following the `swarms.json` format.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
"""The action's name."""
|
|
82
|
+
description: str
|
|
83
|
+
"""The action's description."""
|
|
84
|
+
parameters: dict[str, Any]
|
|
85
|
+
"""The parameters for this action."""
|
|
86
|
+
function: str
|
|
87
|
+
"""The action's function as a Python import string."""
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Addison Kline
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import warnings
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .types import (
|
|
9
|
+
SwarmsJSONAction,
|
|
10
|
+
SwarmsJSONAgent,
|
|
11
|
+
SwarmsJSONFile,
|
|
12
|
+
SwarmsJSONSwarm,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_swarms_json_from_file(path: str) -> SwarmsJSONFile:
|
|
17
|
+
"""
|
|
18
|
+
Load a `swarms.json` file from a given path.
|
|
19
|
+
"""
|
|
20
|
+
with open(path) as f:
|
|
21
|
+
contents = json.load(f)
|
|
22
|
+
if not isinstance(contents, list):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
f"swarms.json file at {path} must contain a list of swarms, actually got {type(contents)}"
|
|
25
|
+
)
|
|
26
|
+
for swarm in contents:
|
|
27
|
+
validate_swarm_from_swarms_json(swarm)
|
|
28
|
+
return SwarmsJSONFile(swarms=contents)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_swarms_json_from_string(contents: str) -> SwarmsJSONFile:
|
|
32
|
+
"""
|
|
33
|
+
Load a `swarms.json` string from a given string of contents.
|
|
34
|
+
"""
|
|
35
|
+
contents = json.loads(contents)
|
|
36
|
+
if not isinstance(contents, list):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"swarms.json string must contain a list of swarms, actually got {type(contents)}"
|
|
39
|
+
)
|
|
40
|
+
for swarm in contents:
|
|
41
|
+
validate_swarm_from_swarms_json(swarm)
|
|
42
|
+
return SwarmsJSONFile(swarms=contents)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_swarms_from_swarms_json(contents: list[Any]) -> list[SwarmsJSONSwarm]:
|
|
46
|
+
"""
|
|
47
|
+
Build a list of `SwarmsJSONSwarm` from a list of `SwarmsJSONFile` contents.
|
|
48
|
+
"""
|
|
49
|
+
for swarm_candidate in contents:
|
|
50
|
+
validate_swarm_from_swarms_json(swarm_candidate)
|
|
51
|
+
|
|
52
|
+
return [
|
|
53
|
+
build_swarm_from_swarms_json(swarm_candidate) for swarm_candidate in contents
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_swarm_from_swarms_json(swarm_candidate: Any) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Ensure the candidate is a valid `SwarmsJSONSwarm`.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(swarm_candidate, dict):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"swarm candidate must be a dict, actually got {type(swarm_candidate)}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
REQUIRED_FIELDS: dict[str, type] = {
|
|
67
|
+
"name": str,
|
|
68
|
+
"version": str,
|
|
69
|
+
"entrypoint": str,
|
|
70
|
+
"agents": list,
|
|
71
|
+
"actions": list,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
OPTIONAL_FIELDS: dict[str, type] = {
|
|
75
|
+
"enable_interswarm": bool,
|
|
76
|
+
"breakpoint_tools": list,
|
|
77
|
+
"exclude_tools": list,
|
|
78
|
+
"action_imports": list,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for field, field_type in REQUIRED_FIELDS.items():
|
|
82
|
+
if field not in swarm_candidate:
|
|
83
|
+
raise ValueError(f"swarm candidate must contain a '{field}' field")
|
|
84
|
+
if not isinstance(swarm_candidate[field], field_type):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"swarm candidate field '{field}' must be a {field_type.__name__}, actually got {type(swarm_candidate[field])}"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for field, field_type in OPTIONAL_FIELDS.items():
|
|
90
|
+
if field not in swarm_candidate:
|
|
91
|
+
continue
|
|
92
|
+
if not isinstance(swarm_candidate[field], field_type):
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"swarm candidate field '{field}' must be a {field_type.__name__}, actually got {type(swarm_candidate[field])}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if "action_imports" in swarm_candidate:
|
|
98
|
+
imports = swarm_candidate["action_imports"]
|
|
99
|
+
if any(not isinstance(item, str) for item in imports):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"swarm candidate field 'action_imports' must be a list of strings"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def build_swarm_from_swarms_json(swarm_candidate: Any) -> SwarmsJSONSwarm:
|
|
108
|
+
"""
|
|
109
|
+
Build a `SwarmsJSONSwarm` from a candidate.
|
|
110
|
+
"""
|
|
111
|
+
validate_swarm_from_swarms_json(swarm_candidate)
|
|
112
|
+
return SwarmsJSONSwarm(
|
|
113
|
+
name=swarm_candidate["name"],
|
|
114
|
+
version=swarm_candidate["version"],
|
|
115
|
+
description=swarm_candidate.get("description", ""),
|
|
116
|
+
keywords=swarm_candidate.get("keywords", []),
|
|
117
|
+
public=swarm_candidate.get("public", False),
|
|
118
|
+
entrypoint=swarm_candidate["entrypoint"],
|
|
119
|
+
agents=[
|
|
120
|
+
build_agent_from_swarms_json(agent) for agent in swarm_candidate["agents"]
|
|
121
|
+
],
|
|
122
|
+
actions=[
|
|
123
|
+
build_action_from_swarms_json(action)
|
|
124
|
+
for action in swarm_candidate["actions"]
|
|
125
|
+
],
|
|
126
|
+
action_imports=swarm_candidate.get("action_imports", []),
|
|
127
|
+
enable_interswarm=swarm_candidate.get("enable_interswarm", False),
|
|
128
|
+
breakpoint_tools=swarm_candidate.get("breakpoint_tools", []),
|
|
129
|
+
exclude_tools=swarm_candidate.get("exclude_tools", []),
|
|
130
|
+
enable_db_agent_histories=swarm_candidate.get(
|
|
131
|
+
"enable_db_agent_histories", False
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def validate_agent_from_swarms_json(agent_candidate: Any) -> None:
|
|
137
|
+
"""
|
|
138
|
+
Ensure the candidate is a valid `SwarmsJSONAgent`.
|
|
139
|
+
"""
|
|
140
|
+
if not isinstance(agent_candidate, dict):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"agent candidate must be a dict, actually got {type(agent_candidate)}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
REQUIRED_FIELDS: dict[str, type] = {
|
|
146
|
+
"name": str,
|
|
147
|
+
"factory": str,
|
|
148
|
+
"comm_targets": list,
|
|
149
|
+
"agent_params": dict,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
OPTIONAL_FIELDS: dict[str, type] = {
|
|
153
|
+
"enable_entrypoint": bool,
|
|
154
|
+
"enable_interswarm": bool,
|
|
155
|
+
"can_complete_tasks": bool,
|
|
156
|
+
"tool_format": str,
|
|
157
|
+
"actions": list,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for field, field_type in REQUIRED_FIELDS.items():
|
|
161
|
+
if field not in agent_candidate:
|
|
162
|
+
raise ValueError(f"agent candidate must contain a '{field}' field")
|
|
163
|
+
if not isinstance(agent_candidate[field], field_type):
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"agent candidate field '{field}' must be a {field_type.__name__}, actually got {type(agent_candidate[field])}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
for field, field_type in OPTIONAL_FIELDS.items():
|
|
169
|
+
if field not in agent_candidate:
|
|
170
|
+
continue
|
|
171
|
+
if not isinstance(agent_candidate[field], field_type):
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"agent candidate field '{field}' must be a {field_type.__name__}, actually got {type(agent_candidate[field])}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Warn about deprecated tool_format placement
|
|
177
|
+
if "agent_params" in agent_candidate:
|
|
178
|
+
if "tool_format" in agent_candidate["agent_params"]:
|
|
179
|
+
warnings.warn(
|
|
180
|
+
f"agent '{agent_candidate.get('name', '?')}' has tool_format inside agent_params; "
|
|
181
|
+
"this is deprecated, use top-level tool_format instead",
|
|
182
|
+
DeprecationWarning,
|
|
183
|
+
stacklevel=2,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_agent_from_swarms_json(agent_candidate: Any) -> SwarmsJSONAgent:
|
|
190
|
+
"""
|
|
191
|
+
Build a `SwarmsJSONAgent` from a candidate.
|
|
192
|
+
"""
|
|
193
|
+
validate_agent_from_swarms_json(agent_candidate)
|
|
194
|
+
return SwarmsJSONAgent(
|
|
195
|
+
name=agent_candidate["name"],
|
|
196
|
+
factory=agent_candidate["factory"],
|
|
197
|
+
comm_targets=agent_candidate["comm_targets"],
|
|
198
|
+
agent_params=agent_candidate["agent_params"],
|
|
199
|
+
enable_entrypoint=agent_candidate.get("enable_entrypoint", False),
|
|
200
|
+
enable_interswarm=agent_candidate.get("enable_interswarm", False),
|
|
201
|
+
can_complete_tasks=agent_candidate.get("can_complete_tasks", False),
|
|
202
|
+
tool_format=agent_candidate.get("tool_format", "responses"),
|
|
203
|
+
actions=agent_candidate.get("actions", []),
|
|
204
|
+
exclude_tools=agent_candidate.get("exclude_tools", []),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def validate_action_from_swarms_json(action_candidate: Any) -> None:
|
|
209
|
+
"""
|
|
210
|
+
Ensure the candidate is a valid `SwarmsJSONAction`.
|
|
211
|
+
"""
|
|
212
|
+
if not isinstance(action_candidate, dict):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"action candidate must be a dict, actually got {type(action_candidate)}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
REQUIRED_FIELDS: dict[str, type] = {
|
|
218
|
+
"name": str,
|
|
219
|
+
"description": str,
|
|
220
|
+
"parameters": dict,
|
|
221
|
+
"function": str,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
OPTIONAL_FIELDS: dict[str, type] = {}
|
|
225
|
+
|
|
226
|
+
for field, field_type in REQUIRED_FIELDS.items():
|
|
227
|
+
if field not in action_candidate:
|
|
228
|
+
raise ValueError(f"action candidate must contain a '{field}' field")
|
|
229
|
+
if not isinstance(action_candidate[field], field_type):
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"action candidate field '{field}' must be a {field_type.__name__}, actually got {type(action_candidate[field])}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
for field, field_type in OPTIONAL_FIELDS.items():
|
|
235
|
+
if field not in action_candidate:
|
|
236
|
+
continue
|
|
237
|
+
if not isinstance(action_candidate[field], field_type):
|
|
238
|
+
raise ValueError(
|
|
239
|
+
f"action candidate field '{field}' must be a {field_type.__name__}, actually got {type(action_candidate[field])}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_action_from_swarms_json(action_candidate: Any) -> SwarmsJSONAction:
|
|
246
|
+
"""
|
|
247
|
+
Build a `SwarmsJSONAction` from a candidate.
|
|
248
|
+
"""
|
|
249
|
+
validate_action_from_swarms_json(action_candidate)
|
|
250
|
+
return SwarmsJSONAction(
|
|
251
|
+
name=action_candidate["name"],
|
|
252
|
+
description=action_candidate["description"],
|
|
253
|
+
parameters=action_candidate["parameters"],
|
|
254
|
+
function=action_candidate["function"],
|
|
255
|
+
)
|
mail/url_scheme.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Addison Kline
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
URL scheme parsing for swarm:// URLs.
|
|
6
|
+
|
|
7
|
+
Supports:
|
|
8
|
+
swarm://connect?server=<host>&token=<api_key>
|
|
9
|
+
swarm://invite?server=<host>&token=<api_key>
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class SwarmURL:
|
|
18
|
+
"""Parsed swarm:// URL."""
|
|
19
|
+
|
|
20
|
+
action: str # "connect" or "invite"
|
|
21
|
+
server: str | None = None
|
|
22
|
+
token: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_swarm_url(url: str) -> SwarmURL | None:
|
|
26
|
+
"""
|
|
27
|
+
Parse a swarm:// URL into its components.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
url: The URL to parse (e.g., "swarm://connect?server=example.com&token=abc")
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
SwarmURL if the URL is a valid swarm:// URL, None otherwise.
|
|
34
|
+
"""
|
|
35
|
+
if not url.startswith("swarm://"):
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
parsed = urlparse(url)
|
|
39
|
+
|
|
40
|
+
# For swarm://connect?server=x, parsed.netloc = "connect", parsed.query = "server=x"
|
|
41
|
+
action = parsed.netloc
|
|
42
|
+
|
|
43
|
+
if action in ("connect", "invite"):
|
|
44
|
+
params = parse_qs(parsed.query)
|
|
45
|
+
return SwarmURL(
|
|
46
|
+
action=action,
|
|
47
|
+
server=params.get("server", [None])[0],
|
|
48
|
+
token=params.get("token", [None])[0],
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
return None
|
mail/utils/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from .auth import (
|
|
2
|
+
caller_is_admin,
|
|
3
|
+
caller_is_admin_or_user,
|
|
4
|
+
caller_is_agent,
|
|
5
|
+
caller_is_user,
|
|
6
|
+
extract_token,
|
|
7
|
+
extract_token_info,
|
|
8
|
+
get_token_info,
|
|
9
|
+
login,
|
|
10
|
+
require_debug,
|
|
11
|
+
)
|
|
12
|
+
from .logger import (
|
|
13
|
+
get_loggers,
|
|
14
|
+
init_logger,
|
|
15
|
+
)
|
|
16
|
+
from .parsing import (
|
|
17
|
+
read_python_string,
|
|
18
|
+
resolve_prefixed_string_references,
|
|
19
|
+
target_address_is_interswarm,
|
|
20
|
+
)
|
|
21
|
+
from .serialize import (
|
|
22
|
+
export,
|
|
23
|
+
serialize_mail_value,
|
|
24
|
+
)
|
|
25
|
+
from .store import (
|
|
26
|
+
get_langmem_store,
|
|
27
|
+
)
|
|
28
|
+
from .version import (
|
|
29
|
+
get_protocol_version,
|
|
30
|
+
get_version,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"login",
|
|
35
|
+
"get_token_info",
|
|
36
|
+
"get_loggers",
|
|
37
|
+
"init_logger",
|
|
38
|
+
"read_python_string",
|
|
39
|
+
"resolve_prefixed_string_references",
|
|
40
|
+
"target_address_is_interswarm",
|
|
41
|
+
"get_langmem_store",
|
|
42
|
+
"caller_is_admin",
|
|
43
|
+
"caller_is_user",
|
|
44
|
+
"caller_is_admin_or_user",
|
|
45
|
+
"caller_is_agent",
|
|
46
|
+
"extract_token_info",
|
|
47
|
+
"extract_token",
|
|
48
|
+
"get_version",
|
|
49
|
+
"get_protocol_version",
|
|
50
|
+
"export",
|
|
51
|
+
"serialize_mail_value",
|
|
52
|
+
"require_debug",
|
|
53
|
+
]
|
mail/utils/auth.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2025 Addison Kline
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
from fastapi import HTTPException, Request
|
|
10
|
+
|
|
11
|
+
JWT_SECRET = os.getenv("JWT_SECRET")
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("mail.auth")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def check_auth_endpoints() -> None:
|
|
17
|
+
"""
|
|
18
|
+
Check if the auth endpoints are set.
|
|
19
|
+
This is necessary for the server to start.
|
|
20
|
+
"""
|
|
21
|
+
AUTH_ENDPOINTS = ["AUTH_ENDPOINT", "TOKEN_INFO_ENDPOINT"]
|
|
22
|
+
for endpoint in AUTH_ENDPOINTS:
|
|
23
|
+
if endpoint not in os.environ or os.getenv(endpoint) is None:
|
|
24
|
+
logger.error(f"required environment variable '{endpoint}' is not set")
|
|
25
|
+
raise Exception(f"required environment variable '{endpoint}' is not set")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_token(request: Request) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Extract the token from the request.
|
|
31
|
+
"""
|
|
32
|
+
auth_header = request.headers.get("Authorization", "")
|
|
33
|
+
if auth_header.startswith("Bearer "):
|
|
34
|
+
return auth_header.split(" ")[1]
|
|
35
|
+
else:
|
|
36
|
+
raise HTTPException(status_code=401, detail="invalid API key format")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def login(api_key: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Authenticate a user with an API key.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
api_key: The API key to validate
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A user token if authentication is successful
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ValueError: If the API key is invalid
|
|
51
|
+
"""
|
|
52
|
+
await check_auth_endpoints()
|
|
53
|
+
AUTH_ENDPOINT = os.getenv("AUTH_ENDPOINT")
|
|
54
|
+
|
|
55
|
+
# hit the login endpoint in the auth service
|
|
56
|
+
async with aiohttp.ClientSession() as session:
|
|
57
|
+
response = await session.post(
|
|
58
|
+
f"{AUTH_ENDPOINT}", headers={"Authorization": f"Bearer {api_key}"}
|
|
59
|
+
)
|
|
60
|
+
if response.status == 200:
|
|
61
|
+
data = await response.json()
|
|
62
|
+
logger.info(
|
|
63
|
+
f"[[green]{api_key[:8]}...[/green]] user or agent authenticated with API key"
|
|
64
|
+
)
|
|
65
|
+
return data["token"]
|
|
66
|
+
elif response.status == 401:
|
|
67
|
+
logger.warning(f"invalid API key: '{api_key}'")
|
|
68
|
+
raise HTTPException(status_code=401, detail="invalid API key")
|
|
69
|
+
else:
|
|
70
|
+
logger.error(
|
|
71
|
+
f"failed to authenticate user or agent with API key: '{api_key}': '{response.status}'"
|
|
72
|
+
)
|
|
73
|
+
raise HTTPException(
|
|
74
|
+
status_code=500,
|
|
75
|
+
detail="failed to authenticate user or agent with API key",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def get_token_info(token: str) -> dict[str, Any]:
|
|
80
|
+
"""
|
|
81
|
+
Get information about a JWT.
|
|
82
|
+
"""
|
|
83
|
+
await check_auth_endpoints()
|
|
84
|
+
TOKEN_INFO_ENDPOINT = os.getenv("TOKEN_INFO_ENDPOINT")
|
|
85
|
+
|
|
86
|
+
async with aiohttp.ClientSession() as session:
|
|
87
|
+
response = await session.get(
|
|
88
|
+
f"{TOKEN_INFO_ENDPOINT}", headers={"Authorization": f"Bearer {token}"}
|
|
89
|
+
)
|
|
90
|
+
if response.status == 200:
|
|
91
|
+
data = await response.json()
|
|
92
|
+
return data
|
|
93
|
+
elif response.status == 401:
|
|
94
|
+
logger.warning(f"invalid token: '{token}'")
|
|
95
|
+
raise HTTPException(status_code=401, detail="invalid token")
|
|
96
|
+
else:
|
|
97
|
+
logger.error(f"failed to get token info: '{token}': '{response.status}'")
|
|
98
|
+
raise HTTPException(status_code=500, detail="failed to get token info")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def caller_is_role(
|
|
102
|
+
request: Request, role: str, raise_on_false: bool = True
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Check if the caller is a specific role.
|
|
106
|
+
"""
|
|
107
|
+
token = request.headers.get("Authorization")
|
|
108
|
+
if token is None:
|
|
109
|
+
logger.warning("no API key provided")
|
|
110
|
+
raise HTTPException(status_code=401, detail="no API key provided")
|
|
111
|
+
|
|
112
|
+
if token.startswith("Bearer "):
|
|
113
|
+
token = token.split(" ")[1]
|
|
114
|
+
else:
|
|
115
|
+
logger.warning("invalid API key format: missing 'Bearer' prefix")
|
|
116
|
+
if raise_on_false:
|
|
117
|
+
raise HTTPException(status_code=401, detail="invalid API key format")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
# login to the auth service
|
|
121
|
+
jwt = await login(token)
|
|
122
|
+
|
|
123
|
+
token_info = await get_token_info(jwt)
|
|
124
|
+
if token_info["role"] != role:
|
|
125
|
+
if raise_on_false:
|
|
126
|
+
logger.warning(f"invalid role: '{token_info['role']}' != '{role}'")
|
|
127
|
+
raise HTTPException(status_code=401, detail="invalid role")
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def caller_is_admin(request: Request, raise_on_false: bool = True) -> bool:
|
|
134
|
+
"""
|
|
135
|
+
Check if the caller is an `admin`.
|
|
136
|
+
"""
|
|
137
|
+
return await caller_is_role(request, "admin", raise_on_false)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
async def caller_is_user(request: Request, raise_on_false: bool = True) -> bool:
|
|
141
|
+
"""
|
|
142
|
+
Check if the caller is a `user`.
|
|
143
|
+
"""
|
|
144
|
+
return await caller_is_role(request, "user", raise_on_false)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
async def caller_is_admin_or_user(request: Request) -> bool:
|
|
148
|
+
"""
|
|
149
|
+
Check if the caller is an `admin` or a `user`.
|
|
150
|
+
"""
|
|
151
|
+
is_admin = await caller_is_admin(request, raise_on_false=False)
|
|
152
|
+
is_user = await caller_is_user(request, raise_on_false=False)
|
|
153
|
+
if is_admin or is_user:
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
logger.warning("invalid role: caller is not admin or user")
|
|
157
|
+
raise HTTPException(status_code=401, detail="invalid role")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def caller_is_agent(request: Request, raise_on_false: bool = True) -> bool:
|
|
161
|
+
"""
|
|
162
|
+
Check if the caller is an `agent`.
|
|
163
|
+
"""
|
|
164
|
+
return await caller_is_role(request, "agent", raise_on_false)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def extract_token_info(request: Request) -> dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
Extract the token info from the request.
|
|
170
|
+
"""
|
|
171
|
+
token = request.headers.get("Authorization")
|
|
172
|
+
|
|
173
|
+
if token is None:
|
|
174
|
+
logger.warning("no API key provided")
|
|
175
|
+
raise HTTPException(status_code=401, detail="no API key provided")
|
|
176
|
+
if token.startswith("Bearer "):
|
|
177
|
+
token = token.split(" ")[1]
|
|
178
|
+
else:
|
|
179
|
+
logger.warning("invalid API key format: missing 'Bearer' prefix")
|
|
180
|
+
raise HTTPException(status_code=401, detail="invalid API key format")
|
|
181
|
+
|
|
182
|
+
# login to the auth service
|
|
183
|
+
jwt = await login(token)
|
|
184
|
+
|
|
185
|
+
return await get_token_info(jwt)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def require_debug(request: Request) -> None:
|
|
189
|
+
"""
|
|
190
|
+
Require the debug mode to be enabled.
|
|
191
|
+
"""
|
|
192
|
+
if not request.app.state.debug:
|
|
193
|
+
logger.warning("debug mode is not enabled")
|
|
194
|
+
raise HTTPException(status_code=404, detail="Not found")
|
mail/utils/context.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
|
|
3
|
+
import litellm
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@lru_cache(maxsize=1000)
|
|
7
|
+
def get_model_ctx_len(llm: str) -> int:
|
|
8
|
+
"""
|
|
9
|
+
Get the context length of a model from the LiteLLM model cost map.
|
|
10
|
+
"""
|
|
11
|
+
item = litellm.model_cost.get(llm)
|
|
12
|
+
if item is None:
|
|
13
|
+
_, slug = llm.split("/")
|
|
14
|
+
item = litellm.model_cost.get(slug)
|
|
15
|
+
if item is None:
|
|
16
|
+
return 0
|
|
17
|
+
return item.get("max_input_tokens", 0)
|