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,149 @@
1
+ """Transient authentication for request-bound, user-authorized tool calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, NoReturn, SupportsIndex
11
+
12
+ from databricks_agentkit.runtime.store import RUNTIME_STORE_LOCAL_ENV
13
+
14
+ if TYPE_CHECKING:
15
+ from databricks.sdk import WorkspaceClient
16
+
17
+
18
+ class AuthError(RuntimeError):
19
+ """A credential-free error suitable for returning to the caller."""
20
+
21
+ def __init__(
22
+ self, code: str, message: str, status_code: int = 401, integration_id: str | None = None
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.code = code
26
+ self.status_code = status_code
27
+ self.integration_id = integration_id
28
+
29
+ def payload(self) -> dict[str, str]:
30
+ result = {"code": self.code, "message": str(self)}
31
+ if self.integration_id is not None:
32
+ result["integration_id"] = self.integration_id
33
+ return result
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class InvocationAuthPolicy:
38
+ """Whether an invocation needs credentials scoped to its active execution attempt."""
39
+
40
+ user_tools: tuple[str, ...] = ()
41
+
42
+ @property
43
+ def requires_user(self) -> bool:
44
+ return bool(self.user_tools)
45
+
46
+ @classmethod
47
+ def from_manifest(cls, *, allow_missing: bool = False) -> InvocationAuthPolicy:
48
+ """Infer request-user tools from ``agent.toml`` in the active project."""
49
+ from databricks_agentkit.runtime.tool_manifest import load_tools, project_root, tomllib
50
+
51
+ try:
52
+ root = project_root()
53
+ except RuntimeError:
54
+ if allow_missing and not os.getenv("AGENTBRICKS_PROJECT_ROOT"):
55
+ return cls()
56
+ raise
57
+ with (root / "agent.toml").open("rb") as source:
58
+ document = tomllib.load(source)
59
+ tools = load_tools(expected_framework=document.get("agent", {}).get("framework", ""))
60
+ return cls(tuple(tool.id for tool in tools if tool.auth == "user"))
61
+
62
+
63
+ class RequestAuthContext:
64
+ """Private request credentials, never part of a persisted invocation payload.
65
+
66
+ Deployed Apps must only be reachable through the trusted Apps ingress, which supplies the
67
+ forwarded identity headers. Local development deliberately ignores those headers.
68
+ """
69
+
70
+ __slots__ = (
71
+ "_token",
72
+ "_principal",
73
+ "_host",
74
+ "_app",
75
+ "_workspace",
76
+ "_local",
77
+ "_clients",
78
+ "_closed",
79
+ )
80
+
81
+ def __init__(self, *, token: str | None, principal: str, local: bool) -> None:
82
+ self._token = token
83
+ self._principal = principal
84
+ self._host = os.getenv("DATABRICKS_HOST", "").rstrip("/")
85
+ self._app = os.getenv("DATABRICKS_APP_NAME", "local")
86
+ self._workspace = os.getenv("DATABRICKS_WORKSPACE_ID", "")
87
+ self._local = local
88
+ self._clients: dict[str, WorkspaceClient] = {}
89
+ self._closed = False
90
+
91
+ @classmethod
92
+ def from_headers(cls, headers: Mapping[str, str]) -> RequestAuthContext:
93
+ local_runtime = os.getenv(RUNTIME_STORE_LOCAL_ENV, "").lower() == "true"
94
+ if local_runtime or not os.getenv("DATABRICKS_APP_NAME"):
95
+ return cls(token=None, principal="local-developer", local=True)
96
+ normalized = {name.lower(): value for name, value in headers.items()}
97
+ token = normalized.get("x-forwarded-access-token", "").strip()
98
+ principal = normalized.get("x-forwarded-user", "").strip()
99
+ if not token:
100
+ raise AuthError("MCP_USER_AUTH_REQUIRED", "Missing request-user access token")
101
+ if not principal:
102
+ raise AuthError("MCP_USER_IDENTITY_MISSING", "Missing trusted request-user identity")
103
+ return cls(token=token, principal=principal, local=False)
104
+
105
+ def __repr__(self) -> str:
106
+ return f"RequestAuthContext(closed={self._closed})"
107
+
108
+ def __reduce_ex__(self, protocol: SupportsIndex, /) -> NoReturn:
109
+ raise TypeError("RequestAuthContext cannot be serialized")
110
+
111
+ def namespace(self, kind: str, value: str) -> str:
112
+ material = json.dumps(
113
+ [self._host, self._workspace, self._app, self._principal, kind, value],
114
+ separators=(",", ":"),
115
+ )
116
+ return hashlib.sha256(material.encode()).hexdigest()
117
+
118
+ def client_for(self, mode: str) -> WorkspaceClient:
119
+ if self._closed:
120
+ raise AuthError(
121
+ "MCP_USER_AUTH_EXPIRED", "Request-user authentication is no longer active"
122
+ )
123
+ if mode not in ("user", "app"):
124
+ raise ValueError("auth must be 'user' or 'app'")
125
+ if mode not in self._clients:
126
+ from databricks_agentkit.runtime.workspace import workspace_client, workspace_headers
127
+
128
+ if mode == "app" or self._local:
129
+ self._clients[mode] = workspace_client()
130
+ else:
131
+ from databricks.sdk import WorkspaceClient
132
+
133
+ if not self._host:
134
+ raise AuthError(
135
+ "MCP_USER_AUTH_CONFIGURATION", "Workspace host is not configured", 500
136
+ )
137
+ self._clients[mode] = WorkspaceClient(
138
+ host=self._host,
139
+ token=self._token,
140
+ auth_type="pat",
141
+ custom_headers=workspace_headers(),
142
+ )
143
+ return self._clients[mode]
144
+
145
+ def close(self) -> None:
146
+ """Drop credentials and prohibit further client resolution."""
147
+ self._closed = True
148
+ self._token = None
149
+ self._clients.clear()
@@ -0,0 +1 @@
1
+ """Durability helpers for the canonical AgentKit runtime."""
@@ -0,0 +1,133 @@
1
+ """Durable scheduling and execution of fenced invocation attempts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import Callable
8
+
9
+ from databricks_agentkit.runtime.durability.heartbeat import Heartbeat
10
+ from databricks_agentkit.runtime.durability.recovery import RecoveryScheduler
11
+ from databricks_agentkit.runtime.durability.store import DurableRuntimeStore
12
+ from databricks_agentkit.runtime.execution import AttemptExecution, InvocationExecutor
13
+ from databricks_agentkit.runtime.types import Invocation, InvocationStatus
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DurableInvocationExecutor(InvocationExecutor):
19
+ """Claim durable attempts and wrap shared execution with heartbeats."""
20
+
21
+ def __init__(
22
+ self,
23
+ execution: AttemptExecution,
24
+ *,
25
+ runtime_store: DurableRuntimeStore,
26
+ recovery_enabled: Callable[[], bool],
27
+ heartbeat_seconds: float,
28
+ stale_seconds: float,
29
+ scan_seconds: float,
30
+ ) -> None:
31
+ if stale_seconds <= heartbeat_seconds:
32
+ raise ValueError("stale_seconds must be greater than heartbeat_seconds")
33
+ self._execution = execution
34
+ self._runtime_store = runtime_store
35
+ self._recovery_enabled = recovery_enabled
36
+ self._heartbeat_seconds = heartbeat_seconds
37
+ self._stale_seconds = stale_seconds
38
+ self._scan_seconds = scan_seconds
39
+ self._tasks: dict[str, asyncio.Task[None]] = {}
40
+ self._queued_scanner: asyncio.Task[None] | None = None
41
+ self._recovery_scheduler: RecoveryScheduler | None = None
42
+
43
+ async def start(self) -> None:
44
+ self._queued_scanner = asyncio.create_task(
45
+ self._scan_queued_loop(),
46
+ name="databricks-durable-runtime-queued-scanner",
47
+ )
48
+ self._recovery_scheduler = RecoveryScheduler(
49
+ scheduler=self,
50
+ runtime_store=self._runtime_store,
51
+ stale_seconds=self._stale_seconds,
52
+ scan_seconds=self._scan_seconds,
53
+ )
54
+ self._recovery_scheduler.start(recover=self._recovery_enabled())
55
+
56
+ async def stop(self) -> None:
57
+ if self._queued_scanner is not None:
58
+ self._queued_scanner.cancel()
59
+ if self._recovery_scheduler is not None:
60
+ await self._recovery_scheduler.stop()
61
+ self._recovery_scheduler = None
62
+ await asyncio.gather(
63
+ *([self._queued_scanner] if self._queued_scanner is not None else []),
64
+ return_exceptions=True,
65
+ )
66
+ self._queued_scanner = None
67
+ tasks = list(self._tasks.values())
68
+ for task in tasks:
69
+ task.cancel()
70
+ await asyncio.gather(*tasks, return_exceptions=True)
71
+ self._tasks.clear()
72
+
73
+ def ensure_scheduled(self, state: Invocation) -> None:
74
+ """Schedule a newly queued invocation on this worker."""
75
+ if state.status != InvocationStatus.QUEUED:
76
+ return
77
+ self._schedule(state.invocation_id, recovery=False)
78
+
79
+ def ensure_recovery_scheduled(self, invocation_id: str) -> None:
80
+ """Schedule stale active work discovered by the recovery scanner."""
81
+ self._schedule(invocation_id, recovery=True)
82
+
83
+ async def _scan_queued_loop(self) -> None:
84
+ """Continuously schedule persisted first attempts, including after process restart."""
85
+ while True:
86
+ try:
87
+ for invocation_id in await self._runtime_store.queued_invocation_ids():
88
+ self._schedule(invocation_id, recovery=False)
89
+ except asyncio.CancelledError:
90
+ raise
91
+ except Exception:
92
+ logger.exception("Databricks durable runtime queued invocation scan failed")
93
+ await asyncio.sleep(self._scan_seconds)
94
+
95
+ def _schedule(self, invocation_id: str, *, recovery: bool) -> None:
96
+ current = self._tasks.get(invocation_id)
97
+ if current is not None and not current.done():
98
+ return
99
+ task = asyncio.create_task(
100
+ self._run(invocation_id, recovery=recovery),
101
+ name=f"durable-invocation-{invocation_id}",
102
+ )
103
+ self._tasks[invocation_id] = task
104
+ task.add_done_callback(lambda completed: self._discard_task(invocation_id, completed))
105
+
106
+ async def _run(self, invocation_id: str, *, recovery: bool) -> None:
107
+ try:
108
+ if recovery:
109
+ claimed = await self._runtime_store.claim_recoverable(
110
+ invocation_id,
111
+ self._stale_seconds,
112
+ )
113
+ else:
114
+ claimed = await self._runtime_store.claim(invocation_id)
115
+ except Exception:
116
+ logger.exception("Failed to claim durable invocation: %s", invocation_id)
117
+ return
118
+ if claimed is None:
119
+ return
120
+
121
+ async with Heartbeat(
122
+ runtime_store=self._runtime_store,
123
+ invocation_id=invocation_id,
124
+ attempt=claimed.attempt,
125
+ heartbeat_seconds=self._heartbeat_seconds,
126
+ ):
127
+ await self._execution.run(claimed)
128
+
129
+ def _discard_task(self, invocation_id: str, completed: asyncio.Task[None]) -> None:
130
+ if self._tasks.get(invocation_id) is completed:
131
+ self._tasks.pop(invocation_id, None)
132
+ if not completed.cancelled():
133
+ completed.exception()
@@ -0,0 +1,64 @@
1
+ """Heartbeat lifecycle for a durable invocation attempt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+
8
+ from databricks_agentkit.runtime.durability.store import DurableRuntimeStore
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Heartbeat:
14
+ """Refresh attempt ownership until execution finishes or ownership changes."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ runtime_store: DurableRuntimeStore,
20
+ invocation_id: str,
21
+ attempt: int,
22
+ heartbeat_seconds: float,
23
+ ) -> None:
24
+ if heartbeat_seconds <= 0:
25
+ raise ValueError("heartbeat_seconds must be positive")
26
+ self._runtime_store = runtime_store
27
+ self._invocation_id = invocation_id
28
+ self._attempt = attempt
29
+ self._heartbeat_seconds = heartbeat_seconds
30
+ self._task: asyncio.Task[None] | None = None
31
+
32
+ async def __aenter__(self) -> Heartbeat:
33
+ self._task = asyncio.create_task(
34
+ self._run(),
35
+ name=f"durable-heartbeat-{self._invocation_id}-{self._attempt}",
36
+ )
37
+ return self
38
+
39
+ async def __aexit__(self, exc_type, exc, traceback) -> None:
40
+ if self._task is None:
41
+ return
42
+ self._task.cancel()
43
+ await asyncio.gather(self._task, return_exceptions=True)
44
+ self._task = None
45
+
46
+ async def _run(self) -> None:
47
+ while True:
48
+ try:
49
+ owns_attempt = await self._runtime_store.heartbeat(
50
+ self._invocation_id,
51
+ self._attempt,
52
+ )
53
+ except Exception:
54
+ logger.warning(
55
+ "Durable heartbeat failed: %s attempt=%d",
56
+ self._invocation_id,
57
+ self._attempt,
58
+ exc_info=True,
59
+ )
60
+ await asyncio.sleep(self._heartbeat_seconds)
61
+ continue
62
+ if not owns_attempt:
63
+ return
64
+ await asyncio.sleep(self._heartbeat_seconds)