@dbx-tools/appkit-mastra 0.1.5 → 0.1.12

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.
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { genie } from "@databricks/appkit";
23
23
  import { createTool } from "@mastra/core/tools";
24
+ import type { MastraPluginConfig } from "./config.js";
24
25
  /** Live AppKit `GeniePlugin` instance. */
25
26
  export type GeniePluginInstance = InstanceType<ReturnType<typeof genie>["plugin"]>;
26
27
  /** Full `exports()` shape of the AppKit `genie` plugin. */
@@ -37,11 +38,16 @@ export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation
37
38
  * Normalised progress event surfaced to the UI as a Mastra
38
39
  * `tool-output` chunk. Loading pill events (`started`, `status`,
39
40
  * `sql`, `suggested`, `error`) are pure UI metadata and never reach
40
- * the LLM. The `chart` variant carries the rows from a Genie SQL
41
- * statement so the host UI's `<ChartSlot>` can render them inline
42
- * via the same path as the `render_data` tool; the LLM still only
43
- * sees the matching {@link datasetSchema} metadata in
44
- * `genieAnswer`'s sibling `datasets[]` field.
41
+ * the LLM.
42
+ *
43
+ * The `chart` variant is the wire shape emitted by
44
+ * {@link emitChartWithPlanning} (used by both this Genie
45
+ * draining loop and the system-level `render_data` tool). All
46
+ * fields except `chartId` are optional because two events per
47
+ * chartId arrive on the wire: the first carries the rows
48
+ * (`title` + `description?` + `data`); the second, on planner
49
+ * success, carries just the resolved Echarts spec (`option`).
50
+ * The host UI's `<ChartSlot>` merges them by `chartId`.
45
51
  */
