@dbx-tools/appkit-mastra 0.1.112 → 0.3.1
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 +309 -344
- package/index.ts +46 -0
- package/package.json +50 -28
- package/src/agents.ts +858 -0
- package/src/chart.ts +695 -0
- package/src/config.ts +443 -0
- package/src/filesystems.ts +1090 -0
- package/src/genie.ts +1091 -0
- package/src/history.ts +297 -0
- package/src/mcp.ts +105 -0
- package/src/memory.ts +300 -0
- package/src/mlflow.ts +149 -0
- package/src/model.ts +163 -0
- package/src/observability.ts +144 -0
- package/src/pagination.ts +34 -0
- package/src/plugin.ts +804 -0
- package/src/processors.ts +168 -0
- package/src/rest.ts +67 -0
- package/src/server.ts +343 -0
- package/src/serving-sanitize.ts +167 -0
- package/src/serving.ts +97 -0
- 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 -1676
- package/dist/index.js +0 -5337
|
@@ -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
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra-specific glue over the generic `@dbx-tools/model` toolkit.
|
|
3
|
+
*
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { override } from "@dbx-tools/shared-mastra";
|
|
13
|
+
import { serving as nodeServing } from "@dbx-tools/model";
|
|
14
|
+
|
|
15
|
+
import type { MastraPluginConfig } from "./config";
|
|
16
|
+
import { string } from "@dbx-tools/shared-core";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `RequestContext` key under which {@link MastraServer} stores the
|
|
20
|
+
* per-request model override (header / query / body). `model.ts`
|
|
21
|
+
* reads it before falling back to the agent / plugin default.
|
|
22
|
+
*/
|
|
23
|
+
export const MASTRA_MODEL_OVERRIDE_KEY = "mastra__model_override";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal Express-ish request shape used by {@link extractModelOverride}.
|
|
27
|
+
* Keeps this module independent of `express` so the helper can be
|
|
28
|
+
* reused from non-Express adapters.
|
|
29
|
+
*/
|
|
30
|
+
export interface ModelOverrideRequest {
|
|
31
|
+
headers?: Record<string, string | string[] | undefined>;
|
|
32
|
+
query?: Record<string, unknown> | undefined;
|
|
33
|
+
body?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pull a model override out of a single HTTP request, checking
|
|
38
|
+
* sources in priority order:
|
|
39
|
+
*
|
|
40
|
+
* 1. `X-Mastra-Model` header
|
|
41
|
+
* 2. `?model=` query string parameter
|
|
42
|
+
* 3. Body field (`model` or `modelId`, in that order)
|
|
43
|
+
*
|
|
44
|
+
* Returns `null` when nothing is set, so callers can wrap with
|
|
45
|
+
* `if (override) ...` without juggling empty strings. Body inspection
|
|
46
|
+
* is lenient - any plain object with one of the configured keys
|
|
47
|
+
* counts, mirroring how AI SDK chat clients pass arbitrary metadata
|
|
48
|
+
* alongside `messages`.
|
|
49
|
+
*/
|
|
50
|
+
export function extractModelOverride(req: ModelOverrideRequest): string | null {
|
|
51
|
+
const headers = req.headers;
|
|
52
|
+
if (headers) {
|
|
53
|
+
const headerVal = string.firstNonEmpty(
|
|
54
|
+
headers[override.MODEL_OVERRIDE_HEADER] ??
|
|
55
|
+
headers[override.MODEL_OVERRIDE_HEADER.toLowerCase()],
|
|
56
|
+
);
|
|
57
|
+
if (headerVal) return headerVal;
|
|
58
|
+
}
|
|
59
|
+
if (req.query) {
|
|
60
|
+
const queryVal = string.firstNonEmpty(req.query[override.MODEL_OVERRIDE_QUERY]);
|
|
61
|
+
if (queryVal) return queryVal;
|
|
62
|
+
}
|
|
63
|
+
if (req.body && typeof req.body === "object") {
|
|
64
|
+
const record = req.body as Record<string, unknown>;
|
|
65
|
+
for (const field of override.MODEL_OVERRIDE_BODY_FIELDS) {
|
|
66
|
+
const bodyVal = string.firstNonEmpty(record[field]);
|
|
67
|
+
if (bodyVal) return bodyVal;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read the fuzzy-resolution config knobs off the plugin config with
|
|
75
|
+
* `@dbx-tools/model` defaults applied. Kept here so `buildModel` and
|
|
76
|
+
* the `/models` route agree on what "enabled" means.
|
|
77
|
+
*
|
|
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.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveServingConfig(config: MastraPluginConfig): {
|
|
84
|
+
ttlMs: number;
|
|
85
|
+
threshold: number;
|
|
86
|
+
fuzzy: boolean;
|
|
87
|
+
allowOverride: boolean;
|
|
88
|
+
fallbacks: readonly string[];
|
|
89
|
+
} {
|
|
90
|
+
return {
|
|
91
|
+
ttlMs: config.modelCacheTtlMs ?? nodeServing.DEFAULT_MODEL_CACHE_TTL_MS,
|
|
92
|
+
threshold: config.modelFuzzyThreshold ?? nodeServing.DEFAULT_FUZZY_THRESHOLD,
|
|
93
|
+
fuzzy: config.modelFuzzyMatch !== false,
|
|
94
|
+
allowOverride: config.modelOverride !== false,
|
|
95
|
+
fallbacks: config.defaultModelFallbacks ?? [],
|
|
96
|
+
};
|
|
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
|
+
}
|
package/src/summarize.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small-tier summarization for the Mastra plugin.
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces, both backed by the fast / small chat tier
|
|
5
|
+
* ({@link model.ModelClass.ChatFast}) resolved through the same
|
|
6
|
+
* `/serving-endpoints` pipeline as the main agents:
|
|
7
|
+
*
|
|
8
|
+
* - A dedicated `summarize` tool (see {@link buildSummarizeTool})
|
|
9
|
+
* agents can call to condense arbitrary text without burning the
|
|
10
|
+
* heavyweight chat model.
|
|
11
|
+
* - The model + instructions Mastra's memory uses to auto-name
|
|
12
|
+
* conversation threads (`generateTitle`), so titling reuses the
|
|
13
|
+
* same small tier rather than the agent's primary model.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the chart-planner wiring in `chart.ts`: a per-config cached
|
|
16
|
+
* `Agent` on the fast tier, invoked via `agent.generate(...)` inside
|
|
17
|
+
* the active `asUser` scope so tokens stay user-scoped.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { model } from "@dbx-tools/shared-model";
|
|
21
|
+
import { Agent } from "@mastra/core/agent";
|
|
22
|
+
import type { MastraModelConfig } from "@mastra/core/llm";
|
|
23
|
+
import type { RequestContext } from "@mastra/core/request-context";
|
|
24
|
+
import { createTool } from "@mastra/core/tools";
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
|
|
27
|
+
import type { MastraPluginConfig } from "./config";
|
|
28
|
+
import { buildModel } from "./model";
|
|
29
|
+
import { string } from "@dbx-tools/shared-core";
|
|
30
|
+
|
|
31
|
+
/** Fast / small chat tier used for both titling and summaries. */
|
|
32
|
+
const SUMMARY_MODEL_CLASS = model.ModelClass.ChatFast;
|
|
33
|
+
|
|
34
|
+
/** System prompt for the summarizer agent (and the `summarize` tool). */
|
|
35
|
+
const SUMMARIZER_INSTRUCTIONS = [
|
|
36
|
+
"You are a summarization engine.",
|
|
37
|
+
"Given a block of text, produce a faithful, concise summary of it.",
|
|
38
|
+
"Default to a few sentences; follow any length guidance the caller gives.",
|
|
39
|
+
"Plain prose. No preamble, no headers, no bullet points unless asked.",
|
|
40
|
+
"Never use emojis. Use hyphens (-) only, never em dashes or en dashes.",
|
|
41
|
+
"Never add information, opinions, or details not present in the input.",
|
|
42
|
+
"Output only the summary.",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Instructions Mastra's `generateTitle` hands the small-tier model to
|
|
47
|
+
* name a conversation thread from its opening turn. Kept terse so the
|
|
48
|
+
* model returns a bare title with no decoration.
|
|
49
|
+
*/
|
|
50
|
+
export const TITLE_INSTRUCTIONS = [
|
|
51
|
+
"Generate a short, specific title for this conversation, 3 to 6 words.",
|
|
52
|
+
"Capture the user's topic, not the assistant's response.",
|
|
53
|
+
"Plain text only: no surrounding quotes, no trailing punctuation,",
|
|
54
|
+
"no emojis, and no em dashes. Output only the title.",
|
|
55
|
+
].join(" ");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the small-tier model for summarization / titling. Reused by
|
|
59
|
+
* both the summarizer agent and Mastra memory's `generateTitle`.
|
|
60
|
+
*
|
|
61
|
+
* Returned as a `requestContext`-taking function (a Mastra
|
|
62
|
+
* `DynamicArgument<MastraModelConfig>`) so each call mints user-scoped
|
|
63
|
+
* tokens via {@link buildModel}, exactly like the primary agents.
|
|
64
|
+
*/
|
|
65
|
+
export function summaryModel(
|
|
66
|
+
config: MastraPluginConfig,
|
|
67
|
+
): (args: { requestContext: RequestContext }) => Promise<MastraModelConfig> {
|
|
68
|
+
return ({ requestContext }) =>
|
|
69
|
+
buildModel(config, requestContext, { modelClass: SUMMARY_MODEL_CLASS });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One summarizer `Agent` per plugin config, cached on config-object
|
|
74
|
+
* identity so a hot tool path doesn't pay the constructor cost each
|
|
75
|
+
* call. `WeakMap` lets retired configs (e.g. test reconfigurations)
|
|
76
|
+
* release their agent without manual eviction.
|
|
77
|
+
*/
|
|
78
|
+
const summarizerAgents = new WeakMap<MastraPluginConfig, Agent>();
|
|
79
|
+
|
|
80
|
+
function getSummarizerAgent(config: MastraPluginConfig): Agent {
|
|
81
|
+
let agent = summarizerAgents.get(config);
|
|
82
|
+
if (!agent) {
|
|
83
|
+
agent = new Agent({
|
|
84
|
+
id: "summarizer",
|
|
85
|
+
name: "Summarizer",
|
|
86
|
+
description: "Condenses text into a short summary using a fast, small model.",
|
|
87
|
+
instructions: SUMMARIZER_INSTRUCTIONS,
|
|
88
|
+
model: summaryModel(config),
|
|
89
|
+
});
|
|
90
|
+
summarizerAgents.set(config, agent);
|
|
91
|
+
}
|
|
92
|
+
return agent;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Inputs accepted by the `summarize` tool. */
|
|
96
|
+
const summarizeInput = z.object({
|
|
97
|
+
text: z.string().min(1).describe("The text to summarize."),
|
|
98
|
+
instructions: z
|
|
99
|
+
.string()
|
|
100
|
+
.optional()
|
|
101
|
+
.describe(
|
|
102
|
+
"Optional extra guidance for the summary, e.g. 'one sentence' or 'list the action items'.",
|
|
103
|
+
),
|
|
104
|
+
maxWords: z
|
|
105
|
+
.number()
|
|
106
|
+
.int()
|
|
107
|
+
.positive()
|
|
108
|
+
.optional()
|
|
109
|
+
.describe("Optional soft cap on the summary length, in words."),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** Options accepted by {@link summarizeText}. */
|
|
113
|
+
export interface SummarizeOptions {
|
|
114
|
+
/** Extra guidance for the summary (length, focus, format). */
|
|
115
|
+
instructions?: string;
|
|
116
|
+
/** Soft cap on summary length, in words. */
|
|
117
|
+
maxWords?: number;
|
|
118
|
+
/** Active request context, so the model resolver mints user-scoped tokens. */
|
|
119
|
+
requestContext?: RequestContext;
|
|
120
|
+
/** Abort signal bridged from the calling tool / request. */
|
|
121
|
+
abortSignal?: AbortSignal;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Summarize `text` with the small-tier summarizer agent, returning the
|
|
126
|
+
* trimmed summary string. Throws on model failure (the caller decides
|
|
127
|
+
* how to degrade).
|
|
128
|
+
*/
|
|
129
|
+
export async function summarizeText(
|
|
130
|
+
config: MastraPluginConfig,
|
|
131
|
+
text: string,
|
|
132
|
+
options: SummarizeOptions = {},
|
|
133
|
+
): Promise<string> {
|
|
134
|
+
const { instructions, maxWords, requestContext, abortSignal } = options;
|
|
135
|
+
const prompt = string.toDescription({
|
|
136
|
+
...(instructions ? { Guidance: instructions } : {}),
|
|
137
|
+
...(maxWords !== undefined ? { "Max length (words)": String(maxWords) } : {}),
|
|
138
|
+
Text: text,
|
|
139
|
+
});
|
|
140
|
+
const result = await getSummarizerAgent(config).generate(prompt, {
|
|
141
|
+
...(requestContext ? { requestContext } : {}),
|
|
142
|
+
...(abortSignal ? { abortSignal } : {}),
|
|
143
|
+
});
|
|
144
|
+
return result.text.trim();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build the `summarize` tool. Exposed as an ambient system tool (like
|
|
149
|
+
* `render_data`) so every agent can offload condensing long content,
|
|
150
|
+
* notes, transcripts, or bulky tool results to the fast tier instead of
|
|
151
|
+
* spending its primary chat model. The tool reads the live
|
|
152
|
+
* `requestContext` / `abortSignal` off the Mastra execution context so
|
|
153
|
+
* its model call stays user-scoped and cancels with the turn.
|
|
154
|
+
*/
|
|
155
|
+
export function buildSummarizeTool(config: MastraPluginConfig) {
|
|
156
|
+
return createTool({
|
|
157
|
+
id: "summarize",
|
|
158
|
+
description:
|
|
159
|
+
"Summarize a block of text using a fast, small model. Use it to condense long " +
|
|
160
|
+
"content, notes, transcripts, or tool results into a short summary without " +
|
|
161
|
+
"spending the main chat model.",
|
|
162
|
+
inputSchema: summarizeInput,
|
|
163
|
+
execute: async (input, context) => {
|
|
164
|
+
const { text, instructions, maxWords } = input as z.infer<typeof summarizeInput>;
|
|
165
|
+
const ctx = context as
|
|
166
|
+
{ requestContext?: RequestContext; abortSignal?: AbortSignal } | undefined;
|
|
167
|
+
const summary = await summarizeText(config, text, {
|
|
168
|
+
...(instructions ? { instructions } : {}),
|
|
169
|
+
...(maxWords !== undefined ? { maxWords } : {}),
|
|
170
|
+
...(ctx?.requestContext ? { requestContext: ctx.requestContext } : {}),
|
|
171
|
+
...(ctx?.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
|
|
172
|
+
});
|
|
173
|
+
return { summary };
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
}
|