@dbx-tools/appkit-mastra 0.1.3 → 0.1.5

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/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ export * from "./src/plugin.js";
13
13
  export * from "@dbx-tools/appkit-mastra-shared";
14
14
  export * from "./src/config.js";
15
15
  export * from "./src/agents.js";
16
+ export * from "./src/chart.js";
16
17
  export * from "./src/genie.js";
17
18
  export { clearServingEndpointsCache, extractModelOverride, listServingEndpoints, MASTRA_MODEL_OVERRIDE_KEY, MODEL_OVERRIDE_BODY_FIELDS, MODEL_OVERRIDE_HEADER, MODEL_OVERRIDE_QUERY, resolveModelId, type ResolvedModel, type ResolveModelOptions, type ServingEndpointSummary, } from "./src/serving.js";
18
19
  export { FALLBACK_MODEL_IDS, MODEL_CATALOG, modelForTier, modelsForTier, ModelTier, } from "./src/model.js";
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ export * from "./src/plugin.js";
13
13
  export * from "@dbx-tools/appkit-mastra-shared";
14
14
  export * from "./src/config.js";
15
15
  export * from "./src/agents.js";
16
+ export * from "./src/chart.js";
16
17
  export * from "./src/genie.js";
17
18
  export { clearServingEndpointsCache, extractModelOverride, listServingEndpoints, MASTRA_MODEL_OVERRIDE_KEY, MODEL_OVERRIDE_BODY_FIELDS, MODEL_OVERRIDE_HEADER, MODEL_OVERRIDE_QUERY, resolveModelId, } from "./src/serving.js";
18
19
  export { FALLBACK_MODEL_IDS, MODEL_CATALOG, modelForTier, modelsForTier, ModelTier, } from "./src/model.js";
@@ -284,7 +284,7 @@ export declare const FALLBACK_AGENT_ID = "default";
284
284
  * Override globally via {@link MastraPluginConfig.styleInstructions}
285
285
  * (pass `false` to disable entirely, or a string to replace).
286
286
  */
287
- export declare const DEFAULT_STYLE_INSTRUCTIONS = "Output style:\n\n- Plain prose. Use hyphens (-) only. Never use em dashes (\u2014) or en dashes (\u2013).\n- Never use emojis.\n- Skip openers like \"Great question\", \"Absolutely\", \"I'd be happy to help\".\n- Skip closers like \"Let me know if you have any questions\".\n- Skip self-disclaimers (\"I should mention\", \"It's important to note\").\n- Answer directly. No preamble before the actual answer.\n- Use lists and headers only when they clarify a multi-part answer; not for short replies.\n- Quote numbers, code, identifiers, and tool output verbatim. Never paraphrase them.";
287
+ export declare const DEFAULT_STYLE_INSTRUCTIONS: string;
288
288
  /**
289
289
  * Resolve every entry in `config.agents` into a Mastra `Agent`
290
290
  * instance. When `config.agents` is omitted the plugin registers a
@@ -16,6 +16,7 @@ import { genie } from "@databricks/appkit";
16
16
  import { logUtils, pluginUtils, stringUtils } from "@dbx-tools/appkit-shared";
17
17
  import { Agent } from "@mastra/core/agent";
18
18
  import { createTool } from "@mastra/core/tools";
19
+ import { buildRenderDataTool } from "./chart.js";
19
20
  import { buildGenieProvider } from "./genie.js";
20
21
  import { buildModel } from "./model.js";
21
22
  /** Re-export of Mastra's native `createTool` for full-feature access. */
@@ -110,16 +111,21 @@ Rules:
110
111
  * Override globally via {@link MastraPluginConfig.styleInstructions}
111
112
  * (pass `false` to disable entirely, or a string to replace).
112
113
  */
