Skip to content

人在回路 (Human-in-the-loop) ​

在敏感工具调用前暂停以等待人工审批、参数修改或人工回复

在现实应用中,智能体可能涉及破坏性操作(如删除数据库记录、覆盖生产文件、发送外部邮件或执行昂贵的 API 调用)。 人在回路(Human-in-the-loop, HITL)使你能够设置安全关卡:智能体在触发目标工具前会自动优雅挂起执行,等待人工审批、人工修改参数或提供额外补充信息后再继续推进。

Deep Agents 深度整合了 LangGraph 的原生中断机制,具有极高的可靠性与状态恢复能力。

基础配置 (Basic configuration) ​

在调用 createDeepAgent 时,通过 interruptOn 参数指定需要拦截审批的工具名称映射:

mermaid
graph LR
    Agent[Agent] --> Check{Interrupt?}
    Check --> |no| Execute[Execute]
    Check --> |yes| Human{Human}

    Human --> |approve| Execute
    Human --> |edit| Execute
    Human --> |reject| ToolMessage[ToolMessage]

    Execute --> Agent
    ToolMessage --> Agent

    classDef trigger fill:#F6FFDB,stroke:#6E8900,stroke-width:2px,color:#2E3900
    classDef process fill:#E5F4FF,stroke:#006DDD,stroke-width:2px,color:#030710
    classDef decision fill:#FDF3FF,stroke:#7E65AE,stroke-width:2px,color:#504B5F
    classDef alert fill:#F8E8E6,stroke:#B27D75,stroke-width:2px,color:#634643

    class Agent trigger
    class Check,Human decision
    class Execute process
    class ToolMessage process

审批决策类型 (Decision types) ​

当智能体因命中规则而暂停执行时,人工审批者可以提交以下四种决策动作之一:

决策动作 (action)说明
approve批准工具调用:按照智能体生成的原始参数正常执行该工具。
edit修改参数后批准:由人工修改工具入参(如纠正参数中的错误路径),然后以修改后的参数执行。
reject明确拒绝执行:阻止该工具调用,并可附带说明告知智能体为何被拒绝,由模型重新规划。
respond替代工具返回结果:人工直接输入拟定的返回值,绕过工具的实际执行。

处理中断与恢复执行 (Handle interrupts) ​

当智能体遇到中断时,其执行会暂停在当前状态,并在状态中包含待处理的中断信息。 使用 Command 对象并附带对应的 resume 数据即可恢复图的执行:

ts
import { tool } from "langchain";
import { createDeepAgent } from "deepagents";
import { MemorySaver } from "@langchain/langgraph";
import { z } from "zod";

const removeFile = tool(
  async ({ path }: { path: string }) => {
    return `Deleted ${path}`;
  },
  {
    name: "remove_file",
    description: "Delete a file from the filesystem.",
    schema: z.object({
      path: z.string(),
    }),
  },
);

const fetchFile = tool(
  async ({ path }: { path: string }) => {
    return `Contents of ${path}`;
  },
  {
    name: "fetch_file",
    description: "Read a file from the filesystem.",
    schema: z.object({
      path: z.string(),
    }),
  },
);

const notifyEmail = tool(
  async ({
    to,
    subject,
    body,
  }: {
    to: string;
    subject: string;
    body: string;
  }) => {
    return `Sent email to ${to}`;
  },
  {
    name: "notify_email",
    description: "Send an email.",
    schema: z.object({
      to: z.string(),
      subject: z.string(),
      body: z.string(),
    }),
  },
);

// Checkpointer is REQUIRED for human-in-the-loop
const checkpointer = new MemorySaver();

const agent = createDeepAgent({
  model: "google:gemini-3.6-flash",
  tools: [removeFile, fetchFile, notifyEmail],
  interruptOn: {
    remove_file: true, // Default: approve, edit, reject, respond
    fetch_file: false, // No interrupts needed
    notify_email: { allowedDecisions: ["approve", "reject"] }, // No editing
  },
  checkpointer, // Required!
});

在流式传输中处理中断 (Handle interrupts with streaming) ​

