first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,78 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { AgentV1 } from './V1/AgentV1.node';
import { AgentV2 } from './V2/AgentV2.node';
import { AgentV3 } from './V3/AgentV3.node';
export class Agent extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'AI Agent',
name: 'agent',
icon: 'fa:robot',
iconColor: 'black',
group: ['transform'],
description: 'Generates an action plan and executes it. Can use external tools.',
codex: {
alias: ['LangChain', 'Chat', 'Conversational', 'Plan and Execute', 'ReAct', 'Tools'],
categories: ['AI'],
subcategories: {
AI: ['Agents', 'Root Nodes'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/',
},
],
},
},
defaultVersion: 3.1,
builderHint: {
relatedNodes: [
{
nodeType: 'n8n-nodes-base.aggregate',
relationHint: 'Use to combine multiple items together before the agent',
},
{
nodeType: '@n8n/n8n-nodes-langchain.outputParserStructured',
relationHint:
'Attach for structured output; reference fields as $json.output.fieldName for use in subsequent nodes (conditions, storing data)',
},
{
nodeType: '@n8n/n8n-nodes-langchain.agentTool',
relationHint: 'For multi-agent systems using orchestrator pattern',
},
{
nodeType: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
relationHint:
'Required for conversational workflows - connect memory to every agent that needs to recall previous messages in the conversation',
},
],
},
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
1: new AgentV1(baseDescription),
1.1: new AgentV1(baseDescription),
1.2: new AgentV1(baseDescription),
1.3: new AgentV1(baseDescription),
1.4: new AgentV1(baseDescription),
1.5: new AgentV1(baseDescription),
1.6: new AgentV1(baseDescription),
1.7: new AgentV1(baseDescription),
1.8: new AgentV1(baseDescription),
1.9: new AgentV1(baseDescription),
2: new AgentV2(baseDescription),
2.1: new AgentV2(baseDescription),
2.2: new AgentV2(baseDescription),
2.3: new AgentV2(baseDescription),
3: new AgentV3(baseDescription),
3.1: new AgentV3(baseDescription),
// IMPORTANT Reminder to update AgentTool
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1,36 @@
import type { INodeTypeBaseDescription, IVersionedNodeType } from 'n8n-workflow';
import { VersionedNodeType } from 'n8n-workflow';
import { AgentToolV2 } from './V2/AgentToolV2.node';
import { AgentToolV3 } from './V3/AgentToolV3.node';
export class AgentTool extends VersionedNodeType {
constructor() {
const baseDescription: INodeTypeBaseDescription = {
displayName: 'AI Agent Tool',
name: 'agentTool',
icon: 'fa:robot',
iconColor: 'black',
group: ['transform'],
description: 'Generates an action plan and executes it. Can use external tools.',
codex: {
alias: ['LangChain', 'Chat', 'Conversational', 'Plan and Execute', 'ReAct', 'Tools'],
categories: ['AI'],
subcategories: {
AI: ['Tools'],
Tools: ['Recommended Tools'],
},
},
defaultVersion: 3,
};
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
// Should have the same versioning as Agent node
// because internal agent logic often checks for node version
2.2: new AgentToolV2(baseDescription),
3: new AgentToolV3(baseDescription),
};
super(nodeVersions, baseDescription);
}
}
@@ -0,0 +1,486 @@
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type {
INodeInputConfiguration,
INodeFilter,
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeProperties,
NodeConnectionType,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import {
promptTypeOptionsDeprecated,
textFromGuardrailsNode,
textFromPreviousNode,
textInput,
} from '@utils/descriptions';
import { conversationalAgentProperties } from '../agents/ConversationalAgent/description';
import { conversationalAgentExecute } from '../agents/ConversationalAgent/execute';
import { openAiFunctionsAgentProperties } from '../agents/OpenAiFunctionsAgent/description';
import { openAiFunctionsAgentExecute } from '../agents/OpenAiFunctionsAgent/execute';
import { planAndExecuteAgentProperties } from '../agents/PlanAndExecuteAgent/description';
import { planAndExecuteAgentExecute } from '../agents/PlanAndExecuteAgent/execute';
import { reActAgentAgentProperties } from '../agents/ReActAgent/description';
import { reActAgentAgentExecute } from '../agents/ReActAgent/execute';
import { sqlAgentAgentProperties } from '../agents/SqlAgent/description';
import { sqlAgentAgentExecute } from '../agents/SqlAgent/execute';
import { toolsAgentProperties } from '../agents/ToolsAgent/V1/description';
import { toolsAgentExecute } from '../agents/ToolsAgent/V1/execute';
// Function used in the inputs expression to figure out which inputs to
// display based on the agent type
/* istanbul ignore next */
function getInputs(
agent:
| 'toolsAgent'
| 'conversationalAgent'
| 'openAiFunctionsAgent'
| 'planAndExecuteAgent'
| 'reActAgent'
| 'sqlAgent',
hasOutputParser?: boolean,
): Array<NodeConnectionType | INodeInputConfiguration> {
interface SpecialInput {
type: NodeConnectionType;
filter?: INodeFilter;
required?: boolean;
}
const getInputData = (
inputs: SpecialInput[],
): Array<NodeConnectionType | INodeInputConfiguration> => {
const displayNames: { [key: string]: string } = {
ai_languageModel: 'Model',
ai_memory: 'Memory',
ai_tool: 'Tool',
ai_outputParser: 'Output Parser',
};
return inputs.map(({ type, filter }) => {
const isModelType = type === ('ai_languageModel' as NodeConnectionType);
let displayName = type in displayNames ? displayNames[type] : undefined;
if (
isModelType &&
['openAiFunctionsAgent', 'toolsAgent', 'conversationalAgent'].includes(agent)
) {
displayName = 'Chat Model';
}
const input: INodeInputConfiguration = {
type,
displayName,
required: isModelType,
maxConnections: ['ai_languageModel', 'ai_memory', 'ai_outputParser'].includes(type)
? 1
: undefined,
};
if (filter) {
input.filter = filter;
}
return input;
});
};
let specialInputs: SpecialInput[] = [];
if (agent === 'conversationalAgent') {
specialInputs = [
{
type: 'ai_languageModel',
filter: {
nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatLemonade',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
'@n8n/n8n-nodes-langchain.lmChatVercelAiGateway',
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
'@n8n/n8n-nodes-langchain.modelSelector',
],
},
},
{
type: 'ai_memory',
},
{
type: 'ai_tool',
},
{
type: 'ai_outputParser',
},
];
} else if (agent === 'toolsAgent') {
specialInputs = [
{
type: 'ai_languageModel',
filter: {
nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatLemonade',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
'@n8n/n8n-nodes-langchain.lmChatGoogleGemini',
'@n8n/n8n-nodes-langchain.lmChatDeepSeek',
'@n8n/n8n-nodes-langchain.lmChatOpenRouter',
'@n8n/n8n-nodes-langchain.lmChatVercelAiGateway',
'@n8n/n8n-nodes-langchain.lmChatXAiGrok',
],
},
},
{
type: 'ai_memory',
},
{
type: 'ai_tool',
required: true,
},
{
type: 'ai_outputParser',
},
];
} else if (agent === 'openAiFunctionsAgent') {
specialInputs = [
{
type: 'ai_languageModel',
filter: {
nodes: [
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
],
},
},
{
type: 'ai_memory',
},
{
type: 'ai_tool',
required: true,
},
{
type: 'ai_outputParser',
},
];
} else if (agent === 'reActAgent') {
specialInputs = [
{
type: 'ai_languageModel',
},
{
type: 'ai_tool',
},
{
type: 'ai_outputParser',
},
];
} else if (agent === 'sqlAgent') {
specialInputs = [
{
type: 'ai_languageModel',
},
{
type: 'ai_memory',
},
];
} else if (agent === 'planAndExecuteAgent') {
specialInputs = [
{
type: 'ai_languageModel',
},
{
type: 'ai_tool',
},
{
type: 'ai_outputParser',
},
];
}
if (hasOutputParser === false) {
specialInputs = specialInputs.filter((input) => input.type !== 'ai_outputParser');
}
return ['main', ...getInputData(specialInputs)];
}
const agentTypeProperty: INodeProperties = {
displayName: 'Agent',
name: 'agent',
type: 'options',
noDataExpression: true,
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Tools Agent',
value: 'toolsAgent',
description:
'Utilizes structured tool schemas for precise and reliable tool selection and execution. Recommended for complex tasks requiring accurate and consistent tool usage, but only usable with models that support tool calling.',
},
{
name: 'Conversational Agent',
value: 'conversationalAgent',
description:
'Describes tools in the system prompt and parses JSON responses for tool calls. More flexible but potentially less reliable than the Tools Agent. Suitable for simpler interactions or with models not supporting structured schemas.',
},
{
name: 'OpenAI Functions Agent',
value: 'openAiFunctionsAgent',
description:
"Leverages OpenAI's function calling capabilities to precisely select and execute tools. Excellent for tasks requiring structured outputs when working with OpenAI models.",
},
{
name: 'Plan and Execute Agent',
value: 'planAndExecuteAgent',
description:
'Creates a high-level plan for complex tasks and then executes each step. Suitable for multi-stage problems or when a strategic approach is needed.',
},
{
name: 'ReAct Agent',
value: 'reActAgent',
description:
'Combines reasoning and action in an iterative process. Effective for tasks that require careful analysis and step-by-step problem-solving.',
},
{
name: 'SQL Agent',
value: 'sqlAgent',
description:
'Specializes in interacting with SQL databases. Ideal for data analysis tasks, generating queries, or extracting insights from structured data.',
},
],
default: '',
};
export class AgentV1 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
version: [1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9],
...baseDescription,
defaults: {
name: 'AI Agent',
color: '#404040',
},
inputs: `={{
((agent, hasOutputParser) => {
${getInputs.toString()};
return getInputs(agent, hasOutputParser)
})($parameter.agent, $parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true)
}}`,
outputs: [NodeConnectionTypes.Main],
builderHint: {
...baseDescription.builderHint,
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
ai_outputParser: {
required: false,
displayOptions: { show: { hasOutputParser: [true] } },
},
},
},
credentials: [
{
name: 'mySql',
required: true,
testedBy: 'mysqlConnectionTest',
displayOptions: {
show: {
agent: ['sqlAgent'],
'/dataSource': ['mysql'],
},
},
},
{
name: 'postgres',
required: true,
displayOptions: {
show: {
agent: ['sqlAgent'],
'/dataSource': ['postgres'],
},
},
},
],
properties: [
{
displayName:
'Tip: Get a feel for agents with our quick <a href="https://docs.n8n.io/advanced-ai/intro-tutorial/" target="_blank">tutorial</a> or see an <a href="/templates/1954" target="_blank">example</a> of how this node works',
name: 'aiAgentStarterCallout',
type: 'callout',
default: '',
displayOptions: {
show: {
agent: ['conversationalAgent', 'toolsAgent'],
},
},
},
{
displayName:
"This node is using Agent that has been deprecated. Please switch to using 'Tools Agent' instead.",
name: 'deprecated',
type: 'notice',
default: '',
displayOptions: {
show: {
agent: [
'conversationalAgent',
'openAiFunctionsAgent',
'planAndExecuteAgent',
'reActAgent',
'sqlAgent',
],
},
},
},
// Make Conversational Agent the default agent for versions 1.5 and below
{
...agentTypeProperty,
options: agentTypeProperty?.options?.filter(
(o) => 'value' in o && o.value !== 'toolsAgent',
),
displayOptions: { show: { '@version': [{ _cnd: { lte: 1.5 } }] } },
default: 'conversationalAgent',
},
// Make Tools Agent the default agent for versions 1.6 and 1.7
{
...agentTypeProperty,
displayOptions: { show: { '@version': [{ _cnd: { between: { from: 1.6, to: 1.7 } } }] } },
default: 'toolsAgent',
},
// Make Tools Agent the only agent option for versions 1.8 and above
{
...agentTypeProperty,
type: 'hidden',
displayOptions: { show: { '@version': [{ _cnd: { gte: 1.8 } }] } },
default: 'toolsAgent',
},
{
...promptTypeOptionsDeprecated,
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.2 } }],
agent: ['sqlAgent'],
},
},
},
{
...textFromGuardrailsNode,
displayOptions: {
show: { promptType: ['guardrails'], '@version': [{ _cnd: { gte: 1.7 } }] },
},
},
{
...textFromPreviousNode,
displayOptions: {
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }] },
// SQL Agent has data source and credentials parameters so we need to include this input there manually
// to preserve the order
hide: {
agent: ['sqlAgent'],
},
},
},
{
...textInput,
displayOptions: {
show: {
promptType: ['define'],
},
hide: {
agent: ['sqlAgent'],
},
},
},
{
displayName:
'For more reliable structured output parsing, consider using the Tools agent',
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
agent: [
'conversationalAgent',
'reActAgent',
'planAndExecuteAgent',
'openAiFunctionsAgent',
],
},
},
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
type: 'boolean',
default: false,
noDataExpression: true,
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.2 } }],
agent: ['sqlAgent'],
},
},
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
agent: ['toolsAgent'],
},
},
},
...toolsAgentProperties,
...conversationalAgentProperties,
...openAiFunctionsAgentProperties,
...reActAgentAgentProperties,
...sqlAgentAgentProperties,
...planAndExecuteAgentProperties,
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const agentType = this.getNodeParameter('agent', 0, '') as string;
const nodeVersion = this.getNode().typeVersion;
if (agentType === 'conversationalAgent') {
return await conversationalAgentExecute.call(this, nodeVersion);
} else if (agentType === 'toolsAgent') {
return await toolsAgentExecute.call(this);
} else if (agentType === 'openAiFunctionsAgent') {
return await openAiFunctionsAgentExecute.call(this, nodeVersion);
} else if (agentType === 'reActAgent') {
return await reActAgentAgentExecute.call(this, nodeVersion);
} else if (agentType === 'sqlAgent') {
return await sqlAgentAgentExecute.call(this);
} else if (agentType === 'planAndExecuteAgent') {
return await planAndExecuteAgentExecute.call(this, nodeVersion);
}
throw new NodeOperationError(this.getNode(), `The agent type "${agentType}" is not supported`);
}
}
@@ -0,0 +1,102 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
ISupplyDataFunctions,
} from 'n8n-workflow';
import { textInput, toolDescription } from '@utils/descriptions';
import { getInputs } from './utils';
import { getToolsAgentProperties } from '../agents/ToolsAgent/V2/description';
import { toolsAgentExecute } from '../agents/ToolsAgent/V2/execute';
export class AgentToolV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: [2.2],
defaults: {
name: 'AI Agent Tool',
color: '#404040',
},
inputs: `={{
((hasOutputParser, needsFallback) => {
${getInputs.toString()};
return getInputs(false, hasOutputParser, needsFallback)
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
}}`,
outputs: [NodeConnectionTypes.AiTool],
builderHint: {
...baseDescription.builderHint,
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
ai_outputParser: {
required: false,
displayOptions: { show: { hasOutputParser: [true] } },
},
},
},
properties: [
toolDescription,
{
...textInput,
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
},
},
},
{
displayName: 'Enable Fallback Model',
name: 'needsFallback',
type: 'boolean',
default: false,
noDataExpression: true,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 2.1 } }],
},
},
},
{
displayName:
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
name: 'fallbackNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
needsFallback: [true],
},
},
},
...getToolsAgentProperties({ withStreaming: false }),
],
};
}
// Automatically wrapped as a tool
async execute(this: IExecuteFunctions | ISupplyDataFunctions): Promise<INodeExecutionData[][]> {
return await toolsAgentExecute.call(this);
}
}
@@ -0,0 +1,144 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
} from 'n8n-workflow';
import {
promptTypeOptionsDeprecated,
textFromGuardrailsNode,
textFromPreviousNode,
textInput,
} from '@utils/descriptions';
import { getToolsAgentProperties } from '../agents/ToolsAgent/V2/description';
import { toolsAgentExecute } from '../agents/ToolsAgent/V2/execute';
import { getInputs } from '../utils';
export class AgentV2 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: [2, 2.1, 2.2],
defaults: {
name: 'AI Agent',
color: '#404040',
},
inputs: `={{
((hasOutputParser, needsFallback) => {
${getInputs.toString()};
return getInputs(true, hasOutputParser, needsFallback);
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
}}`,
outputs: [NodeConnectionTypes.Main],
builderHint: {
...baseDescription.builderHint,
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
ai_outputParser: {
required: false,
displayOptions: { show: { hasOutputParser: [true] } },
},
},
},
properties: [
{
displayName:
'Tip: Get a feel for agents with our quick <a href="https://docs.n8n.io/advanced-ai/intro-tutorial/" target="_blank">tutorial</a> or see an <a href="/workflows/templates/1954" target="_blank">example</a> of how this node works',
name: 'aiAgentStarterCallout',
type: 'callout',
default: '',
},
promptTypeOptionsDeprecated,
{
...textFromGuardrailsNode,
displayOptions: {
show: {
promptType: ['guardrails'],
},
},
},
{
...textFromPreviousNode,
displayOptions: {
show: {
promptType: ['auto'],
},
},
},
{
...textInput,
displayOptions: {
show: {
promptType: ['define'],
},
},
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
},
},
},
{
displayName: 'Enable Fallback Model',
name: 'needsFallback',
type: 'boolean',
default: false,
noDataExpression: true,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 2.1 } }],
},
},
},
{
displayName:
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
name: 'fallbackNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
needsFallback: [true],
},
},
},
...getToolsAgentProperties({ withStreaming: true }),
],
hints: [
{
message:
'You are using streaming responses. Make sure to set the response mode to "Streaming Response" on the connected trigger node.',
type: 'warning',
location: 'outputPane',
whenToDisplay: 'afterExecution',
displayCondition: '={{ $parameter["enableStreaming"] === true }}',
},
],
};
}
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
return await toolsAgentExecute.call(this);
}
}
@@ -0,0 +1,96 @@
// Function used in the inputs expression to figure out which inputs to
import {
type INodeInputConfiguration,
type INodeFilter,
type NodeConnectionType,
} from 'n8n-workflow';
// display based on the agent type
/* istanbul ignore next */
export function getInputs(
hasMainInput?: boolean,
hasOutputParser?: boolean,
needsFallback?: boolean,
): Array<NodeConnectionType | INodeInputConfiguration> {
interface SpecialInput {
type: NodeConnectionType;
filter?: INodeFilter;
displayName: string;
required?: boolean;
}
const getInputData = (
inputs: SpecialInput[],
): Array<NodeConnectionType | INodeInputConfiguration> => {
return inputs.map(({ type, filter, displayName, required }) => {
const input: INodeInputConfiguration = {
type,
displayName,
required,
maxConnections: ['ai_languageModel', 'ai_memory', 'ai_outputParser'].includes(type)
? 1
: undefined,
};
if (filter) {
input.filter = filter;
}
return input;
});
};
let specialInputs: SpecialInput[] = [
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_languageModel',
displayName: 'Fallback Model',
required: true,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
displayName: 'Memory',
type: 'ai_memory',
},
{
displayName: 'Tool',
type: 'ai_tool',
},
{
displayName: 'Output Parser',
type: 'ai_outputParser',
},
];
if (hasOutputParser === false) {
specialInputs = specialInputs.filter((input) => input.type !== 'ai_outputParser');
}
if (needsFallback === false) {
specialInputs = specialInputs.filter((input) => input.displayName !== 'Fallback Model');
}
// Note cannot use NodeConnectionType.Main
// otherwise expression won't evaluate correctly on the FE
const mainInputs = hasMainInput ? ['main' as NodeConnectionType] : [];
return [...mainInputs, ...getInputData(specialInputs)];
}
@@ -0,0 +1,103 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
ISupplyDataFunctions,
EngineResponse,
EngineRequest,
} from 'n8n-workflow';
import type { RequestResponseMetadata } from '@utils/agent-execution';
import { textInput, toolDescription } from '@utils/descriptions';
import { getInputs } from '../utils';
import { toolsAgentProperties } from '../agents/ToolsAgent/V3/description';
import { toolsAgentExecute } from '../agents/ToolsAgent/V3/execute';
export class AgentToolV3 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: [3],
defaults: {
name: 'AI Agent Tool',
color: '#404040',
},
inputs: `={{
((hasOutputParser, needsFallback) => {
${getInputs.toString()};
return getInputs(false, hasOutputParser, needsFallback)
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
}}`,
outputs: [NodeConnectionTypes.AiTool],
builderHint: {
...baseDescription.builderHint,
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
ai_outputParser: {
required: false,
displayOptions: { show: { hasOutputParser: [true] } },
},
},
},
properties: [
toolDescription,
{
...textInput,
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
},
},
},
{
displayName: 'Enable Fallback Model',
name: 'needsFallback',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName:
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
name: 'fallbackNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
needsFallback: [true],
},
},
},
toolsAgentProperties,
],
};
}
// Automatically wrapped as a tool
async execute(
this: IExecuteFunctions | ISupplyDataFunctions,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<INodeExecutionData[][] | EngineRequest<RequestResponseMetadata>> {
return await toolsAgentExecute.call(this, response);
}
}
@@ -0,0 +1,153 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
INodeTypeBaseDescription,
EngineResponse,
EngineRequest,
} from 'n8n-workflow';
import type { RequestResponseMetadata } from '@utils/agent-execution';
import {
promptTypeOptions,
promptTypeOptionsDeprecated,
textFromGuardrailsNode,
textFromPreviousNode,
textInput,
} from '@utils/descriptions';
import { toolsAgentProperties } from '../agents/ToolsAgent/V3/description';
import { toolsAgentExecute } from '../agents/ToolsAgent/V3/execute';
import { getInputs } from '../utils';
export class AgentV3 implements INodeType {
description: INodeTypeDescription;
constructor(baseDescription: INodeTypeBaseDescription) {
this.description = {
...baseDescription,
version: [3, 3.1],
defaults: {
name: 'AI Agent',
color: '#404040',
},
inputs: `={{
((hasOutputParser, needsFallback) => {
${getInputs.toString()};
return getInputs(true, hasOutputParser, needsFallback);
})($parameter.hasOutputParser === undefined || $parameter.hasOutputParser === true, $parameter.needsFallback !== undefined && $parameter.needsFallback === true)
}}`,
outputs: [NodeConnectionTypes.Main],
builderHint: {
...baseDescription.builderHint,
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
ai_outputParser: {
required: false,
displayOptions: { show: { hasOutputParser: [true] } },
},
},
},
properties: [
{
displayName:
'Tip: Get a feel for agents with our quick <a href="https://docs.n8n.io/advanced-ai/intro-tutorial/" target="_blank">tutorial</a> or see an <a href="/workflows/templates/1954" target="_blank">example</a> of how this node works',
name: 'aiAgentStarterCallout',
type: 'callout',
default: '',
},
{
...promptTypeOptionsDeprecated,
displayOptions: { show: { '@version': [{ _cnd: { lt: 3.1 } }] } },
},
{
...promptTypeOptions,
displayOptions: { show: { '@version': [{ _cnd: { gte: 3.1 } }] } },
},
{
...textFromGuardrailsNode,
displayOptions: {
show: {
promptType: ['guardrails'],
},
},
},
{
...textFromPreviousNode,
displayOptions: {
show: {
promptType: ['auto'],
},
},
},
{
...textInput,
displayOptions: {
show: {
promptType: ['define'],
},
},
},
{
displayName: 'Require Specific Output Format',
name: 'hasOutputParser',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
show: {
hasOutputParser: [true],
},
},
},
{
displayName: 'Enable Fallback Model',
name: 'needsFallback',
type: 'boolean',
default: false,
noDataExpression: true,
},
{
displayName:
'Connect an additional language model on the canvas to use it as a fallback if the main model fails',
name: 'fallbackNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
needsFallback: [true],
},
},
},
toolsAgentProperties,
],
hints: [
{
message:
'You are using streaming responses. Make sure to set the response mode to "Streaming Response" on the connected trigger node.',
type: 'warning',
location: 'outputPane',
whenToDisplay: 'afterExecution',
displayCondition: '={{ $parameter["enableStreaming"] === true }}',
},
],
};
}
async execute(
this: IExecuteFunctions,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<INodeExecutionData[][] | EngineRequest<RequestResponseMetadata>> {
return await toolsAgentExecute.call(this, response);
}
}
@@ -0,0 +1,93 @@
import type { INodeProperties } from 'n8n-workflow';
import { SYSTEM_MESSAGE, HUMAN_MESSAGE } from './prompt';
export const conversationalAgentProperties: INodeProperties[] = [
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['conversationalAgent'],
'@version': [1],
},
},
default: '={{ $json.input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['conversationalAgent'],
'@version': [1.1],
},
},
default: '={{ $json.chat_input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['conversationalAgent'],
'@version': [1.2],
},
},
default: '={{ $json.chatInput }}',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['conversationalAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Human Message',
name: 'humanMessage',
type: 'string',
default: HUMAN_MESSAGE,
description: 'The message that will provide the agent with a list of tools to use',
typeOptions: {
rows: 6,
},
},
{
displayName: 'System Message',
name: 'systemMessage',
type: 'string',
default: SYSTEM_MESSAGE,
description: 'The message that will be sent to the agent before the conversation starts',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Max Iterations',
name: 'maxIterations',
type: 'number',
default: 10,
description: 'The maximum number of iterations the agent will run before stopping',
},
{
displayName: 'Return Intermediate Steps',
name: 'returnIntermediateSteps',
type: 'boolean',
default: false,
description: 'Whether or not the output should include intermediate steps the agent took',
},
],
},
];
@@ -0,0 +1,117 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import { PromptTemplate } from '@langchain/core/prompts';
import { initializeAgentExecutorWithOptions } from '@langchain/classic/agents';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { isChatInstance } from '@n8n/ai-utilities';
import { getPromptInputByType, getConnectedTools } from '@utils/helpers';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '@utils/schemaParsing';
import { getTracingConfig } from '@utils/tracing';
import { checkForStructuredTools, extractParsedOutput } from '../utils';
export async function conversationalAgentExecute(
this: IExecuteFunctions,
nodeVersion: number,
): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing Conversational Agent');
const model = await this.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0);
if (!isChatInstance(model)) {
throw new NodeOperationError(this.getNode(), 'Conversational Agent requires Chat Model');
}
const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
const outputParser = await getOptionalOutputParser(this);
await checkForStructuredTools(tools, this.getNode(), 'Conversational Agent');
// TODO: Make it possible in the future to use values for other items than just 0
const options = this.getNodeParameter('options', 0, {}) as {
systemMessage?: string;
humanMessage?: string;
maxIterations?: number;
returnIntermediateSteps?: boolean;
};
const agentExecutor = await initializeAgentExecutorWithOptions(tools, model, {
// Passing "chat-conversational-react-description" as the agent type
// automatically creates and uses BufferMemory with the executor.
// If you would like to override this, you can pass in a custom
// memory option, but the memoryKey set on it must be "chat_history".
agentType: 'chat-conversational-react-description',
memory,
returnIntermediateSteps: options?.returnIntermediateSteps === true,
maxIterations: options.maxIterations ?? 10,
agentArgs: {
systemMessage: options.systemMessage,
humanMessage: options.humanMessage,
},
});
const returnData: INodeExecutionData[] = [];
let prompt: PromptTemplate | undefined;
if (outputParser) {
const formatInstructions = outputParser.getFormatInstructions();
prompt = new PromptTemplate({
template: '{input}\n{formatInstructions}',
inputVariables: ['input'],
partialVariables: { formatInstructions },
});
}
const items = this.getInputData();
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
let input;
if (this.getNode().typeVersion <= 1.2) {
input = this.getNodeParameter('text', itemIndex) as string;
} else {
input = getPromptInputByType({
ctx: this,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
}
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The text parameter is empty.');
}
if (prompt) {
input = (await prompt.invoke({ input })).value;
}
const response = await agentExecutor
.withConfig(getTracingConfig(this))
.invoke({ input, outputParser });
if (outputParser) {
response.output = await extractParsedOutput(this, outputParser, response.output as string);
}
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,21 @@
export const SYSTEM_MESSAGE = `Assistant is a large language model trained by OpenAI.
Assistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.
Assistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.
Overall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist.`;
export const HUMAN_MESSAGE = `TOOLS
------
Assistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:
{tools}
{format_instructions}
USER'S INPUT
--------------------
Here is the user's input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):
{{input}}`;
@@ -0,0 +1,83 @@
import type { INodeProperties } from 'n8n-workflow';
import { SYSTEM_MESSAGE } from './prompt';
export const openAiFunctionsAgentProperties: INodeProperties[] = [
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['openAiFunctionsAgent'],
'@version': [1],
},
},
default: '={{ $json.input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['openAiFunctionsAgent'],
'@version': [1.1],
},
},
default: '={{ $json.chat_input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['openAiFunctionsAgent'],
'@version': [1.2],
},
},
default: '={{ $json.chatInput }}',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['openAiFunctionsAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'System Message',
name: 'systemMessage',
type: 'string',
default: SYSTEM_MESSAGE,
description: 'The message that will be sent to the agent before the conversation starts',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Max Iterations',
name: 'maxIterations',
type: 'number',
default: 10,
description: 'The maximum number of iterations the agent will run before stopping',
},
{
displayName: 'Return Intermediate Steps',
name: 'returnIntermediateSteps',
type: 'boolean',
default: false,
description: 'Whether or not the output should include intermediate steps the agent took',
},
],
},
];
@@ -0,0 +1,20 @@
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { toolsAgentExecute } from '../ToolsAgent/V1/execute';
/**
* OpenAI Functions Agent (legacy) - redirects to Tools Agent
*
* The OpenAI Functions Agent uses the legacy @langchain/classic API which has
* compatibility issues with langchain 1.0. The Tools Agent uses the modern
* createToolCallingAgent API which works correctly.
*
* Since both agents provide similar functionality (calling tools/functions),
* we redirect to the Tools Agent implementation for better compatibility.
*/
export async function openAiFunctionsAgentExecute(
this: IExecuteFunctions,
_nodeVersion: number,
): Promise<INodeExecutionData[][]> {
return await toolsAgentExecute.call(this);
}
@@ -0,0 +1 @@
export const SYSTEM_MESSAGE = 'You are a helpful AI assistant.';
@@ -0,0 +1,69 @@
import type { INodeProperties } from 'n8n-workflow';
import { DEFAULT_STEP_EXECUTOR_HUMAN_CHAT_MESSAGE_TEMPLATE } from './prompt';
export const planAndExecuteAgentProperties: INodeProperties[] = [
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['planAndExecuteAgent'],
'@version': [1],
},
},
default: '={{ $json.input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['planAndExecuteAgent'],
'@version': [1.1],
},
},
default: '={{ $json.chat_input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['planAndExecuteAgent'],
'@version': [1.2],
},
},
default: '={{ $json.chatInput }}',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['planAndExecuteAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Human Message Template',
name: 'humanMessageTemplate',
type: 'string',
default: DEFAULT_STEP_EXECUTOR_HUMAN_CHAT_MESSAGE_TEMPLATE,
description: 'The message that will be sent to the agent during each step execution',
typeOptions: {
rows: 6,
},
},
],
},
];
@@ -0,0 +1,100 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { PromptTemplate } from '@langchain/core/prompts';
import { PlanAndExecuteAgentExecutor } from '@langchain/classic/experimental/plan_and_execute';
import {
type IExecuteFunctions,
type INodeExecutionData,
NodeConnectionTypes,
NodeOperationError,
} from 'n8n-workflow';
import { getConnectedTools, getPromptInputByType } from '@utils/helpers';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '@utils/schemaParsing';
import { getTracingConfig } from '@utils/tracing';
import { checkForStructuredTools, extractParsedOutput } from '../utils';
export async function planAndExecuteAgentExecute(
this: IExecuteFunctions,
nodeVersion: number,
): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing PlanAndExecute Agent');
const model = (await this.getInputConnectionData(
NodeConnectionTypes.AiLanguageModel,
0,
)) as BaseChatModel;
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
await checkForStructuredTools(tools, this.getNode(), 'Plan & Execute Agent');
const outputParser = await getOptionalOutputParser(this);
const options = this.getNodeParameter('options', 0, {}) as {
humanMessageTemplate?: string;
};
const agentExecutor = await PlanAndExecuteAgentExecutor.fromLLMAndTools({
llm: model,
tools,
humanMessageTemplate: options.humanMessageTemplate,
});
const returnData: INodeExecutionData[] = [];
let prompt: PromptTemplate | undefined;
if (outputParser) {
const formatInstructions = outputParser.getFormatInstructions();
prompt = new PromptTemplate({
template: '{input}\n{formatInstructions}',
inputVariables: ['input'],
partialVariables: { formatInstructions },
});
}
const items = this.getInputData();
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
let input;
if (this.getNode().typeVersion <= 1.2) {
input = this.getNodeParameter('text', itemIndex) as string;
} else {
input = getPromptInputByType({
ctx: this,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
}
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The text parameter is empty.');
}
if (prompt) {
input = (await prompt.invoke({ input })).value;
}
const response = await agentExecutor
.withConfig(getTracingConfig(this))
.invoke({ input, outputParser });
if (outputParser) {
response.output = await extractParsedOutput(this, outputParser, response.output as string);
}
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,7 @@
export const DEFAULT_STEP_EXECUTOR_HUMAN_CHAT_MESSAGE_TEMPLATE = `Previous steps: {previous_steps}
Current objective: {current_step}
{agent_scratchpad}
You may extract and combine relevant data from your previous steps when responding to me.`;
@@ -0,0 +1,115 @@
import type { INodeProperties } from 'n8n-workflow';
import { HUMAN_MESSAGE_TEMPLATE, PREFIX, SUFFIX, SUFFIX_CHAT } from './prompt';
export const reActAgentAgentProperties: INodeProperties[] = [
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['reActAgent'],
'@version': [1],
},
},
default: '={{ $json.input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['reActAgent'],
'@version': [1.1],
},
},
default: '={{ $json.chat_input }}',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
displayOptions: {
show: {
agent: ['reActAgent'],
'@version': [1.2],
},
},
default: '={{ $json.chatInput }}',
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['reActAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Human Message Template',
name: 'humanMessageTemplate',
type: 'string',
default: HUMAN_MESSAGE_TEMPLATE,
description: 'String to use directly as the human message template',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Prefix Message',
name: 'prefix',
type: 'string',
default: PREFIX,
description: 'String to put before the list of tools',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Suffix Message for Chat Model',
name: 'suffixChat',
type: 'string',
default: SUFFIX_CHAT,
description:
'String to put after the list of tools that will be used if chat model is used',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Suffix Message for Regular Model',
name: 'suffix',
type: 'string',
default: SUFFIX,
description:
'String to put after the list of tools that will be used if regular model is used',
typeOptions: {
rows: 6,
},
},
{
displayName: 'Max Iterations',
name: 'maxIterations',
type: 'number',
default: 10,
description: 'The maximum number of iterations the agent will run before stopping',
},
{
displayName: 'Return Intermediate Steps',
name: 'returnIntermediateSteps',
type: 'boolean',
default: false,
description: 'Whether or not the output should include intermediate steps the agent took',
},
],
},
];
@@ -0,0 +1,124 @@
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { PromptTemplate } from '@langchain/core/prompts';
import { AgentExecutor, ChatAgent, ZeroShotAgent } from '@langchain/classic/agents';
import {
type IExecuteFunctions,
type INodeExecutionData,
NodeConnectionTypes,
NodeOperationError,
} from 'n8n-workflow';
import { isChatInstance } from '@n8n/ai-utilities';
import { getConnectedTools, getPromptInputByType } from '@utils/helpers';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { throwIfToolSchema } from '@utils/schemaParsing';
import { getTracingConfig } from '@utils/tracing';
import { checkForStructuredTools, extractParsedOutput } from '../utils';
export async function reActAgentAgentExecute(
this: IExecuteFunctions,
nodeVersion: number,
): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing ReAct Agent');
const model = (await this.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0)) as
| BaseLanguageModel
| BaseChatModel;
const tools = await getConnectedTools(this, nodeVersion >= 1.5, true, true);
await checkForStructuredTools(tools, this.getNode(), 'ReAct Agent');
const outputParser = await getOptionalOutputParser(this);
const options = this.getNodeParameter('options', 0, {}) as {
prefix?: string;
suffix?: string;
suffixChat?: string;
maxIterations?: number;
humanMessageTemplate?: string;
returnIntermediateSteps?: boolean;
};
let agent: ChatAgent | ZeroShotAgent;
if (isChatInstance(model)) {
agent = ChatAgent.fromLLMAndTools(model, tools, {
prefix: options.prefix,
suffix: options.suffixChat,
humanMessageTemplate: options.humanMessageTemplate,
});
} else {
agent = ZeroShotAgent.fromLLMAndTools(model, tools, {
prefix: options.prefix,
suffix: options.suffix,
});
}
const agentExecutor = AgentExecutor.fromAgentAndTools({
agent,
tools,
returnIntermediateSteps: options?.returnIntermediateSteps === true,
maxIterations: options.maxIterations ?? 10,
});
const returnData: INodeExecutionData[] = [];
let prompt: PromptTemplate | undefined;
if (outputParser) {
const formatInstructions = outputParser.getFormatInstructions();
prompt = new PromptTemplate({
template: '{input}\n{formatInstructions}',
inputVariables: ['input'],
partialVariables: { formatInstructions },
});
}
const items = this.getInputData();
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
let input;
if (this.getNode().typeVersion <= 1.2) {
input = this.getNodeParameter('text', itemIndex) as string;
} else {
input = getPromptInputByType({
ctx: this,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
}
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The text parameter is empty.');
}
if (prompt) {
input = (await prompt.invoke({ input })).value;
}
const response = await agentExecutor
.withConfig(getTracingConfig(this))
.invoke({ input, outputParser });
if (outputParser) {
response.output = await extractParsedOutput(this, outputParser, response.output as string);
}
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,12 @@
export const PREFIX =
'Answer the following questions as best you can. You have access to the following tools:';
export const SUFFIX_CHAT =
'Begin! Reminder to always use the exact characters `Final Answer` when responding.';
export const SUFFIX = `Begin!
Question: {input}
Thought:{agent_scratchpad}`;
export const HUMAN_MESSAGE_TEMPLATE = '{input}\n\n{agent_scratchpad}';
@@ -0,0 +1,213 @@
import type { INodeProperties } from 'n8n-workflow';
import {
promptTypeOptionsDeprecated,
textFromGuardrailsNode,
textFromPreviousNode,
textInput,
} from '@utils/descriptions';
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
const dataSourceOptions: INodeProperties = {
displayName: 'Data Source',
name: 'dataSource',
type: 'options',
displayOptions: {
show: {
agent: ['sqlAgent'],
},
},
default: 'sqlite',
description: 'SQL database to connect to',
options: [
{
name: 'MySQL',
value: 'mysql',
description: 'Connect to a MySQL database',
},
{
name: 'Postgres',
value: 'postgres',
description: 'Connect to a Postgres database',
},
{
name: 'SQLite',
value: 'sqlite',
description: 'Use SQLite by connecting a database file as binary input',
},
],
};
export const sqlAgentAgentProperties: INodeProperties[] = [
{
...dataSourceOptions,
displayOptions: {
show: {
agent: ['sqlAgent'],
'@version': [{ _cnd: { lt: 1.4 } }],
},
},
},
{
...dataSourceOptions,
default: 'postgres',
displayOptions: {
show: {
agent: ['sqlAgent'],
'@version': [{ _cnd: { gte: 1.4 } }],
},
},
},
{
displayName: 'Credentials',
name: 'credentials',
type: 'credentials',
default: '',
},
{
displayName:
"Pass the SQLite database into this node as binary data, e.g. by inserting a 'Read/Write Files from Disk' node beforehand",
name: 'sqLiteFileNotice',
type: 'notice',
default: '',
displayOptions: {
show: {
agent: ['sqlAgent'],
dataSource: ['sqlite'],
},
},
},
{
displayName: 'Input Binary Field',
name: 'binaryPropertyName',
type: 'string',
default: 'data',
required: true,
placeholder: 'e.g data',
hint: 'The name of the input binary field containing the file to be extracted',
displayOptions: {
show: {
agent: ['sqlAgent'],
dataSource: ['sqlite'],
},
},
},
{
displayName: 'Prompt',
name: 'input',
type: 'string',
displayOptions: {
show: {
agent: ['sqlAgent'],
'@version': [{ _cnd: { lte: 1.2 } }],
},
},
default: '',
required: true,
typeOptions: {
rows: 5,
},
},
{
...promptTypeOptionsDeprecated,
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.2 } }],
},
show: {
agent: ['sqlAgent'],
},
},
},
{
...textFromGuardrailsNode,
displayOptions: {
show: {
promptType: ['guardrails'],
'@version': [{ _cnd: { gte: 1.7 } }],
agent: ['sqlAgent'],
},
},
},
{
...textFromPreviousNode,
displayOptions: {
show: { promptType: ['auto'], '@version': [{ _cnd: { gte: 1.7 } }], agent: ['sqlAgent'] },
},
},
{
...textInput,
displayOptions: {
show: {
promptType: ['define'],
agent: ['sqlAgent'],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['sqlAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [
{
displayName: 'Ignored Tables',
name: 'ignoredTables',
type: 'string',
default: '',
description:
'Comma-separated list of tables to ignore from the database. If empty, no tables are ignored.',
},
{
displayName: 'Include Sample Rows',
name: 'includedSampleRows',
type: 'number',
description:
'Number of sample rows to include in the prompt to the agent. It helps the agent to understand the schema of the database but it also increases the amount of tokens used.',
default: 3,
},
{
displayName: 'Included Tables',
name: 'includedTables',
type: 'string',
default: '',
description:
'Comma-separated list of tables to include in the database. If empty, all tables are included.',
},
{
displayName: 'Prefix Prompt',
name: 'prefixPrompt',
type: 'string',
default: SQL_PREFIX,
description: 'Prefix prompt to use for the agent',
typeOptions: {
rows: 10,
},
},
{
displayName: 'Suffix Prompt',
name: 'suffixPrompt',
type: 'string',
default: SQL_SUFFIX,
description: 'Suffix prompt to use for the agent',
typeOptions: {
rows: 4,
},
},
{
displayName: 'Limit',
name: 'topK',
type: 'number',
default: 10,
description: 'The maximum number of results to return',
},
],
},
];
@@ -0,0 +1,155 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import type { DataSource } from '@n8n/typeorm';
import type { SqlCreatePromptArgs } from '@langchain/classic/agents/toolkits/sql';
import { SqlToolkit, createSqlAgent } from '@langchain/classic/agents/toolkits/sql';
import { SqlDatabase } from '@langchain/classic/sql_db';
import {
type IExecuteFunctions,
type INodeExecutionData,
NodeConnectionTypes,
NodeOperationError,
type IDataObject,
} from 'n8n-workflow';
import { getPromptInputByType, serializeChatHistory } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing';
import { getMysqlDataSource } from './other/handlers/mysql';
import { getPostgresDataSource } from './other/handlers/postgres';
import { getSqliteDataSource } from './other/handlers/sqlite';
import { SQL_PREFIX, SQL_SUFFIX } from './other/prompts';
const parseTablesString = (tablesString: string) =>
tablesString
.split(',')
.map((table) => table.trim())
.filter((table) => table.length > 0);
export async function sqlAgentAgentExecute(
this: IExecuteFunctions,
): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing SQL Agent');
const model = (await this.getInputConnectionData(
NodeConnectionTypes.AiLanguageModel,
0,
)) as BaseLanguageModel;
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let i = 0; i < items.length; i++) {
try {
const item = items[i];
let input;
if (this.getNode().typeVersion <= 1.2) {
input = this.getNodeParameter('input', i) as string;
} else {
input = getPromptInputByType({
ctx: this,
i,
inputKey: 'text',
promptTypeKey: 'promptType',
});
}
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The prompt parameter is empty.');
}
const options = this.getNodeParameter('options', i, {});
const selectedDataSource = this.getNodeParameter('dataSource', i, 'sqlite') as
| 'mysql'
| 'postgres'
| 'sqlite';
const includedSampleRows = options.includedSampleRows as number;
const includedTablesArray = parseTablesString((options.includedTables as string) ?? '');
const ignoredTablesArray = parseTablesString((options.ignoredTables as string) ?? '');
let dataSource: DataSource | null = null;
if (selectedDataSource === 'sqlite') {
if (!item.binary) {
throw new NodeOperationError(
this.getNode(),
'No binary data found, please connect a binary to the input if you want to use SQLite as data source',
);
}
const binaryPropertyName = this.getNodeParameter('binaryPropertyName', i, 'data');
dataSource = await getSqliteDataSource.call(this, item.binary, binaryPropertyName);
}
if (selectedDataSource === 'postgres') {
dataSource = await getPostgresDataSource.call(this);
}
if (selectedDataSource === 'mysql') {
dataSource = await getMysqlDataSource.call(this);
}
if (!dataSource) {
throw new NodeOperationError(
this.getNode(),
'No data source found, please configure data source',
);
}
const agentOptions: SqlCreatePromptArgs = {
topK: (options.topK as number) ?? 10,
prefix: (options.prefixPrompt as string) ?? SQL_PREFIX,
suffix: (options.suffixPrompt as string) ?? SQL_SUFFIX,
inputVariables: ['chatHistory', 'input', 'agent_scratchpad'],
};
const dbInstance = await SqlDatabase.fromDataSourceParams({
appDataSource: dataSource,
includesTables: includedTablesArray.length > 0 ? includedTablesArray : undefined,
ignoreTables: ignoredTablesArray.length > 0 ? ignoredTablesArray : undefined,
sampleRowsInTableInfo: includedSampleRows ?? 3,
});
const toolkit = new SqlToolkit(dbInstance, model);
const agentExecutor = createSqlAgent(model, toolkit, agentOptions);
const memory = (await this.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
agentExecutor.memory = memory;
let chatHistory = '';
if (memory) {
const messages = await memory.chatHistory.getMessages();
chatHistory = serializeChatHistory(messages);
}
let response: IDataObject;
try {
response = await agentExecutor.withConfig(getTracingConfig(this)).invoke({
input,
signal: this.getExecutionCancelSignal(),
chatHistory,
});
} catch (error) {
if ((error.message as IDataObject)?.output) {
response = error.message as IDataObject;
} else {
throw new NodeOperationError(this.getNode(), error.message as string, { itemIndex: i });
}
}
returnData.push({ json: response });
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: i } });
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,20 @@
import { DataSource } from '@n8n/typeorm';
import { type IExecuteFunctions } from 'n8n-workflow';
export async function getMysqlDataSource(this: IExecuteFunctions): Promise<DataSource> {
const credentials = await this.getCredentials('mySql');
const dataSource = new DataSource({
type: 'mysql',
host: credentials.host as string,
port: credentials.port as number,
username: credentials.user as string,
password: credentials.password as string,
database: credentials.database as string,
ssl: {
rejectUnauthorized: credentials.ssl as boolean,
},
});
return dataSource;
}
@@ -0,0 +1,156 @@
import { mock } from 'jest-mock-extended';
import type { PostgresNodeCredentials } from 'n8n-nodes-base/nodes/Postgres/v2/helpers/interfaces';
import type { IExecuteFunctions } from 'n8n-workflow';
import { getPostgresDataSource } from './postgres';
describe('Postgres SSL settings', () => {
const credentials = mock<PostgresNodeCredentials>({
host: 'localhost',
port: 5432,
user: 'user',
password: 'password',
database: 'database',
});
test('ssl is disabled + allowUnauthorizedCerts is false', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'disable',
allowUnauthorizedCerts: false,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: false,
});
});
test('ssl is disabled + allowUnauthorizedCerts is true', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'disable',
allowUnauthorizedCerts: true,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: false,
});
});
test('ssl is disabled + allowUnauthorizedCerts is undefined', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'disable',
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: false,
});
});
test('ssl is allow + allowUnauthorizedCerts is false', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'allow',
allowUnauthorizedCerts: false,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: true,
});
});
test('ssl is allow + allowUnauthorizedCerts is true', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'allow',
allowUnauthorizedCerts: true,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: { rejectUnauthorized: false },
});
});
test('ssl is allow + allowUnauthorizedCerts is undefined', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'allow',
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: true,
});
});
test('ssl is require + allowUnauthorizedCerts is false', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'require',
allowUnauthorizedCerts: false,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: true,
});
});
test('ssl is require + allowUnauthorizedCerts is true', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'require',
allowUnauthorizedCerts: true,
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: { rejectUnauthorized: false },
});
});
test('ssl is require + allowUnauthorizedCerts is undefined', async () => {
const context = mock<IExecuteFunctions>({
getCredentials: jest.fn().mockReturnValue({
...credentials,
ssl: 'require',
}),
});
const dataSource = await getPostgresDataSource.call(context);
expect(dataSource.options).toMatchObject({
ssl: true,
});
});
});
@@ -0,0 +1,23 @@
import { DataSource } from '@n8n/typeorm';
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
import { type IExecuteFunctions } from 'n8n-workflow';
import type { TlsOptions } from 'tls';
export async function getPostgresDataSource(this: IExecuteFunctions): Promise<DataSource> {
const credentials = await this.getCredentials<PostgresNodeCredentials>('postgres');
let ssl: TlsOptions | boolean = !['disable', undefined].includes(credentials.ssl);
if (credentials.allowUnauthorizedCerts && ssl) {
ssl = { rejectUnauthorized: false };
}
return new DataSource({
type: 'postgres',
host: credentials.host,
port: credentials.port,
username: credentials.user,
password: credentials.password,
database: credentials.database,
ssl,
});
}
@@ -0,0 +1,49 @@
import { DataSource } from '@n8n/typeorm';
import * as fs from 'fs';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { BINARY_ENCODING, NodeOperationError } from 'n8n-workflow';
import * as sqlite3 from 'sqlite3';
import * as temp from 'temp';
export async function getSqliteDataSource(
this: IExecuteFunctions,
binary: INodeExecutionData['binary'],
binaryPropertyName = 'data',
): Promise<DataSource> {
const binaryData = binary?.[binaryPropertyName];
if (!binaryData) {
throw new NodeOperationError(this.getNode(), 'No binary data received.');
}
let fileBase64;
if (binaryData.id) {
const chunkSize = 256 * 1024;
const stream = await this.helpers.getBinaryStream(binaryData.id, chunkSize);
const buffer = await this.helpers.binaryToBuffer(stream);
fileBase64 = buffer.toString('base64');
} else {
fileBase64 = binaryData.data;
}
const bufferString = Buffer.from(fileBase64, BINARY_ENCODING);
// Track and cleanup temp files at exit
temp.track();
const tempDbPath = temp.path({ suffix: '.sqlite' });
fs.writeFileSync(tempDbPath, bufferString);
// Initialize a new SQLite database from the temp file
const tempDb = new sqlite3.Database(tempDbPath, (error: Error | null) => {
if (error) {
throw new NodeOperationError(this.getNode(), 'Could not connect to database');
}
});
tempDb.close();
return new DataSource({
type: 'sqlite',
database: tempDbPath,
});
}
@@ -0,0 +1,20 @@
export const SQL_PREFIX = `You are an agent designed to interact with an SQL database.
Given an input question, create a syntactically correct {dialect} query to run, then look at the results of the query and return the answer.
Unless the user specifies a specific number of examples they wish to obtain, always limit your query to at most {top_k} results using the LIMIT clause.
You can order the results by a relevant column to return the most interesting examples in the database.
Never query for all the columns from a specific table, only ask for a the few relevant columns given the question.
You have access to tools for interacting with the database.
Only use the below tools. Only use the information returned by the below tools to construct your final answer.
You MUST double check your query before executing it. If you get an error while executing a query, rewrite the query and try again.
DO NOT make any DML statements (INSERT, UPDATE, DELETE, DROP etc.) to the database.
If the question does not seem related to the database, just return "I don't know" as the answer.`;
export const SQL_SUFFIX = `Begin!
Chat History:
{chatHistory}
Question: {input}
Thought: I should look at the tables in the database to see what I can query.
{agent_scratchpad}`;
@@ -0,0 +1,19 @@
import type { INodeProperties } from 'n8n-workflow';
import { commonOptions } from '../options';
export const toolsAgentProperties: INodeProperties[] = [
{
displayName: 'Options',
name: 'options',
type: 'collection',
displayOptions: {
show: {
agent: ['toolsAgent'],
},
},
default: {},
placeholder: 'Add Option',
options: [...commonOptions],
},
];
@@ -0,0 +1,139 @@
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
import { RunnableSequence } from '@langchain/core/runnables';
import { AgentExecutor, createToolCallingAgent } from '@langchain/classic/agents';
import omit from 'lodash/omit';
import { jsonParse, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData } from 'n8n-workflow';
import { getPromptInputByType } from '@utils/helpers';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import {
fixEmptyContentMessage,
getAgentStepsParser,
getChatModel,
getOptionalMemory,
getTools,
prepareMessages,
preparePrompt,
} from '../common';
import { SYSTEM_MESSAGE } from '../prompt';
/* -----------------------------------------------------------
Main Executor Function
----------------------------------------------------------- */
/**
* The main executor method for the Tools Agent.
*
* This function retrieves necessary components (model, memory, tools), prepares the prompt,
* creates the agent, and processes each input item. The error handling for each item is also
* managed here based on the node's continueOnFail setting.
*
* @returns The array of execution data for all processed items
*/
export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
this.logger.debug('Executing Tools Agent');
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const outputParser = await getOptionalOutputParser(this);
const tools = await getTools(this, outputParser);
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const model = (await getChatModel(this)) as BaseLanguageModel;
const memory = await getOptionalMemory(this);
const input = getPromptInputByType({
ctx: this,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The “text” parameter is empty.');
}
const options = this.getNodeParameter('options', itemIndex, {}) as {
systemMessage?: string;
maxIterations?: number;
returnIntermediateSteps?: boolean;
passthroughBinaryImages?: boolean;
};
// Prepare the prompt messages and prompt template.
const messages = await prepareMessages(this, itemIndex, {
systemMessage: options.systemMessage,
passthroughBinaryImages: options.passthroughBinaryImages ?? true,
outputParser,
});
const prompt = preparePrompt(messages);
// Create the base agent that calls tools.
const agent = createToolCallingAgent({
llm: model,
tools,
prompt,
streamRunnable: false,
});
agent.streamRunnable = false;
// Wrap the agent with parsers and fixes.
const runnableAgent = RunnableSequence.from([
agent,
getAgentStepsParser(outputParser, memory),
fixEmptyContentMessage,
]);
const executor = AgentExecutor.fromAgentAndTools({
agent: runnableAgent,
memory,
tools,
returnIntermediateSteps: options.returnIntermediateSteps === true,
maxIterations: options.maxIterations ?? 10,
});
// Invoke the executor with the given input and system message.
const response = await executor.invoke(
{
input,
system_message: options.systemMessage ?? SYSTEM_MESSAGE,
formatting_instructions:
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
},
{ signal: this.getExecutionCancelSignal() },
);
// If memory and outputParser are connected, parse the output.
if (memory && outputParser) {
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(
response.output as string,
);
response.output = parsedOutput?.output ?? parsedOutput;
}
// Omit internal keys before returning the result.
const itemResult = {
json: omit(
response,
'system_message',
'formatting_instructions',
'input',
'chat_history',
'agent_scratchpad',
),
};
returnData.push(itemResult);
} catch (error) {
if (this.continueOnFail()) {
returnData.push({
json: { error: error.message },
pairedItem: { item: itemIndex },
});
continue;
}
throw error;
}
}
return [returnData];
}
@@ -0,0 +1,48 @@
import type { INodeProperties } from 'n8n-workflow';
import { getBatchingOptionFields } from '@n8n/ai-utilities';
import { commonOptions } from '../options';
const enableStreaminOption: INodeProperties = {
displayName: 'Enable Streaming',
name: 'enableStreaming',
type: 'boolean',
default: true,
description: 'Whether this agent will stream the response in real-time as it generates text',
};
export const getToolsAgentProperties = ({
withStreaming,
}: { withStreaming: boolean }): INodeProperties[] => [
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [
...commonOptions,
getBatchingOptionFields(undefined, 1),
...(withStreaming ? [enableStreaminOption] : []),
],
displayOptions: {
hide: {
'@version': [{ _cnd: { lt: 2.2 } }],
},
},
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [...commonOptions, getBatchingOptionFields(undefined, 1)],
displayOptions: {
show: {
'@version': [{ _cnd: { lt: 2.2 } }],
},
},
},
];
@@ -0,0 +1,371 @@
import type { StreamEvent } from '@langchain/core/dist/tracers/event_stream';
import type { IterableReadableStream } from '@langchain/core/dist/utils/stream';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { AIMessageChunk, MessageContentText } from '@langchain/core/messages';
import type { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';
import {
AgentExecutor,
type AgentRunnableSequence,
createToolCallingAgent,
} from '@langchain/classic/agents';
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools';
import omit from 'lodash/omit';
import { jsonParse, NodeOperationError, sleep } from 'n8n-workflow';
import type { IExecuteFunctions, INodeExecutionData, ISupplyDataFunctions } from 'n8n-workflow';
import assert from 'node:assert';
import { loadMemory } from '@utils/agent-execution';
import { getPromptInputByType } from '@utils/helpers';
import {
getOptionalOutputParser,
type N8nOutputParser,
} from '@utils/output_parsers/N8nOutputParser';
import {
fixEmptyContentMessage,
getAgentStepsParser,
getChatModel,
getOptionalMemory,
getTools,
prepareMessages,
preparePrompt,
} from '../common';
import { SYSTEM_MESSAGE } from '../prompt';
import { ChatOpenAI } from '@langchain/openai';
/**
* Creates an agent executor with the given configuration
*/
export function createAgentExecutor(
model: BaseChatModel,
tools: Array<DynamicStructuredTool | Tool>,
prompt: ChatPromptTemplate,
options: { maxIterations?: number; returnIntermediateSteps?: boolean },
outputParser?: N8nOutputParser,
memory?: BaseChatMemory,
fallbackModel?: BaseChatModel | null,
) {
const agent = createToolCallingAgent({
llm: model,
tools,
prompt,
streamRunnable: false,
});
let fallbackAgent: AgentRunnableSequence | undefined;
if (fallbackModel) {
fallbackAgent = createToolCallingAgent({
llm: fallbackModel,
tools,
prompt,
streamRunnable: false,
});
}
const runnableAgent = RunnableSequence.from([
fallbackAgent ? agent.withFallbacks([fallbackAgent]) : agent,
getAgentStepsParser(outputParser, memory),
fixEmptyContentMessage,
]) as AgentRunnableSequence;
runnableAgent.singleAction = false;
runnableAgent.streamRunnable = false;
return AgentExecutor.fromAgentAndTools({
agent: runnableAgent,
memory,
tools,
returnIntermediateSteps: options.returnIntermediateSteps === true,
maxIterations: options.maxIterations ?? 10,
});
}
async function processEventStream(
ctx: IExecuteFunctions,
eventStream: IterableReadableStream<StreamEvent>,
itemIndex: number,
returnIntermediateSteps: boolean = false,
): Promise<{ output: string; intermediateSteps?: any[] }> {
const agentResult: { output: string; intermediateSteps?: any[] } = {
output: '',
};
if (returnIntermediateSteps) {
agentResult.intermediateSteps = [];
}
ctx.sendChunk('begin', itemIndex);
for await (const event of eventStream) {
// Stream chat model tokens as they come in
switch (event.event) {
case 'on_chat_model_stream':
const chunk = event.data?.chunk as AIMessageChunk;
if (chunk?.content) {
const chunkContent = chunk.content;
let chunkText = '';
if (Array.isArray(chunkContent)) {
for (const message of chunkContent) {
if (message?.type === 'text') {
chunkText += (message as MessageContentText)?.text;
}
}
} else if (typeof chunkContent === 'string') {
chunkText = chunkContent;
}
ctx.sendChunk('item', itemIndex, chunkText);
agentResult.output += chunkText;
}
break;
case 'on_chat_model_end':
// Capture full LLM response with tool calls for intermediate steps
if (returnIntermediateSteps && event.data) {
const chatModelData = event.data as any;
const output = chatModelData.output;
// Check if this LLM response contains tool calls
if (output?.tool_calls && output.tool_calls.length > 0) {
for (const toolCall of output.tool_calls) {
agentResult.intermediateSteps!.push({
action: {
tool: toolCall.name,
toolInput: toolCall.args,
log:
output.content ||
`Calling ${toolCall.name} with input: ${JSON.stringify(toolCall.args)}`,
messageLog: [output], // Include the full LLM response
toolCallId: toolCall.id,
type: toolCall.type,
},
});
}
}
}
break;
case 'on_tool_end':
// Capture tool execution results and match with action
if (returnIntermediateSteps && event.data && agentResult.intermediateSteps!.length > 0) {
const toolData = event.data as any;
// Find the matching intermediate step for this tool call
const matchingStep = agentResult.intermediateSteps!.find(
(step) => !step.observation && step.action.tool === event.name,
);
if (matchingStep) {
matchingStep.observation = toolData.output;
}
}
break;
default:
break;
}
}
ctx.sendChunk('end', itemIndex);
return agentResult;
}
function checkIsResponsesApi(model: BaseChatModel | null | undefined): boolean {
try {
const isUsingResponsesApi =
!!model && model instanceof ChatOpenAI && 'useResponsesApi' in model && model.useResponsesApi;
return isUsingResponsesApi;
} catch (error) {
return false;
}
}
/* -----------------------------------------------------------
Main Executor Function
----------------------------------------------------------- */
/**
* The main executor method for the Tools Agent.
*
* This function retrieves necessary components (model, memory, tools), prepares the prompt,
* creates the agent, and processes each input item. The error handling for each item is also
* managed here based on the node's continueOnFail setting.
*
* @param this Execute context. SupplyDataContext is passed when agent is as a tool
*
* @returns The array of execution data for all processed items
*/
export async function toolsAgentExecute(
this: IExecuteFunctions | ISupplyDataFunctions,
): Promise<INodeExecutionData[][]> {
const version = this.getNode().typeVersion;
this.logger.debug('Executing Tools Agent V2');
const returnData: INodeExecutionData[] = [];
const items = this.getInputData();
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 1) as number;
const delayBetweenBatches = this.getNodeParameter(
'options.batching.delayBetweenBatches',
0,
0,
) as number;
const needsFallback = this.getNodeParameter('needsFallback', 0, false) as boolean;
const memory = await getOptionalMemory(this);
const model = await getChatModel(this, 0);
assert(model, 'Please connect a model to the Chat Model input');
const fallbackModel = needsFallback ? await getChatModel(this, 1) : null;
// FIXME: remove when this is fixed: https://github.com/langchain-ai/langchainjs/pull/9082
// Responses API + tools is broken when using langchain default call handling. In V3 calls are handled differently, so it works.
if (checkIsResponsesApi(model)) {
throw new NodeOperationError(
this.getNode(),
`This model is not supported in ${version} version of the Agent node. Please upgrade the Agent node to the latest version.`,
);
}
if (checkIsResponsesApi(fallbackModel)) {
throw new NodeOperationError(
this.getNode(),
`This fallback model is not supported in ${version} version of the Agent node. Please upgrade the Agent node to the latest version.`,
);
}
if (needsFallback && !fallbackModel) {
throw new NodeOperationError(
this.getNode(),
'Please connect a model to the Fallback Model input or disable the fallback option',
);
}
// Check if streaming is enabled
const enableStreaming = this.getNodeParameter('options.enableStreaming', 0, true) as boolean;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchPromises = batch.map(async (_item, batchItemIndex) => {
const itemIndex = i + batchItemIndex;
const input = getPromptInputByType({
ctx: this,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The "text" parameter is empty.');
}
const outputParser = await getOptionalOutputParser(this, itemIndex);
const tools = await getTools(this, outputParser);
const options = this.getNodeParameter('options', itemIndex, {}) as {
systemMessage?: string;
maxIterations?: number;
returnIntermediateSteps?: boolean;
passthroughBinaryImages?: boolean;
};
// Prepare the prompt messages and prompt template.
const messages = await prepareMessages(this, itemIndex, {
systemMessage: options.systemMessage,
passthroughBinaryImages: options.passthroughBinaryImages ?? true,
outputParser,
});
const prompt: ChatPromptTemplate = preparePrompt(messages);
// Create executors for primary and fallback models
const executor = createAgentExecutor(
model,
tools,
prompt,
options,
outputParser,
memory,
fallbackModel,
);
// Invoke with fallback logic
const invokeParams = {
input,
system_message: options.systemMessage ?? SYSTEM_MESSAGE,
formatting_instructions:
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
};
const executeOptions = { signal: this.getExecutionCancelSignal() };
// Check if streaming is actually available
const isStreamingAvailable = 'isStreaming' in this ? this.isStreaming?.() : undefined;
if (
'isStreaming' in this &&
enableStreaming &&
isStreamingAvailable &&
this.getNode().typeVersion >= 2.1
) {
// Get chat history respecting the context window length configured in memory
const chatHistory = memory ? await loadMemory(memory, model) : undefined;
const eventStream = executor.streamEvents(
{
...invokeParams,
chat_history: chatHistory ?? undefined,
},
{
version: 'v2',
...executeOptions,
},
);
return await processEventStream(
this,
eventStream,
itemIndex,
options.returnIntermediateSteps,
);
} else {
// Handle regular execution
return await executor.invoke(invokeParams, executeOptions);
}
});
const batchResults = await Promise.allSettled(batchPromises);
// This is only used to check if the output parser is connected
// so we can parse the output if needed. Actual output parsing is done in the loop above
const outputParser = await getOptionalOutputParser(this, 0);
batchResults.forEach((result, index) => {
const itemIndex = i + index;
if (result.status === 'rejected') {
const error = result.reason as Error;
if (this.continueOnFail()) {
returnData.push({
json: { error: error.message },
pairedItem: { item: itemIndex },
});
return;
} else {
throw new NodeOperationError(this.getNode(), error);
}
}
const response = result.value;
// If memory and outputParser are connected, parse the output.
if (memory && outputParser) {
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(
response.output as string,
);
response.output = parsedOutput?.output ?? parsedOutput;
}
// Omit internal keys before returning the result.
const itemResult = {
json: omit(
response,
'system_message',
'formatting_instructions',
'input',
'chat_history',
'agent_scratchpad',
),
pairedItem: { item: itemIndex },
};
returnData.push(itemResult);
});
if (i + batchSize < items.length && delayBetweenBatches > 0) {
await sleep(delayBetweenBatches);
}
}
return [returnData];
}
@@ -0,0 +1,36 @@
import type { INodeProperties } from 'n8n-workflow';
import { getBatchingOptionFields } from '@n8n/ai-utilities';
import { commonOptions } from '../options';
const enableStreaminOption: INodeProperties = {
displayName: 'Enable Streaming',
name: 'enableStreaming',
type: 'boolean',
default: true,
description: 'Whether this agent will stream the response in real-time as it generates text',
};
const maxTokensFromMemoryOption: INodeProperties = {
displayName: 'Max Tokens To Read From Memory',
name: 'maxTokensFromMemory',
type: 'hidden',
default: 0,
description:
'The maximum number of tokens to read from the chat memory history. Set to 0 to read all history.',
};
export const toolsAgentProperties: INodeProperties = {
displayName: 'Options',
name: 'options',
type: 'collection',
default: {},
placeholder: 'Add Option',
options: [
...commonOptions,
enableStreaminOption,
getBatchingOptionFields(undefined, 1),
maxTokensFromMemoryOption,
],
};
@@ -0,0 +1,81 @@
import type { RequestResponseMetadata } from '@utils/agent-execution';
import type {
EngineRequest,
EngineResponse,
IExecuteFunctions,
INodeExecutionData,
ISupplyDataFunctions,
} from 'n8n-workflow';
import { sleep } from 'n8n-workflow';
import { buildExecutionContext, executeBatch } from './helpers';
/* -----------------------------------------------------------
Main Executor Function
----------------------------------------------------------- */
/**
* The main executor method for the Tools Agent V3.
*
* This function orchestrates the execution across input batches, handling:
* - Building shared execution context (models, memory, batching config)
* - Processing items in batches with continue-on-fail logic
* - Returning either tool call requests or node output data
*
* @param this Execute context. SupplyDataContext is passed when agent is used as a tool
* @param response Optional engine response containing tool call results from previous execution
* @returns Array of execution data for all processed items, or engine request for tool calls
*/
export async function toolsAgentExecute(
this: IExecuteFunctions | ISupplyDataFunctions,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<INodeExecutionData[][] | EngineRequest<RequestResponseMetadata>> {
this.logger.debug('Executing Tools Agent V3');
let request: EngineRequest<RequestResponseMetadata> | undefined = undefined;
const returnData: INodeExecutionData[] = [];
// Build execution context with shared configuration
const executionContext = await buildExecutionContext(this);
const { items, batchSize, delayBetweenBatches, model, fallbackModel, memory } = executionContext;
// Process items in batches
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const { returnData: batchReturnData, request: batchRequest } = await executeBatch(
this,
batch,
i,
model,
fallbackModel,
memory,
response,
);
// Collect results from batch
returnData.push.apply(returnData, batchReturnData);
// Collect requests from batch
if (batchRequest) {
if (!request) {
request = batchRequest;
} else {
request.actions.push.apply(request.actions, batchRequest.actions);
}
}
// Apply delay between batches if configured
if (i + batchSize < items.length && delayBetweenBatches > 0) {
await sleep(delayBetweenBatches);
}
}
// Return tool call request if any tools need to be executed
if (request) {
return request;
}
// Otherwise return execution data
return [returnData];
}
@@ -0,0 +1,66 @@
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, ISupplyDataFunctions, INodeExecutionData } from 'n8n-workflow';
import assert from 'node:assert';
import { getChatModel, getOptionalMemory } from '../../common';
/**
* Execution context that contains shared configuration needed across all items
*/
export type ToolsAgentExecutionContext = {
items: INodeExecutionData[];
batchSize: number;
delayBetweenBatches: number;
needsFallback: boolean;
model: BaseChatModel;
fallbackModel: BaseChatModel | null;
memory: BaseChatMemory | undefined;
};
/**
* Builds the execution context by collecting shared configuration
* such as models, memory, batching settings, and streaming flags.
*
* @param ctx - The execution context (IExecuteFunctions or ISupplyDataFunctions)
* @returns ExecutionContext containing all shared configuration
*/
export async function buildToolsAgentExecutionContext(
ctx: IExecuteFunctions | ISupplyDataFunctions,
): Promise<ToolsAgentExecutionContext> {
const items = ctx.getInputData();
const batchSize = ctx.getNodeParameter('options.batching.batchSize', 0, 1) as number;
const delayBetweenBatches = ctx.getNodeParameter(
'options.batching.delayBetweenBatches',
0,
0,
) as number;
const needsFallback = ctx.getNodeParameter('needsFallback', 0, false) as boolean;
const memory = await getOptionalMemory(ctx);
const model = await getChatModel(ctx, 0);
assert(model, 'Please connect a model to the Chat Model input');
let fallbackModel: BaseChatModel | null = null;
if (needsFallback) {
const maybeFallbackModel = await getChatModel(ctx, 1);
if (!maybeFallbackModel) {
throw new NodeOperationError(
ctx.getNode(),
'Please connect a model to the Fallback Model input or disable the fallback option',
);
}
fallbackModel = maybeFallbackModel;
}
return {
items,
batchSize,
delayBetweenBatches,
needsFallback,
model,
fallbackModel,
memory,
};
}
@@ -0,0 +1,43 @@
import type { RequestResponseMetadata } from '@utils/agent-execution';
import { NodeOperationError } from 'n8n-workflow';
import type { INode, EngineResponse } from 'n8n-workflow';
/**
* Checks if the maximum iteration limit has been reached and throws an error if so.
*
* This function is called at the start of each agent execution to enforce
* the maximum number of tool call iterations allowed.
*
* @param response - The engine response containing iteration metadata (if this is a continuation)
* @param maxIterations - The maximum number of iterations allowed
* @param node - The current node (for error context)
* @throws {NodeOperationError} When the iteration count reaches or exceeds maxIterations
*
* @example
* ```typescript
* const response: EngineResponse<RequestResponseMetadata> = {
* // ... response data
* metadata: { iterationCount: 3 }
* };
*
* // This will throw if iterationCount >= maxIterations
* checkMaxIterations(response, 2, node);
* ```
*/
export function checkMaxIterations(
response: EngineResponse<RequestResponseMetadata> | undefined,
maxIterations: number,
node: INode,
): void {
// Only check if this is a continuation (response has iteration count)
if (response?.metadata?.iterationCount === undefined) {
return;
}
if (response.metadata.iterationCount >= maxIterations) {
throw new NodeOperationError(
node,
`Max iterations (${maxIterations}) reached. The agent could not complete the task within the allowed number of iterations.`,
);
}
}
@@ -0,0 +1,70 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';
import { type AgentRunnableSequence, createToolCallingAgent } from '@langchain/classic/agents';
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools';
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { fixEmptyContentMessage, getAgentStepsParser } from '../../common';
/**
* Creates an agent sequence with the given configuration.
* The sequence includes the agent, output parser, and fallback logic.
*
* @param model - The primary chat model
* @param tools - Array of tools available to the agent
* @param prompt - The prompt template
* @param _options - Additional options (maxIterations, returnIntermediateSteps)
* @param outputParser - Optional output parser for structured responses
* @param memory - Optional memory for conversation context
* @param fallbackModel - Optional fallback model if primary fails
* @returns AgentRunnableSequence ready for execution
*/
export function createAgentSequence(
model: BaseChatModel,
tools: Array<DynamicStructuredTool | Tool>,
prompt: ChatPromptTemplate,
_options: { maxIterations?: number; returnIntermediateSteps?: boolean },
outputParser?: N8nOutputParser,
memory?: BaseChatMemory,
fallbackModel?: BaseChatModel | null,
) {
const agent = createToolCallingAgent({
llm: model,
tools: getAllTools(model, tools),
prompt,
streamRunnable: false,
});
let fallbackAgent: AgentRunnableSequence | undefined;
if (fallbackModel) {
fallbackAgent = createToolCallingAgent({
llm: fallbackModel,
tools: getAllTools(fallbackModel, tools),
prompt,
streamRunnable: false,
});
}
const runnableAgent = RunnableSequence.from([
fallbackAgent ? agent.withFallbacks([fallbackAgent]) : agent,
getAgentStepsParser(outputParser, memory),
fixEmptyContentMessage,
]) as AgentRunnableSequence;
runnableAgent.singleAction = true;
runnableAgent.streamRunnable = false;
return runnableAgent;
}
/**
* Uses provided tools and tried to get tools from model metadata
* Some chat model nodes can define built-in tools in their metadata
*/
function getAllTools(model: BaseChatModel, tools: Array<DynamicStructuredTool | Tool>) {
const modelTools = (model.metadata?.tools as Tool[]) ?? [];
const allTools = [...tools, ...modelTools];
return allTools;
}
@@ -0,0 +1,140 @@
import type { AgentRunnableSequence } from '@langchain/classic/agents';
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { NodeOperationError, assertParamIsNumber } from 'n8n-workflow';
import type {
IExecuteFunctions,
ISupplyDataFunctions,
INodeExecutionData,
EngineResponse,
EngineRequest,
} from 'n8n-workflow';
import { processHitlResponses } from '@utils/agent-execution';
import type { RequestResponseMetadata } from '@utils/agent-execution/types';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import type { AgentResult } from '../types';
import { createAgentSequence } from './createAgentSequence';
import { finalizeResult } from './finalizeResult';
import { prepareItemContext } from './prepareItemContext';
import { runAgent } from './runAgent';
import { checkMaxIterations } from './checkMaxIterations';
type BatchResult = AgentResult | EngineRequest<RequestResponseMetadata>;
/**
* Executes a batch of items, handling both successful execution and errors.
* Applies continue-on-fail logic when errors occur.
*
* @param ctx - The execution context
* @param batch - Array of items to process in this batch
* @param startIndex - Starting index of the batch in the original items array (used to calculate itemIndex)
* @param model - Primary chat model
* @param fallbackModel - Optional fallback model
* @param memory - Optional memory for conversation context
* @param response - Optional engine response with previous tool calls
* @returns Object containing execution data and optional requests
*/
export async function executeBatch(
ctx: IExecuteFunctions | ISupplyDataFunctions,
batch: INodeExecutionData[],
startIndex: number,
model: BaseChatModel,
fallbackModel: BaseChatModel | null,
memory: BaseChatMemory | undefined,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<{
returnData: INodeExecutionData[];
request: EngineRequest<RequestResponseMetadata> | undefined;
}> {
const returnData: INodeExecutionData[] = [];
let request: EngineRequest<RequestResponseMetadata> | undefined = undefined;
// Process HITL (Human-in-the-Loop) tool responses before running the agent
// If there are approved HITL tools, we need to execute the gated tools first
const hitlResult = processHitlResponses(response, startIndex);
if (hitlResult.hasApprovedHitlTools && hitlResult.pendingGatedToolRequest) {
// Return the gated tool request immediately
// The Agent will resume after the gated tool executes
return {
returnData: [],
request: hitlResult.pendingGatedToolRequest,
};
}
// Use the processed response (with HITL denials properly formatted)
const processedResponse = hitlResult.processedResponse;
// Check max iterations if this is a continuation of a previous execution
const maxIterations = ctx.getNodeParameter('options.maxIterations', 0, 10);
assertParamIsNumber('options.maxIterations', maxIterations, ctx.getNode());
const batchPromises = batch.map(async (_item, batchItemIndex) => {
const itemIndex = startIndex + batchItemIndex;
checkMaxIterations(response, maxIterations, ctx.getNode());
const itemContext = await prepareItemContext(ctx, itemIndex, processedResponse);
const { tools, prompt, options, outputParser } = itemContext;
// Create executors for primary and fallback models
const executor: AgentRunnableSequence = createAgentSequence(
model,
tools,
prompt,
options,
outputParser,
memory,
fallbackModel,
);
// Run the agent with processed response
return await runAgent(ctx, executor, itemContext, model, memory, processedResponse);
});
const batchResults = await Promise.allSettled(batchPromises);
// This is only used to check if the output parser is connected
// so we can parse the output if needed. Actual output parsing is done in the loop above
const outputParser = await getOptionalOutputParser(ctx, 0);
batchResults.forEach((result, index) => {
const itemIndex = startIndex + index;
if (result.status === 'rejected') {
const error = result.reason as Error;
if (ctx.continueOnFail()) {
returnData.push({
json: { error: error.message },
pairedItem: { item: itemIndex },
} as INodeExecutionData);
return;
} else {
throw new NodeOperationError(ctx.getNode(), error);
}
}
const batchResult = result.value as BatchResult;
if (!batchResult) {
return;
}
if ('actions' in batchResult) {
if (!request) {
request = {
actions: batchResult.actions,
metadata: batchResult.metadata,
};
} else {
request.actions.push.apply(request.actions, batchResult.actions);
}
return;
}
// Finalize the result
const itemResult = finalizeResult(batchResult, itemIndex, memory, outputParser);
returnData.push(itemResult);
});
return { returnData, request };
}
@@ -0,0 +1,54 @@
import type { BaseChatMemory } from '@langchain/classic/memory';
import omit from 'lodash/omit';
import { jsonParse } from 'n8n-workflow';
import type { INodeExecutionData } from 'n8n-workflow';
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { serializeIntermediateSteps } from '@utils/agent-execution';
import type { AgentResult } from '../types';
/**
* Finalizes the result by parsing output and preparing execution data.
* Handles output parser integration and memory-based parsing.
*
* @param result - The agent result to finalize
* @param itemIndex - The current item index
* @param memory - Optional memory for parsing context
* @param outputParser - Optional output parser for structured responses
* @returns INodeExecutionData ready for output
*/
export function finalizeResult(
result: AgentResult,
itemIndex: number,
memory: BaseChatMemory | undefined,
outputParser: N8nOutputParser | undefined,
): INodeExecutionData {
// If memory and outputParser are connected, parse the output.
if (memory && outputParser) {
const parsedOutput = jsonParse<{ output: Record<string, unknown> }>(result.output);
// Type assertion needed because parsedOutput can be various types
result.output = (parsedOutput?.output ?? parsedOutput) as unknown as string;
}
// Serialize messageLog entries from LangChain class instances to plain objects
// so that downstream expressions see the same structure as the UI data browser.
if (result.intermediateSteps) {
serializeIntermediateSteps(result.intermediateSteps);
}
// Omit internal keys before returning the result.
const itemResult: INodeExecutionData = {
json: omit(
result,
'system_message',
'formatting_instructions',
'input',
'chat_history',
'agent_scratchpad',
),
pairedItem: { item: itemIndex },
};
return itemResult;
}
@@ -0,0 +1,15 @@
export { buildToolsAgentExecutionContext as buildExecutionContext } from './buildExecutionContext';
export type { ToolsAgentExecutionContext as ExecutionContext } from './buildExecutionContext';
export { createAgentSequence } from './createAgentSequence';
export { prepareItemContext } from './prepareItemContext';
export type { ItemContext } from './prepareItemContext';
export { runAgent } from './runAgent';
export { finalizeResult } from './finalizeResult';
export { executeBatch } from './executeBatch';
export { checkMaxIterations } from './checkMaxIterations';
@@ -0,0 +1,82 @@
import type { ChatPromptTemplate } from '@langchain/core/prompts';
import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools';
import { NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, ISupplyDataFunctions, EngineResponse } from 'n8n-workflow';
import {
buildSteps,
type ToolCallData,
type RequestResponseMetadata,
} from '@utils/agent-execution';
import { getPromptInputByType } from '@utils/helpers';
import { getOptionalOutputParser } from '@utils/output_parsers/N8nOutputParser';
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { getTools, prepareMessages, preparePrompt } from '../../common';
import type { AgentOptions } from '../types';
/**
* Context specific to a single item's processing
*/
export type ItemContext = {
itemIndex: number;
input: string;
steps: ToolCallData[];
tools: Array<DynamicStructuredTool | Tool>;
prompt: ChatPromptTemplate;
options: AgentOptions;
outputParser: N8nOutputParser | undefined;
};
/**
* Prepares the context for processing a single item.
* This includes loading steps, input, tools, prompt, and options.
*
* @param ctx - The execution context
* @param itemIndex - The index of the item to process
* @param response - Optional engine response with previous tool calls
* @returns ItemContext containing all item-specific state
*/
export async function prepareItemContext(
ctx: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<ItemContext> {
const steps = buildSteps(response, itemIndex);
const input = getPromptInputByType({
ctx,
i: itemIndex,
inputKey: 'text',
promptTypeKey: 'promptType',
});
if (input === undefined) {
throw new NodeOperationError(ctx.getNode(), 'The "text" parameter is empty.');
}
const outputParser = await getOptionalOutputParser(ctx, itemIndex);
const tools = await getTools(ctx, outputParser);
const options = ctx.getNodeParameter('options', itemIndex) as AgentOptions;
if (options.enableStreaming === undefined) {
options.enableStreaming = true;
}
// Prepare the prompt messages and prompt template.
const messages = await prepareMessages(ctx, itemIndex, {
systemMessage: options.systemMessage,
passthroughBinaryImages: options.passthroughBinaryImages ?? true,
outputParser,
});
const prompt: ChatPromptTemplate = preparePrompt(messages);
return {
itemIndex,
input,
steps,
tools,
prompt,
options,
outputParser,
};
}
@@ -0,0 +1,131 @@
import type { AgentRunnableSequence } from '@langchain/classic/agents';
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import {
buildResponseMetadata,
createEngineRequests,
loadMemory,
processEventStream,
saveToMemory,
type RequestResponseMetadata,
} from '@utils/agent-execution';
import { getTracingConfig } from '@utils/tracing';
import type {
EngineRequest,
EngineResponse,
IExecuteFunctions,
ISupplyDataFunctions,
} from 'n8n-workflow';
import { SYSTEM_MESSAGE } from '../../prompt';
import type { AgentResult } from '../types';
import type { ItemContext } from './prepareItemContext';
type RunAgentResult = AgentResult | EngineRequest<RequestResponseMetadata>;
/**
* Runs the agent for a single item, choosing between streaming or non-streaming execution.
* Handles both regular execution and execution after tool calls.
*
* @param ctx - The execution context
* @param executor - The agent runnable sequence
* @param itemContext - Context for the current item
* @param model - The chat model for token counting
* @param memory - Optional memory for conversation context
* @param response - Optional engine response with previous tool calls
* @returns AgentResult or engine request with tool calls
*/
export async function runAgent(
ctx: IExecuteFunctions | ISupplyDataFunctions,
executor: AgentRunnableSequence,
itemContext: ItemContext,
model: BaseChatModel,
memory: BaseChatMemory | undefined,
response?: EngineResponse<RequestResponseMetadata>,
): Promise<RunAgentResult> {
const { itemIndex, input, steps, tools, options } = itemContext;
const invokeParams = {
// steps are passed to the ToolCallingAgent in the runnable sequence to keep track of tool calls
steps,
input,
system_message: options.systemMessage ?? SYSTEM_MESSAGE,
formatting_instructions:
'IMPORTANT: For your response to user, you MUST use the `format_final_json_response` tool with your complete answer formatted according to the required schema. Do not attempt to format the JSON manually - always use this tool. Your response will be rejected if it is not properly formatted through this tool. Only use this tool once you are ready to provide your final answer.',
};
const executeOptions = { signal: ctx.getExecutionCancelSignal() };
// Check if streaming is actually available
const isStreamingAvailable = 'isStreaming' in ctx ? ctx.isStreaming?.() : undefined;
if (
'isStreaming' in ctx &&
options.enableStreaming &&
isStreamingAvailable &&
ctx.getNode().typeVersion >= 2.1
) {
const chatHistory = await loadMemory(memory, model, options.maxTokensFromMemory);
const eventStream = executor.withConfig(getTracingConfig(ctx)).streamEvents(
{
...invokeParams,
chat_history: chatHistory,
},
{
version: 'v2',
...executeOptions,
},
);
const result = await processEventStream(ctx, eventStream, itemIndex);
// If result contains tool calls, build the request object like the normal flow
if (result.toolCalls && result.toolCalls.length > 0) {
const actions = createEngineRequests(result.toolCalls, itemIndex, tools);
return {
actions,
metadata: buildResponseMetadata(response, itemIndex),
};
}
// Save conversation to memory including any tool call context
if (memory && input && result?.output) {
const previousCount = response?.metadata?.previousRequests?.length;
await saveToMemory(input, result.output, memory, steps, previousCount);
}
if (options.returnIntermediateSteps && steps.length > 0) {
result.intermediateSteps = steps;
}
return result;
} else {
// Handle regular execution
const chatHistory = await loadMemory(memory, model, options.maxTokensFromMemory);
const modelResponse = await executor.withConfig(getTracingConfig(ctx)).invoke({
...invokeParams,
chat_history: chatHistory,
});
if ('returnValues' in modelResponse) {
// Save conversation to memory including any tool call context
if (memory && input && modelResponse.returnValues.output) {
const previousCount = response?.metadata?.previousRequests?.length;
await saveToMemory(input, modelResponse.returnValues.output, memory, steps, previousCount);
}
// Include intermediate steps if requested
const result = { ...modelResponse.returnValues };
if (options.returnIntermediateSteps && steps.length > 0) {
result.intermediateSteps = steps;
}
return result;
}
// If response contains tool calls, we need to return this in the right format
const actions = createEngineRequests(modelResponse, itemIndex, tools);
return {
actions,
metadata: buildResponseMetadata(response, itemIndex),
};
}
}
@@ -0,0 +1,158 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import { NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, INode, INodeExecutionData } from 'n8n-workflow';
import * as commonHelpers from '../../../common';
import { buildToolsAgentExecutionContext } from '../buildExecutionContext';
jest.mock('../../../common', () => ({
getChatModel: jest.fn(),
getOptionalMemory: jest.fn(),
}));
const mockContext = mock<IExecuteFunctions>();
const mockNode = mock<INode>();
beforeEach(() => {
jest.clearAllMocks();
mockContext.getNode.mockReturnValue(mockNode);
});
describe('buildExecutionContext', () => {
it('should build execution context with default values', async () => {
const mockInputData: INodeExecutionData[] = [
{ json: { text: 'input 1' } },
{ json: { text: 'input 2' } },
];
const mockModel = mock<BaseChatModel>();
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'needsFallback') return defaultValue;
return defaultValue;
});
jest.spyOn(commonHelpers, 'getChatModel').mockResolvedValue(mockModel);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(undefined);
const result = await buildToolsAgentExecutionContext(mockContext);
expect(result).toEqual({
items: mockInputData,
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: mockModel,
fallbackModel: null,
memory: undefined,
});
});
it('should build execution context with custom batch settings', async () => {
const mockInputData: INodeExecutionData[] = [
{ json: { text: 'input 1' } },
{ json: { text: 'input 2' } },
];
const mockModel = mock<BaseChatModel>();
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return 5;
if (param === 'options.batching.delayBetweenBatches') return 1000;
if (param === 'needsFallback') return false;
return defaultValue;
});
jest.spyOn(commonHelpers, 'getChatModel').mockResolvedValue(mockModel);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(undefined);
const result = await buildToolsAgentExecutionContext(mockContext);
expect(result.batchSize).toBe(5);
expect(result.delayBetweenBatches).toBe(1000);
});
it('should build execution context with fallback model when needsFallback is true', async () => {
const mockInputData: INodeExecutionData[] = [{ json: { text: 'input 1' } }];
const mockModel = mock<BaseChatModel>();
const mockFallbackModel = mock<BaseChatModel>();
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'needsFallback') return true;
return defaultValue;
});
jest
.spyOn(commonHelpers, 'getChatModel')
.mockResolvedValueOnce(mockModel)
.mockResolvedValueOnce(mockFallbackModel);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(undefined);
const result = await buildToolsAgentExecutionContext(mockContext);
expect(result.needsFallback).toBe(true);
expect(result.model).toBe(mockModel);
expect(result.fallbackModel).toBe(mockFallbackModel);
expect(commonHelpers.getChatModel).toHaveBeenCalledWith(mockContext, 0);
expect(commonHelpers.getChatModel).toHaveBeenCalledWith(mockContext, 1);
});
it('should throw error when fallback is needed but no fallback model is provided', async () => {
const mockInputData: INodeExecutionData[] = [{ json: { text: 'input 1' } }];
const mockModel = mock<BaseChatModel>();
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'needsFallback') return true;
return defaultValue;
});
jest
.spyOn(commonHelpers, 'getChatModel')
.mockResolvedValueOnce(mockModel)
.mockResolvedValueOnce(undefined);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(undefined);
await expect(buildToolsAgentExecutionContext(mockContext)).rejects.toThrow(NodeOperationError);
});
it('should throw assertion error when no model is provided', async () => {
const mockInputData: INodeExecutionData[] = [{ json: { text: 'input 1' } }];
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((_param, _i, defaultValue) => {
return defaultValue;
});
jest.spyOn(commonHelpers, 'getChatModel').mockResolvedValue(undefined);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(undefined);
await expect(buildToolsAgentExecutionContext(mockContext)).rejects.toThrow(
'Please connect a model to the Chat Model input',
);
});
it('should include memory when available', async () => {
const mockInputData: INodeExecutionData[] = [{ json: { text: 'input 1' } }];
const mockModel = mock<BaseChatModel>();
const mockMemory = mock<any>();
mockContext.getInputData.mockReturnValue(mockInputData);
mockContext.getNodeParameter.mockImplementation((_param, _i, defaultValue) => {
return defaultValue;
});
jest.spyOn(commonHelpers, 'getChatModel').mockResolvedValue(mockModel);
jest.spyOn(commonHelpers, 'getOptionalMemory').mockResolvedValue(mockMemory);
const result = await buildToolsAgentExecutionContext(mockContext);
expect(result.memory).toBe(mockMemory);
});
});
@@ -0,0 +1,133 @@
import type { RequestResponseMetadata } from '@utils/agent-execution';
import { mock } from 'jest-mock-extended';
import { NodeOperationError } from 'n8n-workflow';
import type { INode, EngineResponse } from 'n8n-workflow';
import { checkMaxIterations } from '../checkMaxIterations';
describe('checkMaxIterations', () => {
const mockNode = mock<INode>();
beforeEach(() => {
jest.clearAllMocks();
});
it('should not throw when response is undefined', () => {
expect(() => {
checkMaxIterations(undefined, 10, mockNode);
}).not.toThrow();
});
it('should not throw when response metadata is undefined', () => {
const response = {
actionResponses: [],
} as unknown as EngineResponse<RequestResponseMetadata>;
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).not.toThrow();
});
it('should not throw when response metadata iterationCount is undefined', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {},
};
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).not.toThrow();
});
it('should not throw when iterationCount is below maxIterations', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 5,
},
};
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).not.toThrow();
});
it('should throw NodeOperationError when iterationCount equals maxIterations', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 10,
},
};
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).toThrow(NodeOperationError);
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).toThrow(
'Max iterations (10) reached. The agent could not complete the task within the allowed number of iterations.',
);
});
it('should throw NodeOperationError when iterationCount exceeds maxIterations', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 15,
},
};
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).toThrow(NodeOperationError);
expect(() => {
checkMaxIterations(response, 10, mockNode);
}).toThrow(
'Max iterations (10) reached. The agent could not complete the task within the allowed number of iterations.',
);
});
it('should throw with correct error message for different maxIterations values', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 5,
},
};
expect(() => {
checkMaxIterations(response, 5, mockNode);
}).toThrow(
'Max iterations (5) reached. The agent could not complete the task within the allowed number of iterations.',
);
});
it('should handle edge case of maxIterations = 0', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 0,
},
};
expect(() => {
checkMaxIterations(response, 0, mockNode);
}).toThrow(NodeOperationError);
});
it('should handle edge case of maxIterations = 1 with iterationCount = 0', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 0,
},
};
expect(() => {
checkMaxIterations(response, 1, mockNode);
}).not.toThrow();
});
});
@@ -0,0 +1,205 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { ChatPromptTemplate } from '@langchain/core/prompts';
import { RunnableSequence } from '@langchain/core/runnables';
import { mock } from 'jest-mock-extended';
import { createToolCallingAgent } from '@langchain/classic/agents';
import type { Tool } from '@langchain/classic/tools';
import * as commonHelpers from '../../../common';
import { createAgentSequence } from '../createAgentSequence';
jest.mock('@langchain/classic/agents', () => ({
createToolCallingAgent: jest.fn(),
}));
jest.mock('@langchain/core/runnables', () => ({
RunnableSequence: {
from: jest.fn(),
},
}));
jest.mock('../../../common', () => ({
getAgentStepsParser: jest.fn(),
fixEmptyContentMessage: jest.fn(),
}));
describe('createAgentSequence', () => {
const mockModel = mock<BaseChatModel>();
const mockPrompt = mock<ChatPromptTemplate>();
const mockTool = mock<Tool>();
beforeEach(() => {
jest.clearAllMocks();
});
it('should create agent sequence without fallback', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
const result = createAgentSequence(mockModel, [mockTool], mockPrompt, options);
expect(createToolCallingAgent).toHaveBeenCalledWith({
llm: mockModel,
tools: [mockTool],
prompt: mockPrompt,
streamRunnable: false,
});
expect(RunnableSequence.from).toHaveBeenCalledWith([
mockAgent,
mockStepsParser,
commonHelpers.fixEmptyContentMessage,
]);
expect(result.singleAction).toBe(true);
expect(result.streamRunnable).toBe(false);
});
it('should create agent sequence with fallback model', () => {
const mockFallbackModel = mock<BaseChatModel>();
const mockAgent = mock<any>();
const mockFallbackAgent = mock<any>();
const mockAgentWithFallback = mock<any>();
const mockRunnableSequence = mock<any>();
const mockStepsParser = jest.fn();
mockAgent.withFallbacks = jest.fn().mockReturnValue(mockAgentWithFallback);
(createToolCallingAgent as jest.Mock)
.mockReturnValueOnce(mockAgent)
.mockReturnValueOnce(mockFallbackAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(
mockModel,
[mockTool],
mockPrompt,
options,
undefined,
undefined,
mockFallbackModel,
);
expect(createToolCallingAgent).toHaveBeenCalledTimes(2);
expect(createToolCallingAgent).toHaveBeenNthCalledWith(1, {
llm: mockModel,
tools: [mockTool],
prompt: mockPrompt,
streamRunnable: false,
});
expect(createToolCallingAgent).toHaveBeenNthCalledWith(2, {
llm: mockFallbackModel,
tools: [mockTool],
prompt: mockPrompt,
streamRunnable: false,
});
expect(mockAgent.withFallbacks).toHaveBeenCalledWith([mockFallbackAgent]);
expect(RunnableSequence.from).toHaveBeenCalledWith([
mockAgentWithFallback,
mockStepsParser,
commonHelpers.fixEmptyContentMessage,
]);
});
it('should pass output parser to getAgentStepsParser', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockOutputParser = mock<any>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(mockModel, [mockTool], mockPrompt, options, mockOutputParser);
expect(commonHelpers.getAgentStepsParser).toHaveBeenCalledWith(mockOutputParser, undefined);
});
it('should pass memory to getAgentStepsParser', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockMemory = mock<any>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(mockModel, [mockTool], mockPrompt, options, undefined, mockMemory);
expect(commonHelpers.getAgentStepsParser).toHaveBeenCalledWith(undefined, mockMemory);
});
it('should set streamRunnable to false for agents', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(mockModel, [mockTool], mockPrompt, options);
expect(createToolCallingAgent).toHaveBeenCalledWith(
expect.objectContaining({
streamRunnable: false,
}),
);
});
it('should handle null fallback model', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(mockModel, [mockTool], mockPrompt, options, undefined, undefined, null);
// Should only create one agent (no fallback)
expect(createToolCallingAgent).toHaveBeenCalledTimes(1);
expect(RunnableSequence.from).toHaveBeenCalledWith([
mockAgent,
mockStepsParser,
commonHelpers.fixEmptyContentMessage,
]);
});
it('should create sequence with multiple tools', () => {
const mockAgent = mock<any>();
const mockRunnableSequence = mock<any>();
const mockTool2 = mock<Tool>();
const mockStepsParser = jest.fn();
(createToolCallingAgent as jest.Mock).mockReturnValue(mockAgent);
(RunnableSequence.from as jest.Mock).mockReturnValue(mockRunnableSequence);
jest.spyOn(commonHelpers, 'getAgentStepsParser').mockReturnValue(mockStepsParser);
const options = { maxIterations: 10, returnIntermediateSteps: false };
createAgentSequence(mockModel, [mockTool, mockTool2], mockPrompt, options);
expect(createToolCallingAgent).toHaveBeenCalledWith(
expect.objectContaining({
tools: [mockTool, mockTool2],
}),
);
});
});
@@ -0,0 +1,166 @@
import { mock } from 'jest-mock-extended';
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
import { finalizeResult } from '../finalizeResult';
describe('finalizeResult', () => {
it('should finalize result without memory or output parser', () => {
const result = {
output: 'Test output',
system_message: 'You are a helpful assistant',
formatting_instructions: 'Format as JSON',
input: 'Test input',
chat_history: [],
agent_scratchpad: 'scratch',
};
const finalized = finalizeResult(result, 0, undefined, undefined);
expect(finalized).toEqual({
json: {
output: 'Test output',
},
pairedItem: { item: 0 },
});
});
it('should omit internal keys from result', () => {
const result = {
output: 'Test output',
customField: 'custom value',
system_message: 'You are a helpful assistant',
formatting_instructions: 'Format as JSON',
input: 'Test input',
chat_history: [],
agent_scratchpad: 'scratch',
};
const finalized = finalizeResult(result, 0, undefined, undefined);
expect(finalized.json).toEqual({
output: 'Test output',
customField: 'custom value',
});
expect(finalized.json).not.toHaveProperty('system_message');
expect(finalized.json).not.toHaveProperty('formatting_instructions');
expect(finalized.json).not.toHaveProperty('input');
expect(finalized.json).not.toHaveProperty('chat_history');
expect(finalized.json).not.toHaveProperty('agent_scratchpad');
});
it('should parse output when memory and outputParser are connected', () => {
const mockMemory = mock<BaseChatMemory>();
const mockOutputParser = mock<N8nOutputParser>();
const result = {
output: JSON.stringify({ output: { result: 'parsed result' } }),
};
const finalized = finalizeResult(result, 0, mockMemory, mockOutputParser);
expect(finalized.json.output).toEqual({ result: 'parsed result' });
});
it('should handle output without nested output field when parsing', () => {
const mockMemory = mock<BaseChatMemory>();
const mockOutputParser = mock<N8nOutputParser>();
const result = {
output: JSON.stringify({ result: 'direct result' }),
};
const finalized = finalizeResult(result, 0, mockMemory, mockOutputParser);
expect(finalized.json.output).toEqual({ result: 'direct result' });
});
it('should set correct pairedItem index', () => {
const result = {
output: 'Test output',
};
const finalized = finalizeResult(result, 5, undefined, undefined);
expect(finalized.pairedItem).toEqual({ item: 5 });
});
it('should preserve intermediate steps when present', () => {
const result = {
output: 'Test output',
intermediateSteps: [
{
action: { tool: 'test_tool', toolInput: {}, log: 'log', toolCallId: 'id', type: 'type' },
observation: 'observation',
},
],
};
const finalized = finalizeResult(result, 0, undefined, undefined);
expect(finalized.json.intermediateSteps).toBeDefined();
expect(finalized.json.intermediateSteps).toHaveLength(1);
});
it('should not parse output when only memory is connected', () => {
const mockMemory = mock<BaseChatMemory>();
const result = {
output: JSON.stringify({ output: { result: 'should not parse' } }),
};
const finalized = finalizeResult(result, 0, mockMemory, undefined);
// Should remain as string
expect(typeof finalized.json.output).toBe('string');
expect(finalized.json.output).toBe(JSON.stringify({ output: { result: 'should not parse' } }));
});
it('should not parse output when only outputParser is connected', () => {
const mockOutputParser = mock<N8nOutputParser>();
const result = {
output: JSON.stringify({ output: { result: 'should not parse' } }),
};
const finalized = finalizeResult(result, 0, undefined, mockOutputParser);
// Should remain as string
expect(typeof finalized.json.output).toBe('string');
expect(finalized.json.output).toBe(JSON.stringify({ output: { result: 'should not parse' } }));
});
it('should throw error when parsing invalid JSON', () => {
const mockMemory = mock<BaseChatMemory>();
const mockOutputParser = mock<N8nOutputParser>();
const result = {
output: 'not valid JSON',
};
// jsonParse throws an error on invalid JSON
expect(() => finalizeResult(result, 0, mockMemory, mockOutputParser)).toThrow();
});
it('should handle multiple custom fields in result', () => {
const result = {
output: 'Test output',
field1: 'value1',
field2: 123,
field3: true,
field4: { nested: 'object' },
system_message: 'should be omitted',
};
const finalized = finalizeResult(result, 0, undefined, undefined);
expect(finalized.json).toEqual({
output: 'Test output',
field1: 'value1',
field2: 123,
field3: true,
field4: { nested: 'object' },
});
});
});
@@ -0,0 +1,210 @@
import type { ChatPromptTemplate } from '@langchain/core/prompts';
import { mock } from 'jest-mock-extended';
import type { Tool } from '@langchain/classic/tools';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import * as helpers from '@utils/helpers';
import * as outputParsers from '@utils/output_parsers/N8nOutputParser';
import * as commonHelpers from '../../../common';
import { prepareItemContext } from '../prepareItemContext';
jest.mock('@utils/helpers', () => ({
getPromptInputByType: jest.fn(),
}));
jest.mock('@utils/output_parsers/N8nOutputParser', () => ({
getOptionalOutputParser: jest.fn(),
}));
jest.mock('../../../common', () => ({
getTools: jest.fn(),
prepareMessages: jest.fn(),
preparePrompt: jest.fn(),
}));
const mockContext = mock<IExecuteFunctions>();
const mockNode = mock<INode>();
beforeEach(() => {
jest.clearAllMocks();
mockContext.getNode.mockReturnValue(mockNode);
});
describe('processItem', () => {
it('should throw error when text parameter is empty', async () => {
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue(undefined as any);
await expect(prepareItemContext(mockContext, 0)).rejects.toThrow(
'The "text" parameter is empty.',
);
});
it('should process item and return context', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(undefined);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
}
return undefined;
});
const result = await prepareItemContext(mockContext, 0);
expect(result).not.toBeNull();
expect(result?.itemIndex).toBe(0);
expect(result?.input).toBe('test input');
expect(result?.tools).toEqual([mockTool]);
expect(result?.prompt).toBe(mockPrompt);
expect(result?.steps).toEqual([]);
});
it('should enable streaming by default when not specified', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(undefined);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'You are a helpful assistant',
// enableStreaming not set
};
}
return undefined;
});
const result = await prepareItemContext(mockContext, 0);
expect(result?.options.enableStreaming).toBe(true);
});
it('should respect enableStreaming option when set', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(undefined);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'You are a helpful assistant',
enableStreaming: false,
};
}
return undefined;
});
const result = await prepareItemContext(mockContext, 0);
expect(result?.options.enableStreaming).toBe(false);
});
it('should include output parser when available', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
const mockOutputParser = mock<any>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(mockOutputParser);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'You are a helpful assistant',
};
}
return undefined;
});
const result = await prepareItemContext(mockContext, 0);
expect(result?.outputParser).toBe(mockOutputParser);
});
it('should pass outputParser to prepareMessages', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
const mockOutputParser = mock<any>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(mockOutputParser);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'Test system message',
passthroughBinaryImages: false,
};
}
return undefined;
});
await prepareItemContext(mockContext, 0);
expect(commonHelpers.prepareMessages).toHaveBeenCalledWith(mockContext, 0, {
systemMessage: 'Test system message',
passthroughBinaryImages: false,
outputParser: mockOutputParser,
});
});
it('should use passthroughBinaryImages default value when not specified', async () => {
const mockTool = mock<Tool>();
const mockPrompt = mock<ChatPromptTemplate>();
jest.spyOn(helpers, 'getPromptInputByType').mockReturnValue('test input');
jest.spyOn(outputParsers, 'getOptionalOutputParser').mockResolvedValue(undefined);
jest.spyOn(commonHelpers, 'getTools').mockResolvedValue([mockTool]);
jest.spyOn(commonHelpers, 'prepareMessages').mockResolvedValue([]);
jest.spyOn(commonHelpers, 'preparePrompt').mockReturnValue(mockPrompt);
mockContext.getNodeParameter.mockImplementation((param) => {
if (param === 'options') {
return {
systemMessage: 'Test system message',
// passthroughBinaryImages not set
};
}
return undefined;
});
await prepareItemContext(mockContext, 0);
expect(commonHelpers.prepareMessages).toHaveBeenCalledWith(
mockContext,
0,
expect.objectContaining({
passthroughBinaryImages: true,
}),
);
});
});
@@ -0,0 +1,353 @@
import type { RequestResponseMetadata } from '@utils/agent-execution';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import type { AgentRunnableSequence } from '@langchain/classic/agents';
import type { Tool } from '@langchain/classic/tools';
import type { IExecuteFunctions, INode, EngineResponse } from 'n8n-workflow';
import * as agentExecution from '@utils/agent-execution';
import * as tracing from '@utils/tracing';
import type { ItemContext } from '../prepareItemContext';
import { runAgent } from '../runAgent';
jest.mock('@utils/agent-execution', () => {
const originalModule = jest.requireActual('@utils/agent-execution');
return {
...originalModule,
loadMemory: jest.fn(),
processEventStream: jest.fn(),
buildSteps: jest.fn(),
createEngineRequests: jest.fn(),
saveToMemory: jest.fn(),
};
});
jest.mock('@utils/tracing', () => ({
getTracingConfig: jest.fn(),
}));
const mockContext = mock<IExecuteFunctions>();
const mockNode = mock<INode>();
beforeEach(() => {
jest.clearAllMocks();
mockContext.getNode.mockReturnValue(mockNode);
mockNode.typeVersion = 3;
});
describe('runAgent - iteration count tracking', () => {
it('should set iteration count to 1 on first call (no response)', async () => {
const mockInvoke = jest.fn().mockResolvedValue([
{
toolCalls: [
{
id: 'call_123',
name: 'TestTool',
args: { input: 'test' },
type: 'tool_call',
},
],
},
]);
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: jest.fn().mockReturnValue({ invoke: mockInvoke }),
});
const mockModel = mock<BaseChatModel>();
const mockTool = mock<Tool>();
mockTool.name = 'TestTool';
mockTool.metadata = { sourceNodeName: 'Test Tool' };
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [mockTool],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
},
outputParser: undefined,
};
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'buildSteps').mockReturnValue([]);
jest.spyOn(agentExecution, 'createEngineRequests').mockReturnValue([
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool',
input: { input: 'test' },
type: 'ai_tool' as any,
id: 'call_123',
metadata: { itemIndex: 0 },
},
]);
mockContext.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
const result = await runAgent(mockContext, mockExecutor, itemContext, mockModel, undefined);
expect(result).toHaveProperty('actions');
expect(result).toHaveProperty('metadata');
expect((result as any).metadata.iterationCount).toBe(1);
});
it('should increment iteration count when response is provided', async () => {
const mockInvoke = jest.fn().mockResolvedValue([
{
toolCalls: [
{
id: 'call_456',
name: 'TestTool',
args: { input: 'test2' },
type: 'tool_call',
},
],
},
]);
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: jest.fn().mockReturnValue({ invoke: mockInvoke }),
});
const mockModel = mock<BaseChatModel>();
const mockTool = mock<Tool>();
mockTool.name = 'TestTool';
mockTool.metadata = { sourceNodeName: 'Test Tool' };
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [mockTool],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
},
outputParser: undefined,
};
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: { itemIndex: 0, previousRequests: [], iterationCount: 2 },
};
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'buildSteps').mockReturnValue([]);
jest.spyOn(agentExecution, 'createEngineRequests').mockReturnValue([
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool',
input: { input: 'test2' },
type: 'ai_tool' as any,
id: 'call_456',
metadata: { itemIndex: 0 },
},
]);
mockContext.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
const result = await runAgent(
mockContext,
mockExecutor,
itemContext,
mockModel,
undefined,
response,
);
expect(result).toHaveProperty('actions');
expect(result).toHaveProperty('metadata');
expect((result as any).metadata.iterationCount).toBe(3);
});
it('should set iteration count to 1 in streaming mode on first call', async () => {
const mockEventStream = (async function* () {})();
const mockStreamEvents = jest.fn().mockReturnValue(mockEventStream);
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: jest.fn().mockReturnValue({ streamEvents: mockStreamEvents }),
});
const mockModel = mock<BaseChatModel>();
const mockTool = mock<Tool>();
mockTool.name = 'TestTool';
mockTool.metadata = { sourceNodeName: 'Test Tool' };
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [mockTool],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
enableStreaming: true,
},
outputParser: undefined,
};
const mockContext = mock<IExecuteFunctions>({
getNode: jest.fn().mockReturnValue(mockNode),
isStreaming: jest.fn().mockReturnValue(true),
getExecutionCancelSignal: jest.fn().mockReturnValue(new AbortController().signal),
});
mockNode.typeVersion = 2.1;
// Mock streaming to return tool calls
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'processEventStream').mockResolvedValue({
output: '',
toolCalls: [
{
tool: 'TestTool',
toolInput: { input: 'test' },
toolCallId: 'call_123',
type: 'tool_call',
},
],
});
jest.spyOn(agentExecution, 'buildSteps').mockReturnValue([]);
jest.spyOn(agentExecution, 'createEngineRequests').mockReturnValue([
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool',
input: { input: 'test' },
type: 'ai_tool' as any,
id: 'call_123',
metadata: { itemIndex: 0 },
},
]);
const result = await runAgent(mockContext, mockExecutor, itemContext, mockModel, undefined);
expect(result).toHaveProperty('actions');
expect(result).toHaveProperty('metadata');
expect((result as any).metadata.iterationCount).toBe(1);
});
it('should not include iteration count when returning final result', async () => {
const mockInvoke = jest.fn().mockResolvedValue({
returnValues: {
output: 'Final answer',
},
});
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: jest.fn().mockReturnValue({ invoke: mockInvoke }),
});
const mockModel = mock<BaseChatModel>();
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
},
outputParser: undefined,
};
// Mock the agent to return a final result (no tool calls)
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'saveToMemory').mockResolvedValue();
mockContext.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
const result = await runAgent(mockContext, mockExecutor, itemContext, mockModel, undefined);
expect(result).toHaveProperty('output');
expect(result).not.toHaveProperty('actions');
expect(result).not.toHaveProperty('metadata');
});
});
describe('runAgent - tracing configuration', () => {
it('should apply tracing config in non-streaming mode', async () => {
const mockTracingConfig = {
runName: '[Test Workflow] Test Node',
metadata: { execution_id: 'test-123', workflow: {}, node: 'Test Node' },
};
jest.spyOn(tracing, 'getTracingConfig').mockReturnValue(mockTracingConfig);
const mockInvoke = jest.fn().mockResolvedValue({
returnValues: { output: 'Final answer' },
});
const mockWithConfig = jest.fn().mockReturnValue({ invoke: mockInvoke });
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: mockWithConfig,
});
const mockModel = mock<BaseChatModel>();
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
},
outputParser: undefined,
};
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'saveToMemory').mockResolvedValue();
mockContext.getExecutionCancelSignal.mockReturnValue(new AbortController().signal);
await runAgent(mockContext, mockExecutor, itemContext, mockModel, undefined);
expect(tracing.getTracingConfig).toHaveBeenCalledWith(mockContext);
expect(mockWithConfig).toHaveBeenCalledWith(mockTracingConfig);
expect(mockInvoke).toHaveBeenCalled();
});
it('should apply tracing config in streaming mode', async () => {
const mockTracingConfig = {
runName: '[Test Workflow] Test Node',
metadata: { execution_id: 'test-123', workflow: {}, node: 'Test Node' },
};
jest.spyOn(tracing, 'getTracingConfig').mockReturnValue(mockTracingConfig);
const mockEventStream = (async function* () {})();
const mockStreamEvents = jest.fn().mockReturnValue(mockEventStream);
const mockWithConfig = jest.fn().mockReturnValue({ streamEvents: mockStreamEvents });
const mockExecutor = mock<AgentRunnableSequence>({
withConfig: mockWithConfig,
});
const mockModel = mock<BaseChatModel>();
const itemContext: ItemContext = {
itemIndex: 0,
input: 'test input',
steps: [],
tools: [],
prompt: mock(),
options: {
maxIterations: 10,
returnIntermediateSteps: false,
enableStreaming: true,
},
outputParser: undefined,
};
const streamingContext = mock<IExecuteFunctions>({
getNode: jest.fn().mockReturnValue({ ...mockNode, typeVersion: 2.1 }),
isStreaming: jest.fn().mockReturnValue(true),
getExecutionCancelSignal: jest.fn().mockReturnValue(new AbortController().signal),
});
jest.spyOn(agentExecution, 'loadMemory').mockResolvedValue([]);
jest.spyOn(agentExecution, 'processEventStream').mockResolvedValue({
output: 'Streamed answer',
});
await runAgent(streamingContext, mockExecutor, itemContext, mockModel, undefined);
expect(tracing.getTracingConfig).toHaveBeenCalledWith(streamingContext);
expect(mockWithConfig).toHaveBeenCalledWith(mockTracingConfig);
expect(mockStreamEvents).toHaveBeenCalled();
});
});
@@ -0,0 +1,26 @@
import type { ToolCallData, ToolCallRequest, AgentResult } from '@utils/agent-execution';
// Re-export shared types for backwards compatibility
export type { ToolCallData, ToolCallRequest, AgentResult };
// Keep the IntermediateStep type for compatibility
export type IntermediateStep = {
action: {
tool: string;
toolInput: Record<string, unknown>;
log: string;
messageLog: unknown[];
toolCallId: string;
type: string;
};
observation?: string;
};
export type AgentOptions = {
systemMessage?: string;
maxIterations?: number;
returnIntermediateSteps?: boolean;
passthroughBinaryImages?: boolean;
enableStreaming?: boolean;
maxTokensFromMemory?: number;
};
@@ -0,0 +1,471 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage } from '@langchain/core/messages';
import type { BaseMessage } from '@langchain/core/messages';
import { ChatPromptTemplate, type BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
import type { AgentAction, AgentFinish } from '@langchain/classic/agents';
import type { ToolsAgentAction } from '@langchain/classic/dist/agents/tool_calling/output_parser';
import type { BaseChatMemory } from '@langchain/classic/memory';
import { DynamicStructuredTool, type Tool } from '@langchain/classic/tools';
import { BINARY_ENCODING, jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type { IExecuteFunctions, ISupplyDataFunctions, IWebhookFunctions } from 'n8n-workflow';
import type { ZodObject } from 'zod';
import { z } from 'zod';
import { isChatInstance } from '@n8n/ai-utilities';
import { getConnectedTools } from '@utils/helpers';
import { type N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
/* -----------------------------------------------------------
Output Parser Helper
----------------------------------------------------------- */
/**
* Retrieve the output parser schema.
* If the parser does not return a valid schema, default to a schema with a single text field.
*/
export function getOutputParserSchema(
outputParser: N8nOutputParser,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ZodObject<any, any, any, any> {
const schema =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(outputParser.getSchema() as ZodObject<any, any, any, any>) ?? z.object({ text: z.string() });
return schema;
}
/* -----------------------------------------------------------
Binary Data Helpers
----------------------------------------------------------- */
function isTextFile(mimeType: string): boolean {
return (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
mimeType === 'application/csv' ||
mimeType === 'application/x-yaml' ||
mimeType === 'application/yaml'
);
}
function isImageFile(mimeType: string): boolean {
return mimeType.startsWith('image/');
}
/**
* Extracts binary messages (images and text files) from the input data.
* When operating in filesystem mode, the binary stream is first converted to a buffer.
*
* Images are converted to base64 data URLs.
* Text files are read as UTF-8 text and included in the message content.
*
* @param ctx - The execution context
* @param itemIndex - The current item index
* @returns A HumanMessage containing the binary messages (images and text files).
*/
export async function extractBinaryMessages(
ctx: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
): Promise<HumanMessage> {
const binaryData = ctx.getInputData()?.[itemIndex]?.binary ?? {};
const binaryMessages = await Promise.all(
Object.values(binaryData)
// select only the files we can process
.filter((data) => isImageFile(data.mimeType) || isTextFile(data.mimeType))
.map(async (data) => {
// Handle images
if (isImageFile(data.mimeType)) {
let binaryUrlString: string;
// In filesystem mode we need to get binary stream by id before converting it to buffer
if (data.id) {
const binaryBuffer = await ctx.helpers.binaryToBuffer(
await ctx.helpers.getBinaryStream(data.id),
);
binaryUrlString = `data:${data.mimeType};base64,${Buffer.from(binaryBuffer).toString(
BINARY_ENCODING,
)}`;
} else {
binaryUrlString = data.data.includes('base64')
? data.data
: `data:${data.mimeType};base64,${data.data}`;
}
return {
type: 'image_url',
image_url: {
url: binaryUrlString,
},
};
}
// Handle text files
else {
let textContent: string;
if (data.id) {
const binaryBuffer = await ctx.helpers.binaryToBuffer(
await ctx.helpers.getBinaryStream(data.id),
);
textContent = binaryBuffer.toString('utf-8');
} else {
// Data might be base64 encoded with or without data URL prefix
if (data.data.includes('base64,')) {
const base64Data = data.data.split('base64,')[1];
textContent = Buffer.from(base64Data, 'base64').toString('utf-8');
} else {
// Default: binary data is base64-encoded without prefix
textContent = Buffer.from(data.data, 'base64').toString('utf-8');
}
}
return {
type: 'text',
text: `File: ${data.fileName ?? 'attachment'}\nContent:\n${textContent}`,
};
}
}),
);
return new HumanMessage({
content: [...binaryMessages],
});
}
/* -----------------------------------------------------------
Agent Output Format Helpers
----------------------------------------------------------- */
/**
* Fixes empty content messages in agent steps.
*
* This function is necessary when using RunnableSequence.from in LangChain.
* If a tool doesn't have any arguments, LangChain returns input: '' (empty string).
* This can throw an error for some providers (like Anthropic) which expect the input to always be an object.
* This function replaces empty string inputs with empty objects to prevent such errors.
*
* @param steps - The agent steps to fix
* @returns The fixed agent steps
*/
export function fixEmptyContentMessage(
steps: AgentFinish | ToolsAgentAction[],
): AgentFinish | ToolsAgentAction[] {
if (!Array.isArray(steps)) return steps;
steps.forEach((step) => {
if ('messageLog' in step && step.messageLog !== undefined) {
if (Array.isArray(step.messageLog)) {
step.messageLog.forEach((message: BaseMessage) => {
if ('content' in message && Array.isArray(message.content)) {
(message.content as Array<{ input?: string | object }>).forEach((content) => {
if (content.input === '') {
content.input = {};
}
});
}
});
}
}
});
return steps;
}
/**
* Ensures consistent handling of outputs regardless of the model used,
* providing a unified output format for further processing.
*
* This method is necessary to handle different output formats from various language models.
* Specifically, it checks if the agent step is the final step (contains returnValues) and determines
* if the output is a simple string (e.g., from OpenAI models) or an array of outputs (e.g., from Anthropic models).
*
* Examples:
* 1. Anthropic model output:
* ```json
* {
* "output": [
* {
* "index": 0,
* "type": "text",
* "text": "The result of the calculation is approximately 1001.8166..."
* }
* ]
* }
*```
* 2. OpenAI model output:
* ```json
* {
* "output": "The result of the calculation is approximately 1001.82..."
* }
* ```
*
* @param steps - The agent finish or agent action steps.
* @returns The modified agent finish steps or the original steps.
*/
export function handleAgentFinishOutput(
steps: AgentFinish | AgentAction[],
): AgentFinish | AgentAction[] {
type AgentMultiOutputFinish = AgentFinish & {
returnValues: { output: Array<{ text: string; type: string; index: number }> };
};
const agentFinishSteps = steps as AgentMultiOutputFinish | AgentFinish;
if (agentFinishSteps.returnValues) {
const isMultiOutput = Array.isArray(agentFinishSteps.returnValues?.output);
if (isMultiOutput) {
const multiOutputSteps = agentFinishSteps.returnValues.output as Array<{
index: number;
type: string;
text?: string;
thinking?: string;
}>;
// Filter out thinking blocks and join text blocks
const textOutputs = multiOutputSteps
.filter((output) => output.type === 'text' && output.text)
.map((output) => output.text)
.join('\n')
.trim();
if (textOutputs) {
agentFinishSteps.returnValues.output = textOutputs;
} else {
const thinkingOutputs = multiOutputSteps
.filter((output) => output.type === 'thinking' && output.thinking)
.map((output) => output.thinking)
.join('\n')
.trim();
if (thinkingOutputs) {
agentFinishSteps.returnValues.output = thinkingOutputs;
} else {
// no output was found
agentFinishSteps.returnValues.output = '';
}
}
return agentFinishSteps;
}
}
return agentFinishSteps;
}
/**
* Wraps the parsed output so that it can be stored in memory.
* If memory is connected, the output is stringified.
*
* @param output - The parsed output object
* @param memory - The connected memory (if any)
* @returns The formatted output object
*/
export function handleParsedStepOutput(
output: Record<string, unknown>,
memory?: BaseChatMemory,
): { returnValues: Record<string, unknown>; log: string } {
return {
returnValues: memory ? { output: JSON.stringify(output) } : output,
log: 'Final response formatted',
};
}
/**
* Parses agent steps using the provided output parser.
* If the agent used the 'format_final_json_response' tool, the output is parsed accordingly.
*
* @param steps - The agent finish or action steps
* @param outputParser - The output parser (if defined)
* @param memory - The connected memory (if any)
* @returns The parsed steps with the final output
*/
export const getAgentStepsParser =
(outputParser?: N8nOutputParser, memory?: BaseChatMemory) =>
async (steps: AgentFinish | AgentAction[]): Promise<AgentFinish | AgentAction[]> => {
// Check if the steps contain the 'format_final_json_response' tool invocation.
if (Array.isArray(steps)) {
const responseParserTool = steps.find((step) => step.tool === 'format_final_json_response');
if (responseParserTool && outputParser) {
const toolInput = responseParserTool.toolInput;
// Ensure the tool input is a string
const parserInput = toolInput instanceof Object ? JSON.stringify(toolInput) : toolInput;
const returnValues = (await outputParser.parse(parserInput)) as Record<string, unknown>;
return handleParsedStepOutput(returnValues, memory);
}
}
// Otherwise, if the steps contain a returnValues field, try to parse them manually.
if (outputParser && typeof steps === 'object' && (steps as AgentFinish).returnValues) {
const finalResponse = (steps as AgentFinish).returnValues;
let parserInput: string;
if (finalResponse instanceof Object) {
if ('output' in finalResponse) {
try {
const parsedOutput = jsonParse<Record<string, unknown>>(finalResponse.output);
// Check if the parsed output already has the expected structure
// If it already has { output: ... }, use it as-is to avoid double wrapping
// Otherwise, wrap it in { output: ... } as expected by the parser
if (
parsedOutput !== null &&
typeof parsedOutput === 'object' &&
'output' in parsedOutput &&
Object.keys(parsedOutput).length === 1
) {
// Already has the expected structure, use as-is
parserInput = JSON.stringify(parsedOutput);
} else {
// Needs wrapping for the parser
parserInput = JSON.stringify({ output: parsedOutput });
}
} catch (error) {
// Fallback to the raw output if parsing fails.
parserInput = finalResponse.output;
}
} else {
// If the output is not an object, we will stringify it as it is
parserInput = JSON.stringify(finalResponse);
}
} else {
parserInput = finalResponse;
}
const returnValues = (await outputParser.parse(parserInput)) as Record<string, unknown>;
return handleParsedStepOutput(returnValues, memory);
}
return handleAgentFinishOutput(steps);
};
/* -----------------------------------------------------------
Agent Setup Helpers
----------------------------------------------------------- */
/**
* Retrieves the language model from the input connection.
* Throws an error if the model is not a valid chat instance or does not support tools.
*
* @param ctx - The execution context
* @returns The validated chat model
*/
export async function getChatModel(
ctx: IExecuteFunctions | ISupplyDataFunctions | IWebhookFunctions,
index: number = 0,
): Promise<BaseChatModel | undefined> {
const connectedModels = await ctx.getInputConnectionData(NodeConnectionTypes.AiLanguageModel, 0);
let model;
if (Array.isArray(connectedModels) && index !== undefined) {
if (connectedModels.length <= index) {
return undefined;
}
// We get the models in reversed order from the workflow so we need to reverse them to match the right index
const reversedModels = [...connectedModels].reverse();
model = reversedModels[index] as BaseChatModel;
} else {
model = connectedModels as BaseChatModel;
}
if (!isChatInstance(model) || !model.bindTools) {
throw new NodeOperationError(
ctx.getNode(),
'Tools Agent requires Chat Model which supports Tools calling',
);
}
return model;
}
/**
* Retrieves the memory instance from the input connection if it is connected
*
* @param ctx - The execution context
* @returns The connected memory (if any)
*/
export async function getOptionalMemory(
ctx: IExecuteFunctions | ISupplyDataFunctions | IWebhookFunctions,
): Promise<BaseChatMemory | undefined> {
return (await ctx.getInputConnectionData(NodeConnectionTypes.AiMemory, 0)) as
| BaseChatMemory
| undefined;
}
/**
* Retrieves the connected tools and (if an output parser is defined)
* appends a structured output parser tool.
*
* @param ctx - The execution context
* @param outputParser - The optional output parser
* @returns The array of connected tools
*/
export async function getTools(
ctx: IExecuteFunctions | ISupplyDataFunctions | IWebhookFunctions,
outputParser?: N8nOutputParser,
): Promise<Array<DynamicStructuredTool | Tool>> {
const tools = (await getConnectedTools(ctx, true, false)) as Array<DynamicStructuredTool | Tool>;
// If an output parser is available, create a dynamic tool to validate the final output.
if (outputParser) {
const schema = getOutputParserSchema(outputParser);
const structuredOutputParserTool = new DynamicStructuredTool({
schema,
name: 'format_final_json_response',
description:
'Use this tool to format your final response to the user in a structured JSON format. This tool validates your output against a schema to ensure it meets the required format. ONLY use this tool when you have completed all necessary reasoning and are ready to provide your final answer. Do not use this tool for intermediate steps or for asking questions. The output from this tool will be directly returned to the user.',
// We do not use a function here because we intercept the output with the parser.
func: async () => '',
});
tools.push(structuredOutputParserTool);
}
return tools;
}
/**
* Prepares the prompt messages for the agent.
*
* @param ctx - The execution context
* @param itemIndex - The current item index
* @param options - Options containing systemMessage and other parameters
* @returns The array of prompt messages
*/
export async function prepareMessages(
ctx: IExecuteFunctions | ISupplyDataFunctions,
itemIndex: number,
options: {
systemMessage?: string;
passthroughBinaryImages?: boolean;
outputParser?: N8nOutputParser;
},
): Promise<BaseMessagePromptTemplateLike[]> {
const useSystemMessage = options.systemMessage ?? ctx.getNode().typeVersion < 1.9;
const messages: BaseMessagePromptTemplateLike[] = [];
if (useSystemMessage) {
messages.push([
'system',
`{system_message}${options.outputParser ? '\n\n{formatting_instructions}' : ''}`,
]);
} else if (options.outputParser) {
messages.push(['system', '{formatting_instructions}']);
}
messages.push(['placeholder', '{chat_history}'], ['human', '{input}']);
// If there is binary data and the node option permits it, add a binary message
const hasBinaryData = ctx.getInputData()?.[itemIndex]?.binary !== undefined;
if (hasBinaryData && options.passthroughBinaryImages) {
const binaryMessage = await extractBinaryMessages(ctx, itemIndex);
if (binaryMessage.content.length !== 0) {
messages.push(binaryMessage);
} else {
ctx.logger.debug('Not attaching binary message, since its content was empty');
}
}
// We add the agent scratchpad last, so that the agent will not run in loops
// by adding binary messages between each interaction
messages.push(['placeholder', '{agent_scratchpad}']);
return messages;
}
/**
* Creates the chat prompt from messages.
*
* @param messages - The messages array
* @returns The ChatPromptTemplate instance
*/
export function preparePrompt(messages: BaseMessagePromptTemplateLike[]): ChatPromptTemplate {
return ChatPromptTemplate.fromMessages(messages);
}
@@ -0,0 +1,42 @@
import type { INodeProperties } from 'n8n-workflow';
import { SYSTEM_MESSAGE } from './prompt';
export const commonOptions: INodeProperties[] = [
{
displayName: 'System Message',
name: 'systemMessage',
type: 'string',
default: SYSTEM_MESSAGE,
description: 'The message that will be sent to the agent before the conversation starts',
builderHint: {
message:
"Must include: agent's purpose, exact names of connected tools, and response instructions",
},
typeOptions: {
rows: 6,
},
},
{
displayName: 'Max Iterations',
name: 'maxIterations',
type: 'number',
default: 10,
description: 'The maximum number of iterations the agent will run before stopping',
},
{
displayName: 'Return Intermediate Steps',
name: 'returnIntermediateSteps',
type: 'boolean',
default: false,
description: 'Whether or not the output should include intermediate steps the agent took',
},
{
displayName: 'Automatically Passthrough Binary Images',
name: 'passthroughBinaryImages',
type: 'boolean',
default: true,
description:
'Whether or not binary images should be automatically passed through to the agent as image type messages',
},
];
@@ -0,0 +1 @@
export const SYSTEM_MESSAGE = 'You are a helpful assistant';
@@ -0,0 +1,43 @@
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools';
import { NodeOperationError, type IExecuteFunctions, type INode } from 'n8n-workflow';
import type { ZodObjectAny } from '../../../../types/types';
export async function extractParsedOutput(
ctx: IExecuteFunctions,
outputParser: BaseOutputParser<unknown>,
output: string,
): Promise<Record<string, unknown> | undefined> {
const parsedOutput = (await outputParser.parse(output)) as {
output: Record<string, unknown>;
};
if (ctx.getNode().typeVersion <= 1.6) {
return parsedOutput;
}
// For 1.7 and above, we try to extract the output from the parsed output
// with fallback to the original output if it's not present
return parsedOutput?.output ?? parsedOutput;
}
export async function checkForStructuredTools(
tools: Array<Tool | DynamicStructuredTool<ZodObjectAny>>,
node: INode,
currentAgentType: string,
) {
const dynamicStructuredTools = tools.filter(
(tool) => tool.constructor.name === 'DynamicStructuredTool',
);
if (dynamicStructuredTools.length > 0) {
const getToolName = (tool: Tool | DynamicStructuredTool) => `"${tool.name}"`;
throw new NodeOperationError(
node,
`The selected tools are not supported by "${currentAgentType}", please use "Tools Agent" instead`,
{
itemIndex: 0,
description: `Incompatible connected tools: ${dynamicStructuredTools.map(getToolName).join(', ')}`,
},
);
}
}
@@ -0,0 +1,159 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import { AgentExecutor } from '@langchain/classic/agents';
import type { Tool } from '@langchain/classic/tools';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import * as helpers from '../../../../../utils/helpers';
import { toolsAgentExecute } from '../../agents/ToolsAgent/V1/execute';
const mockHelpers = mock<IExecuteFunctions['helpers']>();
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
beforeEach(() => jest.resetAllMocks());
describe('toolsAgentExecute', () => {
beforeEach(() => {
jest.clearAllMocks();
mockContext.logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
});
it('should process items', async () => {
const mockNode = mock<INode>();
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
// Mock getNodeParameter to return default values
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockExecutor.invoke).toHaveBeenCalledTimes(2);
expect(result[0]).toHaveLength(2);
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
});
it('should handle errors when continueOnFail is true', async () => {
const mockNode = mock<INode>();
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
mockContext.continueOnFail.mockReturnValue(true);
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: '{ "text": "success" }' })
.mockRejectedValueOnce(new Error('Test error')),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(result[0]).toHaveLength(2);
expect(result[0][0].json).toEqual({ output: { text: 'success' } });
expect(result[0][1].json).toEqual({ error: 'Test error' });
});
it('should throw error in when continueOnFail is false', async () => {
const mockNode = mock<INode>();
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
mockContext.continueOnFail.mockReturnValue(false);
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success' }) })
.mockRejectedValueOnce(new Error('Test error')),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
});
});
@@ -0,0 +1,910 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { mock } from 'jest-mock-extended';
import { AgentExecutor } from '@langchain/classic/agents';
import type { Tool } from '@langchain/classic/tools';
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
import * as helpers from '../../../../../utils/helpers';
import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser';
import * as commonModule from '../../agents/ToolsAgent/common';
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';
jest.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({
getOptionalOutputParser: jest.fn(),
N8nStructuredOutputParser: jest.fn(),
}));
jest.mock('../../agents/ToolsAgent/common', () => ({
...jest.requireActual('../../agents/ToolsAgent/common'),
getOptionalMemory: jest.fn(),
}));
const mockHelpers = mock<IExecuteFunctions['helpers']>();
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
describe('toolsAgentExecute', () => {
beforeEach(() => {
jest.clearAllMocks();
mockContext.logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
});
it('should process items sequentially when batchSize is not set', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
// Mock getNodeParameter to return default values
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'needsFallback') return false;
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: { text: 'success 1' } })
.mockResolvedValueOnce({ output: { text: 'success 2' } }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockExecutor.invoke).toHaveBeenCalledTimes(2);
expect(result[0]).toHaveLength(2);
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
});
it('should process items in parallel within batches when batchSize > 1', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
{ json: { text: 'test input 3' } },
{ json: { text: 'test input 4' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return 2;
if (param === 'options.batching.delayBetweenBatches') return 100;
if (param === 'text') return 'test input';
if (param === 'needsFallback') return false;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: { text: 'success 1' } })
.mockResolvedValueOnce({ output: { text: 'success 2' } })
.mockResolvedValueOnce({ output: { text: 'success 3' } })
.mockResolvedValueOnce({ output: { text: 'success 4' } }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockExecutor.invoke).toHaveBeenCalledTimes(4); // Each item is processed individually
expect(result[0]).toHaveLength(4);
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
expect(result[0][2].json).toEqual({ output: { text: 'success 3' } });
expect(result[0][3].json).toEqual({ output: { text: 'success 4' } });
});
it('should handle errors in batch processing when continueOnFail is true', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return 2;
if (param === 'options.batching.delayBetweenBatches') return 0;
if (param === 'text') return 'test input';
if (param === 'needsFallback') return false;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
mockContext.continueOnFail.mockReturnValue(true);
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: { text: 'success' } })
.mockRejectedValueOnce(new Error('Test error')),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(result[0]).toHaveLength(2);
expect(result[0][0].json).toEqual({ output: { text: 'success' } });
expect(result[0][1].json).toEqual({ error: 'Test error' });
});
it('should throw error in batch processing when continueOnFail is false', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return 2;
if (param === 'options.batching.delayBetweenBatches') return 0;
if (param === 'text') return 'test input';
if (param === 'needsFallback') return false;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
mockContext.continueOnFail.mockReturnValue(false);
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success' }) })
.mockRejectedValueOnce(new Error('Test error')),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
});
it('should fetch output parser with correct item index', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
{ json: { text: 'test input 3' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
const mockParser3 = mock<outputParserModule.N8nStructuredOutputParser>();
const getOptionalOutputParserSpy = jest
.spyOn(outputParserModule, 'getOptionalOutputParser')
.mockResolvedValueOnce(mockParser1)
.mockResolvedValueOnce(mockParser2)
.mockResolvedValueOnce(mockParser3)
.mockResolvedValueOnce(undefined); // For the check call
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await toolsAgentExecute.call(mockContext);
// Verify getOptionalOutputParser was called with correct indices
expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 0);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 1);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 0);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 2);
});
it('should pass different output parsers to getTools for each item', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
jest
.spyOn(outputParserModule, 'getOptionalOutputParser')
.mockResolvedValueOnce(mockParser1)
.mockResolvedValueOnce(mockParser2);
const getToolsSpy = jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'text') return 'test input';
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await toolsAgentExecute.call(mockContext);
// Verify getTools was called with different parsers
expect(getToolsSpy).toHaveBeenCalledTimes(2);
expect(getToolsSpy).toHaveBeenNthCalledWith(1, mockContext, true, false);
expect(getToolsSpy).toHaveBeenNthCalledWith(2, mockContext, true, false);
});
it('should maintain correct parser-item mapping in batch processing', async () => {
const mockNode = mock<INode>();
mockNode.typeVersion = 2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
{ json: { text: 'test input 3' } },
{ json: { text: 'test input 4' } },
]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockParsers = [
mock<outputParserModule.N8nStructuredOutputParser>(),
mock<outputParserModule.N8nStructuredOutputParser>(),
mock<outputParserModule.N8nStructuredOutputParser>(),
mock<outputParserModule.N8nStructuredOutputParser>(),
];
const getOptionalOutputParserSpy = jest
.spyOn(outputParserModule, 'getOptionalOutputParser')
.mockImplementation(async (_ctx, index) => mockParsers[index || 0]);
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'options.batching.batchSize') return 2;
if (param === 'options.batching.delayBetweenBatches') return 0;
if (param === 'text') return 'test input';
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest
.fn()
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) })
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await toolsAgentExecute.call(mockContext);
// Verify each item got its corresponding parser based on index
// It's called once per item + once to check if output parser is connected
expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 1);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 0);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 2);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 3);
expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(6, mockContext, 0);
});
describe('streaming', () => {
let mockNode: INode;
let mockModel: BaseChatModel;
beforeEach(() => {
jest.clearAllMocks();
mockNode = mock<INode>();
mockNode.typeVersion = 2.2;
mockContext.getNode.mockReturnValue(mockNode);
mockContext.getInputData.mockReturnValue([{ json: { text: 'test input' } }]);
mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockImplementation(async (type, _index) => {
if (type === 'ai_languageModel') return mockModel;
if (type === 'ai_memory') return undefined;
return undefined;
});
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'enableStreaming') return true;
if (param === 'text') return 'test input';
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
});
it('should handle streaming when enableStreaming is true', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
// Mock async generator for streamEvents
const mockStreamEvents = async function* () {
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: 'Hello ',
},
},
};
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: 'world!',
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Hello ');
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'world!');
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
expect(mockExecutor.streamEvents).toHaveBeenCalledTimes(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('Hello world!');
});
it('should capture intermediate steps during streaming when returnIntermediateSteps is true', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'enableStreaming') return true;
if (param === 'text') return 'test input';
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: true, // Enable intermediate steps
passthroughBinaryImages: true,
};
return defaultValue;
});
// Simulate an AIMessage class instance (has toJSON and direct properties)
const fakeAIMessage = {
content: 'I need to call a tool',
tool_calls: [
{
id: 'call_123',
name: 'TestTool',
args: { input: 'test data' },
type: 'function',
},
],
additional_kwargs: {},
response_metadata: {},
id: 'msg_abc',
toJSON() {
return {
lc: 1,
type: 'constructor',
id: ['langchain_core', 'messages', 'AIMessage'],
kwargs: {
content: this.content,
tool_calls: this.tool_calls,
},
};
},
};
// Mock async generator for streamEvents with tool calls
const mockStreamEvents = async function* () {
// LLM response with tool call (using the fake AIMessage instance)
yield {
event: 'on_chat_model_end',
data: {
output: fakeAIMessage,
},
};
// Tool execution result
yield {
event: 'on_tool_end',
name: 'TestTool',
data: {
output: 'Tool execution result',
},
};
// Final LLM response
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: 'Final response',
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('Final response');
// Check intermediate steps
expect(result[0][0].json.intermediateSteps).toBeDefined();
expect(result[0][0].json.intermediateSteps).toHaveLength(1);
const step = (result[0][0].json.intermediateSteps as any[])[0];
expect(step.action).toBeDefined();
expect(step.action.tool).toBe('TestTool');
expect(step.action.toolInput).toEqual({ input: 'test data' });
expect(step.action.toolCallId).toBe('call_123');
expect(step.action.type).toBe('function');
expect(step.action.messageLog).toBeDefined();
expect(step.observation).toBe('Tool execution result');
const messageLogEntry = step.action.messageLog[0];
expect(messageLogEntry.content).toBe('I need to call a tool');
expect(messageLogEntry.tool_calls).toEqual([
{ id: 'call_123', name: 'TestTool', args: { input: 'test data' }, type: 'function' },
]);
});
it('should use regular execution on version 2.2 when enableStreaming is false', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
const mockExecutor = {
invoke: jest.fn().mockResolvedValue({ output: 'Regular response' }),
streamEvents: jest.fn(),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).not.toHaveBeenCalled();
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
expect(mockExecutor.streamEvents).not.toHaveBeenCalled();
expect(result[0][0].json.output).toBe('Regular response');
});
it('should use regular execution on version 2.2 when streaming is not available', async () => {
mockContext.isStreaming.mockReturnValue(false);
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
const mockExecutor = {
invoke: jest.fn().mockResolvedValue({ output: 'Regular response' }),
streamEvents: jest.fn(),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).not.toHaveBeenCalled();
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
expect(mockExecutor.streamEvents).not.toHaveBeenCalled();
expect(result[0][0].json.output).toBe('Regular response');
});
it('should respect context window length from memory in streaming mode', async () => {
const mockMemory = {
loadMemoryVariables: jest.fn().mockResolvedValue({
chat_history: [
{ role: 'human', content: 'Message 1' },
{ role: 'ai', content: 'Response 1' },
],
}),
chatHistory: {
getMessages: jest.fn().mockResolvedValue([
{ role: 'human', content: 'Message 1' },
{ role: 'ai', content: 'Response 1' },
{ role: 'human', content: 'Message 2' },
{ role: 'ai', content: 'Response 2' },
]),
},
};
jest.spyOn(commonModule, 'getOptionalMemory').mockResolvedValue(mockMemory as any);
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
const mockStreamEvents = async function* () {
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: 'Response',
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
await toolsAgentExecute.call(mockContext);
// Verify that memory.loadMemoryVariables was called instead of chatHistory.getMessages
expect(mockMemory.loadMemoryVariables).toHaveBeenCalledWith({});
expect(mockMemory.chatHistory.getMessages).not.toHaveBeenCalled();
// Verify that streamEvents was called with the filtered chat history from loadMemoryVariables
expect(mockExecutor.streamEvents).toHaveBeenCalledWith(
expect.objectContaining({
chat_history: [
{ role: 'human', content: 'Message 1' },
{ role: 'ai', content: 'Response 1' },
],
}),
expect.any(Object),
);
});
it('should handle mixed message content types in streaming', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
// Mock async generator for streamEvents with mixed content types
const mockStreamEvents = async function* () {
// Message with array content including text and non-text types
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'thinking', content: 'This is thinking content' },
{ type: 'text', text: 'world!' },
{ type: 'image', url: 'data:image/png;base64,abc123' },
],
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Hello world!');
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('Hello world!');
});
it('should handle string content in streaming', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
// Mock async generator for streamEvents with string content
const mockStreamEvents = async function* () {
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: 'Direct string content',
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Direct string content');
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('Direct string content');
});
it('should ignore non-text message types in array content', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
// Mock async generator with only non-text content
const mockStreamEvents = async function* () {
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: [
{ type: 'thinking', content: 'This is thinking content' },
{ type: 'image', url: 'data:image/png;base64,abc123' },
{ type: 'audio', data: 'audio-data' },
],
},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, '');
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('');
});
it('should handle empty chunk content gracefully', async () => {
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
mockContext.isStreaming.mockReturnValue(true);
// Mock async generator with empty content
const mockStreamEvents = async function* () {
yield {
event: 'on_chat_model_stream',
data: {
chunk: {
content: null,
},
},
};
yield {
event: 'on_chat_model_stream',
data: {
chunk: {},
},
};
};
const mockExecutor = {
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockContext);
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json.output).toBe('');
});
});
it('should process items if SupplyDataContext is passed and isStreaming is not set', async () => {
const mockSupplyDataContext = mock<ISupplyDataFunctions>();
// @ts-expect-error isStreaming is not supported by SupplyDataFunctions, but mock object still resolves it
mockSupplyDataContext.isStreaming = undefined;
mockSupplyDataContext.logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
const mockNode = mock<INode>();
mockNode.typeVersion = 2.2; // version where streaming is supported
mockSupplyDataContext.getNode.mockReturnValue(mockNode);
mockSupplyDataContext.getInputData.mockReturnValue([{ json: { text: 'test input 1' } }]);
const mockModel = mock<BaseChatModel>();
mockModel.bindTools = jest.fn();
mockModel.lc_namespace = ['chat_models'];
mockSupplyDataContext.getInputConnectionData.mockResolvedValue(mockModel);
const mockTools = [mock<Tool>()];
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
// Mock getNodeParameter to return default values
mockSupplyDataContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
if (param === 'enableStreaming') return true;
if (param === 'text') return 'test input';
if (param === 'needsFallback') return false;
if (param === 'options.batching.batchSize') return defaultValue;
if (param === 'options.batching.delayBetweenBatches') return defaultValue;
if (param === 'options')
return {
systemMessage: 'You are a helpful assistant',
maxIterations: 10,
returnIntermediateSteps: false,
passthroughBinaryImages: true,
};
return defaultValue;
});
const mockExecutor = {
invoke: jest.fn().mockResolvedValueOnce({ output: { text: 'success 1' } }),
};
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
const result = await toolsAgentExecute.call(mockSupplyDataContext);
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
expect(result[0]).toHaveLength(1);
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
});
});
@@ -0,0 +1,388 @@
import type { RequestResponseMetadata } from '@utils/agent-execution';
import { mock } from 'jest-mock-extended';
import {
sleep,
type IExecuteFunctions,
type INode,
type EngineRequest,
type EngineResponse,
} from 'n8n-workflow';
import { toolsAgentExecute } from '../../agents/ToolsAgent/V3/execute';
import * as helpers from '../../agents/ToolsAgent/V3/helpers';
// Mock the helper modules
jest.mock('../../agents/ToolsAgent/V3/helpers', () => ({
buildExecutionContext: jest.fn(),
executeBatch: jest.fn(),
checkMaxIterations: jest.fn(),
buildResponseMetadata: jest.fn(),
}));
// Mock langchain modules
jest.mock('@langchain/classic/agents', () => ({
createToolCallingAgent: jest.fn(),
}));
jest.mock('@langchain/core/runnables', () => ({
RunnableSequence: {
from: jest.fn(),
},
}));
jest.mock('n8n-workflow', () => ({
...jest.requireActual('n8n-workflow'),
sleep: jest.fn(),
}));
const mockContext = mock<IExecuteFunctions>();
const mockNode = mock<INode>();
beforeEach(() => {
jest.clearAllMocks();
mockContext.getNode.mockReturnValue(mockNode);
mockContext.logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
});
describe('toolsAgentExecute V3 - Execute Function Logic', () => {
it('should build execution context and process single batch', async () => {
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }],
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockBatchResult = {
returnData: [{ json: { output: 'success 1' }, pairedItem: { item: 0 } }],
request: undefined,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
const result = await toolsAgentExecute.call(mockContext);
expect(helpers.buildExecutionContext).toHaveBeenCalledWith(mockContext);
expect(helpers.executeBatch).toHaveBeenCalledTimes(1);
expect(helpers.executeBatch).toHaveBeenCalledWith(
mockContext,
mockExecutionContext.items.slice(0, 1),
0,
mockExecutionContext.model,
mockExecutionContext.fallbackModel,
mockExecutionContext.memory,
undefined,
);
expect(result).toEqual([[{ json: { output: 'success 1' }, pairedItem: { item: 0 } }]]);
});
it('should process multiple batches sequentially', async () => {
const mockExecutionContext = {
items: [
{ json: { text: 'test input 1' } },
{ json: { text: 'test input 2' } },
{ json: { text: 'test input 3' } },
],
batchSize: 2,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockBatchResult1 = {
returnData: [
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
],
request: undefined,
};
const mockBatchResult2 = {
returnData: [{ json: { output: 'success 3' }, pairedItem: { item: 2 } }],
request: undefined,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest
.spyOn(helpers, 'executeBatch')
.mockResolvedValueOnce(mockBatchResult1)
.mockResolvedValueOnce(mockBatchResult2);
const result = await toolsAgentExecute.call(mockContext);
expect(helpers.executeBatch).toHaveBeenCalledTimes(2);
expect(helpers.executeBatch).toHaveBeenNthCalledWith(
1,
mockContext,
mockExecutionContext.items.slice(0, 2),
0,
mockExecutionContext.model,
mockExecutionContext.fallbackModel,
mockExecutionContext.memory,
undefined,
);
expect(helpers.executeBatch).toHaveBeenNthCalledWith(
2,
mockContext,
mockExecutionContext.items.slice(2, 3),
2,
mockExecutionContext.model,
mockExecutionContext.fallbackModel,
mockExecutionContext.memory,
undefined,
);
expect(result).toEqual([
[
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
{ json: { output: 'success 3' }, pairedItem: { item: 2 } },
],
]);
});
it('should return request when batch returns tool call request', async () => {
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }],
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockRequest: EngineRequest<RequestResponseMetadata> = {
actions: [
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool',
input: { input: 'test data' },
type: 'ai_tool',
id: 'call_123',
metadata: { itemIndex: 0 },
},
],
metadata: { previousRequests: [] },
};
const mockBatchResult = {
returnData: [],
request: mockRequest,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
const result = await toolsAgentExecute.call(mockContext);
expect(result).toEqual(mockRequest);
});
it('should merge requests from multiple batches', async () => {
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockRequest1: EngineRequest<RequestResponseMetadata> = {
actions: [
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool 1',
input: { input: 'test data 1' },
type: 'ai_tool',
id: 'call_123',
metadata: { itemIndex: 0 },
},
],
metadata: { previousRequests: [] },
};
const mockRequest2: EngineRequest<RequestResponseMetadata> = {
actions: [
{
actionType: 'ExecutionNodeAction' as const,
nodeName: 'Test Tool 2',
input: { input: 'test data 2' },
type: 'ai_tool',
id: 'call_456',
metadata: { itemIndex: 1 },
},
],
metadata: { previousRequests: [] },
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest
.spyOn(helpers, 'executeBatch')
.mockResolvedValueOnce({ returnData: [], request: mockRequest1 })
.mockResolvedValueOnce({ returnData: [], request: mockRequest2 });
const result = (await toolsAgentExecute.call(
mockContext,
)) as EngineRequest<RequestResponseMetadata>;
expect(result.actions).toHaveLength(2);
expect(result.actions[0].nodeName).toBe('Test Tool 1');
expect(result.actions[1].nodeName).toBe('Test Tool 2');
});
it('should apply delay between batches when configured', async () => {
const sleepMock = sleep as jest.MockedFunction<typeof sleep>;
sleepMock.mockResolvedValue(undefined);
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
batchSize: 1,
delayBetweenBatches: 1000,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockBatchResult = {
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
request: undefined,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
await toolsAgentExecute.call(mockContext);
expect(sleepMock).toHaveBeenCalledWith(1000);
expect(sleepMock).toHaveBeenCalledTimes(1); // Only between batches, not after the last one
});
it('should not apply delay after last batch', async () => {
const sleepMock = sleep as jest.MockedFunction<typeof sleep>;
sleepMock.mockResolvedValue(undefined);
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }],
batchSize: 1,
delayBetweenBatches: 1000,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockBatchResult = {
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
request: undefined,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
await toolsAgentExecute.call(mockContext);
expect(sleepMock).not.toHaveBeenCalled();
});
it('should pass response parameter to executeBatch', async () => {
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }],
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
const mockBatchResult = {
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
request: undefined,
};
const mockResponse: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
{
action: {
id: 'call_123',
nodeName: 'Test Tool',
input: { input: 'test data', id: 'call_123' },
metadata: { itemIndex: 0 },
actionType: 'ExecutionNodeAction',
type: 'ai_tool',
},
data: {
data: { ai_tool: [[{ json: { result: 'tool result' } }]] },
executionTime: 0,
startTime: 0,
executionIndex: 0,
source: [],
},
},
],
metadata: { itemIndex: 0, previousRequests: [] },
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
await toolsAgentExecute.call(mockContext, mockResponse);
expect(helpers.executeBatch).toHaveBeenCalledWith(
mockContext,
mockExecutionContext.items.slice(0, 1),
0,
mockExecutionContext.model,
mockExecutionContext.fallbackModel,
mockExecutionContext.memory,
mockResponse,
);
});
it('should collect return data from multiple batches', async () => {
const mockExecutionContext = {
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
batchSize: 1,
delayBetweenBatches: 0,
needsFallback: false,
model: {} as any,
fallbackModel: null,
memory: undefined,
};
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
jest
.spyOn(helpers, 'executeBatch')
.mockResolvedValueOnce({
returnData: [{ json: { output: 'success 1' }, pairedItem: { item: 0 } }],
request: undefined,
})
.mockResolvedValueOnce({
returnData: [{ json: { output: 'success 2' }, pairedItem: { item: 1 } }],
request: undefined,
});
const result = await toolsAgentExecute.call(mockContext);
expect(result).toEqual([
[
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
],
]);
});
});
@@ -0,0 +1,881 @@
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage } from '@langchain/core/messages';
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
import { FakeLLM, FakeStreamingChatModel } from '@langchain/core/utils/testing';
import { Buffer } from 'buffer';
import { mock } from 'jest-mock-extended';
import type { AgentAction, AgentFinish } from '@langchain/classic/agents';
import type { ToolsAgentAction } from '@langchain/classic/dist/agents/tool_calling/output_parser';
import type { Tool } from '@langchain/classic/tools';
import type { IExecuteFunctions, INode } from 'n8n-workflow';
import { NodeOperationError, BINARY_ENCODING, NodeConnectionTypes } from 'n8n-workflow';
import type { ZodType } from 'zod';
import { z } from 'zod';
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
import {
getOutputParserSchema,
extractBinaryMessages,
fixEmptyContentMessage,
handleParsedStepOutput,
getChatModel,
getOptionalMemory,
prepareMessages,
preparePrompt,
getTools,
getAgentStepsParser,
handleAgentFinishOutput,
} from '../../agents/ToolsAgent/common';
function getFakeOutputParser(returnSchema?: ZodType): N8nOutputParser {
const fakeOutputParser = mock<N8nOutputParser>();
(fakeOutputParser.getSchema as jest.Mock).mockReturnValue(returnSchema);
return fakeOutputParser;
}
function createMockOutputParser(parseReturnValue?: Record<string, unknown>): N8nOutputParser {
const mockParser = mock<N8nOutputParser>();
(mockParser.parse as jest.Mock).mockResolvedValue(parseReturnValue);
return mockParser;
}
const mockHelpers = mock<IExecuteFunctions['helpers']>();
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
beforeEach(() => jest.resetAllMocks());
describe('getOutputParserSchema', () => {
it('should return a default schema if getSchema returns undefined', () => {
const schema = getOutputParserSchema(getFakeOutputParser(undefined));
// The default schema requires a "text" field.
expect(() => schema.parse({})).toThrow();
expect(schema.parse({ text: 'hello' })).toEqual({ text: 'hello' });
});
it('should return the custom schema if provided', () => {
const customSchema = z.object({ custom: z.number() });
const schema = getOutputParserSchema(getFakeOutputParser(customSchema));
expect(() => schema.parse({ custom: 'not a number' })).toThrow();
expect(schema.parse({ custom: 123 })).toEqual({ custom: 123 });
});
});
describe('extractBinaryMessages', () => {
it('should extract a binary message from the input data when no id is provided', async () => {
const fakeItem = {
json: {},
binary: {
img1: {
mimeType: 'image/png',
// simulate that data already includes 'base64'
data: 'data:image/png;base64,sampledata',
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
// Expect the HumanMessage's content to be an array containing one binary message.
expect(Array.isArray(humanMsg.content)).toBe(true);
expect(humanMsg.content[0]).toEqual({
type: 'image_url',
image_url: { url: 'data:image/png;base64,sampledata' },
});
});
it('should extract a binary message using binary stream if id is provided', async () => {
const fakeItem = {
json: {},
binary: {
img2: {
mimeType: 'image/jpeg',
id: '1234',
data: 'nonsense',
},
},
};
mockHelpers.getBinaryStream.mockResolvedValue(mock());
mockHelpers.binaryToBuffer.mockResolvedValue(Buffer.from('fakebufferdata'));
mockContext.getInputData.mockReturnValue([fakeItem]);
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockHelpers.getBinaryStream).toHaveBeenCalledWith('1234');
// eslint-disable-next-line @typescript-eslint/unbound-method
expect(mockHelpers.binaryToBuffer).toHaveBeenCalled();
const expectedUrl = `data:image/jpeg;base64,${Buffer.from('fakebufferdata').toString(
BINARY_ENCODING,
)}`;
expect(humanMsg.content[0]).toEqual({
type: 'image_url',
image_url: { url: expectedUrl },
});
});
it('should extract markdown and CSV text files', async () => {
const mdContent = '# Test Markdown\n\nThis is a test.';
const csvContent = 'name,age\nJohn,30';
const fakeItem = {
json: {},
binary: {
markdown: {
mimeType: 'text/markdown',
fileName: 'test.md',
data: `data:text/markdown;base64,${Buffer.from(mdContent).toString('base64')}`,
},
csv: {
mimeType: 'text/csv',
fileName: 'data.csv',
data: `data:text/csv;base64,${Buffer.from(csvContent).toString('base64')}`,
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
expect(Array.isArray(humanMsg.content)).toBe(true);
expect(humanMsg.content).toHaveLength(2);
expect(humanMsg.content).toEqual(
expect.arrayContaining([
{ type: 'text', text: `File: test.md\nContent:\n${mdContent}` },
{ type: 'text', text: `File: data.csv\nContent:\n${csvContent}` },
]),
);
});
it('should extract both images and text files together', async () => {
const textContent = 'Some text content';
const fakeItem = {
json: {},
binary: {
image: {
mimeType: 'image/png',
fileName: 'test.png',
data: 'imageData123',
},
text: {
mimeType: 'text/plain',
fileName: 'test.txt',
data: `data:text/plain;base64,${Buffer.from(textContent).toString('base64')}`,
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
expect(Array.isArray(humanMsg.content)).toBe(true);
expect(humanMsg.content).toHaveLength(2);
expect(humanMsg.content).toEqual(
expect.arrayContaining([
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,imageData123' },
},
{ type: 'text', text: `File: test.txt\nContent:\n${textContent}` },
]),
);
});
it('should decode base64-encoded text files without prefix', async () => {
const textContent = 'Hello world!';
const fakeItem = {
json: {},
binary: {
text: {
mimeType: 'text/plain',
fileName: 'test.txt',
// Default n8n binary format: base64 without data URL prefix
data: Buffer.from(textContent).toString('base64'),
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
expect(Array.isArray(humanMsg.content)).toBe(true);
expect(humanMsg.content).toHaveLength(1);
expect(humanMsg.content[0]).toEqual({
type: 'text',
text: `File: test.txt\nContent:\n${textContent}`,
});
});
});
describe('fixEmptyContentMessage', () => {
it('should replace empty string inputs with empty objects', () => {
// Cast to any to bypass type issues with AgentFinish/AgentAction.
const fakeSteps: ToolsAgentAction[] = [
{
messageLog: [
{
content: [{ input: '' }, { input: { already: 'object' } }],
},
],
},
] as unknown as ToolsAgentAction[];
const fixed = fixEmptyContentMessage(fakeSteps) as ToolsAgentAction[];
const messageContent = fixed?.[0]?.messageLog?.[0].content;
// Type assertion needed since we're extending MessageContentComplex
expect((messageContent?.[0] as unknown as { input: unknown })?.input).toEqual({});
expect((messageContent?.[1] as unknown as { input: unknown })?.input).toEqual({
already: 'object',
});
});
});
describe('handleParsedStepOutput', () => {
it('should stringify the output if memory is provided', () => {
const output = { key: 'value' };
const fakeMemory = mock<BaseChatMemory>();
const result = handleParsedStepOutput(output, fakeMemory);
expect(result.returnValues).toEqual({ output: JSON.stringify(output) });
expect(result.log).toEqual('Final response formatted');
});
it('should not stringify the output if memory is not provided', () => {
const output = { key: 'value' };
const result = handleParsedStepOutput(output);
expect(result.returnValues).toEqual(output);
});
});
describe('getChatModel', () => {
it('should return the model if it is a valid chat model', async () => {
// Cast fakeChatModel as any
const fakeChatModel = mock<BaseChatModel>();
fakeChatModel.bindTools = jest.fn();
fakeChatModel.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue(fakeChatModel);
const model = await getChatModel(mockContext);
expect(model).toEqual(fakeChatModel);
});
it('should throw if the model is not a valid chat model', async () => {
const fakeInvalidModel = mock<BaseChatModel>(); // missing bindTools & lc_namespace
fakeInvalidModel.lc_namespace = [];
mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
mockContext.getNode.mockReturnValue(mock());
await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
});
it('should return the first model when multiple models are connected and no index specified', async () => {
const fakeChatModel1 = new FakeStreamingChatModel({});
const fakeChatModel2 = new FakeStreamingChatModel({});
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
const model = await getChatModel(mockContext);
expect(model).toEqual(fakeChatModel2); // Should return the last model (reversed array)
});
it('should return the model at specified index when multiple models are connected', async () => {
const fakeChatModel1 = new FakeStreamingChatModel({});
const fakeChatModel2 = new FakeStreamingChatModel({});
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
const model = await getChatModel(mockContext, 0);
expect(model).toEqual(fakeChatModel2); // Should return the first model after reversal (index 0)
});
it('should return the fallback model at index 1 when multiple models are connected', async () => {
const fakeChatModel1 = new FakeStreamingChatModel({});
const fakeChatModel2 = new FakeStreamingChatModel({});
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
const model = await getChatModel(mockContext, 1);
expect(model).toEqual(fakeChatModel1); // Should return the second model after reversal (index 1)
});
it('should return undefined when requested index is out of bounds', async () => {
const fakeChatModel1 = mock<BaseChatModel>();
fakeChatModel1.bindTools = jest.fn();
fakeChatModel1.lc_namespace = ['chat_models'];
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1]);
mockContext.getNode.mockReturnValue(mock());
const result = await getChatModel(mockContext, 2);
expect(result).toBeUndefined();
});
it('should throw error when single model does not support tools', async () => {
const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls
mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
mockContext.getNode.mockReturnValue(mock());
await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
await expect(getChatModel(mockContext)).rejects.toThrow(
'Tools Agent requires Chat Model which supports Tools calling',
);
});
it('should throw error when model at specified index does not support tools', async () => {
const fakeChatModel1 = new FakeStreamingChatModel({});
const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeInvalidModel]);
mockContext.getNode.mockReturnValue(mock());
await expect(getChatModel(mockContext, 0)).rejects.toThrow(NodeOperationError);
});
});
describe('getOptionalMemory', () => {
it('should return the memory if available', async () => {
const fakeMemory = { some: 'memory' };
mockContext.getInputConnectionData.mockResolvedValue(fakeMemory);
const memory = await getOptionalMemory(mockContext);
expect(memory).toEqual(fakeMemory);
});
});
describe('getTools', () => {
beforeEach(() => {
const fakeTool = mock<Tool>();
mockContext.getInputConnectionData
.calledWith(NodeConnectionTypes.AiTool, 0)
.mockResolvedValue([fakeTool]);
});
it('should retrieve tools without appending if outputParser is not provided', async () => {
const tools = await getTools(mockContext);
expect(tools.length).toEqual(1);
});
it('should retrieve tools and append the structured output parser tool if outputParser is provided', async () => {
const fakeOutputParser = getFakeOutputParser(z.object({ text: z.string() }));
const tools = await getTools(mockContext, fakeOutputParser);
// Our fake getConnectedTools returns one tool; with outputParser, one extra is appended.
expect(tools.length).toEqual(2);
const dynamicTool = tools.find((t) => t.name === 'format_final_json_response');
expect(dynamicTool).toBeDefined();
});
});
describe('prepareMessages', () => {
it('should include a binary message if binary data is present and passthroughBinaryImages is true', async () => {
const fakeItem = {
json: {},
binary: {
img1: {
mimeType: 'image/png',
data: 'data:image/png;base64,sampledata',
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
const messages = await prepareMessages(mockContext, 0, {
systemMessage: 'Test system',
passthroughBinaryImages: true,
});
// Check if any message is an instance of HumanMessage
const hasBinaryMessage = messages.some(
(m) => typeof m === 'object' && m instanceof HumanMessage,
);
expect(hasBinaryMessage).toBe(true);
});
it('should not include a binary message if no binary data is present', async () => {
const fakeItem = { json: {} }; // no binary key
mockContext.getInputData.mockReturnValue([fakeItem]);
const messages = await prepareMessages(mockContext, 0, {
systemMessage: 'Test system',
passthroughBinaryImages: true,
});
const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
expect(hasHumanMessage).toBe(false);
});
it('should not include a binary message if no image data is present', async () => {
const fakeItem = {
json: {},
binary: {
img1: {
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,sampledata',
},
},
};
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
const messages = await prepareMessages(mockContext, 0, {
systemMessage: 'Test system',
passthroughBinaryImages: true,
});
const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
expect(hasHumanMessage).toBe(false);
expect(mockContext.logger.debug).toHaveBeenCalledTimes(1);
});
it('should not include system_message in prompt templates if not provided after version 1.9', async () => {
const fakeItem = { json: {} };
const mockNode = mock<INode>();
mockNode.typeVersion = 1.9;
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.getNode.mockReturnValue(mockNode);
const messages = await prepareMessages(mockContext, 0, {});
expect(messages.length).toBe(3);
expect(messages).not.toContainEqual(['system', '{system_message}']);
});
it('should include system_message in prompt templates if provided after version 1.9', async () => {
const fakeItem = { json: {} };
const mockNode = mock<INode>();
mockNode.typeVersion = 1.9;
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.getNode.mockReturnValue(mockNode);
const messages = await prepareMessages(mockContext, 0, { systemMessage: 'Hello' });
expect(messages.length).toBe(4);
expect(messages).toContainEqual(['system', '{system_message}']);
});
it('should include system_message in prompt templates if not provided before version 1.9', async () => {
const fakeItem = { json: {} };
const mockNode = mock<INode>();
mockNode.typeVersion = 1.8;
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.getNode.mockReturnValue(mockNode);
const messages = await prepareMessages(mockContext, 0, {});
expect(messages.length).toBe(4);
expect(messages).toContainEqual(['system', '{system_message}']);
});
it('should include system_message with formatting_instructions in prompt templates if provided before version 1.9', async () => {
const fakeItem = { json: {} };
const mockNode = mock<INode>();
mockNode.typeVersion = 1.8;
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.getNode.mockReturnValue(mockNode);
const messages = await prepareMessages(mockContext, 0, {
systemMessage: 'Hello',
outputParser: mock<N8nOutputParser>(),
});
expect(messages.length).toBe(4);
expect(messages).toContainEqual(['system', '{system_message}\n\n{formatting_instructions}']);
});
it('should add formatting instructions when omitting system message after version 1.9', async () => {
const fakeItem = { json: {} };
const mockNode = mock<INode>();
mockNode.typeVersion = 1.9;
mockContext.getInputData.mockReturnValue([fakeItem]);
mockContext.getNode.mockReturnValue(mockNode);
const messages = await prepareMessages(mockContext, 0, {
outputParser: mock<N8nOutputParser>(),
});
expect(messages.length).toBe(4);
expect(messages).toContainEqual(['system', '{formatting_instructions}']);
});
});
describe('preparePrompt', () => {
it('should return a ChatPromptTemplate instance', () => {
const sampleMessages: BaseMessagePromptTemplateLike[] = [
['system', 'Test'],
['human', 'Hello'],
];
const prompt = preparePrompt(sampleMessages);
expect(prompt).toBeDefined();
});
});
describe('getAgentStepsParser', () => {
let mockMemory: BaseChatMemory;
beforeEach(() => {
mockMemory = mock<BaseChatMemory>();
});
describe('with format_final_json_response tool', () => {
it('should parse output from format_final_json_response tool', async () => {
const steps: AgentAction[] = [
{
tool: 'format_final_json_response',
toolInput: { city: 'Berlin', temperature: 15 },
log: '',
},
];
const mockOutputParser = createMockOutputParser({
city: 'Berlin',
temperature: 15,
});
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"city":"Berlin","temperature":15}');
expect(result).toEqual({
returnValues: { output: '{"city":"Berlin","temperature":15}' },
log: 'Final response formatted',
});
});
it('should stringify tool input if it is not an object', async () => {
const steps: AgentAction[] = [
{
tool: 'format_final_json_response',
toolInput: 'simple string',
log: '',
},
];
const mockOutputParser = createMockOutputParser({ text: 'simple string' });
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
expect(result).toEqual({
returnValues: { output: '{"text":"simple string"}' },
log: 'Final response formatted',
});
});
});
describe('manual parsing path', () => {
it('should handle already wrapped output structure correctly', async () => {
// Agent returns output that already has { output: {...} } structure
const steps: AgentFinish = {
returnValues: {
output: '{"output":{"city":"Berlin","temperature":15}}',
},
log: '',
};
const mockOutputParser = createMockOutputParser({
city: 'Berlin',
temperature: 15,
});
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should detect the existing wrapper and not double-wrap
expect(mockOutputParser.parse).toHaveBeenCalledWith(
'{"output":{"city":"Berlin","temperature":15}}',
);
expect(result).toEqual({
returnValues: { output: '{"city":"Berlin","temperature":15}' },
log: 'Final response formatted',
});
});
it('should wrap output that is not already wrapped', async () => {
// Agent returns plain data without { output: ... } wrapper
const steps: AgentFinish = {
returnValues: {
output: '{"city":"Berlin","temperature":15}',
},
log: '',
};
const mockOutputParser = createMockOutputParser({
city: 'Berlin',
temperature: 15,
});
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should wrap the data in { output: ... } for the parser
expect(mockOutputParser.parse).toHaveBeenCalledWith(
'{"output":{"city":"Berlin","temperature":15}}',
);
expect(result).toEqual({
returnValues: { output: '{"city":"Berlin","temperature":15}' },
log: 'Final response formatted',
});
});
it('should handle output with additional properties correctly', async () => {
// Output has more than just the "output" property
const steps: AgentFinish = {
returnValues: {
output: '{"output":{"text":"Hello"},"metadata":{"source":"test"}}',
},
log: '',
};
const mockOutputParser = createMockOutputParser({
text: 'Hello',
metadata: { source: 'test' },
});
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should wrap since it has multiple properties
expect(mockOutputParser.parse).toHaveBeenCalledWith(
'{"output":{"output":{"text":"Hello"},"metadata":{"source":"test"}}}',
);
expect(result).toEqual({
returnValues: { output: '{"text":"Hello","metadata":{"source":"test"}}' },
log: 'Final response formatted',
});
});
it('should handle parse errors gracefully', async () => {
const steps: AgentFinish = {
returnValues: {
output: 'invalid json',
},
log: '',
};
const mockOutputParser = createMockOutputParser({ text: 'invalid json' });
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should fallback to raw output when JSON parsing fails
expect(mockOutputParser.parse).toHaveBeenCalledWith('invalid json');
expect(result).toEqual({
returnValues: { output: '{"text":"invalid json"}' },
log: 'Final response formatted',
});
});
it('should handle null output correctly', async () => {
const steps: AgentFinish = {
returnValues: {
output: 'null',
},
log: '',
};
const mockOutputParser = createMockOutputParser({ result: null });
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should wrap null in { output: null }
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"output":null}');
expect(result).toEqual({
returnValues: { output: '{"result":null}' },
log: 'Final response formatted',
});
});
it('should handle undefined-like values correctly', async () => {
const steps: AgentFinish = {
returnValues: {
output: 'undefined',
},
log: '',
};
const mockOutputParser = createMockOutputParser({ text: 'undefined' });
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
const result = await parser(steps);
// Should fallback to raw string since "undefined" is not valid JSON
expect(mockOutputParser.parse).toHaveBeenCalledWith('undefined');
expect(result).toEqual({
returnValues: { output: '{"text":"undefined"}' },
log: 'Final response formatted',
});
});
it('should return output as-is without memory', async () => {
const steps: AgentFinish = {
returnValues: {
output: '{"city":"Berlin","temperature":15}',
},
log: '',
};
const mockOutputParser = createMockOutputParser({
city: 'Berlin',
temperature: 15,
});
const parser = getAgentStepsParser(mockOutputParser, undefined);
const result = await parser(steps);
expect(result).toEqual({
returnValues: { city: 'Berlin', temperature: 15 },
log: 'Final response formatted',
});
});
});
describe('without output parser', () => {
it('should pass through agent finish steps unchanged', async () => {
const steps: AgentFinish = {
returnValues: { output: 'Final answer' },
log: '',
};
const parser = getAgentStepsParser(undefined, undefined);
const result = await parser(steps);
expect(result).toEqual({
log: '',
returnValues: { output: 'Final answer' },
});
});
it('should handle array of agent actions', async () => {
const steps: AgentAction[] = [
{ tool: 'some_tool', toolInput: { query: 'test' }, log: '' },
{ tool: 'another_tool', toolInput: { data: 'value' }, log: '' },
];
const parser = getAgentStepsParser(undefined, undefined);
const result = await parser(steps);
expect(result).toEqual(steps);
});
});
});
describe('handleAgentFinishOutput', () => {
it('should merge multi-output text arrays into a single string', () => {
const steps: AgentFinish = {
returnValues: {
output: [
{ index: 0, type: 'text', text: 'First part' },
{ index: 1, type: 'text', text: 'Second part' },
],
},
log: '',
};
const result = handleAgentFinishOutput(steps);
expect(result).toEqual({
log: '',
returnValues: {
output: 'First part\nSecond part',
},
});
});
it('should not modify non-text multi-output arrays', () => {
const steps: AgentFinish = {
returnValues: {
output: [
{ index: 0, type: 'text', text: 'Text part' },
{ index: 1, type: 'image', url: 'http://example.com/image.png' },
],
},
log: '',
};
const result = handleAgentFinishOutput(steps);
expect(result).toEqual(steps);
});
it('should not modify simple string output', () => {
const steps: AgentFinish = {
returnValues: {
output: 'Simple string output',
},
log: '',
};
const result = handleAgentFinishOutput(steps);
expect(result).toEqual(steps);
});
it('should handle agent action arrays unchanged', () => {
const steps: AgentAction[] = [
{
tool: 'tool1',
toolInput: {},
log: '',
},
{
tool: 'tool2',
toolInput: {},
log: '',
},
];
const result = handleAgentFinishOutput(steps);
expect(result).toEqual(steps);
});
it('should filter out thinking blocks and return only text blocks', () => {
const steps: AgentFinish = {
returnValues: {
output: [
{ index: 0, type: 'thinking', thinking: 'Internal reasoning...' },
{ index: 1, type: 'text', text: 'User-facing output' },
],
},
log: '',
};
const result = handleAgentFinishOutput(steps) as AgentFinish;
expect(result.returnValues.output).toBe('User-facing output');
});
it('should return thinking content when no text blocks exist', () => {
const steps: AgentFinish = {
returnValues: {
output: [
{ index: 0, type: 'thinking', thinking: 'Only thinking content' },
{ index: 1, type: 'thinking', thinking: 'More thinking' },
],
},
log: '',
};
const result = handleAgentFinishOutput(steps) as AgentFinish;
expect(result.returnValues.output).toBe('Only thinking content\nMore thinking');
});
it('should return empty string when no text or thinking blocks exist', () => {
const steps: AgentFinish = {
returnValues: {
output: [{ index: 0, type: 'unknown' }],
},
log: '',
};
const result = handleAgentFinishOutput(steps) as AgentFinish;
expect(result.returnValues.output).toBe('');
});
});
@@ -0,0 +1,124 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import path from 'node:path';
import type { WorkflowTestData } from 'n8n-workflow';
// CI has cold-start overhead on the first test (coverage instrumentation, module loading)
jest.setTimeout(10_000);
/**
* Helper to create a standard OpenAI chat completion response.
*/
function chatCompletionResponse(content: string) {
return {
id: 'chatcmpl-test',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: { role: 'assistant', content },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
/**
* Helper to create an OpenAI chat completion response with tool calls.
*/
function toolCallResponse(toolCalls: Array<{ id: string; name: string; arguments: string }>) {
return {
id: 'chatcmpl-test',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.name,
arguments: tc.arguments,
},
})),
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
describe('AgentTool V3 Integration', () => {
const baseUrl = 'https://api.openai.com';
const credentials = {
openAiApi: {
apiKey: 'test-api-key',
url: `${baseUrl}/v1`,
},
};
const testHarness = new NodeTestHarness({
additionalPackagePaths: [path.dirname(require.resolve('n8n-nodes-base'))],
});
describe('Agent as Tool', () => {
const testData: WorkflowTestData = {
description: 'should execute sub-agent tool and return parent final answer',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-tool-v3-basic.json'),
},
output: {
nodeData: {
'Parent Agent': [
[
{
json: {
output: '2+2 equals 4.',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: toolCallResponse([
{
id: 'call_1',
name: 'SubAgent',
arguments: JSON.stringify({ input: 'What is 2+2?' }),
},
]),
},
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('2+2 equals 4.'),
},
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('2+2 equals 4.'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
});
@@ -0,0 +1,503 @@
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
import path from 'node:path';
import type { WorkflowTestData } from 'n8n-workflow';
// CI has cold-start overhead on the first test (coverage instrumentation, module loading)
jest.setTimeout(10_000);
/**
* Helper to create a standard OpenAI chat completion response.
*/
function chatCompletionResponse(content: string) {
return {
id: 'chatcmpl-test',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: { role: 'assistant', content },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
/**
* Helper to create an OpenAI chat completion response with tool calls.
*/
function toolCallResponse(toolCalls: Array<{ id: string; name: string; arguments: string }>) {
return {
id: 'chatcmpl-test',
object: 'chat.completion',
created: 1700000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: toolCalls.map((tc) => ({
id: tc.id,
type: 'function',
function: {
name: tc.name,
arguments: tc.arguments,
},
})),
},
finish_reason: 'tool_calls',
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
};
}
describe('Agent V3 Integration', () => {
const baseUrl = 'https://api.openai.com';
const credentials = {
openAiApi: {
apiKey: 'test-api-key',
url: `${baseUrl}/v1`,
},
};
const testHarness = new NodeTestHarness({
additionalPackagePaths: [path.dirname(require.resolve('n8n-nodes-base'))],
});
describe('Basic Completion', () => {
const testData: WorkflowTestData = {
description: 'should return basic completion from Agent V3',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-basic.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'Hi there!',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('Hi there!'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Tool Calling Flow', () => {
const testData: WorkflowTestData = {
description: 'should execute tool call and return final answer',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-with-tool.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: '5 times 5 equals 25.',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: toolCallResponse([
{
id: 'call_1',
name: 'Calculator',
arguments: JSON.stringify({ input: '5*5' }),
},
]),
},
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('5 times 5 equals 25.'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Custom System Message', () => {
const testData: WorkflowTestData = {
description: 'should pass system message to model',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-system-message.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'Ahoy there, matey!',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('Ahoy there, matey!'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Auto Prompt Type', () => {
const testData: WorkflowTestData = {
description: 'should read prompt from chatInput field when promptType is auto',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-auto-prompt.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'Hi from auto!',
},
},
],
],
},
},
trigger: {
mode: 'trigger',
input: { json: { chatInput: 'Hello!' } },
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('Hi from auto!'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Fallback Model', () => {
const testData: WorkflowTestData = {
description: 'should use fallback model when primary fails',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-fallback-model.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'Hello from fallback!',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 400,
responseBody: {
error: {
message: 'Bad request',
type: 'invalid_request_error',
code: null,
},
},
},
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('Hello from fallback!'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Output Parser', () => {
const testData: WorkflowTestData = {
description: 'should parse structured output via format_final_json_response tool',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-output-parser.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: {
state: 'California',
cities: ['Los Angeles', 'San Francisco'],
},
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: toolCallResponse([
{
id: 'call_1',
name: 'format_final_json_response',
arguments: JSON.stringify({
output: {
state: 'California',
cities: ['Los Angeles', 'San Francisco'],
},
}),
},
]),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Intermediate Steps', () => {
const testData: WorkflowTestData = {
description: 'should include intermediate steps when returnIntermediateSteps is enabled',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-intermediate-steps.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: '5 times 5 equals 25.',
intermediateSteps: expect.arrayContaining([
expect.objectContaining({
action: expect.objectContaining({
tool: 'Calculator',
toolCallId: 'call_1',
}),
observation: expect.any(String),
}),
]),
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: toolCallResponse([
{
id: 'call_1',
name: 'Calculator',
arguments: JSON.stringify({ input: '5*5' }),
},
]),
},
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('5 times 5 equals 25.'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Continue-on-Fail', () => {
const testData: WorkflowTestData = {
description: 'should return error in output when continueOnFail is enabled',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-continue-on-fail.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
error: expect.any(String),
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 400,
responseBody: {
error: {
message: 'Bad request',
type: 'invalid_request_error',
code: null,
},
},
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Memory Integration', () => {
const testData: WorkflowTestData = {
description: 'should complete successfully with buffer window memory connected',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-memory.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'I remember everything!',
},
},
],
],
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('I remember everything!'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
describe('Binary Image Passthrough', () => {
const testData: WorkflowTestData = {
description: 'should complete successfully with binary image data in input',
input: {
workflowData: testHarness.readWorkflowJSON('workflows/agent-v3-binary-images.json'),
},
output: {
nodeData: {
'AI Agent': [
[
{
json: {
output: 'I see a cat in the image.',
},
},
],
],
},
},
trigger: {
mode: 'trigger',
input: {
json: { chatInput: 'What is in this image?' },
binary: {
image: {
mimeType: 'image/png',
data: Buffer.from('fake-png-data').toString('base64'),
fileName: 'test.png',
},
},
},
},
nock: {
baseUrl,
mocks: [
{
method: 'post',
path: '/v1/chat/completions',
statusCode: 200,
responseBody: chatCompletionResponse('I see a cat in the image.'),
},
],
},
};
testHarness.setupTest(testData, { credentials });
});
});
@@ -0,0 +1,123 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Ask the sub agent what 2+2 is"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "parent-agent-id",
"name": "Parent Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "parent-model-id",
"name": "Parent Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"toolDescription": "A sub agent that can answer math questions",
"promptType": "define",
"text": "={{ $json.input }}"
},
"type": "@n8n/n8n-nodes-langchain.agentTool",
"typeVersion": 3,
"position": [400, 0],
"id": "sub-agent-id",
"name": "SubAgent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [400, 200],
"id": "child-model-id",
"name": "Child Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "Parent Agent",
"type": "main",
"index": 0
}
]
]
},
"Parent Model": {
"ai_languageModel": [
[
{
"node": "Parent Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"SubAgent": {
"ai_tool": [
[
{
"node": "Parent Agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Child Model": {
"ai_languageModel": [
[
{
"node": "SubAgent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,76 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "auto"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
},
"pinData": {
"AI Agent": [
{
"json": {
"output": "Hi from auto!"
}
}
]
}
}
@@ -0,0 +1,77 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Hello!"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
},
"pinData": {
"AI Agent": [
{
"json": {
"output": "Hi there!"
}
}
]
}
}
@@ -0,0 +1,70 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "auto",
"options": {
"passthroughBinaryImages": true
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,69 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Hello!"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent",
"onError": "continueRegularOutput"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,110 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Hello!",
"needsFallback": true
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "primary-model-id",
"name": "Primary OpenAI Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [400, 200],
"id": "fallback-model-id",
"name": "Fallback OpenAI Model",
"credentials": {
"openAiApi": {
"id": "456",
"name": "OpenAi account 2"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"Primary OpenAI Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Fallback OpenAI Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 1
}
]
]
}
},
"pinData": {
"AI Agent": [
{
"json": {
"output": "Hello from fallback!"
}
}
]
}
}
@@ -0,0 +1,90 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "What is 5 times 5?",
"options": {
"returnIntermediateSteps": true
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.toolCalculator",
"typeVersion": 1,
"position": [400, 200],
"id": "calculator-id",
"name": "Calculator"
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Calculator": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,91 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Hello!"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "test-session",
"contextWindowLength": 5
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.2,
"position": [400, 200],
"id": "memory-id",
"name": "Simple Memory"
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Simple Memory": {
"ai_memory": [
[
{
"node": "AI Agent",
"type": "ai_memory",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,91 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Tell me about California",
"hasOutputParser": true
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {
"schemaType": "fromJson",
"jsonSchemaExample": "{\n\t\"state\": \"California\",\n\t\"cities\": [\"Los Angeles\", \"San Francisco\"]\n}"
},
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [400, 200],
"id": "parser-id",
"name": "Structured Output Parser"
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Structured Output Parser": {
"ai_outputParser": [
[
{
"node": "AI Agent",
"type": "ai_outputParser",
"index": 0
}
]
]
}
}
}
@@ -0,0 +1,80 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "Hello!",
"options": {
"systemMessage": "You are a pirate. Always respond in pirate speak."
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
}
},
"pinData": {
"AI Agent": [
{
"json": {
"output": "Ahoy there, matey!"
}
}
]
}
}
@@ -0,0 +1,96 @@
{
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0],
"id": "trigger-id",
"name": "When clicking 'Execute workflow'"
},
{
"parameters": {
"promptType": "define",
"text": "What is 5 times 5?"
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [220, 0],
"id": "agent-id",
"name": "AI Agent"
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4o-mini"
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [220, 200],
"id": "model-id",
"name": "OpenAI Chat Model",
"credentials": {
"openAiApi": {
"id": "123",
"name": "OpenAi account"
}
}
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.toolCalculator",
"typeVersion": 1,
"position": [400, 200],
"id": "calculator-id",
"name": "Calculator"
}
],
"connections": {
"When clicking 'Execute workflow'": {
"main": [
[
{
"node": "AI Agent",
"type": "main",
"index": 0
}
]
]
},
"OpenAI Chat Model": {
"ai_languageModel": [
[
{
"node": "AI Agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Calculator": {
"ai_tool": [
[
{
"node": "AI Agent",
"type": "ai_tool",
"index": 0
}
]
]
}
},
"pinData": {
"AI Agent": [
{
"json": {
"output": "5 times 5 equals 25."
}
}
]
}
}
@@ -0,0 +1,277 @@
import type { Tool } from '@langchain/classic/tools';
import { DynamicStructuredTool } from '@langchain/classic/tools';
import { NodeOperationError } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { z } from 'zod';
import type { ZodObjectAny } from '../../../../types/types';
import { checkForStructuredTools } from '../agents/utils';
import { getInputs } from '../utils';
describe('checkForStructuredTools', () => {
let mockNode: INode;
beforeEach(() => {
mockNode = {
id: 'test-node',
name: 'Test Node',
type: 'test',
typeVersion: 1,
position: [0, 0],
parameters: {},
};
});
it('should not throw error when no DynamicStructuredTools are present', async () => {
const tools = [
{
name: 'regular-tool',
constructor: { name: 'Tool' },
} as Tool,
];
await expect(
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
).resolves.not.toThrow();
});
it('should throw NodeOperationError when DynamicStructuredTools are present', async () => {
const dynamicTool = new DynamicStructuredTool({
name: 'dynamic-tool',
description: 'test tool',
schema: z.object({}),
func: async () => 'result',
});
const tools: Array<Tool | DynamicStructuredTool<ZodObjectAny>> = [dynamicTool];
await expect(checkForStructuredTools(tools, mockNode, 'Conversation Agent')).rejects.toThrow(
NodeOperationError,
);
await expect(
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
).rejects.toMatchObject({
message:
'The selected tools are not supported by "Conversation Agent", please use "Tools Agent" instead',
description: 'Incompatible connected tools: "dynamic-tool"',
});
});
it('should list multiple dynamic tools in error message', async () => {
const dynamicTool1 = new DynamicStructuredTool({
name: 'dynamic-tool-1',
description: 'test tool 1',
schema: z.object({}),
func: async () => 'result',
});
const dynamicTool2 = new DynamicStructuredTool({
name: 'dynamic-tool-2',
description: 'test tool 2',
schema: z.object({}),
func: async () => 'result',
});
const tools = [dynamicTool1, dynamicTool2];
await expect(
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
).rejects.toMatchObject({
description: 'Incompatible connected tools: "dynamic-tool-1", "dynamic-tool-2"',
});
});
it('should throw error with mixed tool types and list only dynamic tools in error message', async () => {
const regularTool = {
name: 'regular-tool',
constructor: { name: 'Tool' },
} as Tool;
const dynamicTool = new DynamicStructuredTool({
name: 'dynamic-tool',
description: 'test tool',
schema: z.object({}),
func: async () => 'result',
});
const tools = [regularTool, dynamicTool];
await expect(
checkForStructuredTools(tools, mockNode, 'Conversation Agent'),
).rejects.toMatchObject({
message:
'The selected tools are not supported by "Conversation Agent", please use "Tools Agent" instead',
description: 'Incompatible connected tools: "dynamic-tool"',
});
});
});
describe('getInputs', () => {
it('should include all inputs when no flags are set to false', () => {
const inputs = getInputs(true, true, true);
expect(inputs).toEqual([
'main',
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_languageModel',
displayName: 'Fallback Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_memory',
displayName: 'Memory',
maxConnections: 1,
},
{
type: 'ai_tool',
displayName: 'Tool',
},
{
type: 'ai_outputParser',
displayName: 'Output Parser',
maxConnections: 1,
},
]);
});
it('should exclude Output Parser when hasOutputParser is false', () => {
const inputs = getInputs(true, false, true);
expect(inputs).toEqual([
'main',
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_languageModel',
displayName: 'Fallback Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_memory',
displayName: 'Memory',
maxConnections: 1,
},
{
type: 'ai_tool',
displayName: 'Tool',
},
]);
});
it('should exclude Fallback Model when needsFallback is false', () => {
const inputs = getInputs(true, true, false);
expect(inputs).toEqual([
'main',
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_memory',
displayName: 'Memory',
maxConnections: 1,
},
{
type: 'ai_tool',
displayName: 'Tool',
},
{
type: 'ai_outputParser',
displayName: 'Output Parser',
maxConnections: 1,
},
]);
});
it('should include main input when hasMainInput is true', () => {
const inputs = getInputs(true, true, true);
expect(inputs[0]).toBe('main');
});
it('should exclude main input when hasMainInput is false', () => {
const inputs = getInputs(false, true, true);
expect(inputs).not.toContain('main');
});
it('should handle all flags set to false', () => {
const inputs = getInputs(false, false, false);
expect(inputs).toEqual([
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
maxConnections: 1,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_memory',
displayName: 'Memory',
maxConnections: 1,
},
{
type: 'ai_tool',
displayName: 'Tool',
},
]);
});
});
@@ -0,0 +1,96 @@
// Function used in the inputs expression to figure out which inputs to
import {
type INodeInputConfiguration,
type INodeFilter,
type NodeConnectionType,
} from 'n8n-workflow';
// display based on the agent type
/* istanbul ignore next */
export function getInputs(
hasMainInput?: boolean,
hasOutputParser?: boolean,
needsFallback?: boolean,
): Array<NodeConnectionType | INodeInputConfiguration> {
interface SpecialInput {
type: NodeConnectionType;
filter?: INodeFilter;
displayName: string;
required?: boolean;
}
const getInputData = (
inputs: SpecialInput[],
): Array<NodeConnectionType | INodeInputConfiguration> => {
return inputs.map(({ type, filter, displayName, required }) => {
const input: INodeInputConfiguration = {
type,
displayName,
required,
maxConnections: ['ai_languageModel', 'ai_memory', 'ai_outputParser'].includes(type)
? 1
: undefined,
};
if (filter) {
input.filter = filter;
}
return input;
});
};
let specialInputs: SpecialInput[] = [
{
type: 'ai_languageModel',
displayName: 'Chat Model',
required: true,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
type: 'ai_languageModel',
displayName: 'Fallback Model',
required: true,
filter: {
excludedNodes: [
'@n8n/n8n-nodes-langchain.lmCohere',
'@n8n/n8n-nodes-langchain.lmOllama',
'n8n/n8n-nodes-langchain.lmOpenAi',
'@n8n/n8n-nodes-langchain.lmOpenHuggingFaceInference',
],
},
},
{
displayName: 'Memory',
type: 'ai_memory',
},
{
displayName: 'Tool',
type: 'ai_tool',
},
{
displayName: 'Output Parser',
type: 'ai_outputParser',
},
];
if (hasOutputParser === false) {
specialInputs = specialInputs.filter((input) => input.type !== 'ai_outputParser');
}
if (needsFallback === false) {
specialInputs = specialInputs.filter((input) => input.displayName !== 'Fallback Model');
}
// Note cannot use NodeConnectionType.Main
// otherwise expression won't evaluate correctly on the FE
const mainInputs = hasMainInput ? ['main' as NodeConnectionType] : [];
return [...mainInputs, ...getInputData(specialInputs)];
}
@@ -0,0 +1,415 @@
import { AgentExecutor } from '@langchain/classic/agents';
import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema';
import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import type {
IExecuteFunctions,
INodeExecutionData,
INodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import { OpenAI as OpenAIClient } from 'openai';
import { getConnectedTools, mergeCustomHeaders } from '@utils/helpers';
import { getTracingConfig } from '@utils/tracing';
import { formatToOpenAIAssistantTool } from './utils';
import { Container } from '@n8n/di';
import { AiConfig } from '@n8n/config';
import { checkDomainRestrictions } from '@utils/checkDomainRestrictions';
export class OpenAiAssistant implements INodeType {
description: INodeTypeDescription = {
displayName: 'OpenAI Assistant',
name: 'openAiAssistant',
hidden: true,
icon: 'fa:robot',
group: ['transform'],
version: [1, 1.1],
description: 'Utilizes Assistant API from Open AI.',
subtitle: 'Open AI Assistant',
defaults: {
name: 'OpenAI Assistant',
color: '#404040',
},
codex: {
alias: ['LangChain'],
categories: ['AI'],
subcategories: {
AI: ['Agents', 'Root Nodes'],
},
resources: {
primaryDocumentation: [
{
url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.openaiassistant/',
},
],
},
},
inputs: [
{ type: NodeConnectionTypes.Main },
{ type: NodeConnectionTypes.AiTool, displayName: 'Tools' },
],
outputs: [NodeConnectionTypes.Main],
builderHint: {
inputs: {
ai_tool: { required: false },
},
},
credentials: [
{
name: 'openAiApi',
required: true,
},
],
requestDefaults: {
ignoreHttpStatusErrors: true,
baseURL:
'={{ $parameter.options?.baseURL?.split("/").slice(0,-1).join("/") || "https://api.openai.com" }}',
},
properties: [
{
displayName: 'Operation',
name: 'mode',
type: 'options',
noDataExpression: true,
default: 'existing',
options: [
{
name: 'Use New Assistant',
value: 'new',
},
{
name: 'Use Existing Assistant',
value: 'existing',
},
],
},
{
displayName: 'Name',
name: 'name',
type: 'string',
default: '',
required: true,
displayOptions: {
show: {
'/mode': ['new'],
},
},
},
{
displayName: 'Instructions',
name: 'instructions',
type: 'string',
description: 'How the Assistant and model should behave or respond',
default: '',
typeOptions: {
rows: 5,
},
displayOptions: {
show: {
'/mode': ['new'],
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'options',
description:
'The model which will be used to power the assistant. <a href="https://beta.openai.com/docs/models/overview">Learn more</a>. The Retrieval tool requires gpt-3.5-turbo-1106 and gpt-4-1106-preview models.',
required: true,
displayOptions: {
show: {
'/mode': ['new'],
},
},
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || "v1" }}/models',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'filter',
properties: {
pass: "={{ $responseItem.id.startsWith('gpt-') && !$responseItem.id.includes('instruct') }}",
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.id}}',
value: '={{$responseItem.id}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'model',
},
},
default: 'gpt-3.5-turbo-1106',
},
{
displayName: 'Assistant',
name: 'assistantId',
type: 'options',
noDataExpression: true,
displayOptions: {
show: {
'/mode': ['existing'],
},
},
description:
'The assistant to use. <a href="https://beta.openai.com/docs/assistants/overview">Learn more</a>.',
typeOptions: {
loadOptions: {
routing: {
request: {
method: 'GET',
headers: {
'OpenAI-Beta': 'assistants=v1',
},
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || "v1" }}/assistants',
},
output: {
postReceive: [
{
type: 'rootProperty',
properties: {
property: 'data',
},
},
{
type: 'setKeyValue',
properties: {
name: '={{$responseItem.name}}',
value: '={{$responseItem.id}}',
description: '={{$responseItem.model}}',
},
},
{
type: 'sort',
properties: {
key: 'name',
},
},
],
},
},
},
},
routing: {
send: {
type: 'body',
property: 'assistant',
},
},
required: true,
default: '',
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '={{ $json.chat_input }}',
displayOptions: {
show: {
'@version': [1],
},
},
},
{
displayName: 'Text',
name: 'text',
type: 'string',
required: true,
default: '={{ $json.chatInput }}',
displayOptions: {
show: {
'@version': [1.1],
},
},
},
{
displayName: 'OpenAI Tools',
name: 'nativeTools',
type: 'multiOptions',
default: [],
options: [
{
name: 'Code Interpreter',
value: 'code_interpreter',
},
{
name: 'Knowledge Retrieval',
value: 'retrieval',
},
],
},
{
displayName: 'Connect your own custom tools to this node on the canvas',
name: 'noticeTools',
type: 'notice',
default: '',
},
{
displayName:
'Upload files for retrieval using the <a href="https://platform.openai.com/playground" target="_blank">OpenAI website<a/>',
name: 'noticeTools',
type: 'notice',
typeOptions: {
noticeTheme: 'info',
},
displayOptions: { show: { '/nativeTools': ['retrieval'] } },
default: '',
},
{
displayName: 'Options',
name: 'options',
placeholder: 'Add Option',
description: 'Additional options to add',
type: 'collection',
default: {},
options: [
{
displayName: 'Base URL',
name: 'baseURL',
default: 'https://api.openai.com/v1',
description: 'Override the default base URL for the API',
type: 'string',
},
{
displayName: 'Max Retries',
name: 'maxRetries',
default: 2,
description: 'Maximum number of retries to attempt',
type: 'number',
},
{
displayName: 'Timeout',
name: 'timeout',
default: 10000,
description: 'Maximum amount of time a request is allowed to take in milliseconds',
type: 'number',
},
],
},
],
};
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const nodeVersion = this.getNode().typeVersion;
const tools = await getConnectedTools(this, nodeVersion > 1, false);
const credentials = await this.getCredentials('openAiApi');
const items = this.getInputData();
const returnData: INodeExecutionData[] = [];
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
try {
const input = this.getNodeParameter('text', itemIndex) as string;
const assistantId = this.getNodeParameter('assistantId', itemIndex, '') as string;
const nativeTools = this.getNodeParameter('nativeTools', itemIndex, []) as Array<
'code_interpreter' | 'retrieval'
>;
const options = this.getNodeParameter('options', itemIndex, {}) as {
baseURL?: string;
maxRetries: number;
timeout: number;
};
if (input === undefined) {
throw new NodeOperationError(this.getNode(), 'The text parameter is empty.');
}
const { openAiDefaultHeaders } = Container.get(AiConfig);
const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {});
if (options.baseURL) {
checkDomainRestrictions(this, credentials, options.baseURL);
}
const client = new OpenAIClient({
apiKey: credentials.apiKey as string,
maxRetries: options.maxRetries ?? 2,
timeout: options.timeout ?? 10000,
baseURL: options.baseURL,
defaultHeaders,
});
let agent;
const nativeToolsParsed: OpenAIToolType = nativeTools.map((tool) => ({ type: tool }));
const transformedConnectedTools = tools?.map(formatToOpenAIAssistantTool) ?? [];
const newTools = [...transformedConnectedTools, ...nativeToolsParsed];
// Existing agent, update tools with currently assigned
if (assistantId) {
agent = new OpenAIAssistantRunnable({ assistantId, client, asAgent: true });
await client.beta.assistants.update(assistantId, {
tools: newTools,
});
} else {
const name = this.getNodeParameter('name', itemIndex, '') as string;
const instructions = this.getNodeParameter('instructions', itemIndex, '') as string;
const model = this.getNodeParameter('model', itemIndex, 'gpt-3.5-turbo-1106') as string;
agent = await OpenAIAssistantRunnable.createAssistant({
model,
client,
instructions,
name,
tools: newTools,
asAgent: true,
});
}
const agentExecutor = AgentExecutor.fromAgentAndTools({
agent,
tools,
});
const response = await agentExecutor.withConfig(getTracingConfig(this)).invoke({
content: input,
signal: this.getExecutionCancelSignal(),
timeout: options.timeout ?? 10000,
});
returnData.push({ json: response });
} catch (error) {
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;
}
throw error;
}
}
return [returnData];
}
}
@@ -0,0 +1,45 @@
import type { Tool } from '@langchain/core/tools';
import type { OpenAIClient } from '@langchain/openai';
import { zodToJsonSchema } from 'zod-to-json-schema';
// Copied from langchain(`langchain/src/tools/convert_to_openai.ts`)
// since these functions are not exported
/**
* Formats a `Tool` instance into a format that is compatible
* with OpenAI's ChatCompletionFunctions. It uses the `zodToJsonSchema`
* function to convert the schema of the tool into a JSON
* schema, which is then used as the parameters for the OpenAI function.
*/
export function formatToOpenAIFunction(
tool: Tool,
): OpenAIClient.Chat.ChatCompletionCreateParams.Function {
return {
name: tool.name,
description: tool.description,
parameters: zodToJsonSchema(tool.schema),
};
}
export function formatToOpenAITool(tool: Tool): OpenAIClient.Chat.ChatCompletionTool {
const schema = zodToJsonSchema(tool.schema);
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: schema,
},
};
}
export function formatToOpenAIAssistantTool(tool: Tool): OpenAIClient.Beta.AssistantTool {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: zodToJsonSchema(tool.schema),
},
};
}