@dbx-tools/appkit-mastra 0.1.13 → 0.1.14

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.
Files changed (59) hide show
  1. package/README.md +304 -645
  2. package/index.ts +46 -38
  3. package/package.json +58 -45
  4. package/src/agents.ts +224 -66
  5. package/src/chart.ts +531 -429
  6. package/src/config.ts +270 -19
  7. package/src/filesystems.ts +1090 -0
  8. package/src/genie.ts +1000 -660
  9. package/src/history.ts +166 -79
  10. package/src/mcp.ts +105 -0
  11. package/src/memory.ts +94 -92
  12. package/src/mlflow.ts +149 -0
  13. package/src/model.ts +75 -408
  14. package/src/observability.ts +121 -69
  15. package/src/pagination.ts +34 -0
  16. package/src/plugin.ts +552 -67
  17. package/src/processors.ts +168 -0
  18. package/src/rest.ts +67 -0
  19. package/src/server.ts +232 -45
  20. package/src/serving-sanitize.ts +167 -0
  21. package/src/serving.ts +27 -243
  22. package/src/statement.ts +89 -0
  23. package/src/storage-schema.ts +41 -0
  24. package/src/summarize.ts +176 -0
  25. package/src/threads.ts +338 -0
  26. package/src/workspaces.ts +346 -0
  27. package/src/writer.ts +44 -0
  28. package/tsconfig.json +41 -0
  29. package/dist/index.d.ts +0 -20
  30. package/dist/index.js +0 -20
  31. package/dist/src/agents.d.ts +0 -306
  32. package/dist/src/agents.js +0 -403
  33. package/dist/src/chart.d.ts +0 -170
  34. package/dist/src/chart.js +0 -491
  35. package/dist/src/config.d.ts +0 -183
  36. package/dist/src/config.js +0 -12
  37. package/dist/src/genie.d.ts +0 -131
  38. package/dist/src/genie.js +0 -630
  39. package/dist/src/history.d.ts +0 -67
  40. package/dist/src/history.js +0 -172
  41. package/dist/src/memory.d.ts +0 -100
  42. package/dist/src/memory.js +0 -242
  43. package/dist/src/model.d.ts +0 -159
  44. package/dist/src/model.js +0 -427
  45. package/dist/src/observability.d.ts +0 -33
  46. package/dist/src/observability.js +0 -71
  47. package/dist/src/plugin.d.ts +0 -130
  48. package/dist/src/plugin.js +0 -283
  49. package/dist/src/processors/strip-stale-charts.d.ts +0 -29
  50. package/dist/src/processors/strip-stale-charts.js +0 -96
  51. package/dist/src/server.d.ts +0 -46
  52. package/dist/src/server.js +0 -123
  53. package/dist/src/serving.d.ts +0 -156
  54. package/dist/src/serving.js +0 -231
  55. package/dist/src/tools/email.d.ts +0 -74
  56. package/dist/src/tools/email.js +0 -122
  57. package/dist/tsconfig.build.tsbuildinfo +0 -1
  58. package/src/processors/strip-stale-charts.ts +0 -105
  59. package/src/tools/email.ts +0 -147
package/src/chart.ts CHANGED
@@ -1,58 +1,135 @@
1
1
  /**
2
- * Chart-rendering primitives.
2
+ * Chart planner + chart cache.
3
3
  *
4
- * Three surfaces, one shared brain:
4
+ * Self-contained chart subsystem with two layers:
5
5
  *
6
- * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
- * ("here is a dataset, render it as a chart"). The tool's
8
- * `execute` emits a `kind: "chart"` event with the raw rows to
9
- * `ctx.writer` synchronously, kicks off the chart-planner agent,
10
- * and `await`s the planner promise before returning so the
11
- * planner's latency is attributed to this tool's trace span.
12
- * The LLM-bound output is just `{ chartId }`, so its context
13
- * stays flat regardless of dataset size.
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.
14
15
  *
15
- * - {@link emitChartWithPlanning}: the underlying helper that both
16
- * `render_data` and Genie's `drainGenieStream` call. Mints the
17
- * `chartId`, fires the dataset event immediately, runs the
18
- * planner in the background, and returns `{ chartId,
19
- * plannerPromise }` so callers can choose to await for trace
20
- * shape or fire-and-forget.
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.
21
22
  *
22
- * - {@link runChartPlanner}: the chart-planner Agent + ECOption
23
- * expansion as a plain async function. Used internally by
24
- * {@link emitChartWithPlanning}; producers shouldn't reach for
25
- * it directly so chart events keep a single wire-format
26
- * contract.
27
- *
28
- * The model wires the chart into its reply by emitting the marker
29
- * `[[chart:<chartId>]]` on its own line in markdown. The chat
30
- * client splits the assistant text on these markers and drops a
31
- * `<ChartSlot>` in at the position the model placed it; the slot
32
- * shows a skeleton until the second `kind: "chart"` event (with
33
- * the resolved `EChartsOption`) arrives, then swaps in the
34
- * rendered Echarts visualisation.
23
+ * Wire-format schemas live in `@dbx-tools/shared-mastra` so
24
+ * the demo client and any other UI consumer share the exact same
25
+ * shape this module reads and writes.
35
26
  */
36
27
 
37
- import { randomUUID } from "node:crypto";
38
-
39
- import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
28
+ import { CacheManager } from "@databricks/appkit";
29
+ import { wire, type Chart, type ChartResult } from "@dbx-tools/shared-mastra";
40
30
  import { Agent } from "@mastra/core/agent";
41
31
  import type { RequestContext } from "@mastra/core/request-context";
42
32
  import { createTool } from "@mastra/core/tools";
43
33
  import { z } from "zod";
44
34
 
45
- import type { MastraPluginConfig } from "./config.js";
46
- import { ModelTier, modelForTier, buildModel } from "./model.js";
35
+ import type { MastraPluginConfig } from "./config";
36
+ import { buildModel } from "./model";
37
+ import { model } from "@dbx-tools/shared-model";
38
+ import { async, error, hash, log, string } from "@dbx-tools/shared-core";
39
+
40
+ const logger = log.logger("mastra/chart");
41
+
42
+ /* ------------------------------ constants ------------------------------ */
47
43
 
