mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 19:45:09 +02:00
feat(core): Enable thinking modes for instance ai (#32768)
This commit is contained in:
parent
1c75eb6c25
commit
c8e4c7606a
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ describe('GlobalConfig', () => {
|
|||
outputRedactionPii: 'credit-card',
|
||||
outputRedactionPlaceholder: '[REDACTED]',
|
||||
runDebugEnabled: false,
|
||||
thinkingEnabled: true,
|
||||
},
|
||||
queue: {
|
||||
health: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
43
packages/@n8n/instance-ai/src/agent/apply-agent-thinking.ts
Normal file
43
packages/@n8n/instance-ai/src/agent/apply-agent-thinking.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user