@dbx-tools/appkit-mastra 0.1.12 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/genie.ts CHANGED
@@ -1,751 +1,1296 @@
1
1
  /**
2
- * Mastra tool wrappers around the AppKit `genie` plugin's exports.
2
+ * Genie agent 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
+ * Each configured Genie space exposes a single Mastra tool to the
5
+ * calling agent (`genie` for the `"default"` alias, `genie_<alias>`
6
+ * otherwise). When invoked, the tool runs end-to-end:
8
7
  *
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.
8
+ * 1. Pulls the per-request {@link WorkspaceClient} off
9
+ * `ctx.requestContext` (stamped by `MastraServer`) and emits a
10
+ * `started` writer event so the host UI can show progress
11
+ * immediately, before any LLM round-trip.
12
+ * 2. Spins up a per-call inner Mastra `Agent` with three tools:
13
+ * - `ask_genie`: drives one `genieEventChat` turn, fetches
14
+ * the matching statement's rows when the turn ran SQL,
15
+ * and forwards every wire event (status, thinking, sql,
16
+ * rows) through `ctx.writer` for streaming UI updates.
17
+ * - `get_space_description`: cheap title / description /
18
+ * warehouse id lookup for grounding.
19
+ * - `get_space_serialized`: full `GenieSpace` JSON for
20
+ * column-level grounding when the description isn't
21
+ * enough.
22
+ * 3. Runs the inner agent with `structuredOutput` (Mastra's
23
+ * two-pass mode + `jsonPromptInjection`) to coerce the
24
+ * agent's final answer into a tagged
25
+ * `[{type:"text"|"data", ...}]` array. The two-pass design
26
+ * avoids Databricks Model Serving's `response_format` +
27
+ * `tools` collision; prompt injection sidesteps the
28
+ * separate `response_format` + streaming collision in the
29
+ * structuring agent.
30
+ * 4. Charts every `data` item in parallel via
31
+ * {@link runChartPlanner}, maps `text` items to the shared
32
+ * {@link GenieSummaryItem} `string` variant, and returns the
33
+ * hydrated {@link GenieAgentResult}.
12
34
  *
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.
35
+ * The legacy AppKit `genie` plugin (`@databricks/appkit`'s `genie`)
36
+ * is no longer used at runtime. The inner agent talks to Genie
37
+ * directly via `@dbx-tools/genie` (`genieEventChat`) and the
38
+ * workspace `statementExecution.getStatement` API. The plugin's
39
+ * `spaces` config is still honored so existing AppKit-style wiring
40
+ * keeps working without change.
21
41
  */
22
42
 
23
- import { genie } from "@databricks/appkit";
24
- import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
43
+ import { CacheManager, genie } from "@databricks/appkit";
44
+ import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experimental";
45
+ import { genieEventChat } from "@dbx-tools/genie";
46
+ import { type GenieMessage } from "@dbx-tools/genie-shared";
47
+ import {
48
+ type ChartEvent,
49
+ type GenieAgentResult,
50
+ type GenieDataset,
51
+ type GenieDatasetData,
52
+ type GenieSummaryItem,
53
+ type MastraGenieErrorEvent,
54
+ type MinimalWriter,
55
+ type StartedEvent,
56
+ type SummaryEvent,
57
+ } from "@dbx-tools/appkit-mastra-shared";
58
+ import {
59
+ apiUtils,
60
+ appkitUtils,
61
+ commonUtils,
62
+ logUtils,
63
+ stringUtils,
64
+ } from "@dbx-tools/shared";
65
+ import { Agent } from "@mastra/core/agent";
25
66
  import type { RequestContext } from "@mastra/core/request-context";
67
+ import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
26
68
  import { createTool } from "@mastra/core/tools";
27
- import type { ToolStream } from "@mastra/core/tools";
28
69
  import { z } from "zod";
29
70
 
30
- import { emitChartWithPlanning } from "./chart.js";
71
+ import type { MastraTools } from "./agents.js";
72
+ import { runChartPlanner } from "./chart.js";
31
73
  import type { MastraPluginConfig } from "./config.js";
74
+ import { MASTRA_USER_KEY, type User } from "./config.js";
75
+ import { buildModel } from "./model.js";
76
+
77
+ const log = logUtils.logger("mastra/genie");
78
+
79
+ /** Default alias used when a single unnamed Genie space is wired up. */
80
+ export const DEFAULT_GENIE_ALIAS = "default";
32
81
 
33
82
  /**
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`).
83
+ * Cap on the inner agent's tool-loop steps. 5 (Mastra default) is
84
+ * tight - one `get_space_description` + one `ask_genie` per
85
+ * sub-question saturates fast. 16 leaves room for ~10 `ask_genie`
86
+ * rounds plus grounding plus the structuring pass (which runs
87
+ * after the loop and is its own single call).
39
88
  */
40
- const log = logUtils.logger("mastra/genie");
89
+ const DEFAULT_MAX_STEPS = 16;
90
+
91
+ /* --------------------------- config types --------------------------- */
92
+
93
+ /** Per-space Genie agent configuration. */
94
+ export interface GenieSpaceConfig {
95
+ /** Genie `space_id`. Required; resolves via `client.genie.getSpace`. */
96
+ spaceId: string;
97
+ /**
98
+ * Optional human-readable description appended to the Genie
99
+ * tool's description so the calling LLM has hints about
100
+ * *what data* this space covers (e.g. "orders, returns,
101
+ * fulfillment"). When omitted, only the space's own
102
+ * `description` (fetched on first use) is shown.
103
+ */
104
+ hint?: string;
105
+ }
106
+
107
+ /** Map of alias -> space config. Accepts either explicit objects or bare space ids. */
108
+ export type GenieSpacesConfig = Record<string, GenieSpaceConfig | string>;
109
+
110
+ /* ------------------------- helpers ------------------------- */
111
+
112
+ /** Best-effort numeric coercion for Genie's all-strings cells. */
113
+ function coerceCell(cell: string | null): unknown {
114
+ if (cell === null) return null;
115
+ if (/^-?\d+(\.\d+)?$/.test(cell)) {
116
+ const n = Number(cell);
117
+ if (Number.isFinite(n)) return n;
118
+ }
119
+ return cell;
120
+ }
121
+
122
+ /**
123
+ * Fetch a single Genie statement's rows via the Statement
124
+ * Execution API and reshape into the shared
125
+ * {@link GenieDatasetData} shape (column array + row records).
126
+ */
127
+ async function fetchStatementData(
128
+ client: WorkspaceClient,
129
+ statementId: string,
130
+ signal?: AbortSignal,
131
+ ): Promise<GenieDatasetData> {
132
+ const ctx = signal ? apiUtils.toContext(signal) : undefined;
133
+ const r = await client.statementExecution.getStatement(
134
+ { statement_id: statementId },
135
+ ctx,
136
+ );
137
+ const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
138
+ const dataArray = (r.result?.data_array ?? []) as Array<Array<string | null>>;
139
+ const rows = dataArray.map((row) => {
140
+ const obj: Record<string, unknown> = {};
141
+ columns.forEach((col, i) => {
142
+ obj[col] = coerceCell(row[i] ?? null);
143
+ });
144
+ return obj;
145
+ });
146
+ return {
147
+ columns,
148
+ rows,
149
+ rowCount: r.manifest?.total_row_count ?? rows.length,
150
+ };
151
+ }
41
152
 
