@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.
@@ -0,0 +1,48 @@
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
+ import type express from "express";
21
+ /**
22
+ * Per-frame interceptor invoked with the `JSON.parse`d object of every
23
+ * `data: {json}` frame on the Mastra `/stream` response. The return
24
+ * value decides the frame's fate:
25
+ *
26
+ * - `true`: forward the frame's original bytes unchanged (no
27
+ * re-serialization).
28
+ * - `false`: drop the frame entirely.
29
+ * - `{ replace }`: re-emit the frame with the original `data:` prefix
30
+ * and trailing whitespace preserved and only the JSON body replaced
31
+ * by `JSON.stringify(replace)`.
32
+ */
33
+ export type StreamFrameInterceptor = (chunk: unknown) => {
34
+ replace: unknown;
35
+ } | true | false;
36
+ /**
37
+ * Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
38
+ * response is run through `interceptor` (keep / rewrite / drop).
39
+ *
40
+ * Interception only engages once the response is confirmed to be a
41
+ * `200 text/event-stream` body (decided lazily on the first write,
42
+ * after the handler has set status + content-type); any other
43
+ * response streams through unchanged. A {@link StringDecoder}
44
+ * reassembles multi-byte UTF-8 sequences that straddle chunk
45
+ * boundaries, and a running buffer holds the trailing partial frame
46
+ * until its `\n\n` arrives.
47
+ */
48
+ export declare function installStreamEventInterceptor(res: express.Response, interceptor: StreamFrameInterceptor): void;
@@ -0,0 +1,167 @@
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
+ import { StringDecoder } from "node:string_decoder";
21
+ /** SSE event separator used by the Mastra stream wire format. */
22
+ const FRAME_DELIMITER = "\n\n";
23
+ /**
24
+ * Matches a whole SSE frame whose `data:` payload is a JSON object,
25
+ * capturing it in three parts: the `data:` field prefix (including
26
+ * whatever inter-token whitespace the producer used), the JSON object
27
+ * body, and any trailing whitespace. Anchored end-to-end so it only
28
+ * matches a single self-contained object frame; anything else (SSE
29
+ * comments, the `[DONE]` sentinel, text/array payloads) fails the
30
+ * match and is forwarded verbatim without a parse attempt. On rewrite
31
+ * the captured prefix/suffix are re-emitted as-is so only the body
32
+ * changes.
33
+ */
34
+ const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
35
+ /**
36
+ * True when `res` is a `200 text/event-stream` body - the only
37
+ * response shape we intercept. Content-type based so it tracks the
38
+ * Mastra `/stream` route without hard-coding the path, and naturally
39
+ * skips the JSON routes (history, models, charts, statements) and any
40
+ * non-200 error response.
41
+ */
42
+ function isEventStream(res) {
43
+ if (res.statusCode !== 200)
44
+ return false;
45
+ const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
46
+ return contentType.includes("text/event-stream");
47
+ }
48
+ /**
49
+ * Run `interceptor` over a single SSE frame. Forwards anything that
50
+ * isn't a parseable `data: {json}` object frame verbatim (comments,
51
+ * the `[DONE]` sentinel, blank padding, unparseable / non-object
52
+ * payloads) so the interceptor only ever sees real chunk objects.
53
+ * Returns the frame string to emit, or `undefined` to drop the frame.
54
+ */
55
+ function interceptFrame(frame, interceptor) {
56
+ const match = DATA_OBJECT_RE.exec(frame);
57
+ if (!match)
58
+ return frame;
59
+ const [, prefix, body, suffix] = match;
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(body);
63
+ }
64
+ catch {
65
+ // Forward unparseable payloads untouched - feeding a partial /
66
+ // malformed frame to the interceptor risks corrupting bytes the
67
+ // client could still have handled.
68
+ return frame;
69
+ }
70
+ const result = interceptor(parsed);
71
+ if (result === true)
72
+ return frame;
73
+ if (result === false)
74
+ return undefined;
75
+ // Preserve the exact captured prefix/suffix so only the JSON body
76
+ // changes; the framing the client parses stays byte-identical.
77
+ return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
78
+ }
79
+ /**
80
+ * Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
81
+ * response is run through `interceptor` (keep / rewrite / drop).
82
+ *
83
+ * Interception only engages once the response is confirmed to be a
84
+ * `200 text/event-stream` body (decided lazily on the first write,
85
+ * after the handler has set status + content-type); any other
86
+ * response streams through unchanged. A {@link StringDecoder}
87
+ * reassembles multi-byte UTF-8 sequences that straddle chunk
88
+ * boundaries, and a running buffer holds the trailing partial frame
89
+ * until its `\n\n` arrives.
90
+ */
91
+ export function installStreamEventInterceptor(res, interceptor) {
92
+ const origWrite = res.write.bind(res);
93
+ const origEnd = res.end.bind(res);
94
+ const decoder = new StringDecoder("utf8");
95
+ let engaged;
96
+ let buffer = "";
97
+ // Append decoded text, emit every whole `\n\n`-delimited frame the
98
+ // interceptor keeps, and retain any trailing partial frame in
99
+ // `buffer`. On `final`, the residual buffer is flushed as the last
100
+ // frame so a stream that doesn't end on a delimiter isn't dropped.
101
+ const intercept = (text, final) => {
102
+ buffer += text;
103
+ const out = [];
104
+ let idx;
105
+ while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
106
+ const frame = buffer.slice(0, idx);
107
+ buffer = buffer.slice(idx + FRAME_DELIMITER.length);
108
+ const kept = interceptFrame(frame, interceptor);
109
+ if (kept !== undefined) {
110
+ out.push(kept, FRAME_DELIMITER);
111
+ }
112
+ }
113
+ if (final && buffer.length > 0) {
114
+ const kept = interceptFrame(buffer, interceptor);
115
+ if (kept !== undefined)
116
+ out.push(kept);
117
+ buffer = "";
118
+ }
119
+ return out.length === 0 ? "" : out.join("");
120
+ };
121
+ const toText = (chunk) => {
122
+ if (typeof chunk === "string")
123
+ return chunk;
124
+ if (Buffer.isBuffer(chunk))
125
+ return decoder.write(chunk);
126
+ if (chunk instanceof Uint8Array) {
127
+ return decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
128
+ }
129
+ return "";
130
+ };
131
+ res.write = function patchedWrite(chunk, encodingOrCb, cb) {
132
+ if (engaged === undefined)
133
+ engaged = isEventStream(res);
134
+ if (!engaged) {
135
+ return origWrite(chunk, encodingOrCb, cb);
136
+ }
137
+ const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
138
+ const out = intercept(toText(chunk), false);
139
+ if (out.length === 0) {
140
+ // The chunk only advanced a partial frame; nothing to forward
141
+ // yet. Honor the write callback so backpressure-aware writers
142
+ // don't stall waiting on an ack that never comes.
143
+ if (callback)
144
+ queueMicrotask(() => callback());
145
+ return true;
146
+ }
147
+ return origWrite(out, callback);
148
+ };
149
+ res.end = function patchedEnd(chunk, encodingOrCb, cb) {
150
+ if (engaged === undefined)
151
+ engaged = isEventStream(res);
152
+ if (!engaged) {
153
+ return origEnd(chunk, encodingOrCb, cb);
154
+ }
155
+ const callback = typeof chunk === "function"
156
+ ? chunk
157
+ : typeof encodingOrCb === "function"
158
+ ? encodingOrCb
159
+ : cb;
160
+ const text = (typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
161
+ decoder.end();
162
+ const out = intercept(text, true);
163
+ if (out.length > 0)
164
+ origWrite(out);
165
+ return origEnd(callback);
166
+ };
167
+ }
@@ -109,6 +109,18 @@ export declare class MastraPlugin extends Plugin<MastraPluginConfig> {
109
109
  };
