@dbx-tools/appkit-mastra 0.1.25 → 0.1.27

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/chart.ts CHANGED
@@ -1,33 +1,46 @@
1
1
  /**
2
- * Chart-rendering primitives.
2
+ * Chart planner + chart cache.
3
3
  *
4
- * Two surfaces, one shared brain:
4
+ * Self-contained chart subsystem with two layers:
5
5
  *
6
- * - {@link runChartPlanner}: the chart-planner Agent + ECOption
7
- * expansion as a plain async function. Takes a dataset and
8
- * returns a promise that resolves to a full `EChartsOption`
9
- * JSON plus the chosen `chartType`. No background work, no
10
- * writer side-effects, no id allocation - callers stitch the
11
- * result into whatever shape their producer needs.
6
+ * 1. Inner planner agent (private). Pure dataset-in /
7
+ * `EChartsOption`-out brain. Driven by {@link prepareChart};
8
+ * callers never instantiate it directly.
9
+ * 2. {@link prepareChart}: orchestration on top of the planner.
10
+ * Mints a `chartId`, caches an empty `{ chartId }` record
11
+ * synchronously, then resolves the dataset and runs the
12
+ * planner in the background. The terminal entry settles with
13
+ * either `result` (success) or `error` (failure). Both
14
+ * undefined means the entry is still processing.
12
15
  *
13
- * - {@link buildRenderDataTool}: a Mastra tool the model calls
14
- * ("here is a dataset, render it as a chart"). Mints a short
15
- * `chartId`, `await`s {@link runChartPlanner} so the planner
16
- * latency is attributed to this tool's trace span, emits one
17
- * `type: "chart"` writer event carrying the dataset + resolved
18
- * `option`, and returns `{ chartId }` to the model. The
19
- * LLM-bound output stays flat regardless of dataset size.
16
+ * The cache surface ({@link fetchChart}) is the only state the
17
+ * HTTP route and the chart-producing tools share. `prepareChart`
18
+ * is dataset-agnostic - callers supply a `resolveData` callback
19
+ * that fetches the rows however they like (Genie statement, inline
20
+ * dataset, custom API). The module has no knowledge of Genie or
21
+ * statement ids; those concerns live in the tools that wrap it.
20
22
  *
21
- * The model wires the chart into its reply by emitting the marker
22
- * `[[chart:<chartId>]]` on its own line in markdown. The chat
23
- * client splits the assistant text on these markers and drops a
24
- * `<ChartSlot>` in at the position the model placed it. The slot
25
- * resolves directly to the rendered Echarts visualisation - no
26
- * skeleton state, because the option is in the same event as the
27
- * dataset.
23
+ * Wire-format schemas (`ChartSchema`, `ChartResultSchema`,
24
+ * {@link ChartTypeSchema}) live in
25
+ * `@dbx-tools/appkit-mastra-shared` so the demo client and any
26
+ * other UI consumer share the exact same shape this module reads
27
+ * and writes.
28
+ *
29
+ * Public surface (everything else is module-private):
30
+ * - {@link chartPlannerRequestSchema} / {@link ChartPlannerRequest}
31
+ * - {@link prepareChart} / {@link PrepareChartOptions}
32
+ * - {@link fetchChart} / {@link FetchChartOptions}
33
+ * - {@link buildRenderDataTool} (the `render_data` Mastra tool
34
+ * auto-wired on every agent in `agents.ts`)
28
35
  */
29
36
 
30
- import type { MinimalWriter } from "@dbx-tools/appkit-mastra-shared";
37
+ import { CacheManager } from "@databricks/appkit";
38
+ import {
39
+ ChartSchema,
40
+ ChartTypeSchema,
41
+ type Chart,
42
+ type ChartResult,
43
+ } from "@dbx-tools/appkit-mastra-shared";
31
44
  import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
32
45
  import { Agent } from "@mastra/core/agent";
33
46
  import type { RequestContext } from "@mastra/core/request-context";
@@ -35,17 +48,39 @@ import { createTool } from "@mastra/core/tools";
35
48
  import { z } from "zod";
36
49
 
37
50
  import type { MastraPluginConfig } from "./config.js";
38
- import { ModelTier, modelForTier, buildModel } from "./model.js";
39
- import { safeWrite } from "./writer.js";
51
+ import { buildModel, ModelTier, modelForTier } from "./model.js";
52
+
53
+ const log = logUtils.logger("mastra/chart");
54
+
55
+ /* ------------------------------ constants ------------------------------ */
40
56
 
41
57
  /**
42
- * Module-level logger tagged `[mastra/chart]`. Uses the shared
43
- * {@link logUtils.logger} so calls below `LOG_LEVEL` are
44
- * discarded for free. Default `LOG_LEVEL` is `info`; flip to
45
- * `debug` to see the per-chart timeline (`emit:start`
46
- * `write:ok(data)` `planner:done` `write:ok(option)`).
58
+ * TTL for cached chart entries. One hour balances "long enough for
59
+ * the host UI to fetch the chart well after the model finished
60
+ * talking" against "short enough that abandoned chart ids don't
61
+ * pin storage". Matches the typical Databricks OBO token lifetime
62
+ * so any data re-resolution stays inside the original auth window.
47
63
  */
