@dbx-tools/appkit-mastra 0.1.13 → 0.1.18

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,42 +1,34 @@
1
1
  /**
2
2
  * Chart-rendering primitives.
3
3
  *
4
- * Three 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'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.
14
- *
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.
4
+ * Two surfaces, one shared brain:
21
5
  *
22
6
  * - {@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.
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.
12
+ *
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.
27
20
  *
28
21
  * The model wires the chart into its reply by emitting the marker
29
22
  * `[[chart:<chartId>]]` on its own line in markdown. The chat
30
23
  * 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.
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.
35
28
  */
36
29
 
37
- import { randomUUID } from "node:crypto";
38
-
39
- import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
30
+ import type { MinimalWriter } from "@dbx-tools/appkit-mastra-shared";
31
+ import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
40
32
  import { Agent } from "@mastra/core/agent";
41
33
  import type { RequestContext } from "@mastra/core/request-context";
42
34
  import { createTool } from "@mastra/core/tools";
@@ -54,6 +46,79 @@ import { ModelTier, modelForTier, buildModel } from "./model.js";
54
46
  */
55
47
  const log = logUtils.logger("mastra/chart");
56
48
 
49
+ /**
50
+ * One series data point. Wide variant set so the planner agent can
51
+ * faithfully pass through whatever the SQL row set contained
52
+ * (numbers, stringified numbers, nulls for missing measurements,
53
+ * `[x, y]` tuples for scatter, `{name, value}` slices for pie)
54
+ * without the structured-output guard rejecting the whole plan.
55
+ *
56
+ * Three layers of tolerance:
57
+ *
58
+ * 1. {@link z.preprocess} normalizes wire shapes BEFORE union
59
+ * dispatch: stringified numbers parse to numbers, finite
60
+ * checks reject `NaN` / `Infinity`, 2-element arrays coerce
61
+ * tuple components, and `{value}` objects with missing /
62
+ * stringified `value` get coerced or rejected uniformly.
63
+ * Anything not handleable becomes `null`.
64
+ * 2. The union accepts `null` as a first-class variant. Echarts
65
+ * renders null as a gap on bar / line / area (which is the
66
+ * right visual signal for "missing reading"). Scatter and
67
+ * pie filter nulls in {@link planToEchartsOption} because
68
+ * Echarts crashes on null tuples / slices.
69
+ * 3. {@link z.union#catch} backstops the whole thing: if
70
+ * preprocess somehow produces a shape that still doesn't
71
+ * match any variant, the bad item becomes `null` instead of
72
+ * taking down the entire chart with a
73
+ * `Structured output validation failed` error.
74
+ *
75
+ * Net effect: a 200-row dataset with a few sparse/null/string
76
+ * values still produces a chart; only a totally-malformed planner
77
+ * response (no items at all) falls through to the table fallback.
78
+ */
79
+ const chartDataPointSchema = z
80
+ .preprocess(
81
+ (v) => {
82
+ if (v === null || v === undefined) return null;
83
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
84
+ if (typeof v === "string") {
85
+ const n = Number(v);
86
+ return Number.isFinite(n) ? n : null;
87
+ }
88
+ if (Array.isArray(v) && v.length === 2) {
89
+ const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
90
+ const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
91
+ return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
92
+ }
93
+ if (typeof v === "object" && v !== null && "value" in v) {
94
+ const obj = v as { name?: unknown; value: unknown };
95
+ const val = typeof obj.value === "number" ? obj.value : Number(obj.value);
96
+ if (!Number.isFinite(val)) return null;
97
+ // Coerce numeric / boolean / nullish names to strings so a
98
+ // pie slice keyed on a year (`2024`) or category id is
99
+ // accepted without round-tripping through the catch arm.
100
+ const rawName = obj.name;
101
+ const name =
102
+ typeof rawName === "string"
103
+ ? rawName
104
+ : rawName == null
105
+ ? ""
106
+ : String(rawName);
107
+ return { name, value: val };
108
+ }
109
+ return null;
110
+ },
111
+ z.union([
112
+ z.number(),
113
+ z.null(),
114
+ z.tuple([z.number(), z.number()]),
115
+ z.object({ name: z.string(), value: z.number() }),
116
+ ]),
117
+ )
118
+ .catch(null);
119
+
120
+ type ChartDataPoint = z.infer<typeof chartDataPointSchema>;
121
+
57
122
  /**
58
123
  * Compact, model-friendly representation of an Echarts spec. The
59
124
  * planner agent emits this; {@link planToEchartsOption} expands it
@@ -65,8 +130,7 @@ const log = logUtils.logger("mastra/chart");
65
130
  * across charts.
66
131
  */
