mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00:00
feat(core): Explicitly warn if tool passed binary data to Agent (#14071)
This commit is contained in:
@@ -10,10 +10,12 @@ import type {
|
|||||||
Workflow,
|
Workflow,
|
||||||
INodeType,
|
INodeType,
|
||||||
INodeTypes,
|
INodeTypes,
|
||||||
|
IExecuteFunctions,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
import { ExecuteContext } from '../../execute-context';
|
import { ExecuteContext } from '../../execute-context';
|
||||||
|
import { makeHandleToolInvocation } from '../get-input-connection-data';
|
||||||
|
|
||||||
describe('getInputConnectionData', () => {
|
describe('getInputConnectionData', () => {
|
||||||
const agentNode = mock<INode>({
|
const agentNode = mock<INode>({
|
||||||
@@ -364,3 +366,141 @@ describe('getInputConnectionData', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('makeHandleToolInvocation', () => {
|
||||||
|
const connectedNode = mock<INode>({
|
||||||
|
name: 'Test Tool Node',
|
||||||
|
type: 'test.tool',
|
||||||
|
});
|
||||||
|
const execute = jest.fn();
|
||||||
|
const connectedNodeType = mock<INodeType>({
|
||||||
|
execute,
|
||||||
|
});
|
||||||
|
const contextFactory = jest.fn();
|
||||||
|
const toolArgs = { key: 'value' };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
it('should return stringified results when execution is successful', async () => {
|
||||||
|
const mockContext = mock<IExecuteFunctions>();
|
||||||
|
contextFactory.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
const mockResult = [[{ json: { result: 'success' } }]];
|
||||||
|
execute.mockResolvedValueOnce(mockResult);
|
||||||
|
|
||||||
|
const handleToolInvocation = makeHandleToolInvocation(
|
||||||
|
contextFactory,
|
||||||
|
connectedNode,
|
||||||
|
connectedNodeType,
|
||||||
|
);
|
||||||
|
const result = await handleToolInvocation(toolArgs);
|
||||||
|
|
||||||
|
expect(result).toBe(JSON.stringify([{ result: 'success' }]));
|
||||||
|
expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionTypes.AiTool, 0, [
|
||||||
|
[{ json: { response: [{ result: 'success' }] } }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle binary data and return a warning message', async () => {
|
||||||
|
const mockContext = mock<IExecuteFunctions>();
|
||||||
|
contextFactory.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
const mockResult = [[{ json: {}, binary: { file: 'data' } }]];
|
||||||
|
execute.mockResolvedValueOnce(mockResult);
|
||||||
|
|
||||||
|
const handleToolInvocation = makeHandleToolInvocation(
|
||||||
|
contextFactory,
|
||||||
|
connectedNode,
|
||||||
|
connectedNodeType,
|
||||||
|
);
|
||||||
|
const result = await handleToolInvocation(toolArgs);
|
||||||
|
|
||||||
|
expect(result).toBe(
|
||||||
|
'"Error: The Tool attempted to return binary data, which is not supported in Agents"',
|
||||||
|
);
|
||||||
|
expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionTypes.AiTool, 0, [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
response:
|
||||||
|
'Error: The Tool attempted to return binary data, which is not supported in Agents',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue if json and binary data exist', async () => {
|
||||||
|
const warnFn = jest.fn();
|
||||||
|
const mockContext = mock<IExecuteFunctions>({
|
||||||
|
logger: {
|
||||||
|
warn: warnFn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
contextFactory.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
const mockResult = [[{ json: { a: 3 }, binary: { file: 'data' } }]];
|
||||||
|
execute.mockResolvedValueOnce(mockResult);
|
||||||
|
|
||||||
|
const handleToolInvocation = makeHandleToolInvocation(
|
||||||
|
contextFactory,
|
||||||
|
connectedNode,
|
||||||
|
connectedNodeType,
|
||||||
|
);
|
||||||
|
const result = await handleToolInvocation(toolArgs);
|
||||||
|
|
||||||
|
expect(result).toBe('[{"a":3}]');
|
||||||
|
expect(mockContext.addOutputData).toHaveBeenCalledWith(NodeConnectionTypes.AiTool, 0, [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
json: {
|
||||||
|
response: [{ a: 3 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
expect(warnFn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle execution errors and return an error message', async () => {
|
||||||
|
const mockContext = mock<IExecuteFunctions>();
|
||||||
|
contextFactory.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
const error = new Error('Execution failed');
|
||||||
|
execute.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const handleToolInvocation = makeHandleToolInvocation(
|
||||||
|
contextFactory,
|
||||||
|
connectedNode,
|
||||||
|
connectedNodeType,
|
||||||
|
);
|
||||||
|
const result = await handleToolInvocation(toolArgs);
|
||||||
|
|
||||||
|
expect(result).toBe('Error during node execution: Execution failed');
|
||||||
|
expect(mockContext.addOutputData).toHaveBeenCalledWith(
|
||||||
|
NodeConnectionTypes.AiTool,
|
||||||
|
0,
|
||||||
|
expect.any(NodeOperationError),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should increment the toolRunIndex for each invocation', async () => {
|
||||||
|
const mockContext = mock<IExecuteFunctions>();
|
||||||
|
contextFactory.mockReturnValue(mockContext);
|
||||||
|
|
||||||
|
const handleToolInvocation = makeHandleToolInvocation(
|
||||||
|
contextFactory,
|
||||||
|
connectedNode,
|
||||||
|
connectedNodeType,
|
||||||
|
);
|
||||||
|
|
||||||
|
await handleToolInvocation(toolArgs);
|
||||||
|
await handleToolInvocation(toolArgs);
|
||||||
|
await handleToolInvocation(toolArgs);
|
||||||
|
|
||||||
|
expect(contextFactory).toHaveBeenCalledWith(0);
|
||||||
|
expect(contextFactory).toHaveBeenCalledWith(1);
|
||||||
|
expect(contextFactory).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import type {
|
|||||||
WorkflowExecuteMode,
|
WorkflowExecuteMode,
|
||||||
SupplyData,
|
SupplyData,
|
||||||
AINodeConnectionType,
|
AINodeConnectionType,
|
||||||
|
IDataObject,
|
||||||
|
ISupplyDataFunctions,
|
||||||
|
INodeType,
|
||||||
|
INode,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
@@ -24,6 +28,54 @@ import type { ExecuteContext, WebhookContext } from '../../node-execution-contex
|
|||||||
// eslint-disable-next-line import/no-cycle
|
// eslint-disable-next-line import/no-cycle
|
||||||
import { SupplyDataContext } from '../../node-execution-context/supply-data-context';
|
import { SupplyDataContext } from '../../node-execution-context/supply-data-context';
|
||||||
|
|
||||||
|
export function makeHandleToolInvocation(
|
||||||
|
contextFactory: (runIndex: number) => ISupplyDataFunctions,
|
||||||
|
node: INode,
|
||||||
|
nodeType: INodeType,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* This keeps track of how many times this specific AI tool node has been invoked.
|
||||||
|
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
|
||||||
|
*/
|
||||||
|
let toolRunIndex = 0;
|
||||||
|
return async (toolArgs: IDataObject) => {
|
||||||
|
const runIndex = toolRunIndex++;
|
||||||
|
const context = contextFactory(runIndex);
|
||||||
|
context.addInputData(NodeConnectionTypes.AiTool, [[{ json: toolArgs }]]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the sub-node with the proxied context
|
||||||
|
const result = await nodeType.execute?.call(context as unknown as IExecuteFunctions);
|
||||||
|
|
||||||
|
// Process and map the results
|
||||||
|
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
||||||
|
let response: string | typeof mappedResults = mappedResults;
|
||||||
|
|
||||||
|
// Warn if any (unusable) binary data was returned
|
||||||
|
if (result?.some((x) => x.some((y) => y.binary))) {
|
||||||
|
if (!mappedResults || mappedResults.flatMap((x) => Object.keys(x ?? {})).length === 0) {
|
||||||
|
response =
|
||||||
|
'Error: The Tool attempted to return binary data, which is not supported in Agents';
|
||||||
|
} else {
|
||||||
|
context.logger.warn(
|
||||||
|
`Response from Tool '${node.name}' included binary data, which is not supported in Agents. The binary data was omitted from the response.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add output data to the context
|
||||||
|
context.addOutputData(NodeConnectionTypes.AiTool, runIndex, [[{ json: { response } }]]);
|
||||||
|
|
||||||
|
// Return the stringified results
|
||||||
|
return JSON.stringify(response);
|
||||||
|
} catch (error) {
|
||||||
|
const nodeError = new NodeOperationError(node, error as Error);
|
||||||
|
context.addOutputData(NodeConnectionTypes.AiTool, runIndex, nodeError);
|
||||||
|
return 'Error during node execution: ' + (nodeError.description ?? nodeError.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getInputConnectionData(
|
export async function getInputConnectionData(
|
||||||
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
this: ExecuteContext | WebhookContext | SupplyDataContext,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
@@ -93,41 +145,14 @@ export async function getInputConnectionData(
|
|||||||
|
|
||||||
if (!connectedNodeType.supplyData) {
|
if (!connectedNodeType.supplyData) {
|
||||||
if (connectedNodeType.description.outputs.includes(NodeConnectionTypes.AiTool)) {
|
if (connectedNodeType.description.outputs.includes(NodeConnectionTypes.AiTool)) {
|
||||||
/**
|
|
||||||
* This keeps track of how many times this specific AI tool node has been invoked.
|
|
||||||
* It is incremented on every invocation of the tool to keep the output of each invocation separate from each other.
|
|
||||||
*/
|
|
||||||
let toolRunIndex = 0;
|
|
||||||
const supplyData = createNodeAsTool({
|
const supplyData = createNodeAsTool({
|
||||||
node: connectedNode,
|
node: connectedNode,
|
||||||
nodeType: connectedNodeType,
|
nodeType: connectedNodeType,
|
||||||
handleToolInvocation: async (toolArgs) => {
|
handleToolInvocation: makeHandleToolInvocation(
|
||||||
const runIndex = toolRunIndex++;
|
(i) => contextFactory(i, {}),
|
||||||
const context = contextFactory(runIndex, {});
|
connectedNode,
|
||||||
context.addInputData(NodeConnectionTypes.AiTool, [[{ json: toolArgs }]]);
|
connectedNodeType,
|
||||||
|
),
|
||||||
try {
|
|
||||||
// Execute the sub-node with the proxied context
|
|
||||||
const result = await connectedNodeType.execute?.call(
|
|
||||||
context as unknown as IExecuteFunctions,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process and map the results
|
|
||||||
const mappedResults = result?.[0]?.flatMap((item) => item.json);
|
|
||||||
|
|
||||||
// Add output data to the context
|
|
||||||
context.addOutputData(NodeConnectionTypes.AiTool, runIndex, [
|
|
||||||
[{ json: { response: mappedResults } }],
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Return the stringified results
|
|
||||||
return JSON.stringify(mappedResults);
|
|
||||||
} catch (error) {
|
|
||||||
const nodeError = new NodeOperationError(connectedNode, error as Error);
|
|
||||||
context.addOutputData(NodeConnectionTypes.AiTool, runIndex, nodeError);
|
|
||||||
return 'Error during node execution: ' + nodeError.description;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
nodes.push(supplyData);
|
nodes.push(supplyData);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user