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,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
|