dao-ai 0.1.2__py3-none-any.whl → 0.1.20__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 (69) hide show
  1. dao_ai/apps/__init__.py +24 -0
  2. dao_ai/apps/handlers.py +105 -0
  3. dao_ai/apps/model_serving.py +29 -0
  4. dao_ai/apps/resources.py +1122 -0
  5. dao_ai/apps/server.py +39 -0
  6. dao_ai/cli.py +546 -37
  7. dao_ai/config.py +1179 -139
  8. dao_ai/evaluation.py +543 -0
  9. dao_ai/genie/__init__.py +55 -7
  10. dao_ai/genie/cache/__init__.py +34 -7
  11. dao_ai/genie/cache/base.py +143 -2
  12. dao_ai/genie/cache/context_aware/__init__.py +31 -0
  13. dao_ai/genie/cache/context_aware/base.py +1151 -0
  14. dao_ai/genie/cache/context_aware/in_memory.py +609 -0
  15. dao_ai/genie/cache/context_aware/persistent.py +802 -0
  16. dao_ai/genie/cache/context_aware/postgres.py +1166 -0
  17. dao_ai/genie/cache/core.py +1 -1
  18. dao_ai/genie/cache/lru.py +257 -75
  19. dao_ai/genie/cache/optimization.py +890 -0
  20. dao_ai/genie/core.py +235 -11
  21. dao_ai/memory/postgres.py +175 -39
  22. dao_ai/middleware/__init__.py +38 -0
  23. dao_ai/middleware/assertions.py +3 -3
  24. dao_ai/middleware/context_editing.py +230 -0
  25. dao_ai/middleware/core.py +4 -4
  26. dao_ai/middleware/guardrails.py +3 -3
  27. dao_ai/middleware/human_in_the_loop.py +3 -2
  28. dao_ai/middleware/message_validation.py +4 -4
  29. dao_ai/middleware/model_call_limit.py +77 -0
  30. dao_ai/middleware/model_retry.py +121 -0
  31. dao_ai/middleware/pii.py +157 -0
  32. dao_ai/middleware/summarization.py +1 -1
  33. dao_ai/middleware/tool_call_limit.py +210 -0
  34. dao_ai/middleware/tool_retry.py +174 -0
  35. dao_ai/middleware/tool_selector.py +129 -0
  36. dao_ai/models.py +327 -370
  37. dao_ai/nodes.py +9 -16
  38. dao_ai/orchestration/core.py +33 -9
  39. dao_ai/orchestration/supervisor.py +29 -13
  40. dao_ai/orchestration/swarm.py +6 -1
  41. dao_ai/{prompts.py → prompts/__init__.py} +12 -61
  42. dao_ai/prompts/instructed_retriever_decomposition.yaml +58 -0
  43. dao_ai/prompts/instruction_reranker.yaml +14 -0
  44. dao_ai/prompts/router.yaml +37 -0
  45. dao_ai/prompts/verifier.yaml +46 -0
  46. dao_ai/providers/base.py +28 -2
  47. dao_ai/providers/databricks.py +363 -33
  48. dao_ai/state.py +1 -0
  49. dao_ai/tools/__init__.py +5 -3
  50. dao_ai/tools/genie.py +103 -26
  51. dao_ai/tools/instructed_retriever.py +366 -0
  52. dao_ai/tools/instruction_reranker.py +202 -0
  53. dao_ai/tools/mcp.py +539 -97
  54. dao_ai/tools/router.py +89 -0
  55. dao_ai/tools/slack.py +13 -2
  56. dao_ai/tools/sql.py +7 -3
  57. dao_ai/tools/unity_catalog.py +32 -10
  58. dao_ai/tools/vector_search.py +493 -160
  59. dao_ai/tools/verifier.py +159 -0
  60. dao_ai/utils.py +182 -2
  61. dao_ai/vector_search.py +46 -1
  62. {dao_ai-0.1.2.dist-info → dao_ai-0.1.20.dist-info}/METADATA +45 -9
  63. dao_ai-0.1.20.dist-info/RECORD +89 -0
  64. dao_ai/agent_as_code.py +0 -22
  65. dao_ai/genie/cache/semantic.py +0 -970
  66. dao_ai-0.1.2.dist-info/RECORD +0 -64
  67. {dao_ai-0.1.2.dist-info → dao_ai-0.1.20.dist-info}/WHEEL +0 -0
  68. {dao_ai-0.1.2.dist-info → dao_ai-0.1.20.dist-info}/entry_points.txt +0 -0
  69. {dao_ai-0.1.2.dist-info → dao_ai-0.1.20.dist-info}/licenses/LICENSE +0 -0
dao_ai/cli.py CHANGED
@@ -2,12 +2,13 @@ import argparse
2
2
  import getpass
3
3
  import json