48
44
  /**
49
- * Module-level logger tagged `[mastra/chart]`. Uses the shared
50
- * {@link logUtils.logger} so calls below `LOG_LEVEL` are
51
- * discarded for free. Default `LOG_LEVEL` is `info`; flip to
52
- * `debug` to see the per-chart timeline (`emit:start`
53
- * `write:ok(data)` `planner:done` `write:ok(option)`).
45
+ * TTL for cached chart entries. One hour balances "long enough for
46
+ * the host UI to fetch the chart well after the model finished
47
+ * talking" against "short enough that abandoned chart ids don't
48
+ * pin storage". Matches the typical Databricks OBO token lifetime
49
+ * so any data re-resolution stays inside the original auth window.
54
50
  */
55
- const log = logUtils.logger("mastra/chart");
51
+ const CHART_CACHE_TTL_SEC = 60 * 60;
52
+
53
+ /** Cache namespace; keeps the chart keyspace tidy. */
54
+ const CHART_CACHE_NAMESPACE = "mastra:chart";
55
+
56
+ /**
57
+ * `userKey` for `CacheManager.generateKey`. Chart ids are minted
58
+ * via `hash.id()` (v4 UUID) and are unguessable, so a
59
+ * constant user key is fine. The HTTP route can re-scope to the
60
+ * requesting user when policy demands it.
61
+ */
62
+ const CHART_CACHE_USER_KEY = "mastra-chart";
63
+
64
+ /** Default server-side long-poll budget for {@link fetchChart}. */
65
+ const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
66
+
67
+ /** Default inter-poll sleep for {@link fetchChart}. */
68
+ const DEFAULT_FETCH_INTERVAL_MS = 250;
69
+
70
+ /* ------------------------------- schemas ------------------------------- */
71
+
72
+ /**
73
+ * One series data point. Wide variant set so the planner agent can
74
+ * faithfully pass through whatever the SQL row set contained
75
+ * (numbers, stringified numbers, nulls for missing measurements,
76
+ * `[x, y]` tuples for scatter, `{name, value}` slices for pie)
77
+ * without the structured-output guard rejecting the whole plan.
78
+ *
79
+ * Three layers of tolerance:
80
+ *
81
+ * 1. {@link z.preprocess} normalizes wire shapes BEFORE union
82
+ * dispatch: stringified numbers parse to numbers, finite
83
+ * checks reject `NaN` / `Infinity`, 2-element arrays coerce
84
+ * tuple components, and `{value}` objects with missing /
85
+ * stringified `value` get coerced or rejected uniformly.
86
+ * Anything not handleable becomes `null`.
87
+ * 2. The union accepts `null` as a first-class variant. Echarts
88
+ * renders null as a gap on bar / line / area (which is the
89
+ * right visual signal for "missing reading"). Scatter and
90
+ * pie filter nulls in {@link planToEchartsOption} because
91
+ * Echarts crashes on null tuples / slices.
92
+ * 3. {@link z.union#catch} backstops the whole thing: if
93
+ * preprocess somehow produces a shape that still doesn't
94
+ * match any variant, the bad item becomes `null` instead of
95
+ * taking down the entire chart with a
96
+ * `Structured output validation failed` error.
97
+ */
98
+ const chartDataPointSchema = z
99
+ .preprocess(
100
+ (v) => {
101
+ if (v === null || v === undefined) return null;
102
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
103
+ if (typeof v === "string") {
104
+ const n = Number(v);
105
+ return Number.isFinite(n) ? n : null;
106
+ }
107
+ if (Array.isArray(v) && v.length === 2) {
108
+ const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
109
+ const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
110
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
111
+ }
112
+ if (typeof v === "object" && v !== null && "value" in v) {
113
+ const obj = v as { name?: unknown; value: unknown };
114
+ const val = typeof obj.value === "number" ? obj.value : Number(obj.value);
115
+ if (!Number.isFinite(val)) return null;
116
+ // Coerce numeric / boolean / nullish names to strings so a
117
+ // pie slice keyed on a year (`2024`) or category id is
118
+ // accepted without round-tripping through the catch arm.
119
+ const rawName = obj.name;
120
+ const name = typeof rawName === "string" ? rawName : rawName == null ? "" : String(rawName);
121
+ return { name, value: val };
122
+ }
123
+ return null;
124
+ },
125
+ z.union([
126
+ z.number(),
127
+ z.null(),
128
+ z.tuple([z.number(), z.number()]),
129
+ z.object({ name: z.string(), value: z.number() }),
130
+ ]),
131
+ )
132
+ .catch(null);
56
133
 
57
134
  /**
58
135
  * Compact, model-friendly representation of an Echarts spec. The
@@ -65,463 +142,415 @@ const log = logUtils.logger("mastra/chart");
65
142
  * across charts.
66
143
  */
