databricks-agentbricks 0.2.0__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 (159) hide show
  1. databricks_agentbricks/__init__.py +1 -0
  2. databricks_agentbricks/_group.py +217 -0
  3. databricks_agentbricks/agent_project.py +657 -0
  4. databricks_agentbricks/app_resources.py +139 -0
  5. databricks_agentbricks/cli/__init__.py +12 -0
  6. databricks_agentbricks/cli/app.py +108 -0
  7. databricks_agentbricks/cli/app_auth.py +198 -0
  8. databricks_agentbricks/cli/auth.py +142 -0
  9. databricks_agentbricks/cli/deploy.py +954 -0
  10. databricks_agentbricks/cli/dev.py +310 -0
  11. databricks_agentbricks/cli/endpoint.py +145 -0
  12. databricks_agentbricks/cli/endpoint_examples.py +33 -0
  13. databricks_agentbricks/cli/endpoint_output.py +53 -0
  14. databricks_agentbricks/cli/endpoint_request.py +71 -0
  15. databricks_agentbricks/cli/endpoint_transport.py +148 -0
  16. databricks_agentbricks/cli/help.py +467 -0
  17. databricks_agentbricks/cli/init.py +496 -0
  18. databricks_agentbricks/cli/mcp.py +75 -0
  19. databricks_agentbricks/cli/memory.py +520 -0
  20. databricks_agentbricks/cli/sandbox.py +823 -0
  21. databricks_agentbricks/cli/sessions.py +588 -0
  22. databricks_agentbricks/cli/tools.py +456 -0
  23. databricks_agentbricks/cli/tracing.py +724 -0
  24. databricks_agentbricks/databricks_cli.py +38 -0
  25. databricks_agentbricks/errors.py +114 -0
  26. databricks_agentbricks/lakebase_runtime_store.py +107 -0
  27. databricks_agentbricks/legacy_lakebase_runtime_store.py +74 -0
  28. databricks_agentbricks/project_config.py +157 -0
  29. databricks_agentbricks/project_types.py +55 -0
  30. databricks_agentbricks/py.typed +0 -0
  31. databricks_agentbricks/render.py +349 -0
  32. databricks_agentbricks/templates/agent-bricks-migrate/SKILL.md +101 -0
  33. databricks_agentbricks/templates/agent-langgraph/.env.example +36 -0
  34. databricks_agentbricks/templates/agent-langgraph/.gitignore +199 -0
  35. databricks_agentbricks/templates/agent-langgraph/AGENTKIT_CONTRACT.md +95 -0
  36. databricks_agentbricks/templates/agent-langgraph/AGENTS.md +31 -0
  37. databricks_agentbricks/templates/agent-langgraph/CLAUDE.md +1 -0
  38. databricks_agentbricks/templates/agent-langgraph/README.md +141 -0
  39. databricks_agentbricks/templates/agent-langgraph/agent/__init__.py +0 -0
  40. databricks_agentbricks/templates/agent-langgraph/agent/agent.py +177 -0
  41. databricks_agentbricks/templates/agent-langgraph/agent/mcps.py +27 -0
  42. databricks_agentbricks/templates/agent-langgraph/agent/tools/__init__.py +24 -0
  43. databricks_agentbricks/templates/agent-langgraph/agent/tools/sample_tool.py +15 -0
  44. databricks_agentbricks/templates/agent-langgraph/agent/tools/send_message.py +17 -0
  45. databricks_agentbricks/templates/agent-langgraph/app.yaml +15 -0
  46. databricks_agentbricks/templates/agent-langgraph/pyproject.toml +42 -0
  47. databricks_agentbricks/templates/agent-langgraph/runtime/__init__.py +0 -0
  48. databricks_agentbricks/templates/agent-langgraph/runtime/adapter.py +144 -0
  49. databricks_agentbricks/templates/agent-langgraph/runtime/main.py +25 -0
  50. databricks_agentbricks/templates/agent-langgraph/tests/__init__.py +0 -0
  51. databricks_agentbricks/templates/agent-langgraph/tests/test_agent.py +211 -0
  52. databricks_agentbricks/templates/agent-langgraph/tests/test_runtime.py +19 -0
  53. databricks_agentbricks/templates/agent-langgraph/tests/test_workspace.py +28 -0
  54. databricks_agentbricks/templates/agent-openai/.env.example +37 -0
  55. databricks_agentbricks/templates/agent-openai/.gitignore +199 -0
  56. databricks_agentbricks/templates/agent-openai/AGENTKIT_CONTRACT.md +107 -0
  57. databricks_agentbricks/templates/agent-openai/AGENTS.md +76 -0
  58. databricks_agentbricks/templates/agent-openai/CLAUDE.md +1 -0
  59. databricks_agentbricks/templates/agent-openai/README.md +143 -0
  60. databricks_agentbricks/templates/agent-openai/agent/__init__.py +0 -0
  61. databricks_agentbricks/templates/agent-openai/agent/agent.py +205 -0
  62. databricks_agentbricks/templates/agent-openai/agent/mcps.py +28 -0
  63. databricks_agentbricks/templates/agent-openai/agent/tools/__init__.py +24 -0
  64. databricks_agentbricks/templates/agent-openai/agent/tools/sample_tool.py +15 -0
  65. databricks_agentbricks/templates/agent-openai/agent/tools/send_message.py +18 -0
  66. databricks_agentbricks/templates/agent-openai/app.yaml +15 -0
  67. databricks_agentbricks/templates/agent-openai/pyproject.toml +42 -0
  68. databricks_agentbricks/templates/agent-openai/runtime/__init__.py +0 -0
  69. databricks_agentbricks/templates/agent-openai/runtime/adapter.py +188 -0
  70. databricks_agentbricks/templates/agent-openai/runtime/main.py +25 -0
  71. databricks_agentbricks/templates/agent-openai/tests/__init__.py +0 -0
  72. databricks_agentbricks/templates/agent-openai/tests/test_agent.py +326 -0
  73. databricks_agentbricks/templates/agent-openai/tests/test_runtime.py +19 -0
  74. databricks_agentbricks/templates/agent-openai/tests/test_workspace.py +28 -0
  75. databricks_agentbricks/templates/custom-agent-langgraph/.env.example +1 -0
  76. databricks_agentbricks/templates/custom-agent-langgraph/.gitignore +11 -0
  77. databricks_agentbricks/templates/custom-agent-langgraph/README.md +22 -0
  78. databricks_agentbricks/templates/custom-agent-langgraph/agent/__init__.py +0 -0
  79. databricks_agentbricks/templates/custom-agent-langgraph/agent/agent.py +27 -0
  80. databricks_agentbricks/templates/custom-agent-langgraph/app.yaml +1 -0
  81. databricks_agentbricks/templates/custom-agent-langgraph/pyproject.toml +38 -0
  82. databricks_agentbricks/templates/custom-agent-langgraph/runtime/__init__.py +0 -0
  83. databricks_agentbricks/templates/custom-agent-langgraph/runtime/main.py +31 -0
  84. databricks_agentbricks/templates/custom-agent-langgraph/tests/test_runtime.py +29 -0
  85. databricks_agentbricks/templates/custom-agent-openai/.env.example +1 -0
  86. databricks_agentbricks/templates/custom-agent-openai/.gitignore +11 -0
  87. databricks_agentbricks/templates/custom-agent-openai/README.md +22 -0
  88. databricks_agentbricks/templates/custom-agent-openai/agent/__init__.py +0 -0
  89. databricks_agentbricks/templates/custom-agent-openai/agent/agent.py +29 -0
  90. databricks_agentbricks/templates/custom-agent-openai/app.yaml +1 -0
  91. databricks_agentbricks/templates/custom-agent-openai/pyproject.toml +38 -0
  92. databricks_agentbricks/templates/custom-agent-openai/runtime/__init__.py +0 -0
  93. databricks_agentbricks/templates/custom-agent-openai/runtime/main.py +31 -0
  94. databricks_agentbricks/templates/custom-agent-openai/tests/test_runtime.py +29 -0
  95. databricks_agentbricks/templates/sandbox_mcp.py +39 -0
  96. databricks_agentbricks/templates/sandbox_mcp_langgraph.py +39 -0
  97. databricks_agentbricks/templates/ui/agent-langgraph/CHAT_APP.md +49 -0
  98. databricks_agentbricks/templates/ui/agent-langgraph/runtime/main.py +26 -0
  99. databricks_agentbricks/templates/ui/agent-langgraph/runtime/ui.py +528 -0
  100. databricks_agentbricks/templates/ui/agent-langgraph/tests/test_demo_ui.py +542 -0
  101. databricks_agentbricks/templates/ui/agent-langgraph/ui/agentbricks-mark.png +0 -0
  102. databricks_agentbricks/templates/ui/agent-langgraph/ui/app.js +1464 -0
  103. databricks_agentbricks/templates/ui/agent-langgraph/ui/index.html +281 -0
  104. databricks_agentbricks/templates/ui/agent-langgraph/ui/styles.css +1913 -0
  105. databricks_agentbricks/templates/ui/agent-openai/CHAT_APP.md +54 -0
  106. databricks_agentbricks/templates/ui/agent-openai/runtime/main.py +26 -0
  107. databricks_agentbricks/templates/ui/agent-openai/runtime/ui.py +521 -0
  108. databricks_agentbricks/templates/ui/agent-openai/tests/test_demo_ui.py +512 -0
  109. databricks_agentbricks/templates/ui/agent-openai/ui/agentbricks-mark.png +0 -0
  110. databricks_agentbricks/templates/ui/agent-openai/ui/app.js +1428 -0
  111. databricks_agentbricks/templates/ui/agent-openai/ui/index.html +281 -0
  112. databricks_agentbricks/templates/ui/agent-openai/ui/styles.css +1913 -0
  113. databricks_agentbricks/theme.py +72 -0
  114. databricks_agentbricks-0.2.0.dist-info/METADATA +897 -0
  115. databricks_agentbricks-0.2.0.dist-info/RECORD +159 -0
  116. databricks_agentbricks-0.2.0.dist-info/WHEEL +4 -0
  117. databricks_agentbricks-0.2.0.dist-info/entry_points.txt +2 -0
  118. databricks_agentbricks-0.2.0.dist-info/licenses/NOTICE +39 -0
  119. databricks_agentkit/__init__.py +67 -0
  120. databricks_agentkit/_api_client.py +673 -0
  121. databricks_agentkit/_pagination.py +25 -0
  122. databricks_agentkit/client.py +31 -0
  123. databricks_agentkit/langgraph/__init__.py +98 -0
  124. databricks_agentkit/langgraph/genie.py +50 -0
  125. databricks_agentkit/langgraph/mcp.py +216 -0
  126. databricks_agentkit/langgraph/memory.py +67 -0
  127. databricks_agentkit/langgraph/session_store.py +485 -0
  128. databricks_agentkit/memory_store.py +371 -0
  129. databricks_agentkit/models.py +330 -0
  130. databricks_agentkit/openai/__init__.py +93 -0
  131. databricks_agentkit/openai/genie.py +50 -0
  132. databricks_agentkit/openai/mcp.py +189 -0
  133. databricks_agentkit/openai/memory.py +67 -0
  134. databricks_agentkit/openai/sessions.py +157 -0
  135. databricks_agentkit/py.typed +0 -0
  136. databricks_agentkit/runtime/ARCHITECTURE.md +64 -0
  137. databricks_agentkit/runtime/README.md +184 -0
  138. databricks_agentkit/runtime/__init__.py +69 -0
  139. databricks_agentkit/runtime/app.py +316 -0
  140. databricks_agentkit/runtime/auth.py +149 -0
  141. databricks_agentkit/runtime/durability/__init__.py +1 -0
  142. databricks_agentkit/runtime/durability/execution.py +133 -0
  143. databricks_agentkit/runtime/durability/heartbeat.py +64 -0
  144. databricks_agentkit/runtime/durability/lakebase_runtime_store.py +648 -0
  145. databricks_agentkit/runtime/durability/recovery.py +70 -0
  146. databricks_agentkit/runtime/durability/store.py +33 -0
  147. databricks_agentkit/runtime/execution.py +160 -0
  148. databricks_agentkit/runtime/genie.py +224 -0
  149. databricks_agentkit/runtime/mcp_auth.py +114 -0
  150. databricks_agentkit/runtime/model_services.py +90 -0
  151. databricks_agentkit/runtime/runtime.py +273 -0
  152. databricks_agentkit/runtime/session_store_client.py +120 -0
  153. databricks_agentkit/runtime/store.py +287 -0
  154. databricks_agentkit/runtime/tool_manifest.py +223 -0
  155. databricks_agentkit/runtime/tracing.py +71 -0
  156. databricks_agentkit/runtime/types.py +118 -0
  157. databricks_agentkit/runtime/workspace.py +27 -0
  158. databricks_agentkit/session_store.py +434 -0
  159. databricks_agentkit/timefmt.py +85 -0
