@dbx-tools/appkit-mastra 0.1.5 → 0.1.12

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/dist/index.d.ts CHANGED
@@ -15,5 +15,6 @@ export * from "./src/config.js";
15
15
  export * from "./src/agents.js";
16
16
  export * from "./src/chart.js";
17
17
  export * from "./src/genie.js";
18
+ export * from "./src/tools/email.js";
18
19
  export { clearServingEndpointsCache, extractModelOverride, listServingEndpoints, MASTRA_MODEL_OVERRIDE_KEY, MODEL_OVERRIDE_BODY_FIELDS, MODEL_OVERRIDE_HEADER, MODEL_OVERRIDE_QUERY, resolveModelId, type ResolvedModel, type ResolveModelOptions, type ServingEndpointSummary, } from "./src/serving.js";
19
20
  export { FALLBACK_MODEL_IDS, MODEL_CATALOG, modelForTier, modelsForTier, ModelTier, } from "./src/model.js";
package/dist/index.js CHANGED
@@ -15,5 +15,6 @@ export * from "./src/config.js";
15
15
  export * from "./src/agents.js";
16
16
  export * from "./src/chart.js";
17
17
  export * from "./src/genie.js";
18
+ export * from "./src/tools/email.js";
18
19
  export { clearServingEndpointsCache, extractModelOverride, listServingEndpoints, MASTRA_MODEL_OVERRIDE_KEY, MODEL_OVERRIDE_BODY_FIELDS, MODEL_OVERRIDE_HEADER, MODEL_OVERRIDE_QUERY, resolveModelId, } from "./src/serving.js";
19
20
  export { FALLBACK_MODEL_IDS, MODEL_CATALOG, modelForTier, modelsForTier, ModelTier, } from "./src/model.js";
@@ -19,6 +19,7 @@ import { createTool } from "@mastra/core/tools";
19
19
  import { buildRenderDataTool } from "./chart.js";
20
20
  import { buildGenieProvider } from "./genie.js";
21
21
  import { buildModel } from "./model.js";
22
+ import { stripStaleChartsProcessor } from "./processors/strip-stale-charts.js";
22
23
  /** Re-export of Mastra's native `createTool` for full-feature access. */
23
24
  export { createTool } from "@mastra/core/tools";
24
25
  /**
@@ -64,8 +65,8 @@ export function tool(opts) {
64
65
  /**
65
66
  * Build a deterministic Mastra tool id from a description.
66
67
  * Delegates to {@link stringUtils.toUniqueSlug}: slug + always-on
67
- * SHA-1 suffix so two tools with the same leading words don't
68
- * collide in traces. Stable across runs.
68
+ * 6-char FNV-1a base-32 suffix so two tools with the same leading
69
+ * words don't collide in traces. Stable across runs.
69
70
  */
