@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/README.md +145 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/src/agents.d.ts +1 -1
- package/dist/src/agents.js +43 -19
- package/dist/src/chart.d.ts +170 -0
- package/dist/src/chart.js +491 -0
- package/dist/src/config.d.ts +13 -0
- package/dist/src/genie.d.ts +36 -14
- package/dist/src/genie.js +434 -75
- package/dist/src/history.d.ts +67 -0
- package/dist/src/history.js +172 -0
- package/dist/src/memory.js +15 -2
- package/dist/src/model.js +18 -14
- package/dist/src/plugin.d.ts +11 -1
- package/dist/src/plugin.js +28 -2
- package/dist/src/processors/strip-stale-charts.d.ts +29 -0
- package/dist/src/processors/strip-stale-charts.js +96 -0
- package/dist/src/server.d.ts +4 -0
- package/dist/src/server.js +59 -45
- package/dist/src/serving.js +19 -2
- package/dist/src/tools/email.d.ts +74 -0
- package/dist/src/tools/email.js +122 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/index.ts +2 -0
- package/package.json +21 -25
- package/src/agents.ts +46 -21
- package/src/chart.ts +593 -0
- package/src/config.ts +13 -0
- package/src/genie.ts +499 -102
- package/src/history.ts +210 -0
- package/src/memory.ts +19 -2
- package/src/model.ts +18 -13
- package/src/plugin.ts +30 -2
- package/src/processors/strip-stale-charts.ts +105 -0
- package/src/server.ts +76 -51
- package/src/serving.ts +21 -2
- package/src/tools/email.ts +147 -0
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
* chunk.
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
| {
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
84
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
153
|
-
* agent's calling LLM can reason about
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* so
|
|
161
|
-
*
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
178
|
-
let
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
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:
|
|
244
|
-
title: attachment.query
|
|
245
|
-
description: attachment.query
|
|
246
|
-
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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(
|
|
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
|
|
744
|
+
return [
|
|
745
|
+
...stringUtils.tokenizeWithOptions(
|
|
746
|
+
{ capitalize: true, lowerCase: true },
|
|
747
|
+
status,
|
|
748
|
+
),
|
|
749
|
+
].join(" ");
|
|
353
750
|
}
|
|
354
751
|
}
|