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,724 @@
1
+ """`ab tracing` — send an agent's traces to an MLflow experiment and inspect them.
2
+
3
+ ``ab dev`` runs a **local, sqlite-backed MLflow server** (:func:`start_local_tracing_server`) so
4
+ traces stay on the machine with no workspace setup, viewable in the local MLflow UI. ``ab deploy``
5
+ sends traces to a per-project **workspace** experiment whose **name** lives in agent.toml (a default
6
+ ``/Shared/agentbricks_traces/<project>`` is written at ``ab init``); deploy get-or-creates it in the active
7
+ workspace and wires its ``MLFLOW_EXPERIMENT_ID`` (which binds the agent, grants the deployed app via an
8
+ experiment app resource, reads traces, and builds the UI link). Storing the name (not the id) keeps
9
+ the binding valid across workspaces and profiles, since an id is workspace-local.
10
+
11
+ The bound ``experiment_name``'s presence IS the enable switch: a bound name means tracing is on for
12
+ deploy, its absence means off. ``ab tracing bind`` binds an experiment (by name or id, one
13
+ required); ``ab tracing unbind`` removes the binding; ``list`` / ``get`` read traces back.
14
+
15
+ MLflow (``mlflow-skinny``) is a base dependency, but the ``ab tracing`` commands and the deploy
16
+ experiment provisioning still import it lazily — ``cli.py`` imports this module at startup, so a
17
+ top-level import would pay mlflow's heavy import cost on every Agent Bricks CLI command.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import pathlib
23
+ import re
24
+ import socket
25
+ import subprocess
26
+ import time
27
+ from collections.abc import Iterator
28
+ from contextlib import contextmanager
29
+ from dataclasses import dataclass
30
+ from typing import Any, Optional
31
+
32
+ import click
33
+
34
+ from databricks_agentbricks import render
35
+ from databricks_agentbricks.errors import AgentCliError
36
+ from databricks_agentkit import timefmt
37
+
38
+ _BREADCRUMB = "Agent Tracing"
39
+ # Per-app experiment folder under /Shared: username-free (so `ab init` can name it offline) and
40
+ # workspace-independent (so the same name is valid in whatever workspace the active profile targets).
41
+ _TRACES_DIR = "agentbricks_traces"
42
+
43
+ # The two env vars the deployed/dev agent reads to enable tracing: a destination (the workspace) and
44
+ # an experiment (by id). MLflow turns tracing on only when it has both.
45
+ TRACES_TRACKING_URI_ENV = "MLFLOW_TRACKING_URI"
46
+ TRACES_EXPERIMENT_ID_ENV = "MLFLOW_EXPERIMENT_ID"
47
+
48
+ # The command that binds (enables) tracing. Referenced parameter-free by deploy's "deployed without
49
+ # tracing" guidance and by `unbind`'s "turn it back on" step, so those hints can't go stale if the
50
+ # flags change; the command's own `--help` documents the flags.
51
+ TRACING_BIND_COMMAND = "ab tracing bind"
52
+
53
+
54
+ def default_experiment_name(project: Optional[str], token: Optional[str] = None) -> str:
55
+ """The default MLflow experiment path for a project: ``/Shared/agentbricks_traces/<project>[-<token>]``.
56
+
57
+ Under ``/Shared`` (not a user home) so it needs no username — ``ab init`` writes it offline —
58
+ and it's workspace-independent, so the same name is valid in whatever workspace the active profile
59
+ targets (``ab deploy`` get-or-creates it there). ``project`` is the Agent Bricks project name (the
60
+ source directory's basename); an optional per-scaffold ``token`` (shared with the default store
61
+ names) keeps like-named projects from colliding in the shared ``/Shared`` namespace.
62
+ """
63
+ if not project:
64
+ raise AgentCliError("Cannot derive the default tracing experiment without a project name.")
65
+ slug = re.sub(r"[^a-z0-9-]+", "-", project.lower()).strip("-") or "agent"
66
+ middle = f"-{token}" if token else ""
67
+ return f"/Shared/{_TRACES_DIR}/{slug}{middle}"
68
+
69
+
70
+ def experiment_url(host: Optional[str], experiment_id: str) -> Optional[str]:
71
+ """The workspace MLflow experiment Traces page, or None when the host is unavailable."""
72
+ if not host or host == "unknown":
73
+ return None
74
+ return f"{host.rstrip('/')}/ml/experiments/{experiment_id}?compareRunsMode=TRACES"
75
+
76
+
77
+ def _mlflow():
78
+ """Import mlflow lazily and return the module.
79
+
80
+ mlflow-skinny is a base dependency, so this can't fail on a correct install; the lazy import
81
+ exists only to keep it off the CLI startup path (``cli.py`` imports this module eagerly).
82
+ """
83
+ import mlflow # noqa: PLC0415 - intentional lazy import (startup cost, not optionality)
84
+
85
+ return mlflow
86
+
87
+
88
+ def _workspace_uri(profile: Optional[str]) -> str:
89
+ """The MLflow tracking URI for the workspace (honoring Agent Bricks' --profile)."""
90
+ return f"databricks://{profile}" if profile else "databricks"
91
+
92
+
93
+ def _set_tracking_uri(mlflow, profile: Optional[str]) -> None:
94
+ """Point MLflow at the workspace (honoring Agent Bricks' --profile)."""
95
+ mlflow.set_tracking_uri(_workspace_uri(profile))
96
+
97
+
98
+ # An experiment linked to a UC schema for trace storage carries this tag (the destination schema);
99
+ # managed experiments don't. Agent Bricks supports managed tracing only (UC support is a follow-up).
100
+ _UC_TRACE_TAG = "mlflow.experiment.databricksTraceDestinationPath"
101
+
102
+
103
+ def _is_uc_backed(experiment) -> bool:
104
+ """True if the experiment stores traces in Unity Catalog rather than the managed MLflow backend.
105
+
106
+ An MLflow ``Experiment``'s ``tags`` is always a dict (empty when it has none); ``or {}`` just
107
+ guards a missing attribute.
108
+ """
109
+ return _UC_TRACE_TAG in (getattr(experiment, "tags", None) or {})
110
+
111
+
112
+ def _get_experiment_by_id(mlflow, experiment_id: str):
113
+ """``mlflow.get_experiment`` but returns ``None`` for an unknown id instead of raising.
114
+
115
+ The id lookup raises ``RESOURCE_DOES_NOT_EXIST`` for a missing experiment, whereas the name lookup
116
+ returns ``None`` - normalize so callers treat "not found" the same either way. Other errors (auth,
117
+ network) still propagate.
118
+ """
119
+ from mlflow.exceptions import MlflowException # noqa: PLC0415 - lazy import (startup cost)
120
+
121
+ try:
122
+ return mlflow.get_experiment(experiment_id)
123
+ except MlflowException as exc:
124
+ if getattr(exc, "error_code", "") == "RESOURCE_DOES_NOT_EXIST":
125
+ return None
126
+ raise
127
+
128
+
129
+ def create_experiment_idempotent(profile: Optional[str], client, name: str) -> str:
130
+ """Create the experiment ``name`` if missing and return its id (idempotent).
131
+
132
+ ``create_experiment`` won't make the intermediate workspace folder for a nested path (e.g.
133
+ ``/Users/<you>/agentbricks-traces/<project>``), so the parent dir is created first. Used by dev/deploy to
134
+ provision the managed experiment that traces log to.
135
+
136
+ Rejects a name that already resolves to a UC-backed experiment: binding one is blocked up front,
137
+ but a hand-edited ``agent.toml`` can point at one directly, so deploy re-checks here - Agent Bricks
138
+ supports managed (non-UC) tracing only.
139
+ """
140
+ mlflow = _mlflow()
141
+ _set_tracking_uri(mlflow, profile)
142
+ experiment = mlflow.get_experiment_by_name(name)
143
+ if experiment:
144
+ if _is_uc_backed(experiment):
145
+ raise AgentCliError(
146
+ "UC-backed MLflow tracing is not supported by Agent Bricks.",
147
+ hint="Point this project's tracing at a managed (non-UC) experiment.",
148
+ )
149
+ return experiment.experiment_id
150
+ parent = name.rsplit("/", 1)[0]
151
+ if parent:
152
+ client.ensure_workspace_dir(parent)
153
+ return mlflow.create_experiment(name)
154
+
155
+
156
+ def _resolve_experiment_name(source: pathlib.Path | str) -> Optional[str]:
157
+ """This project's bound ``experiment_name``, or None when tracing is unbound (off).
158
+
159
+ Resolves by name, not a stored id, so it stays correct across workspaces/profiles.
160
+ """
161
+ from databricks_agentbricks.agent_project import (
162
+ AgentProject, # noqa: PLC0415 - avoid import cycle
163
+ )
164
+
165
+ try:
166
+ project = AgentProject.load(source)
167
+ except AgentCliError:
168
+ project = None
169
+ return project.trace_experiment_name if project is not None else None
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class _TraceReadTarget:
174
+ """Where `list`/`get` read traces from: an MLflow ``tracking_uri`` and the ``experiment_id`` in it.
175
+
176
+ ``local`` marks the local ``ab dev`` store (served over a short-lived REST server) so callers can
177
+ label it. A field is None when there's nothing to read (no experiment resolved).
178
+ """
179
+
180
+ tracking_uri: Optional[str]
181
+ experiment_id: Optional[str]
182
+ local: bool = False
183
+
184
+
185
+ @contextmanager
186
+ def _with_trace_read_target(
187
+ source: pathlib.Path | str,
188
+ profile: Optional[str],
189
+ experiment_name: Optional[str] = None,
190
+ experiment_id: Optional[str] = None,
191
+ ) -> Iterator[_TraceReadTarget]:
192
+ """Yield the target `list`/`get` should read from, with MLflow's tracking URI already pointed at it.
193
+
194
+ Precedence: an explicit ``--experiment-name`` / ``--experiment-id`` (must exist), else this
195
+ project's workspace experiment when it's provisioned + managed, else the local ``ab dev`` store
196
+ (``.agentbricks/mlflow.db``). Both target fields are None when nothing has traced anywhere yet.
197
+
198
+ The local store is read over REST from a short-lived MLflow server started here (and torn down on
199
+ exit), not by opening the sqlite file directly: the server that wrote the schema serves it, so the
200
+ CLI's own mlflow-skinny version needn't match the version that wrote the store (the REST protocol is
201
+ compatible across a major version; a direct sqlite open must match the schema head exactly). Callers
202
+ must run their read inside the ``with`` block so the server is still up.
203
+ """
204
+ mlflow = _mlflow()
205
+ # An explicit id/name targets the workspace directly (and must exist); it raises here, before any
206
+ # local server is started, so a typo never spins one up.
207
+ explicit = _workspace_experiment_target(profile, experiment_name, experiment_id)
208
+ if explicit is not None:
209
+ mlflow.set_tracking_uri(explicit.tracking_uri)
210
+ yield explicit
211
+ return
212
+ # This project's bound experiment, if it's provisioned + managed in the workspace.
213
+ name = _resolve_experiment_name(source)
214
+ if name:
215
+ _set_tracking_uri(mlflow, profile)
216
+ try:
217
+ experiment = mlflow.get_experiment_by_name(name)
218
+ except Exception: # noqa: BLE001 - workspace unreachable -> try the local dev store
219
+ experiment = None
220
+ if experiment is not None and not _is_uc_backed(experiment):
221
+ yield _TraceReadTarget(_workspace_uri(profile), experiment.experiment_id)
222
+ return
223
+ # Unbound, not provisioned, or unreachable -> the local dev store, if any. `ab dev` traces
224
+ # locally regardless of the binding, so its store is worth reading even when tracing is unbound.
225
+ db = pathlib.Path(source).resolve() / _AGENTBRICKS_LOCAL_DIR / "mlflow.db"
226
+ if not db.exists():
227
+ yield _TraceReadTarget(None, None)
228
+ return
229
+ server, base_url = _start_read_server(db)
230
+ if server is None: # best-effort: couldn't bring the local store up -> nothing to read
231
+ yield _TraceReadTarget(None, None, local=True)
232
+ return
233
+ try:
234
+ mlflow.set_tracking_uri(base_url)
235
+ local = mlflow.get_experiment_by_name(pathlib.Path(source).resolve().name)
236
+ yield _TraceReadTarget(base_url, local.experiment_id if local else None, local=True)
237
+ finally:
238
+ stop_local_tracing_server(server)
239
+
240
+
241
+ def _workspace_experiment_target(
242
+ profile: Optional[str], experiment_name: Optional[str], experiment_id: Optional[str]
243
+ ) -> Optional[_TraceReadTarget]:
244
+ """The workspace target for an explicit ``--experiment-id`` / ``--experiment-name``, or None when
245
+ neither is given (the caller falls back to the project default).
246
+
247
+ An explicit identifier must exist: an unknown id or name raises rather than resolving to nothing,
248
+ so a typo isn't mistaken for an empty experiment.
249
+ """
250
+ if not (experiment_id or experiment_name):
251
+ return None
252
+ mlflow = _mlflow()
253
+ _set_tracking_uri(mlflow, profile)
254
+ if experiment_id:
255
+ if _get_experiment_by_id(mlflow, experiment_id) is None:
256
+ raise AgentCliError(
257
+ f"No MLflow experiment found with id {experiment_id!r} in this workspace.",
258
+ hint="Check the id, or omit it to use this project's experiment.",
259
+ )
260
+ return _TraceReadTarget(_workspace_uri(profile), experiment_id)
261
+ experiment = mlflow.get_experiment_by_name(experiment_name)
262
+ if experiment is None:
263
+ raise AgentCliError(
264
+ f"No MLflow experiment named {experiment_name!r} in this workspace.",
265
+ hint="Check the name, or omit it to use this project's experiment.",
266
+ )
267
+ return _TraceReadTarget(_workspace_uri(profile), experiment.experiment_id)
268
+
269
+
270
+ def _attr(obj: Any, *paths: str, default: Any = None) -> Any:
271
+ """Read the first present dotted attribute path (MLflow object shapes vary by version)."""
272
+ for path in paths:
273
+ cur = obj
274
+ for part in path.split("."):
275
+ cur = getattr(cur, part, None)
276
+ if cur is None:
277
+ break
278
+ if cur is not None:
279
+ return cur
280
+ return default
281
+
282
+
283
+ def _status_str(status: Any) -> Optional[str]:
284
+ if status is None:
285
+ return None
286
+ return getattr(status, "name", None) or str(status)
287
+
288
+
289
+ def _trace_to_json(trace: Any) -> dict:
290
+ return {
291
+ "trace_id": _attr(trace, "info.trace_id", "info.request_id"),
292
+ "status": _status_str(_attr(trace, "info.status", "info.state")),
293
+ "execution_time_ms": _attr(trace, "info.execution_time_ms", "info.execution_duration_ms"),
294
+ "timestamp_ms": _attr(trace, "info.timestamp_ms", "info.request_time"),
295
+ }
296
+
297
+
298
+ # --- local dev tracing (`ab dev`) ----------------------------------------
299
+
300
+ # Local-only scratch dir (gitignored) under a dev project: holds the sqlite tracing store + artifacts.
301
+ _AGENTBRICKS_LOCAL_DIR = ".agentbricks"
302
+ # The local tracing server's MLflow: a broad 3.x range (the runtime's floor). `ab dev` writes the
303
+ # sqlite store with it, and `list`/`get` read that store back over REST from a short-lived server (see
304
+ # _with_trace_read_target) - never by opening the file with the CLI's own mlflow-skinny. So this needn't
305
+ # match the CLI's version, and uvx reuses ONE cached environment across agentbricks releases (fast startup
306
+ # after the first install) instead of cold-installing a new exact version on every mlflow bump.
307
+ _MLFLOW_SPEC = "mlflow>=3.10,<4"
308
+ # Pin the uvx server's interpreter: on Python 3.13 an older mlflow drags in a pyarrow that builds from
309
+ # source (slow, and fails without a C toolchain); 3.12 resolves to prebuilt wheels. uv fetches it if the
310
+ # machine lacks it.
311
+ _SERVER_PYTHON = "3.12"
312
+
313
+
314
+ def _free_port() -> int:
315
+ """Ask the OS for a free localhost port for the local MLflow tracking server."""
316
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
317
+ sock.bind(("127.0.0.1", 0))
318
+ return sock.getsockname()[1]
319
+
320
+
321
+ def _mlflow_server_argv(db: pathlib.Path, artifacts: pathlib.Path, port: int) -> list[str]:
322
+ """The ``uvx`` argv for a local, sqlite-backed MLflow tracking server on ``port``.
323
+
324
+ Shared by the `ab dev` server and the short-lived server `list`/`get` use to read the local store
325
+ (see _MLFLOW_SPEC / _SERVER_PYTHON for the version + interpreter pins).
326
+ """
327
+ return [
328
+ "uvx",
329
+ "--python",
330
+ _SERVER_PYTHON,
331
+ "--from",
332
+ _MLFLOW_SPEC,
333
+ "mlflow",
334
+ "server",
335
+ "--backend-store-uri",
336
+ f"sqlite:///{db}",
337
+ "--default-artifact-root",
338
+ str(artifacts),
339
+ "--host",
340
+ "127.0.0.1",
341
+ "--port",
342
+ str(port),
343
+ ]
344
+
345
+
346
+ def _wait_for_server(base_url: str, server: subprocess.Popen, timeout: float = 60.0) -> bool:
347
+ """Poll the local MLflow server's health endpoint until it answers, its process exits, or timeout.
348
+
349
+ `ab dev` doesn't wait (the agent traces to the server as it comes up), but a read command queries
350
+ immediately and then tears the server down, so it must block until the server is live. Returns True
351
+ once it responds, False if the process died (e.g. install/bind failure) or it never came up.
352
+ """
353
+ import urllib.error # noqa: PLC0415 - only needed when reading the local store
354
+ import urllib.request # noqa: PLC0415
355
+
356
+ deadline = time.monotonic() + timeout
357
+ while time.monotonic() < deadline:
358
+ if server.poll() is not None: # process exited before serving -> it won't come up
359
+ return False
360
+ try:
361
+ with urllib.request.urlopen(f"{base_url}/health", timeout=2) as resp:
362
+ if resp.status == 200:
363
+ return True
364
+ except (urllib.error.URLError, OSError):
365
+ time.sleep(0.3) # not up yet (connection refused / starting) -> retry
366
+ return False
367
+
368
+
369
+ def _start_read_server(db: pathlib.Path) -> tuple[subprocess.Popen | None, Optional[str]]:
370
+ """Start a short-lived MLflow server over the existing ``ab dev`` store and wait until it's ready.
371
+
372
+ Lets `list`/`get` read local traces over REST (the server owns the sqlite schema). Returns
373
+ ``(server, base_url)``, or ``(None, None)`` when it can't start - best-effort, like `ab dev`, so a
374
+ read degrades to showing nothing rather than erroring. The caller stops the server when done.
375
+ """
376
+ artifacts = (db.parent / "mlartifacts").resolve()
377
+ port = _free_port()
378
+ base_url = f"http://127.0.0.1:{port}"
379
+ try:
380
+ # Dup the log fd to the child and close our copy; keeps the server's output for debugging.
381
+ with (db.parent / "mlflow-read.log").open("w") as log:
382
+ server = subprocess.Popen(
383
+ _mlflow_server_argv(db, artifacts, port), stdout=log, stderr=subprocess.STDOUT
384
+ )
385
+ except OSError as exc:
386
+ render.diagnostic(
387
+ "warning", f"could not read local traces - {exc}", help="is `uv` installed?"
388
+ )
389
+ return None, None
390
+ if not _wait_for_server(base_url, server):
391
+ stop_local_tracing_server(server)
392
+ render.diagnostic(
393
+ "warning", "local trace store did not come up", help="see .agentbricks/mlflow-read.log"
394
+ )
395
+ return None, None
396
+ return server, base_url
397
+
398
+
399
+ def start_local_tracing_server(
400
+ source_dir: pathlib.Path,
401
+ ) -> tuple[subprocess.Popen | None, dict[str, str]]:
402
+ """Start a local, sqlite-backed MLflow tracking server for `ab dev` tracing.
403
+
404
+ Returns ``(server_process, env)`` — ``env`` carries the ``MLFLOW_*`` vars for the dev-only manifest
405
+ so the agent traces locally — or ``(None, {})`` when the server can't be started (dev then runs
406
+ without traces). Launched via ``uvx mlflow`` so it depends on neither the skinny CLI env nor the
407
+ agent venv; it stores traces in ``<source>/.agentbricks/mlflow.db`` and serves the trace UI + REST API on
408
+ a free localhost port. Because the server owns the sqlite schema, there is no client/server
409
+ migration mismatch. The agent picks up the env through the same runtime gate a deployment uses (a
410
+ destination + an experiment), so no agent code differs between dev and deploy.
411
+ """
412
+ agentbricks_dir = source_dir / _AGENTBRICKS_LOCAL_DIR
413
+ try:
414
+ agentbricks_dir.mkdir(exist_ok=True)
415
+ db = (agentbricks_dir / "mlflow.db").resolve()
416
+ artifacts = (agentbricks_dir / "mlartifacts").resolve()
417
+ port = _free_port()
418
+ # Passing the log file into Popen dups its fd to the child; closing our copy here is safe and
419
+ # keeps the server's output for debugging a failed local-tracing run.
420
+ with (agentbricks_dir / "mlflow-server.log").open("w") as log:
421
+ server = subprocess.Popen(
422
+ _mlflow_server_argv(db, artifacts, port), stdout=log, stderr=subprocess.STDOUT
423
+ )
424
+ except OSError as exc:
425
+ render.diagnostic(
426
+ "warning",
427
+ f"local tracing not started — {exc}",
428
+ help="running without traces (is `uv` installed?)",
429
+ )
430
+ return None, {}
431
+ return server, {
432
+ "MLFLOW_TRACKING_URI": f"http://127.0.0.1:{port}",
433
+ # A bare experiment name is fine for local MLflow (no workspace path / username needed).
434
+ "MLFLOW_EXPERIMENT_NAME": source_dir.resolve().name,
435
+ }
436
+
437
+
438
+ def stop_local_tracing_server(server: subprocess.Popen) -> None:
439
+ """Stop the local MLflow tracking server started for `ab dev`."""
440
+ server.terminate()
441
+ try:
442
+ server.wait(timeout=5)
443
+ except subprocess.TimeoutExpired:
444
+ server.kill()
445
+
446
+
447
+ # --- group ------------------------------------------------------------------
448
+
449
+
450
+ @click.group()
451
+ def tracing() -> None:
452
+ """Configure MLflow tracing for your deployed agents, and inspect the traces."""
453
+
454
+
455
+ def _check_experiment_flags(
456
+ experiment_name: Optional[str], experiment_id: Optional[str], *, require_one: bool = False
457
+ ) -> None:
458
+ """Validate the mutually-exclusive ``--experiment-name`` / ``--experiment-id`` selectors.
459
+
460
+ Both given is always an error. ``require_one`` additionally rejects *neither* - `bind` needs one to
461
+ set the binding, while `list`/`get` may omit both and fall back to this project's experiment.
462
+ """
463
+ if experiment_name and experiment_id:
464
+ raise AgentCliError(
465
+ "Pass --experiment-name or --experiment-id, not both.",
466
+ hint="They select the same experiment; use whichever identifier you have.",
467
+ )
468
+ if require_one and not (experiment_name or experiment_id):
469
+ raise AgentCliError(
470
+ "Pass --experiment-name or --experiment-id to bind tracing to an experiment.",
471
+ hint="Absence of a bound experiment means tracing is off; `ab tracing unbind` clears it.",
472
+ )
473
+
474
+
475
+ def _experiment_read_options(command):
476
+ """Add the shared ``--experiment-name`` / ``--experiment-id`` read options to `list` and `get`."""
477
+ command = click.option(
478
+ "--experiment-id",
479
+ "experiment_id",
480
+ default=None,
481
+ help="MLflow experiment id to read (e.g. from the experiment URL). Mutually exclusive with "
482
+ "--experiment-name.",
483
+ )(command)
484
+ command = click.option(
485
+ "--experiment-name",
486
+ "experiment_name",
487
+ default=None,
488
+ help="MLflow experiment name to read (an absolute workspace path). Default: this project's "
489
+ "experiment.",
490
+ )(command)
491
+ return command
492
+
493
+
494
+ # --- bind / unbind ----------------------------------------------------------
495
+
496
+
497
+ @tracing.command("bind")
498
+ @click.option(
499
+ "--experiment-name",
500
+ "experiment_name",
501
+ default=None,
502
+ help="MLflow experiment name to trace to - an absolute workspace path, e.g. "
503
+ "/Shared/agentbricks_traces/<agent> or /Users/<you>/agentbricks_traces/<agent>. Agent Bricks creates it at "
504
+ "deploy. Mutually exclusive with --experiment-id.",
505
+ )
506
+ @click.option(
507
+ "--experiment-id",
508
+ "experiment_id",
509
+ default=None,
510
+ help="MLflow experiment id (e.g. copied from the experiment's workspace URL) to trace to. "
511
+ "Resolved to the experiment's name and stored as a name — Agent Bricks stores names, not ids, so the "
512
+ "binding stays valid across workspaces. Mutually exclusive with --experiment-name.",
513
+ )
514
+ @click.option(
515
+ "--source",
516
+ default=".",
517
+ type=click.Path(exists=True, file_okay=False),
518
+ help="Project directory containing agent.toml. Defaults to the current directory.",
519
+ )
520
+ @click.pass_obj
521
+ def tracing_bind(obj, experiment_name, experiment_id, source) -> None:
522
+ """Bind tracing to an experiment, by name or id. Requires one of them (like `ab memory/sessions
523
+ bind`); the binding's presence is what turns tracing on.
524
+
525
+ The experiment is stored as a NAME, not an id, so the binding stays valid across
526
+ workspaces/profiles — Agent Bricks creates it if absent in the active workspace at deploy. ``--experiment-id``
527
+ (e.g. from the experiment's URL) is a convenience: it's resolved to the experiment's name and
528
+ stored as a name, never as an id.
529
+ """
530
+ from databricks_agentbricks.agent_project import AgentProject # noqa: PLC0415
531
+
532
+ _check_experiment_flags(experiment_name, experiment_id, require_one=True)
533
+
534
+ # The name to store. --experiment-id is resolved to the experiment's name (Agent Bricks stores names,
535
+ # not ids). Either way, a UC-backed experiment is rejected up front — Agent Bricks supports managed
536
+ # tracing only (UC traces need a SQL warehouse to read and UC grants for the app's SP).
537
+ name = experiment_name
538
+ if experiment_id:
539
+ mlflow = _mlflow()
540
+ _set_tracking_uri(mlflow, obj.profile)
541
+ experiment = _get_experiment_by_id(mlflow, experiment_id)
542
+ if experiment is None:
543
+ raise AgentCliError(
544
+ f"No MLflow experiment found with id {experiment_id!r}.",
545
+ hint="Pass an existing experiment id, or use --experiment-name.",
546
+ )
547
+ if _is_uc_backed(experiment):
548
+ raise AgentCliError(
549
+ "UC-backed MLflow tracing is not supported by Agent Bricks.",
550
+ hint="Pass a managed (non-UC) experiment.",
551
+ )
552
+ name = experiment.name
553
+ elif experiment_name:
554
+ if not experiment_name.startswith("/"):
555
+ raise AgentCliError(
556
+ f"Experiment name must be an absolute workspace path, got {experiment_name!r}.",
557
+ hint="Use a path like /Shared/agentbricks_traces/<agent> or "
558
+ "/Users/<you>/agentbricks_traces/<agent>.",
559
+ )
560
+ # A not-yet-created name is fine (deploy creates it); only reject a name that already resolves
561
+ # to a UC-backed experiment.
562
+ mlflow = _mlflow()
563
+ _set_tracking_uri(mlflow, obj.profile)
564
+ existing = mlflow.get_experiment_by_name(experiment_name)
565
+ if existing is not None and _is_uc_backed(existing):
566
+ raise AgentCliError(
567
+ "UC-backed MLflow tracing is not supported by Agent Bricks.",
568
+ hint="Pass a managed (non-UC) experiment.",
569
+ )
570
+
571
+ project = AgentProject.load(pathlib.Path(source))
572
+ project.bind_tracing(name)
573
+ project.write()
574
+
575
+ if obj.output == "json":
576
+ render.emit_json({"experiment_name": name})
577
+ return
578
+ render.success(
579
+ f"Tracing on: experiment {name}",
580
+ fields={"Experiment": name},
581
+ next_steps=[
582
+ ("ab dev", "Run locally with tracing on"),
583
+ ("ab tracing list", "List traces once you have some"),
584
+ ("ab tracing unbind", "Turn tracing off"),
585
+ ],
586
+ )
587
+
588
+
589
+ @tracing.command("unbind")
590
+ @click.option(
591
+ "--source",
592
+ default=".",
593
+ type=click.Path(exists=True, file_okay=False),
594
+ help="Project directory containing agent.toml. Defaults to the current directory.",
595
+ )
596
+ @click.pass_obj
597
+ def tracing_unbind(obj, source) -> None:
598
+ """Unbind tracing: remove the experiment binding from agent.toml, turning tracing off for the
599
+ DEPLOYED agent (`ab deploy` then wires no MLflow env).
600
+
601
+ Deploy-only: `ab dev` still traces locally to its own MLflow server, so you keep local traces
602
+ while the deployed agent stays untraced.
603
+ """
604
+ from databricks_agentbricks.agent_project import AgentProject # noqa: PLC0415
605
+
606
+ project = AgentProject.load(pathlib.Path(source))
607
+ project.unbind_tracing()
608
+ project.write()
609
+
610
+ if obj.output == "json":
611
+ render.emit_json({"experiment_name": None})
612
+ return
613
+ render.success(
614
+ "Tracing unbound (off for the deployed agent; ab dev still traces locally)",
615
+ next_steps=[(TRACING_BIND_COMMAND, "Turn deployed tracing back on")],
616
+ )
617
+
618
+
619
+ # --- list / get -------------------------------------------------------------
620
+
621
+
622
+ @tracing.command("list")
623
+ @_experiment_read_options
624
+ @click.option("--limit", type=int, default=20)
625
+ @click.option(
626
+ "--source",
627
+ default=".",
628
+ type=click.Path(file_okay=False),
629
+ help="Project directory to resolve the default experiment from (default: current dir).",
630
+ )
631
+ @click.pass_obj
632
+ def tracing_list(obj, experiment_name, experiment_id, limit, source) -> None:
633
+ """List recent agent traces in an experiment.
634
+
635
+ An explicit ``--experiment-name`` / ``--experiment-id`` reads that workspace experiment and must
636
+ name one that exists (errors otherwise, so a typo isn't mistaken for an empty experiment). With
637
+ neither, this project's experiment is read: the **workspace** one if it's been provisioned (by
638
+ `ab deploy`), otherwise the local `ab dev` store (``.agentbricks/mlflow.db``), so a not-yet-deployed
639
+ dev run's traces still show up here (tagged "(local dev)"). Nothing traced anywhere yet lists
640
+ nothing.
641
+ """
642
+ _check_experiment_flags(experiment_name, experiment_id)
643
+ mlflow = _mlflow()
644
+ # Read inside the context manager: for a local dev store it keeps the short-lived MLflow server up
645
+ # for the duration of the search (return_type="list" materializes the rows before it's torn down).
646
+ with _with_trace_read_target(source, obj.profile, experiment_name, experiment_id) as target:
647
+ traces = []
648
+ if target.experiment_id:
649
+ traces = mlflow.search_traces(
650
+ locations=[target.experiment_id], max_results=limit, return_type="list"
651
+ )
652
+
653
+ if obj.output == "json":
654
+ render.emit_json([_trace_to_json(t) for t in traces])
655
+ return
656
+ rows = [
657
+ [
658
+ _attr(t, "info.trace_id", "info.request_id"),
659
+ render.status_pill(_status_str(_attr(t, "info.status", "info.state"))),
660
+ _attr(t, "info.execution_time_ms", "info.execution_duration_ms"),
661
+ timefmt.relative(_attr(t, "info.timestamp_ms", "info.request_time")),
662
+ ]
663
+ for t in traces
664
+ ]
665
+ where = " (local dev)" if target.local else ""
666
+ render.resource_table(
667
+ f"Agent Traces · {str(target.experiment_id) + where if target.experiment_id else 'no experiment yet'}",
668
+ [
669
+ ("Trace ID", "left"),
670
+ ("Status", "left"),
671
+ ("Latency (ms)", "left"),
672
+ ("Created", "left"),
673
+ ],
674
+ rows,
675
+ )
676
+
677
+
678
+ @tracing.command("get")
679
+ @click.argument("trace_id")
680
+ @_experiment_read_options
681
+ @click.option(
682
+ "--source",
683
+ default=".",
684
+ type=click.Path(file_okay=False),
685
+ help="Project directory to resolve the experiment from (default: current dir).",
686
+ )
687
+ @click.pass_obj
688
+ def tracing_get(obj, trace_id, experiment_name, experiment_id, source) -> None:
689
+ """Get a single trace by id (status, latency, span count, previews).
690
+
691
+ Reads from the same place as `ab tracing list`: an explicit ``--experiment-name`` /
692
+ ``--experiment-id`` targets that workspace store and must name one that exists (errors otherwise);
693
+ otherwise this project's workspace experiment if provisioned, else its local `ab dev` store.
694
+ """
695
+ _check_experiment_flags(experiment_name, experiment_id)
696
+ mlflow = _mlflow()
697
+ # Read inside the context manager so a local dev store's short-lived server stays up for the fetch.
698
+ with _with_trace_read_target(source, obj.profile, experiment_name, experiment_id) as target:
699
+ # get_trace resolves by id and needs only the tracking URI; when nothing resolved, fall back to
700
+ # the workspace so an id still looks there.
701
+ if target.tracking_uri is None:
702
+ mlflow.set_tracking_uri(_workspace_uri(obj.profile))
703
+ trace = mlflow.get_trace(trace_id)
704
+ if trace is None:
705
+ raise AgentCliError(f"No trace found with id {trace_id!r}.")
706
+ if obj.output == "json":
707
+ render.emit_json(_trace_to_json(trace))
708
+ return
709
+ spans = _attr(trace, "data.spans", default=[]) or []
710
+ render.detail(
711
+ _BREADCRUMB,
712
+ trace_id,
713
+ {
714
+ "Status": _status_str(_attr(trace, "info.status", "info.state")),
715
+ "Latency (ms)": _attr(
716
+ trace, "info.execution_time_ms", "info.execution_duration_ms"
717
+ ),
718
+ "Spans": len(spans),
719
+ "Request": _attr(trace, "info.request_preview", "data.request"),
720
+ "Response": _attr(trace, "info.response_preview", "data.response"),
721
+ "Created": timefmt.absolute(_attr(trace, "info.timestamp_ms", "info.request_time")),
722
+ },
723
+ status=_status_str(_attr(trace, "info.status", "info.state")),
724
+ )