@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.
@@ -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 `/statements/:statementId` route.
114
+ * 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
@@ -32,11 +32,13 @@ 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";
39
40
  import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
41
+ import { fetchStatementData, isStatementNotFoundError, STATEMENT_ROW_CAP, } from "./statement.js";
40
42
  const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
41
43
  const LAKEBASE_MANIFEST = appkitUtils.data(lakebase).plugin.manifest;
42
44
  /**
@@ -171,6 +173,8 @@ export class MastraPlugin extends Plugin {
171
173
  modelsPath: `${basePath}/models`,
172
174
  historyPath: `${basePath}/route/history`,
173
175
  historyPathTemplate: `${basePath}/route/history/:agentId`,
176
+ chartsPathTemplate: `${basePath}/charts/:chartId`,
177
+ statementsPathTemplate: `${basePath}/statements/:statementId`,
174
178
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
175
179
  agents: Object.keys(this.built?.agents ?? {}),
176
180
  };
@@ -188,12 +192,143 @@ export class MastraPlugin extends Plugin {
188
192
  .then((endpoints) => res.json({ endpoints }))
189
193
  .catch(next);
190
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).
205
+ //
206
+ // 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.
209
+ //
210
+ // Query params:
211
+ // - `timeoutMs` (optional, default 60000): how long to long-poll
212
+ // before returning a still-processing entry.
213
+ //
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" });
222
+ return;
223
+ }
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" });
273
+ return;
274
+ }
275
+ const limit = parseStatementLimit(req.query["limit"]);
276
+ const controller = new AbortController();
277
+ 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" });
286
+ return;
287
+ }
288
+ res.json(data);
289
+ })
290
+ .catch(next);
291
+ });
191
292
  router.use("", (req, res, next) => {
192
293
  if (!this.mastraApp)
193
294
  return res.status(503).end();
194
295
  return this.userScopedSelf(req).mastraApp(req, res, next);
195
296
  });
196
297
  }
298
+ /**
299
+ * Implementation backing the `/statements/:statementId` route.
300
+ * Runs inside the AppKit user-context proxy so
301
+ * `getExecutionContext()` returns the OBO-scoped workspace
302
+ * client, then reuses the same `fetchStatementData` pipeline
303
+ * the `get_statement` tool runs so the LLM and the UI see the
304
+ * exact same shape for the same statement.
305
+ *
306
+ * Returns `undefined` for upstream 404s so the route can map
307
+ * them to a clean HTTP 404; any other failure bubbles up.
308
+ */
309
+ async fetchStatement(statementId, options = {}) {
310
+ const client = getExecutionContext().client;
311
+ const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
312
+ try {
313
+ const data = await fetchStatementData(client, statementId, {
314
+ limit,
315
+ ...(options.signal ? { signal: options.signal } : {}),
316
+ });
317
+ return {
318
+ columns: data.columns,
319
+ rows: data.rows,
320
+ rowCount: data.rowCount,
321
+ truncated: data.rows.length < data.rowCount,
322
+ };
323
+ }
324
+ catch (err) {
325
+ // The Databricks SDK throws on 404; surface as `undefined`
326
+ // so the route maps to a clean HTTP 404 instead of a 500.
327
+ if (isStatementNotFoundError(err))
328
+ return undefined;
329
+ throw err;
330
+ }
331
+ }
197
332
  /**
198
333
  * Return `this.asUser(req)` when the request carries an OBO token,
199
334
  * otherwise return `this` directly. Prevents the noisy AppKit warn
@@ -290,4 +425,37 @@ export class MastraPlugin extends Plugin {
290
425
  });
291
426
  }
292
427
  }
428
+ /**
429
+ * Parse the optional `?timeoutMs=<n>` query parameter from a
430
+ * `GET /charts/:chartId` request. Accepts a positive integer up
431
+ * to 5 minutes (clamped) and rejects everything else as
432
+ * `undefined` so {@link fetchChart} falls back to its default.
433
+ * Express produces `string | string[] | undefined`; we normalize
434
+ * to the first scalar before parsing.
435
+ */
436
+ function parseTimeoutMs(raw) {
437
+ const v = Array.isArray(raw) ? raw[0] : raw;
438
+ if (typeof v !== "string")
439
+ return undefined;
440
+ const n = Number(v);
441
+ if (!Number.isFinite(n) || n <= 0)
442
+ return undefined;
443
+ return Math.min(Math.floor(n), 5 * 60_000);
444
+ }
445
+ /**
446
+ * Parse the optional `?limit=<n>` query parameter from a
447
+ * `GET /statements/:statementId` request. Accepts a non-negative
448
+ * integer and lets the route clamp to `STATEMENT_ROW_CAP`;
449
+ * rejects anything else as `undefined` so the route falls back
450
+ * to the server-side cap.
451
+ */
452
+ function parseStatementLimit(raw) {
453
+ const v = Array.isArray(raw) ? raw[0] : raw;
454
+ if (typeof v !== "string")
455
+ return undefined;
456
+ const n = Number(v);
457
+ if (!Number.isFinite(n) || n < 0)
458
+ return undefined;
459
+ return Math.floor(n);
460
+ }
293
461
  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