70
71
  function deriveToolId(description) {
71
72
  return stringUtils.toUniqueSlug(description, { fallbackPrefix: "tool" });
@@ -165,7 +166,7 @@ export async function buildAgents(opts) {
165
166
  const definitions = resolveDefinitions(config);
166
167
  const ids = Object.keys(definitions);
167
168
  const defaultAgentId = config.defaultAgent ?? ids[0] ?? FALLBACK_AGENT_ID;
168
- const plugins = buildPluginsMap(context);
169
+ const plugins = buildPluginsMap(config, context);
169
170
  // System-default ambient tools every agent gets out of the box.
170
171
  // Currently just `render_data` for inline visualizations; the
171
172
  // user can shadow it by including a same-named tool in their own
@@ -176,6 +177,11 @@ export async function buildAgents(opts) {
176
177
  };
177
178
  const ambientTools = { ...systemTools, ...(config.tools ?? {}) };
178
179
  const style = resolveStyleInstructions(config);
180
+ // Default-on protection against the model copying turn-scoped
181
+ // chartIds from prior assistant tool results into the new
182
+ // turn's `[[chart:<id>]]` markers. Opt out per-plugin via
183
+ // `config.stripStaleCharts: false`.
184
+ const inputProcessors = config.stripStaleCharts === false ? [] : [stripStaleChartsProcessor];
179
185
  const agents = {};
180
186
  for (const [id, def] of Object.entries(definitions)) {
181
187
  const tools = await resolveTools(def.tools, plugins, ambientTools);
@@ -188,6 +194,7 @@ export async function buildAgents(opts) {
188
194
  model: resolveModel(config, def.model),
189
195
  tools,
190
196
  ...(memory ? { memory } : {}),
197
+ ...(inputProcessors.length > 0 ? { inputProcessors } : {}),
191
198
  });
192
199
  }
193
200
  if (!agents[defaultAgentId]) {
@@ -318,7 +325,7 @@ async function resolveTools(defTools, plugins, ambientTools) {
318
325
  * Mastra `ctx.writer`, so the UI gets `tool-output` chunks in real
319
326
  * time instead of staring at a spinner for the full Genie round-trip.
320
327
  */
321
- function buildPluginsMap(context) {
328
+ function buildPluginsMap(config, context) {
322
329
  const cache = new Map();
323
330
  return new Proxy({}, {
324
331
  get(_target, propName) {
@@ -326,7 +333,7 @@ function buildPluginsMap(context) {
326
333
  return undefined;
327
334
  if (cache.has(propName))
328
335
  return cache.get(propName) ?? undefined;
329
- const provider = resolveProvider(context, propName);
336
+ const provider = resolveProvider(config, context, propName);
330
337
  cache.set(propName, provider);
331
338
  return provider ?? undefined;
332
339
  },
@@ -336,14 +343,17 @@ function buildPluginsMap(context) {
336
343
  * Pick the right {@link MastraPluginToolkitProvider} for a sibling
337
344
  * plugin lookup. Returns the streaming-aware Genie adapter when the
338
345
  * caller asks for `genie`; falls back to the generic AppKit
339
- * `ToolProvider` adapter for every other plugin name.
346
+ * `ToolProvider` adapter for every other plugin name. `config` is
347
+ * threaded through so Genie's tool can run the chart planner
348
+ * inline against the same model resolver / fallback ladder the
349
+ * agents use.
340
350
  */
341
- function resolveProvider(context, propName) {
351
+ function resolveProvider(config, context, propName) {
342
352
  if (propName === "genie") {
343
353
  const geniePlugin = pluginUtils.instance(context, genie);
344
354
  if (!geniePlugin)
345
355
  return null;
346
- return buildGenieProvider(geniePlugin);
356
+ return buildGenieProvider(geniePlugin, { config });
347
357
  }
348
358
  const plugin = context?.getPlugins().get(propName);
349
359
  return adaptPluginToolkit(plugin);
@@ -1,31 +1,37 @@
1
1
  /**
2
2
  * Chart-rendering primitives.
3
3
  *
4
- * Two surfaces, one shared brain:
4
+ * Three surfaces, one shared brain:
5
5
  *
6
6
  * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
- * ("here is a dataset, render it as a chart"). The tool is
8
- * fire-and-forget by design - it generates a short `chartId`,
9
- * pushes a single `kind: "chart"` event onto `ctx.writer` carrying
10
- * the raw rows, and returns the id to the model immediately. No
11
- * chart planning happens inside the agentic loop, so the model
12
- * never blocks on a downstream LLM call to get its identifier.
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.
13
21
  *
14
22
  * - {@link runChartPlanner}: the chart-planner Agent + ECOption
15
- * expansion as a plain async function. The HTTP route in
16
- * {@link ./render-chart-route.ts} calls this when the client
17
- * POSTs the dataset back; the result is an `EChartsOption` JSON
18
- * the React `<ChartSlot>` renders inline. Decoupling the planner
19
- * from the tool means the planning latency lives entirely
20
- * client-side: the model can finish writing its report while
21
- * the client is still rendering the charts.
23
+ * expansion as a plain async function. Used internally by
24
+ * {@link emitChartWithPlanning}; producers shouldn't reach for
25
+ * it directly so chart events keep a single wire-format
26
+ * contract.
22
27
  *
23
28
  * The model wires the chart into its reply by emitting the marker
24
29
  * `[[chart:<chartId>]]` on its own line in markdown. The chat
25
30
  * client splits the assistant text on these markers and drops a
26
31
  * `<ChartSlot>` in at the position the model placed it; the slot
27
- * then fires the render-chart endpoint on mount and shows a
28
- * skeleton until the option lands.
32
+ * shows a skeleton until the second `kind: "chart"` event (with
33
+ * the resolved `EChartsOption`) arrives, then swaps in the
34
+ * rendered Echarts visualisation.
29
35
  */
30
36
  import type { RequestContext } from "@mastra/core/request-context";
31
37
  import { z } from "zod";
@@ -76,29 +82,89 @@ export interface RunChartPlannerResult {
76
82
  }
77
83
  /**
78
84
  * Run the chart planner against the given dataset and return a
79
- * full Echarts `EChartsOption` JSON. Used by the HTTP route the
80
- * client hits when it sees a `[[chart:<chartId>]]` marker; the
81
- * tool itself does not call this so the model never blocks on
82
- * planning latency.
85
+ * full Echarts `EChartsOption` JSON. Used by
86
+ * {@link emitChartWithPlanning}; tools and producers shouldn't
87
+ * call this directly (use the helper instead so chart events
88
+ * follow the same wire-format contract everywhere).
83
89
  */
84
90
  export declare function runChartPlanner(opts: RunChartPlannerOptions): Promise<RunChartPlannerResult>;
85
91
  /**
86
- * Build the `render_data` tool bound to the given plugin config.
87
- *
88
- * Fire-and-forget by design: the tool returns immediately with a
89
- * short `chartId` and emits a single `kind: "chart"` event over
90
- * `ctx.writer` carrying the raw dataset for the client. The
91
- * client's chart slot then POSTs the data to
92
- * `/route/render-chart` to get an `EChartsOption` back from the
93
- * planner agent. This keeps the calling LLM unblocked - it can
94
- * write the report referencing the chart by id while the client
95
- * is still rendering it.
92
+ * Minimal `ToolStream`-shaped writer surface. Defined locally so
93
+ * helpers can take any object with a `.write` method without
94
+ * importing Mastra's full `ToolStream` (which would also drag in
95
+ * agent / tool types this module doesn't otherwise need).
96
96
  */
97
- export declare function buildRenderDataTool(_config: MastraPluginConfig): import("@mastra/core/tools").Tool<{
97
+ interface MinimalWriter {
98
+ write: (chunk: unknown) => unknown;
99
+ }
100
+ /** Inputs to {@link emitChartWithPlanning}. */
101
+ export interface EmitChartWithPlanningOptions {
102
+ /** Mastra `ctx.writer`; missing or closed writers are tolerated. */
103
+ writer?: MinimalWriter;
104
+ /** Plugin config; used to resolve the planner's model. */
105
+ config: MastraPluginConfig;
106
+ /** Per-request context (OBO auth). */
107
+ requestContext?: RequestContext;
108
+ /** Title shown above the rendered chart. Required. */
98
109
  title: string;
99
- data: Record<string, unknown>[];
100
- description?: string | undefined;
101
- }, {
110
+ /** Optional one-line intent biasing the planner. */
111
+ description?: string;
112
+ /** Tabular dataset to chart (one object per row). */
113
+ data: ReadonlyArray<Record<string, unknown>>;
114
+ }
115
+ /** Output of {@link emitChartWithPlanning}. */
116
+ export interface EmitChartWithPlanningResult {
117
+ /** Short id matching the marker `[[chart:<chartId>]]`. */
102
118
  chartId: string;
103
- }, unknown, unknown, import("@mastra/core/tools").ToolExecutionContext<unknown, unknown, unknown>, "render_data", unknown>;
119
+ /**
120
+ * Promise that resolves once the planner has finished and the
121
+ * `kind: "chart"` event with the option has been emitted (or
122
+ * once the planner has failed silently). Callers that want
123
+ * trace observability should `await` this before returning
124
+ * from their tool's `execute`; callers that want pure
125
+ * fire-and-forget can ignore it.
126
+ */
127
+ plannerPromise: Promise<void>;
128
+ }
129
+ /**
130
+ * Shared chart-emission primitive used by both the `render_data`
131
+ * tool and Genie's `drainGenieStream`. Keeps both producers on
132
+ * one wire-format contract so the chat client only ever has to
133
+ * understand a single chart event shape.
134
+ *
135
+ * Behaviour:
136
+ *
137
+ * 1. Generates a short `chartId` (8 hex chars).
138
+ * 2. Immediately emits `{ kind: "chart", chartId, title,
139
+ * description?, data }` via the writer so the chat client can
140
+ * mount its `<ChartSlot>` with the rows in hand.
141
+ * 3. Kicks off the chart-planner agent in the background. On
142
+ * success, emits a second `{ kind: "chart", chartId, option }`
143
+ * event - same `chartId`, just the spec - so the client merges
144
+ * the two into one rendered chart. On failure, no follow-up
145
+ * event fires; the client falls back to whatever it can do
146
+ * with the dataset alone (typically a "render failed" frame
147
+ * after the parent tool finishes).
148
+ *
149
+ * Returns `chartId` synchronously so the caller can include it in
150
+ * the tool result (model uses it in `[[chart:<chartId>]]`
151
+ * markers), and `plannerPromise` so the caller can choose
152
+ * trace-spanning vs. snappy-return semantics.
153
+ */
154
+ export declare function emitChartWithPlanning(opts: EmitChartWithPlanningOptions): Promise<EmitChartWithPlanningResult>;
155
+ /**
156
+ * Build the `render_data` tool bound to the given plugin config.
157
+ *
158
+ * The tool is a thin wrapper around {@link emitChartWithPlanning}:
159
+ * a single `kind: "chart"` writer event ships the raw rows to
160
+ * the client immediately, the chart-planner agent runs alongside
161
+ * (so the calling LLM stays unblocked while the planner thinks),
162
+ * and a follow-up `kind: "chart"` event with the resolved
163
+ * `EChartsOption` lands when it's ready. The tool's `execute`
164
+ * awaits the planner promise so the planner work shows up under
165
+ * the tool's trace span; the LLM still gets back just
166
+ * `{ chartId }`, so its context stays small regardless of dataset
167
+ * size.
168
+ */
169
+ export declare function buildRenderDataTool(config: MastraPluginConfig): import("@mastra/core/tools").Tool<any, any, any, any, import("@mastra/core/tools").ToolExecutionContext<any, any, unknown>, "render_data", unknown>;
104
170
  export {};
package/dist/src/chart.js CHANGED
@@ -1,38 +1,52 @@
1
1
  /**
2
2
  * Chart-rendering primitives.
3
3
  *
4
- * Two surfaces, one shared brain:
4
+ * Three surfaces, one shared brain:
5
5
  *
6
6
  * - {@link buildRenderDataTool}: a Mastra tool the model calls
7
- * ("here is a dataset, render it as a chart"). The tool is
8
- * fire-and-forget by design - it generates a short `chartId`,
9
- * pushes a single `kind: "chart"` event onto `ctx.writer` carrying
10
- * the raw rows, and returns the id to the model immediately. No
11
- * chart planning happens inside the agentic loop, so the model
12
- * never blocks on a downstream LLM call to get its identifier.
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.
13
21
  *
14
22
  * - {@link runChartPlanner}: the chart-planner Agent + ECOption
15
- * expansion as a plain async function. The HTTP route in
16
- * {@link ./render-chart-route.ts} calls this when the client
17
- * POSTs the dataset back; the result is an `EChartsOption` JSON
18
- * the React `<ChartSlot>` renders inline. Decoupling the planner
19
- * from the tool means the planning latency lives entirely
20
- * client-side: the model can finish writing its report while
21
- * the client is still rendering the charts.
23
+ * expansion as a plain async function. Used internally by
24
+ * {@link emitChartWithPlanning}; producers shouldn't reach for
25
+ * it directly so chart events keep a single wire-format
26
+ * contract.
22
27
  *
23
28
  * The model wires the chart into its reply by emitting the marker
24
29
  * `[[chart:<chartId>]]` on its own line in markdown. The chat
25
30
  * client splits the assistant text on these markers and drops a
26
31
  * `<ChartSlot>` in at the position the model placed it; the slot
27
- * then fires the render-chart endpoint on mount and shows a
28
- * skeleton until the option lands.
32
+ * shows a skeleton until the second `kind: "chart"` event (with
33
+ * the resolved `EChartsOption`) arrives, then swaps in the
34
+ * rendered Echarts visualisation.
29
35
  */
30
36
  import { randomUUID } from "node:crypto";
31
- import { stringUtils } from "@dbx-tools/appkit-shared";
37
+ import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
32
38
  import { Agent } from "@mastra/core/agent";
33
39
  import { createTool } from "@mastra/core/tools";
34
40
  import { z } from "zod";
35
41
  import { ModelTier, modelForTier, buildModel } from "./model.js";
42
+ /**
43
+ * Module-level logger tagged `[mastra/chart]`. Uses the shared
44
+ * {@link logUtils.logger} so calls below `LOG_LEVEL` are
45
+ * discarded for free. Default `LOG_LEVEL` is `info`; flip to
46
+ * `debug` to see the per-chart timeline (`emit:start` →
47
+ * `write:ok(data)` → `planner:done` → `write:ok(option)`).
48
+ */
49
+ const log = logUtils.logger("mastra/chart");
36
50
  /**
37
51
  * Compact, model-friendly representation of an Echarts spec. The
38
52
  * planner agent emits this; {@link planToEchartsOption} expands it
@@ -179,10 +193,10 @@ function getPlannerAgent(config) {
179
193
  }
180
194
  /**
181
195
  * Run the chart planner against the given dataset and return a
182
- * full Echarts `EChartsOption` JSON. Used by the HTTP route the
183
- * client hits when it sees a `[[chart:<chartId>]]` marker; the
184
- * tool itself does not call this so the model never blocks on
185
- * planning latency.
196
+ * full Echarts `EChartsOption` JSON. Used by
197
+ * {@link emitChartWithPlanning}; tools and producers shouldn't
198
+ * call this directly (use the helper instead so chart events
199
+ * follow the same wire-format contract everywhere).
186
200
  */
187
201
  export async function runChartPlanner(opts) {
188
202
  const { config, requestContext, title, description, data } = opts;
@@ -206,6 +220,117 @@ export async function runChartPlanner(opts) {
206
220
  const option = planToEchartsOption(plan, title);
207
221
  return { option, chartType: plan.chartType };
208
222
  }
223
+ /**
224
+ * Shared chart-emission primitive used by both the `render_data`
225
+ * tool and Genie's `drainGenieStream`. Keeps both producers on
226
+ * one wire-format contract so the chat client only ever has to
227
+ * understand a single chart event shape.
228
+ *
229
+ * Behaviour:
230
+ *
231
+ * 1. Generates a short `chartId` (8 hex chars).
232
+ * 2. Immediately emits `{ kind: "chart", chartId, title,
233
+ * description?, data }` via the writer so the chat client can
234
+ * mount its `<ChartSlot>` with the rows in hand.
235
+ * 3. Kicks off the chart-planner agent in the background. On
236
+ * success, emits a second `{ kind: "chart", chartId, option }`
237
+ * event - same `chartId`, just the spec - so the client merges
238
+ * the two into one rendered chart. On failure, no follow-up
239
+ * event fires; the client falls back to whatever it can do
240
+ * with the dataset alone (typically a "render failed" frame
241
+ * after the parent tool finishes).
242
+ *
243
+ * Returns `chartId` synchronously so the caller can include it in
244
+ * the tool result (model uses it in `[[chart:<chartId>]]`
245
+ * markers), and `plannerPromise` so the caller can choose
246
+ * trace-spanning vs. snappy-return semantics.
247
+ */
248
+ export async function emitChartWithPlanning(opts) {
249
+ const { writer, config, requestContext, title, description, data } = opts;
250
+ // Short, marker-friendly id. The LLM types this verbatim into
251
+ // `[[chart:<id>]]`; an 8-hex-char prefix is unique within a
252
+ // single assistant turn (collision odds ~1 in 4 billion) and
253
+ // much less error-prone for the model to reproduce.
254
+ const chartId = randomUUID().replace(/-/g, "").slice(0, 8);
255
+ log.debug("emit:start", {
256
+ chartId,
257
+ title,
258
+ rows: data.length,
259
+ columns: data[0] ? Object.keys(data[0]) : [],
260
+ hasWriter: writer !== undefined,
261
+ });
262
+ // Initial event: rows + metadata, no option yet. The client
263
+ // mounts a chart slot that shows a skeleton until the option
264
+ // event arrives (or until the parent tool finishes without
265
+ // one, in which case it falls back).
266
+ await safeWrite(writer, chartId, "data", {
267
+ kind: "chart",
268
+ chartId,
269
+ title,
270
+ ...(description ? { description } : {}),
271
+ data,
272
+ });
273
+ // Background planner. Awaitable for trace observability via the
274
+ // returned `plannerPromise`; safe to ignore for pure
275
+ // fire-and-forget. Failures are intentionally swallowed (only
276
+ // logged): the dataset event already landed, so the client has
277
+ // enough to surface a fallback.
278
+ const plannerPromise = (async () => {
279
+ const startedAt = Date.now();
280
+ try {
281
+ const { option, chartType } = await runChartPlanner({
282
+ config,
283
+ ...(requestContext ? { requestContext } : {}),
284
+ title,
285
+ ...(description ? { description } : {}),
286
+ data,
287
+ });
288
+ log.debug("planner:done", {
289
+ chartId,
290
+ chartType,
291
+ elapsedMs: Date.now() - startedAt,
292
+ });
293
+ await safeWrite(writer, chartId, "option", { kind: "chart", chartId, option });
294
+ }
295
+ catch (err) {
296
+ // No follow-up event on failure. The client treats a
297
+ // dataset-only chart slot as "render failed" once the
298
+ // parent tool's status flips to done. Surface as a `warn`
299
+ // so the failure is visible at the default log level
300
+ // without being mistaken for a fatal error.
301
+ log.warn("planner:error", {
302
+ chartId,
303
+ elapsedMs: Date.now() - startedAt,
304
+ error: err instanceof Error ? err.message : String(err),
305
+ });
306
+ }
307
+ })();
308
+ return { chartId, plannerPromise };
309
+ }
310
+ /**
311
+ * Best-effort writer.write. Failures are logged at `warn` (a
312
+ * persistently-closed writer is the most likely culprit when
313
+ * chart events go missing client-side) but swallowed so a closed
314
+ * downstream stream (cancelled request, client navigated away)
315
+ * can't take a tool down.
316
+ */
317
+ async function safeWrite(writer, chartId, phase, chunk) {
318
+ if (!writer) {
319
+ log.debug("write:no-writer", { chartId, phase });
320
+ return;
321
+ }
322
+ try {
323
+ await writer.write(chunk);
324
+ log.debug("write:ok", { chartId, phase });
325
+ }
326
+ catch (err) {
327
+ log.warn("write:error", {
328
+ chartId,
329
+ phase,
330
+ error: err instanceof Error ? err.message : String(err),
331
+ });
332
+ }
333
+ }
209
334
  const renderDataInputSchema = z.object({
210
335
  title: z.string().describe(stringUtils.toDescription `
211
336
  Title shown above the rendered chart. Use a concise
@@ -231,30 +356,27 @@ const renderDataInputSchema = z.object({
231
356
  });
232
357
  const renderDataOutputSchema = z.object({
233
358
  chartId: z.string().describe(stringUtils.toDescription `
234
- Identifier of the queued chart. The tool returned
235
- immediately - actual chart planning happens client-side
236
- asynchronously. To position the chart in your reply, embed
237
- the marker \`[[chart:<chartId>]]\` on its own line (with
238
- blank lines above and below) where the chart should appear.
239
- The client renders a skeleton there until the chart is
240
- ready, then swaps in the visualization in place. You can
241
- keep writing prose around the marker; the agent does not
242
- need to wait for the chart to render.
359
+ Identifier of the queued chart. To position the chart in
360
+ your reply, embed the marker \`[[chart:<chartId>]]\` on its
361
+ own line where the chart should appear; the client renders
362
+ it inline.
243
363
  `),
244
364
  });
245
365
  /**
246
366
  * Build the `render_data` tool bound to the given plugin config.
247
367
  *
248
- * Fire-and-forget by design: the tool returns immediately with a
249
- * short `chartId` and emits a single `kind: "chart"` event over
250
- * `ctx.writer` carrying the raw dataset for the client. The
251
- * client's chart slot then POSTs the data to
252
- * `/route/render-chart` to get an `EChartsOption` back from the
253
- * planner agent. This keeps the calling LLM unblocked - it can
254
- * write the report referencing the chart by id while the client
255
- * is still rendering it.
368
+ * The tool is a thin wrapper around {@link emitChartWithPlanning}:
369
+ * a single `kind: "chart"` writer event ships the raw rows to
370
+ * the client immediately, the chart-planner agent runs alongside
371
+ * (so the calling LLM stays unblocked while the planner thinks),
372
+ * and a follow-up `kind: "chart"` event with the resolved
373
+ * `EChartsOption` lands when it's ready. The tool's `execute`
374
+ * awaits the planner promise so the planner work shows up under
375
+ * the tool's trace span; the LLM still gets back just
376
+ * `{ chartId }`, so its context stays small regardless of dataset
377
+ * size.
256
378
  */
257
- export function buildRenderDataTool(_config) {
379
+ export function buildRenderDataTool(config) {
258
380
  return createTool({
259
381
  id: "render_data",
260
382
  description: stringUtils.toDescription `
@@ -262,9 +384,8 @@ export function buildRenderDataTool(_config) {
262
384
  the user's view. Pass a title, the raw rows (array of
263
385
  objects keyed by column name), and an optional one-line
264
386
  description of the insight to highlight. Returns a short
265
- \`chartId\` immediately - chart planning happens
266
- asynchronously in the client, not in this turn, so the tool
267
- does not block your prose.
387
+ \`chartId\`; the chart renders inline at the position you
388
+ embed the matching \`[[chart:<chartId>]]\` marker.
268
389
 
269
390
  Placement contract: embed \`[[chart:<chartId>]]\` on its own
270
391
  line (blank lines above and below) wherever you want the
@@ -286,26 +407,21 @@ export function buildRenderDataTool(_config) {
286
407
  outputSchema: renderDataOutputSchema,
287
408
  execute: async (input, ctx) => {
288
409
  const { title, description, data } = input;
289
- // Short, marker-friendly id. The LLM has to type this
290
- // verbatim into the `[[chart:<id>]]` marker; an 8-hex-char
291
- // prefix is unique within a single assistant turn (collision
292
- // odds ~1 in 4 billion) and much less error-prone for the
293
- // model to reproduce.
294
- const chartId = randomUUID().replace(/-/g, "").slice(0, 8);
295
- const writer = ctx
296
- ?.writer;
297
- try {
298
- await writer?.write({
299
- kind: "chart",
300
- chartId,
301
- title,
302
- ...(description ? { description } : {}),
303
- data,
304
- });
305
- }
306
- catch {
307
- // Ignore: the parent stream may have closed downstream.
308
- }
410
+ const writer = ctx?.writer;
411
+ const requestContext = ctx
412
+ ?.requestContext;
413
+ const { chartId, plannerPromise } = await emitChartWithPlanning({
414
+ ...(writer ? { writer } : {}),
415
+ config,
416
+ ...(requestContext ? { requestContext } : {}),
417
+ title,
418
+ ...(description ? { description } : {}),
419
+ data,
420
+ });
421
+ // Await the planner so its latency is attributed to this
422
+ // tool's trace span. The promise itself swallows planner
423
+ // failures, so this never throws.
424
+ await plannerPromise;
309
425
  return { chartId };
310
426
  },
311
427
  });
@@ -152,6 +152,19 @@ export interface MastraPluginConfig extends BasePluginConfig {
152
152
  * or to add custom endpoints in front of the public catalogue.
153
153
  */
154
154
  defaultModelFallbacks?: readonly string[];
155
+ /**
156
+ * When `true` (default), every agent gets a built-in input
157
+ * processor that strips `chartId` fields from prior assistant
158
+ * tool-invocation results before they reach the model. This
159
+ * prevents the model from reusing turn-scoped chartIds it sees
160
+ * in memory recall (which would leave `[[chart:<id>]]` markers
161
+ * pointing at writer events that no longer exist).
162
+ *
163
+ * Set to `false` to opt out - useful if a non-default agent
164
+ * needs full visibility into prior chartIds (e.g. an audit
165
+ * agent reasoning about chart lineage).
166
+ */
167
+ stripStaleCharts?: boolean;
155
168
  /**
156
169
  * Style guardrails appended to every agent's `instructions` to curb
157
170
  * common LLM-isms (em dashes, emojis, sycophantic openers, throwaway