diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts index 2776a036231..d3a65705129 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts @@ -66,6 +66,7 @@ vi.mock('../../tools', () => ({ ['workflows', mockBuiltTool(`workflows-${context.runLabel ?? 'unknown'}`)], ['evals', mockBuiltTool(`evals-${context.runLabel ?? 'unknown'}`)], ['research', mockBuiltTool(`research-${context.runLabel ?? 'unknown'}`)], + ['n8n-docs', mockBuiltTool(`n8n-docs-${context.runLabel ?? 'unknown'}`)], ['nodes', mockBuiltTool(`nodes-${context.runLabel ?? 'unknown'}`)], ['executions', mockBuiltTool(`executions-${context.runLabel ?? 'unknown'}`)], ['build-workflow', mockBuiltTool(`build-workflow-${context.runLabel ?? 'unknown'}`)], @@ -290,6 +291,39 @@ describe('createInstanceAgent', () => { } }); + // INS-749: `research` (web-search) is always loaded while `n8n-docs` used to be + // deferred, so answering an n8n question from n8n's own docs cost a search_tools + + // load_tool round trip that web search did not. That price gap pushed the agent to + // web-search things the docs already answer. + it('keeps n8n-docs always loaded so it is no costlier to reach than web search', async () => { + await createInstanceAgent({ + modelId: 'test-model', + context: { + runLabel: 'docs-parity-run', + localGatewayStatus: undefined, + licenseHints: undefined, + localMcpServer: undefined, + }, + orchestrationContext: { + runId: 'docs-parity-run', + }, + memoryConfig: {}, + mcpManager: createMcpManagerStub(), + } as never); + + const attachedTools = getAttachedTools(); + const deferredTools = getDeferredTools(); + + expect(attachedTools['n8n-docs-docs-parity-run']).toMatchObject({ + name: 'n8n-docs-docs-parity-run', + }); + expect(deferredTools['n8n-docs-docs-parity-run']).toBeUndefined(); + // Parity is the point: both routes must be one call away. + expect(attachedTools['research-docs-parity-run']).toMatchObject({ + name: 'research-docs-parity-run', + }); + }); + it('attaches the orchestration workspace when provided', async () => { const memoryConfig = {} as never; const fakeWorkspace = { id: 'thread-runtime-workspace' } as never; diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.discovery.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.discovery.test.ts index 029d9bd502a..262b08b60e3 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.discovery.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.discovery.test.ts @@ -167,6 +167,29 @@ describe('getSystemPrompt — browser/computer-use discoverability', () => { }); }); + // INS-749: n8n-docs is always loaded now, so telling the orchestrator to + // discover it via search_tools is both wrong and a nudge away from the tool + // it should reach for first on n8n questions. + describe('n8n-docs is presented as already available, not as something to discover', () => { + // The Tool Discovery section only renders with tool search on, which is where + // the stale "search for n8n docs" example lived. + const toolSearchOptions = { ...browserCapableOptions, toolSearchEnabled: true }; + + it('does not offer n8n docs as a search_tools discovery example', () => { + const prompt = getSystemPrompt(toolSearchOptions); + + expect(prompt).toContain('## Tool Discovery'); + expect(prompt).not.toMatch(/search "n8n docs"/i); + }); + + it('tells the orchestrator to answer n8n questions from n8n-docs rather than web search', () => { + const prompt = getSystemPrompt(toolSearchOptions); + + expect(prompt).toMatch(/prefer[^.]{0,60}n8n-docs/i); + expect(prompt).toMatch(/n8n-docs[^.]{0,120}already (loaded|available)/i); + }); + }); + describe('browser availability state propagates to the prompt', () => { it('includes browser automation rules when browser is available', () => { const prompt = getSystemPrompt({ diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index e096f7e07e3..2e03142ff42 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -65,7 +65,9 @@ function getToolDiscoverySection( ${mcpSearchGuidance}When the available tools do not cover the user's request, remember that you have access to more tools. Use \`search_tools\` with keyword queries to find relevant tools, then \`load_tool\` to activate them. Loaded tools persist for the rest of the conversation. When a loaded skill names a tool you do not see, search for that tool name and load it before proceeding. -Examples: ${mcpExamples}search "n8n docs" for \`n8n-docs\`, search "create tasks" for \`create-tasks\`, search "eval" for \`evals\`. +Examples: ${mcpExamples}search "create tasks" for \`create-tasks\`, search "eval" for \`evals\`. + +For questions about n8n itself — how a node behaves, the shape of its output, what a parameter does, product semantics — prefer \`n8n-docs\` and the node type definitions, both already loaded and needing no search, over web search, which is for third-party services and APIs. `; } diff --git a/packages/@n8n/instance-ai/src/tools/tool-ids.ts b/packages/@n8n/instance-ai/src/tools/tool-ids.ts index f331bd65311..51b009427d0 100644 --- a/packages/@n8n/instance-ai/src/tools/tool-ids.ts +++ b/packages/@n8n/instance-ai/src/tools/tool-ids.ts @@ -64,6 +64,11 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set([ DOMAIN_TOOL_IDS.NODES, ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW, DOMAIN_TOOL_IDS.RESEARCH, + // Paired with RESEARCH on purpose: the research tool tells the agent to prefer + // n8n's own docs for n8n questions, but deferring n8n-docs priced that route at + // search_tools + load_tool while web search stayed one call away — so the agent + // web-searched things the docs answer (INS-749). + DOMAIN_TOOL_IDS.N8N_DOCS, DOMAIN_TOOL_IDS.AGENTS, // Deferring this one defeats its purpose: it exists for the case where // nothing is connected, which is exactly when `search_tools` has no MCP tool