@dbx-tools/appkit-mastra 0.1.13 → 0.1.14

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 (59) hide show
  1. package/README.md +304 -645
  2. package/index.ts +46 -38
  3. package/package.json +58 -45
  4. package/src/agents.ts +224 -66
  5. package/src/chart.ts +531 -429
  6. package/src/config.ts +270 -19
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1000 -660
  9. package/src/history.ts +166 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +94 -92
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +75 -408
  14. package/src/observability.ts +121 -69
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +552 -67
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +232 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -243
  22. package/src/statement.ts +89 -0
  23. package/src/storage-schema.ts +41 -0
  24. package/src/summarize.ts +176 -0
  25. package/src/threads.ts +338 -0
  26. package/src/workspaces.ts +346 -0
  27. package/src/writer.ts +44 -0
  28. package/tsconfig.json +41 -0
  29. package/dist/index.d.ts +0 -20
  30. package/dist/index.js +0 -20
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -403
  33. package/dist/src/chart.d.ts +0 -170
  34. package/dist/src/chart.js +0 -491
  35. package/dist/src/config.d.ts +0 -183
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -131
  38. package/dist/src/genie.js +0 -630
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -172
  41. package/dist/src/memory.d.ts +0 -100
  42. package/dist/src/memory.js +0 -242
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -427
  45. package/dist/src/observability.d.ts +0 -33
  46. package/dist/src/observability.js +0 -71
  47. package/dist/src/plugin.d.ts +0 -130
  48. package/dist/src/plugin.js +0 -283
  49. package/dist/src/processors/strip-stale-charts.d.ts +0 -29
  50. package/dist/src/processors/strip-stale-charts.js +0 -96
  51. package/dist/src/server.d.ts +0 -46
  52. package/dist/src/server.js +0 -123
  53. package/dist/src/serving.d.ts +0 -156
  54. package/dist/src/serving.js +0 -231
  55. package/dist/src/tools/email.d.ts +0 -74
  56. package/dist/src/tools/email.js +0 -122
  57. package/dist/tsconfig.build.tsbuildinfo +0 -1
  58. package/src/processors/strip-stale-charts.ts +0 -105
  59. package/src/tools/email.ts +0 -147
package/src/genie.ts CHANGED
@@ -1,751 +1,1091 @@
1
1
  /**
2
- * Mastra tool wrappers around the AppKit `genie` plugin's exports.
2
+ * Genie tools for Mastra.
3
3
  *
4
- * One `sendMessage` tool is registered per configured space alias so
5
- * the LLM picks the space by tool selection (the description bakes the
6
- * alias in). `getConversation` is registered once, taking `alias` as a
7
- * parameter.
4
+ * Surfaces each configured Genie space as a small set of flat Mastra
5
+ * tools the calling agent drives directly - no inner orchestrator
6
+ * agent. The central agent decomposes user questions, picks which
7
+ * space to ask, streams the per-turn wire events (status, thinking,
8
+ * sql, rows) through `ctx.writer`, and composes the final reply.
9
+ * Rows are never fetched eagerly: the agent reads a statement's
10
+ * values only when it needs to reason about them, otherwise it embeds
11
+ * a `[data:<statement_id>]` marker in prose and lets the host UI
12
+ * resolve the data. Charts are minted asynchronously and referenced
13
+ * by `[chart:<chartId>]` markers so prose isn't blocked on chart
14
+ * generation; the host UI fetches the cached spec by id once ready.
15
+ * Space description and serialized-space lookups are available for
16
+ * grounding when the agent needs schema context.
8
17
  *
9
- * All Genie payload types are inferred from the public `genie` factory
10
- * (`genie().plugin` constructor `exports()` return type), so any
11
- * upstream change in `@databricks/appkit` flows in automatically.
18
+ * Each tool's `execute` pulls the per-request
19
+ * {@link WorkspaceClient} off `ctx.requestContext` (stamped by
20
+ * `MastraServer` under {@link MASTRA_USER_KEY}) and the per-call
21
+ * `writer` / `abortSignal` off `ctx`, so the tools are stateless
22
+ * across requests and the central agent owns the loop.
12
23
  *
13
- * As Genie streams its long-running events (`FETCHING_METADATA` →
14
- * `ASKING_AI` `EXECUTING_QUERY` `COMPLETED`, plus SQL text and
15
- * follow-ups in `message_result.attachments`), the tool forwards a
16
- * normalised {@link GenieProgress} discriminated union out through
17
- * `ctx.writer` so the client can render an incremental loading pill.
18
- * Row payloads from `query_result` are intentionally discarded - the
19
- * LLM never sees rows, and charts come from the separate
20
- * `render_data` tool when the model decides one is useful.
24
+ * The tools talk to Genie directly via `@dbx-tools/genie`
25
+ * (`genieEventChat`); statement-row fetching is delegated to
26
+ * {@link fetchStatementData} from `./statement.js`, which wraps
27
+ * the workspace `statementExecution.getStatement` API. AppKit's
28
+ * stock `genie` plugin is honored only for its `spaces` config
29
+ * so existing AppKit-style wiring keeps working without change.
30
+ *
31
+ * Suggested orchestration prompt for the central agent lives in
32
+ * {@link GENIE_INSTRUCTIONS}; compose it into the agent's own
33
+ * `instructions` when you want the canonical "how to drive the
34
+ * Genie tools" guidance.
21
35
  */
22
36
 
23
- import { genie } from "@databricks/appkit";
24
- import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
37
+ import { CacheManager, genie } from "@databricks/appkit";
38
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
39
+ import { wire, type MastraWriter, type StartedEvent } from "@dbx-tools/shared-mastra";
40
+ import { chat, space as genieSpace } from "@dbx-tools/genie";
41
+ import { genieModel, type GenieMessage } from "@dbx-tools/shared-genie";
42
+ import { error, log, string } from "@dbx-tools/shared-core";
43
+ import { plugin } from "@dbx-tools/appkit";
25
44
  import type { RequestContext } from "@mastra/core/request-context";
45
+ import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
26
46
  import { createTool } from "@mastra/core/tools";
27
- import type { ToolStream } from "@mastra/core/tools";
28
47
  import { z } from "zod";
29
48
 
30
- import { emitChartWithPlanning } from "./chart.js";
31
- import type { MastraPluginConfig } from "./config.js";
49
+ import type { MastraTools } from "./agents";
50
+ import { chartPlannerRequestSchema, prepareChart } from "./chart";
51
+ import type { MastraPluginConfig } from "./config";
52
+ import { MASTRA_USER_KEY, type User } from "./config";
53
+ import { fetchStatementData } from "./statement";
54
+ import { safeWrite } from "./writer";
55
+
56
+ const logger = log.logger("mastra/genie");
57
+
58
+ /** Default alias used when a single unnamed Genie space is wired up. */
59
+ export const DEFAULT_GENIE_ALIAS = "default";
60
+
61
+ /* --------------------------- config types --------------------------- */
62
+
63
+ /** Per-space Genie agent configuration. */
64
+ export interface GenieSpaceConfig {
65
+ /** Genie `space_id`. Required; resolves via `client.genie.getSpace`. */
66
+ spaceId: string;
67
+ /**
68
+ * Optional human-readable description appended to the per-space
69
+ * tool descriptions so the calling LLM has hints about *what
70
+ * data* this space covers (e.g. "orders, returns,
71
+ * fulfillment"). When omitted, only the space's own
72
+ * `description` (fetched on first use of `get_space_description`)
73
+ * is shown.
74
+ */
75
+ hint?: string;
76
+ }
77
+
78
+ /** Map of alias -> space config. Accepts either explicit objects or bare space ids. */
79
+ export type GenieSpacesConfig = Record<string, GenieSpaceConfig | string>;
80
+
81
+ /* ------------------------- ctx helpers ------------------------- */
82
+
83
+ /**
84
+ * Narrow view of the second arg Mastra passes to a tool's
85
+ * `execute(input, ctx)`. Captures the fields the Genie tools
86
+ * actually read - `requestContext` (for user / conversation
87
+ * state), `writer` (for streaming events to the host UI), and
88
+ * `abortSignal` (for per-call cancellation).
89
+ */
90
+ type ToolExecuteCtx =
91
+ | {
92
+ requestContext?: RequestContext;
93
+ writer?: MastraWriter;
94
+ abortSignal?: AbortSignal;
95
+ }
96
+ | undefined;
32
97
 
33
98
  /**
34
- * Module-level logger tagged `[mastra/genie]`. Uses the shared
35
- * {@link logUtils.logger} so calls below `LOG_LEVEL` are
36
- * discarded for free. Default `LOG_LEVEL` is `info`; flip to
37
- * `debug` to see per-turn timing (`query_result` planner
38
- * waits `drain:return`).
99
+ * Pull the per-request {@link WorkspaceClient} off the active
100
+ * `RequestContext`. The Mastra plugin's server middleware stamps
101
+ * a {@link User} on the context under {@link MASTRA_USER_KEY}
102
+ * for every request; tools fail loudly when it's missing because
103
+ * that means the Mastra plugin isn't running (e.g. a tool was
104
+ * invoked outside the chat route).
39
105
  */
40
- const log = logUtils.logger("mastra/genie");
106
+ function requireClient(
107
+ ctx: ToolExecuteCtx,
108
+ toolId: string,
109
+ ): {
110
+ client: WorkspaceClient;
111
+ requestContext: RequestContext;
112
+ } {
113
+ const requestContext = ctx?.requestContext;
114
+ if (!requestContext) {
115
+ throw new Error(`${toolId}: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)`);
116
+ }
117
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
118
+ if (!user) {
119
+ throw new Error(`${toolId}: no user on requestContext (MASTRA_USER_KEY not set)`);
120
+ }
121
+ return { client: user.executionContext.client, requestContext };
122
+ }
41
123
 
