@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.
@@ -33,6 +33,7 @@
33
33
  * `instructions` when you want the canonical "how to drive the
34
34
  * Genie tools" guidance.
35
35
  */
36
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
36
37
  import { appkitUtils } from "@dbx-tools/shared";
37
38
  import type { MastraTools } from "./agents.js";
38
39
  import type { MastraPluginConfig } from "./config.js";
@@ -153,3 +154,20 @@ export declare function buildGenieToolkitProvider(opts: {
153
154
  * absent-plugin semantics) when neither source is configured.
154
155
  */
155
156
  export declare function hasAnyGenieSpaces(config: MastraPluginConfig, context: appkitUtils.PluginContextLike | undefined): boolean;
157
+ /**
158
+ * Collect the curated starter questions across every resolved Genie
159
+ * space, deduped and capped. Each space's `sample_questions` are
160
+ * fetched once (cached for {@link SUGGESTION_CACHE_TTL_MS}) via
161
+ * {@link getGenieSpace} + {@link genieSampleQuestions}, then merged in
162
+ * alias-iteration order so a single-space app surfaces that space's
163
+ * questions and a multi-space app round-trips breadth-first up to the
164
+ * cap. A per-space fetch failure degrades to "no questions for that
165
+ * space" (logged, not thrown) so one unreachable space never blanks
166
+ * the whole list. Returns `[]` when no spaces are configured.
167
+ */
168
+ export declare function collectSpaceSuggestions(opts: {
169
+ spaces: Record<string, GenieSpaceConfig>;
170
+ client: WorkspaceClient;
171
+ signal?: AbortSignal;
172
+ limit?: number;
173
+ }): Promise<string[]>;
package/dist/src/genie.js CHANGED
@@ -36,9 +36,9 @@
36
36
  import { CacheManager, genie } from "@databricks/appkit";
37
37
  import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experimental";
38
38
  import { ChartSchema, } from "@dbx-tools/appkit-mastra-shared";
39
- import { genieEventChat } from "@dbx-tools/genie";
39
+ import { genieEventChat, genieSampleQuestions, getGenieSpace } from "@dbx-tools/genie";
40
40
  import {} from "@dbx-tools/genie-shared";
41
- import { apiUtils, appkitUtils, commonUtils, logUtils, stringUtils, } from "@dbx-tools/shared";
41
+ import { appkitUtils, commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
42
42
  import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
43
43
  import { createTool } from "@mastra/core/tools";
44
44
  import { z } from "zod";
@@ -461,8 +461,15 @@ function buildSpaceDescriptionTool(opts) {
461
461
  const ctx = ctxRaw;
462
462
  const { client } = requireClient(ctx, toolId);
463
463
  const signal = ctx?.abortSignal;
464
- const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
465
- const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
464
+ // Route through the package's central Genie space fetch. The
465
+ // description surface (title / description / warehouse id) lives
466
+ // on the directory-listing shape, so skip the larger
467
+ // `serialized_space` payload here.
468
+ const space = await getGenieSpace(spaceId, {
469
+ workspaceClient: client,
470
+ serialized: false,
471
+ ...(signal ? { context: signal } : {}),
472
+ });
466
473
  return {
467
474
  spaceId,
468
475
  ...(space.title ? { title: space.title } : {}),
@@ -489,8 +496,13 @@ function buildSpaceSerializedTool(opts) {
489
496
  const ctx = ctxRaw;
490
497
  const { client } = requireClient(ctx, toolId);
491
498
  const signal = ctx?.abortSignal;
492
- const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
493
- const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
499
+ // Central Genie space fetch with the opt-in `serialized_space`
500
+ // blob (catalogs, tables, sample questions, prompts) that the
501
+ // typed SDK `getSpace` omits - the whole point of this tool.
502
+ const space = await getGenieSpace(spaceId, {
503
+ workspaceClient: client,
504
+ ...(signal ? { context: signal } : {}),
505
+ });
494
506
  return { space };
495
507
  },
496
508
  });
@@ -893,3 +905,83 @@ export function buildGenieToolkitProvider(opts) {
893
905
  export function hasAnyGenieSpaces(config, context) {
894
906
  return Object.keys(resolveGenieSpaces(config, context)).length > 0;
895
907
  }
908
+ /* --------------------------- starter suggestions --------------------------- */
909
+ /**
910
+ * Default cap on starter suggestions surfaced to the chat empty
911
+ * state. Sample-question lists can run long (10+ on a curated
912
+ * space); a handful of one-tap prompts reads better than a wall of
913
+ * buttons.
914
+ */
915
+ const SUGGESTION_LIMIT = 6;
916
+ /**
917
+ * How long a space's parsed sample questions stay cached. They're
918
+ * authored config that changes rarely, and the lookup is an extra
919
+ * REST round-trip per chat mount, so a few minutes of caching keeps
920
+ * the empty state instant on reload without going stale for long.
921
+ */
922
+ const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
923
+ /** Space-id -> cached sample questions with an absolute expiry. */
924
+ const _suggestionCache = new Map();
925
+ /** Read a space's cached questions, or `undefined` on miss / expiry. */
926
+ function readSuggestionCache(spaceId) {
927
+ const entry = _suggestionCache.get(spaceId);
928
+ if (!entry)
929
+ return undefined;
930
+ if (entry.expires <= Date.now()) {
931
+ _suggestionCache.delete(spaceId);
932
+ return undefined;
933
+ }
934
+ return entry.questions;
935
+ }
936
+ /** Cache a space's parsed questions for {@link SUGGESTION_CACHE_TTL_MS}. */
937
+ function writeSuggestionCache(spaceId, questions) {
938
+ _suggestionCache.set(spaceId, {
939
+ questions,
940
+ expires: Date.now() + SUGGESTION_CACHE_TTL_MS,
941
+ });
942
+ }
943
+ /**
944
+ * Collect the curated starter questions across every resolved Genie
945
+ * space, deduped and capped. Each space's `sample_questions` are
946
+ * fetched once (cached for {@link SUGGESTION_CACHE_TTL_MS}) via
947
+ * {@link getGenieSpace} + {@link genieSampleQuestions}, then merged in
948
+ * alias-iteration order so a single-space app surfaces that space's
949
+ * questions and a multi-space app round-trips breadth-first up to the
950
+ * cap. A per-space fetch failure degrades to "no questions for that
951
+ * space" (logged, not thrown) so one unreachable space never blanks
952
+ * the whole list. Returns `[]` when no spaces are configured.
953
+ */
954
+ export async function collectSpaceSuggestions(opts) {
955
+ const limit = opts.limit ?? SUGGESTION_LIMIT;
956
+ const merged = [];
957
+ const seen = new Set();
958
+ for (const { spaceId } of Object.values(opts.spaces)) {
959
+ let questions = readSuggestionCache(spaceId);
960
+ if (!questions) {
961
+ try {
962
+ const space = await getGenieSpace(spaceId, {
963
+ workspaceClient: opts.client,
964
+ ...(opts.signal ? { context: opts.signal } : {}),
965
+ });
966
+ questions = genieSampleQuestions(space);
967
+ writeSuggestionCache(spaceId, questions);
968
+ }
969
+ catch (err) {
970
+ log.warn("suggestions:fetch-error", {
971
+ spaceId,
972
+ error: commonUtils.errorMessage(err),
973
+ });
974
+ questions = [];
975
+ }
976
+ }
977
+ for (const question of questions) {
978
+ if (seen.has(question))
979
+ continue;
980
+ seen.add(question);
981
+ merged.push(question);
982
+ if (merged.length >= limit)
983
+ return merged;
984
+ }
985
+ }
986
+ return merged;
987
+ }
@@ -109,6 +109,15 @@ export declare class MastraPlugin extends Plugin<MastraPluginConfig> {
109
109
  };
110
110
  clientConfig(): Record<string, unknown>;
111
111
  injectRoutes(router: IAppRouter): void;
112
+ /**
113
+ * Implementation backing the `/suggestions` route. Runs inside the
114
+ * AppKit user-context proxy so `getExecutionContext()` returns the
115
+ * OBO-scoped client. Resolves the plugin's Genie spaces and merges
116
+ * their curated `sample_questions` (see {@link collectSpaceSuggestions}).
117
+ * Returns `[]` when no Genie space is configured so the client
118
+ * shows a bare empty state instead of built-in example prompts.
119
+ */
120
+ private fetchSuggestions;
112
121
  /**
113
122
  * Implementation backing the `data` embed resolver
114
123
  * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
@@ -33,6 +33,7 @@ import { Mastra } from "@mastra/core/mastra";
33
33
  import express from "express";
34
34
  import { buildAgents, FALLBACK_AGENT_ID } from "./agents.js";
35
35
  import { fetchChart } from "./chart.js";
36
+ import { collectSpaceSuggestions, resolveGenieSpaces } from "./genie.js";
36
37
  import { historyRoute } from "./history.js";
37
38
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
38
39
  import { buildObservability } from "./observability.js";
@@ -174,6 +175,8 @@ export class MastraPlugin extends Plugin {
174
175
  historyPath: `${basePath}/route/history`,
175
176
  historyPathTemplate: `${basePath}/route/history/:agentId`,
176
177
  embedPathTemplate: `${basePath}/embed/:type/:id`,
178
+ suggestionsPath: `${basePath}/suggestions`,
179
+ suggestionsPathTemplate: `${basePath}/suggestions/:agentId`,
177
180
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
178
181
  agents: Object.keys(this.built?.agents ?? {}),
179
182
  };
@@ -266,12 +269,59 @@ export class MastraPlugin extends Plugin {
266
269
  })
267
270
  .catch(next);
268
271
  });
272
+ // `GET /suggestions` (and `/suggestions/:agentId`) returns the
273
+ // curated starter questions for the agent's Genie space(s) - the
274
+ // author-configured `sample_questions`, surfaced as one-tap
275
+ // prompts on the chat empty state. Returns `{ questions: [] }`
276
+ // when no Genie space is wired so the client renders a bare
277
+ // empty state (no built-in example prompts). The `:agentId`
278
+ // segment is accepted for URL symmetry with the chat / history
279
+ // routes; Genie spaces are resolved per-plugin, not per-agent,
280
+ // so it doesn't change the result. OBO-scoped like the other
281
+ // data routes so the space lookup runs as the calling user.
282
+ const handleSuggestions = (req, res) => {
283
+ const controller = new AbortController();
284
+ req.on("close", () => controller.abort());
285
+ this.userScopedSelf(req)
286
+ .fetchSuggestions(controller.signal)
287
+ .then((questions) => res.json({ questions }))
288
+ .catch((err) => {
289
+ // Suggestions are a non-critical enhancement; a lookup
290
+ // failure should leave the chat usable with a bare empty
291
+ // state rather than surfacing a 500. Log and degrade.
292
+ this.log.warn("suggestions:error", {
293
+ error: err instanceof Error ? err.message : String(err),
294
+ });
295
+ res.json({ questions: [] });
296
+ });
297
+ };
298
+ router.get("/suggestions", handleSuggestions);
299
+ router.get("/suggestions/:agentId", handleSuggestions);
269
300
  router.use("", (req, res, next) => {
270
301
  if (!this.mastraApp)
271
302
  return res.status(503).end();
272
303
  return this.userScopedSelf(req).mastraApp(req, res, next);
273
304
  });
274
305
  }
306
+ /**
307
+ * Implementation backing the `/suggestions` route. Runs inside the
308
+ * AppKit user-context proxy so `getExecutionContext()` returns the
309
+ * OBO-scoped client. Resolves the plugin's Genie spaces and merges
310
+ * their curated `sample_questions` (see {@link collectSpaceSuggestions}).
311
+ * Returns `[]` when no Genie space is configured so the client
312
+ * shows a bare empty state instead of built-in example prompts.
313
+ */
314
+ async fetchSuggestions(signal) {
315
+ const spaces = resolveGenieSpaces(this.config, this.context);
316
+ if (Object.keys(spaces).length === 0)
317
+ return [];
318
+ const client = getExecutionContext().client;
319
+ return collectSpaceSuggestions({
320
+ spaces,
321
+ client,
322
+ ...(signal ? { signal } : {}),
323
+ });
324
+ }
275
325
  /**
276
326
  * Implementation backing the `data` embed resolver
277
327
  * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so