48
- const log = logUtils.logger("mastra/chart");
64
+ const CHART_CACHE_TTL_SEC = 60 * 60;
65
+
66
+ /** Cache namespace; keeps the chart keyspace tidy. */
67
+ const CHART_CACHE_NAMESPACE = "mastra:chart";
68
+
69
+ /**
70
+ * `userKey` for `CacheManager.generateKey`. Chart ids are minted
71
+ * via `commonUtils.id()` (v4 UUID) and are unguessable, so a
72
+ * constant user key is fine. The HTTP route can re-scope to the
73
+ * requesting user when policy demands it.
74
+ */
75
+ const CHART_CACHE_USER_KEY = "mastra-chart";
76
+
77
+ /** Default server-side long-poll budget for {@link fetchChart}. */
78
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
79
+
80
+ /** Default inter-poll sleep for {@link fetchChart}. */
81
+ const DEFAULT_FETCH_INTERVAL_MS = 250;
82
+
83
+ /* ------------------------------- schemas ------------------------------- */
49
84
 
50
85
  /**
51
86
  * One series data point. Wide variant set so the planner agent can
@@ -72,10 +107,6 @@ const log = logUtils.logger("mastra/chart");
72
107
  * match any variant, the bad item becomes `null` instead of
73
108
  * taking down the entire chart with a
74
109
  * `Structured output validation failed` error.
75
- *
76
- * Net effect: a 200-row dataset with a few sparse/null/string
77
- * values still produces a chart; only a totally-malformed planner
78
- * response (no items at all) falls through to the table fallback.
79
110
  */
80
111
  const chartDataPointSchema = z
81
112
  .preprocess(
@@ -118,8 +149,6 @@ const chartDataPointSchema = z
118
149
  )
119
150
  .catch(null);
120
151
 
121
- type ChartDataPoint = z.infer<typeof chartDataPointSchema>;
122
-
123
152
  /**
124
153
  * Compact, model-friendly representation of an Echarts spec. The
125
154
  * planner agent emits this; {@link planToEchartsOption} expands it
@@ -131,338 +160,424 @@ type ChartDataPoint = z.infer<typeof chartDataPointSchema>;
131
160
  * across charts.
132
161
  */
133
162
  const chartPlanSchema = z.object({
134
- chartType: z.enum(["bar", "line", "area", "scatter", "pie"])
135
- .describe(stringUtils.toDescription`
136
- The chart shape that best matches the data and intent. Use
137
- \`bar\` for category-vs-value comparisons, \`line\` for
138
- trends over an ordered axis, \`area\` for stacked-trend
139
- emphasis, \`scatter\` for two-numeric-axis correlations,
140
- \`pie\` for parts-of-a-whole when categories are few.
141
- `),
142
- title: z.string().optional().describe(stringUtils.toDescription`
143
- Short title shown above the chart. Optional; defaults to the
144
- \`title\` argument the caller passed in.
145
- `),
146
- xAxisLabel: z.string().optional().describe(stringUtils.toDescription`
147
- Axis label below the chart. Used for bar / line / area /
148
- scatter; ignored for pie.
149
- `),
150
- yAxisLabel: z.string().optional().describe(stringUtils.toDescription`
151
- Axis label to the left of the chart. Used for bar / line /
152
- area / scatter; ignored for pie.
153
- `),
154
- categories: z.array(z.string()).optional().describe(stringUtils.toDescription`
155
- X-axis category labels for \`bar\` / \`line\` / \`area\`
156
- charts (one per data point in each series). Omit for
157
- \`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
158
- carries its own \`name\`).
159
- `),
163
+ chartType: ChartTypeSchema,
164
+ title: z
165
+ .string()
166
+ .optional()
167
+ .describe(
168
+ stringUtils.toDescription(`
169
+ Short title shown above the chart. Optional; defaults to the
170
+ \`title\` argument the caller passed in.
171
+ `),
172
+ ),
173
+ xAxisLabel: z
174
+ .string()
175
+ .optional()
176
+ .describe(
177
+ stringUtils.toDescription(`
178
+ Axis label below the chart. Used for bar / line / area / scatter;
179
+ ignored for pie.
180
+ `),
181
+ ),
182
+ yAxisLabel: z
183
+ .string()
184
+ .optional()
185
+ .describe(
186
+ stringUtils.toDescription(`
187
+ Axis label to the left of the chart. Used for bar / line / area /
188
+ scatter; ignored for pie.
189
+ `),
190
+ ),
191
+ categories: z
192
+ .array(z.string())
193
+ .optional()
194
+ .describe(
195
+ stringUtils.toDescription(`
196
+ X-axis category labels for \`bar\` / \`line\` / \`area\` charts
197
+ (one per data point in each series). Omit for \`scatter\` (uses
198
+ [x, y] tuples) and \`pie\` (each slice carries its own \`name\`).
199
+ `),
200
+ ),
160
201
  series: z
161
202
  .array(
162
203
  z.object({
163
- name: z.string().describe(stringUtils.toDescription`
164
- Legend name for this series.
165
- `),
166
- data: z.array(chartDataPointSchema).describe(stringUtils.toDescription`
167
- Data points. For \`bar\` / \`line\` / \`area\`, an
168
- array of numbers aligned to \`categories\`. For
169
- \`scatter\`, an array of \`[x, y]\` numeric tuples.
170
- For \`pie\`, an array of \`{name, value}\` objects.
204
+ name: z.string().describe(
205
+ stringUtils.toDescription(`
206
+ Legend name for this series.
171
207
  `),
208
+ ),
209
+ data: z.array(chartDataPointSchema).describe(
210
+ stringUtils.toDescription(`
211
+ Data points. For \`bar\` / \`line\` / \`area\`, an array of
212
+ numbers aligned to \`categories\`. For \`scatter\`, an array
213
+ of \`[x, y]\` numeric tuples. For \`pie\`, an array of
214
+ \`{name, value}\` objects.
215
+ `),
216
+ ),
172
217
  }),
173
218
  )
174
- .min(1).describe(stringUtils.toDescription`
175
- One or more series to plot. Pie charts use exactly one
176
- series; bar/line/area can stack multiple series sharing
177
- the same \`categories\` axis.
178
- `),
219
+ .min(1)
220
+ .describe(
221
+ stringUtils.toDescription(`
222
+ One or more series to plot. Pie charts use exactly one series;
223
+ bar/line/area can stack multiple series sharing the same
224
+ \`categories\` axis.
225
+ `),
226
+ ),
179
227
  });
