@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.
- package/README.md +304 -645
- package/index.ts +46 -38
- package/package.json +58 -45
- package/src/agents.ts +224 -66
- package/src/chart.ts +531 -429
- package/src/config.ts +270 -19
- package/src/filesystems.ts +1090 -0
- package/src/genie.ts +1000 -660
- package/src/history.ts +166 -79
- package/src/mcp.ts +105 -0
- package/src/memory.ts +94 -92
- package/src/mlflow.ts +149 -0
- package/src/model.ts +75 -408
- package/src/observability.ts +121 -69
- package/src/pagination.ts +34 -0
- package/src/plugin.ts +552 -67
- package/src/processors.ts +168 -0
- package/src/rest.ts +67 -0
- package/src/server.ts +232 -45
- package/src/serving-sanitize.ts +167 -0
- package/src/serving.ts +27 -243
- package/src/statement.ts +89 -0
- package/src/storage-schema.ts +41 -0
- package/src/summarize.ts +176 -0
- package/src/threads.ts +338 -0
- package/src/workspaces.ts +346 -0
- package/src/writer.ts +44 -0
- package/tsconfig.json +41 -0
- package/dist/index.d.ts +0 -20
- package/dist/index.js +0 -20
- package/dist/src/agents.d.ts +0 -306
- package/dist/src/agents.js +0 -403
- package/dist/src/chart.d.ts +0 -170
- package/dist/src/chart.js +0 -491
- package/dist/src/config.d.ts +0 -183
- package/dist/src/config.js +0 -12
- package/dist/src/genie.d.ts +0 -131
- package/dist/src/genie.js +0 -630
- package/dist/src/history.d.ts +0 -67
- package/dist/src/history.js +0 -172
- package/dist/src/memory.d.ts +0 -100
- package/dist/src/memory.js +0 -242
- package/dist/src/model.d.ts +0 -159
- package/dist/src/model.js +0 -427
- package/dist/src/observability.d.ts +0 -33
- package/dist/src/observability.js +0 -71
- package/dist/src/plugin.d.ts +0 -130
- package/dist/src/plugin.js +0 -283
- package/dist/src/processors/strip-stale-charts.d.ts +0 -29
- package/dist/src/processors/strip-stale-charts.js +0 -96
- package/dist/src/server.d.ts +0 -46
- package/dist/src/server.js +0 -123
- package/dist/src/serving.d.ts +0 -156
- package/dist/src/serving.js +0 -231
- package/dist/src/tools/email.d.ts +0 -74
- package/dist/src/tools/email.js +0 -122
- package/dist/tsconfig.build.tsbuildinfo +0 -1
- package/src/processors/strip-stale-charts.ts +0 -105
- package/src/tools/email.ts +0 -147
package/src/chart.ts
CHANGED
|
@@ -1,58 +1,135 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chart
|
|
2
|
+
* Chart planner + chart cache.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Self-contained chart subsystem with two layers:
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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 {
|
|
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
|
|
46
|
-
import {
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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
|
|
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:
|
|
69
|
-
|
|
70
|
-
.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
102
|
-
|
|
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(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
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
|
|
313
|
+
const plannerAgents = new WeakMap<MastraPluginConfig, Agent>();
|
|
314
|
+
|
|
218
315
|
function getPlannerAgent(config: MastraPluginConfig): Agent {
|
|
219
|
-
let agent =
|
|
316
|
+
let agent = plannerAgents.get(config);
|
|
220
317
|
if (!agent) {
|
|
221
|
-
agent =
|
|
222
|
-
|
|
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
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
"Dataset (JSON, one row per object):
|
|
245
|
-
|
|
246
|
-
|
|
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 {
|
|
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
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
/**
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
*
|
|
293
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
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
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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:
|
|
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
|
+
}
|