@dbx-tools/appkit-mastra 0.3.28 → 0.3.30

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/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,8 @@ import { z } from "zod";
50
56
 
51
57
  import type { MastraTools } from "./agents";
52
58
  import { chartPlannerRequestSchema, prepareChart } from "./chart";
53
- import type { MastraPluginConfig, MASTRA_USER_KEY, type User } from "./config";
59
+ import { MASTRA_USER_KEY, resolveUserKey } from "./config";
60
+ import type { MastraPluginConfig, User } from "./config";
54
61
  import { fetchStatementData } from "./statement";
55
62
  import { safeWrite } from "./writer";
56
63
 
@@ -113,11 +120,17 @@ function requireClient(
113
120
  } {
114
121
  const requestContext = ctx?.requestContext;
115
122
  if (!requestContext) {
116
- 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
+ );
117
127
  }
118
128
  const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
119
129
  if (!user) {
120
- 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
+ );
121
134
  }
122
135
  return { client: user.executionContext.client, requestContext };
123
136
  }
@@ -162,15 +175,6 @@ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
162
175
  /** Cache namespace prefix so coexisting Mastra caches don't collide. */
163
176
  const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
164
177
 
165
- /**
166
- * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
167
- * conversations are scoped to a single user + space + thread, and
168
- * `threadId` is already user-scoped (Mastra mints threads per
169
- * `resourceId`), so a constant user key here is safe and keeps the
170
- * cache key short.
171
- */
172
- const CONVERSATION_USER_KEY = "mastra-genie";
173
-
174
178
  /**
175
179
  * Build the per-request {@link RequestContext} key the active
176
180
  * Genie `conversation_id` lives under for `spaceId`. Scoped by
@@ -210,18 +214,23 @@ function writeContextConversationId(
210
214
  }
211
215
 
212
216
  /**
213
- * Build the canonical cache key for a `(spaceId, threadId)` pair.
214
- * 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
215
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.
216
224
  */
217
225
  async function conversationCacheKey(
218
226
  spaceId: string,
219
227
  threadId: string | undefined,
228
+ userKey: string,
220
229
  ): Promise<string | undefined> {
221
230
  if (!threadId) return undefined;
222
231
  return (await CacheManager.getInstance()).generateKey(
223
232
  [CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
224
- CONVERSATION_USER_KEY,
233
+ userKey,
225
234
  );
226
235
  }
227
236
 
@@ -394,7 +403,18 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
394
403
  automatically while the call is in flight.
395
404
  `),
396
405
  inputSchema: z.object({
397
- 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
+ ),
398
418
  }),
399
419
  outputSchema: z.object({
400
420
  message: genieModel.GenieMessageSchema,
@@ -415,10 +435,10 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
415
435
  // of wasting a turn.
416
436
  const trimmed = question.trim();
417
437
  if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
418
- throw new Error(
419
- `${toolId}: refusing placeholder question "${question}" - ` +
420
- `call ${toolId} only with a real natural-language question, ` +
421
- `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",
422
442
  );
423
443
  }
424
444
 
@@ -429,7 +449,11 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
429
449
  // The same `RequestContext` is reused across every `ask_genie`
430
450
  // call within one user turn, so `ensureConversationSeeded`
431
451
  // hits the cache at most once per request per space.
432
- const cacheKey = await conversationCacheKey(spaceId, threadId);
452
+ const cacheKey = await conversationCacheKey(
453
+ spaceId,
454
+ threadId,
455
+ resolveUserKey(requestContext),
456
+ );
433
457
  await ensureConversationSeeded(requestContext, spaceId, cacheKey);
434
458
 
435
459
  // Fire the lifecycle `started` event before any LLM /
@@ -465,7 +489,7 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
465
489
  }
466
490
  }
467
491
  if (!finalMessage) {
468
- throw new Error("Genie turn ended without a result event");
492
+ throw ExecutionError.missingData("Genie result event");
469
493
  }
470
494
  return finalMessage;
471
495
  };
@@ -611,7 +635,17 @@ function buildGetStatementTool() {
611
635
  upstream total - compare to \`rows.length\` to detect truncation.
612
636
  `),