42
- /** Live AppKit `GeniePlugin` instance. */
43
- export type GeniePluginInstance = InstanceType<ReturnType<typeof genie>["plugin"]>;
153
+ /**
154
+ * Resolve the message's representative `statement_id`. Genie
155
+ * returns one statement per turn in practice; we read the
156
+ * (deprecated-but-singular) `message.query_result.statement_id`
157
+ * first and fall back to the first attachment's
158
+ * `query.statement_id`. Returns `undefined` when the turn had no
159
+ * SQL run (pure prose answer).
160
+ */
161
+ function extractStatementId(message: GenieMessage): string | undefined {
162
+ const top = (message.query_result as { statement_id?: string } | undefined)
163
+ ?.statement_id;
164
+ if (top) return top;
165
+ for (const att of message.attachments ?? []) {
166
+ const id = att.query?.statement_id;
167
+ if (id) return id;
168
+ }
169
+ return undefined;
170
+ }
44
171
 
45
- /** Full `exports()` shape of the AppKit `genie` plugin. */
46
- export type GenieExports = ReturnType<GeniePluginInstance["exports"]>;
172
+ /**
173
+ * Best-effort `writer.write`. The writer carries the unified flat
174
+ * event vocabulary directly - no translation layer - so
175
+ * subscribers narrow on `event.type` and read fields inline.
176
+ * Failures (downstream stream closed, cancelled request) are
177
+ * swallowed with a `warn` log so an in-flight Genie turn isn't
178
+ * taken down by a navigated-away client.
179
+ */
180
+ async function safeWrite(
181
+ writer: MinimalWriter | undefined,
182
+ chunk: unknown,
183
+ ): Promise<void> {
184
+ if (!writer) return;
185
+ try {
186
+ await writer.write(chunk);
187
+ } catch (err) {
188
+ log.warn("writer:error", {
189
+ error: err instanceof Error ? err.message : String(err),
190
+ });
191
+ }
192
+ }
47
193
 
48
194
  /**
49
- * Stream event yielded by `genie.exports().sendMessage`. Discriminated
50
- * by `type` (`"message_start" | "status" | "message_result" |
51
- * "query_result" | "error" | "history_info"`).
195
+ * Lowercased placeholder strings we reject at the `ask_genie`
196
+ * boundary so the LLM doesn't spend a Genie round-trip on a
197
+ * non-question. Genie politely answers any of these with "Your
198
+ * request '...' does not relate to..." which is pure UI noise.
199
+ * Kept narrow on purpose - real questions sometimes start with
200
+ * one of these tokens, so we only match the FULL trimmed string.
52
201
  */
53
- export type GenieStreamEvent =
54
- ReturnType<GenieExports["sendMessage"]> extends AsyncGenerator<infer E> ? E : never;
202
+ const PLACEHOLDER_QUESTIONS = new Set([
203
+ "noop",
204
+ "no-op",
205
+ "skip",
206
+ "none",
207
+ "n/a",
208
+ "na",
209
+ "null",
210
+ "undefined",
211
+ "test",
212
+ "placeholder",
213
+ ]);
55
214
 
56
- /** Conversation history returned by `genie.exports().getConversation`. */
57
- export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
215
+ /* ----------------------- conversation state ----------------------- */
58
216
 
59
217
  /**
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.
218
+ * Estimated Genie conversation lifetime in seconds. Databricks
219
+ * publishes no official TTL on the conversation resource itself;
220
+ * community projects (e.g. the open-source Databricks Genie Bot)
221
+ * converge on 4 hours of inactivity as a safe operating window.
222
+ * Treat this as an estimate that gets *extended on every use* by
223
+ * re-setting the cache entry after each successful turn (sliding
224
+ * TTL via re-`set`). When the estimate ends up wrong (conversation
225
+ * deleted, expired upstream, cross-space referenced), the wrapper
226
+ * catches the SDK's `RESOURCE_DOES_NOT_EXIST`/404 and transparently
227
+ * starts a fresh conversation.
65
228
  */
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
- });
229
+ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
230
+
231
+ /** Cache namespace prefix so coexisting Mastra caches don't collide. */
232
+ const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
98
233
 
99
234
  /**
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.
235
+ * Build the per-request {@link RequestContext} key the active
236
+ * Genie `conversation_id` lives under for `spaceId`. Scoped by
237
+ * space so an app calling two Genie spaces in one request keeps
238
+ * each conversation distinct (Genie conversation ids are
239
+ * space-scoped on the wire). The same `RequestContext` instance
240
+ * flows from the outer `genie` tool through to the inner
241
+ * `ask_genie` tool via Mastra, so writes on one side are visible
242
+ * on the other without an explicit shared ref.
104
243
  */
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
- });
244
+ const conversationContextKey = (spaceId: string): string =>
245
+ `mastra__genie_conversation__${spaceId}`;
246
+
247
+ /**
248
+ * Read the active Genie `conversation_id` for `spaceId` off the
249
+ * per-request {@link RequestContext}. Returns `undefined` when no
250
+ * conversation has been started yet this request.
251
+ */
252
+ function readContextConversationId(
253
+ requestContext: RequestContext,
254
+ spaceId: string,
255
+ ): string | undefined {
256
+ return requestContext.get(conversationContextKey(spaceId)) as string | undefined;
257
+ }
258
+
259
+ /**
260
+ * Write the active Genie `conversation_id` for `spaceId` onto the
261
+ * per-request {@link RequestContext}. Subsequent `ask_genie` calls
262
+ * in this request will reuse it; the wrapper's tail logic also
263
+ * reads it back out for the {@link GenieAgentResult}.
264
+ */
265
+ function writeContextConversationId(
266
+ requestContext: RequestContext,
267
+ spaceId: string,
268
+ conversationId: string | undefined,
269
+ ): void {
270
+ requestContext.set(conversationContextKey(spaceId), conversationId);
271
+ }
149
272
 
150
- type DrainResult = z.infer<typeof genieToolOutputSchema>;
273
+ /* ------------------------- chart inventory ------------------------- */
151
274
 
152
275
  /**
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.
276
+ * Per-request {@link RequestContext} key the resolved chart
277
+ * inventory lives under. Keyed by `chartId`, the inventory is a
278
+ * `Map<string, ChartEvent>` carrying the full Echarts spec for
279
+ * every chart minted on this request - the same payload that
280
+ * goes out on the writer stream, kept in-process so output
281
+ * processors and downstream tools can resolve `[[chart:<id>]]`
282
+ * markers without re-running the planner or pulling from the
283
+ * writer stream.
157
284
  *
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`.
285
+ * Shared across all Genie spaces because chart ids are minted
286
+ * via `commonUtils.shortId()` and are unique within a single
287
+ * request regardless of which space produced them.
166
288
  */
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
- });
289
+ const CHART_INVENTORY_CONTEXT_KEY = "mastra__genie_chart_inventory__";
202
290
 
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
- });
291
+ /**
292
+ * Get the chart inventory map for this request, creating it on
293
+ * first access. Subsequent reads return the same map so callers
294
+ * mutate in place. The map is request-scoped (collected with the
295
+ * `RequestContext` at end of request), so there's no per-process
296
+ * leak.
297
+ */
298
+ export function chartInventoryFromContext(
299
+ requestContext: RequestContext,
300
+ ): Map<string, ChartEvent> {
301
+ const existing = requestContext.get(CHART_INVENTORY_CONTEXT_KEY);
302
+ if (existing instanceof Map) {
303
+ return existing as Map<string, ChartEvent>;
304
+ }
305
+ const fresh = new Map<string, ChartEvent>();
306
+ requestContext.set(CHART_INVENTORY_CONTEXT_KEY, fresh);
307
+ return fresh;
308
+ }
212
309
 
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
- });
310
+ /**
311
+ * Stash a resolved chart on the request-scoped inventory so any
312
+ * subsequent code in this request (output processors validating
313
+ * `[[chart:<id>]]` markers, follow-up tools that want to chart
314
+ * the same dataset differently, etc.) can look it up by id.
315
+ * No-op when `requestContext` is missing.
316
+ */
317
+ function recordChartInContext(
318
+ requestContext: RequestContext | undefined,
319
+ chart: ChartEvent,
320
+ ): void {
321
+ if (!requestContext) return;
322
+ chartInventoryFromContext(requestContext).set(chart.chartId, chart);
323
+ }
255
324
 
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
- });
325
+ /**
326
+ * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
327
+ * conversations are scoped to a single user + space + thread, and
328
+ * `threadId` is already user-scoped (Mastra mints threads per
329
+ * `resourceId`), so a constant user key here is safe and keeps the
330
+ * cache key short.
331
+ */
332
+ const CONVERSATION_USER_KEY = "mastra-genie";
284
333
 
