任务移交 架构中,行为会根据状态动态变化。核心机制是: 工具 更新一个状态变量(例如 current_step active_agent ),该变量在多个回合中持续存在,系统读取此变量来调整行为——要么应用不同的配置(系统提示词、工具),要么路由到不同的 智能体 。此模式支持不同智能体之间的任务移交以及单个智能体内的动态配置更改。
任务移交 这一术语由 OpenAI 提出,用于描述通过工具调用(例如 transfer_to_sales_agent )在智能体或状态之间转移控制权。

主要特点

  • 状态驱动的行为:行为根据状态变量(例如 current_step active_agent )变化
  • 基于工具的转换:工具更新状态变量以在状态之间移动
  • 直接用户交互:每个状态的配置直接处理用户消息
  • 持久状态:状态在对话回合之间保持

何时使用

当您需要强制执行顺序约束(仅在满足前提条件后解锁功能)、智能体需要在不同状态下直接与用户对话,或者您正在构建多阶段对话流程时,请使用任务移交模式。此模式对于客户支持场景特别有价值,在这些场景中您需要按特定顺序收集信息——例如,在处理退款之前先收集保修 ID。

基本实现

核心机制是一个 工具 ,它返回一个 Command 来更新状态,触发转换到新的步骤或智能体:
import { tool } from "@langchain/core/tools";
import { Command } from "@langchain/langgraph";
import { ToolMessage } from "@langchain/core/messages";
import { z } from "zod";

const transferToSpecialist = tool(
  async (_, config) => {
    return new Command({
      update: {
        messages: [
          new ToolMessage({  
            content: "Transferred to specialist",
            tool_call_id: config.toolCall?.id!
          })
        ],
        currentStep: "specialist"  // Triggers behavior change
      }
    });
  },
  {
    name: "transfer_to_specialist",
    description: "Transfer to the specialist agent.",
    schema: z.object({})
  }
);
Why include a ToolMessage ? When an LLM calls a tool, it expects a response. The ToolMessage with matching tool_call_id completes this request-response cycle—without it, the conversation history becomes malformed. This is required whenever your handoff tool updates messages.
For a complete implementation, see the tutorial below.

Tutorial: Build customer support with handoffs

Learn how to build a customer support agent using the handoffs pattern, where a single agent transitions between different configurations.

Implementation approaches

There are two ways to implement handoffs: single agent with middleware (one agent with dynamic configuration) or multiple agent subgraphs (distinct agents as graph nodes).

Single agent with middleware

A single agent changes its behavior based on state. Middleware intercepts each model call and dynamically adjusts the system prompt and available tools. Tools update the state variable to trigger transitions:
import { tool } from "@langchain/core/tools";
import { Command } from "@langchain/langgraph";
import { ToolMessage } from "@langchain/core/messages";
import { z } from "zod";

const recordWarrantyStatus = tool(
  async ({ status }, config) => {
    return new Command({
      update: {
        messages: [
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCall?.id!
          })
        ],
        warrantyStatus: status,
        currentStep: "specialist"  // Update state to trigger transition
      }
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition to next step.",
    schema: z.object({
      status: z.string()
    })
  }
);
import { tool, createAgent, AgentState } from "langchain";
import { wrapModelCall, ModelRequest, ModelResponse } from "langchain/agents/middleware";
import { Command } from "@langchain/langgraph";
import { ToolMessage } from "langchain/messages";
import * as z from "zod";

// 1. Define state with current_step tracker
const SupportState = z.object({  
  currentStep: z.string().default("triage"),  
  warrantyStatus: z.string().optional()
});

// 2. Tools update currentStep via Command
const recordWarrantyStatus = tool(
  async ({ status }, config) => {
    return new Command({  
      update: {  
        messages: [  
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCall?.id!
          })
        ],
        warrantyStatus: status,
        // Transition to next step
        currentStep: "specialist",  
      }
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition",
    schema: z.object({ status: z.string() })
  }
);