46
52
  export type GenieProgress = {
47
53
  kind: "started";
@@ -61,9 +67,10 @@ export type GenieProgress = {
61
67
  } | {
62
68
  kind: "chart";
63
69
  chartId: string;
64
- title: string;
70
+ title?: string;
65
71
  description?: string;
66
- data: Array<Record<string, unknown>>;
72
+ data?: Array<Record<string, unknown>>;
73
+ option?: Record<string, unknown>;
67
74
  } | {
68
75
  kind: "text";
69
76
  content: string;
@@ -86,10 +93,16 @@ export declare function defaultGenieToolName(alias: string): string;
86
93
  * Build one `sendMessage` tool per configured Genie alias plus a single
87
94
  * `getConversation` tool. Returns a record keyed by tool id, ready to
88
95
  * spread into an `Agent`'s `tools` map.
96
+ *
97
+ * `config` must be the active plugin config; Genie's
98
+ * `query_result` events are routed through
99
+ * {@link emitChartWithPlanning} which uses it to resolve the
100
+ * chart-planner's model.
89
101
  */
90
102
  export declare function buildGenieTools(opts: {
91
103
  aliases: string[];
92
104
  exports: GenieExports;
105
+ config: MastraPluginConfig;
93
106
  signal?: AbortSignal;
94
107
  }): Record<string, ReturnType<typeof createTool>>;
95
108
  /**
@@ -111,6 +124,8 @@ export declare function buildGenieTools(opts: {
111
124
  * all-or-nothing bundle. Wire `only` / `except` / `prefix` / `rename`
112
125
  * later if a caller needs them.
113
126
  */
114
- export declare function buildGenieProvider(plugin: GeniePluginInstance): {
127
+ export declare function buildGenieProvider(plugin: GeniePluginInstance, opts: {
128
+ config: MastraPluginConfig;
129
+ }): {
115
130
  toolkit(opts?: unknown): Record<string, ReturnType<typeof createTool>>;
116
131
  };
package/dist/src/genie.js CHANGED
@@ -19,11 +19,19 @@
19
19
  * LLM never sees rows, and charts come from the separate
20
20
  * `render_data` tool when the model decides one is useful.
21
21
  */
22
- import { randomUUID } from "node:crypto";
23
22
  import { genie } from "@databricks/appkit";
24
- import { stringUtils } from "@dbx-tools/appkit-shared";
23
+ import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
25
24
  import { createTool } from "@mastra/core/tools";
26
25
  import { z } from "zod";
26
+ import { emitChartWithPlanning } from "./chart.js";
27
+ /**
28
+ * Module-level logger tagged `[mastra/genie]`. Uses the shared
29
+ * {@link logUtils.logger} so calls below `LOG_LEVEL` are
30
+ * discarded for free. Default `LOG_LEVEL` is `info`; flip to
31
+ * `debug` to see per-turn timing (`query_result` → planner
32
+ * waits → `drain:return`).
33
+ */
34
+ const log = logUtils.logger("mastra/genie");
27
35
  /**
28
36
  * Per-dataset metadata surfaced to the LLM. The actual rows are
29
37
  * dispatched separately as a `kind: "chart"` writer event so the
@@ -240,6 +248,11 @@ export function defaultGenieToolName(alias) {
240
248
  * Build one `sendMessage` tool per configured Genie alias plus a single
241
249
  * `getConversation` tool. Returns a record keyed by tool id, ready to
242
250
  * spread into an `Agent`'s `tools` map.
251
+ *
252
+ * `config` must be the active plugin config; Genie's
253
+ * `query_result` events are routed through
254
+ * {@link emitChartWithPlanning} which uses it to resolve the
255
+ * chart-planner's model.
243
256
  */
244
257
  export function buildGenieTools(opts) {
245
258
  const tools = {};
@@ -250,23 +263,17 @@ export function buildGenieTools(opts) {
250
263
  description: stringUtils.toDescription `
251
264
  Ask the Databricks Genie space "${alias}" a single
252
265
  natural-language question. Genie translates it to SQL,
253
- runs the SQL against the configured datasets, and returns
254
- \`genieAnswer\` (its prose answer) plus \`datasets[]\`
255
- (one metadata entry per executed query). Each dataset
256
- carries a short \`chartId\`; embed
257
- \`[[chart:<chartId>]]\` on its own line in your reply at
258
- the position where you want that data rendered as an
259
- inline chart. Do not paraphrase row values - the chart is
260
- the rendering. Add interpretation around the chart
261
- (highlights, deltas, anomalies, takeaways) instead of
262
- repeating numbers.
266
+ runs it, and returns \`genieAnswer\` (prose) plus
267
+ \`datasets[]\` (one entry per executed query, each with
268
+ a short \`chartId\`). Embed \`[[chart:<chartId>]]\` on
269
+ its own line at the position you want that data rendered
270
+ as an inline chart. Add interpretation around the chart
271
+ (deltas, anomalies, takeaways); do not paraphrase row
272
+ values.
263
273
 
264
- Calling this tool is expensive; issue **one** focused
265
- question per user turn. If the first answer doesn't fit,
266
- ask the user a clarifying question rather than
267
- re-querying with rephrased intent. Prefer aggregated
268
- questions over raw-row queries (e.g. ask for "monthly
269
- averages" instead of "all rows" for time-series).
274
+ Issue ONE focused question per user turn. Prefer
275
+ aggregated queries over raw-row queries for time-series
276
+ and distributions.
270
277
  `,
271
278
  inputSchema: sendMessageSchema,
272
279
  outputSchema: genieToolOutputSchema,
@@ -274,7 +281,12 @@ export function buildGenieTools(opts) {
274
281
  const stream = opts.exports.sendMessage(alias, content, conversationId, {
275
282
  signal: opts.signal,
276
283
  });
277
- return drainGenieStream(stream, ctx.writer);
284
+ const requestContext = ctx
285
+ ?.requestContext;
286
+ return drainGenieStream(stream, ctx.writer, {
287
+ config: opts.config,
288
+ ...(requestContext ? { requestContext } : {}),
289
+ });
278
290
  },
279
291
  });
280
292
  }
@@ -303,23 +315,29 @@ export function buildGenieTools(opts) {
303
315
  * 1. {@link GenieProgress} pill events on the writer (`started`,
304
316
  * `status`, `sql`, `suggested`, `error`) drive the loading
305
317
  * pill in the chat bubble.
306
- * 2. `kind: "chart"` events on the writer carry the row payload
307
- * from each Genie SQL statement so the host UI's
308
- * `<ChartSlot>` can render the chart inline at the marker
309
- * position the model picked. The data never reaches the LLM.
310
- * 3. The `DrainResult` returned to the LLM contains
311
- * Genie's prose answer plus a `datasets[]` array of metadata
312
- * (chartId, title, columns, rowCount, sql) the model uses to
313
- * cite charts via `[[chart:<chartId>]]` markers.
318
+ * 2. `kind: "chart"` events on the writer (emitted via
319
+ * {@link emitChartWithPlanning}) carry the row payload from
320
+ * each Genie SQL statement and, on planner success, a
321
+ * follow-up event with the rendered Echarts spec. The host
322
+ * UI's `<ChartSlot>` merges the two by `chartId` and
323
+ * renders inline at the marker position the model picked.
324
+ * The data never reaches the LLM.
325
+ * 3. The `DrainResult` returned to the LLM contains Genie's
326
+ * prose answer plus a `datasets[]` array of metadata
327
+ * (chartId, title, columns, rowCount, sql) the model uses
328
+ * to cite charts via `[[chart:<chartId>]]` markers.
314
329
  *
315
330
  * `query_result` and `message_result` events arrive in either
316
- * order; we buffer per-statement metadata in
317
- * {@link DatasetMeta} so each half can fill in the bits it knows
318
- * about and we emit the chart event once `query_result` lands
319
- * (with whatever title was already set, falling back to a
320
- * generic label otherwise).
331
+ * order; we buffer per-statement scratch keyed by `statementId`
332
+ * so each half can fill in what it knows. The chart event
333
+ * fires the moment `query_result` lands; the planner runs in
334
+ * the background. We `Promise.allSettled` every planner promise
335
+ * before returning so all chart work is attributed to the tool's
336
+ * trace span and so the LLM's `datasets[]` includes every
337
+ * chartId that has actually been queued.
321
338
  */
322
- async function drainGenieStream(stream, writer) {
339
+ async function drainGenieStream(stream, writer, opts) {
340
+ const { config, requestContext } = opts;
323
341
  let conversationId;
324
342
  let genieAnswer;
325
343
  let suggestedFollowUps;
@@ -331,15 +349,23 @@ async function drainGenieStream(stream, writer) {
331
349
  // behaviour here so the UI status pill doesn't flicker and we don't
332
350
  // burn writer bytes on no-op events.
333
351
  let lastStatus;
334
- // Per-statement scratch keyed by Genie's `statementId`. Filled in
335
- // by both `query_result` (rows + columns) and `message_result`
336
- // (sql + title + description); the LLM-bound `datasets[]` is
337
- // built from this at end-of-stream, and chart writer events fire
338
- // when `query_result` lands.
339
- const datasetsByStatementId = new Map();
340
- // Best-effort progress emission. Awaited so the underlying agent
341
- // stream sees events in order; write failures are swallowed so a
342
- // dead writer (e.g. closed downstream) can't take the tool down.
352
+ const scratchByStatementId = new Map();
353
+ const getScratch = (statementId) => {
354
+ let s = scratchByStatementId.get(statementId);
355
+ if (!s) {
356
+ s = { statementId, columns: [], rowCount: 0 };
357
+ scratchByStatementId.set(statementId, s);
358
+ }
359
+ return s;
360
+ };
361
+ /**
362
+ * Planner promises kicked off per `query_result`. Awaited
363
+ * (Promise.allSettled) before drainGenieStream returns so the
364
+ * Genie tool's trace span covers the chart work and the LLM's
365
+ * `datasets[]` accurately reflects every chartId that's been
366
+ * queued for rendering.
367
+ */
368
+ const plannerPromises = [];
343
369
  const emit = async (event) => {
344
370
  if (!writer)
345
371
  return;
@@ -351,13 +377,12 @@ async function drainGenieStream(stream, writer) {
351
377
  }
352
378
  };
353
379
  for await (const event of stream) {
354
- // Uncomment to log every raw Genie wire event before the switch
355
- // routes it through the writer / DrainResult. Useful when tuning
356
- // the pill / answer pipeline against real Genie payloads (status
357
- // codes, attachment shapes, query_result manifests Genie surfaces
358
- // only on certain question types, etc.).
359
- // eslint-disable-next-line no-console
360
- // console.log("[mastra/genie] event", event);
380
+ // Per-event raw payload for tuning the pill / answer pipeline
381
+ // against real Genie traffic. At `info` (the default) this is
382
+ // discarded for free; flip `LOG_LEVEL=debug` to see every
383
+ // raw wire event before the switch routes it through writer
384
+ // and DrainResult.
385
+ log.debug("event", { type: event.type, payload: event });
361
386
  switch (event.type) {
362
387
  case "message_start":
363
388
  conversationId = event.conversationId;
@@ -382,17 +407,30 @@ async function drainGenieStream(stream, writer) {
382
407
  const columns = (event.data?.manifest?.schema?.columns ?? []).map((c) => c.name);
383
408
  const dataArray = (event.data?.result?.data_array ?? []);
384
409
  const rows = genieRowsToObjects(columns, dataArray);
385
- const meta = upsertDatasetMeta(datasetsByStatementId, event.statementId, {
386
- columns,
387
- rowCount: rows.length,
388
- });
389
- await emit({
390
- kind: "chart",
391
- chartId: meta.chartId,
392
- title: meta.title ?? `Genie query`,
393
- ...(meta.description ? { description: meta.description } : {}),
410
+ const scratch = getScratch(event.statementId);
411
+ // emitChartWithPlanning emits the dataset event immediately
412
+ // and kicks off the chart-planner agent in the background.
413
+ // It returns the chartId synchronously; the plannerPromise
414
+ // is awaited at end-of-stream so chart work shows up under
415
+ // this tool's trace span.
416
+ const { chartId, plannerPromise } = await emitChartWithPlanning({
417
+ ...(writer ? { writer } : {}),
418
+ config,
419
+ ...(requestContext ? { requestContext } : {}),
420
+ title: scratch.title ?? `Genie query`,
421
+ ...(scratch.description ? { description: scratch.description } : {}),
394
422
  data: rows,
395
423
  });
424
+ scratch.chartId = chartId;
425
+ scratch.columns = columns;
426
+ scratch.rowCount = rows.length;
427
+ plannerPromises.push(plannerPromise);
428
+ log.debug("query_result", {
429
+ statementId: event.statementId,
430
+ chartId,
431
+ rows: rows.length,
432
+ columns,
433
+ });
396
434
  break;
397
435
  }
398
436
  case "message_result":
@@ -400,14 +438,15 @@ async function drainGenieStream(stream, writer) {
400
438
  for (const attachment of event.message.attachments ?? []) {
401
439
  const sqlText = attachment.query?.query;
402
440
  const stmtId = attachment.query?.statementId;
403
- if (sqlText && stmtId) {
404
- upsertDatasetMeta(datasetsByStatementId, stmtId, {
405
- sql: sqlText,
406
- ...(attachment.query?.title ? { title: attachment.query.title } : {}),
407
- ...(attachment.query?.description
408
- ? { description: attachment.query.description }
409
- : {}),
410
- });
441
+ if (stmtId) {
442
+ const scratch = getScratch(stmtId);
443
+ if (sqlText)
444
+ scratch.sql = sqlText;
445
+ if (attachment.query?.title)
446
+ scratch.title = attachment.query.title;
447
+ if (attachment.query?.description) {
448
+ scratch.description = attachment.query.description;
449
+ }
411
450
  }
412
451
  if (sqlText) {
413
452
  await emit({
@@ -441,20 +480,40 @@ async function drainGenieStream(stream, writer) {
441
480
  break;
442
481
  }
443
482
  }
444
- // Strip statementId / row-only fields when handing the LLM the
445
- // datasets - the model never references statementId, and the
446
- // chartId is what the marker uses.
483
+ // Wait for all chart planners to settle before returning so the
484
+ // tool's trace span covers chart work and the LLM's
485
+ // `datasets[]` reflects only chartIds the client has actually
486
+ // received writer events for. Failures in `emitChartWithPlanning`
487
+ // are already swallowed inside the helper, so this never
488
+ // throws.
489
+ log.debug("planners:awaiting", { count: plannerPromises.length });
490
+ await Promise.allSettled(plannerPromises);
491
+ log.debug("planners:settled", { count: plannerPromises.length });
492
+ // Build the LLM-bound `datasets[]` from scratch entries that
493
+ // actually ran a query (chartId is assigned at `query_result`
494
+ // time). Entries that only saw `message_result` metadata
495
+ // without a row payload are skipped.
447
496
  const datasets = [];
448
- for (const meta of datasetsByStatementId.values()) {
497
+ for (const scratch of scratchByStatementId.values()) {
498
+ if (!scratch.chartId)
499
+ continue;
449
500
  datasets.push({
450
- chartId: meta.chartId,
451
- ...(meta.title ? { title: meta.title } : {}),
452
- ...(meta.description ? { description: meta.description } : {}),
453
- columns: meta.columns,
454
- rowCount: meta.rowCount,
455
- ...(meta.sql ? { sql: meta.sql } : {}),
501
+ chartId: scratch.chartId,
502
+ ...(scratch.title ? { title: scratch.title } : {}),
503
+ ...(scratch.description ? { description: scratch.description } : {}),
504
+ columns: scratch.columns,
505
+ rowCount: scratch.rowCount,
506
+ ...(scratch.sql ? { sql: scratch.sql } : {}),
456
507
  });
457
508
  }
509
+ log.debug("drain:return", {
510
+ conversationId,
511
+ hasAnswer: typeof genieAnswer === "string",
512
+ answerLength: genieAnswer?.length ?? 0,
513
+ chartIds: datasets.map((d) => d.chartId),
514
+ suggestedCount: suggestedFollowUps?.length ?? 0,
515
+ error,
516
+ });
458
517
  return {
459
518
  ...(conversationId ? { conversationId } : {}),
460
519
  ...(genieAnswer ? { genieAnswer } : {}),
@@ -463,30 +522,6 @@ async function drainGenieStream(stream, writer) {
463
522
  ...(error ? { error } : {}),
464
523
  };
465
524
  }
466
- /**
467
- * Get-or-create-and-merge the per-statement scratch entry. Both
468
- * `query_result` and `message_result` paths call this with their
469
- * partial bag of fields; the resulting record is the union of
470
- * everything we know about that statement so far.
471
- */
472
- function upsertDatasetMeta(store, statementId, patch) {
473
- const existing = store.get(statementId);
474
- const merged = {
475
- chartId: existing?.chartId ?? randomUUID().replace(/-/g, "").slice(0, 8),
476
- statementId,
477
- columns: patch.columns ?? existing?.columns ?? [],
478
- rowCount: patch.rowCount ?? existing?.rowCount ?? 0,
479
- ...(patch.title ?? existing?.title
480
- ? { title: patch.title ?? existing?.title }
481
- : {}),
482
- ...(patch.description ?? existing?.description
483
- ? { description: patch.description ?? existing?.description }
484
- : {}),
485
- ...(patch.sql ?? existing?.sql ? { sql: patch.sql ?? existing?.sql } : {}),
486
- };
487
- store.set(statementId, merged);
488
- return merged;
489
- }
490
525
  /**
491
526
  * Convert Genie's `data_array` (column-positional `string | null`
492
527
  * tuples) into plain JS row objects keyed by column name. Numeric
@@ -538,7 +573,7 @@ function coerceCell(cell) {
538
573
  * all-or-nothing bundle. Wire `only` / `except` / `prefix` / `rename`
539
574
  * later if a caller needs them.
540
575
  */
541
- export function buildGenieProvider(plugin) {
576
+ export function buildGenieProvider(plugin, opts) {
542
577
  return {
543
578
  toolkit(_opts) {
544
579
  const aliases = extractGenieAliases(plugin);
@@ -548,6 +583,7 @@ export function buildGenieProvider(plugin) {
548
583
  sendMessage: plugin.sendMessage.bind(plugin),
549
584
  getConversation: plugin.getConversation.bind(plugin),
550
585
  },
586
+ config: opts.config,
551
587
  });
552
588
  },
553
589
  };
@@ -15,9 +15,11 @@
15
15
  * the handler runs - no cookie or user lookups happen here, and the
16
16
  * session-cookie logic stays the single source of truth in `server.ts`.
17
17
  */
18
+ import { logUtils } from "@dbx-tools/appkit-shared";
18
19
  import { toAISdkV5Messages } from "@mastra/ai-sdk/ui";
19
20
  import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY, } from "@mastra/core/request-context";
20
21
  import { registerApiRoute } from "@mastra/core/server";
22
+ const log = logUtils.logger("mastra/history");
21
23
  /** Default history page size; matches the Mastra storage default. */
22
24
  const DEFAULT_PER_PAGE = 20;
23
25
  /** Hard cap so a misbehaving client can't fetch the whole thread at once. */
@@ -42,8 +44,10 @@ export async function loadHistory(opts) {
42
44
  const page = Math.max(0, Math.trunc(opts.page ?? 0));
43
45
  const memory = await opts.agent.getMemory();
44
46
  if (!memory) {
47
+ log.debug("recall:no-memory", { agentId: opts.agent.id, threadId: opts.threadId });
45
48
  return { uiMessages: [], page, perPage, total: 0, hasMore: false };
46
49
  }
50
+ const startedAt = Date.now();
47
51
  const result = await memory.recall({
48
52
  threadId: opts.threadId,
49
53
  ...(opts.resourceId ? { resourceId: opts.resourceId } : {}),
@@ -56,6 +60,16 @@ export async function loadHistory(opts) {
56
60
  });
57
61
  const chronological = sortChronological(result.messages);
58
62
  const uiMessages = toAISdkV5Messages(chronological);
63
+ log.debug("recall:done", {
64
+ agentId: opts.agent.id,
65
+ threadId: opts.threadId,
66
+ page,
67
+ perPage,
68
+ returned: uiMessages.length,
69
+ total: result.total,
70
+ hasMore: result.hasMore,
71
+ elapsedMs: Date.now() - startedAt,
72
+ });
59
73
  return {
60
74
  uiMessages,
61
75
  page,
@@ -19,11 +19,12 @@
19
19
  * is registered); per-agent settings cascade on top of that.
20
20
  */
21
21
  import { lakebase } from "@databricks/appkit";
22
- import { pluginUtils } from "@dbx-tools/appkit-shared";
22
+ import { logUtils, pluginUtils } from "@dbx-tools/appkit-shared";
23
23
  import { fastembed } from "@mastra/fastembed";
24
24
  import { Memory } from "@mastra/memory";
25
25
  import { PgVector, PostgresStore } from "@mastra/pg";
26
26
  import { randomUUID } from "node:crypto";
27
+ const log = logUtils.logger("mastra/memory");
27
28
  /**
28
29
  * True when any plugin-level or per-agent setting could need the
29
30
  * Lakebase pool. Used by `plugin.ts` to gate pool acquisition; the
@@ -80,8 +81,20 @@ export class MemoryBuilder {
80
81
  const memorySetting = def.memory ?? this.config.memory;
81
82
  const storage = this.buildStorage(agentId, storageSetting);
82
83
  const vector = this.buildVector(memorySetting);
83
- if (!storage && !vector)
84
+ if (!storage && !vector) {
85
+ log.debug("agent:stateless", { agentId });
84
86
  return undefined;
87
+ }
88
+ log.debug("agent:configured", {
89
+ agentId,
90
+ storage: storage !== undefined,
91
+ vector: vector !== undefined,
92
+ vectorMode: vector === undefined
93
+ ? "off"
94
+ : typeof memorySetting === "object"
95
+ ? "dedicated"
96
+ : "shared",
97
+ });
85
98
  return new Memory({
86
99
  ...(storage ? { storage } : {}),
87
100
  ...(vector ? { vector, embedder: fastembed } : {}),
package/dist/src/model.js CHANGED
@@ -263,6 +263,7 @@ export async function buildModel(config, requestContext, overrides = {}) {
263
263
  * to the top of the priority list.
264
264
  */
265
265
  async function pickModelId(config, requestContext, overrides, user, host) {
266
+ const log = logUtils.logger(config);
266
267
  const serving = resolveServingConfig(config, FALLBACK_MODEL_IDS);
267
268
  const override = serving.allowOverride
268
269
  ? requestContext.get(MASTRA_MODEL_OVERRIDE_KEY)
@@ -270,15 +271,21 @@ async function pickModelId(config, requestContext, overrides, user, host) {
270
271
  const explicit = override ?? overrides.modelId ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
271
272
  // Cheap exit: when the caller named a specific model and fuzzy
272
273
  // matching is off, there's no reason to touch the catalogue at all.
273
- if (explicit !== undefined && !serving.fuzzy)
274
+ if (explicit !== undefined && !serving.fuzzy) {
275
+ log.debug("model selected", { modelId: explicit, source: "explicit" });
274
276
  return explicit;
277
+ }
275
278
  const endpoints = await listServingEndpoints(user.executionContext.client, host, {
276
279
  ttlMs: serving.ttlMs,
277
280
  });
278
281
  const modelId = explicit !== undefined
279
282
  ? resolveModelId(explicit, endpoints, { threshold: serving.threshold }).modelId
280
283
  : pickFirstAvailable(serving.fallbacks, endpoints);
281
- //logUtils.logger(config).debug(`model selected: ${modelId}`);
284
+ log.debug("model selected", {
285
+ modelId,
286
+ source: explicit !== undefined ? "fuzzy-match" : "fallback",
287
+ requestedExplicit: explicit,
288
+ });
282
289
  return modelId;
283
290
  }
284
291
  /**
@@ -305,9 +312,9 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
305
312
  * 1. Rewrites the outgoing `messages` array to repair Mastra/AI SDK
306
313
  * stream-replay quirks that Databricks-hosted Claude rejects (see
307
314
  * {@link sanitizeServingMessages}).
308
- * 2. When `MASTRA_DEBUG_LLM=1`, dumps the (post-sanitize) JSON body
309
- * to stderr so 4xx debugging doesn't have to fight AI SDK's
310
- * `[Array]` formatter.
315
+ * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
316
+ * 4xx debugging doesn't have to fight AI SDK's `[Array]`
317
+ * formatter.
311
318
  *
312
319
  * Safe to call from any hot path: {@link commonUtils.memoize} ensures
313
320
  * the wrapper is installed at most once per process, so subsequent
@@ -315,7 +322,7 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
315
322
  * {@link buildModel} fires on every agent step.
316
323
  */
317
324
  const setupFetchInterceptor = commonUtils.memoize(() => {
318
- const debug = Boolean(process.env.MASTRA_DEBUG_LLM);
325
+ const log = logUtils.logger("mastra/llm");
319
326
  const original = globalThis.fetch.bind(globalThis);
320
327
  globalThis.fetch = (async (input, init) => {
321
328
  const url = httpUtils.toURL(input);
@@ -328,14 +335,11 @@ const setupFetchInterceptor = commonUtils.memoize(() => {
328
335
  if (rewritten !== init.body) {
329
336
  init = { ...init, body: rewritten };
330
337
  }
331
- if (debug) {
332
- try {
333
- console.error("[mastra:llm-debug] -> POST", url.toString());
334
- console.error(JSON.stringify(JSON.parse(rewritten), null, 2));
335
- }
336
- catch {
337
- console.error("[mastra:llm-debug] -> POST", url.toString(), "(non-JSON body)");
338
- }
338
+ try {
339
+ log.debug("POST", { url: url.toString(), body: JSON.parse(rewritten) });
340
+ }
341
+ catch {
342
+ log.debug("POST", { url: url.toString(), bodyType: "non-JSON" });
339
343
  }
340
344
  return original(input, init);
341
345
  });
@@ -88,7 +88,7 @@ export declare class MastraPlugin extends Plugin<MastraPluginConfig> {
88
88
  */
89
89
  getDefault: () => Agent | null;
90
90
  /** Underlying Mastra instance for advanced use (custom routes etc.). */
91
- getMastra: () => Mastra<Record<string, Agent<any, import("@mastra/core/agent").ToolsInput, undefined, unknown>>, Record<string, import("@mastra/core/workflows").AnyWorkflow>, Record<string, import("@mastra/core/vector").MastraVector<any>>, Record<string, import("@mastra/core/tts").MastraTTS>, import("@mastra/core/logger").IMastraLogger, Record<string, import("@mastra/core/mcp").MCPServerBase<any>>, Record<string, import("@mastra/core/evals").MastraScorer<any, any, any, any>>, Record<string, import("@mastra/core/tools").ToolAction<any, any, any, any, any, any, unknown>>, Record<string, import("@mastra/core/processors").Processor<any, unknown>>, Record<string, import("@mastra/core/memory").MastraMemory>, Record<string, import("@mastra/core/channels").ChannelProvider>> | null;
91
+ getMastra: () => Mastra<Record<string, Agent<any, import("@mastra/core/agent").ToolsInput, undefined, unknown, import("@mastra/core/agent").AgentEditorConfig | undefined>>, Record<string, import("@mastra/core/workflows").AnyWorkflow>, Record<string, import("@mastra/core/vector").MastraVector<any>>, Record<string, import("@mastra/core/tts").MastraTTS>, import("@mastra/core/logger").IMastraLogger, Record<string, import("@mastra/core/mcp").MCPServerBase<any>>, Record<string, import("@mastra/core/evals").MastraScorer<any, any, any, any>>, Record<string, import("@mastra/core/tools").ToolAction<any, any, any, any, any, any, unknown>>, Record<string, import("@mastra/core/processors").Processor<any, unknown>>, Record<string, import("@mastra/core/memory").MastraMemory>, Record<string, import("@mastra/core/channels").ChannelProvider>> | null;
92
92
  /** Express subapp Mastra is mounted on; mostly for tests. */
93
93
  getMastraServer: () => MastraServer | null;
94
94
  /**
@@ -33,7 +33,6 @@ import { Mastra } from "@mastra/core/mastra";
33
33
  import express from "express";
34
34
  import { buildAgents, FALLBACK_AGENT_ID } from "./agents.js";
35
35
  import { historyRoute } from "./history.js";
36
- import { renderChartRoute } from "./render-chart-route.js";
37
36
  import { createMemoryBuilder, needsLakebase } from "./memory.js";
38
37
  import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
39
38
  import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
@@ -163,7 +162,6 @@ export class MastraPlugin extends Plugin {
163
162
  modelsPath: `${basePath}/models`,
164
163
  historyPath: `${basePath}/route/history`,
165
164
  historyPathTemplate: `${basePath}/route/history/:agentId`,
166
- renderChartPath: `${basePath}/route/render-chart`,
167
165
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
168
166
  agents: Object.keys(this.built?.agents ?? {}),
169
167
  };
@@ -218,6 +216,10 @@ export class MastraPlugin extends Plugin {
218
216
  const memoryBuilder = needsLakebase(this.config)
219
217
  ? createMemoryBuilder(this.config, this.context)
220
218
  : undefined;
219
+ this.log.debug("build:start", {
220
+ lakebase: memoryBuilder !== undefined,
221
+ stripStaleCharts: this.config.stripStaleCharts !== false,
222
+ });
221
223
  // Build every agent declared in `config.agents` (or the built-in
222
224
  // fallback when none are declared). Each agent's `model` resolves
223
225
  // workspace URL + bearer at call time so concurrent requests get
@@ -246,10 +248,14 @@ export class MastraPlugin extends Plugin {
246
248
  chatRoute({ path: "/route/chat/:agentId" }),
247
249
  historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
248
250
  historyRoute({ path: "/route/history/:agentId" }),
249
- renderChartRoute({ path: "/route/render-chart", config: this.config }),
250
251
  ],
251
252
  });
252
253
  await this.mastraServer.init();
254
+ this.log.debug("build:done", {
255
+ agents: Object.keys(this.built.agents),
256
+ defaultAgent: this.built.defaultAgentId,
257
+ routes: ["/route/chat", "/route/history", "/models"],
258
+ });
253
259
  }
254
260
  }
255
261
  export const mastra = toPlugin(MastraPlugin);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Mastra input processor that strips `chartId` fields from every
3
+ * tool-invocation result in prior assistant messages before they
4
+ * reach the model.
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.
16
+ *
17
+ * 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.
21
+ */
22
+ import type { InputProcessor } from "@mastra/core/processors";
23
+ /**
24
+ * Input processor that scrubs `chartId` from every tool-invocation
25
+ * result in the message list. Wired onto every agent by default
26
+ * via {@link buildAgents}; opt out with
27
+ * `MastraPluginConfig.stripStaleCharts: false`.
28
+ */
29
+ export declare const stripStaleChartsProcessor: InputProcessor;