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. ' +