@dbx-tools/appkit-mastra 0.3.29 → 0.3.31
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 +86 -15
- package/index.ts +3 -0
- package/package.json +10 -10
- package/src/agents.ts +12 -11
- package/src/chart.ts +69 -38
- package/src/config.ts +170 -18
- package/src/defaults.ts +133 -0
- package/src/filesystems.ts +17 -5
- package/src/genie.ts +80 -39
- package/src/history.ts +5 -2
- package/src/model.ts +7 -0
- package/src/plugin.ts +469 -194
- package/src/rest.ts +5 -2
- package/src/server.ts +2 -1
- package/src/serving-sanitize.ts +15 -5
- package/src/storage-schema.ts +4 -1
- package/src/threads.ts +10 -3
- package/src/validation.ts +22 -0
- package/test/config.test.ts +64 -0
package/src/config.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Plugin configuration types and shared `RequestContext` keys.
|
|
3
3
|
*
|
|
4
|
+
* Owns the typed {@link MastraPluginConfig} (the plugin's slice of AppKit
|
|
5
|
+
* config) and {@link MASTRA_CONFIG_SCHEMA}, the JSON Schema the manifest
|
|
6
|
+
* publishes for it so scaffolding tools and agents can read the option set.
|
|
7
|
+
*
|
|
8
|
+
* Precedence per field is explicit plugin config, then the environment
|
|
9
|
+
* variable named on the field, then a built-in default.
|
|
10
|
+
*
|
|
4
11
|
* Kept in a leaf module so `plugin.ts`, `server.ts`, `model.ts`, and
|
|
5
12
|
* `memory.ts` can import them without creating a cycle.
|
|
6
13
|
*
|
|
7
14
|
* @module
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import type
|
|
17
|
+
import { getExecutionContext, type BasePluginConfig, type ConfigSchema } from "@databricks/appkit";
|
|
11
18
|
import { appkit } from "@dbx-tools/appkit";
|
|
12
19
|
import type { BrandContext } from "@dbx-tools/shared-core";
|
|
13
20
|
import type { AgentConfig } from "@mastra/core/agent";
|
|
21
|
+
import type { RequestContext } from "@mastra/core/request-context";
|
|
14
22
|
import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
|
|
15
23
|
import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
|
|
16
24
|
|
|
@@ -83,6 +91,28 @@ export interface User {
|
|
|
83
91
|
executionContext: appkit.ExecutionContextLike;
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Canonical identity for an AppKit execution context: the OBO user id on a
|
|
96
|
+
* user-scoped call, the service principal id otherwise.
|
|
97
|
+
*/
|
|
98
|
+
export function executionContextUserId(context: appkit.ExecutionContextLike): string {
|
|
99
|
+
return "userId" in context ? context.userId : context.serviceUserId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Identity every per-user cache entry is namespaced under, so an OBO result
|
|
104
|
+
* cannot be read back by another caller.
|
|
105
|
+
*
|
|
106
|
+
* Prefers the {@link User} that {@link MastraServer} stamps on the request
|
|
107
|
+
* context. The MCP transport routes do not thread that context into tool
|
|
108
|
+
* execution, so those fall back to the ambient execution context (the active
|
|
109
|
+
* OBO scope, or the service principal).
|
|
110
|
+
*/
|
|
111
|
+
export function resolveUserKey(requestContext?: RequestContext): string {
|
|
112
|
+
const user = requestContext?.get(MASTRA_USER_KEY) as User | undefined;
|
|
113
|
+
return user?.id ?? executionContextUserId(getExecutionContext());
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
/** PgVector config with an optional Mastra store id. */
|
|
87
117
|
export type MastraMemoryConfig = PgVectorConfig & {
|
|
88
118
|
id?: string;
|
|
@@ -129,7 +159,7 @@ export interface MastraMcpConfig {
|
|
|
129
159
|
|
|
130
160
|
/** Configuration accepted by the Mastra AppKit plugin. */
|
|
131
161
|
export interface MastraPluginConfig extends BasePluginConfig {
|
|
132
|
-
/** Mastra OpenAI-compatible provider id. Defaults to `"databricks"
|
|
162
|
+
/** Mastra OpenAI-compatible provider id. Defaults to `"databricks"`; no env fallback. */
|
|
133
163
|
providerId?: string;
|
|
134
164
|
/**
|
|
135
165
|
* PostgresStore for Mastra threads/messages. `true` reuses the
|
|
@@ -213,34 +243,40 @@ export interface MastraPluginConfig extends BasePluginConfig {
|
|
|
213
243
|
* or `providerId`.
|
|
214
244
|
*
|
|
215
245
|
* Resolution order per agent: `def.model` → `defaultModel` →
|
|
216
|
-
* built-in `/serving-endpoints`
|
|
246
|
+
* `DATABRICKS_SERVING_ENDPOINT_NAME` → built-in `/serving-endpoints`
|
|
247
|
+
* resolver.
|
|
217
248
|
*/
|
|
218
249
|
defaultModel?: AgentConfig["model"] | string;
|
|
219
250
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
251
|
+
* Fuzzy-match loose model names (`"claude sonnet"`) against the workspace's
|
|
252
|
+
* Model Serving endpoints. Defaults to `true`; no env fallback.
|
|
253
|
+
*
|
|
254
|
+
* Set `false` to require exact endpoint names everywhere.
|
|
223
255
|
*/
|
|
224
256
|
modelFuzzyMatch?: boolean;
|
|
225
257
|
/**
|
|
226
|
-
* Fuse.js score threshold for the fuzzy matcher
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
258
|
+
* Fuse.js score threshold for the fuzzy matcher, 0 (exact) to 1 (anything).
|
|
259
|
+
* Defaults to `0.4`; no env fallback.
|
|
260
|
+
*
|
|
261
|
+
* Lower values reject loose matches; raise it if you have a sprawling
|
|
262
|
+
* endpoint catalogue with similar-looking names.
|
|
230
263
|
*/
|
|
231
264
|
modelFuzzyThreshold?: number;
|
|
232
265
|
/**
|
|
233
|
-
* TTL for the in-memory serving-endpoints list cache, in
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
266
|
+
* TTL for the in-memory serving-endpoints list cache, in milliseconds.
|
|
267
|
+
* Defaults to 5 minutes; no env fallback.
|
|
268
|
+
*
|
|
269
|
+
* The cache is per workspace host and shared across users; concurrent
|
|
270
|
+
* callers coalesce on a single in-flight fetch.
|
|
237
271
|
*/
|
|
238
272
|
modelCacheTtlMs?: number;
|
|
239
273
|
/**
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
274
|
+
* Let clients pick the backing endpoint per request. Defaults to `true`;
|
|
275
|
+
* no env fallback.
|
|
276
|
+
*
|
|
277
|
+
* Reads the `X-Mastra-Model` header, the `?model=` query string, or a
|
|
278
|
+
* `model` body field, in that order. Disable when running multi-tenant
|
|
279
|
+
* where untrusted clients shouldn't choose the endpoint.
|
|
244
280
|
*/
|
|
245
281
|
modelOverride?: boolean;
|
|
246
282
|
/**
|
|
@@ -459,3 +495,119 @@ export interface MastraPluginConfig extends BasePluginConfig {
|
|
|
459
495
|
*/
|
|
460
496
|
brand?: BrandContext;
|
|
461
497
|
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* JSON Schema published on the manifest's `config.schema`, mirroring the
|
|
501
|
+
* documented defaults and environment fallbacks of {@link MastraPluginConfig}.
|
|
502
|
+
*
|
|
503
|
+
* Covers the JSON-expressible options only. `agents`, `tools`, and a
|
|
504
|
+
* `defaultModel` passed as a Mastra `DynamicArgument` are code-defined
|
|
505
|
+
* (functions / class instances), so they carry no schema entry; the
|
|
506
|
+
* `defaultModel` property below describes its string form.
|
|
507
|
+
*/
|
|
508
|
+
export const MASTRA_CONFIG_SCHEMA: ConfigSchema = {
|
|
509
|
+
type: "object",
|
|
510
|
+
properties: {
|
|
511
|
+
providerId: {
|
|
512
|
+
type: "string",
|
|
513
|
+
description: 'Mastra OpenAI-compatible provider id. Defaults to "databricks".',
|
|
514
|
+
},
|
|
515
|
+
storage: {
|
|
516
|
+
type: ["boolean", "object"],
|
|
517
|
+
description:
|
|
518
|
+
"PostgresStore for Mastra threads / messages. `true` reuses the `lakebase` plugin's pool, an object opens a dedicated store. Auto-enabled when the `lakebase` plugin is registered.",
|
|
519
|
+
},
|
|
520
|
+
memory: {
|
|
521
|
+
type: ["boolean", "object"],
|
|
522
|
+
description:
|
|
523
|
+
"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.",
|
|
524
|
+
},
|
|
525
|
+
defaultAgent: {
|
|
526
|
+
type: "string",
|
|
527
|
+
description:
|
|
528
|
+
"Agent id used when the client names none. Defaults to the first registered agent, else the built-in `default`.",
|
|
529
|
+
},
|
|
530
|
+
defaultModel: {
|
|
531
|
+
type: "string",
|
|
532
|
+
description:
|
|
533
|
+
"Serving endpoint applied to every agent that omits its own model. Falls back to DATABRICKS_SERVING_ENDPOINT_NAME, then the auto-resolved catalogue.",
|
|
534
|
+
},
|
|
535
|
+
defaultModelFallbacks: {
|
|
536
|
+
type: "array",
|
|
537
|
+
items: { type: "string" },
|
|
538
|
+
description:
|
|
539
|
+
"Priority-ordered endpoint names tried before the score-classified catalogue when nothing else pins a model.",
|
|
540
|
+
},
|
|
541
|
+
modelFuzzyMatch: {
|
|
542
|
+
type: "boolean",
|
|
543
|
+
description:
|
|
544
|
+
"Fuzzy-match loose model names against the workspace catalogue. Defaults to true.",
|
|
545
|
+
},
|
|
546
|
+
modelFuzzyThreshold: {
|
|
547
|
+
type: "number",
|
|
548
|
+
description:
|
|
549
|
+
"Fuse.js score threshold for the fuzzy matcher, 0 (exact) to 1 (anything). Defaults to 0.4.",
|
|
550
|
+
},
|
|
551
|
+
modelCacheTtlMs: {
|
|
552
|
+
type: "number",
|
|
553
|
+
description:
|
|
554
|
+
"TTL in ms for the serving-endpoints list cache, per workspace host. Defaults to 5 minutes.",
|
|
555
|
+
},
|
|
556
|
+
modelOverride: {
|
|
557
|
+
type: "boolean",
|
|
558
|
+
description:
|
|
559
|
+
"Honor a per-request model override from the X-Mastra-Model header, ?model= query, or a `model` body field. Defaults to true.",
|
|
560
|
+
},
|
|
561
|
+
genieSpaces: {
|
|
562
|
+
type: "object",
|
|
563
|
+
additionalProperties: { type: ["string", "object"] },
|
|
564
|
+
description:
|
|
565
|
+
"Genie spaces the agents can delegate to, keyed by alias (the tool-name suffix). Each value is a space id or `{ spaceId, hint }`. Falls back to the `genie` plugin's own `spaces` config, then DATABRICKS_GENIE_SPACE_ID under the `default` alias.",
|
|
566
|
+
},
|
|
567
|
+
genieSpaceCacheTtlMs: {
|
|
568
|
+
type: "number",
|
|
569
|
+
description: "TTL in ms for the Genie space metadata cache. Defaults to 5 minutes.",
|
|
570
|
+
},
|
|
571
|
+
agentMaxSteps: {
|
|
572
|
+
type: "number",
|
|
573
|
+
description:
|
|
574
|
+
"Maximum LLM steps each agent gets per turn (a tool call and the final reply each consume one). Defaults to 25.",
|
|
575
|
+
},
|
|
576
|
+
stripStaleCharts: {
|
|
577
|
+
type: "boolean",
|
|
578
|
+
description:
|
|
579
|
+
"Strip chartIds from recalled assistant tool results so the model cannot reuse a turn-scoped chart marker. Defaults to true.",
|
|
580
|
+
},
|
|
581
|
+
styleInstructions: {
|
|
582
|
+
type: ["string", "boolean"],
|
|
583
|
+
description:
|
|
584
|
+
"Style guardrails appended to every agent's instructions. A string replaces the built-in block, `false` disables it.",
|
|
585
|
+
},
|
|
586
|
+
observability: {
|
|
587
|
+
type: "boolean",
|
|
588
|
+
description:
|
|
589
|
+
"Bridge Mastra spans into AppKit's OTel pipeline. Defaults to on only when OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set.",
|
|
590
|
+
},
|
|
591
|
+
feedback: {
|
|
592
|
+
type: "boolean",
|
|
593
|
+
description:
|
|
594
|
+
"Log thumbs / comment assessments to MLflow and surface the feedback controls. Defaults to on only when an OTLP endpoint and MLFLOW_EXPERIMENT_ID or MLFLOW_EXPERIMENT_NAME are set.",
|
|
595
|
+
},
|
|
596
|
+
mcp: {
|
|
597
|
+
type: ["boolean", "object"],
|
|
598
|
+
description:
|
|
599
|
+
"Expose the registered agents over MCP. Defaults to agents-only; an object tunes the server id, advertised metadata, and whether ambient tools are exposed.",
|
|
600
|
+
},
|
|
601
|
+
apiAccess: {
|
|
602
|
+
type: "string",
|
|
603
|
+
enum: ["scoped", "full"],
|
|
604
|
+
description:
|
|
605
|
+
'How much of the stock @mastra/express API is reachable through the mount. "scoped" (default) allows agent inference, read-only agent metadata, this plugin\'s own routes, and MCP; "full" dispatches the entire management surface.',
|
|
606
|
+
},
|
|
607
|
+
brand: {
|
|
608
|
+
type: "object",
|
|
609
|
+
description:
|
|
610
|
+
"Portable brand context applied to generated charts (series palette from colors.primary / colors.accent, base font from typography.sans).",
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
};
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interceptor defaults for every outbound call the plugin's own routes make
|
|
3
|
+
* through `Plugin.execute()`.
|
|
4
|
+
*
|
|
5
|
+
* One constant per call site so the cache / retry / timeout decision lives
|
|
6
|
+
* beside its reasoning instead of at the call site. The interceptor chain runs
|
|
7
|
+
* telemetry, timeout, retry, cache, so a disabled interceptor is a deliberate
|
|
8
|
+
* choice and is commented as such.
|
|
9
|
+
*
|
|
10
|
+
* The cache interceptor is inert without a `cache.cacheKey`, which is
|
|
11
|
+
* request-specific (a statement id, an agent id). Call sites therefore spread
|
|
12
|
+
* the constant and add the key; the identity namespace comes from the
|
|
13
|
+
* execution context's user id, which `execute()` resolves on its own.
|
|
14
|
+
*
|
|
15
|
+
* Cancellation is not expressed here: `execute()` derives a signal from the
|
|
16
|
+
* `timeout` setting only, so a caller's own signal is combined with it at the
|
|
17
|
+
* call site instead.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-call interceptor settings. Structural stand-in for AppKit's
|
|
24
|
+
* `PluginExecuteConfig`: the barrel exports `StreamExecutionSettings` but not
|
|
25
|
+
* the non-streaming config, so the nominal type is unimportable here. Declared
|
|
26
|
+
* as a type alias (not an interface) so it keeps the implicit index signature
|
|
27
|
+
* AppKit's own config carries.
|
|
28
|
+
*/
|
|
29
|
+
type ExecuteConfig = {
|
|
30
|
+
cache?: { enabled?: boolean; ttl?: number; cacheKey?: (string | number | object)[] };
|
|
31
|
+
retry?: { enabled?: boolean; attempts?: number; initialDelay?: number; maxDelay?: number };
|
|
32
|
+
timeout?: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Default plus optional user-scoped settings, as `execute()` accepts them. */
|
|
36
|
+
type ExecuteSettings = {
|
|
37
|
+
default: ExecuteConfig;
|
|
38
|
+
user?: ExecuteConfig;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Ceiling on a single Model Serving / Genie / MLflow REST round-trip. */
|
|
42
|
+
const REST_TIMEOUT_MS = 30_000;
|
|
43
|
+
|
|
44
|
+
/** Ceiling on one Statement Execution fetch, which can page a large result. */
|
|
45
|
+
const STATEMENT_TIMEOUT_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
/** TTL for a cached statement result set, in seconds. */
|
|
48
|
+
const STATEMENT_CACHE_TTL_SEC = 5 * 60;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `GET /models`: the workspace's Model Serving catalogue.
|
|
52
|
+
*/
|
|
53
|
+
export const modelCatalogueDefaults: ExecuteSettings = {
|
|
54
|
+
default: {
|
|
55
|
+
// Cache disabled here because `listServingEndpoints` already memoizes the
|
|
56
|
+
// catalogue through the same `CacheManager`, keyed by workspace host with
|
|
57
|
+
// `config.modelCacheTtlMs`. That entry is shared across identities; a
|
|
58
|
+
// second interceptor cache would duplicate it once per user.
|
|
59
|
+
cache: { enabled: false },
|
|
60
|
+
// Retry enabled: listing endpoints is an idempotent GET, and the listing
|
|
61
|
+
// is on the chat UI's load path, so a transient 5xx should not blank the
|
|
62
|
+
// model picker.
|
|
63
|
+
retry: { enabled: true, attempts: 3 },
|
|
64
|
+
timeout: REST_TIMEOUT_MS,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* `GET /suggestions`: the curated `sample_questions` on each Genie space.
|
|
70
|
+
*/
|
|
71
|
+
export const genieSuggestionDefaults: ExecuteSettings = {
|
|
72
|
+
default: {
|
|
73
|
+
// Cache disabled here because `collectSpaceSuggestions` keeps its own
|
|
74
|
+
// space-id-keyed entry; the questions are authored config, identical for
|
|
75
|
+
// every caller, so caching them per identity would only fan out copies.
|
|
76
|
+
cache: { enabled: false },
|
|
77
|
+
// Retry enabled: reading space metadata is idempotent, and the route
|
|
78
|
+
// degrades to an empty starter list on failure, so one retry is cheaper
|
|
79
|
+
// than an empty chat state.
|
|
80
|
+
retry: { enabled: true, attempts: 2 },
|
|
81
|
+
timeout: REST_TIMEOUT_MS,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `GET /embed/data/:id`: one Statement Execution result set.
|
|
87
|
+
*/
|
|
88
|
+
export const statementDataDefaults: ExecuteSettings = {
|
|
89
|
+
default: {
|
|
90
|
+
// Cache enabled: a completed statement's result set is immutable, and the
|
|
91
|
+
// same id is re-fetched every time the transcript re-renders a
|
|
92
|
+
// `[data:<statement_id>]` marker. The entry is namespaced by the caller's
|
|
93
|
+
// identity, so an OBO result never crosses users.
|
|
94
|
+
cache: { enabled: true, ttl: STATEMENT_CACHE_TTL_SEC },
|
|
95
|
+
// Retry enabled: fetching a statement by id is idempotent.
|
|
96
|
+
retry: { enabled: true, attempts: 3 },
|
|
97
|
+
timeout: STATEMENT_TIMEOUT_MS,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `GET /embed/chart/:id`: the cached chart entry behind a `[chart:<id>]`
|
|
103
|
+
* marker.
|
|
104
|
+
*/
|
|
105
|
+
export const chartFetchDefaults: ExecuteSettings = {
|
|
106
|
+
default: {
|
|
107
|
+
// Cache disabled: the read long-polls a cache entry that is expected to
|
|
108
|
+
// change from processing to settled, so a cached answer would pin the
|
|
109
|
+
// client to the pre-planner state.
|
|
110
|
+
cache: { enabled: false },
|
|
111
|
+
// Retry disabled: the helper already polls to its own deadline, so a retry
|
|
112
|
+
// would only restart a wait the caller is already inside.
|
|
113
|
+
retry: { enabled: false },
|
|
114
|
+
// No timeout: the poll budget is per request (`?timeoutMs=`, capped by the
|
|
115
|
+
// route) and cancellation rides the connection's abort signal, so a second
|
|
116
|
+
// ceiling here could cut a legitimate long-poll short.
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `POST /route/feedback`: an MLflow trace assessment.
|
|
122
|
+
*/
|
|
123
|
+
export const feedbackWriteDefaults: ExecuteSettings = {
|
|
124
|
+
default: {
|
|
125
|
+
// Cache disabled: a write, and each submission is a distinct assessment.
|
|
126
|
+
cache: { enabled: false },
|
|
127
|
+
// Retry disabled: posting an assessment is not idempotent (a retry records
|
|
128
|
+
// a duplicate), and `logFeedback` already retries the one recoverable case
|
|
129
|
+
// itself, the trace not having finished exporting to MLflow yet.
|
|
130
|
+
retry: { enabled: false },
|
|
131
|
+
timeout: REST_TIMEOUT_MS,
|
|
132
|
+
},
|
|
133
|
+
};
|
package/src/filesystems.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { posix as path } from "node:path";
|
|
17
|
-
import { getExecutionContext } from "@databricks/appkit";
|
|
17
|
+
import { ExecutionError, getExecutionContext, ValidationError } from "@databricks/appkit";
|
|
18
18
|
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
19
19
|
import { error, functionModule, hash, log } from "@dbx-tools/shared-core";
|
|
20
20
|
import {
|
|
@@ -101,7 +101,11 @@ export interface DatabricksWorkspaceFilesystemOptions extends MastraFilesystemOp
|
|
|
101
101
|
export function normalizeDatabricksBasePath(basePath: string): string {
|
|
102
102
|
const trimmed = basePath.trim();
|
|
103
103
|
if (!trimmed.startsWith("/")) {
|
|
104
|
-
throw
|
|
104
|
+
throw ValidationError.invalidValue(
|
|
105
|
+
"basePath",
|
|
106
|
+
basePath,
|
|
107
|
+
"an absolute Databricks path, e.g. /Volumes/catalog/schema/volume",
|
|
108
|
+
);
|
|
105
109
|
}
|
|
106
110
|
return trimmed.replace(/\/+$/, "") || "/";
|
|
107
111
|
}
|
|
@@ -297,8 +301,16 @@ export class DatabricksWorkspaceFilesystem extends MastraFilesystem {
|
|
|
297
301
|
if (ErrorType) {
|
|
298
302
|
throw new ErrorType(workspacePath);
|
|
299
303
|
}
|
|
300
|
-
|
|
301
|
-
|
|
304
|
+
// The upstream message is logged rather than raised: it reaches the model
|
|
305
|
+
// (and from there the chat transcript) as the tool's failure text.
|
|
306
|
+
logger.warn("operation-failed", {
|
|
307
|
+
path: workspacePath,
|
|
308
|
+
error: error.errorMessage(err),
|
|
309
|
+
});
|
|
310
|
+
throw new ExecutionError(`Databricks filesystem operation failed for ${workspacePath}`, {
|
|
311
|
+
cause: error.toError(err),
|
|
312
|
+
context: { path: workspacePath },
|
|
313
|
+
});
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
/* --- lifecycle --- */
|
|
@@ -518,7 +530,7 @@ export class DatabricksWorkspaceFilesystem extends MastraFilesystem {
|
|
|
518
530
|
const created = await this.client.dbfs.create({ path: absolutePath, overwrite });
|
|
519
531
|
const handle = created.handle;
|
|
520
532
|
if (handle === undefined) {
|
|
521
|
-
throw
|
|
533
|
+
throw ExecutionError.missingData("DBFS upload handle");
|
|
522
534
|
}
|
|
523
535
|
for (let offset = 0; offset < buffer.length; offset += DBFS_PUT_MAX_BYTES) {
|
|
524
536
|
const slice = buffer.subarray(offset, offset + DBFS_PUT_MAX_BYTES);
|
package/src/genie.ts
CHANGED
|
@@ -36,7 +36,13 @@
|
|
|
36
36
|
* @module
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
CacheManager,
|
|
41
|
+
ConfigurationError,
|
|
42
|
+
ExecutionError,
|
|
43
|
+
genie,
|
|
44
|
+
ValidationError,
|
|
45
|
+
} from "@databricks/appkit";
|
|
40
46
|
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
41
47
|
import { plugin } from "@dbx-tools/appkit";
|
|
42
48
|
import { chat, space as genieSpace } from "@dbx-tools/genie";
|
|
@@ -50,7 +56,7 @@ import { z } from "zod";
|
|
|
50
56
|
|
|
51
57
|
import type { MastraTools } from "./agents";
|
|
52
58
|
import { chartPlannerRequestSchema, prepareChart } from "./chart";
|
|
53
|
-
import { MASTRA_USER_KEY } from "./config";
|
|
59
|
+
import { MASTRA_USER_KEY, resolveUserKey } from "./config";
|
|
54
60
|
import type { MastraPluginConfig, User } from "./config";
|
|
55
61
|
import { fetchStatementData } from "./statement";
|
|
56
62
|
import { safeWrite } from "./writer";
|
|
@@ -114,11 +120,17 @@ function requireClient(
|
|
|
114
120
|
} {
|
|
115
121
|
const requestContext = ctx?.requestContext;
|
|
116
122
|
if (!requestContext) {
|
|
117
|
-
throw
|
|
123
|
+
throw ConfigurationError.resourceNotFound(
|
|
124
|
+
`${toolId} request context`,
|
|
125
|
+
"Invoke the tool from an agent turn served by the mastra plugin.",
|
|
126
|
+
);
|
|
118
127
|
}
|
|
119
128
|
const user = requestContext.get(MASTRA_USER_KEY) as User | undefined;
|
|
120
129
|
if (!user) {
|
|
121
|
-
throw
|
|
130
|
+
throw ConfigurationError.resourceNotFound(
|
|
131
|
+
`${toolId} user context`,
|
|
132
|
+
"Invoke the tool from an agent turn served by the mastra plugin, which stamps the AppKit user on the Mastra request context.",
|
|
133
|
+
);
|
|
122
134
|
}
|
|
123
135
|
return { client: user.executionContext.client, requestContext };
|
|
124
136
|
}
|
|
@@ -163,15 +175,6 @@ const CONVERSATION_TTL_SEC = 4 * 60 * 60;
|
|
|
163
175
|
/** Cache namespace prefix so coexisting Mastra caches don't collide. */
|
|
164
176
|
const CONVERSATION_CACHE_NAMESPACE = "mastra:genie:conversation";
|
|
165
177
|
|
|
166
|
-
/**
|
|
167
|
-
* `userKey` for `CacheManager.getOrExecute` / `generateKey`. Genie
|
|
168
|
-
* conversations are scoped to a single user + space + thread, and
|
|
169
|
-
* `threadId` is already user-scoped (Mastra mints threads per
|
|
170
|
-
* `resourceId`), so a constant user key here is safe and keeps the
|
|
171
|
-
* cache key short.
|
|
172
|
-
*/
|
|
173
|
-
const CONVERSATION_USER_KEY = "mastra-genie";
|
|
174
|
-
|
|
175
178
|
/**
|
|
176
179
|
* Build the per-request {@link RequestContext} key the active
|
|
177
180
|
* Genie `conversation_id` lives under for `spaceId`. Scoped by
|
|
@@ -211,18 +214,23 @@ function writeContextConversationId(
|
|
|
211
214
|
}
|
|
212
215
|
|
|
213
216
|
/**
|
|
214
|
-
* Build the canonical cache key for a `(spaceId, threadId)` pair
|
|
215
|
-
* Returns `undefined` when `threadId` is missing - callers should
|
|
217
|
+
* Build the canonical cache key for a `(spaceId, threadId)` pair, owned by
|
|
218
|
+
* `userKey`. Returns `undefined` when `threadId` is missing - callers should
|
|
216
219
|
* skip caching entirely in that case (no Mastra memory wired up).
|
|
220
|
+
*
|
|
221
|
+
* The identity is part of the key because a client picks its own `threadId`
|
|
222
|
+
* (the thread-selection header / `?threadId=`), so the id alone does not
|
|
223
|
+
* establish who owns the Genie conversation behind it.
|
|
217
224
|
*/
|
|
218
225
|
async function conversationCacheKey(
|
|
219
226
|
spaceId: string,
|
|
220
227
|
threadId: string | undefined,
|
|
228
|
+
userKey: string,
|
|
221
229
|
): Promise<string | undefined> {
|
|
222
230
|
if (!threadId) return undefined;
|
|
223
231
|
return (await CacheManager.getInstance()).generateKey(
|
|
224
232
|
[CONVERSATION_CACHE_NAMESPACE, spaceId, threadId],
|
|
225
|
-
|
|
233
|
+
userKey,
|
|
226
234
|
);
|
|
227
235
|
}
|
|
228
236
|
|
|
@@ -395,7 +403,18 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
|
|
|
395
403
|
automatically while the call is in flight.
|
|
396
404
|
`),
|
|
397
405
|
inputSchema: z.object({
|
|
398
|
-
question: z
|
|
406
|
+
question: z
|
|
407
|
+
.string()
|
|
408
|
+
.min(1, "question is required")
|
|
409
|
+
.describe(
|
|
410
|
+
string.toDescription(`
|
|
411
|
+
ONE focused natural-language question about the data in this
|
|
412
|
+
space, covering a single metric / dimension / time window
|
|
413
|
+
(e.g. "What was Q3 revenue by region?"). Decompose a
|
|
414
|
+
multi-part ask into separate calls rather than combining it
|
|
415
|
+
here.
|
|
416
|
+
`),
|
|
417
|
+
),
|
|
399
418
|
}),
|
|
400
419
|
outputSchema: z.object({
|
|
401
420
|
message: genieModel.GenieMessageSchema,
|
|
@@ -416,10 +435,10 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
|
|
|
416
435
|
// of wasting a turn.
|
|
417
436
|
const trimmed = question.trim();
|
|
418
437
|
if (trimmed.length === 0 || PLACEHOLDER_QUESTIONS.has(trimmed.toLowerCase())) {
|
|
419
|
-
throw
|
|
420
|
-
`${toolId}
|
|
421
|
-
|
|
422
|
-
|
|
438
|
+
throw ValidationError.invalidValue(
|
|
439
|
+
`${toolId}.question`,
|
|
440
|
+
question,
|
|
441
|
+
"a real natural-language question, not a placeholder; skip the call instead",
|
|
423
442
|
);
|
|
424
443
|
}
|
|
425
444
|
|
|
@@ -430,7 +449,11 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
|
|
|
430
449
|
// The same `RequestContext` is reused across every `ask_genie`
|
|
431
450
|
// call within one user turn, so `ensureConversationSeeded`
|
|
432
451
|
// hits the cache at most once per request per space.
|
|
433
|
-
const cacheKey = await conversationCacheKey(
|
|
452
|
+
const cacheKey = await conversationCacheKey(
|
|
453
|
+
spaceId,
|
|
454
|
+
threadId,
|
|
455
|
+
resolveUserKey(requestContext),
|
|
456
|
+
);
|
|
434
457
|
await ensureConversationSeeded(requestContext, spaceId, cacheKey);
|
|
435
458
|
|
|
436
459
|
// Fire the lifecycle `started` event before any LLM /
|
|
@@ -466,7 +489,7 @@ function buildAskGenieTool(opts: { spaceId: string; alias: string; hint?: string
|
|
|
466
489
|
}
|
|
467
490
|
}
|
|
468
491
|
if (!finalMessage) {
|
|
469
|
-
throw
|
|
492
|
+
throw ExecutionError.missingData("Genie result event");
|
|
470
493
|
}
|
|
471
494
|
return finalMessage;
|
|
472
495
|
};
|
|
@@ -612,7 +635,17 @@ function buildGetStatementTool() {
|
|
|
612
635
|
upstream total - compare to \`rows.length\` to detect truncation.
|
|
613
636
|
`),
|
|
614
637
|
inputSchema: z.object({
|
|
615
|
-
statement_id: z
|
|
638
|
+
statement_id: z
|
|
639
|
+
.string()
|
|
640
|
+
.min(1, "statement_id is required")
|
|
641
|
+
.describe(
|
|
642
|
+
string.toDescription(`
|
|
643
|
+
Genie \`statement_id\` whose rows to read. Take it from
|
|
644
|
+
\`message.query_result.statement_id\` or
|
|
645
|
+
\`message.attachments[*].query.statement_id\` on an
|
|
646
|
+
\`ask_genie\` result; it is never a value you construct.
|
|
647
|
+
`),
|
|
648
|
+
),
|
|
616
649
|
limit: z
|
|
617
650
|
.number()
|
|
618
651
|
.int()
|
|
@@ -702,9 +735,10 @@ function buildPrepareChartTool(opts: { config: MastraPluginConfig }) {
|
|
|
702
735
|
outputSchema: wire.ChartSchema.pick({ chartId: true }),
|
|
703
736
|
execute: async (request, ctxRaw) => {
|
|
704
737
|
const ctx = ctxRaw as ToolExecuteCtx;
|
|
705
|
-
const { client } = requireClient(ctx, toolId);
|
|
738
|
+
const { client, requestContext } = requireClient(ctx, toolId);
|
|
706
739
|
return prepareChart({
|
|
707
740
|
config,
|
|
741
|
+
userKey: resolveUserKey(requestContext),
|
|
708
742
|
...(request.title ? { title: request.title } : {}),
|
|
709
743
|
...(request.description ? { description: request.description } : {}),
|
|
710
744
|
resolveData: (taskSignal) =>
|
|
@@ -855,9 +889,12 @@ export const GENIE_INSTRUCTIONS = string.toDescription([
|
|
|
855
889
|
* Normalize the {@link GenieSpacesConfig} record. Bare-string
|
|
856
890
|
* entries (`{ default: "01ef..." }`) get wrapped as
|
|
857
891
|
* `{ spaceId: "01ef..." }`; object entries pass through unchanged.
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
892
|
+
*
|
|
893
|
+
* @throws ConfigurationError when an alias is present but carries no space id
|
|
894
|
+
* (`{ default: process.env.DATABRICKS_GENIE_SPACE_ID }` with the variable
|
|
895
|
+
* unset). An alias that resolves to nothing is a wiring contradiction: the
|
|
896
|
+
* agent would advertise no Genie tool for a space the deployment believes it
|
|
897
|
+
* configured, so it fails at construction instead of going quiet.
|
|
861
898
|
*/
|
|
862
899
|
export function normalizeGenieSpaces(
|
|
863
900
|
spaces: GenieSpacesConfig | Record<string, string | GenieSpaceConfig | undefined> | undefined,
|
|
@@ -865,18 +902,22 @@ export function normalizeGenieSpaces(
|
|
|
865
902
|
if (!spaces) return {};
|
|
866
903
|
const out: Record<string, GenieSpaceConfig> = {};
|
|
867
904
|
for (const [alias, value] of Object.entries(spaces)) {
|
|
868
|
-
|
|
869
|
-
if (
|
|
870
|
-
|
|
871
|
-
out[alias] = { spaceId: value };
|
|
872
|
-
continue;
|
|
873
|
-
}
|
|
874
|
-
if (!value.spaceId) continue;
|
|
875
|
-
out[alias] = value;
|
|
905
|
+
const spaceId = typeof value === "string" ? value : value?.spaceId;
|
|
906
|
+
if (!spaceId) throw missingSpaceId(alias);
|
|
907
|
+
out[alias] = typeof value === "string" ? { spaceId } : value!;
|
|
876
908
|
}
|
|
877
909
|
return out;
|
|
878
910
|
}
|
|
879
911
|
|
|
912
|
+
/** Config contradiction: an alias present in `genieSpaces` with no space id. */
|
|
913
|
+
function missingSpaceId(alias: string): ConfigurationError {
|
|
914
|
+
const envHint =
|
|
915
|
+
alias === DEFAULT_GENIE_ALIAS
|
|
916
|
+
? "Set DATABRICKS_GENIE_SPACE_ID, pass the space id inline, or drop the alias."
|
|
917
|
+
: `Pass the space id inline (DATABRICKS_GENIE_SPACE_ID only backs the "${DEFAULT_GENIE_ALIAS}" alias) or drop the alias.`;
|
|
918
|
+
return ConfigurationError.resourceNotFound(`genieSpaces.${alias} space id`, envHint);
|
|
919
|
+
}
|
|
920
|
+
|
|
880
921
|
/**
|
|
881
922
|
* AppKit `genie` plugin's config shape, derived from the factory
|
|
882
923
|
* itself so it stays in lock-step with the upstream type without
|
|
@@ -907,9 +948,9 @@ type AppKitGenieConfig = NonNullable<Parameters<typeof genie>[0]>;
|
|
|
907
948
|
* pair just works.
|
|
908
949
|
*
|
|
909
950
|
* Aliases collide cleanly: a higher-precedence source's value
|
|
910
|
-
* replaces a lower one's wholesale.
|
|
911
|
-
* aliases
|
|
912
|
-
*
|
|
951
|
+
* replaces a lower one's wholesale. A source that contributes zero
|
|
952
|
+
* aliases is skipped; a source that names an alias without a space
|
|
953
|
+
* id fails through {@link normalizeGenieSpaces}.
|
|
913
954
|
*/
|
|
914
955
|
export function resolveGenieSpaces(
|
|
915
956
|
config: MastraPluginConfig,
|
package/src/history.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { ValidationError } from "@databricks/appkit";
|
|
21
22
|
import { error, log } from "@dbx-tools/shared-core";
|
|
22
23
|
import type {
|
|
23
24
|
MastraClearHistoryResponse,
|
|
@@ -207,8 +208,10 @@ export function historyRoute(options: HistoryRouteOptions) {
|
|
|
207
208
|
const { path } = options;
|
|
208
209
|
const fixedAgent = "agent" in options ? options.agent : undefined;
|
|
209
210
|
if (!fixedAgent && !path.includes(":agentId")) {
|
|
210
|
-
throw
|
|
211
|
-
"historyRoute
|
|
211
|
+
throw ValidationError.invalidValue(
|
|
212
|
+
"historyRoute.path",
|
|
213
|
+
path,
|
|
214
|
+
"a path containing `:agentId`, or an explicit `agent`",
|
|
212
215
|
);
|
|
213
216
|
}
|
|
214
217
|
// Tiny resolver shared by GET / DELETE: derive the active agent
|