ibm-watsonx-orchestrate 1.2.0__py3-none-any.whl → 1.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. ibm_watsonx_orchestrate/__init__.py +1 -1
  2. ibm_watsonx_orchestrate/agent_builder/agents/types.py +6 -1
  3. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base.py +16 -3
  4. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/knowledge_base_requests.py +4 -20
  5. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/types.py +13 -15
  6. ibm_watsonx_orchestrate/agent_builder/toolkits/base_toolkit.py +32 -0
  7. ibm_watsonx_orchestrate/agent_builder/toolkits/types.py +42 -0
  8. ibm_watsonx_orchestrate/agent_builder/tools/openapi_tool.py +14 -13
  9. ibm_watsonx_orchestrate/agent_builder/tools/python_tool.py +4 -2
  10. ibm_watsonx_orchestrate/agent_builder/tools/types.py +2 -1
  11. ibm_watsonx_orchestrate/cli/commands/agents/agents_command.py +29 -0
  12. ibm_watsonx_orchestrate/cli/commands/agents/agents_controller.py +273 -12
  13. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_command.py +2 -2
  14. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_controller.py +79 -39
  15. ibm_watsonx_orchestrate/cli/commands/models/env_file_model_provider_mapper.py +180 -0
  16. ibm_watsonx_orchestrate/cli/commands/models/models_command.py +194 -8
  17. ibm_watsonx_orchestrate/cli/commands/server/server_command.py +117 -48
  18. ibm_watsonx_orchestrate/cli/commands/server/types.py +105 -0
  19. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_command.py +55 -7
  20. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_controller.py +123 -42
  21. ibm_watsonx_orchestrate/cli/commands/tools/tools_command.py +22 -1
  22. ibm_watsonx_orchestrate/cli/commands/tools/tools_controller.py +209 -25
  23. ibm_watsonx_orchestrate/cli/init_helper.py +43 -0
  24. ibm_watsonx_orchestrate/cli/main.py +3 -1
  25. ibm_watsonx_orchestrate/client/agents/agent_client.py +4 -1
  26. ibm_watsonx_orchestrate/client/agents/assistant_agent_client.py +5 -1
  27. ibm_watsonx_orchestrate/client/agents/external_agent_client.py +5 -1
  28. ibm_watsonx_orchestrate/client/analytics/llm/analytics_llm_client.py +2 -6
  29. ibm_watsonx_orchestrate/client/base_api_client.py +5 -2
  30. ibm_watsonx_orchestrate/client/connections/connections_client.py +15 -21
  31. ibm_watsonx_orchestrate/client/model_policies/__init__.py +0 -0
  32. ibm_watsonx_orchestrate/client/model_policies/model_policies_client.py +47 -0
  33. ibm_watsonx_orchestrate/client/model_policies/types.py +36 -0
  34. ibm_watsonx_orchestrate/client/models/__init__.py +0 -0
  35. ibm_watsonx_orchestrate/client/models/models_client.py +46 -0
  36. ibm_watsonx_orchestrate/client/models/types.py +177 -0
  37. ibm_watsonx_orchestrate/client/toolkit/toolkit_client.py +15 -6
  38. ibm_watsonx_orchestrate/client/tools/tempus_client.py +40 -0
  39. ibm_watsonx_orchestrate/client/tools/tool_client.py +8 -0
  40. ibm_watsonx_orchestrate/docker/compose-lite.yml +68 -13
  41. ibm_watsonx_orchestrate/docker/default.env +22 -12
  42. ibm_watsonx_orchestrate/docker/tempus/common-config.yaml +1 -1
  43. ibm_watsonx_orchestrate/experimental/flow_builder/__init__.py +0 -0
  44. ibm_watsonx_orchestrate/experimental/flow_builder/flows/__init__.py +41 -0
  45. ibm_watsonx_orchestrate/experimental/flow_builder/flows/constants.py +17 -0
  46. ibm_watsonx_orchestrate/experimental/flow_builder/flows/data_map.py +91 -0
  47. ibm_watsonx_orchestrate/experimental/flow_builder/flows/decorators.py +143 -0
  48. ibm_watsonx_orchestrate/experimental/flow_builder/flows/events.py +72 -0
  49. ibm_watsonx_orchestrate/experimental/flow_builder/flows/flow.py +1288 -0
  50. ibm_watsonx_orchestrate/experimental/flow_builder/node.py +97 -0
  51. ibm_watsonx_orchestrate/experimental/flow_builder/resources/flow_status.openapi.yml +98 -0
  52. ibm_watsonx_orchestrate/experimental/flow_builder/types.py +492 -0
  53. ibm_watsonx_orchestrate/experimental/flow_builder/utils.py +113 -0
  54. ibm_watsonx_orchestrate/utils/utils.py +5 -2
  55. {ibm_watsonx_orchestrate-1.2.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/METADATA +6 -2
  56. {ibm_watsonx_orchestrate-1.2.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/RECORD +59 -36
  57. {ibm_watsonx_orchestrate-1.2.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/WHEEL +0 -0
  58. {ibm_watsonx_orchestrate-1.2.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/entry_points.txt +0 -0
  59. {ibm_watsonx_orchestrate-1.2.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/licenses/LICENSE +0 -0
@@ -8,6 +8,9 @@ import sys
8
8
  import re
9
9
  import requests
10
10
  from ibm_watsonx_orchestrate.client.toolkit.toolkit_client import ToolKitClient
11
+ from ibm_watsonx_orchestrate.client.tools.tool_client import ToolClient
12
+ from ibm_watsonx_orchestrate.agent_builder.toolkits.base_toolkit import BaseToolkit, ToolkitSpec
13
+ from ibm_watsonx_orchestrate.agent_builder.toolkits.types import ToolkitKind, Language, ToolkitSource
11
14
  from ibm_watsonx_orchestrate.client.utils import instantiate_client
12
15
  from ibm_watsonx_orchestrate.utils.utils import sanatize_app_id
13
16
  from ibm_watsonx_orchestrate.client.connections import get_connections_client
@@ -16,12 +19,13 @@ import json
16
19
  from rich.console import Console
17
20
  from rich.progress import Progress, SpinnerColumn, TextColumn
18
21
  from ibm_watsonx_orchestrate.client.utils import is_local_dev
22
+ from rich.json import JSON
23
+ import rich
24
+ import rich.table
25
+ import json
19
26
 
20
27
  logger = logging.getLogger(__name__)
21
28
 
22
- class ToolkitKind(str, Enum):
23
- MCP = "mcp"
24
-
25
29
  def get_connection_id(app_id: str) -> str:
26
30
  connections_client = get_connections_client()
27
31
  existing_draft_configuration = connections_client.get_config(app_id=app_id, env='draft')
@@ -47,29 +51,34 @@ def validate_params(kind: str):
47
51
 
48
52
  class ToolkitController:
49
53
  def __init__(
50
- self,
54
+ self,
51
55
  kind: ToolkitKind = None,
52
56
  name: str = None,
53
57
  description: str = None,
54
- package_root: str = None,
55
- command: str = None,
58
+ package: str = None,
59
+ package_root: str = None,
60
+ language: Language = None,
61
+ command: str = None,
56
62
  ):
57
63
  self.kind = kind
58
64
  self.name = name
59
65
  self.description = description
66
+ self.package = package
60
67
  self.package_root = package_root
68
+ self.language = language
61
69
  self.command = command
62
70
  self.client = None
63
71
 
72
+ self.source: ToolkitSource = (
73
+ ToolkitSource.PUBLIC_REGISTRY if package else ToolkitSource.FILES
74
+ )
75
+
64
76
  def get_client(self) -> ToolKitClient:
65
77
  if not self.client:
66
78
  self.client = instantiate_client(ToolKitClient)
67
79
  return self.client
68
80
 
69
81
  def import_toolkit(self, tools: Optional[List[str]] = None, app_id: Optional[List[str]] = None):
70
- if not is_local_dev():
71
- logger.error("This functionality is only available for Local Environments")
72
- sys.exit(1)
73
82
 
74
83
  if app_id and isinstance(app_id, str):
75
84
  app_id = [app_id]
@@ -86,26 +95,28 @@ class ToolkitController:
86
95
  logger.error(f"Existing toolkit found with name '{self.name}'. Failed to create toolkit.")
87
96
  sys.exit(1)
88
97
 
89
- with tempfile.TemporaryDirectory() as tmpdir:
90
- # Handle zip file or directory
91
- if self.package_root.endswith(".zip") and os.path.isfile(self.package_root):
92
- zip_file_path = self.package_root
93
- else:
94
- zip_file_path = os.path.join(tmpdir, os.path.basename(f"{self.package_root.rstrip(os.sep)}.zip"))
95
- with zipfile.ZipFile(zip_file_path, "w", zipfile.ZIP_DEFLATED) as mcp_zip_tool_artifacts:
96
- self._populate_zip(self.package_root, mcp_zip_tool_artifacts)
98
+ try:
99
+ command_parts = json.loads(self.command)
100
+ if not isinstance(command_parts, list):
101
+ raise ValueError("JSON command must be a list of strings")
102
+ except (json.JSONDecodeError, ValueError):
103
+ command_parts = self.command.split()
104
+
105
+ command = command_parts[0]
106
+ args = command_parts[1:]
97
107
 
98
- try:
99
- command_parts = json.loads(self.command)
100
- if not isinstance(command_parts, list):
101
- raise ValueError("JSON command must be a list of strings")
102
- except (json.JSONDecodeError, ValueError):
103
- command_parts = self.command.split()
108
+ console = Console()
104
109
 
105
- command = command_parts[0]
106
- args = command_parts[1:]
110
+ with tempfile.TemporaryDirectory() as tmpdir:
111
+ # Handle zip file or directory
112
+ if self.package_root:
113
+ if self.package_root.endswith(".zip") and os.path.isfile(self.package_root):
114
+ zip_file_path = self.package_root
115
+ else:
116
+ zip_file_path = os.path.join(tmpdir, os.path.basename(f"{self.package_root.rstrip(os.sep)}.zip"))
117
+ with zipfile.ZipFile(zip_file_path, "w", zipfile.ZIP_DEFLATED) as mcp_zip_tool_artifacts:
118
+ self._populate_zip(self.package_root, mcp_zip_tool_artifacts)
107
119
 
108
- console = Console()
109
120
  # List tools if not provided
110
121
  if tools is None:
111
122
  with Progress(
@@ -116,48 +127,59 @@ class ToolkitController:
116
127
  ) as progress:
117
128
  progress.add_task(description="No tools specified, retrieving all tools from provided MCP server", total=None)
118
129
  tools = self.get_client().list_tools(
119
- zip_file_path=zip_file_path,
120
- command=command,
121
- args=args,
122
- )
123
- # Normalize tools to a list of tool names
130
+ zip_file_path=zip_file_path,
131
+ command=command,
132
+ args=args,
133
+ )
134
+
124
135
  tools = [
125
136
  tool["name"] if isinstance(tool, dict) and "name" in tool else tool
126
137
  for tool in tools
127
138
  ]
128
139
 
129
-
130
140
  logger.info("✅ The following tools will be imported:")
131
141
  for tool in tools:
132
142
  console.print(f" • {tool}")
133
143
 
134
-
135
144
  # Create toolkit metadata
136
145
  payload = {
137
146
  "name": self.name,
138
147
  "description": self.description,
139
148
  "mcp": {
140
- "source": "files",
149
+ "source": self.source.value,
141
150
  "command": command,
142
151
  "args": args,
143
152
  "tools": tools,
144
153
  "connections": remapped_connections,
145
154
  }
146
155
  }
147
- toolkit = self.get_client().create_toolkit(payload)
148
- toolkit_id = toolkit["id"]
149
156
 
150
- console = Console()
151
- # Upload zip file
157
+
152
158
  with Progress(
153
159
  SpinnerColumn(spinner_name="dots"),
154
160
  TextColumn("[progress.description]{task.description}"),
155
161
  transient=True,
156
162
  console=console,
157
163
  ) as progress:
158
- progress.add_task(description="Uploading toolkit zip file...", total=None)
159
- self.get_client().upload(toolkit_id=toolkit_id, zip_file_path=zip_file_path)
160
- logger.info(f"Successfully imported tool kit {self.name}")
164
+ progress.add_task(description="Creating toolkit...", total=None)
165
+ toolkit = self.get_client().create_toolkit(payload)
166
+
167
+ toolkit_id = toolkit["id"]
168
+
169
+
170
+
171
+ # Upload zip file
172
+ if self.package_root:
173
+ with Progress(
174
+ SpinnerColumn(spinner_name="dots"),
175
+ TextColumn("[progress.description]{task.description}"),
176
+ transient=True,
177
+ console=console,
178
+ ) as progress:
179
+ progress.add_task(description="Uploading toolkit zip file...", total=None)
180
+ self.get_client().upload(toolkit_id=toolkit_id, zip_file_path=zip_file_path)
181
+
182
+ logger.info(f"Successfully imported tool kit {self.name}")
161
183
 
162
184
  def _populate_zip(self, package_root: str, zipfile: zipfile.ZipFile) -> str:
163
185
  for root, _, files in os.walk(package_root):
@@ -209,4 +231,63 @@ class ToolkitController:
209
231
  logger.warning(f"No toolkit named '{name}' found")
210
232
  except requests.HTTPError as e:
211
233
  logger.error(e.response.text)
212
- exit(1)
234
+ exit(1)
235
+
236
+ def list_toolkits(self, verbose=False):
237
+ client = self.get_client()
238
+ response = client.get()
239
+ toolkit_spec = [ToolkitSpec.model_validate(toolkit) for toolkit in response]
240
+ toolkits = [BaseToolkit(spec=spec) for spec in toolkit_spec]
241
+
242
+ if verbose:
243
+ tools_list = []
244
+ for toolkit in toolkits:
245
+ tools_list.append(json.loads(toolkit.dumps_spec()))
246
+ rich.print(JSON(json.dumps(tools_list, indent=4)))
247
+ else:
248
+ table = rich.table.Table(show_header=True, header_style="bold white", show_lines=True)
249
+ columns = ["Name", "Kind", "Description", "Tools", "App ID"]
250
+ for column in columns:
251
+ table.add_column(column)
252
+
253
+ tools_client = instantiate_client(ToolClient)
254
+
255
+ connections_client = get_connections_client()
256
+ connections = connections_client.list()
257
+
258
+ connections_dict = {conn.connection_id: conn for conn in connections}
259
+
260
+ for toolkit in toolkits:
261
+ tool_ids = toolkit.__toolkit_spec__.tools or []
262
+ tool_names = []
263
+ if len(tool_ids) == 0:
264
+ logger.warning("This toolkit contains no tools.")
265
+
266
+ for tool_id in tool_ids:
267
+ tool = tools_client.get_draft_by_id(tool_id)
268
+ tool_names.append(tool["name"])
269
+
270
+ app_ids = []
271
+ connection_ids = toolkit.__toolkit_spec__.mcp.connections.values()
272
+
273
+ for connection_id in connection_ids:
274
+ connection = connections_dict.get(connection_id)
275
+ if connection:
276
+ app_id = str(connection.app_id or connection.connection_id)
277
+ elif connection_id:
278
+ app_id = str(connection_id)
279
+ else:
280
+ app_id = ""
281
+ app_ids.append(app_id)
282
+
283
+
284
+
285
+ table.add_row(
286
+ toolkit.__toolkit_spec__.name,
287
+ "MCP",
288
+ toolkit.__toolkit_spec__.description,
289
+ ", ".join(tool_names),
290
+ ", ".join(app_ids),
291
+ )
292
+
293
+ rich.print(table)
@@ -15,7 +15,7 @@ def tool_import(
15
15
  typer.Option(
16
16
  "--file",
17
17
  "-f",
18
- help="Path to Python or OpenAPI spec YAML file. Required for kind openapi or python",
18
+ help="Path to Python, OpenAPI spec YAML file or flow JSON or python file. Required for kind openapi, python and flow",
19
19
  ),
20
20
  ] = None,
21
21
  # skillset_id: Annotated[
@@ -83,3 +83,24 @@ def remove_tool(
83
83
  ):
84
84
  tools_controller = ToolsController()
85
85
  tools_controller.remove_tool(name=name)
86
+
87
+ @tools_app.command(name="export", help='Export a tool to a zip file')
88
+ def tool_export(
89
+ name: Annotated[
90
+ str,
91
+ typer.Option("--name", "-n", help="The name of the tool you want to export"),
92
+ ],
93
+ output_file: Annotated[
94
+ str,
95
+ typer.Option(
96
+ "--output",
97
+ "-o",
98
+ help="Path to a where the zip file containing the exported data should be saved",
99
+ ),
100
+ ],
101
+ ):
102
+ tools_controller = ToolsController()
103
+ tools_controller.export_tool(
104
+ name=name,
105
+ output_path=output_file
106
+ )
@@ -3,6 +3,7 @@ import asyncio
3
3
  import importlib
4
4
  import inspect
5
5
  import sys
6
+ import io
6
7
  import re
7
8
  import tempfile
8
9
  import requests
@@ -19,18 +20,28 @@ import glob
19
20
  import rich.table
20
21
  import typer
21
22
 
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+
22
26
  from ibm_watsonx_orchestrate.agent_builder.tools import BaseTool, ToolSpec
23
- from ibm_watsonx_orchestrate.agent_builder.tools.openapi_tool import create_openapi_json_tools_from_uri
27
+ from ibm_watsonx_orchestrate.agent_builder.tools.openapi_tool import create_openapi_json_tools_from_uri,create_openapi_json_tools_from_content
28
+ from ibm_watsonx_orchestrate.cli.commands.models.models_command import ModelHighlighter
24
29
  from ibm_watsonx_orchestrate.cli.commands.tools.types import RegistryType
30
+ from ibm_watsonx_orchestrate.cli.commands.connections.connections_controller import configure_connection, remove_connection, add_connection
31
+ from ibm_watsonx_orchestrate.agent_builder.connections.types import ConnectionType, ConnectionEnvironment, ConnectionPreference
25
32
  from ibm_watsonx_orchestrate.cli.config import Config, CONTEXT_SECTION_HEADER, CONTEXT_ACTIVE_ENV_OPT, \
26
33
  PYTHON_REGISTRY_HEADER, PYTHON_REGISTRY_TYPE_OPT, PYTHON_REGISTRY_TEST_PACKAGE_VERSION_OVERRIDE_OPT, \
27
34
  DEFAULT_CONFIG_FILE_CONTENT
28
35
  from ibm_watsonx_orchestrate.agent_builder.connections import ConnectionSecurityScheme, ExpectedCredentials
36
+ from ibm_watsonx_orchestrate.experimental.flow_builder.flows.decorators import FlowWrapper
29
37
  from ibm_watsonx_orchestrate.client.tools.tool_client import ToolClient
30
38
  from ibm_watsonx_orchestrate.client.toolkit.toolkit_client import ToolKitClient
31
39
  from ibm_watsonx_orchestrate.client.connections import get_connections_client, get_connection_type
32
40
  from ibm_watsonx_orchestrate.client.utils import instantiate_client, is_local_dev
33
41
  from ibm_watsonx_orchestrate.utils.utils import sanatize_app_id
42
+ from ibm_watsonx_orchestrate.client.utils import is_local_dev
43
+ from ibm_watsonx_orchestrate.client.tools.tempus_client import TempusClient
44
+ from ibm_watsonx_orchestrate.experimental.flow_builder.utils import import_flow_model
34
45
 
35
46
  from ibm_watsonx_orchestrate import __version__
36
47
 
@@ -43,19 +54,20 @@ class ToolKind(str, Enum):
43
54
  openapi = "openapi"
44
55
  python = "python"
45
56
  mcp = "mcp"
57
+ flow = "flow"
46
58
  # skill = "skill"
47
59
 
48
60
  def validate_app_ids(kind: ToolKind, **args) -> None:
49
61
  app_ids = args.get("app_id")
50
62
  if not app_ids:
51
63
  return
52
-
64
+
53
65
  if kind == ToolKind.openapi:
54
66
  if app_ids and len(app_ids) > 1:
55
67
  raise typer.BadParameter(
56
68
  "Kind 'openapi' can only take one app-id"
57
69
  )
58
-
70
+
59
71
  connections_client = get_connections_client()
60
72
 
61
73
  imported_connections_list = connections_client.list()
@@ -115,7 +127,7 @@ def get_connection_id(app_id: str) -> str:
115
127
 
116
128
  def parse_python_app_ids(app_ids: List[str]) -> dict[str,str]:
117
129
  app_id_dict = {}
118
- for app_id in app_ids:
130
+ for app_id in app_ids:
119
131
  # Split on = but not on \=
120
132
  split_pattern = re.compile(r"(?<!\\)=")
121
133
  split_id = re.split(split_pattern, app_id)
@@ -139,7 +151,7 @@ def parse_python_app_ids(app_ids: List[str]) -> dict[str,str]:
139
151
  def validate_python_connections(tool: BaseTool):
140
152
  if not tool.expected_credentials:
141
153
  return
142
-
154
+
143
155
  connections_client = get_connections_client()
144
156
  connections = tool.__tool_spec__.binding.python.connections
145
157
 
@@ -158,33 +170,32 @@ def validate_python_connections(tool: BaseTool):
158
170
  expected_tool_conn_types = expected_cred.type
159
171
  else:
160
172
  expected_tool_conn_types = [expected_cred.type]
161
-
173
+
162
174
  sanatized_expected_tool_app_id = sanatize_app_id(expected_tool_app_id)
163
175
  if sanatized_expected_tool_app_id in existing_sanatized_expected_tool_app_ids:
164
176
  logger.error(f"Duplicate App ID found '{expected_tool_app_id}'. Multiple expected app ids in the tool '{tool.__tool_spec__.name}' collide after sanaitization to '{sanatized_expected_tool_app_id}'. Please rename the offending app id in your tool.")
165
177
  sys.exit(1)
166
178
  existing_sanatized_expected_tool_app_ids.add(sanatized_expected_tool_app_id)
167
-
179
+
168
180
  if sanatized_expected_tool_app_id not in provided_connections:
169
181
  logger.error(f"The tool '{tool.__tool_spec__.name}' requires an app-id '{expected_tool_app_id}'. Please use the `--app-id` flag to provide the required app-id")
170
- validation_failed = True
182
+ sys.exit(1)
171
183
 
172
184
  if not connections:
173
185
  continue
174
-
186
+
175
187
  connection_id = connections.get(sanatized_expected_tool_app_id)
176
-
177
188
  imported_connection = imported_connections.get(connection_id)
178
189
  imported_connection_auth_type = get_connection_type(security_scheme=imported_connection.security_scheme, auth_type=imported_connection.auth_type)
179
190
 
180
191
  if connection_id and not imported_connection:
181
- logger.error(f"The expected connection id '{connection_id}' does not match any known connection. This is likely caused by the connection being delted. Please rec-reate the connection and re-import the tool")
192
+ logger.error(f"The expected connection id '{connection_id}' does not match any known connection. This is likely caused by the connection being deleted. Please rec-reate the connection and re-import the tool")
182
193
  validation_failed = True
183
194
 
184
195
  if imported_connection and len(expected_tool_conn_types) and imported_connection_auth_type not in expected_tool_conn_types:
185
196
  logger.error(f"The app-id '{imported_connection.app_id}' is of type '{imported_connection_auth_type.value}'. The tool '{tool.__tool_spec__.name}' accepts connections of the following types '{', '.join(expected_tool_conn_types)}'. Use `orchestrate connections list` to view current connections and use `orchestrate connections add` to create the relevent connection")
186
197
  validation_failed = True
187
-
198
+
188
199
  if validation_failed:
189
200
  exit(1)
190
201
 
@@ -302,22 +313,141 @@ def import_python_tool(file: str, requirements_file: str = None, app_id: List[st
302
313
  obj.__tool_spec__.binding.python.function = f"{file_name.replace('.py', '')}:{fn}"
303
314
 
304
315
  else:
305
- package = package[1:]
316
+ pkg = package[1:]
306
317
  fn = obj.__tool_spec__.binding.python.function[obj.__tool_spec__.binding.python.function.index(':')+1:]
307
- obj.__tool_spec__.binding.python.function = f"{package}:{fn}"
318
+ obj.__tool_spec__.binding.python.function = f"{pkg}:{fn}"
308
319
 
309
320
  if app_id and len(app_id):
310
321
  obj.__tool_spec__.binding.python.connections = parse_python_app_ids(app_id)
311
322
 
312
323
  validate_python_connections(obj)
313
324
  tools.append(obj)
314
-
325
+
315
326
  return tools
316
327
 
328
+ async def import_flow_tool(file: str) -> None:
329
+
330
+ '''
331
+ Import a flow tool from a file. The file can be either a python file or a json file.
332
+ If the file is a python file, it should contain a flow model builder function decorated with the @flow decorator.
333
+ If the file is a json file, it should contain a flow model in json format.
334
+ Also, a connection will be created for the flow if one does not exists and the environment token will be used. This is a
335
+ workaround until flow bindings are supported in the server.
336
+ The function will return a list of tools created from the flow model.
337
+ '''
338
+
339
+ theme = rich.theme.Theme({"model.name": "bold cyan"})
340
+ console = rich.console.Console(highlighter=ModelHighlighter(), theme=theme)
341
+
342
+ message = f"""[bold cyan]Flow Tools: Experimental Feature[/bold cyan]
343
+
344
+ The [bold]flow tool[/bold] is being imported from [green]`{file}`[/green].
345
+
346
+ [bold cyan]Additional information:[/bold cyan]
347
+
348
+ - Ensure the flow engine is running by issuing the [bold cyan]orchestrate server start[/bold cyan] command with the [bold cyan]--with-flow-runtime[/bold cyan] option
349
+ - The [bold green]get_flow_status[/bold green] tool is being imported to support flow tools. Ensure [bold]both this tools and the one you are importing are added to your agent[/bold] to retrieve the flow output.
350
+ - Include additional instructions in your agent to call the [bold green]get_flow_status[/bold green] tool to retrieve the flow output. For example: [green]"If you get an instance_id, use the tool get_flow_status to retrieve the current status of a flow."[/green]
351
+
352
+ """
353
+
354
+ console.print(Panel(message, title="[bold blue]Flow tool support information[/bold blue]", border_style="bright_blue"))
355
+
356
+
357
+ if not is_local_dev():
358
+ raise typer.BadParameter(f"Flow tools are only supported in local environment.")
359
+
360
+ model = None
361
+
362
+ # Load the Flow JSON model from the file
363
+ try:
364
+ file_path = Path(file).absolute()
365
+ file_path_str = str(file_path)
366
+
367
+ if file_path.is_dir():
368
+ raise typer.BadParameter(f"Provided flow file path is not a file.")
369
+
370
+ elif file_path.is_symlink():
371
+ raise typer.BadParameter(f"Symbolic links are not supported for flow file path.")
372
+
373
+ if file_path.suffix.lower() == ".py":
374
+
375
+ # borrow code from python tool import to be able to load the script that holds the flow model
376
+
377
+ resolved_package_root = get_package_root(str(file_path.parent))
378
+ if resolved_package_root:
379
+ resolved_package_root = str(Path(resolved_package_root).absolute())
380
+ package_path = str(Path(resolved_package_root).parent.absolute())
381
+ package_folder = str(Path(resolved_package_root).stem)
382
+ sys.path.append(package_path) # allows you to resolve non relative imports relative to the root of the module
383
+ sys.path.append(resolved_package_root) # allows you to resolve relative imports in combination with import_module(..., package=...)
384
+ package = file_path_str.replace(resolved_package_root, '').replace('.py', '').replace('/', '.').replace('\\', '.')
385
+ if not path.isdir(resolved_package_root):
386
+ raise typer.BadParameter(f"The provided package root is not a directory.")
387
+
388
+ elif not file_path_str.startswith(str(Path(resolved_package_root))):
389
+ raise typer.BadParameter(f"The provided tool file path does not belong to the provided package root.")
390
+
391
+ temp_path = Path(file_path_str[len(str(Path(resolved_package_root))) + 1:])
392
+ if any([__supported_characters_pattern.match(x) is None for x in temp_path.parts[:-1]]):
393
+ raise typer.BadParameter(f"Path to tool file contains unsupported characters. Only alphanumeric characters and underscores are allowed. Path: \"{temp_path}\"")
394
+ else:
395
+ package_folder = file_path.parent
396
+ package = file_path.stem
397
+ sys.path.append(str(package_folder))
398
+
399
+ module = importlib.import_module(package, package=package_folder)
400
+
401
+ if resolved_package_root:
402
+ del sys.path[-1]
403
+ del sys.path[-1]
404
+
405
+ for _, obj in inspect.getmembers(module):
406
+
407
+ if not isinstance(obj, FlowWrapper):
408
+ continue
409
+
410
+ model = obj().to_json()
411
+ break
412
+
413
+ elif file_path.suffix.lower() == ".json":
414
+ with open(file) as f:
415
+ model = json.load(f)
416
+ else:
417
+ raise typer.BadParameter(f"Unknown file type. Only python or json are supported.")
418
+
419
+
420
+ except typer.BadParameter as ex:
421
+ raise ex
422
+
423
+ except Exception as e:
424
+ raise typer.BadParameter(f"Failed to load model from file {file}: {e}")
425
+
426
+ return await import_flow_model(model)
427
+
428
+
317
429
  async def import_openapi_tool(file: str, connection_id: str) -> List[BaseTool]:
318
430
  tools = await create_openapi_json_tools_from_uri(file, connection_id)
319
431
  return tools
320
432
 
433
+ def _get_kind_from_spec(spec: dict) -> ToolKind:
434
+ name = spec.get("name")
435
+ tool_binding = spec.get("binding")
436
+
437
+ if ToolKind.python in tool_binding:
438
+ return ToolKind.python
439
+ elif ToolKind.openapi in tool_binding:
440
+ return ToolKind.openapi
441
+ else:
442
+ logger.error(f"Could not determine 'kind' of tool '{name}'")
443
+ sys.exit(1)
444
+
445
+ def get_whl_in_registry(registry_url: str, version: str) -> str| None:
446
+ orchestrate_links = requests.get(registry_url).text
447
+ wheel_files = [x.group(1) for x in re.finditer( r'href="(.*\.whl).*"', orchestrate_links)]
448
+ wheel_file = next(filter(lambda x: f"{version}-py3-none-any.whl" in x, wheel_files), None)
449
+ return wheel_file
450
+
321
451
  class ToolsController:
322
452
  def __init__(self, tool_kind: ToolKind = None, file: str = None, requirements_file: str = None):
323
453
  self.client = None
@@ -335,7 +465,7 @@ class ToolsController:
335
465
  # Ensure app_id is a list
336
466
  if args.get("app_id") and isinstance(args.get("app_id"), str):
337
467
  args["app_id"] = [args.get("app_id")]
338
-
468
+
339
469
  validate_params(kind=kind, **args)
340
470
 
341
471
  match kind:
@@ -356,6 +486,8 @@ class ToolsController:
356
486
  connection = connections_client.get_draft_by_app_id(app_id=app_id)
357
487
  connection_id = connection.connection_id
358
488
  tools = asyncio.run(import_openapi_tool(file=args["file"], connection_id=connection_id))
489
+ case "flow":
490
+ tools = asyncio.run(import_flow_tool(file=args["file"]))
359
491
  case "skill":
360
492
  tools = []
361
493
  logger.warning("Skill Import not implemented yet")
@@ -390,7 +522,7 @@ class ToolsController:
390
522
 
391
523
  for tool in tools:
392
524
  tool_binding = tool.__tool_spec__.binding
393
-
525
+
394
526
  connection_ids = []
395
527
 
396
528
  if tool_binding is not None:
@@ -400,9 +532,11 @@ class ToolsController:
400
532
  for conn in tool_binding.python.connections:
401
533
  connection_ids.append(tool_binding.python.connections[conn])
402
534
  elif tool_binding.mcp is not None and hasattr(tool_binding.mcp, "connections"):
403
- for conn in tool_binding.mcp.connections:
404
- connection_ids.append(tool_binding.mcp.connections[conn])
405
-
535
+ if tool_binding.mcp.connections is None:
536
+ connection_ids.append(None)
537
+ else:
538
+ for conn in tool_binding.mcp.connections:
539
+ connection_ids.append(tool_binding.mcp.connections[conn])
406
540
 
407
541
  app_ids = []
408
542
  for connection_id in connection_ids:
@@ -463,7 +597,7 @@ class ToolsController:
463
597
  if len(existing_tools) > 1:
464
598
  logger.error(f"Multiple existing tools found with name '{tool.__tool_spec__.name}'. Failed to update tool")
465
599
  sys.exit(1)
466
-
600
+
467
601
  if len(existing_tools) > 0:
468
602
  existing_tool = existing_tools[0]
469
603
  exist = True
@@ -518,12 +652,14 @@ class ToolsController:
518
652
  if registry_type == RegistryType.LOCAL:
519
653
  requirements.append(f"/packages/ibm_watsonx_orchestrate-0.6.0-py3-none-any.whl\n")
520
654
  elif registry_type == RegistryType.PYPI:
655
+ wheel_file = get_whl_in_registry(registry_url='https://pypi.org/simple/ibm-watsonx-orchestrate', version=version)
656
+ if not wheel_file:
657
+ logger.error(f"Could not find ibm-watsonx-orchestrate@{version} on https://pypi.org/project/ibm-watsonx-orchestrate")
658
+ exit(1)
521
659
  requirements.append(f"ibm-watsonx-orchestrate=={version}\n")
522
660
  elif registry_type == RegistryType.TESTPYPI:
523
661
  override_version = cfg.get(PYTHON_REGISTRY_HEADER, PYTHON_REGISTRY_TEST_PACKAGE_VERSION_OVERRIDE_OPT) or version
524
- orchestrate_links = requests.get('https://test.pypi.org/simple/ibm-watsonx-orchestrate').text
525
- wheel_files = [x.group(1) for x in re.finditer( r'href="(.*\.whl).*"', orchestrate_links)]
526
- wheel_file = next(filter(lambda x: f"{override_version}-py3-none-any.whl" in x, wheel_files), None)
662
+ wheel_file = get_whl_in_registry(registry_url='https://test.pypi.org/simple/ibm-watsonx-orchestrate', version=override_version)
527
663
  if not wheel_file:
528
664
  logger.error(f"Could not find ibm-watsonx-orchestrate@{override_version} on https://test.pypi.org/project/ibm-watsonx-orchestrate")
529
665
  exit(1)
@@ -569,7 +705,7 @@ class ToolsController:
569
705
  self.get_client().upload_tools_artifact(tool_id=tool_id, file_path=tool_artifact)
570
706
 
571
707
  logger.info(f"Tool '{tool.__tool_spec__.name}' updated successfully")
572
-
708
+
573
709
  def remove_tool(self, name: str):
574
710
  try:
575
711
  client = self.get_client()
@@ -587,3 +723,51 @@ class ToolsController:
587
723
  except requests.HTTPError as e:
588
724
  logger.error(e.response.text)
589
725
  exit(1)
726
+
727
+ def download_tool(self, name: str) -> bytes | None:
728
+ tool_client = self.get_client()
729
+ draft_tools = tool_client.get_draft_by_name(tool_name=name)
730
+ if len(draft_tools) > 1:
731
+ logger.error(f"Multiple existing tools found with name '{name}'. Failed to get tool")
732
+ sys.exit(1)
733
+ if len(draft_tools) == 0:
734
+ logger.error(f"No tool named '{name}' found")
735
+ sys.exit(1)
736
+
737
+ draft_tool = draft_tools[0]
738
+ draft_tool_kind = _get_kind_from_spec(draft_tool)
739
+
740
+ # TODO: Add openapi tool support
741
+ if draft_tool_kind != ToolKind.python:
742
+ logger.warning(f"Skipping '{name}', {draft_tool_kind.value} tools are currently unsupported by export")
743
+ return
744
+
745
+ tool_id = draft_tool.get("id")
746
+
747
+ tool_artifacts_bytes = tool_client.download_tools_artifact(tool_id=tool_id)
748
+ return tool_artifacts_bytes
749
+
750
+ def export_tool(self, name: str, output_path: str) -> None:
751
+
752
+ output_file = Path(output_path)
753
+ output_file_extension = output_file.suffix
754
+ if output_file_extension != ".zip":
755
+ logger.error(f"Output file must end with the extension '.zip'. Provided file '{output_path}' ends with '{output_file_extension}'")
756
+ sys.exit(1)
757
+
758
+ logger.info(f"Exporting tool definition for '{name}' to '{output_path}'")
759
+
760
+ tool_artifact_bytes = self.download_tool(name)
761
+
762
+ if not tool_artifact_bytes:
763
+ return
764
+
765
+ with zipfile.ZipFile(io.BytesIO(tool_artifact_bytes), "r") as zip_file_in, \
766
+ zipfile.ZipFile(output_path, 'w') as zip_file_out:
767
+
768
+ for item in zip_file_in.infolist():
769
+ buffer = zip_file_in.read(item.filename)
770
+ if (item.filename != 'bundle-format'):
771
+ zip_file_out.writestr(item, buffer)
772
+
773
+ logger.info(f"Successfully exported tool definition for '{name}' to '{output_path}'")