@dbx-tools/appkit-mastra 0.1.4 → 0.1.12

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
@@ -11,20 +11,34 @@
11
11
  * upstream change in `@databricks/appkit` flows in automatically.
12
12
  *
13
13
  * As Genie streams its long-running events (`FETCHING_METADATA` →
14
- * `ASKING_AI` → `EXECUTING_QUERY` → `COMPLETED`, plus SQL queries and
15
- * row data in `message_result.attachments` / `query_result`), the tool
16
- * forwards a normalised {@link GenieProgress} discriminated union out
17
- * through `ctx.writer` so the client can render incremental feedback
18
- * (status pill, SQL code block, row count) while the LLM still sees a
19
- * single clean final payload.
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.
20
21
  */
21
22
 
22
23
  import { genie } from "@databricks/appkit";
23
- import { stringUtils } from "@dbx-tools/appkit-shared";
24
+ import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
25
+ import type { RequestContext } from "@mastra/core/request-context";
24
26
  import { createTool } from "@mastra/core/tools";
25
27
  import type { ToolStream } from "@mastra/core/tools";
26
28
  import { z } from "zod";
27
29
 
30
+ import { emitChartWithPlanning } from "./chart.js";
31
+ import type { MastraPluginConfig } from "./config.js";
32
+
33
+ /**
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`).
39
+ */
40
+ const log = logUtils.logger("mastra/genie");
41
+
28
42
  /** Live AppKit `GeniePlugin` instance. */
29
43
  export type GeniePluginInstance = InstanceType<ReturnType<typeof genie>["plugin"]>;
30
44
 
@@ -42,14 +56,113 @@ export type GenieStreamEvent =
42
56
  /** Conversation history returned by `genie.exports().getConversation`. */
43
57
  export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
44
58
 
45
- type GenieMessage = Extract<GenieStreamEvent, { type: "message_result" }>["message"];
46
- type GenieStatement = Extract<GenieStreamEvent, { type: "query_result" }>["data"];
59
+ /**
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.
65
+ */
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
+ });
98
+
99
+ /**
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.
104
+ */
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
+ });
149
+
150
+ type DrainResult = z.infer<typeof genieToolOutputSchema>;
47
151
 
48
152
  /**
49
- * Normalised progress event surfaced to the UI as a Mastra `tool-output`
50
- * chunk. The discriminator (`kind`) keeps the union open for future
51
- * Genie features (charts, attachments, retries) without forcing the
52
- * client to know any Genie wire format.
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`.
53
166
  */
54
167
  export type GenieProgress =
55
168
  | { kind: "started"; conversationId: string; messageId: string; spaceId: string }
@@ -61,31 +174,132 @@ export type GenieProgress =
61
174
  description?: string;
62
175
  statementId?: string;
63
176
  }
64
- | { kind: "data"; rowCount: number; columns: string[] }
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
+ }
65
185
  | { kind: "text"; content: string }
66
186
  | { kind: "suggested"; questions: string[] }
67
187
  | { kind: "error"; error: string };
68
188
 
69
189
  const sendMessageSchema = z.object({
70
- content: z.string().describe("Natural-language question to send to the Genie space."),
190
+ content: z.string().describe(stringUtils.toDescription`
191
+ Natural-language question to send to the Genie space.
192
+ `),
71
193
  conversationId: z
72
194
  .string()
73
195
  .optional()
74
- .describe(
75
- "Optional Genie conversation id to continue an earlier thread. " +
76
- "Omit on the first call; pass the id returned in the previous " +
77
- "result's `conversationId` to follow up.",
78
- ),
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
+ `),
79
201
  });
80
202
 
81
203
  const getConversationSchema = z.object({
82
- alias: z
83
- .string()
84
- .describe(
85
- "Alias of the Genie space the conversation belongs to (matches the " +
86
- "key in the genie plugin's `spaces` config).",
87
- ),
88
- conversationId: z.string().describe("Genie conversation id whose history to fetch."),
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
+ });
284
+
285
+ /**
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.
289
+ */
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
+ `),
89
303
  });
90
304
 
91
305
  /**
@@ -104,10 +318,16 @@ export function defaultGenieToolName(alias: string): string {
104
318
  * Build one `sendMessage` tool per configured Genie alias plus a single
105
319
  * `getConversation` tool. Returns a record keyed by tool id, ready to
106
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.
107
326
  */