42
- /** Live AppKit `GeniePlugin` instance. */
43
- export type GeniePluginInstance = InstanceType<ReturnType<typeof genie>["plugin"]>;
124
+ /**
125
+ * Lowercased placeholder strings we reject at the `ask_genie`
126
+ * boundary so the LLM doesn't spend a Genie round-trip on a
127
+ * non-question. Genie politely answers any of these with "Your
128
+ * request '...' does not relate to..." which is pure UI noise.
129
+ * Kept narrow on purpose - real questions sometimes start with
130
+ * one of these tokens, so we only match the FULL trimmed string.
131
+ */
132
+ const PLACEHOLDER_QUESTIONS = new Set([
133
+ "noop",
134
+ "no-op",
135
+ "skip",
136
+ "none",
137
+ "n/a",
138
+ "na",
139
+ "null",
140
+ "undefined",
141
+ "test",
142
+ "placeholder",
143
+ ]);
44
144
 
45
- /** Full `exports()` shape of the AppKit `genie` plugin. */
46
- export type GenieExports = ReturnType<GeniePluginInstance["exports"]>;
145
+ /* ----------------------- conversation state ----------------------- */
47
146
 
48
147
  /**
49
- * Stream event yielded by `genie.exports().sendMessage`. Discriminated
50
- * by `type` (`"message_start" | "status" | "message_result" |
51
- * "query_result" | "error" | "history_info"`).
148
+ * Estimated Genie conversation lifetime in seconds. Databricks
149
+ * publishes no official TTL on the conversation resource itself;
150
+ * community projects (e.g. the open-source Databricks Genie Bot)
151
+ * converge on 4 hours of inactivity as a safe operating window.
152
+ * Treat this as an estimate that gets *extended on every use* by
153
+ * re-setting the cache entry after each successful turn (sliding
154
+ * TTL via re-`set`). When the estimate ends up wrong (conversation
155
+ * deleted, expired upstream, cross-space referenced), `ask_genie`
156
+ * catches the SDK's `RESOURCE_DOES_NOT_EXIST`/404 and transparently
157
+ * starts a fresh conversation.
52
158
  */
53
- export type GenieStreamEvent =
54
- ReturnType<GenieExports["sendMessage"]> extends AsyncGenerator<infer E> ? E : never;
159
+ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
55
160
 
56
- /** Conversation history returned by `genie.exports().getConversation`. */
57
- export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
161
+ /** Cache namespace prefix so coexisting Mastra caches don't collide. */
162
+ const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
58
163
 
59
164
  /**
60
- * Per-dataset metadata surfaced to the LLM. The actual rows are
61
- * dispatched separately as a `kind: "chart"` writer event so the
62
- * model never has the rows in its context (token cost stays flat
63
- * regardless of dataset size). The model uses `chartId` to
64
- * reference the chart inline via the `[[chart:<chartId>]]` marker.
165
+ * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
166
+ * conversations are scoped to a single user + space + thread, and
167
+ * `threadId` is already user-scoped (Mastra mints threads per
168
+ * `resourceId`), so a constant user key here is safe and keeps the
169
+ * cache key short.
65
170
  */
66
- const datasetSchema = z.object({
67
- chartId: z.string().describe(stringUtils.toDescription`
68
- Short id (8 hex chars) for the chart-render slot the host UI
69
- has staged for this dataset. Embed
70
- \`[[chart:<chartId>]]\` on its own line in your reply at the
71
- position you want the chart to appear; the client renders it
72
- inline. Do not paraphrase the dataset's rows in prose - the
73
- chart is the rendering.
74
- `),
75
- title: z.string().optional().describe(stringUtils.toDescription`
76
- Genie's own title for the SQL that produced this dataset.
77
- Useful as a label when you reference the chart in prose.
78
- `),
79
- description: z.string().optional().describe(stringUtils.toDescription`
80
- Genie's prose description of the SQL, if any.
81
- `),
82
- columns: z.array(z.string()).describe(stringUtils.toDescription`
83
- Column names in display order. Use these when describing what
84
- is being charted (e.g. "trend of fill_rate over date").
85
- `),
86
- rowCount: z.number().describe(stringUtils.toDescription`
87
- Total rows in this dataset. Mention only if it adds context
88
- (e.g. "across the last 90 days").
89
- `),
90
- sql: z
91
- .string()
92
- .optional()
93
- .describe(stringUtils.toDescription`
94
- SQL Genie generated and executed. The host UI shows this on
95
- demand; you do not need to repeat it.
96
- `),
97
- });
171
+ const CONVERSATION_USER_KEY = "mastra-genie";
98
172
 
99
173
  /**
100
- * Top-level output schema returned to the LLM from a Genie tool
101
- * call. The `datasets` array is intentionally metadata-only - row
102
- * data rides a writer event the host UI consumes directly and is
103
- * not in the model's context.
174
+ * Build the per-request {@link RequestContext} key the active
175
+ * Genie `conversation_id` lives under for `spaceId`. Scoped by
176
+ * space so an app calling two Genie spaces in one request keeps
177
+ * each conversation distinct (Genie conversation ids are
178
+ * space-scoped on the wire). The same `RequestContext` instance
179
+ * flows from the central agent through to every `ask_genie`
180
+ * invocation, so writes on one call are visible on the next
181
+ * without an explicit shared ref.
104
182
  */
105
- const genieToolOutputSchema = z.object({
106
- conversationId: z
107
- .string()
108
- .optional()
109
- .describe(stringUtils.toDescription`
110
- Pass back on the next call to continue the same Genie thread.
111
- `),
112
- genieAnswer: z
113
- .string()
114
- .optional()
115
- .describe(stringUtils.toDescription`
116
- Genie's natural-language answer to the question. Pass this
117
- through to the user (verbatim, or as the basis of your
118
- reply). Genie may have run multiple SQL queries and tools to
119
- produce this; the full text is the answer.
120
- `),
121
- datasets: z
122
- .array(datasetSchema)
123
- .optional()
124
- .describe(stringUtils.toDescription`
125
- Datasets Genie produced for this turn (one per executed SQL
126
- statement). Each entry is metadata only; the rows are
127
- streamed to the host UI out-of-band. To render any of these
128
- as a chart inline in your reply, embed
129
- \`[[chart:<chartId>]]\` where you want the chart to appear.
130
- Do not paraphrase the rows - the chart is what the user
131
- should see; your prose should add interpretation
132
- (highlights, deltas, anomalies) around the chart.
133
- `),
134
- suggestedFollowUps: z
135
- .array(z.string())
136
- .optional()
137
- .describe(stringUtils.toDescription`
138
- Follow-up question suggestions Genie produced. The host UI
139
- renders these as clickable buttons; you do not need to list
140
- them in your reply.
141
- `),
142
- error: z
143
- .string()
144
- .optional()
145
- .describe(stringUtils.toDescription`
146
- Genie-side error message if the request failed.
147
- `),
148
- });
183
+ const conversationContextKey = (spaceId: string): string =>
184
+ `mastra__genie_conversation__${spaceId}`;
149
185
 
150
- type DrainResult = z.infer<typeof genieToolOutputSchema>;
186
+ /**
187
+ * Read the active Genie `conversation_id` for `spaceId` off the
188
+ * per-request {@link RequestContext}. Returns `undefined` when no
189
+ * conversation has been started yet this request.
190
+ */
191
+ function readContextConversationId(
192
+ requestContext: RequestContext,
193
+ spaceId: string,
194
+ ): string | undefined {
195
+ return requestContext.get(conversationContextKey(spaceId)) as string | undefined;
196
+ }
151
197
 
152
198
  /**
153
- * Normalised progress event surfaced to the UI as a Mastra
154
- * `tool-output` chunk. Loading pill events (`started`, `status`,
155
- * `sql`, `suggested`, `error`) are pure UI metadata and never reach
156
- * the LLM.
157
- *
158
- * The `chart` variant is the wire shape emitted by
159
- * {@link emitChartWithPlanning} (used by both this Genie
160
- * draining loop and the system-level `render_data` tool). All
161
- * fields except `chartId` are optional because two events per
162
- * chartId arrive on the wire: the first carries the rows
163
- * (`title` + `description?` + `data`); the second, on planner
164
- * success, carries just the resolved Echarts spec (`option`).
165
- * The host UI's `<ChartSlot>` merges them by `chartId`.
199
+ * Write the active Genie `conversation_id` for `spaceId` onto the
200
+ * per-request {@link RequestContext}. Subsequent `ask_genie` calls
201
+ * in this request will reuse it.
166
202
  */
