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.
- databricks_agentbricks/__init__.py +1 -0
- databricks_agentbricks/_group.py +217 -0
- databricks_agentbricks/agent_project.py +657 -0
- databricks_agentbricks/app_resources.py +139 -0
- databricks_agentbricks/cli/__init__.py +12 -0
- databricks_agentbricks/cli/app.py +108 -0
- databricks_agentbricks/cli/app_auth.py +198 -0
- databricks_agentbricks/cli/auth.py +142 -0
- databricks_agentbricks/cli/deploy.py +954 -0
- databricks_agentbricks/cli/dev.py +310 -0
- databricks_agentbricks/cli/endpoint.py +145 -0
- databricks_agentbricks/cli/endpoint_examples.py +33 -0
- databricks_agentbricks/cli/endpoint_output.py +53 -0
- databricks_agentbricks/cli/endpoint_request.py +71 -0
- databricks_agentbricks/cli/endpoint_transport.py +148 -0
- databricks_agentbricks/cli/help.py +467 -0
- databricks_agentbricks/cli/init.py +496 -0
- databricks_agentbricks/cli/mcp.py +75 -0
- databricks_agentbricks/cli/memory.py +520 -0
- databricks_agentbricks/cli/sandbox.py +823 -0
- databricks_agentbricks/cli/sessions.py +588 -0
- databricks_agentbricks/cli/tools.py +456 -0
- databricks_agentbricks/cli/tracing.py +724 -0
- databricks_agentbricks/databricks_cli.py +38 -0
- databricks_agentbricks/errors.py +114 -0
- databricks_agentbricks/lakebase_runtime_store.py +107 -0
- databricks_agentbricks/legacy_lakebase_runtime_store.py +74 -0
- databricks_agentbricks/project_config.py +157 -0
- databricks_agentbricks/project_types.py +55 -0
- databricks_agentbricks/py.typed +0 -0
- databricks_agentbricks/render.py +349 -0
- databricks_agentbricks/templates/agent-bricks-migrate/SKILL.md +101 -0
- databricks_agentbricks/templates/agent-langgraph/.env.example +36 -0
- databricks_agentbricks/templates/agent-langgraph/.gitignore +199 -0
- databricks_agentbricks/templates/agent-langgraph/AGENTKIT_CONTRACT.md +95 -0
- databricks_agentbricks/templates/agent-langgraph/AGENTS.md +31 -0
- databricks_agentbricks/templates/agent-langgraph/CLAUDE.md +1 -0
- databricks_agentbricks/templates/agent-langgraph/README.md +141 -0
- databricks_agentbricks/templates/agent-langgraph/agent/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/agent/agent.py +177 -0
- databricks_agentbricks/templates/agent-langgraph/agent/mcps.py +27 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/__init__.py +24 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/sample_tool.py +15 -0
- databricks_agentbricks/templates/agent-langgraph/agent/tools/send_message.py +17 -0
- databricks_agentbricks/templates/agent-langgraph/app.yaml +15 -0
- databricks_agentbricks/templates/agent-langgraph/pyproject.toml +42 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/adapter.py +144 -0
- databricks_agentbricks/templates/agent-langgraph/runtime/main.py +25 -0
- databricks_agentbricks/templates/agent-langgraph/tests/__init__.py +0 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_agent.py +211 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_runtime.py +19 -0
- databricks_agentbricks/templates/agent-langgraph/tests/test_workspace.py +28 -0
- databricks_agentbricks/templates/agent-openai/.env.example +37 -0
- databricks_agentbricks/templates/agent-openai/.gitignore +199 -0
- databricks_agentbricks/templates/agent-openai/AGENTKIT_CONTRACT.md +107 -0
- databricks_agentbricks/templates/agent-openai/AGENTS.md +76 -0
- databricks_agentbricks/templates/agent-openai/CLAUDE.md +1 -0
- databricks_agentbricks/templates/agent-openai/README.md +143 -0
- databricks_agentbricks/templates/agent-openai/agent/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/agent/agent.py +205 -0
- databricks_agentbricks/templates/agent-openai/agent/mcps.py +28 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/__init__.py +24 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/sample_tool.py +15 -0
- databricks_agentbricks/templates/agent-openai/agent/tools/send_message.py +18 -0
- databricks_agentbricks/templates/agent-openai/app.yaml +15 -0
- databricks_agentbricks/templates/agent-openai/pyproject.toml +42 -0
- databricks_agentbricks/templates/agent-openai/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/runtime/adapter.py +188 -0
- databricks_agentbricks/templates/agent-openai/runtime/main.py +25 -0
- databricks_agentbricks/templates/agent-openai/tests/__init__.py +0 -0
- databricks_agentbricks/templates/agent-openai/tests/test_agent.py +326 -0
- databricks_agentbricks/templates/agent-openai/tests/test_runtime.py +19 -0
- databricks_agentbricks/templates/agent-openai/tests/test_workspace.py +28 -0
- databricks_agentbricks/templates/custom-agent-langgraph/.env.example +1 -0
- databricks_agentbricks/templates/custom-agent-langgraph/.gitignore +11 -0
- databricks_agentbricks/templates/custom-agent-langgraph/README.md +22 -0
- databricks_agentbricks/templates/custom-agent-langgraph/agent/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-langgraph/agent/agent.py +27 -0
- databricks_agentbricks/templates/custom-agent-langgraph/app.yaml +1 -0
- databricks_agentbricks/templates/custom-agent-langgraph/pyproject.toml +38 -0
- databricks_agentbricks/templates/custom-agent-langgraph/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-langgraph/runtime/main.py +31 -0
- databricks_agentbricks/templates/custom-agent-langgraph/tests/test_runtime.py +29 -0
- databricks_agentbricks/templates/custom-agent-openai/.env.example +1 -0
- databricks_agentbricks/templates/custom-agent-openai/.gitignore +11 -0
- databricks_agentbricks/templates/custom-agent-openai/README.md +22 -0
- databricks_agentbricks/templates/custom-agent-openai/agent/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-openai/agent/agent.py +29 -0
- databricks_agentbricks/templates/custom-agent-openai/app.yaml +1 -0
- databricks_agentbricks/templates/custom-agent-openai/pyproject.toml +38 -0
- databricks_agentbricks/templates/custom-agent-openai/runtime/__init__.py +0 -0
- databricks_agentbricks/templates/custom-agent-openai/runtime/main.py +31 -0
- databricks_agentbricks/templates/custom-agent-openai/tests/test_runtime.py +29 -0
- databricks_agentbricks/templates/sandbox_mcp.py +39 -0
- databricks_agentbricks/templates/sandbox_mcp_langgraph.py +39 -0
- databricks_agentbricks/templates/ui/agent-langgraph/CHAT_APP.md +49 -0
- databricks_agentbricks/templates/ui/agent-langgraph/runtime/main.py +26 -0
- databricks_agentbricks/templates/ui/agent-langgraph/runtime/ui.py +528 -0
- databricks_agentbricks/templates/ui/agent-langgraph/tests/test_demo_ui.py +542 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/agentbricks-mark.png +0 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/app.js +1464 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/index.html +281 -0
- databricks_agentbricks/templates/ui/agent-langgraph/ui/styles.css +1913 -0
- databricks_agentbricks/templates/ui/agent-openai/CHAT_APP.md +54 -0
- databricks_agentbricks/templates/ui/agent-openai/runtime/main.py +26 -0
- databricks_agentbricks/templates/ui/agent-openai/runtime/ui.py +521 -0
- databricks_agentbricks/templates/ui/agent-openai/tests/test_demo_ui.py +512 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/agentbricks-mark.png +0 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/app.js +1428 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/index.html +281 -0
- databricks_agentbricks/templates/ui/agent-openai/ui/styles.css +1913 -0
- databricks_agentbricks/theme.py +72 -0
- databricks_agentbricks-0.2.0.dist-info/METADATA +897 -0
- databricks_agentbricks-0.2.0.dist-info/RECORD +159 -0
- databricks_agentbricks-0.2.0.dist-info/WHEEL +4 -0
- databricks_agentbricks-0.2.0.dist-info/entry_points.txt +2 -0
- databricks_agentbricks-0.2.0.dist-info/licenses/NOTICE +39 -0
- databricks_agentkit/__init__.py +67 -0
- databricks_agentkit/_api_client.py +673 -0
- databricks_agentkit/_pagination.py +25 -0
- databricks_agentkit/client.py +31 -0
- databricks_agentkit/langgraph/__init__.py +98 -0
- databricks_agentkit/langgraph/genie.py +50 -0
- databricks_agentkit/langgraph/mcp.py +216 -0
- databricks_agentkit/langgraph/memory.py +67 -0
- databricks_agentkit/langgraph/session_store.py +485 -0
- databricks_agentkit/memory_store.py +371 -0
- databricks_agentkit/models.py +330 -0
- databricks_agentkit/openai/__init__.py +93 -0
- databricks_agentkit/openai/genie.py +50 -0
- databricks_agentkit/openai/mcp.py +189 -0
- databricks_agentkit/openai/memory.py +67 -0
- databricks_agentkit/openai/sessions.py +157 -0
- databricks_agentkit/py.typed +0 -0
- databricks_agentkit/runtime/ARCHITECTURE.md +64 -0
- databricks_agentkit/runtime/README.md +184 -0
- databricks_agentkit/runtime/__init__.py +69 -0
- databricks_agentkit/runtime/app.py +316 -0
- databricks_agentkit/runtime/auth.py +149 -0
- databricks_agentkit/runtime/durability/__init__.py +1 -0
- databricks_agentkit/runtime/durability/execution.py +133 -0
- databricks_agentkit/runtime/durability/heartbeat.py +64 -0
- databricks_agentkit/runtime/durability/lakebase_runtime_store.py +648 -0
- databricks_agentkit/runtime/durability/recovery.py +70 -0
- databricks_agentkit/runtime/durability/store.py +33 -0
- databricks_agentkit/runtime/execution.py +160 -0
- databricks_agentkit/runtime/genie.py +224 -0
- databricks_agentkit/runtime/mcp_auth.py +114 -0
- databricks_agentkit/runtime/model_services.py +90 -0
- databricks_agentkit/runtime/runtime.py +273 -0
- databricks_agentkit/runtime/session_store_client.py +120 -0
- databricks_agentkit/runtime/store.py +287 -0
- databricks_agentkit/runtime/tool_manifest.py +223 -0
- databricks_agentkit/runtime/tracing.py +71 -0
- databricks_agentkit/runtime/types.py +118 -0
- databricks_agentkit/runtime/workspace.py +27 -0
- databricks_agentkit/session_store.py +434 -0
- 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}'")
|