113
- export const DEFAULT_STYLE_INSTRUCTIONS = `Output style:
114
-
115
- - Plain prose. Use hyphens (-) only. Never use em dashes (—) or en dashes (–).
116
- - Never use emojis.
117
- - Skip openers like "Great question", "Absolutely", "I'd be happy to help".
118
- - Skip closers like "Let me know if you have any questions".
119
- - Skip self-disclaimers ("I should mention", "It's important to note").
120
- - Answer directly. No preamble before the actual answer.
121
- - Use lists and headers only when they clarify a multi-part answer; not for short replies.
122
- - Quote numbers, code, identifiers, and tool output verbatim. Never paraphrase them.`;
114
+ export const DEFAULT_STYLE_INSTRUCTIONS = [
115
+ "Output style:",
116
+ "",
117
+ "Use markdown formatting, including headings, lists, and code blocks.",
118
+ "Avoid lists and headers for short replies.",
119
+ "Plain prose.",
120
+ "Use hyphens (-) only. Never use em dashes or en dashes.",
121
+ "Never use emojis.",
122
+ "Skip openers like 'Great question', 'Absolutely', and 'I'd be happy to help'.",
123
+ "Skip closers like 'Let me know if you have any questions'.",
124
+ "Skip self-disclaimers like 'I should mention' and 'It's important to note'.",
125
+ "Answer directly.",
126
+ "Do not include a preamble before the actual answer.",
127
+ "Use lists and headers only when they clarify a multi-part answer.",
128
+ ].join("\n");
123
129
  /**
124
130
  * Resolve the style block to append to every agent's instructions.
125
131
  * Returns `null` when the caller opted out (`styleInstructions: false`).
@@ -160,7 +166,15 @@ export async function buildAgents(opts) {
160
166
  const ids = Object.keys(definitions);
161
167
  const defaultAgentId = config.defaultAgent ?? ids[0] ?? FALLBACK_AGENT_ID;
162
168
  const plugins = buildPluginsMap(context);
163
- const ambientTools = config.tools ?? {};
169
+ // System-default ambient tools every agent gets out of the box.
170
+ // Currently just `render_data` for inline visualizations; the
171
+ // user can shadow it by including a same-named tool in their own
172
+ // `config.tools` or per-agent `tools`. Order in {@link resolveTools}
173
+ // is `system -> user-ambient -> per-agent`, last write wins.
174
+ const systemTools = {
175
+ render_data: buildRenderDataTool(config),
176
+ };
177
+ const ambientTools = { ...systemTools, ...(config.tools ?? {}) };
164
178
  const style = resolveStyleInstructions(config);
165
179
  const agents = {};
166
180
  for (const [id, def] of Object.entries(definitions)) {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Chart-rendering primitives.
3
+ *
4
+ * Two surfaces, one shared brain:
5
+ *
6
+ * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
+ * ("here is a dataset, render it as a chart"). The tool is
8
+ * fire-and-forget by design - it generates a short `chartId`,
9
+ * pushes a single `kind: "chart"` event onto `ctx.writer` carrying
10
+ * the raw rows, and returns the id to the model immediately. No
11
+ * chart planning happens inside the agentic loop, so the model
12
+ * never blocks on a downstream LLM call to get its identifier.
13
+ *
14
+ * - {@link runChartPlanner}: the chart-planner Agent + ECOption
15
+ * expansion as a plain async function. The HTTP route in
16
+ * {@link ./render-chart-route.ts} calls this when the client
17
+ * POSTs the dataset back; the result is an `EChartsOption` JSON
18
+ * the React `<ChartSlot>` renders inline. Decoupling the planner
19
+ * from the tool means the planning latency lives entirely
20
+ * client-side: the model can finish writing its report while
21
+ * the client is still rendering the charts.
22
+ *
23
+ * The model wires the chart into its reply by emitting the marker
24
+ * `[[chart:<chartId>]]` on its own line in markdown. The chat
25
+ * client splits the assistant text on these markers and drops a
26
+ * `<ChartSlot>` in at the position the model placed it; the slot
27
+ * then fires the render-chart endpoint on mount and shows a
28
+ * skeleton until the option lands.
29
+ */
30
+ import type { RequestContext } from "@mastra/core/request-context";
31
+ import { z } from "zod";
32
+ import type { MastraPluginConfig } from "./config.js";
33
+ /**
34
+ * Compact, model-friendly representation of an Echarts spec. The
35
+ * planner agent emits this; {@link planToEchartsOption} expands it
36
+ * into a real `EChartsOption` JSON. Two layers because letting the
37
+ * model fill in a fully-typed `EChartsOption` is brittle (hundreds
38
+ * of optional fields, deep unions, version-dependent shapes). A
39
+ * small "chart plan" schema is much more reliable for a fast model
40
+ * and keeps animation / tooltip / styling defaults consistent
41
+ * across charts.
42
+ */
43
+ declare const chartPlanSchema: z.ZodObject<{
44
+ chartType: z.ZodEnum<{
45
+ bar: "bar";
46
+ line: "line";
47
+ area: "area";
48
+ scatter: "scatter";
49
+ pie: "pie";
50
+ }>;
51
+ title: z.ZodOptional<z.ZodString>;
52
+ xAxisLabel: z.ZodOptional<z.ZodString>;
53
+ yAxisLabel: z.ZodOptional<z.ZodString>;
54
+ categories: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ series: z.ZodArray<z.ZodObject<{
56
+ name: z.ZodString;
57
+ data: z.ZodArray<z.ZodUnion<readonly [z.ZodNumber, z.ZodTuple<[z.ZodNumber, z.ZodNumber], null>, z.ZodObject<{
58
+ name: z.ZodString;
59
+ value: z.ZodNumber;
60
+ }, z.core.$strip>]>>;
61
+ }, z.core.$strip>>;
62
+ }, z.core.$strip>;
63
+ type ChartPlan = z.infer<typeof chartPlanSchema>;
64
+ /** Inputs to {@link runChartPlanner}. */
65
+ export interface RunChartPlannerOptions {
66
+ config: MastraPluginConfig;
67
+ requestContext?: RequestContext;
68
+ title: string;
69
+ description?: string;
70
+ data: ReadonlyArray<Record<string, unknown>>;
71
+ }
72
+ /** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
73
+ export interface RunChartPlannerResult {
74
+ option: Record<string, unknown>;
75
+ chartType: ChartPlan["chartType"];
76
+ }
77
+ /**
78
+ * Run the chart planner against the given dataset and return a
79
+ * full Echarts `EChartsOption` JSON. Used by the HTTP route the
80
+ * client hits when it sees a `[[chart:<chartId>]]` marker; the
81
+ * tool itself does not call this so the model never blocks on
82
+ * planning latency.
83
+ */
84
+ export declare function runChartPlanner(opts: RunChartPlannerOptions): Promise<RunChartPlannerResult>;
85
+ /**
86
+ * Build the `render_data` tool bound to the given plugin config.
87
+ *
88
+ * Fire-and-forget by design: the tool returns immediately with a
89
+ * short `chartId` and emits a single `kind: "chart"` event over
90
+ * `ctx.writer` carrying the raw dataset for the client. The
91
+ * client's chart slot then POSTs the data to
92
+ * `/route/render-chart` to get an `EChartsOption` back from the
93
+ * planner agent. This keeps the calling LLM unblocked - it can
94
+ * write the report referencing the chart by id while the client
95
+ * is still rendering it.
96
+ */
97
+ export declare function buildRenderDataTool(_config: MastraPluginConfig): import("@mastra/core/tools").Tool<{
98
+ title: string;
99
+ data: Record<string, unknown>[];
100
+ description?: string | undefined;
101
+ }, {
102
+ chartId: string;
103
+ }, unknown, unknown, import("@mastra/core/tools").ToolExecutionContext<unknown, unknown, unknown>, "render_data", unknown>;
104
+ export {};
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Chart-rendering primitives.
3
+ *
4
+ * Two surfaces, one shared brain:
5
+ *
6
+ * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
+ * ("here is a dataset, render it as a chart"). The tool is
8
+ * fire-and-forget by design - it generates a short `chartId`,
9
+ * pushes a single `kind: "chart"` event onto `ctx.writer` carrying
10
+ * the raw rows, and returns the id to the model immediately. No
11
+ * chart planning happens inside the agentic loop, so the model
12
+ * never blocks on a downstream LLM call to get its identifier.
13
+ *
14
+ * - {@link runChartPlanner}: the chart-planner Agent + ECOption
15
+ * expansion as a plain async function. The HTTP route in
16
+ * {@link ./render-chart-route.ts} calls this when the client
17
+ * POSTs the dataset back; the result is an `EChartsOption` JSON
18
+ * the React `<ChartSlot>` renders inline. Decoupling the planner
19
+ * from the tool means the planning latency lives entirely
20
+ * client-side: the model can finish writing its report while
21
+ * the client is still rendering the charts.
22
+ *
23
+ * The model wires the chart into its reply by emitting the marker
24
+ * `[[chart:<chartId>]]` on its own line in markdown. The chat
25
+ * client splits the assistant text on these markers and drops a
26
+ * `<ChartSlot>` in at the position the model placed it; the slot
27
+ * then fires the render-chart endpoint on mount and shows a
28
+ * skeleton until the option lands.
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+ import { stringUtils } from "@dbx-tools/appkit-shared";
32
+ import { Agent } from "@mastra/core/agent";
33
+ import { createTool } from "@mastra/core/tools";
34
+ import { z } from "zod";
35
+ import { ModelTier, modelForTier, buildModel } from "./model.js";
36
+ /**
37
+ * Compact, model-friendly representation of an Echarts spec. The
38
+ * planner agent emits this; {@link planToEchartsOption} expands it
39
+ * into a real `EChartsOption` JSON. Two layers because letting the
40
+ * model fill in a fully-typed `EChartsOption` is brittle (hundreds
41
+ * of optional fields, deep unions, version-dependent shapes). A
42
+ * small "chart plan" schema is much more reliable for a fast model
43
+ * and keeps animation / tooltip / styling defaults consistent
44
+ * across charts.
45
+ */
46
+ const chartPlanSchema = z.object({
47
+ chartType: z
48
+ .enum(["bar", "line", "area", "scatter", "pie"])
49
+ .describe(stringUtils.toDescription `
50
+ The chart shape that best matches the data and intent. Use
51
+ \`bar\` for category-vs-value comparisons, \`line\` for
52
+ trends over an ordered axis, \`area\` for stacked-trend
53
+ emphasis, \`scatter\` for two-numeric-axis correlations,
54
+ \`pie\` for parts-of-a-whole when categories are few.
55
+ `),
56
+ title: z.string().optional().describe(stringUtils.toDescription `
57
+ Short title shown above the chart. Optional; defaults to the
58
+ \`title\` argument the caller passed in.
59
+ `),
60
+ xAxisLabel: z.string().optional().describe(stringUtils.toDescription `
61
+ Axis label below the chart. Used for bar / line / area /
62
+ scatter; ignored for pie.
63
+ `),
64
+ yAxisLabel: z.string().optional().describe(stringUtils.toDescription `
65
+ Axis label to the left of the chart. Used for bar / line /
66
+ area / scatter; ignored for pie.
67
+ `),
68
+ categories: z
69
+ .array(z.string())
70
+ .optional()
71
+ .describe(stringUtils.toDescription `
72
+ X-axis category labels for \`bar\` / \`line\` / \`area\`
73
+ charts (one per data point in each series). Omit for
74
+ \`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
75
+ carries its own \`name\`).
76
+ `),
77
+ series: z
78
+ .array(z.object({
79
+ name: z.string().describe(stringUtils.toDescription `
80
+ Legend name for this series.
81
+ `),
82
+ data: z
83
+ .array(z.union([
84
+ z.number(),
85
+ z.tuple([z.number(), z.number()]),
86
+ z.object({
87
+ name: z.string(),
88
+ value: z.number(),
89
+ }),
90
+ ]))
91
+ .describe(stringUtils.toDescription `
92
+ Data points. For \`bar\` / \`line\` / \`area\`, an
93
+ array of numbers aligned to \`categories\`. For
94
+ \`scatter\`, an array of \`[x, y]\` numeric tuples.
95
+ For \`pie\`, an array of \`{name, value}\` objects.
96
+ `),
97
+ }))
98
+ .min(1)
99
+ .describe(stringUtils.toDescription `
100
+ One or more series to plot. Pie charts use exactly one
101
+ series; bar/line/area can stack multiple series sharing
102
+ the same \`categories\` axis.
103
+ `),
104
+ });
105
+ /**
106
+ * System prompt for the inner chart-planning agent. Tuned for a
107
+ * fast-tier model (Haiku, GPT-5-mini, Gemini Flash Lite).
108
+ */
109
+ const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription `
110
+ You design Apache Echarts visualizations. The user gives you a
111
+ tabular dataset (rows of objects) plus a title and an optional
112
+ description of the intent. You produce a small chart plan
113
+ (chart type, axis labels, categories, series) that best
114
+ conveys the data.
115
+
116
+ Decision guide:
117
+
118
+ - bar: comparing a numeric value across a small/medium set of
119
+ discrete categories (top-N, ranking, group-by).
120
+ - line: ordered-axis trend (time series, sequence).
121
+ - area: same as line but emphasises magnitude or stacked
122
+ composition.
123
+ - scatter: two numeric axes, correlation between fields.
124
+ - pie: parts of a whole when 2-7 categories sum to a
125
+ meaningful total.
126
+
127
+ When in doubt between bar and line, prefer bar for unordered
128
+ categories and line for ordered ones (dates, time buckets,
129
+ ranks). Never pick pie for more than 7 slices.
130
+
131
+ For bar / line / area: pick one column as the category axis
132
+ (usually the only string-valued column) and one or more
133
+ numeric columns as series. Sort categories by the primary
134
+ series value descending unless the data is naturally ordered
135
+ (dates, ranks).
136
+
137
+ For pie: pick the category column for slice names and one
138
+ numeric column for slice values. Emit a single series.
139
+
140
+ For scatter: pick two numeric columns and emit \`[x, y]\`
141
+ tuples in a single series.
142
+
143
+ Keep series names human-readable (use the column name; title
144
+ case it lightly if needed). Keep titles concise; do not
145
+ repeat the user's title in xAxisLabel / yAxisLabel.
146
+ `;
147
+ /**
148
+ * Lazily-constructed inner agent shared across all calls in this
149
+ * process. The agent is stateless (no memory, no tools) so a
150
+ * single instance per plugin config is safe; model resolution
151
+ * still happens per-call against the live `requestContext`, so
152
+ * OBO auth stays user-scoped.
153
+ */
154
+ function createChartPlannerAgent(config) {
155
+ return new Agent({
156
+ id: "render_chart_planner",
157
+ name: "Chart Planner",
158
+ description: "Picks chart type and axis encodings for a dataset.",
159
+ instructions: CHART_PLANNER_INSTRUCTIONS,
160
+ model: ({ requestContext }) => buildModel(config, requestContext, {
161
+ modelId: modelForTier(ModelTier.Fast),
162
+ }),
163
+ });
164
+ }
165
+ /**
166
+ * Module-level cache: one chart-planner agent per plugin config
167
+ * instance. Keyed on the config object identity since each plugin
168
+ * mount provides its own resolver / fallbacks. Re-used across
169
+ * tool invocations and the render-chart HTTP route.
170
+ */
171
+ const _plannerByConfig = new WeakMap();
172
+ function getPlannerAgent(config) {
173
+ let agent = _plannerByConfig.get(config);
174
+ if (!agent) {
175
+ agent = createChartPlannerAgent(config);
176
+ _plannerByConfig.set(config, agent);
177
+ }
178
+ return agent;
179
+ }
180
+ /**
181
+ * Run the chart planner against the given dataset and return a
182
+ * full Echarts `EChartsOption` JSON. Used by the HTTP route the
183
+ * client hits when it sees a `[[chart:<chartId>]]` marker; the
184
+ * tool itself does not call this so the model never blocks on
185
+ * planning latency.
186
+ */
187
+ export async function runChartPlanner(opts) {
188
+ const { config, requestContext, title, description, data } = opts;
189
+ const planner = getPlannerAgent(config);
190
+ const prompt = [
191
+ `Title: ${title}`,
192
+ description ? `Intent: ${description}` : null,
193
+ "",
194
+ "Dataset (JSON, one row per object):",
195
+ "```json",
196
+ JSON.stringify(data, null, 2),
197
+ "```",
198
+ ]
199
+ .filter((line) => line !== null)
200
+ .join("\n");
201
+ const result = await planner.generate(prompt, {
202
+ structuredOutput: { schema: chartPlanSchema },
203
+ ...(requestContext ? { requestContext } : {}),
204
+ });
205
+ const plan = result.object;
206
+ const option = planToEchartsOption(plan, title);
207
+ return { option, chartType: plan.chartType };
208
+ }
209
+ const renderDataInputSchema = z.object({
210
+ title: z.string().describe(stringUtils.toDescription `
211
+ Title shown above the rendered chart. Use a concise
212
+ sentence-case label (e.g. "Top 10 SKUs by On-Hand Units").
213
+ `),
214
+ description: z.string().optional().describe(stringUtils.toDescription `
215
+ Optional one-line intent describing what insight the chart
216
+ should convey (e.g. "highlight the steep drop-off after
217
+ position 5", "compare quarterly revenue across regions").
218
+ The chart-planner reads this when picking the chart type and
219
+ axis encodings; the user does not see it directly.
220
+ `),
221
+ data: z
222
+ .array(z.record(z.string(), z.unknown()))
223
+ .min(1)
224
+ .describe(stringUtils.toDescription `
225
+ Tabular dataset to chart. One object per row, keyed by
226
+ column name. Values may be strings, numbers, booleans, or
227
+ null. The chart-planner decides which columns are
228
+ categories vs. numeric series. Cap at a few hundred rows
229
+ for legibility; sample / aggregate larger datasets first.
230
+ `),
231
+ });
232
+ const renderDataOutputSchema = z.object({
233
+ chartId: z.string().describe(stringUtils.toDescription `
234
+ Identifier of the queued chart. The tool returned
235
+ immediately - actual chart planning happens client-side
236
+ asynchronously. To position the chart in your reply, embed
237
+ the marker \`[[chart:<chartId>]]\` on its own line (with
238
+ blank lines above and below) where the chart should appear.
239
+ The client renders a skeleton there until the chart is
240
+ ready, then swaps in the visualization in place. You can
241
+ keep writing prose around the marker; the agent does not
242
+ need to wait for the chart to render.
243
+ `),
244
+ });
245
+ /**
246
+ * Build the `render_data` tool bound to the given plugin config.
247
+ *
248
+ * Fire-and-forget by design: the tool returns immediately with a
249
+ * short `chartId` and emits a single `kind: "chart"` event over
250
+ * `ctx.writer` carrying the raw dataset for the client. The
251
+ * client's chart slot then POSTs the data to
252
+ * `/route/render-chart` to get an `EChartsOption` back from the
253
+ * planner agent. This keeps the calling LLM unblocked - it can
254
+ * write the report referencing the chart by id while the client
255
+ * is still rendering it.
256
+ */
257
+ export function buildRenderDataTool(_config) {
258
+ return createTool({
259
+ id: "render_data",
260
+ description: stringUtils.toDescription `
261
+ Submit a tabular dataset for inline rendering as a chart in
262
+ the user's view. Pass a title, the raw rows (array of
263
+ objects keyed by column name), and an optional one-line
264
+ description of the insight to highlight. Returns a short
265
+ \`chartId\` immediately - chart planning happens
266
+ asynchronously in the client, not in this turn, so the tool
267
+ does not block your prose.
268
+
269
+ Placement contract: embed \`[[chart:<chartId>]]\` on its own
270
+ line (blank lines above and below) wherever you want the
271
+ chart to appear in your reply. The client shows a skeleton
272
+ at that spot until the chart is ready, then swaps in the
273
+ rendered Echarts visualization. You can call
274
+ \`render_data\` multiple times in the same turn (the tool
275
+ is parallel-safe) and interleave the markers with prose so
276
+ each chart sits next to its commentary. A chart whose
277
+ marker is omitted falls through to the end of your reply
278
+ as a fallback - safe but less polished.
279
+
280
+ Use whenever a SQL row set, API response, or hand-built
281
+ dataset would land better as a picture than as a list or
282
+ table. Cap input at a few hundred rows; sample or
283
+ aggregate larger datasets first.
284
+ `,
285
+ inputSchema: renderDataInputSchema,
286
+ outputSchema: renderDataOutputSchema,
287
+ execute: async (input, ctx) => {
288
+ const { title, description, data } = input;
289
+ // Short, marker-friendly id. The LLM has to type this
290
+ // verbatim into the `[[chart:<id>]]` marker; an 8-hex-char
291
+ // prefix is unique within a single assistant turn (collision
292
+ // odds ~1 in 4 billion) and much less error-prone for the
293
+ // model to reproduce.
294
+ const chartId = randomUUID().replace(/-/g, "").slice(0, 8);
295
+ const writer = ctx
296
+ ?.writer;
297
+ try {
298
+ await writer?.write({
299
+ kind: "chart",
300
+ chartId,
301
+ title,
302
+ ...(description ? { description } : {}),
303
+ data,
304
+ });
305
+ }
306
+ catch {
307
+ // Ignore: the parent stream may have closed downstream.
308
+ }
309
+ return { chartId };
310
+ },
311
+ });
312
+ }
313
+ /**
314
+ * Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
315
+ * JSON. Centralized here so the planner agent only fills in the
316
+ * compact plan shape; tooltip / animation / color / grid defaults
317
+ * stay consistent across charts and are easy to tune without
318
+ * retraining model behaviour.
319
+ */
320
+ function planToEchartsOption(plan, fallbackTitle) {
321
+ const baseTitle = plan.title ?? fallbackTitle;
322
+ const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
323
+ if (plan.chartType === "pie") {
324
+ return {
325
+ title: { text: baseTitle, left: "center" },
326
+ tooltip: { trigger: "item" },
327
+ legend: { bottom: 0 },
328
+ series: [
329
+ {
330
+ name: plan.series[0]?.name ?? baseTitle,
331
+ type: "pie",
332
+ radius: ["35%", "65%"],
333
+ data: plan.series[0]?.data ?? [],
334
+ },
335
+ ],
336
+ };
337
+ }
338
+ if (plan.chartType === "scatter") {
339
+ return {
340
+ title: { text: baseTitle, left: "center" },
341
+ tooltip: { trigger: "item" },
342
+ legend: { bottom: 0 },
343
+ grid,
344
+ xAxis: { type: "value", name: plan.xAxisLabel },
345
+ yAxis: { type: "value", name: plan.yAxisLabel },
346
+ series: plan.series.map((s) => ({
347
+ name: s.name,
348
+ type: "scatter",
349
+ data: s.data,
350
+ })),
351
+ };
352
+ }
353
+ // bar / line / area share the same axis layout.
354
+ const isArea = plan.chartType === "area";
355
+ const seriesType = plan.chartType === "bar" ? "bar" : "line";
356
+ return {
357
+ title: { text: baseTitle, left: "center" },
358
+ tooltip: { trigger: "axis" },
359
+ legend: { bottom: 0 },
360
+ grid,
361
+ xAxis: {
362
+ type: "category",
363
+ data: plan.categories ?? [],
364
+ name: plan.xAxisLabel,
365
+ },
366
+ yAxis: { type: "value", name: plan.yAxisLabel },
367
+ series: plan.series.map((s) => ({
368
+ name: s.name,
369
+ type: seriesType,
370
+ data: s.data,
371
+ smooth: seriesType === "line",
372
+ ...(isArea ? { areaStyle: {} } : {}),
373
+ })),
374
+ };
375
+ }
@@ -11,12 +11,13 @@
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
  import { genie } from "@databricks/appkit";