167
- export type GenieProgress =
168
- | { kind: "started"; conversationId: string; messageId: string; spaceId: string }
169
- | { kind: "status"; status: string; label: string }
170
- | {
171
- kind: "sql";
172
- sql: string;
173
- title?: string;
174
- description?: string;
175
- statementId?: string;
176
- }
177
- | {
178
- kind: "chart";
179
- chartId: string;
180
- title?: string;
181
- description?: string;
182
- data?: Array<Record<string, unknown>>;
183
- option?: Record<string, unknown>;
184
- }
185
- | { kind: "text"; content: string }
186
- | { kind: "suggested"; questions: string[] }
187
- | { kind: "error"; error: string };
188
-
189
- const sendMessageSchema = z.object({
190
- content: z.string().describe(stringUtils.toDescription`
191
- Natural-language question to send to the Genie space.
192
- `),
193
- conversationId: z
194
- .string()
195
- .optional()
196
- .describe(stringUtils.toDescription`
197
- Optional Genie conversation id to continue an earlier thread.
198
- Omit on the first call; pass the id returned in the previous
199
- result's \`conversationId\` to follow up.
200
- `),
201
- });
202
-
203
- const getConversationSchema = z.object({
204
- alias: z.string().describe(stringUtils.toDescription`
205
- Alias of the Genie space the conversation belongs to (matches
206
- the key in the genie plugin's \`spaces\` config).
207
- `),
208
- conversationId: z.string().describe(stringUtils.toDescription`
209
- Genie conversation id whose history to fetch.
210
- `),
211
- });
212
-
213
- /** Per-attachment shape returned inside a stored Genie message. */
214
- const genieAttachmentSchema = z.object({
215
- attachmentId: z.string().optional().describe(stringUtils.toDescription`
216
- Genie attachment id; internal bookkeeping.
217
- `),
218
- query: z
219
- .object({
220
- title: z.string().optional().describe(stringUtils.toDescription`
221
- Genie's title for the SQL, if any.
222
- `),
223
- description: z.string().optional().describe(stringUtils.toDescription`
224
- Genie's prose description of the SQL, if any.
225
- `),
226
- query: z.string().optional().describe(stringUtils.toDescription`
227
- SQL Genie generated and executed.
228
- `),
229
- statementId: z.string().optional().describe(stringUtils.toDescription`
230
- Statement-execution id; internal bookkeeping.
231
- `),
232
- })
233
- .optional()
234
- .describe(stringUtils.toDescription`
235
- SQL Genie attached to this message, if it ran any.
236
- `),
237
- text: z
238
- .object({
239
- content: z.string().optional().describe(stringUtils.toDescription`
240
- Genie's natural-language answer text for this attachment.
241
- `),
242
- })
243
- .optional()
244
- .describe(stringUtils.toDescription`
245
- Per-attachment text content (independent of the message-level
246
- \`content\` field).
247
- `),
248
- suggestedQuestions: z
249
- .array(z.string())
250
- .optional()
251
- .describe(stringUtils.toDescription`
252
- Follow-up question suggestions Genie generated for this turn.
253
- `),
254
- });
255
-
256
- /** Single message inside a Genie conversation history page. */
257
- const genieMessageSchema = z.object({
258
- messageId: z.string().describe(stringUtils.toDescription`
259
- Genie message id; internal bookkeeping.
260
- `),
261
- conversationId: z.string().describe(stringUtils.toDescription`
262
- Conversation id this message belongs to.
263
- `),
264
- spaceId: z.string().describe(stringUtils.toDescription`
265
- Genie space id this message belongs to.
266
- `),
267
- status: z.string().describe(stringUtils.toDescription`
268
- Genie message status (\`COMPLETED\`, \`FAILED\`, etc.).
269
- `),
270
- content: z.string().describe(stringUtils.toDescription`
271
- Outer message-level natural-language content Genie wrote.
272
- `),
273
- attachments: z
274
- .array(genieAttachmentSchema)
275
- .optional()
276
- .describe(stringUtils.toDescription`
277
- Attachments (SQL queries, text blocks, suggested follow-ups)
278
- Genie produced for this message.
279
- `),
280
- error: z.string().optional().describe(stringUtils.toDescription`
281
- Genie-side error attached to this message, if any.
282
- `),
283
- });
203
+ function writeContextConversationId(
204
+ requestContext: RequestContext,
205
+ spaceId: string,
206
+ conversationId: string | undefined,
207
+ ): void {
208
+ requestContext.set(conversationContextKey(spaceId), conversationId);
209
+ }
284
210
 
285
211
  /**
286
- * Output schema for the \`genie_get_conversation\` tool. Mirrors
287
- * AppKit's \`GenieConversationHistoryResponse\` so the model gets a
288
- * clear, typed view of prior messages instead of an opaque blob.
212
+ * Build the canonical cache key for a `(spaceId, threadId)` pair.
213
+ * Returns `undefined` when `threadId` is missing - callers should
214
+ * skip caching entirely in that case (no Mastra memory wired up).
289
215
  */
