@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/README.md +115 -303
- package/dist/src/agents.d.ts +10 -0
- package/dist/src/agents.js +15 -2
- package/dist/src/chart.d.ts +131 -79
- package/dist/src/chart.js +386 -279
- package/dist/src/config.d.ts +19 -16
- package/dist/src/genie.d.ts +90 -106
- package/dist/src/genie.js +523 -642
- package/dist/src/intercept.d.ts +48 -0
- package/dist/src/intercept.js +167 -0
- package/dist/src/plugin.d.ts +12 -0
- package/dist/src/plugin.js +185 -1
- package/dist/src/processors/strip-stale-charts.d.ts +16 -13
- package/dist/src/processors/strip-stale-charts.js +16 -13
- package/dist/src/statement.d.ts +73 -0
- package/dist/src/statement.js +118 -0
- package/dist/src/tools/email.js +27 -28
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/agents.ts +16 -2
- package/src/chart.ts +499 -319
- package/src/config.ts +19 -16
- package/src/genie.ts +560 -768
- package/src/intercept.ts +206 -0
- package/src/plugin.ts +210 -2
- package/src/processors/strip-stale-charts.ts +16 -13
- package/src/statement.ts +127 -0
- package/src/tools/email.ts +27 -28
package/src/genie.ts
CHANGED
|
@@ -1,42 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Genie
|
|
2
|
+
* Genie tools for Mastra.
|
|
3
3
|
*
|
|
4
|
-
* Each configured Genie space
|
|
5
|
-
* calling agent
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
99
|
-
* tool
|
|
100
|
-
*
|
|
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
|
|
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
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
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),
|
|
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
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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(
|
|
441
|
-
const { spaceId,
|
|
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:
|
|
444
|
-
description: stringUtils.toDescription`
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
454
|
-
|
|
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
|
|
465
|
-
const requestContext = ctx
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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.
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
//
|
|
482
|
-
//
|
|
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
|
-
|
|
488
|
-
`call
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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(
|
|
588
|
-
spaceId
|
|
589
|
-
|
|
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:
|
|
595
|
-
description: stringUtils.toDescription`
|
|
596
|
-
Return the Genie space's title, description, and
|
|
597
|
-
|
|
598
|
-
|
|
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 =
|
|
609
|
-
const
|
|
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(
|
|
621
|
-
spaceId
|
|
622
|
-
|
|
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:
|
|
628
|
-
description: stringUtils.toDescription`
|
|
629
|
-
Return the full \`GenieSpace\` JSON for
|
|
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 =
|
|
638
|
-
const
|
|
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
|
-
/* ---------------------------
|
|
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
|
-
*
|
|
713
|
-
* doesn't
|
|
714
|
-
*
|
|
715
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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.
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
/*
|
|
762
|
+
/* --------------------------- orchestration prompt --------------------------- */
|
|
1097
763
|
|
|
1098
764
|
/**
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
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
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1208
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
1234
|
-
|
|
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
|
|
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: {
|