110
110
  clientConfig(): Record<string, unknown>;
111
111
  injectRoutes(router: IAppRouter): void;
112
+ /**
113
+ * Implementation backing the `data` embed resolver
114
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
115
+ * `getExecutionContext()` returns the OBO-scoped workspace
116
+ * client, then reuses the same `fetchStatementData` pipeline
117
+ * the `get_statement` tool runs so the LLM and the UI see the
118
+ * exact same shape for the same statement.
119
+ *
120
+ * Returns `undefined` for upstream 404s so the route can map
121
+ * them to a clean HTTP 404; any other failure bubbles up.
122
+ */
123
+ private fetchStatement;
112
124
  /**
113
125
  * Return `this.asUser(req)` when the request carries an OBO token,
114
126
  * otherwise return `this` directly. Prevents the noisy AppKit warn
@@ -27,16 +27,19 @@
27
27
  * AI SDK transport URL is `/api/mastra/route/chat/<agentId>`.
28
28
  */
29
29
  import { genie, getExecutionContext, lakebase, Plugin, toPlugin, } from "@databricks/appkit";
30
- import { appkitUtils, logUtils } from "@dbx-tools/shared";
30
+ import { appkitUtils, commonUtils, logUtils } from "@dbx-tools/shared";
31
31
  import { chatRoute } from "@mastra/ai-sdk";
