@dbx-tools/appkit-mastra 0.1.33 → 0.1.35

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/package.json CHANGED
@@ -9,13 +9,13 @@
9
9
  }
10
10
  },
11
11
  "name": "@dbx-tools/appkit-mastra",
12
- "version": "0.1.33",
12
+ "version": "0.1.35",
13
13
  "dependencies": {
14
14
  "@databricks/sdk-experimental": "^0.17",
15
- "@dbx-tools/appkit-mastra-shared": "0.1.33",
16
- "@dbx-tools/genie": "0.1.33",
17
- "@dbx-tools/genie-shared": "0.1.33",
18
- "@dbx-tools/shared": "0.1.33",
15
+ "@dbx-tools/appkit-mastra-shared": "0.1.35",
16
+ "@dbx-tools/genie": "0.1.35",
17
+ "@dbx-tools/genie-shared": "0.1.35",
18
+ "@dbx-tools/shared": "0.1.35",
19
19
  "@mastra/ai-sdk": "^1",
20
20
  "@mastra/core": "^1",
21
21
  "@mastra/express": "^1",
package/src/genie.ts CHANGED
@@ -41,15 +41,9 @@ import {
41
41
  type MinimalWriter,
42
42
  type StartedEvent,
43
43
  } from "@dbx-tools/appkit-mastra-shared";
44
- import { genieEventChat } from "@dbx-tools/genie";
44
+ import { genieEventChat, genieSampleQuestions, getGenieSpace } from "@dbx-tools/genie";
45
45
  import { type GenieMessage } from "@dbx-tools/genie-shared";
46
- import {
47
- apiUtils,
48
- appkitUtils,
49
- commonUtils,
50
- logUtils,
51
- stringUtils,
52
- } from "@dbx-tools/shared";
46
+ import { appkitUtils, commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
53
47
  import type { RequestContext } from "@mastra/core/request-context";
54
48
  import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
55
49
  import { createTool } from "@mastra/core/tools";
@@ -565,8 +559,15 @@ function buildSpaceDescriptionTool(opts: { spaceId: string; alias: string }) {
565
559
  const ctx = ctxRaw as ToolExecuteCtx;
566
560
  const { client } = requireClient(ctx, toolId);
567
561
  const signal = ctx?.abortSignal;
568
- const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
569
- const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
562
+ // Route through the package's central Genie space fetch. The
563
+ // description surface (title / description / warehouse id) lives
564
+ // on the directory-listing shape, so skip the larger
565
+ // `serialized_space` payload here.
566
+ const space = await getGenieSpace(spaceId, {
567
+ workspaceClient: client,
568
+ serialized: false,
569
+ ...(signal ? { context: signal } : {}),
570
+ });
570
571
  return {
571
572
  spaceId,
572
573
  ...(space.title ? { title: space.title } : {}),
@@ -594,8 +595,13 @@ function buildSpaceSerializedTool(opts: { spaceId: string; alias: string }) {
594
595
  const ctx = ctxRaw as ToolExecuteCtx;
595
596
  const { client } = requireClient(ctx, toolId);
596
597
  const signal = ctx?.abortSignal;
597
- const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
598
- const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
598
+ // Central Genie space fetch with the opt-in `serialized_space`
599
+ // blob (catalogs, tables, sample questions, prompts) that the
600
+ // typed SDK `getSpace` omits - the whole point of this tool.
601
+ const space = await getGenieSpace(spaceId, {
602
+ workspaceClient: client,
603
+ ...(signal ? { context: signal } : {}),
604
+ });
599
605
  return { space };
600
606
  },
601
607
  });
@@ -1044,3 +1050,92 @@ export function hasAnyGenieSpaces(
1044
1050
  ): boolean {
1045
1051
  return Object.keys(resolveGenieSpaces(config, context)).length > 0;
1046
1052
  }
1053
+
1054
+ /* --------------------------- starter suggestions --------------------------- */
1055
+
1056
+ /**
1057
+ * Default cap on starter suggestions surfaced to the chat empty
1058
+ * state. Sample-question lists can run long (10+ on a curated
1059
+ * space); a handful of one-tap prompts reads better than a wall of
1060
+ * buttons.
1061
+ */
1062
+ const SUGGESTION_LIMIT = 6;
1063
+
1064
+ /**
1065
+ * How long a space's parsed sample questions stay cached. They're
1066
+ * authored config that changes rarely, and the lookup is an extra
1067
+ * REST round-trip per chat mount, so a few minutes of caching keeps
1068
+ * the empty state instant on reload without going stale for long.
1069
+ */
1070
+ const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
1071
+
1072
+ /** Space-id -> cached sample questions with an absolute expiry. */
1073
+ const _suggestionCache = new Map<string, { questions: string[]; expires: number }>();
1074
+
1075
+ /** Read a space's cached questions, or `undefined` on miss / expiry. */
1076
+ function readSuggestionCache(spaceId: string): string[] | undefined {
1077
+ const entry = _suggestionCache.get(spaceId);
1078
+ if (!entry) return undefined;
1079
+ if (entry.expires <= Date.now()) {
1080
+ _suggestionCache.delete(spaceId);
1081
+ return undefined;
1082
+ }
1083
+ return entry.questions;
1084
+ }
1085
+
1086
+ /** Cache a space's parsed questions for {@link SUGGESTION_CACHE_TTL_MS}. */
1087
+ function writeSuggestionCache(spaceId: string, questions: string[]): void {
1088
+ _suggestionCache.set(spaceId, {
1089
+ questions,
1090
+ expires: Date.now() + SUGGESTION_CACHE_TTL_MS,
1091
+ });
1092
+ }
1093
+
1094
+ /**
1095
+ * Collect the curated starter questions across every resolved Genie
1096
+ * space, deduped and capped. Each space's `sample_questions` are
1097
+ * fetched once (cached for {@link SUGGESTION_CACHE_TTL_MS}) via
1098
+ * {@link getGenieSpace} + {@link genieSampleQuestions}, then merged in
1099
+ * alias-iteration order so a single-space app surfaces that space's
1100
+ * questions and a multi-space app round-trips breadth-first up to the
1101
+ * cap. A per-space fetch failure degrades to "no questions for that
1102
+ * space" (logged, not thrown) so one unreachable space never blanks
1103
+ * the whole list. Returns `[]` when no spaces are configured.
1104
+ */
1105
+ export async function collectSpaceSuggestions(opts: {
1106
+ spaces: Record<string, GenieSpaceConfig>;
1107
+ client: WorkspaceClient;
1108
+ signal?: AbortSignal;
1109
+ limit?: number;
1110
+ }): Promise<string[]> {
1111
+ const limit = opts.limit ?? SUGGESTION_LIMIT;
1112
+ const merged: string[] = [];
1113
+ const seen = new Set<string>();
1114
+
1115
+ for (const { spaceId } of Object.values(opts.spaces)) {
1116
+ let questions = readSuggestionCache(spaceId);
1117
+ if (!questions) {
1118
+ try {
1119
+ const space = await getGenieSpace(spaceId, {
1120
+ workspaceClient: opts.client,
1121
+ ...(opts.signal ? { context: opts.signal } : {}),
1122
+ });
1123
+ questions = genieSampleQuestions(space);
1124
+ writeSuggestionCache(spaceId, questions);
1125
+ } catch (err) {
1126
+ log.warn("suggestions:fetch-error", {
1127
+ spaceId,
1128
+ error: commonUtils.errorMessage(err),
1129
+ });
1130
+ questions = [];
1131
+ }
1132
+ }
1133
+ for (const question of questions) {
1134
+ if (seen.has(question)) continue;
1135
+ seen.add(question);
1136
+ merged.push(question);
1137
+ if (merged.length >= limit) return merged;
1138
+ }
1139
+ }
1140
+ return merged;
1141
+ }
package/src/plugin.ts CHANGED
@@ -50,6 +50,7 @@ import type {
50
50
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
51
51
  import { fetchChart } from "./chart.js";
52
52
  import type { MastraPluginConfig } from "./config.js";
53
+ import { collectSpaceSuggestions, resolveGenieSpaces } from "./genie.js";
53
54
  import { historyRoute } from "./history.js";
54
55
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
55
56
  import { buildObservability } from "./observability.js";
@@ -209,6 +210,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
209
210
  historyPath: `${basePath}/route/history`,
210
211
  historyPathTemplate: `${basePath}/route/history/:agentId`,
211
212
  embedPathTemplate: `${basePath}/embed/:type/:id`,
213
+ suggestionsPath: `${basePath}/suggestions`,
214
+ suggestionsPathTemplate: `${basePath}/suggestions/:agentId`,
212
215
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
213
216
  agents: Object.keys(this.built?.agents ?? {}),
214
217
  };
@@ -305,12 +308,60 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
305
308
  .catch(next);
306
309
  });
