@dbx-tools/appkit-mastra 0.1.5 → 0.1.10
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 +394 -0
- package/index.ts +46 -37
- package/package.json +58 -47
- package/src/agents.ts +233 -62
- package/src/chart.ts +555 -285
- package/src/config.ts +282 -18
- package/src/filesystems.ts +1090 -0
- package/src/genie.ts +1004 -601
- package/src/history.ts +178 -79
- package/src/mcp.ts +105 -0
- package/src/memory.ts +129 -74
- package/src/mlflow.ts +149 -0
- package/src/model.ts +80 -408
- package/src/observability.ts +144 -0
- package/src/pagination.ts +34 -0
- package/src/plugin.ts +573 -59
- package/src/processors.ts +168 -0
- package/src/rest.ts +67 -0
- package/src/server.ts +243 -45
- package/src/serving-sanitize.ts +167 -0
- package/src/serving.ts +27 -224
- package/src/statement.ts +89 -0
- package/src/storage-schema.ts +41 -0
- package/src/summarize.ts +176 -0
- package/src/threads.ts +338 -0
- package/src/workspaces.ts +346 -0
- package/src/writer.ts +44 -0
- package/tsconfig.json +41 -0
- package/dist/index.d.ts +0 -19
- package/dist/index.js +0 -19
- package/dist/src/agents.d.ts +0 -306
- package/dist/src/agents.js +0 -393
- package/dist/src/chart.d.ts +0 -104
- package/dist/src/chart.js +0 -375
- package/dist/src/config.d.ts +0 -170
- package/dist/src/config.js +0 -12
- package/dist/src/genie.d.ts +0 -116
- package/dist/src/genie.js +0 -594
- package/dist/src/history.d.ts +0 -67
- package/dist/src/history.js +0 -158
- package/dist/src/memory.d.ts +0 -79
- package/dist/src/memory.js +0 -197
- package/dist/src/model.d.ts +0 -159
- package/dist/src/model.js +0 -423
- package/dist/src/plugin.d.ts +0 -130
- package/dist/src/plugin.js +0 -255
- package/dist/src/render-chart-route.d.ts +0 -33
- package/dist/src/render-chart-route.js +0 -120
- package/dist/src/server.d.ts +0 -46
- package/dist/src/server.js +0 -113
- package/dist/src/serving.d.ts +0 -156
- package/dist/src/serving.js +0 -214
- package/src/render-chart-route.ts +0 -141
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { string } from "@dbx-tools/shared-core";
|
|
2
|
+
/**
|
|
3
|
+
* Repairs Mastra / AI SDK message replays sent to Databricks Model
|
|
4
|
+
* Serving before they hit the OpenAI-compatible `/chat/completions`
|
|
5
|
+
* route.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenAI-flavoured chat message shape we need to mutate. We do not
|
|
10
|
+
* import the OpenAI / AI SDK types because both packages keep these
|
|
11
|
+
* fields under internal namespaces; the wire payload is the contract
|
|
12
|
+
* here and it's stable enough to inline.
|
|
13
|
+
*/
|
|
14
|
+
export interface ServingChatMessage {
|
|
15
|
+
role: "system" | "user" | "assistant" | "tool" | "reasoning";
|
|
16
|
+
content?: string | ServingContentPart[];
|
|
17
|
+
tool_calls?: Array<{ id: string; type: string; function: unknown }>;
|
|
18
|
+
tool_call_id?: string;
|
|
19
|
+
reasoning?: unknown;
|
|
20
|
+
reasoning_content?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ServingContentPart = {
|
|
24
|
+
type?: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const REASONING_PART_TYPES = new Set(["reasoning", "thinking", "redacted_thinking"]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse, sanitize, and re-serialize a `/serving-endpoints/...` POST
|
|
33
|
+
* body. Returns the original string verbatim when the body is not
|
|
34
|
+
* JSON, has no `messages`, or no rewrite was needed.
|
|
35
|
+
*/
|
|
36
|
+
export function rewriteServingBody(body: string): string {
|
|
37
|
+
let parsed: { messages?: unknown };
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(body);
|
|
40
|
+
} catch {
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
if (!Array.isArray(parsed.messages)) return body;
|
|
44
|
+
const messages = parsed.messages as ServingChatMessage[];
|
|
45
|
+
const changed = stripReasoningFromServingMessages(messages) || repairAssistantPrefill(messages);
|
|
46
|
+
return changed ? JSON.stringify(parsed) : body;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drop extended-thinking / reasoning blocks from a replayed transcript.
|
|
51
|
+
*
|
|
52
|
+
* Hybrid Claude endpoints (e.g. Sonnet 4.5+) may emit `reasoning` content
|
|
53
|
+
* parts on the first turn. Mastra persists and replays them on the next
|
|
54
|
+
* agent step, but Databricks-hosted Claude rejects those blocks on
|
|
55
|
+
* multi-turn tool continuations. The UI already captured reasoning for
|
|
56
|
+
* display; stripping here keeps provider replay compatible without
|
|
57
|
+
* changing what users see in the chat bubble.
|
|
58
|
+
*/
|
|
59
|
+
export function stripReasoningFromServingMessages(messages: ServingChatMessage[]): boolean {
|
|
60
|
+
let changed = false;
|
|
61
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
62
|
+
const msg = messages[i];
|
|
63
|
+
if (!msg) continue;
|
|
64
|
+
if (msg.role === "reasoning") {
|
|
65
|
+
messages.splice(i, 1);
|
|
66
|
+
changed = true;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (msg.reasoning !== undefined) {
|
|
70
|
+
delete msg.reasoning;
|
|
71
|
+
changed = true;
|
|
72
|
+
}
|
|
73
|
+
if (msg.reasoning_content !== undefined) {
|
|
74
|
+
delete msg.reasoning_content;
|
|
75
|
+
changed = true;
|
|
76
|
+
}
|
|
77
|
+
if (!Array.isArray(msg.content)) continue;
|
|
78
|
+
const filtered = msg.content.filter((part) => {
|
|
79
|
+
const type = part?.type;
|
|
80
|
+
if (typeof type === "string" && REASONING_PART_TYPES.has(type)) {
|
|
81
|
+
changed = true;
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
if (filtered.length !== msg.content.length) {
|
|
87
|
+
msg.content = filtered;
|
|
88
|
+
}
|
|
89
|
+
const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
|
|
90
|
+
if (msg.role === "assistant" && !hasToolCalls && isEmptyServingContent(msg.content)) {
|
|
91
|
+
messages.splice(i, 1);
|
|
92
|
+
changed = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return changed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Repair a Mastra/AI SDK message replay that Databricks-hosted Claude
|
|
100
|
+
* rejects with `"This model does not support assistant message
|
|
101
|
+
* prefill. The conversation must end with a user message."`.
|
|
102
|
+
*
|
|
103
|
+
* The bug pattern: when an assistant turn streams text *and* a
|
|
104
|
+
* `tool_call`, the AI SDK persists them as two separate assistant
|
|
105
|
+
* entries (text-only and tool-call-only). On the next agent step the
|
|
106
|
+
* tool-call entry is replayed *before* the tool result and the
|
|
107
|
+
* text entry is replayed *after* it, so the conversation ends with a
|
|
108
|
+
* trailing assistant text message. Anthropic interprets that as a
|
|
109
|
+
* prefill request and rejects it on Databricks (the upstream Bedrock
|
|
110
|
+
* route disallows prefill).
|
|
111
|
+
*
|
|
112
|
+
* Fix: when the last message is an assistant text with no `tool_calls`
|
|
113
|
+
* and the chain immediately before it is `assistant(tool_calls=...)`
|
|
114
|
+
* followed only by `tool(...)` results, fold the trailing text back
|
|
115
|
+
* into the `content` of that opening assistant and drop the duplicate.
|
|
116
|
+
*/
|
|
117
|
+
export function repairAssistantPrefill(messages: ServingChatMessage[]): boolean {
|
|
118
|
+
if (messages.length < 2) return false;
|
|
119
|
+
const last = messages[messages.length - 1];
|
|
120
|
+
if (!last || last.role !== "assistant" || (last.tool_calls && last.tool_calls.length > 0)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let i = messages.length - 2;
|
|
125
|
+
while (i >= 0 && messages[i]?.role === "tool") i--;
|
|
126
|
+
if (i < 0) return false;
|
|
127
|
+
const opener = messages[i];
|
|
128
|
+
if (
|
|
129
|
+
!opener ||
|
|
130
|
+
opener.role !== "assistant" ||
|
|
131
|
+
!opener.tool_calls ||
|
|
132
|
+
opener.tool_calls.length === 0
|
|
133
|
+
) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const merged = [
|
|
138
|
+
string.trimToNull(textFromServingContent(opener.content)),
|
|
139
|
+
string.trimToNull(textFromServingContent(last.content)),
|
|
140
|
+
]
|
|
141
|
+
.filter((s): s is string => s !== null)
|
|
142
|
+
.join("\n\n");
|
|
143
|
+
opener.content = merged;
|
|
144
|
+
messages.pop();
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function textFromServingContent(content: ServingChatMessage["content"]): string {
|
|
149
|
+
if (typeof content === "string") return content;
|
|
150
|
+
if (!Array.isArray(content)) return "";
|
|
151
|
+
return content
|
|
152
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
153
|
+
.map((part) => part.text as string)
|
|
154
|
+
.join("\n\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isEmptyServingContent(content: ServingChatMessage["content"]): boolean {
|
|
158
|
+
if (content === undefined) return true;
|
|
159
|
+
if (typeof content === "string") return content.trim().length === 0;
|
|
160
|
+
if (!Array.isArray(content)) return true;
|
|
161
|
+
return content.every((part) => {
|
|
162
|
+
if (part?.type === "text") {
|
|
163
|
+
return typeof part.text !== "string" || part.text.trim().length === 0;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
});
|
|
167
|
+
}
|
package/src/serving.ts
CHANGED
|
@@ -1,41 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Mastra-specific glue over the generic `@dbx-tools/model` toolkit.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 2. **Fuzzy matching** - {@link resolveModelId} runs the user's input
|
|
11
|
-
* through `fuse.js` extended search so loose tokens like
|
|
12
|
-
* `"claude sonnet"` snap to `databricks-claude-sonnet-4-6` even
|
|
13
|
-
* when typed without the full endpoint name.
|
|
14
|
-
* 3. **Per-request override** - {@link extractModelOverride} pulls a
|
|
15
|
-
* model name from the `X-Mastra-Model` header, `?model=` query
|
|
16
|
-
* string, or `model` body field so the same agent can be exercised
|
|
17
|
-
* against different endpoints without redeploying.
|
|
18
|
-
*
|
|
19
|
-
* `model.ts` glues these together inside the per-step model resolver;
|
|
20
|
-
* `plugin.ts` exposes the cached list at `GET /models`.
|
|
4
|
+
* The live `/serving-endpoints` catalogue access, fuzzy name
|
|
5
|
+
* resolution, and class/fallback selection all live in
|
|
6
|
+
* `@dbx-tools/model`; this module only adds what is specific to the
|
|
7
|
+
* Mastra plugin: pulling a per-request model override off an HTTP
|
|
8
|
+
* request (header / query / body) and projecting the plugin config
|
|
9
|
+
* onto the knobs `buildModel` and the `/models` route share.
|
|
21
10
|
*/
|
|
22
11
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import Fuse from "fuse.js";
|
|
26
|
-
|
|
27
|
-
import type { ServingEndpointSummary } from "@dbx-tools/appkit-mastra-shared";
|
|
28
|
-
import type { MastraPluginConfig } from "./config.js";
|
|
12
|
+
import { override } from "@dbx-tools/shared-mastra";
|
|
13
|
+
import { serving as nodeServing } from "@dbx-tools/model";
|
|
29
14
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Structural type for the Databricks workspace client. Derived from
|
|
34
|
-
* AppKit's `ExecutionContext` so this module doesn't take a direct
|
|
35
|
-
* dependency on `@databricks/sdk-experimental`; the dep flows in
|
|
36
|
-
* transitively through `@databricks/appkit`.
|
|
37
|
-
*/
|
|
38
|
-
type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
|
|
15
|
+
import type { MastraPluginConfig } from "./config";
|
|
16
|
+
import { string } from "@dbx-tools/shared-core";
|
|
39
17
|
|
|
40
18
|
/**
|
|
41
19
|
* `RequestContext` key under which {@link MastraServer} stores the
|
|
@@ -44,179 +22,6 @@ type WorkspaceClientLike = ReturnType<typeof getExecutionContext>["client"];
|
|
|
44
22
|
*/
|
|
45
23
|
export const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
|
|
46
24
|
|
|
47
|
-
/** HTTP header inspected for a per-request model override. */
|
|
48
|
-
export const MODEL_OVERRIDE_HEADER = "x-mastra-model";
|
|
49
|
-
|
|
50
|
-
/** Query string parameter inspected for a per-request model override. */
|
|
51
|
-
export const MODEL_OVERRIDE_QUERY = "model";
|
|
52
|
-
|
|
53
|
-
/** Body fields (in priority order) inspected for a per-request model override. */
|
|
54
|
-
export const MODEL_OVERRIDE_BODY_FIELDS = ["model", "modelId"] as const;
|
|
55
|
-
|
|
56
|
-
/** Default TTL for the in-memory endpoint cache. Matches the Databricks SDK's session lifetime budget. */
|
|
57
|
-
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
58
|
-
|
|
59
|
-
/** Default Fuse.js score threshold below which a fuzzy match is accepted. */
|
|
60
|
-
const DEFAULT_FUZZY_THRESHOLD = 0.4;
|
|
61
|
-
|
|
62
|
-
/** Cache key parts under which endpoint listings are stored. */
|
|
63
|
-
const CACHE_KEY_NAMESPACE = "mastra:serving-endpoints";
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Stable `userKey` arg for AppKit's `CacheManager.getOrExecute`.
|
|
67
|
-
* Endpoint visibility is effectively workspace-scoped (we cache by
|
|
68
|
-
* host in the key parts), so a single shared key lets every user of
|
|
69
|
-
* the same workspace share one cached fetch and coalesce on the
|
|
70
|
-
* in-flight promise. Permissions can differ in theory, but the
|
|
71
|
-
* Foundation Model API catalogue is the same view for every caller.
|
|
72
|
-
*/
|
|
73
|
-
const SHARED_USER_KEY = "mastra-shared";
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* List Model Serving endpoints for the workspace owning `client`,
|
|
77
|
-
* routed through AppKit's `CacheManager`. The manager gives us
|
|
78
|
-
* everything `cachetools.TTLCache` provides plus what
|
|
79
|
-
* `cachetools-async` adds on top: per-entry TTL, in-flight request
|
|
80
|
-
* coalescing (concurrent callers share one fetch via the manager's
|
|
81
|
-
* internal `inFlightRequests` map), bounded size, telemetry spans
|
|
82
|
-
* (`cache.getOrExecute`), and optional Lakebase persistence so the
|
|
83
|
-
* catalogue survives restarts when the lakebase plugin is wired up.
|
|
84
|
-
*
|
|
85
|
-
* Returns plain {@link ServingEndpointSummary} objects (a stable
|
|
86
|
-
* subset of the SDK type) so cache hits never expose stale SDK
|
|
87
|
-
* internals. Errors from `CacheManager` or the SDK fetch propagate
|
|
88
|
-
* to the caller - we don't swallow them so users see the real
|
|
89
|
-
* auth / network issue.
|
|
90
|
-
*
|
|
91
|
-
* @param host - Workspace host used as the cache key. Pass the value
|
|
92
|
-
* resolved from `client.config.getHost()` so multi-host apps share
|
|
93
|
-
* one entry per workspace.
|
|
94
|
-
* @param opts.ttlMs - Override the default TTL just for this call.
|
|
95
|
-
* Forwarded to `CacheManager` as seconds.
|
|
96
|
-
*/
|
|
97
|
-
export async function listServingEndpoints(
|
|
98
|
-
client: WorkspaceClientLike,
|
|
99
|
-
host: string,
|
|
100
|
-
opts: { ttlMs?: number } = {},
|
|
101
|
-
): Promise<ServingEndpointSummary[]> {
|
|
102
|
-
const ttlSec = Math.max(1, Math.round((opts.ttlMs ?? DEFAULT_TTL_MS) / 1000));
|
|
103
|
-
return CacheManager.getInstanceSync().getOrExecute(
|
|
104
|
-
[CACHE_KEY_NAMESPACE, host],
|
|
105
|
-
() => fetchEndpoints(client),
|
|
106
|
-
SHARED_USER_KEY,
|
|
107
|
-
{ ttl: ttlSec },
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
async function fetchEndpoints(
|
|
112
|
-
client: WorkspaceClientLike,
|
|
113
|
-
): Promise<ServingEndpointSummary[]> {
|
|
114
|
-
const out: ServingEndpointSummary[] = [];
|
|
115
|
-
for await (const ep of client.servingEndpoints.list()) {
|
|
116
|
-
if (!ep.name) continue;
|
|
117
|
-
out.push({
|
|
118
|
-
name: ep.name,
|
|
119
|
-
...(ep.task !== undefined ? { task: ep.task } : {}),
|
|
120
|
-
...(ep.state?.ready !== undefined ? { state: String(ep.state.ready) } : {}),
|
|
121
|
-
...(ep.description !== undefined ? { description: ep.description } : {}),
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Force-evict cached endpoint listings via AppKit's `CacheManager`.
|
|
129
|
-
* With a `host` deletes that one workspace's entry; without one
|
|
130
|
-
* clears every cache entry on the manager (since `CacheManager`
|
|
131
|
-
* doesn't expose a namespace-scoped clear, this is the brute-force
|
|
132
|
-
* path - fine for tests, avoid in steady-state code).
|
|
133
|
-
*/
|
|
134
|
-
export async function clearServingEndpointsCache(host?: string): Promise<void> {
|
|
135
|
-
const cache = CacheManager.getInstanceSync();
|
|
136
|
-
if (host) {
|
|
137
|
-
const key = cache.generateKey([CACHE_KEY_NAMESPACE, host], SHARED_USER_KEY);
|
|
138
|
-
await cache.delete(key);
|
|
139
|
-
} else {
|
|
140
|
-
await cache.clear();
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Result of fuzzy-resolving a user-supplied model name against the
|
|
146
|
-
* live endpoint list. `score` is Fuse.js's distance (`0` is exact,
|
|
147
|
-
* `1` is no match); `matched` is `false` when the score exceeds the
|
|
148
|
-
* configured threshold so callers can fall back to the original
|
|
149
|
-
* input (Databricks will then return a clean 404).
|
|
150
|
-
*/
|
|
151
|
-
export interface ResolvedModel {
|
|
152
|
-
modelId: string;
|
|
153
|
-
matched: boolean;
|
|
154
|
-
score?: number;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Options accepted by {@link resolveModelId}. */
|
|
158
|
-
export interface ResolveModelOptions {
|
|
159
|
-
/** Fuse.js threshold (0 = exact, 1 = anything). Default `0.4`. */
|
|
160
|
-
threshold?: number;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Snap a user-supplied model name to the closest configured serving
|
|
165
|
-
* endpoint:
|
|
166
|
-
*
|
|
167
|
-
* 1. Exact name match wins immediately (no fuzzy needed).
|
|
168
|
-
* 2. Otherwise the input is tokenized (dashes / underscores / spaces
|
|
169
|
-
* become separators) and fed through Fuse.js extended search,
|
|
170
|
-
* which AND-s each token with fuzzy matching enabled. This is the
|
|
171
|
-
* "tokenized fuzzy match" the user reaches for when they type
|
|
172
|
-
* `"claude sonnet"` instead of the full endpoint name.
|
|
173
|
-
* 3. If the best Fuse score is above `threshold`, return the input
|
|
174
|
-
* unchanged and let the upstream call surface the 404. This keeps
|
|
175
|
-
* deliberate model ids (e.g. brand new endpoints) from being
|
|
176
|
-
* silently rewritten to a similar-looking neighbour.
|
|
177
|
-
*
|
|
178
|
-
* Pass an empty endpoint list to short-circuit fuzzy matching - the
|
|
179
|
-
* input is returned verbatim. This is what {@link buildModel} does
|
|
180
|
-
* when the workspace client can't be reached at resolve time.
|
|
181
|
-
*/
|
|
182
|
-
export function resolveModelId(
|
|
183
|
-
input: string,
|
|
184
|
-
endpoints: readonly ServingEndpointSummary[],
|
|
185
|
-
opts: ResolveModelOptions = {},
|
|
186
|
-
): ResolvedModel {
|
|
187
|
-
if (endpoints.length === 0) {
|
|
188
|
-
return { modelId: input, matched: false };
|
|
189
|
-
}
|
|
190
|
-
for (const ep of endpoints) {
|
|
191
|
-
if (ep.name === input) {
|
|
192
|
-
return { modelId: ep.name, matched: true, score: 0 };
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const threshold = opts.threshold ?? DEFAULT_FUZZY_THRESHOLD;
|
|
196
|
-
const fuse = new Fuse(endpoints, {
|
|
197
|
-
keys: ["name"],
|
|
198
|
-
threshold,
|
|
199
|
-
ignoreLocation: true,
|
|
200
|
-
includeScore: true,
|
|
201
|
-
useExtendedSearch: true,
|
|
202
|
-
isCaseSensitive: false,
|
|
203
|
-
});
|
|
204
|
-
// Fuse 7.3 has no built-in tokenize hook; in extended search,
|
|
205
|
-
// space-separated tokens are AND-ed with fuzzy matching enabled. We
|
|
206
|
-
// lean on the shared tokenizer so the splitting rules stay
|
|
207
|
-
// consistent with the rest of the toolkit.
|
|
208
|
-
const query = Array.from(
|
|
209
|
-
stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
|
|
210
|
-
).join(" ");
|
|
211
|
-
if (!query) return { modelId: input, matched: false };
|
|
212
|
-
const results = fuse.search(query);
|
|
213
|
-
const best = results[0];
|
|
214
|
-
if (best?.item.name && (best.score ?? 0) <= threshold) {
|
|
215
|
-
return { modelId: best.item.name, matched: true, score: best.score };
|
|
216
|
-
}
|
|
217
|
-
return { modelId: input, matched: false };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
25
|
/**
|
|
221
26
|
* Minimal Express-ish request shape used by {@link extractModelOverride}.
|
|
222
27
|
* Keeps this module independent of `express` so the helper can be
|
|
@@ -245,19 +50,20 @@ export interface ModelOverrideRequest {
|
|
|
245
50
|
export function extractModelOverride(req: ModelOverrideRequest): string | null {
|
|
246
51
|
const headers = req.headers;
|
|
247
52
|
if (headers) {
|
|
248
|
-
const headerVal =
|
|
249
|
-
headers[MODEL_OVERRIDE_HEADER] ??
|
|
53
|
+
const headerVal = string.firstNonEmpty(
|
|
54
|
+
headers[override.MODEL_OVERRIDE_HEADER] ??
|
|
55
|
+
headers[override.MODEL_OVERRIDE_HEADER.toLowerCase()],
|
|
250
56
|
);
|
|
251
57
|
if (headerVal) return headerVal;
|
|
252
58
|
}
|
|
253
59
|
if (req.query) {
|
|
254
|
-
const queryVal =
|
|
60
|
+
const queryVal = string.firstNonEmpty(req.query[override.MODEL_OVERRIDE_QUERY]);
|
|
255
61
|
if (queryVal) return queryVal;
|
|
256
62
|
}
|
|
257
63
|
if (req.body && typeof req.body === "object") {
|
|
258
64
|
const record = req.body as Record<string, unknown>;
|
|
259
|
-
for (const field of MODEL_OVERRIDE_BODY_FIELDS) {
|
|
260
|
-
const bodyVal =
|
|
65
|
+
for (const field of override.MODEL_OVERRIDE_BODY_FIELDS) {
|
|
66
|
+
const bodyVal = string.firstNonEmpty(record[field]);
|
|
261
67
|
if (bodyVal) return bodyVal;
|
|
262
68
|
}
|
|
263
69
|
}
|
|
@@ -266,18 +72,15 @@ export function extractModelOverride(req: ModelOverrideRequest): string | null {
|
|
|
266
72
|
|
|
267
73
|
/**
|
|
268
74
|
* Read the fuzzy-resolution config knobs off the plugin config with
|
|
269
|
-
* defaults applied. Kept here so `buildModel` and
|
|
270
|
-
* agree on what "enabled" means.
|
|
75
|
+
* `@dbx-tools/model` defaults applied. Kept here so `buildModel` and
|
|
76
|
+
* the `/models` route agree on what "enabled" means.
|
|
271
77
|
*
|
|
272
|
-
* `fallbacks` is the priority-ordered list `
|
|
273
|
-
* nothing explicit is set; defaults
|
|
274
|
-
*
|
|
275
|
-
*
|
|
78
|
+
* `fallbacks` is the priority-ordered list `resolveModel` walks first
|
|
79
|
+
* when nothing explicit is set; defaults to an empty list so the
|
|
80
|
+
* generic resolver falls through to the live catalogue and its own
|
|
81
|
+
* `FALLBACK_MODEL_IDS` floor.
|
|
276
82
|
*/
|
|
277
|
-
export function resolveServingConfig(
|
|
278
|
-
config: MastraPluginConfig,
|
|
279
|
-
defaultFallbacks: readonly string[] = [],
|
|
280
|
-
): {
|
|
83
|
+
export function resolveServingConfig(config: MastraPluginConfig): {
|
|
281
84
|
ttlMs: number;
|
|
282
85
|
threshold: number;
|
|
283
86
|
fuzzy: boolean;
|
|
@@ -285,10 +88,10 @@ export function resolveServingConfig(
|
|
|
285
88
|
fallbacks: readonly string[];
|
|
286
89
|
} {
|
|
287
90
|
return {
|
|
288
|
-
ttlMs: config.modelCacheTtlMs ??
|
|
289
|
-
threshold: config.modelFuzzyThreshold ?? DEFAULT_FUZZY_THRESHOLD,
|
|
91
|
+
ttlMs: config.modelCacheTtlMs ?? nodeServing.DEFAULT_MODEL_CACHE_TTL_MS,
|
|
92
|
+
threshold: config.modelFuzzyThreshold ?? nodeServing.DEFAULT_FUZZY_THRESHOLD,
|
|
290
93
|
fuzzy: config.modelFuzzyMatch !== false,
|
|
291
94
|
allowOverride: config.modelOverride !== false,
|
|
292
|
-
fallbacks: config.defaultModelFallbacks ??
|
|
95
|
+
fallbacks: config.defaultModelFallbacks ?? [],
|
|
293
96
|
};
|
|
294
97
|
}
|
package/src/statement.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Databricks Statement Execution helpers for the Mastra plugin.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `client.statementExecution.getStatement` with the shape and
|
|
5
|
+
* size handling the plugin's tools and the `/embed/data/:id` route
|
|
6
|
+
* both need: a low-level fetch that returns a raw
|
|
7
|
+
* `{columns, rows, rowCount}` shape and coerces numeric strings to
|
|
8
|
+
* numbers so downstream charts and aggregations don't have to, plus a
|
|
9
|
+
* hard row cap callers clamp `limit` to so a runaway result set can't
|
|
10
|
+
* hose a response. Upstream 404s are detected via
|
|
11
|
+
* `error.errorContext(err).notFound` at the call sites.
|
|
12
|
+
*
|
|
13
|
+
* Not Genie-specific: a Databricks `statement_id` is workspace
|
|
14
|
+
* scoped and lives in the Statement Execution API regardless of
|
|
15
|
+
* which producer (Genie, a tool, a notebook, etc.) submitted the
|
|
16
|
+
* query. Co-located here so consumers can fetch / cap / handle
|
|
17
|
+
* 404s without reaching into the Genie tool module.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { WorkspaceClient } from "@databricks/sdk-experimental";
|
|
21
|
+
import type { GenieDatasetData } from "@dbx-tools/shared-mastra";
|
|
22
|
+
import { databricks } from "@dbx-tools/appkit";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Hard server-side cap on rows returned by the
|
|
26
|
+
* `/embed/data/:id` route. Sized to keep responses small
|
|
27
|
+
* enough for inline tables to render snappily; the route surfaces
|
|
28
|
+
* a `truncated` flag whenever the upstream `rowCount` exceeds
|
|
29
|
+
* this so end users know they're seeing a sample.
|
|
30
|
+
*/
|
|
31
|
+
export const STATEMENT_ROW_CAP = 500;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort numeric coercion for the Statement Execution API's
|
|
35
|
+
* all-strings cells. Leaves non-numeric strings (and explicit
|
|
36
|
+
* `null`s) intact; everything else flows through `Number`.
|
|
37
|
+
*/
|
|
38
|
+
function coerceCell(cell: string | null): unknown {
|
|
39
|
+
if (cell === null) return null;
|
|
40
|
+
if (/^-?\d+(\.\d+)?$/.test(cell)) {
|
|
41
|
+
const n = Number(cell);
|
|
42
|
+
if (Number.isFinite(n)) return n;
|
|
43
|
+
}
|
|
44
|
+
return cell;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Fetch a single statement's rows via the Statement Execution API
|
|
49
|
+
* and reshape into the shared {@link GenieDatasetData} shape
|
|
50
|
+
* (column array + row records).
|
|
51
|
+
*
|
|
52
|
+
* Optional `limit` slices the returned `rows` client-side so the
|
|
53
|
+
* agent can scan a small sample without paging the full result
|
|
54
|
+
* set into context. `rowCount` always reflects the upstream total
|
|
55
|
+
* so callers know when the slice truncated.
|
|
56
|
+
*
|
|
57
|
+
* Exported because every consumer in the plugin (the
|
|
58
|
+
* `get_statement` tool, the `prepare_chart` dataset resolver, and
|
|
59
|
+
* the `/embed/data/:id` route) needs the exact same
|
|
60
|
+
* fetch + coercion pipeline so LLM-side `get_statement` output
|
|
61
|
+
* and UI-side `[data:<id>]` rendering stay shape-identical for
|
|
62
|
+
* the same `statement_id`.
|
|
63
|
+
*/
|
|
64
|
+
export async function fetchStatementData(
|
|
65
|
+
client: WorkspaceClient,
|
|
66
|
+
statementId: string,
|
|
67
|
+
options?: { limit?: number; signal?: AbortSignal },
|
|
68
|
+
): Promise<GenieDatasetData> {
|
|
69
|
+
const ctx = options?.signal ? databricks.toContext(options.signal) : undefined;
|
|
70
|
+
const r = await client.statementExecution.getStatement({ statement_id: statementId }, ctx);
|
|
71
|
+
const columns = (r.manifest?.schema?.columns ?? []).map((c) => c.name ?? "");
|
|
72
|
+
const dataArray = (r.result?.data_array ?? []) as Array<Array<string | null>>;
|
|
73
|
+
const sliced =
|
|
74
|
+
options?.limit !== undefined && options.limit >= 0
|
|
75
|
+
? dataArray.slice(0, options.limit)
|
|
76
|
+
: dataArray;
|
|
77
|
+
const rows = sliced.map((row) => {
|
|
78
|
+
const obj: Record<string, unknown> = {};
|
|
79
|
+
columns.forEach((col, i) => {
|
|
80
|
+
obj[col] = coerceCell(row[i] ?? null);
|
|
81
|
+
});
|
|
82
|
+
return obj;
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
columns,
|
|
86
|
+
rows,
|
|
87
|
+
rowCount: r.manifest?.total_row_count ?? dataArray.length,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { string } from "@dbx-tools/shared-core";
|
|
2
|
+
/**
|
|
3
|
+
* Derive a Postgres-safe schema name for per-agent Mastra storage.
|
|
4
|
+
*
|
|
5
|
+
* Agent ids are often kebab-case route segments (`data-mesh-book-assistant`)
|
|
6
|
+
* but {@link PostgresStore} validates `schemaName` with Mastra's
|
|
7
|
+
* `parseSqlIdentifier` (letter/underscore start, `[A-Za-z0-9_]` only, max 63).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const SCHEMA_PREFIX = "mastra_";
|
|
11
|
+
const MAX_PG_IDENTIFIER_LEN = 63;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default Lakebase schema for one agent's thread/message store:
|
|
15
|
+
* `mastra_<sanitized-agent-id>`.
|
|
16
|
+
*/
|
|
17
|
+
export function agentStorageSchemaName(agentId: string): string {
|
|
18
|
+
const maxSlugLen = MAX_PG_IDENTIFIER_LEN - SCHEMA_PREFIX.length;
|
|
19
|
+
const slug = string.toIdentifierWithOptions(
|
|
20
|
+
{
|
|
21
|
+
delimiter: "_",
|
|
22
|
+
maxLength: maxSlugLen,
|
|
23
|
+
truncateStrategy: "hash",
|
|
24
|
+
truncateHashLength: 6,
|
|
25
|
+
},
|
|
26
|
+
agentId,
|
|
27
|
+
);
|
|
28
|
+
const body =
|
|
29
|
+
slug ||
|
|
30
|
+
string.toIdentifierWithOptions(
|
|
31
|
+
{
|
|
32
|
+
delimiter: "_",
|
|
33
|
+
maxLength: maxSlugLen,
|
|
34
|
+
truncateStrategy: "hash",
|
|
35
|
+
truncateHashLength: 6,
|
|
36
|
+
},
|
|
37
|
+
"agent",
|
|
38
|
+
agentId,
|
|
39
|
+
);
|
|
40
|
+
return `${SCHEMA_PREFIX}${body}`;
|
|
41
|
+
}
|