diff --git a/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts index 2d93f950b01..b73cd5ade60 100644 --- a/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts +++ b/packages/@n8n/agents/src/__tests__/integration/agent-runtime-conversion.test.ts @@ -93,6 +93,36 @@ describe('toAiMessages + fromAiMessages — round-trip', () => { expect(toolCallPart.providerMetadata).toEqual(providerMetadata); }); + it('copies Anthropic reasoning replay metadata into providerOptions on replay', () => { + const providerMetadata = { anthropic: { signature: 'anthropic-thinking-signature' } }; + const input: Message[] = [ + { + role: 'assistant', + content: [ + { + type: 'reasoning', + text: 'Let me think about this...', + providerMetadata, + }, + ], + }, + ]; + + const aiMessages = toAiMessages(input); + const reasoningPart = ( + aiMessages[0] as { + role: string; + content: Array<{ type: string; providerOptions?: unknown; providerMetadata?: unknown }>; + } + ).content[0]; + + expect(reasoningPart.type).toBe('reasoning'); + expect(reasoningPart.providerMetadata).toEqual(providerMetadata); + expect(reasoningPart.providerOptions).toEqual({ + anthropic: { signature: 'anthropic-thinking-signature' }, + }); + }); + it('sanitizes replayed non-object tool-call inputs for provider requests', () => { const input: Message[] = [ { diff --git a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts index deb169ca627..4a34b54f503 100644 --- a/packages/@n8n/agents/src/runtime/loop/runtime-context.ts +++ b/packages/@n8n/agents/src/runtime/loop/runtime-context.ts @@ -220,7 +220,14 @@ export class RuntimeContextBuilder { case 'anthropic': { const cfg = thinking as AnthropicThinkingConfig; if (cfg.mode === 'adaptive') { - return { anthropic: { thinking: { type: 'adaptive' } } }; + return { + anthropic: { + thinking: { + type: 'adaptive', + display: cfg.display ?? 'summarized', + }, + }, + }; } return { anthropic: { diff --git a/packages/@n8n/agents/src/runtime/model/messages.ts b/packages/@n8n/agents/src/runtime/model/messages.ts index 704eba2c9ff..413ba00439c 100644 --- a/packages/@n8n/agents/src/runtime/model/messages.ts +++ b/packages/@n8n/agents/src/runtime/model/messages.ts @@ -1,3 +1,4 @@ +import type { ProviderOptions } from '@ai-sdk/provider-utils'; import { isRecord } from '@n8n/utils'; import type { FilePart, @@ -111,6 +112,30 @@ function isContentToolResultOutput(value: JSONValue): value is ContentToolResult return isRecord(value) && value.type === 'content' && Array.isArray(value.value); } +/** + * Anthropic replays reasoning from `providerOptions`, but the AI SDK exposes the + * replay `signature`/`redactedData` in `providerMetadata`. Copy them across so + * the next request can replay the reasoning block. Existing `providerOptions` + * values win. + */ +function toReasoningProviderOptions(block: ContentReasoning): ProviderOptions | undefined { + const metadata = getRecord(block.providerMetadata?.anthropic); + const signature = metadata?.signature; + const redactedData = metadata?.redactedData; + if (typeof signature !== 'string' && typeof redactedData !== 'string') { + return block.providerOptions; + } + + return { + ...block.providerOptions, + anthropic: { + ...(typeof signature === 'string' && { signature }), + ...(typeof redactedData === 'string' && { redactedData }), + ...getRecord(block.providerOptions?.anthropic), + }, + }; +} + /** Convert a single n8n MessageContent block to an AI SDK content part. */ function toAiContent(block: MessageContent): AiContentPart | undefined { let base: AiContentPart | undefined; @@ -138,10 +163,14 @@ function toAiContent(block: MessageContent): AiContentPart | undefined { // Provider metadata can be required for replay. Gemini attaches // `google.thoughtSignature` to function-call parts, and the next request // is rejected if that signature is dropped from conversation history. + const providerOptions = isReasoning(block) + ? toReasoningProviderOptions(block) + : block.providerOptions; + return { ...base, ...(block.providerMetadata && { providerMetadata: block.providerMetadata }), - ...(block.providerOptions && { providerOptions: block.providerOptions }), + ...(providerOptions && { providerOptions }), } as AiContentPart; } return base; diff --git a/packages/@n8n/agents/src/types/sdk/provider.ts b/packages/@n8n/agents/src/types/sdk/provider.ts index 22e62879e40..a502590e119 100644 --- a/packages/@n8n/agents/src/types/sdk/provider.ts +++ b/packages/@n8n/agents/src/types/sdk/provider.ts @@ -27,7 +27,15 @@ export type Provider = * - `'enabled'` (default): a fixed token budget controlled by `budgetTokens`. */ export type AnthropicThinkingConfig = - | { mode: 'adaptive' } + | { + mode: 'adaptive'; + /** + * Controls whether thinking content is returned. Opus 4.7+ defaults to + * `"omitted"` on the Anthropic API; we default to `"summarized"` so + * reasoning streams and replay metadata are available. + */ + display?: 'omitted' | 'summarized'; + } | { mode?: 'enabled'; /** Token budget for extended thinking. Defaults to 10000. */ diff --git a/packages/@n8n/config/src/configs/instance-ai.config.ts b/packages/@n8n/config/src/configs/instance-ai.config.ts index 2591bb73128..ad90d5306ac 100644 --- a/packages/@n8n/config/src/configs/instance-ai.config.ts +++ b/packages/@n8n/config/src/configs/instance-ai.config.ts @@ -168,4 +168,8 @@ export class InstanceAiConfig { /** Capture orchestrator LLM steps and workflow code snapshots for the dev debug panel. */ @Env('N8N_INSTANCE_AI_RUN_DEBUG_ENABLED') runDebugEnabled: boolean = false; + + /** Enable extended thinking / reasoning for the orchestrator agent. */ + @Env('N8N_INSTANCE_AI_THINKING_ENABLED') + thinkingEnabled: boolean = true; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 7739cf11387..a3b7283a259 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -329,6 +329,7 @@ describe('GlobalConfig', () => { outputRedactionPii: 'credit-card', outputRedactionPlaceholder: '[REDACTED]', runDebugEnabled: false, + thinkingEnabled: true, }, queue: { health: { diff --git a/packages/@n8n/instance-ai/evaluations/discovery/runner.ts b/packages/@n8n/instance-ai/evaluations/discovery/runner.ts index 9ad43d4fba2..4bb57ff4f9f 100644 --- a/packages/@n8n/instance-ai/evaluations/discovery/runner.ts +++ b/packages/@n8n/instance-ai/evaluations/discovery/runner.ts @@ -133,6 +133,7 @@ export async function runDiscoveryScenario( // Eager tool loading — discovery measures dispatch given the full toolset, // not whether the orchestrator can find a tool through search. disableDeferredTools: true, + thinkingEnabled: false, }); const streamSource = normalizeStreamSource( diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/apply-agent-thinking.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/apply-agent-thinking.test.ts new file mode 100644 index 00000000000..cfa43933ce6 --- /dev/null +++ b/packages/@n8n/instance-ai/src/agent/__tests__/apply-agent-thinking.test.ts @@ -0,0 +1,44 @@ +/* eslint-disable import-x/order, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ +import type { Mock } from 'vitest'; + +const mockAgentInstances: Array<{ thinking: Mock }> = []; + +vi.mock('@n8n/agents', () => ({ + Agent: vi.fn().mockImplementation(function Agent(this: (typeof mockAgentInstances)[number]) { + this.thinking = vi.fn().mockReturnThis(); + mockAgentInstances.push(this); + }), +})); + +import { Agent as AgentImport } from '@n8n/agents'; + +import { applyAgentThinking } from '../apply-agent-thinking'; + +const Agent = AgentImport as unknown as Mock; + +describe('applyAgentThinking', () => { + beforeEach(() => { + mockAgentInstances.length = 0; + vi.clearAllMocks(); + }); + + it('enables adaptive thinking for Anthropic', () => { + const agent = new Agent('test'); + applyAgentThinking(agent, 'anthropic/claude-opus-4-8'); + expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', { mode: 'adaptive' }); + }); + + it('enables OpenAI reasoning for supported models', () => { + const agent = new Agent('test'); + applyAgentThinking(agent, 'openai/gpt-5.5'); + expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('openai', { + reasoningEffort: 'high', + }); + }); + + it('skips providers without thinking support', () => { + const agent = new Agent('test'); + applyAgentThinking(agent, 'google/gemini-2.5-pro'); + expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled(); + }); +}); 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 a9ad0a34996..87f728c7f2d 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 @@ -11,6 +11,7 @@ const mockAgentInstances: Array<{ memory: Mock; telemetry: Mock; workspace: Mock; + thinking: Mock; }> = []; const mockMemoryBuilder = { @@ -32,6 +33,7 @@ vi.mock('@n8n/agents', () => ({ this.memory = vi.fn().mockReturnThis(); this.telemetry = vi.fn().mockReturnThis(); this.workspace = vi.fn().mockReturnThis(); + this.thinking = vi.fn().mockReturnThis(); mockAgentInstances.push(this); }), Memory: vi.fn().mockImplementation(function Memory() { @@ -530,4 +532,45 @@ describe('createInstanceAgent', () => { }); expect(mockAgentInstances[0]?.memory).toHaveBeenCalledWith(mockMemoryBuilder); }); + + it('enables adaptive thinking by default for Anthropic models', async () => { + await createInstanceAgent({ + modelId: 'anthropic/claude-opus-4-8', + context: { + runLabel: 'thinking-test', + localGatewayStatus: undefined, + licenseHints: undefined, + localMcpServer: undefined, + }, + orchestrationContext: { + runId: 'thinking-test', + }, + memoryConfig: {}, + mcpManager: createMcpManagerStub(), + } as never); + + expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', { + mode: 'adaptive', + }); + }); + + it('skips thinking when explicitly disabled', async () => { + await createInstanceAgent({ + modelId: 'anthropic/claude-opus-4-8', + context: { + runLabel: 'thinking-off', + localGatewayStatus: undefined, + licenseHints: undefined, + localMcpServer: undefined, + }, + orchestrationContext: { + runId: 'thinking-off', + }, + memoryConfig: {}, + mcpManager: createMcpManagerStub(), + thinkingEnabled: false, + } as never); + + expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled(); + }); }); diff --git a/packages/@n8n/instance-ai/src/agent/apply-agent-thinking.ts b/packages/@n8n/instance-ai/src/agent/apply-agent-thinking.ts new file mode 100644 index 00000000000..dbbd19d6792 --- /dev/null +++ b/packages/@n8n/instance-ai/src/agent/apply-agent-thinking.ts @@ -0,0 +1,43 @@ +import type { Agent, ModelConfig } from '@n8n/agents'; +import { PROVIDER_CAPABILITIES } from '@n8n/api-types'; + +function resolveModelId(modelId: ModelConfig): string | undefined { + if (typeof modelId === 'string') return modelId; + if (typeof modelId === 'object' && modelId !== null && 'id' in modelId) { + return typeof modelId.id === 'string' ? modelId.id : undefined; + } + if ( + typeof modelId === 'object' && + modelId !== null && + 'provider' in modelId && + 'modelId' in modelId + ) { + const provider = typeof modelId.provider === 'string' ? modelId.provider : undefined; + const model = typeof modelId.modelId === 'string' ? modelId.modelId : undefined; + return provider && model ? `${provider}/${model}` : undefined; + } + return undefined; +} + +function resolveModelProvider(modelId: ModelConfig): string | undefined { + const id = resolveModelId(modelId); + if (!id) return undefined; + const slashIndex = id.indexOf('/'); + return slashIndex > 0 ? id.slice(0, slashIndex) : undefined; +} + +export function applyAgentThinking(agent: Agent, modelId: ModelConfig): void { + const provider = resolveModelProvider(modelId); + + if (!provider || !PROVIDER_CAPABILITIES[provider]?.thinking) return; + + if (provider === 'openai') { + agent.thinking('openai', { reasoningEffort: 'high' }); + return; + } + + if (provider === 'anthropic') { + agent.thinking('anthropic', { mode: 'adaptive' }); + return; + } +} diff --git a/packages/@n8n/instance-ai/src/agent/instance-agent.ts b/packages/@n8n/instance-ai/src/agent/instance-agent.ts index 7e0f563ea5e..bcbc2e71553 100644 --- a/packages/@n8n/instance-ai/src/agent/instance-agent.ts +++ b/packages/@n8n/instance-ai/src/agent/instance-agent.ts @@ -1,5 +1,6 @@ import { Agent, Memory } from '@n8n/agents'; +import { applyAgentThinking } from './apply-agent-thinking'; import { addSafeMcpTools, createClaimedToolNames, @@ -159,6 +160,9 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions): }) .tool(toolRegistryValues(runtimeTools)) .checkpoint(options.checkpointStore ?? 'memory'); + if (options.thinkingEnabled !== false) { + applyAgentThinking(agent, modelId); + } if (hasDeferrableTools) { agent.deferredTool(toolRegistryValues(deferredTools), { search: { topK: 5 } }); } diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index fd599f4aefe..62c23d2c391 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -1366,5 +1366,7 @@ export interface CreateInstanceAgentOptions { * Intended for tests and fallback paths that need the full toolset visible immediately. */ disableDeferredTools?: boolean; + /** When false, extended thinking / reasoning is not enabled. Defaults to true. */ + thinkingEnabled?: boolean; onMemoryTaskEvent?: (event: ScopedMemoryTaskEvent) => void; } diff --git a/packages/@n8n/instance-ai/src/utils/__tests__/eval-agents.test.ts b/packages/@n8n/instance-ai/src/utils/__tests__/eval-agents.test.ts index 52592eeb198..d3799ac09e2 100644 --- a/packages/@n8n/instance-ai/src/utils/__tests__/eval-agents.test.ts +++ b/packages/@n8n/instance-ai/src/utils/__tests__/eval-agents.test.ts @@ -69,7 +69,7 @@ describe('eval agent model config', () => { expect(config.apiKey).toBe('generic-key'); }); - it('does not enable OpenAI reasoning when thinking is omitted', () => { + it('enables thinking for supported eval models', () => { process.env.OPENAI_API_KEY = 'openai-key'; createEvalAgent('test-agent', { @@ -77,35 +77,8 @@ describe('eval agent model config', () => { instructions: 'Do the task.', }); - expect(mockAgentInstances[0]?.thinking).not.toHaveBeenCalled(); - }); - - it('enables OpenAI reasoning only when thinking is requested', () => { - process.env.OPENAI_API_KEY = 'openai-key'; - - createEvalAgent('test-agent', { - model: 'openai/gpt-5.5', - instructions: 'Do the task.', - thinking: 'adaptive', - }); - expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('openai', { reasoningEffort: 'high', }); }); - - it('keeps Anthropic budgeted thinking provider-specific', () => { - process.env.N8N_AI_ANTHROPIC_KEY = 'anthropic-key'; - - createEvalAgent('test-agent', { - model: 'anthropic/claude-sonnet-4-6', - instructions: 'Do the task.', - thinking: { budgetTokens: 2048 }, - }); - - expect(mockAgentInstances[0]?.thinking).toHaveBeenCalledWith('anthropic', { - mode: 'enabled', - budgetTokens: 2048, - }); - }); }); diff --git a/packages/@n8n/instance-ai/src/utils/eval-agents.ts b/packages/@n8n/instance-ai/src/utils/eval-agents.ts index 9eb8fed83cb..9813de744ea 100644 --- a/packages/@n8n/instance-ai/src/utils/eval-agents.ts +++ b/packages/@n8n/instance-ai/src/utils/eval-agents.ts @@ -2,6 +2,8 @@ import { Agent, Tool, type GenerateResult } from '@n8n/agents'; +import { applyAgentThinking } from '../agent/apply-agent-thinking'; + export { Tool }; // --------------------------------------------------------------------------- @@ -104,10 +106,9 @@ export function createEvalAgent( model?: string; instructions: string; cache?: boolean; - thinking?: 'adaptive' | 'off' | { budgetTokens: number }; }, ): Agent { - const { modelId, provider, apiKey, url } = resolveEvalModelConfig(options.model); + const { modelId, apiKey, url } = resolveEvalModelConfig(options.model); const agent = new Agent(name).model({ id: modelId, apiKey, @@ -120,14 +121,7 @@ export function createEvalAgent( agent.instructions(options.instructions); } - const thinking = options.thinking ?? 'off'; - if (provider === 'openai' && thinking !== 'off') { - agent.thinking('openai', { reasoningEffort: 'high' }); - } else if (provider === 'anthropic' && thinking === 'adaptive') { - agent.thinking('anthropic', { mode: 'adaptive' }); - } else if (provider === 'anthropic' && typeof thinking === 'object') { - agent.thinking('anthropic', { mode: 'enabled', budgetTokens: thinking.budgetTokens }); - } + applyAgentThinking(agent, modelId); return agent; } diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index d64704d4a68..770b6a5c35d 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -3813,6 +3813,7 @@ export class InstanceAiService { memory, checkpointStore: this.checkpointStore, onMemoryTaskEvent: this.memoryTaskObserverFor(threadId), + thinkingEnabled: this.instanceAiConfig.thinkingEnabled, }); const streamOptions = this.buildOrchestratorAgentStreamOptions(user, threadId, runId, signal); @@ -4503,6 +4504,7 @@ export class InstanceAiService { memory: environment.memory, checkpointStore: this.checkpointStore, onMemoryTaskEvent: this.memoryTaskObserverFor(orphan.threadId), + thinkingEnabled: this.instanceAiConfig.thinkingEnabled, }); } catch (error: unknown) { return { kind: 'agent-failure', error };