@dbx-tools/appkit-mastra 0.1.25 → 0.1.27
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 +115 -303
- package/dist/src/agents.d.ts +10 -0
- package/dist/src/agents.js +15 -2
- package/dist/src/chart.d.ts +131 -79
- package/dist/src/chart.js +386 -279
- package/dist/src/config.d.ts +19 -16
- package/dist/src/genie.d.ts +90 -106
- package/dist/src/genie.js +523 -642
- package/dist/src/intercept.d.ts +48 -0
- package/dist/src/intercept.js +167 -0
- package/dist/src/plugin.d.ts +12 -0
- package/dist/src/plugin.js +185 -1
- package/dist/src/processors/strip-stale-charts.d.ts +16 -13
- package/dist/src/processors/strip-stale-charts.js +16 -13
- package/dist/src/statement.d.ts +73 -0
- package/dist/src/statement.js +118 -0
- package/dist/src/tools/email.js +27 -28
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/agents.ts +16 -2
- package/src/chart.ts +499 -319
- package/src/config.ts +19 -16
- package/src/genie.ts +560 -768
- package/src/intercept.ts +206 -0
- package/src/plugin.ts +210 -2
- package/src/processors/strip-stale-charts.ts +16 -13
- package/src/statement.ts +127 -0
- package/src/tools/email.ts +27 -28
package/dist/src/chart.js
CHANGED
|
@@ -1,45 +1,69 @@
|
|
|
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
|
-
*
|
|
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.
|
|
12
15
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* LLM-bound output stays flat regardless of dataset size.
|
|
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.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
23
|
+
* Wire-format schemas (`ChartSchema`, `ChartResultSchema`,
|
|
24
|
+
* {@link ChartTypeSchema}) live in
|
|
25
|
+
* `@dbx-tools/appkit-mastra-shared` so the demo client and any
|
|
26
|
+
* other UI consumer share the exact same shape this module reads
|
|
27
|
+
* and writes.
|
|
28
|
+
*
|
|
29
|
+
* Public surface (everything else is module-private):
|
|
30
|
+
* - {@link chartPlannerRequestSchema} / {@link ChartPlannerRequest}
|
|
31
|
+
* - {@link prepareChart} / {@link PrepareChartOptions}
|
|
32
|
+
* - {@link fetchChart} / {@link FetchChartOptions}
|
|
33
|
+
* - {@link buildRenderDataTool} (the `render_data` Mastra tool
|
|
34
|
+
* auto-wired on every agent in `agents.ts`)
|
|
28
35
|
*/
|
|
36
|
+
import { CacheManager } from "@databricks/appkit";
|
|
37
|
+
import { ChartSchema, ChartTypeSchema, } from "@dbx-tools/appkit-mastra-shared";
|
|
29
38
|
import { commonUtils, logUtils, stringUtils } from "@dbx-tools/shared";
|
|
30
39
|
import { Agent } from "@mastra/core/agent";
|
|
31
40
|
import { createTool } from "@mastra/core/tools";
|
|
32
41
|
import { z } from "zod";
|
|
33
|
-
import { ModelTier, modelForTier
|
|
34
|
-
|
|
42
|
+
import { buildModel, ModelTier, modelForTier } from "./model.js";
|
|
43
|
+
const log = logUtils.logger("mastra/chart");
|
|
44
|
+
/* ------------------------------ constants ------------------------------ */
|
|
35
45
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
46
|
+
* TTL for cached chart entries. One hour balances "long enough for
|
|
47
|
+
* the host UI to fetch the chart well after the model finished
|
|
48
|
+
* talking" against "short enough that abandoned chart ids don't
|
|
49
|
+
* pin storage". Matches the typical Databricks OBO token lifetime
|
|
50
|
+
* so any data re-resolution stays inside the original auth window.
|
|
41
51
|
*/
|
|
42
|
-
const
|
|
52
|
+
const CHART_CACHE_TTL_SEC = 60 * 60;
|
|
53
|
+
/** Cache namespace; keeps the chart keyspace tidy. */
|
|
54
|
+
const CHART_CACHE_NAMESPACE = "mastra:chart";
|
|
55
|
+
/**
|
|
56
|
+
* `userKey` for `CacheManager.generateKey`. Chart ids are minted
|
|
57
|
+
* via `commonUtils.id()` (v4 UUID) and are unguessable, so a
|
|
58
|
+
* constant user key is fine. The HTTP route can re-scope to the
|
|
59
|
+
* requesting user when policy demands it.
|
|
60
|
+
*/
|
|
61
|
+
const CHART_CACHE_USER_KEY = "mastra-chart";
|
|
62
|
+
/** Default server-side long-poll budget for {@link fetchChart}. */
|
|
63
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
64
|
+
/** Default inter-poll sleep for {@link fetchChart}. */
|
|
65
|
+
const DEFAULT_FETCH_INTERVAL_MS = 250;
|
|
66
|
+
/* ------------------------------- schemas ------------------------------- */
|
|
43
67
|
/**
|
|
44
68
|
* One series data point. Wide variant set so the planner agent can
|
|
45
69
|
* faithfully pass through whatever the SQL row set contained
|
|
@@ -65,10 +89,6 @@ const log = logUtils.logger("mastra/chart");
|
|
|
65
89
|
* match any variant, the bad item becomes `null` instead of
|
|
66
90
|
* taking down the entire chart with a
|
|
67
91
|
* `Structured output validation failed` error.
|
|
68
|
-
*
|
|
69
|
-
* Net effect: a 200-row dataset with a few sparse/null/string
|
|
70
|
-
* values still produces a chart; only a totally-malformed planner
|
|
71
|
-
* response (no items at all) falls through to the table fallback.
|
|
72
92
|
*/
|
|
73
93
|
const chartDataPointSchema = z
|
|
74
94
|
.preprocess((v) => {
|
|
@@ -120,287 +140,311 @@ const chartDataPointSchema = z
|
|
|
120
140
|
* across charts.
|
|
121
141
|
*/
|
|
122
142
|
const chartPlanSchema = z.object({
|
|
123
|
-
chartType:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
143
|
+
chartType: ChartTypeSchema,
|
|
144
|
+
title: z
|
|
145
|
+
.string()
|
|
146
|
+
.optional()
|
|
147
|
+
.describe(stringUtils.toDescription(`
|
|
148
|
+
Short title shown above the chart. Optional; defaults to the
|
|
149
|
+
\`title\` argument the caller passed in.
|
|
150
|
+
`)),
|
|
151
|
+
xAxisLabel: z
|
|
152
|
+
.string()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe(stringUtils.toDescription(`
|
|
155
|
+
Axis label below the chart. Used for bar / line / area / scatter;
|
|
156
|
+
ignored for pie.
|
|
157
|
+
`)),
|
|
158
|
+
yAxisLabel: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe(stringUtils.toDescription(`
|
|
162
|
+
Axis label to the left of the chart. Used for bar / line / area /
|
|
163
|
+
scatter; ignored for pie.
|
|
164
|
+
`)),
|
|
165
|
+
categories: z
|
|
166
|
+
.array(z.string())
|
|
167
|
+
.optional()
|
|
168
|
+
.describe(stringUtils.toDescription(`
|
|
169
|
+
X-axis category labels for \`bar\` / \`line\` / \`area\` charts
|
|
170
|
+
(one per data point in each series). Omit for \`scatter\` (uses
|
|
171
|
+
[x, y] tuples) and \`pie\` (each slice carries its own \`name\`).
|
|
172
|
+
`)),
|
|
149
173
|
series: z
|
|
150
174
|
.array(z.object({
|
|
151
|
-
name: z.string().describe(stringUtils.toDescription
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
data: z.array(chartDataPointSchema).describe(stringUtils.toDescription
|
|
155
|
-
Data points. For \`bar\` / \`line\` / \`area\`, an
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
`),
|
|
175
|
+
name: z.string().describe(stringUtils.toDescription(`
|
|
176
|
+
Legend name for this series.
|
|
177
|
+
`)),
|
|
178
|
+
data: z.array(chartDataPointSchema).describe(stringUtils.toDescription(`
|
|
179
|
+
Data points. For \`bar\` / \`line\` / \`area\`, an array of
|
|
180
|
+
numbers aligned to \`categories\`. For \`scatter\`, an array
|
|
181
|
+
of \`[x, y]\` numeric tuples. For \`pie\`, an array of
|
|
182
|
+
\`{name, value}\` objects.
|
|
183
|
+
`)),
|
|
160
184
|
}))
|
|
161
|
-
.min(1)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
.min(1)
|
|
186
|
+
.describe(stringUtils.toDescription(`
|
|
187
|
+
One or more series to plot. Pie charts use exactly one series;
|
|
188
|
+
bar/line/area can stack multiple series sharing the same
|
|
189
|
+
\`categories\` axis.
|
|
190
|
+
`)),
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* Canonical planner input shape. Tools that source rows from an
|
|
194
|
+
* inline dataset (`render_data`) use it as their `inputSchema`
|
|
195
|
+
* verbatim; tools that resolve rows from a remote (`prepare_chart`
|
|
196
|
+
* over a Genie statement) `omit({ data })` and `extend` with their
|
|
197
|
+
* own identifier field, so the field-level `.describe()` text
|
|
198
|
+
* stays a single source of truth. Server-only - the UI never
|
|
199
|
+
* sees a planner request, only the resolved {@link Chart}.
|
|
200
|
+
*/
|
|
201
|
+
export const chartPlannerRequestSchema = z.object({
|
|
202
|
+
title: z.string().describe(stringUtils.toDescription(`
|
|
203
|
+
Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
|
|
204
|
+
`)),
|
|
205
|
+
description: z
|
|
206
|
+
.string()
|
|
207
|
+
.optional()
|
|
208
|
+
.describe(stringUtils.toDescription(`
|
|
209
|
+
One-line intent the chart-planner uses when picking a chart type
|
|
210
|
+
and axis encodings (e.g. "compare quarterly revenue across
|
|
211
|
+
regions", "highlight the steep drop after position 5"). Not shown
|
|
212
|
+
to the user.
|
|
213
|
+
`)),
|
|
214
|
+
data: z
|
|
215
|
+
.array(z.record(z.string(), z.unknown()))
|
|
216
|
+
.nonempty("Data must contain at least one row")
|
|
217
|
+
.readonly()
|
|
218
|
+
.describe(stringUtils.toDescription(`
|
|
219
|
+
Tabular dataset to chart. One object per row, keyed by column
|
|
220
|
+
name. Values may be strings, numbers, booleans, or null. The
|
|
221
|
+
chart-planner decides which columns are categories vs. numeric
|
|
222
|
+
series. Cap at a few hundred rows for legibility; sample /
|
|
223
|
+
aggregate larger datasets first.
|
|
224
|
+
`)),
|
|
166
225
|
});
|
|
226
|
+
/* --------------------------- planner instructions --------------------------- */
|
|
227
|
+
/**
|
|
228
|
+
* Format {@link ChartTypeSchema}'s variants as a single
|
|
229
|
+
* human-friendly string of `` `<value>` for <description> ``
|
|
230
|
+
* clauses joined by semicolons, drawn from each variant's own
|
|
231
|
+
* `.describe()` so the planner prompt stays in lock-step with
|
|
232
|
+
* the schema by construction.
|
|
233
|
+
*/
|
|
234
|
+
function formatChartTypePicker() {
|
|
235
|
+
return ChartTypeSchema.options
|
|
236
|
+
.map((opt) => `\`${opt.value}\` for ${opt.description ?? ""}`)
|
|
237
|
+
.join("; ");
|
|
238
|
+
}
|
|
167
239
|
/**
|
|
168
240
|
* System prompt for the inner chart-planning agent. Tuned for a
|
|
169
241
|
* fast-tier model (Haiku, GPT-5-mini, Gemini Flash Lite).
|
|
170
242
|
*/
|
|
171
|
-
const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription
|
|
243
|
+
const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription(`
|
|
172
244
|
You design Apache Echarts visualizations. The user gives you a
|
|
173
245
|
tabular dataset (rows of objects) plus a title and an optional
|
|
174
|
-
description of the intent. You produce a small chart plan
|
|
175
|
-
|
|
176
|
-
conveys the data.
|
|
246
|
+
description of the intent. You produce a small chart plan (chart
|
|
247
|
+
type, axis labels, categories, series) that best conveys the data.
|
|
177
248
|
|
|
178
|
-
Decision guide
|
|
179
|
-
|
|
180
|
-
- bar: comparing a numeric value across a small/medium set of
|
|
181
|
-
discrete categories (top-N, ranking, group-by).
|
|
182
|
-
- line: ordered-axis trend (time series, sequence).
|
|
183
|
-
- area: same as line but emphasises magnitude or stacked
|
|
184
|
-
composition.
|
|
185
|
-
- scatter: two numeric axes, correlation between fields.
|
|
186
|
-
- pie: parts of a whole when 2-7 categories sum to a
|
|
187
|
-
meaningful total.
|
|
249
|
+
Decision guide. Pick the chart type whose data shape matches the
|
|
250
|
+
dataset and the user's intent: ${formatChartTypePicker()}.
|
|
188
251
|
|
|
189
252
|
When in doubt between bar and line, prefer bar for unordered
|
|
190
|
-
categories and line for ordered ones (dates, time buckets,
|
|
191
|
-
|
|
253
|
+
categories and line for ordered ones (dates, time buckets, ranks).
|
|
254
|
+
Never pick pie for more than 7 slices.
|
|
192
255
|
|
|
193
|
-
For bar / line / area: pick one column as the category axis
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
(dates, ranks).
|
|
256
|
+
For bar / line / area: pick one column as the category axis (usually
|
|
257
|
+
the only string-valued column) and one or more numeric columns as
|
|
258
|
+
series. Sort categories by the primary series value descending unless
|
|
259
|
+
the data is naturally ordered (dates, ranks).
|
|
198
260
|
|
|
199
|
-
For pie: pick the category column for slice names and one
|
|
200
|
-
|
|
261
|
+
For pie: pick the category column for slice names and one numeric
|
|
262
|
+
column for slice values. Emit a single series.
|
|
201
263
|
|
|
202
|
-
For scatter: pick two numeric columns and emit \`[x, y]\`
|
|
203
|
-
|
|
264
|
+
For scatter: pick two numeric columns and emit \`[x, y]\` tuples in a
|
|
265
|
+
single series.
|
|
204
266
|
|
|
205
|
-
Keep series names human-readable (use the column name; title
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
* Lazily-constructed inner agent shared across all calls in this
|
|
211
|
-
* process. The agent is stateless (no memory, no tools) so a
|
|
212
|
-
* single instance per plugin config is safe; model resolution
|
|
213
|
-
* still happens per-call against the live `requestContext`, so
|
|
214
|
-
* OBO auth stays user-scoped.
|
|
215
|
-
*/
|
|
216
|
-
function createChartPlannerAgent(config) {
|
|
217
|
-
return new Agent({
|
|
218
|
-
id: "render_chart_planner",
|
|
219
|
-
name: "Chart Planner",
|
|
220
|
-
description: "Picks chart type and axis encodings for a dataset.",
|
|
221
|
-
instructions: CHART_PLANNER_INSTRUCTIONS,
|
|
222
|
-
model: ({ requestContext }) => buildModel(config, requestContext, {
|
|
223
|
-
modelId: modelForTier(ModelTier.Fast),
|
|
224
|
-
}),
|
|
225
|
-
});
|
|
226
|
-
}
|
|
267
|
+
Keep series names human-readable (use the column name; title case it
|
|
268
|
+
lightly if needed). Keep titles concise; do not repeat the user's
|
|
269
|
+
title in xAxisLabel / yAxisLabel.
|
|
270
|
+
`);
|
|
271
|
+
/* ----------------------------- planner agent ----------------------------- */
|
|
227
272
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
273
|
+
* One planner `Agent` per plugin config. Cached on config object
|
|
274
|
+
* identity so callers can `prepareChart({ config, ... })` from a
|
|
275
|
+
* hot path without paying the Agent-constructor cost every call.
|
|
276
|
+
* `WeakMap` lets retired configs (e.g. test reconfigurations)
|
|
277
|
+
* release their agent without manual eviction.
|
|
232
278
|
*/
|
|
233
|
-
const
|
|
279
|
+
const plannerAgents = new WeakMap();
|
|
234
280
|
function getPlannerAgent(config) {
|
|
235
|
-
let agent =
|
|
281
|
+
let agent = plannerAgents.get(config);
|
|
236
282
|
if (!agent) {
|
|
237
|
-
agent =
|
|
238
|
-
|
|
283
|
+
agent = new Agent({
|
|
284
|
+
id: "chart_planner",
|
|
285
|
+
name: "Chart Planner",
|
|
286
|
+
description: "Picks chart type and axis encodings for a dataset.",
|
|
287
|
+
instructions: CHART_PLANNER_INSTRUCTIONS,
|
|
288
|
+
model: ({ requestContext }) => buildModel(config, requestContext, {
|
|
289
|
+
modelId: modelForTier(ModelTier.Fast),
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
plannerAgents.set(config, agent);
|
|
239
293
|
}
|
|
240
294
|
return agent;
|
|
241
295
|
}
|
|
242
296
|
/**
|
|
243
|
-
* Run the
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
* Producers (the `render_data` tool, the Genie agent,
|
|
247
|
-
* anything else that needs a chart) await this and stitch the
|
|
248
|
-
* result into whatever shape their wire contract needs.
|
|
297
|
+
* Run the planner against `request` and return the resolved
|
|
298
|
+
* Echarts spec. Throws on planner failure - {@link prepareChart}
|
|
299
|
+
* catches and stashes the error in the cache entry.
|
|
249
300
|
*/
|
|
250
|
-
|
|
251
|
-
const {
|
|
252
|
-
const
|
|
253
|
-
const prompt =
|
|
254
|
-
|
|
255
|
-
description ?
|
|
256
|
-
"",
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
JSON.stringify(data, null, 2),
|
|
260
|
-
"```",
|
|
261
|
-
]
|
|
262
|
-
.filter((line) => line !== null)
|
|
263
|
-
.join("\n");
|
|
264
|
-
const result = await planner.generate(prompt, {
|
|
301
|
+
async function runChartPlanner(config, request, options = {}) {
|
|
302
|
+
const { title, description, data } = request;
|
|
303
|
+
const { requestContext, abortSignal } = options;
|
|
304
|
+
const prompt = stringUtils.toDescription({
|
|
305
|
+
Title: title,
|
|
306
|
+
...(description ? { Description: description } : {}),
|
|
307
|
+
"Dataset (JSON, one row per object)": JSON.stringify(data, null, 2),
|
|
308
|
+
});
|
|
309
|
+
const result = await getPlannerAgent(config).generate(prompt, {
|
|
265
310
|
structuredOutput: { schema: chartPlanSchema },
|
|
266
311
|
...(requestContext ? { requestContext } : {}),
|
|
267
|
-
...(
|
|
312
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
268
313
|
});
|
|
269
314
|
const plan = result.object;
|
|
270
315
|
const option = planToEchartsOption(plan, title);
|
|
271
|
-
return {
|
|
316
|
+
return { chartType: plan.chartType, option };
|
|
317
|
+
}
|
|
318
|
+
/* ------------------------------ cache helpers ------------------------------ */
|
|
319
|
+
/** Build the canonical cache key for a `chartId`. */
|
|
320
|
+
async function chartCacheKey(chartId) {
|
|
321
|
+
return (await CacheManager.getInstance()).generateKey([CHART_CACHE_NAMESPACE, chartId], CHART_CACHE_USER_KEY);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Persist a {@link Chart} entry under its `chartId`. Refreshes
|
|
325
|
+
* the TTL on every write. Cache-layer failures are logged and
|
|
326
|
+
* swallowed so background runners never throw into the
|
|
327
|
+
* unhandled-rejection stream.
|
|
328
|
+
*/
|
|
329
|
+
async function writeChart(entry) {
|
|
330
|
+
try {
|
|
331
|
+
const key = await chartCacheKey(entry.chartId);
|
|
332
|
+
await CacheManager.getInstanceSync().set(key, entry, {
|
|
333
|
+
ttl: CHART_CACHE_TTL_SEC,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
log.warn("write-error", {
|
|
338
|
+
chartId: entry.chartId,
|
|
339
|
+
error: commonUtils.errorMessage(err),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Look up a chart by id. Returns `undefined` on miss, on
|
|
345
|
+
* expiry, or when the cache layer is unhealthy - never throws.
|
|
346
|
+
*/
|
|
347
|
+
async function readChart(chartId) {
|
|
348
|
+
try {
|
|
349
|
+
const key = await chartCacheKey(chartId);
|
|
350
|
+
const v = await CacheManager.getInstanceSync().get(key);
|
|
351
|
+
return v ?? undefined;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
log.warn("read-error", {
|
|
355
|
+
chartId,
|
|
356
|
+
error: commonUtils.errorMessage(err),
|
|
357
|
+
});
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Mint a `chartId`, cache an empty `{ chartId }` placeholder
|
|
363
|
+
* synchronously, and kick off a background task that resolves the
|
|
364
|
+
* dataset and runs the planner. Returns the `chartId` once the
|
|
365
|
+
* placeholder lands so the first {@link fetchChart} call always
|
|
366
|
+
* sees an entry (no spurious 404 race).
|
|
367
|
+
*
|
|
368
|
+
* The background task swallows its own failures and writes them
|
|
369
|
+
* as `error` entries, so callers never see a rejected promise.
|
|
370
|
+
* Cache state machine:
|
|
371
|
+
*
|
|
372
|
+
* - just after this call returns: `{ chartId }` (processing)
|
|
373
|
+
* - on planner success: `{ chartId, result }`
|
|
374
|
+
* - on data / planner failure: `{ chartId, error }`
|
|
375
|
+
*/
|
|
376
|
+
export async function prepareChart(opts) {
|
|
377
|
+
const chartId = commonUtils.id();
|
|
378
|
+
await writeChart({ chartId });
|
|
379
|
+
log.debug("queued", { chartId });
|
|
380
|
+
// Fire-and-forget. Failures land in the cache as `error` entries;
|
|
381
|
+
// never escape into an unhandled rejection.
|
|
382
|
+
void runPrepareChart(chartId, opts);
|
|
383
|
+
return { chartId };
|
|
384
|
+
}
|
|
385
|
+
async function runPrepareChart(chartId, opts) {
|
|
386
|
+
const startedAt = Date.now();
|
|
387
|
+
try {
|
|
388
|
+
const data = await opts.resolveData(opts.signal);
|
|
389
|
+
if (data.rows.length === 0) {
|
|
390
|
+
throw new Error("dataset has no rows; nothing to chart");
|
|
391
|
+
}
|
|
392
|
+
const result = await runChartPlanner(opts.config, {
|
|
393
|
+
title: opts.title ?? "Chart",
|
|
394
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
395
|
+
data: data.rows,
|
|
396
|
+
}, {
|
|
397
|
+
...(opts.requestContext ? { requestContext: opts.requestContext } : {}),
|
|
398
|
+
...(opts.signal ? { abortSignal: opts.signal } : {}),
|
|
399
|
+
});
|
|
400
|
+
await writeChart({ chartId, result });
|
|
401
|
+
log.info("done", {
|
|
402
|
+
chartId,
|
|
403
|
+
chartType: result.chartType,
|
|
404
|
+
elapsedMs: Date.now() - startedAt,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
const error = commonUtils.errorMessage(err);
|
|
409
|
+
log.warn("error", { chartId, error });
|
|
410
|
+
await writeChart({ chartId, error });
|
|
411
|
+
}
|
|
272
412
|
}
|
|
273
|
-
const renderDataInputSchema = z.object({
|
|
274
|
-
title: z.string().describe(stringUtils.toDescription `
|
|
275
|
-
Title shown above the rendered chart. Use a concise
|
|
276
|
-
sentence-case label (e.g. "Top 10 SKUs by On-Hand Units").
|
|
277
|
-
`),
|
|
278
|
-
description: z.string().optional().describe(stringUtils.toDescription `
|
|
279
|
-
Optional one-line intent describing what insight the chart
|
|
280
|
-
should convey (e.g. "highlight the steep drop-off after
|
|
281
|
-
position 5", "compare quarterly revenue across regions").
|
|
282
|
-
The chart-planner reads this when picking the chart type and
|
|
283
|
-
axis encodings; the user does not see it directly.
|
|
284
|
-
`),
|
|
285
|
-
data: z.array(z.record(z.string(), z.unknown())).min(1)
|
|
286
|
-
.describe(stringUtils.toDescription `
|
|
287
|
-
Tabular dataset to chart. One object per row, keyed by
|
|
288
|
-
column name. Values may be strings, numbers, booleans, or
|
|
289
|
-
null. The chart-planner decides which columns are
|
|
290
|
-
categories vs. numeric series. Cap at a few hundred rows
|
|
291
|
-
for legibility; sample / aggregate larger datasets first.
|
|
292
|
-
`),
|
|
293
|
-
});
|
|
294
|
-
const renderDataOutputSchema = z.object({
|
|
295
|
-
chartId: z.string().describe(stringUtils.toDescription `
|
|
296
|
-
Identifier of the queued chart. To position the chart in
|
|
297
|
-
your reply, embed the marker \`[[chart:<chartId>]]\` on its
|
|
298
|
-
own line where the chart should appear; the client renders
|
|
299
|
-
it inline.
|
|
300
|
-
`),
|
|
301
|
-
});
|
|
302
413
|
/**
|
|
303
|
-
*
|
|
414
|
+
* Long-poll the chart cache until the entry settles (`result` or
|
|
415
|
+
* `error` set), the entry is missing, or the server-side timeout
|
|
416
|
+
* elapses.
|
|
417
|
+
*
|
|
418
|
+
* Returns:
|
|
419
|
+
* - the resolved {@link Chart} when it settled, errored, or
|
|
420
|
+
* stayed in processing past `timeoutMs` (so the client can
|
|
421
|
+
* re-poll);
|
|
422
|
+
* - `undefined` when the entry is missing or expired (the
|
|
423
|
+
* consumer should treat as 404).
|
|
304
424
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* resolved `EChartsOption`. The LLM-bound output is just
|
|
309
|
-
* `{ chartId }` so the model's context stays flat regardless of
|
|
310
|
-
* dataset size. Planner failures are caught and surfaced as a
|
|
311
|
-
* `type: "error"` writer event so the slot can fall back to
|
|
312
|
-
* "couldn't render chart" without taking the parent agent down.
|
|
425
|
+
* `signal` lets the caller cancel ahead of timeout (e.g. the HTTP
|
|
426
|
+
* request closed). Cancellation propagates to the inter-poll sleep
|
|
427
|
+
* so the helper returns immediately.
|
|
313
428
|
*/
|
|
314
|
-
export function
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
the markers with prose so each chart sits next to its
|
|
332
|
-
commentary. A chart whose marker is omitted falls through
|
|
333
|
-
to the end of your reply as a fallback - safe but less
|
|
334
|
-
polished.
|
|
335
|
-
|
|
336
|
-
Use whenever a SQL row set, API response, or hand-built
|
|
337
|
-
dataset would land better as a picture than as a list or
|
|
338
|
-
table. Cap input at a few hundred rows; sample or
|
|
339
|
-
aggregate larger datasets first.
|
|
340
|
-
`,
|
|
341
|
-
inputSchema: renderDataInputSchema,
|
|
342
|
-
outputSchema: renderDataOutputSchema,
|
|
343
|
-
execute: async (input, ctx) => {
|
|
344
|
-
const { title, description, data } = input;
|
|
345
|
-
const writer = ctx?.writer;
|
|
346
|
-
const requestContext = ctx
|
|
347
|
-
?.requestContext;
|
|
348
|
-
// Marker-friendly short id. The LLM types this verbatim
|
|
349
|
-
// into `[[chart:<id>]]`; 8 hex chars is unique within a
|
|
350
|
-
// single assistant turn and easy for the model to copy.
|
|
351
|
-
const chartId = commonUtils.shortId();
|
|
352
|
-
const startedAt = Date.now();
|
|
353
|
-
log.debug("render:start", {
|
|
354
|
-
chartId,
|
|
355
|
-
title,
|
|
356
|
-
rows: data.length,
|
|
357
|
-
columns: data[0] ? Object.keys(data[0]) : [],
|
|
358
|
-
hasWriter: writer !== undefined,
|
|
359
|
-
});
|
|
360
|
-
try {
|
|
361
|
-
const { option, chartType } = await runChartPlanner({
|
|
362
|
-
config,
|
|
363
|
-
...(requestContext ? { requestContext } : {}),
|
|
364
|
-
title,
|
|
365
|
-
...(description ? { description } : {}),
|
|
366
|
-
data,
|
|
367
|
-
});
|
|
368
|
-
log.debug("render:done", {
|
|
369
|
-
chartId,
|
|
370
|
-
chartType,
|
|
371
|
-
elapsedMs: Date.now() - startedAt,
|
|
372
|
-
});
|
|
373
|
-
// Single chart event with everything resolved: dataset
|
|
374
|
-
// for the table-like fallback / hover, option for the
|
|
375
|
-
// actual render. Best-effort write so a closed
|
|
376
|
-
// downstream stream can't take the tool down.
|
|
377
|
-
await safeWrite(log, writer, {
|
|
378
|
-
type: "chart",
|
|
379
|
-
chartId,
|
|
380
|
-
title,
|
|
381
|
-
...(description ? { description } : {}),
|
|
382
|
-
data,
|
|
383
|
-
option,
|
|
384
|
-
}, { chartId });
|
|
385
|
-
}
|
|
386
|
-
catch (err) {
|
|
387
|
-
log.warn("render:error", {
|
|
388
|
-
chartId,
|
|
389
|
-
elapsedMs: Date.now() - startedAt,
|
|
390
|
-
error: commonUtils.errorMessage(err),
|
|
391
|
-
});
|
|
392
|
-
// Surface as a writer-level error so the slot can
|
|
393
|
-
// transition to "couldn't render chart" without the
|
|
394
|
-
// parent agent surfacing a stack trace.
|
|
395
|
-
await safeWrite(log, writer, {
|
|
396
|
-
type: "error",
|
|
397
|
-
error: commonUtils.errorMessage(err),
|
|
398
|
-
}, { chartId });
|
|
399
|
-
}
|
|
400
|
-
return { chartId };
|
|
401
|
-
},
|
|
402
|
-
});
|
|
429
|
+
export async function fetchChart(chartId, options = {}) {
|
|
430
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
431
|
+
const intervalMs = options.intervalMs ?? DEFAULT_FETCH_INTERVAL_MS;
|
|
432
|
+
const deadline = Date.now() + timeoutMs;
|
|
433
|
+
let last;
|
|
434
|
+
while (true) {
|
|
435
|
+
options.signal?.throwIfAborted();
|
|
436
|
+
last = await readChart(chartId);
|
|
437
|
+
if (!last)
|
|
438
|
+
return undefined;
|
|
439
|
+
if (last.result !== undefined || last.error !== undefined)
|
|
440
|
+
return last;
|
|
441
|
+
const remaining = deadline - Date.now();
|
|
442
|
+
if (remaining <= 0)
|
|
443
|
+
return last;
|
|
444
|
+
await commonUtils.sleep(Math.min(intervalMs, remaining), options.signal);
|
|
445
|
+
}
|
|
403
446
|
}
|
|
447
|
+
/* ----------------------------- echarts expansion ----------------------------- */
|
|
404
448
|
/**
|
|
405
449
|
* Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
|
|
406
450
|
* JSON. Centralized here so the planner agent only fills in the
|
|
@@ -472,3 +516,66 @@ function planToEchartsOption(plan, fallbackTitle) {
|
|
|
472
516
|
})),
|
|
473
517
|
};
|
|
474
518
|
}
|
|
519
|
+
/* ----------------------------- render_data tool ----------------------------- */
|
|
520
|
+
/**
|
|
521
|
+
* Build the `render_data` Mastra tool bound to the given plugin
|
|
522
|
+
* config. Auto-wired as a system tool on every agent (see
|
|
523
|
+
* `agents.ts`); per-agent tools can shadow it by registering a
|
|
524
|
+
* same-named entry.
|
|
525
|
+
*
|
|
526
|
+
* Thin wrapper over {@link prepareChart} for callers that already
|
|
527
|
+
* have a dataset in hand. Mints a `chartId` synchronously, caches
|
|
528
|
+
* an empty placeholder, and kicks off the chart-planner in the
|
|
529
|
+
* background. Returns just the `chartId`; the host UI resolves
|
|
530
|
+
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
531
|
+
* `/embed/chart/:id` route.
|
|
532
|
+
*
|
|
533
|
+
* For Genie statement results, prefer the Genie agent's
|
|
534
|
+
* `prepare_chart` tool, which accepts a `statement_id` and
|
|
535
|
+
* resolves the rows lazily.
|
|
536
|
+
*/
|
|
537
|
+
export function buildRenderDataTool(config) {
|
|
538
|
+
return createTool({
|
|
539
|
+
id: "render_data",
|
|
540
|
+
description: stringUtils.toDescription([
|
|
541
|
+
`
|
|
542
|
+
Submit a tabular dataset for inline rendering as a chart in
|
|
543
|
+
the user's view. Pass a title, the raw rows (array of objects
|
|
544
|
+
keyed by column name), and an optional one-line description
|
|
545
|
+
of the insight to highlight. Returns a short \`chartId\`;
|
|
546
|
+
the chart renders inline at the position you embed the
|
|
547
|
+
matching \`[chart:<chartId>]\` marker.
|
|
548
|
+
`,
|
|
549
|
+
`
|
|
550
|
+
Placement contract: embed \`[chart:<chartId>]\` on its own
|
|
551
|
+
line (blank lines above and below) wherever you want the
|
|
552
|
+
chart to appear in your reply. The chart resolves
|
|
553
|
+
asynchronously - the tool returns the id immediately and the
|
|
554
|
+
host UI fetches the chart from the cache once the planner
|
|
555
|
+
lands. You can call \`render_data\` multiple times in the
|
|
556
|
+
same turn (the tool is parallel-safe) and interleave the
|
|
557
|
+
markers with prose so each chart sits next to its
|
|
558
|
+
commentary.
|
|
559
|
+
`,
|
|
560
|
+
`
|
|
561
|
+
Use whenever a SQL row set, API response, or hand-built
|
|
562
|
+
dataset would land better as a picture than as a list or
|
|
563
|
+
table. Cap input at a few hundred rows; sample or aggregate
|
|
564
|
+
larger datasets first.
|
|
565
|
+
`,
|
|
566
|
+
]),
|
|
567
|
+
inputSchema: chartPlannerRequestSchema,
|
|
568
|
+
outputSchema: ChartSchema.pick({ chartId: true }),
|
|
569
|
+
execute: async (input, ctxRaw) => {
|
|
570
|
+
const { title, description, data } = input;
|
|
571
|
+
const ctx = ctxRaw;
|
|
572
|
+
return prepareChart({
|
|
573
|
+
config,
|
|
574
|
+
title,
|
|
575
|
+
...(description ? { description } : {}),
|
|
576
|
+
resolveData: () => Promise.resolve({ rows: data }),
|
|
577
|
+
...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
}
|