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,954 @@
1
+ """`ab deploy` and the `ab deployments` group — manage agent deployments.
2
+
3
+ `ab deploy` is the integrated entry point: it provisions the memory/session stores
4
+ bound in `agent.toml`, grants the app's service principal access to them, then rolls out
5
+ the deployment. Agent Bricks Runtime deployments receive a persistent Runtime Store; a temporary rollout
6
+ switch chooses between the legacy per-app Lakebase project and the service-managed database.
7
+ `agent.toml` is the CLI's authoring source, resolved here into the `AGENT_MEMORY_STORE` /
8
+ `AGENT_SESSION_STORE` env vars written into `app.yaml` — the runtime reads those, never
9
+ `agent.toml`. `ab deployments` covers the lifecycle verbs
10
+ (`list`/`get`/`logs`/`start`/`stop`/`delete`).
11
+
12
+ Deployments run on the Databricks Apps runtime, which this module drives via the
13
+ `databricks apps` CLI — an implementation detail that is not part of the Agent Bricks CLI surface.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import pathlib
20
+ import time
21
+ from dataclasses import dataclass
22
+ from typing import Any, Optional
23
+
24
+ import click
25
+ import yaml
26
+
27
+ import databricks_agentbricks.lakebase_runtime_store as managed_runtime_store
28
+ import databricks_agentbricks.legacy_lakebase_runtime_store as legacy_runtime_store
29
+ from databricks_agentbricks import render
30
+ from databricks_agentbricks.app_resources import (
31
+ apply_experiment_resource,
32
+ apply_postgres_resources,
33
+ )
34
+ from databricks_agentbricks.cli.app_auth import (
35
+ apply_app_user_scope_update,
36
+ plan_app_user_scope_update,
37
+ required_user_api_scopes,
38
+ requires_user_auth,
39
+ )
40
+ from databricks_agentbricks.cli.endpoint_examples import print_agent_invoke_command
41
+ from databricks_agentbricks.cli.tracing import (
42
+ TRACES_EXPERIMENT_ID_ENV,
43
+ TRACES_TRACKING_URI_ENV,
44
+ TRACING_BIND_COMMAND,
45
+ create_experiment_idempotent,
46
+ experiment_url,
47
+ )
48
+ from databricks_agentbricks.databricks_cli import _databricks
49
+ from databricks_agentbricks.errors import AgentCliError
50
+ from databricks_agentbricks.project_config import (
51
+ require_managed_tool_support,
52
+ )
53
+ from databricks_agentbricks.project_types import AgentServer
54
+ from databricks_agentbricks.render import field
55
+ from databricks_agentkit import timefmt
56
+ from databricks_agentkit.runtime.store import (
57
+ RUNTIME_STORE_DATABASE_ENV,
58
+ RUNTIME_STORE_LAKEBASE_BRANCH_ENV,
59
+ RUNTIME_STORE_LAKEBASE_ENDPOINT_ENV,
60
+ RUNTIME_STORE_SCHEMA_ENV,
61
+ RUNTIME_STORE_USERNAME_ENV,
62
+ )
63
+ from databricks_agentkit.runtime.tool_manifest import MEMORY_STORE_ENV, SESSION_STORE_ENV
64
+
65
+ # TEMPORARY: the Apps build environment currently can't reach the internal pypi proxy, so builds
66
+ # time out installing dependencies. Point the build at public PyPI (sanctioned interim workaround)
67
+ # until the proxy is reachable from the build sandbox again, then drop this default. pip reads
68
+ # PIP_INDEX_URL; uv reads UV_INDEX_URL / UV_DEFAULT_INDEX — set all three to cover both build paths.
69
+ _DEFAULT_PIP_INDEX_URL = "https://pypi.org/simple/"
70
+ _PIP_INDEX_ENVS = ("PIP_INDEX_URL", "UV_INDEX_URL", "UV_DEFAULT_INDEX")
71
+ _AGENT_COMPUTE_OUTPUT = ("App compute", "Agent compute")
72
+ # Internal rollout switch. Backend selection is intentionally not part of the user-facing CLI or
73
+ # process environment; flip this only in an Agent Bricks release after the managed API is fully deployed.
74
+ _USE_MANAGED_RUNTIME_STORE = False
75
+
76
+ # Agent Bricks deployments use one public prefix for creation and listing.
77
+ _DEPLOYMENT_PREFIX = "agent-bricks-"
78
+ _AGENTKIT_RUNTIME_STORE_SCHEMA = "databricks_agentkit_runtime"
79
+ _MAX_DEPLOYMENT_NAME_LEN = 30 # Databricks Apps name limit
80
+
81
+
82
+ # --- databricks CLI plumbing (the deployment runtime) -----------------------
83
+
84
+
85
+ def _deployment_exists(name: str, profile: Optional[str]) -> bool:
86
+ return _databricks(["apps", "get", name], profile, capture=True, check=False).returncode == 0
87
+
88
+
89
+ def _app_service_principal(name: str, profile: Optional[str]) -> Optional[str]:
90
+ """The app's service principal client id (its Postgres role identity), or None if unavailable."""
91
+ result = _databricks(["apps", "get", name, "-o", "json"], profile, capture=True, check=False)
92
+ if result.returncode != 0:
93
+ return None
94
+ try:
95
+ return json.loads(result.stdout).get("service_principal_client_id")
96
+ except json.JSONDecodeError:
97
+ return None
98
+
99
+
100
+ def _app_url(name: str, profile: Optional[str]) -> Optional[str]:
101
+ """The deployed app's browsable URL, or None if it can't be read."""
102
+ result = _databricks(["apps", "get", name, "-o", "json"], profile, capture=True, check=False)
103
+ if result.returncode != 0:
104
+ return None
105
+ try:
106
+ return json.loads(result.stdout).get("url") or None
107
+ except json.JSONDecodeError:
108
+ return None
109
+
110
+
111
+ def _app_compute_state(name: str, profile: Optional[str]) -> Optional[str]:
112
+ """The app's compute state (e.g. RUNNING), or None if it can't be read."""
113
+ result = _databricks(["apps", "get", name, "-o", "json"], profile, capture=True, check=False)
114
+ if result.returncode != 0:
115
+ return None
116
+ try:
117
+ return json.loads(result.stdout).get("compute_status", {}).get("state")
118
+ except json.JSONDecodeError:
119
+ return None
120
+
121
+
122
+ def _validate_deployment_name(name: str, *, check_length: bool = True) -> str:
123
+ """Reject an empty or unsafe deployment name before it reaches a URL / workspace path."""
124
+ if (
125
+ not (name or "").strip()
126
+ or name != name.strip()
127
+ or any(token in name for token in ("/", "\\", ".."))
128
+ or any(character.isspace() for character in name)
129
+ ):
130
+ raise AgentCliError(
131
+ f"Invalid deployment name {name!r}.",
132
+ hint="Use a non-empty name of letters, digits, and hyphens "
133
+ "(no slashes, spaces, or '..').",
134
+ )
135
+ if check_length and len(name) > _MAX_DEPLOYMENT_NAME_LEN:
136
+ raise AgentCliError(
137
+ f"Deployment name {name!r} is too long ({len(name)} > {_MAX_DEPLOYMENT_NAME_LEN}).",
138
+ hint=f"Databricks app names cap at {_MAX_DEPLOYMENT_NAME_LEN} characters, including the "
139
+ f"'{_DEPLOYMENT_PREFIX}' prefix Agent Bricks adds on deploy.",
140
+ )
141
+ return name
142
+
143
+
144
+ def _instance_args(instances: Optional[int]) -> list[str]:
145
+ """Build runtime instance arguments from the Agent Bricks fixed-count option."""
146
+ if instances is None:
147
+ return []
148
+ return [
149
+ "--compute-min-instances",
150
+ str(instances),
151
+ "--compute-max-instances",
152
+ str(instances),
153
+ ]
154
+
155
+
156
+ def _prefixed_name(name: str) -> str:
157
+ """Add the Agent Bricks deployment prefix unless it is already present."""
158
+ return name if name.startswith(_DEPLOYMENT_PREFIX) else f"{_DEPLOYMENT_PREFIX}{name}"
159
+
160
+
161
+ def _confirm_destroy(target: str, *, assume_yes: bool) -> None:
162
+ """Prompt before a destructive deployment op; --yes/-y skips it (for scripts)."""
163
+ if assume_yes:
164
+ return
165
+ if not click.confirm(f"{target}? This cannot be undone.", default=False):
166
+ raise click.Abort()
167
+
168
+
169
+ def _wait_for_running(name: str, profile: Optional[str], timeout_s: int = 300) -> None:
170
+ """Block until a just-created app's compute is ACTIVE (or raise on timeout).
171
+
172
+ `apps create` returns before compute is provisioned, but `apps deploy` requires the app to be
173
+ ACTIVE — so a first deploy races without this wait.
174
+ """
175
+ deadline = time.monotonic() + timeout_s
176
+ while time.monotonic() < deadline:
177
+ if _app_compute_state(name, profile) == "ACTIVE":
178
+ return
179
+ time.sleep(5)
180
+ raise AgentCliError(
181
+ f"App '{name}' did not reach a running state within {timeout_s}s.",
182
+ hint=f"Check `ab deployments get {name}`, then re-run deploy once it's running.",
183
+ )
184
+
185
+
186
+ # --- app.yaml manifest handling ---------------------------------------------
187
+
188
+
189
+ def _upsert_manifest_env(
190
+ source: pathlib.Path,
191
+ updates: dict[str, str],
192
+ ) -> bool:
193
+ """Reconcile env entries in <source>/app.yaml. Returns True if it scaffolded a new file."""
194
+ app_yaml = source / "app.yaml"
195
+ if app_yaml.exists():
196
+ loaded = yaml.safe_load(app_yaml.read_text())
197
+ doc: dict[str, Any] = loaded if isinstance(loaded, dict) else {}
198
+ scaffolded = False
199
+ else:
200
+ doc = {"command": ["# TODO: set your run command, e.g. ['uvicorn', 'app:app']"], "env": []}
201
+ scaffolded = True
202
+
203
+ raw_env = doc.get("env")
204
+ candidates = raw_env if isinstance(raw_env, list) else []
205
+ env: list[dict[str, Any]] = [entry for entry in candidates if isinstance(entry, dict)]
206
+ by_name = {e.get("name"): e for e in env if isinstance(e, dict)}
207
+ for name, value in updates.items():
208
+ if name in by_name:
209
+ by_name[name]["value"] = value
210
+ by_name[name].pop("valueFrom", None)
211
+ else:
212
+ env.append({"name": name, "value": value})
213
+ doc["env"] = env
214
+ app_yaml.write_text(yaml.safe_dump(doc, sort_keys=False))
215
+ return scaffolded
216
+
217
+
218
+ # --- store provisioning -----------------------------------------------------
219
+
220
+
221
+ _MEMORY_STORE_PAGE_SIZE = 100 # the memory-stores list API caps page_size at 100
222
+
223
+
224
+ def _resolve_memory_store(client, display_name: str) -> Optional[dict]:
225
+ """Find a memory store by display name, paging through the list, or None if none matches.
226
+
227
+ `get_memory_store` looks up by resource id (`memory-stores/<uuid>`), not the display name users
228
+ pass, so resolving a name means listing and matching on `display_name`. The list API caps
229
+ `page_size` at 100, so page through with the `next_page_token` rather than requesting all at once.
230
+ """
231
+ page_token: Optional[str] = None
232
+ while True:
233
+ listing = client.list_memory_stores(
234
+ page_size=_MEMORY_STORE_PAGE_SIZE, page_token=page_token
235
+ )
236
+ for store in field(listing, "managed_memory_stores") or []:
237
+ if field(store, "display_name") == display_name:
238
+ return store
239
+ page_token = field(listing, "next_page_token")
240
+ if not page_token:
241
+ return None
242
+
243
+
244
+ _LAKEBASE_PERMISSION_DOCS = (
245
+ "https://docs.databricks.com/aws/en/oltp/projects/manage-project-permissions"
246
+ )
247
+
248
+
249
+ def _store_create_permission_error(name: str, kind: str, cause: AgentCliError) -> AgentCliError:
250
+ """PERMISSION_DENIED on create: the workspace admin has restricted Lakebase project creation."""
251
+ return AgentCliError(
252
+ f"You don't have permission to create {kind} store '{name}'.",
253
+ error_code=cause.error_code,
254
+ hint=(
255
+ "Creating a managed store provisions a Lakebase project, which your workspace admin "
256
+ "has restricted. Ask your workspace admin to grant you permission to create Lakebase "
257
+ f"projects ({_LAKEBASE_PERMISSION_DOCS}), or bind an existing store you can access "
258
+ "with --no-create-stores."
259
+ ),
260
+ )
261
+
262
+
263
+ def _store_access_error(name: str, kind: str) -> AgentCliError:
264
+ """The store already exists but isn't accessible to the caller."""
265
+ return AgentCliError(
266
+ f"{kind.capitalize()} store '{name}' already exists but you don't have access to it.",
267
+ hint=(
268
+ "Ask the store's owner or your workspace admin to grant you access, or bind a "
269
+ "different store you can access with --no-create-stores."
270
+ ),
271
+ )
272
+
273
+
274
+ def _ensure_memory_store(client, display_name: str) -> tuple[dict, bool]:
275
+ """Create the memory store, or resolve it if it already exists. Returns (store, created)."""
276
+ try:
277
+ return client.create_memory_store(display_name, retry_transient=True), True
278
+ except AgentCliError as exc:
279
+ if exc.error_code == "PERMISSION_DENIED":
280
+ raise _store_create_permission_error(display_name, "memory", exc) from exc
281
+ if exc.error_code != "ALREADY_EXISTS":
282
+ raise
283
+ store = _resolve_memory_store(client, display_name)
284
+ if store is None:
285
+ # ALREADY_EXISTS but not in the caller's listing: the store isn't accessible to them.
286
+ raise _store_access_error(display_name, "memory")
287
+ return store, False
288
+
289
+
290
+ def _ensure_session_store(client, name: str) -> tuple[dict, bool]:
291
+ """Create the session store, or resolve it if it already exists. Returns (store, created)."""
292
+ try:
293
+ return client.create_session_store(name, retry_transient=True), True
294
+ except AgentCliError as exc:
295
+ if exc.error_code == "PERMISSION_DENIED":
296
+ raise _store_create_permission_error(name, "session", exc) from exc
297
+ if exc.error_code != "ALREADY_EXISTS":
298
+ raise
299
+ try:
300
+ return client.get_session_store(name), False
301
+ except AgentCliError as exc:
302
+ if exc.error_code == "PERMISSION_DENIED":
303
+ raise _store_access_error(name, "session") from exc
304
+ raise
305
+
306
+
307
+ def _load_project(source: pathlib.Path):
308
+ """The AgentProject at `source`, or None when agent.toml is absent."""
309
+ from databricks_agentbricks.agent_project import AgentProject
310
+
311
+ if not (source / "agent.toml").is_file():
312
+ return None
313
+ return AgentProject.load(source)
314
+
315
+
316
+ def resource_bindings(
317
+ source: pathlib.Path,
318
+ ) -> tuple[Optional[str], Optional[str], Optional[str]]:
319
+ """The (memory store, session store, tracing experiment) bound in agent.toml.
320
+
321
+ agent.toml is the single source of truth for an agent's resources. Both `ab dev` and `ab deploy`
322
+ resolve through here so the resource env/notices AND the deploy-time provisioning honor the
323
+ same bindings. A missing agent.toml means nothing is bound; an invalid manifest fails with a clear
324
+ error.
325
+ """
326
+ project = _load_project(source)
327
+ if project is None:
328
+ return None, None, None
329
+ # str(): agent.toml bindings come back as tomlkit strings, which don't serialize to app.yaml.
330
+ memory = str(project.memory_store) if project.memory_store else None
331
+ session = str(project.session_store) if project.session_store else None
332
+ experiment = str(project.trace_experiment_name) if project.trace_experiment_name else None
333
+ return memory, session, experiment
334
+
335
+
336
+ def _resolve_deployment_name(project, name: Optional[str]) -> str:
337
+ """The deployment's base name: the NAME arg if given, else agent.toml's [agent].deployment_name.
338
+
339
+ Errors when neither is available, pointing the user at the one-time `ab deploy <name>`.
340
+ """
341
+ if name is not None and name.strip():
342
+ return name.strip()
343
+ if project is not None and project.deployment_name:
344
+ return str(project.deployment_name)
345
+ raise AgentCliError(
346
+ "No deployment name given and none recorded in agent.toml.",
347
+ hint="Run `ab deploy <name>` once to name the agent; later `ab deploy` can omit it.",
348
+ )
349
+
350
+
351
+ def _reconcile_declared_stores(
352
+ memory_store: Optional[str], session_store: Optional[str], client
353
+ ) -> Optional[str]:
354
+ """Create any store DECLARED in agent.toml that doesn't exist yet; return the memory store's id.
355
+
356
+ `ab deploy` is the only verb that provisions stores. It reconciles to the names declared in
357
+ agent.toml (by `ab init` or `ab memory/sessions bind`) — never inventing a name and never
358
+ writing bindings back into the manifest. A store created here gets a one-line notice. The memory
359
+ store's bare id is returned so the caller can wire AGENT_MEMORY_STORE (the entries API is keyed
360
+ by id, not display name); session stores resolve by name and need nothing here.
361
+ """
362
+ memory_store_id: Optional[str] = None
363
+ if memory_store:
364
+ with render.status(f"Reconciling memory store '{memory_store}'…"):
365
+ resolved, created = _ensure_memory_store(client, memory_store)
366
+ memory_store_id = (field(resolved, "name") or "").split("/", 1)[-1] or None
367
+ if created:
368
+ render.console().print(f"[green]✓[/] Created memory store {memory_store!r}")
369
+ if session_store:
370
+ with render.status(f"Reconciling session store '{session_store}'…"):
371
+ _, created = _ensure_session_store(client, session_store)
372
+ if created:
373
+ render.console().print(f"[green]✓[/] Created session store {session_store!r}")
374
+ return memory_store_id
375
+
376
+
377
+ def get_or_create_trace_experiment(source: pathlib.Path, client, profile) -> Optional[str]:
378
+ """Get-or-create this project's bound MLflow experiment in the ``profile``'s workspace and return
379
+ its id, or None when tracing is unbound (no ``experiment_name`` in agent.toml).
380
+
381
+ Resolves by experiment **name**, never a stored id. ``source`` locates agent.toml. Nothing is
382
+ written back to agent.toml. Raises if the experiment can't be created.
383
+ """
384
+ from databricks_agentbricks.agent_project import (
385
+ AgentProject, # noqa: PLC0415 - avoid import cycle
386
+ )
387
+
388
+ try:
389
+ project = AgentProject.load(source)
390
+ except AgentCliError:
391
+ project = None
392
+ name = project.trace_experiment_name if project is not None else None
393
+ if not name:
394
+ return None
395
+ # Show progress while the experiment is get-or-created (a workspace round-trip), matching the
396
+ # memory/session store reconcile spinners so deploy isn't silent about tracing.
397
+ with render.status(f"Reconciling tracing experiment '{name}'…"):
398
+ return create_experiment_idempotent(profile, client, name)
399
+
400
+
401
+ @dataclass(frozen=True)
402
+ class MlflowTracingConfig:
403
+ """The MLflow config that binds a deployed agent to its workspace experiment.
404
+
405
+ The agent enables tracing when it sees both a destination (the workspace tracking uri) and an
406
+ experiment id; ``env`` renders them as the two env vars wired into app.yaml. (`ab dev` builds
407
+ its own local tracing env instead - see ``cli.tracing.start_local_tracing_server``.)
408
+ """
409
+
410
+ experiment_id: str
411
+ tracking_uri: str = "databricks"
412
+
413
+ def env(self) -> dict[str, str]:
414
+ return {
415
+ TRACES_TRACKING_URI_ENV: self.tracking_uri,
416
+ TRACES_EXPERIMENT_ID_ENV: self.experiment_id,
417
+ }
418
+
419
+
420
+ def mlflow_tracing_config(experiment_id: str) -> MlflowTracingConfig:
421
+ """The tracing config binding a deployed agent to ``experiment_id``."""
422
+ return MlflowTracingConfig(experiment_id=experiment_id)
423
+
424
+
425
+ def _grant_store_access(
426
+ client,
427
+ sp: str,
428
+ session_store: Optional[str],
429
+ memory_store: Optional[str],
430
+ ) -> Optional[str]:
431
+ """Grant the app's service principal read/write on its bound stores, via the managed store API.
432
+
433
+ The conversation-store service owns the (service-managed) store Lakebase, so it provisions the
434
+ SP's role and runs the GRANTs itself. Unlike a direct Lakebase grant, this needs neither store
435
+ ownership nor MANAGE on the store's Lakebase project, so it works for non-admin deployers.
436
+ """
437
+ try:
438
+ if session_store:
439
+ client.grant_session_store_permission(session_store, sp)
440
+ if memory_store:
441
+ store = _resolve_memory_store(client, memory_store)
442
+ if store is None:
443
+ return f"memory store {memory_store!r} could not be resolved."
444
+ client.grant_memory_store_permission(field(store, "name"), sp)
445
+ except AgentCliError as exc:
446
+ return exc.hint or str(exc)
447
+ return None
448
+
449
+
450
+ # --- ab deploy -----------------------------------------------------------
451
+
452
+
453
+ @click.command()
454
+ @click.argument("name", required=False)
455
+ @click.option(
456
+ "--source",
457
+ default=".",
458
+ type=click.Path(exists=True, file_okay=False),
459
+ help="Local source directory for the deployment (containing app.yaml). Defaults to the "
460
+ "current directory.",
461
+ )
462
+ @click.option(
463
+ "--pip-index-url",
464
+ default=_DEFAULT_PIP_INDEX_URL,
465
+ show_default=True,
466
+ help="Base URL of the Python Package Index. Defaults to public PyPI.",
467
+ )
468
+ @click.option(
469
+ "--workspace-path",
470
+ default=None,
471
+ help="Workspace destination for the synced source (defaults to a per-user path).",
472
+ )
473
+ @click.option(
474
+ "--instances",
475
+ type=click.IntRange(min=1, max=5),
476
+ default=None,
477
+ help="Number of deployment instances.",
478
+ )
479
+ @click.option(
480
+ "--allow-user-scope-update",
481
+ is_flag=True,
482
+ help="Allow Agent Bricks to add missing user API scopes to an existing App for tools configured with "
483
+ "auth = 'user'. Once added, later deploys do not need this flag.",
484
+ )
485
+ @click.pass_obj
486
+ def deploy(
487
+ obj,
488
+ name,
489
+ source,
490
+ pip_index_url,
491
+ workspace_path,
492
+ instances,
493
+ allow_user_scope_update,
494
+ ) -> None:
495
+ """Deploy your agent to Databricks Apps and get back a hosted URL to try it.
496
+
497
+ Rolls the agent out to Databricks Apps and prints the URL where you (or anyone you share it with)
498
+ can use it. The deployed agent reaches Databricks model serving through the AI Gateway using the
499
+ app's own identity — no model keys to configure — and `deploy` also reconciles the stores declared
500
+ in agent.toml and wires in any tracing.
501
+
502
+ NAME is recorded in agent.toml on the first deploy, so a later `ab deploy` from the project
503
+ directory can omit it (passing NAME again updates the recorded name). New apps are named
504
+ `agent-bricks-<name>`. Use the full app name with the `ab deployments` commands.
505
+
506
+ Any memory/session store declared in agent.toml (for example, by `ab memory/sessions bind`)
507
+ is created if it doesn't exist yet; agent.toml itself is never modified for stores.
508
+
509
+ Scaling to multiple instances (--instances) uses best-effort sticky routing, so a browser
510
+ session automatically stays on one instance.
511
+
512
+ \b
513
+ API clients that need it must resend a stable UUID in this cookie every request:
514
+ __Host-databricks-app-router=<uuid>
515
+ """
516
+ source_dir = pathlib.Path(source)
517
+ project = _load_project(source_dir)
518
+ if project is not None and project.tools:
519
+ require_managed_tool_support(source_dir)
520
+ user_auth = requires_user_auth(project)
521
+ requested_name = name
522
+ base_name = _resolve_deployment_name(project, name)
523
+ name = _prefixed_name(base_name)
524
+ # Validate the shape before checking the Apps name length.
525
+ _validate_deployment_name(name, check_length=False)
526
+ deployment_exists: Optional[bool] = None
527
+ # A project can store the unprefixed base name in agent.toml. When NAME is omitted, reuse the
528
+ # matching Agent Bricks app if it exists.
529
+ if (
530
+ requested_name is None
531
+ and project is not None
532
+ and project.deployment_name
533
+ and not base_name.startswith(_DEPLOYMENT_PREFIX)
534
+ ):
535
+ new_name_exists = len(name) <= _MAX_DEPLOYMENT_NAME_LEN and _deployment_exists(
536
+ name, obj.profile
537
+ )
538
+ deployment_exists = new_name_exists
539
+ _validate_deployment_name(name)
540
+ if allow_user_scope_update and not user_auth:
541
+ raise AgentCliError(
542
+ "--allow-user-scope-update requires a managed tool with auth = 'user' in agent.toml."
543
+ )
544
+ # A request-user tool cannot use OBO until the App forwards request credentials and grants every
545
+ # required user API scope. New Apps are configured automatically. For an existing App, adding a
546
+ # missing scope requires --allow-user-scope-update; already-configured Apps need no flag.
547
+ user_scope_plan = (
548
+ plan_app_user_scope_update(
549
+ name,
550
+ obj.profile,
551
+ allow_existing_app_update=allow_user_scope_update,
552
+ required_scopes=required_user_api_scopes(project),
553
+ )
554
+ if user_auth
555
+ else None
556
+ )
557
+ if user_scope_plan is not None:
558
+ click.echo(
559
+ "User auth: scope updates are not atomic; coordinate with other App owners. "
560
+ "Users may need to sign out and re-consent after scope changes. "
561
+ "Scopes are never removed automatically when tools change.",
562
+ err=True,
563
+ )
564
+ if deployment_exists is None:
565
+ deployment_exists = user_scope_plan.existing_scopes is not None
566
+ apply_app_user_scope_update(user_scope_plan, instances=instances)
567
+ # Persist the base name so a later `ab deploy` (no NAME) resolves to the same app.
568
+ if project is not None and project.set_deployment_name(base_name):
569
+ project.write()
570
+ instance_args = _instance_args(instances)
571
+ client = obj.client()
572
+ use_managed_runtime_store = _USE_MANAGED_RUNTIME_STORE
573
+
574
+ # 1. Reconcile the stores DECLARED in agent.toml: create any that don't exist yet. `ab deploy`
575
+ # is the only reconcile-to-cloud verb; agent.toml is the source of truth and is never rewritten.
576
+ memory_store, session_store, _ = resource_bindings(source_dir)
577
+ memory_store_id = _reconcile_declared_stores(memory_store, session_store, client)
578
+
579
+ # 2. Provision tracing when bound (`ab init` binds a default experiment): get-or-create the
580
+ # experiment NAME from agent.toml and wire the two env vars the runtime reads. Resolved by name,
581
+ # never a stored id, and nothing is written back to agent.toml. (`ab dev` traces to a local
582
+ # MLflow server instead and never touches this workspace experiment.) The app's SP is granted
583
+ # write access to it in step 5 (an experiment app resource). Best-effort: if it can't be set up
584
+ # (no mlflow, offline, permission), the deploy still proceeds without tracing.
585
+ trace_experiment_id: Optional[str] = None
586
+ trace_setup_error: Optional[str] = None
587
+ try:
588
+ trace_experiment_id = get_or_create_trace_experiment(source_dir, client, obj.profile)
589
+ except Exception as exc: # noqa: BLE001 - tracing is best-effort; never block a deploy
590
+ trace_setup_error = str(exc)
591
+ env_updates: dict[str, str] = {}
592
+ provisioned: dict[str, Any] = {}
593
+ if memory_store:
594
+ provisioned["Memory store"] = memory_store
595
+ if session_store:
596
+ provisioned["Session store"] = session_store
597
+ if trace_experiment_id:
598
+ env_updates.update(mlflow_tracing_config(trace_experiment_id).env())
599
+ provisioned["Traces"] = (
600
+ experiment_url(client.host, trace_experiment_id) or trace_experiment_id
601
+ )
602
+ # Known caveat (pre-existing): `_upsert_manifest_env` is upsert-only, so unbinding tracing and
603
+ # redeploying leaves the previous MLFLOW_EXPERIMENT_ID in app.yaml - deploy adds env but never
604
+ # prunes it. A fresh (never-bound) deploy is clean; pruning stale resource env on redeploy is a
605
+ # separate follow-up.
606
+ if memory_store_id:
607
+ env_updates[MEMORY_STORE_ENV] = memory_store_id
608
+ if session_store:
609
+ env_updates[SESSION_STORE_ENV] = session_store
610
+
611
+ legacy_runtime_backend = None
612
+ if (
613
+ project is not None
614
+ and project.server == AgentServer.AGENTBRICKS
615
+ and not use_managed_runtime_store
616
+ ):
617
+ with render.status("Reconciling Runtime Store…"):
618
+ legacy_runtime_backend = legacy_runtime_store.get_or_create_backend(name, obj.profile)
619
+ env_updates[RUNTIME_STORE_LAKEBASE_ENDPOINT_ENV] = legacy_runtime_backend.endpoint_path
620
+ env_updates[RUNTIME_STORE_SCHEMA_ENV] = legacy_runtime_backend.schema
621
+ if pip_index_url:
622
+ for env in _PIP_INDEX_ENVS:
623
+ env_updates[env] = pip_index_url
624
+ provisioned["Package index"] = pip_index_url
625
+ if instances is not None:
626
+ provisioned["Instances"] = str(instances)
627
+
628
+ # 3. Patch app.yaml before creating the app. The managed Runtime Store fields are added after
629
+ # app creation because that API requires the app's service principal.
630
+ scaffolded = False
631
+ if env_updates:
632
+ scaffolded = _upsert_manifest_env(source_dir, env_updates)
633
+
634
+ # 4. Ensure the app exists and its compute is active. Create only when new; the compute wait
635
+ # runs every deploy.
636
+ #
637
+ # `apps create` itself blocks for minutes (it provisions and waits for compute) and we capture
638
+ # its output to relabel "App compute" → "Agent compute", so nothing streams meanwhile. Wrap it
639
+ # in progress (persistent line + spinner) so the CLI isn't silent for the whole provision.
640
+ if deployment_exists is None:
641
+ deployment_exists = _deployment_exists(name, obj.profile)
642
+ if user_scope_plan is None and not deployment_exists:
643
+ with render.progress(
644
+ "Creating the agent and starting its compute (this can take a few minutes)…"
645
+ ):
646
+ result = _databricks(
647
+ ["apps", "create", name, *instance_args],
648
+ obj.profile,
649
+ capture=True,
650
+ action=f"Could not create deployment '{name}'.",
651
+ )
652
+ old, new = _AGENT_COMPUTE_OUTPUT
653
+ click.echo((result.stdout or "").replace(old, new), nl=False)
654
+ elif user_scope_plan is None and instance_args:
655
+ update = {
656
+ "app": {
657
+ "compute_min_instances": instances,
658
+ "compute_max_instances": instances,
659
+ },
660
+ "update_mask": "compute_min_instances,compute_max_instances",
661
+ }
662
+ result = _databricks(
663
+ ["apps", "create-update", name, "--json", json.dumps(update)],
664
+ obj.profile,
665
+ capture=True,
666
+ action=f"Could not update deployment '{name}'.",
667
+ )
668
+ old, new = _AGENT_COMPUTE_OUTPUT
669
+ click.echo((result.stdout or "").replace(old, new), nl=False)
670
+ # `apps deploy` requires the app's compute to be ACTIVE — a just-created app may still be
671
+ # starting, and an existing one may be STOPPED — so wait either way. Returns immediately when
672
+ with render.progress("Waiting for agent compute to start (this can take a few minutes)…"):
673
+ _wait_for_running(name, obj.profile)
674
+
675
+ if legacy_runtime_backend is not None:
676
+ resource_error = apply_postgres_resources(name, [legacy_runtime_backend], obj.profile)
677
+ if resource_error:
678
+ raise AgentCliError(
679
+ "Could not attach the Lakebase resource required for the Runtime Store.",
680
+ hint=resource_error,
681
+ )
682
+ elif project is not None and project.server == AgentServer.AGENTBRICKS:
683
+ app_service_principal_id = _app_service_principal(name, obj.profile)
684
+ with render.status("Reconciling Runtime Store…"):
685
+ runtime_backend = managed_runtime_store.get_or_create_backend(
686
+ client, name, app_service_principal_id
687
+ )
688
+ managed_env = {
689
+ RUNTIME_STORE_LAKEBASE_BRANCH_ENV: runtime_backend.branch,
690
+ RUNTIME_STORE_DATABASE_ENV: runtime_backend.database_id,
691
+ RUNTIME_STORE_USERNAME_ENV: runtime_backend.username,
692
+ }
693
+ if not deployment_exists and name.startswith(_DEPLOYMENT_PREFIX):
694
+ managed_env[RUNTIME_STORE_SCHEMA_ENV] = _AGENTKIT_RUNTIME_STORE_SCHEMA
695
+ scaffolded = _upsert_manifest_env(source_dir, managed_env) or scaffolded
696
+ env_updates.update(managed_env)
697
+
698
+ # 5. Upload the source and roll out the deployment.
699
+ ws_path = (
700
+ workspace_path or f"/Workspace/Users/{client.current_user}/agentbricks_deployments/{name}"
701
+ )
702
+ # Don't ship uv.lock: it pins exact package URLs from whatever index the developer's machine
703
+ # resolved against (often an internal proxy). The Apps build must resolve against its own
704
+ # configured index, so let it lock fresh in-sandbox instead of inheriting the local lock.
705
+ _databricks(
706
+ ["sync", str(source_dir), ws_path, "--exclude", "uv.lock"],
707
+ obj.profile,
708
+ action=f"Could not upload the agent source for '{name}'.",
709
+ )
710
+ _databricks(
711
+ ["apps", "deploy", name, "--source-code-path", ws_path],
712
+ obj.profile,
713
+ action=f"Could not deploy '{name}'.",
714
+ )
715
+
716
+ # 6. Grant the app's service principal what it needs to run (best-effort):
717
+ # - stores: grant the SP read/write via the managed store API (the store service does the
718
+ # underlying Lakebase grant, so no store ownership / Lakebase MANAGE is required here);
719
+ # - tracing: bind the experiment as an `experiment` resource (CAN_EDIT) so it can write traces.
720
+ # The experiment resource is the platform-managed grant — no manual SQL grant needed.
721
+ grants_stores = bool(session_store or memory_store)
722
+ grant_error: Optional[str] = None
723
+ if grants_stores:
724
+ with render.status("Granting the app access to its stores…"):
725
+ sp = _app_service_principal(name, obj.profile)
726
+ if sp is None:
727
+ grant_error = "could not resolve the app's service principal."
728
+ else:
729
+ grant_error = _grant_store_access(client, sp, session_store, memory_store)
730
+ trace_grant_error: Optional[str] = None
731
+ if trace_experiment_id:
732
+ with render.status("Granting the app access to its trace experiment…"):
733
+ trace_grant_error = apply_experiment_resource(name, trace_experiment_id, obj.profile)
734
+
735
+ app_url = _app_url(name, obj.profile)
736
+
737
+ if obj.output == "json":
738
+ render.emit_json(
739
+ {
740
+ "deployment": name,
741
+ "url": app_url,
742
+ "workspace_path": ws_path,
743
+ "env": env_updates,
744
+ "trace_experiment_id": trace_experiment_id,
745
+ "trace_setup_error": trace_setup_error,
746
+ "trace_grant": None
747
+ if not trace_experiment_id
748
+ else ("granted" if trace_grant_error is None else "failed"),
749
+ "trace_grant_error": trace_grant_error,
750
+ "store_grant": "skipped"
751
+ if not grants_stores
752
+ else ("granted" if grant_error is None else "failed"),
753
+ "store_grant_error": grant_error,
754
+ }
755
+ )
756
+ return
757
+
758
+ steps: list[str | tuple[str, str]] = [
759
+ (f"ab deployments get {name}", "Check its status and URL"),
760
+ (f"ab deployments logs {name}", "Tail its logs"),
761
+ ]
762
+ if app_url:
763
+ steps.insert(0, f"Open the deployed agent: {app_url}")
764
+ if scaffolded:
765
+ steps.insert(
766
+ 0, f"Set a real `command:` in {source_dir / 'app.yaml'} (a placeholder was written)"
767
+ )
768
+ if grants_stores and grant_error is not None:
769
+ steps.insert(
770
+ 0,
771
+ "The app's service principal needs read/write on its store tables; that grant couldn't "
772
+ "be applied automatically (it requires store ownership). "
773
+ f"Cause: {grant_error}",
774
+ )
775
+ if trace_experiment_id is None:
776
+ # Deployed without tracing - either unbound, or a bound experiment that couldn't be set up.
777
+ # Tell the developer (in case it wasn't intended) and point at `ab tracing bind`; append the
778
+ # cause when setup actually failed.
779
+ step = (
780
+ "Deployed without tracing. "
781
+ f"Run `{TRACING_BIND_COMMAND}` and redeploy to trace this agent."
782
+ )
783
+ if trace_setup_error is not None:
784
+ step += f" (Tracing setup failed: {trace_setup_error})"
785
+ steps.insert(0, step)
786
+ if trace_experiment_id and trace_grant_error is not None:
787
+ steps.insert(
788
+ 0,
789
+ "The app's service principal needs write access to its trace experiment; that grant "
790
+ f"couldn't be applied automatically. Cause: {trace_grant_error}",
791
+ )
792
+ if grants_stores and grant_error is None:
793
+ provisioned["Store access"] = "granted to app service principal"
794
+ if trace_experiment_id and trace_grant_error is None:
795
+ provisioned["Trace access"] = "granted to app service principal"
796
+ fields = {"URL": app_url} if app_url else {}
797
+ fields.update({"Workspace path": ws_path, **provisioned})
798
+ render.success(
799
+ f"Deployed agent '{name}'",
800
+ fields=fields,
801
+ next_steps=steps,
802
+ )
803
+ print_agent_invoke_command(
804
+ name, uses_runtime_api=bool(project and project.server == AgentServer.AGENTBRICKS)
805
+ )
806
+
807
+
808
+ # --- ab deployments <lifecycle> ------------------------------------------
809
+
810
+
811
+ @click.group()
812
+ def deployments() -> None:
813
+ """Inspect and manage deployed agents: list, get, stream logs, start, stop, or delete."""
814
+
815
+
816
+ def _deployment_status(a: dict) -> Optional[str]:
817
+ for key in ("app_status", "compute_status"):
818
+ section = a.get(key)
819
+ if isinstance(section, dict) and field(section, "state"):
820
+ return field(section, "state")
821
+ return field(a, "state")
822
+
823
+
824
+ @deployments.command("list")
825
+ @click.pass_obj
826
+ def deployments_list(obj) -> None:
827
+ """List Agent Bricks deployments (apps named `agent-bricks-*`)."""
828
+ result = _databricks(
829
+ ["apps", "list", "-o", "json"],
830
+ obj.profile,
831
+ capture=True,
832
+ action="Could not list agent deployments.",
833
+ )
834
+ data = json.loads(result.stdout or "[]")
835
+ items = data.get("apps", data) if isinstance(data, dict) else data
836
+ items = [a for a in items if str(field(a, "name") or "").startswith(_DEPLOYMENT_PREFIX)]
837
+ if obj.output == "json":
838
+ render.emit_json(items)
839
+ return
840
+ rows = [
841
+ [
842
+ render.hyperlink(field(a, "name"), field(a, "url")),
843
+ render.status_pill(_deployment_status(a)),
844
+ timefmt.relative(field(a, "update_time")),
845
+ ]
846
+ for a in items
847
+ ]
848
+ render.resource_table(
849
+ "Agent Deployments",
850
+ [("Name", "left"), ("Status", "left"), ("Updated", "left")],
851
+ rows,
852
+ )
853
+
854
+
855
+ @deployments.command("get")
856
+ @click.argument("name")
857
+ @click.pass_obj
858
+ def deployments_get(obj, name) -> None:
859
+ """Get an agent deployment's details."""
860
+ _validate_deployment_name(name)
861
+ result = _databricks(
862
+ ["apps", "get", name, "-o", "json"],
863
+ obj.profile,
864
+ capture=True,
865
+ action=f"Could not read deployment '{name}'.",
866
+ )
867
+ data = json.loads(result.stdout or "{}")
868
+ if obj.output == "json":
869
+ render.emit_json(data)
870
+ return
871
+ url = field(data, "url")
872
+ render.detail(
873
+ "Agent Deployment",
874
+ field(data, "name") or name,
875
+ {
876
+ "URL": render.hyperlink(url, url) if url else None,
877
+ "Description": field(data, "description"),
878
+ "Created": timefmt.absolute(field(data, "create_time")),
879
+ "Updated": timefmt.absolute(field(data, "update_time")),
880
+ },
881
+ status=_deployment_status(data),
882
+ snippets=[("open", "bash", f"open {url}")] if url else None,
883
+ )
884
+
885
+
886
+ @deployments.command("logs")
887
+ @click.argument("name")
888
+ @click.pass_obj
889
+ def deployments_logs(obj, name) -> None:
890
+ """Stream a deployment's logs."""
891
+ _validate_deployment_name(name)
892
+ _databricks(["apps", "logs", name], obj.profile, action=f"Could not read logs for '{name}'.")
893
+
894
+
895
+ @deployments.command("start")
896
+ @click.argument("name")
897
+ @click.pass_obj
898
+ def deployments_start(obj, name) -> None:
899
+ """Start a deployment."""
900
+ _validate_deployment_name(name)
901
+ _databricks(
902
+ ["apps", "start", name], obj.profile, action=f"Could not start deployment '{name}'."
903
+ )
904
+ if obj.output == "json":
905
+ render.emit_json({"started": name})
906
+ return
907
+ render.success(f"Started deployment '{name}'")
908
+
909
+
910
+ @deployments.command("stop")
911
+ @click.argument("name")
912
+ @click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
913
+ @click.pass_obj
914
+ def deployments_stop(obj, name, yes) -> None:
915
+ """Stop a deployment."""
916
+ _validate_deployment_name(name)
917
+ _confirm_destroy(f"Stop deployment '{name}'", assume_yes=yes)
918
+ _databricks(["apps", "stop", name], obj.profile, action=f"Could not stop deployment '{name}'.")
919
+ if obj.output == "json":
920
+ render.emit_json({"stopped": name})
921
+ return
922
+ render.success(f"Stopped deployment '{name}'")
923
+
924
+
925
+ @deployments.command("delete")
926
+ @click.argument("name")
927
+ @click.option("--yes", "-y", is_flag=True, help="Skip the confirmation prompt.")
928
+ @click.pass_obj
929
+ def deployments_delete(obj, name, yes) -> None:
930
+ """Delete a deployment and, when managed provisioning is enabled, its Runtime Store."""
931
+ _validate_deployment_name(name)
932
+ use_managed_runtime_store = _USE_MANAGED_RUNTIME_STORE
933
+ target = (
934
+ f"Delete deployment '{name}' and its Runtime Store data"
935
+ if use_managed_runtime_store
936
+ else f"Delete deployment '{name}'"
937
+ )
938
+ _confirm_destroy(target, assume_yes=yes)
939
+ if use_managed_runtime_store:
940
+ app_service_principal_id = _app_service_principal(name, obj.profile)
941
+ if not app_service_principal_id:
942
+ raise AgentCliError(
943
+ "Could not resolve the app's service principal for Runtime Store cleanup.",
944
+ hint="The deployment was retained. Check access to the app and retry deletion.",
945
+ )
946
+ with render.status("Deleting Runtime Store…"):
947
+ managed_runtime_store.delete(obj.client(), name, app_service_principal_id)
948
+ _databricks(
949
+ ["apps", "delete", name], obj.profile, action=f"Could not delete deployment '{name}'."
950
+ )
951
+ if obj.output == "json":
952
+ render.emit_json({"deleted": name})
953
+ return
954
+ render.success(f"Deleted deployment '{name}'")