180
228
 
181
229
  type ChartPlan = z.infer<typeof chartPlanSchema>;
182
230
 
231
+ /**
232
+ * Canonical planner input shape. Tools that source rows from an
233
+ * inline dataset (`render_data`) use it as their `inputSchema`
234
+ * verbatim; tools that resolve rows from a remote (`prepare_chart`
235
+ * over a Genie statement) `omit({ data })` and `extend` with their
236
+ * own identifier field, so the field-level `.describe()` text
237
+ * stays a single source of truth. Server-only - the UI never
238
+ * sees a planner request, only the resolved {@link Chart}.
239
+ */
240
+ export const chartPlannerRequestSchema = z.object({
241
+ title: z.string().describe(
242
+ stringUtils.toDescription(`
243
+ Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
244
+ `),
245
+ ),
246
+ description: z
247
+ .string()
248
+ .optional()
249
+ .describe(
250
+ stringUtils.toDescription(`
251
+ One-line intent the chart-planner uses when picking a chart type
252
+ and axis encodings (e.g. "compare quarterly revenue across
253
+ regions", "highlight the steep drop after position 5"). Not shown
254
+ to the user.
255
+ `),
256
+ ),
257
+ data: z
258
+ .array(z.record(z.string(), z.unknown()))
259
+ .nonempty("Data must contain at least one row")
260
+ .readonly()
261
+ .describe(
262
+ stringUtils.toDescription(`
263
+ Tabular dataset to chart. One object per row, keyed by column
264
+ name. Values may be strings, numbers, booleans, or null. The
265
+ chart-planner decides which columns are categories vs. numeric
266
+ series. Cap at a few hundred rows for legibility; sample /
267
+ aggregate larger datasets first.
268
+ `),
269
+ ),
270
+ });
271
+
272
+ export type ChartPlannerRequest = z.infer<typeof chartPlannerRequestSchema>;
273
+
274
+ /* --------------------------- planner instructions --------------------------- */
275
+
276
+ /**
277
+ * Format {@link ChartTypeSchema}'s variants as a single
278
+ * human-friendly string of `` `<value>` for <description> ``
279
+ * clauses joined by semicolons, drawn from each variant's own
280
+ * `.describe()` so the planner prompt stays in lock-step with
281
+ * the schema by construction.
282
+ */
283
+ function formatChartTypePicker(): string {
284
+ return ChartTypeSchema.options
285
+ .map((opt) => `\`${opt.value}\` for ${opt.description ?? ""}`)
286
+ .join("; ");
287
+ }
288
+
183
289
  /**
184
290
  * System prompt for the inner chart-planning agent. Tuned for a
185
291
  * fast-tier model (Haiku, GPT-5-mini, Gemini Flash Lite).
186
292
  */
187
- const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription`
293
+ const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription(`
188
294
  You design Apache Echarts visualizations. The user gives you a
189
295
  tabular dataset (rows of objects) plus a title and an optional
190
- description of the intent. You produce a small chart plan
191
- (chart type, axis labels, categories, series) that best
192
- conveys the data.
193
-
194
- Decision guide:
296
+ description of the intent. You produce a small chart plan (chart
297
+ type, axis labels, categories, series) that best conveys the data.
195
298
 