67
144
  const chartPlanSchema = z.object({
68
- chartType: z
69
- .enum(["bar", "line", "area", "scatter", "pie"])
70
- .describe(stringUtils.toDescription`
71
- The chart shape that best matches the data and intent. Use
72
- \`bar\` for category-vs-value comparisons, \`line\` for
73
- trends over an ordered axis, \`area\` for stacked-trend
74
- emphasis, \`scatter\` for two-numeric-axis correlations,
75
- \`pie\` for parts-of-a-whole when categories are few.
76
- `),
77
- title: z.string().optional().describe(stringUtils.toDescription`
78
- Short title shown above the chart. Optional; defaults to the
79
- \`title\` argument the caller passed in.
80
- `),
81
- xAxisLabel: z.string().optional().describe(stringUtils.toDescription`
82
- Axis label below the chart. Used for bar / line / area /
83
- scatter; ignored for pie.
84
- `),
85
- yAxisLabel: z.string().optional().describe(stringUtils.toDescription`
86
- Axis label to the left of the chart. Used for bar / line /
87
- area / scatter; ignored for pie.
88
- `),
145
+ chartType: wire.ChartTypeSchema,
146
+ title: z
147
+ .string()
148
+ .optional()
149
+ .describe(
150
+ string.toDescription(`
151
+ Short title shown above the chart. Optional; defaults to the
152
+ \`title\` argument the caller passed in.
153
+ `),
154
+ ),
155
+ xAxisLabel: z
156
+ .string()
157
+ .optional()
158
+ .describe(
159
+ string.toDescription(`
160
+ Axis label below the chart. Used for bar / line / area / scatter;
161
+ ignored for pie.
162
+ `),
163
+ ),
164
+ yAxisLabel: z
165
+ .string()
166
+ .optional()
167
+ .describe(
168
+ string.toDescription(`
169
+ Axis label to the left of the chart. Used for bar / line / area /
170
+ scatter; ignored for pie.
171
+ `),
172
+ ),
89
173
  categories: z
90
174
  .array(z.string())
91
175
  .optional()
92
- .describe(stringUtils.toDescription`
93
- X-axis category labels for \`bar\` / \`line\` / \`area\`
94
- charts (one per data point in each series). Omit for
95
- \`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
96
- carries its own \`name\`).
97
- `),
176
+ .describe(
177
+ string.toDescription(`
178
+ X-axis category labels for \`bar\` / \`line\` / \`area\` charts
179
+ (one per data point in each series). Omit for \`scatter\` (uses
180
+ [x, y] tuples) and \`pie\` (each slice carries its own \`name\`).
181
+ `),
182
+ ),
98
183
  series: z
99
184
  .array(
100
185
  z.object({
101
- name: z.string().describe(stringUtils.toDescription`
102
- Legend name for this series.
103
- `),
104
- data: z
105
- .array(
106
- z.union([
107
- z.number(),
108
- z.tuple([z.number(), z.number()]),
109
- z.object({
110
- name: z.string(),
111
- value: z.number(),
112
- }),
113
- ]),
114
- )
115
- .describe(stringUtils.toDescription`
116
- Data points. For \`bar\` / \`line\` / \`area\`, an
117
- array of numbers aligned to \`categories\`. For
118
- \`scatter\`, an array of \`[x, y]\` numeric tuples.
119
- For \`pie\`, an array of \`{name, value}\` objects.
186
+ name: z.string().describe(
187
+ string.toDescription(`
188
+ Legend name for this series.
120
189
  `),
190
+ ),
191
+ data: z.array(chartDataPointSchema).describe(
192
+ string.toDescription(`
193
+ Data points. For \`bar\` / \`line\` / \`area\`, an array of
194
+ numbers aligned to \`categories\`. For \`scatter\`, an array
195
+ of \`[x, y]\` numeric tuples. For \`pie\`, an array of
196
+ \`{name, value}\` objects.
197
+ `),
198
+ ),
121
199
  }),
122
200
  )
123
201
  .min(1)
124
- .describe(stringUtils.toDescription`
125
- One or more series to plot. Pie charts use exactly one
126
- series; bar/line/area can stack multiple series sharing
127
- the same \`categories\` axis.
128
- `),
202
+ .describe(
203
+ string.toDescription(`
204
+ One or more series to plot. Pie charts use exactly one series;
205
+ bar/line/area can stack multiple series sharing the same
206
+ \`categories\` axis.
207
+ `),
208
+ ),
129
209
  });
130
210
 
131
211
  type ChartPlan = z.infer<typeof chartPlanSchema>;
132
212
 
213
+ /**
214
+ * Canonical planner input shape. Tools that source rows from an
215
+ * inline dataset (`render_data`) use it as their `inputSchema`
216
+ * verbatim; tools that resolve rows from a remote (`prepare_chart`
217
+ * over a Genie statement) `omit({ data })` and `extend` with their
218
+ * own identifier field, so the field-level `.describe()` text
219
+ * stays a single source of truth. Server-only - the UI never
220
+ * sees a planner request, only the resolved {@link Chart}.
221
+ */
222
+ export const chartPlannerRequestSchema = z.object({
223
+ title: z.string().describe(
224
+ string.toDescription(`
225
+ Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
226
+ `),
227
+ ),
228
+ description: z
229
+ .string()
230
+ .optional()
231
+ .describe(
232
+ string.toDescription(`
233
+ One-line intent the chart-planner uses when picking a chart type
234
+ and axis encodings (e.g. "compare quarterly revenue across
235
+ regions", "highlight the steep drop after position 5"). Not shown
236
+ to the user.
237
+ `),
238
+ ),
239
+ data: z
240
+ .array(z.record(z.string(), z.unknown()))
241
+ .nonempty("Data must contain at least one row")
242
+ .readonly()
243
+ .describe(
244
+ string.toDescription(`
245
+ Tabular dataset to chart. One object per row, keyed by column
246
+ name. Values may be strings, numbers, booleans, or null. The
247
+ chart-planner decides which columns are categories vs. numeric
248
+ series. Cap at a few hundred rows for legibility; sample /
249
+ aggregate larger datasets first.
250
+ `),
251
+ ),
252
+ });
253
+
254
+ export type ChartPlannerRequest = z.infer<typeof chartPlannerRequestSchema>;
255
+
256
+ /* --------------------------- planner instructions --------------------------- */
257
+
258
+ /**
259
+ * Format {@link wire.ChartTypeSchema}'s variants as a single
260
+ * human-friendly string of `` `<value>` for <description> ``
261
+ * clauses joined by semicolons, drawn from each variant's own
262
+ * `.describe()` so the planner prompt stays in lock-step with
263
+ * the schema by construction.
264
+ */
265
+ function formatChartTypePicker(): string {
266
+ return wire.ChartTypeSchema.options
267
+ .map((opt) => `\`${opt.value}\` for ${opt.description ?? ""}`)
268
+ .join("; ");
269
+ }
270
+
133
271
  /**
134
272
  * System prompt for the inner chart-planning agent. Tuned for a
135
273
  * fast-tier model (Haiku, GPT-5-mini, Gemini Flash Lite).
136
274
  */
137
- const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription`
275
+ const CHART_PLANNER_INSTRUCTIONS = string.toDescription(`
138
276
  You design Apache Echarts visualizations. The user gives you a
139
277
  tabular dataset (rows of objects) plus a title and an optional
140
- description of the intent. You produce a small chart plan
141
- (chart type, axis labels, categories, series) that best
142
- conveys the data.
278
+ description of the intent. You produce a small chart plan (chart
279
+ type, axis labels, categories, series) that best conveys the data.
143
280
 