32
32
  import { Mastra } from "@mastra/core/mastra";
33
33
  import express from "express";
34
34
  import { buildAgents, FALLBACK_AGENT_ID } from "./agents.js";
35
+ import { fetchChart } from "./chart.js";
35
36
  import { historyRoute } from "./history.js";
36
37
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
37
38
  import { buildObservability } from "./observability.js";
38
39
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
40
+ import { installStreamEventInterceptor, } from "./intercept.js";
39
41
  import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
42
+ import { fetchStatementData, isStatementNotFoundError, STATEMENT_ROW_CAP, } from "./statement.js";
40
43
  const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
41
44
  const LAKEBASE_MANIFEST = appkitUtils.data(lakebase).plugin.manifest;
42
45
  /**
@@ -171,6 +174,7 @@ export class MastraPlugin extends Plugin {
171
174
  modelsPath: `${basePath}/models`,
172
175
  historyPath: `${basePath}/route/history`,
173
176
  historyPathTemplate: `${basePath}/route/history/:agentId`,
177
+ embedPathTemplate: `${basePath}/embed/:type/:id`,
174
178
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
175
179
  agents: Object.keys(this.built?.agents ?? {}),
176
180
  };
@@ -188,12 +192,126 @@ export class MastraPlugin extends Plugin {
188
192
  .then((endpoints) => res.json({ endpoints }))
189
193
  .catch(next);
190
194
  });
195
+ // `GET /embed/:type/:id` is the single resolver for every embed
196
+ // marker the agent emits in prose (`[chart:<id>]`,
197
+ // `[data:<id>]`, ...). `:type` selects a resolver from the
198
+ // registry below; `:id` is that resolver's lookup key. The
199
+ // grammar (see `marker.ts`) is type-agnostic on purpose - new
200
+ // embed kinds are added by registering a resolver here, with no
201
+ // client or grammar change.
202
+ //
203
+ // Status codes:
204
+ // - 200 with the resolver's JSON body when the id resolves.
205
+ // - 404 when `:type` isn't registered (unsupported embed
206
+ // type) OR a registered resolver can't find `:id` (unknown
207
+ // / expired - e.g. a chart past its 1h TTL or a fabricated
208
+ // id the model never minted).
209
+ // - 400 when `:id` is empty.
210
+ //
211
+ // Per-type query knobs and behavior:
212
+ // - `chart`: long-polls the chart cache until the entry
213
+ // settles (`result` / `error`) or the budget elapses (then
214
+ // returns the still-processing entry to poll again).
215
+ // `?timeoutMs=<n>` (default 60s, capped 5min) tunes it.
216
+ // - `data`: one OBO-scoped Statement Execution fetch.
217
+ // `?limit=<n>` caps rows (clamped to STATEMENT_ROW_CAP).
218
+ //
219
+ // Built once (this handler is registered once) and keyed by the
220
+ // raw `:type` token. Each resolver gets the request (for query
221
+ // parsing + OBO scoping) and an `AbortSignal` bridged off the
222
+ // connection `close` event so a long-poll unblocks the instant
223
+ // the client disconnects. `undefined` from a resolver maps to a
224
+ // clean 404; thrown errors bubble through `next(err)`.
225
+ const embedResolvers = {
226
+ chart: (req, id, signal) => {
227
+ const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
228
+ return fetchChart(id, {
229
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
230
+ signal,
231
+ });
232
+ },
233
+ data: (req, id, signal) => {
234
+ const limit = parseStatementLimit(req.query["limit"]);
235
+ return this.userScopedSelf(req).fetchStatement(id, {
236
+ ...(limit !== undefined ? { limit } : {}),
237
+ signal,
238
+ });
239
+ },
240
+ };
241
+ router.get("/embed/:type/:id", (req, res, next) => {
242
+ const type = req.params["type"] ?? "";
243
+ const id = req.params["id"];
244
+ const resolve = embedResolvers[type];
245
+ if (!resolve) {
246
+ res.status(404).json({ error: `unsupported embed type: ${type}` });
247
+ return;
248
+ }
249
+ if (!id) {
250
+ res.status(400).json({ error: "id is required" });
251
+ return;
252
+ }
253
+ // Express's `req` predates `AbortSignal`; bridge the `close`
254
+ // event onto an `AbortController` so a closed connection
255
+ // unblocks any long-poll immediately and frees the request
256
+ // thread. The listener is GC'd with the request on normal
257
+ // completion.
258
+ const controller = new AbortController();
259
+ req.on("close", () => controller.abort());
260
+ resolve(req, id, controller.signal)
261
+ .then((entry) => {
262
+ if (entry === undefined) {
263
+ res.status(404).json({ error: `${type} not found` });
264
+ return;
265
+ }
266
+ res.json(entry);
267
+ })
268
+ .catch(next);
269
+ });
191
270
  router.use("", (req, res, next) => {
192
271
  if (!this.mastraApp)
193
272
  return res.status(503).end();
273
+ // Run each Mastra `/stream` SSE frame through the interceptor
274
+ // (keep / rewrite / drop). Only engages on a
275
+ // `200 text/event-stream` body, so the JSON routes above and any
276
+ // error response are untouched.
277
+ installStreamEventInterceptor(res, interceptStreamFrame);
194
278
  return this.userScopedSelf(req).mastraApp(req, res, next);
195
279
  });
196
280
  }
281
+ /**
282
+ * Implementation backing the `data` embed resolver
283
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
284
+ * `getExecutionContext()` returns the OBO-scoped workspace
285
+ * client, then reuses the same `fetchStatementData` pipeline
286
+ * the `get_statement` tool runs so the LLM and the UI see the
287
+ * exact same shape for the same statement.
288
+ *
289
+ * Returns `undefined` for upstream 404s so the route can map
290
+ * them to a clean HTTP 404; any other failure bubbles up.
291
+ */
292
+ async fetchStatement(statementId, options = {}) {
293
+ const client = getExecutionContext().client;
294
+ const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
295
+ try {
296
+ const data = await fetchStatementData(client, statementId, {
297
+ limit,
298
+ ...(options.signal ? { signal: options.signal } : {}),
299
+ });
300
+ return {
301
+ columns: data.columns,
302
+ rows: data.rows,
303
+ rowCount: data.rowCount,
304
+ truncated: data.rows.length < data.rowCount,
305
+ };
306
+ }
307
+ catch (err) {
308
+ // The Databricks SDK throws on 404; surface as `undefined`
309
+ // so the route maps to a clean HTTP 404 instead of a 500.
310
+ if (isStatementNotFoundError(err))
311
+ return undefined;
312
+ throw err;
313
+ }
314
+ }
197
315
  /**
198
316
  * Return `this.asUser(req)` when the request carries an OBO token,
199
317
  * otherwise return `this` directly. Prevents the noisy AppKit warn
@@ -290,4 +408,70 @@ export class MastraPlugin extends Plugin {
290
408
  });
291
409
  }
292
410
  }
411
+ /**
412
+ * Parse the optional `?timeoutMs=<n>` query parameter from a
413
+ * `GET /embed/chart/:id` request. Accepts a positive integer up
414
+ * to 5 minutes (clamped) and rejects everything else as
415
+ * `undefined` so {@link fetchChart} falls back to its default.
416
+ * Express produces `string | string[] | undefined`; we normalize
417
+ * to the first scalar before parsing.
418
+ */
419
+ function parseTimeoutMs(raw) {
420
+ const v = Array.isArray(raw) ? raw[0] : raw;
421
+ if (typeof v !== "string")
422
+ return undefined;
423
+ const n = Number(v);
424
+ if (!Number.isFinite(n) || n <= 0)
425
+ return undefined;
426
+ return Math.min(Math.floor(n), 5 * 60_000);
427
+ }
428
+ /**
429
+ * Parse the optional `?limit=<n>` query parameter from a
430
+ * `GET /embed/data/:id` request. Accepts a non-negative
431
+ * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
432
+ * rejects anything else as `undefined` so the route falls back
433
+ * to the server-side cap.
434
+ */
435
+ function parseStatementLimit(raw) {
436
+ const v = Array.isArray(raw) ? raw[0] : raw;
437
+ if (typeof v !== "string")
438
+ return undefined;
439
+ const n = Number(v);
440
+ if (!Number.isFinite(n) || n < 0)
441
+ return undefined;
442
+ return Math.floor(n);
443
+ }
444
+ /**
445
+ * {@link StreamFrameInterceptor} for Mastra `/stream` responses.
446
+ *
447
+ * Removes large, redundant payloads from terminal `step-finish`,
448
+ * `finish`, and `tool-result` frames before they reach the browser.
449
+ * These frames often repeat information already delivered incrementally
450
+ * via streamed text and tool events, including full tool outputs,
451
+ * accumulated responses, message history, SQL results, chart data, and
452
+ * other large result payloads.
453
+ *
454
+ * The interceptor deletes heavyweight payload properties
455
+ * (`output`, `messages`, `response`, and `result`) while preserving the
456
+ * event envelope and lifecycle metadata required by the client.
457
+ *
458
+ * Deletion is key based, so streams that do not contain these
459
+ * fields are passed through unchanged.
460
+ */
461
+ const interceptStreamFrame = (chunk) => {
462
+ if (!commonUtils.isRecord(chunk))
463
+ return true;
464
+ if (!["step-finish", "finish", "tool-result"].find((type) => type === chunk.type))
465
+ return true;
466
+ const payload = chunk.payload;
467
+ if (!commonUtils.isRecord(payload))
468
+ return true;
469
+ const trimmedPayload = commonUtils.deleteKeys(payload, [
470
+ "output",
471
+ "messages",
472
+ "response",
473
+ "result",
474
+ ]);
475
+ return trimmedPayload ? { replace: chunk } : true;
476
+ };
293
477
  export const mastra = toPlugin(MastraPlugin);
