/ config_portal / backend / server.py
server.py
  1  import sys
  2  from flask import Flask, jsonify, request, send_from_directory, send_file
  3  from flask_cors import CORS
  4  import os
  5  from pathlib import Path
  6  from .common import (
  7      INIT_DEFAULT,
  8      CONTAINER_RUN_COMMAND,
  9      AGENT_DEFAULTS,
 10      GATEWAY_DEFAULTS,
 11      USE_DEFAULT_SHARED_ARTIFACT,
 12  )
 13  from cli.utils import get_formatted_names
 14  
 15  import shutil
 16  from collections import defaultdict
 17  
 18  import logging
 19  
 20  log = logging.getLogger("werkzeug")
 21  log.disabled = True
 22  cli_flask = sys.modules["flask.cli"]
 23  cli_flask.show_server_banner = lambda *x: None
 24  
 25  config_portal_host = "CONFIG_PORTAL_HOST"
 26  
 27  try:
 28      from solace_agent_mesh.agent.tools.registry import tool_registry
 29  except ImportError as err:
 30      try:
 31          from src.solace_agent_mesh.agent.tools.registry import tool_registry
 32      except ImportError as exc:
 33          log.error(
 34              "Importing tool_registry failed. Ensure all the dependencies are installed and the import paths are correct, Error: %s",
 35              str(err),
 36          )
 37          raise err from exc
 38  
 39  
 40  def get_agent_field_definitions():
 41      fields = []
 42      fields.append(
 43          {
 44              "name": "agent_name",
 45              "label": "Agent Name",
 46              "type": "text",
 47              "required": True,
 48              "description": "Unique name for this agent (will be CamelCased).",
 49              "default": "MyNewAgent",
 50          }
 51      )
 52      fields.append(
 53          {
 54              "name": "namespace",
 55              "label": "Namespace",
 56              "type": "text",
 57              "default": AGENT_DEFAULTS["namespace"],
 58              "description": "A2A topic namespace (e.g., myorg/dev). Can use ${NAMESPACE}.",
 59          }
 60      )
 61      fields.append(
 62          {
 63              "name": "supports_streaming",
 64              "label": "Supports Streaming",
 65              "type": "boolean",
 66              "default": AGENT_DEFAULTS["supports_streaming"],
 67          }
 68      )
 69      fields.append(
 70          {
 71              "name": "model_provider",
 72              "label": "Model Provider",
 73              "type": "text",
 74              "default": AGENT_DEFAULTS["model_provider"],
 75              "description": "Model provider alias defined in shared_config.yaml (e.g., general, planning).",
 76          }
 77      )
 78      fields.append(
 79          {
 80              "name": "instruction",
 81              "label": "Instruction",
 82              "type": "textarea",
 83              "default": AGENT_DEFAULTS["instruction"].replace(
 84                  "__AGENT_NAME__", "__AGENT_NAME__"
 85              ),
 86              "description": "System instruction for the agent.",
 87          }
 88      )
 89  
 90      fields.append(
 91          {
 92              "name": "session_service_type",
 93              "label": "Session Service Type",
 94              "type": "select",
 95              "options": ["memory", "vertex_rag"],
 96              "default": AGENT_DEFAULTS["session_service_type"],
 97          }
 98      )
 99      fields.append(
100          {
101              "name": "session_service_behavior",
102              "label": "Session Service Behavior",
103              "type": "select",
104              "options": ["PERSISTENT", "RUN_BASED"],
105              "default": AGENT_DEFAULTS["session_service_behavior"],
106          }
107      )
108  
109      fields.append(
110          {
111              "name": "artifact_service_type",
112              "label": "Artifact Service Type",
113              "type": "select",
114              "options": ["memory", "filesystem", "gcs"],
115              "default": AGENT_DEFAULTS["artifact_service_type"],
116          }
117      )
118      fields.append(
119          {
120              "name": "artifact_service_base_path",
121              "label": "Artifact Service Base Path (if filesystem)",
122              "type": "text",
123              "default": AGENT_DEFAULTS["artifact_service_base_path"],
124              "condition": {"field": "artifact_service_type", "value": "filesystem"},
125          }
126      )
127      fields.append(
128          {
129              "name": "artifact_service_scope",
130              "label": "Artifact Service Scope",
131              "type": "select",
132              "options": ["namespace", "app", "custom"],
133              "default": AGENT_DEFAULTS["artifact_service_scope"],
134          }
135      )
136  
137      fields.append(
138          {
139              "name": "artifact_handling_mode",
140              "label": "Artifact Handling Mode",
141              "type": "select",
142              "options": ["ignore", "embed", "reference"],
143              "default": AGENT_DEFAULTS["artifact_handling_mode"],
144          }
145      )
146      fields.append(
147          {
148              "name": "enable_embed_resolution",
149              "label": "Enable Embed Resolution",
150              "type": "boolean",
151              "default": AGENT_DEFAULTS["enable_embed_resolution"],
152          }
153      )
154      fields.append(
155          {
156              "name": "enable_artifact_content_instruction",
157              "label": "Enable Artifact Content Instruction",
158              "type": "boolean",
159              "default": AGENT_DEFAULTS["enable_artifact_content_instruction"],
160          }
161      )
162      fields.append(
163          {
164              "name": "agent_card_description",
165              "label": "Agent Card Description",
166              "type": "textarea",
167              "default": AGENT_DEFAULTS["agent_card_description"],
168          }
169      )
170      fields.append(
171          {
172              "name": "agent_card_default_input_modes_str",
173              "label": "Agent Card Default Input Modes (comma-sep)",
174              "type": "text",
175              "default": ",".join(AGENT_DEFAULTS["agent_card_default_input_modes"]),
176          }
177      )
178      fields.append(
179          {
180              "name": "agent_card_default_output_modes_str",
181              "label": "Agent Card Default Output Modes (comma-sep)",
182              "type": "text",
183              "default": ",".join(AGENT_DEFAULTS["agent_card_default_output_modes"]),
184          }
185      )
186  
187      fields.append(
188          {
189              "name": "agent_card_publishing_interval",
190              "label": "Agent Card Publishing Interval (s)",
191              "type": "number",
192              "default": AGENT_DEFAULTS["agent_card_publishing_interval"],
193          }
194      )
195      fields.append(
196          {
197              "name": "agent_discovery_enabled",
198              "label": "Enable Agent Discovery",
199              "type": "boolean",
200              "default": AGENT_DEFAULTS["agent_discovery_enabled"],
201          }
202      )
203  
204      fields.append(
205          {
206              "name": "inter_agent_communication_allow_list_str",
207              "label": "Inter-Agent Allow List (comma-sep)",
208              "type": "text",
209              "default": ",".join(AGENT_DEFAULTS["inter_agent_communication_allow_list"]),
210          }
211      )
212      fields.append(
213          {
214              "name": "inter_agent_communication_deny_list_str",
215              "label": "Inter-Agent Deny List (comma-sep)",
216              "type": "text",
217              "default": ",".join(AGENT_DEFAULTS["inter_agent_communication_deny_list"]),
218          }
219      )
220      fields.append(
221          {
222              "name": "inter_agent_communication_timeout",
223              "label": "Inter-Agent Timeout (s)",
224              "type": "number",
225              "default": AGENT_DEFAULTS["inter_agent_communication_timeout"],
226          }
227      )
228  
229      return fields
230  
231  
232  def create_app(shared_config=None):
233      """Factory function that creates the Flask application with configuration injected"""
234      app = Flask(__name__)
235      CORS(app, resources={r"/api/*": {"origins": "*"}})
236  
237      EXCLUDE_OPTIONS = ["container_engine"]
238  
239      @app.route("/api/default_options", methods=["GET"])
240      def get_default_options():
241          """Endpoint that returns the default options for form initialization (init flow)"""
242          path = request.args.get("path", "advanced")
243  
244          modified_default_options = INIT_DEFAULT.copy()
245  
246          base_exclude_options = EXCLUDE_OPTIONS.copy()
247          quick_path_exclude_options = [
248              "broker_url",
249              "broker_vpn",
250              "broker_username",
251              "broker_password",
252              "container_engine",
253          ]
254  
255          exclude_options = base_exclude_options.copy()
256          if path == "quick":
257              exclude_options.extend(quick_path_exclude_options)
258              modified_default_options["dev_mode"] = True
259              modified_default_options["broker_type"] = "3"
260          for option in exclude_options:
261              modified_default_options.pop(option, None)
262  
263          return jsonify(
264              {"default_options": modified_default_options, "status": "success"}
265          )
266  
267      @app.route("/api/form_schema", methods=["GET"])
268      def get_form_schema():
269          """
270          Endpoint that returns defaults and field definitions for a given component type.
271          """
272          component_type = request.args.get("type", "agent")
273  
274          if component_type == "agent":
275              return jsonify(
276                  {
277                      "status": "success",
278                      "schema_type": "agent",
279                      "defaults": AGENT_DEFAULTS,
280                      "field_definitions": get_agent_field_definitions(),
281                  }
282              )
283          elif component_type == "gateway":
284              return jsonify(
285                  {
286                      "status": "success",
287                      "schema_type": "gateway",
288                      "defaults": GATEWAY_DEFAULTS,
289                      "meta": {
290                          "artifact_service_types": [
291                              USE_DEFAULT_SHARED_ARTIFACT,
292                              "memory",
293                              "filesystem",
294                              "gcs",
295                          ],
296                          "artifact_service_scopes": ["namespace", "app", "custom"],
297                      },
298                  }
299              )
300          else:
301              return (
302                  jsonify({"status": "error", "message": "Invalid component type"}),
303                  400,
304              )
305  
306      @app.route("/api/available_tools", methods=["GET"])
307      def get_available_tools():
308          """
309          Endpoint that returns a structured list of all available built-in tools and groups.
310          """
311          try:
312              all_tools = tool_registry.get_all_tools()
313  
314              groups = defaultdict(lambda: {"description": "", "tools": []})
315              tools_map = {}
316  
317              group_descriptions = {
318                  "artifact_management": "Creating, loading, and managing files and artifacts.",
319                  "data_analysis": "Querying, transforming, and visualizing data.",
320                  "general": "General purpose utilities like file conversion and diagram generation.",
321                  "web": "Interacting with web resources via HTTP requests.",
322                  "audio": "Generating and transcribing audio content.",
323                  "image": "Generating, describing, and editing images.",
324                  "test": "Tools for testing and development purposes.",
325              }
326  
327              for tool in sorted(all_tools, key=lambda t: (t.category, t.name)):
328                  groups[tool.category]["tools"].append(tool.name)
329                  if not groups[tool.category]["description"]:
330                      groups[tool.category]["description"] = group_descriptions.get(
331                          tool.category,
332                          f"Tools related to {tool.category.replace('_', ' ').title()}.",
333                      )
334                  tools_map[tool.name] = {
335                      "description": tool.description,
336                      "category": tool.category,
337                  }
338  
339              return jsonify(
340                  {"status": "success", "groups": dict(groups), "tools": tools_map}
341              )
342          except Exception as e:
343              app.logger.error(f"Error in get_available_tools: {e}", exc_info=True)
344              return jsonify({"status": "error", "message": str(e)}), 500
345  
346      @app.route("/api/save_gateway_config", methods=["POST"])
347      def save_gateway_config_route():
348          """
349          Accepts gateway configuration from frontend and passes it back to CLI via shared_config.
350          """
351          try:
352              data = request.json
353              if not data:
354                  return jsonify({"status": "error", "message": "No data received"}), 400
355  
356              gateway_name_input = data.get("gateway_name_input")
357              config_options = data.get("config")
358  
359              if not gateway_name_input or not isinstance(config_options, dict):
360                  return (
361                      jsonify(
362                          {
363                              "status": "error",
364                              "message": "Missing gateway_name_input or config data",
365                          }
366                      ),
367                      400,
368                  )
369  
370              if "namespace" not in config_options:
371                  config_options["namespace"] = GATEWAY_DEFAULTS.get(
372                      "namespace", "${NAMESPACE}"
373                  )
374  
375              if shared_config is not None:
376                  shared_config["status"] = "success_from_gui_save"
377                  shared_config["gateway_name_input"] = gateway_name_input
378                  shared_config["config"] = config_options
379  
380              return jsonify(
381                  {
382                      "status": "success",
383                      "message": "Gateway configuration data received by server. CLI will process.",
384                  }
385              )
386  
387          except Exception as e:
388              app.logger.error(f"Error in save_gateway_config_route: {e}", exc_info=True)
389              if shared_config is not None:
390                  shared_config["status"] = "error_in_gui_save"
391                  shared_config["message"] = str(e)
392              return jsonify({"status": "error", "message": str(e)}), 500
393  
394      @app.route("/api/save_agent_config", methods=["POST"])
395      def save_agent_config_route():
396          """
397          Accepts agent configuration from frontend and passes it back to CLI via shared_config.
398          """
399          try:
400              data = request.json
401              if not data:
402                  return jsonify({"status": "error", "message": "No data received"}), 400
403  
404              agent_name_input = data.get("agent_name_input")
405              config_options = data.get("config")
406  
407              if not agent_name_input or not config_options:
408                  return (
409                      jsonify(
410                          {
411                              "status": "error",
412                              "message": "Missing agent_name_input or config data",
413                          }
414                      ),
415                      400,
416                  )
417  
418              str_list_keys_to_process = {
419                  "agent_card_default_input_modes_str": "agent_card_default_input_modes",
420                  "agent_card_default_output_modes_str": "agent_card_default_output_modes",
421                  "inter_agent_communication_allow_list_str": "inter_agent_communication_allow_list",
422                  "inter_agent_communication_deny_list_str": "inter_agent_communication_deny_list",
423              }
424              processed_options = config_options.copy()
425              for key_str, original_key in str_list_keys_to_process.items():
426                  if key_str in processed_options and isinstance(
427                      processed_options[key_str], str
428                  ):
429                      processed_options[original_key] = [
430                          s.strip()
431                          for s in processed_options[key_str].split(",")
432                          if s.strip()
433                      ]
434                  elif original_key not in processed_options:
435                      processed_options[original_key] = []
436  
437              if "agent_card_skills" in processed_options and isinstance(
438                  processed_options["agent_card_skills"], list
439              ):
440                  if "agent_card_skills_str" in processed_options:
441                      del processed_options["agent_card_skills_str"]
442              elif "agent_card_skills_str" in processed_options and isinstance(
443                  processed_options["agent_card_skills_str"], str
444              ):
445                  try:
446                      import json
447  
448                      parsed_skills = json.loads(
449                          processed_options["agent_card_skills_str"]
450                      )
451                      if isinstance(parsed_skills, list):
452                          processed_options["agent_card_skills"] = parsed_skills
453                      else:
454                          app.logger.warn(
455                              f"Parsed agent_card_skills_str was not a list: {parsed_skills}. Defaulting to empty list."
456                          )
457                          processed_options["agent_card_skills"] = []
458                  except json.JSONDecodeError:
459                      app.logger.warn(
460                          f"Could not parse agent_card_skills_str: {processed_options['agent_card_skills_str']}. Defaulting to empty list."
461                      )
462                      processed_options["agent_card_skills"] = []
463              elif "agent_card_skills" not in processed_options:
464                  processed_options["agent_card_skills"] = []
465  
466              formatted_names = get_formatted_names(agent_name_input)
467              processed_options["agent_name"] = formatted_names["PASCAL_CASE_NAME"]
468              if shared_config is not None:
469                  shared_config["status"] = "success_from_gui_save"
470                  shared_config["agent_name_input"] = agent_name_input
471                  shared_config["config"] = processed_options
472              return jsonify(
473                  {
474                      "status": "success",
475                      "message": "Agent configuration data received by server. CLI will process.",
476                  }
477              )
478  
479          except Exception as e:
480              app.logger.error(f"Error in save_agent_config_route: {e}", exc_info=True)
481              if shared_config is not None:
482                  shared_config["status"] = "error_in_gui_save"
483                  shared_config["message"] = str(e)
484              return jsonify({"status": "error", "message": str(e)}), 500
485  
486      @app.route("/api/save_config", methods=["POST"])
487      def save_config():
488          try:
489              received_data = request.json
490              force = received_data.pop("force", False)
491  
492              if not received_data:
493                  return jsonify({"status": "error", "message": "No data received"}), 400
494  
495              complete_config = INIT_DEFAULT.copy()
496              for key, value in received_data.items():
497                  if key in complete_config or key:
498                      complete_config[key] = value
499  
500              if shared_config is not None:
501                  for key, value in complete_config.items():
502                      shared_config[key] = value
503  
504              return jsonify({"status": "success"})
505  
506          except Exception as e:
507              app.logger.error(f"Error in save_config: {e}", exc_info=True)
508              return jsonify({"status": "error", "message": str(e)}), 500
509  
510      @app.route("/api/runcontainer", methods=["POST"])
511      def runcontainer():
512          try:
513              data = request.json or {}
514              has_podman = shutil.which("podman") is not None
515              has_docker = shutil.which("docker") is not None
516              if not has_podman and not has_docker:
517                  return (
518                      jsonify(
519                          {
520                              "status": "error",
521                              "message": "You need to have either podman or docker installed.",
522                          }
523                      ),
524                      400,
525                  )
526  
527              container_engine = data.get("container_engine")
528              if not container_engine and has_podman and has_docker:
529                  container_engine = "podman"
530              elif not container_engine:
531                  container_engine = "podman" if has_podman else "docker"
532  
533              if container_engine not in ["podman", "docker"]:
534                  return jsonify({"status": "error", "message": "Invalid engine."}), 400
535              if container_engine == "podman" and not has_podman:
536                  return (
537                      jsonify({"status": "error", "message": "Podman not installed."}),
538                      400,
539                  )
540              if container_engine == "docker" and not has_docker:
541                  return (
542                      jsonify({"status": "error", "message": "Docker not installed."}),
543                      400,
544                  )
545  
546              command = container_engine + CONTAINER_RUN_COMMAND
547              response_status = os.system(command)
548  
549              if response_status != 0:
550                  return (
551                      jsonify(
552                          {
553                              "status": "error",
554                              "message": "The creation of a new container failed. You can try switching to dev mode or existing broker mode. You can also check this url to find possible solutions https://docs.solace.com/Software-Broker/Container-Tasks/rootless-containers.htm#rootful-versus-rootless-containers",
555                          }
556                      ),
557                      500,
558                  )
559              return jsonify(
560                  {
561                      "status": "success",
562                      "message": f"Started container via {container_engine}",
563                      "container_engine": container_engine,
564                  }
565              )
566          except Exception as e:
567              return jsonify({"status": "error", "message": str(e)}), 500
568  
569      @app.route("/api/shutdown", methods=["POST"])
570      def shutdown():
571          """Kills this Flask process immediately"""
572          response = jsonify({"message": "Server shutting down...", "status": "success"})
573          os._exit(0)
574          return response
575  
576      frontend_static_dir = (
577          Path(__file__).resolve().parent.parent / "frontend" / "static" / "client"
578      )
579  
580      @app.route("/assets/<path:path>")
581      def serve_assets(path):
582          return send_from_directory(frontend_static_dir / "assets", path)
583  
584      @app.route("/static/client/<path:path>")
585      def serve_client_files(path):
586          return send_from_directory(frontend_static_dir, path)
587  
588      @app.route("/", defaults={"path": ""})
589      @app.route("/<path:path>")
590      def serve_index(path):
591          if os.path.splitext(path)[1] and os.path.exists(frontend_static_dir / path):
592              return send_from_directory(frontend_static_dir, path)
593          return send_file(frontend_static_dir / "index.html")
594  
595      return app
596  
597  
598  def run_flask(host="127.0.0.1", port=5002, shared_config=None):
599      host = os.environ.get(config_portal_host, host)
600      app = create_app(shared_config)
601      app.logger.setLevel(logging.INFO)
602      app.logger.info(f"Starting Flask app on {host}:{port}")
603      app.run(host=host, port=port, debug=False, use_reloader=False)