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.
Files changed (89) hide show
  1. ibm_watsonx_orchestrate/__init__.py +28 -0
  2. ibm_watsonx_orchestrate/agent_builder/__init__.py +0 -0
  3. ibm_watsonx_orchestrate/agent_builder/agents/__init__.py +5 -0
  4. ibm_watsonx_orchestrate/agent_builder/agents/agent.py +27 -0
  5. ibm_watsonx_orchestrate/agent_builder/agents/assistant_agent.py +28 -0
  6. ibm_watsonx_orchestrate/agent_builder/agents/external_agent.py +28 -0
  7. ibm_watsonx_orchestrate/agent_builder/agents/types.py +204 -0
  8. ibm_watsonx_orchestrate/agent_builder/connections/__init__.py +27 -0
  9. ibm_watsonx_orchestrate/agent_builder/connections/connections.py +123 -0
  10. ibm_watsonx_orchestrate/agent_builder/connections/types.py +260 -0
  11. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base.py +27 -0
  12. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base_requests.py +59 -0
  13. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/types.py +243 -0
  14. ibm_watsonx_orchestrate/agent_builder/tools/__init__.py +4 -0
  15. ibm_watsonx_orchestrate/agent_builder/tools/base_tool.py +36 -0
  16. ibm_watsonx_orchestrate/agent_builder/tools/openapi_tool.py +332 -0
  17. ibm_watsonx_orchestrate/agent_builder/tools/python_tool.py +195 -0
  18. ibm_watsonx_orchestrate/agent_builder/tools/types.py +162 -0
  19. ibm_watsonx_orchestrate/agent_builder/utils/__init__.py +0 -0
  20. ibm_watsonx_orchestrate/agent_builder/utils/pydantic_utils.py +149 -0
  21. ibm_watsonx_orchestrate/cli/__init__.py +0 -0
  22. ibm_watsonx_orchestrate/cli/commands/__init__.py +0 -0
  23. ibm_watsonx_orchestrate/cli/commands/agents/agents_command.py +192 -0
  24. ibm_watsonx_orchestrate/cli/commands/agents/agents_controller.py +660 -0
  25. ibm_watsonx_orchestrate/cli/commands/channels/channels_command.py +15 -0
  26. ibm_watsonx_orchestrate/cli/commands/channels/channels_controller.py +16 -0
  27. ibm_watsonx_orchestrate/cli/commands/channels/types.py +15 -0
  28. ibm_watsonx_orchestrate/cli/commands/channels/webchat/channels_webchat_command.py +32 -0
  29. ibm_watsonx_orchestrate/cli/commands/channels/webchat/channels_webchat_controller.py +141 -0
  30. ibm_watsonx_orchestrate/cli/commands/chat/chat_command.py +43 -0
  31. ibm_watsonx_orchestrate/cli/commands/connections/connections_command.py +307 -0
  32. ibm_watsonx_orchestrate/cli/commands/connections/connections_controller.py +517 -0
  33. ibm_watsonx_orchestrate/cli/commands/environment/environment_command.py +78 -0
  34. ibm_watsonx_orchestrate/cli/commands/environment/environment_controller.py +189 -0
  35. ibm_watsonx_orchestrate/cli/commands/environment/types.py +9 -0
  36. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_command.py +79 -0
  37. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_controller.py +201 -0
  38. ibm_watsonx_orchestrate/cli/commands/login/login_command.py +17 -0
  39. ibm_watsonx_orchestrate/cli/commands/models/models_command.py +128 -0
  40. ibm_watsonx_orchestrate/cli/commands/server/server_command.py +623 -0
  41. ibm_watsonx_orchestrate/cli/commands/settings/__init__.py +0 -0
  42. ibm_watsonx_orchestrate/cli/commands/settings/observability/__init__.py +0 -0
  43. ibm_watsonx_orchestrate/cli/commands/settings/observability/langfuse/__init__.py +0 -0
  44. ibm_watsonx_orchestrate/cli/commands/settings/observability/langfuse/langfuse_command.py +175 -0
  45. ibm_watsonx_orchestrate/cli/commands/settings/observability/observability_command.py +11 -0
  46. ibm_watsonx_orchestrate/cli/commands/settings/settings_command.py +10 -0
  47. ibm_watsonx_orchestrate/cli/commands/tools/tools_command.py +85 -0
  48. ibm_watsonx_orchestrate/cli/commands/tools/tools_controller.py +564 -0
  49. ibm_watsonx_orchestrate/cli/commands/tools/types.py +10 -0
  50. ibm_watsonx_orchestrate/cli/config.py +226 -0
  51. ibm_watsonx_orchestrate/cli/main.py +32 -0
  52. ibm_watsonx_orchestrate/client/__init__.py +0 -0
  53. ibm_watsonx_orchestrate/client/agents/agent_client.py +46 -0
  54. ibm_watsonx_orchestrate/client/agents/assistant_agent_client.py +38 -0
  55. ibm_watsonx_orchestrate/client/agents/external_agent_client.py +38 -0
  56. ibm_watsonx_orchestrate/client/analytics/__init__.py +0 -0
  57. ibm_watsonx_orchestrate/client/analytics/llm/__init__.py +0 -0
  58. ibm_watsonx_orchestrate/client/analytics/llm/analytics_llm_client.py +50 -0
  59. ibm_watsonx_orchestrate/client/base_api_client.py +113 -0
  60. ibm_watsonx_orchestrate/client/base_service_instance.py +10 -0
  61. ibm_watsonx_orchestrate/client/client.py +71 -0
  62. ibm_watsonx_orchestrate/client/client_errors.py +359 -0
  63. ibm_watsonx_orchestrate/client/connections/__init__.py +10 -0
  64. ibm_watsonx_orchestrate/client/connections/connections_client.py +162 -0
  65. ibm_watsonx_orchestrate/client/connections/utils.py +27 -0
  66. ibm_watsonx_orchestrate/client/credentials.py +123 -0
  67. ibm_watsonx_orchestrate/client/knowledge_bases/knowledge_base_client.py +46 -0
  68. ibm_watsonx_orchestrate/client/local_service_instance.py +91 -0
  69. ibm_watsonx_orchestrate/client/service_instance.py +73 -0
  70. ibm_watsonx_orchestrate/client/tools/tool_client.py +41 -0
  71. ibm_watsonx_orchestrate/client/utils.py +95 -0
  72. ibm_watsonx_orchestrate/docker/compose-lite.yml +595 -0
  73. ibm_watsonx_orchestrate/docker/default.env +125 -0
  74. ibm_watsonx_orchestrate/docker/sdk/ibm_watsonx_orchestrate-0.6.0-py3-none-any.whl +0 -0
  75. ibm_watsonx_orchestrate/docker/sdk/ibm_watsonx_orchestrate-0.6.0.tar.gz +0 -0
  76. ibm_watsonx_orchestrate/docker/start-up.sh +61 -0
  77. ibm_watsonx_orchestrate/docker/tempus/common-config.yaml +1 -0
  78. ibm_watsonx_orchestrate/run/__init__.py +0 -0
  79. ibm_watsonx_orchestrate/run/connections.py +40 -0
  80. ibm_watsonx_orchestrate/utils/__init__.py +0 -0
  81. ibm_watsonx_orchestrate/utils/logging/__init__.py +0 -0
  82. ibm_watsonx_orchestrate/utils/logging/logger.py +26 -0
  83. ibm_watsonx_orchestrate/utils/logging/logging.yaml +18 -0
  84. ibm_watsonx_orchestrate/utils/utils.py +15 -0
  85. ibm_watsonx_orchestrate-1.0.0.dist-info/METADATA +34 -0
  86. ibm_watsonx_orchestrate-1.0.0.dist-info/RECORD +89 -0
  87. ibm_watsonx_orchestrate-1.0.0.dist-info/WHEEL +4 -0
  88. ibm_watsonx_orchestrate-1.0.0.dist-info/entry_points.txt +2 -0
  89. 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
+