UNPKG

langchain

Version:
406 lines (404 loc) 15.7 kB
const require_rolldown_runtime = require('../../_virtual/rolldown_runtime.cjs'); const require_middleware = require('../middleware.cjs'); const __langchain_core_messages = require_rolldown_runtime.__toESM(require("@langchain/core/messages")); const __langchain_langgraph = require_rolldown_runtime.__toESM(require("@langchain/langgraph")); const zod_v3 = require_rolldown_runtime.__toESM(require("zod/v3")); const __langchain_core_utils_types = require_rolldown_runtime.__toESM(require("@langchain/core/utils/types")); //#region src/agents/middleware/hitl.ts const DescriptionFunctionSchema = zod_v3.z.function().args(zod_v3.z.custom(), zod_v3.z.custom(), zod_v3.z.custom()).returns(zod_v3.z.union([zod_v3.z.string(), zod_v3.z.promise(zod_v3.z.string())])); /** * The type of decision a human can make. */ const ALLOWED_DECISIONS = [ "approve", "edit", "reject" ]; const DecisionType = zod_v3.z.enum(ALLOWED_DECISIONS); const InterruptOnConfigSchema = zod_v3.z.object({ allowedDecisions: zod_v3.z.array(DecisionType), description: zod_v3.z.union([zod_v3.z.string(), DescriptionFunctionSchema]).optional(), argsSchema: zod_v3.z.record(zod_v3.z.any()).optional() }); const contextSchema = zod_v3.z.object({ interruptOn: zod_v3.z.record(zod_v3.z.union([zod_v3.z.boolean(), InterruptOnConfigSchema])).optional(), descriptionPrefix: zod_v3.z.string().default("Tool execution requires approval") }); /** * Creates a Human-in-the-Loop (HITL) middleware for tool approval and oversight. * * This middleware intercepts tool calls made by an AI agent and provides human oversight * capabilities before execution. It enables selective approval workflows where certain tools * require human intervention while others can execute automatically. * * A invocation result that has been interrupted by the middleware will have a `__interrupt__` * property that contains the interrupt request. * * ```ts * import { type HITLRequest, type HITLResponse } from "langchain"; * import { type Interrupt } from "langchain"; * * const result = await agent.invoke(request); * const interruptRequest = result.__interrupt__?.[0] as Interrupt<HITLRequest>; * * // Examine the action requests and review configs * const actionRequests = interruptRequest.value.actionRequests; * const reviewConfigs = interruptRequest.value.reviewConfigs; * * // Create decisions for each action * const resume: HITLResponse = { * decisions: actionRequests.map((action, i) => { * if (action.name === "calculator") { * return { type: "approve" }; * } else if (action.name === "write_file") { * return { * type: "edit", * editedAction: { name: "write_file", args: { filename: "safe.txt", content: "Safe content" } } * }; * } * return { type: "reject", message: "Action not allowed" }; * }) * }; * * // Resume with decisions * await agent.invoke(new Command({ resume }), config); * ``` * * ## Features * * - **Selective Tool Approval**: Configure which tools require human approval * - **Multiple Decision Types**: Approve, edit, or reject tool calls * - **Asynchronous Workflow**: Uses LangGraph's interrupt mechanism for non-blocking approval * - **Custom Approval Messages**: Provide context-specific descriptions for approval requests * * ## Decision Types * * When a tool requires approval, the human operator can respond with: * - `approve`: Execute the tool with original arguments * - `edit`: Modify the tool name and/or arguments before execution * - `reject`: Provide a manual response instead of executing the tool * * @param options - Configuration options for the middleware * @param options.interruptOn - Per-tool configuration mapping tool names to their settings * @param options.interruptOn[toolName].allowedDecisions - Array of decision types allowed for this tool (e.g., ["approve", "edit", "reject"]) * @param options.interruptOn[toolName].description - Custom approval message for the tool. Can be either a static string or a callable that dynamically generates the description based on agent state, runtime, and tool call information * @param options.interruptOn[toolName].argsSchema - JSON schema for the arguments associated with the action, if edits are allowed * @param options.descriptionPrefix - Default prefix for approval messages (default: "Tool execution requires approval"). Only used for tools that do not define a custom `description` in their InterruptOnConfig. * * @returns A middleware instance that can be passed to `createAgent` * * @example * Basic usage with selective tool approval * ```typescript * import { humanInTheLoopMiddleware } from "langchain"; * import { createAgent } from "langchain"; * * const hitlMiddleware = humanInTheLoopMiddleware({ * interruptOn: { * // Interrupt write_file tool and allow edits or approvals * "write_file": { * allowedDecisions: ["approve", "edit"], * description: "⚠️ File write operation requires approval" * }, * // Auto-approve read_file tool * "read_file": false * } * }); * * const agent = createAgent({ * model: "openai:gpt-4", * tools: [writeFileTool, readFileTool], * middleware: [hitlMiddleware] * }); * ``` * * @example * Handling approval requests * ```typescript * import { type HITLRequest, type HITLResponse, type Interrupt } from "langchain"; * import { Command } from "@langchain/langgraph"; * * // Initial agent invocation * const result = await agent.invoke({ * messages: [new HumanMessage("Write 'Hello' to output.txt")] * }, config); * * // Check if agent is paused for approval * if (result.__interrupt__) { * const interruptRequest = result.__interrupt__?.[0] as Interrupt<HITLRequest>; * * // Show tool call details to user * console.log("Actions:", interruptRequest.value.actionRequests); * console.log("Review configs:", interruptRequest.value.reviewConfigs); * * // Resume with approval * const resume: HITLResponse = { * decisions: [{ type: "approve" }] * }; * await agent.invoke( * new Command({ resume }), * config * ); * } * ``` * * @example * Different decision types * ```typescript * import { type HITLResponse } from "langchain"; * * // Approve the tool call as-is * const resume: HITLResponse = { * decisions: [{ type: "approve" }] * }; * * // Edit the tool arguments * const resume: HITLResponse = { * decisions: [{ * type: "edit", * editedAction: { name: "write_file", args: { filename: "safe.txt", content: "Modified" } } * }] * }; * * // Reject with feedback * const resume: HITLResponse = { * decisions: [{ * type: "reject", * message: "File operation not allowed in demo mode" * }] * }; * ``` * * @example * Production use case with database operations * ```typescript * const hitlMiddleware = humanInTheLoopMiddleware({ * interruptOn: { * "execute_sql": { * allowedDecisions: ["approve", "edit", "reject"], * description: "🚨 SQL query requires DBA approval\nPlease review for safety and performance" * }, * "read_schema": false, // Reading metadata is safe * "delete_records": { * allowedDecisions: ["approve", "reject"], * description: "⛔ DESTRUCTIVE OPERATION - Requires manager approval" * } * }, * descriptionPrefix: "Database operation pending approval" * }); * ``` * * @example * Using dynamic callable descriptions * ```typescript * import { type DescriptionFactory, type ToolCall } from "langchain"; * import type { AgentBuiltInState, Runtime } from "langchain/agents"; * * // Define a dynamic description factory * const formatToolDescription: DescriptionFactory = ( * toolCall: ToolCall, * state: AgentBuiltInState, * runtime: Runtime<unknown> * ) => { * return `Tool: ${toolCall.name}\nArguments:\n${JSON.stringify(toolCall.args, null, 2)}`; * }; * * const hitlMiddleware = humanInTheLoopMiddleware({ * interruptOn: { * "write_file": { * allowedDecisions: ["approve", "edit"], * // Use dynamic description that can access tool call, state, and runtime * description: formatToolDescription * }, * // Or use an inline function * "send_email": { * allowedDecisions: ["approve", "reject"], * description: (toolCall, state, runtime) => { * const { to, subject } = toolCall.args; * return `Email to ${to}\nSubject: ${subject}\n\nRequires approval before sending`; * } * } * } * }); * ``` * * @remarks * - Tool calls are processed in the order they appear in the AI message * - Auto-approved tools execute immediately without interruption * - Multiple tools requiring approval are bundled into a single interrupt request * - The middleware operates in the `afterModel` phase, intercepting before tool execution * - Requires a checkpointer to maintain state across interruptions * * @see {@link createAgent} for agent creation * @see {@link Command} for resuming interrupted execution * @public */ function humanInTheLoopMiddleware(options) { const createActionAndConfig = async (toolCall, config, state, runtime) => { const toolName = toolCall.name; const toolArgs = toolCall.args; const descriptionValue = config.description; let description; if (typeof descriptionValue === "function") description = await descriptionValue(toolCall, state, runtime); else if (descriptionValue !== void 0) description = descriptionValue; else description = `${options.descriptionPrefix ?? "Tool execution requires approval"}\n\nTool: ${toolName}\nArgs: ${JSON.stringify(toolArgs, null, 2)}`; /** * Create ActionRequest with description */ const actionRequest = { name: toolName, args: toolArgs, description }; /** * Create ReviewConfig */ const reviewConfig = { actionName: toolName, allowedDecisions: config.allowedDecisions }; if (config.argsSchema) reviewConfig.argsSchema = config.argsSchema; return { actionRequest, reviewConfig }; }; const processDecision = (decision, toolCall, config) => { const allowedDecisions = config.allowedDecisions; if (decision.type === "approve" && allowedDecisions.includes("approve")) return { revisedToolCall: toolCall, toolMessage: null }; if (decision.type === "edit" && allowedDecisions.includes("edit")) { const editedAction = decision.editedAction; /** * Validate edited action structure */ if (!editedAction || typeof editedAction.name !== "string") throw new Error(`Invalid edited action for tool "${toolCall.name}": name must be a string`); if (!editedAction.args || typeof editedAction.args !== "object") throw new Error(`Invalid edited action for tool "${toolCall.name}": args must be an object`); return { revisedToolCall: { type: "tool_call", name: editedAction.name, args: editedAction.args, id: toolCall.id }, toolMessage: null }; } if (decision.type === "reject" && allowedDecisions.includes("reject")) { /** * Validate that message is a string if provided */ if (decision.message !== void 0 && typeof decision.message !== "string") throw new Error(`Tool call response for "${toolCall.name}" must be a string, got ${typeof decision.message}`); const content = decision.message ?? `User rejected the tool call for \`${toolCall.name}\` with id ${toolCall.id}`; const toolMessage = new __langchain_core_messages.ToolMessage({ content, name: toolCall.name, tool_call_id: toolCall.id, status: "error" }); return { revisedToolCall: toolCall, toolMessage }; } const msg = `Unexpected human decision: ${JSON.stringify(decision)}. Decision type '${decision.type}' is not allowed for tool '${toolCall.name}'. Expected one of ${JSON.stringify(allowedDecisions)} based on the tool's configuration.`; throw new Error(msg); }; return require_middleware.createMiddleware({ name: "HumanInTheLoopMiddleware", contextSchema, afterModel: { canJumpTo: ["model"], hook: async (state, runtime) => { const config = (0, __langchain_core_utils_types.interopParse)(contextSchema, { ...options, ...runtime.context || {} }); if (!config) return; const { messages } = state; if (!messages.length) return; /** * Don't do anything if the last message isn't an AI message with tool calls. */ const lastMessage = [...messages].reverse().find((msg) => __langchain_core_messages.AIMessage.isInstance(msg)); if (!lastMessage || !lastMessage.tool_calls?.length) return; /** * If the user omits the interruptOn config, we don't do anything. */ if (!config.interruptOn) return; /** * Resolve per-tool configs (boolean true -> all decisions allowed; false -> auto-approve) */ const resolvedConfigs = {}; for (const [toolName, toolConfig] of Object.entries(config.interruptOn)) if (typeof toolConfig === "boolean") { if (toolConfig === true) resolvedConfigs[toolName] = { allowedDecisions: [...ALLOWED_DECISIONS] }; } else if (toolConfig.allowedDecisions) resolvedConfigs[toolName] = toolConfig; const interruptToolCalls = []; const autoApprovedToolCalls = []; for (const toolCall of lastMessage.tool_calls) if (toolCall.name in resolvedConfigs) interruptToolCalls.push(toolCall); else autoApprovedToolCalls.push(toolCall); /** * No interrupt tool calls, so we can just return. */ if (!interruptToolCalls.length) return; /** * Create action requests and review configs for all tools that need approval */ const actionRequests = []; const reviewConfigs = []; for (const toolCall of interruptToolCalls) { const interruptConfig = resolvedConfigs[toolCall.name]; /** * Create ActionRequest and ReviewConfig using helper method */ const { actionRequest, reviewConfig } = await createActionAndConfig(toolCall, interruptConfig, state, runtime); actionRequests.push(actionRequest); reviewConfigs.push(reviewConfig); } /** * Create single HITLRequest with all actions and configs */ const hitlRequest = { actionRequests, reviewConfigs }; /** * Send interrupt and get response */ const hitlResponse = await (0, __langchain_langgraph.interrupt)(hitlRequest); const decisions = hitlResponse.decisions; /** * Validate that decisions is a valid array before checking length */ if (!decisions || !Array.isArray(decisions)) throw new Error("Invalid HITLResponse: decisions must be a non-empty array"); /** * Validate that the number of decisions matches the number of interrupt tool calls */ if (decisions.length !== interruptToolCalls.length) throw new Error(`Number of human decisions (${decisions.length}) does not match number of hanging tool calls (${interruptToolCalls.length}).`); const revisedToolCalls = [...autoApprovedToolCalls]; const artificialToolMessages = []; /** * Process each decision using helper method */ for (let i = 0; i < decisions.length; i++) { const decision = decisions[i]; const toolCall = interruptToolCalls[i]; const interruptConfig = resolvedConfigs[toolCall.name]; const { revisedToolCall, toolMessage } = processDecision(decision, toolCall, interruptConfig); if (revisedToolCall) revisedToolCalls.push(revisedToolCall); if (toolMessage) artificialToolMessages.push(toolMessage); } /** * Update the AI message to only include approved tool calls */ if (__langchain_core_messages.AIMessage.isInstance(lastMessage)) lastMessage.tool_calls = revisedToolCalls; return { messages: [lastMessage, ...artificialToolMessages] }; } } }); } //#endregion exports.humanInTheLoopMiddleware = humanInTheLoopMiddleware; //# sourceMappingURL=hitl.cjs.map