@dbx-tools/appkit-mastra 0.3.29 → 0.3.31

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.
package/src/config.ts CHANGED
@@ -1,16 +1,24 @@
1
1
  /**
2
2
  * Plugin configuration types and shared `RequestContext` keys.
3
3
  *
4
+ * Owns the typed {@link MastraPluginConfig} (the plugin's slice of AppKit
5
+ * config) and {@link MASTRA_CONFIG_SCHEMA}, the JSON Schema the manifest
6
+ * publishes for it so scaffolding tools and agents can read the option set.
7
+ *
8
+ * Precedence per field is explicit plugin config, then the environment
9
+ * variable named on the field, then a built-in default.
10
+ *
4
11
  * Kept in a leaf module so `plugin.ts`, `server.ts`, `model.ts`, and
5
12
  * `memory.ts` can import them without creating a cycle.
6
13
  *
7
14
  * @module
8
15
  */
9
16
 
10
- import type { BasePluginConfig } from "@databricks/appkit";
17
+ import { getExecutionContext, type BasePluginConfig, type ConfigSchema } from "@databricks/appkit";
11
18
  import { appkit } from "@dbx-tools/appkit";
12
19
  import type { BrandContext } from "@dbx-tools/shared-core";
13
20
  import type { AgentConfig } from "@mastra/core/agent";
21
+ import type { RequestContext } from "@mastra/core/request-context";
14
22
  import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
15
23
  import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
16
24
 
@@ -83,6 +91,28 @@ export interface User {
83
91
  executionContext: appkit.ExecutionContextLike;
84
92
  }
85
93
 
94
+ /**
95
+ * Canonical identity for an AppKit execution context: the OBO user id on a
96
+ * user-scoped call, the service principal id otherwise.
97
+ */
98
+ export function executionContextUserId(context: appkit.ExecutionContextLike): string {
99
+ return "userId" in context ? context.userId : context.serviceUserId;
100
+ }
101
+
102
+ /**
103
+ * Identity every per-user cache entry is namespaced under, so an OBO result
104
+ * cannot be read back by another caller.
105
+ *
106
+ * Prefers the {@link User} that {@link MastraServer} stamps on the request
107
+ * context. The MCP transport routes do not thread that context into tool
108
+ * execution, so those fall back to the ambient execution context (the active
109
+ * OBO scope, or the service principal).
110
+ */
111
+ export function resolveUserKey(requestContext?: RequestContext): string {
112
+ const user = requestContext?.get(MASTRA_USER_KEY) as User | undefined;
113
+ return user?.id ?? executionContextUserId(getExecutionContext());
114
+ }
115
+
86
116
  /** PgVector config with an optional Mastra store id. */
