n8n/packages/@n8n/nodes-langchain/utils/agent-execution/processHitlResponses.ts
yehorkardash a9f00ec49b
feat(AI Agent Node): Pass chat input in denial messages (#24748)
Co-authored-by: Elias Meire <elias@meire.dev>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-26 10:41:56 +00:00

194 lines
5.9 KiB
TypeScript

import { NodeConnectionTypes } from 'n8n-workflow';
import type { EngineResponse, EngineRequest, IDataObject, ExecuteNodeResult } from 'n8n-workflow';
import type { RequestResponseMetadata } from './types';
/**
* HITL metadata type (extracted from RequestResponseMetadata for convenience)
*/
type HitlMetadata = NonNullable<RequestResponseMetadata['hitl']>;
/**
* Result of processing HITL responses
*/
export interface HitlProcessingResult {
/** If we need to execute gated tools, this contains the EngineRequest */
pendingGatedToolRequest?: EngineRequest<RequestResponseMetadata>;
/** Modified response with HITL approvals/denials properly formatted */
processedResponse: EngineResponse<RequestResponseMetadata>;
/** Whether any HITL tools were approved and need gated tool execution */
hasApprovedHitlTools: boolean;
}
/**
* Check if an action response is from an HITL tool
*/
function isHitlActionResponse(
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
): actionResponse is ExecuteNodeResult<RequestResponseMetadata> & {
action: { metadata: RequestResponseMetadata & { hitl: HitlMetadata } };
} {
const hitl = (actionResponse.action?.metadata as { hitl?: HitlMetadata } | undefined)?.hitl;
return hitl !== undefined;
}
/**
* Type guard to check if data contains an approval field
*/
function isApprovalData(data: unknown): data is { approved: boolean } {
return (
typeof data === 'object' &&
data !== null &&
'approved' in data &&
typeof (data as Record<string, unknown>).approved === 'boolean'
);
}
function getActionJsonResponse(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
return actionResponse.data?.data?.ai_tool?.[0]?.[0]?.json;
}
/**
* Extract approval status from HITL response data.
* SendAndWait webhook returns { approved: boolean } or { data: { approved: boolean } }
*/
function getApprovalStatus(
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
): boolean | undefined {
const json = getActionJsonResponse(actionResponse);
if (isApprovalData(json)) {
return json.approved;
}
const nestedData = (json as IDataObject | undefined)?.data;
if (isApprovalData(nestedData)) {
return nestedData.approved;
}
return undefined;
}
function getChatInput(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
const json = getActionJsonResponse(actionResponse);
const chatInput = json?.chatInput ?? (json?.data as IDataObject | undefined)?.chatInput;
if (typeof chatInput === 'string') {
return chatInput;
}
return undefined;
}
function getDenialMessage(toolName: string, toolId: string, chatInput?: string): string {
const parts: string[] = [];
if (chatInput) {
parts.push(
`The user reviewed your planned tool call to ${toolName} (id: ${toolId}) and provided feedback: "${chatInput}".`,
);
} else {
parts.push(`User rejected the tool call to ${toolName} (id: ${toolId}).`);
parts.push('STOP what you are doing and wait for the user to tell you how to proceed.');
}
parts.push('The tool is still available if needed.');
return parts.join(' ');
}
/**
* Process HITL (Human-in-the-Loop) tool responses.
*
* When the Agent receives responses from HITL tools:
* 1. Check if the response indicates approval or denial
* 2. If approved: Generate EngineRequest for the gated tool
* 3. If denied: Modify response to indicate denial so Agent knows not to retry
*
* This enables the flow:
* Agent calls tool → HITL intercepts → sendAndWait → User approves →
* Agent generates new request for gated tool → Gated tool executes → Result to Agent
*/
export function processHitlResponses(
response: EngineResponse<RequestResponseMetadata> | undefined,
itemIndex: number,
): HitlProcessingResult {
if (!response || !response.actionResponses || response.actionResponses.length === 0) {
return {
processedResponse: response ?? { actionResponses: [], metadata: {} },
hasApprovedHitlTools: false,
};
}
const pendingGatedToolActions: EngineRequest<RequestResponseMetadata>['actions'] = [];
const processedActionResponses: Array<ExecuteNodeResult<RequestResponseMetadata>> = [];
let hasApprovedHitlTools = false;
for (const actionResponse of response.actionResponses) {
if (!isHitlActionResponse(actionResponse)) {
// Not an HITL tool, pass through unchanged
processedActionResponses.push(actionResponse);
continue;
}
const { hitl } = actionResponse.action.metadata;
const approved = getApprovalStatus(actionResponse);
const chatInput = getChatInput(actionResponse);
const toolName = hitl.gatedToolNodeName;
const toolId = actionResponse.action.id;
if (approved === true) {
hasApprovedHitlTools = true;
const input =
typeof hitl.originalInput === 'object'
? { tool: hitl.toolName, ...hitl.originalInput }
: { tool: hitl.toolName, input: hitl.originalInput };
pendingGatedToolActions.push({
actionType: 'ExecutionNodeAction' as const,
nodeName: hitl.gatedToolNodeName,
input,
type: NodeConnectionTypes.AiTool,
id: toolId,
metadata: {
itemIndex,
// Set the parent node to the HITL node for proper log tree structure
parentNodeName: actionResponse.action.nodeName,
},
});
} else {
const modifiedResponse: ExecuteNodeResult<RequestResponseMetadata> = {
...actionResponse,
data: {
...actionResponse.data,
data: {
ai_tool: [
[
{
json: {
output: getDenialMessage(toolName, toolId, chatInput),
},
},
],
],
},
},
};
processedActionResponses.push(modifiedResponse);
}
}
const result: HitlProcessingResult = {
processedResponse: {
...response,
actionResponses: processedActionResponses,
},
hasApprovedHitlTools,
};
if (pendingGatedToolActions.length > 0) {
result.pendingGatedToolRequest = {
actions: pendingGatedToolActions,
metadata: {
previousRequests: response.metadata?.previousRequests,
},
};
}
return result;
}