@@ -0,0 +1,139 @@
1
+ """Lakebase/Apps resource plumbing for a deployed app.
2
+
3
+ Binds Databricks Apps resources onto an app so its service principal gets platform-managed grants:
4
+ a `postgres` resource for the legacy per-app Runtime Store and the tracing `experiment` resource.
5
+ The service-managed Runtime Store path grants database access through Conversation Store instead.
6
+
7
+ Managed-store (session/memory) table access is NOT granted here. The deployed app reaches those
8
+ stores over the conversation-store REST API, which grants the app's service principal read/write
9
+ server-side (see `deploy._grant_store_access`), so no direct Lakebase grant is needed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import subprocess
16
+ from dataclasses import dataclass
17
+ from typing import Optional
18
+
19
+ from databricks_agentbricks.databricks_cli import _databricks
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LakebaseBackend:
24
+ """A Lakebase database and endpoint bound as a `postgres` app resource."""
25
+
26
+ project: str
27
+ branch: str
28
+ endpoint_id: str
29
+ database: str
30
+ schema: str
31
+ tables: tuple[str, ...]
32
+ resource_name: str # the app-resource name (must be unique across an app's resources)
33
+
34
+ @property
35
+ def branch_path(self) -> str:
36
+ return f"projects/{self.project}/branches/{self.branch}"
37
+
38
+ @property
39
+ def database_path(self) -> str:
40
+ return f"{self.branch_path}/databases/{self.database}"
41
+
42
+ @property
43
+ def endpoint_path(self) -> str:
44
+ return f"{self.branch_path}/endpoints/{self.endpoint_id}"
45
+
46
+ def postgres_resource(self) -> dict:
47
+ """The `postgres` app-resource entry that grants the SP a Lakebase role + CONNECT."""
48
+ return {
49
+ "name": self.resource_name,
50
+ "postgres": {
51
+ "branch": self.branch_path,
52
+ "database": self.database_path,
53
+ "permission": "CAN_CONNECT_AND_CREATE",
54
+ },
55
+ }
56
+
57
+
58
+ def _current_app_resources(app: str, profile: Optional[str]) -> list[dict]:
59
+ """Read the app's existing resources array (empty list if it can't be read)."""
60
+ result = _databricks(["apps", "get", app, "-o", "json"], profile, capture=True, check=False)
61
+ if result.returncode != 0:
62
+ return []
63
+ try:
64
+ resources = json.loads(result.stdout or "{}").get("resources", [])
65
+ except (json.JSONDecodeError, AttributeError):
66
+ return []
67
+ return resources if isinstance(resources, list) else []
68
+
69
+
70
+ # The app-resource name for the trace experiment (unique across an app's resources, like a store's).
71
+ _TRACE_EXPERIMENT_RESOURCE = "agentbricks-trace-experiment"
72
+
73
+
74
+ def apply_experiment_resource(
75
+ app: str, experiment_id: str, profile: Optional[str]
76
+ ) -> Optional[str]:
77
+ """Bind the trace experiment as an `experiment` app resource so the SP can write traces.
78
+
79
+ This is the platform-managed grant: declaring the experiment as a `CAN_EDIT` resource lets the
80
+ app's service principal log traces to it (no manual SQL grant). Uses the same masked
81
+ read-modify-write as `apply_postgres_resources`: replace the whole resource array (preserving
82
+ every resource we don't own) while `update_mask` keeps the write from touching any other app
83
+ field. Returns None on success or a human-readable reason on failure.
84
+ """
85
+ ours = {
86
+ "name": _TRACE_EXPERIMENT_RESOURCE,
87
+ "experiment": {"experiment_id": experiment_id, "permission": "CAN_EDIT"},
88
+ }
89
+ preserved = [
90
+ r
91
+ for r in _current_app_resources(app, profile)
92
+ if isinstance(r, dict) and r.get("name") != _TRACE_EXPERIMENT_RESOURCE
93
+ ]
94
+ result = _update_app_resources(app, preserved + [ours], profile)
95
+ if result.returncode == 0:
96
+ return None
97
+ return (result.stderr or result.stdout or "").strip() or "unknown error"
98
+
99
+
100
+ def apply_postgres_resources(
101
+ app: str, backends: list[LakebaseBackend], profile: Optional[str]
102
+ ) -> Optional[str]:
103
+ """Bind each backend's database as a `postgres` app resource in one update.
104
+
105
+ Writing the resources field REPLACES the whole array, so we must send the complete set:
106
+ read the app's current resources, drop the ones we manage (matched by name) so re-deploys
107
+ update rather than duplicate them, keep every other (user-owned) resource, and append ours.
108
+ Returns None on success or a human-readable reason on failure.
109
+ """
110
+ ours = [b.postgres_resource() for b in backends]
111
+ our_names = {r["name"] for r in ours}
112
+ preserved = [
113
+ r
114
+ for r in _current_app_resources(app, profile)
115
+ if isinstance(r, dict) and r.get("name") not in our_names
116
+ ]
117
+ result = _update_app_resources(app, preserved + ours, profile)
118
+ if result.returncode == 0:
119
+ return None
120
+ return (result.stderr or result.stdout or "").strip() or "unknown error"
121
+
122
+
123
+ def _update_app_resources(
124
+ app: str, resources: list[dict], profile: Optional[str]
125
+ ) -> subprocess.CompletedProcess:
126
+ """Set the app's resources array without disturbing any other app field.
127
+
128
+ `update_mask` scopes the write to `resources` only. A bare `apps update` replaces the whole app
129
+ spec, so fields we don't send — notably `user_api_scopes` — would reset to their defaults every
130
+ deploy, silently breaking OBO. The masked `create-update` still replaces the resources array
131
+ (callers pass the complete desired set), but leaves everything else untouched.
132
+ """
133
+ payload = {"app": {"resources": resources}, "update_mask": "resources"}
134
+ return _databricks(
135
+ ["apps", "create-update", app, "--json", json.dumps(payload)],
136
+ profile,
137
+ capture=True,
138
+ check=False,
139
+ )
@@ -0,0 +1,12 @@
1
+ """Agent Bricks CLI: the ``ab`` command and its subcommands.
2
+
3
+ The console entrypoint (``project.scripts``) is ``databricks_agentbricks.cli:main``, re-exported here from
4
+ ``databricks_agentbricks.cli.app`` (the command tree) so that path stays stable. The entry function is named
5
+ ``app`` there rather than ``main`` so it does not shadow this package's re-exported ``main``. These
6
+ modules are the command surface; the SDK and deployed-agent runtime live under
7
+ ``databricks_agentkit``.
8
+ """
9
+
10
+ from databricks_agentbricks.cli.app import main
11
+
12
+ __all__ = ["main"]
@@ -0,0 +1,108 @@
1
+ """`ab` — the Databricks CLI for agent deployment, memory, and sessions.
2
+
3
+ Root Click group. Global `--profile` and `--output` flow to every subcommand via
4
+ `CliContext` on `ctx.obj`; subcommands build an authenticated API client on demand.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ import click
12
+
13
+ from databricks_agentbricks import errors
14
+ from databricks_agentbricks.cli.auth import load_default_profile, login, logout
15
+ from databricks_agentbricks.cli.deploy import deploy, deployments
16
+ from databricks_agentbricks.cli.dev import dev
17
+ from databricks_agentbricks.cli.endpoint import endpoint
18
+ from databricks_agentbricks.cli.help import configure_help
19
+ from databricks_agentbricks.cli.init import init
20
+ from databricks_agentbricks.cli.memory import memory
21
+ from databricks_agentbricks.cli.sessions import sessions
22
+ from databricks_agentbricks.cli.tools import tools
23
+ from databricks_agentbricks.cli.tracing import tracing
24
+ from databricks_agentkit._api_client import _AgentBricksApiClient
25
+
26
+
27
+ class CliContext:
28
+ """Shared per-invocation state: selected profile, output mode, lazily-built client."""
29
+
30
+ def __init__(self, profile: Optional[str], output: str):
31
+ self.profile = profile
32
+ self.output = output
33
+ self._client: Optional[_AgentBricksApiClient] = None
34
+
35
+ def client(self) -> _AgentBricksApiClient:
36
+ if self._client is None:
37
+ self._client = _AgentBricksApiClient(self.profile)
38
+ return self._client
39
+
40
+
41
+ @click.group(name="ab", context_settings={"help_option_names": ["-h", "--help"]})
42
+ @click.option(
43
+ "--profile", "-p", default=None, help="~/.databrickscfg profile to authenticate with."
44
+ )
45
+ @click.option(
46
+ "--output",
47
+ "-o",
48
+ type=click.Choice(["text", "json"]),
49
+ default="text",
50
+ help="Output format (default: text).",
51
+ )
52
+ @click.version_option(package_name="databricks-agentbricks")
53
+ @click.pass_context
54
+ def agentbricks(ctx: click.Context, profile: Optional[str], output: str) -> None:
55
+ """Agent Bricks is a CLI for building and deploying custom AI agents on Databricks.
56
+
57
+ The Agent Bricks CLI is experimental: its commands and the underlying agent APIs are all in
58
+ preview, may need to be enabled for your workspace, and are likely to change in
59
+ backward-incompatible ways.
60
+
61
+ Scaffold an agent project from a template, run it locally with a chat UI, and deploy it to
62
+ Databricks Apps — then manage the tools, memory, sessions, and tracing behind it, all from one
63
+ authenticated command.
64
+
65
+ New here? The examples below take you from an empty directory to a deployed agent. Agent Bricks
66
+ authenticates with a Databricks profile: run `ab login` once to save a default, or pass
67
+ --profile / -p (without one, the Databricks SDK's default authentication is used).
68
+
69
+ Agents built with Agent Bricks combine the platform's capabilities:
70
+
71
+ \b
72
+ Models Call Databricks model serving out of the box, routed through
73
+ the AI Gateway for capacity on your existing Databricks auth.
74
+ Tools Data sandboxes, managed MCP services, Unity Catalog
75
+ functions, and local Python tools the agent can call.
76
+ Memory Long-term memory the agent recalls across conversations.
77
+ Sessions The transcript, history, and state of a single conversation.
78
+ Tracing MLflow traces in Unity Catalog to debug and evaluate runs.
79
+ Deployment Hosting on Databricks Apps, with scaling and sticky routing.
80
+
81
+ `ab deploy` provisions and wires these into a single agent hosted on Databricks Apps.
82
+ """
83
+ # Let errors render to match the selected output mode (JSON errors for -o json).
84
+ errors.set_output_mode(output)
85
+ ctx.obj = CliContext(profile=profile or load_default_profile(), output=output)
86
+
87
+
88
+ agentbricks.add_command(login)
89
+ agentbricks.add_command(logout)
90
+ agentbricks.add_command(init)
91
+ agentbricks.add_command(dev)
92
+ agentbricks.add_command(memory)
93
+ agentbricks.add_command(sessions)
94
+ agentbricks.add_command(tracing)
95
+ agentbricks.add_command(deploy)
96
+ agentbricks.add_command(deployments)
97
+ agentbricks.add_command(endpoint)
98
+ agentbricks.add_command(tools)
99
+ configure_help(agentbricks)
100
+
101
+
102
+ def main() -> None:
103
+ # Click derives the display name from argv[0] so the `ab` script is shown in help output.
104
+ agentbricks()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
@@ -0,0 +1,198 @@
1
+ """Preflight and conservative Apps scope reconciliation for request-user tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+
9
+ from databricks.sdk import WorkspaceClient
10
+ from databricks.sdk.errors import DatabricksError, NotFound
11
+ from databricks.sdk.service.apps import App, AppsAPI
12
+
13
+ from databricks_agentbricks.agent_project import AgentProject
14
+ from databricks_agentbricks.errors import AgentCliError
15
+ from databricks_agentbricks.project_types import AgentServer
16
+
17
+ _IDENTITY_DEFAULT_SCOPES = frozenset({"iam.access-control:read", "iam.current-user:read"})
18
+
19
+
20
+ def _validate_forwarding(app: App) -> None:
21
+ if app.forward_user_access_token is False:
22
+ raise AgentCliError(
23
+ f"App '{app.name}' has forward_user_access_token=False; user auth requires forwarding.",
24
+ hint="Enable user access token forwarding in Databricks Apps, then stop and start "
25
+ "the App compute for the setting to take effect before retrying deployment.",
26
+ )
27
+
28
+
29
+ def _validate_implicit_identity_scopes(app: App) -> None:
30
+ if app.user_api_scopes is not None:
31
+ return
32
+ effective = set(app.effective_user_api_scopes or [])
33
+ if effective - _IDENTITY_DEFAULT_SCOPES:
34
+ raise AgentCliError(
35
+ "Apps did not return configured scopes for an App with unexplained effective grants.",
36
+ hint="Inspect the App's scope configuration with its owner before retrying the scope "
37
+ "update.",
38
+ )
39
+
40
+
41
+ def requires_user_auth(project: AgentProject | None) -> bool:
42
+ """Infer request-user auth from tool bindings before any deployment mutation."""
43
+ if project is None or not project.tools:
44
+ return False
45
+ managed = [
46
+ tool
47
+ for tool in project.tools
48
+ if tool.source.kind in ("mcp", "sandbox", "genie_one", "genie_agent")
49
+ ]
50
+ user_auth = any(tool.auth == "user" for tool in managed)
51
+ if user_auth and project.server != AgentServer.AGENTBRICKS:
52
+ raise AgentCliError(
53
+ "Managed tools with auth = 'user' require [agent].server = 'agentbricks'.",
54
+ hint="Migrate to the request-auth-aware Agent Bricks DurableAgentServer template before enabling user "
55
+ "auth. Failure recovery is unsupported for request-user attempts because the credential "
56
+ "is transient.",
57
+ )
58
+ if user_auth:
59
+ unspecified = [tool.id for tool in managed if tool.auth is None]
60
+ if unspecified:
61
+ raise AgentCliError(
62
+ "Request-user tools require explicit auth on every managed "
63
+ f"tool binding: {', '.join(unspecified)}.",
64
+ hint="Choose auth = 'app' to preserve legacy identity, or explicitly choose 'user'.",
65
+ )
66
+ return user_auth
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class AppUserScopeUpdatePlan:
71
+ apps: AppsAPI
72
+ name: str
73
+ existing_scopes: tuple[str, ...] | None
74
+ scopes: tuple[str, ...]
75
+
76
+
77
+ def required_user_api_scopes(project: AgentProject | None) -> set[str]:
78
+ """Return Databricks Apps user API scopes required by request-user tools."""
79
+ scopes: set[str] = set()
80
+ # TODO: Extend this least-privilege mapping for each supported request-user tool kind/service.
81
+ for tool in project.tools if project else ():
82
+ if tool.auth != "user":
83
+ continue
84
+ if tool.source.kind in ("genie_one", "genie_agent"):
85
+ scopes.add("genie")
86
+ continue
87
+ if tool.source.kind not in ("mcp", "sandbox"):
88
+ continue
89
+ scopes.add("ai-gateway")
90
+ if tool.source.service == "system.ai.dbsql":
91
+ scopes.add("sql")
92
+ if tool.source.service == "system.ai.genie_one_mcp":
93
+ scopes.add("genie")
94
+ if tool.source.kind == "sandbox" and any(
95
+ scope.kind == "volume" for scope in tool.policy.downscope
96
+ ):
97
+ scopes.add("files")
98
+ return scopes
99
+
100
+
101
+ def plan_app_user_scope_update(
102
+ name: str,
103
+ profile: str | None,
104
+ *,
105
+ allow_existing_app_update: bool,
106
+ required_scopes: set[str] | None = None,
107
+ ) -> AppUserScopeUpdatePlan:
108
+ """Plan user-scope creation or addition without changing the Databricks App.
109
+
110
+ A new App can be created with the requested user API scopes automatically. An existing App is
111
+ left unchanged unless all requested scopes are already present or the caller explicitly allows
112
+ Agent Bricks to add the missing scopes.
113
+ """
114
+ try:
115
+ apps = WorkspaceClient(profile=profile).apps
116
+ try:
117
+ existing = apps.get(name)
118
+ except NotFound:
119
+ existing = None
120
+ except (DatabricksError, ValueError) as exc:
121
+ raise AgentCliError(f"Could not read Apps user scopes for '{name}'.") from exc
122
+ if existing is not None:
123
+ _validate_forwarding(existing)
124
+ if existing is not None:
125
+ _validate_implicit_identity_scopes(existing)
126
+ configured = tuple(sorted(set(existing.user_api_scopes or []))) if existing else None
127
+ requested = {"ai-gateway"} if required_scopes is None else required_scopes
128
+ scopes = tuple(sorted({*(configured or ()), *requested}))
129
+ if existing is not None and scopes != configured and not allow_existing_app_update:
130
+ raise AgentCliError(
131
+ f"App '{name}' is missing required user API scopes; re-run with "
132
+ "--allow-user-scope-update to add them.",
133
+ hint="Review the existing App scopes and coordinate with other owners first. Agent Bricks "
134
+ "preserves unrelated scopes; later deploys do not need the flag once all required "
135
+ "scopes are present.",
136
+ )
137
+ return AppUserScopeUpdatePlan(apps=apps, name=name, existing_scopes=configured, scopes=scopes)
138
+
139
+
140
+ def apply_app_user_scope_update(
141
+ plan: AppUserScopeUpdatePlan, *, instances: int | None = None, attempts: int = 12
142
+ ) -> None:
143
+ """Create a scoped App or add missing user API scopes to an existing App.
144
+
145
+ New Apps enable user-token forwarding at creation. Existing Apps retain unrelated scopes and
146
+ settings; the update mask includes only user API scopes and explicitly requested instance
147
+ fields. The read-before-write check detects known drift, but Apps has no compare-and-swap
148
+ contract, so owners must still coordinate concurrent updates. Scope removal is never automatic.
149
+ """
150
+ if not plan.scopes or not set(plan.existing_scopes or ()).issubset(plan.scopes):
151
+ raise AgentCliError(
152
+ "Automatic removal of Apps user scopes is unsupported.",
153
+ hint="Remove scopes explicitly in Databricks Apps and verify the effective scopes. "
154
+ "The SDK omits empty lists when serializing App; no removal was sent.",
155
+ )
156
+ desired = App(
157
+ name=plan.name,
158
+ user_api_scopes=list(plan.scopes),
159
+ forward_user_access_token=True if plan.existing_scopes is None else None,
160
+ )
161
+ mask = ["user_api_scopes"]
162
+ if instances is not None:
163
+ desired.compute_min_instances = instances
164
+ desired.compute_max_instances = instances
165
+ mask.extend(["compute_min_instances", "compute_max_instances"])
166
+ try:
167
+ if plan.existing_scopes is None:
168
+ plan.apps.create(desired)
169
+ else:
170
+ current = plan.apps.get(plan.name)
171
+ _validate_forwarding(current)
172
+ _validate_implicit_identity_scopes(current)
173
+ if tuple(sorted(set(current.user_api_scopes or []))) != plan.existing_scopes:
174
+ raise AgentCliError("Apps user scopes changed since preflight; review and retry.")
175
+ if plan.scopes != plan.existing_scopes or instances is not None:
176
+ plan.apps.create_update(plan.name, update_mask=",".join(mask), app=desired).result(
177
+ timeout=timedelta(minutes=5)
178
+ )
179
+ for attempt in range(attempts):
180
+ current = plan.apps.get(plan.name)
181
+ _validate_forwarding(current)
182
+ desired_scopes = set(plan.scopes)
183
+ effective = set(current.effective_user_api_scopes or [])
184
+ if (
185
+ set(current.user_api_scopes or []) == desired_scopes
186
+ and desired_scopes.issubset(effective)
187
+ and effective.issubset(desired_scopes | _IDENTITY_DEFAULT_SCOPES)
188
+ ):
189
+ return
190
+ if attempt + 1 < attempts:
191
+ time.sleep(5)
192
+ except (DatabricksError, TimeoutError) as exc:
193
+ raise AgentCliError(f"Could not reconcile Apps user scopes for '{plan.name}'.") from exc
194
+ raise AgentCliError(
195
+ f"App '{plan.name}' effective user scopes did not converge after {attempts} checks.",
196
+ hint="Source deployment was stopped. Inspect requested/effective scopes in Databricks "
197
+ "Apps; contact your platform administrator if propagation remains blocked. Then retry.",
198
+ )
@@ -0,0 +1,142 @@
1
+ """`ab login` / `logout` — remember an optional Databricks profile.
2
+
3
+ `login` validates a named profile and persists the selection; when credentials are missing or
4
+ rejected in an interactive terminal, it delegates setup to `databricks auth login` and retries.
5
+ The root group falls back to the saved profile whenever `-p` is omitted. Without a saved profile,
6
+ the Databricks SDK performs its normal default authentication resolution. `logout` removes only
7
+ Agent Bricks' saved selection, not the underlying credentials. State lives in a small JSON file under
8
+ `~/.agentbricks` (override the directory with `AGENTBRICKS_CONFIG_HOME`, mainly for tests).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import pathlib
16
+ import subprocess
17
+ import sys
18
+ from typing import Optional
19
+
20
+ import click
21
+
22
+ from databricks_agentbricks import render
23
+ from databricks_agentbricks.errors import AgentCliError
24
+ from databricks_agentkit._api_client import _AgentBricksApiClient
25
+
26
+
27
+ def _config_file() -> pathlib.Path:
28
+ base = os.environ.get("AGENTBRICKS_CONFIG_HOME")
29
+ root = pathlib.Path(base) if base else pathlib.Path.home() / ".agentbricks"
30
+ return root / "config.json"
31
+
32
+
33
+ def load_default_profile() -> Optional[str]:
34
+ """The profile saved by `ab login`, or None if the user never logged in."""
35
+ try:
36
+ return json.loads(_config_file().read_text()).get("profile")
37
+ except (OSError, json.JSONDecodeError):
38
+ return None
39
+
40
+
41
+ def _save_default_profile(profile: str) -> None:
42
+ path = _config_file()
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(json.dumps({"profile": profile}, indent=2) + "\n")
45
+
46
+
47
+ def _validate_profile(profile: str) -> tuple[_AgentBricksApiClient, str]:
48
+ client = _AgentBricksApiClient(profile)
49
+ return client, client.current_user
50
+
51
+
52
+ def _is_interactive() -> bool:
53
+ return sys.stdin.isatty()
54
+
55
+
56
+ def _run_databricks_login(profile: str) -> None:
57
+ command = ["databricks", "auth", "login", "--profile", profile]
58
+ try:
59
+ # Keep the child process interactive while preserving stdout for Agent Bricks JSON output.
60
+ result = subprocess.run(command, text=True, check=False, stdout=sys.stderr)
61
+ except FileNotFoundError as exc:
62
+ raise AgentCliError(
63
+ "Could not configure Databricks authentication: the `databricks` CLI was not found.",
64
+ hint=f"Install the Databricks CLI, then retry `ab login --profile {profile}`.",
65
+ ) from exc
66
+ if result.returncode != 0:
67
+ raise AgentCliError(
68
+ f"`databricks auth login --profile {profile}` failed (exit {result.returncode})."
69
+ )
70
+
71
+
72
+ def _authenticate_profile(profile: str) -> tuple[_AgentBricksApiClient, str]:
73
+ # Local import: pulling databricks.sdk.errors loads the full SDK (~0.7s), which we defer off
74
+ # the CLI startup path. This function already builds a client, so the cost lands here anyway.
75
+ from databricks.sdk.errors import Unauthenticated
76
+
77
+ try:
78
+ return _validate_profile(profile)
79
+ except (AgentCliError, Unauthenticated) as initial_error:
80
+ if not _is_interactive():
81
+ raise AgentCliError(
82
+ f"Could not validate Databricks profile {profile!r}: {initial_error}",
83
+ hint="Run this command in an interactive terminal so Agent Bricks can open "
84
+ "Databricks login, or authenticate first with "
85
+ f"`databricks auth login --profile {profile}`.",
86
+ ) from initial_error
87
+ except Exception as validation_error: # noqa: BLE001 - normalize unexpected API failures
88
+ raise AgentCliError(
89
+ f"Could not validate Databricks profile {profile!r}: {validation_error}"
90
+ ) from validation_error
91
+
92
+ _run_databricks_login(profile)
93
+ try:
94
+ return _validate_profile(profile)
95
+ except Exception as retry_error: # noqa: BLE001 - normalize the post-login failure
96
+ raise AgentCliError(
97
+ f"Databricks login completed, but profile {profile!r} could not be validated: "
98
+ f"{retry_error}"
99
+ ) from retry_error
100
+
101
+
102
+ @click.command()
103
+ @click.option(
104
+ "--profile",
105
+ "-p",
106
+ default=None,
107
+ help="Profile to authenticate with and remember as the default.",
108
+ )
109
+ @click.pass_obj
110
+ def login(obj, profile) -> None:
111
+ """Authenticate a profile and save it as the default, so later commands can omit -p."""
112
+ profile = profile or obj.profile
113
+ if not profile:
114
+ raise AgentCliError(
115
+ "No profile to save.",
116
+ hint="Pass one to remember, e.g. `ab login --profile <profile>`.",
117
+ )
118
+ client, user = _authenticate_profile(profile)
119
+ _save_default_profile(profile)
120
+ if obj.output == "json":
121
+ render.emit_json({"profile": profile, "user": user, "host": client.host})
122
+ return
123
+ render.success(
124
+ f"Logged in as {user}",
125
+ fields={"Profile": profile, "Host": client.host},
126
+ next_steps=[
127
+ ("ab init my-agent", "Scaffold a new agent project"),
128
+ ],
129
+ )
130
+
131
+
132
+ @click.command()
133
+ @click.pass_obj
134
+ def logout(obj) -> None:
135
+ """Forget the saved profile selection without deleting its credentials."""
136
+ path = _config_file()
137
+ existed = path.exists()
138
+ path.unlink(missing_ok=True)
139
+ if obj.output == "json":
140
+ render.emit_json({"logged_out": existed})
141
+ return
142
+ render.success("Logged out" if existed else "No saved login to clear")