feat(core): Enable thinking modes for instance ai (#32768)

This commit is contained in:
Riqwan Thamir 2026-06-22 17:24:31 +02:00 committed by GitHub
parent 1c75eb6c25
commit c8e4c7606a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 226 additions and 41 deletions

View File

@ -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[] = [
{

View File

@ -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: {

View File

@ -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;

View File

@ -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. */

View File

@ -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;
}

View File

@ -329,6 +329,7 @@ describe('GlobalConfig', () => {
outputRedactionPii: 'credit-card',
outputRedactionPlaceholder: '[REDACTED]',
runDebugEnabled: false,
thinkingEnabled: true,
},
queue: {
health: {

View File

@ -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(

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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;
}
}

View File

@ -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 } });
}

View File

@ -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;
}

View File

@ -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,
});
});
});

View File

@ -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;
}

View File

@ -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 };