@dbx-tools/appkit-mastra 0.1.25 → 0.1.26

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/src/plugin.ts CHANGED
@@ -44,7 +44,11 @@ import { Mastra } from "@mastra/core/mastra";
44
44
  import express from "express";
45
45
 
46
46
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
47
- import type { MastraClientConfig } from "@dbx-tools/appkit-mastra-shared";
47
+ import type {
48
+ MastraClientConfig,
49
+ StatementData,
50
+ } from "@dbx-tools/appkit-mastra-shared";
51
+ import { fetchChart } from "./chart.js";
48
52
  import type { MastraPluginConfig } from "./config.js";
49
53
  import { historyRoute } from "./history.js";
50
54
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
@@ -56,6 +60,11 @@ import {
56
60
  resolveServingConfig,
57
61
  type ServingEndpointSummary,
58
62
  } from "./serving.js";
63
+ import {
64
+ fetchStatementData,
65
+ isStatementNotFoundError,
66
+ STATEMENT_ROW_CAP,
67
+ } from "./statement.js";
59
68
 
60
69
  const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
61
70
  const LAKEBASE_MANIFEST = appkitUtils.data(lakebase).plugin.manifest;
@@ -199,6 +208,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
199
208
  modelsPath: `${basePath}/models`,
200
209
  historyPath: `${basePath}/route/history`,
201
210
  historyPathTemplate: `${basePath}/route/history/:agentId`,
211
+ chartsPathTemplate: `${basePath}/charts/:chartId`,
212
+ statementsPathTemplate: `${basePath}/statements/:statementId`,
202
213
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
203
214
  agents: Object.keys(this.built?.agents ?? {}),
204
215
  };
@@ -218,12 +229,147 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
218
229
  .catch(next);
219
230
  });
220
231
 
232
+ // `GET /charts/:chartId` long-polls the chart cache.
233
+ //
234
+ // Charts kicked off by the Genie agent's `prepare_chart` tool
235
+ // (or the system `render_data` tool) resolve asynchronously
236
+ // and land in the AppKit cache under a chartId. The host UI
237
+ // resolves `[chart:<chartId>]` markers by hitting this route,
238
+ // which blocks until the entry settles (`result` or `error`
239
+ // set) or the server-side budget elapses (then returns the
240
+ // last seen still-processing entry so the client can poll
241
+ // again).
242
+ //
243
+ // Status codes:
244
+ // - 200 with JSON body when the entry is found (any state).
245
+ // - 404 when the chartId doesn't exist or its 1h TTL elapsed.
246
+ //
247
+ // Query params:
248
+ // - `timeoutMs` (optional, default 60000): how long to long-poll
249
+ // before returning a still-processing entry.
250
+ //
251
+ // Wired with `req.signal` so a closed connection unblocks the
252
+ // poll immediately and the request thread frees up. Cache
253
+ // failures bubble through `next(err)` to Express's default
254
+ // error handler.
255
+ router.get("/charts/:chartId", (req, res, next) => {
256
+ const chartId = req.params["chartId"];
257
+ if (!chartId) {
258
+ res.status(400).json({ error: "chartId is required" });
259
+ return;
260
+ }
261
+ const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
262
+ // Express's `req` predates `AbortSignal`. Bridge the
263
+ // `close` event onto an `AbortController` so a closed
264
+ // connection unblocks the long-poll immediately and frees
265
+ // the request thread. The `close` listener also fires on
266
+ // a normal completion, but at that point we no longer
267
+ // care about the abort and the listener is GC'd with the
268
+ // request.
269
+ const controller = new AbortController();
270
+ req.on("close", () => controller.abort());
271
+ fetchChart(chartId, {
272
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
273
+ signal: controller.signal,
274
+ })
275
+ .then((entry) => {
276
+ if (!entry) {
277
+ res.status(404).json({ error: "chart not found" });
278
+ return;
279
+ }
280
+ res.json(entry);
281
+ })
282
+ .catch(next);
283
+ });
284
+
285
+ // `GET /statements/:statementId` resolves a Genie / Statement
286
+ // Execution result for inline rendering as a table.
287
+ //
288
+ // The agent embeds `[data:<statement_id>]` markers in prose
289
+ // (see `GENIE_INSTRUCTIONS`) whenever it wants the host UI to
290
+ // display a result set without the model re-spelling every
291
+ // row in markdown. This route is what those markers resolve
292
+ // against - one OBO-scoped fetch returns the columns + rows
293
+ // so the client renders an AppKit Table inline.
294
+ //
295
+ // Status codes:
296
+ // - 200 with `StatementData` body when the statement is found.
297
+ // - 404 when the workspace can't find / read the statement.
298
+ //
299
+ // Query params:
300
+ // - `limit` (optional): row cap. Clamped to
301
+ // {@link STATEMENT_ROW_CAP} so a runaway result set
302
+ // can't hose the response.
303
+ //
304
+ // OBO-scoped via {@link userScopedSelf} so the caller's
305
+ // workspace permissions apply; SDK errors propagate through
306
+ // `next(err)` to Express's default error handler.
307
+ router.get("/statements/:statementId", (req, res, next) => {
308
+ const statementId = req.params["statementId"];
309
+ if (!statementId) {
310
+ res.status(400).json({ error: "statementId is required" });
311
+ return;
312
+ }
313
+ const limit = parseStatementLimit(req.query["limit"]);
314
+ const controller = new AbortController();
315
+ req.on("close", () => controller.abort());
316
+ this.userScopedSelf(req)
317
+ .fetchStatement(statementId, {
318
+ ...(limit !== undefined ? { limit } : {}),
319
+ signal: controller.signal,
320
+ })
321
+ .then((data) => {
322
+ if (!data) {
323
+ res.status(404).json({ error: "statement not found" });
324
+ return;
325
+ }
326
+ res.json(data);
327
+ })
328
+ .catch(next);
329
+ });
330
+
221
331
  router.use("", (req, res, next) => {
222
332
  if (!this.mastraApp) return res.status(503).end();
223
333
  return this.userScopedSelf(req).mastraApp!(req, res, next);
224
334
  });
