mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat: Simplify builder tool calls (no-changelog) (#18798)
This commit is contained in:
@@ -19,22 +19,24 @@ import {
|
||||
} from '@/workflow-builder-agent';
|
||||
|
||||
jest.mock('@/tools/add-node.tool', () => ({
|
||||
createAddNodeTool: jest.fn().mockReturnValue({ name: 'add_node' }),
|
||||
createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }),
|
||||
}));
|
||||
jest.mock('@/tools/connect-nodes.tool', () => ({
|
||||
createConnectNodesTool: jest.fn().mockReturnValue({ name: 'connect_nodes' }),
|
||||
createConnectNodesTool: jest.fn().mockReturnValue({ tool: { name: 'connect_nodes' } }),
|
||||
}));
|
||||
jest.mock('@/tools/node-details.tool', () => ({
|
||||
createNodeDetailsTool: jest.fn().mockReturnValue({ name: 'node_details' }),
|
||||
createNodeDetailsTool: jest.fn().mockReturnValue({ tool: { name: 'node_details' } }),
|
||||
}));
|
||||
jest.mock('@/tools/node-search.tool', () => ({
|
||||
createNodeSearchTool: jest.fn().mockReturnValue({ name: 'node_search' }),
|
||||
createNodeSearchTool: jest.fn().mockReturnValue({ tool: { name: 'node_search' } }),
|
||||
}));
|
||||
jest.mock('@/tools/remove-node.tool', () => ({
|
||||
createRemoveNodeTool: jest.fn().mockReturnValue({ name: 'remove_node' }),
|
||||
createRemoveNodeTool: jest.fn().mockReturnValue({ tool: { name: 'remove_node' } }),
|
||||
}));
|
||||
jest.mock('@/tools/update-node-parameters.tool', () => ({
|
||||
createUpdateNodeParametersTool: jest.fn().mockReturnValue({ name: 'update_node_parameters' }),
|
||||
createUpdateNodeParametersTool: jest
|
||||
.fn()
|
||||
.mockReturnValue({ tool: { name: 'update_node_parameters' } }),
|
||||
}));
|
||||
jest.mock('@/tools/prompts/main-agent.prompt', () => ({
|
||||
mainAgentPrompt: {
|
||||
|
||||
@@ -13,6 +13,8 @@ import { findNodeType } from './helpers/validation';
|
||||
import type { AddedNode } from '../types/nodes';
|
||||
import type { AddNodeOutput, ToolError } from '../types/tools';
|
||||
|
||||
const DISPLAY_TITLE = 'Adding node';
|
||||
|
||||
/**
|
||||
* Schema for node creation input
|
||||
*/
|
||||
@@ -64,13 +66,32 @@ function buildResponseMessage(addedNode: AddedNode, nodeTypes: INodeTypeDescript
|
||||
return `Successfully added "${addedNode.name}" (${addedNode.displayName ?? addedNode.type})${nodeTypeInfo} with ID ${addedNode.id}`;
|
||||
}
|
||||
|
||||
function getCustomNodeTitle(
|
||||
input: Record<string, unknown>,
|
||||
nodeTypes: INodeTypeDescription[],
|
||||
): string {
|
||||
if ('nodeType' in input && typeof input['nodeType'] === 'string') {
|
||||
const nodeType = nodeTypes.find((type) => type.name === input.nodeType);
|
||||
if (nodeType) {
|
||||
return `Adding ${nodeType.displayName} node`;
|
||||
}
|
||||
}
|
||||
|
||||
return DISPLAY_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create the add node tool
|
||||
*/
|
||||
export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) {
|
||||
return tool(
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(config, 'add_nodes');
|
||||
const reporter = createProgressReporter(
|
||||
config,
|
||||
'add_nodes',
|
||||
DISPLAY_TITLE,
|
||||
getCustomNodeTitle(input, nodeTypes),
|
||||
);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@@ -194,4 +215,10 @@ Think through the connectionParametersReasoning FIRST, then set connectionParame
|
||||
schema: nodeCreationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
getCustomDisplayTitle: (input: Record<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,10 +49,12 @@ export const nodeConnectionSchema = z.object({
|
||||
* Factory function to create the connect nodes tool
|
||||
*/
|
||||
export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger?: Logger) {
|
||||
return tool(
|
||||
const DISPLAY_TITLE = 'Connecting nodes';
|
||||
|
||||
const dynamicTool = tool(
|
||||
// eslint-disable-next-line complexity
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'connect_nodes');
|
||||
const reporter = createProgressReporter(config, 'connect_nodes', DISPLAY_TITLE);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@@ -316,4 +318,9 @@ CONNECTION EXAMPLES:
|
||||
schema: nodeConnectionSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,22 +10,36 @@ import type {
|
||||
|
||||
/**
|
||||
* Create a progress reporter for a tool execution
|
||||
*
|
||||
* @param config
|
||||
* @param toolName
|
||||
* @param displayTitle the general tool action name, for example "Searching for nodes"
|
||||
* @param customTitle custom title per tool call, for example "Searching for OpenAI"
|
||||
*/
|
||||
export function createProgressReporter<TToolName extends string = string>(
|
||||
config: ToolRunnableConfig & LangGraphRunnableConfig,
|
||||
toolName: TToolName,
|
||||
displayTitle: string,
|
||||
customTitle?: string,
|
||||
): ProgressReporter {
|
||||
const toolCallId = config.toolCall?.id;
|
||||
|
||||
let customDisplayTitle = customTitle;
|
||||
|
||||
const emit = (message: ToolProgressMessage<TToolName>): void => {
|
||||
config.writer?.(message);
|
||||
};
|
||||
|
||||
const start = <T>(input: T): void => {
|
||||
const start = <T>(input: T, options?: { customDisplayTitle: string }): void => {
|
||||
if (options?.customDisplayTitle) {
|
||||
customDisplayTitle = options.customDisplayTitle;
|
||||
}
|
||||
emit({
|
||||
type: 'tool',
|
||||
toolName,
|
||||
toolCallId,
|
||||
displayTitle,
|
||||
customDisplayTitle,
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
@@ -41,6 +55,8 @@ export function createProgressReporter<TToolName extends string = string>(
|
||||
type: 'tool',
|
||||
toolName,
|
||||
toolCallId,
|
||||
displayTitle,
|
||||
customDisplayTitle,
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
@@ -56,6 +72,8 @@ export function createProgressReporter<TToolName extends string = string>(
|
||||
type: 'tool',
|
||||
toolName,
|
||||
toolCallId,
|
||||
displayTitle,
|
||||
customDisplayTitle,
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
@@ -71,6 +89,8 @@ export function createProgressReporter<TToolName extends string = string>(
|
||||
type: 'tool',
|
||||
toolName,
|
||||
toolCallId,
|
||||
displayTitle,
|
||||
customDisplayTitle,
|
||||
status: 'error',
|
||||
updates: [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import type { ToolRunnableConfig } from '@langchain/core/tools';
|
||||
import type { LangGraphRunnableConfig } from '@langchain/langgraph';
|
||||
|
||||
import type { ToolError } from '../../../types/tools';
|
||||
import {
|
||||
createProgressReporter,
|
||||
reportStart,
|
||||
reportProgress,
|
||||
reportComplete,
|
||||
reportError,
|
||||
createBatchProgressReporter,
|
||||
} from '../progress';
|
||||
|
||||
describe('progress helpers', () => {
|
||||
let mockWriter: jest.MockedFunction<(chunk: unknown) => void>;
|
||||
let mockConfig: ToolRunnableConfig & LangGraphRunnableConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockWriter = jest.fn();
|
||||
mockConfig = {
|
||||
writer: mockWriter,
|
||||
toolCall: {
|
||||
id: 'test-tool-call-id',
|
||||
name: 'test-tool',
|
||||
args: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('createProgressReporter', () => {
|
||||
it('should create a progress reporter with all methods', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
|
||||
expect(reporter).toHaveProperty('start');
|
||||
expect(reporter).toHaveProperty('progress');
|
||||
expect(reporter).toHaveProperty('complete');
|
||||
expect(reporter).toHaveProperty('error');
|
||||
expect(reporter).toHaveProperty('createBatchReporter');
|
||||
expect(typeof reporter.start).toBe('function');
|
||||
expect(typeof reporter.progress).toBe('function');
|
||||
expect(typeof reporter.complete).toBe('function');
|
||||
expect(typeof reporter.error).toBe('function');
|
||||
expect(typeof reporter.createBatchReporter).toBe('function');
|
||||
});
|
||||
|
||||
it('should emit start message with input data', () => {
|
||||
const reporter = createProgressReporter(
|
||||
mockConfig,
|
||||
'add_node',
|
||||
'Adding Node',
|
||||
'Adding Code Node',
|
||||
);
|
||||
const input = { nodeType: 'code', name: 'Test Node' };
|
||||
|
||||
reporter.start(input);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'add_node',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Adding Node',
|
||||
customDisplayTitle: 'Adding Code Node',
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: input,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit progress message with string message', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'connect_nodes', 'Connecting Nodes');
|
||||
|
||||
reporter.progress('Connecting node A to node B');
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'connect_nodes',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Connecting Nodes',
|
||||
customDisplayTitle: undefined,
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Connecting node A to node B' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit progress message with custom data', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'search_nodes', 'Searching Nodes');
|
||||
const customData = { found: 5, query: 'http' };
|
||||
|
||||
reporter.progress('Found nodes', customData);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'search_nodes',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Searching Nodes',
|
||||
customDisplayTitle: undefined,
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: customData,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit complete message with output data', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'remove_node', 'Removing Node');
|
||||
const output = { nodeId: 'node123', success: true };
|
||||
|
||||
reporter.complete(output);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'remove_node',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Removing Node',
|
||||
customDisplayTitle: undefined,
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
type: 'output',
|
||||
data: output,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit error message with error details', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'update_node', 'Updating Node');
|
||||
const error: ToolError = {
|
||||
message: 'Node not found',
|
||||
code: 'NODE_NOT_FOUND',
|
||||
details: { nodeId: 'missing-node' },
|
||||
};
|
||||
|
||||
reporter.error(error);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'update_node',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Updating Node',
|
||||
customDisplayTitle: undefined,
|
||||
status: 'error',
|
||||
updates: [
|
||||
{
|
||||
type: 'error',
|
||||
data: {
|
||||
message: 'Node not found',
|
||||
code: 'NODE_NOT_FOUND',
|
||||
details: { nodeId: 'missing-node' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing writer gracefully', () => {
|
||||
const configWithoutWriter = { ...mockConfig, writer: undefined };
|
||||
const reporter = createProgressReporter(configWithoutWriter, 'test_tool', 'Test Tool');
|
||||
|
||||
expect(() => reporter.start({ test: 'data' })).not.toThrow();
|
||||
expect(() => reporter.progress('test message')).not.toThrow();
|
||||
expect(() => reporter.complete({ result: 'success' })).not.toThrow();
|
||||
expect(() => reporter.error({ message: 'test error' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle missing toolCallId', () => {
|
||||
const configWithoutToolCallId = { ...mockConfig, toolCall: undefined };
|
||||
const reporter = createProgressReporter(configWithoutToolCallId, 'test_tool', 'Test Tool');
|
||||
|
||||
reporter.start({ test: 'data' });
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolCallId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch reporter', () => {
|
||||
it('should create batch reporter with correct interface', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool');
|
||||
const batchReporter = reporter.createBatchReporter('Processing items');
|
||||
|
||||
expect(batchReporter).toHaveProperty('init');
|
||||
expect(batchReporter).toHaveProperty('next');
|
||||
expect(batchReporter).toHaveProperty('complete');
|
||||
expect(typeof batchReporter.init).toBe('function');
|
||||
expect(typeof batchReporter.next).toBe('function');
|
||||
expect(typeof batchReporter.complete).toBe('function');
|
||||
});
|
||||
|
||||
it('should track batch progress correctly', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool');
|
||||
const batchReporter = reporter.createBatchReporter('Processing nodes');
|
||||
|
||||
batchReporter.init(3);
|
||||
batchReporter.next('First node');
|
||||
batchReporter.next('Second node');
|
||||
batchReporter.next('Third node');
|
||||
batchReporter.complete();
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledTimes(4);
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Processing nodes: Processing item 1 of 3: First node' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Processing nodes: Processing item 2 of 3: Second node' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Processing nodes: Processing item 3 of 3: Third node' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{ type: 'progress', data: { message: 'Processing nodes: Completed all 3 items' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset counter when init is called again', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool');
|
||||
const batchReporter = reporter.createBatchReporter('Testing reset');
|
||||
|
||||
batchReporter.init(2);
|
||||
batchReporter.next('Item 1');
|
||||
batchReporter.init(1); // Reset
|
||||
batchReporter.next('New item');
|
||||
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Testing reset: Processing item 1 of 2: Item 1' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(mockWriter).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Testing reset: Processing item 1 of 1: New item' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper functions', () => {
|
||||
it('should call reporter.start through reportStart', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const input = { test: 'input' };
|
||||
|
||||
reportStart(reporter, input);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
updates: [{ type: 'input', data: input }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call reporter.progress through reportProgress', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
|
||||
reportProgress(reporter, 'Test message');
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
updates: [{ type: 'progress', data: { message: 'Test message' } }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call reporter.progress with custom data through reportProgress', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const customData = { step: 2, total: 5 };
|
||||
|
||||
reportProgress(reporter, 'Processing step', customData);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
updates: [{ type: 'progress', data: customData }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call reporter.complete through reportComplete', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const output = { result: 'success' };
|
||||
|
||||
reportComplete(reporter, output);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'completed',
|
||||
updates: [{ type: 'output', data: output }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call reporter.error through reportError', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const error: ToolError = { message: 'Test error', code: 'TEST_ERROR' };
|
||||
|
||||
reportError(reporter, error);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'error',
|
||||
updates: [
|
||||
{
|
||||
type: 'error',
|
||||
data: { message: 'Test error', code: 'TEST_ERROR', details: undefined },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create batch reporter through createBatchProgressReporter', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const batchReporter = createBatchProgressReporter(reporter, 'Batch operation');
|
||||
|
||||
batchReporter.init(1);
|
||||
batchReporter.next('Test item');
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
updates: [
|
||||
{
|
||||
type: 'progress',
|
||||
data: { message: 'Batch operation: Processing item 1 of 1: Test item' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update customDisplayTitle when provided in start options', () => {
|
||||
const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool');
|
||||
const input = { test: 'data' };
|
||||
|
||||
reporter.start(input, { customDisplayTitle: 'Custom Title from Options' });
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Test Tool',
|
||||
customDisplayTitle: 'Custom Title from Options',
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: input,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve initial custom title when start is called without options', () => {
|
||||
const reporter = createProgressReporter(
|
||||
mockConfig,
|
||||
'test_tool',
|
||||
'Test Tool',
|
||||
'Initial Custom Title',
|
||||
);
|
||||
const input = { test: 'data' };
|
||||
|
||||
reporter.start(input);
|
||||
|
||||
expect(mockWriter).toHaveBeenCalledWith({
|
||||
type: 'tool',
|
||||
toolName: 'test_tool',
|
||||
toolCallId: 'test-tool-call-id',
|
||||
displayTitle: 'Test Tool',
|
||||
customDisplayTitle: 'Initial Custom Title',
|
||||
status: 'running',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: input,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -126,9 +126,11 @@ function extractNodeDetails(nodeType: INodeTypeDescription): NodeDetails {
|
||||
* Factory function to create the node details tool
|
||||
*/
|
||||
export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
return tool(
|
||||
const DISPLAY_TITLE = 'Getting node details';
|
||||
|
||||
const dynamicTool = tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(config, 'get_node_details');
|
||||
const reporter = createProgressReporter(config, 'get_node_details', DISPLAY_TITLE);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@@ -194,4 +196,9 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) {
|
||||
schema: nodeDetailsSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,9 +112,11 @@ function buildResponseMessage(
|
||||
* Factory function to create the node search tool
|
||||
*/
|
||||
export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) {
|
||||
return tool(
|
||||
(input: unknown, config) => {
|
||||
const reporter = createProgressReporter(config, 'search_nodes');
|
||||
const DISPLAY_TITLE = 'Searching nodes';
|
||||
|
||||
const dynamicTool = tool(
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'search_nodes', DISPLAY_TITLE);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@@ -210,4 +212,9 @@ You can search for multiple different criteria at once by providing an array of
|
||||
schema: nodeSearchSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,9 +73,11 @@ function buildResponseMessage(
|
||||
* Factory function to create the remove node tool
|
||||
*/
|
||||
export function createRemoveNodeTool(_logger?: Logger) {
|
||||
return tool(
|
||||
const DISPLAY_TITLE = 'Removing node';
|
||||
|
||||
const dynamicTool = tool(
|
||||
(input, config) => {
|
||||
const reporter = createProgressReporter(config, 'remove_node');
|
||||
const reporter = createProgressReporter(config, 'remove_node', DISPLAY_TITLE);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
@@ -152,4 +154,9 @@ export function createRemoveNodeTool(_logger?: Logger) {
|
||||
schema: removeNodeSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ jest.mock('crypto', () => ({
|
||||
|
||||
describe('AddNodeTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let addNodeTool: ReturnType<typeof createAddNodeTool>;
|
||||
let addNodeTool: ReturnType<typeof createAddNodeTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
@@ -47,7 +47,7 @@ describe('AddNodeTool', () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.agent];
|
||||
addNodeTool = createAddNodeTool(nodeTypesList);
|
||||
addNodeTool = createAddNodeTool(nodeTypesList).tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -298,4 +298,23 @@ describe('AddNodeTool', () => {
|
||||
expect(addedNode?.position?.[1]).toBeGreaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomDisplayTitle', () => {
|
||||
it('should return node display name when nodeType exists', () => {
|
||||
const tool = createAddNodeTool(nodeTypesList);
|
||||
const result = tool.getCustomDisplayTitle?.({
|
||||
nodeType: 'n8n-nodes-base.code',
|
||||
name: 'My Code',
|
||||
});
|
||||
|
||||
expect(result).toBe('Adding Code node');
|
||||
});
|
||||
|
||||
it('should return default title when nodeType not found or missing', () => {
|
||||
const tool = createAddNodeTool(nodeTypesList);
|
||||
|
||||
expect(tool.getCustomDisplayTitle?.({ nodeType: 'unknown.node' })).toBe('Adding node');
|
||||
expect(tool.getCustomDisplayTitle?.({ name: 'Some Node' })).toBe('Adding node');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ jest.mock('@langchain/langgraph', () => ({
|
||||
|
||||
describe('ConnectNodesTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let connectNodesTool: ReturnType<typeof createConnectNodesTool>;
|
||||
let connectNodesTool: ReturnType<typeof createConnectNodesTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
@@ -39,7 +39,7 @@ describe('ConnectNodesTool', () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.agent];
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList).tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -112,7 +112,7 @@ describe('ConnectNodesTool', () => {
|
||||
|
||||
// Update node types list
|
||||
nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, agentNodeType, toolNodeType];
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList).tool;
|
||||
|
||||
const existingWorkflow = createWorkflow([
|
||||
createNode({ id: 'agent1', name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
|
||||
@@ -176,7 +176,7 @@ describe('ConnectNodesTool', () => {
|
||||
// Replace the agent node type in the list
|
||||
nodeTypesList = nodeTypesList.filter((nt) => nt.name !== '@n8n/n8n-nodes-langchain.agent');
|
||||
nodeTypesList.push(agentNodeType, languageModelNodeType);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList).tool;
|
||||
|
||||
const existingWorkflow = createWorkflow([
|
||||
createNode({
|
||||
@@ -374,7 +374,7 @@ describe('ConnectNodesTool', () => {
|
||||
});
|
||||
|
||||
nodeTypesList.push(multiOutputNode);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList);
|
||||
connectNodesTool = createConnectNodesTool(nodeTypesList).tool;
|
||||
|
||||
const existingWorkflow = createWorkflow([
|
||||
createNode({ id: 'multi1', name: 'Multi Output', type: 'test.multiOutput' }),
|
||||
|
||||
@@ -27,7 +27,7 @@ jest.mock('@langchain/langgraph', () => ({
|
||||
|
||||
describe('NodeDetailsTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let nodeDetailsTool: ReturnType<typeof createNodeDetailsTool>;
|
||||
let nodeDetailsTool: ReturnType<typeof createNodeDetailsTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -43,7 +43,7 @@ describe('NodeDetailsTool', () => {
|
||||
nodeTypes.mergeNode,
|
||||
nodeTypes.vectorStoreNode,
|
||||
];
|
||||
nodeDetailsTool = createNodeDetailsTool(nodeTypesList);
|
||||
nodeDetailsTool = createNodeDetailsTool(nodeTypesList).tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -296,7 +296,7 @@ describe('NodeDetailsTool', () => {
|
||||
});
|
||||
|
||||
const testNodeTypes = [...nodeTypesList, nodeWithManyProps];
|
||||
const testTool = createNodeDetailsTool(testNodeTypes);
|
||||
const testTool = createNodeDetailsTool(testNodeTypes).tool;
|
||||
|
||||
const mockConfig = createToolConfig('get_node_details', 'test-call-11');
|
||||
|
||||
@@ -373,7 +373,7 @@ describe('NodeDetailsTool', () => {
|
||||
});
|
||||
|
||||
const testNodeTypes = [...nodeTypesList, complexNode];
|
||||
const testTool = createNodeDetailsTool(testNodeTypes);
|
||||
const testTool = createNodeDetailsTool(testNodeTypes).tool;
|
||||
|
||||
const mockConfig = createToolConfig('get_node_details', 'test-call-13');
|
||||
|
||||
@@ -436,7 +436,7 @@ describe('NodeDetailsTool', () => {
|
||||
});
|
||||
|
||||
const testNodeTypes = [...nodeTypesList, noOutputNode];
|
||||
const testTool = createNodeDetailsTool(testNodeTypes);
|
||||
const testTool = createNodeDetailsTool(testNodeTypes).tool;
|
||||
|
||||
const mockConfig = createToolConfig('get_node_details', 'test-call-15');
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jest.mock('@langchain/langgraph', () => ({
|
||||
|
||||
describe('NodeSearchTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let nodeSearchTool: ReturnType<typeof createNodeSearchTool>;
|
||||
let nodeSearchTool: ReturnType<typeof createNodeSearchTool>['tool'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -75,7 +75,7 @@ describe('NodeSearchTool', () => {
|
||||
// Expression-based node
|
||||
nodeTypes.vectorStoreNode,
|
||||
];
|
||||
nodeSearchTool = createNodeSearchTool(nodeTypesList);
|
||||
nodeSearchTool = createNodeSearchTool(nodeTypesList).tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -222,7 +222,7 @@ describe('NodeSearchTool', () => {
|
||||
{
|
||||
queries: [
|
||||
{
|
||||
// @ts-expect-error Testing invalid input
|
||||
// @ts-expect-error testing invalid query type
|
||||
queryType: 'invalid',
|
||||
query: 'test',
|
||||
},
|
||||
@@ -342,7 +342,7 @@ describe('NodeSearchTool', () => {
|
||||
}),
|
||||
);
|
||||
const testNodeTypes = [...nodeTypesList, ...manyHttpNodes];
|
||||
const testTool = createNodeSearchTool(testNodeTypes);
|
||||
const testTool = createNodeSearchTool(testNodeTypes).tool;
|
||||
|
||||
const mockConfig = createToolConfig('search_nodes', 'test-call-13');
|
||||
|
||||
|
||||
@@ -26,14 +26,14 @@ jest.mock('@langchain/langgraph', () => ({
|
||||
}));
|
||||
|
||||
describe('RemoveNodeTool', () => {
|
||||
let removeNodeTool: ReturnType<typeof createRemoveNodeTool>;
|
||||
let removeNodeTool: ReturnType<typeof createRemoveNodeTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
removeNodeTool = createRemoveNodeTool();
|
||||
removeNodeTool = createRemoveNodeTool().tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -36,7 +36,7 @@ jest.mock('../../../src/chains/parameter-updater', () => ({
|
||||
|
||||
describe('UpdateNodeParametersTool', () => {
|
||||
let nodeTypesList: INodeTypeDescription[];
|
||||
let updateNodeParametersTool: ReturnType<typeof createUpdateNodeParametersTool>;
|
||||
let updateNodeParametersTool: ReturnType<typeof createUpdateNodeParametersTool>['tool'];
|
||||
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
|
||||
typeof getCurrentTaskInput
|
||||
>;
|
||||
@@ -59,7 +59,7 @@ describe('UpdateNodeParametersTool', () => {
|
||||
parameterUpdaterModule.createParameterUpdaterChain.mockReturnValue(mockChain);
|
||||
|
||||
nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.setNode];
|
||||
updateNodeParametersTool = createUpdateNodeParametersTool(nodeTypesList, mockLLM);
|
||||
updateNodeParametersTool = createUpdateNodeParametersTool(nodeTypesList, mockLLM).tool;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -468,7 +468,7 @@ describe('UpdateNodeParametersTool', () => {
|
||||
};
|
||||
|
||||
// Create tool with custom node type
|
||||
const customTool = createUpdateNodeParametersTool([customNodeType], mockLLM);
|
||||
const customTool = createUpdateNodeParametersTool([customNodeType], mockLLM).tool;
|
||||
|
||||
const existingWorkflow = createWorkflow([
|
||||
createNode({
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from './utils/parameter-update.utils';
|
||||
import type { UpdateNodeParametersOutput } from '../types/tools';
|
||||
|
||||
const DISPLAY_TITLE = 'Updating node parameters';
|
||||
|
||||
/**
|
||||
* Schema for update node parameters input
|
||||
*/
|
||||
@@ -111,6 +113,17 @@ async function processParameterUpdates(
|
||||
return fixExpressionPrefixes(newParameters.parameters) as INodeParameters;
|
||||
}
|
||||
|
||||
function getCustomNodeTitle(input: Record<string, unknown>, nodes?: INode[]): string {
|
||||
if ('nodeId' in input && typeof input['nodeId'] === 'string') {
|
||||
const targetNode = nodes?.find((node) => node.id === input.nodeId);
|
||||
if (targetNode) {
|
||||
return `Updating "${targetNode.name}" node parameters`;
|
||||
}
|
||||
}
|
||||
|
||||
return DISPLAY_TITLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function to create the update node parameters tool
|
||||
*/
|
||||
@@ -120,22 +133,24 @@ export function createUpdateNodeParametersTool(
|
||||
logger?: Logger,
|
||||
instanceUrl?: string,
|
||||
) {
|
||||
return tool(
|
||||
const dynamicTool = tool(
|
||||
async (input, config) => {
|
||||
const reporter = createProgressReporter(config, 'update_node_parameters');
|
||||
const reporter = createProgressReporter(config, 'update_node_parameters', DISPLAY_TITLE);
|
||||
|
||||
try {
|
||||
// Validate input using Zod schema
|
||||
const validatedInput = updateNodeParametersSchema.parse(input);
|
||||
const { nodeId, changes } = validatedInput;
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput);
|
||||
|
||||
// Get current state
|
||||
const state = getWorkflowState();
|
||||
const workflow = getCurrentWorkflow(state);
|
||||
|
||||
// Report tool start
|
||||
reporter.start(validatedInput, {
|
||||
customDisplayTitle: getCustomNodeTitle(input, workflow.nodes),
|
||||
});
|
||||
|
||||
// Find the node
|
||||
const node = validateNodeExists(nodeId, workflow.nodes);
|
||||
if (!node) {
|
||||
@@ -233,4 +248,9 @@ export function createUpdateNodeParametersTool(
|
||||
schema: updateNodeParametersSchema,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tool: dynamicTool,
|
||||
displayTitle: DISPLAY_TITLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ToolProgressMessage<TToolName extends string = string> {
|
||||
toolCallId?: string;
|
||||
status: 'running' | 'completed' | 'error';
|
||||
updates: ProgressUpdate[];
|
||||
displayTitle?: string; // Name of tool action in UI, for example "Adding nodes"
|
||||
customDisplayTitle?: string; // Custom name for tool action in UI, for example "Adding Gmail node"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +43,7 @@ export interface ToolError {
|
||||
* Progress reporter interface for tools
|
||||
*/
|
||||
export interface ProgressReporter {
|
||||
start: <T>(input: T) => void;
|
||||
start: <T>(input: T, options?: { customDisplayTitle: string }) => void;
|
||||
progress: (message: string, data?: Record<string, unknown>) => void;
|
||||
complete: <T>(output: T) => void;
|
||||
error: (error: ToolError) => void;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { ToolCall } from '@langchain/core/messages/tool';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
|
||||
import type {
|
||||
AgentMessageChunk,
|
||||
@@ -7,6 +9,12 @@ import type {
|
||||
StreamOutput,
|
||||
} from '../types/streaming';
|
||||
|
||||
export interface BuilderTool {
|
||||
tool: DynamicStructuredTool;
|
||||
displayTitle: string;
|
||||
getCustomDisplayTitle?: (values: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools which should trigger canvas updates
|
||||
*/
|
||||
@@ -129,78 +137,135 @@ export async function* createStreamProcessor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a HumanMessage into the expected output format
|
||||
*/
|
||||
function formatHumanMessage(msg: HumanMessage): Record<string, unknown> {
|
||||
return {
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: msg.content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process array content from AIMessage and return formatted text messages
|
||||
*/
|
||||
function processArrayContent(content: unknown[]): Array<Record<string, unknown>> {
|
||||
const textMessages = content.filter(
|
||||
(c): c is { type: string; text: string } =>
|
||||
typeof c === 'object' && c !== null && 'type' in c && c.type === 'text' && 'text' in c,
|
||||
);
|
||||
|
||||
return textMessages.map((textMessage) => ({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: textMessage.text,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process AIMessage content and return formatted messages
|
||||
*/
|
||||
function processAIMessageContent(msg: AIMessage): Array<Record<string, unknown>> {
|
||||
if (!msg.content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(msg.content)) {
|
||||
return processArrayContent(msg.content);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: msg.content,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a formatted tool call message
|
||||
*/
|
||||
function createToolCallMessage(
|
||||
toolCall: ToolCall,
|
||||
builderTool?: BuilderTool,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
toolCallId: toolCall.id,
|
||||
role: 'assistant',
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
displayTitle: builderTool?.displayTitle,
|
||||
customDisplayTitle: toolCall.args && builderTool?.getCustomDisplayTitle?.(toolCall.args),
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: toolCall.args || {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process tool calls from AIMessage and return formatted tool messages
|
||||
*/
|
||||
function processToolCalls(
|
||||
toolCalls: ToolCall[],
|
||||
builderTools?: BuilderTool[],
|
||||
): Array<Record<string, unknown>> {
|
||||
return toolCalls.map((toolCall) => {
|
||||
const builderTool = builderTools?.find((bt) => bt.tool.name === toolCall.name);
|
||||
return createToolCallMessage(toolCall, builderTool);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a ToolMessage and add its output to the corresponding tool call
|
||||
*/
|
||||
function processToolMessage(
|
||||
msg: ToolMessage,
|
||||
formattedMessages: Array<Record<string, unknown>>,
|
||||
): void {
|
||||
const toolCallId = msg.tool_call_id;
|
||||
|
||||
// Find the tool message by ID (search backwards for efficiency)
|
||||
for (let i = formattedMessages.length - 1; i >= 0; i--) {
|
||||
const m = formattedMessages[i];
|
||||
if (m.type === 'tool' && m.id === toolCallId) {
|
||||
// Add output to updates array
|
||||
m.updates ??= [];
|
||||
(m.updates as Array<Record<string, unknown>>).push({
|
||||
type: 'output',
|
||||
data: typeof msg.content === 'string' ? { result: msg.content } : msg.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMessages(
|
||||
messages: Array<AIMessage | HumanMessage | ToolMessage>,
|
||||
builderTools?: BuilderTool[],
|
||||
): Array<Record<string, unknown>> {
|
||||
const formattedMessages: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg instanceof HumanMessage) {
|
||||
formattedMessages.push({
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: msg.content,
|
||||
});
|
||||
formattedMessages.push(formatHumanMessage(msg));
|
||||
} else if (msg instanceof AIMessage) {
|
||||
// Add the AI message content if it exists
|
||||
if (msg.content) {
|
||||
if (Array.isArray(msg.content)) {
|
||||
// Handle array content (multi-part messages)
|
||||
const textMessages = msg.content.filter((c) => c.type === 'text');
|
||||
// Add AI message content
|
||||
formattedMessages.push(...processAIMessageContent(msg));
|
||||
|
||||
textMessages.forEach((textMessage) => {
|
||||
if (textMessage.type !== 'text') {
|
||||
return;
|
||||
}
|
||||
formattedMessages.push({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: textMessage.text,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
formattedMessages.push({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: msg.content,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle tool calls in AI messages
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
// Add tool messages for each tool call
|
||||
for (const toolCall of msg.tool_calls) {
|
||||
formattedMessages.push({
|
||||
id: toolCall.id,
|
||||
toolCallId: toolCall.id,
|
||||
role: 'assistant',
|
||||
type: 'tool',
|
||||
toolName: toolCall.name,
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: toolCall.args || {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
// Add tool calls if present
|
||||
if (msg.tool_calls?.length) {
|
||||
formattedMessages.push(...processToolCalls(msg.tool_calls, builderTools));
|
||||
}
|
||||
} else if (msg instanceof ToolMessage) {
|
||||
// Find the tool message by ID and add the output
|
||||
const toolCallId = msg.tool_call_id;
|
||||
for (let i = formattedMessages.length - 1; i >= 0; i--) {
|
||||
const m = formattedMessages[i];
|
||||
if (m.type === 'tool' && m.id === toolCallId) {
|
||||
// Add output to updates array
|
||||
m.updates ??= [];
|
||||
(m.updates as Array<Record<string, unknown>>).push({
|
||||
type: 'output',
|
||||
data: typeof msg.content === 'string' ? { result: msg.content } : msg.content,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
processToolMessage(msg, formattedMessages);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
|
||||
import type {
|
||||
AgentMessageChunk,
|
||||
@@ -471,5 +472,430 @@ describe('stream-processor', () => {
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle AIMessage with array content (multi-part messages)', () => {
|
||||
const message = new AIMessage('');
|
||||
// Manually set the content to array format since LangChain constructor might not accept arrays directly
|
||||
message.content = [
|
||||
{ type: 'text', text: 'First part' },
|
||||
{ type: 'text', text: 'Second part' },
|
||||
{ type: 'image', url: 'http://example.com/image.png' },
|
||||
];
|
||||
|
||||
const messages = [message];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'First part',
|
||||
});
|
||||
expect(result[1]).toEqual({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Second part',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle AIMessage with array content containing no text', () => {
|
||||
const message = new AIMessage('');
|
||||
message.content = [
|
||||
{ type: 'image', url: 'http://example.com/image.png' },
|
||||
{ type: 'video', url: 'http://example.com/video.mp4' },
|
||||
];
|
||||
|
||||
const messages = [message];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle AIMessage with empty array content', () => {
|
||||
const message = new AIMessage('');
|
||||
message.content = [];
|
||||
|
||||
const messages = [message];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle AIMessage with empty string content', () => {
|
||||
const messages = [new AIMessage('')];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle AIMessage with null content', () => {
|
||||
const message = new AIMessage('');
|
||||
// Test the function's robustness by simulating a corrupted message
|
||||
Object.defineProperty(message, 'content', { value: null, writable: true });
|
||||
|
||||
const messages = [message];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use builder tool display titles', () => {
|
||||
const builderTools = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
displayTitle: 'Add Node',
|
||||
},
|
||||
{
|
||||
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
|
||||
displayTitle: 'Connect Nodes',
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'n8n-nodes-base.code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [aiMessage];
|
||||
|
||||
const result = formatMessages(messages, builderTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: 'call-1',
|
||||
toolCallId: 'call-1',
|
||||
role: 'assistant',
|
||||
type: 'tool',
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Add Node',
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: { nodeType: 'n8n-nodes-base.code' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use custom display titles from builder tools', () => {
|
||||
const builderTools = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
displayTitle: 'Add Node',
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) =>
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
`Add ${values.nodeType} Node`,
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'Code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [aiMessage];
|
||||
|
||||
const result = formatMessages(messages, builderTools);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
id: 'call-1',
|
||||
toolCallId: 'call-1',
|
||||
role: 'assistant',
|
||||
type: 'tool',
|
||||
toolName: 'add_nodes',
|
||||
displayTitle: 'Add Node',
|
||||
customDisplayTitle: 'Add Code Node',
|
||||
status: 'completed',
|
||||
updates: [
|
||||
{
|
||||
type: 'input',
|
||||
data: { nodeType: 'Code' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle custom display title when args is null/undefined', () => {
|
||||
const builderTools = [
|
||||
{
|
||||
tool: { name: 'clear_workflow' } as DynamicStructuredTool,
|
||||
displayTitle: 'Clear Workflow',
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) =>
|
||||
`Custom: ${Object.keys(values).length} args`,
|
||||
},
|
||||
];
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
const toolCall = {
|
||||
id: 'call-1',
|
||||
name: 'clear_workflow',
|
||||
args: {} as Record<string, unknown>,
|
||||
type: 'tool_call' as const,
|
||||
};
|
||||
// Simulate a corrupted tool call with null args
|
||||
Object.defineProperty(toolCall, 'args', { value: null, writable: true });
|
||||
aiMessage.tool_calls = [toolCall];
|
||||
|
||||
const messages = [aiMessage];
|
||||
|
||||
const result = formatMessages(messages, builderTools);
|
||||
|
||||
expect(result[0].customDisplayTitle).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle tool call with undefined args', () => {
|
||||
const aiMessage = new AIMessage('');
|
||||
const toolCall = {
|
||||
id: 'call-1',
|
||||
name: 'clear_workflow',
|
||||
args: {} as Record<string, unknown>,
|
||||
type: 'tool_call' as const,
|
||||
};
|
||||
// Simulate a corrupted tool call with undefined args
|
||||
Object.defineProperty(toolCall, 'args', { value: undefined, writable: true });
|
||||
aiMessage.tool_calls = [toolCall];
|
||||
|
||||
const messages = [aiMessage];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
expect(result[0].updates?.[0]).toEqual({
|
||||
type: 'input',
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ToolMessage with no matching tool call', () => {
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Orphaned tool result',
|
||||
tool_call_id: 'non-existent-call',
|
||||
});
|
||||
|
||||
const messages = [toolMessage];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple ToolMessages for the same tool call', () => {
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'n8n-nodes-base.code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const toolMessage1 = new ToolMessage({
|
||||
content: 'First result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const toolMessage2 = new ToolMessage({
|
||||
content: 'Second result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const messages = [aiMessage, toolMessage1, toolMessage2];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].updates).toHaveLength(3);
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
expect(result[0].updates?.[1]).toEqual({
|
||||
type: 'output',
|
||||
data: { result: 'First result' },
|
||||
});
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
expect(result[0].updates?.[2]).toEqual({
|
||||
type: 'output',
|
||||
data: { result: 'Second result' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ToolMessage appearing before corresponding AIMessage tool call', () => {
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Tool result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'n8n-nodes-base.code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const messages = [toolMessage, aiMessage];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
// When ToolMessage comes before AIMessage, the ToolMessage cannot find the tool call to attach to
|
||||
// so it gets ignored, and only the tool call from AIMessage is processed
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].updates).toHaveLength(1); // Only the input, no output since ToolMessage came before
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
expect(result[0].updates?.[0]).toEqual({
|
||||
type: 'input',
|
||||
data: { nodeType: 'n8n-nodes-base.code' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty messages array', () => {
|
||||
const result = formatMessages([]);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle messages with unknown message type', () => {
|
||||
// Create an object that doesn't match any of the expected message types
|
||||
const unknownMessage = {
|
||||
content: 'Unknown message type',
|
||||
type: 'unknown',
|
||||
};
|
||||
|
||||
const result = formatMessages([
|
||||
unknownMessage as unknown as AIMessage | HumanMessage | ToolMessage,
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should preserve initialization of updates array when undefined', () => {
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'n8n-nodes-base.code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Tool result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const messages = [aiMessage, toolMessage];
|
||||
|
||||
const result = formatMessages(messages);
|
||||
|
||||
expect(result[0].updates).toBeDefined();
|
||||
expect(Array.isArray(result[0].updates)).toBe(true);
|
||||
expect(result[0].updates).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle complex scenario with multiple message types and builder tools', () => {
|
||||
const builderTools = [
|
||||
{
|
||||
tool: { name: 'add_nodes' } as DynamicStructuredTool,
|
||||
displayTitle: 'Add Node',
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
getCustomDisplayTitle: (values: Record<string, unknown>) => `Add ${values.nodeType} Node`,
|
||||
},
|
||||
{
|
||||
tool: { name: 'connect_nodes' } as DynamicStructuredTool,
|
||||
displayTitle: 'Connect Nodes',
|
||||
},
|
||||
];
|
||||
|
||||
const humanMessage = new HumanMessage('Please create a workflow');
|
||||
|
||||
const aiMessage1 = new AIMessage('');
|
||||
aiMessage1.content = [
|
||||
{ type: 'text', text: 'I will help you create a workflow.' },
|
||||
{ type: 'text', text: 'Let me add some nodes.' },
|
||||
];
|
||||
|
||||
const aiMessage2 = new AIMessage('');
|
||||
aiMessage2.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'Code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'connect_nodes',
|
||||
args: { source: 'node1', target: 'node2' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
const toolMessage1 = new ToolMessage({
|
||||
content: 'Node added successfully',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const toolMessage2 = new ToolMessage({
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
content: { success: true, connectionId: 'conn-1' },
|
||||
tool_call_id: 'call-2',
|
||||
});
|
||||
|
||||
const messages = [humanMessage, aiMessage1, aiMessage2, toolMessage1, toolMessage2];
|
||||
|
||||
const result = formatMessages(messages, builderTools);
|
||||
|
||||
expect(result).toHaveLength(5); // 1 user + 2 text messages + 2 tool calls
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
role: 'user',
|
||||
type: 'message',
|
||||
text: 'Please create a workflow',
|
||||
});
|
||||
|
||||
expect(result[1]).toEqual({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'I will help you create a workflow.',
|
||||
});
|
||||
|
||||
expect(result[2]).toEqual({
|
||||
role: 'assistant',
|
||||
type: 'message',
|
||||
text: 'Let me add some nodes.',
|
||||
});
|
||||
|
||||
expect(result[3].toolName).toBe('add_nodes');
|
||||
expect(result[3].displayTitle).toBe('Add Node');
|
||||
expect(result[3].customDisplayTitle).toBe('Add Code Node');
|
||||
expect(result[3].updates).toHaveLength(2);
|
||||
|
||||
expect(result[4].toolName).toBe('connect_nodes');
|
||||
expect(result[4].displayTitle).toBe('Connect Nodes');
|
||||
expect(result[4].customDisplayTitle).toBeUndefined();
|
||||
expect(result[4].updates).toHaveLength(2);
|
||||
// @ts-expect-error Lnagchain types are not propagated
|
||||
expect(result[4].updates?.[1]).toEqual({
|
||||
type: 'output',
|
||||
data: { success: true, connectionId: 'conn-1' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,9 @@ import {
|
||||
type NodeExecutionSchema,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { workflowNameChain } from '@/chains/workflow-name';
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants';
|
||||
|
||||
import { conversationCompactChain } from './chains/conversation-compact';
|
||||
import { workflowNameChain } from './chains/workflow-name';
|
||||
import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from './constants';
|
||||
import { LLMServiceError, ValidationError } from './errors';
|
||||
import { createAddNodeTool } from './tools/add-node.tool';
|
||||
import { createConnectNodesTool } from './tools/connect-nodes.tool';
|
||||
@@ -27,7 +26,7 @@ import { createRemoveNodeTool } from './tools/remove-node.tool';
|
||||
import { createUpdateNodeParametersTool } from './tools/update-node-parameters.tool';
|
||||
import type { SimpleWorkflow } from './types/workflow';
|
||||
import { processOperations } from './utils/operations-processor';
|
||||
import { createStreamProcessor, formatMessages } from './utils/stream-processor';
|
||||
import { createStreamProcessor, formatMessages, type BuilderTool } from './utils/stream-processor';
|
||||
import { extractLastTokenUsage } from './utils/token-usage';
|
||||
import { executeToolsInParallel } from './utils/tool-executor';
|
||||
import { WorkflowState } from './workflow-state';
|
||||
@@ -74,8 +73,8 @@ export class WorkflowBuilderAgent {
|
||||
this.instanceUrl = config.instanceUrl;
|
||||
}
|
||||
|
||||
private createWorkflow() {
|
||||
const tools = [
|
||||
private getBuilderTools(): BuilderTool[] {
|
||||
return [
|
||||
createNodeSearchTool(this.parsedNodeTypes),
|
||||
createNodeDetailsTool(this.parsedNodeTypes),
|
||||
createAddNodeTool(this.parsedNodeTypes),
|
||||
@@ -88,6 +87,13 @@ export class WorkflowBuilderAgent {
|
||||
this.instanceUrl,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private createWorkflow() {
|
||||
const builderTools = this.getBuilderTools();
|
||||
|
||||
// Extract just the tools for LLM binding
|
||||
const tools = builderTools.map((bt) => bt.tool);
|
||||
|
||||
// Create a map for quick tool lookup
|
||||
const toolMap = new Map(tools.map((tool) => [tool.name, tool]));
|
||||
@@ -485,7 +491,7 @@ export class WorkflowBuilderAgent {
|
||||
|
||||
sessions.push({
|
||||
sessionId: threadId,
|
||||
messages: formatMessages(messages),
|
||||
messages: formatMessages(messages, this.getBuilderTools()),
|
||||
lastUpdated: checkpoint.checkpoint.ts,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user