fix(ai-builder): Preserve collected planning context (#29916)

This commit is contained in:
Albert Alises 2026-05-07 10:24:00 +02:00 committed by GitHub
parent 55df7cbd06
commit 5e3aa1a726
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 693 additions and 8 deletions

View File

@ -11,6 +11,8 @@ import type { InstanceAiContext } from '../types';
// ── Constants ──────────────────────────────────────────────────────────────
export const CREDENTIALS_TOOL_ID = 'credentials';
const DEFAULT_LIMIT = 50;
/** Generic auth types that should be excluded from search results — the AI should prefer dedicated types. */
@ -340,7 +342,7 @@ async function handleTest(context: InstanceAiContext, input: Extract<Input, { ac
export function createCredentialsTool(context: InstanceAiContext) {
return createTool({
id: 'credentials',
id: CREDENTIALS_TOOL_ID,
description:
'Manage credentials — list, get, delete, search available types, set up new credentials, and test connections.',
inputSchema,

View File

@ -12,6 +12,8 @@ import type { InstanceAiContext } from '../types';
// ── Shared schemas ─────────────────────────────────────────────────────────
export const DATA_TABLES_TOOL_ID = 'data-tables';
const columnTypeSchema = z.enum(['string', 'number', 'boolean', 'date']);
const filterSchema = z.object({
@ -597,7 +599,7 @@ export function createDataTablesTool(
const inputSchema = sanitizeInputSchema(z.discriminatedUnion('action', [...readOnlyActions]));
return createTool({
id: 'data-tables',
id: DATA_TABLES_TOOL_ID,
description: 'Manage data tables — list, get schema, and query rows.',
inputSchema,
execute: async (input: ReadOnlyInput) => {
@ -616,7 +618,7 @@ export function createDataTablesTool(
const inputSchema = sanitizeInputSchema(z.discriminatedUnion('action', [...allActions]));
return createTool({
id: 'data-tables',
id: DATA_TABLES_TOOL_ID,
description: 'Manage data tables — list, query, create, modify columns, and manage rows.',
inputSchema,
suspendSchema: confirmationSuspendSchema,

View File

@ -11,7 +11,13 @@ jest.mock('@mastra/core/tools', () => ({
import type { OrchestrationContext, PlannedTaskGraph, PlannedTaskService } from '../../../types';
const { __testClearPlannedTaskGraph, __testFormatMessagesForBriefing } =
const {
__testBuildPlannerBriefingContext,
__testClearPlannedTaskGraph,
__testFormatMessagesForBriefing,
__testGetRecentMessages,
__testGetPriorToolObservations,
} =
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
require('../plan-with-agent.tool') as typeof import('../plan-with-agent.tool');
@ -130,4 +136,263 @@ describe('formatMessagesForBriefing', () => {
expect(briefing).toMatch(/<current-datetime>[^<]+<\/current-datetime>/);
expect(briefing).not.toContain('<user-timezone>');
});
it('renders already-collected answers and discovered resources as dedicated sections', () => {
const briefing = __testFormatMessagesForBriefing(
[{ role: 'user', content: 'Build a Slack to-do agent' }],
undefined,
'America/New_York',
{
collectedAnswers: [
'How often should the agent run?: Every morning',
'Credential selected for slackApi: Slack account (slackApi)',
],
discoveredResources: ['Credentials available: Slack account (slackApi)'],
},
);
expect(briefing).toContain('## Already-collected answers');
expect(briefing).toContain('- How often should the agent run?: Every morning');
expect(briefing).toContain('- Credential selected for slackApi: Slack account (slackApi)');
expect(briefing).toContain('## Already-discovered resources');
expect(briefing).toContain('- Credentials available: Slack account (slackApi)');
});
});
describe('buildPlannerBriefingContext', () => {
it('extracts ask-user answers and credential selections from prior tool results', () => {
const context = __testBuildPlannerBriefingContext([
{
toolName: 'credentials',
args: { action: 'list' },
result: {
credentials: [
{ id: 'cred-slack', name: 'Slack account', type: 'slackApi' },
{ id: 'cred-anthropic', name: 'Anthropic account', type: 'anthropicApi' },
],
},
},
{
toolName: 'ask-user',
args: {
questions: [
{
id: 'schedule',
question: 'How often should the agent run?',
type: 'single',
},
],
},
result: {
answered: true,
answers: [
{
questionId: 'schedule',
selectedOptions: ['Every morning'],
},
],
},
},
{
toolName: 'credentials',
args: { action: 'setup' },
result: {
success: true,
credentials: { slackApi: 'cred-slack' },
},
},
]);
expect(context.collectedAnswers).toEqual([
'How often should the agent run?: Every morning',
'Credential selected for slackApi: Slack account (slackApi)',
]);
expect(context.discoveredResources).toEqual([
'Credentials available: Slack account (slackApi), Anthropic account (anthropicApi)',
]);
});
it('ignores unanswered and skipped ask-user answers', () => {
const context = __testBuildPlannerBriefingContext([
{
toolName: 'ask-user',
args: {
questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }],
},
result: {
answered: false,
answers: [
{
questionId: 'purpose',
customText: 'This should not be used',
},
],
},
},
{
toolName: 'ask-user',
args: {
questions: [
{ id: 'schedule', question: 'How often should it run?', type: 'single' },
{ id: 'model', question: 'Which model should it use?', type: 'single' },
],
},
result: {
answered: true,
answers: [
{
questionId: 'schedule',
selectedOptions: ['Every morning'],
skipped: true,
},
{
questionId: 'model',
selectedOptions: ['Anthropic'],
},
],
},
},
]);
expect(context.collectedAnswers).toEqual(['Which model should it use?: Anthropic']);
expect(context.discoveredResources).toEqual([]);
});
});
describe('getPriorToolObservations', () => {
it('reads tool results across the current message group when available', () => {
const askUserCall = {
questions: [{ id: 'purpose', question: 'What should this do?', type: 'text' }],
};
const askUserResult = {
answered: true,
answers: [
{ questionId: 'purpose', question: 'What should this do?', customText: 'Email me' },
],
};
const getEventsForRun = jest.fn().mockReturnValue([]);
const getEventsForRuns = jest.fn().mockReturnValue([
{
type: 'tool-call',
runId: 'run-prior',
agentId: 'orchestrator',
payload: {
toolCallId: 'tool-1',
toolName: 'ask-user',
args: askUserCall,
},
},
{
type: 'tool-result',
runId: 'run-prior',
agentId: 'orchestrator',
payload: {
toolCallId: 'tool-1',
result: askUserResult,
},
},
]);
const context = {
threadId: 'thread-1',
runId: 'run-current',
messageGroupId: 'message-group-1',
eventBus: {
getEventsAfter: jest.fn().mockReturnValue([
{
id: 1,
event: {
type: 'run-start',
runId: 'run-prior',
agentId: 'orchestrator',
payload: { messageId: 'message-1', messageGroupId: 'message-group-1' },
},
},
{
id: 2,
event: {
type: 'run-start',
runId: 'run-other',
agentId: 'orchestrator',
payload: { messageId: 'message-2', messageGroupId: 'message-group-2' },
},
},
]),
getEventsForRuns,
getEventsForRun,
},
} as unknown as OrchestrationContext;
const observations = __testGetPriorToolObservations(context);
expect(getEventsForRuns).toHaveBeenCalledWith('thread-1', ['run-prior', 'run-current']);
expect(getEventsForRun).not.toHaveBeenCalled();
expect(observations).toEqual([
{
toolName: 'ask-user',
args: askUserCall,
result: askUserResult,
},
]);
});
it('pairs out-of-order tool results with their later tool calls', () => {
const args = { action: 'list' };
const result = { credentials: [{ id: 'cred-1', name: 'Slack', type: 'slackApi' }] };
const context = {
threadId: 'thread-1',
runId: 'run-current',
eventBus: {
getEventsForRun: jest.fn().mockReturnValue([
{
type: 'tool-result',
runId: 'run-current',
agentId: 'orchestrator',
payload: { toolCallId: 'tool-1', result },
},
{
type: 'tool-call',
runId: 'run-current',
agentId: 'orchestrator',
payload: { toolCallId: 'tool-1', toolName: 'credentials', args },
},
]),
},
} as unknown as OrchestrationContext;
expect(__testGetPriorToolObservations(context)).toEqual([
{ toolName: 'credentials', args, result },
]);
});
it('returns no observations when event lookup fails', () => {
const context = {
threadId: 'thread-1',
runId: 'run-current',
eventBus: {
getEventsForRun: jest.fn(() => {
throw new Error('storage unavailable');
}),
},
} as unknown as OrchestrationContext;
expect(__testGetPriorToolObservations(context)).toEqual([]);
});
});
describe('getRecentMessages', () => {
it('does not append the current user message when memory already returned it', async () => {
const context = {
threadId: 't-1',
currentUserMessage: 'Build a Slack to-do agent',
memory: {
recall: jest.fn().mockResolvedValue({
messages: [{ role: 'user', content: 'Build a Slack to-do agent' }],
}),
},
} as unknown as OrchestrationContext;
const messages = await __testGetRecentMessages(context, 5);
expect(messages).toEqual([{ role: 'user', content: 'Build a Slack to-do agent' }]);
});
});

View File

@ -20,6 +20,7 @@ ${SUBAGENT_OUTPUT_CONTRACT}
- **Never ask about things you can discover** call \`credentials(action="list")\`, \`data-tables(action="list")\`, \`templates(action="best-practices")\` instead.
- **Never ask about implementation details** trigger types, node choices, schedule times, column names. Pick sensible defaults.
- **Never default resource identifiers** the user didn't mention (Slack channels, calendars, spreadsheets, folders, etc.) leave them for the builder to resolve at build time.
- **Trust already-collected briefing context** if the briefing includes an Already-collected answers or Already-discovered resources section, treat those entries as authoritative. Do not ask again for purpose, trigger, integrations, schedule, model, resource, or credential choices already listed there.
- **Do ask when the answer would significantly change the plan** e.g. the user's goal is ambiguous ("build me a CRM" for sales? support? recruiting?), or a business rule must come from the user ("what should happen when payment fails?").
- **Do ask when a required service has more than one credential of the same type** (e.g. two \`openAiApi\` accounts, three Google Calendar accounts) — which one to use cannot be discovered, only chosen. Record the chosen credential name in \`assumptions\`.
- **List your assumptions** on your first \`add-plan-item\` call. The user reviews the plan before execution and can reject/correct.

View File

@ -15,6 +15,7 @@
import { Agent } from '@mastra/core/agent';
import type { ToolsInput } from '@mastra/core/agent';
import { createTool } from '@mastra/core/tools';
import type { InstanceAiEvent } from '@n8n/api-types';
import { DateTime } from 'luxon';
import { nanoid } from 'nanoid';
import { z } from 'zod';
@ -37,6 +38,9 @@ import { createLlmStepTraceHooks } from '../../runtime/resumable-stream-executor
import { consumeStreamWithHitl } from '../../stream/consume-with-hitl';
import { getTraceParentRun, withTraceParentContext } from '../../tracing/langsmith-tracing';
import type { OrchestrationContext } from '../../types';
import { CREDENTIALS_TOOL_ID } from '../credentials.tool';
import { DATA_TABLES_TOOL_ID } from '../data-tables.tool';
import { ASK_USER_TOOL_ID } from '../shared/ask-user.tool';
import { createTemplatesTool } from '../templates.tool';
/** Number of recent thread messages to include as planner context. */
@ -48,15 +52,43 @@ const PLANNER_DOMAIN_TOOL_NAMES = ['nodes', 'credentials', 'data-tables', 'workf
/** Research tools added when available. */
const PLANNER_RESEARCH_TOOL_NAMES = ['research'];
const RELEVANT_PRIOR_TOOL_NAMES = new Set([
ASK_USER_TOOL_ID,
CREDENTIALS_TOOL_ID,
DATA_TABLES_TOOL_ID,
]);
// ---------------------------------------------------------------------------
// Message history retrieval
// ---------------------------------------------------------------------------
interface FormattedMessage {
role: string;
role: 'user' | 'assistant';
content: string;
}
interface PlannerBriefingContext {
collectedAnswers: string[];
discoveredResources: string[];
}
interface ToolObservation {
toolName: string;
args: Record<string, unknown>;
result: unknown;
}
interface CredentialBrief {
id?: string;
name: string;
type: string;
}
interface DataTableBrief {
id?: string;
name: string;
}
/** Extract plain text from Mastra memory content (string, array of parts, or {format, parts}). */
function extractTextFromMemoryContent(content: unknown): string {
if (typeof content === 'string') return content;
@ -93,6 +125,38 @@ function extractTextParts(parts: unknown[]): string {
.join('\n');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return isRecord(value) ? value : undefined;
}
function readArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function readStringArray(value: unknown): string[] {
return readArray(value).filter((item): item is string => typeof item === 'string');
}
function addUnique(target: string[], seen: Set<string>, value: string | undefined): void {
if (!value || seen.has(value)) return;
seen.add(value);
target.push(value);
}
function summarizeList(values: string[], limit = 10): string {
const visible = values.slice(0, limit).join(', ');
const remaining = values.length - limit;
return remaining > 0 ? `${visible}, and ${remaining} more` : visible;
}
async function getRecentMessages(
context: OrchestrationContext,
count: number,
@ -120,17 +184,343 @@ async function getRecentMessages(
}
// Always append the current in-flight user message (not yet saved to memory)
if (context.currentUserMessage) {
if (shouldAppendCurrentUserMessage(messages, context.currentUserMessage)) {
messages.push({ role: 'user', content: context.currentUserMessage });
}
return messages;
}
function shouldAppendCurrentUserMessage(
messages: FormattedMessage[],
currentUserMessage?: string,
): currentUserMessage is string {
const current = currentUserMessage?.trim();
if (!current) return false;
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user');
return lastUserMessage?.content.trim() !== current;
}
/**
* Reconstructs prior planner-relevant tool calls from the event stream.
*
* Tool-call and tool-result events are correlated by `toolCallId` so the
* planner can receive structured context that is not preserved in text-only
* memory recall, such as ask-user answers and credential selections.
*/
function getPriorToolObservations(context: OrchestrationContext): ToolObservation[] {
type MutableToolObservation = Omit<ToolObservation, 'result'> & {
result: unknown;
hasResult: boolean;
};
const toolCalls = new Map<string, MutableToolObservation>();
const pendingResults = new Map<string, unknown>();
for (const event of getPriorToolEvents(context)) {
if (event.type === 'tool-call') {
const { toolCallId, toolName, args } = event.payload;
if (!RELEVANT_PRIOR_TOOL_NAMES.has(toolName)) continue;
const pendingResult = pendingResults.get(toolCallId);
toolCalls.set(toolCallId, {
toolName,
args,
result: pendingResult,
hasResult: pendingResults.has(toolCallId),
});
continue;
}
if (event.type === 'tool-result') {
const { toolCallId, result } = event.payload;
const existing = toolCalls.get(toolCallId);
if (existing) {
existing.result = result;
existing.hasResult = true;
} else {
pendingResults.set(toolCallId, result);
}
}
}
return [...toolCalls.values()]
.filter((observation) => observation.hasResult)
.map(({ toolName, args, result }) => ({ toolName, args, result }));
}
/**
* Returns the events that may contain prior tool context for this planner run.
*
* When the run belongs to a message group, all runs in that group are searched
* so follow-up runs can see choices collected earlier in the same assistant
* turn. If grouped lookup is unavailable, this falls back to the current run.
*/
function getPriorToolEvents(context: OrchestrationContext): InstanceAiEvent[] {
if (context.messageGroupId) {
const runIds = getMessageGroupRunIds(context);
if (runIds.length > 0) {
try {
return context.eventBus.getEventsForRuns(context.threadId, runIds);
} catch {
// Fall back to the current run below.
}
}
}
try {
return context.eventBus.getEventsForRun(context.threadId, context.runId);
} catch {
return [];
}
}
/**
* Finds run IDs that belong to the current message group from run-start events.
*
* The event bus can fetch events for many run IDs, but the orchestration
* context only carries the current run ID and message group ID. This bridges
* those two concepts while keeping the current run as a defensive fallback.
*/
function getMessageGroupRunIds(context: OrchestrationContext): string[] {
const messageGroupId = context.messageGroupId;
if (!messageGroupId) return [];
const runIds = new Set<string>();
try {
for (const { event } of context.eventBus.getEventsAfter(context.threadId, 0)) {
if (event.type === 'run-start' && event.payload.messageGroupId === messageGroupId) {
runIds.add(event.runId);
}
}
} catch {
return [context.runId];
}
runIds.add(context.runId);
return [...runIds];
}
/**
* Converts raw prior tool observations into planner briefing sections.
*
* The resulting strings are intentionally short and human-readable because
* they are embedded directly into the planner prompt under dedicated headings.
*/
function buildPlannerBriefingContext(observations: ToolObservation[]): PlannerBriefingContext {
const collectedAnswers: string[] = [];
const discoveredResources: string[] = [];
const seenAnswers = new Set<string>();
const seenResources = new Set<string>();
const credentialsById = buildCredentialLookup(observations);
for (const observation of observations) {
if (observation.toolName === ASK_USER_TOOL_ID) {
for (const answer of extractAskUserAnswerLines(observation)) {
addUnique(collectedAnswers, seenAnswers, answer);
}
continue;
}
if (observation.toolName === CREDENTIALS_TOOL_ID) {
const action = readString(observation.args.action);
if (action === 'list') {
addUnique(discoveredResources, seenResources, summarizeCredentials(observation.result));
}
if (action === 'setup') {
for (const selection of extractCredentialSelectionLines(observation, credentialsById)) {
addUnique(collectedAnswers, seenAnswers, selection);
}
}
continue;
}
if (
observation.toolName === DATA_TABLES_TOOL_ID &&
readString(observation.args.action) === 'list'
) {
addUnique(discoveredResources, seenResources, summarizeDataTables(observation.result));
}
}
return { collectedAnswers, discoveredResources };
}
/**
* Builds an ID lookup from prior credential list results.
*
* Credential setup results contain selected IDs, so this lets the briefing
* render stable user-facing names and credential types when a prior list result
* is available.
*/
function buildCredentialLookup(observations: ToolObservation[]): Map<string, CredentialBrief> {
const credentialsById = new Map<string, CredentialBrief>();
for (const observation of observations) {
if (observation.toolName !== CREDENTIALS_TOOL_ID) continue;
for (const credential of extractCredentials(observation.result)) {
if (credential.id) credentialsById.set(credential.id, credential);
}
}
return credentialsById;
}
/**
* Extracts answered ask-user responses as `question: answer` briefing lines.
*
* Skipped or unanswered prompts are ignored, and question text is recovered
* from tool args when the tool result only includes a question ID.
*/
function extractAskUserAnswerLines(observation: ToolObservation): string[] {
const result = readRecord(observation.result);
if (!result || result.answered === false) return [];
const questionsById = extractQuestionTextById(observation.args);
const answers = readArray(result.answers);
const lines: string[] = [];
for (const answerValue of answers) {
const answer = readRecord(answerValue);
if (!answer || answer.skipped === true) continue;
const questionId = readString(answer.questionId);
const question =
readString(answer.question) ?? (questionId ? questionsById.get(questionId) : undefined);
const selectedOptions = readStringArray(answer.selectedOptions);
const customText = readString(answer.customText);
const values = [...selectedOptions, ...(customText ? [customText] : [])];
if (!question || values.length === 0) continue;
lines.push(`${question}: ${values.join(', ')}`);
}
return lines;
}
/**
* Maps ask-user question IDs to display text from the original tool args.
*/
function extractQuestionTextById(args: Record<string, unknown>): Map<string, string> {
const questionsById = new Map<string, string>();
for (const questionValue of readArray(args.questions)) {
const question = readRecord(questionValue);
const id = readString(question?.id);
const text = readString(question?.question);
if (id && text) questionsById.set(id, text);
}
return questionsById;
}
/**
* Renders credential setup selections as briefing lines.
*
* The setup tool returns a `{ credentialType: credentialId }` map. The optional
* credential lookup turns those IDs back into names so the planner can avoid
* asking the user to choose the same credential again.
*/
function extractCredentialSelectionLines(
observation: ToolObservation,
credentialsById: Map<string, CredentialBrief>,
): string[] {
const result = readRecord(observation.result);
const credentials = readRecord(result?.credentials);
if (!credentials) return [];
const lines: string[] = [];
for (const [credentialType, credentialIdValue] of Object.entries(credentials)) {
const credentialId = readString(credentialIdValue);
if (!credentialId) continue;
const credential = credentialsById.get(credentialId);
const label = credential
? `${credential.name} (${credential.type})`
: `credential ID ${credentialId}`;
lines.push(`Credential selected for ${credentialType}: ${label}`);
}
return lines;
}
/**
* Summarizes a credentials list result for the briefing.
*/
function summarizeCredentials(result: unknown): string | undefined {
const credentials = extractCredentials(result);
if (credentials.length === 0) return undefined;
return `Credentials available: ${summarizeList(
credentials.map((credential) => `${credential.name} (${credential.type})`),
)}`;
}
/**
* Reads the minimal credential metadata needed by the planner briefing.
*/
function extractCredentials(result: unknown): CredentialBrief[] {
const record = readRecord(result);
return readArray(record?.credentials)
.map(readCredentialBrief)
.filter((credential): credential is CredentialBrief => credential !== undefined);
}
function readCredentialBrief(value: unknown): CredentialBrief | undefined {
const record = readRecord(value);
const name = readString(record?.name);
const type = readString(record?.type);
if (!name || !type) return undefined;
const id = readString(record?.id);
return {
name,
type,
...(id ? { id } : {}),
};
}
/**
* Summarizes a data-tables list result for the briefing.
*/
function summarizeDataTables(result: unknown): string | undefined {
const tables = extractDataTables(result);
if (tables.length === 0) return undefined;
return `Data tables available: ${summarizeList(tables.map((table) => table.name))}`;
}
/**
* Reads the minimal data-table metadata needed by the planner briefing.
*/
function extractDataTables(result: unknown): DataTableBrief[] {
const record = readRecord(result);
return readArray(record?.tables)
.map(readDataTableBrief)
.filter((table): table is DataTableBrief => table !== undefined);
}
function readDataTableBrief(value: unknown): DataTableBrief | undefined {
const record = readRecord(value);
const name = readString(record?.name);
if (!name) return undefined;
const id = readString(record?.id);
return {
name,
...(id ? { id } : {}),
};
}
/**
* Formats conversation, time, and already-collected context into the planner goal.
*/
function formatMessagesForBriefing(
messages: FormattedMessage[],
guidance?: string,
timeZone?: string,
briefingContext?: PlannerBriefingContext,
): string {
const parts: string[] = [];
@ -151,6 +541,20 @@ function formatMessagesForBriefing(
}
}
if (briefingContext?.collectedAnswers.length) {
parts.push('## Already-collected answers');
for (const answer of briefingContext.collectedAnswers) {
parts.push(`- ${answer}`);
}
}
if (briefingContext?.discoveredResources.length) {
parts.push('## Already-discovered resources');
for (const resource of briefingContext.discoveredResources) {
parts.push(`- ${resource}`);
}
}
if (guidance) {
parts.push(`\n## Orchestrator guidance\n${guidance}`);
}
@ -161,6 +565,9 @@ function formatMessagesForBriefing(
}
export const __testFormatMessagesForBriefing = formatMessagesForBriefing;
export const __testGetRecentMessages = getRecentMessages;
export const __testGetPriorToolObservations = getPriorToolObservations;
export const __testBuildPlannerBriefingContext = buildPlannerBriefingContext;
// ---------------------------------------------------------------------------
// Helper: clear draft checklist from taskStorage
@ -268,7 +675,13 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
// ── Retrieve conversation history ─────────────────────────────
const messages = await getRecentMessages(context, MESSAGE_HISTORY_COUNT);
const briefing = formatMessagesForBriefing(messages, input.guidance, context.timeZone);
const briefingContext = buildPlannerBriefingContext(getPriorToolObservations(context));
const briefing = formatMessagesForBriefing(
messages,
input.guidance,
context.timeZone,
briefingContext,
);
// ── IDs & events ──────────────────────────────────────────────
const subAgentId = `agent-planner-${nanoid(6)}`;

View File

@ -2,6 +2,8 @@ import { createTool } from '@mastra/core/tools';
import { nanoid } from 'nanoid';
import { z } from 'zod';
export const ASK_USER_TOOL_ID = 'ask-user';
const questionSchema = z.object({
id: z.string().describe('Unique question identifier'),
question: z.string().describe('The question text to display to the user'),
@ -36,7 +38,7 @@ export const askUserResumeSchema = z.object({
export function createAskUserTool() {
return createTool({
id: 'ask-user',
id: ASK_USER_TOOL_ID,
description:
'Ask the user one or more structured questions. Each question can be ' +
'single-select (pick one), multi-select (pick many), or free-text. ' +