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
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:
@@ -0,0 +1,170 @@
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { N8nTool } from './N8nTool';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 2,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [60, 760],
|
||||
parameters: {
|
||||
operation: 'test',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Test N8nTool wrapper as DynamicStructuredTool', () => {
|
||||
it('should wrap a tool', () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(tool).toBeInstanceOf(DynamicStructuredTool);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test N8nTool wrapper - DynamicTool fallback', () => {
|
||||
it('should convert the tool to a dynamic tool', () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
expect(dynamicTool).toBeInstanceOf(DynamicTool);
|
||||
});
|
||||
|
||||
it('should format fallback description correctly', () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string(),
|
||||
bar: z.number().optional(),
|
||||
qwe: z.boolean().describe('Boolean description'),
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
expect(dynamicTool.description).toContain('foo: (description: , type: string, required: true)');
|
||||
expect(dynamicTool.description).toContain(
|
||||
'bar: (description: , type: number, required: false)',
|
||||
);
|
||||
|
||||
expect(dynamicTool.description).toContain(
|
||||
'qwe: (description: Boolean description, type: boolean, required: true)',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty parameter list correctly', () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
expect(dynamicTool.description).toEqual('A dummy tool for testing');
|
||||
});
|
||||
|
||||
it('should parse correct parameters', async () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string().describe('Foo description'),
|
||||
bar: z.number().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
const testParameters = { foo: 'some value' };
|
||||
|
||||
await dynamicTool.func(JSON.stringify(testParameters));
|
||||
|
||||
expect(func).toHaveBeenCalledWith(testParameters);
|
||||
});
|
||||
|
||||
it('should recover when 1 parameter is passed directly', async () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string().describe('Foo description'),
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
const testParameter = 'some value';
|
||||
|
||||
await dynamicTool.func(testParameter);
|
||||
|
||||
expect(func).toHaveBeenCalledWith({ foo: testParameter });
|
||||
});
|
||||
|
||||
it('should recover when JS object is passed instead of JSON', async () => {
|
||||
const func = jest.fn();
|
||||
|
||||
const ctx = createMockExecuteFunction<ISupplyDataFunctions>({}, mockNode);
|
||||
|
||||
const tool = new N8nTool(ctx, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func,
|
||||
schema: z.object({
|
||||
foo: z.string().describe('Foo description'),
|
||||
}),
|
||||
});
|
||||
|
||||
const dynamicTool = tool.asDynamicTool();
|
||||
|
||||
await dynamicTool.func('{ foo: "some value" }');
|
||||
|
||||
expect(func).toHaveBeenCalledWith({ foo: 'some value' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { DynamicStructuredToolInput } from '@langchain/core/tools';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import { StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import type { ISupplyDataFunctions, IDataObject } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, jsonParse, NodeOperationError } from 'n8n-workflow';
|
||||
import type { ZodTypeAny } from 'zod';
|
||||
import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod';
|
||||
|
||||
import type { ZodObjectAny } from '../types/types';
|
||||
|
||||
const getSimplifiedType = (schema: ZodTypeAny) => {
|
||||
if (schema instanceof ZodObject) {
|
||||
return 'object';
|
||||
} else if (schema instanceof ZodNumber) {
|
||||
return 'number';
|
||||
} else if (schema instanceof ZodBoolean) {
|
||||
return 'boolean';
|
||||
} else if (schema instanceof ZodNullable || schema instanceof ZodOptional) {
|
||||
return getSimplifiedType(schema.unwrap());
|
||||
}
|
||||
|
||||
return 'string';
|
||||
};
|
||||
|
||||
const getParametersDescription = (parameters: Array<[string, ZodTypeAny]>) =>
|
||||
parameters
|
||||
.map(
|
||||
([name, schema]) =>
|
||||
`${name}: (description: ${schema.description ?? ''}, type: ${getSimplifiedType(schema)}, required: ${!schema.isOptional()})`,
|
||||
)
|
||||
.join(',\n ');
|
||||
|
||||
export const prepareFallbackToolDescription = (toolDescription: string, schema: ZodObject<any>) => {
|
||||
let description = `${toolDescription}`;
|
||||
|
||||
const toolParameters = Object.entries<ZodTypeAny>(schema.shape);
|
||||
|
||||
if (toolParameters.length) {
|
||||
description += `
|
||||
Tool expects valid stringified JSON object with ${toolParameters.length} properties.
|
||||
Property names with description, type and required status:
|
||||
${getParametersDescription(toolParameters)}
|
||||
ALL parameters marked as required must be provided`;
|
||||
}
|
||||
|
||||
return description;
|
||||
};
|
||||
|
||||
export class N8nTool extends DynamicStructuredTool<ZodObjectAny> {
|
||||
constructor(
|
||||
private context: ISupplyDataFunctions,
|
||||
fields: DynamicStructuredToolInput<ZodObjectAny>,
|
||||
) {
|
||||
super(fields);
|
||||
}
|
||||
|
||||
asDynamicTool(): DynamicTool {
|
||||
const { name, func, schema, context, description } = this;
|
||||
|
||||
const parser = new StructuredOutputParser(schema);
|
||||
|
||||
const wrappedFunc = async function (query: string) {
|
||||
let parsedQuery: object;
|
||||
|
||||
// First we try to parse the query using the structured parser (Zod schema)
|
||||
try {
|
||||
parsedQuery = await parser.parse(query);
|
||||
} catch (e) {
|
||||
// If we were unable to parse the query using the schema, we try to gracefully handle it
|
||||
let dataFromModel;
|
||||
|
||||
try {
|
||||
// First we try to parse a JSON with more relaxed rules
|
||||
dataFromModel = jsonParse<IDataObject>(query, { acceptJSObject: true });
|
||||
} catch (error) {
|
||||
// In case of error,
|
||||
// If model supplied a simple string instead of an object AND only one parameter expected, we try to recover the object structure
|
||||
if (Object.keys(schema.shape).length === 1) {
|
||||
const parameterName = Object.keys(schema.shape)[0];
|
||||
dataFromModel = { [parameterName]: query };
|
||||
} else {
|
||||
// Finally throw an error if we were unable to parse the query
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
`Input is not a valid JSON: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If we were able to parse the query with a fallback, we try to validate it using the schema
|
||||
// Here we will throw an error if the data still does not match the schema
|
||||
parsedQuery = schema.parse(dataFromModel);
|
||||
}
|
||||
|
||||
try {
|
||||
// Call tool function with parsed query
|
||||
const result = await func(parsedQuery);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
const { index } = context.addInputData(NodeConnectionTypes.AiTool, [[{ json: { query } }]]);
|
||||
void context.addOutputData(NodeConnectionTypes.AiTool, index, e);
|
||||
|
||||
return e.toString();
|
||||
}
|
||||
};
|
||||
|
||||
return new DynamicTool({
|
||||
name,
|
||||
description: prepareFallbackToolDescription(description, schema),
|
||||
func: wrappedFunc,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { EngineResponse } from 'n8n-workflow';
|
||||
|
||||
import { buildSteps } from './buildSteps';
|
||||
import type { RequestResponseMetadata } from './types';
|
||||
|
||||
/**
|
||||
* Builds metadata for an engine request, tracking iteration count and previous requests.
|
||||
*
|
||||
* This helper centralizes the logic for incrementing iteration count and building
|
||||
* the request history, which is used to enforce max iterations and maintain context.
|
||||
*
|
||||
* @param response - The optional engine response from previous tool execution
|
||||
* @param itemIndex - The current item index being processed
|
||||
* @returns Metadata object with previousRequests and iterationCount
|
||||
*
|
||||
*/
|
||||
export function buildResponseMetadata(
|
||||
response: EngineResponse<RequestResponseMetadata> | undefined,
|
||||
itemIndex: number,
|
||||
): RequestResponseMetadata {
|
||||
const currentIterationCount = response?.metadata?.iterationCount ?? 0;
|
||||
|
||||
return {
|
||||
previousRequests: buildSteps(response, itemIndex),
|
||||
itemIndex,
|
||||
iterationCount: currentIterationCount + 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { AIMessage } from '@langchain/core/messages';
|
||||
import { nodeNameToToolName } from 'n8n-workflow';
|
||||
import type { EngineResponse, EngineResult, IDataObject } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
RequestResponseMetadata,
|
||||
ToolCallData,
|
||||
ThinkingContentBlock,
|
||||
RedactedThinkingContentBlock,
|
||||
ToolUseContentBlock,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Provider-specific metadata extracted from tool action metadata
|
||||
*/
|
||||
interface ProviderMetadata {
|
||||
/** Gemini thought_signature for extended thinking */
|
||||
thoughtSignature?: string;
|
||||
/** Anthropic thinking content */
|
||||
thinkingContent?: string;
|
||||
/** Anthropic thinking type (thinking or redacted_thinking) */
|
||||
thinkingType?: 'thinking' | 'redacted_thinking';
|
||||
/** Anthropic thinking signature */
|
||||
thinkingSignature?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts provider-specific metadata from tool action metadata.
|
||||
* Validates and normalizes metadata from different LLM providers.
|
||||
*
|
||||
* @param metadata - The request/response metadata from tool action
|
||||
* @returns Extracted and validated provider metadata
|
||||
*/
|
||||
function extractProviderMetadata(metadata?: RequestResponseMetadata): ProviderMetadata {
|
||||
if (!metadata) return {};
|
||||
|
||||
// Extract Google/Gemini metadata
|
||||
const thoughtSignature =
|
||||
typeof metadata.google?.thoughtSignature === 'string'
|
||||
? metadata.google.thoughtSignature
|
||||
: undefined;
|
||||
|
||||
// Extract Anthropic metadata
|
||||
const thinkingContent =
|
||||
typeof metadata.anthropic?.thinkingContent === 'string'
|
||||
? metadata.anthropic.thinkingContent
|
||||
: undefined;
|
||||
|
||||
const thinkingType =
|
||||
metadata.anthropic?.thinkingType === 'thinking' ||
|
||||
metadata.anthropic?.thinkingType === 'redacted_thinking'
|
||||
? metadata.anthropic.thinkingType
|
||||
: undefined;
|
||||
|
||||
const thinkingSignature =
|
||||
typeof metadata.anthropic?.thinkingSignature === 'string'
|
||||
? metadata.anthropic.thinkingSignature
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
thoughtSignature,
|
||||
thinkingContent,
|
||||
thinkingType,
|
||||
thinkingSignature,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds Anthropic-specific content blocks for thinking mode.
|
||||
* Creates an array with thinking block followed by tool_use block.
|
||||
*
|
||||
* IMPORTANT: The thinking block must come before tool_use in the message.
|
||||
* When content is an array, LangChain ignores tool_calls field for Anthropic,
|
||||
* so tool_use blocks must be in the content array.
|
||||
*
|
||||
* @param thinkingContent - The thinking content from Anthropic
|
||||
* @param thinkingType - Type of thinking block (thinking or redacted_thinking)
|
||||
* @param thinkingSignature - Optional signature for thinking block
|
||||
* @param toolInput - The tool input data
|
||||
* @param toolId - The tool call ID
|
||||
* @param toolName - The tool name
|
||||
* @returns Array of content blocks with thinking and tool_use
|
||||
*/
|
||||
function buildAnthropicContentBlocks(
|
||||
thinkingContent: string,
|
||||
thinkingType: 'thinking' | 'redacted_thinking',
|
||||
thinkingSignature: string | undefined,
|
||||
toolInput: IDataObject,
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
): Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
|
||||
// Create thinking block with correct field names for Anthropic API
|
||||
const thinkingBlock: ThinkingContentBlock | RedactedThinkingContentBlock =
|
||||
thinkingType === 'thinking'
|
||||
? {
|
||||
type: 'thinking',
|
||||
thinking: thinkingContent,
|
||||
signature: thinkingSignature ?? '', // Use original signature if available
|
||||
}
|
||||
: {
|
||||
type: 'redacted_thinking',
|
||||
data: thinkingContent,
|
||||
};
|
||||
|
||||
// Create tool_use block (required for Anthropic when using structured content)
|
||||
const toolInputData = toolInput.input;
|
||||
const toolUseBlock: ToolUseContentBlock = {
|
||||
type: 'tool_use',
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
input:
|
||||
toolInputData && typeof toolInputData === 'object'
|
||||
? (toolInputData as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
|
||||
return [thinkingBlock, toolUseBlock];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds message content for AI message, handling provider-specific formats.
|
||||
* For Anthropic thinking mode, creates content blocks with thinking and tool_use.
|
||||
* For other providers, creates simple string content.
|
||||
*
|
||||
* @param providerMetadata - Provider-specific metadata
|
||||
* @param toolInput - The tool input data
|
||||
* @param toolId - The tool call ID
|
||||
* @param toolName - The tool name
|
||||
* @param nodeName - The node name for fallback string content
|
||||
* @returns Message content (string or content blocks array)
|
||||
*/
|
||||
function buildMessageContent(
|
||||
providerMetadata: ProviderMetadata,
|
||||
toolInput: IDataObject,
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
): string | Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
|
||||
const { thinkingContent, thinkingType, thinkingSignature } = providerMetadata;
|
||||
|
||||
// Anthropic thinking mode: build content blocks
|
||||
if (thinkingContent && thinkingType) {
|
||||
return buildAnthropicContentBlocks(
|
||||
thinkingContent,
|
||||
thinkingType,
|
||||
thinkingSignature,
|
||||
toolInput,
|
||||
toolId,
|
||||
toolName,
|
||||
);
|
||||
}
|
||||
|
||||
// Default: simple string content
|
||||
return `Calling ${toolName} with input: ${JSON.stringify(toolInput)}`;
|
||||
}
|
||||
|
||||
function resolveToolName(tool: EngineResult<RequestResponseMetadata>): string {
|
||||
return tool.action.metadata?.hitl?.toolName ?? nodeNameToToolName(tool.action.nodeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processed tool response data used during step building.
|
||||
*/
|
||||
interface ProcessedToolResponse {
|
||||
tool: NonNullable<EngineResponse<RequestResponseMetadata>['actionResponses']>[number];
|
||||
toolInput: IDataObject;
|
||||
toolId: string;
|
||||
toolName: string;
|
||||
nodeName: string;
|
||||
providerMetadata: ProviderMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the additional_kwargs needed for Gemini thought signatures.
|
||||
*
|
||||
* The structure matches what @langchain/google-common expects:
|
||||
* - `__gemini_function_call_thought_signatures__`: maps the first tool call ID to the signature
|
||||
* - `tool_calls`: array of tool call descriptors
|
||||
* - `signatures`: array aligned to parts [textPart, functionCall_1, ...], with the signature
|
||||
* only on the first function call (per Google's docs)
|
||||
*
|
||||
* @param toolCalls - Tool calls to include
|
||||
* @param thoughtSignature - The Gemini thought signature
|
||||
* @returns additional_kwargs object for AIMessage
|
||||
*/
|
||||
function buildGeminiAdditionalKwargs(
|
||||
toolCalls: Array<{ id: string; name: string; args: IDataObject }>,
|
||||
thoughtSignature: string,
|
||||
): Record<string, unknown> {
|
||||
const signatures: string[] = ['', thoughtSignature];
|
||||
for (let i = 2; i <= toolCalls.length; i++) {
|
||||
signatures.push('');
|
||||
}
|
||||
|
||||
return {
|
||||
__gemini_function_call_thought_signatures__: {
|
||||
[toolCalls[0].id]: thoughtSignature,
|
||||
},
|
||||
tool_calls: toolCalls.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args })),
|
||||
signatures,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an AIMessage for a single tool call, handling provider-specific formats.
|
||||
*
|
||||
* For Anthropic thinking mode, content is an array of blocks (thinking + tool_use).
|
||||
* For Gemini with thought signatures, additional_kwargs carries the signature.
|
||||
* For other providers, content is a simple string with tool_calls set.
|
||||
*/
|
||||
function buildIndividualAIMessage(
|
||||
toolId: string,
|
||||
toolName: string,
|
||||
toolInput: IDataObject,
|
||||
providerMetadata: ProviderMetadata,
|
||||
): AIMessage {
|
||||
const toolCall = {
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
args: toolInput,
|
||||
type: 'tool_call' as const,
|
||||
};
|
||||
|
||||
const content = buildMessageContent(providerMetadata, toolInput, toolId, toolName);
|
||||
|
||||
return new AIMessage({
|
||||
content,
|
||||
// When content is an array (Anthropic thinking), LangChain ignores tool_calls
|
||||
...(typeof content === 'string' && { tool_calls: [toolCall] }),
|
||||
...(providerMetadata.thoughtSignature && {
|
||||
additional_kwargs: buildGeminiAdditionalKwargs(
|
||||
[{ id: toolId, name: toolName, args: toolInput }],
|
||||
providerMetadata.thoughtSignature,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a shared AIMessage for parallel tool calls with Gemini thought signatures.
|
||||
*
|
||||
* For parallel function calls, Gemini requires ALL function calls in a single "model" turn
|
||||
* with the thought_signature only on the first function call part. This matches the original
|
||||
* model response structure and ensures the cryptographic signature validates correctly.
|
||||
*
|
||||
* LangChain's formatToToolMessages creates one ToolMessage per step. The @langchain/google-common
|
||||
* package automatically merges consecutive "function" role messages, so the function responses
|
||||
* will be correctly grouped.
|
||||
*
|
||||
* @param processedTools - Array of processed tool responses to include
|
||||
* @param thoughtSignature - The shared thought signature from Gemini
|
||||
* @returns AIMessage with all tool calls and proper signature format
|
||||
*/
|
||||
function buildSharedGeminiAIMessage(
|
||||
processedTools: ProcessedToolResponse[],
|
||||
thoughtSignature: string,
|
||||
): AIMessage {
|
||||
const allToolCalls = processedTools.map((pt) => ({
|
||||
id: pt.toolId,
|
||||
name: pt.toolName,
|
||||
args: pt.toolInput,
|
||||
type: 'tool_call' as const,
|
||||
}));
|
||||
|
||||
const toolNames = processedTools.map((pt) => pt.nodeName).join(', ');
|
||||
|
||||
return new AIMessage({
|
||||
content: `Calling tools: ${toolNames}`,
|
||||
tool_calls: allToolCalls,
|
||||
additional_kwargs: buildGeminiAdditionalKwargs(allToolCalls, thoughtSignature),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the observation string from tool result data.
|
||||
*/
|
||||
function buildObservation(toolData: {
|
||||
data?: { ai_tool?: Array<Array<{ json?: unknown }>> };
|
||||
error?: { message?: string; name?: string };
|
||||
}): string {
|
||||
const aiToolData = toolData?.data?.ai_tool?.[0]?.map((item) => item?.json);
|
||||
if (aiToolData && aiToolData.length > 0) {
|
||||
return JSON.stringify(aiToolData);
|
||||
}
|
||||
if (toolData?.error) {
|
||||
const errorInfo = {
|
||||
error: toolData.error.message ?? 'Unknown error',
|
||||
...(toolData.error.name && { errorType: toolData.error.name }),
|
||||
};
|
||||
return JSON.stringify(errorInfo);
|
||||
}
|
||||
return JSON.stringify('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the agent steps from previous tool call responses.
|
||||
* This is used to continue agent execution after tool calls have been made.
|
||||
*
|
||||
* For parallel tool calls with Gemini thought signatures, all function calls are grouped
|
||||
* into a single AIMessage to match the original model response structure. This is required
|
||||
* because the thought_signature is cryptographically tied to the combined parallel call turn.
|
||||
*
|
||||
* This is a generalized version that can be used across different agent types
|
||||
* (Tools Agent, OpenAI Functions Agent, etc.).
|
||||
*
|
||||
* @param response - The engine response containing tool call results
|
||||
* @param itemIndex - The current item index being processed
|
||||
* @returns Array of tool call data representing the agent steps
|
||||
*/
|
||||
export function buildSteps(
|
||||
response: EngineResponse<RequestResponseMetadata> | undefined,
|
||||
itemIndex: number,
|
||||
): ToolCallData[] {
|
||||
const steps: ToolCallData[] = [];
|
||||
|
||||
if (!response) return steps;
|
||||
|
||||
const responses = response.actionResponses ?? [];
|
||||
|
||||
if (response.metadata?.previousRequests) {
|
||||
steps.push(...response.metadata.previousRequests);
|
||||
}
|
||||
|
||||
// First pass: collect all valid tool responses for this batch
|
||||
const batchTools: ProcessedToolResponse[] = [];
|
||||
for (const tool of responses) {
|
||||
if (tool.action?.metadata?.itemIndex !== itemIndex) continue;
|
||||
|
||||
const toolInput: IDataObject = {
|
||||
...tool.action.input,
|
||||
id: tool.action.id,
|
||||
};
|
||||
if (!tool.data) continue;
|
||||
|
||||
const existingStep = steps.find((s) => s.action.toolCallId === toolInput.id);
|
||||
if (existingStep) continue;
|
||||
|
||||
const providerMetadata = extractProviderMetadata(tool.action.metadata);
|
||||
const toolId = typeof toolInput?.id === 'string' ? toolInput.id : 'reconstructed_call';
|
||||
const toolName = resolveToolName(tool);
|
||||
|
||||
batchTools.push({
|
||||
tool,
|
||||
toolInput,
|
||||
toolId,
|
||||
toolName,
|
||||
nodeName: tool.action.nodeName,
|
||||
providerMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this batch has Gemini thought signatures and multiple parallel tool calls.
|
||||
// If so, we must group them into a single AIMessage because:
|
||||
// 1. The thought_signature is cryptographically tied to the combined parallel call turn
|
||||
// 2. Splitting into separate model turns invalidates the signature
|
||||
// 3. Google's API requires matching function response parts per function call turn
|
||||
const sharedThoughtSignature = batchTools.find((bt) => bt.providerMetadata.thoughtSignature)
|
||||
?.providerMetadata.thoughtSignature;
|
||||
|
||||
const sharedAIMessage =
|
||||
sharedThoughtSignature && batchTools.length > 1
|
||||
? buildSharedGeminiAIMessage(batchTools, sharedThoughtSignature)
|
||||
: undefined;
|
||||
|
||||
// Second pass: build steps
|
||||
for (let i = 0; i < batchTools.length; i++) {
|
||||
const { tool, toolInput, toolId, toolName, nodeName, providerMetadata } = batchTools[i];
|
||||
|
||||
const observation = buildObservation(tool.data);
|
||||
|
||||
// Exclude metadata fields (id, log, type) from the tool input forwarded to the result
|
||||
const { id, log, type, ...toolInputForResult } = toolInput;
|
||||
|
||||
// Parallel Gemini tool calls: first step gets the shared AIMessage,
|
||||
// subsequent steps get empty messageLog. LangChain's formatToToolMessages
|
||||
// will produce: [SharedAIMessage, ToolMsg_1, ToolMsg_2, ...]
|
||||
const messageLog = sharedAIMessage
|
||||
? i === 0
|
||||
? [sharedAIMessage]
|
||||
: []
|
||||
: [buildIndividualAIMessage(toolId, toolName, toolInput, providerMetadata)];
|
||||
|
||||
steps.push({
|
||||
action: {
|
||||
tool: toolName,
|
||||
toolInput: toolInputForResult,
|
||||
log: toolInput.log || (messageLog[0]?.content ?? `Calling ${nodeName}`),
|
||||
messageLog,
|
||||
toolCallId: toolInput?.id,
|
||||
type: toolInput.type || 'tool_call',
|
||||
},
|
||||
observation,
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools';
|
||||
import isObject from 'lodash/isObject';
|
||||
import omit from 'lodash/omit';
|
||||
import type { EngineRequest, IDataObject } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
HitlMetadata,
|
||||
RequestResponseMetadata,
|
||||
ThinkingMetadata,
|
||||
ToolCallRequest,
|
||||
ToolMetadata,
|
||||
} from './types';
|
||||
import { isGeminiThoughtSignatureBlock, isRedactedThinkingBlock, isThinkingBlock } from './types';
|
||||
|
||||
export function hasGatedToolNodeName(
|
||||
metadata: unknown,
|
||||
): metadata is ToolMetadata & { gatedToolNodeName: string } {
|
||||
return (
|
||||
isObject(metadata) &&
|
||||
typeof (metadata as Record<string, unknown>).gatedToolNodeName === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function extractHitlMetadata(
|
||||
metadata: ToolMetadata,
|
||||
toolName: string,
|
||||
toolInput: IDataObject,
|
||||
): HitlMetadata | undefined {
|
||||
if (!hasGatedToolNodeName(metadata)) return undefined;
|
||||
|
||||
return {
|
||||
gatedToolNodeName: metadata.gatedToolNodeName,
|
||||
toolName,
|
||||
originalInput: toolInput.toolParameters as IDataObject,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts thinking metadata from tool call, with fallback to shared batch data.
|
||||
* Handles both Gemini thought signatures and Anthropic thinking blocks.
|
||||
*/
|
||||
function extractThinkingMetadata(
|
||||
toolCall: ToolCallRequest,
|
||||
sharedMessageLog: unknown[] | undefined,
|
||||
sharedAdditionalKwargs: Record<string, unknown> | undefined,
|
||||
): ThinkingMetadata {
|
||||
const result: ThinkingMetadata = {};
|
||||
|
||||
// Use toolCall's additionalKwargs or fall back to shared one from batch
|
||||
const effectiveAdditionalKwargs =
|
||||
(toolCall.additionalKwargs as Record<string, unknown> | undefined) ?? sharedAdditionalKwargs;
|
||||
// Use toolCall's messageLog or fall back to shared one from batch
|
||||
const effectiveMessageLog =
|
||||
toolCall.messageLog && toolCall.messageLog.length > 0 ? toolCall.messageLog : sharedMessageLog;
|
||||
|
||||
// Extract thought signatures from additionalKwargs (Gemini)
|
||||
let thoughtSignature: string | undefined;
|
||||
if (effectiveAdditionalKwargs) {
|
||||
// Check for signature mapped by tool call ID
|
||||
const geminiSignatures = effectiveAdditionalKwargs[
|
||||
'__gemini_function_call_thought_signatures__'
|
||||
] as Record<string, string> | undefined;
|
||||
if (geminiSignatures && typeof geminiSignatures === 'object') {
|
||||
// Get signature for this specific tool call, or ANY signature if ID not found
|
||||
// (for parallel calls, signature may be keyed to first call's ID)
|
||||
thoughtSignature =
|
||||
geminiSignatures[toolCall.toolCallId] || Object.values(geminiSignatures)[0];
|
||||
}
|
||||
|
||||
// Also check signatures array format (LangChain Google uses this)
|
||||
if (!thoughtSignature) {
|
||||
const signatures = effectiveAdditionalKwargs.signatures as string[] | undefined;
|
||||
if (signatures && Array.isArray(signatures) && signatures.length > 0) {
|
||||
// First non-empty signature (parallel calls have signature only on first)
|
||||
thoughtSignature = signatures.find((s) => s && s.length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract thinking content and additional thought signatures from messageLog
|
||||
let thinkingContent: string | undefined;
|
||||
let thinkingType: 'thinking' | 'redacted_thinking' | undefined;
|
||||
let thinkingSignature: string | undefined;
|
||||
|
||||
if (effectiveMessageLog && Array.isArray(effectiveMessageLog)) {
|
||||
for (const message of effectiveMessageLog) {
|
||||
// Check if message has content that could contain thought_signature or thinking blocks
|
||||
if (message && typeof message === 'object' && 'content' in message) {
|
||||
const content = message.content;
|
||||
// Content can be string or array of content blocks
|
||||
if (Array.isArray(content)) {
|
||||
// Look for thought_signature in content blocks (Gemini)
|
||||
// and thinking/redacted_thinking blocks (Anthropic)
|
||||
for (const block of content) {
|
||||
// Gemini thought_signature as content block (only if not already found)
|
||||
if (!thoughtSignature && isGeminiThoughtSignatureBlock(block)) {
|
||||
thoughtSignature = block.thoughtSignature;
|
||||
}
|
||||
|
||||
// Anthropic thinking blocks
|
||||
if (isThinkingBlock(block)) {
|
||||
thinkingContent = block.thinking;
|
||||
thinkingType = 'thinking';
|
||||
thinkingSignature = block.signature;
|
||||
} else if (isRedactedThinkingBlock(block)) {
|
||||
thinkingContent = block.data;
|
||||
thinkingType = 'redacted_thinking';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check additional_kwargs on the message for Gemini thought signatures
|
||||
if (!thoughtSignature && 'additional_kwargs' in message) {
|
||||
const msgAdditionalKwargs = message.additional_kwargs as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (msgAdditionalKwargs) {
|
||||
// First check the map format: __gemini_function_call_thought_signatures__
|
||||
const geminiSignatures = msgAdditionalKwargs[
|
||||
'__gemini_function_call_thought_signatures__'
|
||||
] as Record<string, string> | undefined;
|
||||
if (geminiSignatures && typeof geminiSignatures === 'object') {
|
||||
// Get signature for this tool call, or ANY signature for parallel calls
|
||||
thoughtSignature =
|
||||
geminiSignatures[toolCall.toolCallId] || Object.values(geminiSignatures)[0];
|
||||
}
|
||||
|
||||
// If not found, check the signatures array format
|
||||
// LangChain Google returns signatures as an array that corresponds to tool_calls array
|
||||
if (!thoughtSignature) {
|
||||
const signatures = msgAdditionalKwargs.signatures as string[] | undefined;
|
||||
// Get tool_calls from message (not from additional_kwargs)
|
||||
const msgToolCalls =
|
||||
'tool_calls' in message
|
||||
? (message.tool_calls as Array<{ id?: string }> | undefined)
|
||||
: undefined;
|
||||
|
||||
if (signatures && Array.isArray(signatures)) {
|
||||
if (msgToolCalls && Array.isArray(msgToolCalls)) {
|
||||
// Find the index of this tool call by ID
|
||||
const toolCallIndex = msgToolCalls.findIndex(
|
||||
(tc) => tc.id === toolCall.toolCallId,
|
||||
);
|
||||
if (toolCallIndex > 0 && toolCallIndex < signatures.length) {
|
||||
thoughtSignature = signatures[toolCallIndex];
|
||||
}
|
||||
}
|
||||
// Fallback: get first non-empty signature
|
||||
thoughtSignature ??= signatures.find((s) => s && s.length > 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (thoughtSignature || thinkingContent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build result object
|
||||
if (thoughtSignature) {
|
||||
result.google = { thoughtSignature };
|
||||
}
|
||||
|
||||
if (thinkingContent && thinkingType) {
|
||||
result.anthropic = {
|
||||
thinkingContent,
|
||||
thinkingType,
|
||||
...(thinkingSignature ? { thinkingSignature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates engine requests from tool calls.
|
||||
* Maps tool call information to the format expected by the n8n engine
|
||||
* for executing tool nodes.
|
||||
*
|
||||
* This is a generalized version that can be used across different agent types
|
||||
* (Tools Agent, OpenAI Functions Agent, etc.).
|
||||
*
|
||||
* @param toolCalls - Array of tool call requests to convert
|
||||
* @param itemIndex - The current item index
|
||||
* @param tools - Array of available tools
|
||||
* @returns Array of engine request objects (filtered to remove undefined entries)
|
||||
*/
|
||||
export function createEngineRequests(
|
||||
toolCalls: ToolCallRequest[],
|
||||
itemIndex: number,
|
||||
tools: Array<DynamicStructuredTool | Tool>,
|
||||
): EngineRequest<RequestResponseMetadata>['actions'] {
|
||||
// For parallel tool calls, LangChain may only populate messageLog on the first action.
|
||||
// Find a shared messageLog to use for all tool calls in this batch.
|
||||
const sharedMessageLog = toolCalls.find(
|
||||
(tc) => tc.messageLog && tc.messageLog.length > 0,
|
||||
)?.messageLog;
|
||||
// Similarly for additionalKwargs (contains Gemini thought signatures)
|
||||
const sharedAdditionalKwargs = toolCalls.find((tc) => tc.additionalKwargs)?.additionalKwargs as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
return toolCalls
|
||||
.map((toolCall) => {
|
||||
// First try to get from metadata (for toolkit tools)
|
||||
const foundTool = tools.find((tool) => tool.name === toolCall.tool);
|
||||
|
||||
if (!foundTool) return undefined;
|
||||
|
||||
const nodeName = foundTool.metadata?.sourceNodeName;
|
||||
|
||||
if (typeof nodeName !== 'string') return undefined;
|
||||
|
||||
const metadata = (foundTool.metadata ?? {}) as ToolMetadata;
|
||||
const toolInput = toolCall.toolInput as IDataObject;
|
||||
const hitlMetadata = extractHitlMetadata(metadata, toolCall.tool, toolInput);
|
||||
|
||||
let input: IDataObject = toolInput;
|
||||
if (metadata.isFromToolkit) {
|
||||
input = { ...input, tool: toolCall.tool };
|
||||
}
|
||||
if (hitlMetadata) {
|
||||
// This input will be used as HITL node input
|
||||
input = {
|
||||
// omit hitlParameters, because they are destructured into the input instead
|
||||
...omit(input, 'hitlParameters'),
|
||||
...(input.hitlParameters as IDataObject),
|
||||
toolParameters: input.toolParameters,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName,
|
||||
input,
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: toolCall.toolCallId,
|
||||
metadata: {
|
||||
itemIndex,
|
||||
hitl: hitlMetadata,
|
||||
...extractThinkingMetadata(toolCall, sharedMessageLog, sharedAdditionalKwargs),
|
||||
},
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Agent Execution Utilities
|
||||
*
|
||||
* This module contains generalized utilities for agent execution that can be
|
||||
* reused across different agent types (Tools Agent, OpenAI Functions Agent, etc.).
|
||||
*
|
||||
* These utilities support engine-based tool execution, where tool calls are
|
||||
* delegated to the n8n workflow engine instead of being executed inline.
|
||||
*/
|
||||
|
||||
export { createEngineRequests } from './createEngineRequests';
|
||||
export { buildResponseMetadata } from './buildResponseMetadata';
|
||||
export { buildSteps } from './buildSteps';
|
||||
export { processEventStream } from './processEventStream';
|
||||
export { loadMemory, saveToMemory, buildToolContext } from './memoryManagement';
|
||||
export { processHitlResponses, type HitlProcessingResult } from './processHitlResponses';
|
||||
export { serializeIntermediateSteps } from './serializeIntermediateSteps';
|
||||
export type {
|
||||
ToolCallRequest,
|
||||
ToolCallData,
|
||||
AgentResult,
|
||||
RequestResponseMetadata,
|
||||
ToolMetadata,
|
||||
ThinkingMetadata,
|
||||
GoogleThinkingMetadata,
|
||||
AnthropicThinkingMetadata,
|
||||
HitlMetadata,
|
||||
} from './types';
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { BaseChatMemory } from '@langchain/classic/memory';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage, ToolMessage, trimMessages } from '@langchain/core/messages';
|
||||
import type { IDataObject, GenericValue } from 'n8n-workflow';
|
||||
|
||||
import type { ToolCallData } from './types';
|
||||
|
||||
/**
|
||||
* Extracts a string tool_call_id from various possible formats.
|
||||
* Handles the complex type: IDataObject | GenericValue | GenericValue[] | IDataObject[]
|
||||
*
|
||||
* @param toolCallId - The tool call ID in various possible formats
|
||||
* @param toolName - The tool name, used for generating synthetic IDs
|
||||
* @returns A valid string tool_call_id
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* extractToolCallId('call-123', 'calculator') // Returns: 'call-123'
|
||||
* extractToolCallId({ id: 'call-456' }, 'search') // Returns: 'call-456'
|
||||
* extractToolCallId(['call-789'], 'weather') // Returns: 'call-789'
|
||||
* extractToolCallId(null, 'unknown') // Returns: 'synthetic_unknown_1234567890'
|
||||
* ```
|
||||
*/
|
||||
export function extractToolCallId(
|
||||
toolCallId: IDataObject | GenericValue | GenericValue[] | IDataObject[],
|
||||
toolName: string,
|
||||
): string {
|
||||
// Case 1: Already a string
|
||||
if (typeof toolCallId === 'string' && toolCallId.length > 0) {
|
||||
return toolCallId;
|
||||
}
|
||||
|
||||
// Case 2: Object with 'id' property
|
||||
if (
|
||||
typeof toolCallId === 'object' &&
|
||||
toolCallId !== null &&
|
||||
!Array.isArray(toolCallId) &&
|
||||
'id' in toolCallId
|
||||
) {
|
||||
const id = toolCallId.id;
|
||||
if (typeof id === 'string' && id.length > 0) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Array - recursively extract from first element
|
||||
if (Array.isArray(toolCallId) && toolCallId.length > 0) {
|
||||
return extractToolCallId(toolCallId[0], toolName);
|
||||
}
|
||||
|
||||
// Fallback: Generate synthetic ID
|
||||
return `synthetic_${toolName}_${Date.now()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts ToolCallData array into LangChain message sequence.
|
||||
* Creates alternating AIMessage (with tool_calls) and ToolMessage pairs.
|
||||
*
|
||||
* @param steps - Array of tool call data with actions and observations
|
||||
* @returns Array of BaseMessage objects (AIMessage and ToolMessage pairs)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const messages = buildMessagesFromSteps([{
|
||||
* action: {
|
||||
* tool: 'calculator',
|
||||
* toolInput: { expression: '2+2' },
|
||||
* messageLog: [aiMessageWithToolCalls],
|
||||
* toolCallId: 'call-123'
|
||||
* },
|
||||
* observation: '4'
|
||||
* }]);
|
||||
* // Returns: [AIMessage with tool_calls, ToolMessage with result]
|
||||
* ```
|
||||
*/
|
||||
export function buildMessagesFromSteps(steps: ToolCallData[]): BaseMessage[] {
|
||||
const messages: BaseMessage[] = [];
|
||||
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
|
||||
// Try to extract existing AIMessage and its tool_call ID
|
||||
const existingAIMessage = step.action.messageLog?.[0];
|
||||
const existingToolCallId = existingAIMessage?.tool_calls?.[0]?.id;
|
||||
|
||||
// Use existing ID if available, otherwise extract from step data
|
||||
const toolCallId =
|
||||
existingToolCallId ?? extractToolCallId(step.action.toolCallId, step.action.tool);
|
||||
|
||||
// Use existing AIMessage or create a synthetic one
|
||||
const aiMessage =
|
||||
existingAIMessage ??
|
||||
new AIMessage({
|
||||
content: `Calling ${step.action.tool} with input: ${JSON.stringify(step.action.toolInput)}`,
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
name: step.action.tool,
|
||||
args: step.action.toolInput,
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Create ToolMessage with the observation result
|
||||
const toolMessage = new ToolMessage({
|
||||
content: step.observation,
|
||||
tool_call_id: toolCallId,
|
||||
name: step.action.tool,
|
||||
});
|
||||
|
||||
// Add both messages
|
||||
messages.push(aiMessage);
|
||||
messages.push(toolMessage);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a formatted string representation of tool calls for memory storage.
|
||||
* This creates a consistent format that can be used across both streaming and non-streaming modes.
|
||||
*
|
||||
* @deprecated Used only as fallback for custom memory implementations that don't support addMessages
|
||||
* @param steps - Array of tool call data with actions and observations
|
||||
* @returns Formatted string of tool calls separated by semicolons
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const context = buildToolContext([{
|
||||
* action: { tool: 'calculator', toolInput: { expression: '2+2' }, ... },
|
||||
* observation: '4'
|
||||
* }]);
|
||||
* // Returns: "Tool: calculator, Input: {"expression":"2+2"}, Result: 4"
|
||||
* ```
|
||||
*/
|
||||
export function buildToolContext(steps: ToolCallData[]): string {
|
||||
return steps
|
||||
.map(
|
||||
(step) =>
|
||||
`Tool: ${step.action.tool}, Input: ${JSON.stringify(step.action.toolInput)}, Result: ${step.observation}`,
|
||||
)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes orphaned ToolMessages and AIMessages with tool_calls from the start of chat history.
|
||||
* This happens when memory trimming cuts messages mid-turn, leaving incomplete tool call sequences.
|
||||
*
|
||||
* @param chatHistory - Array of messages to clean up
|
||||
* @returns Cleaned array with orphaned messages removed from the start
|
||||
*/
|
||||
function cleanupOrphanedMessages(chatHistory: BaseMessage[]): BaseMessage[] {
|
||||
let changed = true;
|
||||
while (changed && chatHistory.length > 0) {
|
||||
changed = false;
|
||||
|
||||
// Remove orphaned ToolMessages at the start
|
||||
while (chatHistory.length > 0 && chatHistory[0] instanceof ToolMessage) {
|
||||
chatHistory.shift();
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Remove AIMessages with tool_calls if they don't have following ToolMessages
|
||||
if (chatHistory.length > 0) {
|
||||
const firstMessage = chatHistory[0];
|
||||
const hasOrphanedAIMessage =
|
||||
firstMessage instanceof AIMessage &&
|
||||
(firstMessage.tool_calls?.length ?? 0) > 0 &&
|
||||
!(chatHistory[1] instanceof ToolMessage);
|
||||
|
||||
if (hasOrphanedAIMessage) {
|
||||
chatHistory.shift();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chatHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads chat history from memory and optionally trims it to fit within token limits.
|
||||
* Automatically cleans up orphaned tool messages that may result from memory trimming.
|
||||
*
|
||||
* @param memory - The memory instance to load from
|
||||
* @param model - Optional chat model for token counting (required if maxTokens is specified)
|
||||
* @param maxTokens - Optional maximum number of tokens to load from memory
|
||||
* @returns Array of base messages representing the chat history
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Load all history
|
||||
* const messages = await loadMemory(memory);
|
||||
*
|
||||
* // Load with token limit
|
||||
* const messages = await loadMemory(memory, model, 2000);
|
||||
* ```
|
||||
*/
|
||||
export async function loadMemory(
|
||||
memory?: BaseChatMemory,
|
||||
model?: BaseChatModel,
|
||||
maxTokens?: number,
|
||||
): Promise<BaseMessage[] | undefined> {
|
||||
if (!memory) {
|
||||
return undefined;
|
||||
}
|
||||
const memoryVariables = await memory.loadMemoryVariables({});
|
||||
let chatHistory = (memoryVariables['chat_history'] as BaseMessage[]) || [];
|
||||
|
||||
// Clean up any orphaned messages from previous trimming operations
|
||||
chatHistory = cleanupOrphanedMessages(chatHistory);
|
||||
|
||||
// Trim messages if token limit is specified and model is available
|
||||
if (maxTokens && model) {
|
||||
chatHistory = await trimMessages(chatHistory, {
|
||||
strategy: 'last',
|
||||
maxTokens,
|
||||
tokenCounter: model,
|
||||
includeSystem: true,
|
||||
startOn: 'human',
|
||||
allowPartial: true,
|
||||
});
|
||||
|
||||
// Clean up again after trimming, as it may create new orphans
|
||||
chatHistory = cleanupOrphanedMessages(chatHistory);
|
||||
}
|
||||
|
||||
return chatHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a conversation turn (user input + agent output) to memory.
|
||||
* Uses LangChain-native message types (AIMessage with tool_calls, ToolMessage)
|
||||
* when tools are involved, preserving semantic structure for LLMs.
|
||||
*
|
||||
* @param input - The user input/prompt
|
||||
* @param output - The agent's output/response
|
||||
* @param memory - The memory instance to save to
|
||||
* @param steps - Optional tool call data to save as proper message sequence
|
||||
* @param previousStepsCount - Number of steps from previous turns (to filter out duplicates)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Simple conversation (no tools)
|
||||
* await saveToMemory('What is 2+2?', 'The answer is 4', memory);
|
||||
*
|
||||
* // With tool calls (saves full message sequence)
|
||||
* await saveToMemory('Calculate 2+2', 'The answer is 4', memory, steps, 0);
|
||||
* ```
|
||||
*/
|
||||
export async function saveToMemory(
|
||||
input: string,
|
||||
output: string,
|
||||
memory?: BaseChatMemory,
|
||||
steps?: ToolCallData[],
|
||||
previousStepsCount?: number,
|
||||
): Promise<void> {
|
||||
if (!output || !memory) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No tool calls: use simple saveContext (backwards compatible)
|
||||
if (!steps || steps.length === 0) {
|
||||
await memory.saveContext({ input }, { output });
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out previous steps to avoid duplicates (they're already in memory)
|
||||
const newSteps = previousStepsCount ? steps.slice(previousStepsCount) : steps;
|
||||
|
||||
if (newSteps.length === 0) {
|
||||
await memory.saveContext({ input }, { output });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if memory supports addMessages (feature detection)
|
||||
if (
|
||||
!('addMessages' in memory.chatHistory) ||
|
||||
typeof memory.chatHistory.addMessages !== 'function'
|
||||
) {
|
||||
// Fallback: use old string format (only with new steps to avoid duplicates)
|
||||
const toolContext = buildToolContext(newSteps);
|
||||
const fullOutput = `[Used tools: ${toolContext}] ${output}`;
|
||||
await memory.saveContext({ input }, { output: fullOutput });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build full conversation sequence using LangChain-native message types
|
||||
const messages: BaseMessage[] = [];
|
||||
|
||||
// 1. User input
|
||||
messages.push(new HumanMessage(input));
|
||||
|
||||
// 2. Tool call sequence (AIMessage with tool_calls → ToolMessage for each)
|
||||
const toolMessages = buildMessagesFromSteps(newSteps);
|
||||
messages.push.apply(messages, toolMessages);
|
||||
|
||||
// 3. Final AI response (no tool_calls)
|
||||
messages.push(new AIMessage(output));
|
||||
|
||||
// 4. Save all messages in bulk
|
||||
await memory.chatHistory.addMessages(messages);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { StreamEvent } from '@langchain/core/dist/tracers/event_stream';
|
||||
import type { IterableReadableStream } from '@langchain/core/dist/utils/stream';
|
||||
import type { AIMessageChunk, MessageContentText } from '@langchain/core/messages';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import type { AgentResult, ToolCallRequest } from './types';
|
||||
|
||||
/**
|
||||
* Processes the event stream from a streaming agent execution.
|
||||
* Handles streaming chunks, tool calls, and intermediate steps.
|
||||
*
|
||||
* This is a generalized version that can be used across different agent types
|
||||
* (Tools Agent, OpenAI Functions Agent, etc.).
|
||||
*
|
||||
* @param ctx - The execution context
|
||||
* @param eventStream - The stream of events from the agent
|
||||
* @param itemIndex - The current item index
|
||||
* @returns AgentResult containing output and optional tool calls/steps
|
||||
*/
|
||||
export async function processEventStream(
|
||||
ctx: IExecuteFunctions,
|
||||
eventStream: IterableReadableStream<StreamEvent>,
|
||||
itemIndex: number,
|
||||
): Promise<AgentResult> {
|
||||
const agentResult: AgentResult = {
|
||||
output: '',
|
||||
};
|
||||
|
||||
const toolCalls: ToolCallRequest[] = [];
|
||||
|
||||
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 (event.data) {
|
||||
const chatModelData = event.data;
|
||||
const output = chatModelData.output;
|
||||
|
||||
// Check if this LLM response contains tool calls
|
||||
if (output?.tool_calls && output.tool_calls.length > 0) {
|
||||
// Collect tool calls for request building
|
||||
// Note: For Gemini, we pass additional_kwargs to ALL tool calls
|
||||
// so the signature can be applied to each when rebuilding
|
||||
for (const toolCall of output.tool_calls) {
|
||||
toolCalls.push({
|
||||
tool: toolCall.name,
|
||||
toolInput: toolCall.args,
|
||||
toolCallId: toolCall.id || 'unknown',
|
||||
type: toolCall.type || 'tool_call',
|
||||
log:
|
||||
output.content ||
|
||||
`Calling ${toolCall.name} with input: ${JSON.stringify(toolCall.args)}`,
|
||||
messageLog: [output],
|
||||
// Pass additional_kwargs to ALL tool calls so signature is available
|
||||
additionalKwargs: output.additional_kwargs as Record<string, unknown> | undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
ctx.sendChunk('end', itemIndex);
|
||||
|
||||
// Include collected tool calls in the result
|
||||
if (toolCalls.length > 0) {
|
||||
agentResult.toolCalls = toolCalls;
|
||||
}
|
||||
|
||||
return agentResult;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { EngineResponse, EngineRequest, IDataObject, ExecuteNodeResult } from 'n8n-workflow';
|
||||
|
||||
import type { RequestResponseMetadata } from './types';
|
||||
|
||||
/**
|
||||
* HITL metadata type (extracted from RequestResponseMetadata for convenience)
|
||||
*/
|
||||
type HitlMetadata = NonNullable<RequestResponseMetadata['hitl']>;
|
||||
|
||||
/**
|
||||
* Result of processing HITL responses
|
||||
*/
|
||||
export interface HitlProcessingResult {
|
||||
/** If we need to execute gated tools, this contains the EngineRequest */
|
||||
pendingGatedToolRequest?: EngineRequest<RequestResponseMetadata>;
|
||||
/** Modified response with HITL approvals/denials properly formatted */
|
||||
processedResponse: EngineResponse<RequestResponseMetadata>;
|
||||
/** Whether any HITL tools were approved and need gated tool execution */
|
||||
hasApprovedHitlTools: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an action response is from an HITL tool
|
||||
*/
|
||||
function isHitlActionResponse(
|
||||
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
|
||||
): actionResponse is ExecuteNodeResult<RequestResponseMetadata> & {
|
||||
action: { metadata: RequestResponseMetadata & { hitl: HitlMetadata } };
|
||||
} {
|
||||
const hitl = (actionResponse.action?.metadata as { hitl?: HitlMetadata } | undefined)?.hitl;
|
||||
return hitl !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if data contains an approval field
|
||||
*/
|
||||
function isApprovalData(data: unknown): data is { approved: boolean } {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
'approved' in data &&
|
||||
typeof (data as Record<string, unknown>).approved === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
function getActionJsonResponse(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
|
||||
return actionResponse.data?.data?.ai_tool?.[0]?.[0]?.json;
|
||||
}
|
||||
/**
|
||||
* Extract approval status from HITL response data.
|
||||
* SendAndWait webhook returns { approved: boolean } or { data: { approved: boolean } }
|
||||
*/
|
||||
function getApprovalStatus(
|
||||
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
|
||||
): boolean | undefined {
|
||||
const json = getActionJsonResponse(actionResponse);
|
||||
|
||||
if (isApprovalData(json)) {
|
||||
return json.approved;
|
||||
}
|
||||
|
||||
const nestedData = (json as IDataObject | undefined)?.data;
|
||||
if (isApprovalData(nestedData)) {
|
||||
return nestedData.approved;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getChatInput(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
|
||||
const json = getActionJsonResponse(actionResponse);
|
||||
const chatInput = json?.chatInput ?? (json?.data as IDataObject | undefined)?.chatInput;
|
||||
if (typeof chatInput === 'string') {
|
||||
return chatInput;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDenialMessage(toolName: string, toolId: string, chatInput?: string): string {
|
||||
const parts: string[] = [];
|
||||
if (chatInput) {
|
||||
parts.push(
|
||||
`The user reviewed your planned tool call to ${toolName} (id: ${toolId}) and provided feedback: "${chatInput}".`,
|
||||
);
|
||||
} else {
|
||||
parts.push(`User rejected the tool call to ${toolName} (id: ${toolId}).`);
|
||||
parts.push('STOP what you are doing and wait for the user to tell you how to proceed.');
|
||||
}
|
||||
parts.push('The tool is still available if needed.');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Process HITL (Human-in-the-Loop) tool responses.
|
||||
*
|
||||
* When the Agent receives responses from HITL tools:
|
||||
* 1. Check if the response indicates approval or denial
|
||||
* 2. If approved: Generate EngineRequest for the gated tool
|
||||
* 3. If denied: Modify response to indicate denial so Agent knows not to retry
|
||||
*
|
||||
* This enables the flow:
|
||||
* Agent calls tool → HITL intercepts → sendAndWait → User approves →
|
||||
* Agent generates new request for gated tool → Gated tool executes → Result to Agent
|
||||
*/
|
||||
export function processHitlResponses(
|
||||
response: EngineResponse<RequestResponseMetadata> | undefined,
|
||||
itemIndex: number,
|
||||
): HitlProcessingResult {
|
||||
if (!response || !response.actionResponses || response.actionResponses.length === 0) {
|
||||
return {
|
||||
processedResponse: response ?? { actionResponses: [], metadata: {} },
|
||||
hasApprovedHitlTools: false,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingGatedToolActions: EngineRequest<RequestResponseMetadata>['actions'] = [];
|
||||
const processedActionResponses: Array<ExecuteNodeResult<RequestResponseMetadata>> = [];
|
||||
let hasApprovedHitlTools = false;
|
||||
|
||||
for (const actionResponse of response.actionResponses) {
|
||||
if (!isHitlActionResponse(actionResponse)) {
|
||||
// Not an HITL tool, pass through unchanged
|
||||
processedActionResponses.push(actionResponse);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { hitl } = actionResponse.action.metadata;
|
||||
const approved = getApprovalStatus(actionResponse);
|
||||
const chatInput = getChatInput(actionResponse);
|
||||
const toolName = hitl.gatedToolNodeName;
|
||||
const toolId = actionResponse.action.id;
|
||||
if (approved === true) {
|
||||
hasApprovedHitlTools = true;
|
||||
|
||||
const input =
|
||||
typeof hitl.originalInput === 'object'
|
||||
? { tool: hitl.toolName, ...hitl.originalInput }
|
||||
: { tool: hitl.toolName, input: hitl.originalInput };
|
||||
|
||||
pendingGatedToolActions.push({
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: hitl.gatedToolNodeName,
|
||||
input,
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: toolId,
|
||||
metadata: {
|
||||
itemIndex,
|
||||
// Set the parent node to the HITL node for proper log tree structure
|
||||
parentNodeName: actionResponse.action.nodeName,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const modifiedResponse: ExecuteNodeResult<RequestResponseMetadata> = {
|
||||
...actionResponse,
|
||||
data: {
|
||||
...actionResponse.data,
|
||||
data: {
|
||||
ai_tool: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
output: getDenialMessage(toolName, toolId, chatInput),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
processedActionResponses.push(modifiedResponse);
|
||||
}
|
||||
}
|
||||
|
||||
const result: HitlProcessingResult = {
|
||||
processedResponse: {
|
||||
...response,
|
||||
actionResponses: processedActionResponses,
|
||||
},
|
||||
hasApprovedHitlTools,
|
||||
};
|
||||
|
||||
if (pendingGatedToolActions.length > 0) {
|
||||
result.pendingGatedToolRequest = {
|
||||
actions: pendingGatedToolActions,
|
||||
metadata: {
|
||||
previousRequests: response.metadata?.previousRequests,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Converts intermediateSteps messageLog entries from LangChain class instances
|
||||
* to plain objects. This ensures the data structure is consistent between
|
||||
* runtime (expression evaluation) and UI display (after JSON serialization).
|
||||
*
|
||||
* Without this, AIMessage instances have properties like `content` and
|
||||
* `tool_calls` directly, but their `toJSON()` wraps them under `kwargs`,
|
||||
* causing expressions built from UI inspection to fail at runtime.
|
||||
*/
|
||||
export function serializeIntermediateSteps(
|
||||
steps: Array<{ action: { messageLog?: unknown[] }; [key: string]: unknown }>,
|
||||
): void {
|
||||
for (const step of steps) {
|
||||
if (step.action.messageLog) {
|
||||
step.action.messageLog = step.action.messageLog.map((msg) => {
|
||||
if (
|
||||
msg &&
|
||||
typeof msg === 'object' &&
|
||||
typeof (msg as Record<string, unknown>).toJSON === 'function'
|
||||
) {
|
||||
return serializeMessage(msg);
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeMessage(msg: unknown): Record<string, unknown> {
|
||||
const m = msg as Record<string, unknown>;
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const key of Object.keys(m)) {
|
||||
if (key === 'toJSON' || key === '_getType') continue;
|
||||
result[key] = m[key];
|
||||
}
|
||||
|
||||
// Ensure type is included (may come from a getter on the prototype)
|
||||
if (
|
||||
!('type' in result) &&
|
||||
typeof (m as Record<string, (...args: unknown[]) => unknown>)._getType === 'function'
|
||||
) {
|
||||
result.type = (m as Record<string, (...args: unknown[]) => unknown>)._getType();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import type { EngineResponse } from 'n8n-workflow';
|
||||
|
||||
import * as agentExecution from '../buildSteps';
|
||||
|
||||
import type { RequestResponseMetadata } from '../types';
|
||||
import { buildResponseMetadata } from '../buildResponseMetadata';
|
||||
|
||||
// Mock the buildSteps function from agent-execution
|
||||
jest.mock('../buildSteps', () => ({
|
||||
buildSteps: jest.fn((response) => {
|
||||
// Mock implementation: return previous requests if they exist
|
||||
if (response?.actionResponses) {
|
||||
return response.actionResponses.map((ar: any) => ({
|
||||
action: {
|
||||
tool: ar.action.nodeName,
|
||||
toolInput: ar.action.input,
|
||||
log: 'mock log',
|
||||
toolCallId: ar.action.id,
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: JSON.stringify(ar.data),
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('buildIterationMetadata', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response is undefined', () => {
|
||||
const result = buildResponseMetadata(undefined, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response has no metadata', () => {
|
||||
const response = {
|
||||
actionResponses: [],
|
||||
} as unknown as EngineResponse<RequestResponseMetadata>;
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return metadata with iterationCount 1 when response metadata has no iterationCount', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should increment iterationCount when response has existing iterationCount', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {
|
||||
iterationCount: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include previousRequests when response has actionResponses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
{
|
||||
action: {
|
||||
id: 'call_123',
|
||||
nodeName: 'TestTool',
|
||||
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: {
|
||||
iterationCount: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result.itemIndex).toBe(0);
|
||||
expect(result.iterationCount).toBe(2);
|
||||
expect(result.previousRequests).toHaveLength(1);
|
||||
expect(result.previousRequests?.[0]).toMatchObject({
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple iterations correctly', () => {
|
||||
// First iteration
|
||||
const result1 = buildResponseMetadata(undefined, 0);
|
||||
expect(result1.iterationCount).toBe(1);
|
||||
|
||||
// Second iteration
|
||||
const response2: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 1 },
|
||||
};
|
||||
const result2 = buildResponseMetadata(response2, 0);
|
||||
expect(result2.iterationCount).toBe(2);
|
||||
|
||||
// Third iteration
|
||||
const response3: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 2 },
|
||||
};
|
||||
const result3 = buildResponseMetadata(response3, 0);
|
||||
expect(result3.iterationCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should pass correct itemIndex to buildSteps', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: { iterationCount: 1 },
|
||||
};
|
||||
|
||||
buildResponseMetadata(response, 5);
|
||||
|
||||
expect(agentExecution.buildSteps).toHaveBeenCalledWith(response, 5);
|
||||
});
|
||||
|
||||
it('should handle iterationCount starting from 0', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {
|
||||
iterationCount: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const result = buildResponseMetadata(response, 0);
|
||||
|
||||
expect(result).toEqual({
|
||||
previousRequests: [],
|
||||
itemIndex: 0,
|
||||
iterationCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,654 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import {
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
trimMessages,
|
||||
} from '@langchain/core/messages';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { BaseChatMemory } from '@langchain/classic/memory';
|
||||
|
||||
import {
|
||||
loadMemory,
|
||||
saveToMemory,
|
||||
buildToolContext,
|
||||
extractToolCallId,
|
||||
buildMessagesFromSteps,
|
||||
} from '../memoryManagement';
|
||||
import type { ToolCallData } from '../types';
|
||||
|
||||
jest.mock('@langchain/core/messages', () => ({
|
||||
...jest.requireActual('@langchain/core/messages'),
|
||||
trimMessages: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('memoryManagement', () => {
|
||||
let mockMemory: jest.Mocked<BaseChatMemory>;
|
||||
let mockModel: jest.Mocked<BaseChatModel>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockMemory = mock<BaseChatMemory>();
|
||||
mockModel = mock<BaseChatModel>();
|
||||
});
|
||||
|
||||
describe('loadMemory', () => {
|
||||
it('should return undefined when no memory is provided', async () => {
|
||||
const result = await loadMemory(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load chat history from memory', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(mockMemory.loadMemoryVariables).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('should return empty array when chat_history is not present', async () => {
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({});
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should remove orphaned ToolMessage at start of chat history', async () => {
|
||||
// Simulates memory trimming that removed the AIMessage but left the ToolMessage
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result', tool_call_id: 'orphaned-id', name: 'tool' }),
|
||||
new HumanMessage('Next question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove orphaned AIMessage with tool_calls at start', async () => {
|
||||
// Simulates memory trimming that kept AIMessage with tool_calls but removed the ToolMessage
|
||||
const orphanedAI = new AIMessage({
|
||||
content: 'Calling tool',
|
||||
tool_calls: [{ id: 'call-123', name: 'tool', args: {}, type: 'tool_call' }],
|
||||
});
|
||||
const chatHistory = [orphanedAI, new HumanMessage('Next question'), new AIMessage('Answer')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove multiple consecutive orphaned ToolMessages at start', async () => {
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result 1', tool_call_id: 'id-1', name: 'tool1' }),
|
||||
new ToolMessage({ content: 'Result 2', tool_call_id: 'id-2', name: 'tool2' }),
|
||||
new ToolMessage({ content: 'Result 3', tool_call_id: 'id-3', name: 'tool3' }),
|
||||
new HumanMessage('Next question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should remove chain of ToolMessage -> AIMessage(tool_calls) at start via recursive cleanup', async () => {
|
||||
// After removing the first ToolMessage, an orphaned AIMessage with tool_calls is revealed
|
||||
// (not followed by a ToolMessage), requiring another cleanup pass
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Orphan result', tool_call_id: 'id-1', name: 'tool1' }),
|
||||
new AIMessage({
|
||||
content: 'Calling another tool',
|
||||
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
new HumanMessage('Question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should handle orphaned AIMessage(tool_calls) followed by more orphaned ToolMessages', async () => {
|
||||
const chatHistory = [
|
||||
new AIMessage({
|
||||
content: 'Calling tool',
|
||||
tool_calls: [{ id: 'call-1', name: 'tool1', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
// This AIMessage has tool_calls but no following ToolMessage (next is HumanMessage)
|
||||
new AIMessage({
|
||||
content: 'Another call',
|
||||
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
new HumanMessage('Question'),
|
||||
new AIMessage('Answer'),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result?.[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(result?.[1]).toBeInstanceOf(AIMessage);
|
||||
});
|
||||
|
||||
it('should return empty array when all messages are orphans', async () => {
|
||||
const chatHistory = [
|
||||
new ToolMessage({ content: 'Result', tool_call_id: 'id-1', name: 'tool' }),
|
||||
new AIMessage({
|
||||
content: 'Call',
|
||||
tool_calls: [{ id: 'call-1', name: 'tool', args: {}, type: 'tool_call' as const }],
|
||||
}),
|
||||
];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trim messages when maxTokens is provided', async () => {
|
||||
const chatHistory = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('Hello'),
|
||||
new AIMessage('Hi there!'),
|
||||
new HumanMessage('How are you?'),
|
||||
new AIMessage('I am doing well!'),
|
||||
];
|
||||
const trimmedHistory = [
|
||||
new SystemMessage('System prompt'),
|
||||
new HumanMessage('How are you?'),
|
||||
new AIMessage('I am doing well!'),
|
||||
];
|
||||
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
(trimMessages as jest.Mock).mockResolvedValue(trimmedHistory);
|
||||
|
||||
const result = await loadMemory(mockMemory, mockModel, 2000);
|
||||
|
||||
expect(result).toEqual(trimmedHistory);
|
||||
expect(trimMessages).toHaveBeenCalledWith(chatHistory, {
|
||||
strategy: 'last',
|
||||
maxTokens: 2000,
|
||||
tokenCounter: mockModel,
|
||||
includeSystem: true,
|
||||
startOn: 'human',
|
||||
allowPartial: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trim messages when maxTokens is not provided', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory, mockModel);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(trimMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not trim messages when model is not provided', async () => {
|
||||
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
|
||||
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
|
||||
|
||||
const result = await loadMemory(mockMemory, undefined, 2000);
|
||||
|
||||
expect(result).toEqual(chatHistory);
|
||||
expect(trimMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToMemory', () => {
|
||||
it('should save conversation to memory', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = 'The answer is 4';
|
||||
|
||||
await saveToMemory(input, output, mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith({ input }, { output });
|
||||
});
|
||||
|
||||
it('should not save when output is empty', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = '';
|
||||
|
||||
await saveToMemory(input, output, mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save when memory is not provided', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
const output = 'The answer is 4';
|
||||
|
||||
await saveToMemory(input, output, undefined);
|
||||
|
||||
// Should not throw error
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not save when both output and memory are missing', async () => {
|
||||
const input = 'What is 2+2?';
|
||||
|
||||
await saveToMemory(input, '', undefined);
|
||||
|
||||
expect(mockMemory.saveContext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolCallId', () => {
|
||||
beforeEach(() => {
|
||||
// Mock Date.now() to return consistent values for synthetic IDs
|
||||
jest.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should extract string ID directly', () => {
|
||||
const result = extractToolCallId('call-123', 'calculator');
|
||||
expect(result).toBe('call-123');
|
||||
});
|
||||
|
||||
it('should extract ID from object with id property', () => {
|
||||
const result = extractToolCallId({ id: 'call-456' }, 'search');
|
||||
expect(result).toBe('call-456');
|
||||
});
|
||||
|
||||
it('should extract ID from array', () => {
|
||||
const result = extractToolCallId(['call-789'], 'weather');
|
||||
expect(result).toBe('call-789');
|
||||
});
|
||||
|
||||
it('should recursively extract from nested array', () => {
|
||||
const result = extractToolCallId([['call-nested']], 'tool');
|
||||
expect(result).toBe('call-nested');
|
||||
});
|
||||
|
||||
it('should extract from array of objects', () => {
|
||||
const result = extractToolCallId([{ id: 'call-array-obj' }], 'tool');
|
||||
expect(result).toBe('call-array-obj');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for null', () => {
|
||||
const result = extractToolCallId(null, 'unknown');
|
||||
expect(result).toBe('synthetic_unknown_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for undefined', () => {
|
||||
const result = extractToolCallId(undefined, 'test');
|
||||
expect(result).toBe('synthetic_test_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for empty string', () => {
|
||||
const result = extractToolCallId('', 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for object without id property', () => {
|
||||
const result = extractToolCallId({ other: 'value' }, 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
|
||||
it('should generate synthetic ID for empty array', () => {
|
||||
const result = extractToolCallId([], 'tool');
|
||||
expect(result).toBe('synthetic_tool_1234567890');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessagesFromSteps', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should build messages with proper AIMessage from messageLog', () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Let me calculate that',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call-123',
|
||||
name: 'calculator',
|
||||
args: { expression: '2+2' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Using calculator',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBe(aiMessage);
|
||||
expect(result[1]).toBeInstanceOf(ToolMessage);
|
||||
expect(result[1].content).toBe('4');
|
||||
expect((result[1] as ToolMessage).tool_call_id).toBe('call-123');
|
||||
expect((result[1] as ToolMessage).name).toBe('calculator');
|
||||
});
|
||||
|
||||
it('should create synthetic AIMessage when messageLog is missing', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'search',
|
||||
toolInput: { query: 'test' },
|
||||
log: 'Searching',
|
||||
toolCallId: 'call-456',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Found results',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toBeInstanceOf(AIMessage);
|
||||
expect(result[0].content).toContain('search');
|
||||
expect(result[0].content).toContain('test');
|
||||
expect((result[0] as AIMessage).tool_calls).toHaveLength(1);
|
||||
expect((result[0] as AIMessage).tool_calls?.[0].id).toBe('call-456');
|
||||
});
|
||||
|
||||
it('should handle multiple tool calls in sequence', () => {
|
||||
const aiMessage1 = new AIMessage({
|
||||
content: 'Checking weather',
|
||||
tool_calls: [
|
||||
{ id: 'call-1', name: 'weather', args: { location: 'NYC' }, type: 'tool_call' },
|
||||
],
|
||||
});
|
||||
|
||||
const aiMessage2 = new AIMessage({
|
||||
content: 'Getting time',
|
||||
tool_calls: [{ id: 'call-2', name: 'time', args: { timezone: 'EST' }, type: 'tool_call' }],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'weather',
|
||||
toolInput: { location: 'NYC' },
|
||||
log: 'Weather',
|
||||
messageLog: [aiMessage1],
|
||||
toolCallId: 'call-1',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Sunny, 72°F',
|
||||
},
|
||||
{
|
||||
action: {
|
||||
tool: 'time',
|
||||
toolInput: { timezone: 'EST' },
|
||||
log: 'Time',
|
||||
messageLog: [aiMessage2],
|
||||
toolCallId: 'call-2',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '14:30',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildMessagesFromSteps(steps);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0]).toBe(aiMessage1);
|
||||
expect(result[1]).toBeInstanceOf(ToolMessage);
|
||||
expect(result[2]).toBe(aiMessage2);
|
||||
expect(result[3]).toBeInstanceOf(ToolMessage);
|
||||
});
|
||||
|
||||
it('should return empty array for empty steps', () => {
|
||||
const result = buildMessagesFromSteps([]);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveToMemory with steps (message-based storage)', () => {
|
||||
let mockChatHistory: any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(console, 'log').mockImplementation();
|
||||
mockChatHistory = {
|
||||
addMessages: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
mockMemory.chatHistory = mockChatHistory;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should use message-based storage when steps are provided and addMessages is available', async () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Let me calculate',
|
||||
tool_calls: [
|
||||
{ id: 'call-123', name: 'calculator', args: { expression: '2+2' }, type: 'tool_call' },
|
||||
],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
|
||||
|
||||
expect(mockChatHistory.addMessages).toHaveBeenCalledTimes(1);
|
||||
const savedMessages = mockChatHistory.addMessages.mock.calls[0][0];
|
||||
|
||||
expect(savedMessages).toHaveLength(4);
|
||||
expect(savedMessages[0]).toBeInstanceOf(HumanMessage);
|
||||
expect(savedMessages[0].content).toBe('Calculate 2+2');
|
||||
expect(savedMessages[1]).toBe(aiMessage);
|
||||
expect(savedMessages[2]).toBeInstanceOf(ToolMessage);
|
||||
expect(savedMessages[3]).toBeInstanceOf(AIMessage);
|
||||
expect(savedMessages[3].content).toBe('The answer is 4');
|
||||
});
|
||||
|
||||
it('should fall back to string format when addMessages is not available', async () => {
|
||||
// Create a chat history object without addMessages method
|
||||
mockMemory.chatHistory = {} as any;
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Calculate 2+2' },
|
||||
{
|
||||
output:
|
||||
'[Used tools: Tool: calculator, Input: {"expression":"2+2"}, Result: 4] The answer is 4',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should use saveContext when steps array is empty', async () => {
|
||||
await saveToMemory('Simple question', 'Simple answer', mockMemory, []);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Simple question' },
|
||||
{ output: 'Simple answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use saveContext when steps is undefined', async () => {
|
||||
await saveToMemory('Simple question', 'Simple answer', mockMemory);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'Simple question' },
|
||||
{ output: 'Simple answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use saveContext when all steps are from previous turns', async () => {
|
||||
const aiMessage = new AIMessage({
|
||||
content: 'Using tool',
|
||||
tool_calls: [{ id: 'call-123', name: 'calculator', args: {}, type: 'tool_call' }],
|
||||
});
|
||||
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Calc',
|
||||
messageLog: [aiMessage],
|
||||
toolCallId: 'call-123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
// All steps are from previous turns (previousStepsCount = 1)
|
||||
await saveToMemory('New question', 'New answer', mockMemory, steps, 1);
|
||||
|
||||
expect(mockMemory.saveContext).toHaveBeenCalledWith(
|
||||
{ input: 'New question' },
|
||||
{ output: 'New answer' },
|
||||
);
|
||||
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolContext', () => {
|
||||
it('should build tool context string from single step', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'calculator',
|
||||
toolInput: { expression: '2+2' },
|
||||
log: 'Using calculator',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '4',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe('Tool: calculator, Input: {"expression":"2+2"}, Result: 4');
|
||||
});
|
||||
|
||||
it('should build tool context string from multiple steps', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'weather',
|
||||
toolInput: { location: 'New York' },
|
||||
log: 'Getting weather',
|
||||
toolCallId: 'call_123',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Sunny, 72°F',
|
||||
},
|
||||
{
|
||||
action: {
|
||||
tool: 'time',
|
||||
toolInput: { timezone: 'EST' },
|
||||
log: 'Getting time',
|
||||
toolCallId: 'call_124',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: '14:30',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe(
|
||||
'Tool: weather, Input: {"location":"New York"}, Result: Sunny, 72°F; Tool: time, Input: {"timezone":"EST"}, Result: 14:30',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty string for empty steps array', () => {
|
||||
const result = buildToolContext([]);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle complex tool inputs', () => {
|
||||
const steps: ToolCallData[] = [
|
||||
{
|
||||
action: {
|
||||
tool: 'search',
|
||||
toolInput: {
|
||||
query: 'typescript testing',
|
||||
filters: { language: 'en', date: '2024' },
|
||||
limit: 10,
|
||||
},
|
||||
log: 'Searching',
|
||||
toolCallId: 'call_125',
|
||||
type: 'tool_call',
|
||||
},
|
||||
observation: 'Found 10 results',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildToolContext(steps);
|
||||
|
||||
expect(result).toBe(
|
||||
'Tool: search, Input: {"query":"typescript testing","filters":{"language":"en","date":"2024"},"limit":10}, Result: Found 10 results',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { EngineResponse, ExecuteNodeResult, IDataObject, ITaskData } from 'n8n-workflow';
|
||||
|
||||
import { processHitlResponses } from '../processHitlResponses';
|
||||
import type { HitlMetadata, RequestResponseMetadata } from '../types';
|
||||
|
||||
const createMockTaskData = (json: IDataObject): ITaskData => ({
|
||||
executionTime: 0,
|
||||
startTime: Date.now(),
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
data: {
|
||||
ai_tool: [[{ json }]],
|
||||
},
|
||||
});
|
||||
|
||||
const createHitlActionResponse = (
|
||||
approved: boolean,
|
||||
hitlMetadata: HitlMetadata,
|
||||
actionId = 'action-1',
|
||||
chatInput?: string,
|
||||
): ExecuteNodeResult<RequestResponseMetadata> => ({
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'HITL Node',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: actionId,
|
||||
metadata: { hitl: hitlMetadata },
|
||||
},
|
||||
data: createMockTaskData({ approved, chatInput }),
|
||||
});
|
||||
|
||||
const createNonHitlActionResponse = (
|
||||
actionId = 'action-2',
|
||||
): ExecuteNodeResult<RequestResponseMetadata> => ({
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'Regular Tool',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: actionId,
|
||||
metadata: {},
|
||||
},
|
||||
data: createMockTaskData({ result: 'success' }),
|
||||
});
|
||||
|
||||
describe('processHitlResponses', () => {
|
||||
const hitlMetadata = {
|
||||
gatedToolNodeName: 'Gated Tool Node',
|
||||
toolName: 'my_tool',
|
||||
originalInput: { query: 'test' },
|
||||
};
|
||||
|
||||
describe('empty/undefined responses', () => {
|
||||
it('returns empty result for undefined response', () => {
|
||||
const result = processHitlResponses(undefined, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty result for response with no action responses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-HITL responses', () => {
|
||||
it('passes through unchanged', () => {
|
||||
const actionResponse = createNonHitlActionResponse();
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [actionResponse],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
expect(result.processedResponse.actionResponses[0]).toEqual(actionResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved HITL responses', () => {
|
||||
it('creates pending gated tool request', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest).toBeDefined();
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
|
||||
|
||||
const action = result.pendingGatedToolRequest!.actions[0];
|
||||
expect(action.nodeName).toBe('Gated Tool Node');
|
||||
expect(action.input).toEqual({ query: 'test', tool: 'my_tool' });
|
||||
expect(action.id).toBe('action-1');
|
||||
expect(action.metadata?.parentNodeName).toBe('HITL Node');
|
||||
});
|
||||
|
||||
it('removes approved HITL response from processed responses', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles nested approval data format', () => {
|
||||
const actionResponse: ExecuteNodeResult<RequestResponseMetadata> = {
|
||||
action: {
|
||||
actionType: 'ExecutionNodeAction',
|
||||
nodeName: 'HITL Node',
|
||||
input: {},
|
||||
type: NodeConnectionTypes.AiTool,
|
||||
id: 'action-1',
|
||||
metadata: { hitl: hitlMetadata },
|
||||
},
|
||||
data: createMockTaskData({ data: { approved: true } }),
|
||||
};
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [actionResponse],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('denied HITL responses', () => {
|
||||
it('modifies response with denial message', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(false, hitlMetadata)],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
|
||||
const processedData = result.processedResponse.actionResponses[0].data?.data
|
||||
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
|
||||
expect(processedData.output).toMatch(/reject/i);
|
||||
});
|
||||
it('modifies response with denial message and chat input', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [createHitlActionResponse(false, hitlMetadata, 'action-1', 'chat input')],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
expect(result.hasApprovedHitlTools).toBe(false);
|
||||
expect(result.pendingGatedToolRequest).toBeUndefined();
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(1);
|
||||
|
||||
const processedData = result.processedResponse.actionResponses[0].data?.data
|
||||
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
|
||||
expect(processedData.output).toMatch(/chat input/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed responses', () => {
|
||||
it('processes HITL and non-HITL responses correctly', () => {
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
createNonHitlActionResponse('regular-1'),
|
||||
createHitlActionResponse(true, hitlMetadata, 'hitl-approved'),
|
||||
createHitlActionResponse(
|
||||
false,
|
||||
{ ...hitlMetadata, toolName: 'denied_tool' },
|
||||
'hitl-denied',
|
||||
),
|
||||
createNonHitlActionResponse('regular-2'),
|
||||
],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
|
||||
// 2 non-HITL + 1 denied HITL = 3 (approved HITL is removed)
|
||||
expect(result.processedResponse.actionResponses).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple approvals', () => {
|
||||
it('batches multiple gated tool actions', () => {
|
||||
const hitlMetadata2 = { ...hitlMetadata, gatedToolNodeName: 'Another Gated Tool' };
|
||||
const response: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
createHitlActionResponse(true, hitlMetadata, 'hitl-1'),
|
||||
createHitlActionResponse(true, hitlMetadata2, 'hitl-2'),
|
||||
],
|
||||
metadata: { previousRequests: [{ action: {} as never, observation: 'prev' }] },
|
||||
};
|
||||
|
||||
const result = processHitlResponses(response, 0);
|
||||
|
||||
expect(result.hasApprovedHitlTools).toBe(true);
|
||||
expect(result.pendingGatedToolRequest?.actions).toHaveLength(2);
|
||||
expect(result.pendingGatedToolRequest?.metadata?.previousRequests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { serializeIntermediateSteps } from '../serializeIntermediateSteps';
|
||||
|
||||
describe('serializeIntermediateSteps', () => {
|
||||
it('should convert class instances with toJSON to plain objects', () => {
|
||||
const fakeAIMessage = {
|
||||
content: 'I need to call a tool',
|
||||
tool_calls: [{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' }],
|
||||
additional_kwargs: {},
|
||||
response_metadata: { model: 'gpt-4' },
|
||||
id: 'msg_abc',
|
||||
name: undefined,
|
||||
toJSON() {
|
||||
return {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain_core', 'messages', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: this.content,
|
||||
tool_calls: this.tool_calls,
|
||||
additional_kwargs: this.additional_kwargs,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: { input: 'test' },
|
||||
log: 'Calling TestTool',
|
||||
messageLog: [fakeAIMessage],
|
||||
toolCallId: 'call_123',
|
||||
type: 'function',
|
||||
},
|
||||
observation: 'Tool result',
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps);
|
||||
|
||||
const serializedMsg = steps[0].action.messageLog[0] as Record<string, unknown>;
|
||||
|
||||
// Should be a plain object, not the original class instance
|
||||
expect(serializedMsg).not.toBe(fakeAIMessage);
|
||||
expect(typeof serializedMsg.toJSON).toBe('undefined');
|
||||
|
||||
// Direct property access should work
|
||||
expect(serializedMsg.content).toBe('I need to call a tool');
|
||||
expect(serializedMsg.tool_calls).toEqual([
|
||||
{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' },
|
||||
]);
|
||||
expect(serializedMsg.additional_kwargs).toEqual({});
|
||||
expect(serializedMsg.response_metadata).toEqual({ model: 'gpt-4' });
|
||||
expect(serializedMsg.id).toBe('msg_abc');
|
||||
});
|
||||
|
||||
it('should leave plain objects unchanged', () => {
|
||||
const plainMsg = {
|
||||
content: 'Hello',
|
||||
tool_calls: [],
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
messageLog: [plainMsg],
|
||||
toolCallId: 'call_1',
|
||||
type: 'function',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps);
|
||||
|
||||
// Should be the same reference since it has no toJSON
|
||||
expect(steps[0].action.messageLog[0]).toBe(plainMsg);
|
||||
});
|
||||
|
||||
it('should handle steps without messageLog', () => {
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
tool: 'TestTool',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
toolCallId: 'call_1',
|
||||
type: 'function',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
expect(() =>
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty steps array', () => {
|
||||
const steps: Array<{ action: { messageLog?: unknown[] } }> = [];
|
||||
expect(() => serializeIntermediateSteps(steps)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should include type from _getType when not an own property', () => {
|
||||
const proto = {
|
||||
_getType() {
|
||||
return 'ai';
|
||||
},
|
||||
};
|
||||
const fakeMsg = Object.create(proto) as Record<string, unknown>;
|
||||
fakeMsg.content = 'test';
|
||||
fakeMsg.toJSON = () => ({ lc: 1, type: 'constructor', kwargs: {} });
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
messageLog: [fakeMsg],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
|
||||
|
||||
const serialized = steps[0].action.messageLog[0] as Record<string, unknown>;
|
||||
expect(serialized.type).toBe('ai');
|
||||
expect(serialized.content).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle mixed messageLog entries', () => {
|
||||
const classInstance = {
|
||||
content: 'from class',
|
||||
toJSON() {
|
||||
return { kwargs: { content: this.content } };
|
||||
},
|
||||
};
|
||||
const plainObject = { content: 'from plain' };
|
||||
const primitiveValue = 'just a string';
|
||||
|
||||
const steps = [
|
||||
{
|
||||
action: {
|
||||
messageLog: [classInstance, plainObject, primitiveValue],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
|
||||
|
||||
const [serializedClass, unchangedPlain, unchangedPrimitive] = steps[0].action.messageLog;
|
||||
|
||||
// Class instance should be serialized
|
||||
expect(serializedClass).not.toBe(classInstance);
|
||||
expect((serializedClass as Record<string, unknown>).content).toBe('from class');
|
||||
expect(typeof (serializedClass as Record<string, unknown>).toJSON).toBe('undefined');
|
||||
|
||||
// Plain object should be unchanged
|
||||
expect(unchangedPlain).toBe(plainObject);
|
||||
|
||||
// Primitive should be unchanged
|
||||
expect(unchangedPrimitive).toBe(primitiveValue);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { AIMessage } from '@langchain/core/messages';
|
||||
import type { IDataObject, GenericValue } from 'n8n-workflow';
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
/**
|
||||
* Represents a tool call request from an LLM.
|
||||
* This is a generic format that can be used across different agent types.
|
||||
*/
|
||||
export type ToolCallRequest = {
|
||||
/** The name of the tool to call */
|
||||
tool: string;
|
||||
/** The input arguments for the tool */
|
||||
toolInput: Record<string, unknown>;
|
||||
/** Unique identifier for this tool call */
|
||||
toolCallId: string;
|
||||
/** Type of the tool call (e.g., 'tool_call', 'function') */
|
||||
type?: string;
|
||||
/** Log message or description */
|
||||
log?: string;
|
||||
/** Full message log including LLM response */
|
||||
messageLog?: unknown[];
|
||||
/** Additional kwargs from the LLM response (for Gemini thought signatures) */
|
||||
additionalKwargs?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a tool call action and its observation result.
|
||||
* Used for building agent steps and maintaining conversation context.
|
||||
*/
|
||||
export type ToolCallData = {
|
||||
action: {
|
||||
tool: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
log: string | number | true | object;
|
||||
messageLog?: AIMessage[];
|
||||
toolCallId: IDataObject | GenericValue | GenericValue[] | IDataObject[];
|
||||
type: string | number | true | object;
|
||||
};
|
||||
observation: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result from an agent execution, optionally including tool calls and intermediate steps.
|
||||
*/
|
||||
export type AgentResult = {
|
||||
/** The final output from the agent */
|
||||
output: string;
|
||||
/** Tool calls that need to be executed */
|
||||
toolCalls?: ToolCallRequest[];
|
||||
/** Intermediate steps showing the agent's reasoning */
|
||||
intermediateSteps?: ToolCallData[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Anthropic thinking content block
|
||||
*/
|
||||
export type ThinkingContentBlock = {
|
||||
type: 'thinking';
|
||||
thinking: string;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Anthropic redacted thinking content block
|
||||
*/
|
||||
export type RedactedThinkingContentBlock = {
|
||||
type: 'redacted_thinking';
|
||||
data: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Anthropic tool use content block
|
||||
*/
|
||||
export type ToolUseContentBlock = {
|
||||
type: 'tool_use';
|
||||
id: string;
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini thought signature content block
|
||||
*/
|
||||
export type GeminiThoughtSignatureBlock = {
|
||||
thoughtSignature: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type for all supported content blocks
|
||||
*/
|
||||
export type ContentBlock =
|
||||
| ThinkingContentBlock
|
||||
| RedactedThinkingContentBlock
|
||||
| ToolUseContentBlock
|
||||
| GeminiThoughtSignatureBlock;
|
||||
|
||||
/**
|
||||
* Google/Gemini-specific thinking metadata.
|
||||
*/
|
||||
export type GoogleThinkingMetadata = {
|
||||
/** Thought signature for Gemini extended thinking */
|
||||
thoughtSignature?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Anthropic-specific thinking metadata.
|
||||
*/
|
||||
export type AnthropicThinkingMetadata = {
|
||||
/** Thinking content from extended thinking mode */
|
||||
thinkingContent?: string;
|
||||
/** Type of thinking block (thinking or redacted_thinking) */
|
||||
thinkingType?: 'thinking' | 'redacted_thinking';
|
||||
/** Cryptographic signature for thinking blocks */
|
||||
thinkingSignature?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* HITL (Human-in-the-Loop) metadata - presence indicates this is an HITL tool action.
|
||||
*/
|
||||
export type HitlMetadata = {
|
||||
/** The gated tool node name that will be executed after approval */
|
||||
gatedToolNodeName: string;
|
||||
/** The tool name as seen by the LLM */
|
||||
toolName: string;
|
||||
/** Original input for the gated tool */
|
||||
originalInput: IDataObject;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thinking metadata extracted from LLM responses (Anthropic/Google extended thinking).
|
||||
*/
|
||||
export type ThinkingMetadata = {
|
||||
google?: GoogleThinkingMetadata;
|
||||
anthropic?: AnthropicThinkingMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for engine requests and responses.
|
||||
*/
|
||||
export type RequestResponseMetadata = {
|
||||
/** Item index being processed */
|
||||
itemIndex?: number;
|
||||
/** Custom parent node name for log tree structure (overrides default parent) */
|
||||
parentNodeName?: string;
|
||||
/** Previous tool call requests (for multi-turn conversations) */
|
||||
previousRequests?: ToolCallData[];
|
||||
/** Current iteration count (for max iterations enforcement) */
|
||||
iterationCount?: number;
|
||||
/** Google/Gemini-specific metadata */
|
||||
google?: GoogleThinkingMetadata;
|
||||
/** Anthropic-specific metadata */
|
||||
anthropic?: AnthropicThinkingMetadata;
|
||||
/** HITL (Human-in-the-Loop) metadata - presence indicates this is an HITL tool action */
|
||||
hitl?: HitlMetadata;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata attached to LangChain tools for tracking source nodes and HITL gating.
|
||||
* Extends Record<string, unknown> for compatibility with LangChain's Tool.metadata type.
|
||||
*/
|
||||
export interface ToolMetadata extends Record<string, unknown> {
|
||||
/** The n8n node name that provides this tool */
|
||||
sourceNodeName?: string;
|
||||
/** For HITL tools, the gated tool node that will be executed after approval */
|
||||
gatedToolNodeName?: string;
|
||||
/** The original schema of the tool */
|
||||
originalSchema?: ZodType;
|
||||
/** Whether this tool came from a toolkit (vs. a standalone tool node) */
|
||||
isFromToolkit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a block is a thinking content block
|
||||
*/
|
||||
export function isThinkingBlock(block: unknown): block is ThinkingContentBlock {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
block.type === 'thinking' &&
|
||||
'thinking' in block &&
|
||||
typeof block.thinking === 'string' &&
|
||||
'signature' in block &&
|
||||
typeof block.signature === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a block is a redacted thinking content block
|
||||
*/
|
||||
export function isRedactedThinkingBlock(block: unknown): block is RedactedThinkingContentBlock {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'type' in block &&
|
||||
block.type === 'redacted_thinking' &&
|
||||
'data' in block &&
|
||||
typeof block.data === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a block is a Gemini thought signature block
|
||||
*/
|
||||
export function isGeminiThoughtSignatureBlock(
|
||||
block: unknown,
|
||||
): block is GeminiThoughtSignatureBlock {
|
||||
return (
|
||||
typeof block === 'object' &&
|
||||
block !== null &&
|
||||
'thoughtSignature' in block &&
|
||||
typeof block.thoughtSignature === 'string'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ISupplyDataFunctions,
|
||||
IExecuteFunctions,
|
||||
} from 'n8n-workflow';
|
||||
import { isDomainAllowed, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Checks if the URL is allowed based on the allowed domains type and the allowed domains.
|
||||
* If the allowed domains type is 'domains', it checks if the URL is in the allowed domains.
|
||||
* If the allowed domains type is 'none', it checks if the URL is the same as the base URL in the credentials.
|
||||
* @param ctx - The context of the node.
|
||||
* @param credentials - The credentials of the node.
|
||||
* @param url - The URL to check.
|
||||
* @param credentialsUrlKey - The key of the base URL in the credentials.
|
||||
*/
|
||||
export const checkDomainRestrictions = (
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions,
|
||||
credentials: ICredentialDataDecryptedObject,
|
||||
url: string,
|
||||
credentialsUrlKey: string = 'url',
|
||||
): void => {
|
||||
const allowedDomainsType = credentials.allowedHttpRequestDomains;
|
||||
const restrictedMessage = `Domain not allowed: This credential is restricted from accessing ${url}. `;
|
||||
if (allowedDomainsType === 'domains') {
|
||||
const allowedDomains = credentials.allowedDomains as string;
|
||||
|
||||
if (!allowedDomains || allowedDomains.trim() === '') {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isDomainAllowed(url, { allowedDomains })) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
restrictedMessage + `Only the following domains are allowed: ${allowedDomains}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (allowedDomainsType === 'none' && credentials[credentialsUrlKey]) {
|
||||
if (url !== credentials[credentialsUrlKey]) {
|
||||
throw new NodeOperationError(ctx.getNode(), restrictedMessage);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import { buildInputSchemaField } from './descriptions';
|
||||
|
||||
describe('buildInputSchemaField', () => {
|
||||
it('should create input schema field with noDataExpression set to false', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
expect(result.noDataExpression).toBe(false);
|
||||
expect(result.displayName).toBe('Input Schema');
|
||||
expect(result.name).toBe('inputSchema');
|
||||
expect(result.type).toBe('json');
|
||||
});
|
||||
|
||||
it('should include typeOptions with rows set to 10', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
expect(result.typeOptions).toEqual({ rows: 10 });
|
||||
});
|
||||
|
||||
it('should have correct default JSON schema', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
const expectedDefault = `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"some_input": {
|
||||
"type": "string",
|
||||
"description": "Some input to the function"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
expect(result.default).toBe(expectedDefault);
|
||||
});
|
||||
|
||||
it('should include display options with schemaType manual', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
expect(result.displayOptions).toEqual({
|
||||
show: {
|
||||
schemaType: ['manual'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge showExtraProps when provided', () => {
|
||||
const result = buildInputSchemaField({
|
||||
showExtraProps: {
|
||||
mode: ['advanced'],
|
||||
authentication: ['oauth2'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.displayOptions).toEqual({
|
||||
show: {
|
||||
mode: ['advanced'],
|
||||
authentication: ['oauth2'],
|
||||
schemaType: ['manual'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include description and hint', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
expect(result.description).toBe('Schema to use for the function');
|
||||
expect(result.hint).toContain('JSON Schema');
|
||||
expect(result.hint).toContain('json-schema.org');
|
||||
});
|
||||
|
||||
it('should allow data expressions in the schema field', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
// noDataExpression is false, which means expressions are allowed
|
||||
expect(result.noDataExpression).toBe(false);
|
||||
|
||||
// Since noDataExpression is false, this should be valid
|
||||
expect(typeof result.default).toBe('string');
|
||||
expect(result.noDataExpression).toBe(false);
|
||||
});
|
||||
|
||||
it('should be a valid INodeProperties object', () => {
|
||||
const result = buildInputSchemaField();
|
||||
|
||||
// Check all required fields for INodeProperties
|
||||
expect(result).toHaveProperty('displayName');
|
||||
expect(result).toHaveProperty('name');
|
||||
expect(result).toHaveProperty('type');
|
||||
expect(result).toHaveProperty('default');
|
||||
|
||||
// Verify types
|
||||
expect(typeof result.displayName).toBe('string');
|
||||
expect(typeof result.name).toBe('string');
|
||||
expect(typeof result.type).toBe('string');
|
||||
expect(typeof result.default).toBe('string');
|
||||
});
|
||||
|
||||
it('should properly handle edge cases with showExtraProps', () => {
|
||||
// Empty showExtraProps
|
||||
const result1 = buildInputSchemaField({ showExtraProps: {} });
|
||||
expect(result1.displayOptions).toEqual({
|
||||
show: {
|
||||
schemaType: ['manual'],
|
||||
},
|
||||
});
|
||||
|
||||
// showExtraProps with undefined values
|
||||
const result2 = buildInputSchemaField({
|
||||
showExtraProps: {
|
||||
field1: undefined,
|
||||
field2: ['value2'],
|
||||
},
|
||||
});
|
||||
expect(result2.displayOptions).toEqual({
|
||||
show: {
|
||||
field1: undefined,
|
||||
field2: ['value2'],
|
||||
schemaType: ['manual'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { DisplayCondition, INodeProperties, NodeParameterValue } from 'n8n-workflow';
|
||||
|
||||
export const schemaTypeField: INodeProperties = {
|
||||
displayName: 'Schema Type',
|
||||
name: 'schemaType',
|
||||
type: 'options',
|
||||
noDataExpression: true,
|
||||
options: [
|
||||
{
|
||||
name: 'Generate From JSON Example',
|
||||
value: 'fromJson',
|
||||
description: 'Generate a schema from an example JSON object',
|
||||
},
|
||||
{
|
||||
name: 'Define using JSON Schema',
|
||||
value: 'manual',
|
||||
description: 'Define the JSON schema manually',
|
||||
},
|
||||
],
|
||||
default: 'fromJson',
|
||||
description: 'How to specify the schema for the function',
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a field for inputting a JSON example that can be used to generate the schema.
|
||||
* @param props
|
||||
*/
|
||||
export const buildJsonSchemaExampleField = (props?: {
|
||||
showExtraProps?: Record<string, Array<NodeParameterValue | DisplayCondition> | undefined>;
|
||||
}): INodeProperties => ({
|
||||
displayName: 'JSON Example',
|
||||
name: 'jsonSchemaExample',
|
||||
type: 'json',
|
||||
default: `{
|
||||
"some_input": "some_value"
|
||||
}`,
|
||||
noDataExpression: true,
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
...props?.showExtraProps,
|
||||
schemaType: ['fromJson'],
|
||||
},
|
||||
},
|
||||
description: 'Example JSON object to use to generate the schema',
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a notice field about the generated schema properties being required by default.
|
||||
* @param props
|
||||
*/
|
||||
export const buildJsonSchemaExampleNotice = (props?: {
|
||||
showExtraProps?: Record<string, Array<NodeParameterValue | DisplayCondition> | undefined>;
|
||||
}): INodeProperties => ({
|
||||
displayName:
|
||||
"All properties will be required. To make them optional, use the 'JSON Schema' schema type instead",
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
...props?.showExtraProps,
|
||||
schemaType: ['fromJson'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const jsonSchemaExampleField = buildJsonSchemaExampleField();
|
||||
|
||||
export const buildInputSchemaField = (props?: {
|
||||
showExtraProps?: Record<string, Array<NodeParameterValue | DisplayCondition> | undefined>;
|
||||
}): INodeProperties => ({
|
||||
displayName: 'Input Schema',
|
||||
name: 'inputSchema',
|
||||
type: 'json',
|
||||
default: `{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"some_input": {
|
||||
"type": "string",
|
||||
"description": "Some input to the function"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
noDataExpression: false,
|
||||
typeOptions: {
|
||||
rows: 10,
|
||||
},
|
||||
displayOptions: {
|
||||
show: {
|
||||
...props?.showExtraProps,
|
||||
schemaType: ['manual'],
|
||||
},
|
||||
},
|
||||
description: 'Schema to use for the function',
|
||||
hint: 'Use <a target="_blank" href="https://json-schema.org/">JSON Schema</a> format (<a target="_blank" href="https://json-schema.org/learn/miscellaneous-examples.html">examples</a>). $refs syntax is currently not supported.',
|
||||
});
|
||||
|
||||
export const inputSchemaField = buildInputSchemaField();
|
||||
|
||||
export const promptTypeOptionsDeprecated: INodeProperties = {
|
||||
displayName: 'Source for Prompt (User Message)',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Connected Chat Trigger Node',
|
||||
value: 'auto',
|
||||
description:
|
||||
"Looks for an input field called 'chatInput' that is coming from a directly connected Chat Trigger",
|
||||
},
|
||||
{
|
||||
name: 'Connected Guardrails Node',
|
||||
value: 'guardrails',
|
||||
description:
|
||||
"Looks for an input field called 'guardrailsInput' that is coming from a directly connected Guardrails Node",
|
||||
},
|
||||
{
|
||||
name: 'Define below',
|
||||
value: 'define',
|
||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
};
|
||||
|
||||
export const promptTypeOptions: INodeProperties = {
|
||||
displayName: 'Source for Prompt (User Message)',
|
||||
name: 'promptType',
|
||||
type: 'options',
|
||||
options: [
|
||||
{
|
||||
name: 'Connected Chat Trigger Node',
|
||||
value: 'auto',
|
||||
description:
|
||||
"Looks for an input field called 'chatInput' that is coming from a directly connected Chat Trigger",
|
||||
},
|
||||
{
|
||||
name: 'Define below',
|
||||
value: 'define',
|
||||
description: 'Use an expression to reference data in previous nodes or enter static text',
|
||||
},
|
||||
],
|
||||
default: 'auto',
|
||||
builderHint: {
|
||||
message: "Use 'auto' when following a chat trigger, 'define' when custom prompt needed",
|
||||
},
|
||||
};
|
||||
|
||||
export const textInput: INodeProperties = {
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '',
|
||||
placeholder: 'e.g. Hello, how can you help me?',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
builderHint: {
|
||||
placeholderSupported: false,
|
||||
message:
|
||||
'Use expressions to include dynamic data from previous nodes (e.g., "={{ $json.input }}"). Static text prompts ignore incoming data.',
|
||||
},
|
||||
};
|
||||
|
||||
export const textFromPreviousNode: INodeProperties = {
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.chatInput }}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
disabledOptions: { show: { promptType: ['auto'] } },
|
||||
};
|
||||
|
||||
export const textFromGuardrailsNode: INodeProperties = {
|
||||
displayName: 'Prompt (User Message)',
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
required: true,
|
||||
default: '={{ $json.guardrailsInput }}',
|
||||
typeOptions: {
|
||||
rows: 2,
|
||||
},
|
||||
disabledOptions: { show: { promptType: ['guardrails'] } },
|
||||
};
|
||||
|
||||
export const toolDescription: INodeProperties = {
|
||||
displayName: 'Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: 'AI Agent that can call other tools',
|
||||
required: true,
|
||||
typeOptions: { rows: 2 },
|
||||
description:
|
||||
'Explain to the LLM what this tool does, a good, specific description would allow LLMs to produce expected results much more often',
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { validateEmbedQueryInput, validateEmbedDocumentsInput } from '@n8n/ai-utilities';
|
||||
|
||||
const createMockNode = (): INode => ({
|
||||
id: 'test-node',
|
||||
name: 'Test Embeddings Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
describe('embeddingInputValidation', () => {
|
||||
describe('validateEmbedQueryInput', () => {
|
||||
const mockNode = createMockNode();
|
||||
|
||||
it('should throw NodeOperationError when query is undefined', () => {
|
||||
expect(() => validateEmbedQueryInput(undefined, mockNode)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when query is null', () => {
|
||||
expect(() => validateEmbedQueryInput(null, mockNode)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when query is empty string', () => {
|
||||
expect(() => validateEmbedQueryInput('', mockNode)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return query when valid string', () => {
|
||||
const query = 'valid search query';
|
||||
expect(validateEmbedQueryInput(query, mockNode)).toBe(query);
|
||||
});
|
||||
|
||||
it('should return query with whitespace (not trimmed)', () => {
|
||||
const query = ' query with spaces ';
|
||||
expect(validateEmbedQueryInput(query, mockNode)).toBe(query);
|
||||
});
|
||||
|
||||
it('should include helpful error message', () => {
|
||||
try {
|
||||
validateEmbedQueryInput(undefined, mockNode);
|
||||
fail('Expected error to be thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NodeOperationError);
|
||||
expect((e as NodeOperationError).message).toContain('empty or undefined text');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include description with possible causes', () => {
|
||||
try {
|
||||
validateEmbedQueryInput(undefined, mockNode);
|
||||
fail('Expected error to be thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NodeOperationError);
|
||||
const description = (e as NodeOperationError).description as string;
|
||||
expect(description).toContain('expression evaluates to undefined');
|
||||
expect(description).toContain('AI agent');
|
||||
expect(description).toContain('required field is missing');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmbedDocumentsInput', () => {
|
||||
const mockNode = createMockNode();
|
||||
|
||||
it('should throw NodeOperationError when documents is undefined', () => {
|
||||
expect(() => validateEmbedDocumentsInput(undefined, mockNode)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when documents is null', () => {
|
||||
expect(() => validateEmbedDocumentsInput(null, mockNode)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when documents is not an array', () => {
|
||||
expect(() => validateEmbedDocumentsInput('not an array', mockNode)).toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when any document is undefined', () => {
|
||||
expect(() =>
|
||||
validateEmbedDocumentsInput(['valid', undefined, 'also valid'], mockNode),
|
||||
).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when any document is null', () => {
|
||||
expect(() => validateEmbedDocumentsInput(['valid', null], mockNode)).toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NodeOperationError when any document is empty string', () => {
|
||||
expect(() => validateEmbedDocumentsInput(['valid', ''], mockNode)).toThrow(
|
||||
NodeOperationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return documents when all are valid strings', () => {
|
||||
const docs = ['document 1', 'document 2', 'document 3'];
|
||||
expect(validateEmbedDocumentsInput(docs, mockNode)).toBe(docs);
|
||||
});
|
||||
|
||||
it('should return empty array when given empty array', () => {
|
||||
const docs: string[] = [];
|
||||
expect(validateEmbedDocumentsInput(docs, mockNode)).toBe(docs);
|
||||
});
|
||||
|
||||
it('should include index of invalid document in error message', () => {
|
||||
try {
|
||||
validateEmbedDocumentsInput(['valid', 'also valid', undefined], mockNode);
|
||||
fail('Expected error to be thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NodeOperationError);
|
||||
expect((e as NodeOperationError).message).toContain('index 2');
|
||||
}
|
||||
});
|
||||
|
||||
it('should include helpful description in error', () => {
|
||||
try {
|
||||
validateEmbedDocumentsInput(['valid', null], mockNode);
|
||||
fail('Expected error to be thrown');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(NodeOperationError);
|
||||
expect((e as NodeOperationError).description).toContain('non-empty strings');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
const parentNodes =
|
||||
'getParentNodes' in ctx
|
||||
? ctx.getParentNodes(ctx.getNode().name, {
|
||||
connectionType: NodeConnectionTypes.AiTool,
|
||||
depth: 1,
|
||||
})
|
||||
: [];
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers';
|
||||
|
||||
export class N8nItemListOutputParser extends BaseOutputParser<string[]> {
|
||||
lc_namespace = ['n8n-nodes-langchain', 'output_parsers', 'list_items'];
|
||||
|
||||
private numberOfItems: number | undefined;
|
||||
|
||||
private separator: string;
|
||||
|
||||
constructor(options: { numberOfItems?: number; separator?: string }) {
|
||||
super();
|
||||
|
||||
const { numberOfItems = 3, separator = '\n' } = options;
|
||||
|
||||
if (numberOfItems && numberOfItems > 0) {
|
||||
this.numberOfItems = numberOfItems;
|
||||
}
|
||||
|
||||
this.separator = separator;
|
||||
|
||||
if (this.separator === '\\n') {
|
||||
this.separator = '\n';
|
||||
}
|
||||
}
|
||||
|
||||
async parse(text: string): Promise<string[]> {
|
||||
const response = text
|
||||
.split(this.separator)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item);
|
||||
|
||||
if (this.numberOfItems && response.length < this.numberOfItems) {
|
||||
// Only error if to few items got returned, if there are to many we can autofix it
|
||||
throw new OutputParserException(
|
||||
`Wrong number of items returned. Expected ${this.numberOfItems} items but got ${response.length} items instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.slice(0, this.numberOfItems);
|
||||
}
|
||||
|
||||
getFormatInstructions(): string {
|
||||
const instructions = `Your response should be a list of ${
|
||||
this.numberOfItems ? this.numberOfItems + ' ' : ''
|
||||
}items separated by`;
|
||||
|
||||
const numberOfExamples = this.numberOfItems ?? 3; // Default number of examples in case numberOfItems is not set
|
||||
|
||||
const examples: string[] = [];
|
||||
for (let i = 1; i <= numberOfExamples; i++) {
|
||||
examples.push(`item${i}`);
|
||||
}
|
||||
|
||||
return `${instructions} "${this.separator}" (for example: "${examples.join(this.separator)}")`;
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import type { BaseLanguageModel } from '@langchain/core/language_models/base';
|
||||
import type { AIMessage } from '@langchain/core/messages';
|
||||
import { BaseOutputParser, OutputParserException } from '@langchain/core/output_parsers';
|
||||
import type { PromptTemplate } from '@langchain/core/prompts';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
|
||||
import { logAiEvent } from '@n8n/ai-utilities';
|
||||
|
||||
export class N8nOutputFixingParser extends BaseOutputParser {
|
||||
lc_namespace = ['langchain', 'output_parsers', 'fix'];
|
||||
|
||||
constructor(
|
||||
private context: ISupplyDataFunctions,
|
||||
private model: BaseLanguageModel,
|
||||
private outputParser: N8nStructuredOutputParser,
|
||||
private fixPromptTemplate: PromptTemplate,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
getRetryChain() {
|
||||
return this.fixPromptTemplate.pipe(this.model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse the completion string using the output parser.
|
||||
* If the initial parse fails, it tries to fix the output using a retry chain.
|
||||
* @param completion The string to be parsed
|
||||
* @returns The parsed response
|
||||
* @throws Error if both parsing attempts fail
|
||||
*/
|
||||
async parse(completion: string, callbacks?: Callbacks) {
|
||||
const { index } = this.context.addInputData(NodeConnectionTypes.AiOutputParser, [
|
||||
[{ json: { action: 'parse', text: completion } }],
|
||||
]);
|
||||
|
||||
try {
|
||||
// First attempt to parse the completion
|
||||
const response = await this.outputParser.parse(completion, callbacks, (e) => {
|
||||
if (e instanceof OutputParserException) {
|
||||
return e;
|
||||
}
|
||||
return new OutputParserException(e.message, completion);
|
||||
});
|
||||
logAiEvent(this.context, 'ai-output-parsed', { text: completion, response });
|
||||
|
||||
this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [
|
||||
[{ json: { action: 'parse', response } }],
|
||||
]);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (!(error instanceof OutputParserException)) {
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
// Second attempt: use retry chain to fix the output
|
||||
const result = (await this.getRetryChain().invoke({
|
||||
completion,
|
||||
error: error.message,
|
||||
instructions: this.getFormatInstructions(),
|
||||
})) as AIMessage;
|
||||
|
||||
const resultText = result.content.toString();
|
||||
const parsed = await this.outputParser.parse(resultText, callbacks);
|
||||
|
||||
// Add the successfully parsed output to the context
|
||||
this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [
|
||||
[{ json: { action: 'parse', response: parsed } }],
|
||||
]);
|
||||
|
||||
return parsed;
|
||||
} catch (autoParseError) {
|
||||
// If both attempts fail, add the error to the output and throw
|
||||
this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, autoParseError);
|
||||
throw autoParseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to get the format instructions for the parser.
|
||||
* @returns The format instructions for the parser.
|
||||
*/
|
||||
getFormatInstructions() {
|
||||
return this.outputParser.getFormatInstructions();
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return this.outputParser.schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { getOptionalOutputParser } from './N8nOutputParser';
|
||||
import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
|
||||
|
||||
describe('getOptionalOutputParser', () => {
|
||||
let mockContext: jest.Mocked<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<IExecuteFunctions>();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined when hasOutputParser is false', async () => {
|
||||
mockContext.getNodeParameter.mockReturnValue(false);
|
||||
|
||||
const result = await getOptionalOutputParser(mockContext);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
|
||||
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return output parser when hasOutputParser is true with default index', async () => {
|
||||
const mockParser = mock<N8nStructuredOutputParser>();
|
||||
mockContext.getNodeParameter.mockReturnValue(true);
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockParser);
|
||||
|
||||
const result = await getOptionalOutputParser(mockContext);
|
||||
|
||||
expect(result).toBe(mockParser);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
|
||||
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use provided index when fetching output parser', async () => {
|
||||
const mockParser = mock<N8nStructuredOutputParser>();
|
||||
mockContext.getNodeParameter.mockReturnValue(true);
|
||||
mockContext.getInputConnectionData.mockResolvedValue(mockParser);
|
||||
|
||||
const result = await getOptionalOutputParser(mockContext, 2);
|
||||
|
||||
expect(result).toBe(mockParser);
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
|
||||
expect(mockContext.getInputConnectionData).toHaveBeenCalledWith(
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle different index values correctly', async () => {
|
||||
const mockParser1 = mock<N8nStructuredOutputParser>();
|
||||
const mockParser2 = mock<N8nStructuredOutputParser>();
|
||||
const mockParser3 = mock<N8nStructuredOutputParser>();
|
||||
|
||||
mockContext.getNodeParameter.mockReturnValue(true);
|
||||
mockContext.getInputConnectionData
|
||||
.mockResolvedValueOnce(mockParser1)
|
||||
.mockResolvedValueOnce(mockParser2)
|
||||
.mockResolvedValueOnce(mockParser3);
|
||||
|
||||
const result1 = await getOptionalOutputParser(mockContext, 0);
|
||||
const result2 = await getOptionalOutputParser(mockContext, 1);
|
||||
const result3 = await getOptionalOutputParser(mockContext, 5);
|
||||
|
||||
expect(result1).toBe(mockParser1);
|
||||
expect(result2).toBe(mockParser2);
|
||||
expect(result3).toBe(mockParser3);
|
||||
|
||||
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
0,
|
||||
);
|
||||
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
1,
|
||||
);
|
||||
expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('should always check hasOutputParser at index 0', async () => {
|
||||
mockContext.getNodeParameter.mockReturnValue(false);
|
||||
|
||||
await getOptionalOutputParser(mockContext, 3);
|
||||
|
||||
// Even when called with index 3, hasOutputParser is checked at index 0
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true);
|
||||
expect(mockContext.getInputConnectionData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { IExecuteFunctions, ISupplyDataFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { N8nItemListOutputParser } from './N8nItemListOutputParser';
|
||||
import { N8nOutputFixingParser } from './N8nOutputFixingParser';
|
||||
import { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
|
||||
|
||||
export type N8nOutputParser =
|
||||
| N8nOutputFixingParser
|
||||
| N8nStructuredOutputParser
|
||||
| N8nItemListOutputParser;
|
||||
|
||||
export { N8nOutputFixingParser, N8nItemListOutputParser, N8nStructuredOutputParser };
|
||||
|
||||
export async function getOptionalOutputParser(
|
||||
ctx: IExecuteFunctions | ISupplyDataFunctions | IWebhookFunctions,
|
||||
index: number = 0,
|
||||
): Promise<N8nOutputParser | undefined> {
|
||||
let outputParser: N8nOutputParser | undefined;
|
||||
|
||||
if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) {
|
||||
outputParser = (await ctx.getInputConnectionData(
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
index,
|
||||
)) as N8nOutputParser;
|
||||
}
|
||||
|
||||
return outputParser;
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { INode, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { N8nStructuredOutputParser } from './N8nStructuredOutputParser';
|
||||
|
||||
describe('N8nStructuredOutputParser', () => {
|
||||
let mockContext: jest.Mocked<ISupplyDataFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = mock<ISupplyDataFunctions>();
|
||||
mockContext.addInputData.mockReturnValue({ index: 0 });
|
||||
mockContext.addOutputData.mockReturnValue(undefined);
|
||||
mockContext.getNode.mockReturnValue({
|
||||
name: 'Test Node',
|
||||
type: 'n8n-nodes-langchain.outputParserStructured',
|
||||
typeVersion: 1.3,
|
||||
position: [0, 0],
|
||||
} as INode);
|
||||
});
|
||||
|
||||
// Bug AI-1852
|
||||
describe('Backticks in JSON string values', () => {
|
||||
it('should parse JSON containing markdown code blocks in string values', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
status: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
// This is the exact problematic output from the bug report
|
||||
// Valid JSON wrapped in code fence, but contains backticks INSIDE the message field
|
||||
const problematicOutput = `\`\`\`json
|
||||
{
|
||||
"output": {
|
||||
"message": "## Example\\n\`\`\`bash\\n--set globals.enable=false\\n\`\`\`\\n",
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(problematicOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
message: '## Example\n```bash\n--set globals.enable=false\n```\n',
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON containing multiple code blocks in string values', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const multipleCodeBlocksOutput = `\`\`\`json
|
||||
{
|
||||
"output": {
|
||||
"content": "First example:\\n\`\`\`javascript\\nconst x = 1;\\n\`\`\`\\n\\nSecond example:\\n\`\`\`python\\nprint('hello')\\n\`\`\`"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(multipleCodeBlocksOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
content:
|
||||
"First example:\n```javascript\nconst x = 1;\n```\n\nSecond example:\n```python\nprint('hello')\n```",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse nested markdown in complex JSON structures', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
steps: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const complexOutput = `\`\`\`json
|
||||
{
|
||||
"output": {
|
||||
"steps": [
|
||||
{
|
||||
"title": "Step 1",
|
||||
"description": "Run this command:\\n\`\`\`bash\\nnpm install\\n\`\`\`",
|
||||
"code": "npm install"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(complexOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
steps: [
|
||||
{
|
||||
title: 'Step 1',
|
||||
description: 'Run this command:\n```bash\nnpm install\n```',
|
||||
code: 'npm install',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Valid JSON parsing', () => {
|
||||
it('should parse valid JSON without code fence', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
status: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = `{
|
||||
"output": {
|
||||
"message": "Simple message",
|
||||
"status": "completed"
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(validOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
message: 'Simple message',
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse valid JSON wrapped in code fence without internal backticks', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
status: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = `\`\`\`json
|
||||
{
|
||||
"output": {
|
||||
"message": "Simple message",
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(validOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
message: 'Simple message',
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse JSON with escaped quotes and newlines', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = `{
|
||||
"output": {
|
||||
"message": "Line 1\\nLine 2\\n\\"quoted\\""
|
||||
}
|
||||
}`;
|
||||
|
||||
const result = await parser.parse(validOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
message: 'Line 1\nLine 2\n"quoted"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle code fence with json language marker', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
data: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = `\`\`\`json
|
||||
{
|
||||
"output": {
|
||||
"data": "test"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(validOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
data: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle code fence without language marker', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
data: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = `\`\`\`
|
||||
{
|
||||
"output": {
|
||||
"data": "test"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = await parser.parse(validOutput);
|
||||
|
||||
expect(result).toEqual({
|
||||
output: {
|
||||
data: 'test',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle invalid JSON gracefully', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const invalidOutput = 'not valid json';
|
||||
|
||||
await expect(parser.parse(invalidOutput)).rejects.toThrow(
|
||||
"Model output doesn't fit required format",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty output', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const emptyOutput = '{}';
|
||||
|
||||
await expect(parser.parse(emptyOutput)).rejects.toThrow(
|
||||
"Model output doesn't fit required format",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle schema mismatch', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
requiredField: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const mismatchOutput = `{
|
||||
"output": {
|
||||
"message": "Test"
|
||||
}
|
||||
}`;
|
||||
|
||||
await expect(parser.parse(mismatchOutput)).rejects.toThrow(
|
||||
"Model output doesn't fit required format",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context integration', () => {
|
||||
it('should call addInputData with correct parameters', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = '{"output": {"message": "test"}}';
|
||||
|
||||
await parser.parse(validOutput);
|
||||
|
||||
expect(mockContext.addInputData).toHaveBeenCalledWith(NodeConnectionTypes.AiOutputParser, [
|
||||
[{ json: { action: 'parse', text: validOutput } }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call addOutputData with parsed result', async () => {
|
||||
const schema = z.object({
|
||||
output: z.object({
|
||||
message: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const parser = new N8nStructuredOutputParser(mockContext, schema);
|
||||
|
||||
const validOutput = '{"output": {"message": "test"}}';
|
||||
|
||||
await parser.parse(validOutput);
|
||||
|
||||
expect(mockContext.addOutputData).toHaveBeenCalledWith(
|
||||
NodeConnectionTypes.AiOutputParser,
|
||||
0,
|
||||
[[{ json: { action: 'parse', response: { output: { message: 'test' } } } }]],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { Callbacks } from '@langchain/core/callbacks/manager';
|
||||
import { StructuredOutputParser } from '@langchain/classic/output_parsers';
|
||||
import get from 'lodash/get';
|
||||
import type { ISupplyDataFunctions } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { logAiEvent } from '@n8n/ai-utilities';
|
||||
import { unwrapNestedOutput } from '../helpers';
|
||||
|
||||
const STRUCTURED_OUTPUT_KEY = '__structured__output';
|
||||
const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object';
|
||||
const STRUCTURED_OUTPUT_ARRAY_KEY = '__structured__output__array';
|
||||
|
||||
export class N8nStructuredOutputParser extends StructuredOutputParser<
|
||||
z.ZodType<object, z.ZodTypeDef, object>
|
||||
> {
|
||||
constructor(
|
||||
private context: ISupplyDataFunctions,
|
||||
zodSchema: z.ZodSchema<object>,
|
||||
) {
|
||||
super(zodSchema);
|
||||
}
|
||||
|
||||
lc_namespace = ['langchain', 'output_parsers', 'structured'];
|
||||
|
||||
async parse(
|
||||
text: string,
|
||||
_callbacks?: Callbacks,
|
||||
errorMapper?: (error: Error) => Error,
|
||||
): Promise<object> {
|
||||
const { index } = this.context.addInputData(NodeConnectionTypes.AiOutputParser, [
|
||||
[{ json: { action: 'parse', text } }],
|
||||
]);
|
||||
|
||||
try {
|
||||
// Extract JSON from markdown code fence if present
|
||||
// Use line-based approach to avoid matching backticks inside JSON content
|
||||
let jsonString = text.trim();
|
||||
|
||||
// Look for markdown code fence by finding lines that start with ```
|
||||
const lines = jsonString.split('\n');
|
||||
let fenceStartIndex = -1;
|
||||
let fenceEndIndex = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmedLine = lines[i].trim();
|
||||
// Opening fence: line starting with ``` optionally followed by language identifier
|
||||
if (fenceStartIndex === -1 && trimmedLine.match(/^```(?:json)?$/)) {
|
||||
fenceStartIndex = i;
|
||||
} else if (fenceStartIndex !== -1 && trimmedLine === '```') {
|
||||
// Closing fence: line with just ```
|
||||
fenceEndIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found both opening and closing fences, extract the content between them
|
||||
if (fenceStartIndex !== -1 && fenceEndIndex !== -1) {
|
||||
jsonString = lines.slice(fenceStartIndex + 1, fenceEndIndex).join('\n');
|
||||
}
|
||||
|
||||
const json = JSON.parse(jsonString.trim());
|
||||
const parsed = await this.schema.parseAsync(json);
|
||||
|
||||
let result = (get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_OBJECT_KEY]) ??
|
||||
get(parsed, [STRUCTURED_OUTPUT_KEY, STRUCTURED_OUTPUT_ARRAY_KEY]) ??
|
||||
get(parsed, STRUCTURED_OUTPUT_KEY) ??
|
||||
parsed) as Record<string, unknown>;
|
||||
|
||||
// Unwrap any doubly-nested output structures (e.g., {output: {output: {...}}})
|
||||
result = unwrapNestedOutput(result);
|
||||
|
||||
logAiEvent(this.context, 'ai-output-parsed', { text, response: result });
|
||||
|
||||
this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, [
|
||||
[{ json: { action: 'parse', response: result } }],
|
||||
]);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
const nodeError = new NodeOperationError(
|
||||
this.context.getNode(),
|
||||
"Model output doesn't fit required format",
|
||||
{
|
||||
description:
|
||||
"To continue the execution when this happens, change the 'On Error' parameter in the root node's settings",
|
||||
},
|
||||
);
|
||||
|
||||
// Add additional context to the error
|
||||
if (e instanceof SyntaxError) {
|
||||
nodeError.context.outputParserFailReason = 'Invalid JSON in model output';
|
||||
} else if (
|
||||
(typeof text === 'string' && text.trim() === '{}') ||
|
||||
(e instanceof z.ZodError &&
|
||||
e.issues?.[0] &&
|
||||
e.issues?.[0].code === 'invalid_type' &&
|
||||
e.issues?.[0].path?.[0] === 'output' &&
|
||||
e.issues?.[0].expected === 'object' &&
|
||||
e.issues?.[0].received === 'undefined')
|
||||
) {
|
||||
nodeError.context.outputParserFailReason = 'Model output wrapper is an empty object';
|
||||
} else if (e instanceof z.ZodError) {
|
||||
nodeError.context.outputParserFailReason =
|
||||
'Model output does not match the expected schema';
|
||||
}
|
||||
|
||||
logAiEvent(this.context, 'ai-output-parsed', {
|
||||
text,
|
||||
response: e.message ?? e,
|
||||
});
|
||||
|
||||
this.context.addOutputData(NodeConnectionTypes.AiOutputParser, index, nodeError);
|
||||
if (errorMapper) {
|
||||
throw errorMapper(e);
|
||||
}
|
||||
|
||||
throw nodeError;
|
||||
}
|
||||
}
|
||||
|
||||
static async fromZodJsonSchema(
|
||||
zodSchema: z.ZodSchema<object>,
|
||||
nodeVersion: number,
|
||||
context: ISupplyDataFunctions,
|
||||
): Promise<N8nStructuredOutputParser> {
|
||||
let returnSchema: z.ZodType<object, z.ZodTypeDef, object>;
|
||||
if (nodeVersion === 1) {
|
||||
returnSchema = z.object({
|
||||
[STRUCTURED_OUTPUT_KEY]: z
|
||||
.object({
|
||||
[STRUCTURED_OUTPUT_OBJECT_KEY]: zodSchema.optional(),
|
||||
[STRUCTURED_OUTPUT_ARRAY_KEY]: z.array(zodSchema).optional(),
|
||||
})
|
||||
.describe(
|
||||
`Wrapper around the output data. It can only contain ${STRUCTURED_OUTPUT_OBJECT_KEY} or ${STRUCTURED_OUTPUT_ARRAY_KEY} but never both.`,
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// Validate that one and only one of the properties exists
|
||||
return (
|
||||
Boolean(data[STRUCTURED_OUTPUT_OBJECT_KEY]) !==
|
||||
Boolean(data[STRUCTURED_OUTPUT_ARRAY_KEY])
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
'One and only one of __structured__output__object and __structured__output__array should be present.',
|
||||
path: [STRUCTURED_OUTPUT_KEY],
|
||||
},
|
||||
),
|
||||
});
|
||||
} else if (nodeVersion < 1.3) {
|
||||
returnSchema = z.object({
|
||||
output: zodSchema.optional(),
|
||||
});
|
||||
} else {
|
||||
returnSchema = z.object({
|
||||
output: zodSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return new N8nStructuredOutputParser(context, returnSchema);
|
||||
}
|
||||
|
||||
getSchema() {
|
||||
return this.schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PromptTemplate } from '@langchain/core/prompts';
|
||||
|
||||
export const NAIVE_FIX_TEMPLATE = `Instructions:
|
||||
--------------
|
||||
{instructions}
|
||||
--------------
|
||||
Completion:
|
||||
--------------
|
||||
{completion}
|
||||
--------------
|
||||
|
||||
Above, the Completion did not satisfy the constraints given in the Instructions.
|
||||
Error:
|
||||
--------------
|
||||
{error}
|
||||
--------------
|
||||
|
||||
Please try again. Please only respond with an answer that satisfies the constraints laid out in the Instructions:`;
|
||||
|
||||
export const NAIVE_FIX_PROMPT = PromptTemplate.fromTemplate(NAIVE_FIX_TEMPLATE);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { jsonSchemaToZod } from '@n8n/json-schema-to-zod';
|
||||
import { json as generateJsonSchema } from 'generate-schema';
|
||||
import type { SchemaObject } from 'generate-schema';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import type { IExecuteFunctions } from 'n8n-workflow';
|
||||
import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
import type { z } from 'zod';
|
||||
|
||||
function makeAllPropertiesRequired(schema: JSONSchema7): JSONSchema7 {
|
||||
function isPropertySchema(property: unknown): property is JSONSchema7 {
|
||||
return typeof property === 'object' && property !== null && 'type' in property;
|
||||
}
|
||||
|
||||
// Handle object properties
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
const properties = Object.keys(schema.properties);
|
||||
if (properties.length > 0) {
|
||||
schema.required = properties;
|
||||
}
|
||||
|
||||
for (const key of properties) {
|
||||
if (isPropertySchema(schema.properties[key])) {
|
||||
makeAllPropertiesRequired(schema.properties[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (schema.type === 'array' && schema.items && isPropertySchema(schema.items)) {
|
||||
schema.items = makeAllPropertiesRequired(schema.items);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function generateSchemaFromExample(
|
||||
exampleJsonString: string,
|
||||
allFieldsRequired = false,
|
||||
): JSONSchema7 {
|
||||
const parsedExample = jsonParse<SchemaObject>(exampleJsonString);
|
||||
|
||||
const schema = generateJsonSchema(parsedExample) as JSONSchema7;
|
||||
|
||||
if (allFieldsRequired) {
|
||||
return makeAllPropertiesRequired(schema);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function convertJsonSchemaToZod<T extends z.ZodTypeAny = z.ZodTypeAny>(schema: JSONSchema7) {
|
||||
return jsonSchemaToZod<T>(schema);
|
||||
}
|
||||
|
||||
export function throwIfToolSchema(ctx: IExecuteFunctions, error: Error) {
|
||||
if (error?.message?.includes('tool input did not match expected schema')) {
|
||||
throw new NodeOperationError(
|
||||
ctx.getNode(),
|
||||
`${error.message}.
|
||||
This is most likely because some of your tools are configured to require a specific schema. This is not supported by Conversational Agent. Remove the schema from the tool configuration or use Tools agent instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
IExecuteFunctions,
|
||||
INode,
|
||||
ISupplyDataFunctions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { checkDomainRestrictions } from '../checkDomainRestrictions';
|
||||
|
||||
describe('checkDomainRestrictions', () => {
|
||||
let mockNode: INode;
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
let mockSupplyDataFunctions: ISupplyDataFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
|
||||
mockSupplyDataFunctions = mockExecuteFunctions as unknown as ISupplyDataFunctions;
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is "domains"', () => {
|
||||
it('should throw error when allowedDomains is empty', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: '',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when allowedDomains is whitespace only', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: ' ',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when allowedDomains is undefined', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).toThrow(
|
||||
'No allowed domains specified. Configure allowed domains or change restriction setting.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when URL is not in allowed domains', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com,test.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://notallowed.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://notallowed.com');
|
||||
}).toThrow(
|
||||
'Domain not allowed: This credential is restricted from accessing https://notallowed.com. Only the following domains are allowed: example.com,test.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw error when URL is in allowed domains (exact match)', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when URL is in allowed domains (comma-separated list)', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com,test.com,another.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://test.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when URL matches wildcard domain', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: '*.example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://sub.example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with IExecuteFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with ISupplyDataFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockSupplyDataFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is "none"', () => {
|
||||
it('should not throw error when URL matches credentials URL', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when URL does not match credentials URL', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://different.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://different.com');
|
||||
}).toThrow(
|
||||
'Domain not allowed: This credential is restricted from accessing https://different.com. ',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw error when credentials URL key does not exist', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should use custom credentialsUrlKey parameter', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
baseUrl: 'https://custom.example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://custom.example.com',
|
||||
'baseUrl',
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://different.com',
|
||||
'baseUrl',
|
||||
);
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should work with IExecuteFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with ISupplyDataFunctions context', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockSupplyDataFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when allowedDomainsType is undefined or other value', () => {
|
||||
it('should not throw error when allowedDomainsType is undefined', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when allowedDomainsType is empty string', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: '',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw error when allowedDomainsType is other value', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'other' as any,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://any-url.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle URLs with paths and query parameters', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(
|
||||
mockExecuteFunctions,
|
||||
credentials,
|
||||
'https://example.com/api/v1/endpoint?param=value',
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle URLs with ports', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com:8080');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle case-insensitive domain matching', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'EXAMPLE.COM',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle domains with trailing dots', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com.',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle multiple domains with spaces in allowedDomains', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'domains',
|
||||
allowedDomains: 'example.com, test.com , another.com',
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://test.com');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle exact URL match for "none" type with different protocols', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
// Should throw because protocol is different
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'http://example.com');
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should handle exact URL match for "none" type with different paths', () => {
|
||||
const credentials: ICredentialDataDecryptedObject = {
|
||||
allowedHttpRequestDomains: 'none',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
// Should throw because path is different
|
||||
expect(() => {
|
||||
checkDomainRestrictions(mockExecuteFunctions, credentials, 'https://example.com/path');
|
||||
}).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,601 @@
|
||||
import { DynamicTool, type Tool } from '@langchain/core/tools';
|
||||
import { StructuredToolkit } from 'n8n-core';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
escapeSingleCurlyBrackets,
|
||||
getConnectedTools,
|
||||
mergeCustomHeaders,
|
||||
unwrapNestedOutput,
|
||||
getSessionId,
|
||||
} from '../helpers';
|
||||
import { N8nTool } from '../N8nTool';
|
||||
|
||||
describe('escapeSingleCurlyBrackets', () => {
|
||||
it('should return undefined when input is undefined', () => {
|
||||
expect(escapeSingleCurlyBrackets(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should escape single curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {world}')).toBe('Hello {{world}}');
|
||||
expect(escapeSingleCurlyBrackets('Test {value} here')).toBe('Test {{value}} here');
|
||||
});
|
||||
|
||||
it('should not escape already double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{world}}')).toBe('Hello {{world}}');
|
||||
expect(escapeSingleCurlyBrackets('Test {{value}} here')).toBe('Test {{value}} here');
|
||||
});
|
||||
|
||||
it('should handle mixed single and double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{world}} and {earth}')).toBe(
|
||||
'Hello {{world}} and {{earth}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(escapeSingleCurlyBrackets('')).toBe('');
|
||||
});
|
||||
it('should handle string with no curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('should handle string with only opening curly bracket', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello { world')).toBe('Hello {{ world');
|
||||
});
|
||||
|
||||
it('should handle string with only closing curly bracket', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world }')).toBe('Hello world }}');
|
||||
});
|
||||
|
||||
it('should handle string with multiple single curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('{Hello} {world}')).toBe('{{Hello}} {{world}}');
|
||||
});
|
||||
|
||||
it('should handle string with alternating single and double curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('{a} {{b}} {c} {{d}}')).toBe('{{a}} {{b}} {{c}} {{d}}');
|
||||
});
|
||||
|
||||
it('should handle string with curly brackets at the start and end', () => {
|
||||
expect(escapeSingleCurlyBrackets('{start} middle {end}')).toBe('{{start}} middle {{end}}');
|
||||
});
|
||||
|
||||
it('should handle string with special characters', () => {
|
||||
expect(escapeSingleCurlyBrackets('Special {!@#$%^&*} chars')).toBe(
|
||||
'Special {{!@#$%^&*}} chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string with numbers in curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Numbers {123} here')).toBe('Numbers {{123}} here');
|
||||
});
|
||||
|
||||
it('should handle string with whitespace in curly brackets', () => {
|
||||
expect(escapeSingleCurlyBrackets('Whitespace { } here')).toBe('Whitespace {{ }} here');
|
||||
});
|
||||
it('should handle multi-line input with single curly brackets', () => {
|
||||
const input = `
|
||||
Line 1 {test}
|
||||
Line 2 {another test}
|
||||
Line 3
|
||||
`;
|
||||
const expected = `
|
||||
Line 1 {{test}}
|
||||
Line 2 {{another test}}
|
||||
Line 3
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with mixed single and double curly brackets', () => {
|
||||
const input = `
|
||||
{Line 1}
|
||||
{{Line 2}}
|
||||
Line {3} {{4}}
|
||||
`;
|
||||
const expected = `
|
||||
{{Line 1}}
|
||||
{{Line 2}}
|
||||
Line {{3}} {{4}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with curly brackets at line starts and ends', () => {
|
||||
const input = `
|
||||
{Start of line 1
|
||||
End of line 2}
|
||||
{3} Line 3 {3}
|
||||
`;
|
||||
const expected = `
|
||||
{{Start of line 1
|
||||
End of line 2}}
|
||||
{{3}} Line 3 {{3}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multi-line input with nested curly brackets', () => {
|
||||
const input = `
|
||||
Outer {
|
||||
Inner {nested}
|
||||
}
|
||||
`;
|
||||
const expected = `
|
||||
Outer {{
|
||||
Inner {{nested}}
|
||||
}}
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
it('should handle string with triple uneven curly brackets - opening', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello {{{world}')).toBe('Hello {{{{world}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - closing', () => {
|
||||
expect(escapeSingleCurlyBrackets('Hello world}}}')).toBe('Hello world}}}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - mixed opening and closing', () => {
|
||||
expect(escapeSingleCurlyBrackets('{{{Hello}}} {world}}}')).toBe('{{{{Hello}}}} {{world}}}}');
|
||||
});
|
||||
|
||||
it('should handle string with triple uneven curly brackets - multiple occurrences', () => {
|
||||
expect(escapeSingleCurlyBrackets('{{{a}}} {{b}}} {{{c}')).toBe('{{{{a}}}} {{b}}}} {{{{c}}');
|
||||
});
|
||||
|
||||
it('should handle multi-line input with triple uneven curly brackets', () => {
|
||||
const input = `
|
||||
{{{Line 1}
|
||||
Line 2}}}
|
||||
{{{3}}} Line 3 {{{4
|
||||
`;
|
||||
const expected = `
|
||||
{{{{Line 1}}
|
||||
Line 2}}}}
|
||||
{{{{3}}}} Line 3 {{{{4
|
||||
`;
|
||||
expect(escapeSingleCurlyBrackets(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConnectedTools', () => {
|
||||
let mockExecuteFunctions: IExecuteFunctions;
|
||||
let mockNode: INode;
|
||||
let mockN8nTool: N8nTool;
|
||||
|
||||
beforeEach(() => {
|
||||
mockNode = {
|
||||
id: 'test-node',
|
||||
name: 'Test Node',
|
||||
type: 'test',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
mockExecuteFunctions = createMockExecuteFunction({}, mockNode);
|
||||
// Add getParentNodes mock for metadata functionality
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue([]);
|
||||
|
||||
mockN8nTool = new N8nTool(mockExecuteFunctions as unknown as ISupplyDataFunctions, {
|
||||
name: 'Dummy Tool',
|
||||
description: 'A dummy tool for testing',
|
||||
func: jest.fn(),
|
||||
schema: z.object({
|
||||
foo: z.string(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no tools are connected', async () => {
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true);
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return tools without modification when enforceUniqueNames is false', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
{ name: 'tool1', description: 'desc2' }, // Duplicate name
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
expect(tools).toEqual(mockTools);
|
||||
});
|
||||
|
||||
it('should throw error when duplicate tool names exist and enforceUniqueNames is true', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
{ name: 'tool1', description: 'desc2' },
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
await expect(getConnectedTools(mockExecuteFunctions, true)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should escape curly brackets in tool descriptions when escapeCurlyBrackets is true', async () => {
|
||||
const mockTools = [{ name: 'tool1', description: 'Test {value}' }] as Tool[];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, false, true);
|
||||
expect(tools[0].description).toBe('Test {{value}}');
|
||||
});
|
||||
|
||||
it('should convert N8nTool to dynamic tool when convertStructuredTool is true', async () => {
|
||||
const mockDynamicTool = new DynamicTool({
|
||||
name: 'dynamicTool',
|
||||
description: 'desc',
|
||||
func: jest.fn(),
|
||||
});
|
||||
const asDynamicToolSpy = jest.fn().mockReturnValue(mockDynamicTool);
|
||||
mockN8nTool.asDynamicTool = asDynamicToolSpy;
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, true);
|
||||
expect(asDynamicToolSpy).toHaveBeenCalled();
|
||||
expect(tools[0]).toEqual(mockDynamicTool);
|
||||
});
|
||||
|
||||
it('should not convert N8nTool when convertStructuredTool is false', async () => {
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue([mockN8nTool]);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, true, false);
|
||||
expect(tools[0]).toBe(mockN8nTool);
|
||||
});
|
||||
|
||||
it('should flatten tools from a toolkit', async () => {
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
|
||||
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
expect(tools).toEqual([
|
||||
{
|
||||
name: 'tool1',
|
||||
description: 'desc1',
|
||||
metadata: { isFromToolkit: false, sourceNodeName: undefined },
|
||||
},
|
||||
{
|
||||
name: 'toolkitTool1',
|
||||
description: 'toolkitToolDesc1',
|
||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
||||
},
|
||||
{
|
||||
name: 'toolkitTool2',
|
||||
description: 'toolkitToolDesc2',
|
||||
metadata: { isFromToolkit: true, sourceNodeName: undefined },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should add metadata to all tools with source node information', async () => {
|
||||
const mockParentNodes = [{ name: 'RegularTool' }, { name: 'MCP Client Tool' }];
|
||||
const mockTools = [
|
||||
{ name: 'tool1', description: 'desc1' },
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'toolkitToolDesc1' },
|
||||
{ name: 'toolkitTool2', description: 'toolkitToolDesc2' },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue(mockParentNodes);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
|
||||
expect(tools).toHaveLength(3);
|
||||
|
||||
// Regular tool should have metadata with isFromToolkit: false
|
||||
expect(tools[0].name).toBe('tool1');
|
||||
expect(tools[0].metadata).toEqual({
|
||||
isFromToolkit: false,
|
||||
sourceNodeName: 'RegularTool',
|
||||
});
|
||||
|
||||
// Toolkit tools should have metadata with isFromToolkit: true
|
||||
expect(tools[1].name).toBe('toolkitTool1');
|
||||
expect(tools[1].metadata).toEqual({
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
|
||||
expect(tools[2].name).toBe('toolkitTool2');
|
||||
expect(tools[2].metadata).toEqual({
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing metadata when adding toolkit metadata', async () => {
|
||||
const mockParentNodes = [{ name: 'MCP Client Tool' }];
|
||||
const mockTools = [
|
||||
new StructuredToolkit([
|
||||
{ name: 'toolkitTool1', description: 'desc1', metadata: { customField: 'value' } },
|
||||
] as any),
|
||||
];
|
||||
|
||||
mockExecuteFunctions.getInputConnectionData = jest.fn().mockResolvedValue(mockTools);
|
||||
mockExecuteFunctions.getParentNodes = jest.fn().mockReturnValue(mockParentNodes);
|
||||
|
||||
const tools = await getConnectedTools(mockExecuteFunctions, false);
|
||||
|
||||
expect(tools[0].metadata).toEqual({
|
||||
customField: 'value',
|
||||
isFromToolkit: true,
|
||||
sourceNodeName: 'MCP Client Tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unwrapNestedOutput', () => {
|
||||
it('should unwrap doubly nested output', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const expected = {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should not modify regular output object', () => {
|
||||
const input = {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
confidence: 0.95,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify object without output property', () => {
|
||||
const input = {
|
||||
result: 'success',
|
||||
data: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when output is not an object', () => {
|
||||
const input = {
|
||||
output: 'Hello world',
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when object has multiple properties', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
timestamp: 123456789,
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should not modify when inner output has multiple properties', () => {
|
||||
const input = {
|
||||
output: {
|
||||
output: {
|
||||
text: 'Hello world',
|
||||
},
|
||||
meta: {
|
||||
timestamp: 123456789,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle null values properly', () => {
|
||||
const input = {
|
||||
output: null,
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
|
||||
it('should handle empty object values properly', () => {
|
||||
const input = {
|
||||
output: {},
|
||||
};
|
||||
|
||||
expect(unwrapNestedOutput(input)).toEqual(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSessionId', () => {
|
||||
let mockCtx: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCtx = {
|
||||
getNodeParameter: jest.fn(),
|
||||
evaluateExpression: jest.fn(),
|
||||
getChatTrigger: jest.fn(),
|
||||
getNode: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should retrieve sessionId from bodyData', () => {
|
||||
mockCtx.getBodyData = jest.fn();
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.getBodyData.mockReturnValue({ sessionId: '12345' });
|
||||
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('12345');
|
||||
});
|
||||
|
||||
it('should retrieve sessionId from chat trigger', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.evaluateExpression.mockReturnValueOnce(undefined);
|
||||
mockCtx.getChatTrigger.mockReturnValue({ name: 'chatTrigger' });
|
||||
mockCtx.evaluateExpression.mockReturnValueOnce('67890');
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('67890');
|
||||
});
|
||||
|
||||
it('should throw error if sessionId is not found', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValue('fromInput');
|
||||
mockCtx.evaluateExpression.mockReturnValue(undefined);
|
||||
mockCtx.getChatTrigger.mockReturnValue(undefined);
|
||||
|
||||
expect(() => getSessionId(mockCtx, 0)).toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should use custom sessionId if provided', () => {
|
||||
mockCtx.getNodeParameter.mockReturnValueOnce('custom').mockReturnValueOnce('customSessionId');
|
||||
|
||||
const sessionId = getSessionId(mockCtx, 0);
|
||||
expect(sessionId).toBe('customSessionId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCustomHeaders', () => {
|
||||
it('should merge custom header when credential has header enabled', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original headers when header option is disabled', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: false,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerName is empty', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: '',
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerName is not a string', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 123,
|
||||
headerValue: 'custom-value',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when headerValue is not a string', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Custom-Header',
|
||||
headerValue: 123,
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should return original headers when credential has no header properties', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
};
|
||||
const defaultHeaders = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ 'Content-Type': 'application/json' });
|
||||
});
|
||||
|
||||
it('should handle empty defaultHeaders', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'X-Api-Key',
|
||||
headerValue: 'my-api-key',
|
||||
};
|
||||
|
||||
const result = mergeCustomHeaders(credentials, {});
|
||||
|
||||
expect(result).toEqual({ 'X-Api-Key': 'my-api-key' });
|
||||
});
|
||||
|
||||
it('should override existing header with same name', () => {
|
||||
const credentials = {
|
||||
apiKey: 'test-key',
|
||||
header: true,
|
||||
headerName: 'Authorization',
|
||||
headerValue: 'Bearer new-token',
|
||||
};
|
||||
const defaultHeaders = { Authorization: 'Bearer old-token' };
|
||||
|
||||
const result = mergeCustomHeaders(credentials, defaultHeaders);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer new-token' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import type { INode, IExecuteFunctions } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
generateSchemaFromExample,
|
||||
convertJsonSchemaToZod,
|
||||
throwIfToolSchema,
|
||||
} from './../schemaParsing';
|
||||
|
||||
const mockNode: INode = {
|
||||
id: '1',
|
||||
name: 'Mock node',
|
||||
typeVersion: 1,
|
||||
type: 'n8n-nodes-base.mock',
|
||||
position: [60, 760],
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
describe('generateSchemaFromExample', () => {
|
||||
it('should generate schema from simple object', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
active: { type: 'boolean' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from nested object', () => {
|
||||
const example = JSON.stringify({
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string' },
|
||||
notifications: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from array', () => {
|
||||
const example = JSON.stringify({
|
||||
items: ['apple', 'banana', 'cherry'],
|
||||
numbers: [1, 2, 3],
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
numbers: {
|
||||
type: 'array',
|
||||
items: { type: 'number' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate schema from complex nested structure', () => {
|
||||
const example = JSON.stringify({
|
||||
metadata: {
|
||||
version: '1.0.0',
|
||||
tags: ['production', 'api'],
|
||||
},
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Item 1',
|
||||
properties: {
|
||||
color: 'red',
|
||||
size: 'large',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema.type).toBe('object');
|
||||
expect(schema.properties).toHaveProperty('metadata');
|
||||
expect(schema.properties).toHaveProperty('data');
|
||||
expect((schema.properties?.data as JSONSchema7).type).toBe('array');
|
||||
expect(((schema.properties?.data as JSONSchema7).items as JSONSchema7).type).toBe('object');
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
middleName: null,
|
||||
age: 30,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
middleName: { type: 'null' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not require fields by default', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema.required).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should make all fields required when allFieldsRequired is true', () => {
|
||||
const example = JSON.stringify({
|
||||
name: 'John',
|
||||
age: 30,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema.required).toEqual(['name', 'age', 'active']);
|
||||
});
|
||||
|
||||
it('should make all nested fields required when allFieldsRequired is true', () => {
|
||||
const example = JSON.stringify({
|
||||
user: {
|
||||
profile: {
|
||||
name: 'Jane',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
notifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema.required).toEqual(['user']);
|
||||
|
||||
const userSchema = schema.properties?.user as JSONSchema7;
|
||||
|
||||
expect(userSchema.required).toEqual(['profile', 'preferences']);
|
||||
expect((userSchema.properties?.profile as JSONSchema7).required).toEqual(['name', 'email']);
|
||||
expect((userSchema.properties?.preferences as JSONSchema7).required).toEqual([
|
||||
'theme',
|
||||
'notifications',
|
||||
]);
|
||||
|
||||
// Check the full structure
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
profile: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' },
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
preferences: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
theme: { type: 'string' },
|
||||
notifications: { type: 'boolean' },
|
||||
},
|
||||
required: ['theme', 'notifications'],
|
||||
},
|
||||
},
|
||||
required: ['profile', 'preferences'],
|
||||
},
|
||||
},
|
||||
required: ['user'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const example = JSON.stringify({});
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object with allFieldsRequired true', () => {
|
||||
const example = JSON.stringify({});
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', () => {
|
||||
const invalidJson = '{ name: "John", age: 30 }'; // Missing quotes around property names
|
||||
|
||||
expect(() => generateSchemaFromExample(invalidJson)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle array of objects', () => {
|
||||
const example = JSON.stringify([
|
||||
{ id: 1, name: 'Item 1' },
|
||||
{ id: 2, name: 'Item 2' },
|
||||
]);
|
||||
|
||||
const schema = generateSchemaFromExample(example);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array of objects with allFieldsRequired true', () => {
|
||||
const example = JSON.stringify([
|
||||
{ id: 1, name: 'Item 1', metadata: { tag: 'prod' } },
|
||||
{ id: 2, name: 'Item 2', metadata: { tag: 'dev' } },
|
||||
]);
|
||||
|
||||
const schema = generateSchemaFromExample(example, true);
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'number' },
|
||||
name: { type: 'string' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tag: { type: 'string' },
|
||||
},
|
||||
required: ['tag'],
|
||||
},
|
||||
},
|
||||
required: ['id', 'name', 'metadata'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertJsonSchemaToZod', () => {
|
||||
it('should convert simple object schema to zod', () => {
|
||||
const schema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
expect(zodSchema).toBeDefined();
|
||||
expect(typeof zodSchema.parse).toBe('function');
|
||||
});
|
||||
|
||||
it('should convert and validate with zod schema', () => {
|
||||
const schema: JSONSchema7 = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
},
|
||||
required: ['name'],
|
||||
};
|
||||
|
||||
const zodSchema = convertJsonSchemaToZod(schema);
|
||||
|
||||
// Valid data should pass
|
||||
expect(() => zodSchema.parse({ name: 'John', age: 30 })).not.toThrow();
|
||||
expect(() => zodSchema.parse({ name: 'John' })).not.toThrow();
|
||||
|
||||
// Invalid data should throw
|
||||
expect(() => zodSchema.parse({ age: 30 })).toThrow(); // Missing required name
|
||||
expect(() => zodSchema.parse({ name: 'John', age: 'thirty' })).toThrow(); // Wrong type for age
|
||||
});
|
||||
});
|
||||
|
||||
describe('throwIfToolSchema', () => {
|
||||
it('should throw NodeOperationError for tool schema error', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error('tool input did not match expected schema');
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(/tool input did not match expected schema/);
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(
|
||||
/This is most likely because some of your tools are configured to require a specific schema/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw for non-tool schema errors', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error('Some other error');
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not throw for errors without message', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = new Error();
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle errors that are not Error instances', () => {
|
||||
const ctx = createMockExecuteFunction<IExecuteFunctions>({}, mockNode);
|
||||
const error = { message: 'tool input did not match expected schema' } as Error;
|
||||
|
||||
expect(() => throwIfToolSchema(ctx, error)).toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { CallbackManager } from '@langchain/core/callbacks/manager';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
import { getTracingConfig } from './tracing';
|
||||
|
||||
describe('getTracingConfig', () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
};
|
||||
|
||||
const mockNode = {
|
||||
name: 'AI Agent',
|
||||
type: 'n8n-nodes-langchain.agent',
|
||||
typeVersion: 3,
|
||||
};
|
||||
|
||||
describe('with IExecuteFunctions context', () => {
|
||||
it('should return correct runName format', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.runName).toBe('[Test Workflow] AI Agent');
|
||||
});
|
||||
|
||||
it('should return correct metadata', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
execution_id: 'exec-456',
|
||||
workflow: mockWorkflow,
|
||||
node: 'AI Agent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include parent callback manager when available', () => {
|
||||
const mockCallbackManager = mock<CallbackManager>();
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
mockContext.getParentCallbackManager.mockReturnValue(mockCallbackManager);
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.callbacks).toBe(mockCallbackManager);
|
||||
});
|
||||
|
||||
it('should set callbacks to undefined when getParentCallbackManager returns undefined', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
mockContext.getParentCallbackManager.mockReturnValue(undefined);
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.callbacks).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should merge additionalMetadata when provided', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext, {
|
||||
additionalMetadata: {
|
||||
custom_field: 'custom_value',
|
||||
another_field: 123,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
execution_id: 'exec-456',
|
||||
workflow: mockWorkflow,
|
||||
node: 'AI Agent',
|
||||
custom_field: 'custom_value',
|
||||
another_field: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty additionalMetadata', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext, { additionalMetadata: {} });
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
execution_id: 'exec-456',
|
||||
workflow: mockWorkflow,
|
||||
node: 'AI Agent',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with ISupplyDataFunctions context', () => {
|
||||
it('should return correct config without getParentCallbackManager', () => {
|
||||
// ISupplyDataFunctions doesn't have getParentCallbackManager
|
||||
const mockContext = {
|
||||
getWorkflow: jest.fn().mockReturnValue(mockWorkflow),
|
||||
getNode: jest.fn().mockReturnValue(mockNode),
|
||||
getExecutionId: jest.fn().mockReturnValue('exec-789'),
|
||||
} as unknown as ISupplyDataFunctions;
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.runName).toBe('[Test Workflow] AI Agent');
|
||||
expect(result.metadata).toEqual({
|
||||
execution_id: 'exec-789',
|
||||
workflow: mockWorkflow,
|
||||
node: 'AI Agent',
|
||||
});
|
||||
expect(result.callbacks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle workflow names with special characters', () => {
|
||||
const specialWorkflow = {
|
||||
...mockWorkflow,
|
||||
name: 'Workflow [with] special (chars)',
|
||||
};
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(specialWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.runName).toBe('[Workflow [with] special (chars)] AI Agent');
|
||||
});
|
||||
|
||||
it('should handle node names with special characters', () => {
|
||||
const specialNode = {
|
||||
...mockNode,
|
||||
name: 'Node "with" quotes',
|
||||
};
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(specialNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
expect(result.runName).toBe('[Test Workflow] Node "with" quotes');
|
||||
});
|
||||
|
||||
it('should use default empty config when none provided', () => {
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
mockContext.getWorkflow.mockReturnValue(mockWorkflow);
|
||||
mockContext.getNode.mockReturnValue(mockNode as ReturnType<IExecuteFunctions['getNode']>);
|
||||
mockContext.getExecutionId.mockReturnValue('exec-456');
|
||||
|
||||
const result = getTracingConfig(mockContext);
|
||||
|
||||
// Should not throw and should have correct structure
|
||||
expect(result).toHaveProperty('runName');
|
||||
expect(result).toHaveProperty('metadata');
|
||||
expect(result).toHaveProperty('callbacks');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { BaseCallbackConfig } from '@langchain/core/callbacks/manager';
|
||||
import type { IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
|
||||
|
||||
interface TracingConfig {
|
||||
additionalMetadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getTracingConfig(
|
||||
context: IExecuteFunctions | ISupplyDataFunctions,
|
||||
config: TracingConfig = {},
|
||||
): BaseCallbackConfig {
|
||||
const parentRunManager =
|
||||
'getParentCallbackManager' in context && context.getParentCallbackManager
|
||||
? context.getParentCallbackManager()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
runName: `[${context.getWorkflow().name}] ${context.getNode().name}`,
|
||||
metadata: {
|
||||
execution_id: context.getExecutionId(),
|
||||
workflow: context.getWorkflow(),
|
||||
node: context.getNode().name,
|
||||
...(config.additionalMetadata ?? {}),
|
||||
},
|
||||
callbacks: parentRunManager,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user