4
4
  import os
5
+ import signal
5
6
  import subprocess
6
7
  import sys
7
8
  import traceback
8
9
  from argparse import ArgumentParser, Namespace
9
10
  from pathlib import Path
10
- from typing import Optional, Sequence
11
+ from typing import Any, Optional, Sequence
11
12
 
12
13
  from dotenv import find_dotenv, load_dotenv
13
14
  from loguru import logger
@@ -47,6 +48,68 @@ def get_default_user_id() -> str:
47
48
  return local_user
48
49
 
49
50
 
51
+ def detect_cloud_provider(profile: Optional[str] = None) -> Optional[str]:
52
+ """
53
+ Detect the cloud provider from the Databricks workspace URL.
54
+
55
+ The cloud provider is determined by the workspace URL pattern:
56
+ - Azure: *.azuredatabricks.net
57
+ - AWS: *.cloud.databricks.com (without gcp subdomain)
58
+ - GCP: *.gcp.databricks.com
59
+
60
+ Args:
61
+ profile: Optional Databricks CLI profile name
62
+
63
+ Returns:
64
+ Cloud provider string ('azure', 'aws', 'gcp') or None if detection fails
65
+ """
66
+ try:
67
+ import os
68
+
69
+ from databricks.sdk import WorkspaceClient
70
+
71
+ # Check for environment variables that might override profile
72
+ if profile and os.environ.get("DATABRICKS_HOST"):
73
+ logger.warning(
74
+ f"DATABRICKS_HOST environment variable is set, which may override --profile {profile}"
75
+ )
76
+
77
+ # Create workspace client with optional profile
78
+ if profile:
79
+ logger.debug(f"Creating WorkspaceClient with profile: {profile}")
80
+ w = WorkspaceClient(profile=profile)
81
+ else:
82
+ logger.debug("Creating WorkspaceClient with default/ambient credentials")
83
+ w = WorkspaceClient()
84
+
85
+ # Get the workspace URL from config
86
+ host = w.config.host
87
+ logger.debug(f"WorkspaceClient host: {host}, profile used: {profile}")
88
+ if not host:
89
+ logger.warning("Could not determine workspace URL for cloud detection")
90
+ return None
91
+
92
+ host_lower = host.lower()
93
+
94
+ if "azuredatabricks.net" in host_lower:
95
+ logger.debug(f"Detected Azure cloud from workspace URL: {host}")
96
+ return "azure"
97
+ elif ".gcp.databricks.com" in host_lower:
98
+ logger.debug(f"Detected GCP cloud from workspace URL: {host}")
99
+ return "gcp"
100
+ elif ".cloud.databricks.com" in host_lower or "databricks.com" in host_lower:
101
+ # AWS uses *.cloud.databricks.com or regional patterns
102
+ logger.debug(f"Detected AWS cloud from workspace URL: {host}")
103
+ return "aws"
104
+ else:
105
+ logger.warning(f"Could not determine cloud provider from URL: {host}")
106
+ return None
107
+
108
+ except Exception as e:
109
+ logger.warning(f"Could not detect cloud provider: {e}")
110
+ return None
111
+
112
+
50
113
  env_path: str = find_dotenv()
51
114
  if env_path:
52
115
  logger.info(f"Loading environment variables from: {env_path}")
@@ -63,6 +126,7 @@ Examples:
63
126
  dao-ai validate -c config/model_config.yaml # Validate a specific configuration file
64
127
  dao-ai graph -o architecture.png -c my_config.yaml -v # Generate visual graph with verbose output
65
128
  dao-ai chat -c config/retail.yaml --custom-input store_num=87887 # Start interactive chat session
129
+ dao-ai list-mcp-tools -c config/mcp_config.yaml --apply-filters # List filtered MCP tools only
66
130
  dao-ai validate # Validate with detailed logging
67
131
  dao-ai bundle --deploy # Deploy the DAO AI asset bundle
