@dbx-tools/appkit-mastra 0.9.54 → 0.9.60
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 +5 -1
- package/index.ts +1 -1
- package/lib/index.d.ts +1 -1
- package/lib/index.js +2 -2
- package/lib/src/config.d.ts +4 -3
- package/lib/src/config.js +2 -2
- package/lib/src/memory.js +3 -2
- package/lib/src/mlflow.d.ts +12 -0
- package/lib/src/mlflow.js +134 -1
- package/lib/src/plugin.d.ts +7 -0
- package/lib/src/plugin.js +28 -2
- package/lib/src/remote-skills.js +2 -2
- package/lib/src/validation.d.ts +0 -11
- package/lib/src/validation.js +9 -3
- package/package.json +14 -13
- package/src/config.ts +5 -4
- package/src/memory.ts +2 -1
- package/src/mlflow.ts +169 -0
- package/src/plugin.ts +34 -1
- package/src/remote-skills.ts +1 -1
- package/src/validation.ts +8 -2
package/src/mlflow.ts
CHANGED
|
@@ -21,9 +21,11 @@
|
|
|
21
21
|
* @module
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { ConfigurationError } from "@databricks/appkit";
|
|
24
25
|
import { appkit } from "@dbx-tools/appkit";
|
|
25
26
|
import { asyncUtils, errorUtils, log } from "@dbx-tools/shared-core";
|
|
26
27
|
import { feedback } from "@dbx-tools/shared-mastra";
|
|
28
|
+
import { z } from "zod";
|
|
27
29
|
import { databricksFetch, readResponseJson, readResponseText } from "./rest.ts";
|
|
28
30
|
|
|
29
31
|
const logger = log.logger("mastra/mlflow");
|
|
@@ -40,6 +42,78 @@ const NOT_FOUND_RETRIES = 3;
|
|
|
40
42
|
/** Base backoff between "trace not found" retries, in ms (grows linearly). */
|
|
41
43
|
const NOT_FOUND_BACKOFF_MS = 1200;
|
|
42
44
|
|
|
45
|
+
const MlflowExperimentResponseSchema = z
|
|
46
|
+
.object({
|
|
47
|
+
experiment: z
|
|
48
|
+
.object({
|
|
49
|
+
experiment_id: z.string().min(1).describe("Workspace MLflow experiment identifier."),
|
|
50
|
+
})
|
|
51
|
+
.passthrough()
|
|
52
|
+
.describe("Configured MLflow experiment."),
|
|
53
|
+
})
|
|
54
|
+
.passthrough()
|
|
55
|
+
.describe("MLflow get-experiment response.");
|
|
56
|
+
|
|
57
|
+
const CurrentUserResponseSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
userName: z.string().optional().describe("Current workspace user name."),
|
|
60
|
+
applicationId: z.string().optional().describe("Current service principal application id."),
|
|
61
|
+
displayName: z.string().optional().describe("Current principal display name."),
|
|
62
|
+
groups: z
|
|
63
|
+
.array(
|
|
64
|
+
z
|
|
65
|
+
.object({
|
|
66
|
+
value: z.string().optional().describe("Workspace group identifier."),
|
|
67
|
+
display: z.string().optional().describe("Workspace group display name."),
|
|
68
|
+
})
|
|
69
|
+
.passthrough(),
|
|
70
|
+
)
|
|
71
|
+
.optional()
|
|
72
|
+
.describe("Groups assigned to the current principal."),
|
|
73
|
+
})
|
|
74
|
+
.passthrough()
|
|
75
|
+
.describe("Databricks Current User API response.");
|
|
76
|
+
|
|
77
|
+
const ExperimentPermissionsResponseSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
access_control_list: z
|
|
80
|
+
.array(
|
|
81
|
+
z
|
|
82
|
+
.object({
|
|
83
|
+
user_name: z.string().optional().describe("User principal name."),
|
|
84
|
+
group_name: z.string().optional().describe("Group principal name."),
|
|
85
|
+
service_principal_name: z
|
|
86
|
+
.string()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Service principal application id."),
|
|
89
|
+
all_permissions: z
|
|
90
|
+
.array(
|
|
91
|
+
z
|
|
92
|
+
.object({
|
|
93
|
+
permission_level: z.string().describe("Effective experiment permission level."),
|
|
94
|
+
})
|
|
95
|
+
.passthrough(),
|
|
96
|
+
)
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("Direct and inherited permissions for this principal."),
|
|
99
|
+
})
|
|
100
|
+
.passthrough(),
|
|
101
|
+
)
|
|
102
|
+
.optional()
|
|
103
|
+
.describe("Experiment access control entries."),
|
|
104
|
+
})
|
|
105
|
+
.passthrough()
|
|
106
|
+
.describe("Databricks experiment permissions response.");
|
|
107
|
+
|
|
108
|
+
function configuredExperiment(): { id?: string; name?: string } {
|
|
109
|
+
const id = process.env.MLFLOW_EXPERIMENT_ID?.trim();
|
|
110
|
+
const name = process.env.MLFLOW_EXPERIMENT_NAME?.trim();
|
|
111
|
+
return {
|
|
112
|
+
...(id ? { id } : {}),
|
|
113
|
+
...(name ? { name } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
43
117
|
/**
|
|
44
118
|
* Whether MLflow feedback logging is available for this deployment.
|
|
45
119
|
*
|
|
@@ -71,6 +145,101 @@ export function resolveFeedbackEnabled(explicit: boolean | undefined): boolean {
|
|
|
71
145
|
return explicit ?? mlflowEnabled();
|
|
72
146
|
}
|
|
73
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Reject an explicitly enabled feedback surface when no MLflow experiment is
|
|
150
|
+
* configured. Auto mode remains non-fatal and simply disables feedback.
|
|
151
|
+
*/
|
|
152
|
+
export function validateFeedbackConfig(explicit: boolean | undefined): void {
|
|
153
|
+
if (explicit !== true) return;
|
|
154
|
+
const experiment = configuredExperiment();
|
|
155
|
+
if (experiment.id || experiment.name) return;
|
|
156
|
+
throw new ConfigurationError(
|
|
157
|
+
"mastra: feedback is enabled but no MLflow experiment is configured. " +
|
|
158
|
+
"Set MLFLOW_EXPERIMENT_ID or MLFLOW_EXPERIMENT_NAME, use feedback: false, " +
|
|
159
|
+
"or omit feedback to use automatic detection.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Return the configured experiment URL only when the active Databricks
|
|
165
|
+
* principal has effective `CAN_MANAGE` permission. Permission lookup is
|
|
166
|
+
* intentionally best-effort so Apps environments that do not expose the
|
|
167
|
+
* required APIs simply omit the debug affordance.
|
|
168
|
+
*/
|
|
169
|
+
export async function mlflowExperimentManagerUrl(
|
|
170
|
+
client: WorkspaceClient,
|
|
171
|
+
): Promise<string | undefined> {
|
|
172
|
+
try {
|
|
173
|
+
return await resolveMlflowExperimentManagerUrl(client);
|
|
174
|
+
} catch {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function resolveMlflowExperimentManagerUrl(
|
|
180
|
+
client: WorkspaceClient,
|
|
181
|
+
): Promise<string | undefined> {
|
|
182
|
+
const experimentId = await resolveExperimentId(client);
|
|
183
|
+
if (!experimentId) return undefined;
|
|
184
|
+
|
|
185
|
+
const [currentResponse, permissionsResponse] = await Promise.all([
|
|
186
|
+
databricksFetch(client, "/api/2.0/preview/scim/v2/Me", { method: "GET" }),
|
|
187
|
+
databricksFetch(
|
|
188
|
+
client,
|
|
189
|
+
`/api/2.0/permissions/experiments/${encodeURIComponent(experimentId)}`,
|
|
190
|
+
{ method: "GET" },
|
|
191
|
+
),
|
|
192
|
+
]);
|
|
193
|
+
if (!currentResponse.ok || !permissionsResponse.ok) return undefined;
|
|
194
|
+
|
|
195
|
+
const current = CurrentUserResponseSchema.safeParse(await readResponseJson(currentResponse));
|
|
196
|
+
const permissions = ExperimentPermissionsResponseSchema.safeParse(
|
|
197
|
+
await readResponseJson(permissionsResponse),
|
|
198
|
+
);
|
|
199
|
+
if (!current.success || !permissions.success) return undefined;
|
|
200
|
+
|
|
201
|
+
const userNames = new Set(
|
|
202
|
+
[current.data.userName, current.data.applicationId, current.data.displayName].filter(
|
|
203
|
+
(value): value is string => Boolean(value),
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
const groupNames = new Set(
|
|
207
|
+
(current.data.groups ?? []).flatMap(({ value, display }) =>
|
|
208
|
+
[value, display].filter((entry): entry is string => Boolean(entry)),
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
const canManage = (permissions.data.access_control_list ?? []).some((entry) => {
|
|
212
|
+
const matches =
|
|
213
|
+
(entry.user_name !== undefined && userNames.has(entry.user_name)) ||
|
|
214
|
+
(entry.service_principal_name !== undefined && userNames.has(entry.service_principal_name)) ||
|
|
215
|
+
(entry.group_name !== undefined && groupNames.has(entry.group_name));
|
|
216
|
+
return (
|
|
217
|
+
matches &&
|
|
218
|
+
(entry.all_permissions ?? []).some(
|
|
219
|
+
(permission) => permission.permission_level === "CAN_MANAGE",
|
|
220
|
+
)
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
if (!canManage) return undefined;
|
|
224
|
+
|
|
225
|
+
const host = (await client.config.getHost()).toString();
|
|
226
|
+
return new URL(`/ml/experiments/${encodeURIComponent(experimentId)}`, host).toString();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function resolveExperimentId(client: WorkspaceClient): Promise<string | undefined> {
|
|
230
|
+
const experiment = configuredExperiment();
|
|
231
|
+
if (experiment.id) return experiment.id;
|
|
232
|
+
if (!experiment.name) return undefined;
|
|
233
|
+
|
|
234
|
+
const path = `/api/2.0/mlflow/experiments/get-by-name?experiment_name=${encodeURIComponent(
|
|
235
|
+
experiment.name,
|
|
236
|
+
)}`;
|
|
237
|
+
const response = await databricksFetch(client, path, { method: "GET" });
|
|
238
|
+
if (!response.ok) return undefined;
|
|
239
|
+
const parsed = MlflowExperimentResponseSchema.safeParse(await readResponseJson(response));
|
|
240
|
+
return parsed.success ? parsed.data.experiment.experiment_id : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
74
243
|
/** Parameters for {@link logFeedback}. */
|
|
75
244
|
export interface LogFeedbackParams {
|
|
76
245
|
/** MLflow trace id the assessment attaches to (`tr-<hex>`). */
|
package/src/plugin.ts
CHANGED
|
@@ -99,7 +99,12 @@ import {
|
|
|
99
99
|
} from "./identity.ts";
|
|
100
100
|
import { buildMcpServer, type ResolvedMcp } from "./mcp.ts";
|
|
101
101
|
import { createMemoryBuilder, createServicePrincipalPool, needsLakebase } from "./memory.ts";
|
|
102
|
-
import {
|
|
102
|
+
import {
|
|
103
|
+
logFeedback,
|
|
104
|
+
mlflowExperimentManagerUrl,
|
|
105
|
+
resolveFeedbackEnabled,
|
|
106
|
+
validateFeedbackConfig,
|
|
107
|
+
} from "./mlflow.ts";
|
|
103
108
|
import { resolveDefaultModelId } from "./model.ts";
|
|
104
109
|
import { buildObservability, configureOtelPropagation } from "./observability.ts";
|
|
105
110
|
import { provisionRemoteSkills } from "./remote-skills.ts";
|
|
@@ -249,6 +254,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
249
254
|
// Resolve the identity mode up front so an invalid `genieIdentity` fails the
|
|
250
255
|
// app boot with a clear message rather than 500-ing the first chat request.
|
|
251
256
|
this.identityMode = resolveIdentityMode(this.config.genieIdentity);
|
|
257
|
+
validateFeedbackConfig(this.config.feedback);
|
|
252
258
|
// Wait until sibling plugins (e.g. `lakebase`) finish `setup()` so
|
|
253
259
|
// the lakebase pool is valid when storage/memory are enabled.
|
|
254
260
|
this.context?.onLifecycle("setup:complete", async () => {
|
|
@@ -466,6 +472,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
466
472
|
this.registerEmbedRoutes(router);
|
|
467
473
|
this.registerSuggestionRoutes(router);
|
|
468
474
|
this.registerFeedbackRoutes(router);
|
|
475
|
+
this.registerMlflowExperimentRoute(router);
|
|
469
476
|
this.registerAgentRoutes(router);
|
|
470
477
|
}
|
|
471
478
|
|
|
@@ -770,6 +777,23 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
770
777
|
});
|
|
771
778
|
}
|
|
772
779
|
|
|
780
|
+
/** Register the best-effort per-viewer MLflow experiment debug link. */
|
|
781
|
+
private registerMlflowExperimentRoute(router: IAppRouter): void {
|
|
782
|
+
this.route(router, {
|
|
783
|
+
name: "mlflowExperiment",
|
|
784
|
+
method: "get",
|
|
785
|
+
path: routes.MASTRA_ROUTES.mlflowExperiment,
|
|
786
|
+
handler: async (req, res) => {
|
|
787
|
+
if (!this.feedbackEnabled()) {
|
|
788
|
+
res.json({ url: null });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const result = await this.scopedSelf(req).fetchMlflowExperimentUrl();
|
|
792
|
+
res.json({ url: result.ok ? (result.data ?? null) : null });
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
773
797
|
/** Register the gated Mastra catch-all after every explicit route. */
|
|
774
798
|
private registerAgentRoutes(router: IAppRouter): void {
|
|
775
799
|
// Middleware rather than `this.route`: this is the catch-all that hands
|
|
@@ -917,6 +941,15 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
917
941
|
);
|
|
918
942
|
}
|
|
919
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Resolve an experiment link under the active request identity. The helper
|
|
946
|
+
* returns no URL unless the viewer has effective `CAN_MANAGE` permission.
|
|
947
|
+
*/
|
|
948
|
+
private async fetchMlflowExperimentUrl(): Promise<ExecutionResult<string | undefined>> {
|
|
949
|
+
const client = getExecutionContext().client;
|
|
950
|
+
return this.execute(() => mlflowExperimentManagerUrl(client), feedbackWriteDefaults);
|
|
951
|
+
}
|
|
952
|
+
|
|
920
953
|
/**
|
|
921
954
|
* Implementation backing the `data` embed resolver
|
|
922
955
|
* (`GET /embed/data/:id`). Runs inside the AppKit user-context proxy so
|
package/src/remote-skills.ts
CHANGED
|
@@ -567,7 +567,7 @@ function resolveDatabricksBasePath(
|
|
|
567
567
|
client: WorkspaceClientLike | undefined,
|
|
568
568
|
): string | undefined {
|
|
569
569
|
if (!client) return undefined;
|
|
570
|
-
if (options.databricksBasePath) return options.databricksBasePath
|
|
570
|
+
if (options.databricksBasePath) return stringUtils.trimToUndefined(options.databricksBasePath);
|
|
571
571
|
const email = stringUtils.trimToNull(options.userEmail);
|
|
572
572
|
// A named user targets their personal Assistant tree (the "save a skill"
|
|
573
573
|
// target); otherwise the shared workspace Assistant tree, which the built-in
|
package/src/validation.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* @module
|
|
11
11
|
*/
|
|
12
|
+
import { object } from "@dbx-tools/shared-core";
|
|
12
13
|
|
|
13
14
|
/** The subset of a failed parse result these helpers read. */
|
|
14
15
|
export interface SchemaIssues {
|
|
@@ -17,6 +18,11 @@ export interface SchemaIssues {
|
|
|
17
18
|
|
|
18
19
|
/** Distinct dot-joined field paths a failed parse flagged. */
|
|
19
20
|
export function invalidFields(error: SchemaIssues): string[] {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
return [
|
|
22
|
+
...object
|
|
23
|
+
.sequence(error.issues)
|
|
24
|
+
.map((issue) => issue.path.map(String).join("."))
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.distinct(),
|
|
27
|
+
];
|
|
22
28
|
}
|