225
335
  }
226
336
 
337
+ /**
338
+ * Implementation backing the `/statements/:statementId` route.
339
+ * Runs inside the AppKit user-context proxy so
340
+ * `getExecutionContext()` returns the OBO-scoped workspace
341
+ * client, then reuses the same `fetchStatementData` pipeline
342
+ * the `get_statement` tool runs so the LLM and the UI see the
343
+ * exact same shape for the same statement.
344
+ *
345
+ * Returns `undefined` for upstream 404s so the route can map
346
+ * them to a clean HTTP 404; any other failure bubbles up.
347
+ */
348
+ private async fetchStatement(
349
+ statementId: string,
350
+ options: { limit?: number; signal?: AbortSignal } = {},
351
+ ): Promise<StatementData | undefined> {
352
+ const client = getExecutionContext().client;
353
+ const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
354
+ try {
355
+ const data = await fetchStatementData(client, statementId, {
356
+ limit,
357
+ ...(options.signal ? { signal: options.signal } : {}),
358
+ });
359
+ return {
360
+ columns: data.columns,
361
+ rows: data.rows,
362
+ rowCount: data.rowCount,
363
+ truncated: data.rows.length < data.rowCount,
364
+ };
365
+ } catch (err) {
366
+ // The Databricks SDK throws on 404; surface as `undefined`
367
+ // so the route maps to a clean HTTP 404 instead of a 500.
368
+ if (isStatementNotFoundError(err)) return undefined;
369
+ throw err;
370
+ }
371
+ }
372
+
227
373
  /**
228
374
  * Return `this.asUser(req)` when the request carries an OBO token,
229
375
  * otherwise return `this` directly. Prevents the noisy AppKit warn
@@ -326,4 +472,35 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
326
472
  }
327
473
  }
328
474
 
475
+ /**
476
+ * Parse the optional `?timeoutMs=<n>` query parameter from a
477
+ * `GET /charts/:chartId` request. Accepts a positive integer up
478
+ * to 5 minutes (clamped) and rejects everything else as
479
+ * `undefined` so {@link fetchChart} falls back to its default.
480
+ * Express produces `string | string[] | undefined`; we normalize
481
+ * to the first scalar before parsing.
482
+ */
483
+ function parseTimeoutMs(raw: unknown): number | undefined {
484
+ const v = Array.isArray(raw) ? raw[0] : raw;
485
+ if (typeof v !== "string") return undefined;
486
+ const n = Number(v);
487
+ if (!Number.isFinite(n) || n <= 0) return undefined;
488
+ return Math.min(Math.floor(n), 5 * 60_000);
489
+ }
490
+
491
+ /**
492
+ * Parse the optional `?limit=<n>` query parameter from a
493
+ * `GET /statements/:statementId` request. Accepts a non-negative
494
+ * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
495
+ * rejects anything else as `undefined` so the route falls back
496
+ * to the server-side cap.
497
+ */
498
+ function parseStatementLimit(raw: unknown): number | undefined {
499
+ const v = Array.isArray(raw) ? raw[0] : raw;
500
+ if (typeof v !== "string") return undefined;
501
+ const n = Number(v);
502
+ if (!Number.isFinite(n) || n < 0) return undefined;
503
+ return Math.floor(n);
504
+ }
505
+
329
506
  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
 
