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