UNPKG

langchain

Version:
694 lines (693 loc) • 26.8 kB
import { createMiddleware } from "../middleware.js"; import { AIMessage, ToolMessage } from "@langchain/core/messages"; import { Command, interrupt, isCommand } from "@langchain/langgraph"; import { interopParse, interopZodTransformInputSchema, isZodSchemaV4 } from "@langchain/core/utils/types"; import { z } from "zod/v4"; import { z as z$1 } from "zod/v3"; // src/agents/middleware/hitl.ts const WhenFunctionSchema = z$1.function().args(z$1.custom()).returns(z$1.union([z$1.boolean(), z$1.promise(z$1.boolean())])); const DescriptionFunctionSchema = z$1.function().args(z$1.custom(), z$1.custom(), z$1.custom()).returns(z$1.union([z$1.string(), z$1.promise(z$1.string())])); /** * The type of decision a human can make. */ const ALLOWED_DECISIONS = [ "approve", "edit", "reject", "respond" ]; const DecisionType = z$1.enum(ALLOWED_DECISIONS); const InterruptOnConfigSchema = z$1.object({ /** * The decisions that are allowed for this action. */ allowedDecisions: z$1.array(DecisionType), /** * The description attached to the request for human input. * Can be either: * - A static string describing the approval request * - A callable that dynamically generates the description based on agent state, * runtime, and tool call information * * @example * Static string description * ```typescript * import type { InterruptOnConfig } from "langchain"; * * const config: InterruptOnConfig = { * allowedDecisions: ["approve", "reject"], * description: "Please review this tool execution" * }; * ``` * * @example * Dynamic callable description * ```typescript * import type { * AgentBuiltInState, * Runtime, * DescriptionFactory, * ToolCall, * InterruptOnConfig * } from "langchain"; * * const formatToolDescription: DescriptionFactory = ( * toolCall: ToolCall, * state: AgentBuiltInState, * runtime: Runtime<unknown> * ) => { * return `Tool: ${toolCall.name}\nArguments:\n${JSON.stringify(toolCall.args, null, 2)}`; * }; * * const config: InterruptOnConfig = { * allowedDecisions: ["approve", "edit"], * description: formatToolDescription * }; * ``` */ description: z$1.union([z$1.string(), DescriptionFunctionSchema]).optional(), /** * JSON schema for the arguments associated with the action, if edits are allowed. * * Not used in `"per_call"` mode: an edit's `args` are checked against the tool's * own schema there. */ argsSchema: z$1.record(z$1.any()).optional(), /** * Optional predicate controlling whether to interrupt for a given tool call. * * Receives a {@link ToolCallRequest} and returns `true` to interrupt or * `false` to auto-approve the tool call. * * The request is constructed with `tool` set to `undefined` and `runtime` set * to the node-level {@link Runtime}, so `request.tool` is not available. In * `"per_call"` mode the predicate runs in `wrapToolCall` and receives the real * request, with `tool` set. * * @example * ```typescript * import type { InterruptOnConfig } from "langchain"; * * // Only interrupt delete_file calls targeting /etc * const config: InterruptOnConfig = { * allowedDecisions: ["approve", "reject"], * when: (request) => * String(request.toolCall.args.path ?? "").startsWith("/etc"), * }; * ``` */ when: WhenFunctionSchema.optional() }); const ToolApprovalRequestSchema = z.object({ /** Always `"tool_approval"`; tells clients how to read this interrupt. */ type: z.literal("tool_approval"), /** ID of the model's tool call this approval is about. */ tool_call_id: z.string(), /** Tool name, as the model requested it. */ name: z.string(), /** Tool arguments, as the model requested them. */ args: z.record(z.string(), z.unknown()), /** Text shown to the reviewer. */ description: z.string() }); /** * What an edit's `args` must look like: the input side of the tool's schema, if it's * a Zod v4 object. Transforms are left out, so they run once, when the tool does. * Args the tool doesn't declare are handled as the tool handles them from the model. * Any other tool takes any object, and checks it when it runs. */ function editArgs(tool) { const schema = tool && "schema" in tool ? tool.schema : void 0; const input = isZodSchemaV4(schema) ? interopZodTransformInputSchema(schema, true) : void 0; return input instanceof z.ZodObject ? input : z.record(z.string(), z.unknown()); } /** * The answers a reviewer can give to a `"per_call"` approval of tool `name`, by type. * * The edit can't switch tools: its `name` is pinned. */ const toolApprovalDecisions = (name, tool) => ({ approve: z.object({ type: z.literal("approve") }), edit: z.object({ type: z.literal("edit"), edited_action: z.object({ name: z.literal(name), args: editArgs(tool) }) }), reject: z.object({ type: z.literal("reject"), message: z.string().optional() }), respond: z.object({ type: z.literal("respond"), message: z.string() }) }); /** * Whether an interrupt is a `"per_call"` tool approval, e.g. * `result.__interrupt__?.filter(isToolApprovalInterrupt)`. */ function isToolApprovalInterrupt(interrupt) { return ToolApprovalRequestSchema.safeParse(interrupt?.value).success; } /** * The per-call `responseSchema`: the `allowed` decisions for tool `name`. * * An answer is checked only against the branch its `type` names, so a bad one gets a * single error. A one-decision tool gets that decision's plain object schema. */ function decisionSchema(allowed, name, tool) { const byType = toolApprovalDecisions(name, tool); const [first, ...rest] = [...new Set(allowed)].map((d) => byType[d]); if (first === void 0) throw new Error("allowedDecisions must list at least one decision."); return rest.length ? z.discriminatedUnion("type", [first, ...rest]) : first; } const contextSchema = z$1.object({ /** * Mapping of tool name to allowed reviewer responses. * If a tool doesn't have an entry, it's auto-approved by default. * * - `true` -> pause for approval and allow every decision (approve/edit/reject/respond) * - `false` -> auto-approve (no human review) * - `InterruptOnConfig` -> explicitly specify which decisions are allowed for this tool */ interruptOn: z$1.record(z$1.union([z$1.boolean(), InterruptOnConfigSchema])).optional(), /** * Prefix used when constructing human-facing approval messages. * Provides context about the tool call being reviewed; does not change the underlying action. * * Note: This prefix is only applied for tools that do not provide a custom * `description` via their {@link InterruptOnConfig}. If a tool specifies a custom * `description`, that per-tool text is used and this prefix is ignored. */ descriptionPrefix: z$1.string().default("Tool execution requires approval") }); const DEFAULT_EDIT_NOTICE = "Note: a human reviewer replaced this tool call before it ran. The call recorded in your message is the one you produced, not the one that executed. This was intentional and authorized. Do not re-issue your original call."; /** * Resolve `interruptOn`: `true` allows every decision; `false` and missing entries * auto-approve. */ function resolveInterruptOn(interruptOn) { const resolved = {}; for (const [toolName, toolConfig] of Object.entries(interruptOn ?? {})) if (typeof toolConfig === "boolean") { if (toolConfig === true) resolved[toolName] = { allowedDecisions: [...ALLOWED_DECISIONS] }; } else if (toolConfig.allowedDecisions) resolved[toolName] = toolConfig; return resolved; } /** * Return `message` with the reviewer-edit notice prepended, stating the call that ran. */ function prependNotice(message, notice, executed) { const hasContent = message.content.length > 0; const text = `${notice} Executed instead: ${executed.name} with arguments ${JSON.stringify(executed.args)}.${hasContent ? "\n\nTool response:" : ""}`; return new ToolMessage({ content: typeof message.content === "string" ? `${text}${hasContent ? "\n" : ""}${message.content}` : [{ type: "text", text }, ...message.content], tool_call_id: message.tool_call_id, name: message.name, status: message.status, artifact: message.artifact, id: message.id, additional_kwargs: message.additional_kwargs, response_metadata: message.response_metadata }); } /** * Tell the model a reviewer replaced the call, and with what. */ function withEditNotice(result, toolCallId, executed, notice) { if (!notice) return result; if (ToolMessage.isInstance(result)) return prependNotice(result, notice, executed); const update = isCommand(result) ? result.update : void 0; if (typeof update !== "object" || update === null || !("messages" in update) || !Array.isArray(update.messages)) return result; return new Command({ update: { ...update, messages: update.messages.map((message) => ToolMessage.isInstance(message) && message.tool_call_id === toolCallId ? prependNotice(message, notice, executed) : message) }, resume: result.resume, goto: result.goto, graph: result.graph }); } /** * 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.interruptOn[toolName].when - Optional predicate that dynamically controls whether a tool call triggers an interrupt. Returns `true` to interrupt or `false` to auto-approve the tool call. * @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 * - In the default `"batched"` mode, multiple tools requiring approval are bundled into a * single interrupt request, raised in the `afterModel` phase before any tool runs. In * `"per_call"` mode, each gated call raises its own interrupt from `wrapToolCall` * - 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 }; }; /** * Return `false` if the `when` predicate rejects this tool call, `true` otherwise. * * When no `when` predicate is configured the tool call always interrupts. */ const shouldInterrupt = async (toolCall, config, state, runtime) => { const { when } = config; if (when == null) return true; return when({ toolCall, tool: void 0, state, runtime }); }; 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}`; return { revisedToolCall: toolCall, toolMessage: new ToolMessage({ content, name: toolCall.name, tool_call_id: toolCall.id, status: "error" }) }; } if (decision.type === "respond" && allowedDecisions.includes("respond")) { if (typeof decision.message !== "string") throw new Error(`Tool call response for "${toolCall.name}" must be a string, got ${typeof decision.message}`); return { revisedToolCall: toolCall, toolMessage: new ToolMessage({ content: decision.message, name: toolCall.name, tool_call_id: toolCall.id, status: "success" }) }; } 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); }; const interruptMode = options.interruptMode ?? "batched"; if (interruptMode !== "batched" && interruptMode !== "per_call") throw new Error(`interruptMode must be "batched" or "per_call", got ${JSON.stringify(interruptMode)}.`); if (interruptMode === "per_call") { for (const [toolName, toolConfig] of Object.entries(options.interruptOn ?? {})) if (typeof toolConfig === "object" && !toolConfig.allowedDecisions?.length) throw new Error(`Invalid interruptOn config for tool "${toolName}": allowedDecisions must list at least one decision.`); } const editNotice = options.editNotice === void 0 ? DEFAULT_EDIT_NOTICE : options.editNotice; return createMiddleware({ name: "HumanInTheLoopMiddleware", contextSchema, /** * Per-call mode: pause for each gated tool call as it's about to run. */ wrapToolCall: interruptMode === "per_call" ? async (request, handler) => { const { toolCall } = request; const { interruptOn } = interopParse(contextSchema, { ...options, ...request.runtime.context || {} }); const toolConfig = resolveInterruptOn(interruptOn)[toolCall.name]; if (!toolConfig || toolConfig.when && !await toolConfig.when(request)) return handler(request); if (toolCall.id == null) throw new Error(`Tool call \`${toolCall.name}\` has no ID, so its result can't be matched to it. Make sure the chat model returns tool call IDs.`); const { actionRequest } = await createActionAndConfig(toolCall, toolConfig, request.state, request.runtime); const value = { type: "tool_approval", tool_call_id: toolCall.id, name: toolCall.name, args: toolCall.args, description: actionRequest.description ?? "" }; const responseSchema = decisionSchema(toolConfig.allowedDecisions, toolCall.name, request.tool); const decision = interrupt(value, { responseSchema }); if (decision.type === "approve") return handler(request); if (decision.type === "reject") return new ToolMessage({ content: decision.message ?? `User rejected the tool call for \`${toolCall.name}\` with id ${toolCall.id}`, name: toolCall.name, tool_call_id: toolCall.id, status: "error" }); if (decision.type === "respond") return new ToolMessage({ content: decision.message, name: toolCall.name, tool_call_id: toolCall.id, status: "success" }); const executed = decision.edited_action; return withEditNotice(await handler({ ...request, toolCall: { ...toolCall, args: executed.args } }), toolCall.id, executed, editNotice); } : void 0, afterModel: { canJumpTo: ["model"], hook: async (state, runtime) => { if (interruptMode === "per_call") return; const config = 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) => 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 = resolveInterruptOn(config.interruptOn); const interruptToolCalls = []; const autoApprovedToolCalls = []; for (const toolCall of lastMessage.tool_calls) { const interruptConfig = resolvedConfigs[toolCall.name]; /** * A tool call is interrupted only when it has a resolved config and its * optional `when` predicate doesn't opt it out. Otherwise it is * auto-approved. */ if (interruptConfig && await shouldInterrupt(toolCall, interruptConfig, state, runtime)) 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); } const decisions = (await interrupt({ actionRequests, reviewConfigs })).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); } /** * Keep every tool call on the AI message: a dropped call would never * receive a ToolMessage, which provider APIs reject. */ if (AIMessage.isInstance(lastMessage)) lastMessage.tool_calls = revisedToolCalls; const jumpTo = revisedToolCalls.some((toolCall) => !artificialToolMessages.some((message) => message.tool_call_id === toolCall.id)) ? void 0 : "model"; return { messages: [lastMessage, ...artificialToolMessages], jumpTo }; } } }); } export { humanInTheLoopMiddleware, isToolApprovalInterrupt }; //# sourceMappingURL=hitl.js.map