613
637
  inputSchema: z.object({
614
- 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
+ ),
615
649
  limit: z
616
650
  .number()
617
651
  .int()
@@ -701,9 +735,10 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
701
735
  outputSchema: wire.ChartSchema.pick({ chartId: true }),
702
736
  execute: async (request, ctxRaw) => {
703
737
  const ctx = ctxRaw as ToolExecuteCtx;
704
- const { client } = requireClient(ctx, toolId);
738
+ const { client, requestContext } = requireClient(ctx, toolId);
705
739
  return prepareChart({
706
740
  config,
741
+ userKey: resolveUserKey(requestContext),
707
742
  ...(request.title ? { title: request.title } : {}),
708
743
  ...(request.description ? { description: request.description } : {}),
709
744
  resolveData: (taskSignal) =>
@@ -854,9 +889,12 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
854
889
  * Normalize the {@link GenieSpacesConfig} record. Bare-string
855
890
  * entries (`{ default: "01ef..." }`) get wrapped as
856
891
  * `{ spaceId: "01ef..." }`; object entries pass through unchanged.
857
- * `undefined` and empty-string values are dropped so callers can
858
- * pass `process.env.X` directly (matches AppKit `genie()`'s
859
- * 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.
860
898
  */
861
899
  export function normalizeGenieSpaces(
862
900
  spaces: GenieSpacesConfig | Record<string, string | GenieSpaceConfig | undefined> | undefined,
@@ -864,18 +902,22 @@ export function normalizeGenieSpaces(
864
902
  if (!spaces) return {};
865
903
  const out: Record<string, GenieSpaceConfig> = {};
866
904
  for (const [alias, value] of Object.entries(spaces)) {
867
- if (value === undefined) continue;
868
- if (typeof value === "string") {
869
- if (!value) continue;
870
- out[alias] = { spaceId: value };
871
- continue;
872
- }
873
- if (!value.spaceId) continue;
874
- 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!;
875
908
  }
876
909
  return out;
877
910
  }
878
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
+
879
921
  /**
880
922
  * AppKit `genie` plugin's config shape, derived from the factory
881
923
  * itself so it stays in lock-step with the upstream type without
@@ -906,9 +948,9 @@ type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
906
948
  * pair just works.
907
949
  *
908
950
  * Aliases collide cleanly: a higher-precedence source's value
909
- * replaces a lower one's wholesale. Sources that contribute zero
910
- * aliases (or contribute only `undefined` / empty entries) are
911
- * 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}.
912
954
  */
913
955
  export function resolveGenieSpaces(
914
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
package/src/memory.ts CHANGED
@@ -29,9 +29,8 @@
29
29
  * @module
30
30
  */
31
31
 
32
- import { randomUUID } from "node:crypto";
33
32
  import { getUsernameWithApiLookup } from "@databricks/appkit";
34
- import { log } from "@dbx-tools/shared-core";
33
+ import { hash, log } from "@dbx-tools/shared-core";
35
34
  import { fastembed } from "@mastra/fastembed";
36
35
  import { Memory } from "@mastra/memory";
37
36
  import { PgVector, PostgresStore } from "@mastra/pg";
@@ -252,7 +251,7 @@ export class MemoryBuilder {
252
251
  */
253
252
  function buildSharedPgVector(pool: Pool): PgVector {
254
253
  const vector = new PgVector({
255
- id: `pg${randomUUID()}`,
254
+ id: `pg${hash.id()}`,
256
255
  // Keep the recall index out of `public`: on a Lakebase database the app
257
256
  // service principal has no CREATE on `public` (PG15+ locks it down), so a
258
257
  // default-schema PgVector fails on CREATE INDEX with "permission denied for
@@ -276,7 +275,7 @@ function buildSharedPgVector(pool: Pool): PgVector {
276
275
  /** Per-agent dedicated `PgVector` (rare; opt-in via object override). */
277
276
  function buildPgVector(setting: MastraMemoryConfigOverride): PgVector {
278
277
  return new PgVector(
279
- withId(setting, `pg-vector__${randomUUID()}`) as ConstructorParameters<typeof PgVector>[0],
278
+ withId(setting, `pg-vector__${hash.id()}`) as ConstructorParameters<typeof PgVector>[0],
280
279
  );
281
280
  }
282
281
 
package/src/model.ts CHANGED
@@ -25,7 +25,7 @@
25
25
 
26
26
  import { getExecutionContext } from "@databricks/appkit";
27
27
  import { classes, resolve } from "@dbx-tools/model";
28
- import { functionModule, log, net } from "@dbx-tools/shared-core";
28
+ import { functionModule, json, log, net } from "@dbx-tools/shared-core";
29
29
  import { model } from "@dbx-tools/shared-model";
30
30
  import type { MastraModelConfig } from "@mastra/core/llm";
31
31
  import type { RequestContext } from "@mastra/core/request-context";
@@ -62,6 +62,13 @@ export interface BuildModelOverrides {
62
62
  * while `agent.stream` is inside the `asUser(req)` scope so tokens
63
63
  * are user-scoped; outside an active user context the workspace
64
64
  * client falls back to the service principal.
65
+ *
66
+ * Endpoint precedence: the per-request override
67
+ * ({@link MASTRA_MODEL_OVERRIDE_KEY}, only when `config.modelOverride` allows
68
+ * it), then {@link BuildModelOverrides.modelId} from the agent / plugin
69
+ * config, then `DATABRICKS_SERVING_ENDPOINT_NAME`. With none of those set the
70
+ * capability class and fallback ladder in `@dbx-tools/model` choose the
71
+ * endpoint.
65
72
  */
66
73
  export async function buildModel(
67
74
  config: MastraPluginConfig,
@@ -155,11 +162,13 @@ const setupFetchInterceptor = functionModule.memoize((): void => {
155
162
  if (rewritten !== init.body) {
156
163
  init = { ...init, body: rewritten };
157
164
  }
158
- try {
159
- logger.debug("POST", { url: url.toString(), body: JSON.parse(rewritten) });
160
- } catch {
161
- logger.debug("POST", { url: url.toString(), bodyType: "non-JSON" });
162
- }
165
+ const parsed = json.parse<unknown>(rewritten);
166
+ logger.debug(
167
+ "POST",
168
+ parsed === undefined
169
+ ? { url: url.toString(), bodyType: "non-JSON" }
170
+ : { url: url.toString(), body: parsed },
171
+ );
163
172
  return original(input, init);
164
173
  }) as typeof globalThis.fetch;
165
174
  });