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,70 @@
1
+ """Detection and scheduling of recoverable durable attempts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import TYPE_CHECKING, Protocol
8
+
9
+ if TYPE_CHECKING:
10
+ from databricks_agentkit.runtime.durability.store import DurableRuntimeStore
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class RecoverySchedulerTarget(Protocol):
16
+ """Schedule one invocation after recovery finds it eligible."""
17
+
18
+ def ensure_recovery_scheduled(self, invocation_id: str) -> None:
19
+ """Schedule a queued or stale invocation for a recovery attempt."""
20
+ ...
21
+
22
+
23
+ class RecoveryScheduler:
24
+ """Find stale active attempts when recovery is enabled."""
25
+
26
+ def __init__(
27
+ self,
28
+ scheduler: RecoverySchedulerTarget,
29
+ *,
30
+ runtime_store: "DurableRuntimeStore",
31
+ stale_seconds: float,
32
+ scan_seconds: float,
33
+ ) -> None:
34
+ if scan_seconds <= 0:
35
+ raise ValueError("scan_seconds must be positive")
36
+ self._scheduler = scheduler
37
+ self._runtime_store = runtime_store
38
+ self._stale_seconds = stale_seconds
39
+ self._scan_seconds = scan_seconds
40
+ self._scanner: asyncio.Task[None] | None = None
41
+
42
+ def start(self, *, recover: bool) -> None:
43
+ """Enable scheduling and optionally start proactive stale-work scanning."""
44
+ if recover:
45
+ self._scanner = asyncio.create_task(
46
+ self._scan_loop(),
47
+ name="databricks-durable-runtime-scanner",
48
+ )
49
+
50
+ async def stop(self) -> None:
51
+ """Stop scanning, leaving active rows recoverable elsewhere."""
52
+ if self._scanner is not None:
53
+ self._scanner.cancel()
54
+ await asyncio.gather(
55
+ *([self._scanner] if self._scanner is not None else []),
56
+ return_exceptions=True,
57
+ )
58
+ self._scanner = None
59
+
60
+ async def _scan_loop(self) -> None:
61
+ while True:
62
+ try:
63
+ invocation_ids = await self._runtime_store.stale_invocation_ids(self._stale_seconds)
64
+ for invocation_id in invocation_ids:
65
+ self._scheduler.ensure_recovery_scheduled(invocation_id)
66
+ except asyncio.CancelledError:
67
+ raise
68
+ except Exception:
69
+ logger.exception("Databricks durable runtime recovery scan failed")
70
+ await asyncio.sleep(self._scan_seconds)
@@ -0,0 +1,33 @@
1
+ """Runtime Store capabilities required for heartbeat-based recovery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from databricks_agentkit.runtime.store import RuntimeStore
8
+ from databricks_agentkit.runtime.types import Invocation
9
+
10
+
11
+ @runtime_checkable
12
+ class DurableRuntimeStore(RuntimeStore, Protocol):
13
+ """Extend Runtime Store operations with durable recovery coordination."""
14
+
15
+ async def queued_invocation_ids(self) -> list[str]:
16
+ """List invocations waiting to begin their first attempt."""
17
+ ...
18
+
19
+ async def stale_invocation_ids(self, stale_seconds: float) -> list[str]:
20
+ """List active invocations whose heartbeat is stale."""
21
+ ...
22
+
23
+ async def claim_recoverable(
24
+ self,
25
+ invocation_id: str,
26
+ stale_seconds: float,
27
+ ) -> Invocation | None:
28
+ """Replace a stale active attempt and return the new active attempt."""
29
+ ...
30
+
31
+ async def heartbeat(self, invocation_id: str, attempt: int) -> bool:
32
+ """Refresh an active attempt's lease while the caller owns it."""
33
+ ...
@@ -0,0 +1,160 @@
1
+ """Process-local scheduling and shared agent attempt execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import copy
7
+ import json
8
+ import logging
9
+ from typing import Protocol, cast
10
+
11
+ from databricks_agentkit.runtime.store import RuntimeStore
12
+ from databricks_agentkit.runtime.types import (
13
+ Invocation,
14
+ InvocationAttemptContext,
15
+ InvocationExecutorFn,
16
+ InvocationStatus,
17
+ JsonObject,
18
+ JsonValue,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def copy_json_value(value: JsonValue, name: str) -> JsonValue:
25
+ """Return a detached JSON value or reject a value that cannot be persisted."""
26
+ try:
27
+ return cast(JsonValue, json.loads(json.dumps(value, allow_nan=False)))
28
+ except (TypeError, ValueError) as exc:
29
+ raise TypeError(f"{name} must be JSON serializable") from exc
30
+
31
+
32
+ def _copy_json_object(value: JsonObject, name: str) -> JsonObject:
33
+ copied = copy_json_value(value, name)
34
+ if not isinstance(copied, dict):
35
+ raise TypeError(f"{name} must be a JSON object")
36
+ return copied
37
+
38
+
39
+ class InvocationExecutor(Protocol):
40
+ """Schedule invocation attempts for one runtime process."""
41
+
42
+ async def start(self) -> None:
43
+ """Start any background scheduling owned by this executor."""
44
+ ...
45
+
46
+ async def stop(self) -> None:
47
+ """Stop scheduled work and release executor resources."""
48
+ ...
49
+
50
+ def ensure_scheduled(self, state: Invocation) -> None:
51
+ """Arrange for a newly accepted invocation to run on this process."""
52
+ ...
53
+
54
+
55
+ class AttemptExecution:
56
+ """Call the agent hook and commit one already-claimed attempt."""
57
+
58
+ def __init__(self, execute_fn: InvocationExecutorFn, *, runtime_store: RuntimeStore) -> None:
59
+ self._execute_fn = execute_fn
60
+ self._runtime_store = runtime_store
61
+
62
+ async def run(self, claimed: Invocation) -> None:
63
+ invocation_id = claimed.invocation_id
64
+ try:
65
+
66
+ async def emit(event: JsonObject) -> int:
67
+ sequence_number = await self._runtime_store.append_event(
68
+ invocation_id,
69
+ claimed.attempt,
70
+ _copy_json_object(event, "event"),
71
+ )
72
+ if sequence_number is None:
73
+ raise RuntimeError(
74
+ f"invocation {invocation_id!r} no longer owns attempt {claimed.attempt}"
75
+ )
76
+ return sequence_number
77
+
78
+ response = await self._execute_fn(
79
+ copy.deepcopy(claimed.request),
80
+ InvocationAttemptContext(
81
+ invocation_id=invocation_id,
82
+ attempt=claimed.attempt,
83
+ _emit=emit,
84
+ ),
85
+ )
86
+ response = copy_json_value(response, "executor response")
87
+ completed = await self._runtime_store.complete(
88
+ invocation_id,
89
+ claimed.attempt,
90
+ response,
91
+ )
92
+ if not completed:
93
+ logger.info(
94
+ "Skipped completion after Runtime Store ownership changed: %s attempt=%d",
95
+ invocation_id,
96
+ claimed.attempt,
97
+ )
98
+ except asyncio.CancelledError:
99
+ raise
100
+ except Exception:
101
+ logger.exception(
102
+ "Invocation execution failed: %s attempt=%d",
103
+ invocation_id,
104
+ claimed.attempt,
105
+ )
106
+ try:
107
+ await self._runtime_store.fail(invocation_id, claimed.attempt)
108
+ except Exception:
109
+ logger.exception(
110
+ "Failed to persist invocation failure: %s attempt=%d",
111
+ invocation_id,
112
+ claimed.attempt,
113
+ )
114
+
115
+
116
+ class LocalInvocationExecutor(InvocationExecutor):
117
+ """Run background invocations inside the current process without heartbeats."""
118
+
119
+ def __init__(self, execution: AttemptExecution, *, runtime_store: RuntimeStore) -> None:
120
+ self._execution = execution
121
+ self._runtime_store = runtime_store
122
+ self._tasks: dict[str, asyncio.Task[None]] = {}
123
+
124
+ async def start(self) -> None:
125
+ pass
126
+
127
+ async def stop(self) -> None:
128
+ tasks = list(self._tasks.values())
129
+ for task in tasks:
130
+ task.cancel()
131
+ await asyncio.gather(*tasks, return_exceptions=True)
132
+ self._tasks.clear()
133
+
134
+ def ensure_scheduled(self, state: Invocation) -> None:
135
+ if state.status != InvocationStatus.QUEUED:
136
+ return
137
+ current = self._tasks.get(state.invocation_id)
138
+ if current is not None and not current.done():
139
+ return
140
+ task = asyncio.create_task(
141
+ self._run(state.invocation_id),
142
+ name=f"invocation-{state.invocation_id}",
143
+ )
144
+ self._tasks[state.invocation_id] = task
145
+ task.add_done_callback(lambda completed: self._discard_task(state.invocation_id, completed))
146
+
147
+ async def _run(self, invocation_id: str) -> None:
148
+ try:
149
+ claimed = await self._runtime_store.claim(invocation_id)
150
+ except Exception:
151
+ logger.exception("Failed to claim invocation: %s", invocation_id)
152
+ return
153
+ if claimed is not None:
154
+ await self._execution.run(claimed)
155
+
156
+ def _discard_task(self, invocation_id: str, completed: asyncio.Task[None]) -> None:
157
+ if self._tasks.get(invocation_id) is completed:
158
+ self._tasks.pop(invocation_id, None)
159
+ if not completed.cancelled():
160
+ completed.exception()
@@ -0,0 +1,224 @@
1
+ """Framework-neutral tools for a configured Genie Agent's Chat-mode conversation API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from collections.abc import Callable
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from databricks_agentkit.runtime.workspace import workspace_client
12
+
13
+ if TYPE_CHECKING:
14
+ from databricks.sdk import WorkspaceClient
15
+
16
+ _IDENTIFIER = re.compile(r"^[a-f0-9]{32}$")
17
+ _TERMINAL = {"COMPLETED", "FAILED", "CANCELLED", "QUERY_RESULT_EXPIRED"}
18
+
19
+
20
+ async def _run_in_thread(function: Callable[..., Any], *args: Any) -> Any:
21
+ task = asyncio.create_task(asyncio.to_thread(function, *args))
22
+ try:
23
+ return await asyncio.shield(task)
24
+ except asyncio.CancelledError as cancelled:
25
+ while not task.done():
26
+ try:
27
+ await asyncio.shield(task)
28
+ except asyncio.CancelledError:
29
+ continue
30
+ except Exception:
31
+ break
32
+ if not task.cancelled():
33
+ task.exception()
34
+ raise cancelled
35
+
36
+
37
+ def _identifier(value: str, name: str) -> str:
38
+ if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value):
39
+ raise ValueError(f"{name} must be a 32-character lowercase hexadecimal ID.")
40
+ return value
41
+
42
+
43
+ class GenieAgent:
44
+ """Call one configured space without allowing the model to choose credentials or scope."""
45
+
46
+ def __init__(
47
+ self,
48
+ space_id: str,
49
+ *,
50
+ wait_seconds: float = 120.0,
51
+ auth: str = "app",
52
+ workspace_client_for: Callable[[str], WorkspaceClient] | None = None,
53
+ ) -> None:
54
+ self.space_id = _identifier(space_id, "space_id")
55
+ if not 0 < wait_seconds <= 600:
56
+ raise ValueError("wait_seconds must be greater than zero and at most 600.")
57
+ if auth not in ("user", "app"):
58
+ raise ValueError("auth must be 'user' or 'app'.")
59
+ self.wait_seconds = wait_seconds
60
+ self.auth = auth
61
+ self.workspace_client_for = workspace_client_for
62
+
63
+ def _client(self) -> WorkspaceClient:
64
+ if self.workspace_client_for is not None:
65
+ return self.workspace_client_for(self.auth)
66
+ return workspace_client()
67
+
68
+ async def ask(self, question: str, conversation_id: str | None = None) -> dict[str, Any]:
69
+ """Ask the configured Genie Agent a data question, or continue a conversation.
70
+
71
+ Waits up to two minutes for an answer. Preserve returned attachments, SQL and deep_link.
72
+ If timed_out is true and IDs are returned, call the matching poll tool; do not resubmit.
73
+ If indeterminate_submission is true, submission may still complete but no message ID was
74
+ received: do not resubmit automatically. NOT_SUBMITTED means setup timed out before sending
75
+ the question. FAILED, CANCELLED and QUERY_RESULT_EXPIRED are
76
+ terminal, not successful answers. Use query_result with a query attachment's attachment_id
77
+ to read its rows. No workspace or space selection is needed.
78
+ """
79
+ if not isinstance(question, str) or not question.strip():
80
+ raise ValueError("question must not be empty.")
81
+ if conversation_id is not None:
82
+ _identifier(conversation_id, "conversation_id")
83
+ deadline = time.monotonic() + self.wait_seconds
84
+ try:
85
+ client = await asyncio.wait_for(
86
+ _run_in_thread(self._client), timeout=deadline - time.monotonic()
87
+ )
88
+ except asyncio.TimeoutError:
89
+ return self._submission_timeout(conversation_id, indeterminate=False)
90
+ try:
91
+ if conversation_id is None:
92
+ operation = await asyncio.wait_for(
93
+ _run_in_thread(client.genie.start_conversation, self.space_id, question),
94
+ timeout=deadline - time.monotonic(),
95
+ )
96
+ conversation_id = operation.response.conversation_id
97
+ else:
98
+ operation = await asyncio.wait_for(
99
+ _run_in_thread(
100
+ client.genie.create_message, self.space_id, conversation_id, question
101
+ ),
102
+ timeout=deadline - time.monotonic(),
103
+ )
104
+ except asyncio.TimeoutError:
105
+ return self._submission_timeout(conversation_id, indeterminate=True)
106
+ return await self._wait(
107
+ client, conversation_id, operation.response.message_id, deadline=deadline
108
+ )
109
+
110
+ def _submission_timeout(
111
+ self, conversation_id: str | None, *, indeterminate: bool
112
+ ) -> dict[str, Any]:
113
+ return {
114
+ "space_id": self.space_id,
115
+ **({"conversation_id": conversation_id} if conversation_id is not None else {}),
116
+ "status": "INDETERMINATE_SUBMISSION" if indeterminate else "NOT_SUBMITTED",
117
+ "timed_out": True,
118
+ "indeterminate_submission": indeterminate,
119
+ "warning": (
120
+ "Submission may still complete, but no message ID was received. "
121
+ "Do not resubmit automatically."
122
+ if indeterminate
123
+ else "Client setup timed out before submission; the question was not submitted."
124
+ ),
125
+ }
126
+
127
+ async def poll(self, conversation_id: str, message_id: str) -> dict[str, Any]:
128
+ """Resume waiting for a previously submitted Genie Agent message without resubmitting it.
129
+
130
+ Waits up to two minutes, polling with backoff. If timed_out is true, retain the IDs to poll
131
+ again later. Terminal failures and expired results are returned explicitly, never rerun.
132
+ """
133
+ _identifier(conversation_id, "conversation_id")
134
+ _identifier(message_id, "message_id")
135
+ deadline = time.monotonic() + self.wait_seconds
136
+ try:
137
+ client = await asyncio.wait_for(
138
+ _run_in_thread(self._client), timeout=deadline - time.monotonic()
139
+ )
140
+ except asyncio.TimeoutError:
141
+ return {
142
+ "space_id": self.space_id,
143
+ "conversation_id": conversation_id,
144
+ "message_id": message_id,
145
+ "status": "IN_PROGRESS",
146
+ "timed_out": True,
147
+ }
148
+ return await self._wait(client, conversation_id, message_id, deadline=deadline)
149
+
150
+ def _reference(self, client: Any, conversation_id: str, message_id: str) -> dict[str, Any]:
151
+ _identifier(conversation_id, "conversation_id")
152
+ _identifier(message_id, "message_id")
153
+ return {
154
+ "space_id": self.space_id,
155
+ "conversation_id": conversation_id,
156
+ "message_id": message_id,
157
+ "deep_link": f"{client.config.host.rstrip('/')}/genie/spaces/{self.space_id}"
158
+ f"/conversations/{conversation_id}",
159
+ }
160
+
161
+ async def _wait(
162
+ self, client: Any, conversation_id: str, message_id: str, *, deadline: float
163
+ ) -> dict[str, Any]:
164
+ reference = self._reference(client, conversation_id, message_id)
165
+ delay = 2.0
166
+ message: dict[str, Any] = {"status": "IN_PROGRESS"}
167
+ while (remaining := deadline - time.monotonic()) > 0:
168
+ try:
169
+ response = await asyncio.wait_for(
170
+ _run_in_thread(
171
+ client.genie.get_message, self.space_id, conversation_id, message_id
172
+ ),
173
+ timeout=remaining,
174
+ )
175
+ except asyncio.TimeoutError:
176
+ break
177
+ message = response.as_dict()
178
+ if message.get("status") in _TERMINAL:
179
+ return {**message, **reference}
180
+ remaining = deadline - time.monotonic()
181
+ if remaining > 0:
182
+ await asyncio.sleep(min(delay, remaining))
183
+ delay = min(delay * 2, 30.0)
184
+ return {**message, **reference, "timed_out": True}
185
+
186
+ async def query_result(
187
+ self, conversation_id: str, message_id: str, attachment_id: str
188
+ ) -> dict[str, Any]:
189
+ """Read up to 100 rows and column schema for a Genie Agent query attachment.
190
+
191
+ Use the conversation_id, message_id and query attachment's attachment_id from ask/poll.
192
+ Check status before interpreting rows. When truncated is true, deep_link opens the full
193
+ result in Databricks. This never re-executes an expired query or downloads external links.
194
+ """
195
+ _identifier(conversation_id, "conversation_id")
196
+ _identifier(message_id, "message_id")
197
+ _identifier(attachment_id, "attachment_id")
198
+ client = await _run_in_thread(self._client)
199
+ response = await _run_in_thread(
200
+ client.genie.get_message_attachment_query_result,
201
+ self.space_id,
202
+ conversation_id,
203
+ message_id,
204
+ attachment_id,
205
+ )
206
+ statement = response.as_dict().get("statement_response") or {}
207
+ manifest = statement.get("manifest") or {}
208
+ result = statement.get("result") or {}
209
+ rows = result.get("data_array") or []
210
+ total = manifest.get("total_row_count")
211
+ return {
212
+ **self._reference(client, conversation_id, message_id),
213
+ "attachment_id": attachment_id,
214
+ "status": statement.get("status") or {"state": "UNKNOWN"},
215
+ "columns": (manifest.get("schema") or {}).get("columns", []),
216
+ "rows": rows[:100],
217
+ "total_row_count": total,
218
+ "truncated": bool(
219
+ len(rows) > 100
220
+ or manifest.get("truncated")
221
+ or result.get("next_chunk_index") is not None
222
+ or (total is not None and total > len(rows[:100]))
223
+ ),
224
+ }
@@ -0,0 +1,114 @@
1
+ """Shared authentication helpers for framework-specific MCP adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from databricks.sdk import WorkspaceClient
10
+
11
+ from databricks_agentkit.runtime.auth import AuthError
12
+
13
+ WorkspaceClientResolver = Callable[[str], WorkspaceClient]
14
+
15
+
16
+ def resolve_mcp_workspace_client(
17
+ mode: str,
18
+ integration_id: str,
19
+ workspace_client_for: WorkspaceClientResolver | None,
20
+ default_client: Callable[[], WorkspaceClient],
21
+ ) -> WorkspaceClient:
22
+ """Resolve one manifest binding to its request-user or App client.
23
+
24
+ Request-aware templates pass ``RequestAuthContext.client_for`` as ``workspace_client_for``.
25
+ Databricks Apps sets ``DATABRICKS_APP_NAME`` in deployed compute, so a user-auth binding there
26
+ fails closed when invocation plumbing did not supply that request-owned resolver. Local runs
27
+ retain the environment-authenticated client fallback.
28
+ """
29
+ if workspace_client_for is None and mode == "user" and os.getenv("DATABRICKS_APP_NAME"):
30
+ raise AuthError(
31
+ "MCP_USER_AUTHORIZATION_MISSING",
32
+ "This deployed MCP integration requires request-user authorization.",
33
+ integration_id=integration_id,
34
+ )
35
+ try:
36
+ return workspace_client_for(mode) if workspace_client_for else default_client()
37
+ except Exception as error:
38
+ raise mcp_auth_error(error, integration_id) or AuthError(
39
+ "MCP_CLIENT_CONFIGURATION_FAILED",
40
+ "Could not configure the MCP client.",
41
+ 500,
42
+ integration_id,
43
+ ) from None
44
+
45
+
46
+ def mcp_auth_error(
47
+ error: BaseException, integration_id: str, seen: set[int] | None = None
48
+ ) -> AuthError | None:
49
+ """Classify nested credential failures without exposing upstream error details."""
50
+ from databricks.sdk.errors import PermissionDenied, Unauthenticated
51
+
52
+ seen = set() if seen is None else seen
53
+ if id(error) in seen:
54
+ return None
55
+ seen.add(id(error))
56
+ if isinstance(error, AuthError):
57
+ return (
58
+ error
59
+ if error.integration_id
60
+ else AuthError(error.code, str(error), error.status_code, integration_id)
61
+ )
62
+ nested_errors = [
63
+ *getattr(error, "exceptions", ()),
64
+ error.__cause__,
65
+ error.__context__,
66
+ ]
67
+ for nested in nested_errors:
68
+ if nested is not None and (classified := mcp_auth_error(nested, integration_id, seen)):
69
+ return classified
70
+ status = getattr(error, "status_code", None) or getattr(
71
+ getattr(error, "response", None), "status_code", None
72
+ )
73
+ code = getattr(getattr(error, "error", None), "code", None)
74
+ if isinstance(error, (PermissionError, PermissionDenied)) or status == 403:
75
+ return AuthError("MCP_PERMISSION_DENIED", "MCP permission denied.", 403, integration_id)
76
+ if isinstance(error, Unauthenticated) or status == 401:
77
+ return AuthError(
78
+ "MCP_USER_AUTHORIZATION_INVALID",
79
+ "MCP authorization was rejected.",
80
+ 401,
81
+ integration_id,
82
+ )
83
+ if code == -32042:
84
+ return AuthError(
85
+ "MCP_AUTHORIZATION_REQUIRED",
86
+ "Authorize the configured service in Databricks before retrying.",
87
+ 401,
88
+ integration_id,
89
+ )
90
+ return None
91
+
92
+
93
+ def mcp_tool_error(result: Any, integration_id: str) -> AuthError | None:
94
+ """Classify structured MCP authorization failures returned as tool results."""
95
+ structured = getattr(result, "structuredContent", None)
96
+ detail = structured.get("error", structured) if isinstance(structured, dict) else {}
97
+ code = detail.get("code") or detail.get("error_code") if isinstance(detail, dict) else None
98
+ if code in (403, "PERMISSION_DENIED", "MCP_PERMISSION_DENIED"):
99
+ return AuthError("MCP_PERMISSION_DENIED", "MCP permission denied.", 403, integration_id)
100
+ if code in (401, "UNAUTHENTICATED", "MCP_USER_AUTHORIZATION_INVALID"):
101
+ return AuthError(
102
+ "MCP_USER_AUTHORIZATION_INVALID",
103
+ "MCP authorization was rejected.",
104
+ 401,
105
+ integration_id,
106
+ )
107
+ if code == -32042:
108
+ return AuthError(
109
+ "MCP_AUTHORIZATION_REQUIRED",
110
+ "Authorize the configured service in Databricks.",
111
+ 401,
112
+ integration_id,
113
+ )
114
+ return None