307
310
 
311
+ // `GET /suggestions` (and `/suggestions/:agentId`) returns the
312
+ // curated starter questions for the agent's Genie space(s) - the
313
+ // author-configured `sample_questions`, surfaced as one-tap
314
+ // prompts on the chat empty state. Returns `{ questions: [] }`
315
+ // when no Genie space is wired so the client renders a bare
316
+ // empty state (no built-in example prompts). The `:agentId`
317
+ // segment is accepted for URL symmetry with the chat / history
318
+ // routes; Genie spaces are resolved per-plugin, not per-agent,
319
+ // so it doesn't change the result. OBO-scoped like the other
320
+ // data routes so the space lookup runs as the calling user.
321
+ const handleSuggestions = (req: express.Request, res: express.Response): void => {
322
+ const controller = new AbortController();
323
+ req.on("close", () => controller.abort());
324
+ this.userScopedSelf(req)
325
+ .fetchSuggestions(controller.signal)
326
+ .then((questions) => res.json({ questions }))
327
+ .catch((err: unknown) => {
328
+ // Suggestions are a non-critical enhancement; a lookup
329
+ // failure should leave the chat usable with a bare empty
330
+ // state rather than surfacing a 500. Log and degrade.
331
+ this.log.warn("suggestions:error", {
332
+ error: err instanceof Error ? err.message : String(err),
333
+ });
334
+ res.json({ questions: [] });
335
+ });
336
+ };
337
+ router.get("/suggestions", handleSuggestions);
338
+ router.get("/suggestions/:agentId", handleSuggestions);
339
+
308
340
  router.use("", (req, res, next) => {
309
341
  if (!this.mastraApp) return res.status(503).end();
310
342
  return this.userScopedSelf(req).mastraApp!(req, res, next);
311
343
  });
312
344
  }
313
345
 
346
+ /**
347
+ * Implementation backing the `/suggestions` route. Runs inside the
348
+ * AppKit user-context proxy so `getExecutionContext()` returns the
349
+ * OBO-scoped client. Resolves the plugin's Genie spaces and merges
350
+ * their curated `sample_questions` (see {@link collectSpaceSuggestions}).
351
+ * Returns `[]` when no Genie space is configured so the client
352
+ * shows a bare empty state instead of built-in example prompts.
353
+ */
354
+ private async fetchSuggestions(signal?: AbortSignal): Promise<string[]> {
355
+ const spaces = resolveGenieSpaces(this.config, this.context);
356
+ if (Object.keys(spaces).length === 0) return [];
357
+ const client = getExecutionContext().client;
358
+ return collectSpaceSuggestions({
359
+ spaces,
360
+ client,
361
+ ...(signal ? { signal } : {}),
362
+ });
363
+ }
364
+
314
365
  /**
315
366
  * Implementation backing the `data` embed resolver
316
367
  * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so