290
- const genieGetConversationOutputSchema = z.object({
291
- conversationId: z.string().describe(stringUtils.toDescription`
292
- Conversation id you fetched.
293
- `),
294
- spaceId: z.string().describe(stringUtils.toDescription`
295
- Genie space the conversation belongs to.
296
- `),
297
- messages: z.array(genieMessageSchema).describe(stringUtils.toDescription`
298
- Messages in the conversation, oldest to newest. Each
299
- \`message.content\` is Genie's natural-language answer for
300
- that turn; attachments carry the SQL and follow-ups Genie
301
- produced.
302
- `),
303
- });
216
+ async function conversationCacheKey(
217
+ spaceId: string,
218
+ threadId: string | undefined,
219
+ ): Promise<string | undefined> {
220
+ if (!threadId) return undefined;
221
+ return (await CacheManager.getInstance()).generateKey(
222
+ [CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
223
+ CONVERSATION_USER_KEY,
224
+ );
225
+ }
304
226
 
305
227
  /**
306
- * Default tool name for a wired Genie alias. The well-known `default`
307
- * alias collapses to `genie`; everything else gets a `genie_` prefix so
308
- * multiple spaces stay disambiguated when an agent has more than one
309
- * wired. Matches the `genie` / `genie_<alias>` naming used elsewhere in
310
- * dbx-tools AppKit demos.
228
+ * Read the cached Genie conversation id for `(spaceId, threadId)`.
229
+ * Returns `undefined` on miss, on expiry, or when the cache layer
230
+ * is unhealthy - never throws. The TTL is renewed via re-`set`
231
+ * after each successful turn (see {@link saveCachedConversationId}).
311
232
  */
312
- export function defaultGenieToolName(alias: string): string {
313
- if (alias === "default") return "genie";
314
- return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
233
+ async function readCachedConversationId(cacheKey: string | undefined): Promise<string | undefined> {
234
+ if (!cacheKey) return undefined;
235
+ try {
236
+ const v = await CacheManager.getInstanceSync().get<string>(cacheKey);
237
+ return v ?? undefined;
238
+ } catch (err) {
239
+ logger.warn("conversation-cache:read-error", {
240
+ error: error.errorMessage(err),
241
+ });
242
+ return undefined;
243
+ }
315
244
  }
316
245
 
317
246
  /**
318
- * Build one `sendMessage` tool per configured Genie alias plus a single
319
- * `getConversation` tool. Returns a record keyed by tool id, ready to
320
- * spread into an `Agent`'s `tools` map.
321
- *
322
- * `config` must be the active plugin config; Genie's
323
- * `query_result` events are routed through
324
- * {@link emitChartWithPlanning} which uses it to resolve the
325
- * chart-planner's model.
247
+ * Persist the active conversation id under `cacheKey`, refreshing
248
+ * its TTL. Idempotent; no-op when `cacheKey` or `conversationId`
249
+ * is missing. Re-setting the same key acts as a sliding TTL: every
250
+ * turn that uses the conversation extends the window by another
251
+ * {@link CONVERSATION_TTL_SEC} seconds.
326
252
  */
327
- export function buildGenieTools(opts: {
328
- aliases: string[];
329
- exports: GenieExports;
330
- config: MastraPluginConfig;
331
- signal?: AbortSignal;
332
- }): Record<string, ReturnType<typeof createTool>> {
333
- const tools: Record<string, ReturnType<typeof createTool>> = {};
253
+ async function saveCachedConversationId(
254
+ cacheKey: string | undefined,
255
+ conversationId: string | undefined,
256
+ ): Promise<void> {
257
+ if (!cacheKey || !conversationId) return;
258
+ try {
259
+ await CacheManager.getInstanceSync().set(cacheKey, conversationId, {
260
+ ttl: CONVERSATION_TTL_SEC,
261
+ });
262
+ } catch (err) {
263
+ logger.warn("conversation-cache:write-error", {
264
+ error: error.errorMessage(err),
265
+ });
266
+ }
267
+ }
334
268
 
335
- for (const alias of opts.aliases) {
336
- const id = defaultGenieToolName(alias);
337
- tools[id] = createTool({
338
- id,
339
- description: stringUtils.toDescription`
340
- Ask the Databricks Genie space "${alias}" a single
341
- natural-language question. Genie translates it to SQL,
342
- runs it, and returns \`genieAnswer\` (prose) plus
343
- \`datasets[]\` (one entry per executed query, each with
344
- a short \`chartId\`). Embed \`[[chart:<chartId>]]\` on
345
- its own line at the position you want that data rendered
346
- as an inline chart. Add interpretation around the chart
347
- (deltas, anomalies, takeaways); do not paraphrase row
348
- values.
349
-
350
- Issue ONE focused question per user turn. Prefer
351
- aggregated queries over raw-row queries for time-series
352
- and distributions.
353
- `,
354
- inputSchema: sendMessageSchema,
355
- outputSchema: genieToolOutputSchema,
356
- execute: async ({ content, conversationId }, ctx) => {
357
- const stream = opts.exports.sendMessage(alias, content, conversationId, {
358
- signal: opts.signal,
359
- });
360
- const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
361
- ?.requestContext;
362
- return drainGenieStream(stream, ctx.writer, {
363
- config: opts.config,
364
- ...(requestContext ? { requestContext } : {}),
365
- });
366
- },
269
+ /** Force-evict a cached conversation id. Used on the stale-id recovery path. */
270
+ async function evictCachedConversationId(cacheKey: string | undefined): Promise<void> {
271
+ if (!cacheKey) return;
272
+ try {
273
+ await CacheManager.getInstanceSync().delete(cacheKey);
274
+ } catch (err) {
275
+ logger.warn("conversation-cache:delete-error", {
276
+ error: error.errorMessage(err),
367
277
  });
368
278
  }
279
+ }
369
280
 
370
- tools.genie_get_conversation = createTool({
371
- id: "genie_get_conversation",
372
- description: stringUtils.toDescription`
373
- Fetch the full message history of a prior Genie conversation
374
- by id. Use when the user references an earlier Genie thread
375
- by id, or to inspect attachments / SQL from previous turns.
376
- `,
377
- inputSchema: getConversationSchema,
378
- outputSchema: genieGetConversationOutputSchema,
379
- execute: async ({ alias, conversationId }) => {
380
- return opts.exports.getConversation(alias, conversationId, opts.signal);
381
- },
281
+ /**
282
+ * Lazy-seed the active Genie `conversation_id` for `spaceId` from
283
+ * the cross-request cache onto the per-request `RequestContext`.
284
+ * No-op when the slot is already populated (subsequent
285
+ * `ask_genie` calls in the same turn) so we hit the cache at most
286
+ * once per request per space.
287
+ */
288
+ async function ensureConversationSeeded(
289
+ requestContext: RequestContext,
290
+ spaceId: string,
291
+ cacheKey: string | undefined,
292
+ ): Promise<void> {
293
+ if (readContextConversationId(requestContext, spaceId)) return;
294
+ const cached = await readCachedConversationId(cacheKey);
295
+ if (cached) writeContextConversationId(requestContext, spaceId, cached);
296
+ }
297
+
298
+ /* ------------------------ prepare_chart input ------------------------ */
299
+
300
+ /**
301
+ * Agent-facing `prepare_chart` input schema. Reuses
302
+ * {@link chartPlannerRequestSchema} (the dataset-driven planner
303
+ * contract) but swaps the inline `data` field for a Genie
304
+ * `statement_id` the tool resolves into rows server-side.
305
+ * `title` is loosened to optional - the planner falls back to a
306
+ * generic placeholder when the agent doesn't supply one.
307
+ *
308
+ * Shaped to match Genie's wire form - `statement_id` (snake)
309
+ * mirrors `query_result.statement_id` and the `get_statement`
310
+ * tool's input field name, so the LLM only ever sees one
311
+ * spelling for the same identifier.
312
+ */
313
+ const prepareChartRequestSchema = chartPlannerRequestSchema
314
+ .omit({ data: true, title: true })
315
+ .extend({
316
+ statement_id: z
317
+ .string()
318
+ .min(1, "statement_id is required")
319
+ .describe(
320
+ string.toDescription(`
321
+ Genie \`statement_id\` to chart. Read from
322
+ \`message.query_result.statement_id\` or
323
+ \`message.attachments[*].query.statement_id\` returned by
324
+ \`ask_genie\`.
325
+ `),
326
+ ),
327
+ title: chartPlannerRequestSchema.shape.title.optional(),
382
328
  });
383
329
 
384
- return tools;
385
- }
330
+ /* ----------------------------- tool ids ----------------------------- */
386
331
 
387
- /** Inputs to {@link drainGenieStream}. */
388
- interface DrainGenieStreamOptions {
389
- config: MastraPluginConfig;
390
- requestContext?: RequestContext;
332
+ /**
333
+ * Suffix appended to per-space tool ids when the alias isn't the
334
+ * well-known `default`. Single-space deployments get the bare
335
+ * names (`ask_genie`, `get_space_description`, ...); multi-space
336
+ * deployments get `ask_genie_<alias>` etc. so each space's tools
337
+ * stay disambiguated in the central agent's tool registry.
338
+ */
339
+ function aliasSuffix(alias: string): string {
340
+ if (alias === DEFAULT_GENIE_ALIAS) return "";
341
+ const slug = string.toIdentifier(alias);
342
+ return slug ? `_${slug}` : "";
391
343
  }
392
344
 
345
+ /* --------------------------- per-space tools --------------------------- */
346
+
393
347
  /**
394
- * Drain the genie `sendMessage` AsyncGenerator into a flat result
395
- * the agent's calling LLM can reason about, while forwarding
396
- * progress and chart events to the host UI.
397
- *
398
- * Three streams of output happen in parallel:
399
- *
400
- * 1. {@link GenieProgress} pill events on the writer (`started`,
401
- * `status`, `sql`, `suggested`, `error`) drive the loading
402
- * pill in the chat bubble.
403
- * 2. `kind: "chart"` events on the writer (emitted via
404
- * {@link emitChartWithPlanning}) carry the row payload from
405
- * each Genie SQL statement and, on planner success, a
406
- * follow-up event with the rendered Echarts spec. The host
407
- * UI's `<ChartSlot>` merges the two by `chartId` and
408
- * renders inline at the marker position the model picked.
409
- * The data never reaches the LLM.
410
- * 3. The `DrainResult` returned to the LLM contains Genie's
411
- * prose answer plus a `datasets[]` array of metadata
412
- * (chartId, title, columns, rowCount, sql) the model uses
413
- * to cite charts via `[[chart:<chartId>]]` markers.
414
- *
415
- * `query_result` and `message_result` events arrive in either
416
- * order; we buffer per-statement scratch keyed by `statementId`
417
- * so each half can fill in what it knows. The chart event
418
- * fires the moment `query_result` lands; the planner runs in
419
- * the background. We `Promise.allSettled` every planner promise
420
- * before returning so all chart work is attributed to the tool's
421
- * trace span and so the LLM's `datasets[]` includes every
422
- * chartId that has actually been queued.
348
+ * Drop `suggested_questions` attachments from a {@link GenieMessage}
349
+ * before handing it to the central LLM. Those entries already
350
+ * surface in the UI as one-tap pills via the writer's
351
+ * `suggested_questions` events (see `collectSuggestions` on the
352
+ * client); if we let them ride back in the tool result the LLM
353
+ * tends to quote them inline in its prose, double-showing the
354
+ * same questions and stepping on the dedicated suggestion UI.
355
+ * Query / text / row attachments are preserved so the model can
356
+ * still read `statement_id`, SQL, and the answer text.
423
357
  */
424
- async function drainGenieStream(
425
- stream: AsyncGenerator<GenieStreamEvent>,
426
- writer: ToolStream | undefined,
427
- opts: DrainGenieStreamOptions,
428
- ): Promise<DrainResult> {
429
- const { config, requestContext } = opts;
430
- let conversationId: string | undefined;
431
- let genieAnswer: string | undefined;
432
- let suggestedFollowUps: string[] | undefined;
433
- let error: string | undefined;
434
- // AppKit's `streamSendMessage` forwards every SDK `onProgress`
435
- // callback verbatim - the same `EXECUTING_QUERY` can fire several
436
- // times during a single poll loop. AppKit's other path,
437
- // `streamGetMessage`, dedupes on the connector side; we mirror that
438
- // behaviour here so the UI status pill doesn't flicker and we don't
439
- // burn writer bytes on no-op events.
440
- let lastStatus: string | undefined;
441
-
442
- // Per-statement scratch keyed by Genie's `statementId`. Filled in
443
- // by both `query_result` (chartId + columns + rows) and
444
- // `message_result` (sql + title + description). The LLM-bound
445
- // `datasets[]` is built from this at end-of-stream, after all
446
- // planner promises settle.
447
- type Scratch = {
448
- statementId: string;
449
- chartId?: string;
450
- title?: string;
451
- description?: string;
452
- sql?: string;
453
- columns: string[];
454
- rowCount: number;
455
- };
456
- const scratchByStatementId = new Map<string, Scratch>();
457
- const getScratch = (statementId: string): Scratch => {
458
- let s = scratchByStatementId.get(statementId);
459
- if (!s) {
460
- s = { statementId, columns: [], rowCount: 0 };
461
- scratchByStatementId.set(statementId, s);
462
- }
463
- return s;
464
- };
465
- /**
466
- * Planner promises kicked off per `query_result`. Awaited
467
- * (Promise.allSettled) before drainGenieStream returns so the
468
- * Genie tool's trace span covers the chart work and the LLM's
469
- * `datasets[]` accurately reflects every chartId that's been
470
- * queued for rendering.
471
- */
472
- const plannerPromises: Promise<void>[] = [];
473
-
474
- const emit = async (event: GenieProgress) => {
475
- if (!writer) return;
476
- try {
477
- await writer.write(event);
478
- } catch {
479
- // ignore: downstream stream is no longer interested
480
- }
481
- };
358
+ function stripSuggestedQuestions(message: GenieMessage): GenieMessage {
359
+ const attachments = message.attachments;
360
+ if (!attachments || attachments.length === 0) return message;
361
+ const filtered = attachments.filter((att) => !att.suggested_questions);
362
+ if (filtered.length === attachments.length) return message;
363
+ return { ...message, attachments: filtered };
364
+ }
482
365
 
483
- for await (const event of stream) {
484
- // Per-event raw payload for tuning the pill / answer pipeline
485
- // against real Genie traffic. At `info` (the default) this is
486
- // discarded for free; flip `LOG_LEVEL=debug` to see every
487
- // raw wire event before the switch routes it through writer
488
- // and DrainResult.
489
- log.debug("event", { type: event.type, payload: event });
490
- switch (event.type) {
491
- case "message_start":
492
- conversationId = event.conversationId;
493
- await emit({
494
- kind: "started",
495
- conversationId: event.conversationId,
496
- messageId: event.messageId,
497
- spaceId: event.spaceId,
498
- });
499
- break;
500
- case "status":
501
- if (event.status === lastStatus) break;
502
- lastStatus = event.status;
503
- await emit({
504
- kind: "status",
505
- status: event.status,
506
- label: humanizeGenieStatus(event.status),
507
- });
508
- break;
509
- case "query_result": {
510
- const columns = (event.data?.manifest?.schema?.columns ?? []).map(
511
- (c) => c.name,
366
+ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string }) {
367
+ const { spaceId, alias, hint } = opts;
368
+ const toolId = `ask_genie${aliasSuffix(alias)}`;
369
+ const hintLine = hint ? ` (${hint})` : "";
370
+ return createTool({
371
+ id: toolId,
372
+ description: string.toDescription(`
373
+ Ask the Genie space "${alias}"${hintLine} ONE focused
374
+ natural-language sub-question and wait for the turn to
375
+ complete. Genie answers best when each call covers a
376
+ single metric / dimension / time window, so expect to
377
+ call this tool MULTIPLE TIMES per user turn (typically
378
+ two to six) and let the results from earlier calls inform
379
+ later ones - that's the normal pattern, not the exception.
380
+ Do NOT try to cram a multi-part question into a single
381
+ call; decompose first, then ask each piece.
382
+
383
+ Returns the final \`GenieMessage\`. Rows are NOT included -
384
+ the Genie wire response carries the \`statement_id\` for any
385
+ SQL that ran (at \`message.query_result.statement_id\` or the
386
+ first attachment's \`query.statement_id\`); call
387
+ \`get_statement\` with that id only when you need to read
388
+ the underlying values to reason about them. If you just
389
+ want to display the rows to the user, embed a
390
+ \`[data:<statement_id>]\` marker in your prose instead -
391
+ the host UI fetches and renders the rows on its own. Wire
392
+ events (status, thinking, sql) stream to the user
393
+ automatically while the call is in flight.
394
+ `),
395
+ inputSchema: z.object({
396
+ question: z.string().min(1, "question is required"),
397
+ }),
398
+ outputSchema: z.object({
399
+ message: genieModel.GenieMessageSchema,
400
+ }),
401
+ execute: async ({ question }, ctxRaw) => {
402
+ const ctx = ctxRaw as ToolExecuteCtx;
403
+ const { client, requestContext } = requireClient(ctx, toolId);
404
+ const writer = ctx?.writer;
405
+ const signal = ctx?.abortSignal;
406
+ const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
407
+
408
+ // Bounce placeholder / no-op questions BEFORE spending a Genie
409
+ // round-trip on them. Genie answers any of these with "Your
410
+ // request 'noop' does not relate to..." - useless noise that
411
+ // shows up in the UI and eats one of the workspace's 5
412
+ // questions/minute. Returning a clear error here surfaces the
413
+ // issue to the agent loop so the model corrects course instead
414
+ // of wasting a turn.
415
+ const trimmed = question.trim();
416
+ if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
417
+ throw new Error(
418
+ `${toolId}: refusing placeholder question "${question}" - ` +
419
+ `call ${toolId} only with a real natural-language question, ` +
420
+ `or skip the call entirely`,
512
421
  );
513
- const dataArray = (event.data?.result?.data_array ?? []) as Array<
514
- Array<string | null>
515
- >;
516
- const rows = genieRowsToObjects(columns, dataArray);
517
- const scratch = getScratch(event.statementId);
518
- // emitChartWithPlanning emits the dataset event immediately
519
- // and kicks off the chart-planner agent in the background.
520
- // It returns the chartId synchronously; the plannerPromise
521
- // is awaited at end-of-stream so chart work shows up under
522
- // this tool's trace span.
523
- const { chartId, plannerPromise } = await emitChartWithPlanning({
524
- ...(writer ? { writer } : {}),
525
- config,
526
- ...(requestContext ? { requestContext } : {}),
527
- title: scratch.title ?? `Genie query`,
528
- ...(scratch.description ? { description: scratch.description } : {}),
529
- data: rows,
530
- });
531
- scratch.chartId = chartId;
532
- scratch.columns = columns;
533
- scratch.rowCount = rows.length;
534
- plannerPromises.push(plannerPromise);
535
- log.debug("query_result", {
536
- statementId: event.statementId,
537
- chartId,
538
- rows: rows.length,
539
- columns,
540
- });
541
- break;
542
422
  }
543
- case "message_result":
544
- genieAnswer = event.message.content;
545
- for (const attachment of event.message.attachments ?? []) {
546
- const sqlText = attachment.query?.query;
547
- const stmtId = attachment.query?.statementId;
548
- if (stmtId) {
549
- const scratch = getScratch(stmtId);
550
- if (sqlText) scratch.sql = sqlText;
551
- if (attachment.query?.title) scratch.title = attachment.query.title;
552
- if (attachment.query?.description) {
553
- scratch.description = attachment.query.description;
554
- }
555
- }
556
- if (sqlText) {
557
- await emit({
558
- kind: "sql",
559
- sql: sqlText,
560
- title: attachment.query?.title,
561
- description: attachment.query?.description,
562
- statementId: stmtId,
563
- });
423
+
424
+ // Seed the active Genie `conversation_id` onto `RequestContext`
425
+ // from the cross-request cache when a Mastra `threadId` is
426
+ // present so multi-turn chats reuse the same Genie conversation
427
+ // (and Genie's accumulated context) across separate user turns.
428
+ // The same `RequestContext` is reused across every `ask_genie`
429
+ // call within one user turn, so `ensureConversationSeeded`
430
+ // hits the cache at most once per request per space.
431
+ const cacheKey = await conversationCacheKey(spaceId, threadId);
432
+ await ensureConversationSeeded(requestContext, spaceId, cacheKey);
433
+
434
+ // Fire the lifecycle `started` event before any LLM /
435
+ // network round-trip so the host UI can pop a "Thinking..."
436
+ // pill the instant the model decides to delegate.
437
+ const startedEvent: StartedEvent = {
438
+ type: "started",
439
+ spaceId,
440
+ content: question,
441
+ };
442
+ await safeWrite(logger, writer, startedEvent);
443
+
444
+ // Single turn of `genieEventChat`. Hoisted into a closure so
445
+ // we can re-run it after evicting a stale `conversation_id`
446
+ // without duplicating the event-loop body.
447
+ const runTurn = async (): Promise<GenieMessage> => {
448
+ const seedConversationId = readContextConversationId(requestContext, spaceId);
449
+ let finalMessage: GenieMessage | undefined;
450
+ for await (const event of chat.genieEventChat(spaceId, question, {
451
+ workspaceClient: client,
452
+ ...(seedConversationId ? { conversationId: seedConversationId } : {}),
453
+ ...(signal ? { context: signal } : {}),
454
+ })) {
455
+ if (event.type !== "message") {
456
+ await safeWrite(logger, writer, event);
564
457
  }
565
- if (attachment.text?.content) {
566
- await emit({ kind: "text", content: attachment.text.content });
458
+ const eventConversationId = event.conversation_id;
459
+ if (eventConversationId) {
460
+ writeContextConversationId(requestContext, spaceId, eventConversationId);
567
461
  }
568
- if (attachment.suggestedQuestions?.length) {
569
- // Last attachment with suggestions wins (same merge rule
570
- // the UI uses via `collectSuggestions`); keeping just one
571
- // copy per turn caps token usage.
572
- suggestedFollowUps = attachment.suggestedQuestions;
573
- await emit({
574
- kind: "suggested",
575
- questions: attachment.suggestedQuestions,
576
- });
462
+ if (event.type === "result") {
463
+ finalMessage = event.message;
577
464
  }
578
465
  }
579
- break;
580
- case "error":
581
- error = event.error;
582
- await emit({ kind: "error", error: event.error });
583
- break;
584
- default:
585
- break;
586
- }
587
- }
466
+ if (!finalMessage) {
467
+ throw new Error("Genie turn ended without a result event");
468
+ }
469
+ return finalMessage;
470
+ };
588
471
 
589
- // Wait for all chart planners to settle before returning so the
590
- // tool's trace span covers chart work and the LLM's
591
- // `datasets[]` reflects only chartIds the client has actually
592
- // received writer events for. Failures in `emitChartWithPlanning`
593
- // are already swallowed inside the helper, so this never
594
- // throws.
595
- log.debug("planners:awaiting", { count: plannerPromises.length });
596
- await Promise.allSettled(plannerPromises);
597
- log.debug("planners:settled", { count: plannerPromises.length });
598
-
599
- // Build the LLM-bound `datasets[]` from scratch entries that
600
- // actually ran a query (chartId is assigned at `query_result`
601
- // time). Entries that only saw `message_result` metadata
602
- // without a row payload are skipped.
603
- const datasets: Array<z.infer<typeof datasetSchema>> = [];
604
- for (const scratch of scratchByStatementId.values()) {
605
- if (!scratch.chartId) continue;
606
- datasets.push({
607
- chartId: scratch.chartId,
608
- ...(scratch.title ? { title: scratch.title } : {}),
609
- ...(scratch.description ? { description: scratch.description } : {}),
610
- columns: scratch.columns,
611
- rowCount: scratch.rowCount,
612
- ...(scratch.sql ? { sql: scratch.sql } : {}),
613
- });
614
- }
472
+ let finalMessage: GenieMessage;
473
+ try {
474
+ finalMessage = await runTurn();
475
+ } catch (err) {
476
+ // The seeded `conversation_id` was rejected by Genie - most
477
+ // commonly because it was deleted upstream, expired past
478
+ // Databricks' (undocumented) lifetime, or was minted in a
479
+ // different space. Drop both the cached id AND the
480
+ // per-request value so the retry calls `startConversation`,
481
+ // and try once more. Only retry when we *had* a seeded id -
482
+ // a fresh call that 404s shouldn't loop.
483
+ const seeded = readContextConversationId(requestContext, spaceId);
484
+ if (seeded && error.errorContext(err).notAccessible) {
485
+ logger.warn("conversation-cache:stale, resetting", {
486
+ spaceId,
487
+ conversationId: seeded,
488
+ error: error.errorMessage(err),
489
+ });
490
+ await evictCachedConversationId(cacheKey);
491
+ writeContextConversationId(requestContext, spaceId, undefined);
492
+ finalMessage = await runTurn();
493
+ } else {
494
+ throw err;
495
+ }
496
+ }
497
+
498
+ // Refresh the cache entry on every successful turn. Re-setting
499
+ // the same key both persists newly-minted ids (cache miss path)
500
+ // and extends the TTL on active conversations (sliding window).
501
+ await saveCachedConversationId(cacheKey, readContextConversationId(requestContext, spaceId));
615
502
 
616
- log.debug("drain:return", {
617
- conversationId,
618
- hasAnswer: typeof genieAnswer === "string",
619
- answerLength: genieAnswer?.length ?? 0,
620
- chartIds: datasets.map((d) => d.chartId),
621
- suggestedCount: suggestedFollowUps?.length ?? 0,
622
- error,
503
+ return { message: stripSuggestedQuestions(finalMessage) };
504
+ },
623
505
  });
506
+ }
624
507
 
625
- return {
626
- ...(conversationId ? { conversationId } : {}),
627
- ...(genieAnswer ? { genieAnswer } : {}),
628
- ...(datasets.length > 0 ? { datasets } : {}),
629
- ...(suggestedFollowUps ? { suggestedFollowUps } : {}),
630
- ...(error ? { error } : {}),
631
- };
508
+ function buildSpaceDescriptionTool(opts: { spaceId: string; alias: string }) {
509
+ const { spaceId, alias } = opts;
510
+ const toolId = `get_space_description${aliasSuffix(alias)}`;
511
+ return createTool({
512
+ id: toolId,
513
+ description: string.toDescription(`
514
+ Return the Genie space "${alias}"'s title, description, and
515
+ warehouse id. Cheap (single REST call, no LLM round-trip).
516
+ Call this FIRST on any user turn that's going to touch
517
+ \`ask_genie\`, unless the same description already landed
518
+ earlier in this conversation - the title + description tell
519
+ you what tables, metrics, and time windows the space
520
+ actually covers, which is what lets you decompose the
521
+ user's question into the right \`ask_genie\` sub-questions.
522
+ `),
523
+ inputSchema: z.object({}),
524
+ outputSchema: z.object({
525
+ spaceId: z.string(),
526
+ title: z.string().optional(),
527
+ description: z.string().optional(),
528
+ warehouseId: z.string().optional(),
529
+ }),
530
+ execute: async (_input, ctxRaw) => {
531
+ const ctx = ctxRaw as ToolExecuteCtx;
532
+ const { client } = requireClient(ctx, toolId);
533
+ const signal = ctx?.abortSignal;
534
+ // Route through the package's central Genie space fetch. The
535
+ // description surface (title / description / warehouse id) lives
536
+ // on the directory-listing shape, so skip the larger
537
+ // `serialized_space` payload here.
538
+ const space = await genieSpace.getGenieSpace(spaceId, {
539
+ workspaceClient: client,
540
+ serialized: false,
541
+ ...(signal ? { context: signal } : {}),
542
+ });
543
+ return {
544
+ spaceId,
545
+ ...(space.title ? { title: space.title } : {}),
546
+ ...(space.description ? { description: space.description } : {}),
547
+ ...(space.warehouse_id ? { warehouseId: space.warehouse_id } : {}),
548
+ };
549
+ },
550
+ });
551
+ }
552
+
553
+ function buildSpaceSerializedTool(opts: { spaceId: string; alias: string }) {
554
+ const { spaceId, alias } = opts;
555
+ const toolId = `get_space_serialized${aliasSuffix(alias)}`;
556
+ return createTool({
557
+ id: toolId,
558
+ description: string.toDescription(`
559
+ Return the full \`GenieSpace\` JSON for the "${alias}" space.
560
+ Use only when you need exact column / table identifiers
561
+ \`get_space_description\` doesn't expose. Larger payload, so
562
+ prefer the description tool when it's enough.
563
+ `),
564
+ inputSchema: z.object({}),
565
+ outputSchema: z.object({ space: z.unknown() }),
566
+ execute: async (_input, ctxRaw) => {
567
+ const ctx = ctxRaw as ToolExecuteCtx;
568
+ const { client } = requireClient(ctx, toolId);
569
+ const signal = ctx?.abortSignal;
570
+ // Central Genie space fetch with the opt-in `serialized_space`
571
+ // blob (catalogs, tables, sample questions, prompts) that the
572
+ // typed SDK `getSpace` omits - the whole point of this tool.
573
+ const space = await genieSpace.getGenieSpace(spaceId, {
574
+ workspaceClient: client,
575
+ ...(signal ? { context: signal } : {}),
576
+ });
577
+ return { space };
578
+ },
579
+ });
632
580
  }
633
581
 
582
+ /* --------------------------- shared tools --------------------------- */
583
+
634
584
  /**
635
- * Convert Genie's `data_array` (column-positional `string | null`
636
- * tuples) into plain JS row objects keyed by column name. Numeric
637
- * strings are coerced to numbers so the chart-planner picks
638
- * `value` axes instead of `category` axes; everything else passes
639
- * through verbatim. `null` becomes `null`.
585
+ * Default row cap for {@link buildGetStatementTool} when the agent
586
+ * doesn't supply a `limit`. Sized to keep result sets out of the
587
+ * model context unless the agent explicitly opts into more rows -
588
+ * the cheap shape (column names + a handful of representative
589
+ * rows) is usually enough to reason about a query.
640
590
  */
641
- function genieRowsToObjects(
642
- columns: ReadonlyArray<string>,
643
- dataArray: ReadonlyArray<ReadonlyArray<string | null>>,
644
- ): Array<Record<string, unknown>> {
645
- const out: Array<Record<string, unknown>> = [];
646
- for (const row of dataArray) {
647
- const obj: Record<string, unknown> = {};
648
- columns.forEach((col, i) => {
649
- const cell = row[i] ?? null;
650
- obj[col] = coerceCell(cell);
651
- });
652
- out.push(obj);
591
+ const DEFAULT_STATEMENT_LIMIT = 50;
592
+
593
+ function buildGetStatementTool() {
594
+ const toolId = "get_statement";
595
+ return createTool({
596
+ id: toolId,
597
+ description: string.toDescription(`
598
+ Fetch the rows of a Genie statement by its \`statement_id\` (the
599
+ value at \`message.query_result.statement_id\` or
600
+ \`message.attachments[*].query.statement_id\` returned from
601
+ \`ask_genie\`). Use this ONLY when you need to read the underlying
602
+ values to reason about them in your reply - e.g. naming the
603
+ largest row, computing a delta the visualization wouldn't already
604
+ convey, or filtering down to a specific record. If you'd just be
605
+ reciting numbers the user will see anyway, skip the call and
606
+ embed a \`[data:<statement_id>]\` marker in your prose instead;
607
+ the host UI fetches and renders the rows on its own. \`limit\`
608
+ caps the number of rows returned (defaults to
609
+ ${DEFAULT_STATEMENT_LIMIT}). \`rowCount\` reflects the full
610
+ upstream total - compare to \`rows.length\` to detect truncation.
611
+ `),
612
+ inputSchema: z.object({
613
+ statement_id: z.string().min(1, "statement_id is required"),
614
+ limit: z
615
+ .number()
616
+ .int()
617
+ .nonnegative()
618
+ .optional()
619
+ .describe(
620
+ "Max rows to return. Defaults to a small sample; raise only when more rows are genuinely needed.",
621
+ ),
622
+ }),
623
+ outputSchema: z.object({
624
+ columns: z.array(z.string()),
625
+ rows: z.array(z.record(z.string(), z.unknown())),
626
+ rowCount: z.number(),
627
+ truncated: z.boolean(),
628
+ }),
629
+ execute: async ({ statement_id, limit }, ctxRaw) => {
630
+ const ctx = ctxRaw as ToolExecuteCtx;
631
+ const { client } = requireClient(ctx, toolId);
632
+ const signal = ctx?.abortSignal;
633
+ const effectiveLimit = limit ?? DEFAULT_STATEMENT_LIMIT;
634
+ const data = await fetchStatementData(client, statement_id, {
635
+ limit: effectiveLimit,
636
+ ...(signal ? { signal } : {}),
637
+ });
638
+ return {
639
+ columns: data.columns,
640
+ rows: data.rows,
641
+ rowCount: data.rowCount,
642
+ truncated: data.rows.length < data.rowCount,
643
+ };
644
+ },
645
+ });
646
+ }
647
+
648
+ /**
649
+ * `prepare_chart` Mastra tool. Thin wrapper over
650
+ * {@link prepareChart} that resolves the dataset by fetching the
651
+ * Genie statement's rows on demand. The tool mints a `chartId`
652
+ * synchronously, caches an empty placeholder, and kicks off the
653
+ * planner in the background so the agent loop never blocks. The
654
+ * host UI resolves `[chart:<chartId>]` markers by reading the
655
+ * cached {@link Chart} entry (1h TTL).
656
+ *
657
+ * Space-agnostic: a Genie `statement_id` is workspace-scoped, so
658
+ * one shared `prepare_chart` tool covers every wired Genie space.
659
+ *
660
+ * Cancellation: deliberately does NOT forward the per-call
661
+ * `abortSignal` to {@link prepareChart}. The planner task is
662
+ * fire-and-forget background work; the tool's own `execute`
663
+ * resolves the moment the `chartId` is minted, so the per-call
664
+ * signal aborts the second the tool returns. The 1h cache TTL
665
+ * caps abandoned entries.
666
+ */
667
+ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
668
+ const { config } = opts;
669
+ const toolId = "prepare_chart";
670
+ return createTool({
671
+ id: toolId,
672
+ description: string.toDescription([
673
+ `
674
+ Queue a chart for the rows of a Genie statement. Mints a
675
+ short \`chartId\` synchronously and kicks off a BACKGROUND
676
+ task that fetches the statement's rows, runs the
677
+ chart-planner to pick a chart type and Echarts spec, and
678
+ caches the result under the \`chartId\` for one hour. The
679
+ host UI fetches the cached chart on its own once it lands.
680
+ `,
681
+ `
682
+ To display the chart in your reply, embed
683
+ \`[chart:<chartId>]\` on its own line at the position you
684
+ want it to appear, using the EXACT \`chartId\` string this
685
+ call returned. Never construct a chart id yourself (it is
686
+ not the \`statement_id\` or any variation of it) - only a
687
+ value returned by this tool resolves to a real chart. The
688
+ tool returns immediately - do NOT wait or call it again to
689
+ "check progress"; the chart resolves asynchronously on the
690
+ host UI's side.
691
+ `,
692
+ `
693
+ Use this only when the data has a story a chart conveys
694
+ better than a table (trends, rankings, distributions,
695
+ parts-of-a-whole). For raw rows, embed
696
+ \`[data:<statement_id>]\` instead and skip this tool.
697
+ `,
698
+ ]),
699
+ inputSchema: prepareChartRequestSchema,
700
+ outputSchema: wire.ChartSchema.pick({ chartId: true }),
701
+ execute: async (request, ctxRaw) => {
702
+ const ctx = ctxRaw as ToolExecuteCtx;
703
+ const { client } = requireClient(ctx, toolId);
704
+ return prepareChart({
705
+ config,
706
+ ...(request.title ? { title: request.title } : {}),
707
+ ...(request.description ? { description: request.description } : {}),
708
+ resolveData: (taskSignal) =>
709
+ fetchStatementData(client, request.statement_id, {
710
+ ...(taskSignal ? { signal: taskSignal } : {}),
711
+ }),
712
+ ...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
713
+ });
714
+ },
715
+ });
716
+ }
717
+
718
+ /* --------------------------- orchestration prompt --------------------------- */
719
+
720
+ /**
721
+ * Suggested orchestration prompt for the central agent that owns
722
+ * the Genie tools. Compose into your agent's `instructions` to
723
+ * get the canonical "decompose questions, ask Genie focused
724
+ * sub-questions, place data / chart markers in prose" behavior:
725
+ *
726
+ * ```ts
727
+ * createAgent({
728
+ * instructions: `${myAgentInstructions}\n\n${GENIE_INSTRUCTIONS}`,
729
+ * tools(plugins) {
730
+ * return { ...plugins.genie?.toolkit() };
731
+ * },
732
+ * });
733
+ * ```
734
+ *
735
+ * The prompt references the bare tool names (`ask_genie`,
736
+ * `get_space_description`, `get_space_serialized`,
737
+ * `get_statement`, `prepare_chart`) used for the single-space
738
+ * default alias. Multi-space deployments should write their own
739
+ * variant that names the suffixed per-space tools
740
+ * (e.g. `ask_genie_sales`).
741
+ */
742
+ export const GENIE_INSTRUCTIONS = string.toDescription([
743
+ "Genie orchestration. For every user question that needs SQL-backed data:",
744
+ {
745
+ numbered: [
746
+ `
747
+ Start by calling \`get_space_description\` to ground yourself
748
+ in what the space covers (tables, metrics, time windows),
749
+ unless you already saw the same description earlier in this
750
+ conversation. Reach for \`get_space_serialized\` only when you
751
+ need exact column / table identifiers the description doesn't
752
+ expose - it's a much larger payload.
753
+ `,
754
+ `
755
+ Decompose the user's question into focused sub-questions
756
+ BEFORE asking Genie anything. One sub-question per distinct
757
+ metric, dimension, or time window. Then call \`ask_genie\`
758
+ once per sub-question - usually two to six calls per turn,
759
+ and let earlier answers inform what you ask next. Cramming
760
+ a multi-part question into one \`ask_genie\` call almost
761
+ always produces a worse answer than asking the pieces
762
+ separately. Only collapse to a single call when the question
763
+ is genuinely atomic ("what was Q3 revenue?").
764
+
765
+ Worked example: user asks "How did SKU 1234's revenue
766
+ compare to its category average last quarter, and which
767
+ regions drove the gap?" Decomposes to: (a) \`ask_genie\`
768
+ for SKU 1234's Q3 revenue, (b) \`ask_genie\` for the
769
+ category-average Q3 revenue, (c) \`ask_genie\` for SKU
770
+ 1234's Q3 revenue split by region. Three focused calls,
771
+ each grounded in the prior results.
772
+ `,
773
+ `
774
+ Each \`ask_genie\` call returns the terminal \`GenieMessage\`.
775
+ When the turn ran SQL the result has a \`statement_id\` - read
776
+ it from \`message.query_result.statement_id\` (or the first
777
+ attachment's \`query.statement_id\`).
778
+ `,
779
+ [
780
+ `
781
+ To DISPLAY a result set in your reply, embed a marker on its
782
+ own line where the visualization should appear. Two marker
783
+ shapes:
784
+ `,
785
+ {
786
+ bullets: [
787
+ `
788
+ \`[data:<statement_id>]\` - render the rows as a table.
789
+ Use this when there's no clear visual story (long lists,
790
+ reference data, single-row results, or the user just
791
+ wants to see the data). Embed the marker directly; no
792
+ tool call needed.
793
+ `,
794
+ `
795
+ \`[chart:<chartId>]\` - render the rows as a chart. To
796
+ get a \`<chartId>\`, call \`prepare_chart\` with the
797
+ statement's id (and an optional \`title\` / one-line
798
+ \`description\` of the insight to surface). The tool
799
+ returns the \`chartId\` synchronously and prepares the
800
+ chart spec in the background; embed the returned id as
801
+ \`[chart:<chartId>]\` on its own line wherever the
802
+ chart should appear. Use a chart when the data has a
803
+ story a visual conveys better than a table (trends,
804
+ rankings, distributions, parts-of-a-whole).
805
+
806
+ NEVER invent or hand-build a \`<chartId>\`. A valid
807
+ \`<chartId>\` is the opaque token a \`prepare_chart\`
808
+ call returned to you in THIS turn - nothing else. It
809
+ is NOT a \`statement_id\`, and it is NOT a
810
+ \`statement_id\` prefix with a label appended (e.g.
811
+ \`01f1...-region-fill\`). If you have not called
812
+ \`prepare_chart\` and received an id back, do not write
813
+ a \`[chart:...]\` marker at all - use \`[data:...]\`
814
+ instead. A fabricated chart id renders nothing and
815
+ wastes a request.
816
+ `,
817
+ ],
818
+ },
819
+ `
820
+ The host UI resolves both markers on its own once it sees
821
+ them - you do NOT need to call \`get_statement\` just to
822
+ display data, and you do NOT need to wait on
823
+ \`prepare_chart\` (it returns the id immediately and the
824
+ host UI fetches the cached chart later). Pick at most one
825
+ marker per statement; don't chart AND table the same result
826
+ side by side.
827
+ `,
828
+ ],
829
+ `
830
+ Call \`get_statement(statement_id, limit?)\` ONLY when you need
831
+ to read the actual values to reason about them (e.g. naming a
832
+ specific row, computing a delta the table or chart wouldn't
833
+ show, or sanity-checking a result before interpreting it). If
834
+ you'd just be reciting numbers the visualization already shows,
835
+ skip the call and use a marker instead. \`limit\` defaults to a
836
+ small sample; raise it only when you genuinely need more rows.
837
+ `,
838
+ `
839
+ Compose your final reply as plain prose. Interleave paragraphs
840
+ with \`[data:...]\` / \`[chart:...]\` markers wherever a result
841
+ should render. Don't dump all markers at the end - place each
842
+ one next to the prose that interprets it. Don't restate every
843
+ number the visualization already shows; call out deltas,
844
+ anomalies, takeaways.
845
+ `,
846
+ ],
847
+ },
848
+ ]);
849
+
850
+ /* --------------------- multi-alias surface --------------------- */
851
+
852
+ /**
853
+ * Normalize the {@link GenieSpacesConfig} record. Bare-string
854
+ * entries (`{ default: "01ef..." }`) get wrapped as
855
+ * `{ spaceId: "01ef..." }`; object entries pass through unchanged.
856
+ * `undefined` and empty-string values are dropped so callers can
857
+ * pass `process.env.X` directly (matches AppKit `genie()`'s
858
+ * defensive treatment of unset env vars).
859
+ */
860
+ export function normalizeGenieSpaces(
861
+ spaces: GenieSpacesConfig | Record<string, string | GenieSpaceConfig | undefined> | undefined,
862
+ ): Record<string, GenieSpaceConfig> {
863
+ if (!spaces) return {};
864
+ const out: Record<string, GenieSpaceConfig> = {};
865
+ for (const [alias, value] of Object.entries(spaces)) {
866
+ if (value === undefined) continue;
867
+ if (typeof value === "string") {
868
+ if (!value) continue;
869
+ out[alias] = { spaceId: value };
870
+ continue;
871
+ }
872
+ if (!value.spaceId) continue;
873
+ out[alias] = value;
653
874
  }
654
875
  return out;
655
876
  }
656
877
 
657
- /** Best-effort numeric coercion for Genie's all-strings cells. */
658
- function coerceCell(cell: string | null): unknown {
659
- if (cell === null) return null;
660
- // Anchored to keep `12.5px` / `123abc` as strings; only fully
661
- // numeric values become JS numbers.
662
- if (/^-?\d+(\.\d+)?$/.test(cell)) {
663
- const n = Number(cell);
664
- if (Number.isFinite(n)) return n;
878
+ /**
879
+ * AppKit `genie` plugin's config shape, derived from the factory
880
+ * itself so it stays in lock-step with the upstream type without
881
+ * deep-importing `IGenieConfig` (which the package's top-level
882
+ * barrel doesn't surface). The plugin's `config` field is
883
+ * `protected` in TS only; the runtime layout is plain object
884
+ * property access, so reading off the instance with a structural
885
+ * cast is safe.
886
+ */
887
+ type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
888
+
889
+ /**
890
+ * Discover Genie space aliases from every supported source and
891
+ * merge them into a single record. Precedence (highest first):
892
+ *
893
+ * 1. {@link MastraPluginConfig.genieSpaces} on the `mastra(...)`
894
+ * call. Explicit Mastra wiring always wins so users can
895
+ * override AppKit's defaults per-agent.
896
+ * 2. AppKit `genie({ spaces: { ... } })` plugin instance. Lets
897
+ * users keep using the existing AppKit config format
898
+ * (`genie({ spaces: { sales: "...", ops: "..." } })`)
899
+ * without restating the same record on the Mastra plugin.
900
+ * Read off the live plugin instance via a structural cast
901
+ * since `Plugin.config` is TS-protected (not runtime-private).
902
+ * 3. `DATABRICKS_GENIE_SPACE_ID` env var (registered under the
903
+ * well-known `default` alias). Matches the AppKit `genie()`
904
+ * plugin's fallback behavior so a bare `mastra()` + `genie()`
905
+ * pair just works.
906
+ *
907
+ * Aliases collide cleanly: a higher-precedence source's value
908
+ * replaces a lower one's wholesale. Sources that contribute zero
909
+ * aliases (or contribute only `undefined` / empty entries) are
910
+ * silently ignored.
911
+ */
912
+ export function resolveGenieSpaces(
913
+ config: MastraPluginConfig,
914
+ context: plugin.PluginContextLike | undefined,
915
+ ): Record<string, GenieSpaceConfig> {
916
+ const merged: Record<string, GenieSpaceConfig> = {};
917
+
918
+ // Source 3 (lowest precedence): env var.
919
+ const envSpaceId = process.env["DATABRICKS_GENIE_SPACE_ID"];
920
+ if (envSpaceId) {
921
+ merged[DEFAULT_GENIE_ALIAS] = { spaceId: envSpaceId };
922
+ }
923
+
924
+ // Source 2: AppKit `genie()` plugin instance config. Use a
925
+ // structural cast - `Plugin.config` is `protected` in TS only,
926
+ // and the runtime layout is plain object property access.
927
+ const geniePlugin = plugin.instance(context, genie);
928
+ if (geniePlugin) {
929
+ const pluginSpaces = (geniePlugin as unknown as { config?: AppKitGenieConfig }).config?.spaces;
930
+ if (pluginSpaces) {
931
+ Object.assign(merged, normalizeGenieSpaces(pluginSpaces));
932
+ }
933
+ }
934
+
935
+ // Source 1 (highest precedence): explicit Mastra wiring.
936
+ if (config.genieSpaces) {
937
+ Object.assign(merged, normalizeGenieSpaces(config.genieSpaces));
665
938
  }
666
- return cell;
939
+
940
+ return merged;
667
941
  }
668
942
 
669
943
  /**
670
- * Toolkit provider built from a live AppKit `GeniePlugin` instance.
671
- * Returned by {@link buildGenieProvider} so that
672
- * `plugins.genie?.toolkit()` inside an agent's `tools(plugins)` callback
673
- * resolves to the streaming-aware {@link buildGenieTools} record instead
674
- * of the AppKit default (which does one blocking call per tool with no
675
- * mid-flight events).
944
+ * Build the flat Mastra tools record for every configured Genie
945
+ * space. Two shared, space-agnostic tools (`get_statement`,
946
+ * `prepare_chart`) are registered once regardless of how many
947
+ * spaces are wired; the per-space tools (`ask_genie`,
948
+ * `get_space_description`, `get_space_serialized`) are suffixed
949
+ * with `_<alias>` for non-default aliases so multi-space
950
+ * deployments stay disambiguated.
676
951
  *
677
- * The returned `toolkit()` reads alias names off the plugin's
678
- * `getAgentTools()` registry (each entry is `${alias}.sendMessage` or
679
- * `${alias}.getConversation`), then mints one `sendMessage` tool per
680
- * alias plus a shared `getConversation`. `sendMessage` / `getConversation`
681
- * are bound back to the plugin instance so they keep their `this`
682
- * (they are class methods, not free functions).
683
- *
684
- * `_opts` is accepted but unused for now - the streaming tools are an
685
- * all-or-nothing bundle. Wire `only` / `except` / `prefix` / `rename`
686
- * later if a caller needs them.
952
+ * Returns a record keyed by tool id, ready to spread into the
953
+ * central `Agent`'s `tools` map (or surfaced via the
954
+ * `plugins.genie?.toolkit()` callback). Returns an empty record
955
+ * when `spaces` resolves to zero entries so the caller can spread
956
+ * safely.
687
957
  */
688
- export function buildGenieProvider(
689
- plugin: GeniePluginInstance,
690
- opts: { config: MastraPluginConfig },
691
- ): {
692
- toolkit(opts?: unknown): Record<string, ReturnType<typeof createTool>>;
958
+ export function buildGenieTools(opts: {
959
+ spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
960
+ config: MastraPluginConfig;
961
+ }): MastraTools {
962
+ const normalized = normalizeGenieSpaces(opts.spaces);
963
+ const tools: Record<string, ReturnType<typeof createTool>> = {};
964
+ if (Object.keys(normalized).length === 0) return tools;
965
+
966
+ // Shared, space-agnostic tools.
967
+ tools.get_statement = buildGetStatementTool();
968
+ tools.prepare_chart = buildPrepareChartTool({ config: opts.config });
969
+
970
+ for (const [alias, space] of Object.entries(normalized)) {
971
+ const askTool = buildAskGenieTool({
972
+ spaceId: space.spaceId,
973
+ alias,
974
+ ...(space.hint ? { hint: space.hint } : {}),
975
+ });
976
+ const descTool = buildSpaceDescriptionTool({ spaceId: space.spaceId, alias });
977
+ const serTool = buildSpaceSerializedTool({ spaceId: space.spaceId, alias });
978
+ tools[askTool.id] = askTool;
979
+ tools[descTool.id] = descTool;
980
+ tools[serTool.id] = serTool;
981
+ }
982
+ return tools;
983
+ }
984
+
985
+ /**
986
+ * Plugin-toolkit adapter so the `plugins.genie?.toolkit()` lookup
987
+ * inside an agent's `tools(plugins)` callback returns the
988
+ * flat Genie tools record instead of throwing on missing plugin.
989
+ * Mirrors AppKit's `PluginToolkitProvider` shape.
990
+ */
991
+ export function buildGenieToolkitProvider(opts: {
992
+ spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
993
+ config: MastraPluginConfig;
994
+ }): {
995
+ toolkit(opts?: unknown): MastraTools;
693
996
  } {
694
997
  return {
695
998
  toolkit(_opts?: unknown) {
696
- const aliases = extractGenieAliases(plugin);
697
- return buildGenieTools({
698
- aliases,
699
- exports: {
700
- sendMessage: plugin.sendMessage.bind(plugin),
701
- getConversation: plugin.getConversation.bind(plugin),
702
- },
703
- config: opts.config,
704
- });
999
+ return buildGenieTools(opts);
705
1000
  },
706
1001
  };
707
1002
  }
708
1003
 
1004
+ /* --------------------------- starter suggestions --------------------------- */
1005
+
1006
+ /**
1007
+ * Default cap on starter suggestions surfaced to the chat empty
1008
+ * state. Sample-question lists can run long (10+ on a curated
1009
+ * space); a handful of one-tap prompts reads better than a wall of
1010
+ * buttons.
1011
+ */
1012
+ const SUGGESTION_LIMIT = 6;
1013
+
709
1014
  /**
710
- * Pull the configured space aliases out of a live AppKit `GeniePlugin`.
711
- * Reads them off `getAgentTools()` (public API) so we don't poke at the
712
- * `protected config.spaces` field: the plugin registers tools named
713
- * `${alias}.sendMessage` / `${alias}.getConversation`, so the unique
714
- * set of name prefixes is the alias list.
1015
+ * How long a space's parsed sample questions stay cached. They're
1016
+ * authored config that changes rarely, and the lookup is an extra
1017
+ * REST round-trip per chat mount, so a few minutes of caching keeps
1018
+ * the empty state instant on reload without going stale for long.
715
1019
  */
716
- function extractGenieAliases(plugin: GeniePluginInstance): string[] {
717
- const aliases = new Set<string>();
718
- for (const t of plugin.getAgentTools()) {
719
- const dot = t.name.indexOf(".");
720
- if (dot > 0) aliases.add(t.name.slice(0, dot));
1020
+ const SUGGESTION_CACHE_TTL_MS = 10 * 60_000;
1021
+
1022
+ /** Space-id -> cached sample questions with an absolute expiry. */
1023
+ const _suggestionCache = new Map<string, { questions: string[]; expires: number }>();
1024
+
1025
+ /** Read a space's cached questions, or `undefined` on miss / expiry. */
1026
+ function readSuggestionCache(spaceId: string): string[] | undefined {
1027
+ const entry = _suggestionCache.get(spaceId);
1028
+ if (!entry) return undefined;
1029
+ if (entry.expires <= Date.now()) {
1030
+ _suggestionCache.delete(spaceId);
1031
+ return undefined;
721
1032
  }
722
- return [...aliases];
1033
+ return entry.questions;
1034
+ }
1035
+
1036
+ /** Cache a space's parsed questions for {@link SUGGESTION_CACHE_TTL_MS}. */
1037
+ function writeSuggestionCache(spaceId: string, questions: string[]): void {
1038
+ _suggestionCache.set(spaceId, {
1039
+ questions,
1040
+ expires: Date.now() + SUGGESTION_CACHE_TTL_MS,
1041
+ });
723
1042
  }
724
1043
 
725
1044
  /**
726
- * Convert raw Genie status codes (`FETCHING_METADATA`, `ASKING_AI`,
727
- * `EXECUTING_QUERY`, `COMPLETED`, ...) into short, sentence-cased
728
- * labels safe to drop straight into a UI pill. Unknown codes are
729
- * lower-cased with underscores stripped so new states still render.
1045
+ * Collect the curated starter questions across every resolved Genie
1046
+ * space, deduped and capped. Each space's `sample_questions` are
1047
+ * fetched once (cached for {@link SUGGESTION_CACHE_TTL_MS}) via
1048
+ * {@link getGenieSpace} + {@link genieSampleQuestions}, then merged in
1049
+ * alias-iteration order so a single-space app surfaces that space's
1050
+ * questions and a multi-space app round-trips breadth-first up to the
1051
+ * cap. A per-space fetch failure degrades to "no questions for that
1052
+ * space" (logged, not thrown) so one unreachable space never blanks
1053
+ * the whole list. Returns `[]` when no spaces are configured.
730
1054
  */
731
- function humanizeGenieStatus(status: string): string {
732
- switch (status) {
733
- case "FETCHING_METADATA":
734
- return "Fetching metadata";
735
- case "ASKING_AI":
736
- return "Asking Genie";
737
- case "EXECUTING_QUERY":
738
- return "Running SQL query";
739
- case "COMPLETED":
740
- return "Completed";
741
- case "FAILED":
742
- return "Failed";
743
- default:
744
- return [
745
- ...stringUtils.tokenizeWithOptions(
746
- { capitalize: true, lowerCase: true },
747
- status,
748
- ),
749
- ].join(" ");
1055
+ export async function collectSpaceSuggestions(opts: {
1056
+ spaces: Record<string, GenieSpaceConfig>;
1057
+ client: WorkspaceClient;
1058
+ signal?: AbortSignal;
1059
+ limit?: number;
1060
+ }): Promise<string[]> {
1061
+ const limit = opts.limit ?? SUGGESTION_LIMIT;
1062
+ const merged: string[] = [];
1063
+ const seen = new Set<string>();
1064
+
1065
+ for (const { spaceId } of Object.values(opts.spaces)) {
1066
+ let questions = readSuggestionCache(spaceId);
1067
+ if (!questions) {
1068
+ try {
1069
+ const space = await genieSpace.getGenieSpace(spaceId, {
1070
+ workspaceClient: opts.client,
1071
+ ...(opts.signal ? { context: opts.signal } : {}),
1072
+ });
1073
+ questions = genieSpace.genieSampleQuestions(space);
1074
+ writeSuggestionCache(spaceId, questions);
1075
+ } catch (err) {
1076
+ logger.warn("suggestions:fetch-error", {
1077
+ spaceId,
1078
+ error: error.errorMessage(err),
1079
+ });
1080
+ questions = [];
1081
+ }
1082
+ }
1083
+ for (const question of questions) {
1084
+ if (seen.has(question)) continue;
1085
+ seen.add(question);
1086
+ merged.push(question);
1087
+ if (merged.length >= limit) return merged;
1088
+ }
750
1089
  }
1090
+ return merged;
751
1091
  }