67
132
  const chartPlanSchema = z.object({
68
- chartType: z
69
- .enum(["bar", "line", "area", "scatter", "pie"])
133
+ chartType: z.enum(["bar", "line", "area", "scatter", "pie"])
70
134
  .describe(stringUtils.toDescription`
71
135
  The chart shape that best matches the data and intent. Use
72
136
  \`bar\` for category-vs-value comparisons, \`line\` for
@@ -86,10 +150,7 @@ const chartPlanSchema = z.object({
86
150
  Axis label to the left of the chart. Used for bar / line /
87
151
  area / scatter; ignored for pie.
88
152
  `),
89
- categories: z
90
- .array(z.string())
91
- .optional()
92
- .describe(stringUtils.toDescription`
153
+ categories: z.array(z.string()).optional().describe(stringUtils.toDescription`
93
154
  X-axis category labels for \`bar\` / \`line\` / \`area\`
94
155
  charts (one per data point in each series). Omit for
95
156
  \`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
@@ -101,18 +162,7 @@ const chartPlanSchema = z.object({
101
162
  name: z.string().describe(stringUtils.toDescription`
102
163
  Legend name for this series.
103
164
  `),
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`
165
+ data: z.array(chartDataPointSchema).describe(stringUtils.toDescription`
116
166
  Data points. For \`bar\` / \`line\` / \`area\`, an
117
167
  array of numbers aligned to \`categories\`. For
118
168
  \`scatter\`, an array of \`[x, y]\` numeric tuples.
@@ -120,8 +170,7 @@ const chartPlanSchema = z.object({
120
170
  `),
121
171
  }),
122
172
  )
