@dbx-tools/appkit-mastra 0.3.36 → 0.3.39
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 +30 -1
- package/package.json +13 -9
- package/src/model.ts +38 -2
- package/src/plugin.ts +25 -1
- package/src/server.ts +74 -30
- package/src/serving-sanitize.ts +68 -9
- package/test/chart.test.ts +0 -63
- package/test/config.test.ts +0 -64
- package/test/tsconfig.json +0 -14
- package/tsconfig.json +0 -41
package/README.md
CHANGED
|
@@ -388,6 +388,33 @@ plugin.mastra({
|
|
|
388
388
|
Use `mcp: false` to disable MCP. Turn on `tools: true` only for ambient tools
|
|
389
389
|
that are safe outside an in-process chat turn.
|
|
390
390
|
|
|
391
|
+
## Driving A Turn From Outside The Routes
|
|
392
|
+
|
|
393
|
+
Another plugin (or a scheduled job) can run an agent turn directly, but a raw
|
|
394
|
+
`agent.generate(prompt)` loses everything the HTTP middleware stamps - most
|
|
395
|
+
visibly the AppKit user, which every user-scoped tool reads. `ask_genie` then
|
|
396
|
+
fails with "invoke the tool from an agent turn served by the mastra plugin", so
|
|
397
|
+
the turn answers "the data source is unreachable" where the chat routes answer
|
|
398
|
+
with real data.
|
|
399
|
+
|
|
400
|
+
`exports().createRequestContext()` builds the missing context:
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
const mastra = context.getPlugins()?.get("mastra")?.exports();
|
|
404
|
+
const requestContext = await mastra.createRequestContext({
|
|
405
|
+
threadId: conversationId,
|
|
406
|
+
resourceId: userId,
|
|
407
|
+
});
|
|
408
|
+
const result = await mastra.getDefault().generate(prompt, { requestContext });
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
The AppKit user, the memory thread / resource pair, and a request id (so the
|
|
412
|
+
turn's spans join up in traces) are stamped exactly as the request middleware
|
|
413
|
+
stamps them. Call it inside an `asUser(req)` scope to inherit the caller's OBO
|
|
414
|
+
identity; outside one it resolves to the service principal.
|
|
415
|
+
[`@dbx-tools/teams`](../teams) uses this so a Teams card turn has the same tool
|
|
416
|
+
reach - and therefore the same answer - as a chat turn.
|
|
417
|
+
|
|
391
418
|
## API Gate
|
|
392
419
|
|
|
393
420
|
The stock `@mastra/express` app has broad management routes. The plugin's
|
|
@@ -468,7 +495,9 @@ client that talks to these routes.
|
|
|
468
495
|
defaults, and approval-gated tool inspection.
|
|
469
496
|
- `config` - plugin config types and RequestContext key constants.
|
|
470
497
|
- `model` / `serving` / `servingSanitize` - Mastra model config, request
|
|
471
|
-
overrides, serving-endpoint config, and
|
|
498
|
+
overrides, serving-endpoint config, and the on-the-wire request/response
|
|
499
|
+
cleanup that keeps provider-specific payload quirks (Claude's replayed
|
|
500
|
+
thinking blocks, Gemini's content-parts responses) from failing a turn.
|
|
472
501
|
- `genie` - Genie prompt, space normalization, Genie toolkits, and suggestions.
|
|
473
502
|
- `chart` / `statement` / `writer` - chart cache, statement row fetches, and
|
|
474
503
|
safe writer events.
|
package/package.json
CHANGED
|
@@ -28,27 +28,31 @@
|
|
|
28
28
|
"express": "^5.1.0",
|
|
29
29
|
"pg": "^8.22.0",
|
|
30
30
|
"zod": "4.3.6",
|
|
31
|
-
"@dbx-tools/appkit": "0.3.
|
|
32
|
-
"@dbx-tools/
|
|
33
|
-
"@dbx-tools/
|
|
34
|
-
"@dbx-tools/
|
|
35
|
-
"@dbx-tools/shared-
|
|
36
|
-
"@dbx-tools/
|
|
37
|
-
"@dbx-tools/shared-mastra": "0.3.
|
|
38
|
-
"@dbx-tools/shared-model": "0.3.
|
|
31
|
+
"@dbx-tools/appkit": "0.3.39",
|
|
32
|
+
"@dbx-tools/genie": "0.3.39",
|
|
33
|
+
"@dbx-tools/core": "0.3.39",
|
|
34
|
+
"@dbx-tools/shared-core": "0.3.39",
|
|
35
|
+
"@dbx-tools/shared-genie": "0.3.39",
|
|
36
|
+
"@dbx-tools/model": "0.3.39",
|
|
37
|
+
"@dbx-tools/shared-mastra": "0.3.39",
|
|
38
|
+
"@dbx-tools/shared-model": "0.3.39"
|
|
39
39
|
},
|
|
40
40
|
"main": "index.ts",
|
|
41
41
|
"license": "UNLICENSED",
|
|
42
42
|
"publishConfig": {
|
|
43
43
|
"access": "public"
|
|
44
44
|
},
|
|
45
|
-
"version": "0.3.
|
|
45
|
+
"version": "0.3.39",
|
|
46
46
|
"types": "index.ts",
|
|
47
47
|
"type": "module",
|
|
48
48
|
"exports": {
|
|
49
49
|
".": "./index.ts",
|
|
50
50
|
"./package.json": "./package.json"
|
|
51
51
|
},
|
|
52
|
+
"files": [
|
|
53
|
+
"index.ts",
|
|
54
|
+
"src"
|
|
55
|
+
],
|
|
52
56
|
"dbxToolsConfig": {
|
|
53
57
|
"tags": [
|
|
54
58
|
"node"
|
package/src/model.ts
CHANGED
|
@@ -32,7 +32,7 @@ import type { RequestContext } from "@mastra/core/request-context";
|
|
|
32
32
|
|
|
33
33
|
import { MASTRA_USER_KEY, type MastraPluginConfig, type User } from "./config";
|
|
34
34
|
import { MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
|
|
35
|
-
import { rewriteServingBody } from "./serving-sanitize";
|
|
35
|
+
import { rewriteServingBody, rewriteServingResponseBody } from "./serving-sanitize";
|
|
36
36
|
|
|
37
37
|
type ModelClass = model.ModelClass;
|
|
38
38
|
const { parseModelClass } = classes;
|
|
@@ -140,6 +140,10 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
|
|
|
140
140
|
* 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
|
|
141
141
|
* 4xx debugging doesn't have to fight AI SDK's `[Array]`
|
|
142
142
|
* formatter.
|
|
143
|
+
* 3. Repairs the non-streaming JSON response, where Databricks-hosted
|
|
144
|
+
* Gemini returns `choices[].message.content` as a parts array that
|
|
145
|
+
* the AI SDK's OpenAI schema rejects (see
|
|
146
|
+
* {@link rewriteServingResponseBody}).
|
|
143
147
|
*
|
|
144
148
|
* Safe to call from any hot path: {@link functionModule.memoize} ensures
|
|
145
149
|
* the wrapper is installed at most once per process, so subsequent
|
|
@@ -169,6 +173,38 @@ const setupFetchInterceptor = functionModule.memoize((): void => {
|
|
|
169
173
|
? { url: url.toString(), bodyType: "non-JSON" }
|
|
170
174
|
: { url: url.toString(), body: parsed },
|
|
171
175
|
);
|
|
172
|
-
|
|
176
|
+
const response = await original(input, init);
|
|
177
|
+
return repairServingResponse(response);
|
|
173
178
|
}) as typeof globalThis.fetch;
|
|
174
179
|
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Rewrite a non-streaming serving response whose body needs repair, leaving
|
|
183
|
+
* everything else byte-identical.
|
|
184
|
+
*
|
|
185
|
+
* Streaming turns are passed straight through: an SSE body must stay a live
|
|
186
|
+
* stream (buffering it to a string would defeat streaming and break
|
|
187
|
+
* `text/event-stream` parsing), and the delta frames already carry string
|
|
188
|
+
* content, so they never hit the array-shaped `content` bug. Likewise a
|
|
189
|
+
* non-JSON or error body is returned untouched, so the caller still sees the
|
|
190
|
+
* original status and headers.
|
|
191
|
+
*/
|
|
192
|
+
async function repairServingResponse(response: Response): Promise<Response> {
|
|
193
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
194
|
+
if (!contentType.includes("application/json")) return response;
|
|
195
|
+
|
|
196
|
+
const body = await response.clone().text();
|
|
197
|
+
const rewritten = rewriteServingResponseBody(body);
|
|
198
|
+
if (rewritten === body) return response;
|
|
199
|
+
|
|
200
|
+
// `content-length` / `content-encoding` describe the ORIGINAL bytes, so they
|
|
201
|
+
// are dropped: the rewritten body is a different length and already decoded.
|
|
202
|
+
const headers = new Headers(response.headers);
|
|
203
|
+
headers.delete("content-length");
|
|
204
|
+
headers.delete("content-encoding");
|
|
205
|
+
return new Response(rewritten, {
|
|
206
|
+
status: response.status,
|
|
207
|
+
statusText: response.statusText,
|
|
208
|
+
headers,
|
|
209
|
+
});
|
|
210
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
import { display, type ServingEndpointSummary } from "@dbx-tools/shared-model";
|
|
71
71
|
import type { Agent } from "@mastra/core/agent";
|
|
72
72
|
import { Mastra } from "@mastra/core/mastra";
|
|
73
|
+
import type { RequestContext } from "@mastra/core/request-context";
|
|
73
74
|
import express from "express";
|
|
74
75
|
import type { Pool } from "pg";
|
|
75
76
|
|
|
@@ -89,7 +90,12 @@ import { buildMcpServer, type ResolvedMcp } from "./mcp";
|
|
|
89
90
|
import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory";
|
|
90
91
|
import { logFeedback, resolveFeedbackEnabled } from "./mlflow";
|
|
91
92
|
import { buildObservability } from "./observability";
|
|
92
|
-
import {
|
|
93
|
+
import {
|
|
94
|
+
attachRoutePatchMiddleware,
|
|
95
|
+
createRequestContext,
|
|
96
|
+
isMastraRequestAllowed,
|
|
97
|
+
MastraServer,
|
|
98
|
+
} from "./server";
|
|
93
99
|
import { resolveServingConfig } from "./serving";
|
|
94
100
|
import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement";
|
|
95
101
|
import { threadsRoute } from "./threads";
|
|
@@ -303,6 +309,24 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
303
309
|
(this.built && this.built.agents[this.built.defaultAgentId]) ?? null,
|
|
304
310
|
/** Underlying Mastra instance for advanced use (custom routes etc.). */
|
|
305
311
|
getMastra: () => this.mastra,
|
|
312
|
+
/**
|
|
313
|
+
* Build the `RequestContext` an agent turn driven from OUTSIDE this
|
|
314
|
+
* plugin's routes needs (a Teams activity on another plugin's endpoint, a
|
|
315
|
+
* scheduled job).
|
|
316
|
+
*
|
|
317
|
+
* Mastra's user-scoped tools - `ask_genie` most visibly - read the AppKit
|
|
318
|
+
* user off the request context, which only the HTTP middleware stamps.
|
|
319
|
+
* Calling `agent.generate` with a raw prompt therefore answers "the data
|
|
320
|
+
* source is unreachable" where the chat routes answer with real data.
|
|
321
|
+
* Passing this as `requestContext` closes that gap, so an out-of-band turn
|
|
322
|
+
* has exactly the capabilities a chat turn has.
|
|
323
|
+
*
|
|
324
|
+
* Must be called INSIDE an `asUser(req)` scope to inherit the caller's
|
|
325
|
+
* OBO identity; outside one it resolves to the service principal.
|
|
326
|
+
*/
|
|
327
|
+
createRequestContext: (
|
|
328
|
+
options: { threadId?: string; resourceId?: string; requestId?: string } = {},
|
|
329
|
+
): Promise<RequestContext> => createRequestContext(options),
|
|
306
330
|
/**
|
|
307
331
|
* MCP endpoint info when `config.mcp` is enabled, else `null`.
|
|
308
332
|
* Streamable HTTP is `http`; the SSE pair is the legacy transport.
|
package/src/server.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { feedback, thread } from "@dbx-tools/shared-mastra";
|
|
|
13
13
|
import {
|
|
14
14
|
MASTRA_RESOURCE_ID_KEY,
|
|
15
15
|
MASTRA_THREAD_ID_KEY,
|
|
16
|
-
|
|
16
|
+
RequestContext,
|
|
17
17
|
} from "@mastra/core/request-context";
|
|
18
18
|
import { MastraServer as MastraServerExpress } from "@mastra/express";
|
|
19
19
|
import { trace } from "@opentelemetry/api";
|
|
@@ -40,6 +40,78 @@ import { extractModelOverride, MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig }
|
|
|
40
40
|
*/
|
|
41
41
|
const INVALID_TRACE_ID = "0".repeat(32);
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Stamp the AppKit user (plus the resource id and trace metadata) onto
|
|
45
|
+
* `requestContext`.
|
|
46
|
+
*
|
|
47
|
+
* Split out of {@link MastraServer} because the HTTP middleware is not the only
|
|
48
|
+
* caller that needs it: an out-of-band turn (a Teams activity arriving on
|
|
49
|
+
* another plugin's route, a scheduled job) drives `agent.generate` directly, and
|
|
50
|
+
* without this stamp every user-scoped tool - `ask_genie` above all - fails with
|
|
51
|
+
* "invoke the tool from an agent turn served by the mastra plugin". Those
|
|
52
|
+
* callers build a context with {@link createRequestContext} instead of a
|
|
53
|
+
* request.
|
|
54
|
+
*
|
|
55
|
+
* Idempotent: returns immediately when the user and resource id are already
|
|
56
|
+
* present, so the middleware can call it over a context another layer stamped.
|
|
57
|
+
*/
|
|
58
|
+
export async function stampRequestContextUser(requestContext: RequestContext): Promise<void> {
|
|
59
|
+
if ([MASTRA_USER_KEY, MASTRA_RESOURCE_ID_KEY].every((key) => requestContext.get(key))) return;
|
|
60
|
+
const executionContext = getExecutionContext();
|
|
61
|
+
const user: User = {
|
|
62
|
+
id: executionContextUserId(executionContext),
|
|
63
|
+
executionContext,
|
|
64
|
+
};
|
|
65
|
+
requestContext.set(MASTRA_USER_KEY, user);
|
|
66
|
+
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
67
|
+
// AppKit's `UserContext` surfaces display name / email only on
|
|
68
|
+
// OBO requests. Service-context calls (background tasks, server
|
|
69
|
+
// start-up) leave these undefined and we skip the stamp so
|
|
70
|
+
// downstream trace metadata stays absent rather than empty.
|
|
71
|
+
let userName: string | undefined;
|
|
72
|
+
let email: string | undefined;
|
|
73
|
+
if ("isUserContext" in executionContext) {
|
|
74
|
+
userName = executionContext.userName;
|
|
75
|
+
email = executionContext.userEmail;
|
|
76
|
+
} else if (process.env.NODE_ENV === "development") {
|
|
77
|
+
const currentUser = await executionContext.client.currentUser.me();
|
|
78
|
+
userName = currentUser?.userName;
|
|
79
|
+
email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
|
|
80
|
+
?.value as string;
|
|
81
|
+
}
|
|
82
|
+
if (userName) {
|
|
83
|
+
requestContext.set(MASTRA_USER_NAME_KEY, userName);
|
|
84
|
+
}
|
|
85
|
+
if (email) {
|
|
86
|
+
requestContext.set(MASTRA_USER_EMAIL_KEY, email);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build the `RequestContext` a NON-HTTP agent turn needs.
|
|
92
|
+
*
|
|
93
|
+
* Everything Mastra's tools read off the context is stamped the same way the
|
|
94
|
+
* request middleware stamps it - the AppKit user (so `ask_genie` and the model
|
|
95
|
+
* resolver can mint user-scoped tokens), the memory thread / resource pair, and
|
|
96
|
+
* a request id so the turn's spans join up in traces. The result is passed as
|
|
97
|
+
* `requestContext` to `agent.generate` / `agent.stream`.
|
|
98
|
+
*
|
|
99
|
+
* This is what makes a card turn answer with the SAME data a chat turn does: the
|
|
100
|
+
* difference between the two endpoints is presentation, not capability.
|
|
101
|
+
*/
|
|
102
|
+
export async function createRequestContext(
|
|
103
|
+
options: { threadId?: string; resourceId?: string; requestId?: string } = {},
|
|
104
|
+
): Promise<RequestContext> {
|
|
105
|
+
const requestContext = new RequestContext();
|
|
106
|
+
await stampRequestContextUser(requestContext);
|
|
107
|
+
if (options.threadId) requestContext.set(MASTRA_THREAD_ID_KEY, options.threadId);
|
|
108
|
+
// Stamped AFTER the user so an explicit resource id (a channel member, say)
|
|
109
|
+
// wins over the ambient identity `stampRequestContextUser` defaulted to.
|
|
110
|
+
if (options.resourceId) requestContext.set(MASTRA_RESOURCE_ID_KEY, options.resourceId);
|
|
111
|
+
requestContext.set(MASTRA_REQUEST_ID_KEY, options.requestId ?? hash.id());
|
|
112
|
+
return requestContext;
|
|
113
|
+
}
|
|
114
|
+
|
|
43
115
|
/**
|
|
44
116
|
* `@mastra/express` subclass that stamps `RequestContext` with the
|
|
45
117
|
* AppKit user, resource id, and a thread id backed by an HTTP-only
|
|
@@ -92,35 +164,7 @@ export class MastraServer extends MastraServerExpress {
|
|
|
92
164
|
}
|
|
93
165
|
|
|
94
166
|
async configureRequestContextUser(requestContext: RequestContext) {
|
|
95
|
-
|
|
96
|
-
const executionContext = getExecutionContext();
|
|
97
|
-
const user: User = {
|
|
98
|
-
id: executionContextUserId(executionContext),
|
|
99
|
-
executionContext,
|
|
100
|
-
};
|
|
101
|
-
requestContext.set(MASTRA_USER_KEY, user);
|
|
102
|
-
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id);
|
|
103
|
-
// AppKit's `UserContext` surfaces display name / email only on
|
|
104
|
-
// OBO requests. Service-context calls (background tasks, server
|
|
105
|
-
// start-up) leave these undefined and we skip the stamp so
|
|
106
|
-
// downstream trace metadata stays absent rather than empty.
|
|
107
|
-
let userName: string | undefined;
|
|
108
|
-
let email: string | undefined;
|
|
109
|
-
if ("isUserContext" in executionContext) {
|
|
110
|
-
userName = executionContext.userName;
|
|
111
|
-
email = executionContext.userEmail;
|
|
112
|
-
} else if (process.env.NODE_ENV === "development") {
|
|
113
|
-
const currentUser = await executionContext.client.currentUser.me();
|
|
114
|
-
userName = currentUser?.userName;
|
|
115
|
-
email = currentUser?.emails?.filter((email) => email.primary).find((email) => email.value)
|
|
116
|
-
?.value as string;
|
|
117
|
-
}
|
|
118
|
-
if (userName) {
|
|
119
|
-
requestContext.set(MASTRA_USER_NAME_KEY, userName);
|
|
120
|
-
}
|
|
121
|
-
if (email) {
|
|
122
|
-
requestContext.set(MASTRA_USER_EMAIL_KEY, email);
|
|
123
|
-
}
|
|
167
|
+
await stampRequestContextUser(requestContext);
|
|
124
168
|
}
|
|
125
169
|
|
|
126
170
|
/**
|
package/src/serving-sanitize.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Repairs Mastra / AI SDK
|
|
3
|
-
*
|
|
4
|
-
* route.
|
|
2
|
+
* Repairs traffic between Mastra / the AI SDK and Databricks Model Serving's
|
|
3
|
+
* OpenAI-compatible `/chat/completions` route, in both directions.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
* provider will accept back:
|
|
8
|
-
* extended-thinking blocks and reads
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* Outbound ({@link rewriteServingBody}), because the transcript Mastra
|
|
6
|
+
* persists is not always a transcript the provider will accept back:
|
|
7
|
+
* Databricks-hosted Claude rejects replayed extended-thinking blocks and reads
|
|
8
|
+
* a trailing assistant message as a prefill request.
|
|
9
|
+
*
|
|
10
|
+
* Inbound ({@link rewriteServingResponseBody}), because Databricks-hosted
|
|
11
|
+
* Gemini answers with its native content-parts array where the OpenAI contract
|
|
12
|
+
* (and therefore the AI SDK's response schema) requires a plain string.
|
|
13
|
+
*
|
|
14
|
+
* Every repair here is a provider quirk rather than a schema violation, so all
|
|
15
|
+
* of them are applied on the wire (see the `globalThis.fetch` wrapper in
|
|
16
|
+
* `model.ts`) rather than by changing what the agent stores or what the UI
|
|
17
|
+
* shows.
|
|
12
18
|
*
|
|
13
19
|
* @module
|
|
14
20
|
*/
|
|
@@ -186,3 +192,56 @@ function isEmptyServingContent(content: ServingChatMessage["content"]): boolean
|
|
|
186
192
|
return false;
|
|
187
193
|
});
|
|
188
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Parse, sanitize, and re-serialize a `/serving-endpoints/...` non-streaming
|
|
198
|
+
* JSON RESPONSE body. Returns the original string verbatim when the body is
|
|
199
|
+
* not JSON or no rewrite was needed, mirroring
|
|
200
|
+
* {@link rewriteServingBody} on the request side.
|
|
201
|
+
*/
|
|
202
|
+
export function rewriteServingResponseBody(body: string): string {
|
|
203
|
+
const parsed = json.parseRecord(body);
|
|
204
|
+
if (!parsed) return body;
|
|
205
|
+
return flattenChoiceMessageContent(parsed) ? JSON.stringify(parsed) : body;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Collapse a structured `choices[].message.content` array to the plain string
|
|
210
|
+
* the OpenAI Chat Completions contract specifies.
|
|
211
|
+
*
|
|
212
|
+
* Databricks-hosted Gemini answers a non-streaming `/chat/completions` call
|
|
213
|
+
* with the Gemini-native parts shape:
|
|
214
|
+
*
|
|
215
|
+
* ```json
|
|
216
|
+
* "content": [{ "type": "text", "text": "...", "thoughtSignature": "..." }]
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* `@ai-sdk/openai-compatible` validates the response against the OpenAI
|
|
220
|
+
* schema, where `content` is a nullable string, so it throws
|
|
221
|
+
* `Type validation failed: ... expected string, received array` and the whole
|
|
222
|
+
* call fails (`AI_APICallError: Invalid JSON response` on an HTTP 200). Mastra
|
|
223
|
+
* uses `doGenerate` for its side calls, so the visible symptom is
|
|
224
|
+
* `Error generating title` - every thread keeps its placeholder name.
|
|
225
|
+
*
|
|
226
|
+
* Flattening on the wire keeps the repair in one place: the streaming path is
|
|
227
|
+
* unaffected (deltas already carry string content), and neither the agent's
|
|
228
|
+
* stored transcript nor the UI has to know the provider emitted parts. Any
|
|
229
|
+
* non-text part (a `thoughtSignature`-only entry, an inline image) contributes
|
|
230
|
+
* nothing, matching {@link openaiChat.chatContentToText}, and an all-parts-empty
|
|
231
|
+
* message flattens to `""` rather than being dropped, so `finish_reason` and
|
|
232
|
+
* `usage` still round-trip.
|
|
233
|
+
*/
|
|
234
|
+
export function flattenChoiceMessageContent(payload: Record<string, unknown>): boolean {
|
|
235
|
+
if (!Array.isArray(payload.choices)) return false;
|
|
236
|
+
let changed = false;
|
|
237
|
+
for (const choice of payload.choices) {
|
|
238
|
+
if (!choice || typeof choice !== "object") continue;
|
|
239
|
+
const message = (choice as { message?: unknown }).message;
|
|
240
|
+
if (!message || typeof message !== "object") continue;
|
|
241
|
+
const target = message as { content?: unknown };
|
|
242
|
+
if (!Array.isArray(target.content)) continue;
|
|
243
|
+
target.content = openaiChat.chatContentToText(target.content, { types: ["text"] });
|
|
244
|
+
changed = true;
|
|
245
|
+
}
|
|
246
|
+
return changed;
|
|
247
|
+
}
|
package/test/chart.test.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
import { chartPlanSchema } from "../src/chart";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Walk every nested schema node, yielding each object so a test can
|
|
9
|
-
* assert on keywords wherever they appear in the tree.
|
|
10
|
-
*/
|
|
11
|
-
function* nodes(schema: unknown): Generator<Record<string, unknown>> {
|
|
12
|
-
if (Array.isArray(schema)) {
|
|
13
|
-
for (const item of schema) yield* nodes(item);
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (typeof schema !== "object" || schema === null) return;
|
|
17
|
-
const record = schema as Record<string, unknown>;
|
|
18
|
-
yield record;
|
|
19
|
-
for (const value of Object.values(record)) yield* nodes(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("chart plan JSON schema", () => {
|
|
23
|
-
// Databricks' Gemini serving endpoints validate `response_json_schema`
|
|
24
|
-
// and reject JSON Schema 2020-12 `prefixItems` with "schema at
|
|
25
|
-
// properties.series.items.properties.data.items.anyOf.2.items must be a
|
|
26
|
-
// boolean or an object". Zod emits `prefixItems` for `z.tuple`, so the
|
|
27
|
-
// scatter `[x, y]` point must stay a length-constrained number array.
|
|
28
|
-
it("emits no prefixItems, so Gemini accepts it as a response schema", () => {
|
|
29
|
-
const jsonSchema = z.toJSONSchema(chartPlanSchema);
|
|
30
|
-
const offenders = [...nodes(jsonSchema)].filter((node) => "prefixItems" in node);
|
|
31
|
-
assert.deepEqual(offenders, []);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("types the scatter point as a two-number array", () => {
|
|
35
|
-
const jsonSchema = z.toJSONSchema(chartPlanSchema) as Record<string, any>;
|
|
36
|
-
const variants = jsonSchema.properties.series.items.properties.data.items.anyOf;
|
|
37
|
-
const arrayVariant = variants.find((v: Record<string, unknown>) => v.type === "array");
|
|
38
|
-
assert.deepEqual(arrayVariant, {
|
|
39
|
-
type: "array",
|
|
40
|
-
items: { type: "number" },
|
|
41
|
-
minItems: 2,
|
|
42
|
-
maxItems: 2,
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
describe("chart data point coercion", () => {
|
|
48
|
-
const dataPoint = chartPlanSchema.shape.series.element.shape.data.element;
|
|
49
|
-
|
|
50
|
-
it("keeps the shapes a SQL row set produces", () => {
|
|
51
|
-
assert.equal(dataPoint.parse(12.5), 12.5);
|
|
52
|
-
assert.equal(dataPoint.parse("12.5"), 12.5);
|
|
53
|
-
assert.equal(dataPoint.parse(null), null);
|
|
54
|
-
assert.deepEqual(dataPoint.parse([1, 2]), [1, 2]);
|
|
55
|
-
assert.deepEqual(dataPoint.parse({ name: "ATL", value: 3 }), { name: "ATL", value: 3 });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it("degrades unusable values to null instead of failing the plan", () => {
|
|
59
|
-
assert.equal(dataPoint.parse("not a number"), null);
|
|
60
|
-
assert.equal(dataPoint.parse(Number.POSITIVE_INFINITY), null);
|
|
61
|
-
assert.equal(dataPoint.parse({ nope: true }), null);
|
|
62
|
-
});
|
|
63
|
-
});
|
package/test/config.test.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { ConfigurationError } from "@databricks/appkit";
|
|
4
|
-
|
|
5
|
-
import { MASTRA_CONFIG_SCHEMA } from "../src/config";
|
|
6
|
-
import { normalizeGenieSpaces } from "../src/genie";
|
|
7
|
-
import { invalidFields } from "../src/validation";
|
|
8
|
-
|
|
9
|
-
describe("mastra config schema", () => {
|
|
10
|
-
it("describes every published property", () => {
|
|
11
|
-
const properties = Object.entries(MASTRA_CONFIG_SCHEMA.properties ?? {});
|
|
12
|
-
assert.ok(properties.length > 0);
|
|
13
|
-
for (const [name, schema] of properties) {
|
|
14
|
-
assert.equal(typeof schema, "object", `${name} must be a schema object`);
|
|
15
|
-
const description = (schema as { description?: unknown }).description;
|
|
16
|
-
assert.equal(typeof description, "string", `${name} must carry a description`);
|
|
17
|
-
assert.ok((description as string).length > 0, `${name} description must be non-empty`);
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
describe("genie space normalization", () => {
|
|
23
|
-
it("wraps bare space ids and passes objects through", () => {
|
|
24
|
-
assert.deepEqual(normalizeGenieSpaces({ default: "01ef", sales: { spaceId: "02ef" } }), {
|
|
25
|
-
default: { spaceId: "01ef" },
|
|
26
|
-
sales: { spaceId: "02ef" },
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("treats no config as no spaces", () => {
|
|
31
|
-
assert.deepEqual(normalizeGenieSpaces(undefined), {});
|
|
32
|
-
assert.deepEqual(normalizeGenieSpaces({}), {});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("fails loudly on an alias with no space id", () => {
|
|
36
|
-
for (const spaces of [
|
|
37
|
-
{ default: undefined },
|
|
38
|
-
{ default: "" },
|
|
39
|
-
{ sales: { spaceId: "" } },
|
|
40
|
-
] as Parameters<typeof normalizeGenieSpaces>[0][]) {
|
|
41
|
-
assert.throws(() => normalizeGenieSpaces(spaces), ConfigurationError);
|
|
42
|
-
}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("names the env var only for the default alias", () => {
|
|
46
|
-
assert.throws(() => normalizeGenieSpaces({ default: undefined }), /DATABRICKS_GENIE_SPACE_ID/);
|
|
47
|
-
assert.throws(() => normalizeGenieSpaces({ sales: undefined }), /genieSpaces\.sales/);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("request body validation", () => {
|
|
52
|
-
it("reports distinct dot-joined field paths", () => {
|
|
53
|
-
assert.deepEqual(
|
|
54
|
-
invalidFields({
|
|
55
|
-
issues: [{ path: ["traceId"] }, { path: ["value", 0] }, { path: ["traceId"] }],
|
|
56
|
-
}),
|
|
57
|
-
["traceId", "value.0"],
|
|
58
|
-
);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("drops the root path, which names no field", () => {
|
|
62
|
-
assert.deepEqual(invalidFields({ issues: [{ path: [] }] }), []);
|
|
63
|
-
});
|
|
64
|
-
});
|
package/test/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"extends": "../tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"noEmit": true,
|
|
6
|
-
"rootDir": ".."
|
|
7
|
-
},
|
|
8
|
-
"include": [
|
|
9
|
-
"**/*.ts"
|
|
10
|
-
],
|
|
11
|
-
"exclude": [
|
|
12
|
-
"node_modules"
|
|
13
|
-
]
|
|
14
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"rootDir": "src",
|
|
5
|
-
"outDir": "lib",
|
|
6
|
-
"alwaysStrict": true,
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"experimentalDecorators": true,
|
|
10
|
-
"inlineSourceMap": true,
|
|
11
|
-
"inlineSources": true,
|
|
12
|
-
"lib": [
|
|
13
|
-
"ES2022"
|
|
14
|
-
],
|
|
15
|
-
"module": "ESNext",
|
|
16
|
-
"noEmitOnError": false,
|
|
17
|
-
"noFallthroughCasesInSwitch": true,
|
|
18
|
-
"noImplicitAny": true,
|
|
19
|
-
"noImplicitReturns": true,
|
|
20
|
-
"noImplicitThis": true,
|
|
21
|
-
"noUnusedLocals": true,
|
|
22
|
-
"noUnusedParameters": true,
|
|
23
|
-
"resolveJsonModule": true,
|
|
24
|
-
"strict": true,
|
|
25
|
-
"strictNullChecks": true,
|
|
26
|
-
"strictPropertyInitialization": true,
|
|
27
|
-
"stripInternal": true,
|
|
28
|
-
"target": "ES2022",
|
|
29
|
-
"types": [
|
|
30
|
-
"node"
|
|
31
|
-
],
|
|
32
|
-
"moduleResolution": "bundler",
|
|
33
|
-
"skipLibCheck": true
|
|
34
|
-
},
|
|
35
|
-
"include": [
|
|
36
|
-
"src/**/*.ts"
|
|
37
|
-
],
|
|
38
|
-
"exclude": [
|
|
39
|
-
"node_modules"
|
|
40
|
-
]
|
|
41
|
-
}
|