196
- - bar: comparing a numeric value across a small/medium set of
197
- discrete categories (top-N, ranking, group-by).
198
- - line: ordered-axis trend (time series, sequence).
199
- - area: same as line but emphasises magnitude or stacked
200
- composition.
201
- - scatter: two numeric axes, correlation between fields.
202
- - pie: parts of a whole when 2-7 categories sum to a
203
- meaningful total.
299
+ Decision guide. Pick the chart type whose data shape matches the
300
+ dataset and the user's intent: ${formatChartTypePicker()}.
204
301
 
205
302
  When in doubt between bar and line, prefer bar for unordered
206
- categories and line for ordered ones (dates, time buckets,
207
- ranks). Never pick pie for more than 7 slices.
303
+ categories and line for ordered ones (dates, time buckets, ranks).
304
+ Never pick pie for more than 7 slices.
208
305
 
209
- For bar / line / area: pick one column as the category axis
210
- (usually the only string-valued column) and one or more
211
- numeric columns as series. Sort categories by the primary
212
- series value descending unless the data is naturally ordered
213
- (dates, ranks).
306
+ For bar / line / area: pick one column as the category axis (usually
307
+ the only string-valued column) and one or more numeric columns as
308
+ series. Sort categories by the primary series value descending unless
309
+ the data is naturally ordered (dates, ranks).
214
310
 
215
- For pie: pick the category column for slice names and one
216
- numeric column for slice values. Emit a single series.
311
+ For pie: pick the category column for slice names and one numeric
312
+ column for slice values. Emit a single series.
217
313
 
