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,657 @@
1
+ """Typed, comment-preserving access to an Agent Bricks project's ``agent.toml``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ import re
8
+ import tempfile
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal, cast
12
+
13
+ import tomlkit
14
+ from tomlkit import TOMLDocument
15
+ from tomlkit.exceptions import ParseError
16
+
17
+ from databricks_agentbricks.errors import AgentCliError
18
+ from databricks_agentbricks.project_types import (
19
+ AgentFramework,
20
+ AgentServer,
21
+ parse_framework,
22
+ parse_server,
23
+ )
24
+ from databricks_agentkit.runtime import tool_manifest
25
+
26
+ # agent.toml resource-table names, read by the manifest parsing below (and by `ab dev`/`deploy`).
27
+ MEMORY_STORE_TABLE = "memory_store"
28
+ SESSION_STORE_TABLE = "session_store"
29
+ # The tracing binding (`ab tracing bind` / `unbind`): the `experiment_name` key under [tracing] is
30
+ # the bound MLflow experiment. Its presence means tracing is on; an absent binding means off. `ab
31
+ # init` bootstraps a default name.
32
+ TRACING_TABLE = "tracing"
33
+ EXPERIMENT_NAME_KEY = "experiment_name"
34
+
35
+ _SCHEMA_VERSION = 1
36
+ _SUPPORTED_SCOPE_KINDS = {"table", "volume", "workspace"}
37
+ _SUPPORTED_PERMISSIONS = {"read_only", "read_write"}
38
+ _TOOL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
39
+
40
+
41
+ def _three_part_name(value: str, description: str) -> str:
42
+ if (
43
+ not isinstance(value, str)
44
+ or len(value.split(".")) != 3
45
+ or any(not part for part in value.split("."))
46
+ or any(character.isspace() for character in value)
47
+ ):
48
+ raise AgentCliError(
49
+ f"Invalid {description} {value!r}.",
50
+ hint=f"Use a three-part name: catalog.schema.{description.replace(' ', '_')}.",
51
+ )
52
+ return value
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Scope:
57
+ """One sandbox downscope resource."""
58
+
59
+ kind: str
60
+ value: str
61
+ permission: str = "read_only"
62
+
63
+ def __post_init__(self) -> None:
64
+ if self.kind not in _SUPPORTED_SCOPE_KINDS:
65
+ raise AgentCliError(
66
+ f"Unsupported sandbox scope kind {self.kind!r}.",
67
+ hint=f"Supported scope kinds: {', '.join(sorted(_SUPPORTED_SCOPE_KINDS))}.",
68
+ )
69
+ if self.permission not in _SUPPORTED_PERMISSIONS:
70
+ raise AgentCliError(f"Unsupported sandbox permission {self.permission!r}.")
71
+ if self.kind == "workspace":
72
+ if not self.value.startswith("/Workspace/") or any(
73
+ character in self.value for character in ("\r", "\n", "\t")
74
+ ):
75
+ raise AgentCliError(
76
+ f"Invalid workspace scope {self.value!r}.",
77
+ hint="Workspace paths must begin with /Workspace/.",
78
+ )
79
+ else:
80
+ _three_part_name(self.value, f"{self.kind} scope")
81
+
82
+ @classmethod
83
+ def table(cls, value: str, permission: str = "read_only") -> "Scope":
84
+ return cls(kind="table", value=value, permission=permission)
85
+
86
+ @classmethod
87
+ def volume(cls, value: str, permission: str = "read_only") -> "Scope":
88
+ return cls(kind="volume", value=value, permission=permission)
89
+
90
+ @classmethod
91
+ def workspace(cls, value: str, permission: str = "read_only") -> "Scope":
92
+ return cls(kind="workspace", value=value, permission=permission)
93
+
94
+ @classmethod
95
+ def parse(cls, value: str, permission: str = "read_only") -> "Scope":
96
+ """Parse ``kind:value`` CLI form, defaulting dotted names to volumes."""
97
+ original = value.strip()
98
+ if not original:
99
+ raise AgentCliError("Sandbox scopes cannot be empty.")
100
+ prefix, separator, remainder = original.partition(":")
101
+ if separator and prefix in _SUPPORTED_SCOPE_KINDS:
102
+ return cls(prefix, remainder.strip(), permission)
103
+ if original.startswith("/Workspace/"):
104
+ return cls.workspace(original, permission)
105
+ return cls.volume(original, permission)
106
+
107
+ @property
108
+ def resource(self) -> str:
109
+ return f"{self.kind}:{self.value}"
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class ToolSource:
114
+ """Discriminated source for one tool binding."""
115
+
116
+ kind: str
117
+ service: str | None = None
118
+ function: str | None = None
119
+ space_id: str | None = None
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class ToolPolicy:
124
+ """Protected runtime policy for a tool binding."""
125
+
126
+ downscope: tuple[Scope, ...] = ()
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ToolSpec:
131
+ """One framework-neutral tool binding from ``agent.toml``."""
132
+
133
+ id: str
134
+ source: ToolSource
135
+ policy: ToolPolicy = field(default_factory=ToolPolicy)
136
+ auth: Literal["user", "app"] | None = None
137
+
138
+ def __post_init__(self) -> None:
139
+ source_values = {"kind": self.source.kind}
140
+ for key in ("service", "function", "space_id"):
141
+ value = getattr(self.source, key)
142
+ if value is not None:
143
+ source_values[key] = value
144
+ try:
145
+ tool_manifest.validate_genie_source(
146
+ self.id, source_values, has_downscope=bool(self.policy.downscope)
147
+ )
148
+ except tool_manifest.ToolManifestError as exc:
149
+ raise AgentCliError(str(exc)) from exc
150
+ kind = self.source.kind
151
+ if self.auth is not None and self.auth not in ("user", "app"):
152
+ raise AgentCliError("Tool auth must be 'user' or 'app'.")
153
+ if kind in {"genie_one", "genie_agent"}:
154
+ return
155
+ if not _TOOL_ID.fullmatch(self.id):
156
+ raise AgentCliError(f"Invalid tool id {self.id!r}.")
157
+ if kind == "uc_function" and self.auth == "user":
158
+ raise AgentCliError("UC function auth supports only app/default identity.")
159
+ if kind == "sandbox":
160
+ if self.source.service != "system.ai.sandbox":
161
+ raise AgentCliError("Sandbox tools must bind system.ai.sandbox.")
162
+ if not self.policy.downscope:
163
+ raise AgentCliError("Sandbox tools require at least one scope.")
164
+ elif kind == "mcp":
165
+ if self.source.service is None:
166
+ raise AgentCliError("MCP tools require a service.")
167
+ _three_part_name(self.source.service, "MCP service")
168
+ if self.policy.downscope:
169
+ raise AgentCliError("Generic MCP tools do not accept sandbox scopes.")
170
+ elif kind == "uc_function":
171
+ if self.source.function is None:
172
+ raise AgentCliError("UC function tools require a function name.")
173
+ _three_part_name(self.source.function, "UC function")
174
+ if self.policy.downscope:
175
+ raise AgentCliError("UC function tools do not accept sandbox scopes.")
176
+ elif kind == "python":
177
+ raise AgentCliError(
178
+ "Python tools are code-first and cannot be declared in agent.toml.",
179
+ hint="Remove this entry and wire the tool through framework-native agent code.",
180
+ )
181
+ else:
182
+ raise AgentCliError(f"Unsupported tool source kind {kind!r}.")
183
+
184
+ @classmethod
185
+ def sandbox(
186
+ cls,
187
+ tool_id: str,
188
+ *,
189
+ scopes: Sequence[Scope],
190
+ auth: Literal["user", "app"] | None = None,
191
+ ) -> "ToolSpec":
192
+ return cls(
193
+ id=tool_id,
194
+ source=ToolSource(kind="sandbox", service="system.ai.sandbox"),
195
+ policy=ToolPolicy(tuple(scopes)),
196
+ auth=auth,
197
+ )
198
+
199
+ @classmethod
200
+ def mcp(
201
+ cls, tool_id: str, *, service: str, auth: Literal["user", "app"] | None = None
202
+ ) -> "ToolSpec":
203
+ return cls(id=tool_id, source=ToolSource(kind="mcp", service=service), auth=auth)
204
+
205
+ @classmethod
206
+ def uc_function(
207
+ cls, tool_id: str, *, function: str, auth: Literal["app"] | None = None
208
+ ) -> "ToolSpec":
209
+ return cls(
210
+ id=tool_id,
211
+ source=ToolSource(kind="uc_function", function=function),
212
+ auth=auth,
213
+ )
214
+
215
+ @classmethod
216
+ def genie_one(
217
+ cls,
218
+ tool_id: str = "genie_one",
219
+ *,
220
+ auth: Literal["user", "app"] | None = None,
221
+ ) -> "ToolSpec":
222
+ return cls(id=tool_id, source=ToolSource(kind="genie_one"), auth=auth)
223
+
224
+ @classmethod
225
+ def genie_agent(
226
+ cls,
227
+ tool_id: str,
228
+ *,
229
+ space_id: str,
230
+ auth: Literal["user", "app"] | None = None,
231
+ ) -> "ToolSpec":
232
+ return cls(
233
+ id=tool_id,
234
+ source=ToolSource(kind="genie_agent", space_id=space_id),
235
+ auth=auth,
236
+ )
237
+
238
+
239
+ def _required_string(value: object, description: str) -> str:
240
+ if not isinstance(value, str) or not value:
241
+ raise AgentCliError(f"agent.toml must declare {description}.")
242
+ return value
243
+
244
+
245
+ def default_store_name(project_name: str, suffix: str, token: str | None = None) -> str:
246
+ """A store display name derived from the project directory, e.g. ``my-agent`` -> ``my-agent-memory``.
247
+
248
+ Sanitized to the store display-name charset (lower-case alphanumerics and hyphens); a name that
249
+ reduces to nothing (e.g. only punctuation) falls back to ``agent``. An optional per-scaffold
250
+ ``token`` is inserted before the suffix (``my-agent-<token>-memory``) so fresh scaffolds get
251
+ distinct stores while the name still ends with the store kind.
252
+ """
253
+ slug = re.sub(r"[^a-z0-9-]+", "-", project_name.lower()).strip("-")
254
+ middle = f"{token}-" if token else ""
255
+ return f"{slug or 'agent'}-{middle}{suffix}"
256
+
257
+
258
+ def _store_name_from_manifest(value: object, table: str) -> str | None:
259
+ """Read the ``name`` from a ``[memory_store]`` / ``[session_store]`` table, or None if absent."""
260
+ if value is None:
261
+ return None
262
+ if not isinstance(value, Mapping):
263
+ raise AgentCliError(f"agent.toml [{table}] must be a table.")
264
+ return _required_string(cast(Mapping[str, Any], value).get("name"), f"[{table}] name")
265
+
266
+
267
+ def _store_id_from_manifest(value: object) -> str | None:
268
+ """Read the optional bare store ``id`` from a ``[memory_store]`` table, or None if absent."""
269
+ if not isinstance(value, Mapping):
270
+ return None
271
+ store_id = cast(Mapping[str, Any], value).get("id")
272
+ return store_id if isinstance(store_id, str) and store_id else None
273
+
274
+
275
+ def _scope_from_manifest(value: object) -> Scope:
276
+ if not isinstance(value, Mapping):
277
+ raise AgentCliError("Sandbox downscope entries must be TOML tables.")
278
+ value = cast(Mapping[str, Any], value)
279
+ resource = _required_string(value.get("resource"), "a downscope resource")
280
+ permission = value.get("permission", "read_only")
281
+ if not isinstance(permission, str):
282
+ raise AgentCliError("Sandbox downscope permission must be a string.")
283
+ prefix, separator, name = resource.partition(":")
284
+ if not separator:
285
+ raise AgentCliError(
286
+ f"Invalid sandbox downscope resource {resource!r}.",
287
+ hint="Use table:<name>, volume:<name>, or workspace:<path>.",
288
+ )
289
+ return Scope(prefix, name, permission)
290
+
291
+
292
+ def _tool_from_manifest(value: object) -> ToolSpec:
293
+ if not isinstance(value, Mapping):
294
+ raise AgentCliError("Each agent.toml tool must be a TOML table.")
295
+ value = cast(Mapping[str, Any], value)
296
+ source = value.get("source")
297
+ if not isinstance(source, Mapping):
298
+ raise AgentCliError("Each agent.toml tool must declare a source table.")
299
+ source = cast(Mapping[str, Any], source)
300
+ kind = _required_string(source.get("kind"), "a source kind")
301
+ policy_value = value.get("policy", {})
302
+ if not isinstance(policy_value, Mapping):
303
+ raise AgentCliError("Tool policy must be a TOML table.")
304
+ policy_value = cast(Mapping[str, Any], policy_value)
305
+ tool_id = _required_string(value.get("id"), "an id")
306
+ try:
307
+ tool_manifest.validate_genie_source(
308
+ tool_id, source, has_downscope="downscope" in policy_value
309
+ )
310
+ except tool_manifest.ToolManifestError as exc:
311
+ raise AgentCliError(str(exc)) from exc
312
+ downscope_value = policy_value.get("downscope", [])
313
+ if not isinstance(downscope_value, list):
314
+ raise AgentCliError("Tool policy downscope must be an array.")
315
+ return ToolSpec(
316
+ id=tool_id,
317
+ source=ToolSource(
318
+ kind=kind,
319
+ service=source.get("service") if isinstance(source.get("service"), str) else None,
320
+ function=source.get("function") if isinstance(source.get("function"), str) else None,
321
+ space_id=source.get("space_id") if isinstance(source.get("space_id"), str) else None,
322
+ ),
323
+ policy=ToolPolicy(tuple(_scope_from_manifest(item) for item in downscope_value)),
324
+ auth=value.get("auth"),
325
+ )
326
+
327
+
328
+ def _inline_table(values: Mapping[str, str]) -> Any:
329
+ table = tomlkit.inline_table()
330
+ for key, value in values.items():
331
+ table[key] = value
332
+ return table
333
+
334
+
335
+ def _tool_table(spec: ToolSpec) -> Any:
336
+ table = tomlkit.table()
337
+ table.add("id", spec.id)
338
+ if spec.auth is not None:
339
+ table.add("auth", spec.auth)
340
+ source_values = {"kind": spec.source.kind}
341
+ for key in ("service", "function", "space_id"):
342
+ value = getattr(spec.source, key)
343
+ if value is not None:
344
+ source_values[key] = value
345
+ table.add("source", _inline_table(source_values))
346
+ if spec.policy.downscope:
347
+ downscope = tomlkit.array()
348
+ for scope in spec.policy.downscope:
349
+ downscope.append(
350
+ _inline_table({"resource": scope.resource, "permission": scope.permission})
351
+ )
352
+ policy = tomlkit.inline_table()
353
+ policy["downscope"] = downscope
354
+ table.add("policy", policy)
355
+ return table
356
+
357
+
358
+ class AgentProject:
359
+ """Loaded mutable view of a project's canonical agent manifest."""
360
+
361
+ def __init__(
362
+ self,
363
+ root: pathlib.Path,
364
+ document: TOMLDocument,
365
+ framework: AgentFramework,
366
+ server: AgentServer,
367
+ tools: list[ToolSpec],
368
+ memory_store: str | None = None,
369
+ session_store: str | None = None,
370
+ memory_store_id: str | None = None,
371
+ deployment_name: str | None = None,
372
+ trace_experiment_name: str | None = None,
373
+ ) -> None:
374
+ self.root = root
375
+ self.path = root / "agent.toml"
376
+ self._document = document
377
+ self.framework = framework
378
+ # Server selection is deployment behavior, so agent.toml—not hidden template metadata—is
379
+ # the source of truth for whether Agent Bricks provisions and wires a Runtime Store.
380
+ self.server = server
381
+ self.tools = tools
382
+ # Managed store bindings declared in agent.toml; None = unbound. memory_store_id is the bare
383
+ # store id the runtime needs for the entries API (the display name can't be used there).
384
+ self.memory_store = memory_store
385
+ self.session_store = session_store
386
+ self.memory_store_id = memory_store_id
387
+ # Bare deployment names get the `agent-bricks-` prefix.
388
+ self.deployment_name = deployment_name
389
+ # Tracing config: the MLflow experiment NAME to trace to (a workspace path). Its presence IS
390
+ # the enable switch: a bound name means tracing is on (deploy get-or-creates it); None means
391
+ # unbound, i.e. off. Storing a name (not an id) keeps the binding valid across workspaces and
392
+ # profiles, since an id is workspace-local. `ab init` bootstraps a default name.
393
+ self.trace_experiment_name = trace_experiment_name
394
+
395
+ @classmethod
396
+ def load(cls, root: pathlib.Path | str | None = None) -> "AgentProject":
397
+ try:
398
+ project_root = (
399
+ tool_manifest.project_root()
400
+ if root is None
401
+ else pathlib.Path(root).expanduser().resolve()
402
+ )
403
+ except RuntimeError as exc:
404
+ raise AgentCliError(str(exc)) from exc
405
+ path = project_root / "agent.toml"
406
+ try:
407
+ document = tomlkit.parse(path.read_text(encoding="utf-8"))
408
+ except FileNotFoundError as exc:
409
+ raise AgentCliError(
410
+ f"Could not find agent.toml in {project_root}.",
411
+ hint="This command needs an Agent Bricks project. Run `ab init` to create one, "
412
+ "or point at an existing project with --source <dir>.",
413
+ ) from exc
414
+ except (OSError, ParseError) as exc:
415
+ raise AgentCliError(f"Could not read agent manifest at {path}: {exc}.") from exc
416
+ if document.get("schema_version") != _SCHEMA_VERSION:
417
+ raise AgentCliError(
418
+ f"Unsupported agent manifest schema in {path}.",
419
+ hint=f"Expected schema_version = {_SCHEMA_VERSION}.",
420
+ )
421
+ agent = document.get("agent")
422
+ if not isinstance(agent, Mapping):
423
+ raise AgentCliError("agent.toml must declare an [agent] table.")
424
+ framework = parse_framework(_required_string(agent.get("framework"), "agent.framework"))
425
+ server = parse_server(_required_string(agent.get("server"), "agent.server"))
426
+ deployment_name = agent.get("deployment_name")
427
+ if deployment_name is not None and not (
428
+ isinstance(deployment_name, str) and deployment_name
429
+ ):
430
+ raise AgentCliError("agent.toml [agent] deployment_name must be a non-empty string.")
431
+ raw_tools = document.get("tools", [])
432
+ if not isinstance(raw_tools, list):
433
+ raise AgentCliError("agent.toml tools must be an array of tables.")
434
+ tools = [_tool_from_manifest(item) for item in raw_tools]
435
+ ids = [tool.id for tool in tools]
436
+ if len(ids) != len(set(ids)):
437
+ raise AgentCliError("agent.toml tool ids must be unique.")
438
+ memory_store = _store_name_from_manifest(
439
+ document.get(MEMORY_STORE_TABLE), MEMORY_STORE_TABLE
440
+ )
441
+ memory_store_id = _store_id_from_manifest(document.get(MEMORY_STORE_TABLE))
442
+ session_store = _store_name_from_manifest(
443
+ document.get(SESSION_STORE_TABLE), SESSION_STORE_TABLE
444
+ )
445
+ tracing_table = document.get(TRACING_TABLE)
446
+ trace_experiment_name: str | None = None
447
+ if isinstance(tracing_table, Mapping):
448
+ raw_experiment = tracing_table.get(EXPERIMENT_NAME_KEY)
449
+ trace_experiment_name = (
450
+ str(raw_experiment) if isinstance(raw_experiment, str) and raw_experiment else None
451
+ )
452
+ return cls(
453
+ project_root,
454
+ document,
455
+ framework,
456
+ server,
457
+ tools,
458
+ memory_store,
459
+ session_store,
460
+ memory_store_id,
461
+ str(deployment_name) if deployment_name is not None else None,
462
+ trace_experiment_name,
463
+ )
464
+
465
+ @classmethod
466
+ def create(
467
+ cls,
468
+ root: pathlib.Path | str,
469
+ *,
470
+ framework: str,
471
+ server: str,
472
+ memory_store: str | None = None,
473
+ session_store: str | None = None,
474
+ experiment_name: str | None = None,
475
+ ) -> "AgentProject":
476
+ selected_framework = parse_framework(framework)
477
+ selected_server = parse_server(server)
478
+ project_root = pathlib.Path(root).expanduser().resolve()
479
+ document = tomlkit.document()
480
+ document.add("schema_version", _SCHEMA_VERSION)
481
+ document.add(tomlkit.nl())
482
+ agent = tomlkit.table()
483
+ agent.add("framework", selected_framework.value)
484
+ agent.add("server", selected_server.value)
485
+ document.add("agent", agent)
486
+ project = cls(
487
+ project_root,
488
+ document,
489
+ selected_framework,
490
+ selected_server,
491
+ [],
492
+ )
493
+ if memory_store:
494
+ project.bind_memory_store(memory_store)
495
+ if session_store:
496
+ project.bind_session_store(session_store)
497
+ if experiment_name:
498
+ project.bind_tracing(experiment_name)
499
+ return project
500
+
501
+ def set_deployment_name(self, name: str) -> bool:
502
+ """Record the deployment's base name under [agent].deployment_name. True if it changed."""
503
+ name = _required_string(name, "[agent] deployment_name")
504
+ if self.deployment_name == name:
505
+ return False
506
+ agent = self._document.get("agent")
507
+ if not isinstance(agent, Mapping):
508
+ raise AgentCliError("agent.toml must declare an [agent] table.")
509
+ agent["deployment_name"] = name
510
+ self.deployment_name = name
511
+ return True
512
+
513
+ def add_tool(self, spec: ToolSpec) -> bool:
514
+ for existing in self.tools:
515
+ if existing.id != spec.id:
516
+ continue
517
+ if existing == spec:
518
+ return False
519
+
520
+ def _summary(s: ToolSpec) -> str:
521
+ src = s.source
522
+ return src.service or src.function or src.space_id or src.kind
523
+
524
+ raise AgentCliError(
525
+ f"Tool id {spec.id!r} already exists with a different configuration "
526
+ f"(existing: {_summary(existing)}; requested: {_summary(spec)}).",
527
+ hint="Use --name to add it under a different id, or remove the existing "
528
+ "tool from agent.toml first.",
529
+ )
530
+ raw_tools = self._document.get("tools")
531
+ if raw_tools is None:
532
+ raw_tools = tomlkit.aot()
533
+ self._document.append("tools", raw_tools)
534
+ elif not hasattr(raw_tools, "append"):
535
+ raise AgentCliError("agent.toml tools must be an array of tables.")
536
+ raw_tools.append(_tool_table(spec))
537
+ self.tools.append(spec)
538
+ return True
539
+
540
+ def remove_tool(self, tool_id: str) -> bool:
541
+ index = next((index for index, tool in enumerate(self.tools) if tool.id == tool_id), None)
542
+ if index is None:
543
+ return False
544
+ raw_tools = self._document.get("tools")
545
+ if not isinstance(raw_tools, list):
546
+ raise AgentCliError("agent.toml tools must be an array of tables.")
547
+ del raw_tools[index]
548
+ del self.tools[index]
549
+ return True
550
+
551
+ def bind_memory_store(self, name: str, store_id: str | None = None) -> bool:
552
+ """Declare the memory store binding in agent.toml. Returns True if it changed.
553
+
554
+ ``store_id`` is the bare store id (``memory-stores/<id>`` minus the prefix). `ab deploy`
555
+ resolves the id fresh and injects it via ``AGENT_MEMORY_STORE``; the field is only recorded for
556
+ legacy/hand-written bindings that pin the id in the manifest.
557
+ """
558
+ return self._set_store(MEMORY_STORE_TABLE, name, store_id)
559
+
560
+ def bind_session_store(self, name: str) -> bool:
561
+ """Declare the session store binding in agent.toml. Returns True if it changed."""
562
+ return self._set_store(SESSION_STORE_TABLE, name)
563
+
564
+ def unbind_memory_store(self) -> bool:
565
+ """Remove the memory store binding from agent.toml. Returns True if it was present."""
566
+ return self._clear_store(MEMORY_STORE_TABLE)
567
+
568
+ def unbind_session_store(self) -> bool:
569
+ """Remove the session store binding from agent.toml. Returns True if it was present."""
570
+ return self._clear_store(SESSION_STORE_TABLE)
571
+
572
+ def bind_tracing(self, experiment_name: str) -> bool:
573
+ """Bind tracing to an MLflow experiment NAME (an experiment path). Returns True if changed.
574
+
575
+ The binding's presence is the enable switch: a bound name means tracing is on (deploy
576
+ get-or-creates it). Storing a name (not an id) keeps the binding portable across
577
+ workspaces/profiles.
578
+ """
579
+ experiment_name = _required_string(
580
+ experiment_name, f"[{TRACING_TABLE}] {EXPERIMENT_NAME_KEY}"
581
+ )
582
+ if self.trace_experiment_name == experiment_name:
583
+ return False
584
+ table = self._document.get(TRACING_TABLE)
585
+ if not isinstance(table, Mapping):
586
+ table = tomlkit.table()
587
+ self._document.append(TRACING_TABLE, table)
588
+ table[EXPERIMENT_NAME_KEY] = experiment_name
589
+ self.trace_experiment_name = experiment_name
590
+ return True
591
+
592
+ def unbind_tracing(self) -> bool:
593
+ """Unbind tracing: remove the experiment binding so tracing is off. Returns True if changed."""
594
+ if self.trace_experiment_name is None:
595
+ return False
596
+ table = self._document.get(TRACING_TABLE)
597
+ if isinstance(table, Mapping) and EXPERIMENT_NAME_KEY in table:
598
+ del table[EXPERIMENT_NAME_KEY]
599
+ if isinstance(table, Mapping) and not table:
600
+ del self._document[TRACING_TABLE]
601
+ self.trace_experiment_name = None
602
+ return True
603
+
604
+ def _set_store(self, table: str, name: str, store_id: str | None = None) -> bool:
605
+ name = _required_string(name, f"[{table}] name")
606
+ if getattr(self, table) == name and getattr(self, f"{table}_id", None) == store_id:
607
+ return False
608
+ existing = self._document.get(table)
609
+ if isinstance(existing, Mapping):
610
+ existing["name"] = name
611
+ if store_id:
612
+ existing["id"] = store_id
613
+ elif "id" in existing:
614
+ del existing["id"]
615
+ else:
616
+ store_table = tomlkit.table()
617
+ store_table.add("name", name)
618
+ if store_id:
619
+ store_table.add("id", store_id)
620
+ self._document.append(table, store_table)
621
+ setattr(self, table, name)
622
+ if hasattr(self, f"{table}_id"):
623
+ setattr(self, f"{table}_id", store_id)
624
+ return True
625
+
626
+ def _clear_store(self, table: str) -> bool:
627
+ if getattr(self, table) is None:
628
+ return False
629
+ if table in self._document:
630
+ del self._document[table]
631
+ setattr(self, table, None)
632
+ if hasattr(self, f"{table}_id"):
633
+ setattr(self, f"{table}_id", None)
634
+ return True
635
+
636
+ def write(self) -> pathlib.Path:
637
+ self.path.parent.mkdir(parents=True, exist_ok=True)
638
+ temporary: pathlib.Path | None = None
639
+ try:
640
+ with tempfile.NamedTemporaryFile(
641
+ mode="w",
642
+ encoding="utf-8",
643
+ dir=self.path.parent,
644
+ prefix=f".{self.path.name}.",
645
+ suffix=".tmp",
646
+ delete=False,
647
+ ) as output:
648
+ temporary = pathlib.Path(output.name)
649
+ output.write(tomlkit.dumps(self._document))
650
+ output.flush()
651
+ os.fsync(output.fileno())
652
+ os.replace(temporary, self.path)
653
+ except OSError as exc:
654
+ if temporary is not None:
655
+ temporary.unlink(missing_ok=True)
656
+ raise AgentCliError(f"Could not write agent manifest at {self.path}: {exc}.") from exc
657
+ return self.path