ibm-watsonx-orchestrate 1.3.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 (54) hide show
  1. ibm_watsonx_orchestrate/__init__.py +1 -1
  2. ibm_watsonx_orchestrate/agent_builder/agents/types.py +2 -0
  3. ibm_watsonx_orchestrate/agent_builder/knowledge_bases/types.py +9 -2
  4. ibm_watsonx_orchestrate/agent_builder/toolkits/base_toolkit.py +32 -0
  5. ibm_watsonx_orchestrate/agent_builder/toolkits/types.py +42 -0
  6. ibm_watsonx_orchestrate/agent_builder/tools/openapi_tool.py +10 -1
  7. ibm_watsonx_orchestrate/agent_builder/tools/python_tool.py +4 -2
  8. ibm_watsonx_orchestrate/agent_builder/tools/types.py +2 -1
  9. ibm_watsonx_orchestrate/cli/commands/agents/agents_command.py +29 -0
  10. ibm_watsonx_orchestrate/cli/commands/agents/agents_controller.py +271 -12
  11. ibm_watsonx_orchestrate/cli/commands/knowledge_bases/knowledge_bases_controller.py +17 -2
  12. ibm_watsonx_orchestrate/cli/commands/models/env_file_model_provider_mapper.py +180 -0
  13. ibm_watsonx_orchestrate/cli/commands/models/models_command.py +194 -8
  14. ibm_watsonx_orchestrate/cli/commands/server/server_command.py +117 -48
  15. ibm_watsonx_orchestrate/cli/commands/server/types.py +105 -0
  16. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_command.py +55 -7
  17. ibm_watsonx_orchestrate/cli/commands/toolkit/toolkit_controller.py +123 -42
  18. ibm_watsonx_orchestrate/cli/commands/tools/tools_command.py +22 -1
  19. ibm_watsonx_orchestrate/cli/commands/tools/tools_controller.py +197 -12
  20. ibm_watsonx_orchestrate/client/agents/agent_client.py +4 -1
  21. ibm_watsonx_orchestrate/client/agents/assistant_agent_client.py +5 -1
  22. ibm_watsonx_orchestrate/client/agents/external_agent_client.py +5 -1
  23. ibm_watsonx_orchestrate/client/analytics/llm/analytics_llm_client.py +2 -6
  24. ibm_watsonx_orchestrate/client/base_api_client.py +5 -2
  25. ibm_watsonx_orchestrate/client/connections/connections_client.py +3 -9
  26. ibm_watsonx_orchestrate/client/model_policies/__init__.py +0 -0
  27. ibm_watsonx_orchestrate/client/model_policies/model_policies_client.py +47 -0
  28. ibm_watsonx_orchestrate/client/model_policies/types.py +36 -0
  29. ibm_watsonx_orchestrate/client/models/__init__.py +0 -0
  30. ibm_watsonx_orchestrate/client/models/models_client.py +46 -0
  31. ibm_watsonx_orchestrate/client/models/types.py +177 -0
  32. ibm_watsonx_orchestrate/client/toolkit/toolkit_client.py +15 -6
  33. ibm_watsonx_orchestrate/client/tools/tempus_client.py +40 -0
  34. ibm_watsonx_orchestrate/client/tools/tool_client.py +8 -0
  35. ibm_watsonx_orchestrate/docker/compose-lite.yml +68 -13
  36. ibm_watsonx_orchestrate/docker/default.env +22 -12
  37. ibm_watsonx_orchestrate/docker/tempus/common-config.yaml +1 -1
  38. ibm_watsonx_orchestrate/experimental/flow_builder/__init__.py +0 -0
  39. ibm_watsonx_orchestrate/experimental/flow_builder/flows/__init__.py +41 -0
  40. ibm_watsonx_orchestrate/experimental/flow_builder/flows/constants.py +17 -0
  41. ibm_watsonx_orchestrate/experimental/flow_builder/flows/data_map.py +91 -0
  42. ibm_watsonx_orchestrate/experimental/flow_builder/flows/decorators.py +143 -0
  43. ibm_watsonx_orchestrate/experimental/flow_builder/flows/events.py +72 -0
  44. ibm_watsonx_orchestrate/experimental/flow_builder/flows/flow.py +1288 -0
  45. ibm_watsonx_orchestrate/experimental/flow_builder/node.py +97 -0
  46. ibm_watsonx_orchestrate/experimental/flow_builder/resources/flow_status.openapi.yml +98 -0
  47. ibm_watsonx_orchestrate/experimental/flow_builder/types.py +492 -0
  48. ibm_watsonx_orchestrate/experimental/flow_builder/utils.py +113 -0
  49. ibm_watsonx_orchestrate/utils/utils.py +5 -2
  50. {ibm_watsonx_orchestrate-1.3.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/METADATA +4 -1
  51. {ibm_watsonx_orchestrate-1.3.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/RECORD +54 -32
  52. {ibm_watsonx_orchestrate-1.3.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/WHEEL +0 -0
  53. {ibm_watsonx_orchestrate-1.3.0.dist-info → ibm_watsonx_orchestrate-1.4.2.dist-info}/entry_points.txt +0 -0
  54. {ibm_watsonx_orchestrate-1.3.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,6 +54,7 @@ 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:
@@ -167,18 +179,17 @@ def validate_python_connections(tool: BaseTool):
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:
@@ -302,9 +313,9 @@ 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)
@@ -314,10 +325,129 @@ def import_python_tool(file: str, requirements_file: str = None, app_id: List[st
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
@@ -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")
@@ -400,8 +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])
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])
405
540
 
406
541
  app_ids = []
407
542
  for connection_id in connection_ids:
@@ -517,12 +652,14 @@ class ToolsController:
517
652
  if registry_type == RegistryType.LOCAL:
518
653
  requirements.append(f"/packages/ibm_watsonx_orchestrate-0.6.0-py3-none-any.whl\n")
519
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)
520
659
  requirements.append(f"ibm-watsonx-orchestrate=={version}\n")
