feat(HTTP Request Tool Node): Use DynamicStructuredTool with models supporting it (no-changelog) (#10246)

This commit is contained in:
Eugene
2024-08-07 11:20:17 +02:00
committed by GitHub
parent fa17391dbd
commit a936680768
11 changed files with 382 additions and 26 deletions

View File

@@ -0,0 +1,169 @@
import { N8nTool } from './N8nTool';
import { createMockExecuteFunction } from 'n8n-nodes-base/test/nodes/Helpers';
import { z } from 'zod';
import type { INode } from 'n8n-workflow';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
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({}, 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({}, 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({}, 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({}, 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({}, 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({}, 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({}, 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' });
});
});

View File

@@ -0,0 +1,113 @@
import type { DynamicStructuredToolInput } from '@langchain/core/tools';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import type { IExecuteFunctions, IDataObject } from 'n8n-workflow';
import { NodeConnectionType, jsonParse, NodeOperationError } from 'n8n-workflow';
import { StructuredOutputParser } from 'langchain/output_parsers';
import type { ZodTypeAny } from 'zod';
import { ZodBoolean, ZodNullable, ZodNumber, ZodObject, ZodOptional } from 'zod';
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 {
private context: IExecuteFunctions;
constructor(context: IExecuteFunctions, fields: DynamicStructuredToolInput) {
super(fields);
this.context = context;
}
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(NodeConnectionType.AiTool, [[{ json: { query } }]]);
void context.addOutputData(NodeConnectionType.AiTool, index, e);
return e.toString();
}
};
return new DynamicTool({
name,
description: prepareFallbackToolDescription(description, schema),
func: wrappedFunc,
});
}
}

View File

@@ -1,17 +1,19 @@
import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow';
import type {
EventNamesAiNodesType,
IDataObject,
IExecuteFunctions,
IWebhookFunctions,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonStringify } from 'n8n-workflow';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseOutputParser } from '@langchain/core/output_parsers';
import type { BaseMessage } from '@langchain/core/messages';
import { DynamicTool, type Tool } from '@langchain/core/tools';
import type { Tool } from '@langchain/core/tools';
import type { BaseLLM } from '@langchain/core/language_models/llms';
import type { BaseChatMemory } from 'langchain/memory';
import type { BaseChatMessageHistory } from '@langchain/core/chat_history';
import { N8nTool } from './N8nTool';
import { DynamicTool } from '@langchain/core/tools';
function hasMethods<T>(obj: unknown, ...methodNames: Array<string | symbol>): obj is T {
return methodNames.every(
@@ -178,7 +180,11 @@ export function serializeChatHistory(chatHistory: BaseMessage[]): string {
.join('\n');
}
export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNames: boolean) => {
export const getConnectedTools = async (
ctx: IExecuteFunctions,
enforceUniqueNames: boolean,
convertStructuredTool: boolean = true,
) => {
const connectedTools =
((await ctx.getInputConnectionData(NodeConnectionType.AiTool, 0)) as Tool[]) || [];
@@ -186,8 +192,10 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam
const seenNames = new Set<string>();
const finalTools = [];
for (const tool of connectedTools) {
if (!(tool instanceof DynamicTool)) continue;
if (!(tool instanceof DynamicTool) && !(tool instanceof N8nTool)) continue;
const { name } = tool;
if (seenNames.has(name)) {
@@ -197,7 +205,13 @@ export const getConnectedTools = async (ctx: IExecuteFunctions, enforceUniqueNam
);
}
seenNames.add(name);
if (convertStructuredTool && tool instanceof N8nTool) {
finalTools.push(tool.asDynamicTool());
} else {
finalTools.push(tool);
}
}
return connectedTools;
return finalTools;
};