mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
fix(AI Agent Node): Remove assistant tool call redundant message and simplify chat tool response (#33640)
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
||||
type SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper';
|
||||
|
||||
import { ollamaModel, ollamaOptions, ollamaDescription } from '../LMOllama/description';
|
||||
|
||||
export class LmChatOllama implements INodeType {
|
||||
@@ -81,7 +83,7 @@ export class LmChatOllama implements INodeType {
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
response: wrapChatModelMessageInput(model),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ChatCohere } from '@langchain/cohere';
|
||||
import type { LLMResult } from '@langchain/core/outputs';
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import type {
|
||||
INodeType,
|
||||
INodeTypeDescription,
|
||||
@@ -7,11 +12,7 @@ import type {
|
||||
SupplyData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
makeN8nLlmFailedAttemptHandler,
|
||||
N8nLlmTracing,
|
||||
getConnectionHintNoticeField,
|
||||
} from '@n8n/ai-utilities';
|
||||
import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper';
|
||||
|
||||
export function tokensUsageParser(result: LLMResult): {
|
||||
completionTokens: number;
|
||||
@@ -176,7 +177,7 @@ export class LmChatCohere implements INodeType {
|
||||
});
|
||||
|
||||
return {
|
||||
response: model,
|
||||
response: wrapChatModelMessageInput(model),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
getHighlightedInputKey,
|
||||
getHighlightedResponseKey,
|
||||
isToolType,
|
||||
} from 'n8n-workflow';
|
||||
import type {
|
||||
IExecuteFunctions,
|
||||
@@ -36,6 +37,23 @@ import {
|
||||
getSendAndWaitPropertiesForChatNode,
|
||||
} from './util';
|
||||
|
||||
function getToolFlowResponse(context: IExecuteFunctions, data: INodeExecutionData) {
|
||||
// context.isToolExecution() doesn't work with ExecuteFunctionContext
|
||||
const isToolExecution = isToolType(context.getNode().type);
|
||||
if (!isToolExecution) return data;
|
||||
|
||||
// strip empty field and add sent: true to clarify the result for LLMs
|
||||
const json: IDataObject = { ...data.json, sent: true };
|
||||
if (json.chatInput === '') {
|
||||
delete json.chatInput;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
json,
|
||||
};
|
||||
}
|
||||
|
||||
export class Chat implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
usableAsTool: true,
|
||||
@@ -226,7 +244,7 @@ export class Chat implements INodeType {
|
||||
|
||||
if (!waitForReply) {
|
||||
// return original message instead of input data
|
||||
if (nodeVersion >= 1.3) return [[data]];
|
||||
if (nodeVersion >= 1.3) return [[getToolFlowResponse(context, data)]];
|
||||
|
||||
const inputData = context.getInputData();
|
||||
return [inputData];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { INode, IExecuteFunctions } from 'n8n-workflow';
|
||||
import {
|
||||
CHAT_NODE_TYPE,
|
||||
CHAT_TOOL_NODE_TYPE,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
FREE_TEXT_CHAT_RESPONSE_TYPE,
|
||||
SEND_AND_WAIT_OPERATION,
|
||||
@@ -317,6 +318,32 @@ describe('Test Chat Node', () => {
|
||||
expect(result).toEqual([[message]]);
|
||||
});
|
||||
|
||||
it('v1.3 should return a tool-friendly response when used as a tool without waiting for reply', async () => {
|
||||
const chatToolNode = mock<INode>({
|
||||
name: 'Chat',
|
||||
type: CHAT_TOOL_NODE_TYPE,
|
||||
parameters: {},
|
||||
typeVersion: 1.3,
|
||||
});
|
||||
const message = { json: { chatInput: '' } };
|
||||
mockExecuteFunctions.getInputData.mockReturnValue([{ json: { chatInput: 'other input' } }]);
|
||||
mockExecuteFunctions.getNode.mockReturnValue(chatToolNode);
|
||||
mockExecuteFunctions.getNodeParameter.mockImplementation((parameterName) => {
|
||||
switch (parameterName) {
|
||||
case 'operation':
|
||||
return 'send';
|
||||
case 'options':
|
||||
return { memoryConnection: false };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await chat.onMessage(mockExecuteFunctions, message);
|
||||
|
||||
expect(result).toEqual([[{ json: { sent: true } }]]);
|
||||
});
|
||||
|
||||
it('v1.2 should return output data directly without nesting into `data` field (except `approved`)', async () => {
|
||||
const chatNode = mock<INode>({
|
||||
name: 'Chat',
|
||||
|
||||
@@ -134,7 +134,7 @@ function buildMessageContent(
|
||||
toolInput: IDataObject,
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
): string | Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
|
||||
): null | Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
|
||||
const { thinkingContent, thinkingType, thinkingSignature } = providerMetadata;
|
||||
|
||||
// Anthropic thinking mode: build content blocks
|
||||
@@ -149,8 +149,7 @@ function buildMessageContent(
|
||||
);
|
||||
}
|
||||
|
||||
// Default: simple string content
|
||||
return `Calling ${toolName} with input: ${JSON.stringify(toolInput)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveToolName(tool: EngineResult<RequestResponseMetadata>): string {
|
||||
@@ -231,9 +230,9 @@ function buildIndividualAIMessage(
|
||||
const content = buildMessageContent(providerMetadata, toolInput, toolId, toolName);
|
||||
|
||||
return new AIMessage({
|
||||
content,
|
||||
content: content ?? [],
|
||||
// When content is an array (Anthropic thinking), LangChain ignores tool_calls
|
||||
...(typeof content === 'string' && { tool_calls: [toolCall] }),
|
||||
...(content === null && { tool_calls: [toolCall] }),
|
||||
...(providerMetadata.thoughtSignature && {
|
||||
additional_kwargs: buildGeminiAdditionalKwargs(
|
||||
[{ id: toolId, name: toolName, args: toolInput }],
|
||||
@@ -387,11 +386,14 @@ export function buildSteps(
|
||||
: []
|
||||
: [buildIndividualAIMessage(toolId, toolName, toolInput, providerMetadata)];
|
||||
|
||||
const logFallback = messageLog[0]?.content?.length
|
||||
? messageLog[0]?.content
|
||||
: `Calling ${nodeName}`;
|
||||
steps.push({
|
||||
action: {
|
||||
tool: toolName,
|
||||
toolInput: toolInputForResult,
|
||||
log: toolInput.log || (messageLog[0]?.content ?? `Calling ${nodeName}`),
|
||||
log: toolInput.log || logFallback,
|
||||
messageLog,
|
||||
toolCallId: toolInput?.id,
|
||||
type: toolInput.type || 'tool_call',
|
||||
|
||||
@@ -410,6 +410,8 @@ describe('buildSteps', () => {
|
||||
expect(result[0].action.messageLog).toHaveLength(1);
|
||||
|
||||
const message = result[0].action.messageLog![0];
|
||||
expect(message.content).toEqual([]);
|
||||
expect(result[0].action.log).toBe('Calling Calculator Node');
|
||||
expect(message).toHaveProperty('tool_calls');
|
||||
expect(message.tool_calls).toHaveLength(1);
|
||||
expect(message.tool_calls?.[0]).toMatchObject({
|
||||
@@ -878,7 +880,7 @@ describe('buildSteps', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use string content when no thinking blocks present', () => {
|
||||
it('should use empty content and tool_calls when no thinking blocks are present', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
{
|
||||
@@ -917,9 +919,9 @@ describe('buildSteps', () => {
|
||||
expect(result[0].action.messageLog).toHaveLength(1);
|
||||
|
||||
const message = result[0].action.messageLog![0];
|
||||
expect(typeof message.content).toBe('string');
|
||||
expect(message.content).toContain('Calling Calculator');
|
||||
expect(message.content).toEqual([]);
|
||||
expect(message).toHaveProperty('tool_calls');
|
||||
expect(message.tool_calls?.[0].name).toBe('Calculator');
|
||||
});
|
||||
|
||||
it('should handle thinking content without thinkingType', () => {
|
||||
@@ -961,8 +963,9 @@ describe('buildSteps', () => {
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const message = result[0].action.messageLog![0];
|
||||
// Should fall back to string content when thinkingType is missing
|
||||
expect(typeof message.content).toBe('string');
|
||||
// Should fall back to default tool_calls format when thinkingType is missing
|
||||
expect(message.content).toEqual([]);
|
||||
expect(message.tool_calls?.[0].name).toBe('Calculator');
|
||||
});
|
||||
|
||||
it('should work alongside Gemini thought_signature', () => {
|
||||
@@ -1111,53 +1114,6 @@ describe('buildSteps', () => {
|
||||
expect(result[0].action.tool).toBe('Calculator_Node');
|
||||
});
|
||||
|
||||
it('should use HITL toolName in message content', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
{
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'HITL Node',
|
||||
input: {
|
||||
id: 'call_123',
|
||||
input: { query: 'test' },
|
||||
},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: 'call_123',
|
||||
metadata: {
|
||||
itemIndex: 0,
|
||||
hitl: {
|
||||
toolName: 'custom_tool',
|
||||
gatedToolNodeName: 'Custom Tool',
|
||||
originalInput: { query: 'test' },
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
data: {
|
||||
ai_tool: [[{ json: { result: 'success' } }]],
|
||||
},
|
||||
executionTime: 0,
|
||||
startTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = buildSteps(response, itemIndex);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const message = result[0].action.messageLog![0];
|
||||
// Message content should use the HITL toolName
|
||||
expect(message.content).toContain('Calling custom_tool');
|
||||
expect(message.content).not.toContain('HITL Node');
|
||||
// Tool call should also use the HITL toolName
|
||||
expect(message.tool_calls?.[0].name).toBe('custom_tool');
|
||||
});
|
||||
|
||||
it('should use converted nodeName in message content when HITL metadata is absent', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
@@ -1193,8 +1149,7 @@ describe('buildSteps', () => {
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
const message = result[0].action.messageLog![0];
|
||||
// Message content should use the converted tool name
|
||||
expect(message.content).toContain('Calling My_Custom_Node');
|
||||
expect(message.content).toEqual([]);
|
||||
expect(message.tool_calls?.[0].name).toBe('My_Custom_Node');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import {
|
||||
AIMessage,
|
||||
AIMessageChunk,
|
||||
type BaseMessage,
|
||||
HumanMessage,
|
||||
} from '@langchain/core/messages';
|
||||
import { ChatGenerationChunk } from '@langchain/core/outputs';
|
||||
|
||||
import {
|
||||
normalizeEmptyToolCallContent,
|
||||
wrapChatModelMessageInput,
|
||||
} from './chatModelMessageWrapper';
|
||||
|
||||
const toolCall = {
|
||||
id: 'call_123',
|
||||
name: 'Chat',
|
||||
args: { Message: 'hello' },
|
||||
type: 'tool_call' as const,
|
||||
};
|
||||
|
||||
describe('chatModelMessageWrapper', () => {
|
||||
describe('normalizeEmptyToolCallContent', () => {
|
||||
it('converts empty array content on AI tool-call messages to an empty string', () => {
|
||||
const message = new AIMessage({ content: [], tool_calls: [toolCall] });
|
||||
|
||||
const [normalized] = normalizeEmptyToolCallContent([message]);
|
||||
|
||||
expect(AIMessage.isInstance(normalized)).toBe(true);
|
||||
expect(normalized.content).toBe('');
|
||||
expect((normalized as AIMessage).tool_calls).toEqual([toolCall]);
|
||||
});
|
||||
|
||||
it('leaves non-tool-call messages unchanged', () => {
|
||||
const message = new HumanMessage('hello');
|
||||
|
||||
const [normalized] = normalizeEmptyToolCallContent([message]);
|
||||
|
||||
expect(normalized).toBe(message);
|
||||
});
|
||||
|
||||
it('leaves AI messages without tool calls unchanged', () => {
|
||||
const message = new AIMessage({ content: [] });
|
||||
|
||||
const [normalized] = normalizeEmptyToolCallContent([message]);
|
||||
|
||||
expect(normalized).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps generate and stream paths with the message transformer', async () => {
|
||||
const seenGenerateMessages: unknown[] = [];
|
||||
const seenStreamMessages: unknown[] = [];
|
||||
const model = {
|
||||
_generate: vi.fn(async (messages) => {
|
||||
await Promise.resolve();
|
||||
seenGenerateMessages.push(messages);
|
||||
return { generations: [] };
|
||||
}),
|
||||
_streamResponseChunks: vi.fn(async function* (messages) {
|
||||
await Promise.resolve();
|
||||
seenStreamMessages.push(messages);
|
||||
yield new ChatGenerationChunk({
|
||||
text: '',
|
||||
message: new AIMessageChunk({ content: '' }),
|
||||
});
|
||||
}),
|
||||
} as unknown as BaseChatModel;
|
||||
|
||||
const wrapped = wrapChatModelMessageInput(model);
|
||||
const message = new AIMessage({ content: [], tool_calls: [toolCall] });
|
||||
|
||||
expect(wrapped).toBe(model);
|
||||
|
||||
await wrapped._generate([message], {});
|
||||
const streamChunks = [];
|
||||
for await (const chunk of wrapped._streamResponseChunks([message], {})) {
|
||||
streamChunks.push(chunk);
|
||||
}
|
||||
|
||||
expect(seenGenerateMessages).toHaveLength(1);
|
||||
expect(seenStreamMessages).toHaveLength(1);
|
||||
expect(streamChunks).toHaveLength(1);
|
||||
expect((seenGenerateMessages[0] as AIMessage[])[0].content).toBe('');
|
||||
expect((seenStreamMessages[0] as AIMessage[])[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('does not wrap the same model more than once', async () => {
|
||||
const seenGenerateMessages: unknown[] = [];
|
||||
const model = {
|
||||
_generate: vi.fn(async (messages) => {
|
||||
await Promise.resolve();
|
||||
seenGenerateMessages.push(messages);
|
||||
return { generations: [] };
|
||||
}),
|
||||
_streamResponseChunks: vi.fn(async function* () {
|
||||
await Promise.resolve();
|
||||
yield new ChatGenerationChunk({
|
||||
text: '',
|
||||
message: new AIMessageChunk({ content: '' }),
|
||||
});
|
||||
}),
|
||||
} as unknown as BaseChatModel;
|
||||
const firstWrapper = vi.fn((messages: BaseMessage[]) => messages);
|
||||
const secondWrapper = vi.fn((messages: BaseMessage[]) => messages);
|
||||
|
||||
wrapChatModelMessageInput(model, firstWrapper);
|
||||
wrapChatModelMessageInput(model, secondWrapper);
|
||||
|
||||
await model._generate([new HumanMessage('hello')], {});
|
||||
|
||||
expect(firstWrapper).toHaveBeenCalledTimes(1);
|
||||
expect(secondWrapper).not.toHaveBeenCalled();
|
||||
expect(seenGenerateMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
|
||||
|
||||
const wrappedChatModelMessageInput = Symbol('wrappedChatModelMessageInput');
|
||||
|
||||
type GenerateMethod = (
|
||||
messages: BaseMessage[],
|
||||
options: unknown,
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
) => Promise<ChatResult>;
|
||||
|
||||
type StreamMethod = (
|
||||
messages: BaseMessage[],
|
||||
options: unknown,
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
) => AsyncGenerator<ChatGenerationChunk>;
|
||||
|
||||
type PatchableChatModel = {
|
||||
_generate: GenerateMethod;
|
||||
_streamResponseChunks: StreamMethod;
|
||||
[wrappedChatModelMessageInput]?: true;
|
||||
};
|
||||
|
||||
export type ChatModelMessageWrapper = (messages: BaseMessage[]) => BaseMessage[];
|
||||
|
||||
export function normalizeEmptyToolCallContent(messages: BaseMessage[]): BaseMessage[] {
|
||||
return messages.map((message) => {
|
||||
if (
|
||||
AIMessage.isInstance(message) &&
|
||||
Array.isArray(message.content) &&
|
||||
message.content.length === 0 &&
|
||||
message.tool_calls?.length
|
||||
) {
|
||||
return new AIMessage({
|
||||
id: message.id,
|
||||
name: message.name,
|
||||
content: '',
|
||||
additional_kwargs: message.additional_kwargs,
|
||||
response_metadata: message.response_metadata,
|
||||
tool_calls: message.tool_calls,
|
||||
invalid_tool_calls: message.invalid_tool_calls,
|
||||
usage_metadata: message.usage_metadata,
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A method that wraps langchain chat model to convert incoming messages to a format that is compatible with the model.
|
||||
* By default, it normalizes messages with tool calls and content:[] to have content:''. Some old models expect to have some content alongside the tool calls.
|
||||
*/
|
||||
export function wrapChatModelMessageInput<TModel extends BaseChatModel>(
|
||||
model: TModel,
|
||||
wrapMessages: ChatModelMessageWrapper = normalizeEmptyToolCallContent,
|
||||
): TModel {
|
||||
const patchableModel = model as TModel & PatchableChatModel;
|
||||
|
||||
if (patchableModel[wrappedChatModelMessageInput]) return model;
|
||||
|
||||
const originalGenerate = patchableModel._generate.bind(model);
|
||||
const originalStreamResponseChunks = patchableModel._streamResponseChunks.bind(model);
|
||||
|
||||
patchableModel._generate = async (messages, options, runManager) =>
|
||||
await originalGenerate(wrapMessages(messages), options, runManager);
|
||||
|
||||
patchableModel._streamResponseChunks = async function* (messages, options, runManager) {
|
||||
yield* originalStreamResponseChunks(wrapMessages(messages), options, runManager);
|
||||
};
|
||||
|
||||
patchableModel[wrappedChatModelMessageInput] = true;
|
||||
|
||||
return model;
|
||||
}
|
||||
Reference in New Issue
Block a user