68
132
  """,
@@ -220,12 +284,28 @@ Examples:
220
284
  "-t",
221
285
  "--target",
222
286
  type=str,
287
+ help="Bundle target name (default: auto-generated from app name and cloud)",
288
+ )
289
+ bundle_parser.add_argument(
290
+ "--cloud",
291
+ type=str,
292
+ choices=["azure", "aws", "gcp"],
293
+ help="Cloud provider (auto-detected from workspace URL if not specified)",
223
294
  )
224
295
  bundle_parser.add_argument(
225
296
  "--dry-run",
226
297
  action="store_true",
227
298
  help="Perform a dry run without executing the deployment or run commands",
228
299
  )
300
+ bundle_parser.add_argument(
301
+ "--deployment-target",
302
+ type=str,
303
+ choices=["model_serving", "apps"],
304
+ default=None,
305
+ help="Agent deployment target: 'model_serving' or 'apps'. "
306
+ "If not specified, uses app.deployment_target from config file, "
307
+ "or defaults to 'model_serving'. Passed to the deploy notebook.",
308
+ )
229
309
 
230
310
  # Deploy command
231
311
  deploy_parser: ArgumentParser = subparsers.add_parser(
@@ -250,6 +330,63 @@ Examples:
250
330
  metavar="FILE",
251
331
  help="Path to the model configuration file to validate",
252
332
  )
333
+ deploy_parser.add_argument(
334
+ "-t",
335
+ "--target",
336
+ type=str,
337
+ choices=["model_serving", "apps"],
338
+ default=None,
339
+ help="Deployment target: 'model_serving' or 'apps'. "
340
+ "If not specified, uses app.deployment_target from config file, "
341
+ "or defaults to 'model_serving'.",
342
+ )
343
+
344
+ # List MCP tools command
345
+ list_mcp_parser: ArgumentParser = subparsers.add_parser(
346
+ "list-mcp-tools",
347
+ help="List available MCP tools from configuration",
348
+ description="""
349
+ List all available MCP tools from the configured MCP servers.
350
+ This command shows:
351
+ - All MCP servers/functions in the configuration
352
+ - Available tools from each server
353
+ - Full descriptions for each tool (no truncation)
354
+ - Tool parameters in readable format (type, required/optional, descriptions)
355
+ - Which tools are included/excluded based on filters
356
+ - Filter patterns (include_tools, exclude_tools)
357
+
358
+ Use this command to:
359
+ - Discover available tools before configuring agents
360
+ - Review tool descriptions and parameter schemas
361
+ - Debug tool filtering configuration
362
+ - Verify MCP server connectivity
363
+
364
+ Options:
365
+ - Use --apply-filters to only show tools that will be loaded (hides excluded tools)
366
+ - Without --apply-filters, see all available tools with include/exclude status
367
+
368
+ Note: Schemas are displayed in a concise, readable format instead of verbose JSON
369
+ """,
370
+ epilog="""Examples:
371
+ dao-ai list-mcp-tools -c config/model_config.yaml
372
+ dao-ai list-mcp-tools -c config/model_config.yaml --apply-filters
373
+ """,
374
+ formatter_class=argparse.RawDescriptionHelpFormatter,
375
+ )
376
+ list_mcp_parser.add_argument(
377
+ "-c",
378
+ "--config",
379
+ type=str,
380
+ default="./config/model_config.yaml",
381
+ required=False,
382
+ metavar="FILE",
383
+ help="Path to the model configuration file (default: ./config/model_config.yaml)",
384
+ )
385
+ list_mcp_parser.add_argument(
386
+ "--apply-filters",
387
+ action="store_true",
388
+ help="Only show tools that pass include/exclude filters (hide excluded tools)",
389
+ )
253
390
 
254
391
  chat_parser: ArgumentParser = subparsers.add_parser(
255
392
  "chat",
@@ -318,6 +455,18 @@ def handle_chat_command(options: Namespace) -> None:
318
455
  """Interactive chat REPL with the DAO AI system with Human-in-the-Loop support."""
319
456
  logger.debug("Starting chat session with DAO AI system...")
320
457
 
458
+ # Set up signal handler for clean Ctrl+C handling
459
+ def signal_handler(sig: int, frame: Any) -> None:
460
+ try:
461
+ print("\n\n👋 Chat session interrupted. Goodbye!")
462
+ sys.stdout.flush()
463
+ except Exception:
464
+ pass
465
+ sys.exit(0)
466
+
467
+ # Store original handler and set our handler
468
+ original_handler = signal.signal(signal.SIGINT, signal_handler)
469
+
321
470
  try:
322
471
  # Set default user_id if not provided
323
472
  if options.user_id is None:
@@ -385,14 +534,19 @@ def handle_chat_command(options: Namespace) -> None:
385
534
  )
386
535
  continue
387
536
 
537
+ # Normalize user_id for memory namespace compatibility (replace . with _)
538
+ # This matches the normalization in models.py _convert_to_context
539
+ if configurable.get("user_id"):
540
+ configurable["user_id"] = configurable["user_id"].replace(".", "_")
541
+
388
542
  # Create Context object from configurable dict
389
543
  from dao_ai.state import Context
390
544
 
391
545
  context = Context(**configurable)
392
546
 
393
- # Prepare config with thread_id for checkpointer
394
- # Note: thread_id is needed in config for checkpointer/memory
395
- config = {"configurable": {"thread_id": options.thread_id}}
547
+ # Prepare config with all context fields for checkpointer/memory
548
+ # Note: langmem tools require user_id in config.configurable for namespace resolution
549
+ config = {"configurable": context.model_dump()}
396
550
 
397
551
  # Invoke the graph and handle interrupts (HITL)
398
552
  # Wrap in async function to maintain connection pool throughout
@@ -526,6 +680,12 @@ def handle_chat_command(options: Namespace) -> None:
526
680
 
527
681
  try:
528
682
  result = loop.run_until_complete(_invoke_with_hitl())
683
+ except KeyboardInterrupt:
684
+ # Re-raise to be caught by outer handler
685
+ raise
686
+ except asyncio.CancelledError:
687
+ # Treat cancellation like KeyboardInterrupt
688
+ raise KeyboardInterrupt
529
689
  except Exception as e:
530
690
  logger.error(f"Error invoking graph: {e}")
531
691
  print(f"\n❌ Error: {e}")
@@ -549,13 +709,6 @@ def handle_chat_command(options: Namespace) -> None:
549
709
  # Find the last AI message
550
710
  for msg in reversed(latest_messages):
551
711
  if isinstance(msg, AIMessage):
552
- logger.debug(f"AI message content: {msg.content}")
553
- logger.debug(
554
- f"AI message has tool_calls: {hasattr(msg, 'tool_calls')}"
555
- )
556
- if hasattr(msg, "tool_calls"):
557
- logger.debug(f"Tool calls: {msg.tool_calls}")
558
-
559
712
  if hasattr(msg, "content") and msg.content:
560
713
  response_content = msg.content
561
714
  print(response_content, end="", flush=True)
@@ -598,23 +751,34 @@ def handle_chat_command(options: Namespace) -> None:
598
751
  logger.error(f"Response processing error: {e}")
599
752
  logger.error(f"Stack trace: {traceback.format_exc()}")
600
753
 
601
- except EOFError:
602
- # Handle Ctrl-D
603
- print("\n\n👋 Goodbye! Chat session ended.")
604
- break
605
- except KeyboardInterrupt:
606
- # Handle Ctrl-C
607
- print("\n\n👋 Chat session interrupted. Goodbye!")
754
+ except (EOFError, KeyboardInterrupt):
755
+ # Handle Ctrl-D (EOF) or Ctrl-C (interrupt)
756
+ # Use try/except for print in case stdout is closed
757
+ try:
758
+ print("\n\n👋 Goodbye! Chat session ended.")
759
+ sys.stdout.flush()
760
+ except Exception:
761
+ pass
608
762
  break
609
763
  except Exception as e:
610
764
  print(f"\n❌ Error: {e}")
611
765
  logger.error(f"Chat error: {e}")
612
766
  traceback.print_exc()
613
767
 
768
+ except (EOFError, KeyboardInterrupt):
769
+ # Handle interrupts during initialization
770
+ try:
771
+ print("\n\n👋 Chat session interrupted. Goodbye!")
772
+ sys.stdout.flush()
773
+ except Exception:
774
+ pass
614
775
  except Exception as e:
615
776
  logger.error(f"Failed to initialize chat session: {e}")
616
777
  print(f"❌ Failed to start chat session: {e}")
617
778
  sys.exit(1)
779
+ finally:
780
+ # Restore original signal handler
781
+ signal.signal(signal.SIGINT, original_handler)
618
782
 
619
783
 
620
784
  def handle_schema_command(options: Namespace) -> None:
@@ -630,11 +794,28 @@ def handle_graph_command(options: Namespace) -> None:
630
794
 
631
795
 
632
796
  def handle_deploy_command(options: Namespace) -> None:
797
+ from dao_ai.config import DeploymentTarget
798
+
633
799
  logger.debug(f"Validating configuration from {options.config}...")
634
800
  try:
635
801
  config: AppConfig = AppConfig.from_file(options.config)
802
+
803
+ # Hybrid target resolution:
804
+ # 1. CLI --target takes precedence
805
+ # 2. Fall back to config.app.deployment_target
806
+ # 3. Default to MODEL_SERVING (handled in deploy_agent)
807
+ target: DeploymentTarget | None = None
808
+ if options.target is not None:
809
+ target = DeploymentTarget(options.target)
810
+ logger.info(f"Using CLI-specified deployment target: {target.value}")
811
+ elif config.app is not None and config.app.deployment_target is not None:
812
+ target = config.app.deployment_target
813
+ logger.info(f"Using config file deployment target: {target.value}")
814
+ else:
815
+ logger.info("No deployment target specified, defaulting to model_serving")
816
+
636
817
  config.create_agent()
637
- config.deploy_agent()
818
+ config.deploy_agent(target=target)
638
819
  sys.exit(0)
639
820
  except Exception as e:
640
821
  logger.error(f"Deployment failed: {e}")
@@ -653,6 +834,275 @@ def handle_validate_command(options: Namespace) -> None:
653
834
  sys.exit(1)
654
835
 
655
836
 
837
+ def _format_schema_pretty(schema: dict[str, Any], indent: int = 0) -> str:
838
+ """
839
+ Format a JSON schema in a more readable, concise format.
840
+
841
+ Args:
842
+ schema: The JSON schema to format
843
+ indent: Current indentation level
844
+
845
+ Returns:
846
+ Pretty-formatted schema string
847
+ """
848
+ if not schema:
849
+ return ""
850
+
851
+ lines: list[str] = []
852
+ indent_str = " " * indent
853
+
854
+ # Get required fields
855
+ required_fields = set(schema.get("required", []))
856
+
857
+ # Handle object type with properties
858
+ if schema.get("type") == "object" and "properties" in schema:
859
+ properties = schema["properties"]
860
+
861
+ for prop_name, prop_schema in properties.items():
862
+ is_required = prop_name in required_fields
863
+ req_marker = " (required)" if is_required else " (optional)"
864
+
865
+ prop_type = prop_schema.get("type", "any")
866
+ prop_desc = prop_schema.get("description", "")
867
+
868
+ # Handle different types
869
+ if prop_type == "array":
870
+ items = prop_schema.get("items", {})
871
+ item_type = items.get("type", "any")
872
+ type_str = f"array<{item_type}>"
873
+ elif prop_type == "object":
874
+ type_str = "object"
875
+ else:
876
+ type_str = prop_type
877
+
878
+ # Format enum values if present
879
+ if "enum" in prop_schema:
880
+ enum_values = ", ".join(str(v) for v in prop_schema["enum"])
881
+ type_str = f"{type_str} (one of: {enum_values})"
882
+
883
+ # Build the line
884
+ line = f"{indent_str}{prop_name}: {type_str}{req_marker}"
885
+ if prop_desc:
886
+ line += f"\n{indent_str} └─ {prop_desc}"
887
+
888
+ lines.append(line)
889
+
890
+ # Recursively handle nested objects
891
+ if prop_type == "object" and "properties" in prop_schema:
892
+ nested = _format_schema_pretty(prop_schema, indent + 1)
893
+ if nested:
894
+ lines.append(nested)
895
+
896
+ # Handle simple types without properties
897
+ elif "type" in schema:
898
+ schema_type = schema["type"]
899
+ if schema.get("description"):
900
+ lines.append(f"{indent_str}Type: {schema_type}")
901
+ lines.append(f"{indent_str}└─ {schema['description']}")
902
+ else:
903
+ lines.append(f"{indent_str}Type: {schema_type}")
904
+
905
+ return "\n".join(lines)
906
+
907
+
908
+ def handle_list_mcp_tools_command(options: Namespace) -> None:
909
+ """
910
+ List available MCP tools from configuration.
911
+
912
+ Shows all MCP servers and their available tools, indicating which
913
+ are included/excluded based on filter configuration.
914
+ """
915
+ logger.debug(f"Listing MCP tools from configuration: {options.config}")
916
+
917
+ try:
918
+ from dao_ai.config import McpFunctionModel
919
+ from dao_ai.tools.mcp import MCPToolInfo, _matches_pattern, list_mcp_tools
920
+
921
+ # Load configuration
922
+ config: AppConfig = AppConfig.from_file(options.config)
923
+
924
+ # Find all MCP tools in configuration
925
+ mcp_tools_config: list[tuple[str, McpFunctionModel]] = []
926
+ if config.tools:
927
+ for tool_name, tool_model in config.tools.items():
928
+ logger.debug(
929
+ f"Checking tool: {tool_name}, function type: {type(tool_model.function)}"
930
+ )
931
+ if tool_model.function and isinstance(
932
+ tool_model.function, McpFunctionModel
933
+ ):
934
+ mcp_tools_config.append((tool_name, tool_model.function))
935
+
936
+ if not mcp_tools_config:
937
+ logger.warning("No MCP tools found in configuration")
938
+ print("\n⚠️ No MCP tools configured in this file.")
939
+ print(f" Configuration: {options.config}")
940
+ print(
941
+ "\nTo add MCP tools, define them in the 'tools' section with 'type: mcp'"
942
+ )
943
+ sys.exit(0)
944
+
945
+ # Collect all results first (aggregate before displaying)
946
+ results: list[dict[str, Any]] = []
947
+ for tool_name, mcp_function in mcp_tools_config:
948
+ result = {
949
+ "tool_name": tool_name,
950
+ "mcp_function": mcp_function,
951
+ "error": None,
952
+ "all_tools": [],
953
+ "included_tools": [],
954
+ "excluded_tools": [],
955
+ }
956
+
957
+ try:
958
+ logger.info(f"Connecting to MCP server: {mcp_function.mcp_url}")
959
+
960
+ # Get all available tools (unfiltered)
961
+ all_tools: list[MCPToolInfo] = list_mcp_tools(
962
+ mcp_function, apply_filters=False
963
+ )
964
+
965
+ # Get filtered tools (what will actually be loaded)
966
+ filtered_tools: list[MCPToolInfo] = list_mcp_tools(
967
+ mcp_function, apply_filters=True
968
+ )
969
+
970
+ included_names = {t.name for t in filtered_tools}
971
+
972
+ # Categorize tools
973
+ for tool in sorted(all_tools, key=lambda t: t.name):
974
+ if tool.name in included_names:
975
+ result["included_tools"].append(tool)
976
+ else:
977
+ # Determine why it was excluded
978
+ reason = ""
979
+ if mcp_function.exclude_tools:
980
+ if _matches_pattern(tool.name, mcp_function.exclude_tools):
981
+ matching_patterns = [
982
+ p
983
+ for p in mcp_function.exclude_tools
984
+ if _matches_pattern(tool.name, [p])
985
+ ]
986
+ reason = f" (matches exclude pattern: {', '.join(matching_patterns)})"
987
+ if not reason and mcp_function.include_tools:
988
+ reason = " (not in include list)"
989
+ result["excluded_tools"].append((tool, reason))
990
+
991
+ result["all_tools"] = all_tools
992
+
993
+ except KeyboardInterrupt:
994
+ result["error"] = "Connection interrupted by user"
995
+ results.append(result)
996
+ break
997
+ except Exception as e:
998
+ logger.error(f"Failed to list tools from MCP server: {e}")
999
+ result["error"] = str(e)
1000
+
1001
+ results.append(result)
1002
+
1003
+ # Now display all results at once (no logging interleaving)
1004
+ print(f"\n{'=' * 80}")
1005
+ print("MCP TOOLS DISCOVERY")
1006
+ print(f"Configuration: {options.config}")
1007
+ print(f"{'=' * 80}\n")
1008
+
1009
+ for result in results:
1010
+ tool_name = result["tool_name"]
1011
+ mcp_function = result["mcp_function"]
1012
+
1013
+ print(f"📦 Tool: {tool_name}")
1014
+ print(f" Server: {mcp_function.mcp_url}")
1015
+
1016
+ # Show connection type
1017
+ if mcp_function.connection:
1018
+ print(f" Connection: UC Connection '{mcp_function.connection.name}'")
1019
+ else:
1020
+ print(f" Transport: {mcp_function.transport.value}")
1021
+
1022
+ # Show filters if configured
1023
+ if mcp_function.include_tools or mcp_function.exclude_tools:
1024
+ print("\n Filters:")
1025
+ if mcp_function.include_tools:
1026
+ print(f" Include: {', '.join(mcp_function.include_tools)}")
1027
+ if mcp_function.exclude_tools:
1028
+ print(f" Exclude: {', '.join(mcp_function.exclude_tools)}")
1029
+
1030
+ # Check for errors
1031
+ if result["error"]:
1032
+ print(f"\n ❌ Error: {result['error']}")
1033
+ print(" Could not connect to MCP server")
1034
+ if result["error"] != "Connection interrupted by user":
1035
+ print(
1036
+ " Tip: Verify server URL, authentication, and network connectivity"
1037
+ )
1038
+ else:
1039
+ all_tools = result["all_tools"]
1040
+ included_tools = result["included_tools"]
1041
+ excluded_tools = result["excluded_tools"]
1042
+
1043
+ # Show stats based on --apply-filters flag
1044
+ if options.apply_filters:
1045
+ # Simplified view: only show filtered tools count
1046
+ print(
1047
+ f"\n Available Tools: {len(included_tools)} (after filters)"
1048
+ )
1049
+ else:
1050
+ # Full view: show all, included, and excluded counts
1051
+ print(f"\n Available Tools: {len(all_tools)} total")
1052
+ print(f" ├─ ✓ Included: {len(included_tools)}")
1053
+ print(f" └─ ✗ Excluded: {len(excluded_tools)}")
1054
+
1055
+ # Show included tools with FULL descriptions and schemas
1056
+ if included_tools:
1057
+ if options.apply_filters:
1058
+ print(f"\n Tools ({len(included_tools)}):")
1059
+ else:
1060
+ print(f"\n ✓ Included Tools ({len(included_tools)}):")
1061
+
1062
+ for tool in included_tools:
1063
+ print(f"\n • {tool.name}")
1064
+ if tool.description:
1065
+ # Show full description (no truncation)
1066
+ print(f" Description: {tool.description}")
1067
+ if tool.input_schema:
1068
+ # Pretty print schema in readable format
1069
+ print(" Parameters:")
1070
+ pretty_schema = _format_schema_pretty(
1071
+ tool.input_schema, indent=0
1072
+ )
1073
+ if pretty_schema:
1074
+ # Indent the schema for better readability
1075
+ for line in pretty_schema.split("\n"):
1076
+ print(f" {line}")
1077
+ else:
1078
+ print(" (none)")
1079
+
1080
+ # Show excluded tools only if NOT applying filters
1081
+ if excluded_tools and not options.apply_filters:
1082
+ print(f"\n ✗ Excluded Tools ({len(excluded_tools)}):")
1083
+ for tool, reason in excluded_tools:
1084
+ print(f" • {tool.name}{reason}")
1085
+
1086
+ print(f"\n{'-' * 80}\n")
1087
+
1088
+ # Summary
1089
+ print(f"{'=' * 80}")
1090
+ print(f"Summary: Found {len(mcp_tools_config)} MCP server(s)")
1091
+ print(f"{'=' * 80}\n")
1092
+
1093
+ sys.exit(0)
1094
+
1095
+ except FileNotFoundError:
1096
+ logger.error(f"Configuration file not found: {options.config}")
1097
+ print(f"\n❌ Error: Configuration file not found: {options.config}")
1098
+ sys.exit(1)
1099
+ except Exception as e:
1100
+ logger.error(f"Failed to list MCP tools: {e}")
1101
+ logger.debug(traceback.format_exc())
1102
+ print(f"\n❌ Error: {e}")
1103
+ sys.exit(1)
1104
+
1105
+
656
1106
  def setup_logging(verbosity: int) -> None:
657
1107
  levels: dict[int, str] = {
658
1108
  0: "ERROR",
@@ -676,7 +1126,7 @@ def generate_bundle_from_template(config_path: Path, app_name: str) -> Path:
676
1126
  4. Returns the path to the generated file
677
1127
 
678
1128
  The generated databricks.yaml is overwritten on each deployment and is not tracked in git.
679
- Schema reference remains pointing to ./schemas/bundle_config_schema.json.
1129
+ The template contains cloud-specific targets (azure, aws, gcp) with appropriate node types.
680
1130
 
681
1131
  Args:
682
1132
  config_path: Path to the app config file
@@ -713,39 +1163,64 @@ def run_databricks_command(
713
1163
  profile: Optional[str] = None,
714
1164
  config: Optional[str] = None,
715
1165
  target: Optional[str] = None,
1166
+ cloud: Optional[str] = None,
716
1167
  dry_run: bool = False,
1168
+ deployment_target: Optional[str] = None,
717
1169
  ) -> None:
718
- """Execute a databricks CLI command with optional profile and target."""
1170
+ """Execute a databricks CLI command with optional profile, target, and cloud.
1171
+
1172
+ Args:
1173
+ command: The databricks CLI command to execute (e.g., ["bundle", "deploy"])
1174
+ profile: Optional Databricks CLI profile name
1175
+ config: Optional path to the configuration file
1176
+ target: Optional bundle target name (if not provided, auto-generated from app name and cloud)
1177
+ cloud: Optional cloud provider ('azure', 'aws', 'gcp'). Auto-detected if not specified.
1178
+ dry_run: If True, print the command without executing
1179
+ deployment_target: Optional agent deployment target ('model_serving' or 'apps').
1180
+ Passed to the deploy notebook via bundle variable.
1181
+ """
719
1182
  config_path = Path(config) if config else None
720
1183
 
721
1184
  if config_path and not config_path.exists():
722
1185
  logger.error(f"Configuration file {config_path} does not exist.")
723
1186
  sys.exit(1)
724
1187
 
725
- # Load app config and generate bundle from template
1188
+ # Load app config
726
1189
  app_config: AppConfig = AppConfig.from_file(config_path) if config_path else None
727
1190
  normalized_name: str = normalize_name(app_config.app.name) if app_config else None
728
1191
 
1192
+ # Auto-detect cloud provider if not specified (used for node_type selection)
1193
+ if not cloud:
1194
+ cloud = detect_cloud_provider(profile)
1195
+ if cloud:
1196
+ logger.info(f"Auto-detected cloud provider: {cloud}")
1197
+ else:
1198
+ logger.warning("Could not detect cloud provider. Defaulting to 'azure'.")
1199
+ cloud = "azure"
1200
+
729
1201
  # Generate app-specific bundle from template (overwrites databricks.yaml temporarily)
730
1202
  if config_path and app_config:
731
1203
  generate_bundle_from_template(config_path, normalized_name)
732
1204
 
733
- # Use app name as target if not explicitly provided
734
- # This ensures each app gets its own Terraform state in .databricks/bundle/<app-name>/
735
- if not target and normalized_name:
736
- target = normalized_name
737
- logger.debug(f"Using app-specific target: {target}")
1205
+ # Use app-specific cloud target: {app_name}-{cloud}
1206
+ # This ensures each app has unique deployment identity while supporting cloud-specific settings
1207
+ # Can be overridden with explicit --target
1208
+ if not target:
1209
+ target = f"{normalized_name}-{cloud}"
1210
+ logger.info(f"Using app-specific cloud target: {target}")
738
1211
 
739
- # Build databricks command (no -c flag needed, uses databricks.yaml in current dir)
1212
+ # Build databricks command
1213
+ # --profile is a global flag, --target is a subcommand flag for 'bundle'
740
1214
  cmd = ["databricks"]
741
1215
  if profile:
742
1216
  cmd.extend(["--profile", profile])
743
1217
 
1218
+ cmd.extend(command)
1219
+
1220
+ # --target must come after the bundle subcommand (it's a subcommand-specific flag)
744
1221
  if target:
745
1222
  cmd.extend(["--target", target])
746
1223
 
747
- cmd.extend(command)
748
-
749
1224
  # Add config_path variable for notebooks
750
1225
  if config_path and app_config:
751
1226
  # Calculate relative path from notebooks directory to config file
@@ -760,6 +1235,26 @@ def run_databricks_command(
760
1235
 
761
1236
  cmd.append(f'--var="config_path={relative_config}"')
762
1237
 
1238
+ # Add deployment_target variable for notebooks (hybrid resolution)
1239
+ # Priority: CLI arg > config file > default (model_serving)
1240
+ resolved_deployment_target: str = "model_serving"
1241
+ if deployment_target is not None:
1242
+ resolved_deployment_target = deployment_target
1243
+ logger.debug(
1244
+ f"Using CLI-specified deployment target: {resolved_deployment_target}"
1245
+ )
1246
+ elif app_config and app_config.app and app_config.app.deployment_target:
1247
+ # deployment_target is DeploymentTarget enum (str subclass) or string
1248
+ # str() works for both since DeploymentTarget inherits from str
1249
+ resolved_deployment_target = str(app_config.app.deployment_target)
1250
+ logger.debug(
1251
+ f"Using config file deployment target: {resolved_deployment_target}"
1252
+ )
1253
+ else:
1254
+ logger.debug("Using default deployment target: model_serving")
1255
+
1256
+ cmd.append(f'--var="deployment_target={resolved_deployment_target}"')
1257
+
763
1258
  logger.debug(f"Executing command: {' '.join(cmd)}")
764
1259
 
765
1260
  if dry_run:
@@ -800,31 +1295,43 @@ def handle_bundle_command(options: Namespace) -> None:
800
1295
  profile: Optional[str] = options.profile
801
1296
  config: Optional[str] = options.config
802
1297
  target: Optional[str] = options.target
1298
+ cloud: Optional[str] = options.cloud
803
1299
  dry_run: bool = options.dry_run
1300
+ deployment_target: Optional[str] = options.deployment_target
804
1301
 
805
1302
  if options.deploy:
806
1303
  logger.info("Deploying DAO AI asset bundle...")
807
1304
  run_databricks_command(
808
- ["bundle", "deploy"], profile, config, target, dry_run=dry_run
1305
+ ["bundle", "deploy"],
1306
+ profile=profile,
1307
+ config=config,
1308
+ target=target,
1309
+ cloud=cloud,
1310
+ dry_run=dry_run,
1311
+ deployment_target=deployment_target,
809
1312
  )
810
1313
  if options.run:
811
1314
  logger.info("Running DAO AI system with current configuration...")
812
1315
  # Use static job resource key that matches databricks.yaml (resources.jobs.deploy_job)
813
1316
  run_databricks_command(
814
1317
  ["bundle", "run", "deploy_job"],
815
- profile,
816
- config,
817
- target,
1318
+ profile=profile,
1319
+ config=config,
1320
+ target=target,
1321
+ cloud=cloud,
818
1322
  dry_run=dry_run,
1323
+ deployment_target=deployment_target,
819
1324
  )
820
1325
  if options.destroy:
821
1326
  logger.info("Destroying DAO AI system with current configuration...")
822
1327
  run_databricks_command(
823
1328
  ["bundle", "destroy", "--auto-approve"],
824
- profile,
825
- config,
826
- target,
1329
+ profile=profile,
1330
+ config=config,
1331
+ target=target,
1332
+ cloud=cloud,
827
1333
  dry_run=dry_run,
1334
+ deployment_target=deployment_target,
828
1335
  )
829
1336
  else:
830
1337
  logger.warning("No action specified. Use --deploy, --run or --destroy flags.")
@@ -846,6 +1353,8 @@ def main() -> None:
846
1353
  handle_deploy_command(options)
847
1354
  case "chat":
848
1355
  handle_chat_command(options)
1356
+ case "list-mcp-tools":
1357
+ handle_list_mcp_tools_command(options)
849
1358
  case _:
850
1359
  logger.error(f"Unknown command: {options.command}")
851
1360
  sys.exit(1)