108
327
  export function buildGenieTools(opts: {
109
328
  aliases: string[];
110
329
  exports: GenieExports;
330
+ config: MastraPluginConfig;
111
331
  signal?: AbortSignal;
112
332
  }): Record<string, ReturnType<typeof createTool>> {
113
333
  const tools: Record<string, ReturnType<typeof createTool>> = {};
@@ -116,30 +336,46 @@ export function buildGenieTools(opts: {
116
336
  const id = defaultGenieToolName(alias);
117
337
  tools[id] = createTool({
118
338
  id,
119
- description:
120
- `Ask the Databricks Genie space "${alias}" a natural-language ` +
121
- "question. Genie translates the question to SQL, runs it against " +
122
- "the configured datasets, and returns a written answer plus any " +
123
- "SQL statements it executed. Returns `{ conversationId, content, " +
124
- "queries, ... }`; pass `conversationId` back in to follow up in " +
125
- "the same Genie thread.",
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
+ `,
126
354
  inputSchema: sendMessageSchema,
355
+ outputSchema: genieToolOutputSchema,
127
356
  execute: async ({ content, conversationId }, ctx) => {
128
357
  const stream = opts.exports.sendMessage(alias, content, conversationId, {
129
358
  signal: opts.signal,
130
359
  });
131
- return drainGenieStream(stream, ctx.writer);
360
+ const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
361
+ ?.requestContext;
362
+ return drainGenieStream(stream, ctx.writer, {
363
+ config: opts.config,
364
+ ...(requestContext ? { requestContext } : {}),
365
+ });
132
366
  },
133
367
  });
134
368
  }
135
369
 
136
370
  tools.genie_get_conversation = createTool({
137
371
  id: "genie_get_conversation",
138
- description:
139
- "Fetch the full message history of a prior Genie conversation by id. " +
140
- "Use when the user references an earlier Genie thread by id, or to " +
141
- "inspect attachments / SQL from previous turns.",
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
+ `,
142
377
  inputSchema: getConversationSchema,
378
+ outputSchema: genieGetConversationOutputSchema,
143
379
  execute: async ({ alias, conversationId }) => {
144
380
  return opts.exports.getConversation(alias, conversationId, opts.signal);
145
381
  },
@@ -148,47 +384,93 @@ export function buildGenieTools(opts: {
148
384
  return tools;
149
385
  }
150
386
 
387
+ /** Inputs to {@link drainGenieStream}. */
388
+ interface DrainGenieStreamOptions {
389
+ config: MastraPluginConfig;
390
+ requestContext?: RequestContext;
391
+ }
392
+
151
393
  /**
152
- * Drain the genie `sendMessage` AsyncGenerator into a flat result the
153
- * agent's calling LLM can reason about. Final assistant text is pulled
154
- * from the last `message_result`; SQL statements are extracted from
155
- * `query_result` events; conversation / message ids are surfaced so the
156
- * caller can pass `conversationId` back into a follow-up tool call.
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.
157
414
  *
158
- * When a Mastra `writer` is passed (i.e. the tool runs inside an agent
159
- * stream), normalised {@link GenieProgress} events are pushed mid-flight
160
- * so the UI can show status changes, SQL, and row counts as they
161
- * happen instead of staring at a spinner for the full Genie round-trip.
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.
162
423
  */
163
424
  async function drainGenieStream(
164
425
  stream: AsyncGenerator<GenieStreamEvent>,
165
- writer?: ToolStream,
166
- ): Promise<{
167
- conversationId?: string;
168
- messageId?: string;
169
- spaceId?: string;
170
- status?: string;
171
- content?: string;
172
- attachments?: GenieMessage["attachments"];
173
- queries: { attachmentId: string; statementId: string; data: GenieStatement }[];
174
- error?: string;
175
- }> {
426
+ writer: ToolStream | undefined,
427
+ opts: DrainGenieStreamOptions,
428
+ ): Promise<DrainResult> {
429
+ const { config, requestContext } = opts;
176
430
  let conversationId: string | undefined;
177
- let messageId: string | undefined;
178
- let spaceId: string | undefined;
179
- let status: string | undefined;
180
- let content: string | undefined;
181
- let attachments: GenieMessage["attachments"] | undefined;
431
+ let genieAnswer: string | undefined;
432
+ let suggestedFollowUps: string[] | undefined;
182
433
  let error: string | undefined;
183
- const queries: {
184
- attachmentId: string;
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 = {
185
448
  statementId: string;
186
- data: GenieStatement;
187
- }[] = [];
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>[] = [];
188
473
 
189
- // Best-effort progress emission. Awaited so the underlying agent
190
- // stream sees events in order; write failures are swallowed so a
191
- // dead writer (e.g. closed downstream) can't take the tool down.
192
474
  const emit = async (event: GenieProgress) => {
193
475
  if (!writer) return;
194
476
  try {
@@ -199,20 +481,25 @@ async function drainGenieStream(
199
481
  };
200
482
 
201
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 });
202
490
  switch (event.type) {
203
491
  case "message_start":
204
492
  conversationId = event.conversationId;
205
- messageId = event.messageId;
206
- spaceId = event.spaceId;
207
493
  await emit({
208
494
  kind: "started",
209
- conversationId,
210
- messageId,
211
- spaceId,
495
+ conversationId: event.conversationId,
496
+ messageId: event.messageId,
497
+ spaceId: event.spaceId,
212
498
  });
213
499
  break;
214
500
  case "status":
215
- status = event.status;
501
+ if (event.status === lastStatus) break;
502
+ lastStatus = event.status;
216
503
  await emit({
217
504
  kind: "status",
218
505
  status: event.status,
@@ -220,36 +507,69 @@ async function drainGenieStream(
220
507
  });
221
508
  break;
222
509
  case "query_result": {
223
- queries.push({
224
- attachmentId: event.attachmentId,
225
- statementId: event.statementId,
226
- data: event.data,
227
- });
228
- const rowCount = event.data?.result?.data_array?.length ?? 0;
229
510
  const columns = (event.data?.manifest?.schema?.columns ?? []).map(
230
511
  (c) => c.name,
231
512
  );
232
- await emit({ kind: "data", rowCount, columns });
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
+ });
233
541
  break;
234
542
  }
235
543
  case "message_result":
236
- content = event.message.content;
237
- attachments = event.message.attachments;
238
- status = event.message.status;
239
- for (const attachment of attachments ?? []) {
240
- if (attachment.query?.query) {
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) {
241
557
  await emit({
242
558
  kind: "sql",
243
- sql: attachment.query.query,
244
- title: attachment.query.title,
245
- description: attachment.query.description,
246
- statementId: attachment.query.statementId,
559
+ sql: sqlText,
560
+ title: attachment.query?.title,
561
+ description: attachment.query?.description,
562
+ statementId: stmtId,
247
563
  });
248
564
  }
249
565
  if (attachment.text?.content) {
250
566
  await emit({ kind: "text", content: attachment.text.content });
251
567
  }
252
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;
253
573
  await emit({
254
574
  kind: "suggested",
255
575
  questions: attachment.suggestedQuestions,
@@ -266,18 +586,86 @@ async function drainGenieStream(
266
586
  }
267
587
  }
268
588
 
269
- return {
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
+ }
615
+
616
+ log.debug("drain:return", {
270
617
  conversationId,
271
- messageId,
272
- spaceId,
273
- status,
274
- content,
275
- attachments,
276
- queries,
618
+ hasAnswer: typeof genieAnswer === "string",
619
+ answerLength: genieAnswer?.length ?? 0,
620
+ chartIds: datasets.map((d) => d.chartId),
621
+ suggestedCount: suggestedFollowUps?.length ?? 0,
277
622
  error,
623
+ });
624
+
625
+ return {
626
+ ...(conversationId ? { conversationId } : {}),
627
+ ...(genieAnswer ? { genieAnswer } : {}),
628
+ ...(datasets.length > 0 ? { datasets } : {}),
629
+ ...(suggestedFollowUps ? { suggestedFollowUps } : {}),
630
+ ...(error ? { error } : {}),
278
631
  };
279
632
  }
280
633
 
634
+ /**
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`.
640
+ */
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);
653
+ }
654
+ return out;
655
+ }
656
+
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;
665
+ }
666
+ return cell;
667
+ }
668
+
281
669
  /**
282
670
  * Toolkit provider built from a live AppKit `GeniePlugin` instance.
283
671
  * Returned by {@link buildGenieProvider} so that
@@ -297,7 +685,10 @@ async function drainGenieStream(
297
685
  * all-or-nothing bundle. Wire `only` / `except` / `prefix` / `rename`
298
686
  * later if a caller needs them.
299
687
  */
300
- export function buildGenieProvider(plugin: GeniePluginInstance): {
688
+ export function buildGenieProvider(
689
+ plugin: GeniePluginInstance,
690
+ opts: { config: MastraPluginConfig },
691
+ ): {
301
692
  toolkit(opts?: unknown): Record<string, ReturnType<typeof createTool>>;
302
693
  } {
303
694
  return {
@@ -309,6 +700,7 @@ export function buildGenieProvider(plugin: GeniePluginInstance): {
309
700
  sendMessage: plugin.sendMessage.bind(plugin),
310
701
  getConversation: plugin.getConversation.bind(plugin),
311
702
  },
703
+ config: opts.config,
312
704
  });
313
705
  },
314
706
  };
@@ -349,6 +741,11 @@ function humanizeGenieStatus(status: string): string {
349
741
  case "FAILED":
350
742
  return "Failed";
351
743
  default:
352
- return status.toLowerCase().replace(/_/g, " ");
744
+ return [
745
+ ...stringUtils.tokenizeWithOptions(
746
+ { capitalize: true, lowerCase: true },
747
+ status,
748
+ ),
749
+ ].join(" ");
353
750
  }
354
751
  }