// 3. Middleware applies dynamic configuration based on currentStep
const applyStepConfig = wrapModelCall(  
  async (request: ModelRequest, handler) => {
    const step = request.state.currentStep || "triage";  

    // Map steps to their configurations
    const configs = {
      triage: {
        prompt: "Collect warranty information...",
        tools: [recordWarrantyStatus]
      },
      specialist: {
        prompt: `Provide solutions based on warranty: ${request.state.warrantyStatus}`,
        tools: [provideSolution, escalate]
      }
    };

    const config = configs[step];
    request = request.override({  
      systemPrompt: config.prompt,  
      tools: config.tools
    });
    return handler(request);
  }
);

// 4. Create agent with middleware
const agent = createAgent({
  model,
  tools: [recordWarrantyStatus, provideSolution, escalate],
  stateSchema: SupportState,
  middleware: [applyStepConfig],  
  checkpointer: new InMemorySaver()  // Persist state across turns
});

Multiple agent subgraphs

Multiple distinct agents exist as separate nodes in a graph. Handoff tools navigate between agent nodes using Command.PARENT to specify which node to execute next.
Subgraph handoffs require careful context engineering . Unlike single-agent middleware (where message history flows naturally), you must explicitly decide what messages pass between agents. Get this wrong and agents receive malformed conversation history or bloated context. See Context engineering below.
import { tool, ToolRuntime } from "@langchain/core/tools";
import { AIMessage, ToolMessage } from "@langchain/core/messages";
import { Command } from "@langchain/langgraph";
import { z } from "zod";

