@dbx-tools/appkit-mastra 0.1.111 → 0.3.1
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 +309 -344
- package/index.ts +46 -0
- package/package.json +50 -28
- package/src/agents.ts +858 -0
- package/src/chart.ts +695 -0
- package/src/config.ts +443 -0
- package/src/filesystems.ts +1090 -0
- package/src/genie.ts +1091 -0
- package/src/history.ts +297 -0
- package/src/mcp.ts +105 -0
- package/src/memory.ts +300 -0
- package/src/mlflow.ts +149 -0
- package/src/model.ts +163 -0
- package/src/observability.ts +144 -0
- package/src/pagination.ts +34 -0
- package/src/plugin.ts +804 -0
- package/src/processors.ts +168 -0
- package/src/rest.ts +67 -0
- package/src/server.ts +343 -0
- package/src/serving-sanitize.ts +167 -0
- package/src/serving.ts +97 -0
- package/src/statement.ts +89 -0
- package/src/storage-schema.ts +41 -0
- package/src/summarize.ts +176 -0
- package/src/threads.ts +338 -0
- package/src/workspaces.ts +346 -0
- package/src/writer.ts +44 -0
- package/tsconfig.json +41 -0
- package/dist/index.d.ts +0 -1676
- package/dist/index.js +0 -5337
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra stream processors wired onto the plugin's agents.
|
|
3
|
+
*
|
|
4
|
+
* Two complementary processors live here:
|
|
5
|
+
*
|
|
6
|
+
* - An **input** processor ({@link stripStaleChartsProcessor}) that
|
|
7
|
+
* scrubs turn-scoped `chartId` fields out of prior tool results
|
|
8
|
+
* replayed from Memory, so the model can't copy a stale id into the
|
|
9
|
+
* new turn's `[chart:<id>]` markers.
|
|
10
|
+
* - An **output** processor ({@link ResultProcessor}) that trims the
|
|
11
|
+
* bulky, redundant payload fields off targeted outbound stream frames
|
|
12
|
+
* before they're serialized to the SSE client.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { log } from "@dbx-tools/shared-core";
|
|
16
|
+
import type { InputProcessor, ProcessInputArgs, Processor } from "@mastra/core/processors";
|
|
17
|
+
|
|
18
|
+
const logger = log.logger("mastra/processors");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Recursively clone `value`, omitting any property whose key is
|
|
22
|
+
* `chartId`. Arrays are mapped element-wise; primitives are
|
|
23
|
+
* returned as-is. The result is structurally identical to the
|
|
24
|
+
* input minus chartIds, so downstream message-shape consumers
|
|
25
|
+
* keep working.
|
|
26
|
+
*/
|
|
27
|
+
function stripChartIds(value: unknown): unknown {
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
return value.map(stripChartIds);
|
|
30
|
+
}
|
|
31
|
+
if (value && typeof value === "object") {
|
|
32
|
+
const obj = value as Record<string, unknown>;
|
|
33
|
+
const out: Record<string, unknown> = {};
|
|
34
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
35
|
+
if (key === "chartId") continue;
|
|
36
|
+
out[key] = stripChartIds(val);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mastra input processor that strips `chartId` fields from every
|
|
45
|
+
* tool-invocation result in prior assistant messages before they
|
|
46
|
+
* reach the model.
|
|
47
|
+
*
|
|
48
|
+
* Why: chartIds are turn-scoped from the model's point of view -
|
|
49
|
+
* each `prepare_chart` / `render_data` call mints a fresh id and
|
|
50
|
+
* the host UI binds it to that turn's reply. Mastra Memory
|
|
51
|
+
* replays prior tool results into the prompt; if old chartIds
|
|
52
|
+
* leak through, the model is tempted to copy them verbatim into
|
|
53
|
+
* the new turn's `[chart:<id>]` markers and the host UI ends up
|
|
54
|
+
* rendering an unrelated chart from the chart cache (or
|
|
55
|
+
* a 404 once the 1h TTL elapsed). This processor removes the
|
|
56
|
+
* temptation by deleting `chartId` keys from every assistant
|
|
57
|
+
* message's tool results before the prompt is built. The current
|
|
58
|
+
* turn's tool results don't exist yet at `processInput` time, so
|
|
59
|
+
* they pass through unmodified.
|
|
60
|
+
*
|
|
61
|
+
* The strip is recursive - any nested `chartId` field is removed,
|
|
62
|
+
* regardless of which tool produced the result. This covers
|
|
63
|
+
* `prepare_chart` / `render_data` top-level chartIds and any
|
|
64
|
+
* legacy `datasets[].chartId` payloads uniformly without coupling
|
|
65
|
+
* to specific tool ids.
|
|
66
|
+
*
|
|
67
|
+
* Wired onto every agent by default via `buildAgents`; opt out with
|
|
68
|
+
* `MastraPluginConfig.stripStaleCharts: false`.
|
|
69
|
+
*/
|
|
70
|
+
export const stripStaleChartsProcessor: InputProcessor = {
|
|
71
|
+
id: "strip-stale-charts",
|
|
72
|
+
description:
|
|
73
|
+
"Removes chartId fields from prior tool-invocation results so the model can't reuse turn-scoped ids from memory.",
|
|
74
|
+
processInput(args: ProcessInputArgs) {
|
|
75
|
+
let stripped = 0;
|
|
76
|
+
for (const message of args.messages) {
|
|
77
|
+
if (message.role !== "assistant") continue;
|
|
78
|
+
const parts = message.content?.parts;
|
|
79
|
+
if (!Array.isArray(parts)) continue;
|
|
80
|
+
for (const part of parts) {
|
|
81
|
+
// Tool-invocation parts hold the persisted tool result.
|
|
82
|
+
// We don't scrub the input args (`rawInput` / `args`) because
|
|
83
|
+
// the chartId there is the model's outgoing claim, not
|
|
84
|
+
// anything it could re-reference; only `result` carries
|
|
85
|
+
// ids that subsequent turns might copy.
|
|
86
|
+
if ((part as { type?: unknown }).type !== "tool-invocation") {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const inv = (part as { toolInvocation?: { result?: unknown } }).toolInvocation;
|
|
90
|
+
if (!inv || inv.result === undefined) continue;
|
|
91
|
+
const before = inv.result;
|
|
92
|
+
const after = stripChartIds(before);
|
|
93
|
+
// Cheap structural check via JSON length - the actual
|
|
94
|
+
// strip writes a fresh object only when chartId keys
|
|
95
|
+
// existed, so different stringification length is a
|
|
96
|
+
// reliable signal that something was removed.
|
|
97
|
+
if (
|
|
98
|
+
typeof before === "object" &&
|
|
99
|
+
before !== null &&
|
|
100
|
+
JSON.stringify(before).length !== JSON.stringify(after).length
|
|
101
|
+
) {
|
|
102
|
+
inv.result = after;
|
|
103
|
+
stripped += 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stripped > 0) {
|
|
108
|
+
logger.debug("stripped", { results: stripped });
|
|
109
|
+
}
|
|
110
|
+
return args.messages;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Mastra output processor that trims the bulky, redundant payload
|
|
116
|
+
* fields off targeted outbound stream frames.
|
|
117
|
+
*
|
|
118
|
+
* Mastra emits step / finish / tool-result frames whose payloads
|
|
119
|
+
* echo the full tool `output` / `result` / assistant `messages` /
|
|
120
|
+
* `response` - data the chat client already received inline and
|
|
121
|
+
* doesn't need re-sent. This processor empties those keys on the
|
|
122
|
+
* targeted frame types so the outbound SSE stream stays lean;
|
|
123
|
+
* every other frame passes through untouched.
|
|
124
|
+
*/
|
|
125
|
+
export class ResultProcessor implements Processor {
|
|
126
|
+
id = "result-processor";
|
|
127
|
+
|
|
128
|
+
// Tell Mastra to also route tool/data parts to this processor method
|
|
129
|
+
processDataParts = true;
|
|
130
|
+
|
|
131
|
+
async processOutputStream({ part }: { part: any }): Promise<any | null> {
|
|
132
|
+
// 1. Guard clause: Ensure the chunk is a valid object
|
|
133
|
+
if (!part || typeof part !== "object") {
|
|
134
|
+
return part;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 2. Filter for the targeted frame types
|
|
138
|
+
const targetedTypes = ["step-finish", "finish", "tool-result", "data-tool-agent"];
|
|
139
|
+
if (!targetedTypes.includes(part.type)) {
|
|
140
|
+
return part; // Return unchanged to pass-through
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 3. Check for the presence of a payload object
|
|
144
|
+
const payload = part.payload;
|
|
145
|
+
if (!payload || typeof payload !== "object") {
|
|
146
|
+
return part;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 4. Safely delete the unwanted keys from the payload reference
|
|
150
|
+
const keysToDelete = ["output", "messages", "response", "result"];
|
|
151
|
+
for (const key of keysToDelete) {
|
|
152
|
+
if (key in payload) {
|
|
153
|
+
const value = payload[key];
|
|
154
|
+
if (typeof value === "object") {
|
|
155
|
+
payload[key] = {};
|
|
156
|
+
} else if (Array.isArray(value)) {
|
|
157
|
+
payload[key] = [];
|
|
158
|
+
} else {
|
|
159
|
+
delete payload[key];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 5. Return the modified part object. Mastra handles re-serialization
|
|
165
|
+
// for the outbound SSE client stream automatically.
|
|
166
|
+
return part;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/rest.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { appkit } from "@dbx-tools/appkit";
|
|
2
|
+
/**
|
|
3
|
+
* Minimal authenticated Databricks REST helper. Pulls the workspace
|
|
4
|
+
* host and a fresh bearer header off an OBO-scoped `WorkspaceClient`
|
|
5
|
+
* (`client.config.getHost()` + `authenticate()`), then issues a plain
|
|
6
|
+
* `fetch`. Used by modules that hit REST surfaces without a typed SDK
|
|
7
|
+
* method (e.g. the MLflow assessments API); returns the raw `Response`
|
|
8
|
+
* so callers decide how to treat status codes. Also carries the
|
|
9
|
+
* defensive `Response` body readers those callers share.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Workspace client carried on an AppKit execution context. */
|
|
13
|
+
type WorkspaceClient = appkit.WorkspaceClientLike;
|
|
14
|
+
|
|
15
|
+
/** Request options for {@link databricksFetch}. */
|
|
16
|
+
export interface DatabricksFetchInit {
|
|
17
|
+
method: string;
|
|
18
|
+
/** JSON-serialized as the request body when present. */
|
|
19
|
+
body?: unknown;
|
|
20
|
+
/** Extra headers merged over the default `Content-Type: application/json`. */
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the workspace host + an authenticated header set off the
|
|
27
|
+
* client and issue a `fetch` against `path` (mounted on the host).
|
|
28
|
+
* Runs as whatever identity the client carries - the per-request OBO
|
|
29
|
+
* user when called from a request scope, the service principal
|
|
30
|
+
* otherwise.
|
|
31
|
+
*/
|
|
32
|
+
export async function databricksFetch(
|
|
33
|
+
client: WorkspaceClient,
|
|
34
|
+
path: string,
|
|
35
|
+
init: DatabricksFetchInit,
|
|
36
|
+
): Promise<Response> {
|
|
37
|
+
const host = (await client.config.getHost()).toString();
|
|
38
|
+
const headers = new Headers({ "Content-Type": "application/json", ...init.headers });
|
|
39
|
+
await client.config.authenticate(headers);
|
|
40
|
+
const url = new URL(path, host).toString();
|
|
41
|
+
return fetch(url, {
|
|
42
|
+
method: init.method,
|
|
43
|
+
headers,
|
|
44
|
+
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
|
45
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read a response body as text, swallowing read errors (returns `""`). */
|
|
50
|
+
export async function readResponseText(res: Response): Promise<string> {
|
|
51
|
+
try {
|
|
52
|
+
return await res.text();
|
|
53
|
+
} catch {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Parse a response body as JSON, returning `{}` on empty / invalid bodies. */
|
|
59
|
+
export async function readResponseJson(res: Response): Promise<unknown> {
|
|
60
|
+
const text = await readResponseText(res);
|
|
61
|
+
if (!text) return {};
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(text);
|
|
64
|
+
} catch {
|
|
65
|
+
return {};
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express-layer plumbing for the Mastra plugin: a `MastraServer` that
|
|
3
|
+
* stamps the per-request `RequestContext`, and a route-patch middleware
|
|
4
|
+
* that lets the plugin's custom API routes (e.g. `historyRoute`) work
|
|
5
|
+
* behind an Express mount point.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getExecutionContext } from "@databricks/appkit";
|
|
9
|
+
import { feedback, thread } from "@dbx-tools/shared-mastra";
|
|
10
|
+
import {
|
|
11
|
+
MASTRA_RESOURCE_ID_KEY,
|
|
12
|
+
MASTRA_THREAD_ID_KEY,
|
|
13
|
+
type RequestContext,
|
|
14
|
+
} from "@mastra/core/request-context";
|
|
15
|
+
import { MastraServer as MastraServerExpress } from "@mastra/express";
|
|
16
|
+
import { trace } from "@opentelemetry/api";
|
|
17
|
+
import type express from "express";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
import { resolveFeedbackEnabled } from "./mlflow";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
MASTRA_REQUEST_ID_KEY,
|
|
24
|
+
MASTRA_SCOPES_KEY,
|
|
25
|
+
MASTRA_USER_EMAIL_KEY,
|
|
26
|
+
MASTRA_USER_KEY,
|
|
27
|
+
MASTRA_USER_NAME_KEY,
|
|
28
|
+
type MastraPluginConfig,
|
|
29
|
+
type User,
|
|
30
|
+
} from "./config";
|
|
31
|
+
import { http, log, object, string, token } from "@dbx-tools/shared-core";
|
|
32
|
+
import { extractModelOverride, MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
|
|
33
|
+
/**
|
|
34
|
+
* OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars.
|
|
35
|
+
* `trace.getActiveSpan()` returns a non-recording span with this id
|
|
36
|
+
* when no SDK is registered, which must never be surfaced as a trace to
|
|
37
|
+
* attach feedback to.
|
|
38
|
+
*/
|
|
39
|
+
const INVALID_TRACE_ID = "0".repeat(32);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `@mastra/express` subclass that stamps `RequestContext` with the
|
|
43
|
+
* AppKit user, resource id, and a thread id backed by an HTTP-only
|
|
44
|
+
* session cookie (`appkit_<plugin-name>_session_id`).
|
|
45
|
+
*/
|
|
46
|
+
export class MastraServer extends MastraServerExpress {
|
|
47
|
+
private log: log.Logger;
|
|
48
|
+
/**
|
|
49
|
+
* Whether to stamp the MLflow trace-id header on responses. Shares the
|
|
50
|
+
* plugin's feedback gate via {@link resolveFeedbackEnabled}.
|
|
51
|
+
*/
|
|
52
|
+
private feedbackEnabled: boolean;
|
|
53
|
+
|
|
54
|
+
constructor(
|
|
55
|
+
private config: MastraPluginConfig,
|
|
56
|
+
...args: ConstructorParameters<typeof MastraServerExpress>
|
|
57
|
+
) {
|
|
58
|
+
super(...args);
|
|
59
|
+
this.log = log.logger(config);
|
|
60
|
+
this.feedbackEnabled = resolveFeedbackEnabled(config.feedback);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
override registerAuthMiddleware(): void {
|
|
64
|
+
super.registerAuthMiddleware();
|
|
65
|
+
this.app.use(async (req, res, next) => {
|
|
66
|
+
const requestContext = res.locals.requestContext! as RequestContext;
|
|
67
|
+
await this.configureRequestContextUser(requestContext);
|
|
68
|
+
this.configureRequestContextThreadId(req, res, requestContext);
|
|
69
|
+
this.configureRequestContextModelOverride(req, requestContext);
|
|
70
|
+
this.configureRequestContextRequestId(req, res, requestContext);
|
|
71
|
+
this.configureRequestContextScopes(req, requestContext);
|
|
72
|
+
|
|
73
|
+
this.configureMlflowTraceId(res);
|
|
74
|
+
this.log.debug("auth:middleware", {
|
|
75
|
+
method: req.method,
|
|
76
|
+
path: req.path,
|
|
77
|
+
requestId: requestContext.get(MASTRA_REQUEST_ID_KEY),
|
|
78
|
+
threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
|
|
79
|
+
resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
|
|
80
|
+
userName: requestContext.get(MASTRA_USER_NAME_KEY),
|
|
81
|
+
userEmail: requestContext.get(MASTRA_USER_EMAIL_KEY),
|
|
82
|
+
modelOverride: requestContext.get(
|
|
83
|
+
// imported below; logged so a misrouted request shows
|
|
84
|
+
// up alongside its model selection in `LOG_LEVEL=debug`.
|
|
85
|
+
"mastra__model_override",
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
next();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async configureRequestContextUser(requestContext: RequestContext) {
|
|
93
|
+
if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
|
|
94
|
+
const executionContext = getExecutionContext();
|
|
95
|
+
const user: User = {
|
|
96
|
+
id: "userId" in executionContext ? executionContext.userId : executionContext.serviceUserId,
|
|
97
|
+
executionContext,
|
|
98
|
+
};
|
|
99
|
+
requestContext.set(MASTRA_USER_KEY, user);
|
|
100
|
+
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
101
|
+
// AppKit's `UserContext` surfaces display name / email only on
|
|
102
|
+
// OBO requests. Service-context calls (background tasks, server
|
|
103
|
+
// start-up) leave these undefined and we skip the stamp so
|
|
104
|
+
// downstream trace metadata stays absent rather than empty.
|
|
105
|
+
let userName: string | undefined;
|
|
106
|
+
let email: string | undefined;
|
|
107
|
+
if ("isUserContext" in executionContext) {
|
|
108
|
+
userName = executionContext.userName;
|
|
109
|
+
email = executionContext.userEmail;
|
|
110
|
+
} else if (process.env.NODE_ENV === "development") {
|
|
111
|
+
const currentUser = await executionContext.client.currentUser.me();
|
|
112
|
+
userName = currentUser?.userName;
|
|
113
|
+
email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
|
|
114
|
+
?.value as string;
|
|
115
|
+
}
|
|
116
|
+
if (userName) {
|
|
117
|
+
requestContext.set(MASTRA_USER_NAME_KEY, userName);
|
|
118
|
+
}
|
|
119
|
+
if (email) {
|
|
120
|
+
requestContext.set(MASTRA_USER_EMAIL_KEY, email);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Stamp a per-request id and echo it on the response so an upstream
|
|
126
|
+
* proxy / curl client / browser-side log line can pair its view of
|
|
127
|
+
* the request with the matching trace span. Reuses `X-Request-Id`
|
|
128
|
+
* when the upstream already supplies one so multi-hop traces stay
|
|
129
|
+
* joined; otherwise mints a UUIDv4.
|
|
130
|
+
*
|
|
131
|
+
* The id is surfaced as `mastra__requestId` span metadata via
|
|
132
|
+
* {@link TRACE_REQUEST_CONTEXT_KEYS} and as the `X-Request-Id`
|
|
133
|
+
* response header so dev tools can copy it from either side.
|
|
134
|
+
*/
|
|
135
|
+
configureRequestContextRequestId(
|
|
136
|
+
req: express.Request,
|
|
137
|
+
res: express.Response,
|
|
138
|
+
requestContext: RequestContext,
|
|
139
|
+
) {
|
|
140
|
+
if (requestContext.get(MASTRA_REQUEST_ID_KEY)) return;
|
|
141
|
+
const headerValue = req.headers["x-request-id"];
|
|
142
|
+
const upstream = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
143
|
+
const requestId = upstream?.trim() || randomUUID();
|
|
144
|
+
requestContext.set(MASTRA_REQUEST_ID_KEY, requestId);
|
|
145
|
+
res.setHeader("X-Request-Id", requestId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stamp OAuth scopes from the forwarded access token on
|
|
150
|
+
* {@link MASTRA_SCOPES_KEY} for workspace mount gating.
|
|
151
|
+
*/
|
|
152
|
+
configureRequestContextScopes(req: express.Request, requestContext: RequestContext) {
|
|
153
|
+
const scopes = [
|
|
154
|
+
...object
|
|
155
|
+
.sequence(
|
|
156
|
+
token.getAccessTokenScopes(req),
|
|
157
|
+
token.getAccessTokenScopes(req, "authorization"),
|
|
158
|
+
)
|
|
159
|
+
.distinct(),
|
|
160
|
+
];
|
|
161
|
+
requestContext.set(MASTRA_SCOPES_KEY, scopes);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Stamp the turn's MLflow trace id on the response so the chat client
|
|
166
|
+
* can attach thumbs / comment feedback to it later. MLflow derives
|
|
167
|
+
* its trace id from the OpenTelemetry trace id (`tr-<hex>`), and every
|
|
168
|
+
* Mastra span for this request inherits the ambient OTel context (see
|
|
169
|
+
* `observability.ts`), so the active span's trace id here is the id
|
|
170
|
+
* MLflow will record for the turn.
|
|
171
|
+
*
|
|
172
|
+
* No-op unless feedback is enabled, and when no live OTel span is
|
|
173
|
+
* active (e.g. the OTLP SDK isn't registered): in that case the header
|
|
174
|
+
* is simply absent and the client hides feedback for that message,
|
|
175
|
+
* degrading gracefully rather than emitting a bogus trace id.
|
|
176
|
+
*/
|
|
177
|
+
configureMlflowTraceId(res: express.Response) {
|
|
178
|
+
if (!this.feedbackEnabled || res.headersSent) return;
|
|
179
|
+
const traceId = trace.getActiveSpan()?.spanContext().traceId;
|
|
180
|
+
if (!traceId || traceId === INVALID_TRACE_ID) return;
|
|
181
|
+
res.setHeader(feedback.MLFLOW_TRACE_ID_HEADER, `tr-${traceId}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Resolve the thread id this request targets and pin it on
|
|
186
|
+
* `RequestContext` (consumed by the agent stream for persistence and
|
|
187
|
+
* by the history / threads routes). Resolution order:
|
|
188
|
+
*
|
|
189
|
+
* 1. A client-supplied thread id (the thread-selection header /
|
|
190
|
+
* `?threadId=` query). This is how the chat UI references a
|
|
191
|
+
* specific conversation among the many a user owns - it picks a
|
|
192
|
+
* thread id from the `/threads` listing (or mints one for a new
|
|
193
|
+
* conversation) and stamps it here. The id is scoped to the
|
|
194
|
+
* caller's resource by the recall / list routes, so a client
|
|
195
|
+
* can only ever read or write its own threads.
|
|
196
|
+
* 2. The per-session cookie (`appkit_<plugin-name>_session_id`),
|
|
197
|
+
* minted on first contact. This is the default single-thread
|
|
198
|
+
* fallback for clients that don't manage threads explicitly, so
|
|
199
|
+
* existing embeds keep one stable conversation per session with
|
|
200
|
+
* no client changes.
|
|
201
|
+
*/
|
|
202
|
+
configureRequestContextThreadId(
|
|
203
|
+
req: express.Request,
|
|
204
|
+
res: express.Response,
|
|
205
|
+
requestContext: RequestContext,
|
|
206
|
+
) {
|
|
207
|
+
if (requestContext.get(MASTRA_THREAD_ID_KEY)) return;
|
|
208
|
+
const requested = this.readRequestedThreadId(req);
|
|
209
|
+
if (requested) {
|
|
210
|
+
requestContext.set(MASTRA_THREAD_ID_KEY, requested);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const cookies = http.parseCookies(req.headers.cookie);
|
|
214
|
+
const cookieName = string.toIdentifierWithOptions(
|
|
215
|
+
{ delimiter: "_", distinct: true },
|
|
216
|
+
"appkit",
|
|
217
|
+
this.config.name!,
|
|
218
|
+
"sessionId",
|
|
219
|
+
);
|
|
220
|
+
let sessionId = cookies[cookieName];
|
|
221
|
+
if (!sessionId) {
|
|
222
|
+
sessionId = randomUUID();
|
|
223
|
+
res.cookie(cookieName, sessionId, {
|
|
224
|
+
httpOnly: true,
|
|
225
|
+
sameSite: "lax",
|
|
226
|
+
secure: req.secure,
|
|
227
|
+
path: "/",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
requestContext.set(MASTRA_THREAD_ID_KEY, sessionId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Read the client-selected thread id from the request, preferring
|
|
235
|
+
* the thread-selection header over the `?threadId=` query. Returns
|
|
236
|
+
* `null` when neither carries a non-empty value so the caller falls
|
|
237
|
+
* back to the session cookie.
|
|
238
|
+
*/
|
|
239
|
+
private readRequestedThreadId(req: express.Request): string | null {
|
|
240
|
+
const headerValue = req.headers[thread.THREAD_ID_HEADER];
|
|
241
|
+
const queryValue = req.query[thread.THREAD_ID_QUERY];
|
|
242
|
+
return (
|
|
243
|
+
string.trimToNull(Array.isArray(headerValue) ? headerValue[0] : headerValue) ??
|
|
244
|
+
string.trimToNull(Array.isArray(queryValue) ? queryValue[0] : queryValue)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
configureRequestContextModelOverride(req: express.Request, requestContext: RequestContext) {
|
|
249
|
+
// Per-request model override: only honored when the plugin
|
|
250
|
+
// opts in (default). Sources, in priority order, are
|
|
251
|
+
// `X-Mastra-Model` header, `?model=` query, and `model` /
|
|
252
|
+
// `modelId` body field; see `serving.ts`.
|
|
253
|
+
const serving = resolveServingConfig(this.config);
|
|
254
|
+
if (serving.allowOverride) {
|
|
255
|
+
const override = extractModelOverride({
|
|
256
|
+
headers: req.headers as Record<string, string | string[] | undefined>,
|
|
257
|
+
query: req.query as Record<string, unknown>,
|
|
258
|
+
body: req.body,
|
|
259
|
+
});
|
|
260
|
+
if (override) requestContext.set(MASTRA_MODEL_OVERRIDE_KEY, override);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Inputs for {@link isMastraRequestAllowed}. */
|
|
266
|
+
export interface MastraApiGateOptions {
|
|
267
|
+
/** `config.apiAccess`; `"full"` short-circuits to allow everything. */
|
|
268
|
+
access: "scoped" | "full";
|
|
269
|
+
/** Whether the MCP transport is mounted (so `/mcp/*` is legitimate). */
|
|
270
|
+
mcpEnabled: boolean;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Mount-relative agent inference paths (`/agents/:id/<verb>...`). Matched
|
|
275
|
+
* by prefix on the verb so future variants (`streamVNext`, `stream/vnext`,
|
|
276
|
+
* `generateVNext`) stay covered without a code change.
|
|
277
|
+
*
|
|
278
|
+
* Covers the plain inference verbs (`stream` / `generate` / `network`) plus
|
|
279
|
+
* the human-in-the-loop resume verbs a paused `requireApproval` tool needs
|
|
280
|
+
* to continue: `approve-tool-call` / `decline-tool-call` (streaming, and via
|
|
281
|
+
* prefix their `-generate` non-streaming variants), the `-network-` variants,
|
|
282
|
+
* and `resume-stream` (covers `resume-stream-until-idle`). Without these an
|
|
283
|
+
* approval-gated tool (e.g. `send_email`) can be requested but never approved
|
|
284
|
+
* from the browser - the resume `POST` 403s under scoped mode. These are the
|
|
285
|
+
* only *writes* the browser client is allowed to make against stock Mastra.
|
|
286
|
+
*/
|
|
287
|
+
const AGENT_INFERENCE =
|
|
288
|
+
/^\/agents\/[^/]+\/(stream|generate|network|resume-stream|approve-tool-call|decline-tool-call|approve-network-tool-call|decline-network-tool-call)/i;
|
|
289
|
+
|
|
290
|
+
/** Mount-relative read-only agent metadata (`/agents`, `/agents/:id`). */
|
|
291
|
+
const AGENT_METADATA = /^\/agents(\/[^/]+)?$/;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether a request to the stock `@mastra/express` sub-app should be
|
|
295
|
+
* dispatched, given the configured {@link MastraApiGateOptions.access}.
|
|
296
|
+
*
|
|
297
|
+
* `path` is mount-relative (what the plugin's catch-all sees, e.g.
|
|
298
|
+
* `/agents/x/stream`, `/route/history/x`, `/mcp/...`). In `"scoped"`
|
|
299
|
+
* mode the allowlist is deliberately tight - the chat client only ever
|
|
300
|
+
* needs agent inference, read-only agent metadata, this plugin's own
|
|
301
|
+
* OBO/resource-scoped `/route/*` routes, and (when enabled) MCP - so the
|
|
302
|
+
* whole admin / mutating / bulk-export surface Mastra also exposes is
|
|
303
|
+
* denied by default rather than enumerated.
|
|
304
|
+
*/
|
|
305
|
+
export function isMastraRequestAllowed(
|
|
306
|
+
method: string,
|
|
307
|
+
path: string,
|
|
308
|
+
opts: MastraApiGateOptions,
|
|
309
|
+
): boolean {
|
|
310
|
+
if (opts.access === "full") return true;
|
|
311
|
+
const p = path.startsWith("/") ? path : `/${path}`;
|
|
312
|
+
// This plugin's own custom routes are individually OBO- and
|
|
313
|
+
// resource-scoped (see history.ts / threads.ts), so every method is safe.
|
|
314
|
+
if (p === "/route" || p.startsWith("/route/")) return true;
|
|
315
|
+
if (opts.mcpEnabled && (p === "/mcp" || p.startsWith("/mcp/"))) return true;
|
|
316
|
+
const m = method.toUpperCase();
|
|
317
|
+
if (m === "POST" && AGENT_INFERENCE.test(p)) return true;
|
|
318
|
+
if (m === "GET" && AGENT_METADATA.test(p)) return true;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Patches around `@mastra/express`'s custom-route dispatcher so the
|
|
324
|
+
* plugin's custom API routes (e.g. `historyRoute`) work when
|
|
325
|
+
* `MastraServer` is hosted on an Express subapp mounted under a parent
|
|
326
|
+
* path (e.g. `/api/mastra`).
|
|
327
|
+
*
|
|
328
|
+
* The adapter's `registerCustomApiRoutes` matches against `req.path`
|
|
329
|
+
* (mount-relative, correct) but dispatches to its internal Hono
|
|
330
|
+
* mini-app using `req.originalUrl`, which still contains the parent
|
|
331
|
+
* mount prefix. The Hono app registers the literal route paths
|
|
332
|
+
* (for example `/route/history`), so the absolute URL never matches
|
|
333
|
+
* until we overwrite `originalUrl` for `/route` and `/route/*` to the
|
|
334
|
+
* mount-relative path.
|
|
335
|
+
*/
|
|
336
|
+
export function attachRoutePatchMiddleware(app: express.Express): void {
|
|
337
|
+
app.use((req, _res, next) => {
|
|
338
|
+
const isCustomRoute = req.path === "/route" || req.path.startsWith("/route/");
|
|
339
|
+
if (!isCustomRoute) return next();
|
|
340
|
+
req.originalUrl = req.path;
|
|
341
|
+
next();
|
|
342
|
+
});
|
|
343
|
+
}
|