@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.
@@ -27,7 +27,7 @@
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";
@@ -37,6 +37,7 @@ import { historyRoute } from "./history.js";
37
37
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
38
38
  import { buildObservability } from "./observability.js";
39
39
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
40
+ import { installStreamEventInterceptor, } from "./intercept.js";
40
41
  import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
41
42
  import { fetchStatementData, isStatementNotFoundError, STATEMENT_ROW_CAP, } from "./statement.js";
42
43
  const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
@@ -173,8 +174,7 @@ export class MastraPlugin extends Plugin {
173
174
  modelsPath: `${basePath}/models`,
174
175
  historyPath: `${basePath}/route/history`,
175
176
  historyPathTemplate: `${basePath}/route/history/:agentId`,
176
- chartsPathTemplate: `${basePath}/charts/:chartId`,
177
- statementsPathTemplate: `${basePath}/statements/:statementId`,
177
+ embedPathTemplate: `${basePath}/embed/:type/:id`,
178
178
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
179
179
  agents: Object.keys(this.built?.agents ?? {}),
180
180
  };
@@ -192,112 +192,95 @@ export class MastraPlugin extends Plugin {
192
192
  .then((endpoints) => res.json({ endpoints }))
193
193
  .catch(next);
194
194
  });
195
- // `GET /charts/:chartId` long-polls the chart cache.
196
- //
197
- // Charts kicked off by the Genie agent's `prepare_chart` tool
198
- // (or the system `render_data` tool) resolve asynchronously
199
- // and land in the AppKit cache under a chartId. The host UI
200
- // resolves `[chart:<chartId>]` markers by hitting this route,
201
- // which blocks until the entry settles (`result` or `error`
202
- // set) or the server-side budget elapses (then returns the
203
- // last seen still-processing entry so the client can poll
204
- // again).
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.
205
202
  //
206
203
  // Status codes:
207
- // - 200 with JSON body when the entry is found (any state).
208
- // - 404 when the chartId doesn't exist or its 1h TTL elapsed.
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.
209
210
  //
210
- // Query params:
211
- // - `timeoutMs` (optional, default 60000): how long to long-poll
212
- // before returning a still-processing entry.
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).
213
218
  //
