@dbx-tools/appkit-mastra 0.1.25 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -303
- package/dist/src/agents.d.ts +10 -0
- package/dist/src/agents.js +15 -2
- package/dist/src/chart.d.ts +131 -79
- package/dist/src/chart.js +386 -279
- package/dist/src/config.d.ts +19 -16
- package/dist/src/genie.d.ts +90 -106
- package/dist/src/genie.js +523 -642
- package/dist/src/intercept.d.ts +48 -0
- package/dist/src/intercept.js +167 -0
- package/dist/src/plugin.d.ts +12 -0
- package/dist/src/plugin.js +185 -1
- package/dist/src/processors/strip-stale-charts.d.ts +16 -13
- package/dist/src/processors/strip-stale-charts.js +16 -13
- package/dist/src/statement.d.ts +73 -0
- package/dist/src/statement.js +118 -0
- package/dist/src/tools/email.js +27 -28
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/src/agents.ts +16 -2
- package/src/chart.ts +499 -319
- package/src/config.ts +19 -16
- package/src/genie.ts +560 -768
- package/src/intercept.ts +206 -0
- package/src/plugin.ts +210 -2
- package/src/processors/strip-stale-charts.ts +16 -13
- package/src/statement.ts +127 -0
- package/src/tools/email.ts +27 -28
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Server-Sent-Events frame interceptor for the native
|
|
3
|
+
* Mastra agent `/stream` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
|
|
6
|
+
* SSE frame per chunk, where the decoded JSON carries a
|
|
7
|
+
* discriminating `type` (`text-delta`, `reasoning-delta`,
|
|
8
|
+
* `tool-call`, `step-finish`, ...) and is terminated by a
|
|
9
|
+
* `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
|
|
10
|
+
* an Express response and runs a caller-supplied
|
|
11
|
+
* {@link StreamFrameInterceptor} over each decoded frame so the caller
|
|
12
|
+
* can keep, rewrite, or drop it; every non-`data:` byte (SSE
|
|
13
|
+
* keep-alive comments, the `[DONE]` sentinel, blank padding) passes
|
|
14
|
+
* through verbatim.
|
|
15
|
+
*
|
|
16
|
+
* The wrap is inert unless the response turns out to be a streamed
|
|
17
|
+
* `200 text/event-stream` body, so non-streaming JSON routes and
|
|
18
|
+
* error responses are never touched.
|
|
19
|
+
*/
|
|
20
|
+
import type express from "express";
|
|
21
|
+
/**
|
|
22
|
+
* Per-frame interceptor invoked with the `JSON.parse`d object of every
|
|
23
|
+
* `data: {json}` frame on the Mastra `/stream` response. The return
|
|
24
|
+
* value decides the frame's fate:
|
|
25
|
+
*
|
|
26
|
+
* - `true`: forward the frame's original bytes unchanged (no
|
|
27
|
+
* re-serialization).
|
|
28
|
+
* - `false`: drop the frame entirely.
|
|
29
|
+
* - `{ replace }`: re-emit the frame with the original `data:` prefix
|
|
30
|
+
* and trailing whitespace preserved and only the JSON body replaced
|
|
31
|
+
* by `JSON.stringify(replace)`.
|
|
32
|
+
*/
|
|
33
|
+
export type StreamFrameInterceptor = (chunk: unknown) => {
|
|
34
|
+
replace: unknown;
|
|
35
|
+
} | true | false;
|
|
36
|
+
/**
|
|
37
|
+
* Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
|
|
38
|
+
* response is run through `interceptor` (keep / rewrite / drop).
|
|
39
|
+
*
|
|
40
|
+
* Interception only engages once the response is confirmed to be a
|
|
41
|
+
* `200 text/event-stream` body (decided lazily on the first write,
|
|
42
|
+
* after the handler has set status + content-type); any other
|
|
43
|
+
* response streams through unchanged. A {@link StringDecoder}
|
|
44
|
+
* reassembles multi-byte UTF-8 sequences that straddle chunk
|
|
45
|
+
* boundaries, and a running buffer holds the trailing partial frame
|
|
46
|
+
* until its `\n\n` arrives.
|
|
47
|
+
*/
|
|
48
|
+
export declare function installStreamEventInterceptor(res: express.Response, interceptor: StreamFrameInterceptor): void;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Server-Sent-Events frame interceptor for the native
|
|
3
|
+
* Mastra agent `/stream` endpoint.
|
|
4
|
+
*
|
|
5
|
+
* The Mastra stream (`@mastra/express`) emits one `data: <json>\n\n`
|
|
6
|
+
* SSE frame per chunk, where the decoded JSON carries a
|
|
7
|
+
* discriminating `type` (`text-delta`, `reasoning-delta`,
|
|
8
|
+
* `tool-call`, `step-finish`, ...) and is terminated by a
|
|
9
|
+
* `data: [DONE]` sentinel. {@link installStreamEventInterceptor} wraps
|
|
10
|
+
* an Express response and runs a caller-supplied
|
|
11
|
+
* {@link StreamFrameInterceptor} over each decoded frame so the caller
|
|
12
|
+
* can keep, rewrite, or drop it; every non-`data:` byte (SSE
|
|
13
|
+
* keep-alive comments, the `[DONE]` sentinel, blank padding) passes
|
|
14
|
+
* through verbatim.
|
|
15
|
+
*
|
|
16
|
+
* The wrap is inert unless the response turns out to be a streamed
|
|
17
|
+
* `200 text/event-stream` body, so non-streaming JSON routes and
|
|
18
|
+
* error responses are never touched.
|
|
19
|
+
*/
|
|
20
|
+
import { StringDecoder } from "node:string_decoder";
|
|
21
|
+
/** SSE event separator used by the Mastra stream wire format. */
|
|
22
|
+
const FRAME_DELIMITER = "\n\n";
|
|
23
|
+
/**
|
|
24
|
+
* Matches a whole SSE frame whose `data:` payload is a JSON object,
|
|
25
|
+
* capturing it in three parts: the `data:` field prefix (including
|
|
26
|
+
* whatever inter-token whitespace the producer used), the JSON object
|
|
27
|
+
* body, and any trailing whitespace. Anchored end-to-end so it only
|
|
28
|
+
* matches a single self-contained object frame; anything else (SSE
|
|
29
|
+
* comments, the `[DONE]` sentinel, text/array payloads) fails the
|
|
30
|
+
* match and is forwarded verbatim without a parse attempt. On rewrite
|
|
31
|
+
* the captured prefix/suffix are re-emitted as-is so only the body
|
|
32
|
+
* changes.
|
|
33
|
+
*/
|
|
34
|
+
const DATA_OBJECT_RE = /^(data:\s*)(\{[\s\S]*\})(\s*)$/;
|
|
35
|
+
/**
|
|
36
|
+
* True when `res` is a `200 text/event-stream` body - the only
|
|
37
|
+
* response shape we intercept. Content-type based so it tracks the
|
|
38
|
+
* Mastra `/stream` route without hard-coding the path, and naturally
|
|
39
|
+
* skips the JSON routes (history, models, charts, statements) and any
|
|
40
|
+
* non-200 error response.
|
|
41
|
+
*/
|
|
42
|
+
function isEventStream(res) {
|
|
43
|
+
if (res.statusCode !== 200)
|
|
44
|
+
return false;
|
|
45
|
+
const contentType = String(res.getHeader("content-type") ?? "").toLowerCase();
|
|
46
|
+
return contentType.includes("text/event-stream");
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run `interceptor` over a single SSE frame. Forwards anything that
|
|
50
|
+
* isn't a parseable `data: {json}` object frame verbatim (comments,
|
|
51
|
+
* the `[DONE]` sentinel, blank padding, unparseable / non-object
|
|
52
|
+
* payloads) so the interceptor only ever sees real chunk objects.
|
|
53
|
+
* Returns the frame string to emit, or `undefined` to drop the frame.
|
|
54
|
+
*/
|
|
55
|
+
function interceptFrame(frame, interceptor) {
|
|
56
|
+
const match = DATA_OBJECT_RE.exec(frame);
|
|
57
|
+
if (!match)
|
|
58
|
+
return frame;
|
|
59
|
+
const [, prefix, body, suffix] = match;
|
|
60
|
+
let parsed;
|
|
61
|
+
try {
|
|
62
|
+
parsed = JSON.parse(body);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Forward unparseable payloads untouched - feeding a partial /
|
|
66
|
+
// malformed frame to the interceptor risks corrupting bytes the
|
|
67
|
+
// client could still have handled.
|
|
68
|
+
return frame;
|
|
69
|
+
}
|
|
70
|
+
const result = interceptor(parsed);
|
|
71
|
+
if (result === true)
|
|
72
|
+
return frame;
|
|
73
|
+
if (result === false)
|
|
74
|
+
return undefined;
|
|
75
|
+
// Preserve the exact captured prefix/suffix so only the JSON body
|
|
76
|
+
// changes; the framing the client parses stays byte-identical.
|
|
77
|
+
return `${prefix}${JSON.stringify(result.replace)}${suffix}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Wrap `res.write` / `res.end` so each SSE frame on a streamed Mastra
|
|
81
|
+
* response is run through `interceptor` (keep / rewrite / drop).
|
|
82
|
+
*
|
|
83
|
+
* Interception only engages once the response is confirmed to be a
|
|
84
|
+
* `200 text/event-stream` body (decided lazily on the first write,
|
|
85
|
+
* after the handler has set status + content-type); any other
|
|
86
|
+
* response streams through unchanged. A {@link StringDecoder}
|
|
87
|
+
* reassembles multi-byte UTF-8 sequences that straddle chunk
|
|
88
|
+
* boundaries, and a running buffer holds the trailing partial frame
|
|
89
|
+
* until its `\n\n` arrives.
|
|
90
|
+
*/
|
|
91
|
+
export function installStreamEventInterceptor(res, interceptor) {
|
|
92
|
+
const origWrite = res.write.bind(res);
|
|
93
|
+
const origEnd = res.end.bind(res);
|
|
94
|
+
const decoder = new StringDecoder("utf8");
|
|
95
|
+
let engaged;
|
|
96
|
+
let buffer = "";
|
|
97
|
+
// Append decoded text, emit every whole `\n\n`-delimited frame the
|
|
98
|
+
// interceptor keeps, and retain any trailing partial frame in
|
|
99
|
+
// `buffer`. On `final`, the residual buffer is flushed as the last
|
|
100
|
+
// frame so a stream that doesn't end on a delimiter isn't dropped.
|
|
101
|
+
const intercept = (text, final) => {
|
|
102
|
+
buffer += text;
|
|
103
|
+
const out = [];
|
|
104
|
+
let idx;
|
|
105
|
+
while ((idx = buffer.indexOf(FRAME_DELIMITER)) !== -1) {
|
|
106
|
+
const frame = buffer.slice(0, idx);
|
|
107
|
+
buffer = buffer.slice(idx + FRAME_DELIMITER.length);
|
|
108
|
+
const kept = interceptFrame(frame, interceptor);
|
|
109
|
+
if (kept !== undefined) {
|
|
110
|
+
out.push(kept, FRAME_DELIMITER);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (final && buffer.length > 0) {
|
|
114
|
+
const kept = interceptFrame(buffer, interceptor);
|
|
115
|
+
if (kept !== undefined)
|
|
116
|
+
out.push(kept);
|
|
117
|
+
buffer = "";
|
|
118
|
+
}
|
|
119
|
+
return out.length === 0 ? "" : out.join("");
|
|
120
|
+
};
|
|
121
|
+
const toText = (chunk) => {
|
|
122
|
+
if (typeof chunk === "string")
|
|
123
|
+
return chunk;
|
|
124
|
+
if (Buffer.isBuffer(chunk))
|
|
125
|
+
return decoder.write(chunk);
|
|
126
|
+
if (chunk instanceof Uint8Array) {
|
|
127
|
+
return decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength));
|
|
128
|
+
}
|
|
129
|
+
return "";
|
|
130
|
+
};
|
|
131
|
+
res.write = function patchedWrite(chunk, encodingOrCb, cb) {
|
|
132
|
+
if (engaged === undefined)
|
|
133
|
+
engaged = isEventStream(res);
|
|
134
|
+
if (!engaged) {
|
|
135
|
+
return origWrite(chunk, encodingOrCb, cb);
|
|
136
|
+
}
|
|
137
|
+
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
138
|
+
const out = intercept(toText(chunk), false);
|
|
139
|
+
if (out.length === 0) {
|
|
140
|
+
// The chunk only advanced a partial frame; nothing to forward
|
|
141
|
+
// yet. Honor the write callback so backpressure-aware writers
|
|
142
|
+
// don't stall waiting on an ack that never comes.
|
|
143
|
+
if (callback)
|
|
144
|
+
queueMicrotask(() => callback());
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return origWrite(out, callback);
|
|
148
|
+
};
|
|
149
|
+
res.end = function patchedEnd(chunk, encodingOrCb, cb) {
|
|
150
|
+
if (engaged === undefined)
|
|
151
|
+
engaged = isEventStream(res);
|
|
152
|
+
if (!engaged) {
|
|
153
|
+
return origEnd(chunk, encodingOrCb, cb);
|
|
154
|
+
}
|
|
155
|
+
const callback = typeof chunk === "function"
|
|
156
|
+
? chunk
|
|
157
|
+
: typeof encodingOrCb === "function"
|
|
158
|
+
? encodingOrCb
|
|
159
|
+
: cb;
|
|
160
|
+
const text = (typeof chunk !== "function" && chunk !== undefined ? toText(chunk) : "") +
|
|
161
|
+
decoder.end();
|
|
162
|
+
const out = intercept(text, true);
|
|
163
|
+
if (out.length > 0)
|
|
164
|
+
origWrite(out);
|
|
165
|
+
return origEnd(callback);
|
|
166
|
+
};
|
|
167
|
+
}
|
package/dist/src/plugin.d.ts
CHANGED
|
@@ -109,6 +109,18 @@ export declare class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
109
109
|
};
|
|
110
110
|
clientConfig(): Record<string, unknown>;
|
|
111
111
|
injectRoutes(router: IAppRouter): void;
|
|
112
|
+
/**
|
|
113
|
+
* Implementation backing the `data` embed resolver
|
|
114
|
+
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
|
115
|
+
* `getExecutionContext()` returns the OBO-scoped workspace
|
|
116
|
+
* client, then reuses the same `fetchStatementData` pipeline
|
|
117
|
+
* the `get_statement` tool runs so the LLM and the UI see the
|
|
118
|
+
* exact same shape for the same statement.
|
|
119
|
+
*
|
|
120
|
+
* Returns `undefined` for upstream 404s so the route can map
|
|
121
|
+
* them to a clean HTTP 404; any other failure bubbles up.
|
|
122
|
+
*/
|
|
123
|
+
private fetchStatement;
|
|
112
124
|
/**
|
|
113
125
|
* Return `this.asUser(req)` when the request carries an OBO token,
|
|
114
126
|
* otherwise return `this` directly. Prevents the noisy AppKit warn
|
package/dist/src/plugin.js
CHANGED
|
@@ -27,16 +27,19 @@
|
|
|
27
27
|
* AI SDK transport URL is `/api/mastra/route/chat/<agentId>`.
|
|
28
28
|
*/
|
|
29
29
|
import { genie, getExecutionContext, lakebase, Plugin, toPlugin, } from "@databricks/appkit";
|
|
30
|
-
import { appkitUtils, logUtils } from "@dbx-tools/shared";
|
|
30
|
+
import { appkitUtils, commonUtils, logUtils } from "@dbx-tools/shared";
|
|
31
31
|
import { chatRoute } from "@mastra/ai-sdk";
|
|
32
32
|
import { Mastra } from "@mastra/core/mastra";
|
|
33
33
|
import express from "express";
|
|
34
34
|
import { buildAgents, FALLBACK_AGENT_ID } from "./agents.js";
|
|
35
|
+
import { fetchChart } from "./chart.js";
|
|
35
36
|
import { historyRoute } from "./history.js";
|
|
36
37
|
import { createMemoryBuilder, needsLakebase } from "./memory.js";
|
|
37
38
|
import { buildObservability } from "./observability.js";
|
|
38
39
|
import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
|
|
40
|
+
import { installStreamEventInterceptor, } from "./intercept.js";
|
|
39
41
|
import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
|
|
42
|
+
import { fetchStatementData, isStatementNotFoundError, STATEMENT_ROW_CAP, } from "./statement.js";
|
|
40
43
|
const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
|
|
41
44
|
const LAKEBASE_MANIFEST = appkitUtils.data(lakebase).plugin.manifest;
|
|
42
45
|
/**
|
|
@@ -171,6 +174,7 @@ export class MastraPlugin extends Plugin {
|
|
|
171
174
|
modelsPath: `${basePath}/models`,
|
|
172
175
|
historyPath: `${basePath}/route/history`,
|
|
173
176
|
historyPathTemplate: `${basePath}/route/history/:agentId`,
|
|
177
|
+
embedPathTemplate: `${basePath}/embed/:type/:id`,
|
|
174
178
|
defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
|
|
175
179
|
agents: Object.keys(this.built?.agents ?? {}),
|
|
176
180
|
};
|
|
@@ -188,12 +192,126 @@ export class MastraPlugin extends Plugin {
|
|
|
188
192
|
.then((endpoints) => res.json({ endpoints }))
|
|
189
193
|
.catch(next);
|
|
190
194
|
});
|
|
195
|
+
// `GET /embed/:type/:id` is the single resolver for every embed
|
|
196
|
+
// marker the agent emits in prose (`[chart:<id>]`,
|
|
197
|
+
// `[data:<id>]`, ...). `:type` selects a resolver from the
|
|
198
|
+
// registry below; `:id` is that resolver's lookup key. The
|
|
199
|
+
// grammar (see `marker.ts`) is type-agnostic on purpose - new
|
|
200
|
+
// embed kinds are added by registering a resolver here, with no
|
|
201
|
+
// client or grammar change.
|
|
202
|
+
//
|
|
203
|
+
// Status codes:
|
|
204
|
+
// - 200 with the resolver's JSON body when the id resolves.
|
|
205
|
+
// - 404 when `:type` isn't registered (unsupported embed
|
|
206
|
+
// type) OR a registered resolver can't find `:id` (unknown
|
|
207
|
+
// / expired - e.g. a chart past its 1h TTL or a fabricated
|
|
208
|
+
// id the model never minted).
|
|
209
|
+
// - 400 when `:id` is empty.
|
|
210
|
+
//
|
|
211
|
+
// Per-type query knobs and behavior:
|
|
212
|
+
// - `chart`: long-polls the chart cache until the entry
|
|
213
|
+
// settles (`result` / `error`) or the budget elapses (then
|
|
214
|
+
// returns the still-processing entry to poll again).
|
|
215
|
+
// `?timeoutMs=<n>` (default 60s, capped 5min) tunes it.
|
|
216
|
+
// - `data`: one OBO-scoped Statement Execution fetch.
|
|
217
|
+
// `?limit=<n>` caps rows (clamped to STATEMENT_ROW_CAP).
|
|
218
|
+
//
|
|
219
|
+
// Built once (this handler is registered once) and keyed by the
|
|
220
|
+
// raw `:type` token. Each resolver gets the request (for query
|
|
221
|
+
// parsing + OBO scoping) and an `AbortSignal` bridged off the
|
|
222
|
+
// connection `close` event so a long-poll unblocks the instant
|
|
223
|
+
// the client disconnects. `undefined` from a resolver maps to a
|
|
224
|
+
// clean 404; thrown errors bubble through `next(err)`.
|
|
225
|
+
const embedResolvers = {
|
|
226
|
+
chart: (req, id, signal) => {
|
|
227
|
+
const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
|
|
228
|
+
return fetchChart(id, {
|
|
229
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
230
|
+
signal,
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
data: (req, id, signal) => {
|
|
234
|
+
const limit = parseStatementLimit(req.query["limit"]);
|
|
235
|
+
return this.userScopedSelf(req).fetchStatement(id, {
|
|
236
|
+
...(limit !== undefined ? { limit } : {}),
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
router.get("/embed/:type/:id", (req, res, next) => {
|
|
242
|
+
const type = req.params["type"] ?? "";
|
|
243
|
+
const id = req.params["id"];
|
|
244
|
+
const resolve = embedResolvers[type];
|
|
245
|
+
if (!resolve) {
|
|
246
|
+
res.status(404).json({ error: `unsupported embed type: ${type}` });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!id) {
|
|
250
|
+
res.status(400).json({ error: "id is required" });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
// Express's `req` predates `AbortSignal`; bridge the `close`
|
|
254
|
+
// event onto an `AbortController` so a closed connection
|
|
255
|
+
// unblocks any long-poll immediately and frees the request
|
|
256
|
+
// thread. The listener is GC'd with the request on normal
|
|
257
|
+
// completion.
|
|
258
|
+
const controller = new AbortController();
|
|
259
|
+
req.on("close", () => controller.abort());
|
|
260
|
+
resolve(req, id, controller.signal)
|
|
261
|
+
.then((entry) => {
|
|
262
|
+
if (entry === undefined) {
|
|
263
|
+
res.status(404).json({ error: `${type} not found` });
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
res.json(entry);
|
|
267
|
+
})
|
|
268
|
+
.catch(next);
|
|
269
|
+
});
|
|
191
270
|
router.use("", (req, res, next) => {
|
|
192
271
|
if (!this.mastraApp)
|
|
193
272
|
return res.status(503).end();
|
|
273
|
+
// Run each Mastra `/stream` SSE frame through the interceptor
|
|
274
|
+
// (keep / rewrite / drop). Only engages on a
|
|
275
|
+
// `200 text/event-stream` body, so the JSON routes above and any
|
|
276
|
+
// error response are untouched.
|
|
277
|
+
installStreamEventInterceptor(res, interceptStreamFrame);
|
|
194
278
|
return this.userScopedSelf(req).mastraApp(req, res, next);
|
|
195
279
|
});
|
|
196
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Implementation backing the `data` embed resolver
|
|
283
|
+
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
|
284
|
+
* `getExecutionContext()` returns the OBO-scoped workspace
|
|
285
|
+
* client, then reuses the same `fetchStatementData` pipeline
|
|
286
|
+
* the `get_statement` tool runs so the LLM and the UI see the
|
|
287
|
+
* exact same shape for the same statement.
|
|
288
|
+
*
|
|
289
|
+
* Returns `undefined` for upstream 404s so the route can map
|
|
290
|
+
* them to a clean HTTP 404; any other failure bubbles up.
|
|
291
|
+
*/
|
|
292
|
+
async fetchStatement(statementId, options = {}) {
|
|
293
|
+
const client = getExecutionContext().client;
|
|
294
|
+
const limit = Math.min(options.limit ?? STATEMENT_ROW_CAP, STATEMENT_ROW_CAP);
|
|
295
|
+
try {
|
|
296
|
+
const data = await fetchStatementData(client, statementId, {
|
|
297
|
+
limit,
|
|
298
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
columns: data.columns,
|
|
302
|
+
rows: data.rows,
|
|
303
|
+
rowCount: data.rowCount,
|
|
304
|
+
truncated: data.rows.length < data.rowCount,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
// The Databricks SDK throws on 404; surface as `undefined`
|
|
309
|
+
// so the route maps to a clean HTTP 404 instead of a 500.
|
|
310
|
+
if (isStatementNotFoundError(err))
|
|
311
|
+
return undefined;
|
|
312
|
+
throw err;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
197
315
|
/**
|
|
198
316
|
* Return `this.asUser(req)` when the request carries an OBO token,
|
|
199
317
|
* otherwise return `this` directly. Prevents the noisy AppKit warn
|
|
@@ -290,4 +408,70 @@ export class MastraPlugin extends Plugin {
|
|
|
290
408
|
});
|
|
291
409
|
}
|
|
292
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* Parse the optional `?timeoutMs=<n>` query parameter from a
|
|
413
|
+
* `GET /embed/chart/:id` request. Accepts a positive integer up
|
|
414
|
+
* to 5 minutes (clamped) and rejects everything else as
|
|
415
|
+
* `undefined` so {@link fetchChart} falls back to its default.
|
|
416
|
+
* Express produces `string | string[] | undefined`; we normalize
|
|
417
|
+
* to the first scalar before parsing.
|
|
418
|
+
*/
|
|
419
|
+
function parseTimeoutMs(raw) {
|
|
420
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
421
|
+
if (typeof v !== "string")
|
|
422
|
+
return undefined;
|
|
423
|
+
const n = Number(v);
|
|
424
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
425
|
+
return undefined;
|
|
426
|
+
return Math.min(Math.floor(n), 5 * 60_000);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Parse the optional `?limit=<n>` query parameter from a
|
|
430
|
+
* `GET /embed/data/:id` request. Accepts a non-negative
|
|
431
|
+
* integer and lets the route clamp to `STATEMENT_ROW_CAP`;
|
|
432
|
+
* rejects anything else as `undefined` so the route falls back
|
|
433
|
+
* to the server-side cap.
|
|
434
|
+
*/
|
|
435
|
+
function parseStatementLimit(raw) {
|
|
436
|
+
const v = Array.isArray(raw) ? raw[0] : raw;
|
|
437
|
+
if (typeof v !== "string")
|
|
438
|
+
return undefined;
|
|
439
|
+
const n = Number(v);
|
|
440
|
+
if (!Number.isFinite(n) || n < 0)
|
|
441
|
+
return undefined;
|
|
442
|
+
return Math.floor(n);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* {@link StreamFrameInterceptor} for Mastra `/stream` responses.
|
|
446
|
+
*
|
|
447
|
+
* Removes large, redundant payloads from terminal `step-finish`,
|
|
448
|
+
* `finish`, and `tool-result` frames before they reach the browser.
|
|
449
|
+
* These frames often repeat information already delivered incrementally
|
|
450
|
+
* via streamed text and tool events, including full tool outputs,
|
|
451
|
+
* accumulated responses, message history, SQL results, chart data, and
|
|
452
|
+
* other large result payloads.
|
|
453
|
+
*
|
|
454
|
+
* The interceptor deletes heavyweight payload properties
|
|
455
|
+
* (`output`, `messages`, `response`, and `result`) while preserving the
|
|
456
|
+
* event envelope and lifecycle metadata required by the client.
|
|
457
|
+
*
|
|
458
|
+
* Deletion is key based, so streams that do not contain these
|
|
459
|
+
* fields are passed through unchanged.
|
|
460
|
+
*/
|
|
461
|
+
const interceptStreamFrame = (chunk) => {
|
|
462
|
+
if (!commonUtils.isRecord(chunk))
|
|
463
|
+
return true;
|
|
464
|
+
if (!["step-finish", "finish", "tool-result"].find((type) => type === chunk.type))
|
|
465
|
+
return true;
|
|
466
|
+
const payload = chunk.payload;
|
|
467
|
+
if (!commonUtils.isRecord(payload))
|
|
468
|
+
return true;
|
|
469
|
+
const trimmedPayload = commonUtils.deleteKeys(payload, [
|
|
470
|
+
"output",
|
|
471
|
+
"messages",
|
|
472
|
+
"response",
|
|
473
|
+
"result",
|
|
474
|
+
]);
|
|
475
|
+
return trimmedPayload ? { replace: chunk } : true;
|
|
476
|
+
};
|
|
293
477
|
export const mastra = toPlugin(MastraPlugin);
|
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
* tool-invocation result in prior assistant messages before they
|
|
4
4
|
* reach the model.
|
|
5
5
|
*
|
|
6
|
-
* Why: chartIds are
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
6
|
+
* Why: chartIds are turn-scoped from the model's point of view -
|
|
7
|
+
* each `prepare_chart` / `render_data` call mints a fresh id and
|
|
8
|
+
* the host UI binds it to that turn's reply. Mastra Memory
|
|
9
|
+
* replays prior tool results into the prompt; if old chartIds
|
|
10
|
+
* leak through, the model is tempted to copy them verbatim into
|
|
11
|
+
* the new turn's `[chart:<id>]` markers and the host UI ends up
|
|
12
|
+
* rendering an unrelated chart from the chart cache (or
|
|
13
|
+
* a 404 once the 1h TTL elapsed). This processor removes the
|
|
14
|
+
* temptation by deleting `chartId` keys from every assistant
|
|
15
|
+
* message's tool results before the prompt is built. The current
|
|
16
|
+
* turn's tool results don't exist yet at `processInput` time, so
|
|
17
|
+
* they pass through unmodified.
|
|
16
18
|
*
|
|
17
19
|
* The strip is recursive - any nested `chartId` field is removed,
|
|
18
|
-
* regardless of which tool produced the result. This covers
|
|
19
|
-
* `
|
|
20
|
-
* uniformly without coupling
|
|
20
|
+
* regardless of which tool produced the result. This covers
|
|
21
|
+
* `prepare_chart` / `render_data` top-level chartIds and any
|
|
22
|
+
* legacy `datasets[].chartId` payloads uniformly without coupling
|
|
23
|
+
* to specific tool ids.
|
|
21
24
|
*/
|
|
22
25
|
import type { InputProcessor } from "@mastra/core/processors";
|
|
23
26
|
/**
|
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
* tool-invocation result in prior assistant messages before they
|
|
4
4
|
* reach the model.
|
|
5
5
|
*
|
|
6
|
-
* Why: chartIds are
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
6
|
+
* Why: chartIds are turn-scoped from the model's point of view -
|
|
7
|
+
* each `prepare_chart` / `render_data` call mints a fresh id and
|
|
8
|
+
* the host UI binds it to that turn's reply. Mastra Memory
|
|
9
|
+
* replays prior tool results into the prompt; if old chartIds
|
|
10
|
+
* leak through, the model is tempted to copy them verbatim into
|
|
11
|
+
* the new turn's `[chart:<id>]` markers and the host UI ends up
|
|
12
|
+
* rendering an unrelated chart from the chart cache (or
|
|
13
|
+
* a 404 once the 1h TTL elapsed). This processor removes the
|
|
14
|
+
* temptation by deleting `chartId` keys from every assistant
|
|
15
|
+
* message's tool results before the prompt is built. The current
|
|
16
|
+
* turn's tool results don't exist yet at `processInput` time, so
|
|
17
|
+
* they pass through unmodified.
|
|
16
18
|
*
|
|
17
19
|
* The strip is recursive - any nested `chartId` field is removed,
|
|
18
|
-
* regardless of which tool produced the result. This covers
|
|
19
|
-
* `
|
|
20
|
-
* uniformly without coupling
|
|
20
|
+
* regardless of which tool produced the result. This covers
|
|
21
|
+
* `prepare_chart` / `render_data` top-level chartIds and any
|
|
22
|
+
* legacy `datasets[].chartId` payloads uniformly without coupling
|
|
23
|
+
* to specific tool ids.
|
|
21
24
|
*/
|
|
22
25
|
import { logUtils } from "@dbx-tools/shared";
|
|
23
26
|
const log = logUtils.logger("mastra/processor/strip-stale-charts");
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Databricks Statement Execution helpers for the Mastra plugin.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `client.statementExecution.getStatement` with the shape
|
|
5
|
+
* + size + error handling the plugin's tools and the
|
|
6
|
+
* `/embed/data/:id` route both need:
|
|
7
|
+
*
|
|
8
|
+
* - {@link fetchStatementData}: low-level fetch that returns the
|
|
9
|
+
* raw `{columns, rows, rowCount}` shape used by the
|
|
10
|
+
* `get_statement` tool's output, the `prepare_chart` tool's
|
|
11
|
+
* dataset resolver, and the route's response body. Coerces
|
|
12
|
+
* numeric strings to numbers so downstream charts /
|
|
13
|
+
* aggregations don't have to.
|
|
14
|
+
* - {@link STATEMENT_ROW_CAP}: hard cap callers (notably the
|
|
15
|
+
* `/embed/data/:id` route) clamp `limit` to so a
|
|
16
|
+
* runaway result set can't hose a response.
|
|
17
|
+
* - {@link isStatementNotFoundError}: structural detector that
|
|
18
|
+
* normalizes the SDK's two error classes plus the loose
|
|
19
|
+
* `does not exist` / `not found` message shapes into a single
|
|
20
|
+
* boolean - lets the route map upstream 404s to a clean
|
|
21
|
+
* HTTP 404 without coupling to SDK error-class identity.
|
|
22
|
+
*
|
|
23
|
+
* Not Genie-specific: a Databricks `statement_id` is workspace
|
|
24
|
+
* scoped and lives in the Statement Execution API regardless of
|
|
25
|
+
* which producer (Genie, a tool, a notebook, etc.) submitted the
|
|
26
|
+
* query. Co-located here so consumers can fetch / cap / handle
|
|
27
|
+
* 404s without reaching into the Genie tool module.
|
|
28
|
+
*/
|
|
29
|
+
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
30
|
+
import type { GenieDatasetData } from "@dbx-tools/appkit-mastra-shared";
|
|
31
|
+
/**
|
|
32
|
+
* Hard server-side cap on rows returned by the
|
|
33
|
+
* `/embed/data/:id` route. Sized to keep responses small
|
|
34
|
+
* enough for inline tables to render snappily; the route surfaces
|
|
35
|
+
* a `truncated` flag whenever the upstream `rowCount` exceeds
|
|
36
|
+
* this so end users know they're seeing a sample.
|
|
37
|
+
*/
|
|
38
|
+
export declare const STATEMENT_ROW_CAP = 500;
|
|
39
|
+
/**
|
|
40
|
+
* Fetch a single statement's rows via the Statement Execution API
|
|
41
|
+
* and reshape into the shared {@link GenieDatasetData} shape
|
|
42
|
+
* (column array + row records).
|
|
43
|
+
*
|
|
44
|
+
* Optional `limit` slices the returned `rows` client-side so the
|
|
45
|
+
* agent can scan a small sample without paging the full result
|
|
46
|
+
* set into context. `rowCount` always reflects the upstream total
|
|
47
|
+
* so callers know when the slice truncated.
|
|
48
|
+
*
|
|
49
|
+
* Exported because every consumer in the plugin (the
|
|
50
|
+
* `get_statement` tool, the `prepare_chart` dataset resolver, and
|
|
51
|
+
* the `/embed/data/:id` route) needs the exact same
|
|
52
|
+
* fetch + coercion pipeline so LLM-side `get_statement` output
|
|
53
|
+
* and UI-side `[data:<id>]` rendering stay shape-identical for
|
|
54
|
+
* the same `statement_id`.
|
|
55
|
+
*/
|
|
56
|
+
export declare function fetchStatementData(client: WorkspaceClient, statementId: string, options?: {
|
|
57
|
+
limit?: number;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
}): Promise<GenieDatasetData>;
|
|
60
|
+
/**
|
|
61
|
+
* True when `err` looks like the Databricks SDK's "statement not
|
|
62
|
+
* found" error. Matches the typed {@link ApiError} 404 /
|
|
63
|
+
* `RESOURCE_DOES_NOT_EXIST` shape first, then falls back to the
|
|
64
|
+
* lower-level {@link HttpError} 404, then to a loose `does not
|
|
65
|
+
* exist` / `not found` message sniff for SDK shapes we haven't
|
|
66
|
+
* catalogued.
|
|
67
|
+
*
|
|
68
|
+
* Pulled into its own helper so callers (notably the
|
|
69
|
+
* `/embed/data/:id` route) stay decoupled from SDK
|
|
70
|
+
* error-class identity, and the conversion logic stays testable
|
|
71
|
+
* in isolation.
|
|
72
|
+
*/
|
|
73
|
+
export declare function isStatementNotFoundError(err: unknown): boolean;
|