n8n/packages/@n8n/nodes-langchain/utils/helpers.ts

310 lines
10 KiB
TypeScript

import type { BaseMessage } from '@langchain/core/messages';
import { type DynamicStructuredTool, type StructuredTool, Tool } from '@langchain/core/tools';
import type { JSONSchema7 } from 'json-schema';
import { StructuredToolkit, type SupplyDataToolResponse } from 'n8n-core';
import type {
ICredentialDataDecryptedObject,
IExecuteFunctions,
ISupplyDataFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { ZodType } from 'zod';
import { N8nTool } from './N8nTool';
import { convertJsonSchemaToZod } from './schemaParsing';
export function getPromptInputByType(options: {
ctx: IExecuteFunctions | ISupplyDataFunctions;
i: number;
promptTypeKey: string;
inputKey: string;
}) {
const { ctx, i, promptTypeKey, inputKey } = options;
const promptType = ctx.getNodeParameter(promptTypeKey, i, 'define') as string;
let input;
if (promptType === 'auto') {
input = ctx.evaluateExpression('{{ $json["chatInput"] }}', i) as string;
} else if (promptType === 'guardrails') {
input = ctx.evaluateExpression('{{ $json["guardrailsInput"] }}', i) as string;
} else {
input = ctx.getNodeParameter(inputKey, i) as string;
}
if (input === undefined) {
if (promptType === 'auto' || promptType === 'guardrails') {
const key = promptType === 'auto' ? 'chatInput' : 'guardrailsInput';
throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
description: `Expected to find the prompt in an input field called '${key}' (this is what the ${promptType === 'auto' ? 'chat trigger node' : 'guardrails node'} node outputs). To use something else, change the 'Prompt' parameter`,
});
} else {
throw new NodeOperationError(ctx.getNode(), 'No prompt specified', {
description:
'The prompt field is empty or the expression used could not be resolved. Please check the configured prompt value.',
});
}
}
return input;
}
// Minimum version at which a memory node scopes its session id
// to the node's own name
const SESSION_KEY_SCOPING_MIN_VERSION: Record<string, number> = {
'@n8n/n8n-nodes-langchain.memoryBufferWindow': 1.4,
'@n8n/n8n-nodes-langchain.memoryPostgresChat': 1.4,
'@n8n/n8n-nodes-langchain.memoryRedisChat': 1.6,
'@n8n/n8n-nodes-langchain.memoryMongoDbChat': 1.1,
'@n8n/n8n-nodes-langchain.memoryMotorhead': 1.4,
'@n8n/n8n-nodes-langchain.memoryXata': 1.5,
'@n8n/n8n-nodes-langchain.memoryZep': 1.4,
};
function shouldScopeSessionKey(ctx: ISupplyDataFunctions | IWebhookFunctions): boolean {
const node = ctx.getNode();
if (!node) return false;
const minVersion = SESSION_KEY_SCOPING_MIN_VERSION[node.type];
// if the node is not in SESSION_KEY_SCOPING_MIN_VERSION, it should
// scope by default the session key with no retrocompatibility issues
if (minVersion === undefined) return true;
return (node.typeVersion ?? 0) >= minVersion;
}
// Some memory backends (Motorhead URL paths, Xata/Zep record IDs) reject
// characters outside [A-Za-z0-9_-]. Node names in n8n allow spaces, emoji,
// and punctuation, so sanitize before using the name as part of a session key.
function sanitizeForSessionKey(name: string): string {
return name.replace(/[^A-Za-z0-9_-]/g, '_');
}
export function getSessionId(
ctx: ISupplyDataFunctions | IWebhookFunctions,
itemIndex: number,
selectorKey = 'sessionIdType',
autoSelect = 'fromInput',
customKey = 'sessionKey',
) {
let sessionId = '';
const selectorType = ctx.getNodeParameter(selectorKey, itemIndex) as string;
if (selectorType === autoSelect) {
// If memory node is used in webhook like node(like chat trigger node), it doesn't have access to evaluateExpression
// so we try to extract sessionId from the bodyData
if ('getBodyData' in ctx) {
const bodyData = ctx.getBodyData() ?? {};
sessionId = bodyData.sessionId as string;
} else {
sessionId = ctx.evaluateExpression('{{ $json.sessionId }}', itemIndex) as string;
// try to get sessionId from chat trigger
if (!sessionId || sessionId === undefined) {
try {
const chatTrigger = ctx.getChatTrigger();
if (chatTrigger) {
sessionId = ctx.evaluateExpression(
`{{ $('${chatTrigger.name}').first().json.sessionId }}`,
itemIndex,
) as string;
}
} catch (error) {}
}
}
if (sessionId === '' || sessionId === undefined) {
throw new NodeOperationError(ctx.getNode(), 'No session ID found', {
description:
"Expected to find the session ID in an input field called 'sessionId' (this is what the chat trigger node outputs). To use something else, change the 'Session ID' parameter",
itemIndex,
});
}
} else {
sessionId = ctx.getNodeParameter(customKey, itemIndex, '') as string;
if (sessionId === '' || sessionId === undefined) {
throw new NodeOperationError(ctx.getNode(), 'Key parameter is empty', {
description:
"Provide a key to use as session ID in the 'Key' parameter or use the 'Connected Chat Trigger Node' option to use the session ID from your Chat Trigger",
itemIndex,
});
}
}
// Scoping uses the memory node's own name, so connecting a single memory
// node to multiple callers (e.g. an agent and a sub-agent) will still
// produce the same session key for all of them and they will share the
// same memory bucket. To isolate memory per caller, duplicate the memory
// node instead of reusing one.
if (selectorType === autoSelect && shouldScopeSessionKey(ctx)) {
sessionId = `${sessionId}__${sanitizeForSessionKey(ctx.getNode().name)}`;
}
return sessionId;
}
export function serializeChatHistory(chatHistory: BaseMessage[]): string {
return chatHistory
.map((chatMessage) => {
if (chatMessage._getType() === 'human') {
return `Human: ${chatMessage.content}`;
} else if (chatMessage._getType() === 'ai') {
return `Assistant: ${chatMessage.content}`;
} else {
return `${chatMessage.content}`;
}
})
.join('\n');
}
export function escapeSingleCurlyBrackets(text?: string): string | undefined {
if (text === undefined) return undefined;
let result = text;
result = result
// First handle triple brackets to avoid interference with double brackets
.replace(/(?<!{){{{(?!{)/g, '{{{{')
.replace(/(?<!})}}}(?!})/g, '}}}}')
// Then handle single brackets, but only if they're not part of double brackets
// Convert single { to {{ if it's not already part of {{ or {{{
.replace(/(?<!{){(?!{)/g, '{{')
// Convert single } to }} if it's not already part of }} or }}}
.replace(/(?<!})}(?!})/g, '}}');
return result;
}
/* Convert tools with json schema to tools with zod schema and type Tool
* Most nodes expect tools to have a Zod schema and have Tool type, do this conversion to make sure all tools are compatible
*/
const normalizeToolSchema = (tool: Tool | DynamicStructuredTool | StructuredTool) => {
if (tool instanceof Tool) {
return tool;
}
const isZodObject = tool.schema instanceof ZodType;
if (tool.schema && !isZodObject) {
tool.schema = convertJsonSchemaToZod(tool.schema as JSONSchema7);
}
return tool as Tool;
};
export const getConnectedTools = async (
ctx: IExecuteFunctions | IWebhookFunctions | ISupplyDataFunctions,
enforceUniqueNames: boolean,
convertStructuredTool: boolean = true,
escapeCurlyBrackets: boolean = false,
): Promise<Tool[]> => {
const toolkitConnections = (await ctx.getInputConnectionData(
NodeConnectionTypes.AiTool,
0,
)) as SupplyDataToolResponse[];
// Get parent nodes to map toolkits to their source nodes.
// getInputConnectionData filters out disabled nodes, so parents must be filtered
// the same way to keep the index alignment between toolkitConnections and parentNodes.
const parentNodes =
'getParentNodes' in ctx
? ctx
.getParentNodes(ctx.getNode().name, {
connectionType: NodeConnectionTypes.AiTool,
depth: 1,
})
.filter((node) => !node.disabled)
: [];
const connectedTools = (toolkitConnections ?? [])
.flatMap((toolOrToolkit, index) => {
if (toolOrToolkit instanceof StructuredToolkit) {
const tools = toolOrToolkit.tools;
// Add metadata to each tool from the toolkit
return tools.map((tool) => {
const sourceNode = parentNodes[index] ?? tool.name;
tool.metadata ??= {};
tool.metadata.isFromToolkit = true;
tool.metadata.sourceNodeName = sourceNode?.name;
return tool;
});
} else {
const sourceNode = parentNodes[index] ?? toolOrToolkit.name;
toolOrToolkit.metadata ??= {};
toolOrToolkit.metadata.isFromToolkit = false;
toolOrToolkit.metadata.sourceNodeName = sourceNode?.name;
}
return toolOrToolkit;
})
.map(normalizeToolSchema);
if (!enforceUniqueNames) return connectedTools;
const seenNames = new Set<string>();
const finalTools: Tool[] = [];
for (const tool of connectedTools) {
const { name } = tool;
if (seenNames.has(name)) {
throw new NodeOperationError(
ctx.getNode(),
`You have multiple tools with the same name: '${name}', please rename them to avoid conflicts`,
);
}
seenNames.add(name);
if (escapeCurlyBrackets) {
tool.description = escapeSingleCurlyBrackets(tool.description) ?? tool.description;
}
if (convertStructuredTool && tool instanceof N8nTool) {
finalTools.push(tool.asDynamicTool());
} else {
finalTools.push(tool);
}
}
return finalTools;
};
/**
* Merges custom credential headers into an existing defaultHeaders object.
* Used by OpenAI and other LangChain nodes that pass `configuration.defaultHeaders`.
*/
export function mergeCustomHeaders(
credentials: ICredentialDataDecryptedObject,
defaultHeaders: Record<string, string>,
): Record<string, string> {
if (
credentials.header &&
typeof credentials.headerName === 'string' &&
credentials.headerName &&
typeof credentials.headerValue === 'string'
) {
return {
...defaultHeaders,
[credentials.headerName]: credentials.headerValue,
};
}
return defaultHeaders;
}
/**
* Sometimes model output is wrapped in an additional object property.
* This function unwraps the output if it is in the format { output: { output: { ... } } }
*/
export function unwrapNestedOutput(output: Record<string, unknown>): Record<string, unknown> {
if (
'output' in output &&
Object.keys(output).length === 1 &&
typeof output.output === 'object' &&
output.output !== null &&
'output' in output.output &&
Object.keys(output.output).length === 1
) {
return output.output as Record<string, unknown>;
}
return output;
}