feat(core): Implement wrapping of regular nodes as AI Tools (#10641)
Co-authored-by: JP van Oosten <jp@n8n.io>
This commit is contained in:
@@ -1658,6 +1658,11 @@ export interface INodeTypeBaseDescription {
|
||||
* due to deprecation or as a special case (e.g. Start node)
|
||||
*/
|
||||
hidden?: true;
|
||||
|
||||
/**
|
||||
* Whether the node will be wrapped for tool-use by AI Agents
|
||||
*/
|
||||
usableAsTool?: true;
|
||||
}
|
||||
|
||||
export interface INodePropertyRouting {
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
NodeParameterValue,
|
||||
ResourceMapperValue,
|
||||
INodeTypeDescription,
|
||||
INodeTypeBaseDescription,
|
||||
INodeOutputConfiguration,
|
||||
INodeInputConfiguration,
|
||||
GenericValue,
|
||||
@@ -351,6 +352,58 @@ const declarativeNodeOptionParameters: INodeProperties = {
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the node is of INodeType
|
||||
*/
|
||||
export function isINodeType(obj: unknown): obj is INodeType {
|
||||
return typeof obj === 'object' && obj !== null && 'execute' in obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the description of the passed in object, such that it can be used
|
||||
* as an AI Agent Tool.
|
||||
* Returns the modified item (not copied)
|
||||
*/
|
||||
export function convertNodeToAiTool<
|
||||
T extends object & { description: INodeTypeDescription | INodeTypeBaseDescription },
|
||||
>(item: T): T {
|
||||
// quick helper function for typeguard down below
|
||||
function isFullDescription(obj: unknown): obj is INodeTypeDescription {
|
||||
return typeof obj === 'object' && obj !== null && 'properties' in obj;
|
||||
}
|
||||
|
||||
if (isFullDescription(item.description)) {
|
||||
item.description.name += 'Tool';
|
||||
item.description.inputs = [];
|
||||
item.description.outputs = [NodeConnectionType.AiTool];
|
||||
item.description.displayName += ' Tool (wrapped)';
|
||||
delete item.description.usableAsTool;
|
||||
if (!item.description.properties.map((prop) => prop.name).includes('toolDescription')) {
|
||||
const descProp: INodeProperties = {
|
||||
displayName: 'Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: item.description.description,
|
||||
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',
|
||||
placeholder: `e.g. ${item.description.description}`,
|
||||
};
|
||||
item.description.properties.unshift(descProp);
|
||||
}
|
||||
}
|
||||
|
||||
item.description.codex = {
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
};
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the provided node type has any output types other than the main connection type.
|
||||
* @param typeDescription The node's type description to check.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isSingleExecution,
|
||||
isSubNodeType,
|
||||
applyDeclarativeNodeOptionParameters,
|
||||
convertNodeToAiTool,
|
||||
} from '@/NodeHelpers';
|
||||
|
||||
describe('NodeHelpers', () => {
|
||||
@@ -3636,4 +3637,89 @@ describe('NodeHelpers', () => {
|
||||
expect(nodeType.description.properties).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertNodeToAiTool', () => {
|
||||
let fullNodeWrapper: { description: INodeTypeDescription };
|
||||
|
||||
beforeEach(() => {
|
||||
fullNodeWrapper = {
|
||||
description: {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['test'],
|
||||
description: 'A test node',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [NodeConnectionType.Main],
|
||||
outputs: [NodeConnectionType.Main],
|
||||
properties: [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should modify the name and displayName correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.name).toBe('testNodeTool');
|
||||
expect(result.description.displayName).toBe('Test Node Tool (wrapped)');
|
||||
});
|
||||
|
||||
it('should update inputs and outputs', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.inputs).toEqual([]);
|
||||
expect(result.description.outputs).toEqual([NodeConnectionType.AiTool]);
|
||||
});
|
||||
|
||||
it('should remove the usableAsTool property', () => {
|
||||
fullNodeWrapper.description.usableAsTool = true;
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.usableAsTool).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should add toolDescription property if it doesn't exist", () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
const toolDescriptionProp = result.description.properties.find(
|
||||
(prop) => prop.name === 'toolDescription',
|
||||
);
|
||||
expect(toolDescriptionProp).toBeDefined();
|
||||
expect(toolDescriptionProp?.type).toBe('string');
|
||||
expect(toolDescriptionProp?.default).toBe(fullNodeWrapper.description.description);
|
||||
});
|
||||
|
||||
it('should not add toolDescription property if it already exists', () => {
|
||||
const toolDescriptionProp: INodeProperties = {
|
||||
displayName: 'Tool Description',
|
||||
name: 'toolDescription',
|
||||
type: 'string',
|
||||
default: 'Existing description',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [toolDescriptionProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(1);
|
||||
expect(result.description.properties[0]).toEqual(toolDescriptionProp);
|
||||
});
|
||||
|
||||
it('should set codex categories correctly', () => {
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.codex).toEqual({
|
||||
categories: ['AI'],
|
||||
subcategories: {
|
||||
AI: ['Tools'],
|
||||
Tools: ['Other Tools'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing properties', () => {
|
||||
const existingProp: INodeProperties = {
|
||||
displayName: 'Existing Prop',
|
||||
name: 'existingProp',
|
||||
type: 'string',
|
||||
default: 'test',
|
||||
};
|
||||
fullNodeWrapper.description.properties = [existingProp];
|
||||
const result = convertNodeToAiTool(fullNodeWrapper);
|
||||
expect(result.description.properties).toHaveLength(2); // Existing prop + toolDescription
|
||||
expect(result.description.properties).toContainEqual(existingProp);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user