@dbx-tools/appkit-mastra 0.1.26 → 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 +12 -10
- package/dist/src/agents.js +1 -1
- package/dist/src/chart.d.ts +1 -1
- package/dist/src/chart.js +2 -4
- package/dist/src/config.d.ts +1 -1
- package/dist/src/genie.js +18 -3
- package/dist/src/intercept.d.ts +48 -0
- package/dist/src/intercept.js +167 -0
- package/dist/src/plugin.d.ts +2 -2
- package/dist/src/plugin.js +105 -89
- package/dist/src/statement.d.ts +5 -5
- package/dist/src/statement.js +5 -5
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/agents.ts +1 -1
- package/src/chart.ts +4 -6
- package/src/config.ts +1 -1
- package/src/genie.ts +18 -3
- package/src/intercept.ts +206 -0
- package/src/plugin.ts +121 -90
- package/src/statement.ts +5 -5
package/README.md
CHANGED
|
@@ -199,7 +199,7 @@ once regardless of how many spaces are wired:
|
|
|
199
199
|
Genie statement. Use only when the agent needs to read values
|
|
200
200
|
to reason about them; otherwise embed `[data:<statement_id>]`.
|
|
201
201
|
- `prepare_chart({statement_id, title?, description?})` - mint a
|
|
202
|
-
|
|
202
|
+
`chartId` (v4 UUID), kick off a background chart-planner task,
|
|
203
203
|
cache the resolved Echarts spec under the id for one hour.
|
|
204
204
|
Returns `{chartId}` synchronously so the agent can embed
|
|
205
205
|
`[chart:<chartId>]` in prose without blocking.
|
|
@@ -254,8 +254,9 @@ Genie writer event flow:
|
|
|
254
254
|
- Charts ride out-of-band: `prepare_chart` mints a `chartId`
|
|
255
255
|
synchronously, the planner runs in the background and writes
|
|
256
256
|
the spec to the chart cache (1h TTL). The host UI long-polls
|
|
257
|
-
`${MastraClientConfig.
|
|
258
|
-
inline at the matching `[chart:<chartId>]`
|
|
257
|
+
`${MastraClientConfig.embedPathTemplate}` (`/embed/chart/:id`)
|
|
258
|
+
by id and renders inline at the matching `[chart:<chartId>]`
|
|
259
|
+
marker.
|
|
259
260
|
- After a hard reload, the live `started` / `status` / `sql`
|
|
260
261
|
events are gone (they only ride the writer, not persisted
|
|
261
262
|
tool results); the central agent's text reply (with embedded
|
|
@@ -285,7 +286,7 @@ off the chart-planner in the background. The tool returns just
|
|
|
285
286
|
`{ chartId }` so the model can embed `[chart:<chartId>]` in
|
|
286
287
|
prose immediately without blocking on chart generation.
|
|
287
288
|
|
|
288
|
-
1. Mint a
|
|
289
|
+
1. Mint a `chartId` (v4 UUID via `crypto.randomUUID()`).
|
|
289
290
|
2. Cache an empty `{ chartId }` placeholder so the first
|
|
290
291
|
`fetchChart` call always sees an entry (no spurious 404 race).
|
|
291
292
|
3. Fire-and-forget: resolve the dataset (trivial for
|
|
@@ -297,26 +298,27 @@ prose immediately without blocking on chart generation.
|
|
|
297
298
|
4. Return `{ chartId }` to the LLM immediately.
|
|
298
299
|
|
|
299
300
|
The host UI resolves `[chart:<chartId>]` markers by hitting
|
|
300
|
-
the plugin's `GET /
|
|
301
|
+
the plugin's generic `GET /embed/chart/:id` route, which long-polls
|
|
301
302
|
until the entry settles or the server-side timeout elapses.
|
|
302
303
|
|
|
303
304
|
#### Inline placement contract
|
|
304
305
|
|
|
305
|
-
The model embeds `[
|
|
306
|
-
markdown reply at the position where the chart should appear
|
|
306
|
+
The model embeds `[chart:<chartId>]` on its own line in its
|
|
307
|
+
markdown reply at the position where the chart should appear
|
|
308
|
+
(the `<chartId>` is the v4 UUID the tool returned):
|
|
307
309
|
|
|
308
310
|
```markdown
|
|
309
311
|
## Audit Score
|
|
310
312
|
|
|
311
313
|
Audit Score is stable at ~94%, hovering between 93.5 and 95.0.
|
|
312
314
|
|
|
313
|
-
[
|
|
315
|
+
[chart:a3f9c1d2-7b4e-4c1a-9f2d-1e6b8c0a5d31]
|
|
314
316
|
|
|
315
317
|
## Service Time
|
|
316
318
|
|
|
317
319
|
Service time is the outlier at 162.5s, up from a target of 150s.
|
|
318
320
|
|
|
319
|
-
[
|
|
321
|
+
[chart:b7e2d4f1-3a9c-4e58-8d10-2f7a4b6c9e02]
|
|
320
322
|
```
|
|
321
323
|
|
|
322
324
|
The chat client splits the assistant text on these markers and
|
|
@@ -330,7 +332,7 @@ frame so the layout doesn't jump as charts resolve.
|
|
|
330
332
|
#### Trade-offs
|
|
331
333
|
|
|
332
334
|
- Chart entries live in the cache with a 1h TTL. After expiry
|
|
333
|
-
the `/
|
|
335
|
+
the `/embed/chart/:id` route returns 404. The model can call
|
|
334
336
|
`render_data` again on the next turn if the user wants the
|
|
335
337
|
chart back.
|
|
336
338
|
- The chart-planner is a separate model call per dataset (fast
|
package/dist/src/agents.js
CHANGED
|
@@ -188,7 +188,7 @@ export async function buildAgents(opts) {
|
|
|
188
188
|
const style = resolveStyleInstructions(config);
|
|
189
189
|
// Default-on protection against the model copying turn-scoped
|
|
190
190
|
// chartIds from prior assistant tool results into the new
|
|
191
|
-
// turn's `[
|
|
191
|
+
// turn's `[chart:<id>]` markers. Opt out per-plugin via
|
|
192
192
|
// `config.stripStaleCharts: false`.
|
|
193
193
|
const inputProcessors = config.stripStaleCharts === false ? [] : [stripStaleChartsProcessor];
|
|
194
194
|
const agents = {};
|
package/dist/src/chart.d.ts
CHANGED
|
@@ -147,7 +147,7 @@ export declare function fetchChart(chartId: string, options?: FetchChartOptions)
|
|
|
147
147
|
* an empty placeholder, and kicks off the chart-planner in the
|
|
148
148
|
* background. Returns just the `chartId`; the host UI resolves
|
|
149
149
|
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
150
|
-
* `/
|
|
150
|
+
* `/embed/chart/:id` route.
|
|
151
151
|
*
|
|
152
152
|
* For Genie statement results, prefer the Genie agent's
|
|
153
153
|
* `prepare_chart` tool, which accepts a `statement_id` and
|
package/dist/src/chart.js
CHANGED
|
@@ -199,9 +199,7 @@ const chartPlanSchema = z.object({
|
|
|
199
199
|
* sees a planner request, only the resolved {@link Chart}.
|
|
200
200
|
*/
|
|
201
201
|
export const chartPlannerRequestSchema = z.object({
|
|
202
|
-
title: z
|
|
203
|
-
.string()
|
|
204
|
-
.describe(stringUtils.toDescription(`
|
|
202
|
+
title: z.string().describe(stringUtils.toDescription(`
|
|
205
203
|
Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
|
|
206
204
|
`)),
|
|
207
205
|
description: z
|
|
@@ -530,7 +528,7 @@ function planToEchartsOption(plan, fallbackTitle) {
|
|
|
530
528
|
* an empty placeholder, and kicks off the chart-planner in the
|
|
531
529
|
* background. Returns just the `chartId`; the host UI resolves
|
|
532
530
|
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
533
|
-
* `/
|
|
531
|
+
* `/embed/chart/:id` route.
|
|
534
532
|
*
|
|
535
533
|
* For Genie statement results, prefer the Genie agent's
|
|
536
534
|
* `prepare_chart` tool, which accepts a `statement_id` and
|
package/dist/src/config.d.ts
CHANGED
|
@@ -191,7 +191,7 @@ export interface MastraPluginConfig extends BasePluginConfig {
|
|
|
191
191
|
* processor that strips `chartId` fields from prior assistant
|
|
192
192
|
* tool-invocation results before they reach the model. This
|
|
193
193
|
* prevents the model from reusing turn-scoped chartIds it sees
|
|
194
|
-
* in memory recall (which would leave `[
|
|
194
|
+
* in memory recall (which would leave `[chart:<id>]` markers
|
|
195
195
|
* pointing at writer events that no longer exist).
|
|
196
196
|
*
|
|
197
197
|
* Set to `false` to opt out - useful if a non-default agent
|
package/dist/src/genie.js
CHANGED
|
@@ -614,9 +614,13 @@ function buildPrepareChartTool(opts) {
|
|
|
614
614
|
`
|
|
615
615
|
To display the chart in your reply, embed
|
|
616
616
|
\`[chart:<chartId>]\` on its own line at the position you
|
|
617
|
-
want it to appear
|
|
618
|
-
|
|
619
|
-
|
|
617
|
+
want it to appear, using the EXACT \`chartId\` string this
|
|
618
|
+
call returned. Never construct a chart id yourself (it is
|
|
619
|
+
not the \`statement_id\` or any variation of it) - only a
|
|
620
|
+
value returned by this tool resolves to a real chart. The
|
|
621
|
+
tool returns immediately - do NOT wait or call it again to
|
|
622
|
+
"check progress"; the chart resolves asynchronously on the
|
|
623
|
+
host UI's side.
|
|
620
624
|
`,
|
|
621
625
|
`
|
|
622
626
|
Use this only when the data has a story a chart conveys
|
|
@@ -728,6 +732,17 @@ export const GENIE_INSTRUCTIONS = stringUtils.toDescription([
|
|
|
728
732
|
chart should appear. Use a chart when the data has a
|
|
729
733
|
story a visual conveys better than a table (trends,
|
|
730
734
|
rankings, distributions, parts-of-a-whole).
|
|
735
|
+
|
|
736
|
+
NEVER invent or hand-build a \`<chartId>\`. A valid
|
|
737
|
+
\`<chartId>\` is the opaque token a \`prepare_chart\`
|
|
738
|
+
call returned to you in THIS turn - nothing else. It
|
|
739
|
+
is NOT a \`statement_id\`, and it is NOT a
|
|
740
|
+
\`statement_id\` prefix with a label appended (e.g.
|
|
741
|
+
\`01f1...-region-fill\`). If you have not called
|
|
742
|
+
\`prepare_chart\` and received an id back, do not write
|
|
743
|
+
a \`[chart:...]\` marker at all - use \`[data:...]\`
|
|
744
|
+
instead. A fabricated chart id renders nothing and
|
|
745
|
+
wastes a request.
|
|
731
746
|
`,
|
|
732
747
|
],
|
|
733
748
|
},
|
|
@@ -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
|
@@ -110,8 +110,8 @@ export declare class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
110
110
|
clientConfig(): Record<string, unknown>;
|
|
111
111
|
injectRoutes(router: IAppRouter): void;
|
|
112
112
|
/**
|
|
113
|
-
* Implementation backing the
|
|
114
|
-
* Runs inside the AppKit user-context proxy so
|
|
113
|
+
* Implementation backing the `data` embed resolver
|
|
114
|
+
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
|
115
115
|
* `getExecutionContext()` returns the OBO-scoped workspace
|
|
116
116
|
* client, then reuses the same `fetchStatementData` pipeline
|
|
117
117
|
* the `get_statement` tool runs so the LLM and the UI see the
|
package/dist/src/plugin.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
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";
|
|
@@ -37,6 +37,7 @@ import { historyRoute } from "./history.js";
|
|
|
37
37
|
import { createMemoryBuilder, needsLakebase } from "./memory.js";
|
|
38
38
|
import { buildObservability } from "./observability.js";
|
|
39
39
|
import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
|
|
40
|
+
import { installStreamEventInterceptor, } from "./intercept.js";
|
|
40
41
|
import { clearServingEndpointsCache, listServingEndpoints, resolveServingConfig, } from "./serving.js";
|
|
41
42
|
import { fetchStatementData, isStatementNotFoundError, STATEMENT_ROW_CAP, } from "./statement.js";
|
|
42
43
|
const GENIE_MANIFEST = appkitUtils.data(genie).plugin.manifest;
|
|
@@ -173,8 +174,7 @@ export class MastraPlugin extends Plugin {
|
|
|
173
174
|
modelsPath: `${basePath}/models`,
|
|
174
175
|
historyPath: `${basePath}/route/history`,
|
|
175
176
|
historyPathTemplate: `${basePath}/route/history/:agentId`,
|
|
176
|
-
|
|
177
|
-
statementsPathTemplate: `${basePath}/statements/:statementId`,
|
|
177
|
+
embedPathTemplate: `${basePath}/embed/:type/:id`,
|
|
178
178
|
defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
|
|
179
179
|
agents: Object.keys(this.built?.agents ?? {}),
|
|
180
180
|
};
|
|
@@ -192,112 +192,95 @@ export class MastraPlugin extends Plugin {
|
|
|
192
192
|
.then((endpoints) => res.json({ endpoints }))
|
|
193
193
|
.catch(next);
|
|
194
194
|
});
|
|
195
|
-
// `GET /
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
// set) or the server-side budget elapses (then returns the
|
|
203
|
-
// last seen still-processing entry so the client can poll
|
|
204
|
-
// again).
|
|
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.
|
|
205
202
|
//
|
|
206
203
|
// Status codes:
|
|
207
|
-
// - 200 with JSON body when the
|
|
208
|
-
// - 404 when
|
|
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.
|
|
209
210
|
//
|
|
210
|
-
//
|
|
211
|
-
// - `
|
|
212
|
-
//
|
|
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).
|
|
213
218
|
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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}` });
|
|
222
247
|
return;
|
|
223
248
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// `close` event onto an `AbortController` so a closed
|
|
227
|
-
// connection unblocks the long-poll immediately and frees
|
|
228
|
-
// the request thread. The `close` listener also fires on
|
|
229
|
-
// a normal completion, but at that point we no longer
|
|
230
|
-
// care about the abort and the listener is GC'd with the
|
|
231
|
-
// request.
|
|
232
|
-
const controller = new AbortController();
|
|
233
|
-
req.on("close", () => controller.abort());
|
|
234
|
-
fetchChart(chartId, {
|
|
235
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
236
|
-
signal: controller.signal,
|
|
237
|
-
})
|
|
238
|
-
.then((entry) => {
|
|
239
|
-
if (!entry) {
|
|
240
|
-
res.status(404).json({ error: "chart not found" });
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
res.json(entry);
|
|
244
|
-
})
|
|
245
|
-
.catch(next);
|
|
246
|
-
});
|
|
247
|
-
// `GET /statements/:statementId` resolves a Genie / Statement
|
|
248
|
-
// Execution result for inline rendering as a table.
|
|
249
|
-
//
|
|
250
|
-
// The agent embeds `[data:<statement_id>]` markers in prose
|
|
251
|
-
// (see `GENIE_INSTRUCTIONS`) whenever it wants the host UI to
|
|
252
|
-
// display a result set without the model re-spelling every
|
|
253
|
-
// row in markdown. This route is what those markers resolve
|
|
254
|
-
// against - one OBO-scoped fetch returns the columns + rows
|
|
255
|
-
// so the client renders an AppKit Table inline.
|
|
256
|
-
//
|
|
257
|
-
// Status codes:
|
|
258
|
-
// - 200 with `StatementData` body when the statement is found.
|
|
259
|
-
// - 404 when the workspace can't find / read the statement.
|
|
260
|
-
//
|
|
261
|
-
// Query params:
|
|
262
|
-
// - `limit` (optional): row cap. Clamped to
|
|
263
|
-
// {@link STATEMENT_ROW_CAP} so a runaway result set
|
|
264
|
-
// can't hose the response.
|
|
265
|
-
//
|
|
266
|
-
// OBO-scoped via {@link userScopedSelf} so the caller's
|
|
267
|
-
// workspace permissions apply; SDK errors propagate through
|
|
268
|
-
// `next(err)` to Express's default error handler.
|
|
269
|
-
router.get("/statements/:statementId", (req, res, next) => {
|
|
270
|
-
const statementId = req.params["statementId"];
|
|
271
|
-
if (!statementId) {
|
|
272
|
-
res.status(400).json({ error: "statementId is required" });
|
|
249
|
+
if (!id) {
|
|
250
|
+
res.status(400).json({ error: "id is required" });
|
|
273
251
|
return;
|
|
274
252
|
}
|
|
275
|
-
|
|
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.
|
|
276
258
|
const controller = new AbortController();
|
|
277
259
|
req.on("close", () => controller.abort());
|
|
278
|
-
|
|
279
|
-
.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
})
|
|
283
|
-
.then((data) => {
|
|
284
|
-
if (!data) {
|
|
285
|
-
res.status(404).json({ error: "statement not found" });
|
|
260
|
+
resolve(req, id, controller.signal)
|
|
261
|
+
.then((entry) => {
|
|
262
|
+
if (entry === undefined) {
|
|
263
|
+
res.status(404).json({ error: `${type} not found` });
|
|
286
264
|
return;
|
|
287
265
|
}
|
|
288
|
-
res.json(
|
|
266
|
+
res.json(entry);
|
|
289
267
|
})
|
|
290
268
|
.catch(next);
|
|
291
269
|
});
|
|
292
270
|
router.use("", (req, res, next) => {
|
|
293
271
|
if (!this.mastraApp)
|
|
294
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);
|
|
295
278
|
return this.userScopedSelf(req).mastraApp(req, res, next);
|
|
296
279
|
});
|
|
297
280
|
}
|
|
298
281
|
/**
|
|
299
|
-
* Implementation backing the
|
|
300
|
-
* Runs inside the AppKit user-context proxy so
|
|
282
|
+
* Implementation backing the `data` embed resolver
|
|
283
|
+
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
|
301
284
|
* `getExecutionContext()` returns the OBO-scoped workspace
|
|
302
285
|
* client, then reuses the same `fetchStatementData` pipeline
|
|
303
286
|
* the `get_statement` tool runs so the LLM and the UI see the
|
|
@@ -427,7 +410,7 @@ export class MastraPlugin extends Plugin {
|
|
|
427
410
|
}
|
|
428
411
|
/**
|
|
429
412
|
* Parse the optional `?timeoutMs=<n>` query parameter from a
|
|
430
|
-
* `GET /
|
|
413
|
+
* `GET /embed/chart/:id` request. Accepts a positive integer up
|
|
431
414
|
* to 5 minutes (clamped) and rejects everything else as
|
|
432
415
|
* `undefined` so {@link fetchChart} falls back to its default.
|
|
433
416
|
* Express produces `string | string[] | undefined`; we normalize
|
|
@@ -444,7 +427,7 @@ function parseTimeoutMs(raw) {
|
|
|
444
427
|
}
|
|
445
428
|
/**
|
|
446
429
|
* Parse the optional `?limit=<n>` query parameter from a
|
|
447
|
-
* `GET /
|
|
430
|
+
* `GET /embed/data/:id` request. Accepts a non-negative
|
|
448
431
|
* integer and lets the route clamp to `STATEMENT_ROW_CAP`;
|
|
449
432
|
* rejects anything else as `undefined` so the route falls back
|
|
450
433
|
* to the server-side cap.
|
|
@@ -458,4 +441,37 @@ function parseStatementLimit(raw) {
|
|
|
458
441
|
return undefined;
|
|
459
442
|
return Math.floor(n);
|
|
460
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
|
+
};
|
|
461
477
|
export const mastra = toPlugin(MastraPlugin);
|