123
- .min(1)
124
- .describe(stringUtils.toDescription`
173
+ .min(1).describe(stringUtils.toDescription`
125
174
  One or more series to plot. Pie charts use exactly one
126
175
  series; bar/line/area can stack multiple series sharing
127
176
  the same \`categories\` axis.
@@ -200,6 +249,12 @@ export interface RunChartPlannerOptions {
200
249
  title: string;
201
250
  description?: string;
202
251
  data: ReadonlyArray<Record<string, unknown>>;
252
+ /**
253
+ * Cooperative cancellation. Forwarded to the planner agent's
254
+ * `generate({ abortSignal })` call so concurrent renders can be
255
+ * aborted as a group when the parent Genie agent's signal fires.
256
+ */
257
+ signal?: AbortSignal;
203
258
  }
204
259
 
205
260
  /** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
@@ -226,15 +281,16 @@ function getPlannerAgent(config: MastraPluginConfig): Agent {
226
281
 
227
282
  /**
228
283
  * 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).
284
+ * full Echarts `EChartsOption` JSON. Pure async function: no
285
+ * writer side-effects, no id minting, no background work.
286
+ * Producers (the `render_data` tool, the Genie agent,
287
+ * anything else that needs a chart) await this and stitch the
288
+ * result into whatever shape their wire contract needs.
233
289
  */
234
290
  export async function runChartPlanner(
235
291
  opts: RunChartPlannerOptions,
236
292
  ): Promise<RunChartPlannerResult> {
237
- const { config, requestContext, title, description, data } = opts;
293
+ const { config, requestContext, title, description, data, signal } = opts;
238
294
  const planner = getPlannerAgent(config);
239
295
 
240
296
  const prompt = [
@@ -252,176 +308,13 @@ export async function runChartPlanner(
252
308
  const result = await planner.generate(prompt, {
253
309
  structuredOutput: { schema: chartPlanSchema },
254
310
  ...(requestContext ? { requestContext } : {}),
311
+ ...(signal ? { abortSignal: signal } : {}),
255
312
  });
256
- const plan = result.object;
313
+ const plan = result.object as ChartPlan;
257
314
  const option = planToEchartsOption(plan, title);
258
315
  return { option, chartType: plan.chartType };
259
316
  }
260
317
 
261
- /**
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).
266
- */
267
- interface MinimalWriter {
268
- write: (chunk: unknown) => unknown;
269
- }
270
-
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>>;
285
- }
286
-
287
- /** Output of {@link emitChartWithPlanning}. */
288
- export interface EmitChartWithPlanningResult {
289
- /** Short id matching the marker `[[chart:<chartId>]]`. */
290
- chartId: string;
291
- /**
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.
298
- */
299
- plannerPromise: Promise<void>;
300
- }
301
-
302
- /**
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:
309
- *
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).
321
- *
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.
326
- */
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 };
394
- }
395
-
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
- }
413
- try {
414
- await writer.write(chunk);
415
- log.debug("write:ok", { chartId, phase });
416
- } catch (err) {
417
- log.warn("write:error", {
418
- chartId,
419
- phase,
420
- error: err instanceof Error ? err.message : String(err),
421
- });
422
- }
423
- }
424
-
425
318
  const renderDataInputSchema = z.object({
426
319
  title: z.string().describe(stringUtils.toDescription`
427
320
  Title shown above the rendered chart. Use a concise
@@ -434,9 +327,7 @@ const renderDataInputSchema = z.object({
434
327
  The chart-planner reads this when picking the chart type and
435
328
  axis encodings; the user does not see it directly.
436
329
  `),
437
- data: z
438
- .array(z.record(z.string(), z.unknown()))
439
- .min(1)
330
+ data: z.array(z.record(z.string(), z.unknown())).min(1)
440
331
  .describe(stringUtils.toDescription`
441
332
  Tabular dataset to chart. One object per row, keyed by
442
333
  column name. Values may be strings, numbers, booleans, or
@@ -458,16 +349,14 @@ const renderDataOutputSchema = z.object({
458
349
  /**
459
350
  * Build the `render_data` tool bound to the given plugin config.
460
351
  *
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.
352
+ * The tool awaits {@link runChartPlanner} so the planner's
353
+ * latency is attributed to this tool's trace span, then emits
354
+ * one `type: "chart"` writer event carrying the dataset and the
355
+ * resolved `EChartsOption`. The LLM-bound output is just
356
+ * `{ chartId }` so the model's context stays flat regardless of
357
+ * dataset size. Planner failures are caught and surfaced as a
358
+ * `type: "error"` writer event so the slot can fall back to
359
+ * "couldn't render chart" without taking the parent agent down.
471
360
  */
472
361
  export function buildRenderDataTool(config: MastraPluginConfig) {
473
362
  return createTool({
@@ -482,14 +371,14 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
482
371
 
483
372
  Placement contract: embed \`[[chart:<chartId>]]\` on its own
484
373
  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.
374
+ chart to appear in your reply. The chart is fully resolved
375
+ by the time the tool returns, so it renders immediately at
376
+ that spot. You can call \`render_data\` multiple times in
377
+ the same turn (the tool is parallel-safe) and interleave
378
+ the markers with prose so each chart sits next to its
379
+ commentary. A chart whose marker is omitted falls through
380
+ to the end of your reply as a fallback - safe but less
381
+ polished.
493
382
 
494
383
  Use whenever a SQL row set, API response, or hand-built
495
384
  dataset would land better as a picture than as a list or
@@ -505,23 +394,91 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
505
394
  const writer = (ctx as { writer?: MinimalWriter } | undefined)?.writer;
506
395
  const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
507
396
  ?.requestContext;
508
- const { chartId, plannerPromise } = await emitChartWithPlanning({
509
- ...(writer ? { writer } : {}),
510
- config,
511
- ...(requestContext ? { requestContext } : {}),
397
+
398
+ // Marker-friendly short id. The LLM types this verbatim
399
+ // into `[[chart:<id>]]`; 8 hex chars is unique within a
400
+ // single assistant turn and easy for the model to copy.
401
+ const chartId = commonUtils.shortId();
402
+ const startedAt = Date.now();
403
+ log.debug("render:start", {
404
+ chartId,
512
405
  title,
513
- ...(description ? { description } : {}),
514
- data,
406
+ rows: data.length,
407
+ columns: data[0] ? Object.keys(data[0]) : [],
408
+ hasWriter: writer !== undefined,
515
409
  });
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;
410
+
411
+ try {
412
+ const { option, chartType } = await runChartPlanner({
413
+ config,
414
+ ...(requestContext ? { requestContext } : {}),
415
+ title,
416
+ ...(description ? { description } : {}),
417
+ data,
418
+ });
419
+ log.debug("render:done", {
420
+ chartId,
421
+ chartType,
422
+ elapsedMs: Date.now() - startedAt,
423
+ });
424
+ // Single chart event with everything resolved: dataset
425
+ // for the table-like fallback / hover, option for the
426
+ // actual render. Best-effort write so a closed
427
+ // downstream stream can't take the tool down.
428
+ await safeWrite(writer, chartId, {
429
+ type: "chart",
430
+ chartId,
431
+ title,
432
+ ...(description ? { description } : {}),
433
+ data,
434
+ option,
435
+ });
436
+ } catch (err) {
437
+ log.warn("render:error", {
438
+ chartId,
439
+ elapsedMs: Date.now() - startedAt,
440
+ error: err instanceof Error ? err.message : String(err),
441
+ });
442
+ // Surface as a writer-level error so the slot can
443
+ // transition to "couldn't render chart" without the
444
+ // parent agent surfacing a stack trace.
445
+ await safeWrite(writer, chartId, {
446
+ type: "error",
447
+ error: err instanceof Error ? err.message : String(err),
448
+ });
449
+ }
520
450
  return { chartId };
521
451
  },
522
452
  });
523
453
  }
524
454
 
455
+ /**
456
+ * Best-effort writer.write. Failures are logged at `warn` (a
457
+ * persistently-closed writer is the most likely culprit when
458
+ * chart events go missing client-side) but swallowed so a closed
459
+ * downstream stream (cancelled request, client navigated away)
460
+ * can't take a tool down.
461
+ */
462
+ async function safeWrite(
463
+ writer: MinimalWriter | undefined,
464
+ chartId: string,
465
+ chunk: unknown,
466
+ ): Promise<void> {
467
+ if (!writer) {
468
+ log.debug("write:no-writer", { chartId });
469
+ return;
470
+ }
471
+ try {
472
+ await writer.write(chunk);
473
+ log.debug("write:ok", { chartId });
474
+ } catch (err) {
475
+ log.warn("write:error", {
476
+ chartId,
477
+ error: err instanceof Error ? err.message : String(err),
478
+ });
479
+ }
480
+ }
481
+
525
482
  /**
526
483
  * Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
527
484
  * JSON. Centralized here so the planner agent only fills in the
@@ -537,6 +494,14 @@ function planToEchartsOption(
537
494
  const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
538
495
 
539
496
  if (plan.chartType === "pie") {
497
+ // Echarts crashes on null pie slices - filter them out.
498
+ // `{name, value}` slices are the only valid pie data shape,
499
+ // so drop bare numbers / tuples / nulls the planner may
500
+ // have leaked into a pie series.
501
+ const slices = (plan.series[0]?.data ?? []).filter(
502
+ (d): d is { name: string; value: number } =>
503
+ d !== null && typeof d === "object" && !Array.isArray(d),
504
+ );
540
505
  return {
541
506
  title: { text: baseTitle, left: "center" },
542
507
  tooltip: { trigger: "item" },
@@ -546,13 +511,16 @@ function planToEchartsOption(
546
511
  name: plan.series[0]?.name ?? baseTitle,
547
512
  type: "pie",
548
513
  radius: ["35%", "65%"],
549
- data: plan.series[0]?.data ?? [],
514
+ data: slices,
550
515
  },
551
516
  ],
552
517
  };
553
518
  }
554
519
 
555
520
  if (plan.chartType === "scatter") {
521
+ // Echarts crashes on null scatter points - keep only valid
522
+ // `[x, y]` tuples. Bare numbers / objects / nulls from a
523
+ // mismatched plan get dropped silently.
556
524
  return {
557
525
  title: { text: baseTitle, left: "center" },
558
526
  tooltip: { trigger: "item" },
@@ -563,7 +531,9 @@ function planToEchartsOption(
563
531
  series: plan.series.map((s) => ({
564
532
  name: s.name,
565
533
  type: "scatter",
566
- data: s.data,
534
+ data: s.data.filter(
535
+ (d): d is [number, number] => Array.isArray(d) && d.length === 2,
536
+ ),
567
537
  })),
568
538
  };
569
539
  }