@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/README.md +34 -39
- package/dist/src/agents.d.ts +2 -2
- package/dist/src/agents.js +66 -14
- package/dist/src/chart.d.ts +39 -105
- package/dist/src/chart.js +199 -194
- package/dist/src/config.d.ts +104 -0
- package/dist/src/config.js +43 -0
- package/dist/src/genie.d.ts +170 -107
- package/dist/src/genie.js +1003 -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/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +21 -18
- package/src/agents.ts +73 -17
- package/src/chart.ts +221 -251
- package/src/config.ts +120 -0
- package/src/genie.ts +1199 -654
- 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/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";
|
|
@@ -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.
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
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
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
data,
|
|
406
|
+
rows: data.length,
|
|
407
|
+
columns: data[0] ? Object.keys(data[0]) : [],
|
|
408
|
+
hasWriter: writer !== undefined,
|
|
515
409
|
});
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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:
|
|
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
|
}
|