218
- For scatter: pick two numeric columns and emit \`[x, y]\`
219
- tuples in a single series.
314
+ For scatter: pick two numeric columns and emit \`[x, y]\` tuples in a
315
+ single series.
220
316
 
221
- Keep series names human-readable (use the column name; title
222
- case it lightly if needed). Keep titles concise; do not
223
- repeat the user's title in xAxisLabel / yAxisLabel.
224
- `;
317
+ Keep series names human-readable (use the column name; title case it
318
+ lightly if needed). Keep titles concise; do not repeat the user's
319
+ title in xAxisLabel / yAxisLabel.
320
+ `);
225
321
 
226
- /**
227
- * Lazily-constructed inner agent shared across all calls in this
228
- * process. The agent is stateless (no memory, no tools) so a
229
- * single instance per plugin config is safe; model resolution
230
- * still happens per-call against the live `requestContext`, so
231
- * OBO auth stays user-scoped.
232
- */
233
- function createChartPlannerAgent(config: MastraPluginConfig): Agent {
234
- return new Agent({
235
- id: "render_chart_planner",
236
- name: "Chart Planner",
237
- description: "Picks chart type and axis encodings for a dataset.",
238
- instructions: CHART_PLANNER_INSTRUCTIONS,
239
- model: ({ requestContext }) =>
240
- buildModel(config, requestContext, {
241
- modelId: modelForTier(ModelTier.Fast),
242
- }),
243
- });
244
- }
245
-
246
- /** Inputs to {@link runChartPlanner}. */
247
- export interface RunChartPlannerOptions {
248
- config: MastraPluginConfig;
249
- requestContext?: RequestContext;
250
- title: string;
251
- description?: string;
252
- data: ReadonlyArray<Record<string, unknown>>;
253
- /**
254
- * Cooperative cancellation. Forwarded to the planner agent's
255
- * `generate({ abortSignal })` call so concurrent renders can be
256
- * aborted as a group when the parent Genie agent's signal fires.
257
- */
258
- signal?: AbortSignal;
259
- }
260
-
261
- /** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
262
- export interface RunChartPlannerResult {
263
- option: Record<string, unknown>;
264
- chartType: ChartPlan["chartType"];
265
- }
322
+ /* ----------------------------- planner agent ----------------------------- */
266
323
 
267
324
  /**
268
- * Module-level cache: one chart-planner agent per plugin config
269
- * instance. Keyed on the config object identity since each plugin
270
- * mount provides its own resolver / fallbacks. Re-used across
271
- * tool invocations and the render-chart HTTP route.
325
+ * One planner `Agent` per plugin config. Cached on config object
326
+ * identity so callers can `prepareChart({ config, ... })` from a
327
+ * hot path without paying the Agent-constructor cost every call.
328
+ * `WeakMap` lets retired configs (e.g. test reconfigurations)
329
+ * release their agent without manual eviction.
272
330
  */
273
- const _plannerByConfig = new WeakMap<MastraPluginConfig, Agent>();
331
+ const plannerAgents = new WeakMap<MastraPluginConfig, Agent>();
332
+
274
333
  function getPlannerAgent(config: MastraPluginConfig): Agent {
275
- let agent = _plannerByConfig.get(config);
334
+ let agent = plannerAgents.get(config);
276
335
  if (!agent) {
277
- agent = createChartPlannerAgent(config);
278
- _plannerByConfig.set(config, agent);
336
+ agent = new Agent({
337
+ id: "chart_planner",
338
+ name: "Chart Planner",
339
+ description: "Picks chart type and axis encodings for a dataset.",
340
+ instructions: CHART_PLANNER_INSTRUCTIONS,
341
+ model: ({ requestContext }) =>
342
+ buildModel(config, requestContext, {
343
+ modelId: modelForTier(ModelTier.Fast),
344
+ }),
345
+ });
346
+ plannerAgents.set(config, agent);
279
347
  }
280
348
  return agent;
281
349
  }
282
350
 
283
351
  /**
284
- * Run the chart planner against the given dataset and return a
285
- * full Echarts `EChartsOption` JSON. Pure async function: no
286
- * writer side-effects, no id minting, no background work.
287
- * Producers (the `render_data` tool, the Genie agent,
288
- * anything else that needs a chart) await this and stitch the
289
- * result into whatever shape their wire contract needs.
352
+ * Run the planner against `request` and return the resolved
353
+ * Echarts spec. Throws on planner failure - {@link prepareChart}
354
+ * catches and stashes the error in the cache entry.
290
355
  */
291
- export async function runChartPlanner(
292
- opts: RunChartPlannerOptions,
293
- ): Promise<RunChartPlannerResult> {
294
- const { config, requestContext, title, description, data, signal } = opts;
295
- const planner = getPlannerAgent(config);
296
-
297
- const prompt = [
298
- `Title: ${title}`,
299
- description ? `Intent: ${description}` : null,
300
- "",
301
- "Dataset (JSON, one row per object):",
302
- "```json",
303
- JSON.stringify(data, null, 2),
304
- "```",
305
- ]
306
- .filter((line): line is string => line !== null)
307
- .join("\n");
308
-
309
- const result = await planner.generate(prompt, {
356
+ async function runChartPlanner(
357
+ config: MastraPluginConfig,
358
+ request: ChartPlannerRequest,
359
+ options: { requestContext?: RequestContext; abortSignal?: AbortSignal } = {},
360
+ ): Promise<ChartResult> {
361
+ const { title, description, data } = request;
362
+ const { requestContext, abortSignal } = options;
363
+ const prompt = stringUtils.toDescription({
364
+ Title: title,
365
+ ...(description ? { Description: description } : {}),
366
+ "Dataset (JSON, one row per object)": JSON.stringify(data, null, 2),
367
+ });
368
+ const result = await getPlannerAgent(config).generate(prompt, {
310
369
  structuredOutput: { schema: chartPlanSchema },
311
370
  ...(requestContext ? { requestContext } : {}),
312
- ...(signal ? { abortSignal: signal } : {}),
371
+ ...(abortSignal ? { abortSignal } : {}),
313
372
  });
314
373
  const plan = result.object as ChartPlan;
315
374
  const option = planToEchartsOption(plan, title);
316
- return { option, chartType: plan.chartType };
375
+ return { chartType: plan.chartType, option };
317
376
  }
318
377
 
319
- const renderDataInputSchema = z.object({
320
- title: z.string().describe(stringUtils.toDescription`
321
- Title shown above the rendered chart. Use a concise
322
- sentence-case label (e.g. "Top 10 SKUs by On-Hand Units").
323
- `),
324
- description: z.string().optional().describe(stringUtils.toDescription`
325
- Optional one-line intent describing what insight the chart
326
- should convey (e.g. "highlight the steep drop-off after
327
- position 5", "compare quarterly revenue across regions").
328
- The chart-planner reads this when picking the chart type and
329
- axis encodings; the user does not see it directly.
330
- `),
331
- data: z.array(z.record(z.string(), z.unknown())).min(1)
332
- .describe(stringUtils.toDescription`
333
- Tabular dataset to chart. One object per row, keyed by
334
- column name. Values may be strings, numbers, booleans, or
335
- null. The chart-planner decides which columns are
336
- categories vs. numeric series. Cap at a few hundred rows
337
- for legibility; sample / aggregate larger datasets first.
338
- `),
339
- });
378
+ /* ------------------------------ cache helpers ------------------------------ */
340
379
 
341
- const renderDataOutputSchema = z.object({
342
- chartId: z.string().describe(stringUtils.toDescription`
343
- Identifier of the queued chart. To position the chart in
344
- your reply, embed the marker \`[[chart:<chartId>]]\` on its
345
- own line where the chart should appear; the client renders
346
- it inline.
347
- `),
348
- });
380
+ /** Build the canonical cache key for a `chartId`. */
381
+ async function chartCacheKey(chartId: string): Promise<string> {
382
+ return (await CacheManager.getInstance()).generateKey(
383
+ [CHART_CACHE_NAMESPACE, chartId],
384
+ CHART_CACHE_USER_KEY,
385
+ );
386
+ }
387
+
388
+ /**
389
+ * Persist a {@link Chart} entry under its `chartId`. Refreshes
390
+ * the TTL on every write. Cache-layer failures are logged and
391
+ * swallowed so background runners never throw into the
392
+ * unhandled-rejection stream.
393
+ */
394
+ async function writeChart(entry: Chart): Promise<void> {
395
+ try {
396
+ const key = await chartCacheKey(entry.chartId);
397
+ await CacheManager.getInstanceSync().set(key, entry, {
398
+ ttl: CHART_CACHE_TTL_SEC,
399
+ });
400
+ } catch (err) {
401
+ log.warn("write-error", {
402
+ chartId: entry.chartId,
403
+ error: commonUtils.errorMessage(err),
404
+ });
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Look up a chart by id. Returns `undefined` on miss, on
410
+ * expiry, or when the cache layer is unhealthy - never throws.
411
+ */
412
+ async function readChart(chartId: string): Promise<Chart | undefined> {
413
+ try {
414
+ const key = await chartCacheKey(chartId);
415
+ const v = await CacheManager.getInstanceSync().get<Chart>(key);
416
+ return v ?? undefined;
417
+ } catch (err) {
418
+ log.warn("read-error", {
419
+ chartId,
420
+ error: commonUtils.errorMessage(err),
421
+ });
422
+ return undefined;
423
+ }
424
+ }
425
+
426
+ /* --------------------------- prepareChart orchestrator --------------------------- */
427
+
428
+ /** Inputs to {@link prepareChart}. */
429
+ export interface PrepareChartOptions {
430
+ /** Plugin config; resolves the planner agent's model. */
431
+ config: MastraPluginConfig;
432
+ /** Display title forwarded to the planner agent. */
433
+ title?: string;
434
+ /** Optional intent hint forwarded to the planner agent. */
435
+ description?: string;
436
+ /**
437
+ * Resolves the rows to chart. Called once, in the background.
438
+ * Any thrown error lands in the cache as the entry's `error`
439
+ * field (never propagated to the caller of {@link prepareChart}).
440
+ * An empty `rows` array is rejected as `"dataset has no rows;
441
+ * nothing to chart"`.
442
+ */
443
+ resolveData: (
444
+ signal?: AbortSignal,
445
+ ) => Promise<{ rows: ReadonlyArray<Record<string, unknown>> }>;
446
+ /**
447
+ * Per-request `RequestContext`. Forwarded to the planner agent so
448
+ * user-scoped model resolution (OBO) stays in effect.
449
+ */
450
+ requestContext?: RequestContext;
451
+ /**
452
+ * Cooperative cancellation. Forwarded to `resolveData` and the
453
+ * planner agent. Note: the chart task continues running in the
454
+ * background after the parent request ends, so external abort
455
+ * signals are best-effort; typical use is to leave this unset
456
+ * and let the 1h TTL cap stale entries.
457
+ */
458
+ signal?: AbortSignal;
459
+ }
349
460
 
350
461
  /**
351
- * Build the `render_data` tool bound to the given plugin config.
462
+ * Mint a `chartId`, cache an empty `{ chartId }` placeholder
463
+ * synchronously, and kick off a background task that resolves the
464
+ * dataset and runs the planner. Returns the `chartId` once the
465
+ * placeholder lands so the first {@link fetchChart} call always
466
+ * sees an entry (no spurious 404 race).
467
+ *
468
+ * The background task swallows its own failures and writes them
469
+ * as `error` entries, so callers never see a rejected promise.
470
+ * Cache state machine:
352
471
  *
353
- * The tool awaits {@link runChartPlanner} so the planner's
354
- * latency is attributed to this tool's trace span, then emits
355
- * one `type: "chart"` writer event carrying the dataset and the
356
- * resolved `EChartsOption`. The LLM-bound output is just
357
- * `{ chartId }` so the model's context stays flat regardless of
358
- * dataset size. Planner failures are caught and surfaced as a
359
- * `type: "error"` writer event so the slot can fall back to
360
- * "couldn't render chart" without taking the parent agent down.
472
+ * - just after this call returns: `{ chartId }` (processing)
473
+ * - on planner success: `{ chartId, result }`
474
+ * - on data / planner failure: `{ chartId, error }`
361
475
  */
362
- export function buildRenderDataTool(config: MastraPluginConfig) {
363
- return createTool({
364
- id: "render_data",
365
- description: stringUtils.toDescription`
366
- Submit a tabular dataset for inline rendering as a chart in
367
- the user's view. Pass a title, the raw rows (array of
368
- objects keyed by column name), and an optional one-line
369
- description of the insight to highlight. Returns a short
370
- \`chartId\`; the chart renders inline at the position you
371
- embed the matching \`[[chart:<chartId>]]\` marker.
372
-
373
- Placement contract: embed \`[[chart:<chartId>]]\` on its own
374
- line (blank lines above and below) wherever you want the
375
- chart to appear in your reply. The chart is fully resolved
376
- by the time the tool returns, so it renders immediately at
377
- that spot. You can call \`render_data\` multiple times in
378
- the same turn (the tool is parallel-safe) and interleave
379
- the markers with prose so each chart sits next to its
380
- commentary. A chart whose marker is omitted falls through
381
- to the end of your reply as a fallback - safe but less
382
- polished.
383
-
384
- Use whenever a SQL row set, API response, or hand-built
385
- dataset would land better as a picture than as a list or
386
- table. Cap input at a few hundred rows; sample or
387
- aggregate larger datasets first.
388
- `,
389
- inputSchema: renderDataInputSchema,
390
- outputSchema: renderDataOutputSchema,
391
- execute: async (input, ctx) => {
392
- const { title, description, data } = input as z.infer<
393
- typeof renderDataInputSchema
394
- >;
395
- const writer = (ctx as { writer?: MinimalWriter } | undefined)?.writer;
396
- const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
397
- ?.requestContext;
398
-
399
- // Marker-friendly short id. The LLM types this verbatim
400
- // into `[[chart:<id>]]`; 8 hex chars is unique within a
401
- // single assistant turn and easy for the model to copy.
402
- const chartId = commonUtils.shortId();
403
- const startedAt = Date.now();
404
- log.debug("render:start", {
405
- chartId,
406
- title,
407
- rows: data.length,
408
- columns: data[0] ? Object.keys(data[0]) : [],
409
- hasWriter: writer !== undefined,
410
- });
476
+ export async function prepareChart(
477
+ opts: PrepareChartOptions,
478
+ ): Promise<{ chartId: string }> {
479
+ const chartId = commonUtils.id();
480
+ await writeChart({ chartId });
481
+ log.debug("queued", { chartId });
482
+ // Fire-and-forget. Failures land in the cache as `error` entries;
483
+ // never escape into an unhandled rejection.
484
+ void runPrepareChart(chartId, opts);
485
+ return { chartId };
486
+ }
411
487
 
412
- try {
413
- const { option, chartType } = await runChartPlanner({
414
- config,
415
- ...(requestContext ? { requestContext } : {}),
416
- title,
417
- ...(description ? { description } : {}),
418
- data,
419
- });
420
- log.debug("render:done", {
421
- chartId,
422
- chartType,
423
- elapsedMs: Date.now() - startedAt,
424
- });
425
- // Single chart event with everything resolved: dataset
426
- // for the table-like fallback / hover, option for the
427
- // actual render. Best-effort write so a closed
428
- // downstream stream can't take the tool down.
429
- await safeWrite(
430
- log,
431
- writer,
432
- {
433
- type: "chart",
434
- chartId,
435
- title,
436
- ...(description ? { description } : {}),
437
- data,
438
- option,
439
- },
440
- { chartId },
441
- );
442
- } catch (err) {
443
- log.warn("render:error", {
444
- chartId,
445
- elapsedMs: Date.now() - startedAt,
446
- error: commonUtils.errorMessage(err),
447
- });
448
- // Surface as a writer-level error so the slot can
449
- // transition to "couldn't render chart" without the
450
- // parent agent surfacing a stack trace.
451
- await safeWrite(
452
- log,
453
- writer,
454
- {
455
- type: "error",
456
- error: commonUtils.errorMessage(err),
457
- },
458
- { chartId },
459
- );
460
- }
461
- return { chartId };
462
- },
463
- });
488
+ async function runPrepareChart(
489
+ chartId: string,
490
+ opts: PrepareChartOptions,
491
+ ): Promise<void> {
492
+ const startedAt = Date.now();
493
+ try {
494
+ const data = await opts.resolveData(opts.signal);
495
+ if (data.rows.length === 0) {
496
+ throw new Error("dataset has no rows; nothing to chart");
497
+ }
498
+ const result = await runChartPlanner(
499
+ opts.config,
500
+ {
501
+ title: opts.title ?? "Chart",
502
+ ...(opts.description ? { description: opts.description } : {}),
503
+ data: data.rows as ChartPlannerRequest["data"],
504
+ },
505
+ {
506
+ ...(opts.requestContext ? { requestContext: opts.requestContext } : {}),
507
+ ...(opts.signal ? { abortSignal: opts.signal } : {}),
508
+ },
509
+ );
510
+ await writeChart({ chartId, result });
511
+ log.info("done", {
512
+ chartId,
513
+ chartType: result.chartType,
514
+ elapsedMs: Date.now() - startedAt,
515
+ });
516
+ } catch (err) {
517
+ const error = commonUtils.errorMessage(err);
518
+ log.warn("error", { chartId, error });
519
+ await writeChart({ chartId, error });
520
+ }
464
521
  }
465
522
 
523
+ /* ------------------------------- long-poll fetch ------------------------------- */
524
+
525
+ /** Inputs to {@link fetchChart}. */
526
+ export interface FetchChartOptions {
527
+ /**
528
+ * Server-side polling budget in ms. When the entry stays in
529
+ * the processing state past this window, the helper returns the
530
+ * last seen value (still processing) so the client can re-poll.
531
+ * Defaults to {@link DEFAULT_FETCH_TIMEOUT_MS} (60s).
532
+ */
533
+ timeoutMs?: number;
534
+ /**
535
+ * Poll interval in ms. Defaults to
536
+ * {@link DEFAULT_FETCH_INTERVAL_MS} (250ms).
537
+ */
538
+ intervalMs?: number;
539
+ /** External cancellation handle (e.g. request `req.signal`). */
540
+ signal?: AbortSignal;
541
+ }
542
+
543
+ /**
544
+ * Long-poll the chart cache until the entry settles (`result` or
545
+ * `error` set), the entry is missing, or the server-side timeout
546
+ * elapses.
547
+ *
548
+ * Returns:
549
+ * - the resolved {@link Chart} when it settled, errored, or
550
+ * stayed in processing past `timeoutMs` (so the client can
551
+ * re-poll);
552
+ * - `undefined` when the entry is missing or expired (the
553
+ * consumer should treat as 404).
554
+ *
555
+ * `signal` lets the caller cancel ahead of timeout (e.g. the HTTP
556
+ * request closed). Cancellation propagates to the inter-poll sleep
557
+ * so the helper returns immediately.
558
+ */
559
+ export async function fetchChart(
560
+ chartId: string,
561
+ options: FetchChartOptions = {},
562
+ ): Promise<Chart | undefined> {
563
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
564
+ const intervalMs = options.intervalMs ?? DEFAULT_FETCH_INTERVAL_MS;
565
+ const deadline = Date.now() + timeoutMs;
566
+
567
+ let last: Chart | undefined;
568
+ while (true) {
569
+ options.signal?.throwIfAborted();
570
+ last = await readChart(chartId);
571
+ if (!last) return undefined;
572
+ if (last.result !== undefined || last.error !== undefined) return last;
573
+ const remaining = deadline - Date.now();
574
+ if (remaining <= 0) return last;
575
+ await commonUtils.sleep(Math.min(intervalMs, remaining), options.signal);
576
+ }
577
+ }
578
+
579
+ /* ----------------------------- echarts expansion ----------------------------- */
580
+
466
581
  /**
467
582
  * Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
468
583
  * JSON. Centralized here so the planner agent only fills in the
@@ -545,3 +660,68 @@ function planToEchartsOption(
545
660
  })),
546
661
  };
547
662
  }
663
+
664
+ /* ----------------------------- render_data tool ----------------------------- */
665
+
666
+ /**
667
+ * Build the `render_data` Mastra tool bound to the given plugin
668
+ * config. Auto-wired as a system tool on every agent (see
669
+ * `agents.ts`); per-agent tools can shadow it by registering a
670
+ * same-named entry.
671
+ *
672
+ * Thin wrapper over {@link prepareChart} for callers that already
673
+ * have a dataset in hand. Mints a `chartId` synchronously, caches
674
+ * an empty placeholder, and kicks off the chart-planner in the
675
+ * background. Returns just the `chartId`; the host UI resolves
676
+ * `[chart:<chartId>]` markers by hitting the plugin's
677
+ * `/embed/chart/:id` route.
678
+ *
679
+ * For Genie statement results, prefer the Genie agent's
680
+ * `prepare_chart` tool, which accepts a `statement_id` and
681
+ * resolves the rows lazily.
682
+ */
683
+ export function buildRenderDataTool(config: MastraPluginConfig) {
684
+ return createTool({
685
+ id: "render_data",
686
+ description: stringUtils.toDescription([
687
+ `
688
+ Submit a tabular dataset for inline rendering as a chart in
689
+ the user's view. Pass a title, the raw rows (array of objects
690
+ keyed by column name), and an optional one-line description
691
+ of the insight to highlight. Returns a short \`chartId\`;
692
+ the chart renders inline at the position you embed the
693
+ matching \`[chart:<chartId>]\` marker.
694
+ `,
695
+ `
696
+ Placement contract: embed \`[chart:<chartId>]\` on its own
697
+ line (blank lines above and below) wherever you want the
698
+ chart to appear in your reply. The chart resolves
699
+ asynchronously - the tool returns the id immediately and the
700
+ host UI fetches the chart from the cache once the planner
701
+ lands. You can call \`render_data\` multiple times in the
702
+ same turn (the tool is parallel-safe) and interleave the
703
+ markers with prose so each chart sits next to its
704
+ commentary.
705
+ `,
706
+ `
707
+ Use whenever a SQL row set, API response, or hand-built
708
+ dataset would land better as a picture than as a list or
709
+ table. Cap input at a few hundred rows; sample or aggregate
710
+ larger datasets first.
711
+ `,
712
+ ]),
713
+ inputSchema: chartPlannerRequestSchema,
714
+ outputSchema: ChartSchema.pick({ chartId: true }),
715
+ execute: async (input, ctxRaw) => {
716
+ const { title, description, data } = input as ChartPlannerRequest;
717
+ const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
718
+ return prepareChart({
719
+ config,
720
+ title,
721
+ ...(description ? { description } : {}),
722
+ resolveData: () => Promise.resolve({ rows: data }),
723
+ ...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
724
+ });
725
+ },
726
+ });
727
+ }