87
117
  export type MastraMemoryConfig = PgVectorConfig & {
88
118
  id?: string;
@@ -129,7 +159,7 @@ export interface MastraMcpConfig {
129
159
 
130
160
  /** Configuration accepted by the Mastra AppKit plugin. */
131
161
  export interface MastraPluginConfig extends BasePluginConfig {
132
- /** Mastra OpenAI-compatible provider id. Defaults to `"databricks"`. */
162
+ /** Mastra OpenAI-compatible provider id. Defaults to `"databricks"`; no env fallback. */
133
163
  providerId?: string;
134
164
  /**
135
165
  * PostgresStore for Mastra threads/messages. `true` reuses the
@@ -213,34 +243,40 @@ export interface MastraPluginConfig extends BasePluginConfig {
213
243
  * or `providerId`.
214
244
  *
215
245
  * Resolution order per agent: `def.model` → `defaultModel` →
216
- * built-in `/serving-endpoints` resolver.
246
+ * `DATABRICKS_SERVING_ENDPOINT_NAME` → built-in `/serving-endpoints`
247
+ * resolver.
217
248
  */
218
249
  defaultModel?: AgentConfig["model"] | string;
219
250
  /**
220
- * Allow loose model names (`"claude sonnet"`) to be fuzzy-matched
221
- * against the workspace's Model Serving endpoints. Defaults to
222
- * `true`; set `false` to require exact endpoint names everywhere.
251
+ * Fuzzy-match loose model names (`"claude sonnet"`) against the workspace's
252
+ * Model Serving endpoints. Defaults to `true`; no env fallback.
253
+ *
254
+ * Set `false` to require exact endpoint names everywhere.
223
255
  */
224
256
  modelFuzzyMatch?: boolean;
225
257
  /**
226
- * Fuse.js score threshold for the fuzzy matcher (0 = exact match,
227
- * 1 = anything matches). Defaults to `0.4`. Lower values reject
228
- * loose matches; raise it if you have a sprawling endpoint
229
- * catalogue with similar-looking names.
258
+ * Fuse.js score threshold for the fuzzy matcher, 0 (exact) to 1 (anything).
259
+ * Defaults to `0.4`; no env fallback.
260
+ *
261
+ * Lower values reject loose matches; raise it if you have a sprawling
262
+ * endpoint catalogue with similar-looking names.
230
263
  */
231
264
  modelFuzzyThreshold?: number;
232
265
  /**
233
- * TTL for the in-memory serving-endpoints list cache, in
234
- * milliseconds. Defaults to 5 minutes. The cache is per workspace
235
- * host and shared across users; concurrent callers coalesce on a
236
- * single in-flight fetch.
266
+ * TTL for the in-memory serving-endpoints list cache, in milliseconds.
267
+ * Defaults to 5 minutes; no env fallback.
268
+ *
269
+ * The cache is per workspace host and shared across users; concurrent
270
+ * callers coalesce on a single in-flight fetch.
237
271
  */
238
272
  modelCacheTtlMs?: number;
239
273
  /**
240
- * Allow clients to override the active model per request via the
241
- * `X-Mastra-Model` header, `?model=` query string, or `model` body
242
- * field. Defaults to `true`. Disable when running multi-tenant
243
- * where untrusted clients shouldn't pick the backing endpoint.
274
+ * Let clients pick the backing endpoint per request. Defaults to `true`;
275
+ * no env fallback.
276
+ *
277
+ * Reads the `X-Mastra-Model` header, the `?model=` query string, or a
278
+ * `model` body field, in that order. Disable when running multi-tenant
279
+ * where untrusted clients shouldn't choose the endpoint.
244
280
  */
245
281
  modelOverride?: boolean;
246
282
  /**
@@ -459,3 +495,119 @@ export interface MastraPluginConfig extends BasePluginConfig {
459
495
  */
460
496
  brand?: BrandContext;
461
497
  }
498
+
499
+ /**
500
+ * JSON Schema published on the manifest's `config.schema`, mirroring the
501
+ * documented defaults and environment fallbacks of {@link MastraPluginConfig}.
502
+ *
503
+ * Covers the JSON-expressible options only. `agents`, `tools`, and a
504
+ * `defaultModel` passed as a Mastra `DynamicArgument` are code-defined
505
+ * (functions / class instances), so they carry no schema entry; the
506
+ * `defaultModel` property below describes its string form.
507
+ */
508
+ export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
509
+ type: "object",
510
+ properties: {
511
+ providerId: {
512
+ type: "string",
513
+ description: 'Mastra OpenAI-compatible provider id. Defaults to "databricks".',
514
+ },
515
+ storage: {
516
+ type: ["boolean", "object"],
517
+ description:
518
+ "PostgresStore for Mastra threads / messages. `true` reuses the `lakebase` plugin's pool, an object opens a dedicated store. Auto-enabled when the `lakebase` plugin is registered.",
519
+ },
520
+ memory: {
521
+ type: ["boolean", "object"],
522
+ description:
523
+ "PgVector store for Mastra semantic recall. `true` reuses the `lakebase` plugin's pool, an object opens a dedicated store. Auto-enabled when the `lakebase` plugin is registered.",
524
+ },
525
+ defaultAgent: {
526
+ type: "string",
527
+ description:
528
+ "Agent id used when the client names none. Defaults to the first registered agent, else the built-in `default`.",
529
+ },
530
+ defaultModel: {
531
+ type: "string",
532
+ description:
533
+ "Serving endpoint applied to every agent that omits its own model. Falls back to DATABRICKS_SERVING_ENDPOINT_NAME, then the auto-resolved catalogue.",
534
+ },
535
+ defaultModelFallbacks: {
536
+ type: "array",
537
+ items: { type: "string" },
538
+ description:
539
+ "Priority-ordered endpoint names tried before the score-classified catalogue when nothing else pins a model.",
540
+ },
541
+ modelFuzzyMatch: {
542
+ type: "boolean",
543
+ description:
544
+ "Fuzzy-match loose model names against the workspace catalogue. Defaults to true.",
545
+ },
546
+ modelFuzzyThreshold: {
547
+ type: "number",
548
+ description:
549
+ "Fuse.js score threshold for the fuzzy matcher, 0 (exact) to 1 (anything). Defaults to 0.4.",
550
+ },
551
+ modelCacheTtlMs: {
552
+ type: "number",
553
+ description:
554
+ "TTL in ms for the serving-endpoints list cache, per workspace host. Defaults to 5 minutes.",
555
+ },
556
+ modelOverride: {
557
+ type: "boolean",
558
+ description:
559
+ "Honor a per-request model override from the X-Mastra-Model header, ?model= query, or a `model` body field. Defaults to true.",
560
+ },
561
+ genieSpaces: {
562
+ type: "object",
563
+ additionalProperties: { type: ["string", "object"] },
564
+ description:
565
+ "Genie spaces the agents can delegate to, keyed by alias (the tool-name suffix). Each value is a space id or `{ spaceId, hint }`. Falls back to the `genie` plugin's own `spaces` config, then DATABRICKS_GENIE_SPACE_ID under the `default` alias.",
566
+ },
567
+ genieSpaceCacheTtlMs: {
568
+ type: "number",
569
+ description: "TTL in ms for the Genie space metadata cache. Defaults to 5 minutes.",
570
+ },
571
+ agentMaxSteps: {
572
+ type: "number",
573
+ description:
574
+ "Maximum LLM steps each agent gets per turn (a tool call and the final reply each consume one). Defaults to 25.",
575
+ },
576
+ stripStaleCharts: {
577
+ type: "boolean",
578
+ description:
579
+ "Strip chartIds from recalled assistant tool results so the model cannot reuse a turn-scoped chart marker. Defaults to true.",
580
+ },
581
+ styleInstructions: {
582
+ type: ["string", "boolean"],
583
+ description:
584
+ "Style guardrails appended to every agent's instructions. A string replaces the built-in block, `false` disables it.",
585
+ },
586
+ observability: {
587
+ type: "boolean",
588
+ description:
589
+ "Bridge Mastra spans into AppKit's OTel pipeline. Defaults to on only when OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set.",
590
+ },
591
+ feedback: {
592
+ type: "boolean",
593
+ description:
594
+ "Log thumbs / comment assessments to MLflow and surface the feedback controls. Defaults to on only when an OTLP endpoint and MLFLOW_EXPERIMENT_ID or MLFLOW_EXPERIMENT_NAME are set.",
595
+ },
596
+ mcp: {
597
+ type: ["boolean", "object"],
598
+ description:
599
+ "Expose the registered agents over MCP. Defaults to agents-only; an object tunes the server id, advertised metadata, and whether ambient tools are exposed.",
600
+ },
601
+ apiAccess: {
602
+ type: "string",
603
+ enum: ["scoped", "full"],
604
+ description:
605
+ 'How much of the stock @mastra/express API is reachable through the mount. "scoped" (default) allows agent inference, read-only agent metadata, this plugin\'s own routes, and MCP; "full" dispatches the entire management surface.',
606
+ },
607
+ brand: {
608
+ type: "object",
609
+ description:
610
+ "Portable brand context applied to generated charts (series palette from colors.primary / colors.accent, base font from typography.sans).",
611
+ },
612
+ },
613
+ };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Interceptor defaults for every outbound call the plugin's own routes make
3
+ * through `Plugin.execute()`.
4
+ *
5
+ * One constant per call site so the cache / retry / timeout decision lives
6
+ * beside its reasoning instead of at the call site. The interceptor chain runs
7
+ * telemetry, timeout, retry, cache, so a disabled interceptor is a deliberate
8
+ * choice and is commented as such.
9
+ *
10
+ * The cache interceptor is inert without a `cache.cacheKey`, which is
11
+ * request-specific (a statement id, an agent id). Call sites therefore spread
12
+ * the constant and add the key; the identity namespace comes from the
13
+ * execution context's user id, which `execute()` resolves on its own.
14
+ *
15
+ * Cancellation is not expressed here: `execute()` derives a signal from the
16
+ * `timeout` setting only, so a caller's own signal is combined with it at the
17
+ * call site instead.
18
+ *
19
+ * @module
20
+ */
21
+
22
+ /**
23
+ * Per-call interceptor settings. Structural stand-in for AppKit's
24
+ * `PluginExecuteConfig`: the barrel exports `StreamExecutionSettings` but not
25
+ * the non-streaming config, so the nominal type is unimportable here. Declared
26
+ * as a type alias (not an interface) so it keeps the implicit index signature
27
+ * AppKit's own config carries.
28
+ */
29
+ type ExecuteConfig = {
30
+ cache?: { enabled?: boolean; ttl?: number; cacheKey?: (string | number | object)[] };
31
+ retry?: { enabled?: boolean; attempts?: number; initialDelay?: number; maxDelay?: number };
32
+ timeout?: number;
33
+ };
34
+
35
+ /** Default plus optional user-scoped settings, as `execute()` accepts them. */
36
+ type ExecuteSettings = {
37
+ default: ExecuteConfig;
38
+ user?: ExecuteConfig;
39
+ };
40
+
41
+ /** Ceiling on a single Model Serving / Genie / MLflow REST round-trip. */
42
+ const REST_TIMEOUT_MS = 30_000;
43
+
44
+ /** Ceiling on one Statement Execution fetch, which can page a large result. */
45
+ const STATEMENT_TIMEOUT_MS = 60_000;
46
+
47
+ /** TTL for a cached statement result set, in seconds. */
48
+ const STATEMENT_CACHE_TTL_SEC = 5 * 60;
49
+
50
+ /**
51
+ * `GET /models`: the workspace's Model Serving catalogue.
52
+ */
53
+ export const modelCatalogueDefaults: ExecuteSettings = {
54
+ default: {
55
+ // Cache disabled here because `listServingEndpoints` already memoizes the
56
+ // catalogue through the same `CacheManager`, keyed by workspace host with
57
+ // `config.modelCacheTtlMs`. That entry is shared across identities; a
58
+ // second interceptor cache would duplicate it once per user.
59
+ cache: { enabled: false },
60
+ // Retry enabled: listing endpoints is an idempotent GET, and the listing
61
+ // is on the chat UI's load path, so a transient 5xx should not blank the
62
+ // model picker.
63
+ retry: { enabled: true, attempts: 3 },
64
+ timeout: REST_TIMEOUT_MS,
65
+ },
66
+ };
67
+
68
+ /**
69
+ * `GET /suggestions`: the curated `sample_questions` on each Genie space.
70
+ */
71
+ export const genieSuggestionDefaults: ExecuteSettings = {
72
+ default: {
73
+ // Cache disabled here because `collectSpaceSuggestions` keeps its own
74
+ // space-id-keyed entry; the questions are authored config, identical for
75
+ // every caller, so caching them per identity would only fan out copies.
76
+ cache: { enabled: false },
77
+ // Retry enabled: reading space metadata is idempotent, and the route
78
+ // degrades to an empty starter list on failure, so one retry is cheaper
79
+ // than an empty chat state.
80
+ retry: { enabled: true, attempts: 2 },
81
+ timeout: REST_TIMEOUT_MS,
82
+ },
83
+ };
84
+
85
+ /**
86
+ * `GET /embed/data/:id`: one Statement Execution result set.
87
+ */
88
+ export const statementDataDefaults: ExecuteSettings = {
89
+ default: {
90
+ // Cache enabled: a completed statement's result set is immutable, and the
91
+ // same id is re-fetched every time the transcript re-renders a
92
+ // `[data:<statement_id>]` marker. The entry is namespaced by the caller's
93
+ // identity, so an OBO result never crosses users.
94
+ cache: { enabled: true, ttl: STATEMENT_CACHE_TTL_SEC },
95
+ // Retry enabled: fetching a statement by id is idempotent.
96
+ retry: { enabled: true, attempts: 3 },
97
+ timeout: STATEMENT_TIMEOUT_MS,
98
+ },
99
+ };
100
+
101
+ /**
102
+ * `GET /embed/chart/:id`: the cached chart entry behind a `[chart:<id>]`
103
+ * marker.
104
+ */
105
+ export const chartFetchDefaults: ExecuteSettings = {
106
+ default: {
107
+ // Cache disabled: the read long-polls a cache entry that is expected to
108
+ // change from processing to settled, so a cached answer would pin the
109
+ // client to the pre-planner state.
110
+ cache: { enabled: false },
111
+ // Retry disabled: the helper already polls to its own deadline, so a retry
112
+ // would only restart a wait the caller is already inside.
113
+ retry: { enabled: false },
114
+ // No timeout: the poll budget is per request (`?timeoutMs=`, capped by the
115
+ // route) and cancellation rides the connection's abort signal, so a second
116
+ // ceiling here could cut a legitimate long-poll short.
117
+ },
118
+ };
119
+
120
+ /**
121
+ * `POST /route/feedback`: an MLflow trace assessment.
122
+ */
123
+ export const feedbackWriteDefaults: ExecuteSettings = {
124
+ default: {
125
+ // Cache disabled: a write, and each submission is a distinct assessment.
126
+ cache: { enabled: false },
127
+ // Retry disabled: posting an assessment is not idempotent (a retry records
128
+ // a duplicate), and `logFeedback` already retries the one recoverable case
129
+ // itself, the trace not having finished exporting to MLflow yet.
130
+ retry: { enabled: false },
131
+ timeout: REST_TIMEOUT_MS,
132
+ },
133
+ };
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import { posix as path } from "node:path";
17
- import { getExecutionContext } from "@databricks/appkit";
17
+ import { ExecutionError, getExecutionContext, ValidationError } from "@databricks/appkit";
18
18
  import { WorkspaceClient } from "@databricks/sdk-experimental";
19
19
  import { error, functionModule, hash, log } from "@dbx-tools/shared-core";
20
20
  import {
@@ -101,7 +101,11 @@ export interface DatabricksWorkspaceFilesystemOptions extends MastraFilesystemOp
101
101
  export function normalizeDatabricksBasePath(basePath: string): string {
102
102
  const trimmed = basePath.trim();
103
103
  if (!trimmed.startsWith("/")) {
104
- throw new Error(`Databricks base path must be absolute: ${basePath}`);
104
+ throw ValidationError.invalidValue(
105
+ "basePath",
106
+ basePath,
107
+ "an absolute Databricks path, e.g. /Volumes/catalog/schema/volume",
108
+ );
105
109
  }
106
110
  return trimmed.replace(/\/+$/, "") || "/";
107
111
  }
@@ -297,8 +301,16 @@ export class DatabricksWorkspaceFilesystem extends MastraFilesystem {
297
301
  if (ErrorType) {
298
302
  throw new ErrorType(workspacePath);
299
303
  }
300
- const message = error.errorMessage(err);
301
- throw new Error(`Databricks filesystem ${workspacePath}: ${message}`);
304
+ // The upstream message is logged rather than raised: it reaches the model
305
+ // (and from there the chat transcript) as the tool's failure text.
306
+ logger.warn("operation-failed", {
307
+ path: workspacePath,
308
+ error: error.errorMessage(err),
309
+ });
310
+ throw new ExecutionError(`Databricks filesystem operation failed for ${workspacePath}`, {
311
+ cause: error.toError(err),
312
+ context: { path: workspacePath },
313
+ });
302
314
  }
303
315
 
304
316
  /* --- lifecycle --- */
@@ -518,7 +530,7 @@ export class DatabricksWorkspaceFilesystem extends MastraFilesystem {
518
530
  const created = await this.client.dbfs.create({ path: absolutePath, overwrite });
519
531
  const handle = created.handle;
520
532
  if (handle === undefined) {
521
- throw new Error(`DBFS create did not return a handle for ${absolutePath}`);
533
+ throw ExecutionError.missingData("DBFS upload handle");
522
534
  }
523
535
  for (let offset = 0; offset < buffer.length; offset += DBFS_PUT_MAX_BYTES) {
524
536
  const slice = buffer.subarray(offset, offset + DBFS_PUT_MAX_BYTES);
package/src/genie.ts CHANGED
@@ -36,7 +36,13 @@
36
36
  * @module
37
37
  */
38
38
 
39
- import { CacheManager, genie } from "@databricks/appkit";
39
+ import {
40
+ CacheManager,
41
+ ConfigurationError,
42
+ ExecutionError,
43
+ genie,
44
+ ValidationError,
45
+ } from "@databricks/appkit";
40
46
  import { WorkspaceClient } from "@databricks/sdk-experimental";
41
47
  import { plugin } from "@dbx-tools/appkit";
42
48
  import { chat, space as genieSpace } from "@dbx-tools/genie";
@@ -50,7 +56,7 @@ import { z } from "zod";
50
56
 
51
57
  import type { MastraTools } from "./agents";
52
58
  import { chartPlannerRequestSchema, prepareChart } from "./chart";
53
- import { MASTRA_USER_KEY } from "./config";
59
+ import { MASTRA_USER_KEY, resolveUserKey } from "./config";
54
60
  import type { MastraPluginConfig, User } from "./config";
55
61
  import { fetchStatementData } from "./statement";
56
62
  import { safeWrite } from "./writer";
@@ -114,11 +120,17 @@ function requireClient(
114
120
  } {
115
121
  const requestContext = ctx?.requestContext;
116
122
  if (!requestContext) {
117
- throw new Error(`${toolId}: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)`);
123
+ throw ConfigurationError.resourceNotFound(
124
+ `${toolId} request context`,
125
+ "Invoke the tool from an agent turn served by the mastra plugin.",
126
+ );
118
127
  }
119
128
  const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
120
129
  if (!user) {
121
- throw new Error(`${toolId}: no user on requestContext (MASTRA_USER_KEY not set)`);
130
+ throw ConfigurationError.resourceNotFound(
131
+ `${toolId} user context`,
132
+ "Invoke the tool from an agent turn served by the mastra plugin, which stamps the AppKit user on the Mastra request context.",
133
+ );
122
134
  }
123
135
  return { client: user.executionContext.client, requestContext };
124
136
  }
@@ -163,15 +175,6 @@ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
163
175
  /** Cache namespace prefix so coexisting Mastra caches don't collide. */
164
176
  const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
165
177
 
166
- /**
167
- * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
168
- * conversations are scoped to a single user + space + thread, and
169
- * `threadId` is already user-scoped (Mastra mints threads per
170
- * `resourceId`), so a constant user key here is safe and keeps the
171
- * cache key short.
172
- */
173
- const CONVERSATION_USER_KEY = "mastra-genie";
174
-
175
178
  /**
176
179
  * Build the per-request {@link RequestContext} key the active
177
180
  * Genie `conversation_id` lives under for `spaceId`. Scoped by
@@ -211,18 +214,23 @@ function writeContextConversationId(
211
214
  }
212
215
 
213
216
  /**
214
- * Build the canonical cache key for a `(spaceId, threadId)` pair.
215
- * Returns `undefined` when `threadId` is missing - callers should
217
+ * Build the canonical cache key for a `(spaceId, threadId)` pair, owned by
218
+ * `userKey`. Returns `undefined` when `threadId` is missing - callers should
216
219
  * skip caching entirely in that case (no Mastra memory wired up).
220
+ *
221
+ * The identity is part of the key because a client picks its own `threadId`
222
+ * (the thread-selection header / `?threadId=`), so the id alone does not
223
+ * establish who owns the Genie conversation behind it.
217
224
  */
218
225
  async function conversationCacheKey(
219
226
  spaceId: string,
220
227
  threadId: string | undefined,
228
+ userKey: string,
221
229
  ): Promise<string | undefined> {
222
230
  if (!threadId) return undefined;
223
231
  return (await CacheManager.getInstance()).generateKey(
224
232
  [CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
225
- CONVERSATION_USER_KEY,
233
+ userKey,
226
234
  );
227
235
  }
228
236
 
@@ -395,7 +403,18 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
395
403
  automatically while the call is in flight.
396
404
  `),
397
405
  inputSchema: z.object({
398
- question: z.string().min(1, "question is required"),
406
+ question: z
407
+ .string()
408
+ .min(1, "question is required")
409
+ .describe(
410
+ string.toDescription(`
411
+ ONE focused natural-language question about the data in this
412
+ space, covering a single metric / dimension / time window
413
+ (e.g. "What was Q3 revenue by region?"). Decompose a
414
+ multi-part ask into separate calls rather than combining it
415
+ here.
416
+ `),
417
+ ),
399
418
  }),
400
419
  outputSchema: z.object({
401
420
  message: genieModel.GenieMessageSchema,
@@ -416,10 +435,10 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
416
435
  // of wasting a turn.
417
436
  const trimmed = question.trim();
418
437
  if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
419
- throw new Error(
420
- `${toolId}: refusing placeholder question "${question}" - ` +
421
- `call ${toolId} only with a real natural-language question, ` +
422
- `or skip the call entirely`,
438
+ throw ValidationError.invalidValue(
439
+ `${toolId}.question`,
440
+ question,
441
+ "a real natural-language question, not a placeholder; skip the call instead",
423
442
  );
424
443
  }
425
444
 
@@ -430,7 +449,11 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
430
449
  // The same `RequestContext` is reused across every `ask_genie`
431
450
  // call within one user turn, so `ensureConversationSeeded`
432
451
  // hits the cache at most once per request per space.
433
- const cacheKey = await conversationCacheKey(spaceId, threadId);
452
+ const cacheKey = await conversationCacheKey(
453
+ spaceId,
454
+ threadId,
455
+ resolveUserKey(requestContext),
456
+ );
434
457
  await ensureConversationSeeded(requestContext, spaceId, cacheKey);
435
458
 
436
459
  // Fire the lifecycle `started` event before any LLM /
@@ -466,7 +489,7 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
466
489
  }
467
490
  }
468
491
  if (!finalMessage) {
469
- throw new Error("Genie turn ended without a result event");
492
+ throw ExecutionError.missingData("Genie result event");
470
493
  }
471
494
  return finalMessage;
472
495
  };
@@ -612,7 +635,17 @@ function buildGetStatementTool() {
612
635
  upstream total - compare to \`rows.length\` to detect truncation.
613
636
  `),
614
637
  inputSchema: z.object({
615
- statement_id: z.string().min(1, "statement_id is required"),
638
+ statement_id: z
639
+ .string()
640
+ .min(1, "statement_id is required")
641
+ .describe(
642
+ string.toDescription(`
643
+ Genie \`statement_id\` whose rows to read. Take it from
644
+ \`message.query_result.statement_id\` or
645
+ \`message.attachments[*].query.statement_id\` on an
646
+ \`ask_genie\` result; it is never a value you construct.
647
+ `),
648
+ ),
616
649
  limit: z
617
650
  .number()
618
651
  .int()
@@ -702,9 +735,10 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
702
735
  outputSchema: wire.ChartSchema.pick({ chartId: true }),
703
736
  execute: async (request, ctxRaw) => {
704
737
  const ctx = ctxRaw as ToolExecuteCtx;
705
- const { client } = requireClient(ctx, toolId);
738
+ const { client, requestContext } = requireClient(ctx, toolId);
706
739
  return prepareChart({
707
740
  config,
741
+ userKey: resolveUserKey(requestContext),
708
742
  ...(request.title ? { title: request.title } : {}),
709
743
  ...(request.description ? { description: request.description } : {}),
710
744
  resolveData: (taskSignal) =>
@@ -855,9 +889,12 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
855
889
  * Normalize the {@link GenieSpacesConfig} record. Bare-string
856
890
  * entries (`{ default: "01ef..." }`) get wrapped as
857
891
  * `{ spaceId: "01ef..." }`; object entries pass through unchanged.
858
- * `undefined` and empty-string values are dropped so callers can
859
- * pass `process.env.X` directly (matches AppKit `genie()`'s
860
- * defensive treatment of unset env vars).
892
+ *
893
+ * @throws ConfigurationError when an alias is present but carries no space id
894
+ * (`{ default: process.env.DATABRICKS_GENIE_SPACE_ID }` with the variable
895
+ * unset). An alias that resolves to nothing is a wiring contradiction: the
896
+ * agent would advertise no Genie tool for a space the deployment believes it
897
+ * configured, so it fails at construction instead of going quiet.
861
898
  */
862
899
  export function normalizeGenieSpaces(
863
900
  spaces: GenieSpacesConfig | Record<string, string | GenieSpaceConfig | undefined> | undefined,
@@ -865,18 +902,22 @@ export function normalizeGenieSpaces(
865
902
  if (!spaces) return {};
866
903
  const out: Record<string, GenieSpaceConfig> = {};
867
904
  for (const [alias, value] of Object.entries(spaces)) {
868
- if (value === undefined) continue;
869
- if (typeof value === "string") {
870
- if (!value) continue;
871
- out[alias] = { spaceId: value };
872
- continue;
873
- }
874
- if (!value.spaceId) continue;
875
- out[alias] = value;
905
+ const spaceId = typeof value === "string" ? value : value?.spaceId;
906
+ if (!spaceId) throw missingSpaceId(alias);
907
+ out[alias] = typeof value === "string" ? { spaceId } : value!;
876
908
  }
877
909
  return out;
878
910
  }
879
911
 
912
+ /** Config contradiction: an alias present in `genieSpaces` with no space id. */
913
+ function missingSpaceId(alias: string): ConfigurationError {
914
+ const envHint =
915
+ alias === DEFAULT_GENIE_ALIAS
916
+ ? "Set DATABRICKS_GENIE_SPACE_ID, pass the space id inline, or drop the alias."
917
+ : `Pass the space id inline (DATABRICKS_GENIE_SPACE_ID only backs the "${DEFAULT_GENIE_ALIAS}" alias) or drop the alias.`;
918
+ return ConfigurationError.resourceNotFound(`genieSpaces.${alias} space id`, envHint);
919
+ }
920
+
880
921
  /**
881
922
  * AppKit `genie` plugin's config shape, derived from the factory
882
923
  * itself so it stays in lock-step with the upstream type without
@@ -907,9 +948,9 @@ type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
907
948
  * pair just works.
908
949
  *
909
950
  * Aliases collide cleanly: a higher-precedence source's value
910
- * replaces a lower one's wholesale. Sources that contribute zero
911
- * aliases (or contribute only `undefined` / empty entries) are
912
- * silently ignored.
951
+ * replaces a lower one's wholesale. A source that contributes zero
952
+ * aliases is skipped; a source that names an alias without a space
953
+ * id fails through {@link normalizeGenieSpaces}.
913
954
  */
914
955
  export function resolveGenieSpaces(
915
956
  config: MastraPluginConfig,
package/src/history.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * @module
19
19
  */
20
20
 
21
+ import { ValidationError } from "@databricks/appkit";
21
22
  import { error, log } from "@dbx-tools/shared-core";
22
23
  import type {
23
24
  MastraClearHistoryResponse,
@@ -207,8 +208,10 @@ export function historyRoute(options: HistoryRouteOptions) {
207
208
  const { path } = options;
208
209
  const fixedAgent = "agent" in options ? options.agent : undefined;
209
210
  if (!fixedAgent && !path.includes(":agentId")) {
210
- throw new Error(
211
- "historyRoute path must include `:agentId` or `agent` must be passed explicitly",
211
+ throw ValidationError.invalidValue(
212
+ "historyRoute.path",
213
+ path,
214
+ "a path containing `:agentId`, or an explicit `agent`",
212
215
  );
213
216
  }
214
217
  // Tiny resolver shared by GET / DELETE: derive the active agent