22
23
  import { createTool } from "@mastra/core/tools";
@@ -33,10 +34,14 @@ export type GenieStreamEvent = ReturnType<GenieExports["sendMessage"]> extends A
33
34
  /** Conversation history returned by `genie.exports().getConversation`. */
34
35
  export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
35
36
  /**
36
- * Normalised progress event surfaced to the UI as a Mastra `tool-output`
37
- * chunk. The discriminator (`kind`) keeps the union open for future
38
- * Genie features (charts, attachments, retries) without forcing the
39
- * client to know any Genie wire format.
37
+ * Normalised progress event surfaced to the UI as a Mastra
38
+ * `tool-output` chunk. Loading pill events (`started`, `status`,
39
+ * `sql`, `suggested`, `error`) are pure UI metadata and never reach
40
+ * the LLM. The `chart` variant carries the rows from a Genie SQL
41
+ * statement so the host UI's `<ChartSlot>` can render them inline
42
+ * via the same path as the `render_data` tool; the LLM still only
43
+ * sees the matching {@link datasetSchema} metadata in
44
+ * `genieAnswer`'s sibling `datasets[]` field.
40
45
  */
41
46
  export type GenieProgress = {
42
47
  kind: "started";
@@ -54,9 +59,11 @@ export type GenieProgress = {
54
59
  description?: string;
55
60
  statementId?: string;
56
61
  } | {
57
- kind: "data";
58
- rowCount: number;
59
- columns: string[];
62
+ kind: "chart";
63
+ chartId: string;
64
+ title: string;
65
+ description?: string;
66
+ data: Array<Record<string, unknown>>;
60
67
  } | {
61
68
  kind: "text";
62
69
  content: string;