@dbx-tools/appkit-mastra 0.1.25 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/genie.ts CHANGED
@@ -1,42 +1,59 @@
1
1
  /**
2
- * Genie agent for Mastra.
2
+ * Genie tools for Mastra.
3
3
  *
4
- * Each configured Genie space exposes a single Mastra tool to the
5
- * calling agent (`genie` for the `"default"` alias, `genie_<alias>`
6
- * otherwise). When invoked, the tool runs end-to-end:
4
+ * Each configured Genie space is surfaced as a small set of flat
5
+ * Mastra tools the calling agent can drive directly - no inner
6
+ * orchestrator agent. The central agent decomposes user questions,
7
+ * picks which Genie space to ask, and composes the final reply.
7
8
  *
8
- * 1. Pulls the per-request {@link WorkspaceClient} off
9
- * `ctx.requestContext` (stamped by `MastraServer`) and emits a
10
- * `started` writer event so the host UI can show progress
11
- * immediately, before any LLM round-trip.
12
- * 2. Spins up a per-call inner Mastra `Agent` with three tools:
13
- * - `ask_genie`: drives one `genieEventChat` turn, fetches
14
- * the matching statement's rows when the turn ran SQL,
15
- * and forwards every wire event (status, thinking, sql,
16
- * rows) through `ctx.writer` for streaming UI updates.
17
- * - `get_space_description`: cheap title / description /
18
- * warehouse id lookup for grounding.
19
- * - `get_space_serialized`: full `GenieSpace` JSON for
20
- * column-level grounding when the description isn't
21
- * enough.
22
- * 3. Runs the inner agent with `structuredOutput` (Mastra's
23
- * two-pass mode + `jsonPromptInjection`) to coerce the
24
- * agent's final answer into a tagged
25
- * `[{type:"text"|"data", ...}]` array. The two-pass design
26
- * avoids Databricks Model Serving's `response_format` +
27
- * `tools` collision; prompt injection sidesteps the
28
- * separate `response_format` + streaming collision in the
29
- * structuring agent.
30
- * 4. Charts every `data` item in parallel via
31
- * {@link runChartPlanner}, maps `text` items to the shared
32
- * {@link GenieSummaryItem} `string` variant, and returns the
33
- * hydrated {@link GenieAgentResult}.
9
+ * Per-space tools (suffixed with `_<alias>` for non-default
10
+ * aliases):
34
11
  *
35
- * The inner agent talks to Genie directly via
36
- * `@dbx-tools/genie` (`genieEventChat`) and the workspace
37
- * `statementExecution.getStatement` API. AppKit's stock `genie`
38
- * plugin is honored only for its `spaces` config so existing
39
- * AppKit-style wiring keeps working without change.
12
+ * - `ask_genie`: drives one `genieEventChat` turn and
13
+ * forwards every wire event (status, thinking, sql, rows)
14
+ * through `ctx.writer` for streaming UI updates. Returns
15
+ * the terminal `GenieMessage` only - rows are NOT fetched
16
+ * eagerly.
17
+ * - `get_space_description`: cheap title / description /
18
+ * warehouse id lookup for grounding.
19
+ * - `get_space_serialized`: full `GenieSpace` JSON for
20
+ * column-level grounding when the description isn't enough.
21
+ *
22
+ * Space-agnostic shared tools (registered once, regardless of
23
+ * how many spaces are wired):
24
+ *
25
+ * - `get_statement`: opt-in lookup of a Genie statement's rows
26
+ * by `statement_id` (with a row `limit`). The agent calls
27
+ * this only when it needs to read values to reason about
28
+ * them; if the data is just being displayed, it embeds a
29
+ * `[data:<statement_id>]` marker in prose instead and lets
30
+ * the host UI resolve it.
31
+ * - `prepare_chart`: mints a short `chartId`, kicks off a
32
+ * background task that fetches the statement's rows and
33
+ * runs the chart-planner, and caches the resolved Echarts
34
+ * spec under the id (1h TTL). Returns the `chartId`
35
+ * synchronously so the agent embeds `[chart:<chartId>]`
36
+ * markers in prose without blocking on chart generation;
37
+ * the host UI fetches the cached chart by id once it's
38
+ * ready (see {@link fetchChart}).
39
+ *
40
+ * Each tool's `execute` pulls the per-request
41
+ * {@link WorkspaceClient} off `ctx.requestContext` (stamped by
42
+ * `MastraServer` under {@link MASTRA_USER_KEY}) and the per-call
43
+ * `writer` / `abortSignal` off `ctx`, so the tools are stateless
44
+ * across requests and the central agent owns the loop.
45
+ *
46
+ * The tools talk to Genie directly via `@dbx-tools/genie`
47
+ * (`genieEventChat`); statement-row fetching is delegated to
48
+ * {@link fetchStatementData} from `./statement.js`, which wraps
49
+ * the workspace `statementExecution.getStatement` API. AppKit's
50
+ * stock `genie` plugin is honored only for its `spaces` config
51
+ * so existing AppKit-style wiring keeps working without change.
52
+ *
53
+ * Suggested orchestration prompt for the central agent lives in
54
+ * {@link GENIE_INSTRUCTIONS}; compose it into the agent's own
55
+ * `instructions` when you want the canonical "how to drive the
56
+ * Genie tools" guidance.
40
57
  */
41
58
 
42
59
  import { CacheManager, genie } from "@databricks/appkit";
@@ -44,15 +61,9 @@ import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experiment
44
61
  import { genieEventChat } from "@dbx-tools/genie";
45
62
  import { type GenieMessage } from "@dbx-tools/genie-shared";
46
63
  import {
47
- type ChartEvent,
48
- type GenieAgentResult,
49
- type GenieDataset,
50
- type GenieDatasetData,
51
- type GenieSummaryItem,
52
- type MastraGenieErrorEvent,
64
+ ChartSchema,
53
65
  type MinimalWriter,
54
66
  type StartedEvent,
55
- type SummaryEvent,
56
67
  } from "@dbx-tools/appkit-mastra-shared";