144
- Decision guide:
145
-
146
- - bar: comparing a numeric value across a small/medium set of
147
- discrete categories (top-N, ranking, group-by).
148
- - line: ordered-axis trend (time series, sequence).
149
- - area: same as line but emphasises magnitude or stacked
150
- composition.
151
- - scatter: two numeric axes, correlation between fields.
152
- - pie: parts of a whole when 2-7 categories sum to a
153
- meaningful total.
281
+ Decision guide. Pick the chart type whose data shape matches the
282
+ dataset and the user's intent: ${formatChartTypePicker()}.
154
283
 
155
284
  When in doubt between bar and line, prefer bar for unordered
156
- categories and line for ordered ones (dates, time buckets,
157
- ranks). Never pick pie for more than 7 slices.
285
+ categories and line for ordered ones (dates, time buckets, ranks).
286
+ Never pick pie for more than 7 slices.
158
287
 
159
- For bar / line / area: pick one column as the category axis
160
- (usually the only string-valued column) and one or more
161
- numeric columns as series. Sort categories by the primary
162
- series value descending unless the data is naturally ordered
163
- (dates, ranks).
288
+ For bar / line / area: pick one column as the category axis (usually
289
+ the only string-valued column) and one or more numeric columns as
290
+ series. Sort categories by the primary series value descending unless
291
+ the data is naturally ordered (dates, ranks).
164
292
 
165
- For pie: pick the category column for slice names and one
166
- numeric column for slice values. Emit a single series.
293
+ For pie: pick the category column for slice names and one numeric
294
+ column for slice values. Emit a single series.
167
295
 
