@dbx-tools/appkit-mastra 0.1.26 → 0.1.28
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/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/src/agents.js +1 -1
- package/dist/src/chart.d.ts +4 -13
- package/dist/src/chart.js +5 -16
- package/dist/src/config.d.ts +1 -1
- package/dist/src/genie.d.ts +13 -35
- package/dist/src/genie.js +31 -38
- 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/serving.d.ts +10 -17
- package/dist/src/serving.js +10 -17
- package/dist/src/statement.d.ts +14 -21
- package/dist/src/statement.js +14 -21
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/index.ts +4 -3
- package/package.json +5 -5
- package/src/agents.ts +1 -1
- package/src/chart.ts +7 -18
- package/src/config.ts +1 -1
- package/src/genie.ts +31 -38
- package/src/intercept.ts +206 -0
- package/src/plugin.ts +121 -90
- package/src/serving.ts +10 -17
- package/src/statement.ts +14 -21
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/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* plugin
|
|
4
|
-
*
|
|
2
|
+
* Server-side entry point for the AppKit Mastra integration. Mounts
|
|
3
|
+
* the plugin via {@link mastra} and re-exports the full server surface
|
|
4
|
+
* (config, agent wiring, Genie and chart tooling, and dynamic Model
|
|
5
|
+
* Serving resolution) so apps build agent backends from one import.
|
|
5
6
|
*
|
|
6
7
|
* Client-side consumers should import URL helpers and the
|
|
7
8
|
* {@link MastraClientConfig} type from `@dbx-tools/appkit-mastra-shared`
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* plugin
|
|
4
|
-
*
|
|
2
|
+
* Server-side entry point for the AppKit Mastra integration. Mounts
|
|
3
|
+
* the plugin via {@link mastra} and re-exports the full server surface
|
|
4
|
+
* (config, agent wiring, Genie and chart tooling, and dynamic Model
|
|
5
|
+
* Serving resolution) so apps build agent backends from one import.
|
|
5
6
|
*
|
|
6
7
|
* Client-side consumers should import URL helpers and the
|
|
7
8
|
* {@link MastraClientConfig} type from `@dbx-tools/appkit-mastra-shared`
|
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
|
@@ -20,18 +20,9 @@
|
|
|
20
20
|
* dataset, custom API). The module has no knowledge of Genie or
|
|
21
21
|
* statement ids; those concerns live in the tools that wrap it.
|
|
22
22
|
*
|
|
23
|
-
* Wire-format schemas
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* other UI consumer share the exact same shape this module reads
|
|
27
|
-
* and writes.
|
|
28
|
-
*
|
|
29
|
-
* Public surface (everything else is module-private):
|
|
30
|
-
* - {@link chartPlannerRequestSchema} / {@link ChartPlannerRequest}
|
|
31
|
-
* - {@link prepareChart} / {@link PrepareChartOptions}
|
|
32
|
-
* - {@link fetchChart} / {@link FetchChartOptions}
|
|
33
|
-
* - {@link buildRenderDataTool} (the `render_data` Mastra tool
|
|
34
|
-
* auto-wired on every agent in `agents.ts`)
|
|
23
|
+
* Wire-format schemas live in `@dbx-tools/appkit-mastra-shared` so
|
|
24
|
+
* the demo client and any other UI consumer share the exact same
|
|
25
|
+
* shape this module reads and writes.
|
|
35
26
|
*/
|
|
36
27
|
import { type Chart } from "@dbx-tools/appkit-mastra-shared";
|
|
37
28
|
import type { RequestContext } from "@mastra/core/request-context";
|
|
@@ -147,7 +138,7 @@ export declare function fetchChart(chartId: string, options?: FetchChartOptions)
|
|
|
147
138
|
* an empty placeholder, and kicks off the chart-planner in the
|
|
148
139
|
* background. Returns just the `chartId`; the host UI resolves
|
|
149
140
|
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
150
|
-
* `/
|
|
141
|
+
* `/embed/chart/:id` route.
|
|
151
142
|
*
|
|
152
143
|
* For Genie statement results, prefer the Genie agent's
|
|
153
144
|
* `prepare_chart` tool, which accepts a `statement_id` and
|
package/dist/src/chart.js
CHANGED
|
@@ -20,18 +20,9 @@
|
|
|
20
20
|
* dataset, custom API). The module has no knowledge of Genie or
|
|
21
21
|
* statement ids; those concerns live in the tools that wrap it.
|
|
22
22
|
*
|
|
23
|
-
* Wire-format schemas
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* other UI consumer share the exact same shape this module reads
|
|
27
|
-
* and writes.
|
|
28
|
-
*
|
|
29
|
-
* Public surface (everything else is module-private):
|
|
30
|
-
* - {@link chartPlannerRequestSchema} / {@link ChartPlannerRequest}
|
|
31
|
-
* - {@link prepareChart} / {@link PrepareChartOptions}
|
|
32
|
-
* - {@link fetchChart} / {@link FetchChartOptions}
|
|
33
|
-
* - {@link buildRenderDataTool} (the `render_data` Mastra tool
|
|
34
|
-
* auto-wired on every agent in `agents.ts`)
|
|
23
|
+
* Wire-format schemas live in `@dbx-tools/appkit-mastra-shared` so
|
|
24
|
+
* the demo client and any other UI consumer share the exact same
|
|
25
|
+
* shape this module reads and writes.
|
|
35
26
|
*/
|
|
36
27
|
import { CacheManager } from "@databricks/appkit";
|
|
37
28
|
import { ChartSchema, ChartTypeSchema, } from "@dbx-tools/appkit-mastra-shared";
|
|
@@ -199,9 +190,7 @@ const chartPlanSchema = z.object({
|
|
|
199
190
|
* sees a planner request, only the resolved {@link Chart}.
|
|
200
191
|
*/
|
|
201
192
|
export const chartPlannerRequestSchema = z.object({
|
|
202
|
-
title: z
|
|
203
|
-
.string()
|
|
204
|
-
.describe(stringUtils.toDescription(`
|
|
193
|
+
title: z.string().describe(stringUtils.toDescription(`
|
|
205
194
|
Concise title shown above the chart (e.g. "Top 10 SKUs by Revenue").
|
|
206
195
|
`)),
|
|
207
196
|
description: z
|
|
@@ -530,7 +519,7 @@ function planToEchartsOption(plan, fallbackTitle) {
|
|
|
530
519
|
* an empty placeholder, and kicks off the chart-planner in the
|
|
531
520
|
* background. Returns just the `chartId`; the host UI resolves
|
|
532
521
|
* `[chart:<chartId>]` markers by hitting the plugin's
|
|
533
|
-
* `/
|
|
522
|
+
* `/embed/chart/:id` route.
|
|
534
523
|
*
|
|
535
524
|
* For Genie statement results, prefer the Genie agent's
|
|
536
525
|
* `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.d.ts
CHANGED
|
@@ -1,41 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Genie tools for Mastra.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - `get_space_description`: cheap title / description /
|
|
18
|
-
* warehouse id lookup for grounding.
|
|
19
|
-
* - `get_space_serialized`: full `GenieSpace` JSON for
|
|
20
|
-
* column-level grounding when the description isn't enough.
|
|
21
|
-
*
|
|
22
|
-
* Space-agnostic shared tools (registered once, regardless of
|
|
23
|
-
* how many spaces are wired):
|
|
24
|
-
*
|
|
25
|
-
* - `get_statement`: opt-in lookup of a Genie statement's rows
|
|
26
|
-
* by `statement_id` (with a row `limit`). The agent calls
|
|
27
|
-
* this only when it needs to read values to reason about
|
|
28
|
-
* them; if the data is just being displayed, it embeds a
|
|
29
|
-
* `[data:<statement_id>]` marker in prose instead and lets
|
|
30
|
-
* the host UI resolve it.
|
|
31
|
-
* - `prepare_chart`: mints a short `chartId`, kicks off a
|
|
32
|
-
* background task that fetches the statement's rows and
|
|
33
|
-
* runs the chart-planner, and caches the resolved Echarts
|
|
34
|
-
* spec under the id (1h TTL). Returns the `chartId`
|
|
35
|
-
* synchronously so the agent embeds `[chart:<chartId>]`
|
|
36
|
-
* markers in prose without blocking on chart generation;
|
|
37
|
-
* the host UI fetches the cached chart by id once it's
|
|
38
|
-
* ready (see {@link fetchChart}).
|
|
4
|
+
* Surfaces each configured Genie space as a small set of flat Mastra
|
|
5
|
+
* tools the calling agent drives directly - no inner orchestrator
|
|
6
|
+
* agent. The central agent decomposes user questions, picks which
|
|
7
|
+
* space to ask, streams the per-turn wire events (status, thinking,
|
|
8
|
+
* sql, rows) through `ctx.writer`, and composes the final reply.
|
|
9
|
+
* Rows are never fetched eagerly: the agent reads a statement's
|
|
10
|
+
* values only when it needs to reason about them, otherwise it embeds
|
|
11
|
+
* a `[data:<statement_id>]` marker in prose and lets the host UI
|
|
12
|
+
* resolve the data. Charts are minted asynchronously and referenced
|
|
13
|
+
* by `[chart:<chartId>]` markers so prose isn't blocked on chart
|
|
14
|
+
* generation; the host UI fetches the cached spec by id once ready.
|
|
15
|
+
* Space description and serialized-space lookups are available for
|
|
16
|
+
* grounding when the agent needs schema context.
|
|
39
17
|
*
|
|
40
18
|
* Each tool's `execute` pulls the per-request
|
|
41
19
|
* {@link WorkspaceClient} off `ctx.requestContext` (stamped by
|
package/dist/src/genie.js
CHANGED
|
@@ -1,41 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Genie tools for Mastra.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* - `get_space_description`: cheap title / description /
|
|
18
|
-
* warehouse id lookup for grounding.
|
|
19
|
-
* - `get_space_serialized`: full `GenieSpace` JSON for
|
|
20
|
-
* column-level grounding when the description isn't enough.
|
|
21
|
-
*
|
|
22
|
-
* Space-agnostic shared tools (registered once, regardless of
|
|
23
|
-
* how many spaces are wired):
|
|
24
|
-
*
|
|
25
|
-
* - `get_statement`: opt-in lookup of a Genie statement's rows
|
|
26
|
-
* by `statement_id` (with a row `limit`). The agent calls
|
|
27
|
-
* this only when it needs to read values to reason about
|
|
28
|
-
* them; if the data is just being displayed, it embeds a
|
|
29
|
-
* `[data:<statement_id>]` marker in prose instead and lets
|
|
30
|
-
* the host UI resolve it.
|
|
31
|
-
* - `prepare_chart`: mints a short `chartId`, kicks off a
|
|
32
|
-
* background task that fetches the statement's rows and
|
|
33
|
-
* runs the chart-planner, and caches the resolved Echarts
|
|
34
|
-
* spec under the id (1h TTL). Returns the `chartId`
|
|
35
|
-
* synchronously so the agent embeds `[chart:<chartId>]`
|
|
36
|
-
* markers in prose without blocking on chart generation;
|
|
37
|
-
* the host UI fetches the cached chart by id once it's
|
|
38
|
-
* ready (see {@link fetchChart}).
|
|
4
|
+
* Surfaces each configured Genie space as a small set of flat Mastra
|
|
5
|
+
* tools the calling agent drives directly - no inner orchestrator
|
|
6
|
+
* agent. The central agent decomposes user questions, picks which
|
|
7
|
+
* space to ask, streams the per-turn wire events (status, thinking,
|
|
8
|
+
* sql, rows) through `ctx.writer`, and composes the final reply.
|
|
9
|
+
* Rows are never fetched eagerly: the agent reads a statement's
|
|
10
|
+
* values only when it needs to reason about them, otherwise it embeds
|
|
11
|
+
* a `[data:<statement_id>]` marker in prose and lets the host UI
|
|
12
|
+
* resolve the data. Charts are minted asynchronously and referenced
|
|
13
|
+
* by `[chart:<chartId>]` markers so prose isn't blocked on chart
|
|
14
|
+
* generation; the host UI fetches the cached spec by id once ready.
|
|
15
|
+
* Space description and serialized-space lookups are available for
|
|
16
|
+
* grounding when the agent needs schema context.
|
|
39
17
|
*
|
|
40
18
|
* Each tool's `execute` pulls the per-request
|
|
41
19
|
* {@link WorkspaceClient} off `ctx.requestContext` (stamped by
|
|
@@ -614,9 +592,13 @@ function buildPrepareChartTool(opts) {
|
|
|
614
592
|
`
|
|
615
593
|
To display the chart in your reply, embed
|
|
616
594
|
\`[chart:<chartId>]\` on its own line at the position you
|
|
617
|
-
want it to appear
|
|
618
|
-
|
|
619
|
-
|
|
595
|
+
want it to appear, using the EXACT \`chartId\` string this
|
|
596
|
+
call returned. Never construct a chart id yourself (it is
|
|
597
|
+
not the \`statement_id\` or any variation of it) - only a
|
|
598
|
+
value returned by this tool resolves to a real chart. The
|
|
599
|
+
tool returns immediately - do NOT wait or call it again to
|
|
600
|
+
"check progress"; the chart resolves asynchronously on the
|
|
601
|
+
host UI's side.
|
|
620
602
|
`,
|
|
621
603
|
`
|
|
622
604
|
Use this only when the data has a story a chart conveys
|
|
@@ -728,6 +710,17 @@ export const GENIE_INSTRUCTIONS = stringUtils.toDescription([
|
|
|
728
710
|
chart should appear. Use a chart when the data has a
|
|
729
711
|
story a visual conveys better than a table (trends,
|
|
730
712
|
rankings, distributions, parts-of-a-whole).
|
|
713
|
+
|
|
714
|
+
NEVER invent or hand-build a \`<chartId>\`. A valid
|
|
715
|
+
\`<chartId>\` is the opaque token a \`prepare_chart\`
|
|
716
|
+
call returned to you in THIS turn - nothing else. It
|
|
717
|
+
is NOT a \`statement_id\`, and it is NOT a
|
|
718
|
+
\`statement_id\` prefix with a label appended (e.g.
|
|
719
|
+
\`01f1...-region-fill\`). If you have not called
|
|
720
|
+
\`prepare_chart\` and received an id back, do not write
|
|
721
|
+
a \`[chart:...]\` marker at all - use \`[data:...]\`
|
|
722
|
+
instead. A fabricated chart id renders nothing and
|
|
723
|
+
wastes a request.
|
|
731
724
|
`,
|
|
732
725
|
],
|
|
733
726
|
},
|
|
@@ -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
|