@dbx-tools/appkit-mastra 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Plugin configuration types and shared `RequestContext` keys.
3
+ *
4
+ * Kept in a leaf module so `plugin.ts`, `server.ts`, `model.ts`, and
5
+ * `memory.ts` can import them without creating a cycle.
6
+ */
7
+
8
+ import type { BasePluginConfig, getExecutionContext } from "@databricks/appkit";
9
+ import type { AgentConfig } from "@mastra/core/agent";
10
+ import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
11
+
12
+ import type { MastraAgentDefinition, MastraTools } from "./agents.js";
13
+
14
+ /**
15
+ * `RequestContext` key under which {@link MastraServer} stores the
16
+ * resolved AppKit user. `model.ts` reads it to mint user-scoped
17
+ * Databricks tokens.
18
+ */
19
+ export const MASTRA_USER_KEY = "mastra__user";
20
+
21
+ /** AppKit execution context plus the canonical user id. */
22
+ export interface User {
23
+ id: string;
24
+ executionContext: ReturnType<typeof getExecutionContext>;
25
+ }
26
+
27
+ /** PgVector config with an optional Mastra store id. */
28
+ export type MastraMemoryConfig = PgVectorConfig & {
29
+ id?: string;
30
+ };
31
+
32
+ /** Configuration accepted by the Mastra AppKit plugin. */
33
+ export interface MastraPluginConfig extends BasePluginConfig {
34
+ /** Mastra OpenAI-compatible provider id. Defaults to `"databricks"`. */
35
+ providerId?: string;
36
+ /**
37
+ * PostgresStore for Mastra threads/messages. `true` reuses the
38
+ * `lakebase` plugin's pool; an object opens a dedicated store.
39
+ */
40
+ storage?: boolean | PostgresStoreConfig;
41
+ /**
42
+ * PgVector store for Mastra memory recall. `true` reuses the
43
+ * `lakebase` plugin's pool; an object opens a dedicated store.
44
+ */
45
+ memory?: boolean | MastraMemoryConfig;
46
+ /**
47
+ * Code-defined agents. Accepts three shapes for convenience:
48
+ *
49
+ * - **Record**: `{ analyst: def, helper: def }` - keys become the
50
+ * registered ids and the first key is the default.
51
+ * - **Single definition**: `def` - registered under
52
+ * `slugify(def.name)` (or `"default"` when `name` is omitted) and
53
+ * automatically marked as the default agent.
54
+ * - **Array**: `[def1, def2]` - each registered under
55
+ * `slugify(def.name)` (or `agent_${i}` when `name` is omitted);
56
+ * the first entry is the default.
57
+ *
58
+ * Each entry becomes a Mastra `Agent` reachable at
59
+ * `/api/<plugin>/route/chat/<id>` (the chat route also matches
60
+ * `:agentId`). When `agents` is omitted entirely, the plugin
61
+ * registers a single built-in `default` analyst so the bare
62
+ * `mastra()` call still mounts a working chat endpoint.
63
+ *
64
+ * @example Single-agent shorthand
65
+ * ```ts
66
+ * mastra({
67
+ * agents: createAgent({ instructions: "..." }),
68
+ * });
69
+ * ```
70
+ *
71
+ * @example Array
72
+ * ```ts
73
+ * mastra({
74
+ * agents: [
75
+ * createAgent({ name: "analyst", instructions: "..." }),
76
+ * createAgent({ name: "helper", instructions: "..." }),
77
+ * ],
78
+ * });
79
+ * ```
80
+ *
81
+ * @example Record (explicit ids)
82
+ * ```ts
83
+ * mastra({
84
+ * agents: {
85
+ * analyst: createAgent({ instructions: "..." }),
86
+ * helper: createAgent({ instructions: "..." }),
87
+ * },
88
+ * defaultAgent: "analyst",
89
+ * });
90
+ * ```
91
+ */
92
+ agents?:
93
+ | Record<string, MastraAgentDefinition>
94
+ | MastraAgentDefinition
95
+ | MastraAgentDefinition[];
96
+ /**
97
+ * Ambient tools spread into every registered agent's tools record;
98
+ * per-agent tools win on key collision. Use for a small shared
99
+ * library; for per-agent tools set `agents[id].tools` instead.
100
+ */
101
+ tools?: MastraTools;
102
+ /**
103
+ * Agent id used by `chatRoute` when the client doesn't specify one.
104
+ * Defaults to the first key in `agents` (or `"default"` when
105
+ * `agents` is omitted). Must match an id in `agents` when both are
106
+ * set; a mismatch throws at setup with the available candidates.
107
+ */
108
+ defaultAgent?: string;
109
+ /**
110
+ * Plugin-level default model applied to every agent that omits its
111
+ * own `model`. Mirrors AppKit's `agents({ defaultModel })`.
112
+ *
113
+ * - `string`: shorthand for "use the OBO auto-resolver but swap the
114
+ * `modelId`" (e.g. `"databricks-claude-sonnet-4-6"`).
115
+ * - Any other Mastra `DynamicArgument<MastraModelConfig>`: passed
116
+ * through verbatim. Use this when you need full control over auth
117
+ * or `providerId`.
118
+ *
119
+ * Resolution order per agent: `def.model` → `defaultModel` →
120
+ * built-in `/serving-endpoints` resolver.
121
+ */
122
+ defaultModel?: AgentConfig["model"] | string;
123
+ /**
124
+ * Allow loose model names (`"claude sonnet"`) to be fuzzy-matched
125
+ * against the workspace's Model Serving endpoints. Defaults to
126
+ * `true`; set `false` to require exact endpoint names everywhere.
127
+ */
128
+ modelFuzzyMatch?: boolean;
129
+ /**
130
+ * Fuse.js score threshold for the fuzzy matcher (0 = exact match,
131
+ * 1 = anything matches). Defaults to `0.4`. Lower values reject
132
+ * loose matches; raise it if you have a sprawling endpoint
133
+ * catalogue with similar-looking names.
134
+ */
135
+ modelFuzzyThreshold?: number;
136
+ /**
137
+ * TTL for the in-memory serving-endpoints list cache, in
138
+ * milliseconds. Defaults to 5 minutes. The cache is per workspace
139
+ * host and shared across users; concurrent callers coalesce on a
140
+ * single in-flight fetch.
141
+ */
142
+ modelCacheTtlMs?: number;
143
+ /**
144
+ * Allow clients to override the active model per request via the
145
+ * `X-Mastra-Model` header, `?model=` query string, or `model` body
146
+ * field. Defaults to `true`. Disable when running multi-tenant
147
+ * where untrusted clients shouldn't pick the backing endpoint.
148
+ */
149
+ modelOverride?: boolean;
150
+ /**
151
+ * Priority-ordered list of endpoint names tried when no agent /
152
+ * plugin / env / request-override model id is set. The resolver
153
+ * picks the first id that is actually present in the workspace's
154
+ * `/serving-endpoints` listing - this is what lets a workspace
155
+ * without Claude Opus still get a sensible default automatically.
156
+ *
157
+ * Defaults to the built-in list in `model.ts` (`FALLBACK_MODEL_IDS`):
158
+ * Claude (Opus -> Sonnet -> Haiku), then OpenAI GPT-5 family, then
159
+ * open weights (Llama 4, Llama 3.3, GPT-OSS, Qwen, Llama 3.1).
160
+ * Override here to pin a regulated workspace to an approved subset
161
+ * or to add custom endpoints in front of the public catalogue.
162
+ */
163
+ defaultModelFallbacks?: readonly string[];
164
+ /**
165
+ * Style guardrails appended to every agent's `instructions` to curb
166
+ * common LLM-isms (em dashes, emojis, sycophantic openers, throwaway
167
+ * closers, excessive hedging).
168
+ *
169
+ * - `undefined` (default): use the built-in
170
+ * `DEFAULT_STYLE_INSTRUCTIONS` from `agents.ts`.
171
+ * - `string`: replace the default with the supplied block.
172
+ * - `false`: disable entirely (agents see only their bespoke
173
+ * `instructions`).
174
+ *
175
+ * Appended (not prepended) so the agent's role and rules come first
176
+ * and the style block leans on the model's recency bias.
177
+ */
178
+ styleInstructions?: string | false;
179
+ }
package/src/genie.ts ADDED
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Mastra tool wrappers around the AppKit `genie` plugin's exports.
3
+ *
4
+ * One `sendMessage` tool is registered per configured space alias so
5
+ * the LLM picks the space by tool selection (the description bakes the
6
+ * alias in). `getConversation` is registered once, taking `alias` as a
7
+ * parameter.
8
+ *
9
+ * All Genie payload types are inferred from the public `genie` factory
10
+ * (`genie().plugin` constructor → `exports()` return type), so any
11
+ * upstream change in `@databricks/appkit` flows in automatically.
12
+ *
13
+ * As Genie streams its long-running events (`FETCHING_METADATA` →
14
+ * `ASKING_AI` → `EXECUTING_QUERY` → `COMPLETED`, plus SQL queries and
15
+ * row data in `message_result.attachments` / `query_result`), the tool
16
+ * forwards a normalised {@link GenieProgress} discriminated union out
17
+ * through `ctx.writer` so the client can render incremental feedback
18
+ * (status pill, SQL code block, row count) while the LLM still sees a
19
+ * single clean final payload.
20
+ */
21
+
22
+ import { genie } from "@databricks/appkit";
23
+ import { stringUtils } from "@dbx-tools/appkit-shared";
24
+ import { createTool } from "@mastra/core/tools";
25
+ import type { ToolStream } from "@mastra/core/tools";
26
+ import { z } from "zod";
27
+
28
+ /** Live AppKit `GeniePlugin` instance. */
29
+ export type GeniePluginInstance = InstanceType<ReturnType<typeof genie>["plugin"]>;
30
+
31
+ /** Full `exports()` shape of the AppKit `genie` plugin. */
32
+ export type GenieExports = ReturnType<GeniePluginInstance["exports"]>;
33
+
34
+ /**
35
+ * Stream event yielded by `genie.exports().sendMessage`. Discriminated
36
+ * by `type` (`"message_start" | "status" | "message_result" |
37
+ * "query_result" | "error" | "history_info"`).
38
+ */
39
+ export type GenieStreamEvent =
40
+ ReturnType<GenieExports["sendMessage"]> extends AsyncGenerator<infer E> ? E : never;
41
+
42
+ /** Conversation history returned by `genie.exports().getConversation`. */
43
+ export type GenieConversation = Awaited<ReturnType<GenieExports["getConversation"]>>;
44
+
45
+ type GenieMessage = Extract<GenieStreamEvent, { type: "message_result" }>["message"];
46
+ type GenieStatement = Extract<GenieStreamEvent, { type: "query_result" }>["data"];
47
+
48
+ /**
49
+ * Normalised progress event surfaced to the UI as a Mastra `tool-output`
50
+ * chunk. The discriminator (`kind`) keeps the union open for future
51
+ * Genie features (charts, attachments, retries) without forcing the
52
+ * client to know any Genie wire format.
53
+ */
54
+ export type GenieProgress =
55
+ | { kind: "started"; conversationId: string; messageId: string; spaceId: string }
56
+ | { kind: "status"; status: string; label: string }
57
+ | {
58
+ kind: "sql";
59
+ sql: string;
60
+ title?: string;
61
+ description?: string;
62
+ statementId?: string;
63
+ }
64
+ | { kind: "data"; rowCount: number; columns: string[] }
65
+ | { kind: "text"; content: string }
66
+ | { kind: "suggested"; questions: string[] }
67
+ | { kind: "error"; error: string };
68
+
69
+ const sendMessageSchema = z.object({
70
+ content: z.string().describe("Natural-language question to send to the Genie space."),
71
+ conversationId: z
72
+ .string()
73
+ .optional()
74
+ .describe(
75
+ "Optional Genie conversation id to continue an earlier thread. " +
76
+ "Omit on the first call; pass the id returned in the previous " +
77
+ "result's `conversationId` to follow up.",
78
+ ),
79
+ });
80
+
81
+ const getConversationSchema = z.object({
82
+ alias: z
83
+ .string()
84
+ .describe(
85
+ "Alias of the Genie space the conversation belongs to (matches the " +
86
+ "key in the genie plugin's `spaces` config).",
87
+ ),
88
+ conversationId: z.string().describe("Genie conversation id whose history to fetch."),
89
+ });
90
+
91
+ /**
92
+ * Default tool name for a wired Genie alias. The well-known `default`
93
+ * alias collapses to `genie`; everything else gets a `genie_` prefix so
94
+ * multiple spaces stay disambiguated when an agent has more than one
95
+ * wired. Matches the `genie` / `genie_<alias>` naming used elsewhere in
96
+ * dbx-tools AppKit demos.
97
+ */
98
+ export function defaultGenieToolName(alias: string): string {
99
+ if (alias === "default") return "genie";
100
+ return stringUtils.toIdentifierWithOptions({ distinct: true }, "genie", alias);
101
+ }
102
+
103
+ /**
104
+ * Build one `sendMessage` tool per configured Genie alias plus a single
105
+ * `getConversation` tool. Returns a record keyed by tool id, ready to
106
+ * spread into an `Agent`'s `tools` map.
107
+ */
108
+ export function buildGenieTools(opts: {
109
+ aliases: string[];
110
+ exports: GenieExports;
111
+ signal?: AbortSignal;
112
+ }): Record<string, ReturnType<typeof createTool>> {
113
+ const tools: Record<string, ReturnType<typeof createTool>> = {};
114
+
115
+ for (const alias of opts.aliases) {
116
+ const id = defaultGenieToolName(alias);
117
+ tools[id] = createTool({
118
+ id,
119
+ description:
120
+ `Ask the Databricks Genie space "${alias}" a natural-language ` +
121
+ "question. Genie translates the question to SQL, runs it against " +
122
+ "the configured datasets, and returns a written answer plus any " +
123
+ "SQL statements it executed. Returns `{ conversationId, content, " +
124
+ "queries, ... }`; pass `conversationId` back in to follow up in " +
125
+ "the same Genie thread.",
126
+ inputSchema: sendMessageSchema,
127
+ execute: async ({ content, conversationId }, ctx) => {
128
+ const stream = opts.exports.sendMessage(alias, content, conversationId, {
129
+ signal: opts.signal,
130
+ });
131
+ return drainGenieStream(stream, ctx.writer);
132
+ },
133
+ });
134
+ }
135
+
136
+ tools.genie_get_conversation = createTool({
137
+ id: "genie_get_conversation",
138
+ description:
139
+ "Fetch the full message history of a prior Genie conversation by id. " +
140
+ "Use when the user references an earlier Genie thread by id, or to " +
141
+ "inspect attachments / SQL from previous turns.",
142
+ inputSchema: getConversationSchema,
143
+ execute: async ({ alias, conversationId }) => {
144
+ return opts.exports.getConversation(alias, conversationId, opts.signal);
145
+ },
146
+ });
147
+
148
+ return tools;
149
+ }
150
+
151
+ /**
152
+ * Drain the genie `sendMessage` AsyncGenerator into a flat result the
153
+ * agent's calling LLM can reason about. Final assistant text is pulled
154
+ * from the last `message_result`; SQL statements are extracted from
155
+ * `query_result` events; conversation / message ids are surfaced so the
156
+ * caller can pass `conversationId` back into a follow-up tool call.
157
+ *
158
+ * When a Mastra `writer` is passed (i.e. the tool runs inside an agent
159
+ * stream), normalised {@link GenieProgress} events are pushed mid-flight
160
+ * so the UI can show status changes, SQL, and row counts as they
161
+ * happen instead of staring at a spinner for the full Genie round-trip.
162
+ */
163
+ async function drainGenieStream(
164
+ stream: AsyncGenerator<GenieStreamEvent>,
165
+ writer?: ToolStream,
166
+ ): Promise<{
167
+ conversationId?: string;
168
+ messageId?: string;
169
+ spaceId?: string;
170
+ status?: string;
171
+ content?: string;
172
+ attachments?: GenieMessage["attachments"];
173
+ queries: { attachmentId: string; statementId: string; data: GenieStatement }[];
174
+ error?: string;
175
+ }> {
176
+ let conversationId: string | undefined;
177
+ let messageId: string | undefined;
178
+ let spaceId: string | undefined;
179
+ let status: string | undefined;
180
+ let content: string | undefined;
181
+ let attachments: GenieMessage["attachments"] | undefined;
182
+ let error: string | undefined;
183
+ const queries: {
184
+ attachmentId: string;
185
+ statementId: string;
186
+ data: GenieStatement;
187
+ }[] = [];
188
+
189
+ // Best-effort progress emission. Awaited so the underlying agent
190
+ // stream sees events in order; write failures are swallowed so a
191
+ // dead writer (e.g. closed downstream) can't take the tool down.
192
+ const emit = async (event: GenieProgress) => {
193
+ if (!writer) return;
194
+ try {
195
+ await writer.write(event);
196
+ } catch {
197
+ // ignore: downstream stream is no longer interested
198
+ }
199
+ };
200
+
201
+ for await (const event of stream) {
202
+ switch (event.type) {
203
+ case "message_start":
204
+ conversationId = event.conversationId;
205
+ messageId = event.messageId;
206
+ spaceId = event.spaceId;
207
+ await emit({
208
+ kind: "started",
209
+ conversationId,
210
+ messageId,
211
+ spaceId,
212
+ });
213
+ break;
214
+ case "status":
215
+ status = event.status;
216
+ await emit({
217
+ kind: "status",
218
+ status: event.status,
219
+ label: humanizeGenieStatus(event.status),
220
+ });
221
+ break;
222
+ case "query_result": {
223
+ queries.push({
224
+ attachmentId: event.attachmentId,
225
+ statementId: event.statementId,
226
+ data: event.data,
227
+ });
228
+ const rowCount = event.data?.result?.data_array?.length ?? 0;
229
+ const columns = (event.data?.manifest?.schema?.columns ?? []).map(
230
+ (c) => c.name,
231
+ );
232
+ await emit({ kind: "data", rowCount, columns });
233
+ break;
234
+ }
235
+ case "message_result":
236
+ content = event.message.content;
237
+ attachments = event.message.attachments;
238
+ status = event.message.status;
239
+ for (const attachment of attachments ?? []) {
240
+ if (attachment.query?.query) {
241
+ await emit({
242
+ kind: "sql",
243
+ sql: attachment.query.query,
244
+ title: attachment.query.title,
245
+ description: attachment.query.description,
246
+ statementId: attachment.query.statementId,
247
+ });
248
+ }
249
+ if (attachment.text?.content) {
250
+ await emit({ kind: "text", content: attachment.text.content });
251
+ }
252
+ if (attachment.suggestedQuestions?.length) {
253
+ await emit({
254
+ kind: "suggested",
255
+ questions: attachment.suggestedQuestions,
256
+ });
257
+ }
258
+ }
259
+ break;
260
+ case "error":
261
+ error = event.error;
262
+ await emit({ kind: "error", error: event.error });
263
+ break;
264
+ default:
265
+ break;
266
+ }
267
+ }
268
+
269
+ return {
270
+ conversationId,
271
+ messageId,
272
+ spaceId,
273
+ status,
274
+ content,
275
+ attachments,
276
+ queries,
277
+ error,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Toolkit provider built from a live AppKit `GeniePlugin` instance.
283
+ * Returned by {@link buildGenieProvider} so that
284
+ * `plugins.genie?.toolkit()` inside an agent's `tools(plugins)` callback
285
+ * resolves to the streaming-aware {@link buildGenieTools} record instead
286
+ * of the AppKit default (which does one blocking call per tool with no
287
+ * mid-flight events).
288
+ *
289
+ * The returned `toolkit()` reads alias names off the plugin's
290
+ * `getAgentTools()` registry (each entry is `${alias}.sendMessage` or
291
+ * `${alias}.getConversation`), then mints one `sendMessage` tool per
292
+ * alias plus a shared `getConversation`. `sendMessage` / `getConversation`
293
+ * are bound back to the plugin instance so they keep their `this`
294
+ * (they are class methods, not free functions).
295
+ *
296
+ * `_opts` is accepted but unused for now - the streaming tools are an
297
+ * all-or-nothing bundle. Wire `only` / `except` / `prefix` / `rename`
298
+ * later if a caller needs them.
299
+ */
300
+ export function buildGenieProvider(plugin: GeniePluginInstance): {
301
+ toolkit(opts?: unknown): Record<string, ReturnType<typeof createTool>>;
302
+ } {
303
+ return {
304
+ toolkit(_opts?: unknown) {
305
+ const aliases = extractGenieAliases(plugin);
306
+ return buildGenieTools({
307
+ aliases,
308
+ exports: {
309
+ sendMessage: plugin.sendMessage.bind(plugin),
310
+ getConversation: plugin.getConversation.bind(plugin),
311
+ },
312
+ });
313
+ },
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Pull the configured space aliases out of a live AppKit `GeniePlugin`.
319
+ * Reads them off `getAgentTools()` (public API) so we don't poke at the
320
+ * `protected config.spaces` field: the plugin registers tools named
321
+ * `${alias}.sendMessage` / `${alias}.getConversation`, so the unique
322
+ * set of name prefixes is the alias list.
323
+ */
324
+ function extractGenieAliases(plugin: GeniePluginInstance): string[] {
325
+ const aliases = new Set<string>();
326
+ for (const t of plugin.getAgentTools()) {
327
+ const dot = t.name.indexOf(".");
328
+ if (dot > 0) aliases.add(t.name.slice(0, dot));
329
+ }
330
+ return [...aliases];
331
+ }
332
+
333
+ /**
334
+ * Convert raw Genie status codes (`FETCHING_METADATA`, `ASKING_AI`,
335
+ * `EXECUTING_QUERY`, `COMPLETED`, ...) into short, sentence-cased
336
+ * labels safe to drop straight into a UI pill. Unknown codes are
337
+ * lower-cased with underscores stripped so new states still render.
338
+ */
339
+ function humanizeGenieStatus(status: string): string {
340
+ switch (status) {
341
+ case "FETCHING_METADATA":
342
+ return "Fetching metadata";
343
+ case "ASKING_AI":
344
+ return "Asking Genie";
345
+ case "EXECUTING_QUERY":
346
+ return "Running SQL query";
347
+ case "COMPLETED":
348
+ return "Completed";
349
+ case "FAILED":
350
+ return "Failed";
351
+ default:
352
+ return status.toLowerCase().replace(/_/g, " ");
353
+ }
354
+ }