@dbx-tools/appkit-mastra 0.1.58 → 0.1.67
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 +41 -1
- package/dist/index.d.ts +1229 -17
- package/dist/index.js +3348 -19
- package/package.json +17 -28
- package/src/agents.ts +7 -1
- package/src/config.ts +64 -0
- package/src/genie.ts +9 -21
- package/{index.ts → src/index.ts} +7 -9
- package/src/mcp.ts +89 -0
- package/src/model.ts +9 -3
- package/src/plugin.ts +51 -8
- package/src/serving.ts +5 -9
- package/src/statement.ts +8 -36
- package/dist/src/agents.d.ts +0 -316
- package/dist/src/agents.js +0 -470
- package/dist/src/chart.d.ts +0 -153
- package/dist/src/chart.js +0 -570
- package/dist/src/config.d.ts +0 -295
- package/dist/src/config.js +0 -55
- package/dist/src/genie.d.ts +0 -161
- package/dist/src/genie.js +0 -973
- package/dist/src/history.d.ts +0 -95
- package/dist/src/history.js +0 -278
- package/dist/src/memory.d.ts +0 -109
- package/dist/src/memory.js +0 -253
- package/dist/src/model.d.ts +0 -52
- package/dist/src/model.js +0 -198
- package/dist/src/observability.d.ts +0 -64
- package/dist/src/observability.js +0 -83
- package/dist/src/plugin.d.ts +0 -177
- package/dist/src/plugin.js +0 -554
- package/dist/src/processor.d.ts +0 -8
- package/dist/src/processor.js +0 -40
- package/dist/src/processors/strip-stale-charts.d.ts +0 -32
- package/dist/src/processors/strip-stale-charts.js +0 -98
- package/dist/src/server.d.ts +0 -51
- package/dist/src/server.js +0 -152
- package/dist/src/serving.d.ts +0 -65
- package/dist/src/serving.js +0 -79
- package/dist/src/statement.d.ts +0 -66
- package/dist/src/statement.js +0 -111
- package/dist/src/writer.d.ts +0 -23
- package/dist/src/writer.js +0 -37
- package/dist/tsconfig.build.tsbuildinfo +0 -1
package/dist/index.js
CHANGED
|
@@ -1,20 +1,3349 @@
|
|
|
1
|
+
import { ChartSchema, ChartTypeSchema, MASTRA_ROUTES, MODEL_OVERRIDE_BODY_FIELDS, MODEL_OVERRIDE_HEADER, MODEL_OVERRIDE_QUERY } from "@dbx-tools/appkit-mastra-shared";
|
|
2
|
+
import { DEFAULT_FUZZY_THRESHOLD, DEFAULT_MODEL_CACHE_TTL_MS, FALLBACK_MODEL_IDS, ModelClass, clearServingEndpointsCache, listServingEndpoints, parseModelClass, selectModel } from "@dbx-tools/model";
|
|
3
|
+
import { apiUtils, appkitUtils, commonUtils, httpUtils, logUtils, netUtils, projectUtils, stringUtils } from "@dbx-tools/shared";
|
|
4
|
+
import { Agent } from "@mastra/core/agent";
|
|
5
|
+
import { createTool, createTool as createTool$1 } from "@mastra/core/tools";
|
|
6
|
+
import { CacheManager, Plugin, genie, getExecutionContext, getUsernameWithApiLookup, lakebase, toPlugin } from "@databricks/appkit";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
|
|
9
|
+
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
10
|
+
import { genieEventChat, genieSampleQuestions, getGenieSpace } from "@dbx-tools/genie";
|
|
11
|
+
import { GenieMessageSchema } from "@dbx-tools/genie-shared";
|
|
12
|
+
import { MCPServer } from "@mastra/mcp";
|
|
13
|
+
import { Mastra } from "@mastra/core/mastra";
|
|
14
|
+
import express from "express";
|
|
15
|
+
import { toAISdkV5Messages } from "@mastra/ai-sdk/ui";
|
|
16
|
+
import { registerApiRoute } from "@mastra/core/server";
|
|
17
|
+
import { fastembed } from "@mastra/fastembed";
|
|
18
|
+
import { Memory } from "@mastra/memory";
|
|
19
|
+
import { PgVector, PostgresStore } from "@mastra/pg";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { Pool } from "pg";
|
|
22
|
+
import { Observability } from "@mastra/observability";
|
|
23
|
+
import { OtelBridge } from "@mastra/otel-bridge";
|
|
24
|
+
import { MastraServer } from "@mastra/express";
|
|
25
|
+
|
|
26
|
+
export * from "@dbx-tools/appkit-mastra-shared"
|
|
27
|
+
|
|
28
|
+
export * from "@dbx-tools/model"
|
|
29
|
+
|
|
30
|
+
//#region packages/appkit-mastra/src/config.ts
|
|
1
31
|
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
32
|
+
* `RequestContext` key under which {@link MastraServer} stores the
|
|
33
|
+
* resolved AppKit user. `model.ts` reads it to mint user-scoped
|
|
34
|
+
* Databricks tokens.
|
|
35
|
+
*/
|
|
36
|
+
const MASTRA_USER_KEY = "mastra__user";
|
|
37
|
+
/**
|
|
38
|
+
* `RequestContext` keys for AppKit user metadata stamped by
|
|
39
|
+
* {@link MastraServer}. Surfaced as trace metadata via
|
|
40
|
+
* {@link TRACE_REQUEST_CONTEXT_KEYS} so traces are filterable by who
|
|
41
|
+
* issued the request without leaking the full user object.
|
|
42
|
+
*/
|
|
43
|
+
const MASTRA_USER_NAME_KEY = "mastra__userName";
|
|
44
|
+
const MASTRA_USER_EMAIL_KEY = "mastra__userEmail";
|
|
45
|
+
/**
|
|
46
|
+
* `RequestContext` key for the per-HTTP-request id stamped by
|
|
47
|
+
* {@link MastraServer}. Reads `X-Request-Id` from the incoming
|
|
48
|
+
* headers when present (so an upstream load balancer / API gateway
|
|
49
|
+
* can keep its trace correlation), falls back to a freshly minted
|
|
50
|
+
* UUID. Echoed back on the response and surfaced on every span via
|
|
51
|
+
* {@link TRACE_REQUEST_CONTEXT_KEYS} so logs and traces share a
|
|
52
|
+
* join key.
|
|
53
|
+
*/
|
|
54
|
+
const MASTRA_REQUEST_ID_KEY = "mastra__requestId";
|
|
55
|
+
/**
|
|
56
|
+
* Canonical list of `RequestContext` keys we want Mastra to extract
|
|
57
|
+
* as metadata on every observability span (agent runs, model calls,
|
|
58
|
+
* tool invocations, workflow steps).
|
|
59
|
+
*
|
|
60
|
+
* Mirrors {@link https://mastra.ai/docs/observability/tracing/overview#automatic-metadata-from-requestcontext}:
|
|
61
|
+
* passed verbatim into `Observability.configs[*].requestContextKeys`,
|
|
62
|
+
* so any key listed here is read from `RequestContext` at trace
|
|
63
|
+
* start and attached as scalar span metadata. Keep the set to plain
|
|
64
|
+
* scalars - never include {@link MASTRA_USER_KEY} (it carries the
|
|
65
|
+
* full AppKit execution context with a `WorkspaceClient` reference).
|
|
66
|
+
*
|
|
67
|
+
* Order is purely cosmetic; Mastra de-dupes internally.
|
|
68
|
+
*/
|
|
69
|
+
const TRACE_REQUEST_CONTEXT_KEYS = [
|
|
70
|
+
MASTRA_RESOURCE_ID_KEY,
|
|
71
|
+
MASTRA_THREAD_ID_KEY,
|
|
72
|
+
MASTRA_REQUEST_ID_KEY,
|
|
73
|
+
MASTRA_USER_NAME_KEY,
|
|
74
|
+
MASTRA_USER_EMAIL_KEY,
|
|
75
|
+
"mastra__model_override"
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region packages/appkit-mastra/src/serving.ts
|
|
80
|
+
/**
|
|
81
|
+
* Mastra-specific glue over the generic `@dbx-tools/model` toolkit.
|
|
82
|
+
*
|
|
83
|
+
* The live `/serving-endpoints` catalogue access, fuzzy name
|
|
84
|
+
* resolution, and class/fallback selection all live in
|
|
85
|
+
* `@dbx-tools/model`; this module only adds what is specific to the
|
|
86
|
+
* Mastra plugin: pulling a per-request model override off an HTTP
|
|
87
|
+
* request (header / query / body) and projecting the plugin config
|
|
88
|
+
* onto the knobs `buildModel` and the `/models` route share.
|
|
89
|
+
*/
|
|
90
|
+
/**
|
|
91
|
+
* `RequestContext` key under which {@link MastraServer} stores the
|
|
92
|
+
* per-request model override (header / query / body). `model.ts`
|
|
93
|
+
* reads it before falling back to the agent / plugin default.
|
|
94
|
+
*/
|
|
95
|
+
const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
|
|
96
|
+
/**
|
|
97
|
+
* Pull a model override out of a single HTTP request, checking
|
|
98
|
+
* sources in priority order:
|
|
99
|
+
*
|
|
100
|
+
* 1. `X-Mastra-Model` header
|
|
101
|
+
* 2. `?model=` query string parameter
|
|
102
|
+
* 3. Body field (`model` or `modelId`, in that order)
|
|
103
|
+
*
|
|
104
|
+
* Returns `null` when nothing is set, so callers can wrap with
|
|
105
|
+
* `if (override) ...` without juggling empty strings. Body inspection
|
|
106
|
+
* is lenient - any plain object with one of the configured keys
|
|
107
|
+
* counts, mirroring how AI SDK chat clients pass arbitrary metadata
|
|
108
|
+
* alongside `messages`.
|
|
109
|
+
*/
|
|
110
|
+
function extractModelOverride(req) {
|
|
111
|
+
const headers = req.headers;
|
|
112
|
+
if (headers) {
|
|
113
|
+
const headerVal = stringUtils.firstNonEmpty(headers[MODEL_OVERRIDE_HEADER] ?? headers[MODEL_OVERRIDE_HEADER.toLowerCase()]);
|
|
114
|
+
if (headerVal) return headerVal;
|
|
115
|
+
}
|
|
116
|
+
if (req.query) {
|
|
117
|
+
const queryVal = stringUtils.firstNonEmpty(req.query[MODEL_OVERRIDE_QUERY]);
|
|
118
|
+
if (queryVal) return queryVal;
|
|
119
|
+
}
|
|
120
|
+
if (req.body && typeof req.body === "object") {
|
|
121
|
+
const record = req.body;
|
|
122
|
+
for (const field of MODEL_OVERRIDE_BODY_FIELDS) {
|
|
123
|
+
const bodyVal = stringUtils.firstNonEmpty(record[field]);
|
|
124
|
+
if (bodyVal) return bodyVal;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read the fuzzy-resolution config knobs off the plugin config with
|
|
131
|
+
* `@dbx-tools/model` defaults applied. Kept here so `buildModel` and
|
|
132
|
+
* the `/models` route agree on what "enabled" means.
|
|
133
|
+
*
|
|
134
|
+
* `fallbacks` is the priority-ordered list `resolveModel` walks first
|
|
135
|
+
* when nothing explicit is set; defaults to an empty list so the
|
|
136
|
+
* generic resolver falls through to the live catalogue and its own
|
|
137
|
+
* `FALLBACK_MODEL_IDS` floor.
|
|
138
|
+
*/
|
|
139
|
+
function resolveServingConfig(config) {
|
|
140
|
+
return {
|
|
141
|
+
ttlMs: config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS,
|
|
142
|
+
threshold: config.modelFuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD,
|
|
143
|
+
fuzzy: config.modelFuzzyMatch !== false,
|
|
144
|
+
allowOverride: config.modelOverride !== false,
|
|
145
|
+
fallbacks: config.defaultModelFallbacks ?? []
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region packages/appkit-mastra/src/model.ts
|
|
151
|
+
/**
|
|
152
|
+
* Databricks Model Serving resolver for Mastra agents.
|
|
153
|
+
*
|
|
154
|
+
* Each agent step calls {@link buildModel} with the active
|
|
155
|
+
* `RequestContext`. The user stamped by `MastraServer` carries an
|
|
156
|
+
* AppKit `WorkspaceClient`; we ask it for the workspace host and a
|
|
157
|
+
* fresh bearer header, then point Mastra's OpenAI-compatible provider
|
|
158
|
+
* at `/serving-endpoints` on that host.
|
|
159
|
+
*
|
|
160
|
+
* This module only adds the Mastra-specific glue. The actual model
|
|
161
|
+
* selection - listing the workspace catalogue and resolving an
|
|
162
|
+
* explicit name / class / fallback chain to a real endpoint id - lives
|
|
163
|
+
* in `@dbx-tools/model` ({@link selectModel}) so non-Mastra consumers
|
|
164
|
+
* (e.g. a job that just needs a model name) can reuse it. Here we
|
|
165
|
+
* assemble the explicit ask from Mastra's request context (the
|
|
166
|
+
* per-request override under {@link MASTRA_MODEL_OVERRIDE_KEY}, the
|
|
167
|
+
* agent / plugin `modelId`, or `DATABRICKS_SERVING_ENDPOINT_NAME`),
|
|
168
|
+
* pass the plugin's fuzzy / class / fallback knobs through, and wrap
|
|
169
|
+
* the resolved id in the OpenAI-compatible provider config Mastra
|
|
170
|
+
* expects. Catalogue fetches fail loud: network / auth errors
|
|
171
|
+
* propagate so callers see the real SDK message.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a `MastraModelConfig` for the current agent step. Runs
|
|
175
|
+
* while `agent.stream` is inside the `asUser(req)` scope so tokens
|
|
176
|
+
* are user-scoped; outside an active user context the workspace
|
|
177
|
+
* client falls back to the service principal.
|
|
178
|
+
*/
|
|
179
|
+
async function buildModel(config, requestContext, overrides = {}) {
|
|
180
|
+
setupFetchInterceptor();
|
|
181
|
+
const executionContext = requestContext.get(MASTRA_USER_KEY)?.executionContext ?? getExecutionContext();
|
|
182
|
+
const clientConfig = executionContext.client.config;
|
|
183
|
+
const host = (await clientConfig.getHost()).toString();
|
|
184
|
+
const headers = new Headers();
|
|
185
|
+
await clientConfig.authenticate(headers);
|
|
186
|
+
const url = new URL("/serving-endpoints", host).toString().replace(/\/$/, "");
|
|
187
|
+
const log = logUtils.logger(config);
|
|
188
|
+
const serving = resolveServingConfig(config);
|
|
189
|
+
const requested = (serving.allowOverride ? requestContext.get(MASTRA_MODEL_OVERRIDE_KEY) : void 0) ?? overrides.modelId ?? process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
|
|
190
|
+
const requestedClass = requested !== void 0 ? parseModelClass(requested) : null;
|
|
191
|
+
const explicit = requestedClass === null ? requested : void 0;
|
|
192
|
+
const modelClass = requestedClass ?? overrides.modelClass;
|
|
193
|
+
const { modelId, source } = await selectModel(executionContext.client, host, {
|
|
194
|
+
...explicit !== void 0 ? { explicit } : {},
|
|
195
|
+
fuzzy: serving.fuzzy,
|
|
196
|
+
threshold: serving.threshold,
|
|
197
|
+
...modelClass !== void 0 ? { modelClass } : {},
|
|
198
|
+
fallbacks: serving.fallbacks,
|
|
199
|
+
ttlMs: serving.ttlMs
|
|
200
|
+
});
|
|
201
|
+
log.debug("model selected", {
|
|
202
|
+
modelId,
|
|
203
|
+
source,
|
|
204
|
+
requested
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
providerId: config.providerId ?? "databricks",
|
|
208
|
+
modelId,
|
|
209
|
+
url,
|
|
210
|
+
headers: Object.fromEntries(headers.entries())
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Path prefix that identifies a Databricks Model Serving REST call. */
|
|
214
|
+
const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
|
|
215
|
+
/**
|
|
216
|
+
* Install a single shared `globalThis.fetch` wrapper for every POST to
|
|
217
|
+
* `/serving-endpoints/...`. The wrapper does two things:
|
|
218
|
+
*
|
|
219
|
+
* 1. Rewrites the outgoing `messages` array to repair Mastra/AI SDK
|
|
220
|
+
* stream-replay quirks that Databricks-hosted Claude rejects (see
|
|
221
|
+
* {@link sanitizeServingMessages}).
|
|
222
|
+
* 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
|
|
223
|
+
* 4xx debugging doesn't have to fight AI SDK's `[Array]`
|
|
224
|
+
* formatter.
|
|
225
|
+
*
|
|
226
|
+
* Safe to call from any hot path: {@link commonUtils.memoize} ensures
|
|
227
|
+
* the wrapper is installed at most once per process, so subsequent
|
|
228
|
+
* calls collapse to a single cached promise even when
|
|
229
|
+
* {@link buildModel} fires on every agent step.
|
|
230
|
+
*/
|
|
231
|
+
const setupFetchInterceptor = commonUtils.memoize(() => {
|
|
232
|
+
const log = logUtils.logger("mastra/llm");
|
|
233
|
+
const original = globalThis.fetch.bind(globalThis);
|
|
234
|
+
globalThis.fetch = (async (input, init) => {
|
|
235
|
+
const url = netUtils.urlBuilder(input);
|
|
236
|
+
if (!url || !url.pathname.startsWith(SERVING_ENDPOINTS_PATH_PREFIX) || typeof init?.body !== "string") return original(input, init);
|
|
237
|
+
const rewritten = rewriteServingBody(init.body);
|
|
238
|
+
if (rewritten !== init.body) init = {
|
|
239
|
+
...init,
|
|
240
|
+
body: rewritten
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
log.debug("POST", {
|
|
244
|
+
url: url.toString(),
|
|
245
|
+
body: JSON.parse(rewritten)
|
|
246
|
+
});
|
|
247
|
+
} catch {
|
|
248
|
+
log.debug("POST", {
|
|
249
|
+
url: url.toString(),
|
|
250
|
+
bodyType: "non-JSON"
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return original(input, init);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
/**
|
|
257
|
+
* Parse, sanitize, and re-serialize a `/serving-endpoints/...` POST
|
|
258
|
+
* body. Returns the original string verbatim when the body is not
|
|
259
|
+
* JSON, has no `messages`, or no rewrite was needed; this lets the
|
|
260
|
+
* caller skip the allocation of a new `init` object in the common
|
|
261
|
+
* pass-through case.
|
|
262
|
+
*/
|
|
263
|
+
function rewriteServingBody(body) {
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(body);
|
|
267
|
+
} catch {
|
|
268
|
+
return body;
|
|
269
|
+
}
|
|
270
|
+
if (!Array.isArray(parsed.messages)) return body;
|
|
271
|
+
return sanitizeServingMessages(parsed.messages) ? JSON.stringify(parsed) : body;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Repair a Mastra/AI SDK message replay that Databricks-hosted Claude
|
|
275
|
+
* rejects with `"This model does not support assistant message
|
|
276
|
+
* prefill. The conversation must end with a user message."`.
|
|
277
|
+
*
|
|
278
|
+
* The bug pattern: when an assistant turn streams text *and* a
|
|
279
|
+
* `tool_call`, the AI SDK persists them as two separate assistant
|
|
280
|
+
* entries (text-only and tool-call-only). On the next agent step the
|
|
281
|
+
* tool-call entry is replayed *before* the tool result and the
|
|
282
|
+
* text entry is replayed *after* it, so the conversation ends with a
|
|
283
|
+
* trailing assistant text message. Anthropic interprets that as a
|
|
284
|
+
* prefill request and rejects it on Databricks (the upstream Bedrock
|
|
285
|
+
* route disallows prefill).
|
|
286
|
+
*
|
|
287
|
+
* Fix: when the last message is an assistant text with no `tool_calls`
|
|
288
|
+
* and the chain immediately before it is `assistant(tool_calls=...)`
|
|
289
|
+
* followed only by `tool(...)` results, fold the trailing text back
|
|
290
|
+
* into the `content` of that opening assistant and drop the duplicate.
|
|
291
|
+
* The result is the canonical OpenAI shape
|
|
292
|
+
* `[..., user, assistant(text + tool_calls), tool(...)]` which both
|
|
293
|
+
* Databricks Claude and every other endpoint accept.
|
|
294
|
+
*
|
|
295
|
+
* Mutates `messages` in place; returns `true` when something changed
|
|
296
|
+
* so the caller knows whether to re-serialize.
|
|
297
|
+
*/
|
|
298
|
+
function sanitizeServingMessages(messages) {
|
|
299
|
+
if (messages.length < 2) return false;
|
|
300
|
+
const last = messages[messages.length - 1];
|
|
301
|
+
if (!last || last.role !== "assistant" || last.tool_calls && last.tool_calls.length > 0) return false;
|
|
302
|
+
let i = messages.length - 2;
|
|
303
|
+
while (i >= 0 && messages[i]?.role === "tool") i--;
|
|
304
|
+
if (i < 0) return false;
|
|
305
|
+
const opener = messages[i];
|
|
306
|
+
if (!opener || opener.role !== "assistant" || !opener.tool_calls || opener.tool_calls.length === 0) return false;
|
|
307
|
+
opener.content = [stringUtils.trimToNull(opener.content), stringUtils.trimToNull(last.content)].filter((s) => s !== null).join("\n\n");
|
|
308
|
+
messages.pop();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
//#endregion
|
|
313
|
+
//#region packages/appkit-mastra/src/chart.ts
|
|
314
|
+
/**
|
|
315
|
+
* Chart planner + chart cache.
|
|
316
|
+
*
|
|
317
|
+
* Self-contained chart subsystem with two layers:
|
|
318
|
+
*
|
|
319
|
+
* 1. Inner planner agent (private). Pure dataset-in /
|
|
320
|
+
* `EChartsOption`-out brain. Driven by {@link prepareChart};
|
|
321
|
+
* callers never instantiate it directly.
|
|
322
|
+
* 2. {@link prepareChart}: orchestration on top of the planner.
|
|
323
|
+
* Mints a `chartId`, caches an empty `{ chartId }` record
|
|
324
|
+
* synchronously, then resolves the dataset and runs the
|
|
325
|
+
* planner in the background. The terminal entry settles with
|
|
326
|
+
* either `result` (success) or `error` (failure). Both
|
|
327
|
+
* undefined means the entry is still processing.
|
|
328
|
+
*
|
|
329
|
+
* The cache surface ({@link fetchChart}) is the only state the
|
|
330
|
+
* HTTP route and the chart-producing tools share. `prepareChart`
|
|
331
|
+
* is dataset-agnostic - callers supply a `resolveData` callback
|
|
332
|
+
* that fetches the rows however they like (Genie statement, inline
|
|
333
|
+
* dataset, custom API). The module has no knowledge of Genie or
|
|
334
|
+
* statement ids; those concerns live in the tools that wrap it.
|
|
335
|
+
*
|
|
336
|
+
* Wire-format schemas live in `@dbx-tools/appkit-mastra-shared` so
|
|
337
|
+
* the demo client and any other UI consumer share the exact same
|
|
338
|
+
* shape this module reads and writes.
|
|
339
|
+
*/
|
|
340
|
+
const log$5 = logUtils.logger("mastra/chart");
|
|
341
|
+
/**
|
|
342
|
+
* TTL for cached chart entries. One hour balances "long enough for
|
|
343
|
+
* the host UI to fetch the chart well after the model finished
|
|
344
|
+
* talking" against "short enough that abandoned chart ids don't
|
|
345
|
+
* pin storage". Matches the typical Databricks OBO token lifetime
|
|
346
|
+
* so any data re-resolution stays inside the original auth window.
|
|
347
|
+
*/
|
|
348
|
+
const CHART_CACHE_TTL_SEC = 3600;
|
|
349
|
+
/** Cache namespace; keeps the chart keyspace tidy. */
|
|
350
|
+
const CHART_CACHE_NAMESPACE = "mastra:chart";
|
|
351
|
+
/**
|
|
352
|
+
* `userKey` for `CacheManager.generateKey`. Chart ids are minted
|
|
353
|
+
* via `commonUtils.id()` (v4 UUID) and are unguessable, so a
|
|
354
|
+
* constant user key is fine. The HTTP route can re-scope to the
|
|
355
|
+
* requesting user when policy demands it.
|
|
356
|
+
*/
|
|
357
|
+
const CHART_CACHE_USER_KEY = "mastra-chart";
|
|
358
|
+
/** Default server-side long-poll budget for {@link fetchChart}. */
|
|
359
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 6e4;
|
|
360
|
+
/** Default inter-poll sleep for {@link fetchChart}. */
|
|
361
|
+
const DEFAULT_FETCH_INTERVAL_MS = 250;
|
|
362
|
+
/**
|
|
363
|
+
* One series data point. Wide variant set so the planner agent can
|
|
364
|
+
* faithfully pass through whatever the SQL row set contained
|
|
365
|
+
* (numbers, stringified numbers, nulls for missing measurements,
|
|
366
|
+
* `[x, y]` tuples for scatter, `{name, value}` slices for pie)
|
|
367
|
+
* without the structured-output guard rejecting the whole plan.
|
|
368
|
+
*
|
|
369
|
+
* Three layers of tolerance:
|
|
370
|
+
*
|
|
371
|
+
* 1. {@link z.preprocess} normalizes wire shapes BEFORE union
|
|
372
|
+
* dispatch: stringified numbers parse to numbers, finite
|
|
373
|
+
* checks reject `NaN` / `Infinity`, 2-element arrays coerce
|
|
374
|
+
* tuple components, and `{value}` objects with missing /
|
|
375
|
+
* stringified `value` get coerced or rejected uniformly.
|
|
376
|
+
* Anything not handleable becomes `null`.
|
|
377
|
+
* 2. The union accepts `null` as a first-class variant. Echarts
|
|
378
|
+
* renders null as a gap on bar / line / area (which is the
|
|
379
|
+
* right visual signal for "missing reading"). Scatter and
|
|
380
|
+
* pie filter nulls in {@link planToEchartsOption} because
|
|
381
|
+
* Echarts crashes on null tuples / slices.
|
|
382
|
+
* 3. {@link z.union#catch} backstops the whole thing: if
|
|
383
|
+
* preprocess somehow produces a shape that still doesn't
|
|
384
|
+
* match any variant, the bad item becomes `null` instead of
|
|
385
|
+
* taking down the entire chart with a
|
|
386
|
+
* `Structured output validation failed` error.
|
|
387
|
+
*/
|
|
388
|
+
const chartDataPointSchema = z.preprocess((v) => {
|
|
389
|
+
if (v === null || v === void 0) return null;
|
|
390
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : null;
|
|
391
|
+
if (typeof v === "string") {
|
|
392
|
+
const n = Number(v);
|
|
393
|
+
return Number.isFinite(n) ? n : null;
|
|
394
|
+
}
|
|
395
|
+
if (Array.isArray(v) && v.length === 2) {
|
|
396
|
+
const x = typeof v[0] === "number" ? v[0] : Number(v[0]);
|
|
397
|
+
const y = typeof v[1] === "number" ? v[1] : Number(v[1]);
|
|
398
|
+
return Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
|
|
399
|
+
}
|
|
400
|
+
if (typeof v === "object" && v !== null && "value" in v) {
|
|
401
|
+
const obj = v;
|
|
402
|
+
const val = typeof obj.value === "number" ? obj.value : Number(obj.value);
|
|
403
|
+
if (!Number.isFinite(val)) return null;
|
|
404
|
+
const rawName = obj.name;
|
|
405
|
+
return {
|
|
406
|
+
name: typeof rawName === "string" ? rawName : rawName == null ? "" : String(rawName),
|
|
407
|
+
value: val
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
return null;
|
|
411
|
+
}, z.union([
|
|
412
|
+
z.number(),
|
|
413
|
+
z.null(),
|
|
414
|
+
z.tuple([z.number(), z.number()]),
|
|
415
|
+
z.object({
|
|
416
|
+
name: z.string(),
|
|
417
|
+
value: z.number()
|
|
418
|
+
})
|
|
419
|
+
])).catch(null);
|
|
420
|
+
/**
|
|
421
|
+
* Compact, model-friendly representation of an Echarts spec. The
|
|
422
|
+
* planner agent emits this; {@link planToEchartsOption} expands it
|
|
423
|
+
* into a real `EChartsOption` JSON. Two layers because letting the
|
|
424
|
+
* model fill in a fully-typed `EChartsOption` is brittle (hundreds
|
|
425
|
+
* of optional fields, deep unions, version-dependent shapes). A
|
|
426
|
+
* small "chart plan" schema is much more reliable for a fast model
|
|
427
|
+
* and keeps animation / tooltip / styling defaults consistent
|
|
428
|
+
* across charts.
|
|
429
|
+
*/
|
|
430
|
+
const chartPlanSchema = z.object({
|
|
431
|
+
chartType: ChartTypeSchema,
|
|
432
|
+
title: z.string().optional().describe(stringUtils.toDescription(`
|
|
433
|
+
Short title shown above the chart. Optional; defaults to the
|
|
434
|
+
\`title\` argument the caller passed in.
|
|
435
|
+
`)),
|
|
436
|
+
xAxisLabel: z.string().optional().describe(stringUtils.toDescription(`
|
|
437
|
+
Axis label below the chart. Used for bar / line / area / scatter;
|
|
438
|
+
ignored for pie.
|
|
439
|
+
`)),
|
|
440
|
+
yAxisLabel: z.string().optional().describe(stringUtils.toDescription(`
|
|
441
|
+
Axis label to the left of the chart. Used for bar / line / area /
|
|
442
|
+
scatter; ignored for pie.
|
|
443
|
+
`)),
|
|
444
|
+
categories: z.array(z.string()).optional().describe(stringUtils.toDescription(`
|
|
445
|
+
X-axis category labels for \`bar\` / \`line\` / \`area\` charts
|
|
446
|
+
(one per data point in each series). Omit for \`scatter\` (uses
|
|
447
|
+
[x, y] tuples) and \`pie\` (each slice carries its own \`name\`).
|
|
448
|
+
`)),
|
|
449
|
+
series: z.array(z.object({
|
|
450
|
+
name: z.string().describe(stringUtils.toDescription(`
|
|
451
|
+
Legend name for this series.
|
|
452
|
+
`)),
|
|
453
|
+
data: z.array(chartDataPointSchema).describe(stringUtils.toDescription(`
|
|
454
|
+
Data points. For \`bar\` / \`line\` / \`area\`, an array of
|
|
455
|
+
numbers aligned to \`categories\`. For \`scatter\`, an array
|
|
456
|
+
of \`[x, y]\` numeric tuples. For \`pie\`, an array of
|
|
457
|
+
\`{name, value}\` objects.
|
|
458
|
+
`))
|
|
459
|
+
})).min(1).describe(stringUtils.toDescription(`
|
|
460
|
+
One or more series to plot. Pie charts use exactly one series;
|
|
461
|
+
bar/line/area can stack multiple series sharing the same
|
|
462
|
+
\`categories\` axis.
|
|
463
|
+
`))
|
|
464
|
+
});
|
|
465
|
+
/**
|
|
466
|
+
* Canonical planner input shape. Tools that source rows from an
|
|
467
|
+
* inline dataset (`render_data`) use it as their `inputSchema`
|
|
468
|
+
* verbatim; tools that resolve rows from a remote (`prepare_chart`
|
|
469
|
+
* over a Genie statement) `omit({ data })` and `extend` with their
|
|
470
|
+
* own identifier field, so the field-level `.describe()` text
|
|
471
|
+
* stays a single source of truth. Server-only - the UI never
|
|
472
|
+
* sees a planner request, only the resolved {@link Chart}.
|
|
473
|
+
*/
|
|
474
|
+
const chartPlannerRequestSchema = z.object({
|
|
475
|
+
title: z.string().describe(stringUtils.toDescription(`
|
|
476
|
+
Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
|
|
477
|
+
`)),
|
|
478
|
+
description: z.string().optional().describe(stringUtils.toDescription(`
|
|
479
|
+
One-line intent the chart-planner uses when picking a chart type
|
|
480
|
+
and axis encodings (e.g. "compare quarterly revenue across
|
|
481
|
+
regions", "highlight the steep drop after position 5"). Not shown
|
|
482
|
+
to the user.
|
|
483
|
+
`)),
|
|
484
|
+
data: z.array(z.record(z.string(), z.unknown())).nonempty("Data must contain at least one row").readonly().describe(stringUtils.toDescription(`
|
|
485
|
+
Tabular dataset to chart. One object per row, keyed by column
|
|
486
|
+
name. Values may be strings, numbers, booleans, or null. The
|
|
487
|
+
chart-planner decides which columns are categories vs. numeric
|
|
488
|
+
series. Cap at a few hundred rows for legibility; sample /
|
|
489
|
+
aggregate larger datasets first.
|
|
490
|
+
`))
|
|
491
|
+
});
|
|
492
|
+
/**
|
|
493
|
+
* Format {@link ChartTypeSchema}'s variants as a single
|
|
494
|
+
* human-friendly string of `` `<value>` for <description> ``
|
|
495
|
+
* clauses joined by semicolons, drawn from each variant's own
|
|
496
|
+
* `.describe()` so the planner prompt stays in lock-step with
|
|
497
|
+
* the schema by construction.
|
|
498
|
+
*/
|
|
499
|
+
function formatChartTypePicker() {
|
|
500
|
+
return ChartTypeSchema.options.map((opt) => `\`${opt.value}\` for ${opt.description ?? ""}`).join("; ");
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* System prompt for the inner chart-planning agent. Tuned for a
|
|
504
|
+
* fast-tier model (Haiku, GPT-5-mini, Gemini Flash Lite).
|
|
505
|
+
*/
|
|
506
|
+
const CHART_PLANNER_INSTRUCTIONS = stringUtils.toDescription(`
|
|
507
|
+
You design Apache Echarts visualizations. The user gives you a
|
|
508
|
+
tabular dataset (rows of objects) plus a title and an optional
|
|
509
|
+
description of the intent. You produce a small chart plan (chart
|
|
510
|
+
type, axis labels, categories, series) that best conveys the data.
|
|
511
|
+
|
|
512
|
+
Decision guide. Pick the chart type whose data shape matches the
|
|
513
|
+
dataset and the user's intent: ${formatChartTypePicker()}.
|
|
514
|
+
|
|
515
|
+
When in doubt between bar and line, prefer bar for unordered
|
|
516
|
+
categories and line for ordered ones (dates, time buckets, ranks).
|
|
517
|
+
Never pick pie for more than 7 slices.
|
|
518
|
+
|
|
519
|
+
For bar / line / area: pick one column as the category axis (usually
|
|
520
|
+
the only string-valued column) and one or more numeric columns as
|
|
521
|
+
series. Sort categories by the primary series value descending unless
|
|
522
|
+
the data is naturally ordered (dates, ranks).
|
|
523
|
+
|
|
524
|
+
For pie: pick the category column for slice names and one numeric
|
|
525
|
+
column for slice values. Emit a single series.
|
|
526
|
+
|
|
527
|
+
For scatter: pick two numeric columns and emit \`[x, y]\` tuples in a
|
|
528
|
+
single series.
|
|
529
|
+
|
|
530
|
+
Keep series names human-readable (use the column name; title case it
|
|
531
|
+
lightly if needed). Keep titles concise; do not repeat the user's
|
|
532
|
+
title in xAxisLabel / yAxisLabel.
|
|
533
|
+
`);
|
|
534
|
+
/**
|
|
535
|
+
* One planner `Agent` per plugin config. Cached on config object
|
|
536
|
+
* identity so callers can `prepareChart({ config, ... })` from a
|
|
537
|
+
* hot path without paying the Agent-constructor cost every call.
|
|
538
|
+
* `WeakMap` lets retired configs (e.g. test reconfigurations)
|
|
539
|
+
* release their agent without manual eviction.
|
|
540
|
+
*/
|
|
541
|
+
const plannerAgents = /* @__PURE__ */ new WeakMap();
|
|
542
|
+
function getPlannerAgent(config) {
|
|
543
|
+
let agent = plannerAgents.get(config);
|
|
544
|
+
if (!agent) {
|
|
545
|
+
agent = new Agent({
|
|
546
|
+
id: "chart_planner",
|
|
547
|
+
name: "Chart Planner",
|
|
548
|
+
description: "Picks chart type and axis encodings for a dataset.",
|
|
549
|
+
instructions: CHART_PLANNER_INSTRUCTIONS,
|
|
550
|
+
model: ({ requestContext }) => buildModel(config, requestContext, { modelClass: ModelClass.ChatFast })
|
|
551
|
+
});
|
|
552
|
+
plannerAgents.set(config, agent);
|
|
553
|
+
}
|
|
554
|
+
return agent;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Run the planner against `request` and return the resolved
|
|
558
|
+
* Echarts spec. Throws on planner failure - {@link prepareChart}
|
|
559
|
+
* catches and stashes the error in the cache entry.
|
|
560
|
+
*/
|
|
561
|
+
async function runChartPlanner(config, request, options = {}) {
|
|
562
|
+
const { title, description, data } = request;
|
|
563
|
+
const { requestContext, abortSignal } = options;
|
|
564
|
+
const prompt = stringUtils.toDescription({
|
|
565
|
+
Title: title,
|
|
566
|
+
...description ? { Description: description } : {},
|
|
567
|
+
"Dataset (JSON, one row per object)": JSON.stringify(data, null, 2)
|
|
568
|
+
});
|
|
569
|
+
const result = await getPlannerAgent(config).generate(prompt, {
|
|
570
|
+
structuredOutput: { schema: chartPlanSchema },
|
|
571
|
+
...requestContext ? { requestContext } : {},
|
|
572
|
+
...abortSignal ? { abortSignal } : {}
|
|
573
|
+
});
|
|
574
|
+
const plan = chartPlanSchema.parse(result.object);
|
|
575
|
+
const option = planToEchartsOption(plan, title);
|
|
576
|
+
return {
|
|
577
|
+
chartType: plan.chartType,
|
|
578
|
+
option
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
/** Build the canonical cache key for a `chartId`. */
|
|
582
|
+
async function chartCacheKey(chartId) {
|
|
583
|
+
return (await CacheManager.getInstance()).generateKey([CHART_CACHE_NAMESPACE, chartId], CHART_CACHE_USER_KEY);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Persist a {@link Chart} entry under its `chartId`. Refreshes
|
|
587
|
+
* the TTL on every write. Cache-layer failures are logged and
|
|
588
|
+
* swallowed so background runners never throw into the
|
|
589
|
+
* unhandled-rejection stream.
|
|
590
|
+
*/
|
|
591
|
+
async function writeChart(entry) {
|
|
592
|
+
try {
|
|
593
|
+
const key = await chartCacheKey(entry.chartId);
|
|
594
|
+
await CacheManager.getInstanceSync().set(key, entry, { ttl: CHART_CACHE_TTL_SEC });
|
|
595
|
+
} catch (err) {
|
|
596
|
+
log$5.warn("write-error", {
|
|
597
|
+
chartId: entry.chartId,
|
|
598
|
+
error: commonUtils.errorMessage(err)
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Look up a chart by id. Returns `undefined` on miss, on
|
|
604
|
+
* expiry, or when the cache layer is unhealthy - never throws.
|
|
605
|
+
*/
|
|
606
|
+
async function readChart(chartId) {
|
|
607
|
+
try {
|
|
608
|
+
const key = await chartCacheKey(chartId);
|
|
609
|
+
return await CacheManager.getInstanceSync().get(key) ?? void 0;
|
|
610
|
+
} catch (err) {
|
|
611
|
+
log$5.warn("read-error", {
|
|
612
|
+
chartId,
|
|
613
|
+
error: commonUtils.errorMessage(err)
|
|
614
|
+
});
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Mint a `chartId`, cache an empty `{ chartId }` placeholder
|
|
620
|
+
* synchronously, and kick off a background task that resolves the
|
|
621
|
+
* dataset and runs the planner. Returns the `chartId` once the
|
|
622
|
+
* placeholder lands so the first {@link fetchChart} call always
|
|
623
|
+
* sees an entry (no spurious 404 race).
|
|
624
|
+
*
|
|
625
|
+
* The background task swallows its own failures and writes them
|
|
626
|
+
* as `error` entries, so callers never see a rejected promise.
|
|
627
|
+
* Cache state machine:
|
|
628
|
+
*
|
|
629
|
+
* - just after this call returns: `{ chartId }` (processing)
|
|
630
|
+
* - on planner success: `{ chartId, result }`
|
|
631
|
+
* - on data / planner failure: `{ chartId, error }`
|
|
632
|
+
*/
|
|
633
|
+
async function prepareChart(opts) {
|
|
634
|
+
const chartId = commonUtils.id();
|
|
635
|
+
await writeChart({ chartId });
|
|
636
|
+
log$5.debug("queued", { chartId });
|
|
637
|
+
runPrepareChart(chartId, opts);
|
|
638
|
+
return { chartId };
|
|
639
|
+
}
|
|
640
|
+
async function runPrepareChart(chartId, opts) {
|
|
641
|
+
const startedAt = Date.now();
|
|
642
|
+
try {
|
|
643
|
+
const data = await opts.resolveData(opts.signal);
|
|
644
|
+
if (data.rows.length === 0) throw new Error("dataset has no rows; nothing to chart");
|
|
645
|
+
const result = await runChartPlanner(opts.config, {
|
|
646
|
+
title: opts.title ?? "Chart",
|
|
647
|
+
...opts.description ? { description: opts.description } : {},
|
|
648
|
+
data: data.rows
|
|
649
|
+
}, {
|
|
650
|
+
...opts.requestContext ? { requestContext: opts.requestContext } : {},
|
|
651
|
+
...opts.signal ? { abortSignal: opts.signal } : {}
|
|
652
|
+
});
|
|
653
|
+
await writeChart({
|
|
654
|
+
chartId,
|
|
655
|
+
result
|
|
656
|
+
});
|
|
657
|
+
log$5.info("done", {
|
|
658
|
+
chartId,
|
|
659
|
+
chartType: result.chartType,
|
|
660
|
+
elapsedMs: Date.now() - startedAt
|
|
661
|
+
});
|
|
662
|
+
} catch (err) {
|
|
663
|
+
const error = commonUtils.errorMessage(err);
|
|
664
|
+
log$5.warn("error", {
|
|
665
|
+
chartId,
|
|
666
|
+
error
|
|
667
|
+
});
|
|
668
|
+
await writeChart({
|
|
669
|
+
chartId,
|
|
670
|
+
error
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Long-poll the chart cache until the entry settles (`result` or
|
|
676
|
+
* `error` set), the entry is missing, or the server-side timeout
|
|
677
|
+
* elapses.
|
|
678
|
+
*
|
|
679
|
+
* Returns:
|
|
680
|
+
* - the resolved {@link Chart} when it settled, errored, or
|
|
681
|
+
* stayed in processing past `timeoutMs` (so the client can
|
|
682
|
+
* re-poll);
|
|
683
|
+
* - `undefined` when the entry is missing or expired (the
|
|
684
|
+
* consumer should treat as 404).
|
|
685
|
+
*
|
|
686
|
+
* `signal` lets the caller cancel ahead of timeout (e.g. the HTTP
|
|
687
|
+
* request closed). Cancellation propagates to the inter-poll sleep
|
|
688
|
+
* so the helper returns immediately.
|
|
689
|
+
*/
|
|
690
|
+
async function fetchChart(chartId, options = {}) {
|
|
691
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
692
|
+
const intervalMs = options.intervalMs ?? DEFAULT_FETCH_INTERVAL_MS;
|
|
693
|
+
const deadline = Date.now() + timeoutMs;
|
|
694
|
+
let last;
|
|
695
|
+
while (true) {
|
|
696
|
+
options.signal?.throwIfAborted();
|
|
697
|
+
last = await readChart(chartId);
|
|
698
|
+
if (!last) return void 0;
|
|
699
|
+
if (last.result !== void 0 || last.error !== void 0) return last;
|
|
700
|
+
const remaining = deadline - Date.now();
|
|
701
|
+
if (remaining <= 0) return last;
|
|
702
|
+
await commonUtils.sleep(Math.min(intervalMs, remaining), options.signal);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
|
|
707
|
+
* JSON. Centralized here so the planner agent only fills in the
|
|
708
|
+
* compact plan shape; tooltip / animation / color / grid defaults
|
|
709
|
+
* stay consistent across charts and are easy to tune without
|
|
710
|
+
* retraining model behaviour.
|
|
711
|
+
*/
|
|
712
|
+
function planToEchartsOption(plan, fallbackTitle) {
|
|
713
|
+
const baseTitle = plan.title ?? fallbackTitle;
|
|
714
|
+
const grid = {
|
|
715
|
+
left: 48,
|
|
716
|
+
right: 24,
|
|
717
|
+
top: 56,
|
|
718
|
+
bottom: 48,
|
|
719
|
+
containLabel: true
|
|
720
|
+
};
|
|
721
|
+
if (plan.chartType === "pie") {
|
|
722
|
+
const slices = (plan.series[0]?.data ?? []).filter((d) => d !== null && typeof d === "object" && !Array.isArray(d));
|
|
723
|
+
return {
|
|
724
|
+
title: {
|
|
725
|
+
text: baseTitle,
|
|
726
|
+
left: "center"
|
|
727
|
+
},
|
|
728
|
+
tooltip: { trigger: "item" },
|
|
729
|
+
legend: { bottom: 0 },
|
|
730
|
+
series: [{
|
|
731
|
+
name: plan.series[0]?.name ?? baseTitle,
|
|
732
|
+
type: "pie",
|
|
733
|
+
radius: ["35%", "65%"],
|
|
734
|
+
data: slices
|
|
735
|
+
}]
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
if (plan.chartType === "scatter") return {
|
|
739
|
+
title: {
|
|
740
|
+
text: baseTitle,
|
|
741
|
+
left: "center"
|
|
742
|
+
},
|
|
743
|
+
tooltip: { trigger: "item" },
|
|
744
|
+
legend: { bottom: 0 },
|
|
745
|
+
grid,
|
|
746
|
+
xAxis: {
|
|
747
|
+
type: "value",
|
|
748
|
+
name: plan.xAxisLabel
|
|
749
|
+
},
|
|
750
|
+
yAxis: {
|
|
751
|
+
type: "value",
|
|
752
|
+
name: plan.yAxisLabel
|
|
753
|
+
},
|
|
754
|
+
series: plan.series.map((s) => ({
|
|
755
|
+
name: s.name,
|
|
756
|
+
type: "scatter",
|
|
757
|
+
data: s.data.filter((d) => Array.isArray(d) && d.length === 2)
|
|
758
|
+
}))
|
|
759
|
+
};
|
|
760
|
+
const isArea = plan.chartType === "area";
|
|
761
|
+
const seriesType = plan.chartType === "bar" ? "bar" : "line";
|
|
762
|
+
return {
|
|
763
|
+
title: {
|
|
764
|
+
text: baseTitle,
|
|
765
|
+
left: "center"
|
|
766
|
+
},
|
|
767
|
+
tooltip: { trigger: "axis" },
|
|
768
|
+
legend: { bottom: 0 },
|
|
769
|
+
grid,
|
|
770
|
+
xAxis: {
|
|
771
|
+
type: "category",
|
|
772
|
+
data: plan.categories ?? [],
|
|
773
|
+
name: plan.xAxisLabel
|
|
774
|
+
},
|
|
775
|
+
yAxis: {
|
|
776
|
+
type: "value",
|
|
777
|
+
name: plan.yAxisLabel
|
|
778
|
+
},
|
|
779
|
+
series: plan.series.map((s) => ({
|
|
780
|
+
name: s.name,
|
|
781
|
+
type: seriesType,
|
|
782
|
+
data: s.data,
|
|
783
|
+
smooth: seriesType === "line",
|
|
784
|
+
...isArea ? { areaStyle: {} } : {}
|
|
785
|
+
}))
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Build the `render_data` Mastra tool bound to the given plugin
|
|
790
|
+
* config. Auto-wired as a system tool on every agent (see
|
|
791
|
+
* `agents.ts`); per-agent tools can shadow it by registering a
|
|
792
|
+
* same-named entry.
|
|
793
|
+
*
|
|
794
|
+
* Thin wrapper over {@link prepareChart} for callers that already
|
|
795
|
+
* have a dataset in hand. Mints a `chartId` synchronously, caches
|
|
796
|
+
* an empty placeholder, and kicks off the chart-planner in the
|
|
797
|
+
* background. Returns just the `chartId`; the host UI resolves
|
|
798
|
+
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
799
|
+
* `/embed/chart/:id` route.
|
|
800
|
+
*
|
|
801
|
+
* For Genie statement results, prefer the Genie agent's
|
|
802
|
+
* `prepare_chart` tool, which accepts a `statement_id` and
|
|
803
|
+
* resolves the rows lazily.
|
|
804
|
+
*/
|
|
805
|
+
function buildRenderDataTool(config) {
|
|
806
|
+
return createTool$1({
|
|
807
|
+
id: "render_data",
|
|
808
|
+
description: stringUtils.toDescription([
|
|
809
|
+
`
|
|
810
|
+
Submit a tabular dataset for inline rendering as a chart in
|
|
811
|
+
the user's view. Pass a title, the raw rows (array of objects
|
|
812
|
+
keyed by column name), and an optional one-line description
|
|
813
|
+
of the insight to highlight. Returns a short \`chartId\`;
|
|
814
|
+
the chart renders inline at the position you embed the
|
|
815
|
+
matching \`[chart:<chartId>]\` marker.
|
|
816
|
+
`,
|
|
817
|
+
`
|
|
818
|
+
Placement contract: embed \`[chart:<chartId>]\` on its own
|
|
819
|
+
line (blank lines above and below) wherever you want the
|
|
820
|
+
chart to appear in your reply. The chart resolves
|
|
821
|
+
asynchronously - the tool returns the id immediately and the
|
|
822
|
+
host UI fetches the chart from the cache once the planner
|
|
823
|
+
lands. You can call \`render_data\` multiple times in the
|
|
824
|
+
same turn (the tool is parallel-safe) and interleave the
|
|
825
|
+
markers with prose so each chart sits next to its
|
|
826
|
+
commentary.
|
|
827
|
+
`,
|
|
828
|
+
`
|
|
829
|
+
Use whenever a SQL row set, API response, or hand-built
|
|
830
|
+
dataset would land better as a picture than as a list or
|
|
831
|
+
table. Cap input at a few hundred rows; sample or aggregate
|
|
832
|
+
larger datasets first.
|
|
833
|
+
`
|
|
834
|
+
]),
|
|
835
|
+
inputSchema: chartPlannerRequestSchema,
|
|
836
|
+
outputSchema: ChartSchema.pick({ chartId: true }),
|
|
837
|
+
execute: async (input, ctxRaw) => {
|
|
838
|
+
const { title, description, data } = input;
|
|
839
|
+
const ctx = ctxRaw;
|
|
840
|
+
return prepareChart({
|
|
841
|
+
config,
|
|
842
|
+
title,
|
|
843
|
+
...description ? { description } : {},
|
|
844
|
+
resolveData: () => Promise.resolve({ rows: data }),
|
|
845
|
+
...ctx?.requestContext ? { requestContext: ctx.requestContext } : {}
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
//#endregion
|
|
852
|
+
//#region packages/appkit-mastra/src/statement.ts
|
|
853
|
+
/**
|
|
854
|
+
* Hard server-side cap on rows returned by the
|
|
855
|
+
* `/embed/data/:id` route. Sized to keep responses small
|
|
856
|
+
* enough for inline tables to render snappily; the route surfaces
|
|
857
|
+
* a `truncated` flag whenever the upstream `rowCount` exceeds
|
|
858
|
+
* this so end users know they're seeing a sample.
|
|
859
|
+
*/
|
|
860
|
+
const STATEMENT_ROW_CAP = 500;
|
|
861
|
+
/**
|
|
862
|
+
* Best-effort numeric coercion for the Statement Execution API's
|
|
863
|
+
* all-strings cells. Leaves non-numeric strings (and explicit
|
|
864
|
+
* `null`s) intact; everything else flows through `Number`.
|
|
865
|
+
*/
|
|
866
|
+
function coerceCell(cell) {
|
|
867
|
+
if (cell === null) return null;
|
|
868
|
+
if (/^-?\d+(\.\d+)?$/.test(cell)) {
|
|
869
|
+
const n = Number(cell);
|
|
870
|
+
if (Number.isFinite(n)) return n;
|
|
871
|
+
}
|
|
872
|
+
return cell;
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* Fetch a single statement's rows via the Statement Execution API
|
|
876
|
+
* and reshape into the shared {@link GenieDatasetData} shape
|
|
877
|
+
* (column array + row records).
|
|
878
|
+
*
|
|
879
|
+
* Optional `limit` slices the returned `rows` client-side so the
|
|
880
|
+
* agent can scan a small sample without paging the full result
|
|
881
|
+
* set into context. `rowCount` always reflects the upstream total
|
|
882
|
+
* so callers know when the slice truncated.
|
|
883
|
+
*
|
|
884
|
+
* Exported because every consumer in the plugin (the
|
|
885
|
+
* `get_statement` tool, the `prepare_chart` dataset resolver, and
|
|
886
|
+
* the `/embed/data/:id` route) needs the exact same
|
|
887
|
+
* fetch + coercion pipeline so LLM-side `get_statement` output
|
|
888
|
+
* and UI-side `[data:<id>]` rendering stay shape-identical for
|
|
889
|
+
* the same `statement_id`.
|
|
890
|
+
*/
|
|
891
|
+
async function fetchStatementData(client, statementId, options) {
|
|
892
|
+
const ctx = options?.signal ? apiUtils.toContext(options.signal) : void 0;
|
|
893
|
+
const r = await client.statementExecution.getStatement({ statement_id: statementId }, ctx);
|
|
894
|
+
const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
|
|
895
|
+
const dataArray = r.result?.data_array ?? [];
|
|
896
|
+
return {
|
|
897
|
+
columns,
|
|
898
|
+
rows: (options?.limit !== void 0 && options.limit >= 0 ? dataArray.slice(0, options.limit) : dataArray).map((row) => {
|
|
899
|
+
const obj = {};
|
|
900
|
+
columns.forEach((col, i) => {
|
|
901
|
+
obj[col] = coerceCell(row[i] ?? null);
|
|
902
|
+
});
|
|
903
|
+
return obj;
|
|
904
|
+
}),
|
|
905
|
+
rowCount: r.manifest?.total_row_count ?? dataArray.length
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
//#endregion
|
|
910
|
+
//#region packages/appkit-mastra/src/writer.ts
|
|
911
|
+
/**
|
|
912
|
+
* Best-effort `writer.write`. No-op when `writer` is undefined;
|
|
913
|
+
* caught errors are logged via `log.warn("writer:error", ...)`
|
|
914
|
+
* along with any caller-supplied `context` fields (e.g. a
|
|
915
|
+
* `chartId` or `messageId`) so the warning is greppable per
|
|
916
|
+
* resource.
|
|
917
|
+
*
|
|
918
|
+
* Returns when the write resolves or rejects; never throws.
|
|
919
|
+
*/
|
|
920
|
+
async function safeWrite(log, writer, chunk, context = {}) {
|
|
921
|
+
if (!writer) {
|
|
922
|
+
log.debug("writer:no-writer", context);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
await writer.write(chunk);
|
|
927
|
+
log.debug("writer:ok", context);
|
|
928
|
+
} catch (err) {
|
|
929
|
+
log.warn("writer:error", {
|
|
930
|
+
...context,
|
|
931
|
+
error: commonUtils.errorMessage(err)
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region packages/appkit-mastra/src/genie.ts
|
|
938
|
+
/**
|
|
939
|
+
* Genie tools for Mastra.
|
|
940
|
+
*
|
|
941
|
+
* Surfaces each configured Genie space as a small set of flat Mastra
|
|
942
|
+
* tools the calling agent drives directly - no inner orchestrator
|
|
943
|
+
* agent. The central agent decomposes user questions, picks which
|
|
944
|
+
* space to ask, streams the per-turn wire events (status, thinking,
|
|
945
|
+
* sql, rows) through `ctx.writer`, and composes the final reply.
|
|
946
|
+
* Rows are never fetched eagerly: the agent reads a statement's
|
|
947
|
+
* values only when it needs to reason about them, otherwise it embeds
|
|
948
|
+
* a `[data:<statement_id>]` marker in prose and lets the host UI
|
|
949
|
+
* resolve the data. Charts are minted asynchronously and referenced
|
|
950
|
+
* by `[chart:<chartId>]` markers so prose isn't blocked on chart
|
|
951
|
+
* generation; the host UI fetches the cached spec by id once ready.
|
|
952
|
+
* Space description and serialized-space lookups are available for
|
|
953
|
+
* grounding when the agent needs schema context.
|
|
954
|
+
*
|
|
955
|
+
* Each tool's `execute` pulls the per-request
|
|
956
|
+
* {@link WorkspaceClient} off `ctx.requestContext` (stamped by
|
|
957
|
+
* `MastraServer` under {@link MASTRA_USER_KEY}) and the per-call
|
|
958
|
+
* `writer` / `abortSignal` off `ctx`, so the tools are stateless
|
|
959
|
+
* across requests and the central agent owns the loop.
|
|
960
|
+
*
|
|
961
|
+
* The tools talk to Genie directly via `@dbx-tools/genie`
|
|
962
|
+
* (`genieEventChat`); statement-row fetching is delegated to
|
|
963
|
+
* {@link fetchStatementData} from `./statement.js`, which wraps
|
|
964
|
+
* the workspace `statementExecution.getStatement` API. AppKit's
|
|
965
|
+
* stock `genie` plugin is honored only for its `spaces` config
|
|
966
|
+
* so existing AppKit-style wiring keeps working without change.
|
|
967
|
+
*
|
|
968
|
+
* Suggested orchestration prompt for the central agent lives in
|
|
969
|
+
* {@link GENIE_INSTRUCTIONS}; compose it into the agent's own
|
|
970
|
+
* `instructions` when you want the canonical "how to drive the
|
|
971
|
+
* Genie tools" guidance.
|
|
972
|
+
*/
|
|
973
|
+
const log$4 = logUtils.logger("mastra/genie");
|
|
974
|
+
/** Default alias used when a single unnamed Genie space is wired up. */
|
|
975
|
+
const DEFAULT_GENIE_ALIAS = "default";
|
|
976
|
+
/**
|
|
977
|
+
* Pull the per-request {@link WorkspaceClient} off the active
|
|
978
|
+
* `RequestContext`. The Mastra plugin's server middleware stamps
|
|
979
|
+
* a {@link User} on the context under {@link MASTRA_USER_KEY}
|
|
980
|
+
* for every request; tools fail loudly when it's missing because
|
|
981
|
+
* that means the Mastra plugin isn't running (e.g. a tool was
|
|
982
|
+
* invoked outside the chat route).
|
|
983
|
+
*/
|
|
984
|
+
function requireClient(ctx, toolId) {
|
|
985
|
+
const requestContext = ctx?.requestContext;
|
|
986
|
+
if (!requestContext) throw new Error(`${toolId}: missing requestContext (MastraServer must stamp MASTRA_USER_KEY)`);
|
|
987
|
+
const user = requestContext.get(MASTRA_USER_KEY);
|
|
988
|
+
if (!user) throw new Error(`${toolId}: no user on requestContext (MASTRA_USER_KEY not set)`);
|
|
989
|
+
return {
|
|
990
|
+
client: user.executionContext.client,
|
|
991
|
+
requestContext
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Lowercased placeholder strings we reject at the `ask_genie`
|
|
996
|
+
* boundary so the LLM doesn't spend a Genie round-trip on a
|
|
997
|
+
* non-question. Genie politely answers any of these with "Your
|
|
998
|
+
* request '...' does not relate to..." which is pure UI noise.
|
|
999
|
+
* Kept narrow on purpose - real questions sometimes start with
|
|
1000
|
+
* one of these tokens, so we only match the FULL trimmed string.
|
|
1001
|
+
*/
|
|
1002
|
+
const PLACEHOLDER_QUESTIONS = new Set([
|
|
1003
|
+
"noop",
|
|
1004
|
+
"no-op",
|
|
1005
|
+
"skip",
|
|
1006
|
+
"none",
|
|
1007
|
+
"n/a",
|
|
1008
|
+
"na",
|
|
1009
|
+
"null",
|
|
1010
|
+
"undefined",
|
|
1011
|
+
"test",
|
|
1012
|
+
"placeholder"
|
|
1013
|
+
]);
|
|
1014
|
+
/**
|
|
1015
|
+
* Estimated Genie conversation lifetime in seconds. Databricks
|
|
1016
|
+
* publishes no official TTL on the conversation resource itself;
|
|
1017
|
+
* community projects (e.g. the open-source Databricks Genie Bot)
|
|
1018
|
+
* converge on 4 hours of inactivity as a safe operating window.
|
|
1019
|
+
* Treat this as an estimate that gets *extended on every use* by
|
|
1020
|
+
* re-setting the cache entry after each successful turn (sliding
|
|
1021
|
+
* TTL via re-`set`). When the estimate ends up wrong (conversation
|
|
1022
|
+
* deleted, expired upstream, cross-space referenced), `ask_genie`
|
|
1023
|
+
* catches the SDK's `RESOURCE_DOES_NOT_EXIST`/404 and transparently
|
|
1024
|
+
* starts a fresh conversation.
|
|
1025
|
+
*/
|
|
1026
|
+
const CONVERSATION_TTL_SEC = 14400;
|
|
1027
|
+
/** Cache namespace prefix so coexisting Mastra caches don't collide. */
|
|
1028
|
+
const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
|
|
1029
|
+
/**
|
|
1030
|
+
* `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
|
|
1031
|
+
* conversations are scoped to a single user + space + thread, and
|
|
1032
|
+
* `threadId` is already user-scoped (Mastra mints threads per
|
|
1033
|
+
* `resourceId`), so a constant user key here is safe and keeps the
|
|
1034
|
+
* cache key short.
|
|
1035
|
+
*/
|
|
1036
|
+
const CONVERSATION_USER_KEY = "mastra-genie";
|
|
1037
|
+
/**
|
|
1038
|
+
* Build the per-request {@link RequestContext} key the active
|
|
1039
|
+
* Genie `conversation_id` lives under for `spaceId`. Scoped by
|
|
1040
|
+
* space so an app calling two Genie spaces in one request keeps
|
|
1041
|
+
* each conversation distinct (Genie conversation ids are
|
|
1042
|
+
* space-scoped on the wire). The same `RequestContext` instance
|
|
1043
|
+
* flows from the central agent through to every `ask_genie`
|
|
1044
|
+
* invocation, so writes on one call are visible on the next
|
|
1045
|
+
* without an explicit shared ref.
|
|
1046
|
+
*/
|
|
1047
|
+
const conversationContextKey = (spaceId) => `mastra__genie_conversation__${spaceId}`;
|
|
1048
|
+
/**
|
|
1049
|
+
* Read the active Genie `conversation_id` for `spaceId` off the
|
|
1050
|
+
* per-request {@link RequestContext}. Returns `undefined` when no
|
|
1051
|
+
* conversation has been started yet this request.
|
|
1052
|
+
*/
|
|
1053
|
+
function readContextConversationId(requestContext, spaceId) {
|
|
1054
|
+
return requestContext.get(conversationContextKey(spaceId));
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Write the active Genie `conversation_id` for `spaceId` onto the
|
|
1058
|
+
* per-request {@link RequestContext}. Subsequent `ask_genie` calls
|
|
1059
|
+
* in this request will reuse it.
|
|
1060
|
+
*/
|
|
1061
|
+
function writeContextConversationId(requestContext, spaceId, conversationId) {
|
|
1062
|
+
requestContext.set(conversationContextKey(spaceId), conversationId);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Build the canonical cache key for a `(spaceId, threadId)` pair.
|
|
1066
|
+
* Returns `undefined` when `threadId` is missing - callers should
|
|
1067
|
+
* skip caching entirely in that case (no Mastra memory wired up).
|
|
1068
|
+
*/
|
|
1069
|
+
async function conversationCacheKey(spaceId, threadId) {
|
|
1070
|
+
if (!threadId) return void 0;
|
|
1071
|
+
return (await CacheManager.getInstance()).generateKey([
|
|
1072
|
+
CONVERSATION_CACHE_NAMESPACE,
|
|
1073
|
+
spaceId,
|
|
1074
|
+
threadId
|
|
1075
|
+
], CONVERSATION_USER_KEY);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Read the cached Genie conversation id for `(spaceId, threadId)`.
|
|
1079
|
+
* Returns `undefined` on miss, on expiry, or when the cache layer
|
|
1080
|
+
* is unhealthy - never throws. The TTL is renewed via re-`set`
|
|
1081
|
+
* after each successful turn (see {@link saveCachedConversationId}).
|
|
1082
|
+
*/
|
|
1083
|
+
async function readCachedConversationId(cacheKey) {
|
|
1084
|
+
if (!cacheKey) return void 0;
|
|
1085
|
+
try {
|
|
1086
|
+
return await CacheManager.getInstanceSync().get(cacheKey) ?? void 0;
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
log$4.warn("conversation-cache:read-error", { error: commonUtils.errorMessage(err) });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Persist the active conversation id under `cacheKey`, refreshing
|
|
1094
|
+
* its TTL. Idempotent; no-op when `cacheKey` or `conversationId`
|
|
1095
|
+
* is missing. Re-setting the same key acts as a sliding TTL: every
|
|
1096
|
+
* turn that uses the conversation extends the window by another
|
|
1097
|
+
* {@link CONVERSATION_TTL_SEC} seconds.
|
|
1098
|
+
*/
|
|
1099
|
+
async function saveCachedConversationId(cacheKey, conversationId) {
|
|
1100
|
+
if (!cacheKey || !conversationId) return;
|
|
1101
|
+
try {
|
|
1102
|
+
await CacheManager.getInstanceSync().set(cacheKey, conversationId, { ttl: CONVERSATION_TTL_SEC });
|
|
1103
|
+
} catch (err) {
|
|
1104
|
+
log$4.warn("conversation-cache:write-error", { error: commonUtils.errorMessage(err) });
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/** Force-evict a cached conversation id. Used on the stale-id recovery path. */
|
|
1108
|
+
async function evictCachedConversationId(cacheKey) {
|
|
1109
|
+
if (!cacheKey) return;
|
|
1110
|
+
try {
|
|
1111
|
+
await CacheManager.getInstanceSync().delete(cacheKey);
|
|
1112
|
+
} catch (err) {
|
|
1113
|
+
log$4.warn("conversation-cache:delete-error", { error: commonUtils.errorMessage(err) });
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Lazy-seed the active Genie `conversation_id` for `spaceId` from
|
|
1118
|
+
* the cross-request cache onto the per-request `RequestContext`.
|
|
1119
|
+
* No-op when the slot is already populated (subsequent
|
|
1120
|
+
* `ask_genie` calls in the same turn) so we hit the cache at most
|
|
1121
|
+
* once per request per space.
|
|
1122
|
+
*/
|
|
1123
|
+
async function ensureConversationSeeded(requestContext, spaceId, cacheKey) {
|
|
1124
|
+
if (readContextConversationId(requestContext, spaceId)) return;
|
|
1125
|
+
const cached = await readCachedConversationId(cacheKey);
|
|
1126
|
+
if (cached) writeContextConversationId(requestContext, spaceId, cached);
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Agent-facing `prepare_chart` input schema. Reuses
|
|
1130
|
+
* {@link chartPlannerRequestSchema} (the dataset-driven planner
|
|
1131
|
+
* contract) but swaps the inline `data` field for a Genie
|
|
1132
|
+
* `statement_id` the tool resolves into rows server-side.
|
|
1133
|
+
* `title` is loosened to optional - the planner falls back to a
|
|
1134
|
+
* generic placeholder when the agent doesn't supply one.
|
|
1135
|
+
*
|
|
1136
|
+
* Shaped to match Genie's wire form - `statement_id` (snake)
|
|
1137
|
+
* mirrors `query_result.statement_id` and the `get_statement`
|
|
1138
|
+
* tool's input field name, so the LLM only ever sees one
|
|
1139
|
+
* spelling for the same identifier.
|
|
1140
|
+
*/
|
|
1141
|
+
const prepareChartRequestSchema = chartPlannerRequestSchema.omit({
|
|
1142
|
+
data: true,
|
|
1143
|
+
title: true
|
|
1144
|
+
}).extend({
|
|
1145
|
+
statement_id: z.string().min(1, "statement_id is required").describe(stringUtils.toDescription(`
|
|
1146
|
+
Genie \`statement_id\` to chart. Read from
|
|
1147
|
+
\`message.query_result.statement_id\` or
|
|
1148
|
+
\`message.attachments[*].query.statement_id\` returned by
|
|
1149
|
+
\`ask_genie\`.
|
|
1150
|
+
`)),
|
|
1151
|
+
title: chartPlannerRequestSchema.shape.title.optional()
|
|
1152
|
+
});
|
|
1153
|
+
/**
|
|
1154
|
+
* Suffix appended to per-space tool ids when the alias isn't the
|
|
1155
|
+
* well-known `default`. Single-space deployments get the bare
|
|
1156
|
+
* names (`ask_genie`, `get_space_description`, ...); multi-space
|
|
1157
|
+
* deployments get `ask_genie_<alias>` etc. so each space's tools
|
|
1158
|
+
* stay disambiguated in the central agent's tool registry.
|
|
1159
|
+
*/
|
|
1160
|
+
function aliasSuffix(alias) {
|
|
1161
|
+
if (alias === DEFAULT_GENIE_ALIAS) return "";
|
|
1162
|
+
const slug = stringUtils.toIdentifier(alias);
|
|
1163
|
+
return slug ? `_${slug}` : "";
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Drop `suggested_questions` attachments from a {@link GenieMessage}
|
|
1167
|
+
* before handing it to the central LLM. Those entries already
|
|
1168
|
+
* surface in the UI as one-tap pills via the writer's
|
|
1169
|
+
* `suggested_questions` events (see `collectSuggestions` on the
|
|
1170
|
+
* client); if we let them ride back in the tool result the LLM
|
|
1171
|
+
* tends to quote them inline in its prose, double-showing the
|
|
1172
|
+
* same questions and stepping on the dedicated suggestion UI.
|
|
1173
|
+
* Query / text / row attachments are preserved so the model can
|
|
1174
|
+
* still read `statement_id`, SQL, and the answer text.
|
|
1175
|
+
*/
|
|
1176
|
+
function stripSuggestedQuestions(message) {
|
|
1177
|
+
const attachments = message.attachments;
|
|
1178
|
+
if (!attachments || attachments.length === 0) return message;
|
|
1179
|
+
const filtered = attachments.filter((att) => !att.suggested_questions);
|
|
1180
|
+
if (filtered.length === attachments.length) return message;
|
|
1181
|
+
return {
|
|
1182
|
+
...message,
|
|
1183
|
+
attachments: filtered
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
function buildAskGenieTool(opts) {
|
|
1187
|
+
const { spaceId, alias, hint } = opts;
|
|
1188
|
+
const toolId = `ask_genie${aliasSuffix(alias)}`;
|
|
1189
|
+
const hintLine = hint ? ` (${hint})` : "";
|
|
1190
|
+
return createTool$1({
|
|
1191
|
+
id: toolId,
|
|
1192
|
+
description: stringUtils.toDescription(`
|
|
1193
|
+
Ask the Genie space "${alias}"${hintLine} ONE focused
|
|
1194
|
+
natural-language sub-question and wait for the turn to
|
|
1195
|
+
complete. Genie answers best when each call covers a
|
|
1196
|
+
single metric / dimension / time window, so expect to
|
|
1197
|
+
call this tool MULTIPLE TIMES per user turn (typically
|
|
1198
|
+
two to six) and let the results from earlier calls inform
|
|
1199
|
+
later ones - that's the normal pattern, not the exception.
|
|
1200
|
+
Do NOT try to cram a multi-part question into a single
|
|
1201
|
+
call; decompose first, then ask each piece.
|
|
1202
|
+
|
|
1203
|
+
Returns the final \`GenieMessage\`. Rows are NOT included -
|
|
1204
|
+
the Genie wire response carries the \`statement_id\` for any
|
|
1205
|
+
SQL that ran (at \`message.query_result.statement_id\` or the
|
|
1206
|
+
first attachment's \`query.statement_id\`); call
|
|
1207
|
+
\`get_statement\` with that id only when you need to read
|
|
1208
|
+
the underlying values to reason about them. If you just
|
|
1209
|
+
want to display the rows to the user, embed a
|
|
1210
|
+
\`[data:<statement_id>]\` marker in your prose instead -
|
|
1211
|
+
the host UI fetches and renders the rows on its own. Wire
|
|
1212
|
+
events (status, thinking, sql) stream to the user
|
|
1213
|
+
automatically while the call is in flight.
|
|
1214
|
+
`),
|
|
1215
|
+
inputSchema: z.object({ question: z.string().min(1, "question is required") }),
|
|
1216
|
+
outputSchema: z.object({ message: GenieMessageSchema }),
|
|
1217
|
+
execute: async ({ question }, ctxRaw) => {
|
|
1218
|
+
const ctx = ctxRaw;
|
|
1219
|
+
const { client, requestContext } = requireClient(ctx, toolId);
|
|
1220
|
+
const writer = ctx?.writer;
|
|
1221
|
+
const signal = ctx?.abortSignal;
|
|
1222
|
+
const threadId = requestContext.get(MASTRA_THREAD_ID_KEY);
|
|
1223
|
+
const trimmed = question.trim();
|
|
1224
|
+
if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) throw new Error(`${toolId}: refusing placeholder question "${question}" - call ${toolId} only with a real natural-language question, or skip the call entirely`);
|
|
1225
|
+
const cacheKey = await conversationCacheKey(spaceId, threadId);
|
|
1226
|
+
await ensureConversationSeeded(requestContext, spaceId, cacheKey);
|
|
1227
|
+
await safeWrite(log$4, writer, {
|
|
1228
|
+
type: "started",
|
|
1229
|
+
spaceId,
|
|
1230
|
+
content: question
|
|
1231
|
+
});
|
|
1232
|
+
const runTurn = async () => {
|
|
1233
|
+
const seedConversationId = readContextConversationId(requestContext, spaceId);
|
|
1234
|
+
let finalMessage;
|
|
1235
|
+
for await (const event of genieEventChat(spaceId, question, {
|
|
1236
|
+
workspaceClient: client,
|
|
1237
|
+
...seedConversationId ? { conversationId: seedConversationId } : {},
|
|
1238
|
+
...signal ? { context: signal } : {}
|
|
1239
|
+
})) {
|
|
1240
|
+
if (event.type !== "message") await safeWrite(log$4, writer, event);
|
|
1241
|
+
const eventConversationId = event.conversation_id;
|
|
1242
|
+
if (eventConversationId) writeContextConversationId(requestContext, spaceId, eventConversationId);
|
|
1243
|
+
if (event.type === "result") finalMessage = event.message;
|
|
1244
|
+
}
|
|
1245
|
+
if (!finalMessage) throw new Error("Genie turn ended without a result event");
|
|
1246
|
+
return finalMessage;
|
|
1247
|
+
};
|
|
1248
|
+
let finalMessage;
|
|
1249
|
+
try {
|
|
1250
|
+
finalMessage = await runTurn();
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
const seeded = readContextConversationId(requestContext, spaceId);
|
|
1253
|
+
if (seeded && apiUtils.isNotFoundError(err)) {
|
|
1254
|
+
log$4.warn("conversation-cache:stale, resetting", {
|
|
1255
|
+
spaceId,
|
|
1256
|
+
conversationId: seeded,
|
|
1257
|
+
error: commonUtils.errorMessage(err)
|
|
1258
|
+
});
|
|
1259
|
+
await evictCachedConversationId(cacheKey);
|
|
1260
|
+
writeContextConversationId(requestContext, spaceId, void 0);
|
|
1261
|
+
finalMessage = await runTurn();
|
|
1262
|
+
} else throw err;
|
|
1263
|
+
}
|
|
1264
|
+
await saveCachedConversationId(cacheKey, readContextConversationId(requestContext, spaceId));
|
|
1265
|
+
return { message: stripSuggestedQuestions(finalMessage) };
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
function buildSpaceDescriptionTool(opts) {
|
|
1270
|
+
const { spaceId, alias } = opts;
|
|
1271
|
+
const toolId = `get_space_description${aliasSuffix(alias)}`;
|
|
1272
|
+
return createTool$1({
|
|
1273
|
+
id: toolId,
|
|
1274
|
+
description: stringUtils.toDescription(`
|
|
1275
|
+
Return the Genie space "${alias}"'s title, description, and
|
|
1276
|
+
warehouse id. Cheap (single REST call, no LLM round-trip).
|
|
1277
|
+
Call this FIRST on any user turn that's going to touch
|
|
1278
|
+
\`ask_genie\`, unless the same description already landed
|
|
1279
|
+
earlier in this conversation - the title + description tell
|
|
1280
|
+
you what tables, metrics, and time windows the space
|
|
1281
|
+
actually covers, which is what lets you decompose the
|
|
1282
|
+
user's question into the right \`ask_genie\` sub-questions.
|
|
1283
|
+
`),
|
|
1284
|
+
inputSchema: z.object({}),
|
|
1285
|
+
outputSchema: z.object({
|
|
1286
|
+
spaceId: z.string(),
|
|
1287
|
+
title: z.string().optional(),
|
|
1288
|
+
description: z.string().optional(),
|
|
1289
|
+
warehouseId: z.string().optional()
|
|
1290
|
+
}),
|
|
1291
|
+
execute: async (_input, ctxRaw) => {
|
|
1292
|
+
const ctx = ctxRaw;
|
|
1293
|
+
const { client } = requireClient(ctx, toolId);
|
|
1294
|
+
const signal = ctx?.abortSignal;
|
|
1295
|
+
const space = await getGenieSpace(spaceId, {
|
|
1296
|
+
workspaceClient: client,
|
|
1297
|
+
serialized: false,
|
|
1298
|
+
...signal ? { context: signal } : {}
|
|
1299
|
+
});
|
|
1300
|
+
return {
|
|
1301
|
+
spaceId,
|
|
1302
|
+
...space.title ? { title: space.title } : {},
|
|
1303
|
+
...space.description ? { description: space.description } : {},
|
|
1304
|
+
...space.warehouse_id ? { warehouseId: space.warehouse_id } : {}
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
function buildSpaceSerializedTool(opts) {
|
|
1310
|
+
const { spaceId, alias } = opts;
|
|
1311
|
+
const toolId = `get_space_serialized${aliasSuffix(alias)}`;
|
|
1312
|
+
return createTool$1({
|
|
1313
|
+
id: toolId,
|
|
1314
|
+
description: stringUtils.toDescription(`
|
|
1315
|
+
Return the full \`GenieSpace\` JSON for the "${alias}" space.
|
|
1316
|
+
Use only when you need exact column / table identifiers
|
|
1317
|
+
\`get_space_description\` doesn't expose. Larger payload, so
|
|
1318
|
+
prefer the description tool when it's enough.
|
|
1319
|
+
`),
|
|
1320
|
+
inputSchema: z.object({}),
|
|
1321
|
+
outputSchema: z.object({ space: z.unknown() }),
|
|
1322
|
+
execute: async (_input, ctxRaw) => {
|
|
1323
|
+
const ctx = ctxRaw;
|
|
1324
|
+
const { client } = requireClient(ctx, toolId);
|
|
1325
|
+
const signal = ctx?.abortSignal;
|
|
1326
|
+
return { space: await getGenieSpace(spaceId, {
|
|
1327
|
+
workspaceClient: client,
|
|
1328
|
+
...signal ? { context: signal } : {}
|
|
1329
|
+
}) };
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Default row cap for {@link buildGetStatementTool} when the agent
|
|
1335
|
+
* doesn't supply a `limit`. Sized to keep result sets out of the
|
|
1336
|
+
* model context unless the agent explicitly opts into more rows -
|
|
1337
|
+
* the cheap shape (column names + a handful of representative
|
|
1338
|
+
* rows) is usually enough to reason about a query.
|
|
1339
|
+
*/
|
|
1340
|
+
const DEFAULT_STATEMENT_LIMIT = 50;
|
|
1341
|
+
function buildGetStatementTool() {
|
|
1342
|
+
const toolId = "get_statement";
|
|
1343
|
+
return createTool$1({
|
|
1344
|
+
id: toolId,
|
|
1345
|
+
description: stringUtils.toDescription(`
|
|
1346
|
+
Fetch the rows of a Genie statement by its \`statement_id\` (the
|
|
1347
|
+
value at \`message.query_result.statement_id\` or
|
|
1348
|
+
\`message.attachments[*].query.statement_id\` returned from
|
|
1349
|
+
\`ask_genie\`). Use this ONLY when you need to read the underlying
|
|
1350
|
+
values to reason about them in your reply - e.g. naming the
|
|
1351
|
+
largest row, computing a delta the visualization wouldn't already
|
|
1352
|
+
convey, or filtering down to a specific record. If you'd just be
|
|
1353
|
+
reciting numbers the user will see anyway, skip the call and
|
|
1354
|
+
embed a \`[data:<statement_id>]\` marker in your prose instead;
|
|
1355
|
+
the host UI fetches and renders the rows on its own. \`limit\`
|
|
1356
|
+
caps the number of rows returned (defaults to
|
|
1357
|
+
${DEFAULT_STATEMENT_LIMIT}). \`rowCount\` reflects the full
|
|
1358
|
+
upstream total - compare to \`rows.length\` to detect truncation.
|
|
1359
|
+
`),
|
|
1360
|
+
inputSchema: z.object({
|
|
1361
|
+
statement_id: z.string().min(1, "statement_id is required"),
|
|
1362
|
+
limit: z.number().int().nonnegative().optional().describe("Max rows to return. Defaults to a small sample; raise only when more rows are genuinely needed.")
|
|
1363
|
+
}),
|
|
1364
|
+
outputSchema: z.object({
|
|
1365
|
+
columns: z.array(z.string()),
|
|
1366
|
+
rows: z.array(z.record(z.string(), z.unknown())),
|
|
1367
|
+
rowCount: z.number(),
|
|
1368
|
+
truncated: z.boolean()
|
|
1369
|
+
}),
|
|
1370
|
+
execute: async ({ statement_id, limit }, ctxRaw) => {
|
|
1371
|
+
const ctx = ctxRaw;
|
|
1372
|
+
const { client } = requireClient(ctx, toolId);
|
|
1373
|
+
const signal = ctx?.abortSignal;
|
|
1374
|
+
const data = await fetchStatementData(client, statement_id, {
|
|
1375
|
+
limit: limit ?? DEFAULT_STATEMENT_LIMIT,
|
|
1376
|
+
...signal ? { signal } : {}
|
|
1377
|
+
});
|
|
1378
|
+
return {
|
|
1379
|
+
columns: data.columns,
|
|
1380
|
+
rows: data.rows,
|
|
1381
|
+
rowCount: data.rowCount,
|
|
1382
|
+
truncated: data.rows.length < data.rowCount
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* `prepare_chart` Mastra tool. Thin wrapper over
|
|
1389
|
+
* {@link prepareChart} that resolves the dataset by fetching the
|
|
1390
|
+
* Genie statement's rows on demand. The tool mints a `chartId`
|
|
1391
|
+
* synchronously, caches an empty placeholder, and kicks off the
|
|
1392
|
+
* planner in the background so the agent loop never blocks. The
|
|
1393
|
+
* host UI resolves `[chart:<chartId>]` markers by reading the
|
|
1394
|
+
* cached {@link Chart} entry (1h TTL).
|
|
1395
|
+
*
|
|
1396
|
+
* Space-agnostic: a Genie `statement_id` is workspace-scoped, so
|
|
1397
|
+
* one shared `prepare_chart` tool covers every wired Genie space.
|
|
1398
|
+
*
|
|
1399
|
+
* Cancellation: deliberately does NOT forward the per-call
|
|
1400
|
+
* `abortSignal` to {@link prepareChart}. The planner task is
|
|
1401
|
+
* fire-and-forget background work; the tool's own `execute`
|
|
1402
|
+
* resolves the moment the `chartId` is minted, so the per-call
|
|
1403
|
+
* signal aborts the second the tool returns. The 1h cache TTL
|
|
1404
|
+
* caps abandoned entries.
|
|
1405
|
+
*/
|
|
1406
|
+
function buildPrepareChartTool(opts) {
|
|
1407
|
+
const { config } = opts;
|
|
1408
|
+
const toolId = "prepare_chart";
|
|
1409
|
+
return createTool$1({
|
|
1410
|
+
id: toolId,
|
|
1411
|
+
description: stringUtils.toDescription([
|
|
1412
|
+
`
|
|
1413
|
+
Queue a chart for the rows of a Genie statement. Mints a
|
|
1414
|
+
short \`chartId\` synchronously and kicks off a BACKGROUND
|
|
1415
|
+
task that fetches the statement's rows, runs the
|
|
1416
|
+
chart-planner to pick a chart type and Echarts spec, and
|
|
1417
|
+
caches the result under the \`chartId\` for one hour. The
|
|
1418
|
+
host UI fetches the cached chart on its own once it lands.
|
|
1419
|
+
`,
|
|
1420
|
+
`
|
|
1421
|
+
To display the chart in your reply, embed
|
|
1422
|
+
\`[chart:<chartId>]\` on its own line at the position you
|
|
1423
|
+
want it to appear, using the EXACT \`chartId\` string this
|
|
1424
|
+
call returned. Never construct a chart id yourself (it is
|
|
1425
|
+
not the \`statement_id\` or any variation of it) - only a
|
|
1426
|
+
value returned by this tool resolves to a real chart. The
|
|
1427
|
+
tool returns immediately - do NOT wait or call it again to
|
|
1428
|
+
"check progress"; the chart resolves asynchronously on the
|
|
1429
|
+
host UI's side.
|
|
1430
|
+
`,
|
|
1431
|
+
`
|
|
1432
|
+
Use this only when the data has a story a chart conveys
|
|
1433
|
+
better than a table (trends, rankings, distributions,
|
|
1434
|
+
parts-of-a-whole). For raw rows, embed
|
|
1435
|
+
\`[data:<statement_id>]\` instead and skip this tool.
|
|
1436
|
+
`
|
|
1437
|
+
]),
|
|
1438
|
+
inputSchema: prepareChartRequestSchema,
|
|
1439
|
+
outputSchema: ChartSchema.pick({ chartId: true }),
|
|
1440
|
+
execute: async (request, ctxRaw) => {
|
|
1441
|
+
const ctx = ctxRaw;
|
|
1442
|
+
const { client } = requireClient(ctx, toolId);
|
|
1443
|
+
return prepareChart({
|
|
1444
|
+
config,
|
|
1445
|
+
...request.title ? { title: request.title } : {},
|
|
1446
|
+
...request.description ? { description: request.description } : {},
|
|
1447
|
+
resolveData: (taskSignal) => fetchStatementData(client, request.statement_id, { ...taskSignal ? { signal: taskSignal } : {} }),
|
|
1448
|
+
...ctx?.requestContext ? { requestContext: ctx.requestContext } : {}
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
/**
|
|
1454
|
+
* Suggested orchestration prompt for the central agent that owns
|
|
1455
|
+
* the Genie tools. Compose into your agent's `instructions` to
|
|
1456
|
+
* get the canonical "decompose questions, ask Genie focused
|
|
1457
|
+
* sub-questions, place data / chart markers in prose" behavior:
|
|
1458
|
+
*
|
|
1459
|
+
* ```ts
|
|
1460
|
+
* createAgent({
|
|
1461
|
+
* instructions: `${myAgentInstructions}\n\n${GENIE_INSTRUCTIONS}`,
|
|
1462
|
+
* tools(plugins) {
|
|
1463
|
+
* return { ...plugins.genie?.toolkit() };
|
|
1464
|
+
* },
|
|
1465
|
+
* });
|
|
1466
|
+
* ```
|
|
1467
|
+
*
|
|
1468
|
+
* The prompt references the bare tool names (`ask_genie`,
|
|
1469
|
+
* `get_space_description`, `get_space_serialized`,
|
|
1470
|
+
* `get_statement`, `prepare_chart`) used for the single-space
|
|
1471
|
+
* default alias. Multi-space deployments should write their own
|
|
1472
|
+
* variant that names the suffixed per-space tools
|
|
1473
|
+
* (e.g. `ask_genie_sales`).
|
|
1474
|
+
*/
|
|
1475
|
+
const GENIE_INSTRUCTIONS = stringUtils.toDescription(["Genie orchestration. For every user question that needs SQL-backed data:", { numbered: [
|
|
1476
|
+
`
|
|
1477
|
+
Start by calling \`get_space_description\` to ground yourself
|
|
1478
|
+
in what the space covers (tables, metrics, time windows),
|
|
1479
|
+
unless you already saw the same description earlier in this
|
|
1480
|
+
conversation. Reach for \`get_space_serialized\` only when you
|
|
1481
|
+
need exact column / table identifiers the description doesn't
|
|
1482
|
+
expose - it's a much larger payload.
|
|
1483
|
+
`,
|
|
1484
|
+
`
|
|
1485
|
+
Decompose the user's question into focused sub-questions
|
|
1486
|
+
BEFORE asking Genie anything. One sub-question per distinct
|
|
1487
|
+
metric, dimension, or time window. Then call \`ask_genie\`
|
|
1488
|
+
once per sub-question - usually two to six calls per turn,
|
|
1489
|
+
and let earlier answers inform what you ask next. Cramming
|
|
1490
|
+
a multi-part question into one \`ask_genie\` call almost
|
|
1491
|
+
always produces a worse answer than asking the pieces
|
|
1492
|
+
separately. Only collapse to a single call when the question
|
|
1493
|
+
is genuinely atomic ("what was Q3 revenue?").
|
|
1494
|
+
|
|
1495
|
+
Worked example: user asks "How did SKU 1234's revenue
|
|
1496
|
+
compare to its category average last quarter, and which
|
|
1497
|
+
regions drove the gap?" Decomposes to: (a) \`ask_genie\`
|
|
1498
|
+
for SKU 1234's Q3 revenue, (b) \`ask_genie\` for the
|
|
1499
|
+
category-average Q3 revenue, (c) \`ask_genie\` for SKU
|
|
1500
|
+
1234's Q3 revenue split by region. Three focused calls,
|
|
1501
|
+
each grounded in the prior results.
|
|
1502
|
+
`,
|
|
1503
|
+
`
|
|
1504
|
+
Each \`ask_genie\` call returns the terminal \`GenieMessage\`.
|
|
1505
|
+
When the turn ran SQL the result has a \`statement_id\` - read
|
|
1506
|
+
it from \`message.query_result.statement_id\` (or the first
|
|
1507
|
+
attachment's \`query.statement_id\`).
|
|
1508
|
+
`,
|
|
1509
|
+
[
|
|
1510
|
+
`
|
|
1511
|
+
To DISPLAY a result set in your reply, embed a marker on its
|
|
1512
|
+
own line where the visualization should appear. Two marker
|
|
1513
|
+
shapes:
|
|
1514
|
+
`,
|
|
1515
|
+
{ bullets: [`
|
|
1516
|
+
\`[data:<statement_id>]\` - render the rows as a table.
|
|
1517
|
+
Use this when there's no clear visual story (long lists,
|
|
1518
|
+
reference data, single-row results, or the user just
|
|
1519
|
+
wants to see the data). Embed the marker directly; no
|
|
1520
|
+
tool call needed.
|
|
1521
|
+
`, `
|
|
1522
|
+
\`[chart:<chartId>]\` - render the rows as a chart. To
|
|
1523
|
+
get a \`<chartId>\`, call \`prepare_chart\` with the
|
|
1524
|
+
statement's id (and an optional \`title\` / one-line
|
|
1525
|
+
\`description\` of the insight to surface). The tool
|
|
1526
|
+
returns the \`chartId\` synchronously and prepares the
|
|
1527
|
+
chart spec in the background; embed the returned id as
|
|
1528
|
+
\`[chart:<chartId>]\` on its own line wherever the
|
|
1529
|
+
chart should appear. Use a chart when the data has a
|
|
1530
|
+
story a visual conveys better than a table (trends,
|
|
1531
|
+
rankings, distributions, parts-of-a-whole).
|
|
1532
|
+
|
|
1533
|
+
NEVER invent or hand-build a \`<chartId>\`. A valid
|
|
1534
|
+
\`<chartId>\` is the opaque token a \`prepare_chart\`
|
|
1535
|
+
call returned to you in THIS turn - nothing else. It
|
|
1536
|
+
is NOT a \`statement_id\`, and it is NOT a
|
|
1537
|
+
\`statement_id\` prefix with a label appended (e.g.
|
|
1538
|
+
\`01f1...-region-fill\`). If you have not called
|
|
1539
|
+
\`prepare_chart\` and received an id back, do not write
|
|
1540
|
+
a \`[chart:...]\` marker at all - use \`[data:...]\`
|
|
1541
|
+
instead. A fabricated chart id renders nothing and
|
|
1542
|
+
wastes a request.
|
|
1543
|
+
`] },
|
|
1544
|
+
`
|
|
1545
|
+
The host UI resolves both markers on its own once it sees
|
|
1546
|
+
them - you do NOT need to call \`get_statement\` just to
|
|
1547
|
+
display data, and you do NOT need to wait on
|
|
1548
|
+
\`prepare_chart\` (it returns the id immediately and the
|
|
1549
|
+
host UI fetches the cached chart later). Pick at most one
|
|
1550
|
+
marker per statement; don't chart AND table the same result
|
|
1551
|
+
side by side.
|
|
1552
|
+
`
|
|
1553
|
+
],
|
|
1554
|
+
`
|
|
1555
|
+
Call \`get_statement(statement_id, limit?)\` ONLY when you need
|
|
1556
|
+
to read the actual values to reason about them (e.g. naming a
|
|
1557
|
+
specific row, computing a delta the table or chart wouldn't
|
|
1558
|
+
show, or sanity-checking a result before interpreting it). If
|
|
1559
|
+
you'd just be reciting numbers the visualization already shows,
|
|
1560
|
+
skip the call and use a marker instead. \`limit\` defaults to a
|
|
1561
|
+
small sample; raise it only when you genuinely need more rows.
|
|
1562
|
+
`,
|
|
1563
|
+
`
|
|
1564
|
+
Compose your final reply as plain prose. Interleave paragraphs
|
|
1565
|
+
with \`[data:...]\` / \`[chart:...]\` markers wherever a result
|
|
1566
|
+
should render. Don't dump all markers at the end - place each
|
|
1567
|
+
one next to the prose that interprets it. Don't restate every
|
|
1568
|
+
number the visualization already shows; call out deltas,
|
|
1569
|
+
anomalies, takeaways.
|
|
1570
|
+
`
|
|
1571
|
+
] }]);
|
|
1572
|
+
/**
|
|
1573
|
+
* Normalize the {@link GenieSpacesConfig} record. Bare-string
|
|
1574
|
+
* entries (`{ default: "01ef..." }`) get wrapped as
|
|
1575
|
+
* `{ spaceId: "01ef..." }`; object entries pass through unchanged.
|
|
1576
|
+
* `undefined` and empty-string values are dropped so callers can
|
|
1577
|
+
* pass `process.env.X` directly (matches AppKit `genie()`'s
|
|
1578
|
+
* defensive treatment of unset env vars).
|
|
1579
|
+
*/
|
|
1580
|
+
function normalizeGenieSpaces(spaces) {
|
|
1581
|
+
if (!spaces) return {};
|
|
1582
|
+
const out = {};
|
|
1583
|
+
for (const [alias, value] of Object.entries(spaces)) {
|
|
1584
|
+
if (value === void 0) continue;
|
|
1585
|
+
if (typeof value === "string") {
|
|
1586
|
+
if (!value) continue;
|
|
1587
|
+
out[alias] = { spaceId: value };
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (!value.spaceId) continue;
|
|
1591
|
+
out[alias] = value;
|
|
1592
|
+
}
|
|
1593
|
+
return out;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Discover Genie space aliases from every supported source and
|
|
1597
|
+
* merge them into a single record. Precedence (highest first):
|
|
1598
|
+
*
|
|
1599
|
+
* 1. {@link MastraPluginConfig.genieSpaces} on the `mastra(...)`
|
|
1600
|
+
* call. Explicit Mastra wiring always wins so users can
|
|
1601
|
+
* override AppKit's defaults per-agent.
|
|
1602
|
+
* 2. AppKit `genie({ spaces: { ... } })` plugin instance. Lets
|
|
1603
|
+
* users keep using the existing AppKit config format
|
|
1604
|
+
* (`genie({ spaces: { sales: "...", ops: "..." } })`)
|
|
1605
|
+
* without restating the same record on the Mastra plugin.
|
|
1606
|
+
* Read off the live plugin instance via a structural cast
|
|
1607
|
+
* since `Plugin.config` is TS-protected (not runtime-private).
|
|
1608
|
+
* 3. `DATABRICKS_GENIE_SPACE_ID` env var (registered under the
|
|
1609
|
+
* well-known `default` alias). Matches the AppKit `genie()`
|
|
1610
|
+
* plugin's fallback behavior so a bare `mastra()` + `genie()`
|
|
1611
|
+
* pair just works.
|
|
1612
|
+
*
|
|
1613
|
+
* Aliases collide cleanly: a higher-precedence source's value
|
|
1614
|
+
* replaces a lower one's wholesale. Sources that contribute zero
|
|
1615
|
+
* aliases (or contribute only `undefined` / empty entries) are
|
|
1616
|
+
* silently ignored.
|
|
1617
|
+
*/
|
|
1618
|
+
function resolveGenieSpaces(config, context) {
|
|
1619
|
+
const merged = {};
|
|
1620
|
+
const envSpaceId = process.env["DATABRICKS_GENIE_SPACE_ID"];
|
|
1621
|
+
if (envSpaceId) merged[DEFAULT_GENIE_ALIAS] = { spaceId: envSpaceId };
|
|
1622
|
+
const geniePlugin = appkitUtils.instance(context, genie);
|
|
1623
|
+
if (geniePlugin) {
|
|
1624
|
+
const pluginSpaces = geniePlugin.config?.spaces;
|
|
1625
|
+
if (pluginSpaces) Object.assign(merged, normalizeGenieSpaces(pluginSpaces));
|
|
1626
|
+
}
|
|
1627
|
+
if (config.genieSpaces) Object.assign(merged, normalizeGenieSpaces(config.genieSpaces));
|
|
1628
|
+
return merged;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Build the flat Mastra tools record for every configured Genie
|
|
1632
|
+
* space. Two shared, space-agnostic tools (`get_statement`,
|
|
1633
|
+
* `prepare_chart`) are registered once regardless of how many
|
|
1634
|
+
* spaces are wired; the per-space tools (`ask_genie`,
|
|
1635
|
+
* `get_space_description`, `get_space_serialized`) are suffixed
|
|
1636
|
+
* with `_<alias>` for non-default aliases so multi-space
|
|
1637
|
+
* deployments stay disambiguated.
|
|
1638
|
+
*
|
|
1639
|
+
* Returns a record keyed by tool id, ready to spread into the
|
|
1640
|
+
* central `Agent`'s `tools` map (or surfaced via the
|
|
1641
|
+
* `plugins.genie?.toolkit()` callback). Returns an empty record
|
|
1642
|
+
* when `spaces` resolves to zero entries so the caller can spread
|
|
1643
|
+
* safely.
|
|
1644
|
+
*/
|
|
1645
|
+
function buildGenieTools(opts) {
|
|
1646
|
+
const normalized = normalizeGenieSpaces(opts.spaces);
|
|
1647
|
+
const tools = {};
|
|
1648
|
+
if (Object.keys(normalized).length === 0) return tools;
|
|
1649
|
+
tools.get_statement = buildGetStatementTool();
|
|
1650
|
+
tools.prepare_chart = buildPrepareChartTool({ config: opts.config });
|
|
1651
|
+
for (const [alias, space] of Object.entries(normalized)) {
|
|
1652
|
+
const askTool = buildAskGenieTool({
|
|
1653
|
+
spaceId: space.spaceId,
|
|
1654
|
+
alias,
|
|
1655
|
+
...space.hint ? { hint: space.hint } : {}
|
|
1656
|
+
});
|
|
1657
|
+
const descTool = buildSpaceDescriptionTool({
|
|
1658
|
+
spaceId: space.spaceId,
|
|
1659
|
+
alias
|
|
1660
|
+
});
|
|
1661
|
+
const serTool = buildSpaceSerializedTool({
|
|
1662
|
+
spaceId: space.spaceId,
|
|
1663
|
+
alias
|
|
1664
|
+
});
|
|
1665
|
+
tools[askTool.id] = askTool;
|
|
1666
|
+
tools[descTool.id] = descTool;
|
|
1667
|
+
tools[serTool.id] = serTool;
|
|
1668
|
+
}
|
|
1669
|
+
return tools;
|
|
1670
|
+
}
|
|
1671
|
+
/**
|
|
1672
|
+
* Plugin-toolkit adapter so the `plugins.genie?.toolkit()` lookup
|
|
1673
|
+
* inside an agent's `tools(plugins)` callback returns the
|
|
1674
|
+
* flat Genie tools record instead of throwing on missing plugin.
|
|
1675
|
+
* Mirrors AppKit's `PluginToolkitProvider` shape.
|
|
1676
|
+
*/
|
|
1677
|
+
function buildGenieToolkitProvider(opts) {
|
|
1678
|
+
return { toolkit(_opts) {
|
|
1679
|
+
return buildGenieTools(opts);
|
|
1680
|
+
} };
|
|
1681
|
+
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Default cap on starter suggestions surfaced to the chat empty
|
|
1684
|
+
* state. Sample-question lists can run long (10+ on a curated
|
|
1685
|
+
* space); a handful of one-tap prompts reads better than a wall of
|
|
1686
|
+
* buttons.
|
|
1687
|
+
*/
|
|
1688
|
+
const SUGGESTION_LIMIT = 6;
|
|
1689
|
+
/**
|
|
1690
|
+
* How long a space's parsed sample questions stay cached. They're
|
|
1691
|
+
* authored config that changes rarely, and the lookup is an extra
|
|
1692
|
+
* REST round-trip per chat mount, so a few minutes of caching keeps
|
|
1693
|
+
* the empty state instant on reload without going stale for long.
|
|
1694
|
+
*/
|
|
1695
|
+
const SUGGESTION_CACHE_TTL_MS = 10 * 6e4;
|
|
1696
|
+
/** Space-id -> cached sample questions with an absolute expiry. */
|
|
1697
|
+
const _suggestionCache = /* @__PURE__ */ new Map();
|
|
1698
|
+
/** Read a space's cached questions, or `undefined` on miss / expiry. */
|
|
1699
|
+
function readSuggestionCache(spaceId) {
|
|
1700
|
+
const entry = _suggestionCache.get(spaceId);
|
|
1701
|
+
if (!entry) return void 0;
|
|
1702
|
+
if (entry.expires <= Date.now()) {
|
|
1703
|
+
_suggestionCache.delete(spaceId);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
return entry.questions;
|
|
1707
|
+
}
|
|
1708
|
+
/** Cache a space's parsed questions for {@link SUGGESTION_CACHE_TTL_MS}. */
|
|
1709
|
+
function writeSuggestionCache(spaceId, questions) {
|
|
1710
|
+
_suggestionCache.set(spaceId, {
|
|
1711
|
+
questions,
|
|
1712
|
+
expires: Date.now() + SUGGESTION_CACHE_TTL_MS
|
|
1713
|
+
});
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Collect the curated starter questions across every resolved Genie
|
|
1717
|
+
* space, deduped and capped. Each space's `sample_questions` are
|
|
1718
|
+
* fetched once (cached for {@link SUGGESTION_CACHE_TTL_MS}) via
|
|
1719
|
+
* {@link getGenieSpace} + {@link genieSampleQuestions}, then merged in
|
|
1720
|
+
* alias-iteration order so a single-space app surfaces that space's
|
|
1721
|
+
* questions and a multi-space app round-trips breadth-first up to the
|
|
1722
|
+
* cap. A per-space fetch failure degrades to "no questions for that
|
|
1723
|
+
* space" (logged, not thrown) so one unreachable space never blanks
|
|
1724
|
+
* the whole list. Returns `[]` when no spaces are configured.
|
|
1725
|
+
*/
|
|
1726
|
+
async function collectSpaceSuggestions(opts) {
|
|
1727
|
+
const limit = opts.limit ?? SUGGESTION_LIMIT;
|
|
1728
|
+
const merged = [];
|
|
1729
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1730
|
+
for (const { spaceId } of Object.values(opts.spaces)) {
|
|
1731
|
+
let questions = readSuggestionCache(spaceId);
|
|
1732
|
+
if (!questions) try {
|
|
1733
|
+
questions = genieSampleQuestions(await getGenieSpace(spaceId, {
|
|
1734
|
+
workspaceClient: opts.client,
|
|
1735
|
+
...opts.signal ? { context: opts.signal } : {}
|
|
1736
|
+
}));
|
|
1737
|
+
writeSuggestionCache(spaceId, questions);
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
log$4.warn("suggestions:fetch-error", {
|
|
1740
|
+
spaceId,
|
|
1741
|
+
error: commonUtils.errorMessage(err)
|
|
1742
|
+
});
|
|
1743
|
+
questions = [];
|
|
1744
|
+
}
|
|
1745
|
+
for (const question of questions) {
|
|
1746
|
+
if (seen.has(question)) continue;
|
|
1747
|
+
seen.add(question);
|
|
1748
|
+
merged.push(question);
|
|
1749
|
+
if (merged.length >= limit) return merged;
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return merged;
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
//#endregion
|
|
1756
|
+
//#region packages/appkit-mastra/src/processor.ts
|
|
1757
|
+
var ResultProcessor = class {
|
|
1758
|
+
id = "result-processor";
|
|
1759
|
+
processDataParts = true;
|
|
1760
|
+
async processOutputStream({ part }) {
|
|
1761
|
+
if (!part || typeof part !== "object") return part;
|
|
1762
|
+
if (![
|
|
1763
|
+
"step-finish",
|
|
1764
|
+
"finish",
|
|
1765
|
+
"tool-result",
|
|
1766
|
+
"data-tool-agent"
|
|
1767
|
+
].includes(part.type)) return part;
|
|
1768
|
+
const payload = part.payload;
|
|
1769
|
+
if (!payload || typeof payload !== "object") return part;
|
|
1770
|
+
for (const key of [
|
|
1771
|
+
"output",
|
|
1772
|
+
"messages",
|
|
1773
|
+
"response",
|
|
1774
|
+
"result"
|
|
1775
|
+
]) if (key in payload) {
|
|
1776
|
+
const value = payload[key];
|
|
1777
|
+
if (typeof value === "object") payload[key] = {};
|
|
1778
|
+
else if (Array.isArray(value)) payload[key] = [];
|
|
1779
|
+
else delete payload[key];
|
|
1780
|
+
}
|
|
1781
|
+
return part;
|
|
1782
|
+
}
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region packages/appkit-mastra/src/processors/strip-stale-charts.ts
|
|
1787
|
+
/**
|
|
1788
|
+
* Mastra input processor that strips `chartId` fields from every
|
|
1789
|
+
* tool-invocation result in prior assistant messages before they
|
|
1790
|
+
* reach the model.
|
|
1791
|
+
*
|
|
1792
|
+
* Why: chartIds are turn-scoped from the model's point of view -
|
|
1793
|
+
* each `prepare_chart` / `render_data` call mints a fresh id and
|
|
1794
|
+
* the host UI binds it to that turn's reply. Mastra Memory
|
|
1795
|
+
* replays prior tool results into the prompt; if old chartIds
|
|
1796
|
+
* leak through, the model is tempted to copy them verbatim into
|
|
1797
|
+
* the new turn's `[chart:<id>]` markers and the host UI ends up
|
|
1798
|
+
* rendering an unrelated chart from the chart cache (or
|
|
1799
|
+
* a 404 once the 1h TTL elapsed). This processor removes the
|
|
1800
|
+
* temptation by deleting `chartId` keys from every assistant
|
|
1801
|
+
* message's tool results before the prompt is built. The current
|
|
1802
|
+
* turn's tool results don't exist yet at `processInput` time, so
|
|
1803
|
+
* they pass through unmodified.
|
|
1804
|
+
*
|
|
1805
|
+
* The strip is recursive - any nested `chartId` field is removed,
|
|
1806
|
+
* regardless of which tool produced the result. This covers
|
|
1807
|
+
* `prepare_chart` / `render_data` top-level chartIds and any
|
|
1808
|
+
* legacy `datasets[].chartId` payloads uniformly without coupling
|
|
1809
|
+
* to specific tool ids.
|
|
1810
|
+
*/
|
|
1811
|
+
const log$3 = logUtils.logger("mastra/processor/strip-stale-charts");
|
|
1812
|
+
/**
|
|
1813
|
+
* Recursively clone `value`, omitting any property whose key is
|
|
1814
|
+
* `chartId`. Arrays are mapped element-wise; primitives are
|
|
1815
|
+
* returned as-is. The result is structurally identical to the
|
|
1816
|
+
* input minus chartIds, so downstream message-shape consumers
|
|
1817
|
+
* keep working.
|
|
1818
|
+
*/
|
|
1819
|
+
function stripChartIds(value) {
|
|
1820
|
+
if (Array.isArray(value)) return value.map(stripChartIds);
|
|
1821
|
+
if (value && typeof value === "object") {
|
|
1822
|
+
const obj = value;
|
|
1823
|
+
const out = {};
|
|
1824
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
1825
|
+
if (key === "chartId") continue;
|
|
1826
|
+
out[key] = stripChartIds(val);
|
|
1827
|
+
}
|
|
1828
|
+
return out;
|
|
1829
|
+
}
|
|
1830
|
+
return value;
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* Input processor that scrubs `chartId` from every tool-invocation
|
|
1834
|
+
* result in the message list. Wired onto every agent by default
|
|
1835
|
+
* via {@link buildAgents}; opt out with
|
|
1836
|
+
* `MastraPluginConfig.stripStaleCharts: false`.
|
|
1837
|
+
*/
|
|
1838
|
+
const stripStaleChartsProcessor = {
|
|
1839
|
+
id: "strip-stale-charts",
|
|
1840
|
+
description: "Removes chartId fields from prior tool-invocation results so the model can't reuse turn-scoped ids from memory.",
|
|
1841
|
+
processInput(args) {
|
|
1842
|
+
let stripped = 0;
|
|
1843
|
+
for (const message of args.messages) {
|
|
1844
|
+
if (message.role !== "assistant") continue;
|
|
1845
|
+
const parts = message.content?.parts;
|
|
1846
|
+
if (!Array.isArray(parts)) continue;
|
|
1847
|
+
for (const part of parts) {
|
|
1848
|
+
if (part.type !== "tool-invocation") continue;
|
|
1849
|
+
const inv = part.toolInvocation;
|
|
1850
|
+
if (!inv || inv.result === void 0) continue;
|
|
1851
|
+
const before = inv.result;
|
|
1852
|
+
const after = stripChartIds(before);
|
|
1853
|
+
if (typeof before === "object" && before !== null && JSON.stringify(before).length !== JSON.stringify(after).length) {
|
|
1854
|
+
inv.result = after;
|
|
1855
|
+
stripped += 1;
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if (stripped > 0) log$3.debug("stripped", { results: stripped });
|
|
1860
|
+
return args.messages;
|
|
1861
|
+
}
|
|
1862
|
+
};
|
|
1863
|
+
|
|
1864
|
+
//#endregion
|
|
1865
|
+
//#region packages/appkit-mastra/src/agents.ts
|
|
1866
|
+
/**
|
|
1867
|
+
* Agent registration for the Mastra AppKit plugin.
|
|
1868
|
+
*
|
|
1869
|
+
* Mirrors the shape of the AppKit `agents` plugin (`config.agents` map
|
|
1870
|
+
* of {@link MastraAgentDefinition}, dual-form `tools` accepting a plain
|
|
1871
|
+
* record or a `(plugins) => tools` callback). Resolves each definition
|
|
1872
|
+
* into a Mastra `Agent` instance during plugin setup; user-supplied
|
|
1873
|
+
* tool callbacks are invoked exactly once with a typed
|
|
1874
|
+
* {@link MastraPlugins} map built from registered sibling plugins.
|
|
1875
|
+
*
|
|
1876
|
+
* When no agents are registered the plugin falls back to a single
|
|
1877
|
+
* built-in analyst so the bare `mastra()` call still mounts a working
|
|
1878
|
+
* streamable agent for demos.
|
|
1879
|
+
*/
|
|
1880
|
+
/**
|
|
1881
|
+
* AppKit-shaped tool factory. Lets users mix-and-match tools across
|
|
1882
|
+
* AppKit's `agents` plugin and `mastra` with a single import:
|
|
1883
|
+
*
|
|
1884
|
+
* ```ts
|
|
1885
|
+
* import { tool } from "@dbx-tools/appkit-mastra";
|
|
1886
|
+
* import { z } from "zod";
|
|
1887
|
+
*
|
|
1888
|
+
* get_weather: tool({
|
|
1889
|
+
* description: "Weather",
|
|
1890
|
+
* schema: z.object({ city: z.string() }),
|
|
1891
|
+
* execute: async ({ city }) => `Sunny in ${city}`,
|
|
1892
|
+
* }),
|
|
1893
|
+
* ```
|
|
1894
|
+
*
|
|
1895
|
+
* Maps onto Mastra's `createTool`:
|
|
1896
|
+
*
|
|
1897
|
+
* - `description` -> `description` (required)
|
|
1898
|
+
* - `schema` -> `inputSchema` (optional)
|
|
1899
|
+
* - `execute(input)` -> `execute(input, ctx)` - Mastra already calls
|
|
1900
|
+
* the first arg with the parsed inputs, so the body shape is
|
|
1901
|
+
* identical. The Mastra `context` arg is forwarded as the second
|
|
1902
|
+
* parameter when the caller declares it.
|
|
1903
|
+
* - `id`: optional. Defaults to a stable identifier derived from
|
|
1904
|
+
* `description` (slugified, with a short hash suffix for
|
|
1905
|
+
* uniqueness). Pass an explicit `id` when you need a stable string
|
|
1906
|
+
* for tracing or MCP exposure.
|
|
1907
|
+
*
|
|
1908
|
+
* Reach for {@link createTool} when you need Mastra-only fields
|
|
1909
|
+
* (`outputSchema`, `suspendSchema`, `requireApproval`, `mcp`, etc.).
|
|
1910
|
+
*/
|
|
1911
|
+
function tool(opts) {
|
|
1912
|
+
return createTool$1({
|
|
1913
|
+
id: opts.id ?? deriveToolId(opts.description),
|
|
1914
|
+
description: opts.description,
|
|
1915
|
+
...opts.schema ? { inputSchema: opts.schema } : {},
|
|
1916
|
+
execute: opts.execute
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Build a deterministic Mastra tool id from a description.
|
|
1921
|
+
* Delegates to {@link stringUtils.toUniqueSlug}: slug + always-on
|
|
1922
|
+
* 6-char FNV-1a base-32 suffix so two tools with the same leading
|
|
1923
|
+
* words don't collide in traces. Stable across runs.
|
|
1924
|
+
*/
|
|
1925
|
+
function deriveToolId(description) {
|
|
1926
|
+
return stringUtils.toUniqueSlug(description, { fallbackPrefix: "tool" });
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Identity helper that brands a definition as a Mastra agent. Mirrors
|
|
1930
|
+
* AppKit's `createAgent(def)` so the registration shape matches:
|
|
1931
|
+
*
|
|
1932
|
+
* ```ts
|
|
1933
|
+
* const support = createAgent({
|
|
1934
|
+
* instructions: "...",
|
|
1935
|
+
* model: "databricks-claude-sonnet-4-6",
|
|
1936
|
+
* tools(plugins) { return { ... }; },
|
|
1937
|
+
* });
|
|
1938
|
+
* ```
|
|
1939
|
+
*
|
|
1940
|
+
* Returns the definition unchanged - the wrapper exists only to anchor
|
|
1941
|
+
* type inference and to match the AppKit API surface.
|
|
1942
|
+
*/
|
|
1943
|
+
function createAgent(def) {
|
|
1944
|
+
return def;
|
|
1945
|
+
}
|
|
1946
|
+
/** Fallback agent id used when `config.agents` is omitted entirely. */
|
|
1947
|
+
const FALLBACK_AGENT_ID = "default";
|
|
1948
|
+
/**
|
|
1949
|
+
* Default per-turn step ceiling applied to every registered agent
|
|
1950
|
+
* when {@link MastraPluginConfig.agentMaxSteps} is unset. Sized to
|
|
1951
|
+
* fit a decomposed Genie turn (grounding + several `ask_genie`
|
|
1952
|
+
* calls + `prepare_chart` per dataset + the final-text reply) with
|
|
1953
|
+
* headroom for the model to chain a couple of follow-ups before
|
|
1954
|
+
* answering - well above Mastra's own `agent.generate` default of
|
|
1955
|
+
* 5, which would cut multi-step orchestration off mid-loop.
|
|
1956
|
+
*/
|
|
1957
|
+
const DEFAULT_AGENT_MAX_STEPS = 25;
|
|
1958
|
+
const FALLBACK_AGENT_INSTRUCTIONS = `You are a data analyst. The user will ask questions about
|
|
1959
|
+
business metrics and may share personal preferences you should remember across turns.
|
|
1960
|
+
|
|
1961
|
+
Rules:
|
|
1962
|
+
|
|
1963
|
+
1. Quote numbers exactly. Never invent data.
|
|
1964
|
+
2. When the user states a preference or durable fact about themselves
|
|
1965
|
+
("I'm in EU so use EUR", "always show me the SQL"), acknowledge that
|
|
1966
|
+
you will remember it.
|
|
1967
|
+
3. If you don't have enough information to answer, ask a clarifying
|
|
1968
|
+
question instead of guessing.`;
|
|
1969
|
+
/**
|
|
1970
|
+
* Style guardrails appended to every agent's `instructions` to curb
|
|
1971
|
+
* common LLM-isms (em dashes, emojis, sycophantic openers, excessive
|
|
1972
|
+
* hedging, throwaway closers). Appended rather than prepended so the
|
|
1973
|
+
* agent's role/context comes first; the model's recency bias then
|
|
1974
|
+
* helps the style rules dominate the response surface.
|
|
1975
|
+
*
|
|
1976
|
+
* Override globally via {@link MastraPluginConfig.styleInstructions}
|
|
1977
|
+
* (pass `false` to disable entirely, or a string to replace).
|
|
1978
|
+
*/
|
|
1979
|
+
const DEFAULT_STYLE_INSTRUCTIONS = [
|
|
1980
|
+
"Output style:",
|
|
1981
|
+
"",
|
|
1982
|
+
"Use markdown formatting, including headings, lists, and code blocks.",
|
|
1983
|
+
"Avoid lists and headers for short replies.",
|
|
1984
|
+
"Plain prose.",
|
|
1985
|
+
"Use hyphens (-) only. Never use em dashes or en dashes.",
|
|
1986
|
+
"Never use emojis.",
|
|
1987
|
+
"Skip openers like 'Great question', 'Absolutely', and 'I'd be happy to help'.",
|
|
1988
|
+
"Skip closers like 'Let me know if you have any questions'.",
|
|
1989
|
+
"Skip self-disclaimers like 'I should mention' and 'It's important to note'.",
|
|
1990
|
+
"Answer directly.",
|
|
1991
|
+
"Do not include a preamble before the actual answer.",
|
|
1992
|
+
"Use lists and headers only when they clarify a multi-part answer."
|
|
1993
|
+
].join("\n");
|
|
1994
|
+
/**
|
|
1995
|
+
* Resolve the style block to append to every agent's instructions.
|
|
1996
|
+
* Returns `null` when the caller opted out (`styleInstructions: false`).
|
|
1997
|
+
*/
|
|
1998
|
+
function resolveStyleInstructions(config) {
|
|
1999
|
+
if (config.styleInstructions === false) return null;
|
|
2000
|
+
if (typeof config.styleInstructions === "string") return config.styleInstructions;
|
|
2001
|
+
return DEFAULT_STYLE_INSTRUCTIONS;
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Join an agent's bespoke instructions with the resolved style block.
|
|
2005
|
+
* Returns the bespoke text unchanged when the style block is disabled.
|
|
2006
|
+
*/
|
|
2007
|
+
function composeInstructions(agentInstructions, style) {
|
|
2008
|
+
if (!style) return agentInstructions;
|
|
2009
|
+
return `${agentInstructions.trimEnd()}\n\n${style}`;
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Resolve every entry in `config.agents` into a Mastra `Agent`
|
|
2013
|
+
* instance. When `config.agents` is omitted the plugin registers a
|
|
2014
|
+
* single built-in `default` analyst so the bare `mastra()` call still
|
|
2015
|
+
* yields a working agent.
|
|
2016
|
+
*
|
|
2017
|
+
* Per-agent tool callbacks are invoked once with a typed
|
|
2018
|
+
* {@link MastraPlugins} index built from registered sibling plugins
|
|
2019
|
+
* (currently `genie`; extend `MastraPlugins` to surface more).
|
|
2020
|
+
*
|
|
2021
|
+
* @throws when `config.defaultAgent` is set to an id that isn't in the
|
|
2022
|
+
* resolved registry; this is a wiring bug, not a runtime condition.
|
|
2023
|
+
*/
|
|
2024
|
+
async function buildAgents(opts) {
|
|
2025
|
+
const { config, context, memoryBuilder, log } = opts;
|
|
2026
|
+
const definitions = resolveDefinitions(config);
|
|
2027
|
+
const ids = Object.keys(definitions);
|
|
2028
|
+
const defaultAgentId = config.defaultAgent ?? ids[0] ?? FALLBACK_AGENT_ID;
|
|
2029
|
+
const plugins = buildPluginsMap(config, context);
|
|
2030
|
+
const ambientTools = {
|
|
2031
|
+
render_data: buildRenderDataTool(config),
|
|
2032
|
+
...config.tools ?? {}
|
|
2033
|
+
};
|
|
2034
|
+
const style = resolveStyleInstructions(config);
|
|
2035
|
+
const outputProcessors = [new ResultProcessor()];
|
|
2036
|
+
const inputProcessors = config.stripStaleCharts === false ? [] : [stripStaleChartsProcessor];
|
|
2037
|
+
const agents = {};
|
|
2038
|
+
for (const [id, def] of Object.entries(definitions)) {
|
|
2039
|
+
const tools = await resolveTools(def.tools, plugins, ambientTools);
|
|
2040
|
+
const memory = memoryBuilder?.forAgent(id, def);
|
|
2041
|
+
agents[id] = new Agent({
|
|
2042
|
+
id,
|
|
2043
|
+
name: def.name ?? id,
|
|
2044
|
+
...def.description !== void 0 ? { description: def.description } : {},
|
|
2045
|
+
instructions: composeInstructions(def.instructions, style),
|
|
2046
|
+
model: resolveModel(config, def.model),
|
|
2047
|
+
defaultOptions: { maxSteps: config.agentMaxSteps ?? DEFAULT_AGENT_MAX_STEPS },
|
|
2048
|
+
tools,
|
|
2049
|
+
...memory ? { memory } : {},
|
|
2050
|
+
inputProcessors,
|
|
2051
|
+
outputProcessors
|
|
2052
|
+
});
|
|
2053
|
+
log.info("agent registered", {
|
|
2054
|
+
id,
|
|
2055
|
+
name: def.name ?? id,
|
|
2056
|
+
defaultModel: describeAgentDefaultModel(config, def),
|
|
2057
|
+
tools: Object.keys(tools)
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
if (!agents[defaultAgentId]) throw new Error(`mastra: defaultAgent "${defaultAgentId}" not found in registered agents (${ids.join(", ") || "none"})`);
|
|
2061
|
+
log.info("agents ready", {
|
|
2062
|
+
ids,
|
|
2063
|
+
defaultAgentId
|
|
2064
|
+
});
|
|
2065
|
+
return {
|
|
2066
|
+
agents,
|
|
2067
|
+
defaultAgentId,
|
|
2068
|
+
ambientTools
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Best-effort description of the *static* default model an agent will
|
|
2073
|
+
* resolve to at call time. Walks the same precedence ladder as
|
|
2074
|
+
* {@link resolveModel} / {@link buildModel}:
|
|
2075
|
+
*
|
|
2076
|
+
* 1. Per-agent `def.model` (string sugar -> the literal id;
|
|
2077
|
+
* function / `DynamicArgument` -> `"<dynamic>"` because the
|
|
2078
|
+
* resolver decides at call time).
|
|
2079
|
+
* 2. Plugin-level `config.defaultModel` (same rules).
|
|
2080
|
+
* 3. `DATABRICKS_SERVING_ENDPOINT_NAME` env var.
|
|
2081
|
+
* 4. First entry of `config.defaultModelFallbacks ?? FALLBACK_MODEL_IDS`.
|
|
2082
|
+
*
|
|
2083
|
+
* Used for the startup `agent registered` log so operators can see
|
|
2084
|
+
* which endpoint each agent points at by default. Per-request
|
|
2085
|
+
* overrides (`X-Mastra-Model` etc.) and the workspace-catalogue
|
|
2086
|
+
* fuzzy match are still applied at runtime.
|
|
2087
|
+
*/
|
|
2088
|
+
function describeAgentDefaultModel(config, def) {
|
|
2089
|
+
const effective = def.model ?? config.defaultModel;
|
|
2090
|
+
if (typeof effective === "string") return effective;
|
|
2091
|
+
if (effective !== void 0) return "<dynamic>";
|
|
2092
|
+
return process.env.DATABRICKS_SERVING_ENDPOINT_NAME ?? config.defaultModelFallbacks?.[0] ?? FALLBACK_MODEL_IDS[0];
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Normalize `config.agents` into a `Record<id, definition>`. Accepts
|
|
2096
|
+
* any of the three shapes documented on
|
|
2097
|
+
* {@link MastraPluginConfig.agents}:
|
|
2098
|
+
*
|
|
2099
|
+
* - Record - returned as-is when non-empty.
|
|
2100
|
+
* - Single definition (detected via the required `instructions`
|
|
2101
|
+
* field) - keyed by `slugify(def.name)` or `FALLBACK_AGENT_ID`.
|
|
2102
|
+
* - Array - keyed by `slugify(def.name)` or `agent_${i}`; duplicate
|
|
2103
|
+
* slugs fail loudly so users know to set explicit names.
|
|
2104
|
+
*
|
|
2105
|
+
* Omitted or empty inputs fall back to a single built-in analyst so
|
|
2106
|
+
* the bare `mastra()` call still mounts a working chat route.
|
|
2107
|
+
*/
|
|
2108
|
+
function resolveDefinitions(config) {
|
|
2109
|
+
const input = config.agents;
|
|
2110
|
+
if (!input) return fallbackDefinitions();
|
|
2111
|
+
if (Array.isArray(input)) {
|
|
2112
|
+
if (input.length === 0) return fallbackDefinitions();
|
|
2113
|
+
const out = {};
|
|
2114
|
+
input.forEach((def, i) => {
|
|
2115
|
+
const key = deriveAgentKey(def, i);
|
|
2116
|
+
if (out[key]) throw new Error(`mastra: duplicate agent id "${key}" derived from name "${def.name ?? ""}"; set unique \`name\`s on each definition`);
|
|
2117
|
+
out[key] = def;
|
|
2118
|
+
});
|
|
2119
|
+
return out;
|
|
2120
|
+
}
|
|
2121
|
+
if (typeof input.instructions === "string") {
|
|
2122
|
+
const def = input;
|
|
2123
|
+
return { [deriveAgentKey(def)]: def };
|
|
2124
|
+
}
|
|
2125
|
+
const record = input;
|
|
2126
|
+
if (Object.keys(record).length === 0) return fallbackDefinitions();
|
|
2127
|
+
return record;
|
|
2128
|
+
}
|
|
2129
|
+
/** Derive a registry id from a definition's `name`, with a fallback. */
|
|
2130
|
+
function deriveAgentKey(def, index) {
|
|
2131
|
+
if (def.name) {
|
|
2132
|
+
const slug = stringUtils.toIdentifier(def.name);
|
|
2133
|
+
if (slug) return slug;
|
|
2134
|
+
}
|
|
2135
|
+
return index === void 0 ? FALLBACK_AGENT_ID : `agent_${index}`;
|
|
2136
|
+
}
|
|
2137
|
+
/** Built-in fallback registry used when `agents` is omitted / empty. */
|
|
2138
|
+
function fallbackDefinitions() {
|
|
2139
|
+
return { [FALLBACK_AGENT_ID]: {
|
|
2140
|
+
name: "Default Agent",
|
|
2141
|
+
instructions: FALLBACK_AGENT_INSTRUCTIONS
|
|
2142
|
+
} };
|
|
2143
|
+
}
|
|
2144
|
+
/**
|
|
2145
|
+
* Pick the effective model spec for an agent. Fallback ladder, in
|
|
2146
|
+
* order:
|
|
2147
|
+
*
|
|
2148
|
+
* 1. Per-agent `def.model` (string sugar or `DynamicArgument`).
|
|
2149
|
+
* 2. Plugin-level `config.defaultModel` (string sugar or
|
|
2150
|
+
* `DynamicArgument`) - mirrors AppKit's `agents({ defaultModel })`.
|
|
2151
|
+
* 3. The auto-resolver that mints user-scoped tokens against
|
|
2152
|
+
* `/serving-endpoints` via {@link buildModel}.
|
|
2153
|
+
*
|
|
2154
|
+
* String values are treated as `modelId` sugar and threaded through
|
|
2155
|
+
* `buildModel`'s override hook so the runtime fuzzy matcher and the
|
|
2156
|
+
* per-request `X-Mastra-Model` override layer on top of the static
|
|
2157
|
+
* choice. Non-string `DynamicArgument`s are passed through verbatim;
|
|
2158
|
+
* callers that need full control over `providerId` / `headers` /
|
|
2159
|
+
* `modelId` bypass the resolver pipeline entirely.
|
|
2160
|
+
*/
|
|
2161
|
+
function resolveModel(config, override) {
|
|
2162
|
+
const effective = override ?? config.defaultModel;
|
|
2163
|
+
if (effective === void 0) return ({ requestContext }) => buildModel(config, requestContext);
|
|
2164
|
+
if (typeof effective === "string") {
|
|
2165
|
+
const modelId = effective;
|
|
2166
|
+
return ({ requestContext }) => buildModel(config, requestContext, { modelId });
|
|
2167
|
+
}
|
|
2168
|
+
return effective;
|
|
2169
|
+
}
|
|
2170
|
+
/**
|
|
2171
|
+
* Resolve a definition's `tools` field to a flat `MastraTools` record,
|
|
2172
|
+
* merging in plugin-level ambient tools (per-agent tools win on key
|
|
2173
|
+
* collision). Callback errors propagate verbatim so the original stack
|
|
2174
|
+
* survives - the caller already knows which agent was registering.
|
|
2175
|
+
*/
|
|
2176
|
+
async function resolveTools(defTools, plugins, ambientTools) {
|
|
2177
|
+
if (!defTools) return { ...ambientTools };
|
|
2178
|
+
const resolved = typeof defTools === "function" ? await defTools(plugins) : defTools;
|
|
2179
|
+
return {
|
|
2180
|
+
...ambientTools,
|
|
2181
|
+
...resolved
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Build the {@link MastraPlugins} runtime proxy handed to
|
|
2186
|
+
* `tools(plugins)` callbacks.
|
|
2187
|
+
*
|
|
2188
|
+
* Implemented as a `Proxy` over the AppKit plugin context so
|
|
2189
|
+
* `plugins.<name>` resolves at first access. Any sibling plugin that
|
|
2190
|
+
* implements AppKit's standard `ToolProvider` interface
|
|
2191
|
+
* (`toolkit(opts?)` + `executeAgentTool(name, args, signal?)`) is
|
|
2192
|
+
* auto-adapted into Mastra tools. Unknown names return `undefined`,
|
|
2193
|
+
* matching AppKit's `Plugins` semantics so `plugins.foo?.toolkit()`
|
|
2194
|
+
* remains safe in environments where `foo` isn't registered.
|
|
2195
|
+
*
|
|
2196
|
+
* `genie` is special-cased to swap the generic AppKit toolkit (which
|
|
2197
|
+
* runs `executeAgentTool` and only emits a single final `tool-result`
|
|
2198
|
+
* chunk per call) for the streaming-aware tools built by
|
|
2199
|
+
* {@link buildGenieProvider}. The streaming variant forwards each
|
|
2200
|
+
* Genie wire event (status, SQL, row counts, errors) out through the
|
|
2201
|
+
* Mastra `ctx.writer`, so the UI gets `tool-output` chunks in real
|
|
2202
|
+
* time instead of staring at a spinner for the full Genie round-trip.
|
|
2203
|
+
*/
|
|
2204
|
+
function buildPluginsMap(config, context) {
|
|
2205
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2206
|
+
return new Proxy({}, { get(_target, propName) {
|
|
2207
|
+
if (typeof propName !== "string") return void 0;
|
|
2208
|
+
if (cache.has(propName)) return cache.get(propName) ?? void 0;
|
|
2209
|
+
const provider = resolveProvider(config, context, propName);
|
|
2210
|
+
cache.set(propName, provider);
|
|
2211
|
+
return provider ?? void 0;
|
|
2212
|
+
} });
|
|
2213
|
+
}
|
|
2214
|
+
/**
|
|
2215
|
+
* Pick the right {@link MastraPluginToolkitProvider} for a sibling
|
|
2216
|
+
* plugin lookup. Returns the Genie agent-backed adapter when
|
|
2217
|
+
* the caller asks for `genie` AND at least one space is reachable
|
|
2218
|
+
* via {@link resolveGenieSpaces} (the explicit
|
|
2219
|
+
* `config.genieSpaces`, the registered AppKit `genie()` plugin's
|
|
2220
|
+
* `spaces` config, or the `DATABRICKS_GENIE_SPACE_ID` env var).
|
|
2221
|
+
* Falls back to the generic AppKit `ToolProvider` adapter for
|
|
2222
|
+
* every other plugin name. `config` is threaded through so the
|
|
2223
|
+
* Genie agent inherits the same model resolver / fallback
|
|
2224
|
+
* ladder the calling agents use.
|
|
2225
|
+
*
|
|
2226
|
+
* The Genie agent talks to Genie directly via `@dbx-tools/genie`
|
|
2227
|
+
* (`genieEventChat`) and the workspace
|
|
2228
|
+
* `statementExecution.getStatement` API. AppKit's stock `genie`
|
|
2229
|
+
* plugin is honored only for its resource manifest and `spaces`
|
|
2230
|
+
* config so existing `app.yaml` configs and `genie({ spaces })`
|
|
2231
|
+
* wiring keep working without change.
|
|
2232
|
+
*/
|
|
2233
|
+
function resolveProvider(config, context, propName) {
|
|
2234
|
+
if (propName === "genie") {
|
|
2235
|
+
const spaces = resolveGenieSpaces(config, context);
|
|
2236
|
+
if (Object.keys(spaces).length === 0) return null;
|
|
2237
|
+
return buildGenieToolkitProvider({
|
|
2238
|
+
spaces,
|
|
2239
|
+
config
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
const plugin = context?.getPlugins().get(propName);
|
|
2243
|
+
return adaptPluginToolkit(plugin);
|
|
2244
|
+
}
|
|
2245
|
+
/**
|
|
2246
|
+
* Adapt an AppKit `ToolProvider` plugin instance into a
|
|
2247
|
+
* {@link MastraPluginToolkitProvider}. Returns `null` for any plugin
|
|
2248
|
+
* that doesn't implement both `toolkit` and `executeAgentTool` (e.g.
|
|
2249
|
+
* `server`, `lakebase` when used only as a Postgres pool, etc.).
|
|
2250
|
+
*/
|
|
2251
|
+
function adaptPluginToolkit(plugin) {
|
|
2252
|
+
if (!plugin || typeof plugin !== "object") return null;
|
|
2253
|
+
const p = plugin;
|
|
2254
|
+
if (typeof p.toolkit !== "function" || typeof p.executeAgentTool !== "function") return null;
|
|
2255
|
+
return { toolkit(opts) {
|
|
2256
|
+
const entries = p.toolkit(opts);
|
|
2257
|
+
const tools = {};
|
|
2258
|
+
for (const [key, entry] of Object.entries(entries)) tools[key] = toolkitEntryToMastraTool(entry, p);
|
|
2259
|
+
return tools;
|
|
2260
|
+
} };
|
|
2261
|
+
}
|
|
2262
|
+
/**
|
|
2263
|
+
* Wrap a single {@link AppKitToolkitEntry} as a Mastra tool whose
|
|
2264
|
+
* `execute` dispatches back through `plugin.executeAgentTool(...)` so
|
|
2265
|
+
* AppKit's OBO auth (`asUser`) and telemetry spans stay intact. JSON
|
|
2266
|
+
* Schema parameters pass through unchanged - Mastra's `PublicSchema`
|
|
2267
|
+
* accepts `JSONSchema7` directly via `@mastra/schema-compat`.
|
|
2268
|
+
*/
|
|
2269
|
+
function toolkitEntryToMastraTool(entry, plugin) {
|
|
2270
|
+
return createTool$1({
|
|
2271
|
+
id: `${entry.pluginName}__${entry.localName}`,
|
|
2272
|
+
description: entry.def.description,
|
|
2273
|
+
...entry.def.parameters ? { inputSchema: entry.def.parameters } : {},
|
|
2274
|
+
execute: async (input, context) => {
|
|
2275
|
+
const signal = context?.abortSignal;
|
|
2276
|
+
return plugin.executeAgentTool(entry.localName, input, signal);
|
|
2277
|
+
}
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
//#endregion
|
|
2282
|
+
//#region packages/appkit-mastra/src/mcp.ts
|
|
2283
|
+
/** MCP server version advertised when the caller doesn't pin one. */
|
|
2284
|
+
const DEFAULT_MCP_VERSION = "1.0.0";
|
|
2285
|
+
/**
|
|
2286
|
+
* Build the plugin's MCP server, or `null` when `config.mcp` is falsy
|
|
2287
|
+
* (the default - no MCP endpoints).
|
|
2288
|
+
*
|
|
2289
|
+
* `true` exposes every registered agent as an `ask_<agentId>` MCP tool
|
|
2290
|
+
* under a server id equal to the plugin name. The object form
|
|
2291
|
+
* ({@link MastraMcpConfig}) tunes the id / advertised metadata and can
|
|
2292
|
+
* additionally expose the plugin's ambient tools or a set of extra
|
|
2293
|
+
* MCP-only tools.
|
|
2294
|
+
*/
|
|
2295
|
+
function buildMcpServer(opts) {
|
|
2296
|
+
const { config, pluginName, displayName, agents, ambientTools } = opts;
|
|
2297
|
+
if (!config.mcp) return null;
|
|
2298
|
+
const settings = config.mcp === true ? {} : config.mcp;
|
|
2299
|
+
const serverId = settings.serverId ?? pluginName;
|
|
2300
|
+
const exposeAgents = settings.agents !== false;
|
|
2301
|
+
const tools = {
|
|
2302
|
+
...settings.tools === true ? ambientTools : {},
|
|
2303
|
+
...settings.extraTools ?? {}
|
|
2304
|
+
};
|
|
2305
|
+
return {
|
|
2306
|
+
serverId,
|
|
2307
|
+
server: new MCPServer({
|
|
2308
|
+
id: serverId,
|
|
2309
|
+
name: settings.name ?? `${displayName} MCP`,
|
|
2310
|
+
version: settings.version ?? DEFAULT_MCP_VERSION,
|
|
2311
|
+
...settings.description ? { description: settings.description } : {},
|
|
2312
|
+
...exposeAgents ? { agents } : {},
|
|
2313
|
+
tools
|
|
2314
|
+
}),
|
|
2315
|
+
httpPath: `/mcp/${serverId}/mcp`,
|
|
2316
|
+
ssePath: `/mcp/${serverId}/sse`,
|
|
2317
|
+
messagePath: `/mcp/${serverId}/messages`
|
|
2318
|
+
};
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
//#endregion
|
|
2322
|
+
//#region packages/appkit-mastra/src/history.ts
|
|
2323
|
+
const log$2 = logUtils.logger("mastra/history");
|
|
2324
|
+
/** Default history page size; matches the Mastra storage default. */
|
|
2325
|
+
const DEFAULT_PER_PAGE = 20;
|
|
2326
|
+
/** Hard cap so a misbehaving client can't fetch the whole thread at once. */
|
|
2327
|
+
const MAX_PER_PAGE = 200;
|
|
2328
|
+
/**
|
|
2329
|
+
* Fetch a page of UI-formatted messages for a thread.
|
|
2330
|
+
*
|
|
2331
|
+
* Uses the agent's resolved `Memory` (`getMemory()`) so per-agent
|
|
2332
|
+
* storage namespaces (`mastra_<agentId>` schemas) and any future
|
|
2333
|
+
* memory-side filters apply automatically. When the agent has no
|
|
2334
|
+
* memory configured the response is a successful empty page so
|
|
2335
|
+
* callers don't have to special-case stateless agents.
|
|
2336
|
+
*
|
|
2337
|
+
* Pagination is descending-by-default: page 0 is the most recent
|
|
2338
|
+
* page, page 1 the page before that, etc. The returned `uiMessages`
|
|
2339
|
+
* are always re-sorted into chronological order (oldest -> newest)
|
|
2340
|
+
* so the client can prepend them above the existing transcript
|
|
2341
|
+
* without sorting locally.
|
|
2342
|
+
*/
|
|
2343
|
+
async function loadHistory(opts) {
|
|
2344
|
+
const perPage = clampPerPage(opts.perPage);
|
|
2345
|
+
const page = Math.max(0, Math.trunc(opts.page ?? 0));
|
|
2346
|
+
const memory = await opts.agent.getMemory();
|
|
2347
|
+
if (!memory) {
|
|
2348
|
+
log$2.debug("recall:no-memory", {
|
|
2349
|
+
agentId: opts.agent.id,
|
|
2350
|
+
threadId: opts.threadId
|
|
2351
|
+
});
|
|
2352
|
+
return {
|
|
2353
|
+
uiMessages: [],
|
|
2354
|
+
page,
|
|
2355
|
+
perPage,
|
|
2356
|
+
total: 0,
|
|
2357
|
+
hasMore: false
|
|
2358
|
+
};
|
|
2359
|
+
}
|
|
2360
|
+
const startedAt = Date.now();
|
|
2361
|
+
const result = await memory.recall({
|
|
2362
|
+
threadId: opts.threadId,
|
|
2363
|
+
...opts.resourceId ? { resourceId: opts.resourceId } : {},
|
|
2364
|
+
page,
|
|
2365
|
+
perPage,
|
|
2366
|
+
orderBy: {
|
|
2367
|
+
field: "createdAt",
|
|
2368
|
+
direction: opts.ascending ? "ASC" : "DESC"
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
const uiMessages = toAISdkV5Messages(sortChronological(result.messages));
|
|
2372
|
+
log$2.debug("recall:done", {
|
|
2373
|
+
agentId: opts.agent.id,
|
|
2374
|
+
threadId: opts.threadId,
|
|
2375
|
+
page,
|
|
2376
|
+
perPage,
|
|
2377
|
+
returned: uiMessages.length,
|
|
2378
|
+
total: result.total,
|
|
2379
|
+
hasMore: result.hasMore,
|
|
2380
|
+
elapsedMs: Date.now() - startedAt
|
|
2381
|
+
});
|
|
2382
|
+
return {
|
|
2383
|
+
uiMessages,
|
|
2384
|
+
page,
|
|
2385
|
+
perPage,
|
|
2386
|
+
total: result.total,
|
|
2387
|
+
hasMore: result.hasMore
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
/**
|
|
2391
|
+
* Wipe every persisted message tied to a thread. Returns the count
|
|
2392
|
+
* of messages that were on the thread at delete time so the caller
|
|
2393
|
+
* can render a "cleared N messages" affordance without an
|
|
2394
|
+
* additional round-trip.
|
|
2395
|
+
*
|
|
2396
|
+
* Agents without a configured `Memory` resolve to a no-op (count
|
|
2397
|
+
* 0), matching {@link loadHistory}'s "stateless agents return an
|
|
2398
|
+
* empty page" stance so callers don't have to special-case them.
|
|
2399
|
+
* Threads that don't exist yet are also a successful no-op - the
|
|
2400
|
+
* operation is idempotent so the UI can fire-and-forget without
|
|
2401
|
+
* tracking thread existence.
|
|
2402
|
+
*/
|
|
2403
|
+
async function clearHistory(opts) {
|
|
2404
|
+
const memory = await opts.agent.getMemory();
|
|
2405
|
+
if (!memory) {
|
|
2406
|
+
log$2.debug("clear:no-memory", {
|
|
2407
|
+
agentId: opts.agent.id,
|
|
2408
|
+
threadId: opts.threadId
|
|
2409
|
+
});
|
|
2410
|
+
return { cleared: 0 };
|
|
2411
|
+
}
|
|
2412
|
+
let cleared = 0;
|
|
2413
|
+
try {
|
|
2414
|
+
cleared = (await memory.recall({
|
|
2415
|
+
threadId: opts.threadId,
|
|
2416
|
+
page: 0,
|
|
2417
|
+
perPage: 1
|
|
2418
|
+
})).total;
|
|
2419
|
+
} catch (err) {
|
|
2420
|
+
log$2.debug("clear:probe-failed", {
|
|
2421
|
+
agentId: opts.agent.id,
|
|
2422
|
+
threadId: opts.threadId,
|
|
2423
|
+
error: commonUtils.errorMessage(err)
|
|
2424
|
+
});
|
|
2425
|
+
}
|
|
2426
|
+
const startedAt = Date.now();
|
|
2427
|
+
try {
|
|
2428
|
+
await memory.deleteThread(opts.threadId);
|
|
2429
|
+
} catch (err) {
|
|
2430
|
+
log$2.warn("clear:delete-soft-failed", {
|
|
2431
|
+
agentId: opts.agent.id,
|
|
2432
|
+
threadId: opts.threadId,
|
|
2433
|
+
error: commonUtils.errorMessage(err)
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
log$2.info("clear:done", {
|
|
2437
|
+
agentId: opts.agent.id,
|
|
2438
|
+
threadId: opts.threadId,
|
|
2439
|
+
cleared,
|
|
2440
|
+
elapsedMs: Date.now() - startedAt
|
|
2441
|
+
});
|
|
2442
|
+
return { cleared };
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* Register the `<path>` Mastra custom API route. Handles two
|
|
2446
|
+
* methods on the same mount:
|
|
2447
|
+
*
|
|
2448
|
+
* - `GET`: return a page of AI SDK V5 `UIMessage`s for the
|
|
2449
|
+
* caller's current thread ({@link loadHistory}).
|
|
2450
|
+
* - `DELETE`: wipe every persisted message on the caller's
|
|
2451
|
+
* thread ({@link clearHistory}). The session cookie that
|
|
2452
|
+
* anchors the thread id is left alone so the user keeps the
|
|
2453
|
+
* same thread - only the contents go away.
|
|
2454
|
+
*
|
|
2455
|
+
* Follows the `@mastra/ai-sdk` `chatRoute` convention for agent
|
|
2456
|
+
* binding: pass `agent` for a fixed-agent mount, or include
|
|
2457
|
+
* `:agentId` in the path for dynamic routing. The plugin registers
|
|
2458
|
+
* both `/route/history` (default agent) and `/route/history/:agentId`.
|
|
2459
|
+
*
|
|
2460
|
+
* The handler reads `threadId` and `resourceId` from `RequestContext`
|
|
2461
|
+
* (populated upstream by `MastraServer.registerAuthMiddleware`), so
|
|
2462
|
+
* no cookie or user lookups happen here.
|
|
2463
|
+
*/
|
|
2464
|
+
function historyRoute(options) {
|
|
2465
|
+
const { path } = options;
|
|
2466
|
+
const fixedAgent = "agent" in options ? options.agent : void 0;
|
|
2467
|
+
if (!fixedAgent && !path.includes(":agentId")) throw new Error("historyRoute path must include `:agentId` or `agent` must be passed explicitly");
|
|
2468
|
+
const resolveContext = (c) => {
|
|
2469
|
+
const mastra = c.get("mastra");
|
|
2470
|
+
const requestContext = c.get("requestContext");
|
|
2471
|
+
const agentId = fixedAgent ?? c.req.param("agentId");
|
|
2472
|
+
if (!agentId) return { error: c.json({ error: "agentId is required" }, 400) };
|
|
2473
|
+
const agent = mastra.getAgentById(agentId);
|
|
2474
|
+
if (!agent) return { error: c.json({ error: `Unknown agent "${agentId}"` }, 404) };
|
|
2475
|
+
const threadId = requestContext.get(MASTRA_THREAD_ID_KEY);
|
|
2476
|
+
if (!threadId) return { error: c.json({ error: "thread id missing from request context" }, 400) };
|
|
2477
|
+
return {
|
|
2478
|
+
agentId,
|
|
2479
|
+
agent,
|
|
2480
|
+
threadId,
|
|
2481
|
+
resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY)
|
|
2482
|
+
};
|
|
2483
|
+
};
|
|
2484
|
+
return [registerApiRoute(path, {
|
|
2485
|
+
method: "GET",
|
|
2486
|
+
handler: async (c) => {
|
|
2487
|
+
const ctx = resolveContext(c);
|
|
2488
|
+
if ("error" in ctx) return ctx.error;
|
|
2489
|
+
const payload = await loadHistory({
|
|
2490
|
+
agent: ctx.agent,
|
|
2491
|
+
threadId: ctx.threadId,
|
|
2492
|
+
...ctx.resourceId ? { resourceId: ctx.resourceId } : {},
|
|
2493
|
+
page: parseIntParam(c.req.query("page")),
|
|
2494
|
+
perPage: parseIntParam(c.req.query("perPage"))
|
|
2495
|
+
});
|
|
2496
|
+
return c.json(payload);
|
|
2497
|
+
}
|
|
2498
|
+
}), registerApiRoute(path, {
|
|
2499
|
+
method: "DELETE",
|
|
2500
|
+
handler: async (c) => {
|
|
2501
|
+
const ctx = resolveContext(c);
|
|
2502
|
+
if ("error" in ctx) return ctx.error;
|
|
2503
|
+
const { cleared } = await clearHistory({
|
|
2504
|
+
agent: ctx.agent,
|
|
2505
|
+
threadId: ctx.threadId
|
|
2506
|
+
});
|
|
2507
|
+
const payload = {
|
|
2508
|
+
ok: true,
|
|
2509
|
+
agentId: ctx.agentId,
|
|
2510
|
+
threadId: ctx.threadId,
|
|
2511
|
+
cleared
|
|
2512
|
+
};
|
|
2513
|
+
return c.json(payload);
|
|
2514
|
+
}
|
|
2515
|
+
})];
|
|
2516
|
+
}
|
|
2517
|
+
/** Coerce / clamp `perPage`; falls back to the page-size default. */
|
|
2518
|
+
function clampPerPage(value) {
|
|
2519
|
+
if (value === void 0 || Number.isNaN(value)) return DEFAULT_PER_PAGE;
|
|
2520
|
+
const n = Math.trunc(value);
|
|
2521
|
+
if (n <= 0) return DEFAULT_PER_PAGE;
|
|
2522
|
+
return Math.min(n, MAX_PER_PAGE);
|
|
2523
|
+
}
|
|
2524
|
+
/**
|
|
2525
|
+
* Sort messages oldest-first by `createdAt`, falling back to whatever
|
|
2526
|
+
* order the storage returned them in. The native `recall` call honors
|
|
2527
|
+
* `orderBy` but doesn't guarantee a stable secondary sort, so we
|
|
2528
|
+
* normalize here before handing the page to the AI SDK converter.
|
|
2529
|
+
*/
|
|
2530
|
+
function sortChronological(messages) {
|
|
2531
|
+
return [...messages].sort((a, b) => {
|
|
2532
|
+
return toEpoch(a.createdAt) - toEpoch(b.createdAt);
|
|
2533
|
+
});
|
|
2534
|
+
}
|
|
2535
|
+
function toEpoch(value) {
|
|
2536
|
+
if (value instanceof Date) return value.getTime();
|
|
2537
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
2538
|
+
const parsed = new Date(value).getTime();
|
|
2539
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
2540
|
+
}
|
|
2541
|
+
return 0;
|
|
2542
|
+
}
|
|
2543
|
+
/**
|
|
2544
|
+
* Coerce a Hono query value into a non-negative integer. Returns
|
|
2545
|
+
* `undefined` for empty / non-numeric / negative inputs so
|
|
2546
|
+
* {@link loadHistory} can apply its built-in defaults.
|
|
2547
|
+
*/
|
|
2548
|
+
function parseIntParam(value) {
|
|
2549
|
+
if (!value) return void 0;
|
|
2550
|
+
const n = Number(value);
|
|
2551
|
+
if (!Number.isFinite(n) || n < 0) return void 0;
|
|
2552
|
+
return Math.trunc(n);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region packages/appkit-mastra/src/memory.ts
|
|
2557
|
+
/**
|
|
2558
|
+
* Lakebase-backed Mastra memory wiring.
|
|
2559
|
+
*
|
|
2560
|
+
* Provides a {@link MemoryBuilder} that mints one `Memory` per agent
|
|
2561
|
+
* with two independent knobs:
|
|
2562
|
+
*
|
|
2563
|
+
* - **Storage** (threads / messages via `PostgresStore`): defaults to
|
|
2564
|
+
* **per-agent** namespacing via `schemaName: "mastra_<agentId>"` so
|
|
2565
|
+
* conversation history stays isolated between agents in the same
|
|
2566
|
+
* database. `PostgresStore` auto-creates the schema with
|
|
2567
|
+
* `CREATE SCHEMA IF NOT EXISTS` on init.
|
|
2568
|
+
* - **Memory** (semantic recall via `PgVector`): defaults to a single
|
|
2569
|
+
* **shared** instance across every agent. Cross-agent recall on one
|
|
2570
|
+
* index is almost always what users want; opt into per-agent recall
|
|
2571
|
+
* by passing a {@link MastraMemoryConfigOverride} on the agent.
|
|
2572
|
+
*
|
|
2573
|
+
* Additionally, {@link MemoryBuilder.instanceStorage} returns a
|
|
2574
|
+
* **Mastra-instance-level** `PostgresStore` (schema `mastra_instance`)
|
|
2575
|
+
* used for workflow snapshots - the persistence layer
|
|
2576
|
+
* `agent.resumeStream()` reads from when waking a suspended
|
|
2577
|
+
* `requireApproval` tool call. Per-agent stores are not enough for
|
|
2578
|
+
* this: workflow runs are scoped to the Mastra instance, not an
|
|
2579
|
+
* individual agent's `Memory`.
|
|
2580
|
+
*
|
|
2581
|
+
* Plugin-level `config.storage` / `config.memory` act as the baseline
|
|
2582
|
+
* (auto-defaulted to `true` in `plugin.ts` when the `lakebase` plugin
|
|
2583
|
+
* is registered); per-agent settings cascade on top of that.
|
|
2584
|
+
*/
|
|
2585
|
+
const log$1 = logUtils.logger("mastra/memory");
|
|
2586
|
+
/**
|
|
2587
|
+
* Build a dedicated **service-principal** Lakebase pool for Mastra
|
|
2588
|
+
* memory from the lakebase plugin's resolved SP pg config.
|
|
2589
|
+
*
|
|
2590
|
+
* The plugin's `exports().pool` is a `RoutingPool` that switches to
|
|
2591
|
+
* the per-user (OBO) pool whenever a query runs inside an `asUser`
|
|
2592
|
+
* scope - exactly the context the mastra plugin establishes around
|
|
2593
|
+
* every chat turn. Memory (threads / messages + semantic recall) must
|
|
2594
|
+
* instead always act as the app service principal: it owns the
|
|
2595
|
+
* auto-created `mastra_*` schemas (a per-user role usually can't
|
|
2596
|
+
* `CREATE SCHEMA`) and is shared across users, so it cannot inherit a
|
|
2597
|
+
* request's OBO identity.
|
|
2598
|
+
*
|
|
2599
|
+
* `pgConfig` must be the plugin's `exports().getPgConfig()` evaluated
|
|
2600
|
+
* **outside** any `asUser` scope (i.e. during setup), so it carries
|
|
2601
|
+
* the SP connection target, OAuth token-refresh `password` callback,
|
|
2602
|
+
* and any `lakebase({ pool })` tuning overrides - all of which this
|
|
2603
|
+
* pool inherits. See the call site in `plugin.ts`.
|
|
2604
|
+
*/
|
|
2605
|
+
async function createServicePrincipalPool(pgConfig) {
|
|
2606
|
+
const user = pgConfig.user ?? await getUsernameWithApiLookup();
|
|
2607
|
+
return new Pool({
|
|
2608
|
+
...pgConfig,
|
|
2609
|
+
user
|
|
2610
|
+
});
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* True when any plugin-level or per-agent setting could need the
|
|
2614
|
+
* Lakebase pool. Used by `plugin.ts` to gate creation of the
|
|
2615
|
+
* service-principal pool and the {@link MemoryBuilder} that consumes
|
|
2616
|
+
* it; when false neither is built.
|
|
2617
|
+
*/
|
|
2618
|
+
function needsLakebase(config) {
|
|
2619
|
+
if (settingNeedsSharedPool(config.storage)) return true;
|
|
2620
|
+
if (settingNeedsSharedPool(config.memory)) return true;
|
|
2621
|
+
return collectAgentDefinitions(config).some((d) => settingNeedsSharedPool(d.storage) || settingNeedsSharedPool(d.memory));
|
|
2622
|
+
}
|
|
2623
|
+
/**
|
|
2624
|
+
* Construct a per-agent {@link Memory} factory bound to the supplied
|
|
2625
|
+
* service-principal pool (see {@link createServicePrincipalPool}).
|
|
2626
|
+
* Caches the shared `PgVector` singleton (built on first need) so each
|
|
2627
|
+
* agent build is O(1) after the first.
|
|
2628
|
+
*/
|
|
2629
|
+
function createMemoryBuilder(config, servicePrincipalPool) {
|
|
2630
|
+
return new MemoryBuilder(config, servicePrincipalPool);
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* Builds one `Memory` per agent against a shared service-principal
|
|
2634
|
+
* Lakebase pool. Per-instance state keeps the shared `PgVector` alive
|
|
2635
|
+
* across calls so registering N agents stays cheap.
|
|
2636
|
+
*/
|
|
2637
|
+
var MemoryBuilder = class {
|
|
2638
|
+
sharedVector;
|
|
2639
|
+
constructor(config, servicePrincipalPool) {
|
|
2640
|
+
this.config = config;
|
|
2641
|
+
this.servicePrincipalPool = servicePrincipalPool;
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Build a `Memory` for `agentId` after the plugin/agent cascade.
|
|
2645
|
+
* Returns `undefined` when the agent has neither storage nor a
|
|
2646
|
+
* vector store enabled - Mastra accepts a missing `memory` field
|
|
2647
|
+
* and treats the agent as stateless.
|
|
2648
|
+
*/
|
|
2649
|
+
/**
|
|
2650
|
+
* Build the Mastra-instance-level storage used for workflow
|
|
2651
|
+
* snapshots. Returns `undefined` when plugin-level `storage` is
|
|
2652
|
+
* disabled, in which case `agent.resumeStream()` (and therefore
|
|
2653
|
+
* the `requireApproval` flow) will not be available.
|
|
2654
|
+
*
|
|
2655
|
+
* The store lives in a dedicated `mastra_instance` schema so it
|
|
2656
|
+
* never collides with per-agent `mastra_<agentId>` namespaces.
|
|
2657
|
+
* Workflow snapshots are not per-agent state; they belong to the
|
|
2658
|
+
* `Mastra` instance that owns the workflow execution.
|
|
2659
|
+
*/
|
|
2660
|
+
instanceStorage() {
|
|
2661
|
+
const setting = this.config.storage;
|
|
2662
|
+
if (!setting) return void 0;
|
|
2663
|
+
if (typeof setting === "object") return new PostgresStore(withId(setting, "mastra-store__instance"));
|
|
2664
|
+
return new PostgresStore({
|
|
2665
|
+
id: "mastra-store__instance",
|
|
2666
|
+
schemaName: "mastra_instance",
|
|
2667
|
+
pool: this.servicePrincipalPool
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
forAgent(agentId, def) {
|
|
2671
|
+
const storageSetting = def.storage ?? this.config.storage;
|
|
2672
|
+
const memorySetting = def.memory ?? this.config.memory;
|
|
2673
|
+
const storage = this.buildStorage(agentId, storageSetting);
|
|
2674
|
+
const vector = this.buildVector(memorySetting);
|
|
2675
|
+
if (!storage && !vector) {
|
|
2676
|
+
log$1.debug("agent:stateless", { agentId });
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
log$1.debug("agent:configured", {
|
|
2680
|
+
agentId,
|
|
2681
|
+
storage: storage !== void 0,
|
|
2682
|
+
vector: vector !== void 0,
|
|
2683
|
+
vectorMode: vector === void 0 ? "off" : typeof memorySetting === "object" ? "dedicated" : "shared"
|
|
2684
|
+
});
|
|
2685
|
+
return new Memory({
|
|
2686
|
+
...storage ? { storage } : {},
|
|
2687
|
+
...vector ? {
|
|
2688
|
+
vector,
|
|
2689
|
+
embedder: fastembed
|
|
2690
|
+
} : {},
|
|
2691
|
+
options: {
|
|
2692
|
+
lastMessages: 10,
|
|
2693
|
+
...vector ? { semanticRecall: {
|
|
2694
|
+
topK: 3,
|
|
2695
|
+
messageRange: 2
|
|
2696
|
+
} } : {}
|
|
2697
|
+
}
|
|
2698
|
+
});
|
|
2699
|
+
}
|
|
2700
|
+
buildStorage(agentId, setting) {
|
|
2701
|
+
if (!setting) return void 0;
|
|
2702
|
+
if (typeof setting === "boolean") return new PostgresStore({
|
|
2703
|
+
id: `mastra-store__${agentId}`,
|
|
2704
|
+
schemaName: `mastra_${agentId}`,
|
|
2705
|
+
pool: this.servicePrincipalPool
|
|
2706
|
+
});
|
|
2707
|
+
return new PostgresStore(withId(setting, `mastra-store__${agentId}`));
|
|
2708
|
+
}
|
|
2709
|
+
/**
|
|
2710
|
+
* Resolve the agent's vector store. Cascade:
|
|
2711
|
+
*
|
|
2712
|
+
* - falsy: no vector.
|
|
2713
|
+
* - `boolean` / `undefined-inheriting-true`: return the shared
|
|
2714
|
+
* singleton (built lazily on first call). All agents that
|
|
2715
|
+
* default-enable memory write into and recall from one index.
|
|
2716
|
+
* - object: build a dedicated `PgVector` for this agent.
|
|
2717
|
+
*/
|
|
2718
|
+
buildVector(setting) {
|
|
2719
|
+
if (!setting) return void 0;
|
|
2720
|
+
if (typeof setting === "boolean") return this.getSharedVector();
|
|
2721
|
+
return buildPgVector(setting);
|
|
2722
|
+
}
|
|
2723
|
+
getSharedVector() {
|
|
2724
|
+
if (!this.sharedVector) this.sharedVector = buildSharedPgVector(this.servicePrincipalPool);
|
|
2725
|
+
return this.sharedVector;
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
/**
|
|
2729
|
+
* Build the shared `PgVector` that backs the default
|
|
2730
|
+
* `def.memory === true` case across every agent.
|
|
2731
|
+
*
|
|
2732
|
+
* `PgVector`'s constructor accepts only connection-style configs
|
|
2733
|
+
* (`HostConfig` / `ConnectionStringConfig` / `ClientConfig`); there is
|
|
2734
|
+
* no `{ pool }` shorthand the way `PostgresStore` has one. Worse, the
|
|
2735
|
+
* constructor synchronously kicks off a `cacheWarmupPromise` IIFE that
|
|
2736
|
+
* calls `this.pool.connect()` before returning, so we can't cleanly
|
|
2737
|
+
* hand it an inert config and patch the pool afterwards.
|
|
2738
|
+
*
|
|
2739
|
+
* The trick: pass illegal-but-validation-passing placeholders so the
|
|
2740
|
+
* warmup's `net.connect()` rejects synchronously with `RangeError`
|
|
2741
|
+
* (Node validates `0 <= port < 65536`). The IIFE's `catch {}` swallows
|
|
2742
|
+
* it, no DNS lookup or TCP attempt happens, and we then swap
|
|
2743
|
+
* `pgVector.pool` to the lakebase pool. Every subsequent `PgVector`
|
|
2744
|
+
* method reads `this.pool` at call time, so all real I/O goes through
|
|
2745
|
+
* the lakebase pool from then on. The placeholder pool is `.end()`'d
|
|
2746
|
+
* so its socket book-keeping is released.
|
|
2747
|
+
*/
|
|
2748
|
+
function buildSharedPgVector(pool) {
|
|
2749
|
+
const vector = new PgVector({
|
|
2750
|
+
id: `pg${randomUUID()}`,
|
|
2751
|
+
host: "-1",
|
|
2752
|
+
port: -1,
|
|
2753
|
+
database: "_",
|
|
2754
|
+
user: "_",
|
|
2755
|
+
password: "_"
|
|
2756
|
+
});
|
|
2757
|
+
const placeholder = vector.pool;
|
|
2758
|
+
vector.pool = pool;
|
|
2759
|
+
placeholder.end().catch(() => void 0);
|
|
2760
|
+
return vector;
|
|
2761
|
+
}
|
|
2762
|
+
/** Per-agent dedicated `PgVector` (rare; opt-in via object override). */
|
|
2763
|
+
function buildPgVector(setting) {
|
|
2764
|
+
return new PgVector(withId(setting, `pg-vector__${randomUUID()}`));
|
|
2765
|
+
}
|
|
2766
|
+
/** True when this setting requires the shared Lakebase pool. */
|
|
2767
|
+
function settingNeedsSharedPool(setting) {
|
|
2768
|
+
return setting === true;
|
|
2769
|
+
}
|
|
2770
|
+
/** Walk the three shapes of `config.agents` into a flat list. */
|
|
2771
|
+
function collectAgentDefinitions(config) {
|
|
2772
|
+
const agents = config.agents;
|
|
2773
|
+
if (!agents) return [];
|
|
2774
|
+
if (Array.isArray(agents)) return agents;
|
|
2775
|
+
if (typeof agents.instructions === "string") return [agents];
|
|
2776
|
+
return Object.values(agents);
|
|
2777
|
+
}
|
|
2778
|
+
/** Fill in a default `id` when the caller didn't supply one. */
|
|
2779
|
+
function withId(value, fallback) {
|
|
2780
|
+
return value.id ? value : {
|
|
2781
|
+
...value,
|
|
2782
|
+
id: fallback
|
|
2783
|
+
};
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
//#endregion
|
|
2787
|
+
//#region packages/appkit-mastra/src/observability.ts
|
|
2788
|
+
/**
|
|
2789
|
+
* Mastra observability wired through the same OTel pipeline AppKit's
|
|
2790
|
+
* built-in plugins (e.g. `agents`) use, via `@mastra/otel-bridge`.
|
|
2791
|
+
*
|
|
2792
|
+
* How traces flow:
|
|
2793
|
+
*
|
|
2794
|
+
* 1. `@databricks/appkit` boots a global `NodeSDK` in
|
|
2795
|
+
* `TelemetryManager.initialize()` (during `createApp`) when
|
|
2796
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` is set in the process env.
|
|
2797
|
+
* 2. Every AppKit plugin span (e.g. the `agents` plugin's
|
|
2798
|
+
* `executeStream`) is created via the global OTel tracer
|
|
2799
|
+
* (`trace.getTracer(<plugin>)`), so it lands on that NodeSDK and
|
|
2800
|
+
* is shipped through its OTLP exporter.
|
|
2801
|
+
* 3. The Mastra `OtelBridge` ALSO creates real OTel spans on the same
|
|
2802
|
+
* global tracer for every Mastra operation (agent runs, model
|
|
2803
|
+
* calls, tool invocations, workflow steps). They inherit the
|
|
2804
|
+
* ambient OTel context, so when Mastra is invoked from inside an
|
|
2805
|
+
* AppKit HTTP span the trace stays connected.
|
|
2806
|
+
*
|
|
2807
|
+
* Net effect: Mastra spans get exactly the treatment AppKit's
|
|
2808
|
+
* `agents` plugin gets. No custom OTLP pipeline lives in this
|
|
2809
|
+
* package; the OTLP endpoint, headers, and resource attributes are
|
|
2810
|
+
* driven by the standard OTel env vars
|
|
2811
|
+
* (`OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`,
|
|
2812
|
+
* `OTEL_SERVICE_NAME`, `OTEL_RESOURCE_ATTRIBUTES`, ...) and consumed
|
|
2813
|
+
* by AppKit's `TelemetryManager`. Set those once and both AppKit and
|
|
2814
|
+
* Mastra spans end up at the same backend.
|
|
2815
|
+
*
|
|
2816
|
+
* When `OTEL_EXPORTER_OTLP_ENDPOINT` is unset the bridge's spans go
|
|
2817
|
+
* to the global noop tracer, mirroring how the `agents` plugin
|
|
2818
|
+
* silently no-ops in the same situation.
|
|
2819
|
+
*/
|
|
2820
|
+
const log = logUtils.logger("mastra/observability");
|
|
2821
|
+
const DEFAULT_SERVICE_NAME = "mastra";
|
|
2822
|
+
/**
|
|
2823
|
+
* Build a Mastra `Observability` whose spans ride AppKit's global
|
|
2824
|
+
* OTel pipeline via `@mastra/otel-bridge`.
|
|
2825
|
+
*
|
|
2826
|
+
* Returns `undefined` only if someone explicitly opts out in the
|
|
2827
|
+
* future; today it always returns an `Observability` because the
|
|
2828
|
+
* bridge degrades gracefully (no-op tracer) when no global OTel SDK
|
|
2829
|
+
* is registered. Callers can spread `...(observability ? { observability } : {})`
|
|
2830
|
+
* either way to stay forward-compatible.
|
|
2831
|
+
*/
|
|
2832
|
+
async function buildObservability(options) {
|
|
2833
|
+
const serviceName = options?.serviceName ?? await projectUtils.name() ?? DEFAULT_SERVICE_NAME;
|
|
2834
|
+
const requestContextKeys = [...options?.requestContextKeys ?? TRACE_REQUEST_CONTEXT_KEYS];
|
|
2835
|
+
const otelBase = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
2836
|
+
const otelTracesOverride = process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
|
|
2837
|
+
const resolvedTracesUrl = otelTracesOverride ? otelTracesOverride : otelBase ? `${otelBase.replace(/\/+$/, "")}/v1/traces` : void 0;
|
|
2838
|
+
log.info("Mastra observability wired through OTel bridge", {
|
|
2839
|
+
serviceName,
|
|
2840
|
+
requestContextKeys,
|
|
2841
|
+
otelBase: otelBase ?? "<unset>",
|
|
2842
|
+
resolvedTracesUrl: resolvedTracesUrl ?? "<noop; OTLP endpoint unset>"
|
|
2843
|
+
});
|
|
2844
|
+
return new Observability({ configs: { serviceName: {
|
|
2845
|
+
serviceName,
|
|
2846
|
+
bridge: new OtelBridge(),
|
|
2847
|
+
requestContextKeys
|
|
2848
|
+
} } });
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
//#endregion
|
|
2852
|
+
//#region packages/appkit-mastra/src/server.ts
|
|
2853
|
+
/**
|
|
2854
|
+
* Express-layer plumbing for the Mastra plugin: a `MastraServer` that
|
|
2855
|
+
* stamps the per-request `RequestContext`, and a route-patch middleware
|
|
2856
|
+
* that lets the plugin's custom API routes (e.g. `historyRoute`) work
|
|
2857
|
+
* behind an Express mount point.
|
|
2858
|
+
*/
|
|
2859
|
+
/**
|
|
2860
|
+
* `@mastra/express` subclass that stamps `RequestContext` with the
|
|
2861
|
+
* AppKit user, resource id, and a thread id backed by an HTTP-only
|
|
2862
|
+
* session cookie (`appkit_<plugin-name>_session_id`).
|
|
2863
|
+
*/
|
|
2864
|
+
var MastraServer$1 = class extends MastraServer {
|
|
2865
|
+
log;
|
|
2866
|
+
constructor(config, ...args) {
|
|
2867
|
+
super(...args);
|
|
2868
|
+
this.config = config;
|
|
2869
|
+
this.log = logUtils.logger(config);
|
|
2870
|
+
}
|
|
2871
|
+
registerAuthMiddleware() {
|
|
2872
|
+
super.registerAuthMiddleware();
|
|
2873
|
+
this.app.use((req, res, next) => {
|
|
2874
|
+
const requestContext = res.locals.requestContext;
|
|
2875
|
+
this.configureRequestContextUser(requestContext);
|
|
2876
|
+
this.configureRequestContextThreadId(req, res, requestContext);
|
|
2877
|
+
this.configureRequestContextModelOverride(req, requestContext);
|
|
2878
|
+
this.configureRequestContextRequestId(req, res, requestContext);
|
|
2879
|
+
this.log.debug("auth:middleware", {
|
|
2880
|
+
method: req.method,
|
|
2881
|
+
path: req.path,
|
|
2882
|
+
requestId: requestContext.get(MASTRA_REQUEST_ID_KEY),
|
|
2883
|
+
threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
|
|
2884
|
+
resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
|
|
2885
|
+
userName: requestContext.get(MASTRA_USER_NAME_KEY),
|
|
2886
|
+
userEmail: requestContext.get(MASTRA_USER_EMAIL_KEY),
|
|
2887
|
+
modelOverride: requestContext.get("mastra__model_override")
|
|
2888
|
+
});
|
|
2889
|
+
next();
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2892
|
+
configureRequestContextUser(requestContext) {
|
|
2893
|
+
if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
|
|
2894
|
+
const executionContext = getExecutionContext();
|
|
2895
|
+
const user = {
|
|
2896
|
+
id: "userId" in executionContext ? executionContext.userId : executionContext.serviceUserId,
|
|
2897
|
+
executionContext
|
|
2898
|
+
};
|
|
2899
|
+
requestContext.set(MASTRA_USER_KEY, user);
|
|
2900
|
+
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
2901
|
+
if ("isUserContext" in executionContext) {
|
|
2902
|
+
if (executionContext.userName) requestContext.set(MASTRA_USER_NAME_KEY, executionContext.userName);
|
|
2903
|
+
if (executionContext.userEmail) requestContext.set(MASTRA_USER_EMAIL_KEY, executionContext.userEmail);
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Stamp a per-request id and echo it on the response so an upstream
|
|
2908
|
+
* proxy / curl client / browser-side log line can pair its view of
|
|
2909
|
+
* the request with the matching trace span. Reuses `X-Request-Id`
|
|
2910
|
+
* when the upstream already supplies one so multi-hop traces stay
|
|
2911
|
+
* joined; otherwise mints a UUIDv4.
|
|
2912
|
+
*
|
|
2913
|
+
* The id is surfaced as `mastra__requestId` span metadata via
|
|
2914
|
+
* {@link TRACE_REQUEST_CONTEXT_KEYS} and as the `X-Request-Id`
|
|
2915
|
+
* response header so dev tools can copy it from either side.
|
|
2916
|
+
*/
|
|
2917
|
+
configureRequestContextRequestId(req, res, requestContext) {
|
|
2918
|
+
if (requestContext.get(MASTRA_REQUEST_ID_KEY)) return;
|
|
2919
|
+
const headerValue = req.headers["x-request-id"];
|
|
2920
|
+
const requestId = (Array.isArray(headerValue) ? headerValue[0] : headerValue)?.trim() || randomUUID();
|
|
2921
|
+
requestContext.set(MASTRA_REQUEST_ID_KEY, requestId);
|
|
2922
|
+
res.setHeader("X-Request-Id", requestId);
|
|
2923
|
+
}
|
|
2924
|
+
configureRequestContextThreadId(req, res, requestContext) {
|
|
2925
|
+
if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
|
|
2926
|
+
const cookies = httpUtils.parseCookies(req.headers.cookie);
|
|
2927
|
+
const cookieName = stringUtils.toIdentifierWithOptions({
|
|
2928
|
+
delimiter: "_",
|
|
2929
|
+
distinct: true
|
|
2930
|
+
}, "appkit", this.config.name, "sessionId");
|
|
2931
|
+
let sessionId = cookies[cookieName];
|
|
2932
|
+
if (!sessionId) {
|
|
2933
|
+
sessionId = randomUUID();
|
|
2934
|
+
res.cookie(cookieName, sessionId, {
|
|
2935
|
+
httpOnly: true,
|
|
2936
|
+
sameSite: "lax",
|
|
2937
|
+
secure: req.secure,
|
|
2938
|
+
path: "/"
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
|
|
2942
|
+
}
|
|
2943
|
+
configureRequestContextModelOverride(req, requestContext) {
|
|
2944
|
+
if (resolveServingConfig(this.config).allowOverride) {
|
|
2945
|
+
const override = extractModelOverride({
|
|
2946
|
+
headers: req.headers,
|
|
2947
|
+
query: req.query,
|
|
2948
|
+
body: req.body
|
|
2949
|
+
});
|
|
2950
|
+
if (override) requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
};
|
|
2954
|
+
/**
|
|
2955
|
+
* Patches around `@mastra/express`'s custom-route dispatcher so the
|
|
2956
|
+
* plugin's custom API routes (e.g. `historyRoute`) work when
|
|
2957
|
+
* `MastraServer` is hosted on an Express subapp mounted under a parent
|
|
2958
|
+
* path (e.g. `/api/mastra`).
|
|
2959
|
+
*
|
|
2960
|
+
* The adapter's `registerCustomApiRoutes` matches against `req.path`
|
|
2961
|
+
* (mount-relative, correct) but dispatches to its internal Hono
|
|
2962
|
+
* mini-app using `req.originalUrl`, which still contains the parent
|
|
2963
|
+
* mount prefix. The Hono app registers the literal route paths
|
|
2964
|
+
* (for example `/route/history`), so the absolute URL never matches
|
|
2965
|
+
* until we overwrite `originalUrl` for `/route` and `/route/*` to the
|
|
2966
|
+
* mount-relative path.
|
|
2967
|
+
*/
|
|
2968
|
+
function attachRoutePatchMiddleware(app) {
|
|
2969
|
+
app.use((req, _res, next) => {
|
|
2970
|
+
if (!(req.path === "/route" || req.path.startsWith("/route/"))) return next();
|
|
2971
|
+
req.originalUrl = req.path;
|
|
2972
|
+
next();
|
|
2973
|
+
});
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
//#endregion
|
|
2977
|
+
//#region packages/appkit-mastra/src/plugin.ts
|
|
2978
|
+
/**
|
|
2979
|
+
* AppKit plugin that builds one or more Mastra `Agent` instances and
|
|
2980
|
+
* mounts the `@mastra/express` server. Clients drive the conversation
|
|
2981
|
+
* over the standard Mastra agent stream (`@mastra/client-js`'s
|
|
2982
|
+
* `getAgent(id).stream()`), so there's no bespoke chat transport to
|
|
2983
|
+
* keep in sync.
|
|
2984
|
+
*
|
|
2985
|
+
* - Agents: registered through `config.agents` at plugin creation
|
|
2986
|
+
* ({@link MastraAgentDefinition}). Each entry's `tools` field accepts
|
|
2987
|
+
* either a plain record or a `(plugins) => tools` callback that gets
|
|
2988
|
+
* a typed sibling-plugin index ({@link MastraPlugins}). Omit
|
|
2989
|
+
* `config.agents` to get a single built-in `default` analyst.
|
|
2990
|
+
* - Model: each agent call resolves a `MastraModelConfig` via
|
|
2991
|
+
* {@link buildModel} from `./model.js`. Per-agent `model` overrides
|
|
2992
|
+
* (`AgentConfig["model"]` or a `modelId` string) flow through
|
|
2993
|
+
* {@link buildAgents}.
|
|
2994
|
+
* - Memory / storage: per-agent, built by {@link createMemoryBuilder}
|
|
2995
|
+
* from `./memory.js`. Both auto-default to `true` when the
|
|
2996
|
+
* `lakebase` plugin is registered (unless the caller passed
|
|
2997
|
+
* `false` or a custom config). Storage namespaces per agent via
|
|
2998
|
+
* `schemaName: "mastra_<agentId>"`; the vector store is a single
|
|
2999
|
+
* shared singleton across every agent.
|
|
3000
|
+
* - Server: the Express subapp wiring lives in `./server.js`.
|
|
3001
|
+
* - HTTP: AppKit mounts this plugin under `/api/mastra`. Alongside the
|
|
3002
|
+
* Mastra agent routes, the plugin registers `/route/history`
|
|
3003
|
+
* (load + clear thread history), `/models`, `/suggestions`, and the
|
|
3004
|
+
* generic `/embed/:type/:id` resolver for inline chart / data
|
|
3005
|
+
* markers.
|
|
3006
|
+
* - MCP: opt in with `config.mcp` to expose the agents (and optionally
|
|
3007
|
+
* tools) as a Mastra `MCPServer`. It is registered on the `Mastra`
|
|
3008
|
+
* instance via `mcpServers`, so `@mastra/express` serves the stock
|
|
3009
|
+
* MCP transport routes (`/mcp/<serverId>/...`) under the mount. See
|
|
3010
|
+
* `./mcp.js`.
|
|
3011
|
+
*/
|
|
3012
|
+
const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
|
|
3013
|
+
const LAKEBASE_MANIFEST = appkitUtils.data(lakebase).plugin.manifest;
|
|
3014
|
+
/**
|
|
3015
|
+
* AppKit plugin (registered name: `mastra`) that hosts Mastra agents
|
|
3016
|
+
* with optional Lakebase-backed memory and AI SDK chat routes under
|
|
3017
|
+
* the plugin mount (typically `/api/mastra`).
|
|
3018
|
+
*/
|
|
3019
|
+
var MastraPlugin = class MastraPlugin extends Plugin {
|
|
3020
|
+
static manifest = {
|
|
3021
|
+
name: "mastra",
|
|
3022
|
+
displayName: "Mastra",
|
|
3023
|
+
description: "Builds a Mastra Agent with user-scoped workspace auth (asUser) and optional Postgres-backed Mastra Memory via the `lakebase` plugin.",
|
|
3024
|
+
stability: "beta",
|
|
3025
|
+
resources: {
|
|
3026
|
+
required: [],
|
|
3027
|
+
optional: [...GENIE_MANIFEST.resources.required, ...LAKEBASE_MANIFEST.resources.required]
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
/**
|
|
3031
|
+
* Tighten resource requirements based on which features are enabled.
|
|
3032
|
+
* AppKit calls this at registration time (config-aware) so disabled
|
|
3033
|
+
* features don't surface their resource asks to the host app.
|
|
3034
|
+
*/
|
|
3035
|
+
static getResourceRequirements(config) {
|
|
3036
|
+
const resources = [];
|
|
3037
|
+
const enabledManifests = [];
|
|
3038
|
+
if (needsLakebase(config)) enabledManifests.push(LAKEBASE_MANIFEST);
|
|
3039
|
+
for (const m of enabledManifests) for (const resource of m.resources.required) resources.push({
|
|
3040
|
+
...resource,
|
|
3041
|
+
required: true
|
|
3042
|
+
});
|
|
3043
|
+
return resources;
|
|
3044
|
+
}
|
|
3045
|
+
log = logUtils.logger(this);
|
|
3046
|
+
built = null;
|
|
3047
|
+
mastra = null;
|
|
3048
|
+
mastraApp = null;
|
|
3049
|
+
mastraServer = null;
|
|
3050
|
+
/**
|
|
3051
|
+
* The optional MCP server exposing this plugin's agents / tools, or
|
|
3052
|
+
* `null` when `config.mcp` is disabled (the default). Built in
|
|
3053
|
+
* {@link buildAgentAndServer} and registered on the Mastra instance.
|
|
3054
|
+
*/
|
|
3055
|
+
mcp = null;
|
|
3056
|
+
/**
|
|
3057
|
+
* Dedicated service-principal Lakebase pool backing Mastra memory /
|
|
3058
|
+
* storage. Built once in {@link buildAgentAndServer} (outside any
|
|
3059
|
+
* `asUser` scope, so it never inherits a request's OBO identity) and
|
|
3060
|
+
* drained in {@link abortActiveOperations}. `null` until setup runs
|
|
3061
|
+
* or when Lakebase isn't needed.
|
|
3062
|
+
*/
|
|
3063
|
+
servicePrincipalPool = null;
|
|
3064
|
+
async setup() {
|
|
3065
|
+
this.context?.onLifecycle("setup:complete", async () => {
|
|
3066
|
+
this.applyLakebaseAutoDefaults();
|
|
3067
|
+
this.log.info("setup:complete");
|
|
3068
|
+
await this.buildAgentAndServer();
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
/**
|
|
3072
|
+
* When the `lakebase` plugin is registered, auto-enable `storage`
|
|
3073
|
+
* and `memory` unless the caller opted out explicitly (`false` or a
|
|
3074
|
+
* custom config object). Run after `setup:complete` so the lookup
|
|
3075
|
+
* is reliable: any plugin that registers itself synchronously is
|
|
3076
|
+
* already in the registry by the time this fires.
|
|
3077
|
+
*/
|
|
3078
|
+
applyLakebaseAutoDefaults() {
|
|
3079
|
+
if (!(appkitUtils.instance(this.context, lakebase) !== void 0)) return;
|
|
3080
|
+
if (this.config.storage === void 0) this.config.storage = true;
|
|
3081
|
+
if (this.config.memory === void 0) this.config.memory = true;
|
|
3082
|
+
}
|
|
3083
|
+
/**
|
|
3084
|
+
* Drain the memory service-principal pool on shutdown. AppKit calls
|
|
3085
|
+
* this during teardown; the lakebase plugin closes its own SP / OBO
|
|
3086
|
+
* pools the same way. Fire-and-forget so shutdown isn't blocked on a
|
|
3087
|
+
* slow drain, and clear the handle so a re-`setup()` rebuilds it.
|
|
3088
|
+
*/
|
|
3089
|
+
abortActiveOperations() {
|
|
3090
|
+
super.abortActiveOperations();
|
|
3091
|
+
if (this.servicePrincipalPool) {
|
|
3092
|
+
this.log.info("closing memory SP pool");
|
|
3093
|
+
const pool = this.servicePrincipalPool;
|
|
3094
|
+
this.servicePrincipalPool = null;
|
|
3095
|
+
pool.end().catch((err) => {
|
|
3096
|
+
this.log.error("error closing memory SP pool", { error: commonUtils.errorMessage(err) });
|
|
3097
|
+
});
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
exports() {
|
|
3101
|
+
return {
|
|
3102
|
+
list: () => Object.keys(this.built?.agents ?? {}),
|
|
3103
|
+
get: (id) => this.built?.agents[id] ?? null,
|
|
3104
|
+
getDefault: () => (this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
|
|
3105
|
+
getMastra: () => this.mastra,
|
|
3106
|
+
getMcp: () => this.mcp ? {
|
|
3107
|
+
serverId: this.mcp.serverId,
|
|
3108
|
+
http: `/api/${this.name}${this.mcp.httpPath}`,
|
|
3109
|
+
sse: `/api/${this.name}${this.mcp.ssePath}`,
|
|
3110
|
+
messages: `/api/${this.name}${this.mcp.messagePath}`
|
|
3111
|
+
} : null,
|
|
3112
|
+
getMastraServer: () => this.mastraServer,
|
|
3113
|
+
listModels: () => this.listModels(),
|
|
3114
|
+
clearModelsCache: (host) => clearServingEndpointsCache(host)
|
|
3115
|
+
};
|
|
3116
|
+
}
|
|
3117
|
+
clientConfig() {
|
|
3118
|
+
return {
|
|
3119
|
+
basePath: `/api/${this.name}`,
|
|
3120
|
+
defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
|
|
3121
|
+
agents: Object.keys(this.built?.agents ?? {})
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
injectRoutes(router) {
|
|
3125
|
+
router.get(MASTRA_ROUTES.models, (req, res, next) => {
|
|
3126
|
+
this.userScopedSelf(req).listModels().then((endpoints) => res.json({ endpoints })).catch(next);
|
|
3127
|
+
});
|
|
3128
|
+
const embedResolvers = {
|
|
3129
|
+
chart: (req, id, signal) => {
|
|
3130
|
+
const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
|
|
3131
|
+
return fetchChart(id, {
|
|
3132
|
+
...timeoutMs !== void 0 ? { timeoutMs } : {},
|
|
3133
|
+
signal
|
|
3134
|
+
});
|
|
3135
|
+
},
|
|
3136
|
+
data: (req, id, signal) => {
|
|
3137
|
+
const limit = parseStatementLimit(req.query["limit"]);
|
|
3138
|
+
return this.userScopedSelf(req).fetchStatement(id, {
|
|
3139
|
+
...limit !== void 0 ? { limit } : {},
|
|
3140
|
+
signal
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
};
|
|
3144
|
+
router.get(`${MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
|
|
3145
|
+
const type = req.params["type"] ?? "";
|
|
3146
|
+
const id = req.params["id"];
|
|
3147
|
+
const resolve = embedResolvers[type];
|
|
3148
|
+
if (!resolve) {
|
|
3149
|
+
res.status(404).json({ error: `unsupported embed type: ${type}` });
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
if (!id) {
|
|
3153
|
+
res.status(400).json({ error: "id is required" });
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
const controller = new AbortController();
|
|
3157
|
+
req.on("close", () => controller.abort());
|
|
3158
|
+
resolve(req, id, controller.signal).then((entry) => {
|
|
3159
|
+
if (entry === void 0) {
|
|
3160
|
+
res.status(404).json({ error: `${type} not found` });
|
|
3161
|
+
return;
|
|
3162
|
+
}
|
|
3163
|
+
res.json(entry);
|
|
3164
|
+
}).catch(next);
|
|
3165
|
+
});
|
|
3166
|
+
const handleSuggestions = (req, res) => {
|
|
3167
|
+
const controller = new AbortController();
|
|
3168
|
+
req.on("close", () => controller.abort());
|
|
3169
|
+
this.userScopedSelf(req).fetchSuggestions(controller.signal).then((questions) => res.json({ questions })).catch((err) => {
|
|
3170
|
+
this.log.warn("suggestions:error", { error: commonUtils.errorMessage(err) });
|
|
3171
|
+
res.json({ questions: [] });
|
|
3172
|
+
});
|
|
3173
|
+
};
|
|
3174
|
+
router.get(MASTRA_ROUTES.suggestions, handleSuggestions);
|
|
3175
|
+
router.get(`${MASTRA_ROUTES.suggestions}/:agentId`, handleSuggestions);
|
|
3176
|
+
router.use((req, res, next) => {
|
|
3177
|
+
if (!this.mastraApp) return res.status(503).end();
|
|
3178
|
+
return this.userScopedSelf(req).dispatchMastra(req, res, next);
|
|
3179
|
+
});
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* Invoke the Mastra express sub-app. Exists as a method (instead of reading
|
|
3183
|
+
* `this.mastraApp` through the `asUser(req)` proxy at the call site) so the
|
|
3184
|
+
* proxy binds this plain method - whose `.bind` is `Function.prototype.bind`
|
|
3185
|
+
* - rather than the express app, whose `.bind` is the HTTP BIND route
|
|
3186
|
+
* registrar (see the note in `injectRoutes`). Runs inside the user scope so
|
|
3187
|
+
* `getExecutionContext()` returns the OBO client for the agent/model
|
|
3188
|
+
* resolvers.
|
|
3189
|
+
*/
|
|
3190
|
+
dispatchMastra(req, res, next) {
|
|
3191
|
+
this.mastraApp(req, res, next);
|
|
3192
|
+
}
|
|
3193
|
+
/**
|
|
3194
|
+
* Implementation backing the `/suggestions` route. Runs inside the
|
|
3195
|
+
* AppKit user-context proxy so `getExecutionContext()` returns the
|
|
3196
|
+
* OBO-scoped client. Resolves the plugin's Genie spaces and merges
|
|
3197
|
+
* their curated `sample_questions` (see {@link collectSpaceSuggestions}).
|
|
3198
|
+
* Returns `[]` when no Genie space is configured so the client
|
|
3199
|
+
* shows a bare empty state instead of built-in example prompts.
|
|
3200
|
+
*/
|
|
3201
|
+
async fetchSuggestions(signal) {
|
|
3202
|
+
const spaces = resolveGenieSpaces(this.config, this.context);
|
|
3203
|
+
if (Object.keys(spaces).length === 0) return [];
|
|
3204
|
+
const client = getExecutionContext().client;
|
|
3205
|
+
return collectSpaceSuggestions({
|
|
3206
|
+
spaces,
|
|
3207
|
+
client,
|
|
3208
|
+
...signal ? { signal } : {}
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Implementation backing the `data` embed resolver
|
|
3213
|
+
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
|
3214
|
+
* `getExecutionContext()` returns the OBO-scoped workspace
|
|
3215
|
+
* client, then reuses the same `fetchStatementData` pipeline
|
|
3216
|
+
* the `get_statement` tool runs so the LLM and the UI see the
|
|
3217
|
+
* exact same shape for the same statement.
|
|
3218
|
+
*
|
|
3219
|
+
* Returns `undefined` for upstream 404s so the route can map
|
|
3220
|
+
* them to a clean HTTP 404; any other failure bubbles up.
|
|
3221
|
+
*/
|
|
3222
|
+
async fetchStatement(statementId, options = {}) {
|
|
3223
|
+
const client = getExecutionContext().client;
|
|
3224
|
+
const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
|
|
3225
|
+
try {
|
|
3226
|
+
const data = await fetchStatementData(client, statementId, {
|
|
3227
|
+
limit,
|
|
3228
|
+
...options.signal ? { signal: options.signal } : {}
|
|
3229
|
+
});
|
|
3230
|
+
return {
|
|
3231
|
+
columns: data.columns,
|
|
3232
|
+
rows: data.rows,
|
|
3233
|
+
rowCount: data.rowCount,
|
|
3234
|
+
truncated: data.rows.length < data.rowCount
|
|
3235
|
+
};
|
|
3236
|
+
} catch (err) {
|
|
3237
|
+
if (apiUtils.isNotFoundError(err)) return void 0;
|
|
3238
|
+
throw err;
|
|
3239
|
+
}
|
|
3240
|
+
}
|
|
3241
|
+
/**
|
|
3242
|
+
* Return `this.asUser(req)` when the request carries an OBO token,
|
|
3243
|
+
* otherwise return `this` directly. Prevents the noisy AppKit warn
|
|
3244
|
+
* (`asUser() called without user token in development mode. Skipping
|
|
3245
|
+
* user impersonation.`) on every request in local dev where the
|
|
3246
|
+
* browser never sends `x-forwarded-access-token`. Behavior is
|
|
3247
|
+
* unchanged in production: a missing token always means a real OBO
|
|
3248
|
+
* proxy call (and AppKit will throw upstream if that's wrong).
|
|
3249
|
+
*/
|
|
3250
|
+
userScopedSelf(req) {
|
|
3251
|
+
return req.header("x-forwarded-access-token") ? this.asUser(req) : this;
|
|
3252
|
+
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Implementation backing both the `/models` route and the
|
|
3255
|
+
* `listModels` export. Runs inside the AppKit user-context proxy so
|
|
3256
|
+
* `getExecutionContext()` returns the OBO-scoped client.
|
|
3257
|
+
*/
|
|
3258
|
+
async listModels() {
|
|
3259
|
+
const client = getExecutionContext().client;
|
|
3260
|
+
return listServingEndpoints(client, (await client.config.getHost()).toString(), { ttlMs: resolveServingConfig(this.config).ttlMs });
|
|
3261
|
+
}
|
|
3262
|
+
async buildAgentAndServer() {
|
|
3263
|
+
if (needsLakebase(this.config)) this.servicePrincipalPool = await createServicePrincipalPool(appkitUtils.require(this.context, lakebase, this.config).exports().getPgConfig());
|
|
3264
|
+
const memoryBuilder = this.servicePrincipalPool ? createMemoryBuilder(this.config, this.servicePrincipalPool) : void 0;
|
|
3265
|
+
this.log.debug("build:start", {
|
|
3266
|
+
lakebase: memoryBuilder !== void 0,
|
|
3267
|
+
stripStaleCharts: this.config.stripStaleCharts !== false
|
|
3268
|
+
});
|
|
3269
|
+
this.built = await buildAgents({
|
|
3270
|
+
config: this.config,
|
|
3271
|
+
context: this.context,
|
|
3272
|
+
memoryBuilder,
|
|
3273
|
+
log: this.log
|
|
3274
|
+
});
|
|
3275
|
+
const instanceStorage = memoryBuilder?.instanceStorage();
|
|
3276
|
+
const observability = await buildObservability({ serviceName: this.name });
|
|
3277
|
+
this.mcp = buildMcpServer({
|
|
3278
|
+
config: this.config,
|
|
3279
|
+
pluginName: this.name,
|
|
3280
|
+
displayName: MastraPlugin.manifest.displayName,
|
|
3281
|
+
agents: this.built.agents,
|
|
3282
|
+
ambientTools: this.built.ambientTools
|
|
3283
|
+
});
|
|
3284
|
+
this.mastra = new Mastra({
|
|
3285
|
+
agents: this.built.agents,
|
|
3286
|
+
...instanceStorage ? { storage: instanceStorage } : {},
|
|
3287
|
+
...observability ? { observability } : {},
|
|
3288
|
+
...this.mcp ? { mcpServers: { [this.mcp.serverId]: this.mcp.server } } : {}
|
|
3289
|
+
});
|
|
3290
|
+
this.mastraApp = express();
|
|
3291
|
+
attachRoutePatchMiddleware(this.mastraApp);
|
|
3292
|
+
this.mastraServer = new MastraServer$1(this.config, {
|
|
3293
|
+
app: this.mastraApp,
|
|
3294
|
+
mastra: this.mastra,
|
|
3295
|
+
prefix: "",
|
|
3296
|
+
customApiRoutes: [...historyRoute({
|
|
3297
|
+
path: MASTRA_ROUTES.history,
|
|
3298
|
+
agent: this.built.defaultAgentId
|
|
3299
|
+
}), ...historyRoute({ path: `${MASTRA_ROUTES.history}/:agentId` })]
|
|
3300
|
+
});
|
|
3301
|
+
await this.mastraServer.init();
|
|
3302
|
+
this.log.debug("build:done", {
|
|
3303
|
+
agents: Object.keys(this.built.agents),
|
|
3304
|
+
defaultAgent: this.built.defaultAgentId,
|
|
3305
|
+
routes: [
|
|
3306
|
+
"/route/history",
|
|
3307
|
+
"/models",
|
|
3308
|
+
"/suggestions",
|
|
3309
|
+
"/embed/:type/:id"
|
|
3310
|
+
],
|
|
3311
|
+
instanceStorage: instanceStorage !== void 0,
|
|
3312
|
+
observability: observability !== void 0 ? "mlflow" : "off",
|
|
3313
|
+
mcp: this.mcp ? `/api/${this.name}${this.mcp.httpPath}` : "off"
|
|
3314
|
+
});
|
|
3315
|
+
}
|
|
3316
|
+
};
|
|
3317
|
+
/**
|
|
3318
|
+
* Parse the optional `?timeoutMs=<n>` query parameter from a
|
|
3319
|
+
* `GET /embed/chart/:id` request. Accepts a positive integer up
|
|
3320
|
+
* to 5 minutes (clamped) and rejects everything else as
|
|
3321
|
+
* `undefined` so {@link fetchChart} falls back to its default.
|
|
3322
|
+
* Express produces `string | string[] | undefined`; we normalize
|
|
3323
|
+
* to the first scalar before parsing.
|
|
3324
|
+
*/
|
|
3325
|
+
function parseTimeoutMs(raw) {
|
|
3326
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
3327
|
+
if (typeof v !== "string") return void 0;
|
|
3328
|
+
const n = Number(v);
|
|
3329
|
+
if (!Number.isFinite(n) || n <= 0) return void 0;
|
|
3330
|
+
return Math.min(Math.floor(n), 5 * 6e4);
|
|
3331
|
+
}
|
|
3332
|
+
/**
|
|
3333
|
+
* Parse the optional `?limit=<n>` query parameter from a
|
|
3334
|
+
* `GET /embed/data/:id` request. Accepts a non-negative
|
|
3335
|
+
* integer and lets the route clamp to `STATEMENT_ROW_CAP`;
|
|
3336
|
+
* rejects anything else as `undefined` so the route falls back
|
|
3337
|
+
* to the server-side cap.
|
|
3338
|
+
*/
|
|
3339
|
+
function parseStatementLimit(raw) {
|
|
3340
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
3341
|
+
if (typeof v !== "string") return void 0;
|
|
3342
|
+
const n = Number(v);
|
|
3343
|
+
if (!Number.isFinite(n) || n < 0) return void 0;
|
|
3344
|
+
return Math.floor(n);
|
|
3345
|
+
}
|
|
3346
|
+
const mastra = toPlugin(MastraPlugin);
|
|
3347
|
+
|
|
3348
|
+
//#endregion
|
|
3349
|
+
export { DEFAULT_AGENT_MAX_STEPS, DEFAULT_GENIE_ALIAS, DEFAULT_STYLE_INSTRUCTIONS, FALLBACK_AGENT_ID, GENIE_INSTRUCTIONS, MASTRA_MODEL_OVERRIDE_KEY, MASTRA_REQUEST_ID_KEY, MASTRA_USER_EMAIL_KEY, MASTRA_USER_KEY, MASTRA_USER_NAME_KEY, MastraPlugin, TRACE_REQUEST_CONTEXT_KEYS, buildAgents, buildGenieToolkitProvider, buildGenieTools, buildMcpServer, buildRenderDataTool, chartPlannerRequestSchema, collectSpaceSuggestions, createAgent, createTool, extractModelOverride, fetchChart, mastra, normalizeGenieSpaces, prepareChart, resolveGenieSpaces, tool };
|