@dbx-tools/appkit-mastra 0.9.140 → 0.9.143

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.
@@ -0,0 +1,392 @@
1
+ /**
2
+ * ChatFast post-turn follow-up suggestion generation.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { asyncUtils, errorUtils, log, stringUtils } from "@dbx-tools/shared-core";
8
+ import {
9
+ FOLLOW_UP_SUGGESTIONS_PART_TYPE,
10
+ type FollowUpSuggestionsPartData,
11
+ FollowUpSuggestionsPartDataSchema,
12
+ } from "@dbx-tools/shared-mastra/wire";
13
+ import { model } from "@dbx-tools/shared-model";
14
+ import { Agent } from "@mastra/core/agent";
15
+ import type { MessageList } from "@mastra/core/agent/message-list";
16
+ import type { MastraModelConfig } from "@mastra/core/llm";
17
+ import type { OutputProcessor } from "@mastra/core/processors";
18
+ import type { RequestContext } from "@mastra/core/request-context";
19
+ import type { DataChunkType, LLMStepResult, MastraStreamTransform } from "@mastra/core/stream";
20
+ import { createTool } from "@mastra/core/tools";
21
+ import { z } from "zod";
22
+
23
+ import type { FollowUpSuggestionsConfig, MastraPluginConfig } from "./config.ts";
24
+ import { buildModel } from "./model.ts";
25
+ import { TYPOGRAPHY_RULE } from "./style.ts";
26
+
27
+ const logger = log.logger("mastra/follow-up-suggestions");
28
+ const FOLLOW_UP_MODEL_CLASS = model.ModelClass.ChatFast;
29
+ const DEFAULT_QUESTION_LIMIT = 3;
30
+ const MAX_QUESTION_LIMIT = 10;
31
+ const DEFAULT_TIMEOUT_MS = 10_000;
32
+ const MAX_TURN_CONTEXT_CHARS = 16_000;
33
+ const GENERIC_QUESTIONS = new Set([
34
+ "anything else",
35
+ "can you tell me more",
36
+ "tell me more",
37
+ "what else",
38
+ "what would you like to know",
39
+ ]);
40
+ const ASSISTANT_DIRECTED_QUESTIONS = [
41
+ /^(?:would|do) you (?:like|want|prefer)\b/,
42
+ /^(?:should|shall|can|may) (?:i|we)\b/,
43
+ /^are you interested\b/,
44
+ /^should (?:the |this )?(?:chart|graph|table|visualization|response|output|results?|data)\b/,
45
+ ] as const;
46
+
47
+ /** Trusted request-context key carrying the current user message. */
48
+ export const FOLLOW_UP_USER_TEXT_KEY = "mastra__follow_up_user_text";
49
+ /** Trusted request-context key carrying native Genie follow-up questions. */
50
+ export const FOLLOW_UP_NATIVE_QUESTIONS_KEY = "mastra__follow_up_native_questions";
51
+ /** Trusted request-context key coordinating one post-turn stream with its processor. */
52
+ export const FOLLOW_UP_RUN_ID_KEY = "mastra__follow_up_run_id";
53
+
54
+ /** Fully resolved follow-up suggestion settings. */
55
+ export interface ResolvedFollowUpSuggestionsConfig {
56
+ mode: "auto" | "generated" | "genie";
57
+ limit: number;
58
+ timeoutMs: number;
59
+ exposeTool: boolean;
60
+ }
61
+
62
+ /** Inputs for one follow-up generation pass. */
63
+ export interface SuggestFollowUpsOptions {
64
+ userText: string;
65
+ answer: string;
66
+ /** Bounded serialized tool inputs and outputs that ground the next questions. */
67
+ turnContext?: string;
68
+ nativeQuestions?: readonly string[];
69
+ requestContext?: RequestContext;
70
+ abortSignal?: AbortSignal;
71
+ }
72
+
73
+ /** Replaceable suggestion boundary used by the finish callback and tests. */
74
+ export type FollowUpSuggester = (
75
+ config: MastraPluginConfig,
76
+ options: SuggestFollowUpsOptions,
77
+ ) => Promise<string[]>;
78
+
79
+ const FollowUpOutputSchema = z
80
+ .object({
81
+ questions: z
82
+ .array(z.string().trim().min(1))
83
+ .max(MAX_QUESTION_LIMIT)
84
+ .describe("Specific next questions grounded in the completed turn."),
85
+ })
86
+ .strict();
87
+
88
+ const FollowUpToolInputSchema = z
89
+ .object({
90
+ userText: z.string().trim().min(1).describe("User message that started the completed turn."),
91
+ answer: z.string().trim().min(1).describe("Assistant answer from the completed turn."),
92
+ turnContext: z
93
+ .string()
94
+ .trim()
95
+ .max(MAX_TURN_CONTEXT_CHARS)
96
+ .optional()
97
+ .describe("Relevant tool inputs and outputs from the completed turn."),
98
+ nativeQuestions: z
99
+ .array(z.string().trim().min(1))
100
+ .optional()
101
+ .describe("Existing follow-up questions to avoid repeating."),
102
+ })
103
+ .strict();
104
+
105
+ const FOLLOW_UP_INSTRUCTIONS = [
106
+ "Generate concise questions that a user can send to dig further into a completed assistant turn.",
107
+ "Write every item in the user's voice as a message for the assistant to answer when clicked.",
108
+ "Never address the user or ask what they would like, want, prefer, or how the response should be customized.",
109
+ "Every question must be answerable from the same tools and data domain as the completed turn.",
110
+ "Ground questions in the answer and turn tool data, including displayed chart or table values.",
111
+ "Prefer concrete analyses, comparisons, rankings, calculations, filters, or explanations of the result.",
112
+ "For a chart or table, ask about the values and patterns before suggesting presentation changes.",
113
+ "Good examples include 'Which category has the highest value?', 'How large is the gap between the highest and lowest categories?', and 'What pattern stands out in these values?'.",
114
+ "Do not repeat the user's question, the answer, or an existing suggestion.",
115
+ "Do not ask generic filler such as 'Anything else?' or request unsupported data.",
116
+ "Return zero questions when no useful grounded follow-up exists.",
117
+ TYPOGRAPHY_RULE,
118
+ ].join(" ");
119
+
120
+ const suggestionAgents = new WeakMap<MastraPluginConfig, Agent>();
121
+ const generatedRunParts = new Map<string, FollowUpSuggestionsPartData>();
122
+ const GENERATED_RUN_PART_TTL_MS = 5 * 60_000;
123
+
124
+ /** Resolve disabled, shorthand, and configured suggestion policy forms. */
125
+ export function resolveFollowUpSuggestionsConfig(
126
+ value: MastraPluginConfig["followUpSuggestions"],
127
+ ): ResolvedFollowUpSuggestionsConfig | undefined {
128
+ if (!value) return undefined;
129
+ const configured: FollowUpSuggestionsConfig = typeof value === "object" ? value : { mode: value };
130
+ return {
131
+ mode: configured.mode === true ? "auto" : configured.mode,
132
+ limit: Math.min(MAX_QUESTION_LIMIT, Math.max(1, configured.limit ?? DEFAULT_QUESTION_LIMIT)),
133
+ timeoutMs: Math.min(30_000, Math.max(1_000, configured.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
134
+ exposeTool: configured.exposeTool ?? false,
135
+ };
136
+ }
137
+
138
+ function followUpModel(
139
+ config: MastraPluginConfig,
140
+ ): (args: { requestContext: RequestContext }) => Promise<MastraModelConfig> {
141
+ return ({ requestContext }) =>
142
+ buildModel(config, requestContext, { modelClass: FOLLOW_UP_MODEL_CLASS });
143
+ }
144
+
145
+ function suggestionAgent(config: MastraPluginConfig): Agent {
146
+ let agent = suggestionAgents.get(config);
147
+ if (!agent) {
148
+ agent = new Agent({
149
+ id: "follow-up-suggestions",
150
+ name: "Follow-up suggestions",
151
+ description: "Generates grounded next questions using the fast chat model.",
152
+ instructions: FOLLOW_UP_INSTRUCTIONS,
153
+ model: followUpModel(config),
154
+ });
155
+ suggestionAgents.set(config, agent);
156
+ }
157
+ return agent;
158
+ }
159
+
160
+ function normalizedQuestion(value: string): string {
161
+ return value
162
+ .trim()
163
+ .toLowerCase()
164
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
165
+ .replace(/\s+/g, " ");
166
+ }
167
+
168
+ function acceptedQuestions(
169
+ questions: readonly string[],
170
+ options: SuggestFollowUpsOptions,
171
+ maxQuestions: number,
172
+ ): string[] {
173
+ const excluded = new Set([
174
+ normalizedQuestion(options.userText),
175
+ ...(options.nativeQuestions ?? []).map(normalizedQuestion),
176
+ ]);
177
+ const accepted: string[] = [];
178
+ for (const question of questions) {
179
+ const trimmed = question.trim();
180
+ const normalized = normalizedQuestion(trimmed);
181
+ if (
182
+ !normalized ||
183
+ normalized.length < 8 ||
184
+ excluded.has(normalized) ||
185
+ GENERIC_QUESTIONS.has(normalized) ||
186
+ ASSISTANT_DIRECTED_QUESTIONS.some((pattern) => pattern.test(normalized))
187
+ ) {
188
+ continue;
189
+ }
190
+ excluded.add(normalized);
191
+ accepted.push(trimmed);
192
+ if (accepted.length >= maxQuestions) break;
193
+ }
194
+ return accepted;
195
+ }
196
+
197
+ function serializedTurnContext(steps: readonly LLMStepResult[]): string | undefined {
198
+ const activity = steps.flatMap((step) => [
199
+ ...step.toolCalls.map((call) => ({
200
+ tool: call.payload.toolName,
201
+ input: call.payload.args,
202
+ })),
203
+ ...step.toolResults.map((result) => ({
204
+ tool: result.payload.toolName,
205
+ output: result.payload.result,
206
+ ...(result.payload.isError ? { isError: true } : {}),
207
+ })),
208
+ ]);
209
+ if (activity.length === 0) return undefined;
210
+ try {
211
+ const serialized = JSON.stringify(activity.slice(-12));
212
+ return serialized.length > MAX_TURN_CONTEXT_CHARS
213
+ ? serialized.slice(0, MAX_TURN_CONTEXT_CHARS)
214
+ : serialized;
215
+ } catch {
216
+ return undefined;
217
+ }
218
+ }
219
+
220
+ /** Generate grounded follow-ups through ChatFast, failing open to an empty list. */
221
+ export async function suggestFollowUps(
222
+ config: MastraPluginConfig,
223
+ options: SuggestFollowUpsOptions,
224
+ ): Promise<string[]> {
225
+ const settings = resolveFollowUpSuggestionsConfig(config.followUpSuggestions);
226
+ if (!settings || settings.mode === "genie") return [];
227
+ const timeout = AbortSignal.timeout(settings.timeoutMs);
228
+ const abortSignal = asyncUtils.combineAbortSignals(options.abortSignal, timeout);
229
+ const prompt = stringUtils.toDescription({
230
+ "User message": options.userText,
231
+ "Assistant answer": options.answer,
232
+ ...(options.turnContext ? { "Turn tool data": options.turnContext } : {}),
233
+ ...(options.nativeQuestions?.length
234
+ ? { "Existing suggestions": JSON.stringify(options.nativeQuestions) }
235
+ : {}),
236
+ "Maximum questions": String(settings.limit),
237
+ });
238
+ try {
239
+ const result = await suggestionAgent(config).generate(prompt, {
240
+ structuredOutput: { schema: FollowUpOutputSchema },
241
+ ...(options.requestContext ? { requestContext: options.requestContext } : {}),
242
+ abortSignal,
243
+ });
244
+ const parsed = FollowUpOutputSchema.parse(result.object);
245
+ return acceptedQuestions(parsed.questions, options, settings.limit);
246
+ } catch (error) {
247
+ if (!options.abortSignal?.aborted) {
248
+ logger.warn("generation-failed", { error: errorUtils.errorMessage(error) });
249
+ }
250
+ return [];
251
+ }
252
+ }
253
+
254
+ function lastAssistantResponse(messageList: MessageList) {
255
+ const messages = messageList.get.response.db();
256
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
257
+ const message = messages[index];
258
+ if (message?.role === "assistant") return message;
259
+ }
260
+ return undefined;
261
+ }
262
+
263
+ function persistedFollowUpData(messageList: MessageList): FollowUpSuggestionsPartData | undefined {
264
+ const message = lastAssistantResponse(messageList);
265
+ if (!message) return undefined;
266
+ for (const part of message.content.parts) {
267
+ if (part.type !== FOLLOW_UP_SUGGESTIONS_PART_TYPE || !("data" in part)) continue;
268
+ const parsed = FollowUpSuggestionsPartDataSchema.safeParse(part.data);
269
+ if (parsed.success) return parsed.data;
270
+ }
271
+ return undefined;
272
+ }
273
+
274
+ function persistFollowUpData(messageList: MessageList, data: FollowUpSuggestionsPartData): void {
275
+ const message = lastAssistantResponse(messageList);
276
+ if (!message) return;
277
+ messageList.add(
278
+ {
279
+ id: message.id,
280
+ role: "assistant",
281
+ parts: [{ type: FOLLOW_UP_SUGGESTIONS_PART_TYPE, data }],
282
+ },
283
+ "response",
284
+ { merge: true },
285
+ );
286
+ }
287
+
288
+ function rememberGeneratedRunPart(runId: string | undefined, data: FollowUpSuggestionsPartData) {
289
+ if (!runId) return;
290
+ generatedRunParts.set(runId, data);
291
+ const timer = setTimeout(() => generatedRunParts.delete(runId), GENERATED_RUN_PART_TTL_MS);
292
+ timer.unref();
293
+ }
294
+
295
+ /** Build Mastra's final output processor for configured generated follow-ups. */
296
+ export function buildFollowUpSuggestionsProcessor(
297
+ config: MastraPluginConfig,
298
+ suggester: FollowUpSuggester = suggestFollowUps,
299
+ ): OutputProcessor | undefined {
300
+ const settings = resolveFollowUpSuggestionsConfig(config.followUpSuggestions);
301
+ if (!settings || settings.mode === "genie") return undefined;
302
+ return {
303
+ id: "follow-up-suggestions",
304
+ async processOutputResult({
305
+ result,
306
+ messageList,
307
+ requestContext,
308
+ abortSignal,
309
+ }): Promise<MessageList> {
310
+ if (!requestContext || result.finishReason === "abort" || result.finishReason === "error") {
311
+ return messageList;
312
+ }
313
+ const runIdValue = requestContext.getRaw(FOLLOW_UP_RUN_ID_KEY);
314
+ const runId = typeof runIdValue === "string" ? runIdValue : undefined;
315
+ const persisted = persistedFollowUpData(messageList);
316
+ if (persisted) {
317
+ rememberGeneratedRunPart(runId, persisted);
318
+ return messageList;
319
+ }
320
+ const answer = result.text.trim();
321
+ const userText = requestContext.getRaw(FOLLOW_UP_USER_TEXT_KEY);
322
+ if (!answer || typeof userText !== "string") return messageList;
323
+ const nativeValue = requestContext.getRaw(FOLLOW_UP_NATIVE_QUESTIONS_KEY);
324
+ const nativeQuestions =
325
+ settings.mode === "auto" && Array.isArray(nativeValue)
326
+ ? nativeValue.filter((value): value is string => typeof value === "string")
327
+ : [];
328
+ const turnContext = serializedTurnContext(result.steps);
329
+ const suggestionOptions: SuggestFollowUpsOptions = {
330
+ userText,
331
+ answer,
332
+ ...(turnContext ? { turnContext } : {}),
333
+ ...(nativeQuestions.length > 0 ? { nativeQuestions } : {}),
334
+ requestContext,
335
+ ...(abortSignal ? { abortSignal } : {}),
336
+ };
337
+ const suggested = await suggester(config, suggestionOptions);
338
+ const questions = acceptedQuestions(suggested, suggestionOptions, settings.limit);
339
+ if (questions.length === 0) return messageList;
340
+ const data = FollowUpSuggestionsPartDataSchema.parse({
341
+ questions,
342
+ source: "model",
343
+ });
344
+ persistFollowUpData(messageList, data);
345
+ rememberGeneratedRunPart(runId, data);
346
+ return messageList;
347
+ },
348
+ };
349
+ }
350
+
351
+ /** Inject the processor's persisted data part before Mastra's finish chunk. */
352
+ export function buildFollowUpSuggestionsTransform(runId: string): MastraStreamTransform {
353
+ return () =>
354
+ new TransformStream({
355
+ transform(chunk, controller) {
356
+ if (chunk.type === "finish") {
357
+ const data = generatedRunParts.get(runId);
358
+ if (data) {
359
+ const part: DataChunkType = {
360
+ type: FOLLOW_UP_SUGGESTIONS_PART_TYPE,
361
+ data,
362
+ };
363
+ controller.enqueue(part as never);
364
+ }
365
+ }
366
+ controller.enqueue(chunk);
367
+ },
368
+ });
369
+ }
370
+
371
+ /** Build the optional explicitly callable follow-up suggestion tool. */
372
+ export function buildSuggestFollowUpsTool(config: MastraPluginConfig) {
373
+ return createTool({
374
+ id: "suggest_follow_ups",
375
+ description:
376
+ "Generate a short list of grounded follow-up questions for a completed user message and answer.",
377
+ inputSchema: FollowUpToolInputSchema,
378
+ outputSchema: FollowUpOutputSchema,
379
+ execute: async (input, context) => {
380
+ const values = FollowUpToolInputSchema.parse(input);
381
+ const questions = await suggestFollowUps(config, {
382
+ userText: values.userText,
383
+ answer: values.answer,
384
+ ...(values.turnContext ? { turnContext: values.turnContext } : {}),
385
+ ...(values.nativeQuestions ? { nativeQuestions: values.nativeQuestions } : {}),
386
+ ...(context?.requestContext ? { requestContext: context.requestContext } : {}),
387
+ ...(context?.abortSignal ? { abortSignal: context.abortSignal } : {}),
388
+ });
389
+ return { questions };
390
+ },
391
+ });
392
+ }
package/src/genie.ts CHANGED
@@ -59,6 +59,7 @@ import type { MastraTools } from "./agents.ts";
59
59
  import { chartPlannerRequestSchema, chartToolOutputSchema, prepareChart } from "./chart.ts";
60
60
  import { MASTRA_USER_KEY, resolveUserKey } from "./config.ts";
61
61
  import type { MastraPluginConfig, User } from "./config.ts";
62
+ import { FOLLOW_UP_NATIVE_QUESTIONS_KEY } from "./follow-up-suggestions.ts";
62
63
  import { fetchStatementData } from "./statement.ts";
63
64
  import { recordActiveTraceAuth, stampGenieToolSpan } from "./telemetry.ts";
64
65
  import { safeWriteProgress } from "./writer.ts";
@@ -491,6 +492,15 @@ function buildAskGenieTool(opts: {
491
492
  ...(signal ? { context: signal } : {}),
492
493
  })) {
493
494
  if (event.type !== "message") {
495
+ if (event.type === "suggested_questions") {
496
+ const current = requestContext.getRaw(FOLLOW_UP_NATIVE_QUESTIONS_KEY);
497
+ const questions = Array.isArray(current)
498
+ ? current.filter((value): value is string => typeof value === "string")
499
+ : [];
500
+ requestContext.setRaw(FOLLOW_UP_NATIVE_QUESTIONS_KEY, [
501
+ ...new Set([...questions, ...event.questions]),
502
+ ]);
503
+ }
494
504
  await safeWriteProgress(logger, writer, toolCallId, event);
495
505
  }
496
506
  const eventConversationId = event.conversation_id;
package/src/plugin.ts CHANGED
@@ -103,6 +103,7 @@ import {
103
103
  statementDataDefaults,
104
104
  } from "./defaults.ts";
105
105
  import { resolveFeedbackMode } from "./feedback-mode.ts";
106
+ import { resolveFollowUpSuggestionsConfig } from "./follow-up-suggestions.ts";
106
107
  import {
107
108
  deleteGenieThread,
108
109
  deleteGenieThreadMessage,
@@ -471,12 +472,15 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
471
472
  // Return widens to `Record<string, unknown>` to satisfy the
472
473
  // base-class signature; consumers read it through the typed
473
474
  // `MastraClientConfig` shape via `usePluginClientConfig<...>(...)`.
475
+ const followUps = resolveFollowUpSuggestionsConfig(this.config.followUpSuggestions);
474
476
  const config: MastraClientConfig = {
475
477
  basePath: this.basePath,
476
478
  defaultAgent: this.built?.defaultAgentId ?? FALLBACK_AGENT_ID,
477
479
  agents: Object.keys(this.built?.agents ?? {}),
478
480
  feedbackMode: this.feedbackMode(),
479
481
  backgroundTurns: this.config.backgroundTurns !== false,
482
+ followUpSuggestions: followUps?.mode ?? "none",
483
+ followUpSuggestionLimit: followUps?.limit ?? 3,
480
484
  // In `service-principal` mode the chat works for any caller who can open
481
485
  // the app, so a UI that hides chat behind an OBO-token probe should show
482
486
  // it regardless. `"user"` mode keeps the OBO-gated behavior.
@@ -1436,7 +1440,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
1436
1440
  mastra: this.mastra,
1437
1441
  prefix: "",
1438
1442
  customApiRoutes: [
1439
- ...agentChatRoutes(),
1443
+ ...agentChatRoutes(this.config),
1440
1444
  workflowRoute({
1441
1445
  path: "/route/workflows/:workflowId/stream",
1442
1446
  version: "v7",
package/src/server.ts CHANGED
@@ -29,6 +29,11 @@ import {
29
29
  type MastraPluginConfig,
30
30
  type User,
31
31
  } from "./config.ts";
32
+ import {
33
+ FOLLOW_UP_NATIVE_QUESTIONS_KEY,
34
+ FOLLOW_UP_RUN_ID_KEY,
35
+ FOLLOW_UP_USER_TEXT_KEY,
36
+ } from "./follow-up-suggestions.ts";
32
37
  import { requestUserEmail, requestUserId } from "./identity.ts";
33
38
  import { MASTRA_ORIGIN_KEY } from "./request-context.ts";
34
39
  import {
@@ -58,6 +63,9 @@ const TRUSTED_REQUEST_CONTEXT_KEYS = [
58
63
  MASTRA_SCOPES_KEY,
59
64
  MASTRA_MODEL_OVERRIDE_KEY,
60
65
  MASTRA_RESOLVED_MODEL_KEY,
66
+ FOLLOW_UP_USER_TEXT_KEY,
67
+ FOLLOW_UP_NATIVE_QUESTIONS_KEY,
68
+ FOLLOW_UP_RUN_ID_KEY,
61
69
  ] as const;
62
70
 
63
71
  /** Remove browser-supplied values for fields owned by trusted server middleware. */