diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index bee126faa5a..38e3eb4e863 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -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 { @@ -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, diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts index cb0dbbe3240..4200bdca7b9 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan-with-agent.tool.test.ts @@ -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>/); expect(briefing).not.toContain(''); }); + + 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' }]); + }); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts index be0cb5f1626..0bb2f2d9a74 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan-agent-prompt.ts @@ -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. diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts index 0f22807e57a..00ee06f92a6 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan-with-agent.tool.ts @@ -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; + 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 { + 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 | 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, 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 & { + result: unknown; + hasResult: boolean; + }; + + const toolCalls = new Map(); + const pendingResults = new Map(); + + 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(); + 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(); + const seenResources = new Set(); + 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 { + const credentialsById = new Map(); + + 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): Map { + const questionsById = new Map(); + + 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[] { + 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)}`; diff --git a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts index 0d21ce854a1..8f9553c34e8 100644 --- a/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/shared/ask-user.tool.ts @@ -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. ' +