@dbx-tools/appkit-mastra 0.1.5 → 0.1.13
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 +735 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/src/agents.js +18 -8
- package/dist/src/chart.d.ts +101 -35
- package/dist/src/chart.js +178 -62
- package/dist/src/config.d.ts +13 -0
- package/dist/src/genie.d.ts +23 -8
- package/dist/src/genie.js +137 -101
- package/dist/src/history.js +14 -0
- package/dist/src/memory.d.ts +21 -0
- package/dist/src/memory.js +47 -2
- package/dist/src/model.js +18 -14
- package/dist/src/observability.d.ts +33 -0
- package/dist/src/observability.js +71 -0
- package/dist/src/plugin.d.ts +1 -1
- package/dist/src/plugin.js +32 -4
- package/dist/src/processors/strip-stale-charts.d.ts +29 -0
- package/dist/src/processors/strip-stale-charts.js +96 -0
- package/dist/src/server.js +10 -0
- package/dist/src/serving.js +19 -2
- package/dist/src/tools/email.d.ts +74 -0
- package/dist/src/tools/email.js +122 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/index.ts +1 -0
- package/package.json +23 -25
- package/src/agents.ts +19 -6
- package/src/chart.ts +232 -64
- package/src/config.ts +13 -0
- package/src/genie.ts +179 -116
- package/src/history.ts +19 -7
- package/src/memory.ts +55 -2
- package/src/model.ts +18 -13
- package/src/observability.ts +92 -0
- package/src/plugin.ts +33 -4
- package/src/processors/strip-stale-charts.ts +105 -0
- package/src/server.ts +11 -0
- package/src/serving.ts +21 -2
- package/src/tools/email.ts +147 -0
- package/dist/src/render-chart-route.d.ts +0 -33
- package/dist/src/render-chart-route.js +0 -120
- package/src/render-chart-route.ts +0 -141
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra observability wiring for the `@dbx-tools/appkit-phoenix`
|
|
3
|
+
* sibling plugin.
|
|
4
|
+
*
|
|
5
|
+
* Mastra's `Observability` registry accepts any
|
|
6
|
+
* `@mastra/observability` `BaseExporter`. We use `OtelExporter` from
|
|
7
|
+
* `@mastra/otel-exporter` (Mastra's first-party OTLP shim) with the
|
|
8
|
+
* `custom` provider pointed at Phoenix's local collector URL. No
|
|
9
|
+
* Arize-specific wrapper is needed - Phoenix is a vanilla
|
|
10
|
+
* OpenInference-compatible OTLP/HTTP receiver.
|
|
11
|
+
*
|
|
12
|
+
* Discovery is structural so this module doesn't depend on
|
|
13
|
+
* `@dbx-tools/appkit-phoenix` at compile time: we look up the
|
|
14
|
+
* registered plugin by its registered name (`"phoenix"`) and read its
|
|
15
|
+
* `exports().collectorEndpoint()` if it is shaped like the phoenix
|
|
16
|
+
* plugin. The phoenix package is therefore an *optional* sibling -
|
|
17
|
+
* apps that don't install it just get an undefined observability
|
|
18
|
+
* config and Mastra runs without OTLP export.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { pluginUtils } from "@dbx-tools/appkit-shared";
|
|
22
|
+
import { Observability } from "@mastra/observability";
|
|
23
|
+
import { OtelExporter } from "@mastra/otel-exporter";
|
|
24
|
+
|
|
25
|
+
/** Plugin name the phoenix plugin registers under (matches `phoenix()`). */
|
|
26
|
+
const PHOENIX_PLUGIN_NAME = "phoenix";
|
|
27
|
+
|
|
28
|
+
/** Structural shape of the bits of `phoenix().exports()` we touch. */
|
|
29
|
+
interface PhoenixExportsLike {
|
|
30
|
+
collectorEndpoint?(): string | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Structural shape of an AppKit plugin instance with `exports()`. */
|
|
34
|
+
interface PluginWithExports {
|
|
35
|
+
exports?(): unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* If the sibling `phoenix` plugin is registered AND has booted with a
|
|
40
|
+
* usable collector URL, return a Mastra `Observability` configured to
|
|
41
|
+
* stream traces + logs there. Otherwise return `undefined` so the
|
|
42
|
+
* caller can omit the field on the `new Mastra({...})` constructor.
|
|
43
|
+
*
|
|
44
|
+
* The exporter uses `provider.custom` with `http/protobuf`, which is
|
|
45
|
+
* what Phoenix's `/v1/traces` endpoint speaks natively. Switching
|
|
46
|
+
* Phoenix to gRPC would be a one-line `protocol: "grpc"` change and
|
|
47
|
+
* a different exported URL.
|
|
48
|
+
*/
|
|
49
|
+
export function buildPhoenixObservability(
|
|
50
|
+
context: pluginUtils.PluginContextLike | undefined,
|
|
51
|
+
serviceName: string,
|
|
52
|
+
): Observability | undefined {
|
|
53
|
+
const endpoint = readPhoenixEndpoint(context);
|
|
54
|
+
if (!endpoint) return undefined;
|
|
55
|
+
|
|
56
|
+
return new Observability({
|
|
57
|
+
configs: {
|
|
58
|
+
phoenix: {
|
|
59
|
+
serviceName,
|
|
60
|
+
exporters: [
|
|
61
|
+
new OtelExporter({
|
|
62
|
+
provider: {
|
|
63
|
+
custom: {
|
|
64
|
+
endpoint,
|
|
65
|
+
protocol: "http/protobuf",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Pull the OTLP collector URL out of the registered `phoenix` plugin.
|
|
77
|
+
* Tolerant of the plugin being absent (returns `undefined`) and of a
|
|
78
|
+
* future shape change in its exports (anything that's not a string
|
|
79
|
+
* is ignored). The lookup is keyed off the registered plugin *name*
|
|
80
|
+
* so this file does not depend on `@dbx-tools/appkit-phoenix`.
|
|
81
|
+
*/
|
|
82
|
+
function readPhoenixEndpoint(
|
|
83
|
+
context: pluginUtils.PluginContextLike | undefined,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
if (!context) return undefined;
|
|
86
|
+
const plugin = context.getPlugins().get(PHOENIX_PLUGIN_NAME) as
|
|
87
|
+
| PluginWithExports
|
|
88
|
+
| undefined;
|
|
89
|
+
const exports_ = plugin?.exports?.() as PhoenixExportsLike | undefined;
|
|
90
|
+
const url = exports_?.collectorEndpoint?.();
|
|
91
|
+
return typeof url === "string" ? url : undefined;
|
|
92
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -47,8 +47,8 @@ import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents.js";
|
|
|
47
47
|
import type { MastraClientConfig } from "@dbx-tools/appkit-mastra-shared";
|
|
48
48
|
import type { MastraPluginConfig } from "./config.js";
|
|
49
49
|
import { historyRoute } from "./history.js";
|
|
50
|
-
import { renderChartRoute } from "./render-chart-route.js";
|
|
51
50
|
import { createMemoryBuilder, needsLakebase } from "./memory.js";
|
|
51
|
+
import { buildPhoenixObservability } from "./observability.js";
|
|
52
52
|
import { attachRoutePatchMiddleware, MastraServer } from "./server.js";
|
|
53
53
|
import {
|
|
54
54
|
clearServingEndpointsCache,
|
|
@@ -191,7 +191,6 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
191
191
|
modelsPath: `${basePath}/models`,
|
|
192
192
|
historyPath: `${basePath}/route/history`,
|
|
193
193
|
historyPathTemplate: `${basePath}/route/history/:agentId`,
|
|
194
|
-
renderChartPath: `${basePath}/route/render-chart`,
|
|
195
194
|
defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
|
|
196
195
|
agents: Object.keys(this.built?.agents ?? {}),
|
|
197
196
|
};
|
|
@@ -251,6 +250,11 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
251
250
|
? createMemoryBuilder(this.config, this.context)
|
|
252
251
|
: undefined;
|
|
253
252
|
|
|
253
|
+
this.log.debug("build:start", {
|
|
254
|
+
lakebase: memoryBuilder !== undefined,
|
|
255
|
+
stripStaleCharts: this.config.stripStaleCharts !== false,
|
|
256
|
+
});
|
|
257
|
+
|
|
254
258
|
// Build every agent declared in `config.agents` (or the built-in
|
|
255
259
|
// fallback when none are declared). Each agent's `model` resolves
|
|
256
260
|
// workspace URL + bearer at call time so concurrent requests get
|
|
@@ -268,7 +272,26 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
268
272
|
// dev server. Since we're hosting Mastra inside our own Express
|
|
269
273
|
// subapp via `@mastra/express`, custom routes must be passed to
|
|
270
274
|
// the `MastraServer` constructor directly.
|
|
271
|
-
|
|
275
|
+
//
|
|
276
|
+
// `storage` here is *Mastra-instance-level* and persists workflow
|
|
277
|
+
// snapshots (where suspended `requireApproval` tool calls live).
|
|
278
|
+
// It's separate from each agent's `Memory.storage`, which only
|
|
279
|
+
// covers thread / message history. Without it,
|
|
280
|
+
// `agent.resumeStream()` errors with "could not find a suspended
|
|
281
|
+
// run" and the approval UI hangs after the user clicks Approve.
|
|
282
|
+
const instanceStorage = memoryBuilder?.instanceStorage();
|
|
283
|
+
// Auto-wire OTLP trace export to the sibling `phoenix` plugin if
|
|
284
|
+
// it's registered. Returns undefined when phoenix isn't around so
|
|
285
|
+
// the field stays off the constructor and Mastra keeps its noop
|
|
286
|
+
// observability default. The serviceName is the plugin's bound
|
|
287
|
+
// name so multiple mastra instances in one process stay
|
|
288
|
+
// distinguishable in Phoenix.
|
|
289
|
+
const observability = buildPhoenixObservability(this.context, this.name);
|
|
290
|
+
this.mastra = new Mastra({
|
|
291
|
+
agents: this.built.agents,
|
|
292
|
+
...(instanceStorage ? { storage: instanceStorage } : {}),
|
|
293
|
+
...(observability ? { observability } : {}),
|
|
294
|
+
});
|
|
272
295
|
this.mastraApp = express();
|
|
273
296
|
attachRoutePatchMiddleware(this.mastraApp);
|
|
274
297
|
this.mastraServer = new MastraServer(this.config, {
|
|
@@ -280,10 +303,16 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
|
|
|
280
303
|
chatRoute({ path: "/route/chat/:agentId" }),
|
|
281
304
|
historyRoute({ path: "/route/history", agent: this.built.defaultAgentId }),
|
|
282
305
|
historyRoute({ path: "/route/history/:agentId" }),
|
|
283
|
-
renderChartRoute({ path: "/route/render-chart", config: this.config }),
|
|
284
306
|
],
|
|
285
307
|
});
|
|
286
308
|
await this.mastraServer.init();
|
|
309
|
+
this.log.debug("build:done", {
|
|
310
|
+
agents: Object.keys(this.built.agents),
|
|
311
|
+
defaultAgent: this.built.defaultAgentId,
|
|
312
|
+
routes: ["/route/chat", "/route/history", "/models"],
|
|
313
|
+
instanceStorage: instanceStorage !== undefined,
|
|
314
|
+
observability: observability !== undefined ? "phoenix" : "off",
|
|
315
|
+
});
|
|
287
316
|
}
|
|
288
317
|
}
|
|
289
318
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra input processor that strips `chartId` fields from every
|
|
3
|
+
* tool-invocation result in prior assistant messages before they
|
|
4
|
+
* reach the model.
|
|
5
|
+
*
|
|
6
|
+
* Why: chartIds are only meaningful within the assistant turn that
|
|
7
|
+
* minted them - the writer events backing them are gone after the
|
|
8
|
+
* stream closes. When the model sees old chartIds in memory recall
|
|
9
|
+
* (Mastra Memory persists tool results), it's tempted to type
|
|
10
|
+
* those ids into the new turn's `[[chart:<id>]]` markers, leaving
|
|
11
|
+
* the chat client's chart slots stuck with no matching event. This
|
|
12
|
+
* processor removes the temptation by deleting `chartId` keys from
|
|
13
|
+
* every assistant message's tool results before the prompt is
|
|
14
|
+
* built. The current turn's tool results don't exist yet at
|
|
15
|
+
* `processInput` time, so they pass through unmodified.
|
|
16
|
+
*
|
|
17
|
+
* The strip is recursive - any nested `chartId` field is removed,
|
|
18
|
+
* regardless of which tool produced the result. This covers Genie's
|
|
19
|
+
* `datasets[].chartId` and `render_data`'s top-level `chartId`
|
|
20
|
+
* uniformly without coupling to specific tool ids.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { logUtils } from "@dbx-tools/appkit-shared";
|
|
24
|
+
import type {
|
|
25
|
+
InputProcessor,
|
|
26
|
+
ProcessInputArgs,
|
|
27
|
+
} from "@mastra/core/processors";
|
|
28
|
+
|
|
29
|
+
const log = logUtils.logger("mastra/processor/strip-stale-charts");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Recursively clone `value`, omitting any property whose key is
|
|
33
|
+
* `chartId`. Arrays are mapped element-wise; primitives are
|
|
34
|
+
* returned as-is. The result is structurally identical to the
|
|
35
|
+
* input minus chartIds, so downstream message-shape consumers
|
|
36
|
+
* keep working.
|
|
37
|
+
*/
|
|
38
|
+
function stripChartIds(value: unknown): unknown {
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
return value.map(stripChartIds);
|
|
41
|
+
}
|
|
42
|
+
if (value && typeof value === "object") {
|
|
43
|
+
const obj = value as Record<string, unknown>;
|
|
44
|
+
const out: Record<string, unknown> = {};
|
|
45
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
46
|
+
if (key === "chartId") continue;
|
|
47
|
+
out[key] = stripChartIds(val);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Input processor that scrubs `chartId` from every tool-invocation
|
|
56
|
+
* result in the message list. Wired onto every agent by default
|
|
57
|
+
* via {@link buildAgents}; opt out with
|
|
58
|
+
* `MastraPluginConfig.stripStaleCharts: false`.
|
|
59
|
+
*/
|
|
60
|
+
export const stripStaleChartsProcessor: InputProcessor = {
|
|
61
|
+
id: "strip-stale-charts",
|
|
62
|
+
description:
|
|
63
|
+
"Removes chartId fields from prior tool-invocation results so the model can't reuse turn-scoped ids from memory.",
|
|
64
|
+
processInput(args: ProcessInputArgs) {
|
|
65
|
+
let stripped = 0;
|
|
66
|
+
for (const message of args.messages) {
|
|
67
|
+
if (message.role !== "assistant") continue;
|
|
68
|
+
const parts = message.content?.parts;
|
|
69
|
+
if (!Array.isArray(parts)) continue;
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
// Tool-invocation parts hold the persisted tool result.
|
|
72
|
+
// We don't scrub the input args (`rawInput` / `args`) because
|
|
73
|
+
// the chartId there is the model's outgoing claim, not
|
|
74
|
+
// anything it could re-reference; only `result` carries
|
|
75
|
+
// ids that subsequent turns might copy.
|
|
76
|
+
if (
|
|
77
|
+
(part as { type?: unknown }).type !== "tool-invocation"
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const inv = (part as { toolInvocation?: { result?: unknown } })
|
|
82
|
+
.toolInvocation;
|
|
83
|
+
if (!inv || inv.result === undefined) continue;
|
|
84
|
+
const before = inv.result;
|
|
85
|
+
const after = stripChartIds(before);
|
|
86
|
+
// Cheap structural check via JSON length - the actual
|
|
87
|
+
// strip writes a fresh object only when chartId keys
|
|
88
|
+
// existed, so different stringification length is a
|
|
89
|
+
// reliable signal that something was removed.
|
|
90
|
+
if (
|
|
91
|
+
typeof before === "object" &&
|
|
92
|
+
before !== null &&
|
|
93
|
+
JSON.stringify(before).length !== JSON.stringify(after).length
|
|
94
|
+
) {
|
|
95
|
+
inv.result = after;
|
|
96
|
+
stripped += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (stripped > 0) {
|
|
101
|
+
log.debug("stripped", { results: stripped });
|
|
102
|
+
}
|
|
103
|
+
return args.messages;
|
|
104
|
+
},
|
|
105
|
+
};
|
package/src/server.ts
CHANGED
|
@@ -46,6 +46,17 @@ export class MastraServer extends MastraServerExpress {
|
|
|
46
46
|
this.configureRequestContextUser(requestContext);
|
|
47
47
|
this.configureRequestContextThreadId(req, res, requestContext);
|
|
48
48
|
this.configureRequestContextModelOverride(req, requestContext);
|
|
49
|
+
this.log.debug("auth:middleware", {
|
|
50
|
+
method: req.method,
|
|
51
|
+
path: req.path,
|
|
52
|
+
threadId: requestContext.get(MASTRA_THREAD_ID_KEY),
|
|
53
|
+
resourceId: requestContext.get(MASTRA_RESOURCE_ID_KEY),
|
|
54
|
+
modelOverride: requestContext.get(
|
|
55
|
+
// imported below; logged so a misrouted request shows
|
|
56
|
+
// up alongside its model selection in `LOG_LEVEL=debug`.
|
|
57
|
+
"mastra__model_override",
|
|
58
|
+
),
|
|
59
|
+
});
|
|
49
60
|
next();
|
|
50
61
|
});
|
|
51
62
|
}
|
package/src/serving.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { CacheManager, type getExecutionContext } from "@databricks/appkit";
|
|
24
|
-
import { stringUtils } from "@dbx-tools/appkit-shared";
|
|
24
|
+
import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
|
|
25
25
|
import Fuse from "fuse.js";
|
|
26
26
|
|
|
27
27
|
import type { ServingEndpointSummary } from "@dbx-tools/appkit-mastra-shared";
|
|
@@ -29,6 +29,8 @@ import type { MastraPluginConfig } from "./config.js";
|
|
|
29
29
|
|
|
30
30
|
export type { ServingEndpointSummary };
|
|
31
31
|
|
|
32
|
+
const log = logUtils.logger("mastra/serving");
|
|
33
|
+
|
|
32
34
|
/**
|
|
33
35
|
* Structural type for the Databricks workspace client. Derived from
|
|
34
36
|
* AppKit's `ExecutionContext` so this module doesn't take a direct
|
|
@@ -111,6 +113,7 @@ export async function listServingEndpoints(
|
|
|
111
113
|
async function fetchEndpoints(
|
|
112
114
|
client: WorkspaceClientLike,
|
|
113
115
|
): Promise<ServingEndpointSummary[]> {
|
|
116
|
+
const startedAt = Date.now();
|
|
114
117
|
const out: ServingEndpointSummary[] = [];
|
|
115
118
|
for await (const ep of client.servingEndpoints.list()) {
|
|
116
119
|
if (!ep.name) continue;
|
|
@@ -121,6 +124,7 @@ async function fetchEndpoints(
|
|
|
121
124
|
...(ep.description !== undefined ? { description: ep.description } : {}),
|
|
122
125
|
});
|
|
123
126
|
}
|
|
127
|
+
log.debug("listed", { count: out.length, elapsedMs: Date.now() - startedAt });
|
|
124
128
|
return out;
|
|
125
129
|
}
|
|
126
130
|
|
|
@@ -185,10 +189,12 @@ export function resolveModelId(
|
|
|
185
189
|
opts: ResolveModelOptions = {},
|
|
186
190
|
): ResolvedModel {
|
|
187
191
|
if (endpoints.length === 0) {
|
|
192
|
+
log.debug("resolve:no-endpoints", { input });
|
|
188
193
|
return { modelId: input, matched: false };
|
|
189
194
|
}
|
|
190
195
|
for (const ep of endpoints) {
|
|
191
196
|
if (ep.name === input) {
|
|
197
|
+
log.debug("resolve:exact", { input });
|
|
192
198
|
return { modelId: ep.name, matched: true, score: 0 };
|
|
193
199
|
}
|
|
194
200
|
}
|
|
@@ -208,12 +214,25 @@ export function resolveModelId(
|
|
|
208
214
|
const query = Array.from(
|
|
209
215
|
stringUtils.tokenizeWithOptions({ lowerCase: true, camelCase: false }, input),
|
|
210
216
|
).join(" ");
|
|
211
|
-
if (!query)
|
|
217
|
+
if (!query) {
|
|
218
|
+
log.debug("resolve:empty-tokens", { input });
|
|
219
|
+
return { modelId: input, matched: false };
|
|
220
|
+
}
|
|
212
221
|
const results = fuse.search(query);
|
|
213
222
|
const best = results[0];
|
|
214
223
|
if (best?.item.name && (best.score ?? 0) <= threshold) {
|
|
224
|
+
log.debug("resolve:fuzzy-match", {
|
|
225
|
+
input,
|
|
226
|
+
modelId: best.item.name,
|
|
227
|
+
score: best.score,
|
|
228
|
+
});
|
|
215
229
|
return { modelId: best.item.name, matched: true, score: best.score };
|
|
216
230
|
}
|
|
231
|
+
log.debug("resolve:no-match", {
|
|
232
|
+
input,
|
|
233
|
+
bestScore: best?.score,
|
|
234
|
+
threshold,
|
|
235
|
+
});
|
|
217
236
|
return { modelId: input, matched: false };
|
|
218
237
|
}
|
|
219
238
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mastra tool: `send_email`. Gated behind {@link requireApproval}
|
|
3
|
+
* so the model can call it freely but execution is paused until a
|
|
4
|
+
* human approves via the chat UI.
|
|
5
|
+
*
|
|
6
|
+
* The execute body is a stub - it logs the would-be email to the
|
|
7
|
+
* server console (via `logUtils.logger`) and returns success. Swap
|
|
8
|
+
* in a real SMTP / SES / Resend / Workspace Mail call later by
|
|
9
|
+
* editing the `execute` body; the tool surface and approval gate
|
|
10
|
+
* stay the same.
|
|
11
|
+
*
|
|
12
|
+
* Approval flow (Mastra + AI SDK V5):
|
|
13
|
+
*
|
|
14
|
+
* 1. Model calls the tool with `{ to, subject, body, ... }`.
|
|
15
|
+
* 2. Mastra evaluates `requireApproval` (here always `true`),
|
|
16
|
+
* pauses the agent loop, and emits a `tool-call-approval`
|
|
17
|
+
* chunk on the response stream.
|
|
18
|
+
* 3. The chat client renders an approve/deny prompt against the
|
|
19
|
+
* `state: 'approval-requested'` tool part. On approve, it sends
|
|
20
|
+
* a `MastraToolApproval` response back; on deny, the tool call
|
|
21
|
+
* is rejected and the model sees an error.
|
|
22
|
+
* 4. On approve, this `execute` runs and logs the email.
|
|
23
|
+
*
|
|
24
|
+
* The tool is intentionally NOT auto-installed on every agent -
|
|
25
|
+
* email is domain-specific, not infrastructure. Spread it into the
|
|
26
|
+
* specific agents that should be able to draft emails.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { logUtils, stringUtils } from "@dbx-tools/appkit-shared";
|
|
30
|
+
import { createTool } from "@mastra/core/tools";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
|
|
33
|
+
const log = logUtils.logger("mastra/tool/send-email");
|
|
34
|
+
|
|
35
|
+
const emailInputSchema = z.object({
|
|
36
|
+
to: z.string().describe(stringUtils.toDescription`
|
|
37
|
+
Single recipient email address (e.g. "alice@example.com"). For
|
|
38
|
+
multiple recipients, comma-separate them yourself.
|
|
39
|
+
`),
|
|
40
|
+
subject: z.string().describe(stringUtils.toDescription`
|
|
41
|
+
Subject line.
|
|
42
|
+
`),
|
|
43
|
+
body: z.string().describe(stringUtils.toDescription`
|
|
44
|
+
Email body. Plain text or markdown; the renderer downstream
|
|
45
|
+
decides which to honour. Be specific - the recipient may not
|
|
46
|
+
have any context the model has from prior chat turns.
|
|
47
|
+
`),
|
|
48
|
+
cc: z
|
|
49
|
+
.array(z.string())
|
|
50
|
+
.optional()
|
|
51
|
+
.describe(stringUtils.toDescription`
|
|
52
|
+
Optional CC recipients.
|
|
53
|
+
`),
|
|
54
|
+
bcc: z
|
|
55
|
+
.array(z.string())
|
|
56
|
+
.optional()
|
|
57
|
+
.describe(stringUtils.toDescription`
|
|
58
|
+
Optional BCC recipients.
|
|
59
|
+
`),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const emailOutputSchema = z.object({
|
|
63
|
+
sent: z.boolean().describe(stringUtils.toDescription`
|
|
64
|
+
True when the email was dispatched. The current implementation
|
|
65
|
+
always returns true after console-logging the would-be email;
|
|
66
|
+
swap in a real provider to make this meaningful.
|
|
67
|
+
`),
|
|
68
|
+
recipient: z.string().describe(stringUtils.toDescription`
|
|
69
|
+
Echo of the \`to\` field for confirmation.
|
|
70
|
+
`),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** Options accepted by {@link buildEmailTool}. */
|
|
74
|
+
export interface BuildEmailToolOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Override the tool id. Defaults to `"send_email"`. Useful if a
|
|
77
|
+
* caller wants `send_internal_email` / `send_external_email`
|
|
78
|
+
* variants.
|
|
79
|
+
*/
|
|
80
|
+
id?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Replace the default execute body with a real provider call.
|
|
83
|
+
* Receives the validated input and must return `{sent, recipient}`.
|
|
84
|
+
* The console-log default is meant for demos / dev; production
|
|
85
|
+
* deployments should wire SMTP / SES / Resend / Workspace Mail
|
|
86
|
+
* here.
|
|
87
|
+
*/
|
|
88
|
+
send?: (input: z.infer<typeof emailInputSchema>) => Promise<void> | void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the `send_email` tool. Approval-gated by default; the
|
|
93
|
+
* execute body either calls the supplied {@link send} hook or
|
|
94
|
+
* logs the email to the server console as a demo stub.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* ```ts
|
|
98
|
+
* import { buildEmailTool, createAgent, mastra } from "@dbx-tools/appkit-mastra";
|
|
99
|
+
*
|
|
100
|
+
* const support = createAgent({
|
|
101
|
+
* instructions: "...",
|
|
102
|
+
* tools(plugins) {
|
|
103
|
+
* return {
|
|
104
|
+
* ...(plugins.genie?.toolkit() ?? {}),
|
|
105
|
+
* send_email: buildEmailTool(),
|
|
106
|
+
* };
|
|
107
|
+
* },
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function buildEmailTool(opts: BuildEmailToolOptions = {}) {
|
|
112
|
+
return createTool({
|
|
113
|
+
id: opts.id ?? "send_email",
|
|
114
|
+
description: stringUtils.toDescription`
|
|
115
|
+
Send an email on the user's behalf. Pass a recipient
|
|
116
|
+
address, subject, and body; the user will be prompted to
|
|
117
|
+
approve the send before it goes out (the tool is
|
|
118
|
+
approval-gated). Use this when the user explicitly asks
|
|
119
|
+
to send / forward / share something via email - never
|
|
120
|
+
autonomously. Keep subjects short and bodies focused; the
|
|
121
|
+
recipient may not have any of the chat context.
|
|
122
|
+
`,
|
|
123
|
+
inputSchema: emailInputSchema,
|
|
124
|
+
outputSchema: emailOutputSchema,
|
|
125
|
+
requireApproval: true,
|
|
126
|
+
execute: async (input) => {
|
|
127
|
+
const { to, subject, body, cc, bcc } = input as z.infer<
|
|
128
|
+
typeof emailInputSchema
|
|
129
|
+
>;
|
|
130
|
+
// Default behaviour: dump the email to the server console so
|
|
131
|
+
// demos can see the gate fire end-to-end without a real
|
|
132
|
+
// provider. Replace by passing `opts.send`.
|
|
133
|
+
log.info("send", {
|
|
134
|
+
to,
|
|
135
|
+
...(cc && cc.length > 0 ? { cc } : {}),
|
|
136
|
+
...(bcc && bcc.length > 0 ? { bcc } : {}),
|
|
137
|
+
subject,
|
|
138
|
+
bodyLength: body.length,
|
|
139
|
+
body,
|
|
140
|
+
});
|
|
141
|
+
if (opts.send) {
|
|
142
|
+
await opts.send(input as z.infer<typeof emailInputSchema>);
|
|
143
|
+
}
|
|
144
|
+
return { sent: true, recipient: to };
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chart-render HTTP endpoint for the Mastra plugin.
|
|
3
|
-
*
|
|
4
|
-
* The `render_data` tool returns immediately with a `chartId` and
|
|
5
|
-
* emits the dataset over `ctx.writer`; the client then POSTs that
|
|
6
|
-
* dataset to this endpoint to actually run the chart-planner
|
|
7
|
-
* agent and get back an Echarts `EChartsOption` JSON. Planning
|
|
8
|
-
* happens out-of-band so the calling agent's response stream
|
|
9
|
-
* doesn't sit idle waiting for it - the model can finish the
|
|
10
|
-
* report while the client is still rendering charts.
|
|
11
|
-
*
|
|
12
|
-
* Auth flows through the standard Mastra middleware: the route
|
|
13
|
-
* sits in the same dispatcher pipeline as `chatRoute` /
|
|
14
|
-
* `historyRoute`, so by the time the handler runs the
|
|
15
|
-
* `RequestContext` is populated with the workspace user and
|
|
16
|
-
* the chart-planner's model resolver has the OBO token it
|
|
17
|
-
* needs.
|
|
18
|
-
*/
|
|
19
|
-
import type { MastraPluginConfig } from "./config.js";
|
|
20
|
-
/** Options accepted by {@link renderChartRoute}. */
|
|
21
|
-
export interface RenderChartRouteOptions {
|
|
22
|
-
path: string;
|
|
23
|
-
config: MastraPluginConfig;
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Register a `POST <path>` Mastra custom API route that runs the
|
|
27
|
-
* chart-planner agent against a dataset and returns an Echarts
|
|
28
|
-
* `EChartsOption` JSON.
|
|
29
|
-
*
|
|
30
|
-
* Body shape: {@link RenderChartRequest}; response:
|
|
31
|
-
* {@link RenderChartResponse}.
|
|
32
|
-
*/
|
|
33
|
-
export declare function renderChartRoute(options: RenderChartRouteOptions): import("@mastra/core/server").ApiRoute;
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chart-render HTTP endpoint for the Mastra plugin.
|
|
3
|
-
*
|
|
4
|
-
* The `render_data` tool returns immediately with a `chartId` and
|
|
5
|
-
* emits the dataset over `ctx.writer`; the client then POSTs that
|
|
6
|
-
* dataset to this endpoint to actually run the chart-planner
|
|
7
|
-
* agent and get back an Echarts `EChartsOption` JSON. Planning
|
|
8
|
-
* happens out-of-band so the calling agent's response stream
|
|
9
|
-
* doesn't sit idle waiting for it - the model can finish the
|
|
10
|
-
* report while the client is still rendering charts.
|
|
11
|
-
*
|
|
12
|
-
* Auth flows through the standard Mastra middleware: the route
|
|
13
|
-
* sits in the same dispatcher pipeline as `chatRoute` /
|
|
14
|
-
* `historyRoute`, so by the time the handler runs the
|
|
15
|
-
* `RequestContext` is populated with the workspace user and
|
|
16
|
-
* the chart-planner's model resolver has the OBO token it
|
|
17
|
-
* needs.
|
|
18
|
-
*/
|
|
19
|
-
import { registerApiRoute } from "@mastra/core/server";
|
|
20
|
-
import { runChartPlanner } from "./chart.js";
|
|
21
|
-
/** Hard cap so a misbehaving client can't hand us a million-row payload. */
|
|
22
|
-
const MAX_ROWS = 5_000;
|
|
23
|
-
/**
|
|
24
|
-
* Hard cap on the JSON body the route accepts (in bytes). Mirrors
|
|
25
|
-
* the same intent as {@link MAX_ROWS}: bound the chart-planner's
|
|
26
|
-
* prompt size and protect against accidental denial-of-service
|
|
27
|
-
* from a runaway tool that ships an enormous payload.
|
|
28
|
-
*/
|
|
29
|
-
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
30
|
-
/**
|
|
31
|
-
* Register a `POST <path>` Mastra custom API route that runs the
|
|
32
|
-
* chart-planner agent against a dataset and returns an Echarts
|
|
33
|
-
* `EChartsOption` JSON.
|
|
34
|
-
*
|
|
35
|
-
* Body shape: {@link RenderChartRequest}; response:
|
|
36
|
-
* {@link RenderChartResponse}.
|
|
37
|
-
*/
|
|
38
|
-
export function renderChartRoute(options) {
|
|
39
|
-
const { path, config } = options;
|
|
40
|
-
return registerApiRoute(path, {
|
|
41
|
-
method: "POST",
|
|
42
|
-
handler: async (c) => {
|
|
43
|
-
const requestContext = c.get("requestContext");
|
|
44
|
-
// Hono parses the body as JSON; we still validate shape /
|
|
45
|
-
// size since the tool's structured output is a contract,
|
|
46
|
-
// not a guarantee, and the route is publicly mountable.
|
|
47
|
-
const raw = (await c.req.json().catch(() => null));
|
|
48
|
-
const validation = validateBody(raw);
|
|
49
|
-
if ("error" in validation) {
|
|
50
|
-
return c.json({ error: validation.error }, 400);
|
|
51
|
-
}
|
|
52
|
-
const { title, description, data } = validation.body;
|
|
53
|
-
try {
|
|
54
|
-
const result = await runChartPlanner({
|
|
55
|
-
config,
|
|
56
|
-
...(requestContext ? { requestContext } : {}),
|
|
57
|
-
title,
|
|
58
|
-
...(description ? { description } : {}),
|
|
59
|
-
data,
|
|
60
|
-
});
|
|
61
|
-
const payload = {
|
|
62
|
-
option: result.option,
|
|
63
|
-
chartType: result.chartType,
|
|
64
|
-
};
|
|
65
|
-
return c.json(payload);
|
|
66
|
-
}
|
|
67
|
-
catch (err) {
|
|
68
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
69
|
-
return c.json({ error: message }, 500);
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Best-effort body validation. Surfaces a 400 for malformed input
|
|
76
|
-
* instead of letting a downstream `.map` / `.length` blow up
|
|
77
|
-
* inside the planner agent. Field-level shape mirrors
|
|
78
|
-
* {@link RenderChartRequest}.
|
|
79
|
-
*/
|
|
80
|
-
function validateBody(raw) {
|
|
81
|
-
if (!raw || typeof raw !== "object") {
|
|
82
|
-
return { error: "request body must be a JSON object" };
|
|
83
|
-
}
|
|
84
|
-
const r = raw;
|
|
85
|
-
const title = r.title;
|
|
86
|
-
if (typeof title !== "string" || title.length === 0) {
|
|
87
|
-
return { error: "`title` must be a non-empty string" };
|
|
88
|
-
}
|
|
89
|
-
if (r.description !== undefined && typeof r.description !== "string") {
|
|
90
|
-
return { error: "`description` must be a string when provided" };
|
|
91
|
-
}
|
|
92
|
-
if (!Array.isArray(r.data)) {
|
|
93
|
-
return { error: "`data` must be an array of row objects" };
|
|
94
|
-
}
|
|
95
|
-
if (r.data.length === 0) {
|
|
96
|
-
return { error: "`data` must contain at least one row" };
|
|
97
|
-
}
|
|
98
|
-
if (r.data.length > MAX_ROWS) {
|
|
99
|
-
return { error: `\`data\` exceeds the per-request limit of ${MAX_ROWS} rows` };
|
|
100
|
-
}
|
|
101
|
-
for (const [i, row] of r.data.entries()) {
|
|
102
|
-
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
103
|
-
return { error: `data[${i}] must be a plain object` };
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
// Approximate body-size check; spares us pulling Buffer in.
|
|
107
|
-
const approximateBytes = JSON.stringify(r.data).length;
|
|
108
|
-
if (approximateBytes > MAX_BODY_BYTES) {
|
|
109
|
-
return {
|
|
110
|
-
error: `\`data\` exceeds the per-request size limit of ${MAX_BODY_BYTES} bytes`,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
return {
|
|
114
|
-
body: {
|
|
115
|
-
title,
|
|
116
|
-
...(typeof r.description === "string" ? { description: r.description } : {}),
|
|
117
|
-
data: r.data,
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|