@dbx-tools/appkit-mastra 0.6.216 → 0.6.217

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/src/agents.ts CHANGED
@@ -151,8 +151,9 @@ function deriveToolId(description: string): string {
151
151
  * });
152
152
  * ```
153
153
  *
154
- * Returns the definition unchanged - the wrapper exists only to anchor
155
- * type inference and to match the AppKit API surface.
154
+ * Adds the package's default workspace when the definition omits one. That
155
+ * workspace carries Databricks skill mounts and Databricks Sandbox command
156
+ * execution. An explicit workspace remains the caller's complete override.
156
157
  */
157
158
  export function createAgent<T extends MastraAgentDefinition>(def: T): T {
158
159
  if (def.workspace) return { ...def };
@@ -250,7 +251,7 @@ export type MastraPlugins = Record<string, MastraPluginToolkitProvider>;
250
251
  /** Function form of {@link MastraAgentDefinition.tools}. */
251
252
  export type MastraToolsFn = (plugins: MastraPlugins) => MastraTools | Promise<MastraTools>;
252
253
 
253
- /** Function form of {@link MastraAgentDefinition.workspace}. */
254
+ /** Function form of {@link MastraAgentDefinition.workspace}; `undefined` disables it for this agent. */
254
255
  export type MastraAgentWorkspaceResolver = () => Workspace | undefined;
255
256
 
256
257
  /**
@@ -512,8 +513,15 @@ export async function buildAgents(opts: {
512
513
  for (const [id, def] of Object.entries(definitions)) {
513
514
  const tools = await resolveTools(def.tools, plugins, ambientTools);
514
515
  let workspace = resolveAgentWorkspace(def.workspace);
515
- if (extraSkillPaths?.length && isDefaultWorkspace(workspace)) {
516
- workspace = createWorkspace({ extraSkillPaths });
516
+ if (
517
+ (def.workspace === undefined && !workspace) ||
518
+ ((extraSkillPaths?.length || config.sandbox !== undefined) && isDefaultWorkspace(workspace))
519
+ ) {
520
+ workspace = createWorkspace({
521
+ extraSkillPaths,
522
+ sandbox:
523
+ config.sandbox === undefined || config.sandbox === true ? "databricks" : config.sandbox,
524
+ });
517
525
  markDefaultWorkspace(workspace);
518
526
  }
519
527
  const gated = approvalGatedToolIds(tools);
package/src/config.ts CHANGED
@@ -26,6 +26,7 @@ import type { MastraAgentDefinition, MastraTools } from "./agents.ts";
26
26
  import type { GenieSpacesConfig } from "./genie.ts";
27
27
  import { IDENTITY_MODES, type MastraIdentityMode } from "./identity.ts";
28
28
  import type { RemoteSkillsOption } from "./remote-skills.ts";
29
+ import type { DatabricksWorkspaceSandboxOptions } from "./sandbox.ts";
29
30
 
30
31
  /**
31
32
  * `RequestContext` key under which {@link MastraServer} stores the
@@ -198,6 +199,13 @@ export interface MastraPluginConfig extends BasePluginConfig {
198
199
  * `lakebase` plugin's pool; an object opens a dedicated store.
199
200
  */
200
201
  memory?: boolean | MastraMemoryConfig;
202
+ /**
203
+ * Sandbox for auto-created agent workspaces. Defaults to Databricks Sandbox.
204
+ * `true` selects Databricks with defaults, an object configures its lifecycle,
205
+ * and `false` disables workspace command execution. An agent with an explicit
206
+ * custom `workspace` keeps that workspace and its sandbox.
207
+ */
208
+ sandbox?: boolean | "databricks" | "monty" | DatabricksWorkspaceSandboxOptions;
201
209
  /**
202
210
  * Code-defined agents. Accepts three shapes for convenience:
203
211
  *
@@ -630,6 +638,11 @@ export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
630
638
  description:
631
639
  "PgVector store for Mastra semantic recall. `true` reuses the `lakebase` plugin's pool, an object opens a dedicated store. Auto-enabled when the `lakebase` plugin is registered.",
632
640
  },
641
+ sandbox: {
642
+ type: ["boolean", "string", "object"],
643
+ description:
644
+ 'Command sandbox for auto-created agent workspaces. Defaults to Databricks Sandbox with Monty fallback; false disables command execution, "monty" selects Monty directly, and an object configures Databricks lifecycle/fallback settings.',
645
+ },
633
646
  defaultAgent: {
634
647
  type: "string",
635
648
  description:
package/src/model.ts CHANGED
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { getExecutionContext } from "@databricks/appkit";
27
- import { classes, resolve } from "@dbx-tools/model";
27
+ import { classes, invoke, resolve } from "@dbx-tools/model";
28
28
  import { functionModule, json, log, net } from "@dbx-tools/shared-core";
29
29
  import { model } from "@dbx-tools/shared-model";
30
30
  import type { MastraModelConfig } from "@mastra/core/llm";
@@ -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.ts";
34
34
  import {
35
- rewriteServingBody,
35
+ rewriteServingRequest,
36
36
  rewriteServingResponseBody,
37
37
  rewriteServingResponseStream,
38
38
  } from "./serving-sanitize.ts";
@@ -131,16 +131,16 @@ export async function buildModel(
131
131
  };
132
132
  }
133
133
 
134
- /** Path prefix that identifies a Databricks Model Serving REST call. */
135
- const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
134
+ /** Chat Completions route whose provider-specific wire shapes are normalized here. */
135
+ const CHAT_COMPLETIONS_PATH = `/${invoke.CHAT_COMPLETIONS_PATH}`;
136
136
 
137
137
  /**
138
- * Install a single shared `globalThis.fetch` wrapper for every POST to
139
- * `/serving-endpoints/...`. The wrapper does two things:
138
+ * Install a single shared `globalThis.fetch` wrapper for Chat Completions
139
+ * requests. The wrapper does three things:
140
140
  *
141
- * 1. Rewrites the outgoing `messages` array to repair Mastra/AI SDK
142
- * stream-replay quirks that Databricks-hosted Claude rejects (see
143
- * {@link rewriteServingBody} in `./serving-sanitize.js`).
141
+ * 1. Rewrites outgoing JSON from either `init.body` or a `Request` to repair
142
+ * provider-specific request constraints (see {@link rewriteServingRequest}
143
+ * in `./serving-sanitize.js`).
144
144
  * 2. At `LOG_LEVEL=debug`, dumps the (post-sanitize) JSON body so
145
145
  * 4xx debugging doesn't have to fight AI SDK's `[Array]`
146
146
  * formatter.
@@ -154,32 +154,30 @@ const SERVING_ENDPOINTS_PATH_PREFIX = "/serving-endpoints/";
154
154
  * step.
155
155
  */
156
156
  const setupFetchInterceptor = functionModule.memoize((): void => {
157
+ globalThis.fetch = createServingFetchInterceptor(globalThis.fetch.bind(globalThis));
158
+ });
159
+
160
+ /** Build the serving fetch wrapper; exported for transport-level regression tests. */
161
+ export function createServingFetchInterceptor(original: typeof fetch): typeof fetch {
157
162
  const logger = log.logger("mastra/llm");
158
- const original = globalThis.fetch.bind(globalThis);
159
- globalThis.fetch = (async (input, init) => {
163
+ return (async (input, init) => {
160
164
  const url = net.urlBuilder(input);
161
- if (
162
- !url ||
163
- !url.pathname.startsWith(SERVING_ENDPOINTS_PATH_PREFIX) ||
164
- typeof init?.body !== "string"
165
- ) {
165
+ const method = init?.method ?? (input instanceof Request ? input.method : "GET");
166
+ if (!url || url.pathname !== CHAT_COMPLETIONS_PATH || method.toUpperCase() !== "POST") {
166
167
  return original(input, init);
167
168
  }
168
- const rewritten = rewriteServingBody(init.body);
169
- if (rewritten !== init.body) {
170
- init = { ...init, body: rewritten };
171
- }
172
- const parsed = json.parse<unknown>(rewritten);
169
+ const rewritten = await rewriteServingRequest(input, init);
170
+ const parsed = json.parse<unknown>(rewritten.body);
173
171
  logger.debug(
174
172
  "POST",
175
173
  parsed === undefined
176
174
  ? { url: url.toString(), bodyType: "non-JSON" }
177
175
  : { url: url.toString(), body: parsed },
178
176
  );
179
- const response = await original(input, init);
177
+ const response = await original(rewritten.input, rewritten.init);
180
178
  return repairServingResponse(response);
181
179
  }) as typeof globalThis.fetch;
182
- });
180
+ }
183
181
 
184
182
  /**
185
183
  * Rewrite a serving response whose body needs repair, leaving unsupported
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Pydantic Monty fallback for Mastra command execution.
3
+ *
4
+ * Uses the Node subprocess-worker build of Monty. It executes Python source
5
+ * with no host filesystem, network, environment, shell, or third-party package
6
+ * access unless a future caller explicitly adds those capabilities.
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { error, functionModule } from "@dbx-tools/shared-core";
12
+ import type {
13
+ CommandResult,
14
+ ExecuteCommandOptions,
15
+ SandboxInfo,
16
+ WorkspaceSandbox,
17
+ } from "@mastra/core/workspace";
18
+ import type { CheckoutOptions, Monty as MontyPool, MontySession } from "@pydantic/monty/node";
19
+
20
+ const DEFAULT_COMMAND_TIMEOUT_MS = 30_000;
21
+ const DEFAULT_MAX_MEMORY_BYTES = 10_000_000;
22
+
23
+ const montyModule = functionModule.memoize(() => import("@pydantic/monty/node"));
24
+ const montyPool = functionModule.memoize(async (): Promise<MontyPool> => {
25
+ const { Monty } = await montyModule();
26
+ return Monty.create({
27
+ minProcesses: 1,
28
+ maxProcesses: 4,
29
+ requestTimeout: 35,
30
+ });
31
+ });
32
+
33
+ /** Resource controls for the Monty Python fallback. */
34
+ export interface MontySandboxOptions {
35
+ /** Mastra provider id. */
36
+ id?: string;
37
+ /** Display name. */
38
+ name?: string;
39
+ /** Default execution timeout. Per-call `options.timeout` wins. */
40
+ commandTimeoutMs?: number;
41
+ /** Maximum Monty heap bytes for one checkout. */
42
+ maxMemoryBytes?: number;
43
+ /** Type-check each Python snippet before running it. Defaults to false. */
44
+ typeCheck?: boolean;
45
+ }
46
+
47
+ /** Python-only, deny-by-default sandbox backed by Monty's Node worker pool. */
48
+ export class MontySandbox implements WorkspaceSandbox {
49
+ readonly id: string;
50
+ readonly name: string;
51
+ readonly provider = "monty";
52
+ status: WorkspaceSandbox["status"] = "pending";
53
+ error?: string;
54
+
55
+ private readonly commandTimeoutMs: number;
56
+ private readonly maxMemoryBytes: number;
57
+ private readonly typeCheck: boolean;
58
+ private readonly createdAt = new Date();
59
+ private lastUsedAt?: Date;
60
+
61
+ constructor(options: MontySandboxOptions = {}) {
62
+ this.id = options.id ?? "monty";
63
+ this.name = options.name ?? "Pydantic Monty";
64
+ this.commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
65
+ this.maxMemoryBytes = options.maxMemoryBytes ?? DEFAULT_MAX_MEMORY_BYTES;
66
+ this.typeCheck = options.typeCheck ?? false;
67
+ }
68
+
69
+ /** Warm the shared crash-isolated worker pool. */
70
+ async start(): Promise<void> {
71
+ if (this.status === "running") return;
72
+ this.status = "starting";
73
+ this.error = undefined;
74
+ try {
75
+ await montyPool();
76
+ this.status = "running";
77
+ } catch (caught) {
78
+ this.status = "error";
79
+ this.error = error.errorMessage(caught);
80
+ throw caught;
81
+ }
82
+ }
83
+
84
+ /** The process-wide pool stays warm; this instance carries no open session. */
85
+ async stop(): Promise<void> {
86
+ this.status = "stopped";
87
+ }
88
+
89
+ /** The process-wide pool stays warm; each command closes its own checkout. */
90
+ async destroy(): Promise<void> {
91
+ this.status = "destroyed";
92
+ }
93
+
94
+ /** Monty sessions are ephemeral and do not provide persistent checkpoints. */
95
+ async snapshot(): Promise<void> {}
96
+
97
+ readonly supportsCheckpoints = false;
98
+
99
+ /** Execute Python source and map Monty output to Mastra's command result. */
100
+ async executeCommand(
101
+ command: string,
102
+ args: string[] = [],
103
+ options: ExecuteCommandOptions = {},
104
+ ): Promise<CommandResult> {
105
+ const started = performance.now();
106
+ const code = montyCode(command, args);
107
+ if (code === undefined) {
108
+ return failedResult(
109
+ command,
110
+ args,
111
+ started,
112
+ "Monty fallback accepts Python source directly, or python/python3 with a single -c script.",
113
+ );
114
+ }
115
+ const environment = Object.entries(options.env ?? {}).filter(
116
+ ([, value]) => value !== undefined,
117
+ );
118
+ if (environment.length > 0) {
119
+ return failedResult(
120
+ command,
121
+ args,
122
+ started,
123
+ "Monty fallback does not expose host environment variables.",
124
+ );
125
+ }
126
+ if (options.cwd) {
127
+ return failedResult(
128
+ command,
129
+ args,
130
+ started,
131
+ "Monty fallback does not expose a host working directory.",
132
+ );
133
+ }
134
+ if (options.abortSignal?.aborted) throw options.abortSignal.reason;
135
+
136
+ await this.start();
137
+ const timeout = options.timeout ?? this.commandTimeoutMs;
138
+ const pool = await montyPool();
139
+ const session = await checkoutSession(
140
+ pool,
141
+ {
142
+ limits: {
143
+ maxDurationSecs: Math.max(0.001, timeout / 1_000),
144
+ maxMemory: this.maxMemoryBytes,
145
+ },
146
+ typeCheck: this.typeCheck,
147
+ },
148
+ options.abortSignal,
149
+ );
150
+ const output = new RetainedOutput(options);
151
+ const workerPid = session.workerPid;
152
+ let aborted = false;
153
+ const onAbort = (): void => {
154
+ aborted = true;
155
+ terminateWorker(workerPid);
156
+ };
157
+ options.abortSignal?.addEventListener("abort", onAbort, { once: true });
158
+ if (options.abortSignal?.aborted) onAbort();
159
+ try {
160
+ if (aborted) throw abortReason(options.abortSignal);
161
+ const value = await session.feedRun(stripPythonFence(code), {
162
+ printCallback: (stream, text) => output.emit(stream, text),
163
+ });
164
+ const returned = formatResult(value);
165
+ if (returned) {
166
+ const suffix = returned.endsWith("\n") ? returned : `${returned}\n`;
167
+ output.emit("stdout", suffix);
168
+ }
169
+ this.lastUsedAt = new Date();
170
+ return {
171
+ command,
172
+ args,
173
+ success: true,
174
+ exitCode: 0,
175
+ stdout: output.stdout.value,
176
+ stderr: output.stderr.value,
177
+ executionTimeMs: performance.now() - started,
178
+ ...output.metadata(),
179
+ };
180
+ } catch (caught) {
181
+ if (aborted || options.abortSignal?.aborted) {
182
+ throw abortReason(options.abortSignal);
183
+ }
184
+ const message = error.errorMessage(caught);
185
+ output.emit("stderr", output.stderr.value ? `\n${message}` : message);
186
+ const { MontyCrashedError } = await montyModule();
187
+ const timedOut = caught instanceof MontyCrashedError && caught.timedOut;
188
+ return {
189
+ command,
190
+ args,
191
+ success: false,
192
+ exitCode: timedOut ? -1 : 1,
193
+ stdout: output.stdout.value,
194
+ stderr: output.stderr.value,
195
+ executionTimeMs: performance.now() - started,
196
+ ...(timedOut ? { timedOut: true, killed: true } : {}),
197
+ ...output.metadata(),
198
+ };
199
+ } finally {
200
+ options.abortSignal?.removeEventListener("abort", onAbort);
201
+ await session.close();
202
+ }
203
+ }
204
+
205
+ async isReady(): Promise<boolean> {
206
+ return this.status === "running";
207
+ }
208
+
209
+ getInfo(): SandboxInfo {
210
+ return {
211
+ id: this.id,
212
+ name: this.name,
213
+ provider: this.provider,
214
+ status: this.status,
215
+ createdAt: this.createdAt,
216
+ ...(this.lastUsedAt ? { lastUsedAt: this.lastUsedAt } : {}),
217
+ metadata: {
218
+ language: "python",
219
+ hostAccess: false,
220
+ },
221
+ };
222
+ }
223
+
224
+ getInstructions(): string {
225
+ return [
226
+ "Commands run as Python source in the Pydantic Monty fallback.",
227
+ "Pass Python code directly rather than shell syntax.",
228
+ "There is no host filesystem, network, environment, shell, or third-party package access.",
229
+ ].join(" ");
230
+ }
231
+ }
232
+
233
+ function montyCode(command: string, args: readonly string[]): string | undefined {
234
+ if (args.length === 0) return command;
235
+ if ((command === "python" || command === "python3") && args.length === 2 && args[0] === "-c") {
236
+ return args[1];
237
+ }
238
+ return undefined;
239
+ }
240
+
241
+ function stripPythonFence(code: string): string {
242
+ const trimmed = code.trim();
243
+ const match = /^```(?:python|py)?\s*\n([\s\S]*?)\n```$/i.exec(trimmed);
244
+ return match?.[1] ?? code;
245
+ }
246
+
247
+ function formatResult(value: unknown): string {
248
+ if (value === undefined || value === null) return "";
249
+ if (typeof value === "string") return value;
250
+ try {
251
+ return JSON.stringify(value, null, 2) ?? String(value);
252
+ } catch {
253
+ return String(value);
254
+ }
255
+ }
256
+
257
+ class RetainedOutput {
258
+ readonly stdout: RetainedText;
259
+ readonly stderr: RetainedText;
260
+
261
+ private readonly onStdout: ExecuteCommandOptions["onStdout"];
262
+ private readonly onStderr: ExecuteCommandOptions["onStderr"];
263
+
264
+ constructor(options: ExecuteCommandOptions) {
265
+ this.stdout = new RetainedText(options.maxRetainedBytes);
266
+ this.stderr = new RetainedText(options.maxRetainedBytes);
267
+ this.onStdout = options.onStdout;
268
+ this.onStderr = options.onStderr;
269
+ }
270
+
271
+ emit(stream: "stdout" | "stderr", text: string): void {
272
+ if (stream === "stdout") {
273
+ this.onStdout?.(text);
274
+ this.stdout.append(text);
275
+ } else {
276
+ this.onStderr?.(text);
277
+ this.stderr.append(text);
278
+ }
279
+ }
280
+
281
+ metadata(): Pick<
282
+ CommandResult,
283
+ "stdoutTruncated" | "stderrTruncated" | "stdoutDroppedBytes" | "stderrDroppedBytes"
284
+ > {
285
+ return {
286
+ ...(this.stdout.droppedBytes > 0
287
+ ? {
288
+ stdoutTruncated: true,
289
+ stdoutDroppedBytes: this.stdout.droppedBytes,
290
+ }
291
+ : {}),
292
+ ...(this.stderr.droppedBytes > 0
293
+ ? {
294
+ stderrTruncated: true,
295
+ stderrDroppedBytes: this.stderr.droppedBytes,
296
+ }
297
+ : {}),
298
+ };
299
+ }
300
+ }
301
+
302
+ class RetainedText {
303
+ value = "";
304
+ droppedBytes = 0;
305
+
306
+ private readonly maxBytes: number;
307
+ private readonly encoder = new TextEncoder();
308
+ private readonly decoder = new TextDecoder();
309
+
310
+ constructor(maxBytes: number | undefined) {
311
+ const resolved = maxBytes ?? Number.POSITIVE_INFINITY;
312
+ if (
313
+ resolved !== Number.POSITIVE_INFINITY &&
314
+ (!Number.isSafeInteger(resolved) || resolved < 0)
315
+ ) {
316
+ throw new TypeError("maxRetainedBytes must be a non-negative safe integer or Infinity");
317
+ }
318
+ this.maxBytes = resolved;
319
+ }
320
+
321
+ append(text: string): void {
322
+ if (!text) return;
323
+ if (this.maxBytes === Number.POSITIVE_INFINITY) {
324
+ this.value += text;
325
+ return;
326
+ }
327
+ const combined = this.encoder.encode(this.value + text);
328
+ if (combined.length <= this.maxBytes) {
329
+ this.value += text;
330
+ return;
331
+ }
332
+ let start = combined.length - this.maxBytes;
333
+ while (start < combined.length && (combined[start]! & 0xc0) === 0x80) start++;
334
+ const retained = combined.subarray(start);
335
+ this.droppedBytes += combined.length - retained.length;
336
+ this.value = this.decoder.decode(retained);
337
+ }
338
+ }
339
+
340
+ function terminateWorker(workerPid: number | undefined): void {
341
+ if (workerPid === undefined) return;
342
+ try {
343
+ process.kill(workerPid, "SIGKILL");
344
+ } catch {
345
+ // The command can finish between the abort event and the kill request.
346
+ }
347
+ }
348
+
349
+ async function checkoutSession(
350
+ pool: MontyPool,
351
+ options: CheckoutOptions,
352
+ signal: AbortSignal | undefined,
353
+ ): Promise<MontySession> {
354
+ const checkout = pool.checkout(options);
355
+ if (!signal) return checkout;
356
+ if (signal.aborted) {
357
+ void checkout.then((session) => session.close()).catch(() => undefined);
358
+ throw abortReason(signal);
359
+ }
360
+
361
+ return new Promise<MontySession>((resolve, reject) => {
362
+ let settled = false;
363
+ const onAbort = (): void => {
364
+ if (settled) return;
365
+ settled = true;
366
+ reject(abortReason(signal));
367
+ void checkout.then((session) => session.close()).catch(() => undefined);
368
+ };
369
+ signal.addEventListener("abort", onAbort, { once: true });
370
+ checkout.then(
371
+ (session) => {
372
+ if (settled) return;
373
+ settled = true;
374
+ signal.removeEventListener("abort", onAbort);
375
+ resolve(session);
376
+ },
377
+ (caught: unknown) => {
378
+ if (settled) return;
379
+ settled = true;
380
+ signal.removeEventListener("abort", onAbort);
381
+ reject(caught);
382
+ },
383
+ );
384
+ });
385
+ }
386
+
387
+ function abortReason(signal: AbortSignal | undefined): unknown {
388
+ return signal?.reason ?? new DOMException("The command was aborted", "AbortError");
389
+ }
390
+
391
+ function failedResult(
392
+ command: string,
393
+ args: string[],
394
+ started: number,
395
+ stderr: string,
396
+ ): CommandResult {
397
+ return {
398
+ command,
399
+ args,
400
+ success: false,
401
+ exitCode: 1,
402
+ stdout: "",
403
+ stderr,
404
+ executionTimeMs: performance.now() - started,
405
+ };
406
+ }