在流式模式下,客户端通过监听流事件实时捕获中断事件,弹出审批卡片并在用户交互后提交恢复指令:

typescript
const interruptOn = {
  // Sensitive operations: allow all options
  delete_file: { allowedDecisions: ["approve", "edit", "reject"] },

  // Moderate risk: approval or rejection only
  write_file: { allowedDecisions: ["approve", "reject"] },

  // Must approve (no rejection allowed)
  critical_operation: { allowedDecisions: ["approve"] },
};

并行多工具调用审批 (Multiple tool calls) ​

当大语言模型在单轮中同时生成了多个工具调用时,审批机制支持按批次统一处理,或通过单调用模式(per-call mode)逐一单独审批。

拒绝消息反馈 (Rejection messages) ​

当审批者选择 reject 拒绝操作时,建议附带一段文本反馈(例如“由于当前处于生产封板期,禁止修改配置”)。该理由会作为工具消息回传给模型,引导其生成合规的替代方案。

人工编辑工具入参 (Edit tool arguments) ​

审批者不仅可以拒绝或放行,还可以在执行前直接修正大模型生成的参数值。修改后的参数同样会通过工具的 Schema 校验。

子智能体中断审批 (Subagent interrupts) ​

子智能体内部的敏感工具调用同样支持人在回路控制:

1. 工具调用级中断 ​

可以在子智能体规格说明中单独为其指定 interruptOn 规则。

2. 工具调用内部动态中断 ​

工具可以在执行到一半时,根据业务逻辑动态调用 interrupt() 向人工求助(例如请求输入一次性动态验证码 OTP)。

typescript
import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";

// Create config with thread_id for state persistence
const config = { configurable: { thread_id: uuid7() } };

// Invoke the agent
let result = await agent.invoke({
  messages: [{ role: "user", content: "Delete the file temp.txt" }],
}, config);

// Check if execution was interrupted
if (result.__interrupt__) {
  // Extract interrupt information
  const interrupts = result.__interrupt__[0].value;
  const actionRequests = interrupts.actionRequests;
  const reviewConfigs = interrupts.reviewConfigs;

  // Create a lookup map from tool name to review config
  const configMap = Object.fromEntries(
    reviewConfigs.map((cfg) => [cfg.actionName, cfg])
  );

  // Display the pending actions to the user
  for (const action of actionRequests) {
    const reviewConfig = configMap[action.name];
    console.log(`Tool: ${action.name}`);
    console.log(`Arguments: ${JSON.stringify(action.args)}`);
    console.log(`Allowed decisions: ${reviewConfig.allowedDecisions}`);
  }

  // Get user decisions (one per actionRequest, in order)
  const decisions = [
    {
      type: "reject",
      message: "User rejected deleting temp.txt. Do not retry deletion.",
    }
  ];

  // Resume execution with decisions
  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config  // Must use the same config!
  );
}

// Process final result
console.log(result.messages[result.messages.length - 1].content);

Handle interrupts with streaming ​

For real-time UIs, use .stream() instead of .invoke() to display tokens and tool call progress while handling HITL interrupts. Interrupts appear as __interrupt__ entries in the updates stream. After the stream completes, collect human decisions and resume by streaming again with Command(resume=...).

NOTE

For a typical interrupt_on HITL pause, resume with a flat {"decisions": [...]} payload, the same shape as with .invoke(). When multiple interrupts are pending at once (for example, parallel branches), map each interrupt ID to its resume value instead. See Handling multiple interrupts.

typescript
import { v7 as uuid7 } from "uuid";
import { Command } from "@langchain/langgraph";

const config = { configurable: { thread_id: uuid7() } };
let streamInput: Record<string, any> | Command = {
  messages: [{ role: "user", content: "Delete the file temp.txt" }],
};