168
- For scatter: pick two numeric columns and emit \`[x, y]\`
169
- tuples in a single series.
296
+ For scatter: pick two numeric columns and emit \`[x, y]\` tuples in a
297
+ single series.
170
298
 
171
- Keep series names human-readable (use the column name; title
172
- case it lightly if needed). Keep titles concise; do not
173
- repeat the user's title in xAxisLabel / yAxisLabel.
174
- `;
299
+ Keep series names human-readable (use the column name; title case it
300
+ lightly if needed). Keep titles concise; do not repeat the user's
301
+ title in xAxisLabel / yAxisLabel.
302
+ `);
175
303
 
176
- /**
177
- * Lazily-constructed inner agent shared across all calls in this
178
- * process. The agent is stateless (no memory, no tools) so a
179
- * single instance per plugin config is safe; model resolution
180
- * still happens per-call against the live `requestContext`, so
181
- * OBO auth stays user-scoped.
182
- */
183
- function createChartPlannerAgent(config: MastraPluginConfig): Agent {
184
- return new Agent({
185
- id: "render_chart_planner",
186
- name: "Chart Planner",
187
- description: "Picks chart type and axis encodings for a dataset.",
188
- instructions: CHART_PLANNER_INSTRUCTIONS,
189
- model: ({ requestContext }) =>
190
- buildModel(config, requestContext, {
191
- modelId: modelForTier(ModelTier.Fast),
192
- }),
193
- });
194
- }
195
-
196
- /** Inputs to {@link runChartPlanner}. */
197
- export interface RunChartPlannerOptions {
198
- config: MastraPluginConfig;
199
- requestContext?: RequestContext;
200
- title: string;
201
- description?: string;
202
- data: ReadonlyArray<Record<string, unknown>>;
203
- }
204
-
205
- /** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
206
- export interface RunChartPlannerResult {
207
- option: Record<string, unknown>;
208
- chartType: ChartPlan["chartType"];
209
- }
304
+ /* ----------------------------- planner agent ----------------------------- */
210
305
 
211
306
  /**
212
- * Module-level cache: one chart-planner agent per plugin config
213
- * instance. Keyed on the config object identity since each plugin
214
- * mount provides its own resolver / fallbacks. Re-used across
215
- * tool invocations and the render-chart HTTP route.
307
+ * One planner `Agent` per plugin config. Cached on config object
308
+ * identity so callers can `prepareChart({ config, ... })` from a
309
+ * hot path without paying the Agent-constructor cost every call.
310
+ * `WeakMap` lets retired configs (e.g. test reconfigurations)
311
+ * release their agent without manual eviction.
216
312
  */
217
- const _plannerByConfig = new WeakMap<MastraPluginConfig, Agent>();
313
+ const plannerAgents = new WeakMap<MastraPluginConfig, Agent>();
314
+
218
315
  function getPlannerAgent(config: MastraPluginConfig): Agent {
219
- let agent = _plannerByConfig.get(config);
316
+ let agent = plannerAgents.get(config);
220
317
  if (!agent) {
221
- agent = createChartPlannerAgent(config);
222
- _plannerByConfig.set(config, agent);
318
+ agent = new Agent({
319
+ id: "chart_planner",
320
+ name: "Chart Planner",
321
+ description: "Picks chart type and axis encodings for a dataset.",
322
+ instructions: CHART_PLANNER_INSTRUCTIONS,
323
+ model: ({ requestContext }) =>
324
+ buildModel(config, requestContext, { modelClass: model.ModelClass.ChatFast }),
325
+ });
326
+ plannerAgents.set(config, agent);
223
327
  }
224
328
  return agent;
225
329
  }
226
330
 
227
331
  /**
228
- * Run the chart planner against the given dataset and return a
229
- * full Echarts `EChartsOption` JSON. Used by
230
- * {@link emitChartWithPlanning}; tools and producers shouldn't
231
- * call this directly (use the helper instead so chart events
232
- * follow the same wire-format contract everywhere).
332
+ * Run the planner against `request` and return the resolved
333
+ * Echarts spec. Throws on planner failure - {@link prepareChart}
334
+ * catches and stashes the error in the cache entry.
233
335
  */
234
- export async function runChartPlanner(
235
- opts: RunChartPlannerOptions,
236
- ): Promise<RunChartPlannerResult> {
237
- const { config, requestContext, title, description, data } = opts;
238
- const planner = getPlannerAgent(config);
239
-
240
- const prompt = [
241
- `Title: ${title}`,
242
- description ? `Intent: ${description}` : null,
243
- "",
244
- "Dataset (JSON, one row per object):",
245
- "```json",
246
- JSON.stringify(data, null, 2),
247
- "```",
248
- ]
249
- .filter((line): line is string => line !== null)
250
- .join("\n");
251
-
252
- const result = await planner.generate(prompt, {
336
+ async function runChartPlanner(
337
+ config: MastraPluginConfig,
338
+ request: ChartPlannerRequest,
339
+ options: { requestContext?: RequestContext; abortSignal?: AbortSignal } = {},
340
+ ): Promise<ChartResult> {
341
+ const { title, description, data } = request;
342
+ const { requestContext, abortSignal } = options;
343
+ const prompt = string.toDescription({
344
+ Title: title,
345
+ ...(description ? { Description: description } : {}),
346
+ "Dataset (JSON, one row per object)": JSON.stringify(data, null, 2),
347
+ });
348
+ const result = await getPlannerAgent(config).generate(prompt, {
253
349
  structuredOutput: { schema: chartPlanSchema },
254
350
  ...(requestContext ? { requestContext } : {}),
351
+ ...(abortSignal ? { abortSignal } : {}),
255
352
  });
256
- const plan = result.object;
353
+ const plan = chartPlanSchema.parse(result.object);
257
354
  const option = planToEchartsOption(plan, title);
258
- return { option, chartType: plan.chartType };
355
+ return { chartType: plan.chartType, option };
356
+ }
357
+
358
+ /* ------------------------------ cache helpers ------------------------------ */
359
+
360
+ /** Build the canonical cache key for a `chartId`. */
361
+ async function chartCacheKey(chartId: string): Promise<string> {
362
+ return (await CacheManager.getInstance()).generateKey(
363
+ [CHART_CACHE_NAMESPACE, chartId],
364
+ CHART_CACHE_USER_KEY,
365
+ );
259
366
  }
260
367
 
261
368
  /**
262
- * Minimal `ToolStream`-shaped writer surface. Defined locally so
263
- * helpers can take any object with a `.write` method without
264
- * importing Mastra's full `ToolStream` (which would also drag in
265
- * agent / tool types this module doesn't otherwise need).
369
+ * Persist a {@link Chart} entry under its `chartId`. Refreshes
370
+ * the TTL on every write. Cache-layer failures are logged and
371
+ * swallowed so background runners never throw into the
372
+ * unhandled-rejection stream.
266
373
  */
267
- interface MinimalWriter {
268
- write: (chunk: unknown) => unknown;
374
+ async function writeChart(entry: Chart): Promise<void> {
375
+ try {
376
+ const key = await chartCacheKey(entry.chartId);
377
+ await CacheManager.getInstanceSync().set(key, entry, {
378
+ ttl: CHART_CACHE_TTL_SEC,
379
+ });
380
+ } catch (err) {
381
+ logger.warn("write-error", {
382
+ chartId: entry.chartId,
383
+ error: error.errorMessage(err),
384
+ });
385
+ }
269
386
  }
270
387
 
271
- /** Inputs to {@link emitChartWithPlanning}. */
272
- export interface EmitChartWithPlanningOptions {
273
- /** Mastra `ctx.writer`; missing or closed writers are tolerated. */
274
- writer?: MinimalWriter;
275
- /** Plugin config; used to resolve the planner's model. */
276
- config: MastraPluginConfig;
277
- /** Per-request context (OBO auth). */
278
- requestContext?: RequestContext;
279
- /** Title shown above the rendered chart. Required. */
280
- title: string;
281
- /** Optional one-line intent biasing the planner. */
282
- description?: string;
283
- /** Tabular dataset to chart (one object per row). */
284
- data: ReadonlyArray<Record<string, unknown>>;
388
+ /**
389
+ * Look up a chart by id. Returns `undefined` on miss, on
390
+ * expiry, or when the cache layer is unhealthy - never throws.
391
+ */
392
+ async function readChart(chartId: string): Promise<Chart | undefined> {
393
+ try {
394
+ const key = await chartCacheKey(chartId);
395
+ const v = await CacheManager.getInstanceSync().get<Chart>(key);
396
+ return v ?? undefined;
397
+ } catch (err) {
398
+ logger.warn("read-error", {
399
+ chartId,
400
+ error: error.errorMessage(err),
401
+ });
402
+ return undefined;
403
+ }
285
404
  }
286
405
 
287
- /** Output of {@link emitChartWithPlanning}. */
288
- export interface EmitChartWithPlanningResult {
289
- /** Short id matching the marker `[[chart:<chartId>]]`. */
290
- chartId: string;
406
+ /* --------------------------- prepareChart orchestrator --------------------------- */
407
+
408
+ /** Inputs to {@link prepareChart}. */
409
+ export interface PrepareChartOptions {
410
+ /** Plugin config; resolves the planner agent's model. */
411
+ config: MastraPluginConfig;
412
+ /** Display title forwarded to the planner agent. */
413
+ title?: string;
414
+ /** Optional intent hint forwarded to the planner agent. */
415
+ description?: string;
416
+ /**
417
+ * Resolves the rows to chart. Called once, in the background.
418
+ * Any thrown error lands in the cache as the entry's `error`
419
+ * field (never propagated to the caller of {@link prepareChart}).
420
+ * An empty `rows` array is rejected as `"dataset has no rows;
421
+ * nothing to chart"`.
422
+ */
423
+ resolveData: (signal?: AbortSignal) => Promise<{ rows: ReadonlyArray<Record<string, unknown>> }>;
291
424
  /**
292
- * Promise that resolves once the planner has finished and the
293
- * `kind: "chart"` event with the option has been emitted (or
294
- * once the planner has failed silently). Callers that want
295
- * trace observability should `await` this before returning
296
- * from their tool's `execute`; callers that want pure
297
- * fire-and-forget can ignore it.
425
+ * Per-request `RequestContext`. Forwarded to the planner agent so
426
+ * user-scoped model resolution (OBO) stays in effect.
298
427
  */
299
- plannerPromise: Promise<void>;
428
+ requestContext?: RequestContext;
429
+ /**
430
+ * Cooperative cancellation. Forwarded to `resolveData` and the
431
+ * planner agent. Note: the chart task continues running in the
432
+ * background after the parent request ends, so external abort
433
+ * signals are best-effort; typical use is to leave this unset
434
+ * and let the 1h TTL cap stale entries.
435
+ */
436
+ signal?: AbortSignal;
300
437
  }
301
438
 
302
439
  /**
303
- * Shared chart-emission primitive used by both the `render_data`
304
- * tool and Genie's `drainGenieStream`. Keeps both producers on
305
- * one wire-format contract so the chat client only ever has to
306
- * understand a single chart event shape.
307
- *
308
- * Behaviour:
440
+ * Mint a `chartId`, cache an empty `{ chartId }` placeholder
441
+ * synchronously, and kick off a background task that resolves the
442
+ * dataset and runs the planner. Returns the `chartId` once the
443
+ * placeholder lands so the first {@link fetchChart} call always
444
+ * sees an entry (no spurious 404 race).
309
445
  *
310
- * 1. Generates a short `chartId` (8 hex chars).
311
- * 2. Immediately emits `{ kind: "chart", chartId, title,
312
- * description?, data }` via the writer so the chat client can
313
- * mount its `<ChartSlot>` with the rows in hand.
314
- * 3. Kicks off the chart-planner agent in the background. On
315
- * success, emits a second `{ kind: "chart", chartId, option }`
316
- * event - same `chartId`, just the spec - so the client merges
317
- * the two into one rendered chart. On failure, no follow-up
318
- * event fires; the client falls back to whatever it can do
319
- * with the dataset alone (typically a "render failed" frame
320
- * after the parent tool finishes).
446
+ * The background task swallows its own failures and writes them
447
+ * as `error` entries, so callers never see a rejected promise.
448
+ * Cache state machine:
321
449
  *
322
- * Returns `chartId` synchronously so the caller can include it in
323
- * the tool result (model uses it in `[[chart:<chartId>]]`
324
- * markers), and `plannerPromise` so the caller can choose
325
- * trace-spanning vs. snappy-return semantics.
450
+ * - just after this call returns: `{ chartId }` (processing)
451
+ * - on planner success: `{ chartId, result }`
452
+ * - on data / planner failure: `{ chartId, error }`
326
453
  */
327
- export async function emitChartWithPlanning(
328
- opts: EmitChartWithPlanningOptions,
329
- ): Promise<EmitChartWithPlanningResult> {
330
- const { writer, config, requestContext, title, description, data } = opts;
331
-
332
- // Short, marker-friendly id. The LLM types this verbatim into
333
- // `[[chart:<id>]]`; an 8-hex-char prefix is unique within a
334
- // single assistant turn (collision odds ~1 in 4 billion) and
335
- // much less error-prone for the model to reproduce.
336
- const chartId = randomUUID().replace(/-/g, "").slice(0, 8);
337
-
338
- log.debug("emit:start", {
339
- chartId,
340
- title,
341
- rows: data.length,
342
- columns: data[0] ? Object.keys(data[0]) : [],
343
- hasWriter: writer !== undefined,
344
- });
345
-
346
- // Initial event: rows + metadata, no option yet. The client
347
- // mounts a chart slot that shows a skeleton until the option
348
- // event arrives (or until the parent tool finishes without
349
- // one, in which case it falls back).
350
- await safeWrite(writer, chartId, "data", {
351
- kind: "chart",
352
- chartId,
353
- title,
354
- ...(description ? { description } : {}),
355
- data,
356
- });
357
-
358
- // Background planner. Awaitable for trace observability via the
359
- // returned `plannerPromise`; safe to ignore for pure
360
- // fire-and-forget. Failures are intentionally swallowed (only
361
- // logged): the dataset event already landed, so the client has
362
- // enough to surface a fallback.
363
- const plannerPromise = (async () => {
364
- const startedAt = Date.now();
365
- try {
366
- const { option, chartType } = await runChartPlanner({
367
- config,
368
- ...(requestContext ? { requestContext } : {}),
369
- title,
370
- ...(description ? { description } : {}),
371
- data,
372
- });
373
- log.debug("planner:done", {
374
- chartId,
375
- chartType,
376
- elapsedMs: Date.now() - startedAt,
377
- });
378
- await safeWrite(writer, chartId, "option", { kind: "chart", chartId, option });
379
- } catch (err) {
380
- // No follow-up event on failure. The client treats a
381
- // dataset-only chart slot as "render failed" once the
382
- // parent tool's status flips to done. Surface as a `warn`
383
- // so the failure is visible at the default log level
384
- // without being mistaken for a fatal error.
385
- log.warn("planner:error", {
386
- chartId,
387
- elapsedMs: Date.now() - startedAt,
388
- error: err instanceof Error ? err.message : String(err),
389
- });
390
- }
391
- })();
392
-
393
- return { chartId, plannerPromise };
454
+ export async function prepareChart(opts: PrepareChartOptions): Promise<{ chartId: string }> {
455
+ const chartId = hash.id();
456
+ await writeChart({ chartId });
457
+ logger.debug("queued", { chartId });
458
+ // Fire-and-forget. Failures land in the cache as `error` entries;
459
+ // never escape into an unhandled rejection.
460
+ void runPrepareChart(chartId, opts);
461
+ return { chartId };
394
462
  }
395
463
 
396
- /**
397
- * Best-effort writer.write. Failures are logged at `warn` (a
398
- * persistently-closed writer is the most likely culprit when
399
- * chart events go missing client-side) but swallowed so a closed
400
- * downstream stream (cancelled request, client navigated away)
401
- * can't take a tool down.
402
- */
403
- async function safeWrite(
404
- writer: MinimalWriter | undefined,
405
- chartId: string,
406
- phase: "data" | "option",
407
- chunk: unknown,
408
- ): Promise<void> {
409
- if (!writer) {
410
- log.debug("write:no-writer", { chartId, phase });
411
- return;
412
- }
464
+ async function runPrepareChart(chartId: string, opts: PrepareChartOptions): Promise<void> {
465
+ const startedAt = Date.now();
413
466
  try {
414
- await writer.write(chunk);
415
- log.debug("write:ok", { chartId, phase });
416
- } catch (err) {
417
- log.warn("write:error", {
467
+ const data = await opts.resolveData(opts.signal);
468
+ if (data.rows.length === 0) {
469
+ throw new Error("dataset has no rows; nothing to chart");
470
+ }
471
+ const result = await runChartPlanner(
472
+ opts.config,
473
+ {
474
+ title: opts.title ?? "Chart",
475
+ ...(opts.description ? { description: opts.description } : {}),
476
+ data: data.rows as ChartPlannerRequest["data"],
477
+ },
478
+ {
479
+ ...(opts.requestContext ? { requestContext: opts.requestContext } : {}),
480
+ ...(opts.signal ? { abortSignal: opts.signal } : {}),
481
+ },
482
+ );
483
+ await writeChart({ chartId, result });
484
+ logger.info("done", {
418
485
  chartId,
419
- phase,
420
- error: err instanceof Error ? err.message : String(err),
486
+ chartType: result.chartType,
487
+ elapsedMs: Date.now() - startedAt,
421
488
  });
489
+ } catch (err) {
490
+ const errText = error.errorMessage(err);
491
+ logger.warn("error", { chartId, error: errText });
492
+ await writeChart({ chartId, error: errText });
422
493
  }
423
494
  }
424
495
 
425
- const renderDataInputSchema = z.object({
426
- title: z.string().describe(stringUtils.toDescription`
427
- Title shown above the rendered chart. Use a concise
428
- sentence-case label (e.g. "Top 10 SKUs by On-Hand Units").
429
- `),
430
- description: z.string().optional().describe(stringUtils.toDescription`
431
- Optional one-line intent describing what insight the chart
432
- should convey (e.g. "highlight the steep drop-off after
433
- position 5", "compare quarterly revenue across regions").
434
- The chart-planner reads this when picking the chart type and
435
- axis encodings; the user does not see it directly.
436
- `),
437
- data: z
438
- .array(z.record(z.string(), z.unknown()))
439
- .min(1)
440
- .describe(stringUtils.toDescription`
441
- Tabular dataset to chart. One object per row, keyed by
442
- column name. Values may be strings, numbers, booleans, or
443
- null. The chart-planner decides which columns are
444
- categories vs. numeric series. Cap at a few hundred rows
445
- for legibility; sample / aggregate larger datasets first.
446
- `),
447
- });
496
+ /* ------------------------------- long-poll fetch ------------------------------- */
448
497
 
449
- const renderDataOutputSchema = z.object({
450
- chartId: z.string().describe(stringUtils.toDescription`
451
- Identifier of the queued chart. To position the chart in
452
- your reply, embed the marker \`[[chart:<chartId>]]\` on its
453
- own line where the chart should appear; the client renders
454
- it inline.
455
- `),
456
- });
498
+ /** Inputs to {@link fetchChart}. */
499
+ export interface FetchChartOptions {
500
+ /**
501
+ * Server-side polling budget in ms. When the entry stays in
502
+ * the processing state past this window, the helper returns the
503
+ * last seen value (still processing) so the client can re-poll.
504
+ * Defaults to {@link DEFAULT_FETCH_TIMEOUT_MS} (60s).
505
+ */
506
+ timeoutMs?: number;
507
+ /**
508
+ * Poll interval in ms. Defaults to
509
+ * {@link DEFAULT_FETCH_INTERVAL_MS} (250ms).
510
+ */
511
+ intervalMs?: number;
512
+ /** External cancellation handle (e.g. request `req.signal`). */
513
+ signal?: AbortSignal;
514
+ }
457
515
 
458
516
  /**
459
- * Build the `render_data` tool bound to the given plugin config.
517
+ * Long-poll the chart cache until the entry settles (`result` or
518
+ * `error` set), the entry is missing, or the server-side timeout
519
+ * elapses.
520
+ *
521
+ * Returns:
522
+ * - the resolved {@link Chart} when it settled, errored, or
523
+ * stayed in processing past `timeoutMs` (so the client can
524
+ * re-poll);
525
+ * - `undefined` when the entry is missing or expired (the
526
+ * consumer should treat as 404).
460
527
  *
461
- * The tool is a thin wrapper around {@link emitChartWithPlanning}:
462
- * a single `kind: "chart"` writer event ships the raw rows to
463
- * the client immediately, the chart-planner agent runs alongside
464
- * (so the calling LLM stays unblocked while the planner thinks),
465
- * and a follow-up `kind: "chart"` event with the resolved
466
- * `EChartsOption` lands when it's ready. The tool's `execute`
467
- * awaits the planner promise so the planner work shows up under
468
- * the tool's trace span; the LLM still gets back just
469
- * `{ chartId }`, so its context stays small regardless of dataset
470
- * size.
528
+ * `signal` lets the caller cancel ahead of timeout (e.g. the HTTP
529
+ * request closed). Cancellation propagates to the inter-poll sleep
530
+ * so the helper returns immediately.
471
531
  */
472
- export function buildRenderDataTool(config: MastraPluginConfig) {
473
- return createTool({
474
- id: "render_data",
475
- description: stringUtils.toDescription`
476
- Submit a tabular dataset for inline rendering as a chart in
477
- the user's view. Pass a title, the raw rows (array of
478
- objects keyed by column name), and an optional one-line
479
- description of the insight to highlight. Returns a short
480
- \`chartId\`; the chart renders inline at the position you
481
- embed the matching \`[[chart:<chartId>]]\` marker.
482
-
483
- Placement contract: embed \`[[chart:<chartId>]]\` on its own
484
- line (blank lines above and below) wherever you want the
485
- chart to appear in your reply. The client shows a skeleton
486
- at that spot until the chart is ready, then swaps in the
487
- rendered Echarts visualization. You can call
488
- \`render_data\` multiple times in the same turn (the tool
489
- is parallel-safe) and interleave the markers with prose so
490
- each chart sits next to its commentary. A chart whose
491
- marker is omitted falls through to the end of your reply
492
- as a fallback - safe but less polished.
493
-
494
- Use whenever a SQL row set, API response, or hand-built
495
- dataset would land better as a picture than as a list or
496
- table. Cap input at a few hundred rows; sample or
497
- aggregate larger datasets first.
498
- `,
499
- inputSchema: renderDataInputSchema,
500
- outputSchema: renderDataOutputSchema,
501
- execute: async (input, ctx) => {
502
- const { title, description, data } = input as z.infer<
503
- typeof renderDataInputSchema
504
- >;
505
- const writer = (ctx as { writer?: MinimalWriter } | undefined)?.writer;
506
- const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
507
- ?.requestContext;
508
- const { chartId, plannerPromise } = await emitChartWithPlanning({
509
- ...(writer ? { writer } : {}),
510
- config,
511
- ...(requestContext ? { requestContext } : {}),
512
- title,
513
- ...(description ? { description } : {}),
514
- data,
515
- });
516
- // Await the planner so its latency is attributed to this
517
- // tool's trace span. The promise itself swallows planner
518
- // failures, so this never throws.
519
- await plannerPromise;
520
- return { chartId };
521
- },
522
- });
532
+ export async function fetchChart(
533
+ chartId: string,
534
+ options: FetchChartOptions = {},
535
+ ): Promise<Chart | undefined> {
536
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
537
+ const intervalMs = options.intervalMs ?? DEFAULT_FETCH_INTERVAL_MS;
538
+ const deadline = Date.now() + timeoutMs;
539
+
540
+ let last: Chart | undefined;
541
+ while (true) {
542
+ options.signal?.throwIfAborted();
543
+ last = await readChart(chartId);
544
+ if (!last) return undefined;
545
+ if (last.result !== undefined || last.error !== undefined) return last;
546
+ const remaining = deadline - Date.now();
547
+ if (remaining <= 0) return last;
548
+ await async.sleep(Math.min(intervalMs, remaining), options.signal);
549
+ }
523
550
  }
524
551
 
552
+ /* ----------------------------- echarts expansion ----------------------------- */
553
+
525
554
  /**
526
555
  * Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
527
556
  * JSON. Centralized here so the planner agent only fills in the
@@ -529,14 +558,19 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
529
558
  * stay consistent across charts and are easy to tune without
530
559
  * retraining model behaviour.
531
560
  */
532
- function planToEchartsOption(
533
- plan: ChartPlan,
534
- fallbackTitle: string,
535
- ): Record<string, unknown> {
561
+ function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<string, unknown> {
536
562
  const baseTitle = plan.title ?? fallbackTitle;
537
563
  const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
538
564
 
539
565
  if (plan.chartType === "pie") {
566
+ // Echarts crashes on null pie slices - filter them out.
567
+ // `{name, value}` slices are the only valid pie data shape,
568
+ // so drop bare numbers / tuples / nulls the planner may
569
+ // have leaked into a pie series.
570
+ const slices = (plan.series[0]?.data ?? []).filter(
571
+ (d): d is { name: string; value: number } =>
572
+ d !== null && typeof d === "object" && !Array.isArray(d),
573
+ );
540
574
  return {
541
575
  title: { text: baseTitle, left: "center" },
542
576
  tooltip: { trigger: "item" },
@@ -546,13 +580,16 @@ function planToEchartsOption(
546
580
  name: plan.series[0]?.name ?? baseTitle,
547
581
  type: "pie",
548
582
  radius: ["35%", "65%"],
549
- data: plan.series[0]?.data ?? [],
583
+ data: slices,
550
584
  },
551
585
  ],
552
586
  };
553
587
  }
554
588
 
555
589
  if (plan.chartType === "scatter") {
590
+ // Echarts crashes on null scatter points - keep only valid
591
+ // `[x, y]` tuples. Bare numbers / objects / nulls from a
592
+ // mismatched plan get dropped silently.
556
593
  return {
557
594
  title: { text: baseTitle, left: "center" },
558
595
  tooltip: { trigger: "item" },
@@ -563,7 +600,7 @@ function planToEchartsOption(
563
600
  series: plan.series.map((s) => ({
564
601
  name: s.name,
565
602
  type: "scatter",
566
- data: s.data,
603
+ data: s.data.filter((d): d is [number, number] => Array.isArray(d) && d.length === 2),
567
604
  })),
568
605
  };
569
606
  }
@@ -591,3 +628,68 @@ function planToEchartsOption(
591
628
  })),
592
629
  };
593
630
  }
631
+
632
+ /* ----------------------------- render_data tool ----------------------------- */
633
+
634
+ /**
635
+ * Build the `render_data` Mastra tool bound to the given plugin
636
+ * config. Auto-wired as a system tool on every agent (see
637
+ * `agents.ts`); per-agent tools can shadow it by registering a
638
+ * same-named entry.
639
+ *
640
+ * Thin wrapper over {@link prepareChart} for callers that already
641
+ * have a dataset in hand. Mints a `chartId` synchronously, caches
642
+ * an empty placeholder, and kicks off the chart-planner in the
643
+ * background. Returns just the `chartId`; the host UI resolves
644
+ * `[chart:<chartId>]` markers by hitting the plugin's
645
+ * `/embed/chart/:id` route.
646
+ *
647
+ * For Genie statement results, prefer the Genie agent's
648
+ * `prepare_chart` tool, which accepts a `statement_id` and
649
+ * resolves the rows lazily.
650
+ */
651
+ export function buildRenderDataTool(config: MastraPluginConfig) {
652
+ return createTool({
653
+ id: "render_data",
654
+ description: string.toDescription([
655
+ `
656
+ Submit a tabular dataset for inline rendering as a chart in
657
+ the user's view. Pass a title, the raw rows (array of objects
658
+ keyed by column name), and an optional one-line description
659
+ of the insight to highlight. Returns a short \`chartId\`;
660
+ the chart renders inline at the position you embed the
661
+ matching \`[chart:<chartId>]\` marker.
662
+ `,
663
+ `
664
+ Placement contract: embed \`[chart:<chartId>]\` on its own
665
+ line (blank lines above and below) wherever you want the
666
+ chart to appear in your reply. The chart resolves
667
+ asynchronously - the tool returns the id immediately and the
668
+ host UI fetches the chart from the cache once the planner
669
+ lands. You can call \`render_data\` multiple times in the
670
+ same turn (the tool is parallel-safe) and interleave the
671
+ markers with prose so each chart sits next to its
672
+ commentary.
673
+ `,
674
+ `
675
+ Use whenever a SQL row set, API response, or hand-built
676
+ dataset would land better as a picture than as a list or
677
+ table. Cap input at a few hundred rows; sample or aggregate
678
+ larger datasets first.
679
+ `,
680
+ ]),
681
+ inputSchema: chartPlannerRequestSchema,
682
+ outputSchema: wire.ChartSchema.pick({ chartId: true }),
683
+ execute: async (input, ctxRaw) => {
684
+ const { title, description, data } = input as ChartPlannerRequest;
685
+ const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
686
+ return prepareChart({
687
+ config,
688
+ title,
689
+ ...(description ? { description } : {}),
690
+ resolveData: () => Promise.resolve({ rows: data }),
691
+ ...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
692
+ });
693
+ },
694
+ });
695
+ }