@@ -3,21 +3,24 @@
3
3
  * tool-invocation result in prior assistant messages before they
4
4
  * reach the model.
5
5
  *
6
- * Why: chartIds are only meaningful within the assistant turn that
7
- * minted them - the writer events backing them are gone after the
8
- * stream closes. When the model sees old chartIds in memory recall
9
- * (Mastra Memory persists tool results), it's tempted to type
10
- * those ids into the new turn's `[[chart:<id>]]` markers, leaving
11
- * the chat client's chart slots stuck with no matching event. This
12
- * processor removes the temptation by deleting `chartId` keys from
13
- * every assistant message's tool results before the prompt is
14
- * built. The current turn's tool results don't exist yet at
15
- * `processInput` time, so they pass through unmodified.
6
+ * Why: chartIds are turn-scoped from the model's point of view -
7
+ * each `prepare_chart` / `render_data` call mints a fresh id and
8
+ * the host UI binds it to that turn's reply. Mastra Memory
9
+ * replays prior tool results into the prompt; if old chartIds
10
+ * leak through, the model is tempted to copy them verbatim into
11
+ * the new turn's `[chart:<id>]` markers and the host UI ends up
12
+ * rendering an unrelated chart from the chart cache (or
13
+ * a 404 once the 1h TTL elapsed). This processor removes the
14
+ * temptation by deleting `chartId` keys from every assistant
15
+ * message's tool results before the prompt is built. The current
16
+ * turn's tool results don't exist yet at `processInput` time, so
17
+ * they pass through unmodified.
16
18
  *
