@dbx-tools/appkit-mastra 0.1.12 → 0.1.18
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 +47 -45
- package/dist/src/agents.d.ts +2 -2
- package/dist/src/agents.js +66 -14
- package/dist/src/chart.d.ts +39 -105
- package/dist/src/chart.js +199 -194
- package/dist/src/config.d.ts +104 -0
- package/dist/src/config.js +43 -0
- package/dist/src/genie.d.ts +170 -107
- package/dist/src/genie.js +1003 -577
- package/dist/src/history.d.ts +31 -3
- package/dist/src/history.js +137 -31
- package/dist/src/memory.d.ts +25 -4
- package/dist/src/memory.js +34 -2
- package/dist/src/model.js +2 -2
- package/dist/src/observability.d.ts +64 -0
- package/dist/src/observability.js +85 -0
- package/dist/src/plugin.js +39 -7
- package/dist/src/processors/strip-stale-charts.js +1 -1
- package/dist/src/server.d.ts +12 -0
- package/dist/src/server.js +38 -2
- package/dist/src/serving.js +1 -1
- package/dist/src/tools/email.js +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +21 -16
- package/src/agents.ts +73 -17
- package/src/chart.ts +221 -251
- package/src/config.ts +120 -0
- package/src/genie.ts +1199 -654
- package/src/history.ts +147 -33
- package/src/memory.ts +41 -5
- package/src/model.ts +3 -3
- package/src/observability.ts +116 -0
- package/src/plugin.ts +39 -7
- package/src/processors/strip-stale-charts.ts +1 -1
- package/src/server.ts +49 -2
- package/src/serving.ts +1 -1
- package/src/tools/email.ts +1 -1
package/src/genie.ts
CHANGED
|
@@ -1,751 +1,1296 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Genie agent for Mastra.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* parameter.
|
|
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:
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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}.
|
|
12
34
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `
|
|
18
|
-
*
|
|
19
|
-
* LLM never sees rows, and charts come from the separate
|
|
20
|
-
* `render_data` tool when the model decides one is useful.
|
|
35
|
+
* The legacy AppKit `genie` plugin (`@databricks/appkit`'s `genie`)
|
|
36
|
+
* is no longer used at runtime. The inner agent talks to Genie
|
|
37
|
+
* directly via `@dbx-tools/genie` (`genieEventChat`) and the
|
|
38
|
+
* workspace `statementExecution.getStatement` API. The plugin's
|
|
39
|
+
* `spaces` config is still honored so existing AppKit-style wiring
|
|
40
|
+
* keeps working without change.
|
|
21
41
|
*/
|
|
22
42
|
|
|
23
|
-
import { genie } from "@databricks/appkit";
|
|
24
|
-
import {
|
|
43
|
+
import { CacheManager, genie } from "@databricks/appkit";
|
|
44
|
+
import { ApiError, HttpError, WorkspaceClient } from "@databricks/sdk-experimental";
|
|
45
|
+
import { genieEventChat } from "@dbx-tools/genie";
|
|
46
|
+
import { type GenieMessage } from "@dbx-tools/genie-shared";
|
|
47
|
+
import {
|
|
48
|
+
type ChartEvent,
|
|
49
|
+
type GenieAgentResult,
|
|
50
|
+
type GenieDataset,
|
|
51
|
+
type GenieDatasetData,
|
|
52
|
+
type GenieSummaryItem,
|
|
53
|
+
type MastraGenieErrorEvent,
|
|
54
|
+
type MinimalWriter,
|
|
55
|
+
type StartedEvent,
|
|
56
|
+
type SummaryEvent,
|
|
57
|
+
} from "@dbx-tools/appkit-mastra-shared";
|
|
58
|
+
import {
|
|
59
|
+
apiUtils,
|
|
60
|
+
appkitUtils,
|
|
61
|
+
commonUtils,
|
|
62
|
+
logUtils,
|
|
63
|
+
stringUtils,
|
|
64
|
+
} from "@dbx-tools/shared";
|
|
65
|
+
import { Agent } from "@mastra/core/agent";
|
|
25
66
|
import type { RequestContext } from "@mastra/core/request-context";
|
|
67
|
+
import { MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
|
|
26
68
|
import { createTool } from "@mastra/core/tools";
|
|
27
|
-
import type { ToolStream } from "@mastra/core/tools";
|
|
28
69
|
import { z } from "zod";
|
|
29
70
|
|
|
30
|
-
import {
|
|
71
|
+
import type { MastraTools } from "./agents.js";
|
|
72
|
+
import { runChartPlanner } from "./chart.js";
|
|
31
73
|
import type { MastraPluginConfig } from "./config.js";
|
|
74
|
+
import { MASTRA_USER_KEY, type User } from "./config.js";
|
|
75
|
+
import { buildModel } from "./model.js";
|
|
76
|
+
|
|
77
|
+
const log = logUtils.logger("mastra/genie");
|
|
78
|
+
|
|
79
|
+
/** Default alias used when a single unnamed Genie space is wired up. */
|
|
80
|
+
export const DEFAULT_GENIE_ALIAS = "default";
|
|
32
81
|
|
|
33
82
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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).
|
|
39
88
|
*/
|
|
40
|
-
const
|
|
89
|
+
const DEFAULT_MAX_STEPS = 16;
|
|
90
|
+
|
|
91
|
+
/* --------------------------- config types --------------------------- */
|
|
92
|
+
|
|
93
|
+
/** Per-space Genie agent configuration. */
|
|
94
|
+
export interface GenieSpaceConfig {
|
|
95
|
+
/** Genie `space_id`. Required; resolves via `client.genie.getSpace`. */
|
|
96
|
+
spaceId: string;
|
|
97
|
+
/**
|
|
98
|
+
* Optional human-readable description appended to the Genie
|
|
99
|
+
* tool's description so the calling LLM has hints about
|
|
100
|
+
* *what data* this space covers (e.g. "orders, returns,
|
|
101
|
+
* fulfillment"). When omitted, only the space's own
|
|
102
|
+
* `description` (fetched on first use) is shown.
|
|
103
|
+
*/
|
|
104
|
+
hint?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Map of alias -> space config. Accepts either explicit objects or bare space ids. */
|
|
108
|
+
export type GenieSpacesConfig = Record<string, GenieSpaceConfig | string>;
|
|
109
|
+
|
|
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
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Fetch a single Genie statement's rows via the Statement
|
|
124
|
+
* Execution API and reshape into the shared
|
|
125
|
+
* {@link GenieDatasetData} shape (column array + row records).
|
|
126
|
+
*/
|
|
127
|
+
async function fetchStatementData(
|
|
128
|
+
client: WorkspaceClient,
|
|
129
|
+
statementId: string,
|
|
130
|
+
signal?: AbortSignal,
|
|
131
|
+
): Promise<GenieDatasetData> {
|
|
132
|
+
const ctx = signal ? apiUtils.toContext(signal) : undefined;
|
|
133
|
+
const r = await client.statementExecution.getStatement(
|
|
134
|
+
{ statement_id: statementId },
|
|
135
|
+
ctx,
|
|
136
|
+
);
|
|
137
|
+
const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
|
|
138
|
+
const dataArray = (r.result?.data_array ?? []) as Array<Array<string | null>>;
|
|
139
|
+
const rows = dataArray.map((row) => {
|
|
140
|
+
const obj: Record<string, unknown> = {};
|
|
141
|
+
columns.forEach((col, i) => {
|
|
142
|
+
obj[col] = coerceCell(row[i] ?? null);
|
|
143
|
+
});
|
|
144
|
+
return obj;
|
|
145
|
+
});
|
|
146
|
+
return {
|
|
147
|
+
columns,
|
|
148
|
+
rows,
|
|
149
|
+
rowCount: r.manifest?.total_row_count ?? rows.length,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
41
152
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the message's representative `statement_id`. Genie
|
|
155
|
+
* returns one statement per turn in practice; we read the
|
|
156
|
+
* (deprecated-but-singular) `message.query_result.statement_id`
|
|
157
|
+
* first and fall back to the first attachment's
|
|
158
|
+
* `query.statement_id`. Returns `undefined` when the turn had no
|
|
159
|
+
* SQL run (pure prose answer).
|
|
160
|
+
*/
|
|
161
|
+
function extractStatementId(message: GenieMessage): string | undefined {
|
|
162
|
+
const top = (message.query_result as { statement_id?: string } | undefined)
|
|
163
|
+
?.statement_id;
|
|
164
|
+
if (top) return top;
|
|
165
|
+
for (const att of message.attachments ?? []) {
|
|
166
|
+
const id = att.query?.statement_id;
|
|
167
|
+
if (id) return id;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
44
171
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Best-effort `writer.write`. The writer carries the unified flat
|
|
174
|
+
* event vocabulary directly - no translation layer - so
|
|
175
|
+
* subscribers narrow on `event.type` and read fields inline.
|
|
176
|
+
* Failures (downstream stream closed, cancelled request) are
|
|
177
|
+
* swallowed with a `warn` log so an in-flight Genie turn isn't
|
|
178
|
+
* taken down by a navigated-away client.
|
|
179
|
+
*/
|
|
180
|
+
async function safeWrite(
|
|
181
|
+
writer: MinimalWriter | undefined,
|
|
182
|
+
chunk: unknown,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
if (!writer) return;
|
|
185
|
+
try {
|
|
186
|
+
await writer.write(chunk);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log.warn("writer:error", {
|
|
189
|
+
error: err instanceof Error ? err.message : String(err),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
47
193
|
|
|
48
194
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
195
|
+
* Lowercased placeholder strings we reject at the `ask_genie`
|
|
196
|
+
* boundary so the LLM doesn't spend a Genie round-trip on a
|
|
197
|
+
* non-question. Genie politely answers any of these with "Your
|
|
198
|
+
* request '...' does not relate to..." which is pure UI noise.
|
|
199
|
+
* Kept narrow on purpose - real questions sometimes start with
|
|
200
|
+
* one of these tokens, so we only match the FULL trimmed string.
|
|
52
201
|
*/
|
|
53
|
-
|
|
54
|
-
|
|
202
|
+
const PLACEHOLDER_QUESTIONS = new Set([
|
|
203
|
+
"noop",
|
|
204
|
+
"no-op",
|
|
205
|
+
"skip",
|
|
206
|
+
"none",
|
|
207
|
+
"n/a",
|
|
208
|
+
"na",
|
|
209
|
+
"null",
|
|
210
|
+
"undefined",
|
|
211
|
+
"test",
|
|
212
|
+
"placeholder",
|
|
213
|
+
]);
|
|
55
214
|
|
|
56
|
-
|
|
57
|
-
export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
|
|
215
|
+
/* ----------------------- conversation state ----------------------- */
|
|
58
216
|
|
|
59
217
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
218
|
+
* Estimated Genie conversation lifetime in seconds. Databricks
|
|
219
|
+
* publishes no official TTL on the conversation resource itself;
|
|
220
|
+
* community projects (e.g. the open-source Databricks Genie Bot)
|
|
221
|
+
* converge on 4 hours of inactivity as a safe operating window.
|
|
222
|
+
* Treat this as an estimate that gets *extended on every use* by
|
|
223
|
+
* re-setting the cache entry after each successful turn (sliding
|
|
224
|
+
* TTL via re-`set`). When the estimate ends up wrong (conversation
|
|
225
|
+
* deleted, expired upstream, cross-space referenced), the wrapper
|
|
226
|
+
* catches the SDK's `RESOURCE_DOES_NOT_EXIST`/404 and transparently
|
|
227
|
+
* starts a fresh conversation.
|
|
65
228
|
*/
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
\`[[chart:<chartId>]]\` on its own line in your reply at the
|
|
71
|
-
position you want the chart to appear; the client renders it
|
|
72
|
-
inline. Do not paraphrase the dataset's rows in prose - the
|
|
73
|
-
chart is the rendering.
|
|
74
|
-
`),
|
|
75
|
-
title: z.string().optional().describe(stringUtils.toDescription`
|
|
76
|
-
Genie's own title for the SQL that produced this dataset.
|
|
77
|
-
Useful as a label when you reference the chart in prose.
|
|
78
|
-
`),
|
|
79
|
-
description: z.string().optional().describe(stringUtils.toDescription`
|
|
80
|
-
Genie's prose description of the SQL, if any.
|
|
81
|
-
`),
|
|
82
|
-
columns: z.array(z.string()).describe(stringUtils.toDescription`
|
|
83
|
-
Column names in display order. Use these when describing what
|
|
84
|
-
is being charted (e.g. "trend of fill_rate over date").
|
|
85
|
-
`),
|
|
86
|
-
rowCount: z.number().describe(stringUtils.toDescription`
|
|
87
|
-
Total rows in this dataset. Mention only if it adds context
|
|
88
|
-
(e.g. "across the last 90 days").
|
|
89
|
-
`),
|
|
90
|
-
sql: z
|
|
91
|
-
.string()
|
|
92
|
-
.optional()
|
|
93
|
-
.describe(stringUtils.toDescription`
|
|
94
|
-
SQL Genie generated and executed. The host UI shows this on
|
|
95
|
-
demand; you do not need to repeat it.
|
|
96
|
-
`),
|
|
97
|
-
});
|
|
229
|
+
const CONVERSATION_TTL_SEC = 4 * 60 * 60;
|
|
230
|
+
|
|
231
|
+
/** Cache namespace prefix so coexisting Mastra caches don't collide. */
|
|
232
|
+
const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
|
|
98
233
|
|
|
99
234
|
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
235
|
+
* Build the per-request {@link RequestContext} key the active
|
|
236
|
+
* Genie `conversation_id` lives under for `spaceId`. Scoped by
|
|
237
|
+
* space so an app calling two Genie spaces in one request keeps
|
|
238
|
+
* each conversation distinct (Genie conversation ids are
|
|
239
|
+
* space-scoped on the wire). The same `RequestContext` instance
|
|
240
|
+
* flows from the outer `genie` tool through to the inner
|
|
241
|
+
* `ask_genie` tool via Mastra, so writes on one side are visible
|
|
242
|
+
* on the other without an explicit shared ref.
|
|
104
243
|
*/
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`),
|
|
134
|
-
suggestedFollowUps: z
|
|
135
|
-
.array(z.string())
|
|
136
|
-
.optional()
|
|
137
|
-
.describe(stringUtils.toDescription`
|
|
138
|
-
Follow-up question suggestions Genie produced. The host UI
|
|
139
|
-
renders these as clickable buttons; you do not need to list
|
|
140
|
-
them in your reply.
|
|
141
|
-
`),
|
|
142
|
-
error: z
|
|
143
|
-
.string()
|
|
144
|
-
.optional()
|
|
145
|
-
.describe(stringUtils.toDescription`
|
|
146
|
-
Genie-side error message if the request failed.
|
|
147
|
-
`),
|
|
148
|
-
});
|
|
244
|
+
const conversationContextKey = (spaceId: string): string =>
|
|
245
|
+
`mastra__genie_conversation__${spaceId}`;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Read the active Genie `conversation_id` for `spaceId` off the
|
|
249
|
+
* per-request {@link RequestContext}. Returns `undefined` when no
|
|
250
|
+
* conversation has been started yet this request.
|
|
251
|
+
*/
|
|
252
|
+
function readContextConversationId(
|
|
253
|
+
requestContext: RequestContext,
|
|
254
|
+
spaceId: string,
|
|
255
|
+
): string | undefined {
|
|
256
|
+
return requestContext.get(conversationContextKey(spaceId)) as string | undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Write the active Genie `conversation_id` for `spaceId` onto the
|
|
261
|
+
* per-request {@link RequestContext}. Subsequent `ask_genie` calls
|
|
262
|
+
* in this request will reuse it; the wrapper's tail logic also
|
|
263
|
+
* reads it back out for the {@link GenieAgentResult}.
|
|
264
|
+
*/
|
|
265
|
+
function writeContextConversationId(
|
|
266
|
+
requestContext: RequestContext,
|
|
267
|
+
spaceId: string,
|
|
268
|
+
conversationId: string | undefined,
|
|
269
|
+
): void {
|
|
270
|
+
requestContext.set(conversationContextKey(spaceId), conversationId);
|
|
271
|
+
}
|
|
149
272
|
|
|
150
|
-
|
|
273
|
+
/* ------------------------- chart inventory ------------------------- */
|
|
151
274
|
|
|
152
275
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* `
|
|
156
|
-
* the
|
|
276
|
+
* Per-request {@link RequestContext} key the resolved chart
|
|
277
|
+
* inventory lives under. Keyed by `chartId`, the inventory is a
|
|
278
|
+
* `Map<string, ChartEvent>` carrying the full Echarts spec for
|
|
279
|
+
* every chart minted on this request - the same payload that
|
|
280
|
+
* goes out on the writer stream, kept in-process so output
|
|
281
|
+
* processors and downstream tools can resolve `[[chart:<id>]]`
|
|
282
|
+
* markers without re-running the planner or pulling from the
|
|
283
|
+
* writer stream.
|
|
157
284
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* fields except `chartId` are optional because two events per
|
|
162
|
-
* chartId arrive on the wire: the first carries the rows
|
|
163
|
-
* (`title` + `description?` + `data`); the second, on planner
|
|
164
|
-
* success, carries just the resolved Echarts spec (`option`).
|
|
165
|
-
* The host UI's `<ChartSlot>` merges them by `chartId`.
|
|
285
|
+
* Shared across all Genie spaces because chart ids are minted
|
|
286
|
+
* via `commonUtils.shortId()` and are unique within a single
|
|
287
|
+
* request regardless of which space produced them.
|
|
166
288
|
*/
|
|
167
|
-
|
|
168
|
-
| { kind: "started"; conversationId: string; messageId: string; spaceId: string }
|
|
169
|
-
| { kind: "status"; status: string; label: string }
|
|
170
|
-
| {
|
|
171
|
-
kind: "sql";
|
|
172
|
-
sql: string;
|
|
173
|
-
title?: string;
|
|
174
|
-
description?: string;
|
|
175
|
-
statementId?: string;
|
|
176
|
-
}
|
|
177
|
-
| {
|
|
178
|
-
kind: "chart";
|
|
179
|
-
chartId: string;
|
|
180
|
-
title?: string;
|
|
181
|
-
description?: string;
|
|
182
|
-
data?: Array<Record<string, unknown>>;
|
|
183
|
-
option?: Record<string, unknown>;
|
|
184
|
-
}
|
|
185
|
-
| { kind: "text"; content: string }
|
|
186
|
-
| { kind: "suggested"; questions: string[] }
|
|
187
|
-
| { kind: "error"; error: string };
|
|
188
|
-
|
|
189
|
-
const sendMessageSchema = z.object({
|
|
190
|
-
content: z.string().describe(stringUtils.toDescription`
|
|
191
|
-
Natural-language question to send to the Genie space.
|
|
192
|
-
`),
|
|
193
|
-
conversationId: z
|
|
194
|
-
.string()
|
|
195
|
-
.optional()
|
|
196
|
-
.describe(stringUtils.toDescription`
|
|
197
|
-
Optional Genie conversation id to continue an earlier thread.
|
|
198
|
-
Omit on the first call; pass the id returned in the previous
|
|
199
|
-
result's \`conversationId\` to follow up.
|
|
200
|
-
`),
|
|
201
|
-
});
|
|
289
|
+
const CHART_INVENTORY_CONTEXT_KEY = "mastra__genie_chart_inventory__";
|
|
202
290
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
291
|
+
/**
|
|
292
|
+
* Get the chart inventory map for this request, creating it on
|
|
293
|
+
* first access. Subsequent reads return the same map so callers
|
|
294
|
+
* mutate in place. The map is request-scoped (collected with the
|
|
295
|
+
* `RequestContext` at end of request), so there's no per-process
|
|
296
|
+
* leak.
|
|
297
|
+
*/
|
|
298
|
+
export function chartInventoryFromContext(
|
|
299
|
+
requestContext: RequestContext,
|
|
300
|
+
): Map<string, ChartEvent> {
|
|
301
|
+
const existing = requestContext.get(CHART_INVENTORY_CONTEXT_KEY);
|
|
302
|
+
if (existing instanceof Map) {
|
|
303
|
+
return existing as Map<string, ChartEvent>;
|
|
304
|
+
}
|
|
305
|
+
const fresh = new Map<string, ChartEvent>();
|
|
306
|
+
requestContext.set(CHART_INVENTORY_CONTEXT_KEY, fresh);
|
|
307
|
+
return fresh;
|
|
308
|
+
}
|
|
212
309
|
|
|
213
|
-
/**
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
SQL Genie generated and executed.
|
|
228
|
-
`),
|
|
229
|
-
statementId: z.string().optional().describe(stringUtils.toDescription`
|
|
230
|
-
Statement-execution id; internal bookkeeping.
|
|
231
|
-
`),
|
|
232
|
-
})
|
|
233
|
-
.optional()
|
|
234
|
-
.describe(stringUtils.toDescription`
|
|
235
|
-
SQL Genie attached to this message, if it ran any.
|
|
236
|
-
`),
|
|
237
|
-
text: z
|
|
238
|
-
.object({
|
|
239
|
-
content: z.string().optional().describe(stringUtils.toDescription`
|
|
240
|
-
Genie's natural-language answer text for this attachment.
|
|
241
|
-
`),
|
|
242
|
-
})
|
|
243
|
-
.optional()
|
|
244
|
-
.describe(stringUtils.toDescription`
|
|
245
|
-
Per-attachment text content (independent of the message-level
|
|
246
|
-
\`content\` field).
|
|
247
|
-
`),
|
|
248
|
-
suggestedQuestions: z
|
|
249
|
-
.array(z.string())
|
|
250
|
-
.optional()
|
|
251
|
-
.describe(stringUtils.toDescription`
|
|
252
|
-
Follow-up question suggestions Genie generated for this turn.
|
|
253
|
-
`),
|
|
254
|
-
});
|
|
310
|
+
/**
|
|
311
|
+
* Stash a resolved chart on the request-scoped inventory so any
|
|
312
|
+
* subsequent code in this request (output processors validating
|
|
313
|
+
* `[[chart:<id>]]` markers, follow-up tools that want to chart
|
|
314
|
+
* the same dataset differently, etc.) can look it up by id.
|
|
315
|
+
* No-op when `requestContext` is missing.
|
|
316
|
+
*/
|
|
317
|
+
function recordChartInContext(
|
|
318
|
+
requestContext: RequestContext | undefined,
|
|
319
|
+
chart: ChartEvent,
|
|
320
|
+
): void {
|
|
321
|
+
if (!requestContext) return;
|
|
322
|
+
chartInventoryFromContext(requestContext).set(chart.chartId, chart);
|
|
323
|
+
}
|
|
255
324
|
|
|
256
|
-
/**
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
spaceId: z.string().describe(stringUtils.toDescription`
|
|
265
|
-
Genie space id this message belongs to.
|
|
266
|
-
`),
|
|
267
|
-
status: z.string().describe(stringUtils.toDescription`
|
|
268
|
-
Genie message status (\`COMPLETED\`, \`FAILED\`, etc.).
|
|
269
|
-
`),
|
|
270
|
-
content: z.string().describe(stringUtils.toDescription`
|
|
271
|
-
Outer message-level natural-language content Genie wrote.
|
|
272
|
-
`),
|
|
273
|
-
attachments: z
|
|
274
|
-
.array(genieAttachmentSchema)
|
|
275
|
-
.optional()
|
|
276
|
-
.describe(stringUtils.toDescription`
|
|
277
|
-
Attachments (SQL queries, text blocks, suggested follow-ups)
|
|
278
|
-
Genie produced for this message.
|
|
279
|
-
`),
|
|
280
|
-
error: z.string().optional().describe(stringUtils.toDescription`
|
|
281
|
-
Genie-side error attached to this message, if any.
|
|
282
|
-
`),
|
|
283
|
-
});
|
|
325
|
+
/**
|
|
326
|
+
* `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
|
|
327
|
+
* conversations are scoped to a single user + space + thread, and
|
|
328
|
+
* `threadId` is already user-scoped (Mastra mints threads per
|
|
329
|
+
* `resourceId`), so a constant user key here is safe and keeps the
|
|
330
|
+
* cache key short.
|
|
331
|
+
*/
|
|
332
|
+
const CONVERSATION_USER_KEY = "mastra-genie";
|
|
284
333
|
|
|
285
334
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
335
|
+
* Build the canonical cache key for a `(spaceId, threadId)` pair.
|
|
336
|
+
* Returns `undefined` when `threadId` is missing - callers should
|
|
337
|
+
* skip caching entirely in that case (no Mastra memory wired up).
|
|
289
338
|
*/
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
that turn; attachments carry the SQL and follow-ups Genie
|
|
301
|
-
produced.
|
|
302
|
-
`),
|
|
303
|
-
});
|
|
339
|
+
async function conversationCacheKey(
|
|
340
|
+
spaceId: string,
|
|
341
|
+
threadId: string | undefined,
|
|
342
|
+
): Promise<string | undefined> {
|
|
343
|
+
if (!threadId) return undefined;
|
|
344
|
+
return (await CacheManager.getInstance()).generateKey(
|
|
345
|
+
[CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
|
|
346
|
+
CONVERSATION_USER_KEY,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
304
349
|
|
|
305
350
|
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
* dbx-tools AppKit demos.
|
|
351
|
+
* Read the cached Genie conversation id for `(spaceId, threadId)`.
|
|
352
|
+
* Returns `undefined` on miss, on expiry, or when the cache layer
|
|
353
|
+
* is unhealthy - never throws. The TTL is renewed via re-`set`
|
|
354
|
+
* after each successful turn (see {@link saveCachedConversationId}).
|
|
311
355
|
*/
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
356
|
+
async function readCachedConversationId(
|
|
357
|
+
cacheKey: string | undefined,
|
|
358
|
+
): Promise<string | undefined> {
|
|
359
|
+
if (!cacheKey) return undefined;
|
|
360
|
+
try {
|
|
361
|
+
const v = await CacheManager.getInstanceSync().get<string>(cacheKey);
|
|
362
|
+
return v ?? undefined;
|
|
363
|
+
} catch (err) {
|
|
364
|
+
log.warn("conversation-cache:read-error", {
|
|
365
|
+
error: err instanceof Error ? err.message : String(err),
|
|
366
|
+
});
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
315
369
|
}
|
|
316
370
|
|
|
317
371
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* `query_result` events are routed through
|
|
324
|
-
* {@link emitChartWithPlanning} which uses it to resolve the
|
|
325
|
-
* chart-planner's model.
|
|
372
|
+
* Persist the active conversation id under `cacheKey`, refreshing
|
|
373
|
+
* its TTL. Idempotent; no-op when `cacheKey` or `conversationId`
|
|
374
|
+
* is missing. Re-setting the same key acts as a sliding TTL: every
|
|
375
|
+
* turn that uses the conversation extends the window by another
|
|
376
|
+
* {@link CONVERSATION_TTL_SEC} seconds.
|
|
326
377
|
*/
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
378
|
+
async function saveCachedConversationId(
|
|
379
|
+
cacheKey: string | undefined,
|
|
380
|
+
conversationId: string | undefined,
|
|
381
|
+
): Promise<void> {
|
|
382
|
+
if (!cacheKey || !conversationId) return;
|
|
383
|
+
try {
|
|
384
|
+
await CacheManager.getInstanceSync().set(cacheKey, conversationId, {
|
|
385
|
+
ttl: CONVERSATION_TTL_SEC,
|
|
386
|
+
});
|
|
387
|
+
} catch (err) {
|
|
388
|
+
log.warn("conversation-cache:write-error", {
|
|
389
|
+
error: err instanceof Error ? err.message : String(err),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
334
393
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
\`datasets[]\` (one entry per executed query, each with
|
|
344
|
-
a short \`chartId\`). Embed \`[[chart:<chartId>]]\` on
|
|
345
|
-
its own line at the position you want that data rendered
|
|
346
|
-
as an inline chart. Add interpretation around the chart
|
|
347
|
-
(deltas, anomalies, takeaways); do not paraphrase row
|
|
348
|
-
values.
|
|
349
|
-
|
|
350
|
-
Issue ONE focused question per user turn. Prefer
|
|
351
|
-
aggregated queries over raw-row queries for time-series
|
|
352
|
-
and distributions.
|
|
353
|
-
`,
|
|
354
|
-
inputSchema: sendMessageSchema,
|
|
355
|
-
outputSchema: genieToolOutputSchema,
|
|
356
|
-
execute: async ({ content, conversationId }, ctx) => {
|
|
357
|
-
const stream = opts.exports.sendMessage(alias, content, conversationId, {
|
|
358
|
-
signal: opts.signal,
|
|
359
|
-
});
|
|
360
|
-
const requestContext = (ctx as { requestContext?: RequestContext } | undefined)
|
|
361
|
-
?.requestContext;
|
|
362
|
-
return drainGenieStream(stream, ctx.writer, {
|
|
363
|
-
config: opts.config,
|
|
364
|
-
...(requestContext ? { requestContext } : {}),
|
|
365
|
-
});
|
|
366
|
-
},
|
|
394
|
+
/** Force-evict a cached conversation id. Used on the stale-id recovery path. */
|
|
395
|
+
async function evictCachedConversationId(cacheKey: string | undefined): Promise<void> {
|
|
396
|
+
if (!cacheKey) return;
|
|
397
|
+
try {
|
|
398
|
+
await CacheManager.getInstanceSync().delete(cacheKey);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
log.warn("conversation-cache:delete-error", {
|
|
401
|
+
error: err instanceof Error ? err.message : String(err),
|
|
367
402
|
});
|
|
368
403
|
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* True when `err` is the SDK error Genie returns for a
|
|
408
|
+
* conversation id that no longer exists (deleted, expired upstream,
|
|
409
|
+
* or referenced from the wrong space). Matches the typed
|
|
410
|
+
* {@link ApiError} 404 / `RESOURCE_DOES_NOT_EXIST` shape first, then
|
|
411
|
+
* falls back to the lower-level {@link HttpError} 404, then to a
|
|
412
|
+
* loose message sniff for SDK shapes we haven't catalogued.
|
|
413
|
+
*/
|
|
414
|
+
function isConversationGoneError(err: unknown): boolean {
|
|
415
|
+
if (err instanceof ApiError) {
|
|
416
|
+
if (err.statusCode === 404) return true;
|
|
417
|
+
if (err.errorCode === "RESOURCE_DOES_NOT_EXIST") return true;
|
|
418
|
+
}
|
|
419
|
+
if (err instanceof HttpError && err.code === 404) return true;
|
|
420
|
+
if (err instanceof Error && /does not exist/i.test(err.message)) return true;
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* --------------------------- inner tools --------------------------- */
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* One entry in {@link InnerToolDeps.resultSets}: the rows for a
|
|
428
|
+
* Genie statement plus the Genie `message_id` of the `ask_genie`
|
|
429
|
+
* turn that produced it. Tracking `messageId` here lets the
|
|
430
|
+
* outer chart loop stamp the chart event (and any chart-error
|
|
431
|
+
* writer event) with the same `messageId` the rest of that
|
|
432
|
+
* ask's wire events carry, so the host UI groups the chart into
|
|
433
|
+
* the same `message_id` pill bucket without a separate lookup.
|
|
434
|
+
*/
|
|
435
|
+
interface StatementEntry {
|
|
436
|
+
data: GenieDatasetData;
|
|
437
|
+
messageId: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Per-call mutable state shared by the inner agent's three tools.
|
|
442
|
+
* `resultSets` lets the wrapper pull rows by `statementId` after
|
|
443
|
+
* the agent finishes, so the chart-planner doesn't re-fetch. The
|
|
444
|
+
* active Genie `conversation_id` lives on `RequestContext` (read
|
|
445
|
+
* via {@link readContextConversationId} on the inner tool's `ctx`)
|
|
446
|
+
* rather than a shared ref - the same `RequestContext` instance
|
|
447
|
+
* threads from `agent.generate({requestContext})` through to every
|
|
448
|
+
* tool invocation, so writes propagate to subsequent `ask_genie`
|
|
449
|
+
* calls without an extra object. `cacheKey` is the
|
|
450
|
+
* {@link CacheManager} key for cross-request persistence (`undefined`
|
|
451
|
+
* when `threadId` isn't available and caching is disabled).
|
|
452
|
+
*/
|
|
453
|
+
interface InnerToolDeps {
|
|
454
|
+
spaceId: string;
|
|
455
|
+
client: WorkspaceClient;
|
|
456
|
+
writer?: MinimalWriter;
|
|
457
|
+
signal?: AbortSignal;
|
|
458
|
+
resultSets: Map<string, StatementEntry>;
|
|
459
|
+
cacheKey?: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function buildAskGenieTool(deps: InnerToolDeps) {
|
|
463
|
+
const { spaceId, client, writer, signal, resultSets, cacheKey } = deps;
|
|
464
|
+
return createTool({
|
|
465
|
+
id: "ask_genie",
|
|
466
|
+
description: stringUtils.toDescription`
|
|
467
|
+
Send ONE focused natural-language question to the Genie
|
|
468
|
+
space and wait for the turn to complete. Returns the final
|
|
469
|
+
\`GenieMessage\` plus, when the turn ran SQL, the rows of
|
|
470
|
+
the resulting query as \`query_result_data\`. The
|
|
471
|
+
\`statement_id\` you reference in your final \`data\`
|
|
472
|
+
blocks lives at \`message.query_result.statement_id\` (or
|
|
473
|
+
the first attachment's \`query.statement_id\`). Wire
|
|
474
|
+
events (status, thinking, sql) stream to the user
|
|
475
|
+
automatically. Call multiple times to gather different
|
|
476
|
+
angles before composing the final response.
|
|
477
|
+
`,
|
|
478
|
+
inputSchema: z.object({
|
|
479
|
+
question: z.string().min(1, "question is required"),
|
|
480
|
+
}),
|
|
481
|
+
outputSchema: z.object({
|
|
482
|
+
message: z.custom<GenieMessage>(),
|
|
483
|
+
query_result_data: z.custom<GenieDatasetData>().optional(),
|
|
484
|
+
}),
|
|
485
|
+
execute: async ({ question }, ctxRaw) => {
|
|
486
|
+
const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
|
|
487
|
+
const requestContext = ctx?.requestContext;
|
|
488
|
+
if (!requestContext) {
|
|
489
|
+
// Mastra always passes a `RequestContext` to tools when the
|
|
490
|
+
// parent agent received one. The outer Genie tool insists on
|
|
491
|
+
// it (it sources the user from there), so this only fires
|
|
492
|
+
// if a misconfigured caller invokes `ask_genie` directly.
|
|
493
|
+
throw new Error(
|
|
494
|
+
"ask_genie: missing requestContext (parent agent must propagate it)",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Bounce placeholder / no-op questions BEFORE spending a Genie
|
|
499
|
+
// round-trip on them. The structuring pass occasionally pads
|
|
500
|
+
// out the tool loop with a fake `ask_genie("noop")` call,
|
|
501
|
+
// which Genie answers with "Your request 'noop' does not
|
|
502
|
+
// relate to..." - useless noise that shows up in the UI and
|
|
503
|
+
// eats one of the workspace's 5 questions/minute. Returning
|
|
504
|
+
// a clear error here surfaces the issue to the agent loop so
|
|
505
|
+
// the model corrects course instead of wasting a turn.
|
|
506
|
+
const trimmed = question.trim();
|
|
507
|
+
if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`ask_genie: refusing placeholder question "${question}" - ` +
|
|
510
|
+
`call ask_genie only with a real natural-language question, ` +
|
|
511
|
+
`or skip the call entirely`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Single turn of `genieEventChat`. Hoisted into a closure so
|
|
516
|
+
// we can re-run it after evicting a stale `conversation_id`
|
|
517
|
+
// without duplicating the event-loop body.
|
|
518
|
+
const runTurn = async (): Promise<GenieMessage> => {
|
|
519
|
+
const seedConversationId = readContextConversationId(requestContext, spaceId);
|
|
520
|
+
let finalMessage: GenieMessage | undefined;
|
|
521
|
+
for await (const event of genieEventChat(spaceId, question, {
|
|
522
|
+
workspaceClient: client,
|
|
523
|
+
...(seedConversationId ? { conversationId: seedConversationId } : {}),
|
|
524
|
+
...(signal ? { context: signal } : {}),
|
|
525
|
+
})) {
|
|
526
|
+
await safeWrite(writer, event);
|
|
527
|
+
// Wire events come in two flavors: the lifecycle `message`
|
|
528
|
+
// event embeds the raw `GenieMessage` (read its
|
|
529
|
+
// `conversation_id`), and the rest carry a flat
|
|
530
|
+
// `conversation_id` field at the top level. The terminal
|
|
531
|
+
// `result` event also carries the final `GenieMessage`
|
|
532
|
+
// inline so we can capture the snapshot without re-reading
|
|
533
|
+
// a buffered `message` event.
|
|
534
|
+
const eventConversationId =
|
|
535
|
+
event.type === "message"
|
|
536
|
+
? event.message.conversation_id
|
|
537
|
+
: event.conversation_id;
|
|
538
|
+
if (eventConversationId) {
|
|
539
|
+
writeContextConversationId(requestContext, spaceId, eventConversationId);
|
|
540
|
+
}
|
|
541
|
+
if (event.type === "result") {
|
|
542
|
+
finalMessage = event.message;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (!finalMessage) {
|
|
546
|
+
throw new Error("Genie turn ended without a result event");
|
|
547
|
+
}
|
|
548
|
+
return finalMessage;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
let finalMessage: GenieMessage;
|
|
552
|
+
try {
|
|
553
|
+
finalMessage = await runTurn();
|
|
554
|
+
} catch (err) {
|
|
555
|
+
// The seeded `conversation_id` was rejected by Genie - most
|
|
556
|
+
// commonly because it was deleted upstream, expired past
|
|
557
|
+
// Databricks' (undocumented) lifetime, or was minted in a
|
|
558
|
+
// different space. Drop both the cached id AND the
|
|
559
|
+
// per-request value so the retry calls `startConversation`,
|
|
560
|
+
// and try once more. Only retry when we *had* a seeded id -
|
|
561
|
+
// a fresh call that 404s shouldn't loop.
|
|
562
|
+
const seeded = readContextConversationId(requestContext, spaceId);
|
|
563
|
+
if (seeded && isConversationGoneError(err)) {
|
|
564
|
+
log.warn("conversation-cache:stale, resetting", {
|
|
565
|
+
spaceId,
|
|
566
|
+
conversationId: seeded,
|
|
567
|
+
error: err instanceof Error ? err.message : String(err),
|
|
568
|
+
});
|
|
569
|
+
await evictCachedConversationId(cacheKey);
|
|
570
|
+
writeContextConversationId(requestContext, spaceId, undefined);
|
|
571
|
+
finalMessage = await runTurn();
|
|
572
|
+
} else {
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Refresh the cache entry on every successful turn. Re-setting
|
|
578
|
+
// the same key both persists newly-minted ids (cache miss path)
|
|
579
|
+
// and extends the TTL on active conversations (sliding window).
|
|
580
|
+
await saveCachedConversationId(
|
|
581
|
+
cacheKey,
|
|
582
|
+
readContextConversationId(requestContext, spaceId),
|
|
583
|
+
);
|
|
369
584
|
|
|
370
|
-
|
|
371
|
-
|
|
585
|
+
const statementId = extractStatementId(finalMessage);
|
|
586
|
+
let queryResultData: GenieDatasetData | undefined;
|
|
587
|
+
if (statementId) {
|
|
588
|
+
const data = await fetchStatementData(client, statementId, signal);
|
|
589
|
+
if (data.rowCount > 0) {
|
|
590
|
+
queryResultData = data;
|
|
591
|
+
// Stash with this ask's `message_id` so the outer chart
|
|
592
|
+
// loop can stamp downstream `chart` events with the
|
|
593
|
+
// same id the wire events carry - keeps the chart in
|
|
594
|
+
// the same `message_id` pill bucket on the host UI.
|
|
595
|
+
resultSets.set(statementId, {
|
|
596
|
+
data,
|
|
597
|
+
messageId: finalMessage.message_id,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
message: finalMessage,
|
|
603
|
+
...(queryResultData ? { query_result_data: queryResultData } : {}),
|
|
604
|
+
};
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function buildSpaceDescriptionTool(deps: {
|
|
610
|
+
spaceId: string;
|
|
611
|
+
client: WorkspaceClient;
|
|
612
|
+
signal?: AbortSignal;
|
|
613
|
+
}) {
|
|
614
|
+
const { spaceId, client, signal } = deps;
|
|
615
|
+
return createTool({
|
|
616
|
+
id: "get_space_description",
|
|
372
617
|
description: stringUtils.toDescription`
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
618
|
+
Return the Genie space's title, description, and warehouse id.
|
|
619
|
+
Cheap. Call once at the start of a turn to ground yourself
|
|
620
|
+
in what data the space covers.
|
|
376
621
|
`,
|
|
377
|
-
inputSchema:
|
|
378
|
-
outputSchema:
|
|
379
|
-
|
|
380
|
-
|
|
622
|
+
inputSchema: z.object({}),
|
|
623
|
+
outputSchema: z.object({
|
|
624
|
+
spaceId: z.string(),
|
|
625
|
+
title: z.string().optional(),
|
|
626
|
+
description: z.string().optional(),
|
|
627
|
+
warehouseId: z.string().optional(),
|
|
628
|
+
}),
|
|
629
|
+
execute: async () => {
|
|
630
|
+
const ctx = signal ? apiUtils.toContext(signal) : undefined;
|
|
631
|
+
const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
|
|
632
|
+
return {
|
|
633
|
+
spaceId,
|
|
634
|
+
...(space.title ? { title: space.title } : {}),
|
|
635
|
+
...(space.description ? { description: space.description } : {}),
|
|
636
|
+
...(space.warehouse_id ? { warehouseId: space.warehouse_id } : {}),
|
|
637
|
+
};
|
|
381
638
|
},
|
|
382
639
|
});
|
|
640
|
+
}
|
|
383
641
|
|
|
384
|
-
|
|
642
|
+
function buildSpaceSerializedTool(deps: {
|
|
643
|
+
spaceId: string;
|
|
644
|
+
client: WorkspaceClient;
|
|
645
|
+
signal?: AbortSignal;
|
|
646
|
+
}) {
|
|
647
|
+
const { spaceId, client, signal } = deps;
|
|
648
|
+
return createTool({
|
|
649
|
+
id: "get_space_serialized",
|
|
650
|
+
description: stringUtils.toDescription`
|
|
651
|
+
Return the full \`GenieSpace\` JSON for this space. Use only
|
|
652
|
+
when you need exact column / table identifiers
|
|
653
|
+
\`get_space_description\` doesn't expose. Larger payload, so
|
|
654
|
+
prefer the description tool when it's enough.
|
|
655
|
+
`,
|
|
656
|
+
inputSchema: z.object({}),
|
|
657
|
+
outputSchema: z.object({ space: z.unknown() }),
|
|
658
|
+
execute: async () => {
|
|
659
|
+
const ctx = signal ? apiUtils.toContext(signal) : undefined;
|
|
660
|
+
const space = await client.genie.getSpace({ space_id: spaceId }, ctx);
|
|
661
|
+
return { space };
|
|
662
|
+
},
|
|
663
|
+
});
|
|
385
664
|
}
|
|
386
665
|
|
|
387
|
-
|
|
388
|
-
|
|
666
|
+
/* --------------------------- inner agent --------------------------- */
|
|
667
|
+
|
|
668
|
+
const AGENT_INSTRUCTIONS = stringUtils.toDescription`
|
|
669
|
+
You orchestrate a Databricks Genie space. For every user
|
|
670
|
+
question:
|
|
671
|
+
|
|
672
|
+
1. Optionally call \`get_space_description\` to ground; reach
|
|
673
|
+
for \`get_space_serialized\` only when you need exact
|
|
674
|
+
column / table names the description doesn't expose.
|
|
675
|
+
2. Decompose the question into focused sub-questions (one per
|
|
676
|
+
distinct metric / dimension / time window) and call
|
|
677
|
+
\`ask_genie\` once per sub-question. Two to six calls is
|
|
678
|
+
typical for a non-trivial question; one call is fine when
|
|
679
|
+
the question is genuinely atomic.
|
|
680
|
+
3. Each \`ask_genie\` call returns the terminal
|
|
681
|
+
\`GenieMessage\`. When the turn ran SQL it also returns
|
|
682
|
+
\`query_result_data\` - the actual rows. The matching
|
|
683
|
+
\`statement_id\` is on
|
|
684
|
+
\`message.query_result.statement_id\` (or the first
|
|
685
|
+
attachment's \`query.statement_id\`). You will reference
|
|
686
|
+
that exact id in your final \`data\` blocks.
|
|
687
|
+
4. Produce a final structured summary as an ordered array
|
|
688
|
+
interleaving \`text\` paragraphs with \`data\` blocks.
|
|
689
|
+
INTERLEAVE: prose first, then the \`data\` block it
|
|
690
|
+
interprets, then the next prose / data pair. Never dump
|
|
691
|
+
all data at the end.
|
|
692
|
+
5. For every \`data\` block, supply the exact
|
|
693
|
+
\`statement_id\` you saw on the \`ask_genie\` response. A
|
|
694
|
+
short \`description\` ("compare quarterly revenue across
|
|
695
|
+
regions", "highlight the steep drop after position 5")
|
|
696
|
+
biases the chart-planner's choice of visual. Do NOT pick
|
|
697
|
+
chart types or axis labels - the host wraps each \`data\`
|
|
698
|
+
block in a chart automatically.
|
|
699
|
+
6. Each \`data\` block should be followed by a short
|
|
700
|
+
\`text\` interpretation (deltas, anomalies, takeaways).
|
|
701
|
+
Don't paraphrase numbers the visualization will already
|
|
702
|
+
show. Skip openers / closers. Plain prose, hyphens (not em
|
|
703
|
+
/ en dashes), no emojis.
|
|
704
|
+
`;
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Boundary schema for the inner agent's structured output. Two
|
|
708
|
+
* tagged shapes only - text or data. The wrapper maps these onto
|
|
709
|
+
* the shared {@link GenieSummaryItem} (`string` / `visualize`)
|
|
710
|
+
* after charting; we don't redefine GenieSummaryItem here.
|
|
711
|
+
*/
|
|
712
|
+
const agentSummarySchema = z.object({
|
|
713
|
+
summary: z.array(
|
|
714
|
+
z.discriminatedUnion("type", [
|
|
715
|
+
z.object({
|
|
716
|
+
type: z.literal("text"),
|
|
717
|
+
text: z.string(),
|
|
718
|
+
}),
|
|
719
|
+
z.object({
|
|
720
|
+
type: z.literal("data"),
|
|
721
|
+
statementId: z.string(),
|
|
722
|
+
title: z.string().optional(),
|
|
723
|
+
description: z.string().optional(),
|
|
724
|
+
}),
|
|
725
|
+
]),
|
|
726
|
+
),
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
type AgentSummaryItem = z.infer<typeof agentSummarySchema>["summary"][number];
|
|
730
|
+
|
|
731
|
+
/* ----------------------------- factory ----------------------------- */
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Options for {@link createGenieTool}. Only carries config that
|
|
735
|
+
* doesn't vary per request - the per-request {@link WorkspaceClient},
|
|
736
|
+
* `RequestContext`, writer, and abort signal flow through the
|
|
737
|
+
* tool's `execute(_, ctx)` and are not captured here.
|
|
738
|
+
*/
|
|
739
|
+
export interface CreateGenieToolOptions {
|
|
740
|
+
/** Genie space id this tool targets. */
|
|
741
|
+
spaceId: string;
|
|
742
|
+
/** Plugin config; resolves the LLM and chart planner agent. */
|
|
389
743
|
config: MastraPluginConfig;
|
|
390
|
-
|
|
744
|
+
/** Override the registered tool id. Defaults to `"genie"`. */
|
|
745
|
+
toolId?: string;
|
|
746
|
+
/** Override the tool description shown to the calling LLM. */
|
|
747
|
+
toolDescription?: string;
|
|
748
|
+
/**
|
|
749
|
+
* Override the inner agent's max tool-loop steps. Defaults to
|
|
750
|
+
* {@link DEFAULT_MAX_STEPS}.
|
|
751
|
+
*/
|
|
752
|
+
maxSteps?: number;
|
|
391
753
|
}
|
|
392
754
|
|
|
393
755
|
/**
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
* progress and chart events to the host UI.
|
|
756
|
+
* Build the calling agent's Genie tool. The returned Mastra tool
|
|
757
|
+
* runs end-to-end on each invocation:
|
|
397
758
|
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* order; we buffer per-statement scratch keyed by `statementId`
|
|
417
|
-
* so each half can fill in what it knows. The chart event
|
|
418
|
-
* fires the moment `query_result` lands; the planner runs in
|
|
419
|
-
* the background. We `Promise.allSettled` every planner promise
|
|
420
|
-
* before returning so all chart work is attributed to the tool's
|
|
421
|
-
* trace span and so the LLM's `datasets[]` includes every
|
|
422
|
-
* chartId that has actually been queued.
|
|
759
|
+
* 1. Pull the per-request `WorkspaceClient` off
|
|
760
|
+
* `ctx.requestContext` (stamped by `MastraServer` under
|
|
761
|
+
* {@link MASTRA_USER_KEY}) and emit a `started` writer
|
|
762
|
+
* event so the host UI shows progress immediately.
|
|
763
|
+
* 2. Spin up the inner Mastra agent + three tools, fresh per
|
|
764
|
+
* call so the row cache stays invocation-scoped.
|
|
765
|
+
* 3. Run the agent with `structuredOutput` against
|
|
766
|
+
* {@link agentSummarySchema}. Mastra's two-pass design keeps
|
|
767
|
+
* the inner loop tools-only (no `response_format`), so the
|
|
768
|
+
* Databricks Model Serving `response_format`+`tools`
|
|
769
|
+
* collision never fires.
|
|
770
|
+
* 4. Walk the returned `[text|data][]`, map `text` items to
|
|
771
|
+
* shared `GenieSummaryItem.string`, and chart every `data`
|
|
772
|
+
* item in parallel via {@link runChartPlanner} to a
|
|
773
|
+
* `GenieSummaryItem.visualize`. Items referencing a missing
|
|
774
|
+
* `statementId` are dropped with a warn log; chart-planner
|
|
775
|
+
* failures leave `dataset.chart` unset so the host UI falls
|
|
776
|
+
* back to a table.
|
|
423
777
|
*/
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
let genieAnswer: string | undefined;
|
|
432
|
-
let suggestedFollowUps: string[] | undefined;
|
|
433
|
-
let error: string | undefined;
|
|
434
|
-
// AppKit's `streamSendMessage` forwards every SDK `onProgress`
|
|
435
|
-
// callback verbatim - the same `EXECUTING_QUERY` can fire several
|
|
436
|
-
// times during a single poll loop. AppKit's other path,
|
|
437
|
-
// `streamGetMessage`, dedupes on the connector side; we mirror that
|
|
438
|
-
// behaviour here so the UI status pill doesn't flicker and we don't
|
|
439
|
-
// burn writer bytes on no-op events.
|
|
440
|
-
let lastStatus: string | undefined;
|
|
441
|
-
|
|
442
|
-
// Per-statement scratch keyed by Genie's `statementId`. Filled in
|
|
443
|
-
// by both `query_result` (chartId + columns + rows) and
|
|
444
|
-
// `message_result` (sql + title + description). The LLM-bound
|
|
445
|
-
// `datasets[]` is built from this at end-of-stream, after all
|
|
446
|
-
// planner promises settle.
|
|
447
|
-
type Scratch = {
|
|
448
|
-
statementId: string;
|
|
449
|
-
chartId?: string;
|
|
450
|
-
title?: string;
|
|
451
|
-
description?: string;
|
|
452
|
-
sql?: string;
|
|
453
|
-
columns: string[];
|
|
454
|
-
rowCount: number;
|
|
455
|
-
};
|
|
456
|
-
const scratchByStatementId = new Map<string, Scratch>();
|
|
457
|
-
const getScratch = (statementId: string): Scratch => {
|
|
458
|
-
let s = scratchByStatementId.get(statementId);
|
|
459
|
-
if (!s) {
|
|
460
|
-
s = { statementId, columns: [], rowCount: 0 };
|
|
461
|
-
scratchByStatementId.set(statementId, s);
|
|
462
|
-
}
|
|
463
|
-
return s;
|
|
464
|
-
};
|
|
465
|
-
/**
|
|
466
|
-
* Planner promises kicked off per `query_result`. Awaited
|
|
467
|
-
* (Promise.allSettled) before drainGenieStream returns so the
|
|
468
|
-
* Genie tool's trace span covers the chart work and the LLM's
|
|
469
|
-
* `datasets[]` accurately reflects every chartId that's been
|
|
470
|
-
* queued for rendering.
|
|
471
|
-
*/
|
|
472
|
-
const plannerPromises: Promise<void>[] = [];
|
|
473
|
-
|
|
474
|
-
const emit = async (event: GenieProgress) => {
|
|
475
|
-
if (!writer) return;
|
|
476
|
-
try {
|
|
477
|
-
await writer.write(event);
|
|
478
|
-
} catch {
|
|
479
|
-
// ignore: downstream stream is no longer interested
|
|
480
|
-
}
|
|
481
|
-
};
|
|
778
|
+
export function createGenieTool(opts: CreateGenieToolOptions) {
|
|
779
|
+
const {
|
|
780
|
+
spaceId,
|
|
781
|
+
config,
|
|
782
|
+
toolId = "genie",
|
|
783
|
+
toolDescription = stringUtils.toDescription`
|
|
784
|
+
Ask a question about the Databricks Genie space.
|
|
482
785
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
786
|
+
Returns \`{ summary: SummaryItem[] }\` where each item is
|
|
787
|
+
one of:
|
|
788
|
+
|
|
789
|
+
- \`{ type: "string", text }\` - prose to weave into your
|
|
790
|
+
reply verbatim or paraphrase.
|
|
791
|
+
- \`{ type: "visualize", statementId, title?, description?,
|
|
792
|
+
dataset: { data: { columns, rows, rowCount },
|
|
793
|
+
chart?: { chartId, chartType } } }\` - a chartable result
|
|
794
|
+
set. When \`dataset.chart\` is present the chart is ALREADY
|
|
795
|
+
rendered and queued for inline display; embed the marker
|
|
796
|
+
\`[[chart:<chartId>]]\` on its own line at the position
|
|
797
|
+
you want it to appear and the host UI drops the rendered
|
|
798
|
+
chart in. Re-use the chartId verbatim - do NOT call
|
|
799
|
+
\`render_data\` for the same dataset (it would render the
|
|
800
|
+
same chart a second time and stall your stream). Only
|
|
801
|
+
fall back to \`render_data\` when \`dataset.chart\` is
|
|
802
|
+
missing (chart-planner failed) AND you genuinely need a
|
|
803
|
+
picture; otherwise present the data inline as prose or a
|
|
804
|
+
short table.
|
|
805
|
+
`,
|
|
806
|
+
maxSteps = DEFAULT_MAX_STEPS,
|
|
807
|
+
} = opts;
|
|
808
|
+
|
|
809
|
+
return createTool({
|
|
810
|
+
id: toolId,
|
|
811
|
+
description: toolDescription,
|
|
812
|
+
inputSchema: z.object({
|
|
813
|
+
question: z.string().describe(stringUtils.toDescription`
|
|
814
|
+
Natural-language question about the data in this Genie
|
|
815
|
+
space. Phrase it from the user's perspective; the agent
|
|
816
|
+
decomposes it internally.
|
|
817
|
+
`),
|
|
818
|
+
}),
|
|
819
|
+
outputSchema: z.custom<GenieAgentResult>(),
|
|
820
|
+
execute: async (input, ctxRaw) => {
|
|
821
|
+
const ctx = ctxRaw as
|
|
822
|
+
| {
|
|
823
|
+
requestContext?: RequestContext;
|
|
824
|
+
writer?: MinimalWriter;
|
|
825
|
+
abortSignal?: AbortSignal;
|
|
826
|
+
}
|
|
827
|
+
| undefined;
|
|
828
|
+
const requestContext = ctx?.requestContext;
|
|
829
|
+
if (!requestContext) {
|
|
830
|
+
throw new Error(
|
|
831
|
+
"genie: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)",
|
|
512
832
|
);
|
|
513
|
-
const dataArray = (event.data?.result?.data_array ?? []) as Array<
|
|
514
|
-
Array<string | null>
|
|
515
|
-
>;
|
|
516
|
-
const rows = genieRowsToObjects(columns, dataArray);
|
|
517
|
-
const scratch = getScratch(event.statementId);
|
|
518
|
-
// emitChartWithPlanning emits the dataset event immediately
|
|
519
|
-
// and kicks off the chart-planner agent in the background.
|
|
520
|
-
// It returns the chartId synchronously; the plannerPromise
|
|
521
|
-
// is awaited at end-of-stream so chart work shows up under
|
|
522
|
-
// this tool's trace span.
|
|
523
|
-
const { chartId, plannerPromise } = await emitChartWithPlanning({
|
|
524
|
-
...(writer ? { writer } : {}),
|
|
525
|
-
config,
|
|
526
|
-
...(requestContext ? { requestContext } : {}),
|
|
527
|
-
title: scratch.title ?? `Genie query`,
|
|
528
|
-
...(scratch.description ? { description: scratch.description } : {}),
|
|
529
|
-
data: rows,
|
|
530
|
-
});
|
|
531
|
-
scratch.chartId = chartId;
|
|
532
|
-
scratch.columns = columns;
|
|
533
|
-
scratch.rowCount = rows.length;
|
|
534
|
-
plannerPromises.push(plannerPromise);
|
|
535
|
-
log.debug("query_result", {
|
|
536
|
-
statementId: event.statementId,
|
|
537
|
-
chartId,
|
|
538
|
-
rows: rows.length,
|
|
539
|
-
columns,
|
|
540
|
-
});
|
|
541
|
-
break;
|
|
542
833
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
834
|
+
const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
|
|
835
|
+
if (!user) {
|
|
836
|
+
throw new Error("genie: no user on requestContext (MASTRA_USER_KEY not set)");
|
|
837
|
+
}
|
|
838
|
+
const client = user.executionContext.client;
|
|
839
|
+
const writer = ctx?.writer;
|
|
840
|
+
const signal = ctx?.abortSignal;
|
|
841
|
+
const threadId = requestContext.get(MASTRA_THREAD_ID_KEY) as string | undefined;
|
|
842
|
+
|
|
843
|
+
// Fire the lifecycle `started` event before any LLM /
|
|
844
|
+
// network round-trip so the host UI can pop a "Thinking..."
|
|
845
|
+
// pill the instant the model decides to delegate. The wire
|
|
846
|
+
// `conversation_id` / `message_id` aren't known yet (no
|
|
847
|
+
// Genie call has been made) and ride as `undefined` -
|
|
848
|
+
// subscribers that need them watch the later
|
|
849
|
+
// `message` / `result` wire events for the real ids.
|
|
850
|
+
const startedEvent: StartedEvent = {
|
|
851
|
+
type: "started",
|
|
852
|
+
spaceId,
|
|
853
|
+
content: input.question,
|
|
854
|
+
};
|
|
855
|
+
await safeWrite(writer, startedEvent);
|
|
856
|
+
|
|
857
|
+
const resultSets = new Map<string, StatementEntry>();
|
|
858
|
+
|
|
859
|
+
// Seed the active Genie `conversation_id` onto
|
|
860
|
+
// `RequestContext` from AppKit's `CacheManager` when a Mastra
|
|
861
|
+
// `threadId` is present so multi-turn chats reuse the same
|
|
862
|
+
// Genie conversation (and Genie's accumulated context) across
|
|
863
|
+
// separate tool invocations. The same `RequestContext` flows
|
|
864
|
+
// to the inner `ask_genie` tool via Mastra, which reads and
|
|
865
|
+
// updates the same slot as Genie hands out / rotates ids.
|
|
866
|
+
// Cache misses, threads without memory, and unhealthy cache
|
|
867
|
+
// storage all leave the slot unset, which makes `ask_genie`
|
|
868
|
+
// call `startConversation` and mint a fresh id (then cache
|
|
869
|
+
// it).
|
|
870
|
+
const cacheKey = await conversationCacheKey(spaceId, threadId);
|
|
871
|
+
const cachedConversationId = await readCachedConversationId(cacheKey);
|
|
872
|
+
if (cachedConversationId) {
|
|
873
|
+
writeContextConversationId(requestContext, spaceId, cachedConversationId);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const innerDeps: InnerToolDeps = {
|
|
877
|
+
spaceId,
|
|
878
|
+
client,
|
|
879
|
+
...(writer ? { writer } : {}),
|
|
880
|
+
...(signal ? { signal } : {}),
|
|
881
|
+
resultSets,
|
|
882
|
+
...(cacheKey ? { cacheKey } : {}),
|
|
883
|
+
};
|
|
884
|
+
const tools = {
|
|
885
|
+
ask_genie: buildAskGenieTool(innerDeps),
|
|
886
|
+
get_space_description: buildSpaceDescriptionTool({
|
|
887
|
+
spaceId,
|
|
888
|
+
client,
|
|
889
|
+
...(signal ? { signal } : {}),
|
|
890
|
+
}),
|
|
891
|
+
get_space_serialized: buildSpaceSerializedTool({
|
|
892
|
+
spaceId,
|
|
893
|
+
client,
|
|
894
|
+
...(signal ? { signal } : {}),
|
|
895
|
+
}),
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// Resolve the model config once for this request so we can
|
|
899
|
+
// share it with the structuring pass below. The agent's
|
|
900
|
+
// `model` field accepts a function form for per-request
|
|
901
|
+
// resolution, but `structuredOutput.model` requires a
|
|
902
|
+
// static `MastraModelConfig`, and we need both to be on
|
|
903
|
+
// the same Databricks endpoint with the same OBO-scoped
|
|
904
|
+
// headers. Calling `buildModel` here (inside `execute`)
|
|
905
|
+
// keeps user scoping correct because `requestContext`
|
|
906
|
+
// already reflects the active request's user.
|
|
907
|
+
const resolvedModel = await buildModel(config, requestContext);
|
|
908
|
+
|
|
909
|
+
const agent = new Agent({
|
|
910
|
+
id: `genie__${spaceId}`,
|
|
911
|
+
name: `Genie (${spaceId})`,
|
|
912
|
+
description: stringUtils.toDescription`
|
|
913
|
+
Inner orchestrator for the "${spaceId}" Genie space.
|
|
914
|
+
Asks Genie one focused sub-question at a time and
|
|
915
|
+
returns an interleaved [text|data] summary.
|
|
916
|
+
`,
|
|
917
|
+
instructions: AGENT_INSTRUCTIONS,
|
|
918
|
+
model: resolvedModel,
|
|
919
|
+
tools,
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Mastra's `structuredOutput` operates in one of two modes
|
|
923
|
+
// based on whether `model` is set:
|
|
924
|
+
// - "direct" (no model) -> the schema is enforced
|
|
925
|
+
// in the SAME LLM call as
|
|
926
|
+
// the agent loop, by
|
|
927
|
+
// adding `response_format`
|
|
928
|
+
// alongside `tools`.
|
|
929
|
+
// Databricks Model Serving
|
|
930
|
+
// rejects that combination
|
|
931
|
+
// with `INVALID_PARAMETER_VALUE:
|
|
932
|
+
// Cannot specify both
|
|
933
|
+
// response_format and tools
|
|
934
|
+
// in the same request.`
|
|
935
|
+
// - "processor" (model passed) -> the main loop carries
|
|
936
|
+
// tools and NO
|
|
937
|
+
// `response_format`; a
|
|
938
|
+
// separate, tool-free
|
|
939
|
+
// structuring agent
|
|
940
|
+
// re-prompts the model
|
|
941
|
+
// with `response_format`
|
|
942
|
+
// to coerce the agent's
|
|
943
|
+
// final text into the
|
|
944
|
+
// schema.
|
|
945
|
+
// We use "processor" mode but ALSO set
|
|
946
|
+
// `jsonPromptInjection: true`. Mastra's structuring agent
|
|
947
|
+
// calls `.stream(...)` under the hood, and Databricks Model
|
|
948
|
+
// Serving rejects `response_format` together with streaming
|
|
949
|
+
// (`INVALID_PARAMETER_VALUE: Structured output is not
|
|
950
|
+
// currently supported with streaming.`). Prompt injection
|
|
951
|
+
// sidesteps that by embedding the JSON Schema in the
|
|
952
|
+
// structuring agent's system prompt instead of sending
|
|
953
|
+
// `response_format`. `errorStrategy: "warn"` keeps a
|
|
954
|
+
// structuring failure from escaping as an unhandled
|
|
955
|
+
// promise rejection: it logs and leaves `result.object`
|
|
956
|
+
// undefined, which we surface as a clean error in
|
|
957
|
+
// {@link GenieAgentResult}.
|
|
958
|
+
const agentResult = await agent.generate(input.question, {
|
|
959
|
+
requestContext,
|
|
960
|
+
maxSteps,
|
|
961
|
+
structuredOutput: {
|
|
962
|
+
schema: agentSummarySchema,
|
|
963
|
+
model: resolvedModel,
|
|
964
|
+
jsonPromptInjection: true,
|
|
965
|
+
errorStrategy: "warn",
|
|
966
|
+
},
|
|
967
|
+
...(signal ? { abortSignal: signal } : {}),
|
|
968
|
+
});
|
|
969
|
+
const submission = agentResult.object;
|
|
970
|
+
if (!submission) {
|
|
971
|
+
const message = "Genie agent returned no structured summary";
|
|
972
|
+
log.warn("agent:no-summary", { spaceId });
|
|
973
|
+
const finalConversationId = readContextConversationId(requestContext, spaceId);
|
|
974
|
+
return {
|
|
975
|
+
spaceId,
|
|
976
|
+
summary: [],
|
|
977
|
+
...(finalConversationId ? { conversationId: finalConversationId } : {}),
|
|
978
|
+
error: message,
|
|
979
|
+
} satisfies GenieAgentResult;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Lifecycle hook: the agent + structuring pass are done.
|
|
983
|
+
// Emit one `summary` event with the structured-item counts
|
|
984
|
+
// so the host UI can transition from "thinking" to
|
|
985
|
+
// "charting" and seed N chart skeletons before the
|
|
986
|
+
// per-chart `chart` events arrive. We can't fire this
|
|
987
|
+
// EARLIER (i.e. when the structuring pass starts) because
|
|
988
|
+
// Mastra runs the inner loop + structuring pass together
|
|
989
|
+
// inside `agent.generate(...)` with no observable boundary
|
|
990
|
+
// between them.
|
|
991
|
+
const textItemCount = submission.summary.filter(
|
|
992
|
+
(i: AgentSummaryItem) => i.type === "text",
|
|
993
|
+
).length;
|
|
994
|
+
const dataItemCount = submission.summary.length - textItemCount;
|
|
995
|
+
const summaryEvent: SummaryEvent = {
|
|
996
|
+
type: "summary",
|
|
997
|
+
spaceId,
|
|
998
|
+
items: submission.summary.length,
|
|
999
|
+
textItems: textItemCount,
|
|
1000
|
+
dataItems: dataItemCount,
|
|
1001
|
+
};
|
|
1002
|
+
await safeWrite(writer, summaryEvent);
|
|
1003
|
+
|
|
1004
|
+
// Chart every `data` item in parallel; map `text` items to
|
|
1005
|
+
// the shared `string` summary variant verbatim. Missing
|
|
1006
|
+
// statement ids are dropped (the agent referenced something
|
|
1007
|
+
// that never came back from `ask_genie`), planner failures
|
|
1008
|
+
// leave `dataset.chart` unset so the host UI falls back to
|
|
1009
|
+
// a table render. Each successfully planned chart pushes a
|
|
1010
|
+
// `chart` writer event so the UI can fade in the rendered
|
|
1011
|
+
// chart slot the moment its planner returns rather than
|
|
1012
|
+
// waiting for the entire batch to finish.
|
|
1013
|
+
const hydrated = await Promise.all(
|
|
1014
|
+
submission.summary.map(async (item: AgentSummaryItem): Promise<GenieSummaryItem | undefined> => {
|
|
1015
|
+
if (item.type === "text") {
|
|
1016
|
+
return { type: "string", text: item.text };
|
|
555
1017
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
title: attachment.query?.title,
|
|
561
|
-
description: attachment.query?.description,
|
|
562
|
-
statementId: stmtId,
|
|
1018
|
+
const entry = resultSets.get(item.statementId);
|
|
1019
|
+
if (!entry) {
|
|
1020
|
+
log.warn("data:missing-statement", {
|
|
1021
|
+
statementId: item.statementId,
|
|
563
1022
|
});
|
|
1023
|
+
return undefined;
|
|
564
1024
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
questions: attachment.suggestedQuestions,
|
|
1025
|
+
const { data, messageId } = entry;
|
|
1026
|
+
let dataset: GenieDataset = { data };
|
|
1027
|
+
try {
|
|
1028
|
+
const planned = await runChartPlanner({
|
|
1029
|
+
config,
|
|
1030
|
+
requestContext,
|
|
1031
|
+
title: item.title ?? "Genie result",
|
|
1032
|
+
...(item.description ? { description: item.description } : {}),
|
|
1033
|
+
data: data.rows,
|
|
1034
|
+
...(signal ? { signal } : {}),
|
|
576
1035
|
});
|
|
1036
|
+
const chartId = commonUtils.shortId();
|
|
1037
|
+
// Slim chart reference for the LLM-bound result: just
|
|
1038
|
+
// `chartId` + `chartType`. The full Echarts spec goes
|
|
1039
|
+
// to the UI via the writer event AND into the
|
|
1040
|
+
// request-scoped chart inventory below; the model
|
|
1041
|
+
// only needs the id to place `[[chart:<id>]]`.
|
|
1042
|
+
dataset = {
|
|
1043
|
+
data,
|
|
1044
|
+
chart: {
|
|
1045
|
+
chartId,
|
|
1046
|
+
chartType: planned.chartType,
|
|
1047
|
+
},
|
|
1048
|
+
};
|
|
1049
|
+
const chartEvent: ChartEvent = {
|
|
1050
|
+
type: "chart",
|
|
1051
|
+
chartId,
|
|
1052
|
+
statementId: item.statementId,
|
|
1053
|
+
messageId,
|
|
1054
|
+
...(item.title ? { title: item.title } : {}),
|
|
1055
|
+
...(item.description ? { description: item.description } : {}),
|
|
1056
|
+
data: data.rows,
|
|
1057
|
+
option: planned.option,
|
|
1058
|
+
};
|
|
1059
|
+
await safeWrite(writer, chartEvent);
|
|
1060
|
+
// Stash the resolved chart on the per-request
|
|
1061
|
+
// `RequestContext` so downstream code in the same
|
|
1062
|
+
// request (output processors, follow-up tool calls,
|
|
1063
|
+
// any post-run hook) can look up the full spec by
|
|
1064
|
+
// `chartId` without re-fetching or re-planning.
|
|
1065
|
+
recordChartInContext(requestContext, chartEvent);
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
1068
|
+
log.warn("chart:error", {
|
|
1069
|
+
statementId: item.statementId,
|
|
1070
|
+
messageId,
|
|
1071
|
+
error: errorMessage,
|
|
1072
|
+
});
|
|
1073
|
+
// Surface the chart-planner failure as a writer event
|
|
1074
|
+
// stamped with the same `messageId` the rest of this
|
|
1075
|
+
// ask's wire events carry, so the host UI groups the
|
|
1076
|
+
// failure into the same pill bucket and can surface
|
|
1077
|
+
// a "couldn't render chart" note next to the table
|
|
1078
|
+
// fallback instead of silently dropping the chart.
|
|
1079
|
+
const errorEvent: MastraGenieErrorEvent = {
|
|
1080
|
+
type: "error",
|
|
1081
|
+
spaceId,
|
|
1082
|
+
messageId,
|
|
1083
|
+
error: `chart-planner: ${errorMessage}`,
|
|
1084
|
+
};
|
|
1085
|
+
await safeWrite(writer, errorEvent);
|
|
577
1086
|
}
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
1087
|
+
return {
|
|
1088
|
+
type: "visualize",
|
|
1089
|
+
statementId: item.statementId,
|
|
1090
|
+
...(item.title ? { title: item.title } : {}),
|
|
1091
|
+
...(item.description ? { description: item.description } : {}),
|
|
1092
|
+
dataset,
|
|
1093
|
+
};
|
|
1094
|
+
}),
|
|
1095
|
+
);
|
|
1096
|
+
const summary = hydrated.filter((x): x is GenieSummaryItem => x !== undefined);
|
|
588
1097
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
await Promise.allSettled(plannerPromises);
|
|
597
|
-
log.debug("planners:settled", { count: plannerPromises.length });
|
|
598
|
-
|
|
599
|
-
// Build the LLM-bound `datasets[]` from scratch entries that
|
|
600
|
-
// actually ran a query (chartId is assigned at `query_result`
|
|
601
|
-
// time). Entries that only saw `message_result` metadata
|
|
602
|
-
// without a row payload are skipped.
|
|
603
|
-
const datasets: Array<z.infer<typeof datasetSchema>> = [];
|
|
604
|
-
for (const scratch of scratchByStatementId.values()) {
|
|
605
|
-
if (!scratch.chartId) continue;
|
|
606
|
-
datasets.push({
|
|
607
|
-
chartId: scratch.chartId,
|
|
608
|
-
...(scratch.title ? { title: scratch.title } : {}),
|
|
609
|
-
...(scratch.description ? { description: scratch.description } : {}),
|
|
610
|
-
columns: scratch.columns,
|
|
611
|
-
rowCount: scratch.rowCount,
|
|
612
|
-
...(scratch.sql ? { sql: scratch.sql } : {}),
|
|
613
|
-
});
|
|
614
|
-
}
|
|
1098
|
+
log.info("genie:done", {
|
|
1099
|
+
spaceId,
|
|
1100
|
+
items: summary.length,
|
|
1101
|
+
statementsCharted: summary.filter(
|
|
1102
|
+
(s) => s.type === "visualize" && s.dataset.chart,
|
|
1103
|
+
).length,
|
|
1104
|
+
});
|
|
615
1105
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1106
|
+
const finalConversationId = readContextConversationId(requestContext, spaceId);
|
|
1107
|
+
return {
|
|
1108
|
+
spaceId,
|
|
1109
|
+
summary,
|
|
1110
|
+
...(finalConversationId ? { conversationId: finalConversationId } : {}),
|
|
1111
|
+
} satisfies GenieAgentResult;
|
|
1112
|
+
},
|
|
623
1113
|
});
|
|
1114
|
+
}
|
|
624
1115
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
1116
|
+
/* --------------------- multi-alias surface --------------------- */
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Default tool id for a wired Genie alias. The well-known
|
|
1120
|
+
* `default` alias collapses to `genie`; every other alias gets a
|
|
1121
|
+
* `genie_` prefix so multi-space registrations stay
|
|
1122
|
+
* disambiguated.
|
|
1123
|
+
*/
|
|
1124
|
+
export function defaultGenieToolName(alias: string): string {
|
|
1125
|
+
if (alias === DEFAULT_GENIE_ALIAS) return "genie";
|
|
1126
|
+
return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
|
|
632
1127
|
}
|
|
633
1128
|
|
|
634
1129
|
/**
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* `
|
|
639
|
-
*
|
|
1130
|
+
* Normalize the {@link GenieSpacesConfig} record. Bare-string
|
|
1131
|
+
* entries (`{ default: "01ef..." }`) get wrapped as
|
|
1132
|
+
* `{ spaceId: "01ef..." }`; object entries pass through unchanged.
|
|
1133
|
+
* `undefined` and empty-string values are dropped so callers can
|
|
1134
|
+
* pass `process.env.X` directly (matches AppKit `genie()`'s
|
|
1135
|
+
* defensive treatment of unset env vars).
|
|
640
1136
|
*/
|
|
641
|
-
function
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1137
|
+
export function normalizeGenieSpaces(
|
|
1138
|
+
spaces:
|
|
1139
|
+
| GenieSpacesConfig
|
|
1140
|
+
| Record<string, string | GenieSpaceConfig | undefined>
|
|
1141
|
+
| undefined,
|
|
1142
|
+
): Record<string, GenieSpaceConfig> {
|
|
1143
|
+
if (!spaces) return {};
|
|
1144
|
+
const out: Record<string, GenieSpaceConfig> = {};
|
|
1145
|
+
for (const [alias, value] of Object.entries(spaces)) {
|
|
1146
|
+
if (value === undefined) continue;
|
|
1147
|
+
if (typeof value === "string") {
|
|
1148
|
+
if (!value) continue;
|
|
1149
|
+
out[alias] = { spaceId: value };
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
if (!value.spaceId) continue;
|
|
1153
|
+
out[alias] = value;
|
|
653
1154
|
}
|
|
654
1155
|
return out;
|
|
655
1156
|
}
|
|
656
1157
|
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
1158
|
+
/**
|
|
1159
|
+
* AppKit `genie` plugin's config shape, derived from the factory
|
|
1160
|
+
* itself so it stays in lock-step with the upstream type without
|
|
1161
|
+
* deep-importing `IGenieConfig` (which the package's top-level
|
|
1162
|
+
* barrel doesn't surface). The plugin's `config` field is
|
|
1163
|
+
* `protected` in TS only; the runtime layout is plain object
|
|
1164
|
+
* property access, so reading off the instance with a structural
|
|
1165
|
+
* cast is safe.
|
|
1166
|
+
*/
|
|
1167
|
+
type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Discover Genie space aliases from every supported source and
|
|
1171
|
+
* merge them into a single record. Precedence (highest first):
|
|
1172
|
+
*
|
|
1173
|
+
* 1. {@link MastraPluginConfig.genieSpaces} on the `mastra(...)`
|
|
1174
|
+
* call. Explicit Mastra wiring always wins so users can
|
|
1175
|
+
* override AppKit's defaults per-agent.
|
|
1176
|
+
* 2. AppKit `genie({ spaces: { ... } })` plugin instance. Lets
|
|
1177
|
+
* users keep using the existing AppKit config format
|
|
1178
|
+
* (`genie({ spaces: { sales: "...", ops: "..." } })`)
|
|
1179
|
+
* without restating the same record on the Mastra plugin.
|
|
1180
|
+
* Read off the live plugin instance via a structural cast
|
|
1181
|
+
* since `Plugin.config` is TS-protected (not runtime-private).
|
|
1182
|
+
* 3. `DATABRICKS_GENIE_SPACE_ID` env var (registered under the
|
|
1183
|
+
* well-known `default` alias). Matches the AppKit `genie()`
|
|
1184
|
+
* plugin's fallback behavior so a bare `mastra()` + `genie()`
|
|
1185
|
+
* pair just works.
|
|
1186
|
+
*
|
|
1187
|
+
* Aliases collide cleanly: a higher-precedence source's value
|
|
1188
|
+
* replaces a lower one's wholesale. Sources that contribute zero
|
|
1189
|
+
* aliases (or contribute only `undefined` / empty entries) are
|
|
1190
|
+
* silently ignored.
|
|
1191
|
+
*/
|
|
1192
|
+
export function resolveGenieSpaces(
|
|
1193
|
+
config: MastraPluginConfig,
|
|
1194
|
+
context: appkitUtils.PluginContextLike | undefined,
|
|
1195
|
+
): Record<string, GenieSpaceConfig> {
|
|
1196
|
+
const merged: Record<string, GenieSpaceConfig> = {};
|
|
1197
|
+
|
|
1198
|
+
// Source 3 (lowest precedence): env var.
|
|
1199
|
+
const envSpaceId = process.env["DATABRICKS_GENIE_SPACE_ID"];
|
|
1200
|
+
if (envSpaceId) {
|
|
1201
|
+
merged[DEFAULT_GENIE_ALIAS] = { spaceId: envSpaceId };
|
|
665
1202
|
}
|
|
666
|
-
|
|
1203
|
+
|
|
1204
|
+
// Source 2: AppKit `genie()` plugin instance config. Use a
|
|
1205
|
+
// structural cast - `Plugin.config` is `protected` in TS only,
|
|
1206
|
+
// and the runtime layout is plain object property access.
|
|
1207
|
+
const geniePlugin = appkitUtils.instance(context, genie);
|
|
1208
|
+
if (geniePlugin) {
|
|
1209
|
+
const pluginSpaces = (geniePlugin as unknown as { config?: AppKitGenieConfig })
|
|
1210
|
+
.config?.spaces;
|
|
1211
|
+
if (pluginSpaces) {
|
|
1212
|
+
Object.assign(merged, normalizeGenieSpaces(pluginSpaces));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Source 1 (highest precedence): explicit Mastra wiring.
|
|
1217
|
+
if (config.genieSpaces) {
|
|
1218
|
+
Object.assign(merged, normalizeGenieSpaces(config.genieSpaces));
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
return merged;
|
|
667
1222
|
}
|
|
668
1223
|
|
|
669
1224
|
/**
|
|
670
|
-
*
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
*
|
|
674
|
-
* of the AppKit default (which does one blocking call per tool with no
|
|
675
|
-
* mid-flight events).
|
|
1225
|
+
* Build one Mastra tool per configured Genie space. Each tool is
|
|
1226
|
+
* a thin {@link createGenieTool} wrapper with the alias-derived
|
|
1227
|
+
* id and a hint-flavored description so the calling LLM knows
|
|
1228
|
+
* which space covers what data.
|
|
676
1229
|
*
|
|
677
|
-
*
|
|
678
|
-
* `
|
|
679
|
-
*
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1230
|
+
* Returns a record keyed by tool id, ready to spread into an
|
|
1231
|
+
* `Agent`'s `tools` map (or surfaced via
|
|
1232
|
+
* `plugins.genie?.toolkit()`).
|
|
1233
|
+
*/
|
|
1234
|
+
export function buildGenieTools(opts: {
|
|
1235
|
+
spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
|
|
1236
|
+
config: MastraPluginConfig;
|
|
1237
|
+
}): MastraTools {
|
|
1238
|
+
const normalized = normalizeGenieSpaces(opts.spaces);
|
|
1239
|
+
const tools: Record<string, ReturnType<typeof createTool>> = {};
|
|
1240
|
+
for (const [alias, space] of Object.entries(normalized)) {
|
|
1241
|
+
const id = defaultGenieToolName(alias);
|
|
1242
|
+
const toolDescription = stringUtils.toDescription`
|
|
1243
|
+
Delegate a natural-language data question to the
|
|
1244
|
+
Databricks Genie space "${alias}"${space.hint ? ` (${space.hint})` : ""}.
|
|
1245
|
+
Returns an ordered (text | dataset)[] summary the host UI
|
|
1246
|
+
renders inline; datasets carry the rows and a
|
|
1247
|
+
pre-rendered Echarts spec when the chart-planner
|
|
1248
|
+
succeeded. Progress events (status, SQL, row counts,
|
|
1249
|
+
charts) stream to the UI automatically.
|
|
1250
|
+
`;
|
|
1251
|
+
tools[id] = createGenieTool({
|
|
1252
|
+
spaceId: space.spaceId,
|
|
1253
|
+
config: opts.config,
|
|
1254
|
+
toolId: id,
|
|
1255
|
+
toolDescription,
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
return tools;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* Plugin-toolkit adapter so the `plugins.genie?.toolkit()` lookup
|
|
1263
|
+
* inside an agent's `tools(plugins)` callback returns the
|
|
1264
|
+
* Genie agent-backed tools instead of throwing on missing plugin.
|
|
1265
|
+
* Mirrors AppKit's `PluginToolkitProvider` shape.
|
|
687
1266
|
*/
|
|
688
|
-
export function
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
): {
|
|
692
|
-
toolkit(opts?: unknown):
|
|
1267
|
+
export function buildGenieToolkitProvider(opts: {
|
|
1268
|
+
spaces: GenieSpacesConfig | Record<string, GenieSpaceConfig>;
|
|
1269
|
+
config: MastraPluginConfig;
|
|
1270
|
+
}): {
|
|
1271
|
+
toolkit(opts?: unknown): MastraTools;
|
|
693
1272
|
} {
|
|
694
1273
|
return {
|
|
695
1274
|
toolkit(_opts?: unknown) {
|
|
696
|
-
|
|
697
|
-
return buildGenieTools({
|
|
698
|
-
aliases,
|
|
699
|
-
exports: {
|
|
700
|
-
sendMessage: plugin.sendMessage.bind(plugin),
|
|
701
|
-
getConversation: plugin.getConversation.bind(plugin),
|
|
702
|
-
},
|
|
703
|
-
config: opts.config,
|
|
704
|
-
});
|
|
1275
|
+
return buildGenieTools(opts);
|
|
705
1276
|
},
|
|
706
1277
|
};
|
|
707
1278
|
}
|
|
708
1279
|
|
|
709
1280
|
/**
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const dot = t.name.indexOf(".");
|
|
720
|
-
if (dot > 0) aliases.add(t.name.slice(0, dot));
|
|
721
|
-
}
|
|
722
|
-
return [...aliases];
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Convert raw Genie status codes (`FETCHING_METADATA`, `ASKING_AI`,
|
|
727
|
-
* `EXECUTING_QUERY`, `COMPLETED`, ...) into short, sentence-cased
|
|
728
|
-
* labels safe to drop straight into a UI pill. Unknown codes are
|
|
729
|
-
* lower-cased with underscores stripped so new states still render.
|
|
1281
|
+
* Returns `true` when at least one Genie space is reachable
|
|
1282
|
+
* through {@link resolveGenieSpaces} - either via
|
|
1283
|
+
* {@link MastraPluginConfig.genieSpaces}, the AppKit `genie()`
|
|
1284
|
+
* plugin instance, or the `DATABRICKS_GENIE_SPACE_ID` env var.
|
|
1285
|
+
*
|
|
1286
|
+
* Cheap to call from `resolveProvider` to short-circuit `genie`
|
|
1287
|
+
* lookups when nothing is wired, so the `plugins.genie` lookup
|
|
1288
|
+
* still resolves to `undefined` (matching AppKit's
|
|
1289
|
+
* absent-plugin semantics) when neither source is configured.
|
|
730
1290
|
*/
|
|
731
|
-
function
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return "Asking Genie";
|
|
737
|
-
case "EXECUTING_QUERY":
|
|
738
|
-
return "Running SQL query";
|
|
739
|
-
case "COMPLETED":
|
|
740
|
-
return "Completed";
|
|
741
|
-
case "FAILED":
|
|
742
|
-
return "Failed";
|
|
743
|
-
default:
|
|
744
|
-
return [
|
|
745
|
-
...stringUtils.tokenizeWithOptions(
|
|
746
|
-
{ capitalize: true, lowerCase: true },
|
|
747
|
-
status,
|
|
748
|
-
),
|
|
749
|
-
].join(" ");
|
|
750
|
-
}
|
|
1291
|
+
export function hasAnyGenieSpaces(
|
|
1292
|
+
config: MastraPluginConfig,
|
|
1293
|
+
context: appkitUtils.PluginContextLike | undefined,
|
|
1294
|
+
): boolean {
|
|
1295
|
+
return Object.keys(resolveGenieSpaces(config, context)).length > 0;
|
|
751
1296
|
}
|