@dbx-tools/appkit-mastra 0.1.26 → 0.1.28

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/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
- * AppKit Mastra integration: {@link MastraPlugin} / {@link mastra},
3
- * plugin config types, agent registration helpers, Genie tool
4
- * builders, and dynamic Model Serving endpoint resolution.
2
+ * Server-side entry point for the AppKit Mastra integration. Mounts
3
+ * the plugin via {@link mastra} and re-exports the full server surface
4
+ * (config, agent wiring, Genie and chart tooling, and dynamic Model
5
+ * Serving resolution) so apps build agent backends from one import.
5
6
  *
6
7
  * Client-side consumers should import URL helpers and the
7
8
  * {@link MastraClientConfig} type from `@dbx-tools/appkit-mastra-shared`
package/package.json CHANGED
@@ -9,13 +9,13 @@
9
9
  }
10
10
  },
11
11
  "name": "@dbx-tools/appkit-mastra",
12
- "version": "0.1.26",
12
+ "version": "0.1.28",
13
13
  "dependencies": {
14
14
  "@databricks/sdk-experimental": "^0.17",
15
- "@dbx-tools/appkit-mastra-shared": "0.1.26",
16
- "@dbx-tools/genie": "0.1.26",
17
- "@dbx-tools/genie-shared": "0.1.26",
18
- "@dbx-tools/shared": "0.1.26",
15
+ "@dbx-tools/appkit-mastra-shared": "0.1.28",
16
+ "@dbx-tools/genie": "0.1.28",
17
+ "@dbx-tools/genie-shared": "0.1.28",
18
+ "@dbx-tools/shared": "0.1.28",
19
19
  "@mastra/ai-sdk": "^1",
20
20
  "@mastra/core": "^1",
21
21
  "@mastra/express": "^1",
package/src/agents.ts CHANGED
@@ -431,7 +431,7 @@ export async function buildAgents(opts: {
431
431
  const style = resolveStyleInstructions(config);
432
432
  // Default-on protection against the model copying turn-scoped
433
433
  // chartIds from prior assistant tool results into the new
434
- // turn's `[[chart:<id>]]` markers. Opt out per-plugin via
434
+ // turn's `[chart:<id>]` markers. Opt out per-plugin via
435
435
  // `config.stripStaleCharts: false`.
436
436
  const inputProcessors =
437
437
  config.stripStaleCharts === false ? [] : [stripStaleChartsProcessor];
package/src/chart.ts CHANGED
@@ -20,18 +20,9 @@
20
20
  * dataset, custom API). The module has no knowledge of Genie or
21
21
  * statement ids; those concerns live in the tools that wrap it.
22
22
  *
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`)
23
+ * Wire-format schemas live in `@dbx-tools/appkit-mastra-shared` 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
28
  import { CacheManager } from "@databricks/appkit";
@@ -238,13 +229,11 @@ type ChartPlan = z.infer<typeof chartPlanSchema>;
238
229
  * sees a planner request, only the resolved {@link Chart}.
239
230
  */
240
231
  export const chartPlannerRequestSchema = z.object({
241
- title: z
242
- .string()
243
- .describe(
244
- stringUtils.toDescription(`
232
+ title: z.string().describe(
233
+ stringUtils.toDescription(`
245
234
  Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
246
235
  `),
247
- ),
236
+ ),
248
237
  description: z
249
238
  .string()
250
239
  .optional()
@@ -676,7 +665,7 @@ function planToEchartsOption(
676
665
  * an empty placeholder, and kicks off the chart-planner in the
677
666
  * background. Returns just the `chartId`; the host UI resolves
678
667
  * `[chart:<chartId>]` markers by hitting the plugin's
679
- * `/charts/:chartId` route.
668
+ * `/embed/chart/:id` route.
680
669
  *
681
670
  * For Genie statement results, prefer the Genie agent's
682
671
  * `prepare_chart` tool, which accepts a `statement_id` and
package/src/config.ts CHANGED
@@ -216,7 +216,7 @@ export interface MastraPluginConfig extends BasePluginConfig {
216
216
  * processor that strips `chartId` fields from prior assistant
217
217
  * tool-invocation results before they reach the model. This
218
218
  * prevents the model from reusing turn-scoped chartIds it sees
219
- * in memory recall (which would leave `[[chart:<id>]]` markers
219
+ * in memory recall (which would leave `[chart:<id>]` markers
220
220
  * pointing at writer events that no longer exist).
221
221
  *
222
222
  * Set to `false` to opt out - useful if a non-default agent
package/src/genie.ts CHANGED
@@ -1,41 +1,19 @@
1
1
  /**
2
2
  * Genie tools for Mastra.
3
3
  *
4
- * Each configured Genie space is surfaced as a small set of flat
5
- * Mastra tools the calling agent can drive directly - no inner
6
- * orchestrator agent. The central agent decomposes user questions,
7
- * picks which Genie space to ask, and composes the final reply.
8
- *
9
- * Per-space tools (suffixed with `_<alias>` for non-default
10
- * aliases):
11
- *
12
- * - `ask_genie`: drives one `genieEventChat` turn and
13
- * forwards every wire event (status, thinking, sql, rows)
14
- * through `ctx.writer` for streaming UI updates. Returns
15
- * the terminal `GenieMessage` only - rows are NOT fetched
16
- * eagerly.
17
- * - `get_space_description`: cheap title / description /
18
- * warehouse id lookup for grounding.
19
- * - `get_space_serialized`: full `GenieSpace` JSON for
20
- * column-level grounding when the description isn't enough.
21
- *
22
- * Space-agnostic shared tools (registered once, regardless of
23
- * how many spaces are wired):
24
- *
25
- * - `get_statement`: opt-in lookup of a Genie statement's rows
26
- * by `statement_id` (with a row `limit`). The agent calls
27
- * this only when it needs to read values to reason about
28
- * them; if the data is just being displayed, it embeds a
29
- * `[data:<statement_id>]` marker in prose instead and lets
30
- * the host UI resolve it.
31
- * - `prepare_chart`: mints a short `chartId`, kicks off a
32
- * background task that fetches the statement's rows and
33
- * runs the chart-planner, and caches the resolved Echarts
34
- * spec under the id (1h TTL). Returns the `chartId`
35
- * synchronously so the agent embeds `[chart:<chartId>]`
36
- * markers in prose without blocking on chart generation;
37
- * the host UI fetches the cached chart by id once it's
38
- * ready (see {@link fetchChart}).
4
+ * Surfaces each configured Genie space as a small set of flat Mastra
5
+ * tools the calling agent drives directly - no inner orchestrator
6
+ * agent. The central agent decomposes user questions, picks which
7
+ * space to ask, streams the per-turn wire events (status, thinking,
8
+ * sql, rows) through `ctx.writer`, and composes the final reply.
9
+ * Rows are never fetched eagerly: the agent reads a statement's
10
+ * values only when it needs to reason about them, otherwise it embeds
11
+ * a `[data:<statement_id>]` marker in prose and lets the host UI
12
+ * resolve the data. Charts are minted asynchronously and referenced
13
+ * by `[chart:<chartId>]` markers so prose isn't blocked on chart
14
+ * generation; the host UI fetches the cached spec by id once ready.
15
+ * Space description and serialized-space lookups are available for
16
+ * grounding when the agent needs schema context.
39
17
  *
40
18
  * Each tool's `execute` pulls the per-request
41
19
  * {@link WorkspaceClient} off `ctx.requestContext` (stamped by
@@ -725,9 +703,13 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
725
703
  `
726
704
  To display the chart in your reply, embed
727
705
  \`[chart:<chartId>]\` on its own line at the position you
728
- want it to appear. The tool returns immediately - do NOT
729
- wait or call it again to "check progress"; the chart
730
- resolves asynchronously on the host UI's side.
706
+ want it to appear, using the EXACT \`chartId\` string this
707
+ call returned. Never construct a chart id yourself (it is
708
+ not the \`statement_id\` or any variation of it) - only a
709
+ value returned by this tool resolves to a real chart. The
710
+ tool returns immediately - do NOT wait or call it again to
711
+ "check progress"; the chart resolves asynchronously on the
712
+ host UI's side.
731
713
  `,
732
714
  `
733
715
  Use this only when the data has a story a chart conveys
@@ -842,6 +824,17 @@ export const GENIE_INSTRUCTIONS = stringUtils.toDescription([
842
824
  chart should appear. Use a chart when the data has a
843
825
  story a visual conveys better than a table (trends,
844
826
  rankings, distributions, parts-of-a-whole).
827
+
828
+ NEVER invent or hand-build a \`<chartId>\`. A valid
829
+ \`<chartId>\` is the opaque token a \`prepare_chart\`
830
+ call returned to you in THIS turn - nothing else. It
831
+ is NOT a \`statement_id\`, and it is NOT a
832
+ \`statement_id\` prefix with a label appended (e.g.
833
+ \`01f1...-region-fill\`). If you have not called
834
+ \`prepare_chart\` and received an id back, do not write
835
+ a \`[chart:...]\` marker at all - use \`[data:...]\`
836
+ instead. A fabricated chart id renders nothing and
837
+ wastes a request.
845
838
  `,
846
839
  ],
847
840
  },
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Server-side Server-Sent-Events frame interceptor for the native
3
+ * Mastra agent `/stream` endpoint.
4
+ *
5
+ * The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
6
+ * SSE frame per chunk, where the decoded JSON carries a
7
+ * discriminating `type` (`text-delta`, `reasoning-delta`,
8
+ * `tool-call`, `step-finish`, ...) and is terminated by a
9
+ * `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
10
+ * an Express response and runs a caller-supplied
11
+ * {@link StreamFrameInterceptor} over each decoded frame so the caller
12
+ * can keep, rewrite, or drop it; every non-`data:` byte (SSE
13
+ * keep-alive comments, the `[DONE]` sentinel, blank padding) passes
14
+ * through verbatim.
15
+ *
16
+ * The wrap is inert unless the response turns out to be a streamed
17
+ * `200 text/event-stream` body, so non-streaming JSON routes and
18
+ * error responses are never touched.
19
+ */
20
+
21
+ import { StringDecoder } from "node:string_decoder";
22
+
23
+ import type express from "express";
24
+
25
+ /** SSE event separator used by the Mastra stream wire format. */
26
+ const FRAME_DELIMITER = "\n\n";
27
+
28
+ /**
29
+ * Matches a whole SSE frame whose `data:` payload is a JSON object,
30
+ * capturing it in three parts: the `data:` field prefix (including
31
+ * whatever inter-token whitespace the producer used), the JSON object
32
+ * body, and any trailing whitespace. Anchored end-to-end so it only
33
+ * matches a single self-contained object frame; anything else (SSE
34
+ * comments, the `[DONE]` sentinel, text/array payloads) fails the
35
+ * match and is forwarded verbatim without a parse attempt. On rewrite
36
+ * the captured prefix/suffix are re-emitted as-is so only the body
37
+ * changes.
38
+ */
39
+ const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
40
+
41
+ /**
42
+ * Per-frame interceptor invoked with the `JSON.parse`d object of every
43
+ * `data: {json}` frame on the Mastra `/stream` response. The return
44
+ * value decides the frame's fate:
45
+ *
46
+ * - `true`: forward the frame's original bytes unchanged (no
47
+ * re-serialization).
48
+ * - `false`: drop the frame entirely.
49
+ * - `{ replace }`: re-emit the frame with the original `data:` prefix
50
+ * and trailing whitespace preserved and only the JSON body replaced
51
+ * by `JSON.stringify(replace)`.
52
+ */
53
+ export type StreamFrameInterceptor = (
54
+ chunk: unknown,
55
+ ) => { replace: unknown } | true | false;
56
+
57
+ /**
58
+ * True when `res` is a `200 text/event-stream` body - the only
59
+ * response shape we intercept. Content-type based so it tracks the
60
+ * Mastra `/stream` route without hard-coding the path, and naturally
61
+ * skips the JSON routes (history, models, charts, statements) and any
62
+ * non-200 error response.
63
+ */
64
+ function isEventStream(res: express.Response): boolean {
65
+ if (res.statusCode !== 200) return false;
66
+ const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
67
+ return contentType.includes("text/event-stream");
68
+ }
69
+
70
+ /**
71
+ * Run `interceptor` over a single SSE frame. Forwards anything that
72
+ * isn't a parseable `data: {json}` object frame verbatim (comments,
73
+ * the `[DONE]` sentinel, blank padding, unparseable / non-object
74
+ * payloads) so the interceptor only ever sees real chunk objects.
75
+ * Returns the frame string to emit, or `undefined` to drop the frame.
76
+ */
77
+ function interceptFrame(
78
+ frame: string,
79
+ interceptor: StreamFrameInterceptor,
80
+ ): string | undefined {
81
+ const match = DATA_OBJECT_RE.exec(frame);
82
+ if (!match) return frame;
83
+ const [, prefix, body, suffix] = match;
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(body!);
87
+ } catch {
88
+ // Forward unparseable payloads untouched - feeding a partial /
89
+ // malformed frame to the interceptor risks corrupting bytes the
90
+ // client could still have handled.
91
+ return frame;
92
+ }
93
+ const result = interceptor(parsed);
94
+ if (result === true) return frame;
95
+ if (result === false) return undefined;
96
+ // Preserve the exact captured prefix/suffix so only the JSON body
97
+ // changes; the framing the client parses stays byte-identical.
98
+ return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
99
+ }
100
+
101
+ /**
102
+ * Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
103
+ * response is run through `interceptor` (keep / rewrite / drop).
104
+ *
105
+ * Interception only engages once the response is confirmed to be a
106
+ * `200 text/event-stream` body (decided lazily on the first write,
107
+ * after the handler has set status + content-type); any other
108
+ * response streams through unchanged. A {@link StringDecoder}
109
+ * reassembles multi-byte UTF-8 sequences that straddle chunk
110
+ * boundaries, and a running buffer holds the trailing partial frame
111
+ * until its `\n\n` arrives.
112
+ */
113
+ export function installStreamEventInterceptor(
114
+ res: express.Response,
115
+ interceptor: StreamFrameInterceptor,
116
+ ): void {
117
+ const origWrite = res.write.bind(res);
118
+ const origEnd = res.end.bind(res);
119
+ const decoder = new StringDecoder("utf8");
120
+ let engaged: boolean | undefined;
121
+ let buffer = "";
122
+
123
+ // Append decoded text, emit every whole `\n\n`-delimited frame the
124
+ // interceptor keeps, and retain any trailing partial frame in
125
+ // `buffer`. On `final`, the residual buffer is flushed as the last
126
+ // frame so a stream that doesn't end on a delimiter isn't dropped.
127
+ const intercept = (text: string, final: boolean): string => {
128
+ buffer += text;
129
+
130
+ const out: string[] = [];
131
+
132
+ let idx: number;
133
+ while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
134
+ const frame = buffer.slice(0, idx);
135
+ buffer = buffer.slice(idx + FRAME_DELIMITER.length);
136
+
137
+ const kept = interceptFrame(frame, interceptor);
138
+ if (kept !== undefined) {
139
+ out.push(kept, FRAME_DELIMITER);
140
+ }
141
+ }
142
+
143
+ if (final && buffer.length > 0) {
144
+ const kept = interceptFrame(buffer, interceptor);
145
+ if (kept !== undefined) out.push(kept);
146
+ buffer = "";
147
+ }
148
+
149
+ return out.length === 0 ? "" : out.join("");
150
+ };
151
+
152
+ const toText = (chunk: unknown): string => {
153
+ if (typeof chunk === "string") return chunk;
154
+ if (Buffer.isBuffer(chunk)) return decoder.write(chunk);
155
+ if (chunk instanceof Uint8Array) {
156
+ return decoder.write(
157
+ Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength),
158
+ );
159
+ }
160
+ return "";
161
+ };
162
+
163
+ res.write = function patchedWrite(
164
+ chunk: unknown,
165
+ encodingOrCb?: BufferEncoding | ((error?: Error | null) => void),
166
+ cb?: (error?: Error | null) => void,
167
+ ): boolean {
168
+ if (engaged === undefined) engaged = isEventStream(res);
169
+ if (!engaged) {
170
+ return origWrite(chunk as never, encodingOrCb as never, cb as never);
171
+ }
172
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
173
+ const out = intercept(toText(chunk), false);
174
+ if (out.length === 0) {
175
+ // The chunk only advanced a partial frame; nothing to forward
176
+ // yet. Honor the write callback so backpressure-aware writers
177
+ // don't stall waiting on an ack that never comes.
178
+ if (callback) queueMicrotask(() => callback());
179
+ return true;
180
+ }
181
+ return origWrite(out, callback as never);
182
+ } as typeof res.write;
183
+
184
+ res.end = function patchedEnd(
185
+ chunk?: unknown | (() => void),
186
+ encodingOrCb?: BufferEncoding | (() => void),
187
+ cb?: () => void,
188
+ ): express.Response {
189
+ if (engaged === undefined) engaged = isEventStream(res);
190
+ if (!engaged) {
191
+ return origEnd(chunk as never, encodingOrCb as never, cb as never);
192
+ }
193
+ const callback =
194
+ typeof chunk === "function"
195
+ ? chunk
196
+ : typeof encodingOrCb === "function"
197
+ ? encodingOrCb
198
+ : cb;
199
+ const text =
200
+ (typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
201
+ decoder.end();
202
+ const out = intercept(text, true);
203
+ if (out.length > 0) origWrite(out);
204
+ return origEnd(callback as never);
205
+ } as typeof res.end;
206
+ }