const transferToSales = tool(
  async (_, { runtime }: { runtime: ToolRuntime }) => {
    const lastAiMessage = [...runtime.state.messages]  
      .reverse()  
      .find((msg): msg is AIMessage => msg instanceof AIMessage);  
    const transferMessage = new ToolMessage({  
      content: "Transferred to sales agent",  
      tool_call_id: runtime.toolCallId
    });  
    return new Command({
      goto: "sales_agent",
      update: {
        activeAgent: "sales_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),  
      },
      graph: Command.PARENT
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({})
  }
);
This example shows a multi-agent system with separate sales and support agents. Each agent is a separate graph node, and handoff tools allow agents to transfer conversations to each other.
import { StateGraph, START, END, MessagesAnnotation, Annotation, Command } from "@langchain/langgraph";
import { createAgent } from "langchain";
import { tool, ToolRuntime } from "@langchain/core/tools";
import { AIMessage, ToolMessage } from "@langchain/core/messages";
import { z } from "zod";

// 1. Define state with active_agent tracker
const MultiAgentState = Annotation.Root({
  ...MessagesAnnotation.spec,
  activeAgent: Annotation<string | undefined>({
    reducer: (x, y) => y ?? x,
  })
});

// 2. Create handoff tools
const transferToSales = tool(
  async (_, { runtime }: { runtime: ToolRuntime }) => {
    const lastAiMessage = [...runtime.state.messages]  
      .reverse()  
      .find((msg): msg is AIMessage => msg instanceof AIMessage);  
    const transferMessage = new ToolMessage({  
      content: "Transferred to sales agent from support agent",  
      tool_call_id: runtime.toolCallId
    });  
    return new Command({
      goto: "sales_agent",
      update: {
        activeAgent: "sales_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),  
      },
      graph: Command.PARENT
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({})
  }
);

const transferToSupport = tool(
  async (_, { runtime }: { runtime: ToolRuntime }) => {
    const lastAiMessage = [...runtime.state.messages]  
      .reverse()  
      .find((msg): msg is AIMessage => msg instanceof AIMessage);  
    const transferMessage = new ToolMessage({  
      content: "Transferred to support agent from sales agent",  
      tool_call_id: runtime.toolCallId
    });  
    return new Command({
      goto: "support_agent",
      update: {
        activeAgent: "support_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),  
      },
      graph: Command.PARENT
    });
  },
  {
    name: "transfer_to_support",
    description: "Transfer to the support agent.",
    schema: z.object({})
  }
);

// 3. Create agents with handoff tools
const salesAgent = createAgent({
  model: "anthropic:claude-sonnet-4-20250514",
  tools: [transferToSupport],
  prompt: "You are a sales agent. Help with sales inquiries. If asked about technical issues or support, transfer to the support agent."
});

const supportAgent = createAgent({
  model: "anthropic:claude-sonnet-4-20250514",
  tools: [transferToSales],
  prompt: "You are a support agent. Help with technical issues. If asked about pricing or purchasing, transfer to the sales agent."
});

// 4. Create agent nodes that invoke the agents
const callSalesAgent = async (state: typeof MultiAgentState.State) => {
  const response = await salesAgent.invoke(state);
  return response;
};

const callSupportAgent = async (state: typeof MultiAgentState.State) => {
  const response = await supportAgent.invoke(state);
  return response;
};

// 5. Create router that checks if we should end or continue
const routeAfterAgent = (
  state: typeof MultiAgentState.State
): "sales_agent" | "support_agent" | "__end__" => {
  const messages = state.messages ?? [];

  // Check the last message - if it's an AIMessage without tool calls, we're done
  if (messages.length > 0) {
    const lastMsg = messages[messages.length - 1];
    if (lastMsg instanceof AIMessage && !lastMsg.tool_calls?.length) {  
      return "__end__";  
    }
  }

  // Otherwise route to the active agent
  const active = state.activeAgent ?? "sales_agent";
  return active as "sales_agent" | "support_agent";
};

const routeInitial = (
  state: typeof MultiAgentState.State
): "sales_agent" | "support_agent" => {
  // Route to the active agent based on state, default to sales agent
  return (state.activeAgent ?? "sales_agent") as "sales_agent" | "support_agent";
};

// 6. Build the graph
const builder = new StateGraph(MultiAgentState)
  .addNode("sales_agent", callSalesAgent)
  .addNode("support_agent", callSupportAgent);

// Start with conditional routing based on initial activeAgent
builder.addConditionalEdges(START, routeInitial, ["sales_agent", "support_agent"]);

// After each agent, check if we should end or route to another agent
builder.addConditionalEdges(
  "sales_agent",
  routeAfterAgent,
  ["sales_agent", "support_agent", END]
);
builder.addConditionalEdges(
  "support_agent",
  routeAfterAgent,
  ["sales_agent", "support_agent", END]
);

const graph = builder.compile();
const result = await graph.invoke({
  messages: [
    {
      role: "user",
      content: "Hi, I'm having trouble with my account login. Can you help?"
    }
  ]
});

for (const msg of result.messages) {
  msg.pretty_print();
}
Use single agent with middleware for most handoffs use cases—it’s simpler. Only use multiple agent subgraphs when you need bespoke agent implementations (e.g., a node that’s itself a complex graph with reflection or retrieval steps).

Context engineering

With subgraph handoffs, you control exactly what messages flow between agents. This precision is essential for maintaining valid conversation history and avoiding context bloat that could confuse downstream agents. For more on this topic, see context engineering . Handling context during handoffs When handing off between agents, you need to ensure the conversation history remains valid. LLMs expect tool calls to be paired with their responses, so when using Command.PARENT to hand off to another agent, you must include both:
  1. The AIMessage containing the tool call (the message that triggered the handoff)
  2. A ToolMessage acknowledging the handoff (the artificial response to that tool call)
Without this pairing, the receiving agent will see an incomplete conversation and may produce errors or unexpected behavior. The example below assumes only the handoff tool was called (no parallel tool calls):
Why not pass all subagent messages? While you could include the full subagent conversation in the handoff, this often creates problems. The receiving agent may become confused by irrelevant internal reasoning, and token costs increase unnecessarily. By passing only the handoff pair, you keep the parent graph’s context focused on high-level coordination. If the receiving agent needs additional context, consider summarizing the subagent’s work in the ToolMessage content instead of passing raw message history.
Returning control to the user When returning control to the user (ending the agent’s turn), ensure the final message is an AIMessage . This maintains valid conversation history and signals to the user interface that the agent has finished its work.

Implementation Considerations

As you design your multi-agent system, consider:
  • Context filtering strategy : Will each agent receive full conversation history, filtered portions, or summaries? Different agents may need different context depending on their role.
  • Tool semantics : Clarify whether handoff tools only update routing state or also perform side effects. For example, should transfer_to_sales() also create a support ticket, or should that be a separate action?
  • Token efficiency : Balance context completeness against token costs. Summarization and selective context passing become more important as conversations grow longer.

Connect these docs to Claude, VSCode, and more via MCP for real-time answers.