521
660
  elif registry_type == RegistryType.TESTPYPI:
522
661
  override_version = cfg.get(PYTHON_REGISTRY_HEADER, PYTHON_REGISTRY_TEST_PACKAGE_VERSION_OVERRIDE_OPT) or version
523
- orchestrate_links = requests.get('https://test.pypi.org/simple/ibm-watsonx-orchestrate').text
524
- wheel_files = [x.group(1) for x in re.finditer( r'href="(.*\.whl).*"', orchestrate_links)]
525
- 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)
526
663
  if not wheel_file:
527
664
  logger.error(f"Could not find ibm-watsonx-orchestrate@{override_version} on https://test.pypi.org/project/ibm-watsonx-orchestrate")
528
665
  exit(1)
@@ -586,3 +723,51 @@ class ToolsController:
586
723
  except requests.HTTPError as e:
587
724
  logger.error(e.response.text)
588
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}'")
@@ -42,5 +42,8 @@ class AgentClient(BaseAPIClient):
42
42
  if e.response.status_code == 404 and "not found with the given name" in e.response.text:
43
43
  return ""
44
44
  raise(e)
45
-
45
+
46
+ def get_drafts_by_ids(self, agent_ids: List[str]) -> List[dict]:
47
+ formatted_agent_ids = [f"ids={x}" for x in agent_ids]
48
+ return self._get(f"{self.base_endpoint}?{'&'.join(formatted_agent_ids)}")
46
49
 
@@ -25,7 +25,7 @@ class AssistantAgentClient(BaseAPIClient):
25
25
  formatted_agent_names = [f"names={x}" for x in agent_names]
26
26
  return self._get(f"/assistants/watsonx?{'&'.join(formatted_agent_names)}")
27
27
 
28
- def get_draft_by_id(self, agent_id: str) -> List[dict]:
28
+ def get_draft_by_id(self, agent_id: str) -> dict | str:
29
29
  if agent_id is None:
30
30
  return ""
31
31
  else:
@@ -36,3 +36,7 @@ class AssistantAgentClient(BaseAPIClient):
36
36
  if e.response.status_code == 404 and "Assistant not found" in e.response.text:
37
37
  return ""
38
38
  raise(e)
39
+
40
+ def get_drafts_by_ids(self, agent_ids: List[str]) -> List[dict]:
41
+ formatted_agent_ids = [f"ids={x}" for x in agent_ids]
42
+ return self._get(f"/assistants/watsonx?{'&'.join(formatted_agent_ids)}")
@@ -33,6 +33,10 @@ class ExternalAgentClient(BaseAPIClient):
33
33
  agent = self._get(f"/agents/external-chat/{agent_id}")
34
34
  return agent
35
35
  except ClientAPIException as e:
36
- if e.response.status_code == 404 and "not found with the given name" in e.response.text:
36
+ if e.response.status_code == 404 and ("not found with the given name" in e.response.text or "Assistant not found" in e.response.text):
37
37
  return ""
38
38
  raise(e)
39
+
40
+ def get_drafts_by_ids(self, agent_ids: List[str]) -> List[dict]:
41
+ formatted_agent_ids = [f"ids={x}" for x in agent_ids]
42
+ return self._get(f"/agents/external-chat?{'&'.join(formatted_agent_ids)}")
@@ -32,12 +32,8 @@ class AnalyticsLLMClient(BaseAPIClient):
32
32
  def create(self):
33
33
  raise RuntimeError('unimplemented')
34
34
 
35
- def get(self, project_id: Optional[str] = None) -> AnalyticsLLMConfig:
36
- params = {}
37
- if project_id:
38
- params['project_id'] = project_id
39
-
40
- response = self._get(f"/analytics/llm", params=params)
35
+ def get(self) -> AnalyticsLLMConfig:
36
+ response = self._get(f"/analytics/llm")
41
37
 
42
38
  return AnalyticsLLMConfig.model_validate(response)
43
39
 
@@ -45,12 +45,15 @@ class BaseAPIClient:
45
45
  headers["Authorization"] = f"Bearer {self.authenticator.token_manager.get_token()}"
46
46
  return headers
47
47
 
48
- def _get(self, path: str, params: dict = None, data=None) -> dict:
48
+ def _get(self, path: str, params: dict = None, data=None, return_raw=False) -> dict:
49
49
 
50
50
  url = f"{self.base_url}{path}"
51
51
  response = requests.get(url, headers=self._get_headers(), params=params, data=data)
52
52
  self._check_response(response)
53
- return response.json()
53
+ if not return_raw:
54
+ return response.json()
55
+ else:
56
+ return response
54
57
 
55
58
  def _post(self, path: str, data: dict = None, files: dict = None) -> dict:
56
59
  url = f"{self.base_url}{path}"