@dbx-tools/appkit-mastra 0.9.70 → 0.9.72
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 +6 -1
- package/index.ts +4 -2
- package/lib/index.d.ts +4 -2
- package/lib/index.js +4 -3
- package/lib/src/agents.js +20 -1
- package/lib/src/model.js +3 -2
- package/lib/src/openapi-tool.d.ts +29 -0
- package/lib/src/openapi-tool.js +181 -0
- package/lib/src/server.js +3 -2
- package/lib/src/serving.d.ts +2 -0
- package/lib/src/serving.js +3 -1
- package/lib/src/workspaces.js +73 -4
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -14
- package/src/agents.ts +25 -1
- package/src/model.ts +6 -1
- package/src/openapi-tool.ts +252 -0
- package/src/server.ts +4 -5
- package/src/serving.ts +3 -0
- package/src/workspaces.ts +78 -3
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI operation loading and JSON Schema extraction.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { object } from "@dbx-tools/shared-core";
|
|
8
|
+
import { urlBuilder, type UrlBuilder } from "@dbx-tools/shared-core/net";
|
|
9
|
+
import type { JSONSchema7 } from "json-schema";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
const HTTP_METHODS = [
|
|
13
|
+
"GET",
|
|
14
|
+
"POST",
|
|
15
|
+
"PUT",
|
|
16
|
+
"PATCH",
|
|
17
|
+
"DELETE",
|
|
18
|
+
"OPTIONS",
|
|
19
|
+
"HEAD",
|
|
20
|
+
"TRACE",
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
const JsonObjectSchema = z
|
|
24
|
+
.record(z.string(), z.unknown())
|
|
25
|
+
.describe("A JSON object embedded in an OpenAPI document.");
|
|
26
|
+
|
|
27
|
+
const OpenApiMediaTypeSchema = z
|
|
28
|
+
.object({
|
|
29
|
+
schema: JsonObjectSchema.describe("The JSON Schema for this media type."),
|
|
30
|
+
})
|
|
31
|
+
.describe("One OpenAPI media type.");
|
|
32
|
+
|
|
33
|
+
const OpenApiRequestBodySchema = z
|
|
34
|
+
.object({
|
|
35
|
+
content: z
|
|
36
|
+
.record(z.string(), OpenApiMediaTypeSchema)
|
|
37
|
+
.describe("Request media types keyed by content type."),
|
|
38
|
+
})
|
|
39
|
+
.describe("An OpenAPI request body.");
|
|
40
|
+
|
|
41
|
+
const OpenApiResponseSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
content: z
|
|
44
|
+
.record(z.string(), OpenApiMediaTypeSchema)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe("Response media types keyed by content type."),
|
|
47
|
+
})
|
|
48
|
+
.describe("One OpenAPI response.");
|
|
49
|
+
|
|
50
|
+
const OpenApiOperationSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
operationId: z.string().describe("The stable operation identifier."),
|
|
53
|
+
summary: z.string().optional().describe("The short operation summary."),
|
|
54
|
+
description: z.string().optional().describe("The full operation description."),
|
|
55
|
+
requestBody: OpenApiRequestBodySchema.optional().describe(
|
|
56
|
+
"The optional operation request body.",
|
|
57
|
+
),
|
|
58
|
+
responses: z
|
|
59
|
+
.record(z.string(), OpenApiResponseSchema)
|
|
60
|
+
.describe("Operation responses keyed by HTTP status."),
|
|
61
|
+
})
|
|
62
|
+
.describe("The OpenAPI fields required to define a tool.");
|
|
63
|
+
|
|
64
|
+
const OpenApiPathSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
get: OpenApiOperationSchema.optional(),
|
|
67
|
+
post: OpenApiOperationSchema.optional(),
|
|
68
|
+
put: OpenApiOperationSchema.optional(),
|
|
69
|
+
patch: OpenApiOperationSchema.optional(),
|
|
70
|
+
delete: OpenApiOperationSchema.optional(),
|
|
71
|
+
options: OpenApiOperationSchema.optional(),
|
|
72
|
+
head: OpenApiOperationSchema.optional(),
|
|
73
|
+
trace: OpenApiOperationSchema.optional(),
|
|
74
|
+
})
|
|
75
|
+
.describe("Operations available at one OpenAPI path.");
|
|
76
|
+
|
|
77
|
+
const OpenApiDocumentSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
paths: z
|
|
80
|
+
.record(z.string(), OpenApiPathSchema)
|
|
81
|
+
.refine((paths) => Object.keys(paths).length > 0, "OpenAPI paths must not be empty")
|
|
82
|
+
.describe("OpenAPI path items keyed by URL path."),
|
|
83
|
+
components: JsonObjectSchema.optional().describe("Reusable OpenAPI components."),
|
|
84
|
+
servers: z
|
|
85
|
+
.array(
|
|
86
|
+
z.object({
|
|
87
|
+
url: z.string().describe("Server base URL."),
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe("OpenAPI server definitions."),
|
|
92
|
+
})
|
|
93
|
+
.describe("An OpenAPI document used to define HTTP tools.");
|
|
94
|
+
|
|
95
|
+
type OpenApiDocument = z.infer<typeof OpenApiDocumentSchema>;
|
|
96
|
+
type OpenApiOperation = z.infer<typeof OpenApiOperationSchema>;
|
|
97
|
+
|
|
98
|
+
/** HTTP method supported by an {@link OpenApiTool}. */
|
|
99
|
+
export type OpenApiHttpMethod = (typeof HTTP_METHODS)[number];
|
|
100
|
+
|
|
101
|
+
/** One executable HTTP operation derived from an OpenAPI document. */
|
|
102
|
+
export interface OpenApiTool {
|
|
103
|
+
/** Stable operation identifier. */
|
|
104
|
+
id: string;
|
|
105
|
+
/** Model-facing operation description. */
|
|
106
|
+
description: string;
|
|
107
|
+
/** Resolved operation URL, or the OpenAPI path for a local document without a server URL. */
|
|
108
|
+
url: string;
|
|
109
|
+
/** HTTP method used by the operation. */
|
|
110
|
+
method: OpenApiHttpMethod;
|
|
111
|
+
/** JSON Schema describing operation input. */
|
|
112
|
+
inputSchema: JSONSchema7;
|
|
113
|
+
/** JSON Schema describing a successful operation result. */
|
|
114
|
+
outputSchema: JSONSchema7;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Request options used while loading an OpenAPI document. */
|
|
118
|
+
export interface OpenApiToolsOptions {
|
|
119
|
+
/** Cancels an HTTP request or file read. */
|
|
120
|
+
signal?: AbortSignal;
|
|
121
|
+
/** Headers sent when `source` is an HTTP(S) URL. */
|
|
122
|
+
headers?: RequestInit["headers"];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Load an HTTP(S) URL or local file and return its OpenAPI operations as tool schemas. */
|
|
126
|
+
export async function openApiTools(
|
|
127
|
+
source: string,
|
|
128
|
+
options: OpenApiToolsOptions = {},
|
|
129
|
+
): Promise<OpenApiTool[]> {
|
|
130
|
+
const sourceUrl = httpUrl(source);
|
|
131
|
+
const document = OpenApiDocumentSchema.parse(
|
|
132
|
+
sourceUrl
|
|
133
|
+
? await fetchDocument(sourceUrl, options)
|
|
134
|
+
: await readDocument(source, options.signal),
|
|
135
|
+
);
|
|
136
|
+
return Object.entries(document.paths).flatMap(([path, item]) =>
|
|
137
|
+
HTTP_METHODS.flatMap((method) => {
|
|
138
|
+
const operation = item[method.toLowerCase() as Lowercase<OpenApiHttpMethod>];
|
|
139
|
+
return operation ? [operationTool(document, sourceUrl, path, method, operation)] : [];
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function httpUrl(source: string): UrlBuilder | undefined {
|
|
145
|
+
const url = urlBuilder(source);
|
|
146
|
+
return url?.scheme === "http" || url?.scheme === "https" ? url : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function fetchDocument(url: URL, options: OpenApiToolsOptions): Promise<unknown> {
|
|
150
|
+
const response = await fetch(url, {
|
|
151
|
+
signal: options.signal,
|
|
152
|
+
headers: options.headers,
|
|
153
|
+
});
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
throw new Error(`OpenAPI request failed with HTTP ${response.status}: ${url}`);
|
|
156
|
+
}
|
|
157
|
+
return response.json();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readDocument(path: string, signal?: AbortSignal): Promise<unknown> {
|
|
161
|
+
return JSON.parse(await readFile(path, { encoding: "utf8", signal }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function operationTool(
|
|
165
|
+
document: OpenApiDocument,
|
|
166
|
+
sourceUrl: URL | undefined,
|
|
167
|
+
path: string,
|
|
168
|
+
method: OpenApiHttpMethod,
|
|
169
|
+
operation: OpenApiOperation,
|
|
170
|
+
): OpenApiTool {
|
|
171
|
+
const requestSchema = jsonSchema(operation.requestBody?.content) ?? { type: "object" };
|
|
172
|
+
const responseSchema = successfulResponseSchema(operation);
|
|
173
|
+
return {
|
|
174
|
+
id: operation.operationId,
|
|
175
|
+
description:
|
|
176
|
+
operation.description?.trim() || operation.summary?.trim() || operation.operationId,
|
|
177
|
+
url: operationUrl(document, sourceUrl, path),
|
|
178
|
+
method,
|
|
179
|
+
inputSchema: resolveReferences(document, requestSchema) as JSONSchema7,
|
|
180
|
+
outputSchema: resolveReferences(document, responseSchema) as JSONSchema7,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function successfulResponseSchema(operation: OpenApiOperation): Record<string, unknown> {
|
|
185
|
+
for (const [status, response] of Object.entries(operation.responses)) {
|
|
186
|
+
const schema = jsonSchema(response.content);
|
|
187
|
+
if (/^2(?:\d{2}|XX)$/i.test(status) && schema) return schema;
|
|
188
|
+
}
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function jsonSchema(
|
|
193
|
+
content: Record<string, z.infer<typeof OpenApiMediaTypeSchema>> | undefined,
|
|
194
|
+
): Record<string, unknown> | undefined {
|
|
195
|
+
const mediaType = Object.entries(content ?? {}).find(([type]) =>
|
|
196
|
+
/^application\/(?:[^+;]+\+)?json(?:;|$)/i.test(type),
|
|
197
|
+
);
|
|
198
|
+
return mediaType?.[1].schema;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function operationUrl(document: OpenApiDocument, sourceUrl: URL | undefined, path: string): string {
|
|
202
|
+
const server = document.servers?.[0]?.url;
|
|
203
|
+
if (server) {
|
|
204
|
+
const base = sourceUrl ? urlBuilder(new URL(server, sourceUrl)) : httpUrl(server);
|
|
205
|
+
return base
|
|
206
|
+
? base.withPathAppend(path).toString()
|
|
207
|
+
: `${server.replace(/\/$/, "")}/${path.replace(/^\//, "")}`;
|
|
208
|
+
}
|
|
209
|
+
return sourceUrl ? urlBuilder(sourceUrl)?.withPathReplace(path).toString() ?? path : path;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function resolveReferences(
|
|
213
|
+
document: OpenApiDocument,
|
|
214
|
+
value: unknown,
|
|
215
|
+
active: ReadonlySet<string> = new Set(),
|
|
216
|
+
): unknown {
|
|
217
|
+
if (Array.isArray(value)) return value.map((item) => resolveReferences(document, item, active));
|
|
218
|
+
if (!object.isRecord(value)) return value;
|
|
219
|
+
const reference = typeof value.$ref === "string" ? value.$ref : undefined;
|
|
220
|
+
if (reference) {
|
|
221
|
+
if (active.has(reference)) throw new Error(`Cyclic OpenAPI reference: ${reference}`);
|
|
222
|
+
const target = referenceTarget(document, reference);
|
|
223
|
+
const next = new Set(active).add(reference);
|
|
224
|
+
const { $ref: _ignored, ...siblings } = value;
|
|
225
|
+
return {
|
|
226
|
+
...(resolveReferences(document, target, next) as Record<string, unknown>),
|
|
227
|
+
...(resolveReferences(document, siblings, next) as Record<string, unknown>),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return Object.fromEntries(
|
|
231
|
+
Object.entries(value).map(([key, child]) => [key, resolveReferences(document, child, active)]),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function referenceTarget(document: OpenApiDocument, reference: string): unknown {
|
|
236
|
+
if (!reference.startsWith("#/")) throw new Error(`Unsupported OpenAPI reference: ${reference}`);
|
|
237
|
+
let current: unknown = document;
|
|
238
|
+
for (const rawSegment of reference.slice(2).split("/")) {
|
|
239
|
+
const segment = rawSegment.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
240
|
+
if (!object.isRecord(current) || !(segment in current)) {
|
|
241
|
+
throw new Error(`Unresolved OpenAPI reference: ${reference}`);
|
|
242
|
+
}
|
|
243
|
+
current = current[segment];
|
|
244
|
+
}
|
|
245
|
+
return current;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (import.meta.main) {
|
|
249
|
+
const source = process.argv[2];
|
|
250
|
+
if (!source) throw new Error("Usage: bun openapi-tool.ts <openapi-url-or-file>");
|
|
251
|
+
console.log(JSON.stringify(await openApiTools(source), null, 2));
|
|
252
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -29,13 +29,13 @@ import {
|
|
|
29
29
|
} from "./config.ts";
|
|
30
30
|
import { requestUserEmail, requestUserId } from "./identity.ts";
|
|
31
31
|
import { resolveFeedbackEnabled } from "./mlflow.ts";
|
|
32
|
-
import { recordActiveTraceAuth, recordActiveTraceUser } from "./telemetry.ts";
|
|
33
|
-
|
|
34
32
|
import {
|
|
35
33
|
extractModelOverride,
|
|
36
34
|
MASTRA_MODEL_OVERRIDE_KEY,
|
|
35
|
+
MASTRA_RESOLVED_MODEL_KEY,
|
|
37
36
|
resolveServingConfig,
|
|
38
37
|
} from "./serving.ts";
|
|
38
|
+
import { recordActiveTraceAuth, recordActiveTraceUser } from "./telemetry.ts";
|
|
39
39
|
/**
|
|
40
40
|
* OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars.
|
|
41
41
|
* `trace.getActiveSpan()` returns a non-recording span with this id
|
|
@@ -52,6 +52,7 @@ const TRUSTED_REQUEST_CONTEXT_KEYS = [
|
|
|
52
52
|
MASTRA_REQUEST_ID_KEY,
|
|
53
53
|
MASTRA_SCOPES_KEY,
|
|
54
54
|
MASTRA_MODEL_OVERRIDE_KEY,
|
|
55
|
+
MASTRA_RESOLVED_MODEL_KEY,
|
|
55
56
|
] as const;
|
|
56
57
|
|
|
57
58
|
/** Remove browser-supplied values for fields owned by trusted server middleware. */
|
|
@@ -225,9 +226,7 @@ export class MastraServer extends ExpressMastraServer {
|
|
|
225
226
|
});
|
|
226
227
|
const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
|
|
227
228
|
if (user) {
|
|
228
|
-
recordActiveTraceAuth(
|
|
229
|
-
"isUserContext" in user.executionContext ? "obo" : "service-principal",
|
|
230
|
-
);
|
|
229
|
+
recordActiveTraceAuth("isUserContext" in user.executionContext ? "obo" : "service-principal");
|
|
231
230
|
}
|
|
232
231
|
const traceUser = [
|
|
233
232
|
requestContext.get(MASTRA_USER_EMAIL_KEY),
|
package/src/serving.ts
CHANGED
|
@@ -24,6 +24,9 @@ import type { MastraPluginConfig } from "./config.ts";
|
|
|
24
24
|
*/
|
|
25
25
|
export const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
|
|
26
26
|
|
|
27
|
+
/** RequestContext key carrying the endpoint selected for the active agent step. */
|
|
28
|
+
export const MASTRA_RESOLVED_MODEL_KEY = "mastra__resolved_model";
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Minimal Express-ish request shape used by {@link extractModelOverride}.
|
|
29
32
|
* Keeps this module independent of `express` so the helper can be
|
package/src/workspaces.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { ConfigurationError, createWorkspaceClient } from "@databricks/appkit";
|
|
|
23
23
|
import type { WorkspaceClient } from "@databricks/appkit";
|
|
24
24
|
import { DatabricksFileSystem, workspaceClient } from "@dbx-tools/databricks";
|
|
25
25
|
import { errorUtils, log, object, stringUtils, token } from "@dbx-tools/shared-core";
|
|
26
|
+
import { posixPath } from "@dbx-tools/shared-fs";
|
|
26
27
|
import type { RequestContext } from "@mastra/core/request-context";
|
|
27
28
|
import {
|
|
28
29
|
CompositeFilesystem,
|
|
@@ -676,9 +677,83 @@ function buildWorkspaceSkillsResolver(
|
|
|
676
677
|
extraSkillPaths: string[] = [],
|
|
677
678
|
): SkillsResolver {
|
|
678
679
|
return async (context: SkillsContext) => {
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
680
|
+
const contribution = await resolveWorkspaceContribution(resolvers, context);
|
|
681
|
+
const skillPaths = await uniqueSkillPaths(contribution);
|
|
682
|
+
const merged = [...skillPaths, ...extraSkillPaths];
|
|
683
|
+
logger.debug("skills:resolved", {
|
|
684
|
+
configuredSkillPaths: contribution.skillPaths,
|
|
685
|
+
skillPaths,
|
|
686
|
+
extraSkillPaths,
|
|
687
|
+
});
|
|
682
688
|
return merged;
|
|
683
689
|
};
|
|
684
690
|
}
|
|
691
|
+
|
|
692
|
+
/** Expand mounted roots and keep the first concrete skill for each directory name. */
|
|
693
|
+
async function uniqueSkillPaths(contribution: WorkspaceMountContribution): Promise<string[]> {
|
|
694
|
+
const selected = new Map<string, string>();
|
|
695
|
+
const unresolved: string[] = [];
|
|
696
|
+
for (const root of contribution.skillPaths ?? []) {
|
|
697
|
+
const mount = mountedFilesystem(root, contribution.mounts);
|
|
698
|
+
if (!mount) {
|
|
699
|
+
unresolved.push(root);
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
let candidates: string[];
|
|
703
|
+
try {
|
|
704
|
+
candidates = await concreteSkillPaths(root, mount.path, mount.filesystem);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
logger.debug("skills:root-expansion-failed", {
|
|
707
|
+
root,
|
|
708
|
+
error: errorUtils.errorMessage(error),
|
|
709
|
+
});
|
|
710
|
+
unresolved.push(root);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (candidates.length === 0) {
|
|
714
|
+
unresolved.push(root);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
for (const candidate of candidates) {
|
|
718
|
+
const name = posixPath.basename(candidate);
|
|
719
|
+
const existing = selected.get(name);
|
|
720
|
+
if (existing) {
|
|
721
|
+
logger.debug("skills:duplicate-skipped", { name, path: candidate, selected: existing });
|
|
722
|
+
} else {
|
|
723
|
+
selected.set(name, candidate);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return [...selected.values(), ...unresolved];
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Find the most-specific mounted filesystem containing one skill path. */
|
|
731
|
+
function mountedFilesystem(
|
|
732
|
+
skillPath: string,
|
|
733
|
+
mounts: Readonly<Record<string, WorkspaceFilesystem>>,
|
|
734
|
+
): { filesystem: WorkspaceFilesystem; path: string } | undefined {
|
|
735
|
+
return Object.entries(mounts)
|
|
736
|
+
.filter(([mount]) => skillPath === mount || skillPath.startsWith(`${mount}/`))
|
|
737
|
+
.sort(([left], [right]) => right.length - left.length)
|
|
738
|
+
.map(([path, filesystem]) => ({ filesystem, path }))[0];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Return concrete child skill directories from one mounted skill root. */
|
|
742
|
+
async function concreteSkillPaths(
|
|
743
|
+
root: string,
|
|
744
|
+
mount: string,
|
|
745
|
+
filesystem: WorkspaceFilesystem,
|
|
746
|
+
): Promise<string[]> {
|
|
747
|
+
const relativeRoot = root === mount ? "." : root.slice(mount.length + 1);
|
|
748
|
+
if (await filesystem.exists(posixPath.join(relativeRoot, "SKILL.md"))) return [root];
|
|
749
|
+
const entries = await filesystem.readdir(relativeRoot);
|
|
750
|
+
const candidates: string[] = [];
|
|
751
|
+
for (const entry of entries) {
|
|
752
|
+
if (entry.type !== "directory") continue;
|
|
753
|
+
const relative = posixPath.join(relativeRoot, entry.name);
|
|
754
|
+
if (await filesystem.exists(posixPath.join(relative, "SKILL.md"))) {
|
|
755
|
+
candidates.push(posixPath.join(root, entry.name));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return candidates;
|
|
759
|
+
}
|