while (true) {
  let pendingInterrupt: { value: any } | undefined;

  for await (const chunk of await agent.stream(  
    streamInput,
    {
      streamMode: ["messages", "updates"],  
      ...config,
    }
  )) {
    const [mode, data] = chunk;

    if (mode === "messages") {
      const [token] = data;
      // Display tokens in real time
      if (token.text) {
        process.stdout.write(token.text);
      }
    }

    if (mode === "updates" && data?.__interrupt__) {  
      pendingInterrupt = data.__interrupt__[0];  
    }
  }

  // If no interrupt occurred, the agent finished
  if (!pendingInterrupt) break;

  const interruptValue = pendingInterrupt.value;
  const actionRequests = interruptValue.actionRequests;
  const reviewConfigs = interruptValue.reviewConfigs;
  const configMap = Object.fromEntries(
    reviewConfigs.map((cfg: any) => [cfg.actionName, cfg])
  );

  for (const action of actionRequests) {
    const review = configMap[action.name];
    console.log(`\nTool: ${action.name}`);
    console.log(`Arguments: ${JSON.stringify(action.args)}`);
    console.log(`Allowed: ${review.allowedDecisions}`);
  }

  // Collect one decision per actionRequest, in order
  const decisions = actionRequests.map(() => ({ type: "approve" }));
  streamInput = new Command({ resume: { decisions } });  
}

process.stdout.write("\n");

TIP

Combine "messages" and "updates" stream modes to show real-time LLM tokens to the user while still catching HITL interrupts. The "messages" mode streams tokens; the "updates" mode delivers interrupt payloads.

Multiple tool calls ​

When the agent calls multiple tools that require approval, all interrupts are batched together in a single interrupt. You must provide decisions for each one in order.

typescript
const config = { configurable: { thread_id: uuid7() } };

let result = await agent.invoke({
  messages: [{
    role: "user",
    content: "Delete temp.txt and send an email to admin@example.com"
  }]
}, config);

if (result.__interrupt__) {
  const interrupts = result.__interrupt__[0].value;
  const actionRequests = interrupts.actionRequests;

  // Two tools need approval
  console.assert(actionRequests.length === 2);

  // Provide decisions in the same order as actionRequests
  const decisions = [
    { type: "approve" },  // First tool: delete_file
    {
      type: "reject",
      message: "User rejected this action. Do not retry this tool call.",
    }  // Second tool: send_email
  ];

  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config
  );
}

Rejection messages ​

When a reviewer returns a reject decision, Deep Agents skip the tool call and send rejection feedback back to the agent. If you omit message, the default feedback tells the model that the tool was not executed and not to retry the same tool call unless the user asks.

For sensitive or side-effecting tools, pass a domain-specific message with the decision. Be explicit about whether the agent should abandon the action, ask a follow-up question, or try a safer alternative.

typescript
const decisions = [
  {
    type: "reject",
    message: "User rejected deleting this file. Do not retry deletion. Ask which file to archive instead.",
  },
];

Edit tool arguments ​

When "edit" is in the allowed decisions, you can modify the tool arguments before execution:

typescript
if (result.__interrupt__) {
  const interrupts = result.__interrupt__[0].value;
  const actionRequest = interrupts.actionRequests[0];

  // Original args from the agent
  console.log(actionRequest.args);  // { to: "everyone@company.com", ... }

  // User decides to edit the recipient
  const decisions = [{
    type: "edit",
    editedAction: {
      name: actionRequest.name,  // Must include the tool name
      args: { to: "team@company.com", subject: "...", body: "..." }
    }
  }];

  result = await agent.invoke(
    new Command({ resume: { decisions } }),
    config
  );
}

Subagent interrupts ​

When using subagents, you can use interrupts on tool calls and within tool calls.

Interrupts on tool calls ​

Each subagent can have its own interrupt_on configuration that overrides the main agent's settings:

typescript
const agent = createDeepAgent({
  tools: [deleteFile, readFile],
  interruptOn: {
    delete_file: true,
    read_file: false,
  },
  subagents: [{
    name: "file-manager",
    description: "Manages file operations",
    systemPrompt: "You are a file management assistant.",
    tools: [deleteFile, readFile],
    interruptOn: {
      // Override: require approval for reads in this subagent
      delete_file: true,
      read_file: true,  // Different from main agent!
    }
  }],
  checkpointer
});

