ibm-watsonx-orchestrate 1.0.0__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.
- ibm_watsonx_orchestrate/__init__.py +28 -0
- ibm_watsonx_orchestrate/agent_builder/__init__.py +0 -0
- ibm_watsonx_orchestrate/agent_builder/agents/__init__.py +5 -0
- ibm_watsonx_orchestrate/agent_builder/agents/agent.py +27 -0
- ibm_watsonx_orchestrate/agent_builder/agents/assistant_agent.py +28 -0
- ibm_watsonx_orchestrate/agent_builder/agents/external_agent.py +28 -0
- ibm_watsonx_orchestrate/agent_builder/agents/types.py +204 -0
- ibm_watsonx_orchestrate/agent_builder/connections/__init__.py +27 -0
- ibm_watsonx_orchestrate/agent_builder/connections/connections.py +123 -0
- ibm_watsonx_orchestrate/agent_builder/connections/types.py +260 -0
- ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base.py +27 -0
- ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base_requests.py +59 -0
- ibm_watsonx_orchestrate/agent_builder/knowledge_bases/types.py +243 -0
- ibm_watsonx_orchestrate/agent_builder/tools/__init__.py +4 -0
- ibm_watsonx_orchestrate/agent_builder/tools/base_tool.py +36 -0
- ibm_watsonx_orchestrate/agent_builder/tools/openapi_tool.py +332 -0
- ibm_watsonx_orchestrate/agent_builder/tools/python_tool.py +195 -0
- ibm_watsonx_orchestrate/agent_builder/tools/types.py +162 -0
- ibm_watsonx_orchestrate/agent_builder/utils/__init__.py +0 -0
- ibm_watsonx_orchestrate/agent_builder/utils/pydantic_utils.py +149 -0
- ibm_watsonx_orchestrate/cli/__init__.py +0 -0
- ibm_watsonx_orchestrate/cli/commands/__init__.py +0 -0
- ibm_watsonx_orchestrate/cli/commands/agents/agents_command.py +192 -0
- ibm_watsonx_orchestrate/cli/commands/agents/agents_controller.py +660 -0
- ibm_watsonx_orchestrate/cli/commands/channels/channels_command.py +15 -0
- ibm_watsonx_orchestrate/cli/commands/channels/channels_controller.py +16 -0
- ibm_watsonx_orchestrate/cli/commands/channels/types.py +15 -0
- ibm_watsonx_orchestrate/cli/commands/channels/webchat/channels_webchat_command.py +32 -0
- ibm_watsonx_orchestrate/cli/commands/channels/webchat/channels_webchat_controller.py +141 -0
- ibm_watsonx_orchestrate/cli/commands/chat/chat_command.py +43 -0
- ibm_watsonx_orchestrate/cli/commands/connections/connections_command.py +307 -0
- ibm_watsonx_orchestrate/cli/commands/connections/connections_controller.py +517 -0
- ibm_watsonx_orchestrate/cli/commands/environment/environment_command.py +78 -0
- ibm_watsonx_orchestrate/cli/commands/environment/environment_controller.py +189 -0
- ibm_watsonx_orchestrate/cli/commands/environment/types.py +9 -0
- ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_command.py +79 -0
- ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_controller.py +201 -0
- ibm_watsonx_orchestrate/cli/commands/login/login_command.py +17 -0
- ibm_watsonx_orchestrate/cli/commands/models/models_command.py +128 -0
- ibm_watsonx_orchestrate/cli/commands/server/server_command.py +623 -0
- ibm_watsonx_orchestrate/cli/commands/settings/__init__.py +0 -0
- ibm_watsonx_orchestrate/cli/commands/settings/observability/__init__.py +0 -0
- ibm_watsonx_orchestrate/cli/commands/settings/observability/langfuse/__init__.py +0 -0
- ibm_watsonx_orchestrate/cli/commands/settings/observability/langfuse/langfuse_command.py +175 -0
- ibm_watsonx_orchestrate/cli/commands/settings/observability/observability_command.py +11 -0
- ibm_watsonx_orchestrate/cli/commands/settings/settings_command.py +10 -0
- ibm_watsonx_orchestrate/cli/commands/tools/tools_command.py +85 -0
- ibm_watsonx_orchestrate/cli/commands/tools/tools_controller.py +564 -0
- ibm_watsonx_orchestrate/cli/commands/tools/types.py +10 -0
- ibm_watsonx_orchestrate/cli/config.py +226 -0
- ibm_watsonx_orchestrate/cli/main.py +32 -0
- ibm_watsonx_orchestrate/client/__init__.py +0 -0
- ibm_watsonx_orchestrate/client/agents/agent_client.py +46 -0
- ibm_watsonx_orchestrate/client/agents/assistant_agent_client.py +38 -0
- ibm_watsonx_orchestrate/client/agents/external_agent_client.py +38 -0
- ibm_watsonx_orchestrate/client/analytics/__init__.py +0 -0
- ibm_watsonx_orchestrate/client/analytics/llm/__init__.py +0 -0
- ibm_watsonx_orchestrate/client/analytics/llm/analytics_llm_client.py +50 -0
- ibm_watsonx_orchestrate/client/base_api_client.py +113 -0
- ibm_watsonx_orchestrate/client/base_service_instance.py +10 -0
- ibm_watsonx_orchestrate/client/client.py +71 -0
- ibm_watsonx_orchestrate/client/client_errors.py +359 -0
- ibm_watsonx_orchestrate/client/connections/__init__.py +10 -0
- ibm_watsonx_orchestrate/client/connections/connections_client.py +162 -0
- ibm_watsonx_orchestrate/client/connections/utils.py +27 -0
- ibm_watsonx_orchestrate/client/credentials.py +123 -0
- ibm_watsonx_orchestrate/client/knowledge_bases/knowledge_base_client.py +46 -0
- ibm_watsonx_orchestrate/client/local_service_instance.py +91 -0
- ibm_watsonx_orchestrate/client/service_instance.py +73 -0
- ibm_watsonx_orchestrate/client/tools/tool_client.py +41 -0
- ibm_watsonx_orchestrate/client/utils.py +95 -0
- ibm_watsonx_orchestrate/docker/compose-lite.yml +595 -0
- ibm_watsonx_orchestrate/docker/default.env +125 -0
- ibm_watsonx_orchestrate/docker/sdk/ibm_watsonx_orchestrate-0.6.0-py3-none-any.whl +0 -0
- ibm_watsonx_orchestrate/docker/sdk/ibm_watsonx_orchestrate-0.6.0.tar.gz +0 -0
- ibm_watsonx_orchestrate/docker/start-up.sh +61 -0
- ibm_watsonx_orchestrate/docker/tempus/common-config.yaml +1 -0
- ibm_watsonx_orchestrate/run/__init__.py +0 -0
- ibm_watsonx_orchestrate/run/connections.py +40 -0
- ibm_watsonx_orchestrate/utils/__init__.py +0 -0
- ibm_watsonx_orchestrate/utils/logging/__init__.py +0 -0
- ibm_watsonx_orchestrate/utils/logging/logger.py +26 -0
- ibm_watsonx_orchestrate/utils/logging/logging.yaml +18 -0
- ibm_watsonx_orchestrate/utils/utils.py +15 -0
- ibm_watsonx_orchestrate-1.0.0.dist-info/METADATA +34 -0
- ibm_watsonx_orchestrate-1.0.0.dist-info/RECORD +89 -0
- ibm_watsonx_orchestrate-1.0.0.dist-info/WHEEL +4 -0
- ibm_watsonx_orchestrate-1.0.0.dist-info/entry_points.txt +2 -0
- ibm_watsonx_orchestrate-1.0.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,332 @@
|
|
1
|
+
import copy
|
2
|
+
import json
|
3
|
+
import os.path
|
4
|
+
import logging
|
5
|
+
from typing import Dict, Any, List
|
6
|
+
|
7
|
+
import yaml
|
8
|
+
import yaml.constructor
|
9
|
+
import re
|
10
|
+
import httpx
|
11
|
+
import jsonref
|
12
|
+
from ibm_watsonx_orchestrate.utils.utils import yaml_safe_load
|
13
|
+
from .types import ToolSpec
|
14
|
+
from .base_tool import BaseTool
|
15
|
+
from .types import HTTP_METHOD, ToolPermission, ToolRequestBody, ToolResponseBody, \
|
16
|
+
OpenApiToolBinding, \
|
17
|
+
JsonSchemaObject, ToolBinding, OpenApiSecurityScheme
|
18
|
+
|
19
|
+
import json
|
20
|
+
|
21
|
+
logger = logging.getLogger(__name__)
|
22
|
+
|
23
|
+
# disables the automatic conversion of date-time objects to datetime objects and leaves them as strings
|
24
|
+
yaml.constructor.SafeConstructor.yaml_constructors[u'tag:yaml.org,2002:timestamp'] = \
|
25
|
+
yaml.constructor.SafeConstructor.yaml_constructors[u'tag:yaml.org,2002:str']
|
26
|
+
|
27
|
+
|
28
|
+
class HTTPException(Exception):
|
29
|
+
def __init__(self, status_code: int, message: str):
|
30
|
+
self.status_code = status_code
|
31
|
+
self.message = message
|
32
|
+
super().__init__(f"{status_code} {message}")
|
33
|
+
|
34
|
+
def __str__(self):
|
35
|
+
return f"{self.status_code} {self.message}"
|
36
|
+
|
37
|
+
|
38
|
+
class OpenAPITool(BaseTool):
|
39
|
+
def __init__(self, spec: ToolSpec):
|
40
|
+
BaseTool.__init__(self, spec=spec)
|
41
|
+
|
42
|
+
if self.__tool_spec__.binding.openapi is None:
|
43
|
+
raise ValueError('Missing openapi binding')
|
44
|
+
|
45
|
+
async def __call__(self, **kwargs):
|
46
|
+
raise RuntimeError('OpenAPI Tools are only available when deployed onto watson orchestrate or the watson '
|
47
|
+
'orchestrate-light runtime')
|
48
|
+
|
49
|
+
@staticmethod
|
50
|
+
def from_spec(file: str) -> 'OpenAPITool':
|
51
|
+
with open(file, 'r') as f:
|
52
|
+
if file.endswith('.yaml') or file.endswith('.yml'):
|
53
|
+
spec = ToolSpec.model_validate(yaml_safe_load(f))
|
54
|
+
elif file.endswith('.json'):
|
55
|
+
spec = ToolSpec.model_validate(json.load(f))
|
56
|
+
else:
|
57
|
+
raise ValueError('file must end in .json, .yaml, or .yml')
|
58
|
+
|
59
|
+
if spec.binding.openapi is None or spec.binding.openapi is None:
|
60
|
+
raise ValueError('failed to load python tool as the tool had no openapi binding')
|
61
|
+
|
62
|
+
return OpenAPITool(spec=spec)
|
63
|
+
|
64
|
+
def __repr__(self):
|
65
|
+
return f"OpenAPITool(method={self.__tool_spec__.binding.openapi.http_method}, path={self.__tool_spec__.binding.openapi.http_path}, name='{self.__tool_spec__.name}', description='{self.__tool_spec__.description}')"
|
66
|
+
|
67
|
+
def __str__(self):
|
68
|
+
return self.__repr__()
|
69
|
+
|
70
|
+
@property
|
71
|
+
def __doc__(self):
|
72
|
+
return self.__tool_spec__.description
|
73
|
+
|
74
|
+
|
75
|
+
def create_openapi_json_tool(
|
76
|
+
openapi_spec: dict,
|
77
|
+
http_path: str,
|
78
|
+
http_method: HTTP_METHOD,
|
79
|
+
http_success_response_code: int = 200,
|
80
|
+
http_response_content_type='application/json',
|
81
|
+
name: str = None,
|
82
|
+
description: str = None,
|
83
|
+
permission: ToolPermission = None,
|
84
|
+
input_schema: ToolRequestBody = None,
|
85
|
+
output_schema: ToolResponseBody = None,
|
86
|
+
connection_id: str = None
|
87
|
+
) -> OpenAPITool:
|
88
|
+
"""
|
89
|
+
Creates a tool from an openapi spec
|
90
|
+
|
91
|
+
:param openapi_spec: The parsed dictionary representation of an openapi spec
|
92
|
+
:param http_path: Which path to create a tool for
|
93
|
+
:param http_method: Which method on that path to create the tool for
|
94
|
+
:param http_success_response_code: Which http status code should be considered a successful call (defaults to 200)
|
95
|
+
:param http_response_content_type: Which http response type should be considered successful (default to application/json)
|
96
|
+
:param name: The name of the resulting tool (used to invoke the tool by the agent)
|
97
|
+
:param description: The description of the resulting tool (used as the semantic layer to help the agent with tool selection)
|
98
|
+
:param permission: Which orchestrate permission level does a user need to have to invoke this tool
|
99
|
+
:param input_schema: The JSONSchema of the inputs to the http request
|
100
|
+
:param output_schema: The expected JSON schema of the outputs of the http response
|
101
|
+
:param connection_id: The connection id of the application containing the credentials needed to authenticate against this api
|
102
|
+
:return: An OpenAPITool that can be used by an agent
|
103
|
+
"""
|
104
|
+
|
105
|
+
# limitation does not support circular $refs
|
106
|
+
openapi_contents = jsonref.replace_refs(openapi_spec, jsonschema=True)
|
107
|
+
|
108
|
+
paths = openapi_contents.get('paths', {})
|
109
|
+
route = paths.get(http_path)
|
110
|
+
if route is None:
|
111
|
+
raise ValueError(f"Path {http_path} not found in paths. Available endpoints are: {list(paths.keys())}")
|
112
|
+
|
113
|
+
route_spec = route.get(http_method.lower(), route.get(http_method.upper()))
|
114
|
+
if route_spec is None:
|
115
|
+
raise ValueError(
|
116
|
+
f"Path {http_path} did not have an http_method {http_method}. Available methods are {list(route.keys())}")
|
117
|
+
|
118
|
+
operation_id = (
|
119
|
+
re.sub(
|
120
|
+
'_+',
|
121
|
+
'_',
|
122
|
+
re.sub(
|
123
|
+
r'[^a-zA-Z_]',
|
124
|
+
'_',
|
125
|
+
route_spec.get('operationId', None)
|
126
|
+
)
|
127
|
+
)
|
128
|
+
) if route_spec.get('operationId', None) is not None else None
|
129
|
+
spec_name = name or operation_id
|
130
|
+
spec_permission = permission or _action_to_perm(route_spec.get('x-ibm-operation', {}).get('action'))
|
131
|
+
if spec_name is None:
|
132
|
+
raise ValueError(
|
133
|
+
f"No name provided for tool. {http_method}: {http_path} did not specify an operationId, and no name was provided")
|
134
|
+
|
135
|
+
spec_description = description or route_spec.get('description')
|
136
|
+
if spec_description is None:
|
137
|
+
raise ValueError(
|
138
|
+
f"No description provided for tool. {http_method}: {http_path} did not specify a description field, and no description was provided")
|
139
|
+
|
140
|
+
spec = ToolSpec(
|
141
|
+
name=spec_name,
|
142
|
+
description=spec_description,
|
143
|
+
permission=spec_permission
|
144
|
+
)
|
145
|
+
|
146
|
+
spec.input_schema = input_schema or ToolRequestBody(
|
147
|
+
type='object',
|
148
|
+
properties={},
|
149
|
+
required=[]
|
150
|
+
)
|
151
|
+
spec.output_schema = output_schema or ToolResponseBody(properties={}, required=[])
|
152
|
+
|
153
|
+
parameters = route_spec.get('parameters') or []
|
154
|
+
for parameter in parameters:
|
155
|
+
name = f"{parameter['in']}_{parameter['name']}"
|
156
|
+
if parameter.get('required'):
|
157
|
+
spec.input_schema.required.append(name)
|
158
|
+
parameter['schema']['title'] = parameter['name']
|
159
|
+
parameter['schema']['description'] = parameter.get('description', None)
|
160
|
+
spec.input_schema.properties[name] = JsonSchemaObject.model_validate(parameter['schema'])
|
161
|
+
spec.input_schema.properties[name].in_field = parameter['in']
|
162
|
+
spec.input_schema.properties[name].aliasName = parameter['name']
|
163
|
+
|
164
|
+
# special case in runtime where __requestBody__ will be directly translated to the request body without translation
|
165
|
+
request_body_params = route_spec.get('requestBody', {}).get('content', {}).get(http_response_content_type, {}).get(
|
166
|
+
'schema', None)
|
167
|
+
if request_body_params is not None:
|
168
|
+
spec.input_schema.required.append('__requestBody__')
|
169
|
+
request_body_params = copy.deepcopy(request_body_params)
|
170
|
+
request_body_params['in'] = 'body'
|
171
|
+
if request_body_params.get('title') is None:
|
172
|
+
request_body_params['title'] = 'RequestBody'
|
173
|
+
if request_body_params.get('description') is None:
|
174
|
+
request_body_params['description'] = 'The html request body used to satisfy this user utterance.'
|
175
|
+
|
176
|
+
spec.input_schema.properties['__requestBody__'] = JsonSchemaObject.model_validate(request_body_params)
|
177
|
+
|
178
|
+
responses = route_spec.get('responses', {})
|
179
|
+
response = responses.get(str(http_success_response_code), {})
|
180
|
+
response_description = response.get('description')
|
181
|
+
response_schema = response.get('content', {}).get(http_response_content_type, {}).get('schema', {})
|
182
|
+
|
183
|
+
response_schema['required'] = []
|
184
|
+
spec.output_schema = ToolResponseBody.model_validate(response_schema)
|
185
|
+
spec.output_schema.description = response_description
|
186
|
+
|
187
|
+
servers = list(map(lambda x: x if isinstance(x, str) else x['url'],
|
188
|
+
openapi_contents.get('servers', openapi_contents.get('x-servers', []))))
|
189
|
+
|
190
|
+
raw_open_api_security_schemes = openapi_contents.get('components', {}).get('securitySchemes', {})
|
191
|
+
security_schemes_map = {}
|
192
|
+
for key, security_scheme in raw_open_api_security_schemes.items():
|
193
|
+
security_schemes_map[key] = OpenApiSecurityScheme(
|
194
|
+
type=security_scheme['type'],
|
195
|
+
scheme=security_scheme.get('scheme'),
|
196
|
+
flows=security_scheme.get('flows'),
|
197
|
+
name=security_scheme.get('name'),
|
198
|
+
open_id_connect_url=security_scheme.get('openId', {}).get('openIdConnectUrl'),
|
199
|
+
in_field=security_scheme.get('in', security_scheme.get('in_field'))
|
200
|
+
)
|
201
|
+
|
202
|
+
# - Note it's possible for security to be configured per route or globally
|
203
|
+
# - Note we have no concept of scope because to a user their auth cred either has access or it doesn't
|
204
|
+
# unless we ask them for a scope they don't know to validate it provides no value
|
205
|
+
security = []
|
206
|
+
for needed_security in route_spec.get('security', []) + openapi_spec.get('security', []):
|
207
|
+
name = next(iter(needed_security.keys()), None)
|
208
|
+
if name is None or name not in security_schemes_map:
|
209
|
+
raise ValueError(f"Invalid openapi spec, {HTTP_METHOD} {http_path} asks for a security scheme of {name}, "
|
210
|
+
f"but no such security scheme was configured in the .security section of the spec")
|
211
|
+
|
212
|
+
security.append(security_schemes_map[name])
|
213
|
+
|
214
|
+
spec.binding = ToolBinding(openapi=OpenApiToolBinding(
|
215
|
+
http_path=http_path,
|
216
|
+
http_method=http_method,
|
217
|
+
security=security,
|
218
|
+
servers=servers,
|
219
|
+
connection_id=connection_id
|
220
|
+
))
|
221
|
+
|
222
|
+
return OpenAPITool(spec=spec)
|
223
|
+
|
224
|
+
|
225
|
+
async def _get_openapi_spec_from_uri(openapi_uri: str) -> Dict[str, Any]:
|
226
|
+
if os.path.exists(openapi_uri) or openapi_uri.startswith('file://'):
|
227
|
+
with open(openapi_uri, 'r') as fp:
|
228
|
+
if openapi_uri.endswith('.json'):
|
229
|
+
openapi_contents = json.load(fp)
|
230
|
+
elif openapi_uri.endswith('.yaml') or openapi_uri.endswith('.yml'):
|
231
|
+
openapi_contents = yaml_safe_load(fp)
|
232
|
+
else:
|
233
|
+
raise ValueError(
|
234
|
+
f"Unexpected file extension for file {openapi_uri}, expected one of [.json, .yaml, .yml]")
|
235
|
+
elif openapi_uri.endswith('.json'):
|
236
|
+
async with httpx.AsyncClient() as client:
|
237
|
+
r = await client.get(openapi_uri)
|
238
|
+
if r.status_code != 200:
|
239
|
+
raise ValueError(f"Failed to fetch an openapi spec from {openapi_uri}, status code: {r.status_code}")
|
240
|
+
openapi_contents = r.json()
|
241
|
+
elif openapi_uri.endswith('.yaml'):
|
242
|
+
async with httpx.AsyncClient() as client:
|
243
|
+
r = await client.get(openapi_uri)
|
244
|
+
if r.status_code != 200:
|
245
|
+
raise ValueError(f"Failed to fetch an openapi spec from {openapi_uri}, status code: {r.status_code}")
|
246
|
+
openapi_contents = yaml_safe_load(r.text)
|
247
|
+
|
248
|
+
if openapi_contents is None:
|
249
|
+
raise ValueError(f"Unrecognized path or uri {openapi_uri}")
|
250
|
+
|
251
|
+
return openapi_contents
|
252
|
+
|
253
|
+
|
254
|
+
def _action_to_perm(action: str) -> str:
|
255
|
+
if action and (
|
256
|
+
action.lower().startswith('create') or action.lower().startswith('update') or action.lower().startswith(
|
257
|
+
'delete')):
|
258
|
+
return ToolPermission.READ_WRITE
|
259
|
+
return ToolPermission.READ_ONLY
|
260
|
+
|
261
|
+
|
262
|
+
async def create_openapi_json_tool_from_uri(
|
263
|
+
openapi_uri: str,
|
264
|
+
http_path: str,
|
265
|
+
http_method: HTTP_METHOD,
|
266
|
+
http_success_response_code: int = 200,
|
267
|
+
http_response_content_type='application/json',
|
268
|
+
permission: ToolPermission = ToolPermission.READ_ONLY,
|
269
|
+
name: str = None,
|
270
|
+
description: str = None,
|
271
|
+
input_schema: ToolRequestBody = None,
|
272
|
+
output_schema: ToolResponseBody = None,
|
273
|
+
app_id: str = None
|
274
|
+
) -> OpenAPITool:
|
275
|
+
"""
|
276
|
+
Creates a tool from an openapi spec
|
277
|
+
|
278
|
+
:param openapi_uri: The uri to the openapi spec to generate the tool from (ie file://path/to/openapi_file.json, https://catfact.ninja/docs/api-docs.json)
|
279
|
+
:param http_path: Which path to create a tool for
|
280
|
+
:param http_method: Which method on that path to create the tool for
|
281
|
+
:param http_success_response_code: Which http status code should be considered a successful call (defaults to 200)
|
282
|
+
:param http_response_content_type: Which http response type should be considered successful (default to application/json)
|
283
|
+
:param name: The name of the resulting tool (used to invoke the tool by the agent)
|
284
|
+
:param description: The description of the resulting tool (used as the semantic layer to help the agent with tool selection)
|
285
|
+
:param permission: Which orchestrate permission level does a user need to have to invoke this tool
|
286
|
+
:param input_schema: The JSONSchema of the inputs to the http request
|
287
|
+
:param output_schema: The expected JSON schema of the outputs of the http response
|
288
|
+
:param app_id: The app id of the connection containing the credentials needed to authenticate against this api
|
289
|
+
:return: An OpenAPITool that can be used by an agent
|
290
|
+
"""
|
291
|
+
openapi_contents = await _get_openapi_spec_from_uri(openapi_uri)
|
292
|
+
|
293
|
+
return create_openapi_json_tool(
|
294
|
+
openapi_spec=openapi_contents,
|
295
|
+
http_path=http_path,
|
296
|
+
http_method=http_method,
|
297
|
+
http_success_response_code=http_success_response_code,
|
298
|
+
http_response_content_type=http_response_content_type,
|
299
|
+
permission=permission,
|
300
|
+
name=name,
|
301
|
+
description=description,
|
302
|
+
input_schema=input_schema,
|
303
|
+
output_schema=output_schema,
|
304
|
+
app_id=app_id
|
305
|
+
)
|
306
|
+
|
307
|
+
|
308
|
+
async def create_openapi_json_tools_from_uri(
|
309
|
+
openapi_uri: str,
|
310
|
+
connection_id: str = None
|
311
|
+
) -> List[OpenAPITool]:
|
312
|
+
openapi_contents = await _get_openapi_spec_from_uri(openapi_uri)
|
313
|
+
tools: List[OpenAPITool] = []
|
314
|
+
|
315
|
+
for path, methods in openapi_contents.get('paths', {}).items():
|
316
|
+
for method, spec in methods.items():
|
317
|
+
if method.lower() == 'head':
|
318
|
+
continue
|
319
|
+
success_codes = list(filter(lambda code: 200 <= int(code) < 300, spec['responses'].keys()))
|
320
|
+
if len(success_codes) > 1:
|
321
|
+
logger.warning(
|
322
|
+
f"There were multiple candidate success codes for {method} {path}, using {success_codes[0]} to generate output schema")
|
323
|
+
|
324
|
+
tools.append(create_openapi_json_tool(
|
325
|
+
openapi_contents,
|
326
|
+
http_path=path,
|
327
|
+
http_method=method.upper(),
|
328
|
+
http_success_response_code=success_codes[0] if len(success_codes) > 0 else None,
|
329
|
+
connection_id=connection_id
|
330
|
+
))
|
331
|
+
|
332
|
+
return tools
|
@@ -0,0 +1,195 @@
|
|
1
|
+
import importlib
|
2
|
+
import inspect
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
from typing import Callable, List
|
6
|
+
import logging
|
7
|
+
|
8
|
+
import docstring_parser
|
9
|
+
from langchain_core.tools.base import create_schema_from_function
|
10
|
+
from langchain_core.utils.json_schema import dereference_refs
|
11
|
+
from pydantic import TypeAdapter, BaseModel
|
12
|
+
|
13
|
+
from ibm_watsonx_orchestrate.utils.utils import yaml_safe_load
|
14
|
+
from ibm_watsonx_orchestrate.agent_builder.connections import ExpectedCredentials
|
15
|
+
from .base_tool import BaseTool
|
16
|
+
from .types import ToolSpec, ToolPermission, ToolRequestBody, ToolResponseBody, JsonSchemaObject, ToolBinding, \
|
17
|
+
PythonToolBinding
|
18
|
+
|
19
|
+
_all_tools = []
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
class PythonTool(BaseTool):
|
23
|
+
def __init__(self, fn, spec: ToolSpec, expected_credentials: List[ExpectedCredentials]=None):
|
24
|
+
BaseTool.__init__(self, spec=spec)
|
25
|
+
self.fn = fn
|
26
|
+
self.expected_credentials=expected_credentials
|
27
|
+
|
28
|
+
def __call__(self, *args, **kwargs):
|
29
|
+
return self.fn(*args, **kwargs)
|
30
|
+
|
31
|
+
@staticmethod
|
32
|
+
def from_spec(file: str) -> 'PythonTool':
|
33
|
+
with open(file, 'r') as f:
|
34
|
+
if file.endswith('.yaml') or file.endswith('.yml'):
|
35
|
+
spec = ToolSpec.model_validate(yaml_safe_load(f))
|
36
|
+
elif file.endswith('.json'):
|
37
|
+
spec = ToolSpec.model_validate(json.load(f))
|
38
|
+
else:
|
39
|
+
raise ValueError('file must end in .json, .yaml, or .yml')
|
40
|
+
|
41
|
+
if spec.binding.python is None:
|
42
|
+
raise ValueError('failed to load python tool as the tool had no python binding')
|
43
|
+
|
44
|
+
[module, fn_name] = spec.binding.python.function.split(':')
|
45
|
+
fn = getattr(importlib.import_module(module), fn_name)
|
46
|
+
|
47
|
+
return PythonTool(fn=fn, spec=spec)
|
48
|
+
|
49
|
+
def __repr__(self):
|
50
|
+
return f"PythonTool(fn={self.__tool_spec__.binding.python.function}, name='{self.__tool_spec__.name}', description='{self.__tool_spec__.description}')"
|
51
|
+
|
52
|
+
def __str__(self):
|
53
|
+
return self.__repr__()
|
54
|
+
|
55
|
+
def _fix_optional(schema):
|
56
|
+
if schema.properties is None:
|
57
|
+
return schema
|
58
|
+
# Pydantic tends to create types of anyOf: [{type: thing}, {type: null}] instead of simply
|
59
|
+
# while simultaneously marking the field as required, which can be confusing for the model.
|
60
|
+
# This removes union types with null and simply marks the field as not required
|
61
|
+
not_required = []
|
62
|
+
replacements = {}
|
63
|
+
if schema.required is None:
|
64
|
+
schema.required = []
|
65
|
+
|
66
|
+
for k, v in schema.properties.items():
|
67
|
+
if v.type == 'null' and k in schema.required:
|
68
|
+
not_required.append(k)
|
69
|
+
if v.anyOf is not None and next(filter(lambda x: x.type == 'null', v.anyOf)) and k in schema.required:
|
70
|
+
v.anyOf = list(filter(lambda x: x.type != 'null', v.anyOf))
|
71
|
+
if len(v.anyOf) == 1:
|
72
|
+
replacements[k] = v.anyOf[0]
|
73
|
+
not_required.append(k)
|
74
|
+
schema.required = list(filter(lambda x: x not in not_required, schema.required if schema.required is not None else []))
|
75
|
+
for k, v in replacements.items():
|
76
|
+
combined = {
|
77
|
+
**schema.properties[k].model_dump(exclude_unset=True, exclude_none=True),
|
78
|
+
**v.model_dump(exclude_unset=True, exclude_none=True)
|
79
|
+
}
|
80
|
+
schema.properties[k] = JsonSchemaObject(**combined)
|
81
|
+
schema.properties[k].anyOf = None
|
82
|
+
|
83
|
+
for k in schema.properties.keys():
|
84
|
+
if schema.properties[k].type == 'object':
|
85
|
+
schema.properties[k] = _fix_optional(schema.properties[k])
|
86
|
+
|
87
|
+
return schema
|
88
|
+
|
89
|
+
def _validate_input_schema(input_schema: ToolRequestBody) -> None:
|
90
|
+
props = input_schema.properties
|
91
|
+
for prop in props:
|
92
|
+
if not props.get(prop).type:
|
93
|
+
logger.warning(f"Missing type hint for tool property '{prop}' defaulting to 'str'. To remove this warning add a type hint to the property in the tools signature. See Python docs for guidance: https://docs.python.org/3/library/typing.html")
|
94
|
+
|
95
|
+
def tool(
|
96
|
+
*args,
|
97
|
+
name: str = None,
|
98
|
+
description: str = None,
|
99
|
+
input_schema: ToolRequestBody = None,
|
100
|
+
output_schema: ToolResponseBody = None,
|
101
|
+
permission: ToolPermission = ToolPermission.READ_ONLY,
|
102
|
+
expected_credentials: List[ExpectedCredentials] = None
|
103
|
+
) -> Callable[[{__name__, __doc__}], PythonTool]:
|
104
|
+
"""
|
105
|
+
Decorator to convert a python function into a callable tool.
|
106
|
+
|
107
|
+
:param name: the agent facing name of the tool (defaults to the function name)
|
108
|
+
:param description: the description of the tool (used for tool routing by the agent)
|
109
|
+
:param input_schema: the json schema args to the tool
|
110
|
+
:param output_schema: the response json schema for the tool
|
111
|
+
:param permission: the permissions needed by the user of the agent to invoke the tool
|
112
|
+
:return:
|
113
|
+
"""
|
114
|
+
# inspiration: https://github.com/pydantic/pydantic/blob/main/pydantic/validate_call_decorator.py
|
115
|
+
def _tool_decorator(fn):
|
116
|
+
if fn.__doc__ is not None:
|
117
|
+
doc = docstring_parser.parse(fn.__doc__)
|
118
|
+
else:
|
119
|
+
doc = None
|
120
|
+
|
121
|
+
_desc = description
|
122
|
+
if description is None and doc is not None:
|
123
|
+
_desc = doc.description
|
124
|
+
|
125
|
+
spec = ToolSpec(
|
126
|
+
name=name or fn.__name__,
|
127
|
+
description=_desc,
|
128
|
+
permission=permission
|
129
|
+
)
|
130
|
+
|
131
|
+
parsed_expected_credentials = []
|
132
|
+
if expected_credentials:
|
133
|
+
for credential in expected_credentials:
|
134
|
+
if isinstance(credential, ExpectedCredentials):
|
135
|
+
parsed_expected_credentials.append(credential)
|
136
|
+
else:
|
137
|
+
parsed_expected_credentials.append(ExpectedCredentials.model_validate(credential))
|
138
|
+
|
139
|
+
t = PythonTool(fn=fn, spec=spec, expected_credentials=parsed_expected_credentials)
|
140
|
+
spec.binding = ToolBinding(python=PythonToolBinding(function=''))
|
141
|
+
|
142
|
+
linux_friendly_os_cwd = os.getcwd().replace("\\", "/")
|
143
|
+
function_binding = (inspect.getsourcefile(fn)
|
144
|
+
.replace("\\", "/")
|
145
|
+
.replace(linux_friendly_os_cwd+'/', '')
|
146
|
+
.replace('.py', '')
|
147
|
+
.replace('/','.') +
|
148
|
+
f":{fn.__name__}")
|
149
|
+
spec.binding.python.function = function_binding
|
150
|
+
|
151
|
+
sig = inspect.signature(fn)
|
152
|
+
if not input_schema:
|
153
|
+
input_schema_model: type[BaseModel] = create_schema_from_function(spec.name, fn, parse_docstring=True)
|
154
|
+
input_schema_json = input_schema_model.model_json_schema()
|
155
|
+
input_schema_json = dereference_refs(input_schema_json)
|
156
|
+
|
157
|
+
# Convert the input schema to a JsonSchemaObject
|
158
|
+
input_schema_obj = JsonSchemaObject(**input_schema_json)
|
159
|
+
input_schema_obj = _fix_optional(input_schema_obj)
|
160
|
+
|
161
|
+
spec.input_schema = ToolRequestBody(
|
162
|
+
type='object',
|
163
|
+
properties=input_schema_obj.properties or {},
|
164
|
+
required=input_schema_obj.required or []
|
165
|
+
)
|
166
|
+
else:
|
167
|
+
spec.input_schema = input_schema
|
168
|
+
|
169
|
+
_validate_input_schema(spec.input_schema)
|
170
|
+
|
171
|
+
if not output_schema:
|
172
|
+
ret = sig.return_annotation
|
173
|
+
if ret != sig.empty:
|
174
|
+
_schema = dereference_refs(TypeAdapter(ret).json_schema())
|
175
|
+
if '$defs' in _schema:
|
176
|
+
_schema.pop('$defs')
|
177
|
+
spec.output_schema = _fix_optional(ToolResponseBody(**_schema))
|
178
|
+
else:
|
179
|
+
spec.output_schema = ToolResponseBody()
|
180
|
+
|
181
|
+
if doc is not None and doc.returns is not None and doc.returns.description is not None:
|
182
|
+
spec.output_schema.description = doc.returns.description
|
183
|
+
|
184
|
+
else:
|
185
|
+
spec.output_schema = ToolResponseBody()
|
186
|
+
_all_tools.append(t)
|
187
|
+
return t
|
188
|
+
|
189
|
+
if len(args) == 1 and callable(args[0]):
|
190
|
+
return _tool_decorator(args[0])
|
191
|
+
return _tool_decorator
|
192
|
+
|
193
|
+
|
194
|
+
def get_all_python_tools():
|
195
|
+
return [t for t in _all_tools]
|
@@ -0,0 +1,162 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
from typing import List, Any, Dict, Literal, Optional
|
3
|
+
|
4
|
+
from pydantic import BaseModel, model_validator, ConfigDict, Field, AliasChoices
|
5
|
+
|
6
|
+
|
7
|
+
class ToolPermission(str, Enum):
|
8
|
+
READ_ONLY = 'read_only'
|
9
|
+
WRITE_ONLY = 'write_only'
|
10
|
+
READ_WRITE = 'read_write'
|
11
|
+
ADMIN = 'admin'
|
12
|
+
|
13
|
+
|
14
|
+
class JsonSchemaObject(BaseModel):
|
15
|
+
model_config = ConfigDict(extra='allow')
|
16
|
+
|
17
|
+
type: Optional[Literal['object', 'string', 'number', 'integer', 'boolean', 'array', 'null']] = None
|
18
|
+
title: str | None = None
|
19
|
+
description: str | None = None
|
20
|
+
properties: Optional[Dict[str, 'JsonSchemaObject']] = None
|
21
|
+
required: Optional[List[str]] = None
|
22
|
+
items: Optional['JsonSchemaObject'] = None
|
23
|
+
uniqueItems: bool | None = None
|
24
|
+
default: Any | None = None
|
25
|
+
enum: List[Any] | None = None
|
26
|
+
minimum: float | None = None
|
27
|
+
maximum: float | None = None
|
28
|
+
minLength: int | None = None
|
29
|
+
maxLength: int | None = None
|
30
|
+
format: str | None = None
|
31
|
+
pattern: str | None = None
|
32
|
+
anyOf: Optional[List['JsonSchemaObject']] = None
|
33
|
+
in_field: Optional[Literal['query', 'header', 'path', 'body']] = Field(None, alias='in')
|
34
|
+
aliasName: str | None = None
|
35
|
+
"Runtime feature where the sdk can provide the original name of a field before prefixing"
|
36
|
+
|
37
|
+
|
38
|
+
class ToolRequestBody(BaseModel):
|
39
|
+
model_config = ConfigDict(extra='allow')
|
40
|
+
|
41
|
+
type: Literal['object']
|
42
|
+
properties: Dict[str, JsonSchemaObject]
|
43
|
+
required: List[str]
|
44
|
+
|
45
|
+
|
46
|
+
class ToolResponseBody(BaseModel):
|
47
|
+
model_config = ConfigDict(extra='allow')
|
48
|
+
|
49
|
+
type: Literal['object', 'string', 'number', 'integer', 'boolean', 'array','null'] = None
|
50
|
+
description: str = None
|
51
|
+
properties: Dict[str, JsonSchemaObject] = None
|
52
|
+
items: JsonSchemaObject = None
|
53
|
+
uniqueItems: bool = None
|
54
|
+
anyOf: List['JsonSchemaObject'] = None
|
55
|
+
required: List[str] = None
|
56
|
+
|
57
|
+
class OpenApiSecurityScheme(BaseModel):
|
58
|
+
type: Literal['apiKey', 'http', 'oauth2', 'openIdConnect']
|
59
|
+
scheme: Optional[Literal['basic', 'bearer', 'oauth']] = None
|
60
|
+
in_field: Optional[Literal['query', 'header', 'cookie']] = Field(None, validation_alias=AliasChoices('in', 'in_field'), serialization_alias='in')
|
61
|
+
name: str | None = None
|
62
|
+
open_id_connect_url: str | None = None
|
63
|
+
flows: dict | None = None
|
64
|
+
|
65
|
+
@model_validator(mode='after')
|
66
|
+
def validate_security_scheme(self) -> 'OpenApiSecurityScheme':
|
67
|
+
if self.type == 'http' and self.scheme is None:
|
68
|
+
raise ValueError("'scheme' is required when type is 'http'")
|
69
|
+
|
70
|
+
if self.type == 'oauth2' and self.flows is None:
|
71
|
+
raise ValueError("'flows' is required when type is 'oauth2'")
|
72
|
+
|
73
|
+
if self.type == 'openIdConnect' and self.open_id_connect_url is None:
|
74
|
+
raise ValueError("'open_id_connect_url' is required when type is 'openIdConnect'")
|
75
|
+
|
76
|
+
if self.type == 'apiKey':
|
77
|
+
if self.name is None:
|
78
|
+
raise ValueError("'name' is required when type is 'apiKey'")
|
79
|
+
if self.in_field is None:
|
80
|
+
raise ValueError("'in_field' is required when type is 'apiKey'")
|
81
|
+
|
82
|
+
return self
|
83
|
+
|
84
|
+
|
85
|
+
HTTP_METHOD = Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
|
86
|
+
|
87
|
+
|
88
|
+
class OpenApiToolBinding(BaseModel):
|
89
|
+
http_method: HTTP_METHOD
|
90
|
+
http_path: str
|
91
|
+
success_status_code: int = 200 # this is a diff from the spec
|
92
|
+
security: Optional[List[OpenApiSecurityScheme]] = None
|
93
|
+
servers: Optional[List[str]] = None
|
94
|
+
connection_id: str | None = None
|
95
|
+
|
96
|
+
@model_validator(mode='after')
|
97
|
+
def validate_openapi_tool_binding(self):
|
98
|
+
if len(self.servers) != 1:
|
99
|
+
raise ValueError("OpenAPI definition must include exactly one server")
|
100
|
+
return self
|
101
|
+
|
102
|
+
|
103
|
+
class PythonToolBinding(BaseModel):
|
104
|
+
function: str
|
105
|
+
requirements: Optional[List[str]] = []
|
106
|
+
connections: dict[str, str] = None
|
107
|
+
|
108
|
+
|
109
|
+
class WxFlowsToolBinding(BaseModel):
|
110
|
+
endpoint: str
|
111
|
+
flow_name: str
|
112
|
+
security: OpenApiSecurityScheme
|
113
|
+
|
114
|
+
@model_validator(mode='after')
|
115
|
+
def validate_security_scheme(self) -> 'WxFlowsToolBinding':
|
116
|
+
if self.security.type != 'apiKey':
|
117
|
+
raise ValueError("'security' scheme must be of type 'apiKey'")
|
118
|
+
return self
|
119
|
+
|
120
|
+
|
121
|
+
class SkillToolBinding(BaseModel):
|
122
|
+
skillset_id: str
|
123
|
+
skill_id: str
|
124
|
+
skill_operator_path: str
|
125
|
+
http_method: HTTP_METHOD
|
126
|
+
|
127
|
+
|
128
|
+
class ClientSideToolBinding(BaseModel):
|
129
|
+
pass
|
130
|
+
|
131
|
+
|
132
|
+
class ToolBinding(BaseModel):
|
133
|
+
openapi: OpenApiToolBinding = None
|
134
|
+
python: PythonToolBinding = None
|
135
|
+
wxflows: WxFlowsToolBinding = None
|
136
|
+
skill: SkillToolBinding = None
|
137
|
+
client_side: ClientSideToolBinding = None
|
138
|
+
|
139
|
+
@model_validator(mode='after')
|
140
|
+
def validate_binding_type(self) -> 'ToolBinding':
|
141
|
+
bindings = [
|
142
|
+
self.openapi is not None,
|
143
|
+
self.python is not None,
|
144
|
+
self.wxflows is not None,
|
145
|
+
self.skill is not None,
|
146
|
+
self.client_side is not None
|
147
|
+
]
|
148
|
+
if sum(bindings) == 0:
|
149
|
+
raise ValueError("One binding must be set")
|
150
|
+
if sum(bindings) > 1:
|
151
|
+
raise ValueError("Only one binding can be set")
|
152
|
+
return self
|
153
|
+
|
154
|
+
|
155
|
+
class ToolSpec(BaseModel):
|
156
|
+
name: str
|
157
|
+
description: str
|
158
|
+
permission: ToolPermission
|
159
|
+
input_schema: ToolRequestBody = None
|
160
|
+
output_schema: ToolResponseBody = None
|
161
|
+
binding: ToolBinding = None
|
162
|
+
|
File without changes
|