feat(Custom n8n Workflow Tool Node): Add support for tool input schema (#9470)

Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
oleg 2024-05-22 14:29:32 +02:00 committed by GitHub
parent ef9d4aba90
commit 2fa46b6faa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 373 additions and 108 deletions

View file

@ -13,6 +13,7 @@ import {
getConnectedTools,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
export async function conversationalAgentExecute(
this: IExecuteFunctions,
@ -111,6 +112,8 @@ export async function conversationalAgentExecute(
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;

View file

@ -16,6 +16,7 @@ import {
getPromptInputByType,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
export async function planAndExecuteAgentExecute(
this: IExecuteFunctions,
@ -91,6 +92,7 @@ export async function planAndExecuteAgentExecute(
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;

View file

@ -18,6 +18,7 @@ import {
isChatInstance,
} from '../../../../../utils/helpers';
import { getTracingConfig } from '../../../../../utils/tracing';
import { throwIfToolSchema } from '../../../../../utils/schemaParsing';
export async function reActAgentAgentExecute(
this: IExecuteFunctions,
@ -112,6 +113,7 @@ export async function reActAgentAgentExecute(
returnData.push({ json: response });
} catch (error) {
throwIfToolSchema(this, error);
if (this.continueOnFail()) {
returnData.push({ json: { error: error.message }, pairedItem: { item: itemIndex } });
continue;

View file

@ -92,6 +92,8 @@ function getSandbox(
// eslint-disable-next-line @typescript-eslint/unbound-method
context.executeWorkflow = this.executeWorkflow;
// eslint-disable-next-line @typescript-eslint/unbound-method
context.getWorkflowDataProxy = this.getWorkflowDataProxy;
// eslint-disable-next-line @typescript-eslint/unbound-method
context.logger = this.logger;
if (options?.addItems) {

View file

@ -13,11 +13,15 @@ import type { JSONSchema7 } from 'json-schema';
import { StructuredOutputParser } from 'langchain/output_parsers';
import { OutputParserException } from '@langchain/core/output_parsers';
import get from 'lodash/get';
import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox';
import { makeResolverFromLegacyOptions } from '@n8n/vm2';
import type { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
import { logWrapper } from '../../../utils/logWrapper';
import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing';
import {
inputSchemaField,
jsonSchemaExampleField,
schemaTypeField,
} from '../../../utils/descriptions';
const STRUCTURED_OUTPUT_KEY = '__structured__output';
const STRUCTURED_OUTPUT_OBJECT_KEY = '__structured__output__object';
@ -87,8 +91,8 @@ export class OutputParserStructured implements INodeType {
name: 'outputParserStructured',
icon: 'fa:code',
group: ['transform'],
version: [1, 1.1],
defaultVersion: 1.1,
version: [1, 1.1, 1.2],
defaultVersion: 1.2,
description: 'Return data in a defined JSON format',
defaults: {
name: 'Structured Output Parser',
@ -115,6 +119,33 @@ export class OutputParserStructured implements INodeType {
outputNames: ['Output Parser'],
properties: [
getConnectionHintNoticeField([NodeConnectionType.AiChain, NodeConnectionType.AiAgent]),
{ ...schemaTypeField, displayOptions: { show: { '@version': [{ _cnd: { gte: 1.2 } }] } } },
{
...jsonSchemaExampleField,
default: `{
"state": "California",
"cities": ["Los Angeles", "San Francisco", "San Diego"]
}`,
},
{
...inputSchemaField,
displayName: 'JSON Schema',
description: 'JSON Schema to structure and validate the output against',
default: `{
"type": "object",
"properties": {
"state": {
"type": "string"
},
"cities": {
"type": "array",
"items": {
"type": "string"
}
}
}
}`,
},
{
displayName: 'JSON Schema',
name: 'jsonSchema',
@ -138,6 +169,11 @@ export class OutputParserStructured implements INodeType {
rows: 10,
},
required: true,
displayOptions: {
show: {
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
},
{
displayName:
@ -145,72 +181,36 @@ export class OutputParserStructured implements INodeType {
name: 'notice',
type: 'notice',
default: '',
displayOptions: {
hide: {
schemaType: ['fromJson'],
},
},
},
],
};
async supplyData(this: IExecuteFunctions, itemIndex: number): Promise<SupplyData> {
const schema = this.getNodeParameter('jsonSchema', itemIndex) as string;
const schemaType = this.getNodeParameter('schemaType', itemIndex, '') as 'fromJson' | 'manual';
// We initialize these even though one of them will always be empty
// it makes it easer to navigate the ternary operator
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
let inputSchema: string;
let itemSchema: JSONSchema7;
try {
itemSchema = jsonParse<JSONSchema7>(schema);
// If the type is not defined, we assume it's an object
if (itemSchema.type === undefined) {
itemSchema = {
type: 'object',
properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }),
};
}
} catch (error) {
throw new NodeOperationError(this.getNode(), 'Error during parsing of JSON Schema.');
if (this.getNode().typeVersion <= 1.1) {
inputSchema = this.getNodeParameter('jsonSchema', itemIndex, '') as string;
} else {
inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
}
const vmResolver = makeResolverFromLegacyOptions({
external: {
modules: ['json-schema-to-zod', 'zod'],
transitive: false,
},
resolve(moduleName, parentDirname) {
if (moduleName === 'json-schema-to-zod') {
return require.resolve(
'@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js',
{
paths: [parentDirname],
},
);
}
if (moduleName === 'zod') {
return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', {
paths: [parentDirname],
});
}
return;
},
builtin: [],
});
const context = getSandboxContext.call(this, itemIndex);
// Make sure to remove the description from root schema
const { description, ...restOfSchema } = itemSchema;
const sandboxedSchema = new JavaScriptSandbox(
context,
`
const { z } = require('zod');
const { parseSchema } = require('json-schema-to-zod');
const zodSchema = parseSchema(${JSON.stringify(restOfSchema)});
const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z)
return itemSchema
`,
itemIndex,
this.helpers,
{ resolver: vmResolver },
);
const jsonSchema =
schemaType === 'fromJson' ? generateSchema(jsonExample) : jsonParse<JSONSchema7>(inputSchema);
const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0);
const nodeVersion = this.getNode().typeVersion;
try {
const parser = await N8nStructuredOutputParser.fromZedJsonSchema(
sandboxedSchema,
zodSchemaSandbox,
nodeVersion,
);
return {

View file

@ -9,16 +9,23 @@ import type {
ExecutionError,
IDataObject,
} from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError } from 'n8n-workflow';
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import { DynamicTool } from '@langchain/core/tools';
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
import type { JSONSchema7 } from 'json-schema';
import { getConnectionHintNoticeField } from '../../../utils/sharedFields';
import type { DynamicZodObject } from '../../../types/zod.types';
import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing';
import {
jsonSchemaExampleField,
schemaTypeField,
inputSchemaField,
} from '../../../utils/descriptions';
export class ToolWorkflow implements INodeType {
description: INodeTypeDescription = {
displayName: 'Custom n8n Workflow Tool',
@ -314,6 +321,21 @@ export class ToolWorkflow implements INodeType {
},
],
},
// ----------------------------------
// Output Parsing
// ----------------------------------
{
displayName: 'Specify Input Schema',
name: 'specifyInputSchema',
type: 'boolean',
description:
'Whether to specify the schema for the function. This would require the LLM to provide the input in the correct format and would validate it against the schema.',
noDataExpression: true,
default: false,
},
{ ...schemaTypeField, displayOptions: { show: { specifyInputSchema: [true] } } },
jsonSchemaExampleField,
inputSchemaField,
],
};
@ -321,8 +343,11 @@ export class ToolWorkflow implements INodeType {
const name = this.getNodeParameter('name', itemIndex) as string;
const description = this.getNodeParameter('description', itemIndex) as string;
const useSchema = this.getNodeParameter('specifyInputSchema', itemIndex) as boolean;
let tool: DynamicTool | DynamicStructuredTool | undefined = undefined;
const runFunction = async (
query: string,
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const source = this.getNodeParameter('source', itemIndex) as string;
@ -416,50 +441,86 @@ export class ToolWorkflow implements INodeType {
return response;
};
const toolHandler = async (
query: string | IDataObject,
runManager?: CallbackManagerForToolRun,
): Promise<string> => {
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
let response: string = '';
let executionError: ExecutionError | undefined;
try {
response = await runFunction(query, runManager);
} catch (error) {
// TODO: Do some more testing. Issues here should actually fail the workflow
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
executionError = error;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
response = `There was an error: "${error.message}"`;
}
if (typeof response === 'number') {
response = (response as number).toString();
}
if (isObject(response)) {
response = JSON.stringify(response, null, 2);
}
if (typeof response !== 'string') {
// TODO: Do some more testing. Issues here should actually fail the workflow
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
response = `There was an error: "${executionError.message}"`;
}
if (executionError) {
void this.addOutputData(NodeConnectionType.AiTool, index, executionError);
} else {
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]);
}
return response;
};
const functionBase = {
name,
description,
func: toolHandler,
};
if (useSchema) {
try {
// We initialize these even though one of them will always be empty
// it makes it easer to navigate the ternary operator
const jsonExample = this.getNodeParameter('jsonSchemaExample', itemIndex, '') as string;
const inputSchema = this.getNodeParameter('inputSchema', itemIndex, '') as string;
const schemaType = this.getNodeParameter('schemaType', itemIndex) as 'fromJson' | 'manual';
const jsonSchema =
schemaType === 'fromJson'
? generateSchema(jsonExample)
: jsonParse<JSONSchema7>(inputSchema);
const zodSchemaSandbox = getSandboxWithZod(this, jsonSchema, 0);
const zodSchema = (await zodSchemaSandbox.runCode()) as DynamicZodObject;
tool = new DynamicStructuredTool<typeof zodSchema>({
schema: zodSchema,
...functionBase,
});
} catch (error) {
throw new NodeOperationError(
this.getNode(),
'Error during parsing of JSON Schema. \n ' + error,
);
}
} else {
tool = new DynamicTool(functionBase);
}
return {
response: new DynamicTool({
name,
description,
func: async (query: string, runManager?: CallbackManagerForToolRun): Promise<string> => {
const { index } = this.addInputData(NodeConnectionType.AiTool, [[{ json: { query } }]]);
let response: string = '';
let executionError: ExecutionError | undefined;
try {
response = await runFunction(query, runManager);
} catch (error) {
// TODO: Do some more testing. Issues here should actually fail the workflow
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
executionError = error;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
response = `There was an error: "${error.message}"`;
}
if (typeof response === 'number') {
response = (response as number).toString();
}
if (isObject(response)) {
response = JSON.stringify(response, null, 2);
}
if (typeof response !== 'string') {
// TODO: Do some more testing. Issues here should actually fail the workflow
executionError = new NodeOperationError(this.getNode(), 'Wrong output type returned', {
description: `The response property should be a string, but it is an ${typeof response}`,
});
response = `There was an error: "${executionError.message}"`;
}
if (executionError) {
void this.addOutputData(NodeConnectionType.AiTool, index, executionError);
} else {
void this.addOutputData(NodeConnectionType.AiTool, index, [[{ json: { response } }]]);
}
return response;
},
}),
response: tool,
};
}
}

View file

@ -157,6 +157,7 @@
"d3-dsv": "2.0.0",
"epub2": "3.0.2",
"form-data": "4.0.0",
"generate-schema": "^2.6.0",
"html-to-text": "9.0.5",
"jest-mock-extended": "^3.0.4",
"json-schema-to-zod": "2.0.14",

View file

@ -11,7 +11,8 @@
"credentials/**/*.ts",
"nodes/**/*.ts",
"nodes/**/*.json",
"credentials/translations/**/*.json"
"credentials/translations/**/*.json",
"types/*.ts"
],
"exclude": ["nodes/**/*.test.ts", "test/**"]
}

View file

@ -20,5 +20,5 @@
"skipLibCheck": true,
"outDir": "./dist/"
},
"include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json"]
"include": ["credentials/**/*", "nodes/**/*", "utils/**/*.ts", "nodes/**/*.json", "types/*.ts"]
}

View file

@ -0,0 +1,27 @@
declare module 'generate-schema' {
export interface SchemaObject {
$schema: string;
title?: string;
type: string;
properties?: {
[key: string]: SchemaObject | SchemaArray | SchemaProperty;
};
required?: string[];
items?: SchemaObject | SchemaArray;
}
export interface SchemaArray {
type: string;
items?: SchemaObject | SchemaArray | SchemaProperty;
oneOf?: Array<SchemaObject | SchemaArray | SchemaProperty>;
required?: string[];
}
export interface SchemaProperty {
type: string | string[];
format?: string;
}
export function json(title: string, schema: SchemaObject): SchemaObject;
export function json(schema: SchemaObject): SchemaObject;
}

View file

@ -0,0 +1,4 @@
import type { z } from 'zod';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type DynamicZodObject = z.ZodObject<any, any, any, any>;

View file

@ -1,5 +1,70 @@
import type { INodeProperties } 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 Below',
value: 'manual',
description: 'Define the JSON schema manually',
},
],
default: 'fromJson',
description: 'How to specify the schema for the function',
};
export const jsonSchemaExampleField: INodeProperties = {
displayName: 'JSON Example',
name: 'jsonSchemaExample',
type: 'json',
default: `{
"some_input": "some_value"
}`,
noDataExpression: true,
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
schemaType: ['fromJson'],
},
},
description: 'Example JSON object to use to generate the schema',
};
export const inputSchemaField: INodeProperties = {
displayName: 'Input Schema',
name: 'inputSchema',
type: 'json',
default: `{
"type": "object",
"properties": {
"some_input": {
"type": "string",
"description": "Some input to the function"
}
}
}`,
noDataExpression: true,
typeOptions: {
rows: 10,
},
displayOptions: {
show: {
schemaType: ['manual'],
},
},
description: 'Schema to use for the function',
};
export const promptTypeOptions: INodeProperties = {
displayName: 'Prompt',
name: 'promptType',

View file

@ -0,0 +1,81 @@
import { makeResolverFromLegacyOptions } from '@n8n/vm2';
import { json as generateJsonSchema } from 'generate-schema';
import type { SchemaObject } from 'generate-schema';
import type { JSONSchema7 } from 'json-schema';
import { JavaScriptSandbox } from 'n8n-nodes-base/dist/nodes/Code/JavaScriptSandbox';
import { getSandboxContext } from 'n8n-nodes-base/dist/nodes/Code/Sandbox';
import type { IExecuteFunctions } from 'n8n-workflow';
import { NodeOperationError, jsonParse } from 'n8n-workflow';
const vmResolver = makeResolverFromLegacyOptions({
external: {
modules: ['json-schema-to-zod', 'zod'],
transitive: false,
},
resolve(moduleName, parentDirname) {
if (moduleName === 'json-schema-to-zod') {
return require.resolve(
'@n8n/n8n-nodes-langchain/node_modules/json-schema-to-zod/dist/cjs/jsonSchemaToZod.js',
{
paths: [parentDirname],
},
);
}
if (moduleName === 'zod') {
return require.resolve('@n8n/n8n-nodes-langchain/node_modules/zod.cjs', {
paths: [parentDirname],
});
}
return;
},
builtin: [],
});
export function getSandboxWithZod(ctx: IExecuteFunctions, schema: JSONSchema7, itemIndex: number) {
const context = getSandboxContext.call(ctx, itemIndex);
let itemSchema: JSONSchema7 = schema;
try {
// If the root type is not defined, we assume it's an object
if (itemSchema.type === undefined) {
itemSchema = {
type: 'object',
properties: itemSchema.properties ?? (itemSchema as { [key: string]: JSONSchema7 }),
};
}
} catch (error) {
throw new NodeOperationError(ctx.getNode(), 'Error during parsing of JSON Schema.');
}
// Make sure to remove the description from root schema
const { description, ...restOfSchema } = itemSchema;
const sandboxedSchema = new JavaScriptSandbox(
context,
`
const { z } = require('zod');
const { parseSchema } = require('json-schema-to-zod');
const zodSchema = parseSchema(${JSON.stringify(restOfSchema)});
const itemSchema = new Function('z', 'return (' + zodSchema + ')')(z)
return itemSchema
`,
itemIndex,
ctx.helpers,
{ resolver: vmResolver },
);
return sandboxedSchema;
}
export function generateSchema(schemaString: string): JSONSchema7 {
const parsedSchema = jsonParse<SchemaObject>(schemaString);
return generateJsonSchema(parsedSchema) as JSONSchema7;
}
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.`,
);
}
}

View file

@ -315,6 +315,9 @@ importers:
form-data:
specifier: 4.0.0
version: 4.0.0
generate-schema:
specifier: ^2.6.0
version: 2.6.0
html-to-text:
specifier: 9.0.5
version: 9.0.5
@ -15175,6 +15178,14 @@ packages:
is-property: 1.0.2
dev: false
/generate-schema@2.6.0:
resolution: {integrity: sha512-EUBKfJNzT8f91xUk5X5gKtnbdejZeE065UAJ3BCzE8VEbvwKI9Pm5jaWmqVeK1MYc1g5weAVFDTSJzN7ymtTqA==}
hasBin: true
dependencies:
commander: 2.20.3
type-of-is: 3.5.1
dev: false
/generic-pool@3.9.0:
resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==}
engines: {node: '>= 4'}
@ -23560,6 +23571,11 @@ packages:
media-typer: 0.3.0
mime-types: 2.1.35
/type-of-is@3.5.1:
resolution: {integrity: sha512-SOnx8xygcAh8lvDU2exnK2bomASfNjzB3Qz71s2tw9QnX8fkAo7aC+D0H7FV0HjRKj94CKV2Hi71kVkkO6nOxg==}
engines: {node: '>=0.10.5'}
dev: false
/type@1.2.0:
resolution: {integrity: sha512-+5nt5AAniqsCnu2cEQQdpzCAh33kVx8n0VoFidKpB1dVVLAN/F+bgVOqOJqOnEnrhp222clB5p3vUlD+1QAnfg==}
dev: false