first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+432
@@ -0,0 +1,432 @@
|
||||
import type { CallbackManagerForToolRun } from '@langchain/core/callbacks/manager';
|
||||
import { DynamicStructuredTool, DynamicTool } from '@langchain/core/tools';
|
||||
import isArray from 'lodash/isArray';
|
||||
import isObject from 'lodash/isObject';
|
||||
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 { getCurrentWorkflowInputData } from 'n8n-nodes-base/dist/utils/workflowInputsResourceMapping/GenericFunctions';
|
||||
import type {
|
||||
ExecuteWorkflowData,
|
||||
ExecutionError,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
IExecuteWorkflowInfo,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
ISupplyDataFunctions,
|
||||
ITaskMetadata,
|
||||
IWorkflowBase,
|
||||
IWorkflowDataProxyData,
|
||||
ResourceMapperValue,
|
||||
} from 'n8n-workflow';
|
||||
import {
|
||||
jsonParse,
|
||||
NodeConnectionTypes,
|
||||
NodeOperationError,
|
||||
parseErrorMetadata,
|
||||
sleepWithAbort,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities';
|
||||
|
||||
function isNodeExecutionData(data: unknown): data is INodeExecutionData[] {
|
||||
return isArray(data) && Boolean(data.length) && isObject(data[0]) && 'json' in data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
Main class for creating the Workflow tool
|
||||
Processes the node parameters and creates AI Agent tool capable of executing n8n workflows
|
||||
*/
|
||||
export class WorkflowToolService {
|
||||
// Determines if we should use input schema when creating the tool
|
||||
private useSchema: boolean;
|
||||
|
||||
// Sub-workflow id, pulled from referenced sub-workflow
|
||||
private subWorkflowId: string | undefined;
|
||||
|
||||
// Sub-workflow execution id, will be set after the sub-workflow is executed
|
||||
private subExecutionId: string | undefined;
|
||||
|
||||
private returnAllItems: boolean = false;
|
||||
|
||||
constructor(
|
||||
private baseContext: ISupplyDataFunctions | IExecuteFunctions,
|
||||
options?: { returnAllItems: boolean },
|
||||
) {
|
||||
const subWorkflowInputs = this.baseContext.getNode().parameters
|
||||
.workflowInputs as ResourceMapperValue;
|
||||
this.useSchema = (subWorkflowInputs?.schema ?? []).length > 0;
|
||||
this.returnAllItems = options?.returnAllItems ?? false;
|
||||
}
|
||||
|
||||
// Creates the tool based on the provided parameters
|
||||
async createTool({
|
||||
ctx,
|
||||
name,
|
||||
description,
|
||||
itemIndex,
|
||||
manualLogging = true,
|
||||
}: {
|
||||
ctx: ISupplyDataFunctions | IExecuteFunctions;
|
||||
name: string;
|
||||
description: string;
|
||||
itemIndex: number;
|
||||
manualLogging?: boolean;
|
||||
}): Promise<DynamicTool | DynamicStructuredTool> {
|
||||
// Handler for the tool execution, will be called when the tool is executed
|
||||
// This function will execute the sub-workflow and return the response
|
||||
// We get the runIndex from the context to handle multiple executions
|
||||
// of the same tool when the tool is used in a loop or in a parallel execution.
|
||||
const node = ctx.getNode();
|
||||
|
||||
let runIndex: number = 'getNextRunIndex' in ctx ? ctx.getNextRunIndex() : 0;
|
||||
const toolHandler = async (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<IDataObject | IDataObject[] | string> => {
|
||||
let maxTries = 1;
|
||||
if (node.retryOnFail === true) {
|
||||
maxTries = Math.min(5, Math.max(2, node.maxTries ?? 3));
|
||||
}
|
||||
|
||||
let waitBetweenTries = 0;
|
||||
if (node.retryOnFail === true) {
|
||||
waitBetweenTries = Math.min(5000, Math.max(0, node.waitBetweenTries ?? 1000));
|
||||
}
|
||||
|
||||
let lastError: ExecutionError | undefined;
|
||||
|
||||
for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) {
|
||||
const localRunIndex = runIndex++;
|
||||
|
||||
let context = this.baseContext;
|
||||
// We need to clone the context here to handle runIndex correctly
|
||||
// Otherwise the runIndex will be shared between different executions
|
||||
// Causing incorrect data to be passed to the sub-workflow and via $fromAI
|
||||
if ('cloneWith' in this.baseContext) {
|
||||
context = this.baseContext.cloneWith({
|
||||
runIndex: localRunIndex,
|
||||
inputData: [[{ json: { query } }]],
|
||||
});
|
||||
}
|
||||
|
||||
// Get abort signal from context for cancellation support
|
||||
const abortSignal = context.getExecutionCancelSignal?.();
|
||||
|
||||
// Check if execution was cancelled before retry
|
||||
if (abortSignal?.aborted) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
|
||||
if (tryIndex !== 0) {
|
||||
// Reset error from previous attempt
|
||||
lastError = undefined;
|
||||
if (waitBetweenTries !== 0) {
|
||||
try {
|
||||
await sleepWithAbort(waitBetweenTries, abortSignal);
|
||||
} catch (abortError) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.runFunction(context, query, itemIndex, runManager);
|
||||
|
||||
const processedResponse = this.handleToolResponse(response);
|
||||
|
||||
let responseData: INodeExecutionData[];
|
||||
if (isNodeExecutionData(response)) {
|
||||
responseData = response;
|
||||
} else {
|
||||
const reParsedData = jsonParse<IDataObject>(processedResponse, {
|
||||
fallbackValue: { response: processedResponse },
|
||||
});
|
||||
|
||||
responseData = [{ json: reParsedData }];
|
||||
}
|
||||
|
||||
// Once the sub-workflow is executed, add the output data to the context
|
||||
// This will be used to link the sub-workflow execution in the parent workflow
|
||||
let metadata: ITaskMetadata | undefined;
|
||||
if (this.subExecutionId && this.subWorkflowId) {
|
||||
metadata = {
|
||||
subExecution: {
|
||||
executionId: this.subExecutionId,
|
||||
workflowId: this.subWorkflowId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// If manualLogging is enabled we've been called by the AgentExecutor
|
||||
// and have to return a stringified response.
|
||||
if (manualLogging) {
|
||||
void context.addOutputData(
|
||||
NodeConnectionTypes.AiTool,
|
||||
localRunIndex,
|
||||
[responseData],
|
||||
metadata,
|
||||
);
|
||||
|
||||
return processedResponse;
|
||||
}
|
||||
// If manualLogging is false we've been called by the engine and need
|
||||
// the structured response.
|
||||
|
||||
if (metadata && 'setMetadata' in context) {
|
||||
void context.setMetadata(metadata);
|
||||
}
|
||||
|
||||
return responseData;
|
||||
} catch (error) {
|
||||
// Check if error is due to cancellation
|
||||
if (abortSignal?.aborted) {
|
||||
return 'There was an error: "Execution was cancelled"';
|
||||
}
|
||||
|
||||
const executionError = error as ExecutionError;
|
||||
lastError = executionError;
|
||||
const errorResponse = `There was an error: "${executionError.message}"`;
|
||||
|
||||
if (manualLogging) {
|
||||
const metadata = parseErrorMetadata(error);
|
||||
// Wrap error in INodeExecutionData format so it can be properly processed
|
||||
// by buildSteps and displayed in the UI execution data
|
||||
const errorData: INodeExecutionData[] = [{ json: { error: errorResponse } }];
|
||||
void context.addOutputData(
|
||||
NodeConnectionTypes.AiTool,
|
||||
localRunIndex,
|
||||
[errorData],
|
||||
metadata,
|
||||
);
|
||||
}
|
||||
|
||||
if (tryIndex === maxTries - 1) {
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `There was an error: ${lastError?.message ?? 'Unknown error'}`;
|
||||
};
|
||||
|
||||
// Create structured tool if input schema is provided
|
||||
return this.useSchema
|
||||
? this.createStructuredTool(name, description, toolHandler)
|
||||
: new DynamicTool({ name, description, func: toolHandler });
|
||||
}
|
||||
|
||||
private handleToolResponse(response: unknown): string {
|
||||
if (typeof response === 'number') {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
if (isNodeExecutionData(response)) {
|
||||
return JSON.stringify(
|
||||
response.map((item) => item.json),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(response)) {
|
||||
return JSON.stringify(response, null, 2);
|
||||
}
|
||||
|
||||
if (typeof response !== 'string') {
|
||||
throw new NodeOperationError(this.baseContext.getNode(), 'Wrong output type returned', {
|
||||
description: `The response property should be a string, but it is an ${typeof response}`,
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes specified sub-workflow with provided inputs
|
||||
*/
|
||||
private async executeSubWorkflow(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
workflowInfo: IExecuteWorkflowInfo,
|
||||
items: INodeExecutionData[],
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<{ response: IDataObject | INodeExecutionData[]; subExecutionId: string }> {
|
||||
let receivedData: ExecuteWorkflowData;
|
||||
try {
|
||||
receivedData = await context.executeWorkflow(workflowInfo, items, runManager?.getChild(), {
|
||||
parentExecution: {
|
||||
executionId: workflowProxy.$execution.id,
|
||||
workflowId: workflowProxy.$workflow.id,
|
||||
},
|
||||
});
|
||||
// Set sub-workflow execution id so it can be used in other places
|
||||
this.subExecutionId = receivedData.executionId;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(context.getNode(), error as Error);
|
||||
}
|
||||
|
||||
let response: IDataObject | INodeExecutionData[] | undefined;
|
||||
if (this.returnAllItems) {
|
||||
response = receivedData?.data?.[0]?.length ? receivedData.data[0] : undefined;
|
||||
} else {
|
||||
response = receivedData?.data?.[0]?.[0]?.json;
|
||||
}
|
||||
if (response === undefined) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
'There was an error: "The workflow did not return a response"',
|
||||
);
|
||||
}
|
||||
|
||||
return { response, subExecutionId: receivedData.executionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sub-workflow info based on the source and executes it.
|
||||
* This function will be called as part of the tool execution (from the toolHandler)
|
||||
*/
|
||||
private async runFunction(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
): Promise<IDataObject | INodeExecutionData[]> {
|
||||
const source = context.getNodeParameter('source', itemIndex) as string;
|
||||
const workflowProxy = context.getWorkflowDataProxy(0);
|
||||
|
||||
const { workflowInfo } = await this.getSubWorkflowInfo(
|
||||
context,
|
||||
source,
|
||||
itemIndex,
|
||||
workflowProxy,
|
||||
);
|
||||
const rawData = this.prepareRawData(context, query, itemIndex);
|
||||
const items = await this.prepareWorkflowItems(context, query, itemIndex, rawData);
|
||||
|
||||
this.subWorkflowId = workflowInfo.id;
|
||||
|
||||
const { response } = await this.executeSubWorkflow(
|
||||
context,
|
||||
workflowInfo,
|
||||
items,
|
||||
workflowProxy,
|
||||
runManager,
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the sub-workflow info based on the source (database or parameter)
|
||||
*/
|
||||
private async getSubWorkflowInfo(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
source: string,
|
||||
itemIndex: number,
|
||||
workflowProxy: IWorkflowDataProxyData,
|
||||
): Promise<{
|
||||
workflowInfo: IExecuteWorkflowInfo;
|
||||
subWorkflowId: string;
|
||||
}> {
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
let subWorkflowId: string;
|
||||
|
||||
if (source === 'database') {
|
||||
const { value } = context.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
subWorkflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
const workflowJson = context.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
try {
|
||||
workflowInfo.code = JSON.parse(workflowJson) as IWorkflowBase;
|
||||
// subworkflow is same as parent workflow
|
||||
subWorkflowId = workflowProxy.$workflow.id;
|
||||
} catch (error) {
|
||||
throw new NodeOperationError(
|
||||
context.getNode(),
|
||||
`The provided workflow is not valid JSON: "${(error as Error).message}"`,
|
||||
{ itemIndex },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { workflowInfo, subWorkflowId: subWorkflowId! };
|
||||
}
|
||||
|
||||
private prepareRawData(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
): IDataObject {
|
||||
const rawData: IDataObject = { query };
|
||||
const workflowFieldsJson = context.getNodeParameter('fields.values', itemIndex, [], {
|
||||
rawExpressions: true,
|
||||
}) as SetField[];
|
||||
|
||||
// Copied from Set Node v2
|
||||
for (const entry of workflowFieldsJson) {
|
||||
if (entry.type === 'objectValue' && (entry.objectValue as string).startsWith('=')) {
|
||||
rawData[entry.name] = (entry.objectValue as string).replace(/^=+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
return rawData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the sub-workflow items for execution
|
||||
*/
|
||||
private async prepareWorkflowItems(
|
||||
context: ISupplyDataFunctions | IExecuteFunctions,
|
||||
query: string | IDataObject,
|
||||
itemIndex: number,
|
||||
rawData: IDataObject,
|
||||
): Promise<INodeExecutionData[]> {
|
||||
const options: SetNodeOptions = { include: 'all' };
|
||||
let jsonData = typeof query === 'object' ? query : { query };
|
||||
|
||||
if (this.useSchema) {
|
||||
const currentWorkflowInputs = getCurrentWorkflowInputData.call(context);
|
||||
jsonData = currentWorkflowInputs[itemIndex].json;
|
||||
}
|
||||
|
||||
const newItem = await manual.execute.call(
|
||||
context,
|
||||
{ json: jsonData },
|
||||
itemIndex,
|
||||
options,
|
||||
rawData,
|
||||
context.getNode(),
|
||||
);
|
||||
|
||||
return [newItem] as INodeExecutionData[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create structured tool by parsing the sub-workflow input schema
|
||||
*/
|
||||
private createStructuredTool(
|
||||
name: string,
|
||||
description: string,
|
||||
func: (
|
||||
query: string | IDataObject,
|
||||
runManager?: CallbackManagerForToolRun,
|
||||
) => Promise<string | IDataObject | IDataObject[]>,
|
||||
): DynamicStructuredTool | DynamicTool {
|
||||
const collectedArguments = extractFromAIParameters(this.baseContext.getNode().parameters);
|
||||
|
||||
// If there are no `fromAI` arguments, fallback to creating a simple tool
|
||||
if (collectedArguments.length === 0) {
|
||||
return new DynamicTool({ name, description, func });
|
||||
}
|
||||
|
||||
// Prepare Zod schema for the structured tool
|
||||
const schema = createZodSchemaFromArgs(collectedArguments);
|
||||
|
||||
return new DynamicStructuredTool({ schema, name, description, func });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user