57
68
  import {
58
69
  apiUtils,
@@ -61,17 +72,16 @@ import {
61
72
  logUtils,
62
73
  stringUtils,
63
74
  } from "@dbx-tools/shared";
64
- import { Agent } from "@mastra/core/agent";
65
75
  import type { RequestContext } from "@mastra/core/request-context";
66
76
  import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
67
77
  import { createTool } from "@mastra/core/tools";
68
78
  import { z } from "zod";
69
79
 
70
80
  import type { MastraTools } from "./agents.js";
71
- import { runChartPlanner } from "./chart.js";
81
+ import { chartPlannerRequestSchema, prepareChart } from "./chart.js";
72
82
  import type { MastraPluginConfig } from "./config.js";
73
83
  import { MASTRA_USER_KEY, type User } from "./config.js";
74
- import { buildModel } from "./model.js";
84
+ import { fetchStatementData } from "./statement.js";
75
85
  import { safeWrite } from "./writer.js";
76
86
 
77
87
  const log = logUtils.logger("mastra/genie");
@@ -79,15 +89,6 @@ const log = logUtils.logger("mastra/genie");
79
89
  /** Default alias used when a single unnamed Genie space is wired up. */
80
90
  export const DEFAULT_GENIE_ALIAS = "default";
81
91
 
82
- /**
83
- * Cap on the inner agent's tool-loop steps. 5 (Mastra default) is
84
- * tight - one `get_space_description` + one `ask_genie` per
85
- * sub-question saturates fast. 16 leaves room for ~10 `ask_genie`
86
- * rounds plus grounding plus the structuring pass (which runs
87
- * after the loop and is its own single call).
88
- */
89
- const DEFAULT_MAX_STEPS = 16;
90
-
91
92
  /* --------------------------- config types --------------------------- */
92
93
 
93
94
  /** Per-space Genie agent configuration. */
@@ -95,11 +96,12 @@ export interface GenieSpaceConfig {
95
96
  /** Genie `space_id`. Required; resolves via `client.genie.getSpace`. */
96
97
  spaceId: string;
97
98
  /**
98
- * Optional human-readable description appended to the Genie
99
- * tool's description so the calling LLM has hints about
100
- * *what data* this space covers (e.g. "orders, returns,
99
+ * Optional human-readable description appended to the per-space
100
+ * tool descriptions so the calling LLM has hints about *what
101
+ * data* this space covers (e.g. "orders, returns,
101
102
  * fulfillment"). When omitted, only the space's own
102
- * `description` (fetched on first use) is shown.
103
+ * `description` (fetched on first use of `get_space_description`)
104
+ * is shown.
103
105
  */
104
106
  hint?: string;
105
107
  }
@@ -107,66 +109,49 @@ export interface GenieSpaceConfig {
107
109
  /** Map of alias -> space config. Accepts either explicit objects or bare space ids. */
108
110
  export type GenieSpacesConfig = Record<string, GenieSpaceConfig | string>;
109
111
 
110
- /* ------------------------- helpers ------------------------- */
111
-
112
- /** Best-effort numeric coercion for Genie's all-strings cells. */
113
- function coerceCell(cell: string | null): unknown {
114
- if (cell === null) return null;
115
- if (/^-?\d+(\.\d+)?$/.test(cell)) {
116
- const n = Number(cell);
117
- if (Number.isFinite(n)) return n;
118
- }
119
- return cell;
120
- }
112
+ /* ------------------------- ctx helpers ------------------------- */
121
113
 
122
114
  /**
123
- * Fetch a single Genie statement's rows via the Statement
124
- * Execution API and reshape into the shared
125
- * {@link GenieDatasetData} shape (column array + row records).
115
+ * Narrow view of the second arg Mastra passes to a tool's
116
+ * `execute(input, ctx)`. Captures the fields the Genie tools
117
+ * actually read - `requestContext` (for user / conversation
118
+ * state), `writer` (for streaming events to the host UI), and
119
+ * `abortSignal` (for per-call cancellation).
126
120
  */
127
- async function fetchStatementData(
128
- client: WorkspaceClient,
129
- statementId: string,
130
- signal?: AbortSignal,
131
- ): Promise<GenieDatasetData> {
132
- const ctx = signal ? apiUtils.toContext(signal) : undefined;
133
- const r = await client.statementExecution.getStatement(
134
- { statement_id: statementId },
135
- ctx,
136
- );
137
- const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
138
- const dataArray = (r.result?.data_array ?? []) as Array<Array<string | null>>;
139
- const rows = dataArray.map((row) => {
140
- const obj: Record<string, unknown> = {};
141
- columns.forEach((col, i) => {
142
- obj[col] = coerceCell(row[i] ?? null);
143
- });
144
- return obj;
145
- });
146
- return {
147
- columns,
148
- rows,
149
- rowCount: r.manifest?.total_row_count ?? rows.length,
150
- };
151
- }
121
+ type ToolExecuteCtx =
122
+ | {
123
+ requestContext?: RequestContext;
124
+ writer?: MinimalWriter;
125
+ abortSignal?: AbortSignal;
126
+ }
127
+ | undefined;
152
128
 
153
129
  /**
154
- * Resolve the message's representative `statement_id`. Genie
155
- * returns one statement per turn in practice; we read the
156
- * (deprecated-but-singular) `message.query_result.statement_id`
157
- * first and fall back to the first attachment's
158
- * `query.statement_id`. Returns `undefined` when the turn had no
159
- * SQL run (pure prose answer).
130
+ * Pull the per-request {@link WorkspaceClient} off the active
131
+ * `RequestContext`. The Mastra plugin's server middleware stamps
132
+ * a {@link User} on the context under {@link MASTRA_USER_KEY}
133
+ * for every request; tools fail loudly when it's missing because
134
+ * that means the Mastra plugin isn't running (e.g. a tool was
135
+ * invoked outside the chat route).
160
136
  */
161
- function extractStatementId(message: GenieMessage): string | undefined {
162
- const top = (message.query_result as { statement_id?: string } | undefined)
163
- ?.statement_id;
164
- if (top) return top;
165
- for (const att of message.attachments ?? []) {
166
- const id = att.query?.statement_id;
167
- if (id) return id;
137
+ function requireClient(
138
+ ctx: ToolExecuteCtx,
139
+ toolId: string,
140
+ ): {
141
+ client: WorkspaceClient;
142
+ requestContext: RequestContext;
143
+ } {
144
+ const requestContext = ctx?.requestContext;
145
+ if (!requestContext) {
146
+ throw new Error(
147
+ `${toolId}: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)`,
148
+ );
149
+ }
150
+ const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
151
+ if (!user) {
152
+ throw new Error(`${toolId}: no user on requestContext (MASTRA_USER_KEY not set)`);
168
153
  }
169
- return undefined;
154
+ return { client: user.executionContext.client, requestContext };
170
155
  }
171
156
 
172
157
  /**
@@ -200,7 +185,7 @@ const PLACEHOLDER_QUESTIONS = new Set([
200
185
  * Treat this as an estimate that gets *extended on every use* by
201
186
  * re-setting the cache entry after each successful turn (sliding
202
187
  * TTL via re-`set`). When the estimate ends up wrong (conversation
203
- * deleted, expired upstream, cross-space referenced), the wrapper
188
+ * deleted, expired upstream, cross-space referenced), `ask_genie`
204
189
  * catches the SDK's `RESOURCE_DOES_NOT_EXIST`/404 and transparently
205
190
  * starts a fresh conversation.
206
191
  */
@@ -209,15 +194,24 @@ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
209
194
  /** Cache namespace prefix so coexisting Mastra caches don't collide. */
210
195
  const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
211
196
 
197
+ /**
198
+ * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
199
+ * conversations are scoped to a single user + space + thread, and
200
+ * `threadId` is already user-scoped (Mastra mints threads per
201
+ * `resourceId`), so a constant user key here is safe and keeps the
202
+ * cache key short.
203
+ */
204
+ const CONVERSATION_USER_KEY = "mastra-genie";
205
+
212
206
  /**
213
207
  * Build the per-request {@link RequestContext} key the active
214
208
  * Genie `conversation_id` lives under for `spaceId`. Scoped by
215
209
  * space so an app calling two Genie spaces in one request keeps
216
210
  * each conversation distinct (Genie conversation ids are
217
211
  * space-scoped on the wire). The same `RequestContext` instance
218
- * flows from the outer `genie` tool through to the inner
219
- * `ask_genie` tool via Mastra, so writes on one side are visible
220
- * on the other without an explicit shared ref.
212
+ * flows from the central agent through to every `ask_genie`
213
+ * invocation, so writes on one call are visible on the next
214
+ * without an explicit shared ref.
221
215
  */
222
216
  const conversationContextKey = (spaceId: string): string =>
223
217
  `mastra__genie_conversation__${spaceId}`;
@@ -237,8 +231,7 @@ function readContextConversationId(
237
231
  /**
238
232
  * Write the active Genie `conversation_id` for `spaceId` onto the
239
233
  * per-request {@link RequestContext}. Subsequent `ask_genie` calls
240
- * in this request will reuse it; the wrapper's tail logic also
241
- * reads it back out for the {@link GenieAgentResult}.
234
+ * in this request will reuse it.
242
235
  */
243
236
  function writeContextConversationId(
244
237
  requestContext: RequestContext,
@@ -248,67 +241,6 @@ function writeContextConversationId(
248
241
  requestContext.set(conversationContextKey(spaceId), conversationId);
249
242
  }
250
243
 
251
- /* ------------------------- chart inventory ------------------------- */
252
-
253
- /**
254
- * Per-request {@link RequestContext} key the resolved chart
255
- * inventory lives under. Keyed by `chartId`, the inventory is a
256
- * `Map<string, ChartEvent>` carrying the full Echarts spec for
257
- * every chart minted on this request - the same payload that
258
- * goes out on the writer stream, kept in-process so output
259
- * processors and downstream tools can resolve `[[chart:<id>]]`
260
- * markers without re-running the planner or pulling from the
261
- * writer stream.
262
- *
263
- * Shared across all Genie spaces because chart ids are minted
264
- * via `commonUtils.shortId()` and are unique within a single
265
- * request regardless of which space produced them.
266
- */
267
- const CHART_INVENTORY_CONTEXT_KEY = "mastra__genie_chart_inventory__";
268
-
269
- /**
270
- * Get the chart inventory map for this request, creating it on
271
- * first access. Subsequent reads return the same map so callers
272
- * mutate in place. The map is request-scoped (collected with the
273
- * `RequestContext` at end of request), so there's no per-process
274
- * leak.
275
- */
276
- export function chartInventoryFromContext(
277
- requestContext: RequestContext,
278
- ): Map<string, ChartEvent> {
279
- const existing = requestContext.get(CHART_INVENTORY_CONTEXT_KEY);
280
- if (existing instanceof Map) {
281
- return existing as Map<string, ChartEvent>;
282
- }
283
- const fresh = new Map<string, ChartEvent>();
284
- requestContext.set(CHART_INVENTORY_CONTEXT_KEY, fresh);
285
- return fresh;
286
- }
287
-
288
- /**
289
- * Stash a resolved chart on the request-scoped inventory so any
290
- * subsequent code in this request (output processors validating
291
- * `[[chart:<id>]]` markers, follow-up tools that want to chart
292
- * the same dataset differently, etc.) can look it up by id.
293
- * No-op when `requestContext` is missing.
294
- */
295
- function recordChartInContext(
296
- requestContext: RequestContext | undefined,
297
- chart: ChartEvent,
298
- ): void {
299
- if (!requestContext) return;
300
- chartInventoryFromContext(requestContext).set(chart.chartId, chart);
301
- }
302
-
303
- /**
304
- * `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
305
- * conversations are scoped to a single user + space + thread, and
306
- * `threadId` is already user-scoped (Mastra mints threads per
307
- * `resourceId`), so a constant user key here is safe and keeps the
308
- * cache key short.
309
- */
310
- const CONVERSATION_USER_KEY = "mastra-genie";
311
-
312
244
  /**
313
245
  * Build the canonical cache key for a `(spaceId, threadId)` pair.
314
246
  * Returns `undefined` when `threadId` is missing - callers should
@@ -381,6 +313,23 @@ async function evictCachedConversationId(cacheKey: string | undefined): Promise<
381
313
  }
382
314
  }
383
315
 
316
+ /**
317
+ * Lazy-seed the active Genie `conversation_id` for `spaceId` from
318
+ * the cross-request cache onto the per-request `RequestContext`.
319
+ * No-op when the slot is already populated (subsequent
320
+ * `ask_genie` calls in the same turn) so we hit the cache at most
321
+ * once per request per space.
322
+ */
323
+ async function ensureConversationSeeded(
324
+ requestContext: RequestContext,
325
+ spaceId: string,
326
+ cacheKey: string | undefined,
327
+ ): Promise<void> {
328
+ if (readContextConversationId(requestContext, spaceId)) return;
329
+ const cached = await readCachedConversationId(cacheKey);
330
+ if (cached) writeContextConversationId(requestContext, spaceId, cached);
331
+ }
332
+
384
333
  /**
385
334
  * True when `err` is the SDK error Genie returns for a
386
335
  * conversation id that no longer exists (deleted, expired upstream,
@@ -399,97 +348,152 @@ function isConversationGoneError(err: unknown): boolean {
399
348
  return false;
400
349
  }
401
350
 
402
- /* --------------------------- inner tools --------------------------- */
351
+ /* ------------------------ prepare_chart input ------------------------ */
352
+
353
+ /**
354
+ * Agent-facing `prepare_chart` input schema. Reuses
355
+ * {@link chartPlannerRequestSchema} (the dataset-driven planner
356
+ * contract) but swaps the inline `data` field for a Genie
357
+ * `statement_id` the tool resolves into rows server-side.
358
+ * `title` is loosened to optional - the planner falls back to a
359
+ * generic placeholder when the agent doesn't supply one.
360
+ *
361
+ * Shaped to match Genie's wire form - `statement_id` (snake)
362
+ * mirrors `query_result.statement_id` and the `get_statement`
363
+ * tool's input field name, so the LLM only ever sees one
364
+ * spelling for the same identifier.
365
+ */
366
+ const prepareChartRequestSchema = chartPlannerRequestSchema
367
+ .omit({ data: true, title: true })
368
+ .extend({
369
+ statement_id: z
370
+ .string()
371
+ .min(1, "statement_id is required")
372
+ .describe(
373
+ stringUtils.toDescription(`
374
+ Genie \`statement_id\` to chart. Read from
375
+ \`message.query_result.statement_id\` or
376
+ \`message.attachments[*].query.statement_id\` returned by
377
+ \`ask_genie\`.
378
+ `),
379
+ ),
380
+ title: chartPlannerRequestSchema.shape.title.optional(),
381
+ });
382
+
383
+ /* ----------------------------- tool ids ----------------------------- */
403
384
 
404
385
  /**
405
- * One entry in {@link InnerToolDeps.resultSets}: the rows for a
406
- * Genie statement plus the Genie `message_id` of the `ask_genie`
407
- * turn that produced it. Tracking `messageId` here lets the
408
- * outer chart loop stamp the chart event (and any chart-error
409
- * writer event) with the same `messageId` the rest of that
410
- * ask's wire events carry, so the host UI groups the chart into
411
- * the same `message_id` pill bucket without a separate lookup.
386
+ * Suffix appended to per-space tool ids when the alias isn't the
387
+ * well-known `default`. Single-space deployments get the bare
388
+ * names (`ask_genie`, `get_space_description`, ...); multi-space
389
+ * deployments get `ask_genie_<alias>` etc. so each space's tools
390
+ * stay disambiguated in the central agent's tool registry.
412
391
  */
413
- interface StatementEntry {
414
- data: GenieDatasetData;
415
- messageId: string;
392
+ function aliasSuffix(alias: string): string {
393
+ if (alias === DEFAULT_GENIE_ALIAS) return "";
394
+ const slug = stringUtils.toIdentifier(alias);
395
+ return slug ? `_${slug}` : "";
416
396
  }
417
397
 
398
+ /* --------------------------- per-space tools --------------------------- */
399
+
418
400
  /**
419
- * Per-call mutable state shared by the inner agent's three tools.
420
- * `resultSets` lets the wrapper pull rows by `statementId` after
421
- * the agent finishes, so the chart-planner doesn't re-fetch. The
422
- * active Genie `conversation_id` lives on `RequestContext` (read
423
- * via {@link readContextConversationId} on the inner tool's `ctx`)
424
- * rather than a shared ref - the same `RequestContext` instance
425
- * threads from `agent.generate({requestContext})` through to every
426
- * tool invocation, so writes propagate to subsequent `ask_genie`
427
- * calls without an extra object. `cacheKey` is the
428
- * {@link CacheManager} key for cross-request persistence (`undefined`
429
- * when `threadId` isn't available and caching is disabled).
401
+ * Drop `suggested_questions` attachments from a {@link GenieMessage}
402
+ * before handing it to the central LLM. Those entries already
403
+ * surface in the UI as one-tap pills via the writer's
404
+ * `suggested_questions` events (see `collectSuggestions` on the
405
+ * client); if we let them ride back in the tool result the LLM
406
+ * tends to quote them inline in its prose, double-showing the
407
+ * same questions and stepping on the dedicated suggestion UI.
408
+ * Query / text / row attachments are preserved so the model can
409
+ * still read `statement_id`, SQL, and the answer text.
430
410
  */
431
- interface InnerToolDeps {
432
- spaceId: string;
433
- client: WorkspaceClient;
434
- writer?: MinimalWriter;
435
- signal?: AbortSignal;
436
- resultSets: Map<string, StatementEntry>;
437
- cacheKey?: string;
411
+ function stripSuggestedQuestions(message: GenieMessage): GenieMessage {
412
+ const attachments = message.attachments;
413
+ if (!attachments || attachments.length === 0) return message;
414
+ const filtered = attachments.filter((att) => !att.suggested_questions);
415
+ if (filtered.length === attachments.length) return message;
416
+ return { ...message, attachments: filtered };
438
417
  }
439
418
 
440
- function buildAskGenieTool(deps: InnerToolDeps) {
441
- const { spaceId, client, writer, signal, resultSets, cacheKey } = deps;
419
+ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string }) {
420
+ const { spaceId, alias, hint } = opts;
421
+ const toolId = `ask_genie${aliasSuffix(alias)}`;
422
+ const hintLine = hint ? ` (${hint})` : "";
442
423
  return createTool({
443
- id: "ask_genie",
444
- description: stringUtils.toDescription`
445
- Send ONE focused natural-language question to the Genie
446
- space and wait for the turn to complete. Returns the final
447
- \`GenieMessage\` plus, when the turn ran SQL, the rows of
448
- the resulting query as \`query_result_data\`. The
449
- \`statement_id\` you reference in your final \`data\`
450
- blocks lives at \`message.query_result.statement_id\` (or
451
- the first attachment's \`query.statement_id\`). Wire
424
+ id: toolId,
425
+ description: stringUtils.toDescription(`
426
+ Ask the Genie space "${alias}"${hintLine} ONE focused
427
+ natural-language sub-question and wait for the turn to
428
+ complete. Genie answers best when each call covers a
429
+ single metric / dimension / time window, so expect to
430
+ call this tool MULTIPLE TIMES per user turn (typically
431
+ two to six) and let the results from earlier calls inform
432
+ later ones - that's the normal pattern, not the exception.
433
+ Do NOT try to cram a multi-part question into a single
434
+ call; decompose first, then ask each piece.
435
+
436
+ Returns the final \`GenieMessage\`. Rows are NOT included -
437
+ the Genie wire response carries the \`statement_id\` for any
438
+ SQL that ran (at \`message.query_result.statement_id\` or the
439
+ first attachment's \`query.statement_id\`); call
440
+ \`get_statement\` with that id only when you need to read
441
+ the underlying values to reason about them. If you just
442
+ want to display the rows to the user, embed a
443
+ \`[data:<statement_id>]\` marker in your prose instead -
444
+ the host UI fetches and renders the rows on its own. Wire
452
445
  events (status, thinking, sql) stream to the user
453
- automatically. Call multiple times to gather different
454
- angles before composing the final response.
455
- `,
446
+ automatically while the call is in flight.
447
+ `),
456
448
  inputSchema: z.object({
457
449
  question: z.string().min(1, "question is required"),
458
450
  }),
459
451
  outputSchema: z.object({
460
452
  message: z.custom<GenieMessage>(),
461
- query_result_data: z.custom<GenieDatasetData>().optional(),
462
453
  }),
463
454
  execute: async ({ question }, ctxRaw) => {
464
- const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
465
- const requestContext = ctx?.requestContext;
466
- if (!requestContext) {
467
- // Mastra always passes a `RequestContext` to tools when the
468
- // parent agent received one. The outer Genie tool insists on
469
- // it (it sources the user from there), so this only fires
470
- // if a misconfigured caller invokes `ask_genie` directly.
471
- throw new Error(
472
- "ask_genie: missing requestContext (parent agent must propagate it)",
473
- );
474
- }
455
+ const ctx = ctxRaw as ToolExecuteCtx;
456
+ const { client, requestContext } = requireClient(ctx, toolId);
457
+ const writer = ctx?.writer;
458
+ const signal = ctx?.abortSignal;
459
+ const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
475
460
 
476
461
  // Bounce placeholder / no-op questions BEFORE spending a Genie
477
- // round-trip on them. The structuring pass occasionally pads
478
- // out the tool loop with a fake `ask_genie("noop")` call,
479
- // which Genie answers with "Your request 'noop' does not
480
- // relate to..." - useless noise that shows up in the UI and
481
- // eats one of the workspace's 5 questions/minute. Returning
482
- // a clear error here surfaces the issue to the agent loop so
483
- // the model corrects course instead of wasting a turn.
462
+ // round-trip on them. Genie answers any of these with "Your
463
+ // request 'noop' does not relate to..." - useless noise that
464
+ // shows up in the UI and eats one of the workspace's 5
465
+ // questions/minute. Returning a clear error here surfaces the
466
+ // issue to the agent loop so the model corrects course instead
467
+ // of wasting a turn.
484
468
  const trimmed = question.trim();
485
469
  if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
486
470
  throw new Error(
487
- `ask_genie: refusing placeholder question "${question}" - ` +
488
- `call ask_genie only with a real natural-language question, ` +
471
+ `${toolId}: refusing placeholder question "${question}" - ` +
472
+ `call ${toolId} only with a real natural-language question, ` +
489
473
  `or skip the call entirely`,
490
474
  );
491
475
  }
492
476
 
477
+ // Seed the active Genie `conversation_id` onto `RequestContext`
478
+ // from the cross-request cache when a Mastra `threadId` is
479
+ // present so multi-turn chats reuse the same Genie conversation
480
+ // (and Genie's accumulated context) across separate user turns.
481
+ // The same `RequestContext` is reused across every `ask_genie`
482
+ // call within one user turn, so `ensureConversationSeeded`
483
+ // hits the cache at most once per request per space.
484
+ const cacheKey = await conversationCacheKey(spaceId, threadId);
485
+ await ensureConversationSeeded(requestContext, spaceId, cacheKey);
486
+
487
+ // Fire the lifecycle `started` event before any LLM /
488
+ // network round-trip so the host UI can pop a "Thinking..."
489
+ // pill the instant the model decides to delegate.
490
+ const startedEvent: StartedEvent = {
491
+ type: "started",
492
+ spaceId,
493
+ content: question,
494
+ };
495
+ await safeWrite(log, writer, startedEvent);
496
+
493
497
  // Single turn of `genieEventChat`. Hoisted into a closure so
494
498
  // we can re-run it after evicting a stale `conversation_id`
495
499
  // without duplicating the event-loop body.
@@ -501,18 +505,10 @@ function buildAskGenieTool(deps: InnerToolDeps) {
501
505
  ...(seedConversationId ? { conversationId: seedConversationId } : {}),
502
506
  ...(signal ? { context: signal } : {}),
503
507
  })) {
504
- await safeWrite(log, writer, event);
505
- // Wire events come in two flavors: the lifecycle `message`
506
- // event embeds the raw `GenieMessage` (read its
507
- // `conversation_id`), and the rest carry a flat
508
- // `conversation_id` field at the top level. The terminal
509
- // `result` event also carries the final `GenieMessage`
510
- // inline so we can capture the snapshot without re-reading
511
- // a buffered `message` event.
512
- const eventConversationId =
513
- event.type === "message"
514
- ? event.message.conversation_id
515
- : event.conversation_id;
508
+ if (event.type !== "message") {
509
+ await safeWrite(log, writer, event);
510
+ }
511
+ const eventConversationId = event.conversation_id;
516
512
  if (eventConversationId) {
517
513
  writeContextConversationId(requestContext, spaceId, eventConversationId);
518
514
  }
@@ -560,43 +556,26 @@ function buildAskGenieTool(deps: InnerToolDeps) {
560
556
  readContextConversationId(requestContext, spaceId),
561
557
  );
562
558
 
563
- const statementId = extractStatementId(finalMessage);
564
- let queryResultData: GenieDatasetData | undefined;
565
- if (statementId) {
566
- const data = await fetchStatementData(client, statementId, signal);
567
- if (data.rowCount > 0) {
568
- queryResultData = data;
569
- // Stash with this ask's `message_id` so the outer chart
570
- // loop can stamp downstream `chart` events with the
571
- // same id the wire events carry - keeps the chart in
572
- // the same `message_id` pill bucket on the host UI.
573
- resultSets.set(statementId, {
574
- data,
575
- messageId: finalMessage.message_id,
576
- });
577
- }
578
- }
579
- return {
580
- message: finalMessage,
581
- ...(queryResultData ? { query_result_data: queryResultData } : {}),
582
- };
559
+ return { message: stripSuggestedQuestions(finalMessage) };
583
560
  },
584
561
  });
585
562
  }
586
563
 
587
- function buildSpaceDescriptionTool(deps: {
588
- spaceId: string;
589
- client: WorkspaceClient;
590
- signal?: AbortSignal;
591
- }) {
592
- const { spaceId, client, signal } = deps;
564
+ function buildSpaceDescriptionTool(opts: { spaceId: string; alias: string }) {
565
+ const { spaceId, alias } = opts;
566
+ const toolId = `get_space_description${aliasSuffix(alias)}`;
593
567
  return createTool({
594
- id: "get_space_description",
595
- description: stringUtils.toDescription`
596
- Return the Genie space's title, description, and warehouse id.
597
- Cheap. Call once at the start of a turn to ground yourself
598
- in what data the space covers.
599
- `,
568
+ id: toolId,
569
+ description: stringUtils.toDescription(`
570
+ Return the Genie space "${alias}"'s title, description, and
571
+ warehouse id. Cheap (single REST call, no LLM round-trip).
572
+ Call this FIRST on any user turn that's going to touch
573
+ \`ask_genie\`, unless the same description already landed
574
+ earlier in this conversation - the title + description tell
575
+ you what tables, metrics, and time windows the space
576
+ actually covers, which is what lets you decompose the
577
+ user's question into the right \`ask_genie\` sub-questions.
578
+ `),
600
579
  inputSchema: z.object({}),
601
580
  outputSchema: z.object({
602
581
  spaceId: z.string(),
@@ -604,9 +583,12 @@ function buildSpaceDescriptionTool(deps: {
604
583
  description: z.string().optional(),
605
584
  warehouseId: z.string().optional(),
606
585
  }),
607
- execute: async () => {
608
- const ctx = signal ? apiUtils.toContext(signal) : undefined;
609
- const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
586
+ execute: async (_input, ctxRaw) => {
587
+ const ctx = ctxRaw as ToolExecuteCtx;
588
+ const { client } = requireClient(ctx, toolId);
589
+ const signal = ctx?.abortSignal;
590
+ const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
591
+ const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
610
592
  return {
611
593
  spaceId,
612
594
  ...(space.title ? { title: space.title } : {}),
@@ -617,494 +599,299 @@ function buildSpaceDescriptionTool(deps: {
617
599
  });
618
600
  }
619
601
 
620
- function buildSpaceSerializedTool(deps: {
621
- spaceId: string;
622
- client: WorkspaceClient;
623
- signal?: AbortSignal;
624
- }) {
625
- const { spaceId, client, signal } = deps;
602
+ function buildSpaceSerializedTool(opts: { spaceId: string; alias: string }) {
603
+ const { spaceId, alias } = opts;
604
+ const toolId = `get_space_serialized${aliasSuffix(alias)}`;
626
605
  return createTool({
627
- id: "get_space_serialized",
628
- description: stringUtils.toDescription`
629
- Return the full \`GenieSpace\` JSON for this space. Use only
630
- when you need exact column / table identifiers
606
+ id: toolId,
607
+ description: stringUtils.toDescription(`
608
+ Return the full \`GenieSpace\` JSON for the "${alias}" space.
609
+ Use only when you need exact column / table identifiers
631
610
  \`get_space_description\` doesn't expose. Larger payload, so
632
611
  prefer the description tool when it's enough.
633
- `,
612
+ `),
634
613
  inputSchema: z.object({}),
635
614
  outputSchema: z.object({ space: z.unknown() }),
636
- execute: async () => {
637
- const ctx = signal ? apiUtils.toContext(signal) : undefined;
638
- const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
615
+ execute: async (_input, ctxRaw) => {
616
+ const ctx = ctxRaw as ToolExecuteCtx;
617
+ const { client } = requireClient(ctx, toolId);
618
+ const signal = ctx?.abortSignal;
619
+ const apiCtx = signal ? apiUtils.toContext(signal) : undefined;
620
+ const space = await client.genie.getSpace({ space_id: spaceId }, apiCtx);
639
621
  return { space };
640
622
  },
641
623
  });
642
624
  }
643
625
 
644
- /* --------------------------- inner agent --------------------------- */
645
-
646
- const AGENT_INSTRUCTIONS = stringUtils.toDescription`
647
- You orchestrate a Databricks Genie space. For every user
648
- question:
649
-
650
- 1. Optionally call \`get_space_description\` to ground; reach
651
- for \`get_space_serialized\` only when you need exact
652
- column / table names the description doesn't expose.
653
- 2. Decompose the question into focused sub-questions (one per
654
- distinct metric / dimension / time window) and call
655
- \`ask_genie\` once per sub-question. Two to six calls is
656
- typical for a non-trivial question; one call is fine when
657
- the question is genuinely atomic.
658
- 3. Each \`ask_genie\` call returns the terminal
659
- \`GenieMessage\`. When the turn ran SQL it also returns
660
- \`query_result_data\` - the actual rows. The matching
661
- \`statement_id\` is on
662
- \`message.query_result.statement_id\` (or the first
663
- attachment's \`query.statement_id\`). You will reference
664
- that exact id in your final \`data\` blocks.
665
- 4. Produce a final structured summary as an ordered array
666
- interleaving \`text\` paragraphs with \`data\` blocks.
667
- INTERLEAVE: prose first, then the \`data\` block it
668
- interprets, then the next prose / data pair. Never dump
669
- all data at the end.
670
- 5. For every \`data\` block, supply the exact
671
- \`statement_id\` you saw on the \`ask_genie\` response. A
672
- short \`description\` ("compare quarterly revenue across
673
- regions", "highlight the steep drop after position 5")
674
- biases the chart-planner's choice of visual. Do NOT pick
675
- chart types or axis labels - the host wraps each \`data\`
676
- block in a chart automatically.
677
- 6. Each \`data\` block should be followed by a short
678
- \`text\` interpretation (deltas, anomalies, takeaways).
679
- Don't paraphrase numbers the visualization will already
680
- show. Skip openers / closers. Plain prose, hyphens (not em
681
- / en dashes), no emojis.
682
- `;
683
-
684
- /**
685
- * Boundary schema for the inner agent's structured output. Two
686
- * tagged shapes only - text or data. The wrapper maps these onto
687
- * the shared {@link GenieSummaryItem} (`string` / `visualize`)
688
- * after charting; we don't redefine GenieSummaryItem here.
689
- */
690
- const agentSummarySchema = z.object({
691
- summary: z.array(
692
- z.discriminatedUnion("type", [
693
- z.object({
694
- type: z.literal("text"),
695
- text: z.string(),
696
- }),
697
- z.object({
698
- type: z.literal("data"),
699
- statementId: z.string(),
700
- title: z.string().optional(),
701
- description: z.string().optional(),
702
- }),
703
- ]),
704
- ),
705
- });
706
-
707
- type AgentSummaryItem = z.infer<typeof agentSummarySchema>["summary"][number];
708
-
709
- /* ----------------------------- factory ----------------------------- */
626
+ /* --------------------------- shared tools --------------------------- */
710
627
 
711
628
  /**
712
- * Options for {@link createGenieTool}. Only carries config that
713
- * doesn't vary per request - the per-request {@link WorkspaceClient},
714
- * `RequestContext`, writer, and abort signal flow through the
715
- * tool's `execute(_, ctx)` and are not captured here.
629
+ * Default row cap for {@link buildGetStatementTool} when the agent
630
+ * doesn't supply a `limit`. Sized to keep result sets out of the
631
+ * model context unless the agent explicitly opts into more rows -
632
+ * the cheap shape (column names + a handful of representative
633
+ * rows) is usually enough to reason about a query.
716
634
  */
717
- export interface CreateGenieToolOptions {
718
- /** Genie space id this tool targets. */
719
- spaceId: string;
720
- /** Plugin config; resolves the LLM and chart planner agent. */
721
- config: MastraPluginConfig;
722
- /** Override the registered tool id. Defaults to `"genie"`. */
723
- toolId?: string;
724
- /** Override the tool description shown to the calling LLM. */
725
- toolDescription?: string;
726
- /**
727
- * Override the inner agent's max tool-loop steps. Defaults to
728
- * {@link DEFAULT_MAX_STEPS}.
729
- */
730
- maxSteps?: number;
731
- }
732
-
733
- /**
734
- * Build the calling agent's Genie tool. The returned Mastra tool
735
- * runs end-to-end on each invocation:
736
- *
737
- * 1. Pull the per-request `WorkspaceClient` off
738
- * `ctx.requestContext` (stamped by `MastraServer` under
739
- * {@link MASTRA_USER_KEY}) and emit a `started` writer
740
- * event so the host UI shows progress immediately.
741
- * 2. Spin up the inner Mastra agent + three tools, fresh per
742
- * call so the row cache stays invocation-scoped.
743
- * 3. Run the agent with `structuredOutput` against
744
- * {@link agentSummarySchema}. Mastra's two-pass design keeps
745
- * the inner loop tools-only (no `response_format`), so the
746
- * Databricks Model Serving `response_format`+`tools`
747
- * collision never fires.
748
- * 4. Walk the returned `[text|data][]`, map `text` items to
749
- * shared `GenieSummaryItem.string`, and chart every `data`
750
- * item in parallel via {@link runChartPlanner} to a
751
- * `GenieSummaryItem.visualize`. Items referencing a missing
752
- * `statementId` are dropped with a warn log; chart-planner
753
- * failures leave `dataset.chart` unset so the host UI falls
754
- * back to a table.
755
- */
756
- export function createGenieTool(opts: CreateGenieToolOptions) {
757
- const {
758
- spaceId,
759
- config,
760
- toolId = "genie",
761
- toolDescription = stringUtils.toDescription`
762
- Ask a question about the Databricks Genie space.
763
-
764
- Returns \`{ summary: SummaryItem[] }\` where each item is
765
- one of:
766
-
767
- - \`{ type: "string", text }\` - prose to weave into your
768
- reply verbatim or paraphrase.
769
- - \`{ type: "visualize", statementId, title?, description?,
770
- dataset: { data: { columns, rows, rowCount },
771
- chart?: { chartId, chartType } } }\` - a chartable result
772
- set. When \`dataset.chart\` is present the chart is ALREADY
773
- rendered and queued for inline display; embed the marker
774
- \`[[chart:<chartId>]]\` on its own line at the position
775
- you want it to appear and the host UI drops the rendered
776
- chart in. Re-use the chartId verbatim - do NOT call
777
- \`render_data\` for the same dataset (it would render the
778
- same chart a second time and stall your stream). Only
779
- fall back to \`render_data\` when \`dataset.chart\` is
780
- missing (chart-planner failed) AND you genuinely need a
781
- picture; otherwise present the data inline as prose or a
782
- short table.
783
- `,
784
- maxSteps = DEFAULT_MAX_STEPS,
785
- } = opts;
635
+ const DEFAULT_STATEMENT_LIMIT = 50;
786
636
 
637
+ function buildGetStatementTool() {
638
+ const toolId = "get_statement";
787
639
  return createTool({
788
640
  id: toolId,
789
- description: toolDescription,
641
+ description: stringUtils.toDescription(`
642
+ Fetch the rows of a Genie statement by its \`statement_id\` (the
643
+ value at \`message.query_result.statement_id\` or
644
+ \`message.attachments[*].query.statement_id\` returned from
645
+ \`ask_genie\`). Use this ONLY when you need to read the underlying
646
+ values to reason about them in your reply - e.g. naming the
647
+ largest row, computing a delta the visualization wouldn't already
648
+ convey, or filtering down to a specific record. If you'd just be
649
+ reciting numbers the user will see anyway, skip the call and
650
+ embed a \`[data:<statement_id>]\` marker in your prose instead;
651
+ the host UI fetches and renders the rows on its own. \`limit\`
652
+ caps the number of rows returned (defaults to
653
+ ${DEFAULT_STATEMENT_LIMIT}). \`rowCount\` reflects the full
654
+ upstream total - compare to \`rows.length\` to detect truncation.
655
+ `),
790
656
  inputSchema: z.object({
791
- question: z.string().describe(stringUtils.toDescription`
792
- Natural-language question about the data in this Genie
793
- space. Phrase it from the user's perspective; the agent
794
- decomposes it internally.
795
- `),
657
+ statement_id: z.string().min(1, "statement_id is required"),
658
+ limit: z
659
+ .number()
660
+ .int()
661
+ .nonnegative()
662
+ .optional()
663
+ .describe(
664
+ "Max rows to return. Defaults to a small sample; raise only when more rows are genuinely needed.",
665
+ ),
796
666
  }),
797
- outputSchema: z.custom<GenieAgentResult>(),
798
- execute: async (input, ctxRaw) => {
799
- const ctx = ctxRaw as
800
- | {
801
- requestContext?: RequestContext;
802
- writer?: MinimalWriter;
803
- abortSignal?: AbortSignal;
804
- }
805
- | undefined;
806
- const requestContext = ctx?.requestContext;
807
- if (!requestContext) {
808
- throw new Error(
809
- "genie: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)",
810
- );
811
- }
812
- const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
813
- if (!user) {
814
- throw new Error("genie: no user on requestContext (MASTRA_USER_KEY not set)");
815
- }
816
- const client = user.executionContext.client;
817
- const writer = ctx?.writer;
667
+ outputSchema: z.object({
668
+ columns: z.array(z.string()),
669
+ rows: z.array(z.record(z.string(), z.unknown())),
670
+ rowCount: z.number(),
671
+ truncated: z.boolean(),
672
+ }),
673
+ execute: async ({ statement_id, limit }, ctxRaw) => {
674
+ const ctx = ctxRaw as ToolExecuteCtx;
675
+ const { client } = requireClient(ctx, toolId);
818
676
  const signal = ctx?.abortSignal;
819
- const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
820
-
821
- // Fire the lifecycle `started` event before any LLM /
822
- // network round-trip so the host UI can pop a "Thinking..."
823
- // pill the instant the model decides to delegate. The wire
824
- // `conversation_id` / `message_id` aren't known yet (no
825
- // Genie call has been made) and ride as `undefined` -
826
- // subscribers that need them watch the later
827
- // `message` / `result` wire events for the real ids.
828
- const startedEvent: StartedEvent = {
829
- type: "started",
830
- spaceId,
831
- content: input.question,
832
- };
833
- await safeWrite(log, writer, startedEvent);
834
-
835
- const resultSets = new Map<string, StatementEntry>();
836
-
837
- // Seed the active Genie `conversation_id` onto
838
- // `RequestContext` from AppKit's `CacheManager` when a Mastra
839
- // `threadId` is present so multi-turn chats reuse the same
840
- // Genie conversation (and Genie's accumulated context) across
841
- // separate tool invocations. The same `RequestContext` flows
842
- // to the inner `ask_genie` tool via Mastra, which reads and
843
- // updates the same slot as Genie hands out / rotates ids.
844
- // Cache misses, threads without memory, and unhealthy cache
845
- // storage all leave the slot unset, which makes `ask_genie`
846
- // call `startConversation` and mint a fresh id (then cache
847
- // it).
848
- const cacheKey = await conversationCacheKey(spaceId, threadId);
849
- const cachedConversationId = await readCachedConversationId(cacheKey);
850
- if (cachedConversationId) {
851
- writeContextConversationId(requestContext, spaceId, cachedConversationId);
852
- }
853
-
854
- const innerDeps: InnerToolDeps = {
855
- spaceId,
856
- client,
857
- ...(writer ? { writer } : {}),
677
+ const effectiveLimit = limit ?? DEFAULT_STATEMENT_LIMIT;
678
+ const data = await fetchStatementData(client, statement_id, {
679
+ limit: effectiveLimit,
858
680
  ...(signal ? { signal } : {}),
859
- resultSets,
860
- ...(cacheKey ? { cacheKey } : {}),
861
- };
862
- const tools = {
863
- ask_genie: buildAskGenieTool(innerDeps),
864
- get_space_description: buildSpaceDescriptionTool({
865
- spaceId,
866
- client,
867
- ...(signal ? { signal } : {}),
868
- }),
869
- get_space_serialized: buildSpaceSerializedTool({
870
- spaceId,
871
- client,
872
- ...(signal ? { signal } : {}),
873
- }),
874
- };
875
-
876
- // Resolve the model config once for this request so we can
877
- // share it with the structuring pass below. The agent's
878
- // `model` field accepts a function form for per-request
879
- // resolution, but `structuredOutput.model` requires a
880
- // static `MastraModelConfig`, and we need both to be on
881
- // the same Databricks endpoint with the same OBO-scoped
882
- // headers. Calling `buildModel` here (inside `execute`)
883
- // keeps user scoping correct because `requestContext`
884
- // already reflects the active request's user.
885
- const resolvedModel = await buildModel(config, requestContext);
886
-
887
- const agent = new Agent({
888
- id: `genie__${spaceId}`,
889
- name: `Genie (${spaceId})`,
890
- description: stringUtils.toDescription`
891
- Inner orchestrator for the "${spaceId}" Genie space.
892
- Asks Genie one focused sub-question at a time and
893
- returns an interleaved [text|data] summary.
894
- `,
895
- instructions: AGENT_INSTRUCTIONS,
896
- model: resolvedModel,
897
- tools,
898
681
  });
899
-
900
- // Mastra's `structuredOutput` operates in one of two modes
901
- // based on whether `model` is set:
902
- // - "direct" (no model) -> the schema is enforced
903
- // in the SAME LLM call as
904
- // the agent loop, by
905
- // adding `response_format`
906
- // alongside `tools`.
907
- // Databricks Model Serving
908
- // rejects that combination
909
- // with `INVALID_PARAMETER_VALUE:
910
- // Cannot specify both
911
- // response_format and tools
912
- // in the same request.`
913
- // - "processor" (model passed) -> the main loop carries
914
- // tools and NO
915
- // `response_format`; a
916
- // separate, tool-free
917
- // structuring agent
918
- // re-prompts the model
919
- // with `response_format`
920
- // to coerce the agent's
921
- // final text into the
922
- // schema.
923
- // We use "processor" mode but ALSO set
924
- // `jsonPromptInjection: true`. Mastra's structuring agent
925
- // calls `.stream(...)` under the hood, and Databricks Model
926
- // Serving rejects `response_format` together with streaming
927
- // (`INVALID_PARAMETER_VALUE: Structured output is not
928
- // currently supported with streaming.`). Prompt injection
929
- // sidesteps that by embedding the JSON Schema in the
930
- // structuring agent's system prompt instead of sending
931
- // `response_format`. `errorStrategy: "warn"` keeps a
932
- // structuring failure from escaping as an unhandled
933
- // promise rejection: it logs and leaves `result.object`
934
- // undefined, which we surface as a clean error in
935
- // {@link GenieAgentResult}.
936
- const agentResult = await agent.generate(input.question, {
937
- requestContext,
938
- maxSteps,
939
- structuredOutput: {
940
- schema: agentSummarySchema,
941
- model: resolvedModel,
942
- jsonPromptInjection: true,
943
- errorStrategy: "warn",
944
- },
945
- ...(signal ? { abortSignal: signal } : {}),
946
- });
947
- const submission = agentResult.object;
948
- if (!submission) {
949
- const message = "Genie agent returned no structured summary";
950
- log.warn("agent:no-summary", { spaceId });
951
- const finalConversationId = readContextConversationId(requestContext, spaceId);
952
- return {
953
- spaceId,
954
- summary: [],
955
- ...(finalConversationId ? { conversationId: finalConversationId } : {}),
956
- error: message,
957
- } satisfies GenieAgentResult;
958
- }
959
-
960
- // Lifecycle hook: the agent + structuring pass are done.
961
- // Emit one `summary` event with the structured-item counts
962
- // so the host UI can transition from "thinking" to
963
- // "charting" and seed N chart skeletons before the
964
- // per-chart `chart` events arrive. We can't fire this
965
- // EARLIER (i.e. when the structuring pass starts) because
966
- // Mastra runs the inner loop + structuring pass together
967
- // inside `agent.generate(...)` with no observable boundary
968
- // between them.
969
- const textItemCount = submission.summary.filter(
970
- (i: AgentSummaryItem) => i.type === "text",
971
- ).length;
972
- const dataItemCount = submission.summary.length - textItemCount;
973
- const summaryEvent: SummaryEvent = {
974
- type: "summary",
975
- spaceId,
976
- items: submission.summary.length,
977
- textItems: textItemCount,
978
- dataItems: dataItemCount,
682
+ return {
683
+ columns: data.columns,
684
+ rows: data.rows,
685
+ rowCount: data.rowCount,
686
+ truncated: data.rows.length < data.rowCount,
979
687
  };
980
- await safeWrite(log, writer, summaryEvent);
981
-
982
- // Chart every `data` item in parallel; map `text` items to
983
- // the shared `string` summary variant verbatim. Missing
984
- // statement ids are dropped (the agent referenced something
985
- // that never came back from `ask_genie`), planner failures
986
- // leave `dataset.chart` unset so the host UI falls back to
987
- // a table render. Each successfully planned chart pushes a
988
- // `chart` writer event so the UI can fade in the rendered
989
- // chart slot the moment its planner returns rather than
990
- // waiting for the entire batch to finish.
991
- const hydrated = await Promise.all(
992
- submission.summary.map(
993
- async (item: AgentSummaryItem): Promise<GenieSummaryItem | undefined> => {
994
- if (item.type === "text") {
995
- return { type: "string", text: item.text };
996
- }
997
- const entry = resultSets.get(item.statementId);
998
- if (!entry) {
999
- log.warn("data:missing-statement", {
1000
- statementId: item.statementId,
1001
- });
1002
- return undefined;
1003
- }
1004
- const { data, messageId } = entry;
1005
- let dataset: GenieDataset = { data };
1006
- try {
1007
- const planned = await runChartPlanner({
1008
- config,
1009
- requestContext,
1010
- title: item.title ?? "Genie result",
1011
- ...(item.description ? { description: item.description } : {}),
1012
- data: data.rows,
1013
- ...(signal ? { signal } : {}),
1014
- });
1015
- const chartId = commonUtils.shortId();
1016
- // Slim chart reference for the LLM-bound result: just
1017
- // `chartId` + `chartType`. The full Echarts spec goes
1018
- // to the UI via the writer event AND into the
1019
- // request-scoped chart inventory below; the model
1020
- // only needs the id to place `[[chart:<id>]]`.
1021
- dataset = {
1022
- data,
1023
- chart: {
1024
- chartId,
1025
- chartType: planned.chartType,
1026
- },
1027
- };
1028
- const chartEvent: ChartEvent = {
1029
- type: "chart",
1030
- chartId,
1031
- statementId: item.statementId,
1032
- messageId,
1033
- ...(item.title ? { title: item.title } : {}),
1034
- ...(item.description ? { description: item.description } : {}),
1035
- data: data.rows,
1036
- option: planned.option,
1037
- };
1038
- await safeWrite(log, writer, chartEvent);
1039
- // Stash the resolved chart on the per-request
1040
- // `RequestContext` so downstream code in the same
1041
- // request (output processors, follow-up tool calls,
1042
- // any post-run hook) can look up the full spec by
1043
- // `chartId` without re-fetching or re-planning.
1044
- recordChartInContext(requestContext, chartEvent);
1045
- } catch (err) {
1046
- const errorMessage = commonUtils.errorMessage(err);
1047
- log.warn("chart:error", {
1048
- statementId: item.statementId,
1049
- messageId,
1050
- error: errorMessage,
1051
- });
1052
- // Surface the chart-planner failure as a writer event
1053
- // stamped with the same `messageId` the rest of this
1054
- // ask's wire events carry, so the host UI groups the
1055
- // failure into the same pill bucket and can surface
1056
- // a "couldn't render chart" note next to the table
1057
- // fallback instead of silently dropping the chart.
1058
- const errorEvent: MastraGenieErrorEvent = {
1059
- type: "error",
1060
- spaceId,
1061
- messageId,
1062
- error: `chart-planner: ${errorMessage}`,
1063
- };
1064
- await safeWrite(log, writer, errorEvent);
1065
- }
1066
- return {
1067
- type: "visualize",
1068
- statementId: item.statementId,
1069
- ...(item.title ? { title: item.title } : {}),
1070
- ...(item.description ? { description: item.description } : {}),
1071
- dataset,
1072
- };
1073
- },
1074
- ),
1075
- );
1076
- const summary = hydrated.filter((x): x is GenieSummaryItem => x !== undefined);
688
+ },
689
+ });
690
+ }
1077
691
 
1078
- log.info("genie:done", {
1079
- spaceId,
1080
- items: summary.length,
1081
- statementsCharted: summary.filter(
1082
- (s) => s.type === "visualize" && s.dataset.chart,
1083
- ).length,
692
+ /**
693
+ * `prepare_chart` Mastra tool. Thin wrapper over
694
+ * {@link prepareChart} that resolves the dataset by fetching the
695
+ * Genie statement's rows on demand. The tool mints a `chartId`
696
+ * synchronously, caches an empty placeholder, and kicks off the
697
+ * planner in the background so the agent loop never blocks. The
698
+ * host UI resolves `[chart:<chartId>]` markers by reading the
699
+ * cached {@link Chart} entry (1h TTL).
700
+ *
701
+ * Space-agnostic: a Genie `statement_id` is workspace-scoped, so
702
+ * one shared `prepare_chart` tool covers every wired Genie space.
703
+ *
704
+ * Cancellation: deliberately does NOT forward the per-call
705
+ * `abortSignal` to {@link prepareChart}. The planner task is
706
+ * fire-and-forget background work; the tool's own `execute`
707
+ * resolves the moment the `chartId` is minted, so the per-call
708
+ * signal aborts the second the tool returns. The 1h cache TTL
709
+ * caps abandoned entries.
710
+ */
711
+ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
712
+ const { config } = opts;
713
+ const toolId = "prepare_chart";
714
+ return createTool({
715
+ id: toolId,
716
+ description: stringUtils.toDescription([
717
+ `
718
+ Queue a chart for the rows of a Genie statement. Mints a
719
+ short \`chartId\` synchronously and kicks off a BACKGROUND
720
+ task that fetches the statement's rows, runs the
721
+ chart-planner to pick a chart type and Echarts spec, and
722
+ caches the result under the \`chartId\` for one hour. The
723
+ host UI fetches the cached chart on its own once it lands.
724
+ `,
725
+ `
726
+ To display the chart in your reply, embed
727
+ \`[chart:<chartId>]\` on its own line at the position you
728
+ want it to appear, using the EXACT \`chartId\` string this
729
+ call returned. Never construct a chart id yourself (it is
730
+ not the \`statement_id\` or any variation of it) - only a
731
+ value returned by this tool resolves to a real chart. The
732
+ tool returns immediately - do NOT wait or call it again to
733
+ "check progress"; the chart resolves asynchronously on the
734
+ host UI's side.
735
+ `,
736
+ `
737
+ Use this only when the data has a story a chart conveys
738
+ better than a table (trends, rankings, distributions,
739
+ parts-of-a-whole). For raw rows, embed
740
+ \`[data:<statement_id>]\` instead and skip this tool.
741
+ `,
742
+ ]),
743
+ inputSchema: prepareChartRequestSchema,
744
+ outputSchema: ChartSchema.pick({ chartId: true }),
745
+ execute: async (request, ctxRaw) => {
746
+ const ctx = ctxRaw as ToolExecuteCtx;
747
+ const { client } = requireClient(ctx, toolId);
748
+ return prepareChart({
749
+ config,
750
+ ...(request.title ? { title: request.title } : {}),
751
+ ...(request.description ? { description: request.description } : {}),
752
+ resolveData: (taskSignal) =>
753
+ fetchStatementData(client, request.statement_id, {
754
+ ...(taskSignal ? { signal: taskSignal } : {}),
755
+ }),
756
+ ...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
1084
757
  });
1085
-
1086
- const finalConversationId = readContextConversationId(requestContext, spaceId);
1087
- return {
1088
- spaceId,
1089
- summary,
1090
- ...(finalConversationId ? { conversationId: finalConversationId } : {}),
1091
- } satisfies GenieAgentResult;
1092
758
  },
1093
759
  });
1094
760
  }
1095
761
 
1096
- /* --------------------- multi-alias surface --------------------- */
762
+ /* --------------------------- orchestration prompt --------------------------- */
1097
763
 
1098
764
  /**
1099
- * Default tool id for a wired Genie alias. The well-known
1100
- * `default` alias collapses to `genie`; every other alias gets a
1101
- * `genie_` prefix so multi-space registrations stay
1102
- * disambiguated.
765
+ * Suggested orchestration prompt for the central agent that owns
766
+ * the Genie tools. Compose into your agent's `instructions` to
767
+ * get the canonical "decompose questions, ask Genie focused
768
+ * sub-questions, place data / chart markers in prose" behavior:
769
+ *
770
+ * ```ts
771
+ * createAgent({
772
+ * instructions: `${myAgentInstructions}\n\n${GENIE_INSTRUCTIONS}`,
773
+ * tools(plugins) {
774
+ * return { ...plugins.genie?.toolkit() };
775
+ * },
776
+ * });
777
+ * ```
778
+ *
779
+ * The prompt references the bare tool names (`ask_genie`,
780
+ * `get_space_description`, `get_space_serialized`,
781
+ * `get_statement`, `prepare_chart`) used for the single-space
782
+ * default alias. Multi-space deployments should write their own
783
+ * variant that names the suffixed per-space tools
784
+ * (e.g. `ask_genie_sales`).
1103
785
  */
1104
- export function defaultGenieToolName(alias: string): string {
1105
- if (alias === DEFAULT_GENIE_ALIAS) return "genie";
1106
- return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
1107
- }
786
+ export const GENIE_INSTRUCTIONS = stringUtils.toDescription([
787
+ "Genie orchestration. For every user question that needs SQL-backed data:",
788
+ {
789
+ numbered: [
790
+ `
791
+ Start by calling \`get_space_description\` to ground yourself
792
+ in what the space covers (tables, metrics, time windows),
793
+ unless you already saw the same description earlier in this
794
+ conversation. Reach for \`get_space_serialized\` only when you
795
+ need exact column / table identifiers the description doesn't
796
+ expose - it's a much larger payload.
797
+ `,
798
+ `
799
+ Decompose the user's question into focused sub-questions
800
+ BEFORE asking Genie anything. One sub-question per distinct
801
+ metric, dimension, or time window. Then call \`ask_genie\`
802
+ once per sub-question - usually two to six calls per turn,
803
+ and let earlier answers inform what you ask next. Cramming
804
+ a multi-part question into one \`ask_genie\` call almost
805
+ always produces a worse answer than asking the pieces
806
+ separately. Only collapse to a single call when the question
807
+ is genuinely atomic ("what was Q3 revenue?").
808
+
809
+ Worked example: user asks "How did SKU 1234's revenue
810
+ compare to its category average last quarter, and which
811
+ regions drove the gap?" Decomposes to: (a) \`ask_genie\`
812
+ for SKU 1234's Q3 revenue, (b) \`ask_genie\` for the
813
+ category-average Q3 revenue, (c) \`ask_genie\` for SKU
814
+ 1234's Q3 revenue split by region. Three focused calls,
815
+ each grounded in the prior results.
816
+ `,
817
+ `
818
+ Each \`ask_genie\` call returns the terminal \`GenieMessage\`.
819
+ When the turn ran SQL the result has a \`statement_id\` - read
820
+ it from \`message.query_result.statement_id\` (or the first
821
+ attachment's \`query.statement_id\`).
822
+ `,
823
+ [
824
+ `
825
+ To DISPLAY a result set in your reply, embed a marker on its
826
+ own line where the visualization should appear. Two marker
827
+ shapes:
828
+ `,
829
+ {
830
+ bullets: [
831
+ `
832
+ \`[data:<statement_id>]\` - render the rows as a table.
833
+ Use this when there's no clear visual story (long lists,
834
+ reference data, single-row results, or the user just
835
+ wants to see the data). Embed the marker directly; no
836
+ tool call needed.
837
+ `,
838
+ `
839
+ \`[chart:<chartId>]\` - render the rows as a chart. To
840
+ get a \`<chartId>\`, call \`prepare_chart\` with the
841
+ statement's id (and an optional \`title\` / one-line
842
+ \`description\` of the insight to surface). The tool
843
+ returns the \`chartId\` synchronously and prepares the
844
+ chart spec in the background; embed the returned id as
845
+ \`[chart:<chartId>]\` on its own line wherever the
846
+ chart should appear. Use a chart when the data has a
847
+ story a visual conveys better than a table (trends,
848
+ rankings, distributions, parts-of-a-whole).
849
+
850
+ NEVER invent or hand-build a \`<chartId>\`. A valid
851
+ \`<chartId>\` is the opaque token a \`prepare_chart\`
852
+ call returned to you in THIS turn - nothing else. It
853
+ is NOT a \`statement_id\`, and it is NOT a
854
+ \`statement_id\` prefix with a label appended (e.g.
855
+ \`01f1...-region-fill\`). If you have not called
856
+ \`prepare_chart\` and received an id back, do not write
857
+ a \`[chart:...]\` marker at all - use \`[data:...]\`
858
+ instead. A fabricated chart id renders nothing and
859
+ wastes a request.
860
+ `,
861
+ ],
862
+ },
863
+ `
864
+ The host UI resolves both markers on its own once it sees
865
+ them - you do NOT need to call \`get_statement\` just to
866
+ display data, and you do NOT need to wait on
867
+ \`prepare_chart\` (it returns the id immediately and the
868
+ host UI fetches the cached chart later). Pick at most one
869
+ marker per statement; don't chart AND table the same result
870
+ side by side.
871
+ `,
872
+ ],
873
+ `
874
+ Call \`get_statement(statement_id, limit?)\` ONLY when you need
875
+ to read the actual values to reason about them (e.g. naming a
876
+ specific row, computing a delta the table or chart wouldn't
877
+ show, or sanity-checking a result before interpreting it). If
878
+ you'd just be reciting numbers the visualization already shows,
879
+ skip the call and use a marker instead. \`limit\` defaults to a
880
+ small sample; raise it only when you genuinely need more rows.
881
+ `,
882
+ `
883
+ Compose your final reply as plain prose. Interleave paragraphs
884
+ with \`[data:...]\` / \`[chart:...]\` markers wherever a result
885
+ should render. Don't dump all markers at the end - place each
886
+ one next to the prose that interprets it. Don't restate every
887
+ number the visualization already shows; call out deltas,
888
+ anomalies, takeaways.
889
+ `,
890
+ ],
891
+ },
892
+ ]);
893
+
894
+ /* --------------------- multi-alias surface --------------------- */
1108
895
 
1109
896
  /**
1110
897
  * Normalize the {@link GenieSpacesConfig} record. Bare-string
@@ -1202,14 +989,19 @@ export function resolveGenieSpaces(
1202
989
  }
1203
990
 
1204
991
  /**
1205
- * Build one Mastra tool per configured Genie space. Each tool is
1206
- * a thin {@link createGenieTool} wrapper with the alias-derived
1207
- * id and a hint-flavored description so the calling LLM knows
1208
- * which space covers what data.
992
+ * Build the flat Mastra tools record for every configured Genie
993
+ * space. Two shared, space-agnostic tools (`get_statement`,
994
+ * `prepare_chart`) are registered once regardless of how many
995
+ * spaces are wired; the per-space tools (`ask_genie`,
996
+ * `get_space_description`, `get_space_serialized`) are suffixed
997
+ * with `_<alias>` for non-default aliases so multi-space
998
+ * deployments stay disambiguated.
1209
999
  *
1210
- * Returns a record keyed by tool id, ready to spread into an
1211
- * `Agent`'s `tools` map (or surfaced via
1212
- * `plugins.genie?.toolkit()`).
1000
+ * Returns a record keyed by tool id, ready to spread into the
1001
+ * central `Agent`'s `tools` map (or surfaced via the
1002
+ * `plugins.genie?.toolkit()` callback). Returns an empty record
1003
+ * when `spaces` resolves to zero entries so the caller can spread
1004
+ * safely.
1213
1005
  */
1214
1006
  export function buildGenieTools(opts: {
1215
1007
  spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
@@ -1217,23 +1009,23 @@ export function buildGenieTools(opts: {
1217
1009
  }): MastraTools {
1218
1010
  const normalized = normalizeGenieSpaces(opts.spaces);
1219
1011
  const tools: Record<string, ReturnType<typeof createTool>> = {};
1012
+ if (Object.keys(normalized).length === 0) return tools;
1013
+
1014
+ // Shared, space-agnostic tools.
1015
+ tools.get_statement = buildGetStatementTool();
1016
+ tools.prepare_chart = buildPrepareChartTool({ config: opts.config });
1017
+
1220
1018
  for (const [alias, space] of Object.entries(normalized)) {
1221
- const id = defaultGenieToolName(alias);
1222
- const toolDescription = stringUtils.toDescription`
1223
- Delegate a natural-language data question to the
1224
- Databricks Genie space "${alias}"${space.hint ? ` (${space.hint})` : ""}.
1225
- Returns an ordered (text | dataset)[] summary the host UI
1226
- renders inline; datasets carry the rows and a
1227
- pre-rendered Echarts spec when the chart-planner
1228
- succeeded. Progress events (status, SQL, row counts,
1229
- charts) stream to the UI automatically.
1230
- `;
1231
- tools[id] = createGenieTool({
1019
+ const askTool = buildAskGenieTool({
1232
1020
  spaceId: space.spaceId,
1233
- config: opts.config,
1234
- toolId: id,
1235
- toolDescription,
1021
+ alias,
1022
+ ...(space.hint ? { hint: space.hint } : {}),
1236
1023
  });
1024
+ const descTool = buildSpaceDescriptionTool({ spaceId: space.spaceId, alias });
1025
+ const serTool = buildSpaceSerializedTool({ spaceId: space.spaceId, alias });
1026
+ tools[askTool.id] = askTool;
1027
+ tools[descTool.id] = descTool;
1028
+ tools[serTool.id] = serTool;
1237
1029
  }
1238
1030
  return tools;
1239
1031
  }
@@ -1241,7 +1033,7 @@ export function buildGenieTools(opts: {
1241
1033
  /**
1242
1034
  * Plugin-toolkit adapter so the `plugins.genie?.toolkit()` lookup
1243
1035
  * inside an agent's `tools(plugins)` callback returns the
1244
- * Genie agent-backed tools instead of throwing on missing plugin.
1036
+ * flat Genie tools record instead of throwing on missing plugin.
1245
1037
  * Mirrors AppKit's `PluginToolkitProvider` shape.
1246
1038
  */
1247
1039
  export function buildGenieToolkitProvider(opts: {