285
334
  /**
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.
335
+ * Build the canonical cache key for a `(spaceId, threadId)` pair.
336
+ * Returns `undefined` when `threadId` is missing - callers should
337
+ * skip caching entirely in that case (no Mastra memory wired up).
289
338
  */
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
- });
339
+ async function conversationCacheKey(
340
+ spaceId: string,
341
+ threadId: string | undefined,
342
+ ): Promise<string | undefined> {
343
+ if (!threadId) return undefined;
344
+ return (await CacheManager.getInstance()).generateKey(
345
+ [CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
346
+ CONVERSATION_USER_KEY,
347
+ );
348
+ }
304
349
 
305
350
  /**
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.
351
+ * Read the cached Genie conversation id for `(spaceId, threadId)`.
352
+ * Returns `undefined` on miss, on expiry, or when the cache layer
353
+ * is unhealthy - never throws. The TTL is renewed via re-`set`
354
+ * after each successful turn (see {@link saveCachedConversationId}).
311
355
  */
312
- export function defaultGenieToolName(alias: string): string {
313
- if (alias === "default") return "genie";
314
- return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
356
+ async function readCachedConversationId(
357
+ cacheKey: string | undefined,
358
+ ): Promise<string | undefined> {
359
+ if (!cacheKey) return undefined;
360
+ try {
361
+ const v = await CacheManager.getInstanceSync().get<string>(cacheKey);
362
+ return v ?? undefined;
363
+ } catch (err) {
364
+ log.warn("conversation-cache:read-error", {
365
+ error: err instanceof Error ? err.message : String(err),
366
+ });
367
+ return undefined;
368
+ }
315
369
  }
316
370
 
317
371
  /**
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.
372
+ * Persist the active conversation id under `cacheKey`, refreshing
373
+ * its TTL. Idempotent; no-op when `cacheKey` or `conversationId`
374
+ * is missing. Re-setting the same key acts as a sliding TTL: every
375
+ * turn that uses the conversation extends the window by another
376
+ * {@link CONVERSATION_TTL_SEC} seconds.
326
377
  */
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>> = {};
378
+ async function saveCachedConversationId(
379
+ cacheKey: string | undefined,
380
+ conversationId: string | undefined,
381
+ ): Promise<void> {
382
+ if (!cacheKey || !conversationId) return;
383
+ try {
384
+ await CacheManager.getInstanceSync().set(cacheKey, conversationId, {
385
+ ttl: CONVERSATION_TTL_SEC,
386
+ });
387
+ } catch (err) {
388
+ log.warn("conversation-cache:write-error", {
389
+ error: err instanceof Error ? err.message : String(err),
390
+ });
391
+ }
392
+ }
334
393
 
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
- },
394
+ /** Force-evict a cached conversation id. Used on the stale-id recovery path. */
395
+ async function evictCachedConversationId(cacheKey: string | undefined): Promise<void> {
396
+ if (!cacheKey) return;
397
+ try {
398
+ await CacheManager.getInstanceSync().delete(cacheKey);
399
+ } catch (err) {
400
+ log.warn("conversation-cache:delete-error", {
401
+ error: err instanceof Error ? err.message : String(err),
367
402
  });
368
403
  }
404
+ }
405
+
406
+ /**
407
+ * True when `err` is the SDK error Genie returns for a
408
+ * conversation id that no longer exists (deleted, expired upstream,
409
+ * or referenced from the wrong space). Matches the typed
410
+ * {@link ApiError} 404 / `RESOURCE_DOES_NOT_EXIST` shape first, then
411
+ * falls back to the lower-level {@link HttpError} 404, then to a
412
+ * loose message sniff for SDK shapes we haven't catalogued.
413
+ */
414
+ function isConversationGoneError(err: unknown): boolean {
415
+ if (err instanceof ApiError) {
416
+ if (err.statusCode === 404) return true;
417
+ if (err.errorCode === "RESOURCE_DOES_NOT_EXIST") return true;
418
+ }
419
+ if (err instanceof HttpError && err.code === 404) return true;
420
+ if (err instanceof Error && /does not exist/i.test(err.message)) return true;
421
+ return false;
422
+ }
423
+
424
+ /* --------------------------- inner tools --------------------------- */
425
+
426
+ /**
427
+ * One entry in {@link InnerToolDeps.resultSets}: the rows for a
428
+ * Genie statement plus the Genie `message_id` of the `ask_genie`
429
+ * turn that produced it. Tracking `messageId` here lets the
430
+ * outer chart loop stamp the chart event (and any chart-error
431
+ * writer event) with the same `messageId` the rest of that
432
+ * ask's wire events carry, so the host UI groups the chart into
433
+ * the same `message_id` pill bucket without a separate lookup.
434
+ */
435
+ interface StatementEntry {
436
+ data: GenieDatasetData;
437
+ messageId: string;
438
+ }
439
+
440
+ /**
441
+ * Per-call mutable state shared by the inner agent's three tools.
442
+ * `resultSets` lets the wrapper pull rows by `statementId` after
443
+ * the agent finishes, so the chart-planner doesn't re-fetch. The
444
+ * active Genie `conversation_id` lives on `RequestContext` (read
445
+ * via {@link readContextConversationId} on the inner tool's `ctx`)
446
+ * rather than a shared ref - the same `RequestContext` instance
447
+ * threads from `agent.generate({requestContext})` through to every
448
+ * tool invocation, so writes propagate to subsequent `ask_genie`
449
+ * calls without an extra object. `cacheKey` is the
450
+ * {@link CacheManager} key for cross-request persistence (`undefined`
451
+ * when `threadId` isn't available and caching is disabled).
452
+ */
453
+ interface InnerToolDeps {
454
+ spaceId: string;
455
+ client: WorkspaceClient;
456
+ writer?: MinimalWriter;
457
+ signal?: AbortSignal;
458
+ resultSets: Map<string, StatementEntry>;
459
+ cacheKey?: string;
460
+ }
461
+
462
+ function buildAskGenieTool(deps: InnerToolDeps) {
463
+ const { spaceId, client, writer, signal, resultSets, cacheKey } = deps;
464
+ return createTool({
465
+ id: "ask_genie",
466
+ description: stringUtils.toDescription`
467
+ Send ONE focused natural-language question to the Genie
468
+ space and wait for the turn to complete. Returns the final
469
+ \`GenieMessage\` plus, when the turn ran SQL, the rows of
470
+ the resulting query as \`query_result_data\`. The
471
+ \`statement_id\` you reference in your final \`data\`
472
+ blocks lives at \`message.query_result.statement_id\` (or
473
+ the first attachment's \`query.statement_id\`). Wire
474
+ events (status, thinking, sql) stream to the user
475
+ automatically. Call multiple times to gather different
476
+ angles before composing the final response.
477
+ `,
478
+ inputSchema: z.object({
479
+ question: z.string().min(1, "question is required"),
480
+ }),
481
+ outputSchema: z.object({
482
+ message: z.custom<GenieMessage>(),
483
+ query_result_data: z.custom<GenieDatasetData>().optional(),
484
+ }),
485
+ execute: async ({ question }, ctxRaw) => {
486
+ const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
487
+ const requestContext = ctx?.requestContext;
488
+ if (!requestContext) {
489
+ // Mastra always passes a `RequestContext` to tools when the
490
+ // parent agent received one. The outer Genie tool insists on
491
+ // it (it sources the user from there), so this only fires
492
+ // if a misconfigured caller invokes `ask_genie` directly.
493
+ throw new Error(
494
+ "ask_genie: missing requestContext (parent agent must propagate it)",
495
+ );
496
+ }
497
+
498
+ // Bounce placeholder / no-op questions BEFORE spending a Genie
499
+ // round-trip on them. The structuring pass occasionally pads
500
+ // out the tool loop with a fake `ask_genie("noop")` call,
501
+ // which Genie answers with "Your request 'noop' does not
502
+ // relate to..." - useless noise that shows up in the UI and
503
+ // eats one of the workspace's 5 questions/minute. Returning
504
+ // a clear error here surfaces the issue to the agent loop so
505
+ // the model corrects course instead of wasting a turn.
506
+ const trimmed = question.trim();
507
+ if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
508
+ throw new Error(
509
+ `ask_genie: refusing placeholder question "${question}" - ` +
510
+ `call ask_genie only with a real natural-language question, ` +
511
+ `or skip the call entirely`,
512
+ );
513
+ }
514
+
515
+ // Single turn of `genieEventChat`. Hoisted into a closure so
516
+ // we can re-run it after evicting a stale `conversation_id`
517
+ // without duplicating the event-loop body.
518
+ const runTurn = async (): Promise<GenieMessage> => {
519
+ const seedConversationId = readContextConversationId(requestContext, spaceId);
520
+ let finalMessage: GenieMessage | undefined;
521
+ for await (const event of genieEventChat(spaceId, question, {
522
+ workspaceClient: client,
523
+ ...(seedConversationId ? { conversationId: seedConversationId } : {}),
524
+ ...(signal ? { context: signal } : {}),
525
+ })) {
526
+ await safeWrite(writer, event);
527
+ // Wire events come in two flavors: the lifecycle `message`
528
+ // event embeds the raw `GenieMessage` (read its
529
+ // `conversation_id`), and the rest carry a flat
530
+ // `conversation_id` field at the top level. The terminal
531
+ // `result` event also carries the final `GenieMessage`
532
+ // inline so we can capture the snapshot without re-reading
533
+ // a buffered `message` event.
534
+ const eventConversationId =
535
+ event.type === "message"
536
+ ? event.message.conversation_id
537
+ : event.conversation_id;
538
+ if (eventConversationId) {
539
+ writeContextConversationId(requestContext, spaceId, eventConversationId);
540
+ }
541
+ if (event.type === "result") {
542
+ finalMessage = event.message;
543
+ }
544
+ }
545
+ if (!finalMessage) {
546
+ throw new Error("Genie turn ended without a result event");
547
+ }
548
+ return finalMessage;
549
+ };
550
+
551
+ let finalMessage: GenieMessage;
552
+ try {
553
+ finalMessage = await runTurn();
554
+ } catch (err) {
555
+ // The seeded `conversation_id` was rejected by Genie - most
556
+ // commonly because it was deleted upstream, expired past
557
+ // Databricks' (undocumented) lifetime, or was minted in a
558
+ // different space. Drop both the cached id AND the
559
+ // per-request value so the retry calls `startConversation`,
560
+ // and try once more. Only retry when we *had* a seeded id -
561
+ // a fresh call that 404s shouldn't loop.
562
+ const seeded = readContextConversationId(requestContext, spaceId);
563
+ if (seeded && isConversationGoneError(err)) {
564
+ log.warn("conversation-cache:stale, resetting", {
565
+ spaceId,
566
+ conversationId: seeded,
567
+ error: err instanceof Error ? err.message : String(err),
568
+ });
569
+ await evictCachedConversationId(cacheKey);
570
+ writeContextConversationId(requestContext, spaceId, undefined);
571
+ finalMessage = await runTurn();
572
+ } else {
573
+ throw err;
574
+ }
575
+ }
576
+
577
+ // Refresh the cache entry on every successful turn. Re-setting
578
+ // the same key both persists newly-minted ids (cache miss path)
579
+ // and extends the TTL on active conversations (sliding window).
580
+ await saveCachedConversationId(
581
+ cacheKey,
582
+ readContextConversationId(requestContext, spaceId),
583
+ );
369
584
 
370
- tools.genie_get_conversation = createTool({
371
- id: "genie_get_conversation",
585
+ const statementId = extractStatementId(finalMessage);
586
+ let queryResultData: GenieDatasetData | undefined;
587
+ if (statementId) {
588
+ const data = await fetchStatementData(client, statementId, signal);
589
+ if (data.rowCount > 0) {
590
+ queryResultData = data;
591
+ // Stash with this ask's `message_id` so the outer chart
592
+ // loop can stamp downstream `chart` events with the
593
+ // same id the wire events carry - keeps the chart in
594
+ // the same `message_id` pill bucket on the host UI.
595
+ resultSets.set(statementId, {
596
+ data,
597
+ messageId: finalMessage.message_id,
598
+ });
599
+ }
600
+ }
601
+ return {
602
+ message: finalMessage,
603
+ ...(queryResultData ? { query_result_data: queryResultData } : {}),
604
+ };
605
+ },
606
+ });
607
+ }
608
+
609
+ function buildSpaceDescriptionTool(deps: {
610
+ spaceId: string;
611
+ client: WorkspaceClient;
612
+ signal?: AbortSignal;
613
+ }) {
614
+ const { spaceId, client, signal } = deps;
615
+ return createTool({
616
+ id: "get_space_description",
372
617
  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.
618
+ Return the Genie space's title, description, and warehouse id.
619
+ Cheap. Call once at the start of a turn to ground yourself
620
+ in what data the space covers.
376
621
  `,
377
- inputSchema: getConversationSchema,
378
- outputSchema: genieGetConversationOutputSchema,
379
- execute: async ({ alias, conversationId }) => {
380
- return opts.exports.getConversation(alias, conversationId, opts.signal);
622
+ inputSchema: z.object({}),
623
+ outputSchema: z.object({
624
+ spaceId: z.string(),
625
+ title: z.string().optional(),
626
+ description: z.string().optional(),
627
+ warehouseId: z.string().optional(),
628
+ }),
629
+ execute: async () => {
630
+ const ctx = signal ? apiUtils.toContext(signal) : undefined;
631
+ const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
632
+ return {
633
+ spaceId,
634
+ ...(space.title ? { title: space.title } : {}),
635
+ ...(space.description ? { description: space.description } : {}),
636
+ ...(space.warehouse_id ? { warehouseId: space.warehouse_id } : {}),
637
+ };
381
638
  },
382
639
  });
640
+ }
383
641
 
384
- return tools;
642
+ function buildSpaceSerializedTool(deps: {
643
+ spaceId: string;
644
+ client: WorkspaceClient;
645
+ signal?: AbortSignal;
646
+ }) {
647
+ const { spaceId, client, signal } = deps;
648
+ return createTool({
649
+ id: "get_space_serialized",
650
+ description: stringUtils.toDescription`
651
+ Return the full \`GenieSpace\` JSON for this space. Use only
652
+ when you need exact column / table identifiers
653
+ \`get_space_description\` doesn't expose. Larger payload, so
654
+ prefer the description tool when it's enough.
655
+ `,
656
+ inputSchema: z.object({}),
657
+ outputSchema: z.object({ space: z.unknown() }),
658
+ execute: async () => {
659
+ const ctx = signal ? apiUtils.toContext(signal) : undefined;
660
+ const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
661
+ return { space };
662
+ },
663
+ });
385
664
  }
386
665
 
387
- /** Inputs to {@link drainGenieStream}. */
388
- interface DrainGenieStreamOptions {
666
+ /* --------------------------- inner agent --------------------------- */
667
+
668
+ const AGENT_INSTRUCTIONS = stringUtils.toDescription`
669
+ You orchestrate a Databricks Genie space. For every user
670
+ question:
671
+
672
+ 1. Optionally call \`get_space_description\` to ground; reach
673
+ for \`get_space_serialized\` only when you need exact
674
+ column / table names the description doesn't expose.
675
+ 2. Decompose the question into focused sub-questions (one per
676
+ distinct metric / dimension / time window) and call
677
+ \`ask_genie\` once per sub-question. Two to six calls is
678
+ typical for a non-trivial question; one call is fine when
679
+ the question is genuinely atomic.
680
+ 3. Each \`ask_genie\` call returns the terminal
681
+ \`GenieMessage\`. When the turn ran SQL it also returns
682
+ \`query_result_data\` - the actual rows. The matching
683
+ \`statement_id\` is on
684
+ \`message.query_result.statement_id\` (or the first
685
+ attachment's \`query.statement_id\`). You will reference
686
+ that exact id in your final \`data\` blocks.
687
+ 4. Produce a final structured summary as an ordered array
688
+ interleaving \`text\` paragraphs with \`data\` blocks.
689
+ INTERLEAVE: prose first, then the \`data\` block it
690
+ interprets, then the next prose / data pair. Never dump
691
+ all data at the end.
692
+ 5. For every \`data\` block, supply the exact
693
+ \`statement_id\` you saw on the \`ask_genie\` response. A
694
+ short \`description\` ("compare quarterly revenue across
695
+ regions", "highlight the steep drop after position 5")
696
+ biases the chart-planner's choice of visual. Do NOT pick
697
+ chart types or axis labels - the host wraps each \`data\`
698
+ block in a chart automatically.
699
+ 6. Each \`data\` block should be followed by a short
700
+ \`text\` interpretation (deltas, anomalies, takeaways).
701
+ Don't paraphrase numbers the visualization will already
702
+ show. Skip openers / closers. Plain prose, hyphens (not em
703
+ / en dashes), no emojis.
704
+ `;
705
+
706
+ /**
707
+ * Boundary schema for the inner agent's structured output. Two
708
+ * tagged shapes only - text or data. The wrapper maps these onto
709
+ * the shared {@link GenieSummaryItem} (`string` / `visualize`)
710
+ * after charting; we don't redefine GenieSummaryItem here.
711
+ */
712
+ const agentSummarySchema = z.object({
713
+ summary: z.array(
714
+ z.discriminatedUnion("type", [
715
+ z.object({
716
+ type: z.literal("text"),
717
+ text: z.string(),
718
+ }),
719
+ z.object({
720
+ type: z.literal("data"),
721
+ statementId: z.string(),
722
+ title: z.string().optional(),
723
+ description: z.string().optional(),
724
+ }),
725
+ ]),
726
+ ),
727
+ });
728
+
729
+ type AgentSummaryItem = z.infer<typeof agentSummarySchema>["summary"][number];
730
+
731
+ /* ----------------------------- factory ----------------------------- */
732
+
733
+ /**
734
+ * Options for {@link createGenieTool}. Only carries config that
735
+ * doesn't vary per request - the per-request {@link WorkspaceClient},
736
+ * `RequestContext`, writer, and abort signal flow through the
737
+ * tool's `execute(_, ctx)` and are not captured here.
738
+ */
739
+ export interface CreateGenieToolOptions {
740
+ /** Genie space id this tool targets. */
741
+ spaceId: string;
742
+ /** Plugin config; resolves the LLM and chart planner agent. */
389
743
  config: MastraPluginConfig;
390
- requestContext?: RequestContext;
744
+ /** Override the registered tool id. Defaults to `"genie"`. */
745
+ toolId?: string;
746
+ /** Override the tool description shown to the calling LLM. */
747
+ toolDescription?: string;
748
+ /**
749
+ * Override the inner agent's max tool-loop steps. Defaults to
750
+ * {@link DEFAULT_MAX_STEPS}.
751
+ */
752
+ maxSteps?: number;
391
753
  }
392
754
 
393
755
  /**
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.
756
+ * Build the calling agent's Genie tool. The returned Mastra tool
757
+ * runs end-to-end on each invocation:
397
758
  *
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.
759
+ * 1. Pull the per-request `WorkspaceClient` off
760
+ * `ctx.requestContext` (stamped by `MastraServer` under
761
+ * {@link MASTRA_USER_KEY}) and emit a `started` writer
762
+ * event so the host UI shows progress immediately.
763
+ * 2. Spin up the inner Mastra agent + three tools, fresh per
764
+ * call so the row cache stays invocation-scoped.
765
+ * 3. Run the agent with `structuredOutput` against
766
+ * {@link agentSummarySchema}. Mastra's two-pass design keeps
767
+ * the inner loop tools-only (no `response_format`), so the
768
+ * Databricks Model Serving `response_format`+`tools`
769
+ * collision never fires.
770
+ * 4. Walk the returned `[text|data][]`, map `text` items to
771
+ * shared `GenieSummaryItem.string`, and chart every `data`
772
+ * item in parallel via {@link runChartPlanner} to a
773
+ * `GenieSummaryItem.visualize`. Items referencing a missing
774
+ * `statementId` are dropped with a warn log; chart-planner
775
+ * failures leave `dataset.chart` unset so the host UI falls
776
+ * back to a table.
423
777
  */
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
- };
778
+ export function createGenieTool(opts: CreateGenieToolOptions) {
779
+ const {
780
+ spaceId,
781
+ config,
782
+ toolId = "genie",
783
+ toolDescription = stringUtils.toDescription`
784
+ Ask a question about the Databricks Genie space.
482
785
 
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,
786
+ Returns \`{ summary: SummaryItem[] }\` where each item is
787
+ one of:
788
+
789
+ - \`{ type: "string", text }\` - prose to weave into your
790
+ reply verbatim or paraphrase.
791
+ - \`{ type: "visualize", statementId, title?, description?,
792
+ dataset: { data: { columns, rows, rowCount },
793
+ chart?: { chartId, chartType } } }\` - a chartable result
794
+ set. When \`dataset.chart\` is present the chart is ALREADY
795
+ rendered and queued for inline display; embed the marker
796
+ \`[[chart:<chartId>]]\` on its own line at the position
797
+ you want it to appear and the host UI drops the rendered
798
+ chart in. Re-use the chartId verbatim - do NOT call
799
+ \`render_data\` for the same dataset (it would render the
800
+ same chart a second time and stall your stream). Only
801
+ fall back to \`render_data\` when \`dataset.chart\` is
802
+ missing (chart-planner failed) AND you genuinely need a
803
+ picture; otherwise present the data inline as prose or a
804
+ short table.
805
+ `,
806
+ maxSteps = DEFAULT_MAX_STEPS,
807
+ } = opts;
808
+
809
+ return createTool({
810
+ id: toolId,
811
+ description: toolDescription,
812
+ inputSchema: z.object({
813
+ question: z.string().describe(stringUtils.toDescription`
814
+ Natural-language question about the data in this Genie
815
+ space. Phrase it from the user's perspective; the agent
816
+ decomposes it internally.
817
+ `),
818
+ }),
819
+ outputSchema: z.custom<GenieAgentResult>(),
820
+ execute: async (input, ctxRaw) => {
821
+ const ctx = ctxRaw as
822
+ | {
823
+ requestContext?: RequestContext;
824
+ writer?: MinimalWriter;
825
+ abortSignal?: AbortSignal;
826
+ }
827
+ | undefined;
828
+ const requestContext = ctx?.requestContext;
829
+ if (!requestContext) {
830
+ throw new Error(
831
+ "genie: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)",
512
832
  );
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
833
  }
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
- }
834
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
835
+ if (!user) {
836
+ throw new Error("genie: no user on requestContext (MASTRA_USER_KEY not set)");
837
+ }
838
+ const client = user.executionContext.client;
839
+ const writer = ctx?.writer;
840
+ const signal = ctx?.abortSignal;
841
+ const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
842
+
843
+ // Fire the lifecycle `started` event before any LLM /
844
+ // network round-trip so the host UI can pop a "Thinking..."
845
+ // pill the instant the model decides to delegate. The wire
846
+ // `conversation_id` / `message_id` aren't known yet (no
847
+ // Genie call has been made) and ride as `undefined` -
848
+ // subscribers that need them watch the later
849
+ // `message` / `result` wire events for the real ids.
850
+ const startedEvent: StartedEvent = {
851
+ type: "started",
852
+ spaceId,
853
+ content: input.question,
854
+ };
855
+ await safeWrite(writer, startedEvent);
856
+
857
+ const resultSets = new Map<string, StatementEntry>();
858
+
859
+ // Seed the active Genie `conversation_id` onto
860
+ // `RequestContext` from AppKit's `CacheManager` when a Mastra
861
+ // `threadId` is present so multi-turn chats reuse the same
862
+ // Genie conversation (and Genie's accumulated context) across
863
+ // separate tool invocations. The same `RequestContext` flows
864
+ // to the inner `ask_genie` tool via Mastra, which reads and
865
+ // updates the same slot as Genie hands out / rotates ids.
866
+ // Cache misses, threads without memory, and unhealthy cache
867
+ // storage all leave the slot unset, which makes `ask_genie`
868
+ // call `startConversation` and mint a fresh id (then cache
869
+ // it).
870
+ const cacheKey = await conversationCacheKey(spaceId, threadId);
871
+ const cachedConversationId = await readCachedConversationId(cacheKey);
872
+ if (cachedConversationId) {
873
+ writeContextConversationId(requestContext, spaceId, cachedConversationId);
874
+ }
875
+
876
+ const innerDeps: InnerToolDeps = {
877
+ spaceId,
878
+ client,
879
+ ...(writer ? { writer } : {}),
880
+ ...(signal ? { signal } : {}),
881
+ resultSets,
882
+ ...(cacheKey ? { cacheKey } : {}),
883
+ };
884
+ const tools = {
885
+ ask_genie: buildAskGenieTool(innerDeps),
886
+ get_space_description: buildSpaceDescriptionTool({
887
+ spaceId,
888
+ client,
889
+ ...(signal ? { signal } : {}),
890
+ }),
891
+ get_space_serialized: buildSpaceSerializedTool({
892
+ spaceId,
893
+ client,
894
+ ...(signal ? { signal } : {}),
895
+ }),
896
+ };
897
+
898
+ // Resolve the model config once for this request so we can
899
+ // share it with the structuring pass below. The agent's
900
+ // `model` field accepts a function form for per-request
901
+ // resolution, but `structuredOutput.model` requires a
902
+ // static `MastraModelConfig`, and we need both to be on
903
+ // the same Databricks endpoint with the same OBO-scoped
904
+ // headers. Calling `buildModel` here (inside `execute`)
905
+ // keeps user scoping correct because `requestContext`
906
+ // already reflects the active request's user.
907
+ const resolvedModel = await buildModel(config, requestContext);
908
+
909
+ const agent = new Agent({
910
+ id: `genie__${spaceId}`,
911
+ name: `Genie (${spaceId})`,
912
+ description: stringUtils.toDescription`
913
+ Inner orchestrator for the "${spaceId}" Genie space.
914
+ Asks Genie one focused sub-question at a time and
915
+ returns an interleaved [text|data] summary.
916
+ `,
917
+ instructions: AGENT_INSTRUCTIONS,
918
+ model: resolvedModel,
919
+ tools,
920
+ });
921
+
922
+ // Mastra's `structuredOutput` operates in one of two modes
923
+ // based on whether `model` is set:
924
+ // - "direct" (no model) -> the schema is enforced
925
+ // in the SAME LLM call as
926
+ // the agent loop, by
927
+ // adding `response_format`
928
+ // alongside `tools`.
929
+ // Databricks Model Serving
930
+ // rejects that combination
931
+ // with `INVALID_PARAMETER_VALUE:
932
+ // Cannot specify both
933
+ // response_format and tools
934
+ // in the same request.`
935
+ // - "processor" (model passed) -> the main loop carries
936
+ // tools and NO
937
+ // `response_format`; a
938
+ // separate, tool-free
939
+ // structuring agent
940
+ // re-prompts the model
941
+ // with `response_format`
942
+ // to coerce the agent's
943
+ // final text into the
944
+ // schema.
945
+ // We use "processor" mode but ALSO set
946
+ // `jsonPromptInjection: true`. Mastra's structuring agent
947
+ // calls `.stream(...)` under the hood, and Databricks Model
948
+ // Serving rejects `response_format` together with streaming
949
+ // (`INVALID_PARAMETER_VALUE: Structured output is not
950
+ // currently supported with streaming.`). Prompt injection
951
+ // sidesteps that by embedding the JSON Schema in the
952
+ // structuring agent's system prompt instead of sending
953
+ // `response_format`. `errorStrategy: "warn"` keeps a
954
+ // structuring failure from escaping as an unhandled
955
+ // promise rejection: it logs and leaves `result.object`
956
+ // undefined, which we surface as a clean error in
957
+ // {@link GenieAgentResult}.
958
+ const agentResult = await agent.generate(input.question, {
959
+ requestContext,
960
+ maxSteps,
961
+ structuredOutput: {
962
+ schema: agentSummarySchema,
963
+ model: resolvedModel,
964
+ jsonPromptInjection: true,
965
+ errorStrategy: "warn",
966
+ },
967
+ ...(signal ? { abortSignal: signal } : {}),
968
+ });
969
+ const submission = agentResult.object;
970
+ if (!submission) {
971
+ const message = "Genie agent returned no structured summary";
972
+ log.warn("agent:no-summary", { spaceId });
973
+ const finalConversationId = readContextConversationId(requestContext, spaceId);
974
+ return {
975
+ spaceId,
976
+ summary: [],
977
+ ...(finalConversationId ? { conversationId: finalConversationId } : {}),
978
+ error: message,
979
+ } satisfies GenieAgentResult;
980
+ }
981
+
982
+ // Lifecycle hook: the agent + structuring pass are done.
983
+ // Emit one `summary` event with the structured-item counts
984
+ // so the host UI can transition from "thinking" to
985
+ // "charting" and seed N chart skeletons before the
986
+ // per-chart `chart` events arrive. We can't fire this
987
+ // EARLIER (i.e. when the structuring pass starts) because
988
+ // Mastra runs the inner loop + structuring pass together
989
+ // inside `agent.generate(...)` with no observable boundary
990
+ // between them.
991
+ const textItemCount = submission.summary.filter(
992
+ (i: AgentSummaryItem) => i.type === "text",
993
+ ).length;
994
+ const dataItemCount = submission.summary.length - textItemCount;
995
+ const summaryEvent: SummaryEvent = {
996
+ type: "summary",
997
+ spaceId,
998
+ items: submission.summary.length,
999
+ textItems: textItemCount,
1000
+ dataItems: dataItemCount,
1001
+ };
1002
+ await safeWrite(writer, summaryEvent);
1003
+
1004
+ // Chart every `data` item in parallel; map `text` items to
1005
+ // the shared `string` summary variant verbatim. Missing
1006
+ // statement ids are dropped (the agent referenced something
1007
+ // that never came back from `ask_genie`), planner failures
1008
+ // leave `dataset.chart` unset so the host UI falls back to
1009
+ // a table render. Each successfully planned chart pushes a
1010
+ // `chart` writer event so the UI can fade in the rendered
1011
+ // chart slot the moment its planner returns rather than
1012
+ // waiting for the entire batch to finish.
1013
+ const hydrated = await Promise.all(
1014
+ submission.summary.map(async (item: AgentSummaryItem): Promise<GenieSummaryItem | undefined> => {
1015
+ if (item.type === "text") {
1016
+ return { type: "string", text: item.text };
555
1017
  }
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,
1018
+ const entry = resultSets.get(item.statementId);
1019
+ if (!entry) {
1020
+ log.warn("data:missing-statement", {
1021
+ statementId: item.statementId,
563
1022
  });
1023
+ return undefined;
564
1024
  }
565
- if (attachment.text?.content) {
566
- await emit({ kind: "text", content: attachment.text.content });
567
- }
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,
1025
+ const { data, messageId } = entry;
1026
+ let dataset: GenieDataset = { data };
1027
+ try {
1028
+ const planned = await runChartPlanner({
1029
+ config,
1030
+ requestContext,
1031
+ title: item.title ?? "Genie result",
1032
+ ...(item.description ? { description: item.description } : {}),
1033
+ data: data.rows,
1034
+ ...(signal ? { signal } : {}),
576
1035
  });
1036
+ const chartId = commonUtils.shortId();
1037
+ // Slim chart reference for the LLM-bound result: just
1038
+ // `chartId` + `chartType`. The full Echarts spec goes
1039
+ // to the UI via the writer event AND into the
1040
+ // request-scoped chart inventory below; the model
1041
+ // only needs the id to place `[[chart:<id>]]`.
1042
+ dataset = {
1043
+ data,
1044
+ chart: {
1045
+ chartId,
1046
+ chartType: planned.chartType,
1047
+ },
1048
+ };
1049
+ const chartEvent: ChartEvent = {
1050
+ type: "chart",
1051
+ chartId,
1052
+ statementId: item.statementId,
1053
+ messageId,
1054
+ ...(item.title ? { title: item.title } : {}),
1055
+ ...(item.description ? { description: item.description } : {}),
1056
+ data: data.rows,
1057
+ option: planned.option,
1058
+ };
1059
+ await safeWrite(writer, chartEvent);
1060
+ // Stash the resolved chart on the per-request
1061
+ // `RequestContext` so downstream code in the same
1062
+ // request (output processors, follow-up tool calls,
1063
+ // any post-run hook) can look up the full spec by
1064
+ // `chartId` without re-fetching or re-planning.
1065
+ recordChartInContext(requestContext, chartEvent);
1066
+ } catch (err) {
1067
+ const errorMessage = err instanceof Error ? err.message : String(err);
1068
+ log.warn("chart:error", {
1069
+ statementId: item.statementId,
1070
+ messageId,
1071
+ error: errorMessage,
1072
+ });
1073
+ // Surface the chart-planner failure as a writer event
1074
+ // stamped with the same `messageId` the rest of this
1075
+ // ask's wire events carry, so the host UI groups the
1076
+ // failure into the same pill bucket and can surface
1077
+ // a "couldn't render chart" note next to the table
1078
+ // fallback instead of silently dropping the chart.
1079
+ const errorEvent: MastraGenieErrorEvent = {
1080
+ type: "error",
1081
+ spaceId,
1082
+ messageId,
1083
+ error: `chart-planner: ${errorMessage}`,
1084
+ };
1085
+ await safeWrite(writer, errorEvent);
577
1086
  }
578
- }
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
- }
1087
+ return {
1088
+ type: "visualize",
1089
+ statementId: item.statementId,
1090
+ ...(item.title ? { title: item.title } : {}),
1091
+ ...(item.description ? { description: item.description } : {}),
1092
+ dataset,
1093
+ };
1094
+ }),
1095
+ );
1096
+ const summary = hydrated.filter((x): x is GenieSummaryItem => x !== undefined);
588
1097
 
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
- }
1098
+ log.info("genie:done", {
1099
+ spaceId,
1100
+ items: summary.length,
1101
+ statementsCharted: summary.filter(
1102
+ (s) => s.type === "visualize" && s.dataset.chart,
1103
+ ).length,
1104
+ });
615
1105
 
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,
1106
+ const finalConversationId = readContextConversationId(requestContext, spaceId);
1107
+ return {
1108
+ spaceId,
1109
+ summary,
1110
+ ...(finalConversationId ? { conversationId: finalConversationId } : {}),
1111
+ } satisfies GenieAgentResult;
1112
+ },
623
1113
  });