17
19
  * The strip is recursive - any nested `chartId` field is removed,
18
- * regardless of which tool produced the result. This covers Genie's
19
- * `datasets[].chartId` and `render_data`'s top-level `chartId`
20
- * uniformly without coupling to specific tool ids.
20
+ * regardless of which tool produced the result. This covers
21
+ * `prepare_chart` / `render_data` top-level chartIds and any
22
+ * legacy `datasets[].chartId` payloads uniformly without coupling
23
+ * to specific tool ids.
21
24
  */
22
25
  import type { InputProcessor } from "@mastra/core/processors";
23
26
  /**
@@ -3,21 +3,24 @@
3
3
  * tool-invocation result in prior assistant messages before they
4
4
  * reach the model.
5
5
  *
6
- * Why: chartIds are only meaningful within the assistant turn that
7
- * minted them - the writer events backing them are gone after the
8
- * stream closes. When the model sees old chartIds in memory recall
9
- * (Mastra Memory persists tool results), it's tempted to type
10
- * those ids into the new turn's `[[chart:<id>]]` markers, leaving
11
- * the chat client's chart slots stuck with no matching event. This
12
- * processor removes the temptation by deleting `chartId` keys from
13
- * every assistant message's tool results before the prompt is
14
- * built. The current turn's tool results don't exist yet at
15
- * `processInput` time, so they pass through unmodified.
6
+ * Why: chartIds are turn-scoped from the model's point of view -
7
+ * each `prepare_chart` / `render_data` call mints a fresh id and
8
+ * the host UI binds it to that turn's reply. Mastra Memory
9
+ * replays prior tool results into the prompt; if old chartIds
10
+ * leak through, the model is tempted to copy them verbatim into
11
+ * the new turn's `[chart:<id>]` markers and the host UI ends up
12
+ * rendering an unrelated chart from the chart cache (or
13
+ * a 404 once the 1h TTL elapsed). This processor removes the
14
+ * temptation by deleting `chartId` keys from every assistant
15
+ * message's tool results before the prompt is built. The current
16
+ * turn's tool results don't exist yet at `processInput` time, so
17
+ * they pass through unmodified.
16
18
  *
17
19
  * The strip is recursive - any nested `chartId` field is removed,
18
- * regardless of which tool produced the result. This covers Genie's
19
- * `datasets[].chartId` and `render_data`'s top-level `chartId`
20
- * uniformly without coupling to specific tool ids.
20
+ * regardless of which tool produced the result. This covers
21
+ * `prepare_chart` / `render_data` top-level chartIds and any
22
+ * legacy `datasets[].chartId` payloads uniformly without coupling
23
+ * to specific tool ids.
21
24
  */