23
26
  import { logUtils } from "@dbx-tools/shared";
@@ -0,0 +1,127 @@
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
+ * `/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.
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
+
30
+ import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experimental";
31
+ import type { GenieDatasetData } from "@dbx-tools/appkit-mastra-shared";
32
+ import { apiUtils } from "@dbx-tools/shared";
33
+
34
+ /**
35
+ * Hard server-side cap on rows returned by the
36
+ * `/statements/:statementId` route. Sized to keep responses small
37
+ * enough for inline tables to render snappily; the route surfaces
38
+ * a `truncated` flag whenever the upstream `rowCount` exceeds
39
+ * this so end users know they're seeing a sample.
40
+ */
41
+ export const STATEMENT_ROW_CAP = 500;
42
+
43
+ /**
44
+ * Best-effort numeric coercion for the Statement Execution API's
45
+ * all-strings cells. Leaves non-numeric strings (and explicit
46
+ * `null`s) intact; everything else flows through `Number`.
47
+ */
48
+ function coerceCell(cell: string | null): unknown {
49
+ if (cell === null) return null;
50
+ if (/^-?\d+(\.\d+)?$/.test(cell)) {
51
+ const n = Number(cell);
52
+ if (Number.isFinite(n)) return n;
53
+ }
54
+ return cell;
55
+ }
56
+
57
+ /**
58
+ * Fetch a single statement's rows via the Statement Execution API
59
+ * and reshape into the shared {@link GenieDatasetData} shape
60
+ * (column array + row records).
61
+ *
62
+ * Optional `limit` slices the returned `rows` client-side so the
63
+ * agent can scan a small sample without paging the full result
64
+ * set into context. `rowCount` always reflects the upstream total
65
+ * so callers know when the slice truncated.
66
+ *
67
+ * Exported because every consumer in the plugin (the
68
+ * `get_statement` tool, the `prepare_chart` dataset resolver, and
69
+ * the `/statements/:statementId` route) needs the exact same
70
+ * fetch + coercion pipeline so LLM-side `get_statement` output
71
+ * and UI-side `[data:<id>]` rendering stay shape-identical for
72
+ * the same `statement_id`.
73
+ */
74
+ export async function fetchStatementData(
75
+ client: WorkspaceClient,
76
+ statementId: string,
77
+ options?: { limit?: number; signal?: AbortSignal },
78
+ ): Promise<GenieDatasetData> {
79
+ const ctx = options?.signal ? apiUtils.toContext(options.signal) : undefined;
80
+ const r = await client.statementExecution.getStatement(
81
+ { statement_id: statementId },
82
+ ctx,
83
+ );
84
+ const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
85
+ const dataArray = (r.result?.data_array ?? []) as Array<Array<string | null>>;
86
+ const sliced =
87
+ options?.limit !== undefined && options.limit >= 0
88
+ ? dataArray.slice(0, options.limit)
89
+ : dataArray;
90
+ const rows = sliced.map((row) => {
91
+ const obj: Record<string, unknown> = {};
92
+ columns.forEach((col, i) => {
93
+ obj[col] = coerceCell(row[i] ?? null);
94
+ });
95
+ return obj;
96
+ });
97
+ return {
98
+ columns,
99
+ rows,
100
+ rowCount: r.manifest?.total_row_count ?? dataArray.length,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * True when `err` looks like the Databricks SDK's "statement not
106
+ * found" error. Matches the typed {@link ApiError} 404 /
107
+ * `RESOURCE_DOES_NOT_EXIST` shape first, then falls back to the
108
+ * lower-level {@link HttpError} 404, then to a loose `does not
109
+ * exist` / `not found` message sniff for SDK shapes we haven't
110
+ * catalogued.
111
+ *
112
+ * Pulled into its own helper so callers (notably the
113
+ * `/statements/:statementId` route) stay decoupled from SDK
114
+ * error-class identity, and the conversion logic stays testable
115
+ * in isolation.
116
+ */
117
+ export function isStatementNotFoundError(err: unknown): boolean {
118
+ if (err instanceof ApiError) {
119
+ if (err.statusCode === 404) return true;
120
+ if (err.errorCode === "RESOURCE_DOES_NOT_EXIST") return true;
121
+ }
122
+ if (err instanceof HttpError && err.code === 404) return true;
123
+ if (err instanceof Error && /does not exist|not found/i.test(err.message)) {
124
+ return true;
125
+ }
126
+ return false;
127
+ }
@@ -33,41 +33,41 @@ import { z } from "zod";
33
33
  const log = logUtils.logger("mastra/tool/send-email");
34
34
 
35
35
  const emailInputSchema = z.object({
36
- to: z.string().describe(stringUtils.toDescription`
36
+ to: z.string().describe(stringUtils.toDescription(`
37
37
  Single recipient email address (e.g. "alice@example.com"). For
38
38
  multiple recipients, comma-separate them yourself.
39
- `),
40
- subject: z.string().describe(stringUtils.toDescription`
39
+ `)),
40
+ subject: z.string().describe(stringUtils.toDescription(`
41
41
  Subject line.
42
- `),
43
- body: z.string().describe(stringUtils.toDescription`
44
- Email body. Plain text or markdown; the renderer downstream
45
- decides which to honour. Be specific - the recipient may not
46
- have any context the model has from prior chat turns.
47
- `),
42
+ `)),
43
+ body: z.string().describe(stringUtils.toDescription(`
44
+ Email body. Plain text or markdown; the renderer downstream decides
45
+ which to honour. Be specific - the recipient may not have any
46
+ context the model has from prior chat turns.
47
+ `)),
48
48
  cc: z
49
49
  .array(z.string())
50
50
  .optional()
51
- .describe(stringUtils.toDescription`
51
+ .describe(stringUtils.toDescription(`
52
52
  Optional CC recipients.
53
- `),
53
+ `)),
54
54
  bcc: z
55
55
  .array(z.string())
56
56
  .optional()
57
- .describe(stringUtils.toDescription`
57
+ .describe(stringUtils.toDescription(`
58
58
  Optional BCC recipients.
59
- `),
59
+ `)),
60
60
  });
61
61
 
62
62
  const emailOutputSchema = z.object({
63
- sent: z.boolean().describe(stringUtils.toDescription`
63
+ sent: z.boolean().describe(stringUtils.toDescription(`
64
64
  True when the email was dispatched. The current implementation
65
- always returns true after console-logging the would-be email;
66
- swap in a real provider to make this meaningful.
67
- `),
68
- recipient: z.string().describe(stringUtils.toDescription`
65
+ always returns true after console-logging the would-be email; swap
66
+ in a real provider to make this meaningful.
67
+ `)),
68
+ recipient: z.string().describe(stringUtils.toDescription(`
69
69
  Echo of the \`to\` field for confirmation.
70
- `),
70
+ `)),
71
71
  });
72
72
 
73
73
  /** Options accepted by {@link buildEmailTool}. */
@@ -111,15 +111,14 @@ export interface BuildEmailToolOptions {
111
111
  export function buildEmailTool(opts: BuildEmailToolOptions = {}) {
112
112
  return createTool({
113
113
  id: opts.id ?? "send_email",
114
- description: stringUtils.toDescription`
115
- Send an email on the user's behalf. Pass a recipient
116
- address, subject, and body; the user will be prompted to
117
- approve the send before it goes out (the tool is
118
- approval-gated). Use this when the user explicitly asks
119
- to send / forward / share something via email - never
120
- autonomously. Keep subjects short and bodies focused; the
121
- recipient may not have any of the chat context.
122
- `,
114
+ description: stringUtils.toDescription(`
115
+ Send an email on the user's behalf. Pass a recipient address,
116
+ subject, and body; the user will be prompted to approve the send
117
+ before it goes out (the tool is approval-gated). Use this when
118
+ the user explicitly asks to send / forward / share something via
119
+ email - never autonomously. Keep subjects short and bodies
120
+ focused; the recipient may not have any of the chat context.
121
+ `),
123
122
  inputSchema: emailInputSchema,
124
123
  outputSchema: emailOutputSchema,
125
124
  requireApproval: true,