1114
+ }
624
1115
 
625
- return {
626
- ...(conversationId ? { conversationId } : {}),
627
- ...(genieAnswer ? { genieAnswer } : {}),
628
- ...(datasets.length > 0 ? { datasets } : {}),
629
- ...(suggestedFollowUps ? { suggestedFollowUps } : {}),
630
- ...(error ? { error } : {}),
631
- };
1116
+ /* --------------------- multi-alias surface --------------------- */
1117
+
1118
+ /**
1119
+ * Default tool id for a wired Genie alias. The well-known
1120
+ * `default` alias collapses to `genie`; every other alias gets a
1121
+ * `genie_` prefix so multi-space registrations stay
1122
+ * disambiguated.
1123
+ */
1124
+ export function defaultGenieToolName(alias: string): string {
1125
+ if (alias === DEFAULT_GENIE_ALIAS) return "genie";
1126
+ return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
632
1127
  }
633
1128
 
634
1129
  /**
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`.
1130
+ * Normalize the {@link GenieSpacesConfig} record. Bare-string
1131
+ * entries (`{ default: "01ef..." }`) get wrapped as
1132
+ * `{ spaceId: "01ef..." }`; object entries pass through unchanged.
1133
+ * `undefined` and empty-string values are dropped so callers can
1134
+ * pass `process.env.X` directly (matches AppKit `genie()`'s
1135
+ * defensive treatment of unset env vars).
640
1136
  */
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);
1137
+ export function normalizeGenieSpaces(
1138
+ spaces:
1139
+ | GenieSpacesConfig
1140
+ | Record<string, string | GenieSpaceConfig | undefined>
1141
+ | undefined,
1142
+ ): Record<string, GenieSpaceConfig> {
1143
+ if (!spaces) return {};
1144
+ const out: Record<string, GenieSpaceConfig> = {};
1145
+ for (const [alias, value] of Object.entries(spaces)) {
1146
+ if (value === undefined) continue;
1147
+ if (typeof value === "string") {
1148
+ if (!value) continue;
1149
+ out[alias] = { spaceId: value };
1150
+ continue;
1151
+ }
1152
+ if (!value.spaceId) continue;
1153
+ out[alias] = value;
653
1154
  }
654
1155
  return out;
655
1156
  }