22
25
  import { logUtils } from "@dbx-tools/shared";
23
26
  const log = logUtils.logger("mastra/processor/strip-stale-charts");
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Databricks Statement Execution helpers for the Mastra plugin.
3
+ *
4
+ * Wraps `client.statementExecution.getStatement` with the shape
5
+ * + size + error handling the plugin's tools and the
6
+ * `/embed/data/:id` route both need:
7
+ *
8
+ * - {@link fetchStatementData}: low-level fetch that returns the
9
+ * raw `{columns, rows, rowCount}` shape used by the
10
+ * `get_statement` tool's output, the `prepare_chart` tool's
11
+ * dataset resolver, and the route's response body. Coerces
12
+ * numeric strings to numbers so downstream charts /
13
+ * aggregations don't have to.
14
+ * - {@link STATEMENT_ROW_CAP}: hard cap callers (notably the
15
+ * `/embed/data/:id` route) clamp `limit` to so a
16
+ * runaway result set can't hose a response.
17
+ * - {@link isStatementNotFoundError}: structural detector that
18
+ * normalizes the SDK's two error classes plus the loose
19
+ * `does not exist` / `not found` message shapes into a single
20
+ * boolean - lets the route map upstream 404s to a clean
21
+ * HTTP 404 without coupling to SDK error-class identity.
22
+ *
23
+ * Not Genie-specific: a Databricks `statement_id` is workspace
24
+ * scoped and lives in the Statement Execution API regardless of
25
+ * which producer (Genie, a tool, a notebook, etc.) submitted the
26
+ * query. Co-located here so consumers can fetch / cap / handle
27
+ * 404s without reaching into the Genie tool module.
28
+ */
29
+ import { WorkspaceClient } from "@databricks/sdk-experimental";
30
+ import type { GenieDatasetData } from "@dbx-tools/appkit-mastra-shared";
31
+ /**
32
+ * Hard server-side cap on rows returned by the
33
+ * `/embed/data/:id` route. Sized to keep responses small
34
+ * enough for inline tables to render snappily; the route surfaces
35
+ * a `truncated` flag whenever the upstream `rowCount` exceeds
36
+ * this so end users know they're seeing a sample.
37
+ */
38
+ export declare const STATEMENT_ROW_CAP = 500;
39
+ /**
40
+ * Fetch a single statement's rows via the Statement Execution API
41
+ * and reshape into the shared {@link GenieDatasetData} shape
42
+ * (column array + row records).
43
+ *
44
+ * Optional `limit` slices the returned `rows` client-side so the
45
+ * agent can scan a small sample without paging the full result
46
+ * set into context. `rowCount` always reflects the upstream total
47
+ * so callers know when the slice truncated.
48
+ *
49
+ * Exported because every consumer in the plugin (the
50
+ * `get_statement` tool, the `prepare_chart` dataset resolver, and
51
+ * the `/embed/data/:id` route) needs the exact same
52
+ * fetch + coercion pipeline so LLM-side `get_statement` output
53
+ * and UI-side `[data:<id>]` rendering stay shape-identical for
54
+ * the same `statement_id`.
55
+ */
56
+ export declare function fetchStatementData(client: WorkspaceClient, statementId: string, options?: {
57
+ limit?: number;
58
+ signal?: AbortSignal;
59
+ }): Promise<GenieDatasetData>;
60
+ /**
61
+ * True when `err` looks like the Databricks SDK's "statement not
62
+ * found" error. Matches the typed {@link ApiError} 404 /
63
+ * `RESOURCE_DOES_NOT_EXIST` shape first, then falls back to the
64
+ * lower-level {@link HttpError} 404, then to a loose `does not
65
+ * exist` / `not found` message sniff for SDK shapes we haven't
66
+ * catalogued.
67
+ *
68
+ * Pulled into its own helper so callers (notably the
69
+ * `/embed/data/:id` route) stay decoupled from SDK
70
+ * error-class identity, and the conversion logic stays testable
71
+ * in isolation.
72
+ */
73
+ export declare function isStatementNotFoundError(err: unknown): boolean;