214
- // Wired with `req.signal` so a closed connection unblocks the
215
- // poll immediately and the request thread frees up. Cache
216
- // failures bubble through `next(err)` to Express's default
217
- // error handler.
218
- router.get("/charts/:chartId", (req, res, next) => {
219
- const chartId = req.params["chartId"];
220
- if (!chartId) {
221
- res.status(400).json({ error: "chartId is required" });
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}` });
222
247
  return;
223
248
  }
224
- const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
225
- // Express's `req` predates `AbortSignal`. Bridge the
226
- // `close` event onto an `AbortController` so a closed
227
- // connection unblocks the long-poll immediately and frees
228
- // the request thread. The `close` listener also fires on
229
- // a normal completion, but at that point we no longer
230
- // care about the abort and the listener is GC'd with the
231
- // request.
232
- const controller = new AbortController();
233
- req.on("close", () => controller.abort());
234
- fetchChart(chartId, {
235
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
236
- signal: controller.signal,
237
- })
238
- .then((entry) => {
239
- if (!entry) {
240
- res.status(404).json({ error: "chart not found" });
241
- return;
242
- }
243
- res.json(entry);
244
- })
245
- .catch(next);
246
- });
247
- // `GET /statements/:statementId` resolves a Genie / Statement
248
- // Execution result for inline rendering as a table.
249
- //
250
- // The agent embeds `[data:<statement_id>]` markers in prose
251
- // (see `GENIE_INSTRUCTIONS`) whenever it wants the host UI to
252
- // display a result set without the model re-spelling every
253
- // row in markdown. This route is what those markers resolve
254
- // against - one OBO-scoped fetch returns the columns + rows
255
- // so the client renders an AppKit Table inline.
256
- //
257
- // Status codes:
258
- // - 200 with `StatementData` body when the statement is found.
259
- // - 404 when the workspace can't find / read the statement.
260
- //
261
- // Query params:
262
- // - `limit` (optional): row cap. Clamped to
263
- // {@link STATEMENT_ROW_CAP} so a runaway result set
264
- // can't hose the response.
265
- //
266
- // OBO-scoped via {@link userScopedSelf} so the caller's
267
- // workspace permissions apply; SDK errors propagate through
268
- // `next(err)` to Express's default error handler.
269
- router.get("/statements/:statementId", (req, res, next) => {
270
- const statementId = req.params["statementId"];
271
- if (!statementId) {
272
- res.status(400).json({ error: "statementId is required" });
249
+ if (!id) {
250
+ res.status(400).json({ error: "id is required" });
273
251
  return;
274
252
  }
275
- const limit = parseStatementLimit(req.query["limit"]);
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.
276
258
  const controller = new AbortController();
277
259
  req.on("close", () => controller.abort());
278
- this.userScopedSelf(req)
279
- .fetchStatement(statementId, {
280
- ...(limit !== undefined ? { limit } : {}),
281
- signal: controller.signal,
282
- })
283
- .then((data) => {
284
- if (!data) {
285
- res.status(404).json({ error: "statement not found" });
260
+ resolve(req, id, controller.signal)
261
+ .then((entry) => {
262
+ if (entry === undefined) {
263
+ res.status(404).json({ error: `${type} not found` });
286
264
  return;
287
265
  }
288
- res.json(data);
266
+ res.json(entry);
289
267
  })
290
268
  .catch(next);
291
269
  });
292
270
  router.use("", (req, res, next) => {
293
271
  if (!this.mastraApp)
294
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);
295
278
  return this.userScopedSelf(req).mastraApp(req, res, next);
296
279
  });
297
280
  }
298
281
  /**
299
- * Implementation backing the `/statements/:statementId` route.
300
- * Runs inside the AppKit user-context proxy so
282
+ * Implementation backing the `data` embed resolver
283
+ * (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
301
284
  * `getExecutionContext()` returns the OBO-scoped workspace
302
285
  * client, then reuses the same `fetchStatementData` pipeline
303
286
  * the `get_statement` tool runs so the LLM and the UI see the
@@ -427,7 +410,7 @@ export class MastraPlugin extends Plugin {
427
410
  }
428
411
  /**
429
412
  * Parse the optional `?timeoutMs=<n>` query parameter from a
430
- * `GET /charts/:chartId` request. Accepts a positive integer up
413
+ * `GET /embed/chart/:id` request. Accepts a positive integer up
431
414
  * to 5 minutes (clamped) and rejects everything else as
432
415
  * `undefined` so {@link fetchChart} falls back to its default.
433
416
  * Express produces `string | string[] | undefined`; we normalize
@@ -444,7 +427,7 @@ function parseTimeoutMs(raw) {
444
427
  }
445
428
  /**
446
429
  * Parse the optional `?limit=<n>` query parameter from a
447
- * `GET /statements/:statementId` request. Accepts a non-negative
430
+ * `GET /embed/data/:id` request. Accepts a non-negative
448
431
  * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
449
432
  * rejects anything else as `undefined` so the route falls back
450
433
  * to the server-side cap.
@@ -458,4 +441,37 @@ function parseStatementLimit(raw) {
458
441
  return undefined;
459
442
  return Math.floor(n);
460
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
+ };
461
477
  export const mastra = toPlugin(MastraPlugin);
@@ -1,23 +1,16 @@
1
1
  /**
2
2
  * Dynamic model resolution against Databricks Model Serving.
3
3
  *
4
- * Three concerns live here:
5
- *
6
- * 1. **Listing** - {@link listServingEndpoints} pulls the workspace's
7
- * `/serving-endpoints` via the SDK and caches the result per host
8
- * with a TTL. Concurrent callers share one in-flight promise (the
9
- * same coalescing pattern as Python's `cachetools-async`).
10
- * 2. **Fuzzy matching** - {@link resolveModelId} runs the user's input
11
- * through `fuse.js` extended search so loose tokens like
12
- * `"claude sonnet"` snap to `databricks-claude-sonnet-4-6` even
13
- * when typed without the full endpoint name.
14
- * 3. **Per-request override** - {@link extractModelOverride} pulls a
15
- * model name from the `X-Mastra-Model` header, `?model=` query
16
- * string, or `model` body field so the same agent can be exercised
17
- * against different endpoints without redeploying.
18
- *
19
- * `model.ts` glues these together inside the per-step model resolver;
20
- * `plugin.ts` exposes the cached list at `GET /models`.
4
+ * Turns loose, human-typed model names into real serving-endpoint ids
5
+ * so the same agent can target different endpoints without code
6
+ * changes. The workspace's `/serving-endpoints` list is fetched once
7
+ * per host and cached with a TTL, with concurrent callers sharing one
8
+ * in-flight promise (the coalescing pattern of Python's
9
+ * `cachetools-async`). Names are matched through `fuse.js` extended
10
+ * search so tokens like `"claude sonnet"` snap to
11
+ * `databricks-claude-sonnet-4-6`, and a per-request override can be
12
+ * pulled from the request header, query string, or body so a model
13
+ * can be swapped per call without redeploying.
21
14
  */
22
15
  import { type getExecutionContext } from "@databricks/appkit";
23
16
  import type { ServingEndpointSummary } from "@dbx-tools/appkit-mastra-shared";
@@ -1,23 +1,16 @@
1
1
  /**
2
2
  * Dynamic model resolution against Databricks Model Serving.
3
3
  *
4
- * Three concerns live here:
5
- *
6
- * 1. **Listing** - {@link listServingEndpoints} pulls the workspace's
7
- * `/serving-endpoints` via the SDK and caches the result per host
8
- * with a TTL. Concurrent callers share one in-flight promise (the
9
- * same coalescing pattern as Python's `cachetools-async`).
10
- * 2. **Fuzzy matching** - {@link resolveModelId} runs the user's input
11
- * through `fuse.js` extended search so loose tokens like
12
- * `"claude sonnet"` snap to `databricks-claude-sonnet-4-6` even
13
- * when typed without the full endpoint name.
14
- * 3. **Per-request override** - {@link extractModelOverride} pulls a
15
- * model name from the `X-Mastra-Model` header, `?model=` query
16
- * string, or `model` body field so the same agent can be exercised
17
- * against different endpoints without redeploying.
18
- *
19
- * `model.ts` glues these together inside the per-step model resolver;
20
- * `plugin.ts` exposes the cached list at `GET /models`.
4
+ * Turns loose, human-typed model names into real serving-endpoint ids
5
+ * so the same agent can target different endpoints without code
6
+ * changes. The workspace's `/serving-endpoints` list is fetched once
7
+ * per host and cached with a TTL, with concurrent callers sharing one
8
+ * in-flight promise (the coalescing pattern of Python's
9
+ * `cachetools-async`). Names are matched through `fuse.js` extended
10
+ * search so tokens like `"claude sonnet"` snap to
11
+ * `databricks-claude-sonnet-4-6`, and a per-request override can be
12
+ * pulled from the request header, query string, or body so a model
13
+ * can be swapped per call without redeploying.
21
14
  */
22
15
  import { CacheManager } from "@databricks/appkit";
23
16
  import { logUtils, stringUtils } from "@dbx-tools/shared";
@@ -1,24 +1,17 @@
1
1
  /**
2
2
  * Databricks Statement Execution helpers for the Mastra plugin.
3
3
  *
4
- * Wraps `client.statementExecution.getStatement` with the shape
5
- * + size + error handling the plugin's tools and the
6
- * `/statements/:statementId` 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
- * `/statements/:statementId` 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.
4
+ * Wraps `client.statementExecution.getStatement` with the shape,
5
+ * size, and error handling the plugin's tools and the
6
+ * `/embed/data/:id` route both need: a low-level fetch that returns a
7
+ * raw `{columns, rows, rowCount}` shape and coerces numeric strings
8
+ * to numbers so downstream charts and aggregations don't have to, a
9
+ * hard row cap callers clamp `limit` to so a runaway result set can't
10
+ * hose a response, and a structural not-found detector that
11
+ * normalizes the SDK's error classes and loose `does not exist` /
12
+ * `not found` message shapes into one boolean so the route can map
13
+ * upstream 404s to a clean HTTP 404 without coupling to SDK
14
+ * error-class identity.
22
15
  *
23
16
  * Not Genie-specific: a Databricks `statement_id` is workspace
24
17
  * scoped and lives in the Statement Execution API regardless of
@@ -30,7 +23,7 @@ import { WorkspaceClient } from "@databricks/sdk-experimental";
30
23
  import type { GenieDatasetData } from "@dbx-tools/appkit-mastra-shared";
31
24
  /**
32
25
  * Hard server-side cap on rows returned by the
33
- * `/statements/:statementId` route. Sized to keep responses small
26
+ * `/embed/data/:id` route. Sized to keep responses small
34
27
  * enough for inline tables to render snappily; the route surfaces
35
28
  * a `truncated` flag whenever the upstream `rowCount` exceeds
36
29
  * this so end users know they're seeing a sample.
@@ -48,7 +41,7 @@ export declare const STATEMENT_ROW_CAP = 500;
48
41
  *
49
42
  * Exported because every consumer in the plugin (the
50
43
  * `get_statement` tool, the `prepare_chart` dataset resolver, and
51
- * the `/statements/:statementId` route) needs the exact same
44
+ * the `/embed/data/:id` route) needs the exact same
52
45
  * fetch + coercion pipeline so LLM-side `get_statement` output
53
46
  * and UI-side `[data:<id>]` rendering stay shape-identical for
54
47
  * the same `statement_id`.
@@ -66,7 +59,7 @@ export declare function fetchStatementData(client: WorkspaceClient, statementId:
66
59
  * catalogued.
67
60
  *
68
61
  * Pulled into its own helper so callers (notably the
69
- * `/statements/:statementId` route) stay decoupled from SDK
62
+ * `/embed/data/:id` route) stay decoupled from SDK
70
63
  * error-class identity, and the conversion logic stays testable
71
64
  * in isolation.
72
65
  */
@@ -1,24 +1,17 @@
1
1
  /**
2
2
  * Databricks Statement Execution helpers for the Mastra plugin.
3
3
  *
4
- * Wraps `client.statementExecution.getStatement` with the shape
5
- * + size + error handling the plugin's tools and the
6
- * `/statements/:statementId` 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
- * `/statements/:statementId` 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.
4
+ * Wraps `client.statementExecution.getStatement` with the shape,
5
+ * size, and error handling the plugin's tools and the
6
+ * `/embed/data/:id` route both need: a low-level fetch that returns a
7
+ * raw `{columns, rows, rowCount}` shape and coerces numeric strings
8
+ * to numbers so downstream charts and aggregations don't have to, a
9
+ * hard row cap callers clamp `limit` to so a runaway result set can't
10
+ * hose a response, and a structural not-found detector that
11
+ * normalizes the SDK's error classes and loose `does not exist` /
12
+ * `not found` message shapes into one boolean so the route can map
13
+ * upstream 404s to a clean HTTP 404 without coupling to SDK
14
+ * error-class identity.
22
15
  *
23
16
  * Not Genie-specific: a Databricks `statement_id` is workspace
24
17
  * scoped and lives in the Statement Execution API regardless of
@@ -30,7 +23,7 @@ import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experiment
30
23
  import { apiUtils } from "@dbx-tools/shared";
31
24
  /**
32
25
  * Hard server-side cap on rows returned by the
33
- * `/statements/:statementId` route. Sized to keep responses small
26
+ * `/embed/data/:id` route. Sized to keep responses small
34
27
  * enough for inline tables to render snappily; the route surfaces
35
28
  * a `truncated` flag whenever the upstream `rowCount` exceeds
36
29
  * this so end users know they're seeing a sample.
@@ -63,7 +56,7 @@ function coerceCell(cell) {
63
56
  *
64
57
  * Exported because every consumer in the plugin (the
65
58
  * `get_statement` tool, the `prepare_chart` dataset resolver, and
66
- * the `/statements/:statementId` route) needs the exact same
59
+ * the `/embed/data/:id` route) needs the exact same
67
60
  * fetch + coercion pipeline so LLM-side `get_statement` output
68
61
  * and UI-side `[data:<id>]` rendering stay shape-identical for
69
62
  * the same `statement_id`.
@@ -98,7 +91,7 @@ export async function fetchStatementData(client, statementId, options) {
98
91
  * catalogued.
99
92
  *
100
93
  * Pulled into its own helper so callers (notably the
101
- * `/statements/:statementId` route) stay decoupled from SDK
94
+ * `/embed/data/:id` route) stay decoupled from SDK
102
95
  * error-class identity, and the conversion logic stays testable
103
96
  * in isolation.
104
97
  */