656
1157
 
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;
1158
+ /**
1159
+ * AppKit `genie` plugin's config shape, derived from the factory
1160
+ * itself so it stays in lock-step with the upstream type without
1161
+ * deep-importing `IGenieConfig` (which the package's top-level
1162
+ * barrel doesn't surface). The plugin's `config` field is
1163
+ * `protected` in TS only; the runtime layout is plain object
1164
+ * property access, so reading off the instance with a structural
1165
+ * cast is safe.
1166
+ */
1167
+ type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
1168
+
1169
+ /**
1170
+ * Discover Genie space aliases from every supported source and
1171
+ * merge them into a single record. Precedence (highest first):
1172
+ *
1173
+ * 1. {@link MastraPluginConfig.genieSpaces} on the `mastra(...)`
1174
+ * call. Explicit Mastra wiring always wins so users can
1175
+ * override AppKit's defaults per-agent.
1176
+ * 2. AppKit `genie({ spaces: { ... } })` plugin instance. Lets
1177
+ * users keep using the existing AppKit config format
1178
+ * (`genie({ spaces: { sales: "...", ops: "..." } })`)
1179
+ * without restating the same record on the Mastra plugin.
1180
+ * Read off the live plugin instance via a structural cast
1181
+ * since `Plugin.config` is TS-protected (not runtime-private).
1182
+ * 3. `DATABRICKS_GENIE_SPACE_ID` env var (registered under the
1183
+ * well-known `default` alias). Matches the AppKit `genie()`
1184
+ * plugin's fallback behavior so a bare `mastra()` + `genie()`
1185
+ * pair just works.
1186
+ *
1187
+ * Aliases collide cleanly: a higher-precedence source's value
1188
+ * replaces a lower one's wholesale. Sources that contribute zero
1189
+ * aliases (or contribute only `undefined` / empty entries) are
1190
+ * silently ignored.
1191
+ */
1192
+ export function resolveGenieSpaces(
1193
+ config: MastraPluginConfig,
1194
+ context: appkitUtils.PluginContextLike | undefined,
1195
+ ): Record<string, GenieSpaceConfig> {
1196
+ const merged: Record<string, GenieSpaceConfig> = {};
1197
+
1198
+ // Source 3 (lowest precedence): env var.
1199
+ const envSpaceId = process.env["DATABRICKS_GENIE_SPACE_ID"];
1200
+ if (envSpaceId) {
1201
+ merged[DEFAULT_GENIE_ALIAS] = { spaceId: envSpaceId };
665
1202
  }
666
- return cell;
1203
+
1204
+ // Source 2: AppKit `genie()` plugin instance config. Use a
1205
+ // structural cast - `Plugin.config` is `protected` in TS only,
1206
+ // and the runtime layout is plain object property access.
1207
+ const geniePlugin = appkitUtils.instance(context, genie);
1208
+ if (geniePlugin) {
1209
+ const pluginSpaces = (geniePlugin as unknown as { config?: AppKitGenieConfig })
1210
+ .config?.spaces;
1211
+ if (pluginSpaces) {
1212
+ Object.assign(merged, normalizeGenieSpaces(pluginSpaces));
1213
+ }
1214
+ }
1215
+
1216
+ // Source 1 (highest precedence): explicit Mastra wiring.
1217
+ if (config.genieSpaces) {
1218
+ Object.assign(merged, normalizeGenieSpaces(config.genieSpaces));
1219
+ }
1220
+
1221
+ return merged;
667
1222
  }