When a subagent triggers an interrupt, the handling is the same—check for __interrupt__ on the result and resume with Command.

When streaming, subagent interrupts also surface as __interrupt__ entries in the parent updates stream. You do not need subgraphs=True for interrupt delivery. Enable subgraphs=True if you also want to stream subagent tokens and progress events. See Handle interrupts with streaming for the full pattern.

Interrupts within tool calls ​

Subagent tools can call interrupt() directly to pause execution and await approval:

typescript
import { createAgent, tool } from "langchain";
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage } from "@langchain/core/messages";
import { MemorySaver, Command, interrupt } from "@langchain/langgraph";
import { createDeepAgent } from "deepagents";
import { z } from "zod";

const requestApproval = tool(
  async ({ actionDescription }: { actionDescription: string }) => {
    const approval = interrupt({
      type: "approval_request",
      action: actionDescription,
      message: `Please approve or reject: ${actionDescription}`,
    }) as { approved?: boolean; reason?: string };

    if (approval.approved) {
      return `Action '${actionDescription}' was APPROVED. Proceeding...`;
    } else {
      return `Action '${actionDescription}' was REJECTED. Reason: ${
        approval.reason || "No reason provided"
      }`;
    }
  },
  {
    name: "request_approval",
    description: "Request human approval before proceeding with an action.",
    schema: z.object({
      actionDescription: z
        .string()
        .describe("The action that requires approval"),
    }),
  }
);

async function main() {
  const checkpointer = new MemorySaver();
  const model = new ChatOpenAI({
    model: "gpt-5.4-mini",
    maxTokens: 4096,
  });

  const compiledSubagent = createAgent({
    model: model,
    tools: [requestApproval],
    name: "approval-agent",
  });

  const parentAgent = await createDeepAgent({
    checkpointer: checkpointer,
    subagents: [
      {
        name: "approval-agent",
        description: "An agent that can request approvals",
        runnable: compiledSubagent as any,
      },
    ],
  });

  const threadId = "test_interrupt_directly";
  const config = { configurable: { thread_id: threadId } };

  console.log("Invoking agent - sub-agent will use request_approval tool...");

  let result = await parentAgent.invoke(
    {
      messages: [
        new HumanMessage({
          content:
            "Use the task tool to launch the approval-agent sub-agent. " +
            "Tell it to use the request_approval tool to request approval for 'deploying to production'.",
        }),
      ],
    },
    config
  );

  if (result.__interrupt__) {
    const interruptValue = result.__interrupt__[0].value as {
      type?: string;
      action?: string;
      message?: string;
    };
    console.log("\nInterrupt received!");
    console.log(`  Type: ${interruptValue.type}`);
    console.log(`  Action: ${interruptValue.action}`);
    console.log(`  Message: ${interruptValue.message}`);

    console.log("\nResuming with Command(resume={'approved': true})...");
    const result2 = await parentAgent.invoke(
      new Command({ resume: { approved: true } }),
      config
    );

    if (!result2.__interrupt__) {
      console.log("\nExecution completed!");
      // Find the tool response
      const toolMsgs = result2.messages?.filter((m) => m.type === "tool") || [];
      if (toolMsgs.length > 0) {
        const lastToolMsg = toolMsgs[toolMsgs.length - 1];
        console.log(`  Tool result: ${lastToolMsg.content}`);
      }
    } else {
      console.log("\nAnother interrupt occurred");
    }
  } else {
    console.log(
      "\n  No interrupt - the model may not have called request_approval"
    );
  }
}

main().catch(console.error);

When run, this produces the following output:

text
Invoking agent - sub-agent will use request_approval tool...

Interrupt received!
  Type: approval_request
  Action: deploying to production
  Message: Please approve or reject: deploying to production

Resuming with Command(resume={'approved': true})...

Execution completed!
  Tool result: Approval for "deploying to production" has been granted. You can proceed with the deployment.

Connect these docs to your agent of choice via MCP for real-time answers.

Edit this page on GitHub or file an issue.