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)