668
1223
 
669
1224
  /**
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).
1225
+ * Build one Mastra tool per configured Genie space. Each tool is
1226
+ * a thin {@link createGenieTool} wrapper with the alias-derived
1227
+ * id and a hint-flavored description so the calling LLM knows
1228
+ * which space covers what data.
676
1229
  *
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.
1230
+ * Returns a record keyed by tool id, ready to spread into an
1231
+ * `Agent`'s `tools` map (or surfaced via
1232
+ * `plugins.genie?.toolkit()`).
1233
+ */
1234
+ export function buildGenieTools(opts: {
1235
+ spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
1236
+ config: MastraPluginConfig;
1237
+ }): MastraTools {
1238
+ const normalized = normalizeGenieSpaces(opts.spaces);
1239
+ const tools: Record<string, ReturnType<typeof createTool>> = {};
1240
+ for (const [alias, space] of Object.entries(normalized)) {
1241
+ const id = defaultGenieToolName(alias);
1242
+ const toolDescription = stringUtils.toDescription`
1243
+ Delegate a natural-language data question to the
1244
+ Databricks Genie space "${alias}"${space.hint ? ` (${space.hint})` : ""}.
1245
+ Returns an ordered (text | dataset)[] summary the host UI
1246
+ renders inline; datasets carry the rows and a
1247
+ pre-rendered Echarts spec when the chart-planner
1248
+ succeeded. Progress events (status, SQL, row counts,
1249
+ charts) stream to the UI automatically.
1250
+ `;
1251
+ tools[id] = createGenieTool({
1252
+ spaceId: space.spaceId,
1253
+ config: opts.config,
1254
+ toolId: id,
1255
+ toolDescription,
1256
+ });
1257
+ }
1258
+ return tools;
1259
+ }
1260
+
1261
+ /**
1262
+ * Plugin-toolkit adapter so the `plugins.genie?.toolkit()` lookup
1263
+ * inside an agent's `tools(plugins)` callback returns the
1264
+ * Genie agent-backed tools instead of throwing on missing plugin.
1265
+ * Mirrors AppKit's `PluginToolkitProvider` shape.
687
1266
  */