+ * `/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
+ 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
+ * `/statements/:statementId` 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 `/statements/:statementId` 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
+ * `/statements/:statementId` 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;
@@ -0,0 +1,118 @@
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
+ import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experimental";
30
+ import { apiUtils } from "@dbx-tools/shared";
31
+ /**
32
+ * Hard server-side cap on rows returned by the
33
+ * `/statements/:statementId` 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 const STATEMENT_ROW_CAP = 500;
39
+ /**
40
+ * Best-effort numeric coercion for the Statement Execution API's
41
+ * all-strings cells. Leaves non-numeric strings (and explicit
42
+ * `null`s) intact; everything else flows through `Number`.
43
+ */
44
+ function coerceCell(cell) {
45
+ if (cell === null)
46
+ return null;
47
+ if (/^-?\d+(\.\d+)?$/.test(cell)) {
48
+ const n = Number(cell);
49
+ if (Number.isFinite(n))
50
+ return n;
51
+ }
52
+ return cell;
53
+ }
54
+ /**
55
+ * Fetch a single statement's rows via the Statement Execution API
56
+ * and reshape into the shared {@link GenieDatasetData} shape
57
+ * (column array + row records).
58
+ *
59
+ * Optional `limit` slices the returned `rows` client-side so the
60
+ * agent can scan a small sample without paging the full result
61
+ * set into context. `rowCount` always reflects the upstream total
62
+ * so callers know when the slice truncated.
63
+ *
64
+ * Exported because every consumer in the plugin (the
65
+ * `get_statement` tool, the `prepare_chart` dataset resolver, and
66
+ * the `/statements/:statementId` route) needs the exact same
67
+ * fetch + coercion pipeline so LLM-side `get_statement` output
68
+ * and UI-side `[data:<id>]` rendering stay shape-identical for
69
+ * the same `statement_id`.
70
+ */
71
+ export async function fetchStatementData(client, statementId, options) {
72
+ const ctx = options?.signal ? apiUtils.toContext(options.signal) : undefined;
73
+ const r = await client.statementExecution.getStatement({ statement_id: statementId }, ctx);
74
+ const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
75
+ const dataArray = (r.result?.data_array ?? []);
76
+ const sliced = options?.limit !== undefined && options.limit >= 0
77
+ ? dataArray.slice(0, options.limit)
78
+ : dataArray;
79
+ const rows = sliced.map((row) => {
80
+ const obj = {};
81
+ columns.forEach((col, i) => {
82
+ obj[col] = coerceCell(row[i] ?? null);
83
+ });
84
+ return obj;
85
+ });
86
+ return {
87
+ columns,
88
+ rows,
89
+ rowCount: r.manifest?.total_row_count ?? dataArray.length,
90
+ };
91
+ }
92
+ /**
93
+ * True when `err` looks like the Databricks SDK's "statement not
94
+ * found" error. Matches the typed {@link ApiError} 404 /
95
+ * `RESOURCE_DOES_NOT_EXIST` shape first, then falls back to the
96
+ * lower-level {@link HttpError} 404, then to a loose `does not
97
+ * exist` / `not found` message sniff for SDK shapes we haven't
98
+ * catalogued.
99
+ *
100
+ * Pulled into its own helper so callers (notably the
101
+ * `/statements/:statementId` route) stay decoupled from SDK
102
+ * error-class identity, and the conversion logic stays testable
103
+ * in isolation.
104
+ */
105
+ export function isStatementNotFoundError(err) {
106
+ if (err instanceof ApiError) {
107
+ if (err.statusCode === 404)
108
+ return true;
109
+ if (err.errorCode === "RESOURCE_DOES_NOT_EXIST")
110
+ return true;
111
+ }
112
+ if (err instanceof HttpError && err.code === 404)
113
+ return true;
114
+ if (err instanceof Error && /does not exist|not found/i.test(err.message)) {
115
+ return true;
116
+ }
117
+ return false;
118
+ }
@@ -30,40 +30,40 @@ import { createTool } from "@mastra/core/tools";
30
30
  import { z } from "zod";
31
31
  const log = logUtils.logger("mastra/tool/send-email");
32
32
  const emailInputSchema = z.object({
33
- to: z.string().describe(stringUtils.toDescription `
33
+ to: z.string().describe(stringUtils.toDescription(`
34
34
  Single recipient email address (e.g. "alice@example.com"). For
35
35
  multiple recipients, comma-separate them yourself.
36
- `),
37
- subject: z.string().describe(stringUtils.toDescription `
36
+ `)),
37
+ subject: z.string().describe(stringUtils.toDescription(`
38
38
  Subject line.
39
- `),
40
- body: z.string().describe(stringUtils.toDescription `
41
- Email body. Plain text or markdown; the renderer downstream
42
- decides which to honour. Be specific - the recipient may not
43
- have any context the model has from prior chat turns.
44
- `),
39
+ `)),
40
+ body: z.string().describe(stringUtils.toDescription(`
41
+ Email body. Plain text or markdown; the renderer downstream decides
42
+ which to honour. Be specific - the recipient may not have any
43
+ context the model has from prior chat turns.
44
+ `)),
45
45
  cc: z
46
46
  .array(z.string())
47
47
  .optional()
48
- .describe(stringUtils.toDescription `
48
+ .describe(stringUtils.toDescription(`
49
49
  Optional CC recipients.
50
- `),
50
+ `)),
51
51
  bcc: z
52
52
  .array(z.string())
53
53
  .optional()
54
- .describe(stringUtils.toDescription `
54
+ .describe(stringUtils.toDescription(`
55
55
  Optional BCC recipients.
56
- `),
56
+ `)),
57
57
  });
58
58
  const emailOutputSchema = z.object({
59
- sent: z.boolean().describe(stringUtils.toDescription `
59
+ sent: z.boolean().describe(stringUtils.toDescription(`
60
60
  True when the email was dispatched. The current implementation
61
- always returns true after console-logging the would-be email;
62
- swap in a real provider to make this meaningful.
63
- `),
64
- recipient: z.string().describe(stringUtils.toDescription `
61
+ always returns true after console-logging the would-be email; swap
62
+ in a real provider to make this meaningful.
63
+ `)),
64
+ recipient: z.string().describe(stringUtils.toDescription(`
65
65
  Echo of the \`to\` field for confirmation.
66
- `),
66
+ `)),
67
67
  });
68
68
  /**
69
69
  * Build the `send_email` tool. Approval-gated by default; the
@@ -88,15 +88,14 @@ const emailOutputSchema = z.object({
88
88
  export function buildEmailTool(opts = {}) {
89
89
  return createTool({
90
90
  id: opts.id ?? "send_email",
91
- description: stringUtils.toDescription `
92
- Send an email on the user's behalf. Pass a recipient
93
- address, subject, and body; the user will be prompted to
94
- approve the send before it goes out (the tool is
95
- approval-gated). Use this when the user explicitly asks
96
- to send / forward / share something via email - never
97
- autonomously. Keep subjects short and bodies focused; the
98
- recipient may not have any of the chat context.
99
- `,
91
+ description: stringUtils.toDescription(`
92
+ Send an email on the user's behalf. Pass a recipient address,
93
+ subject, and body; the user will be prompted to approve the send
94
+ before it goes out (the tool is approval-gated). Use this when
95
+ the user explicitly asks to send / forward / share something via
96
+ email - never autonomously. Keep subjects short and bodies
97
+ focused; the recipient may not have any of the chat context.
98
+ `),
100
99
  inputSchema: emailInputSchema,
101
100
  outputSchema: emailOutputSchema,
102
101
  requireApproval: true,