@dbx-tools/appkit-mastra 0.1.13 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -102
- package/dist/src/agents.d.ts +2 -2
- package/dist/src/agents.js +65 -14
- package/dist/src/chart.d.ts +39 -105
- package/dist/src/chart.js +177 -194
- package/dist/src/config.d.ts +104 -0
- package/dist/src/config.js +43 -0
- package/dist/src/genie.d.ts +169 -107
- package/dist/src/genie.js +983 -577
- package/dist/src/history.d.ts +31 -3
- package/dist/src/history.js +137 -31
- package/dist/src/memory.d.ts +4 -4
- package/dist/src/memory.js +2 -2
- package/dist/src/model.js +2 -2
- package/dist/src/observability.d.ts +56 -25
- package/dist/src/observability.js +70 -56
- package/dist/src/plugin.js +25 -15
- package/dist/src/processors/strip-stale-charts.js +1 -1
- package/dist/src/server.d.ts +12 -0
- package/dist/src/server.js +38 -2
- package/dist/src/serving.js +1 -1
- package/dist/src/tools/email.js +1 -1
- package/dist/src/writer.d.ts +23 -0
- package/dist/src/writer.js +37 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +21 -18
- package/src/agents.ts +72 -17
- package/src/chart.ts +205 -251
- package/src/config.ts +120 -0
- package/src/genie.ts +1183 -658
- package/src/history.ts +147 -33
- package/src/memory.ts +5 -5
- package/src/model.ts +3 -3
- package/src/observability.ts +94 -70
- package/src/plugin.ts +25 -15
- package/src/processors/strip-stale-charts.ts +1 -1
- package/src/server.ts +49 -2
- package/src/serving.ts +1 -1
- package/src/tools/email.ts +1 -1
- package/src/writer.ts +44 -0
package/src/chart.ts
CHANGED
|
@@ -1,42 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Chart-rendering primitives.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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.
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 {
|
|
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";
|
|
@@ -44,6 +36,7 @@ import { z } from "zod";
|
|
|
44
36
|
|
|
45
37
|
import type { MastraPluginConfig } from "./config.js";
|
|
46
38
|
import { ModelTier, modelForTier, buildModel } from "./model.js";
|
|
39
|
+
import { safeWrite } from "./writer.js";
|
|
47
40
|
|
|
48
41
|
/**
|
|
49
42
|
* Module-level logger tagged `[mastra/chart]`. Uses the shared
|
|
@@ -54,6 +47,79 @@ import { ModelTier, modelForTier, buildModel } from "./model.js";
|
|
|
54
47
|
*/
|
|
55
48
|
const log = logUtils.logger("mastra/chart");
|
|
56
49
|
|
|
50
|
+
/**
|
|
51
|
+
* One series data point. Wide variant set so the planner agent can
|
|
52
|
+
* faithfully pass through whatever the SQL row set contained
|
|
53
|
+
* (numbers, stringified numbers, nulls for missing measurements,
|
|
54
|
+
* `[x, y]` tuples for scatter, `{name, value}` slices for pie)
|
|
55
|
+
* without the structured-output guard rejecting the whole plan.
|
|
56
|
+
*
|
|
57
|
+
* Three layers of tolerance:
|
|
58
|
+
*
|
|
59
|
+
* 1. {@link z.preprocess} normalizes wire shapes BEFORE union
|
|
60
|
+
* dispatch: stringified numbers parse to numbers, finite
|
|
61
|
+
* checks reject `NaN` / `Infinity`, 2-element arrays coerce
|
|
62
|
+
* tuple components, and `{value}` objects with missing /
|
|
63
|
+
* stringified `value` get coerced or rejected uniformly.
|
|
64
|
+
* Anything not handleable becomes `null`.
|
|
65
|
+
* 2. The union accepts `null` as a first-class variant. Echarts
|
|
66
|
+
* renders null as a gap on bar / line / area (which is the
|
|
67
|
+
* right visual signal for "missing reading"). Scatter and
|
|
68
|
+
* pie filter nulls in {@link planToEchartsOption} because
|
|
69
|
+
* Echarts crashes on null tuples / slices.
|
|
70
|
+
* 3. {@link z.union#catch} backstops the whole thing: if
|
|
71
|
+
* preprocess somehow produces a shape that still doesn't
|
|
72
|
+
* match any variant, the bad item becomes `null` instead of
|
|
73
|
+
* taking down the entire chart with a
|
|
74
|
+
* `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
|
+
*/
|
|
80
|
+
const chartDataPointSchema = z
|
|
81
|
+
.preprocess(
|
|
82
|
+
(v) => {
|
|
83
|
+
if (v === null || v === undefined) return null;
|
|
84
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
85
|
+
if (typeof v === "string") {
|
|
86
|
+
const n = Number(v);
|
|
87
|
+
return Number.isFinite(n) ? n : null;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(v) && v.length === 2) {
|
|
90
|
+
const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
|
|
91
|
+
const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
|
|
92
|
+
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
|
|
93
|
+
}
|
|
94
|
+
if (typeof v === "object" && v !== null && "value" in v) {
|
|
95
|
+
const obj = v as { name?: unknown; value: unknown };
|
|
96
|
+
const val = typeof obj.value === "number" ? obj.value : Number(obj.value);
|
|
97
|
+
if (!Number.isFinite(val)) return null;
|
|
98
|
+
// Coerce numeric / boolean / nullish names to strings so a
|
|
99
|
+
// pie slice keyed on a year (`2024`) or category id is
|
|
100
|
+
// accepted without round-tripping through the catch arm.
|
|
101
|
+
const rawName = obj.name;
|
|
102
|
+
const name =
|
|
103
|
+
typeof rawName === "string"
|
|
104
|
+
? rawName
|
|
105
|
+
: rawName == null
|
|
106
|
+
? ""
|
|
107
|
+
: String(rawName);
|
|
108
|
+
return { name, value: val };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
},
|
|
112
|
+
z.union([
|
|
113
|
+
z.number(),
|
|
114
|
+
z.null(),
|
|
115
|
+
z.tuple([z.number(), z.number()]),
|
|
116
|
+
z.object({ name: z.string(), value: z.number() }),
|
|
117
|
+
]),
|
|
118
|
+
)
|
|
119
|
+
.catch(null);
|
|
120
|
+
|
|
121
|
+
type ChartDataPoint = z.infer<typeof chartDataPointSchema>;
|
|
122
|
+
|
|
57
123
|
/**
|
|
58
124
|
* Compact, model-friendly representation of an Echarts spec. The
|
|
59
125
|
* planner agent emits this; {@link planToEchartsOption} expands it
|
|
@@ -65,8 +131,7 @@ const log = logUtils.logger("mastra/chart");
|
|
|
65
131
|
* across charts.
|
|
66
132
|
*/
|
|
67
133
|
const chartPlanSchema = z.object({
|
|
68
|
-
chartType: z
|
|
69
|
-
.enum(["bar", "line", "area", "scatter", "pie"])
|
|
134
|
+
chartType: z.enum(["bar", "line", "area", "scatter", "pie"])
|
|
70
135
|
.describe(stringUtils.toDescription`
|
|
71
136
|
The chart shape that best matches the data and intent. Use
|
|
72
137
|
\`bar\` for category-vs-value comparisons, \`line\` for
|
|
@@ -86,10 +151,7 @@ const chartPlanSchema = z.object({
|
|
|
86
151
|
Axis label to the left of the chart. Used for bar / line /
|
|
87
152
|
area / scatter; ignored for pie.
|
|
88
153
|
`),
|
|
89
|
-
categories: z
|
|
90
|
-
.array(z.string())
|
|
91
|
-
.optional()
|
|
92
|
-
.describe(stringUtils.toDescription`
|
|
154
|
+
categories: z.array(z.string()).optional().describe(stringUtils.toDescription`
|
|
93
155
|
X-axis category labels for \`bar\` / \`line\` / \`area\`
|
|
94
156
|
charts (one per data point in each series). Omit for
|
|
95
157
|
\`scatter\` (uses [x, y] tuples) and \`pie\` (each slice
|
|
@@ -101,18 +163,7 @@ const chartPlanSchema = z.object({
|
|
|
101
163
|
name: z.string().describe(stringUtils.toDescription`
|
|
102
164
|
Legend name for this series.
|
|
103
165
|
`),
|
|
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`
|
|
166
|
+
data: z.array(chartDataPointSchema).describe(stringUtils.toDescription`
|
|
116
167
|
Data points. For \`bar\` / \`line\` / \`area\`, an
|
|
117
168
|
array of numbers aligned to \`categories\`. For
|
|
118
169
|
\`scatter\`, an array of \`[x, y]\` numeric tuples.
|
|
@@ -120,8 +171,7 @@ const chartPlanSchema = z.object({
|
|
|
120
171
|
`),
|
|
121
172
|
}),
|
|
122
173
|
)
|
|
123
|
-
.min(1)
|
|
124
|
-
.describe(stringUtils.toDescription`
|
|
174
|
+
.min(1).describe(stringUtils.toDescription`
|
|
125
175
|
One or more series to plot. Pie charts use exactly one
|
|
126
176
|
series; bar/line/area can stack multiple series sharing
|
|
127
177
|
the same \`categories\` axis.
|
|
@@ -200,6 +250,12 @@ export interface RunChartPlannerOptions {
|
|
|
200
250
|
title: string;
|
|
201
251
|
description?: string;
|
|
202
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;
|
|
203
259
|
}
|
|
204
260
|
|
|
205
261
|
/** Output of {@link runChartPlanner}: a fully-formed Echarts spec. */
|
|
@@ -226,15 +282,16 @@ function getPlannerAgent(config: MastraPluginConfig): Agent {
|
|
|
226
282
|
|
|
227
283
|
/**
|
|
228
284
|
* Run the chart planner against the given dataset and return a
|
|
229
|
-
* full Echarts `EChartsOption` JSON.
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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.
|
|
233
290
|
*/
|
|
234
291
|
export async function runChartPlanner(
|
|
235
292
|
opts: RunChartPlannerOptions,
|
|
236
293
|
): Promise<RunChartPlannerResult> {
|
|
237
|
-
const { config, requestContext, title, description, data } = opts;
|
|
294
|
+
const { config, requestContext, title, description, data, signal } = opts;
|
|
238
295
|
const planner = getPlannerAgent(config);
|
|
239
296
|
|
|
240
297
|
const prompt = [
|
|
@@ -252,176 +309,13 @@ export async function runChartPlanner(
|
|
|
252
309
|
const result = await planner.generate(prompt, {
|
|
253
310
|
structuredOutput: { schema: chartPlanSchema },
|
|
254
311
|
...(requestContext ? { requestContext } : {}),
|
|
312
|
+
...(signal ? { abortSignal: signal } : {}),
|
|
255
313
|
});
|
|
256
|
-
const plan = result.object;
|
|
314
|
+
const plan = result.object as ChartPlan;
|
|
257
315
|
const option = planToEchartsOption(plan, title);
|
|
258
316
|
return { option, chartType: plan.chartType };
|
|
259
317
|
}
|
|
260
318
|
|
|
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
319
|
const renderDataInputSchema = z.object({
|
|
426
320
|
title: z.string().describe(stringUtils.toDescription`
|
|
427
321
|
Title shown above the rendered chart. Use a concise
|
|
@@ -434,9 +328,7 @@ const renderDataInputSchema = z.object({
|
|
|
434
328
|
The chart-planner reads this when picking the chart type and
|
|
435
329
|
axis encodings; the user does not see it directly.
|
|
436
330
|
`),
|
|
437
|
-
data: z
|
|
438
|
-
.array(z.record(z.string(), z.unknown()))
|
|
439
|
-
.min(1)
|
|
331
|
+
data: z.array(z.record(z.string(), z.unknown())).min(1)
|
|
440
332
|
.describe(stringUtils.toDescription`
|
|
441
333
|
Tabular dataset to chart. One object per row, keyed by
|
|
442
334
|
column name. Values may be strings, numbers, booleans, or
|
|
@@ -458,16 +350,14 @@ const renderDataOutputSchema = z.object({
|
|
|
458
350
|
/**
|
|
459
351
|
* Build the `render_data` tool bound to the given plugin config.
|
|
460
352
|
*
|
|
461
|
-
* The tool
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
* `{ chartId }`, so its context stays small regardless of dataset
|
|
470
|
-
* size.
|
|
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.
|
|
471
361
|
*/
|
|
472
362
|
export function buildRenderDataTool(config: MastraPluginConfig) {
|
|
473
363
|
return createTool({
|
|
@@ -482,14 +372,14 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
|
|
|
482
372
|
|
|
483
373
|
Placement contract: embed \`[[chart:<chartId>]]\` on its own
|
|
484
374
|
line (blank lines above and below) wherever you want the
|
|
485
|
-
chart to appear in your reply. The
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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.
|
|
493
383
|
|
|
494
384
|
Use whenever a SQL row set, API response, or hand-built
|
|
495
385
|
dataset would land better as a picture than as a list or
|
|
@@ -505,18 +395,69 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
|
|
|
505
395
|
const writer = (ctx as { writer?: MinimalWriter } | undefined)?.writer;
|
|
506
396
|
const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
|
|
507
397
|
?.requestContext;
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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,
|
|
512
406
|
title,
|
|
513
|
-
|
|
514
|
-
data,
|
|
407
|
+
rows: data.length,
|
|
408
|
+
columns: data[0] ? Object.keys(data[0]) : [],
|
|
409
|
+
hasWriter: writer !== undefined,
|
|
515
410
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
411
|
+
|
|
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
|
+
}
|
|
520
461
|
return { chartId };
|
|
521
462
|
},
|
|
522
463
|
});
|
|
@@ -537,6 +478,14 @@ function planToEchartsOption(
|
|
|
537
478
|
const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
|
|
538
479
|
|
|
539
480
|
if (plan.chartType === "pie") {
|
|
481
|
+
// Echarts crashes on null pie slices - filter them out.
|
|
482
|
+
// `{name, value}` slices are the only valid pie data shape,
|
|
483
|
+
// so drop bare numbers / tuples / nulls the planner may
|
|
484
|
+
// have leaked into a pie series.
|
|
485
|
+
const slices = (plan.series[0]?.data ?? []).filter(
|
|
486
|
+
(d): d is { name: string; value: number } =>
|
|
487
|
+
d !== null && typeof d === "object" && !Array.isArray(d),
|
|
488
|
+
);
|
|
540
489
|
return {
|
|
541
490
|
title: { text: baseTitle, left: "center" },
|
|
542
491
|
tooltip: { trigger: "item" },
|
|
@@ -546,13 +495,16 @@ function planToEchartsOption(
|
|
|
546
495
|
name: plan.series[0]?.name ?? baseTitle,
|
|
547
496
|
type: "pie",
|
|
548
497
|
radius: ["35%", "65%"],
|
|
549
|
-
data:
|
|
498
|
+
data: slices,
|
|
550
499
|
},
|
|
551
500
|
],
|
|
552
501
|
};
|
|
553
502
|
}
|
|
554
503
|
|
|
555
504
|
if (plan.chartType === "scatter") {
|
|
505
|
+
// Echarts crashes on null scatter points - keep only valid
|
|
506
|
+
// `[x, y]` tuples. Bare numbers / objects / nulls from a
|
|
507
|
+
// mismatched plan get dropped silently.
|
|
556
508
|
return {
|
|
557
509
|
title: { text: baseTitle, left: "center" },
|
|
558
510
|
tooltip: { trigger: "item" },
|
|
@@ -563,7 +515,9 @@ function planToEchartsOption(
|
|
|
563
515
|
series: plan.series.map((s) => ({
|
|
564
516
|
name: s.name,
|
|
565
517
|
type: "scatter",
|
|
566
|
-
data: s.data
|
|
518
|
+
data: s.data.filter(
|
|
519
|
+
(d): d is [number, number] => Array.isArray(d) && d.length === 2,
|
|
520
|
+
),
|
|
567
521
|
})),
|
|
568
522
|
};
|
|
569
523
|
}
|