688
- export function buildGenieProvider(
689
- plugin: GeniePluginInstance,
690
- opts: { config: MastraPluginConfig },
691
- ): {
692
- toolkit(opts?: unknown): Record<string, ReturnType<typeof createTool>>;
1267
+ export function buildGenieToolkitProvider(opts: {
1268
+ spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
1269
+ config: MastraPluginConfig;
1270
+ }): {
1271
+ toolkit(opts?: unknown): MastraTools;
693
1272
  } {
694
1273
  return {
695
1274
  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
- });
1275
+ return buildGenieTools(opts);
705
1276
  },
706
1277
  };
707
1278
  }
708
1279
 
709
1280
  /**
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.
715
- */
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));
721
- }
722
- return [...aliases];
723
- }
724
-
725
- /**
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.
1281
+ * Returns `true` when at least one Genie space is reachable
1282
+ * through {@link resolveGenieSpaces} - either via
1283
+ * {@link MastraPluginConfig.genieSpaces}, the AppKit `genie()`
1284
+ * plugin instance, or the `DATABRICKS_GENIE_SPACE_ID` env var.
1285
+ *
1286
+ * Cheap to call from `resolveProvider` to short-circuit `genie`
1287
+ * lookups when nothing is wired, so the `plugins.genie` lookup
1288
+ * still resolves to `undefined` (matching AppKit's
1289
+ * absent-plugin semantics) when neither source is configured.
730
1290
  */
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(" ");
750
- }
1291
+ export function hasAnyGenieSpaces(
1292
+ config: MastraPluginConfig,
1293
+ context: appkitUtils.PluginContextLike | undefined,
1294
+ ): boolean {
1295
+ return Object.keys(resolveGenieSpaces(config, context)).length > 0;
751
1296
  }