🕵️ SicherheitslückenWhat continuous operational resilience looks like under DORA(09.09.2026 um 17:53 Uhr)
🔧 AI Nachrichten OpenAI seeks tougher AI rules. CIOs may feel the ripple effects(10.09.2026 um 12:11 Uhr)
🔧 AI Nachrichten Mistral valued at €21bn after €3bn Series D funding round(08.09.2026 um 10:19 Uhr)
🪟 Windows TippsWindows XP's Cursor Indicator Is Getting a Windows 11 Refresh(25.08.2026 um 13:00 Uhr)
🕵️ SicherheitslückenWhat continuous operational resilience looks like under DORA(09.09.2026 um 17:53 Uhr)
🔧 AI Nachrichten OpenAI seeks tougher AI rules. CIOs may feel the ripple effects(10.09.2026 um 12:11 Uhr)
🔧 AI Nachrichten Mistral valued at €21bn after €3bn Series D funding round(08.09.2026 um 10:19 Uhr)
🪟 Windows TippsWindows XP's Cursor Indicator Is Getting a Windows 11 Refresh(25.08.2026 um 13:00 Uhr)

🔧 Programmierung 🕛 vor 1 Monat 11 Min Lesezeit
0

Understanding "Handoffs" in LangChain(One Agent, Many Personalities)

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

If you've been building with LangChain agents, you've probably run into a situation like this: you want your agent to behave differently depending on where the conversation is. Maybe it should ask clarifying questions at first, then switch to solving the problem, then wrap things up politely.



The tempting solution is to build three separate agents and pass the conversation between them. But there's a simpler pattern that gets you the same result with a single agent: handoffs.



In this post, I'll walk through a small support-bot example that uses this pattern, and explain what's actually happening under the hood.






What is a "handoff," really?



A handoff is not a new agent taking over. It's the same agent, but its instructions and available tools change based on a piece of state.



Think of it like a single customer service rep who has a different script taped to their desk depending on which "stage" a ticket is in. The rep doesn't change. The script does.



In code, this means:




  1. You track the current stage in your agent's state (a plain variable like current_step).

  2. Before each model call, you check that variable and swap in the right system prompt and the right set of tools.

  3. A tool call can update that variable, which moves the conversation into the next stage.



That's the whole idea. No sub-agents, no orchestrator, no complicated routing logic. Just state and a middleware function that reads it.






The example: a simple support bot






CODE
"""
Handoffs Demo
=============
A single agent changes behavior based on a state variable (current_step).
Tools update the state to trigger transitions between steps.

Flow:
Step
"triage" -> Collects issue type from user
Step
"resolve" -> Provides solution based on issue type
Step
"done" -> Conversation ends
"""

import os
from dotenv import load_dotenv

load_dotenv()

from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.messages import ToolMessage
from langchain.tools import tool, ToolRuntime
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import Command
from typing import Callable

# =============================================
# 1. STATE — tracks which step we're on
# =============================================

class SupportState(AgentState):
current_step: str = "triage"
issue_type: str = ""

# =============================================
# 2. TOOLS — each updates current_step (handoff)
# =============================================

@tool
def record_issue(
issue: str,
runtime: ToolRuntime[None, SupportState],
) -> Command:
"""Record the user's issue type and handoff to the resolution step."""
return Command(
update={
"messages": [
ToolMessage(
content=f"Issue recorded: {issue}",
tool_call_id=runtime.tool_call_id,
)
],
"issue_type": issue,
"current_step": "resolve",
}
)

@tool
def resolve_billing(
runtime: ToolRuntime[None, SupportState],
) -> str:
"""Resolve billing issue."""
return "We have issued a refund. It will reflect in 3-5 business days."

@tool
def resolve_technical(
runtime: ToolRuntime[None, SupportState],
) -> str:
"""Resolve technical issue."""
return "Please clear your cache and restart the app. If the issue persists, reinstall."

@tool
def resolve_other(
runtime: ToolRuntime[None, SupportState],
) -> str:
"""Resolve general inquiry."""
return "Thank you for reaching out. A support agent will contact you within 24 hours."

@tool
def end_conversation(
runtime: ToolRuntime[None, SupportState],
) -> Command:
"""Mark the conversation as done."""
return Command(
update={
"messages": [
ToolMessage(
content="Conversation ended.",
tool_call_id=runtime.tool_call_id,
)
],
"current_step": "done",
},
)

# =============================================
# 3. MIDDLEWARE — changes behavior per step
# =============================================

@wrap_model_call
def apply_step_config(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
step = request.state.get("current_step", "triage")

configs = {
"triage": {
"prompt": (
"You are a support agent. Your job is to identify the user's issue type.\n"
"Available issue types: billing, technical, other.\n"
"Call record_issue once you know the issue type."
),
"tools": [record_issue],
},
"resolve": {
"prompt": (
"You are a support agent. The user's issue type is: {issue_type}.\n"
"Call the appropriate resolve_ tool, then call end_conversation."
),
"tools": [resolve_billing, resolve_technical, resolve_other, end_conversation],
},
"done": {
"prompt": "The conversation is complete. Say goodbye.",
"tools": [],
},
}

config = configs.get(step, configs["triage"])
request = request.override(
system_prompt=config["prompt"].format(**request.state),
tools=config["tools"],
)
return handler(request)

# =============================================
# 4. BUILD AGENT
# =============================================

free_llm = ChatOpenAI(
model="nvidia/nemotron-nano-9b-v2:free",
base_url="https://openrouter.ai/api/v1",
api_key=os.environ.get("OPENROUTER_API_KEY"),
)

agent = create_agent(
model=free_llm,
tools=[record_issue, resolve_billing, resolve_technical, resolve_other, end_conversation],
state_schema=SupportState,
middleware=[apply_step_config],
checkpointer=MemorySaver(),
)

# =============================================
# 5. RUN — simulate a conversation
# =============================================

print("=" * 60)
print("Handoffs Demo")
print("=" * 60)

config = {"configurable": {"thread_id": "demo-1"}}

response = agent.invoke(
{"messages": [{"role": "user", "content": "I was charged twice for my subscription."}]},
config=config,
)

print("\nFull conversation (handoffs in action):")
for msg in response["messages"]:
role = msg.__class__.__name__.replace("Message", "")
if hasattr(msg, "content") and msg.content:
print(f" [{role}] {msg.content[:200]}")
elif hasattr(msg, "tool_calls") and msg.tool_calls:
for tc in msg.tool_calls:
print(f" [{role}] Tool call: {tc['name']}")

print("\nState changes:")
for msg in response["messages"]:
if hasattr(msg, "additional_kwargs") and msg.additional_kwargs:
continue

print(" Step flow: triage -> resolve -> done")






The demo bot has three stages:





  • triage — figure out what kind of issue the user has (billing, technical, or something else)


  • resolve — actually solve it, based on what was found in triage


  • done — the conversation is wrapped up



Let's break down how each piece makes that happen.






1. State holds the "current step"






CODE
class SupportState(AgentState):
current_step: str = "triage"
issue_type: str = ""






This is the whole trick. current_step starts at "triage" and will change as the conversation moves forward. issue_type stores what the user's problem actually was, so later stages can refer back to it.



If you're new to LangChain agents, AgentState is just the base class that already tracks things like the message history. Here we're extending it with two extra fields specific to this bot.






2. Tools that trigger the handoff



This is the part that trips people up at first, because the tools don't just return an answer — some of them return a Command that updates state.




CODE
@tool
def record_issue(
issue: str,
runtime: ToolRuntime[None, SupportState],
) -> Command:
"""Record the user's issue type and handoff to the resolution step."""
return Command(
update={
"messages": [
ToolMessage(
content=f"Issue recorded: {issue}",
tool_call_id=runtime.tool_call_id,
)
],
"issue_type": issue,
"current_step": "resolve",
}
)






When the model calls record_issue, three things happen at once:




  • A ToolMessage gets added to the conversation (so the model sees the tool ran)


  • issue_type gets saved


  • current_step flips from "triage" to "resolve"



That last line is the actual handoff. The very next time the model is called, it will be running under completely different instructions, because current_step changed.



Compare that to the other tools, like resolve_billing:




CODE
@tool
def resolve_billing(
runtime: ToolRuntime[None, SupportState],
) -> str:
"""Resolve billing issue."""
return "We have issued a refund. It will reflect in 3-5 business days."






This one just returns a plain string. It does its job but doesn't change the stage. The actual stage-ending move happens in end_conversation, which — same as record_issue — returns a Command that sets current_step to "done".



So the pattern is: tools that just do something return a plain value, and tools that move the conversation forward return a Command updating state.






3. Middleware: the part that reads state and reconfigures the agent



This is where the different "scripts" actually get swapped in.




CODE
@wrap_model_call
def apply_step_config(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
step = request.state.get("current_step", "triage")

configs = {
"triage": {
"prompt": (
"You are a support agent. Your job is to identify the user's issue type.\n"
"Available issue types: billing, technical, other.\n"
"Call record_issue once you know the issue type."
),
"tools": [record_issue],
},
"resolve": {
"prompt": (
"You are a support agent. The user's issue type is: {issue_type}.\n"
"Call the appropriate resolve_ tool, then call end_conversation."
),
"tools": [resolve_billing, resolve_technical, resolve_other, end_conversation],
},
"done": {
"prompt": "The conversation is complete. Say goodbye.",
"tools": [],
},
}

config = configs.get(step, configs["triage"])
request = request.override(
system_prompt=config["prompt"].format(**request.state),
tools=config["tools"],
)
return handler(request)






@wrap_model_call is middleware that runs right before every single model call. Every time, it:




  1. Reads current_step out of state

  2. Looks up the matching prompt and tool list from the configs dictionary

  3. Overrides the request with that prompt and those tools

  4. Passes the modified request along to the actual model call



Notice something important: during triage, the model literally does not have access to resolve_billing, resolve_technical, or end_conversation. They're not in its tool list. It physically cannot call them yet, because they haven't been handed to it. This isn't the model "choosing" to behave — it's the code controlling what's even possible at each stage.



Also worth noticing: config["prompt"].format(**request.state) fills in {issue_type} from state. So once triage records "billing", the resolve-stage prompt literally becomes "The user's issue type is: billing," dynamically, without you having to write separate hardcoded prompts for every issue type.






4. Wiring it together






CODE
agent = create_agent(
model=free_llm,
tools=[record_issue, resolve_billing, resolve_technical, resolve_other, end_conversation],
state_schema=SupportState,
middleware=[apply_step_config],
checkpointer=MemorySaver(),
)






A few things to point out for beginners:




  • You still pass all the tools to create_agent. The middleware is what actually restricts which ones are usable at any given moment — the full list here is just the total pool.


  • state_schema=SupportState tells the agent to use your custom state class instead of the default one, which is how current_step and issue_type become available.


  • checkpointer=MemorySaver() lets the agent remember state across multiple calls in the same conversation thread. Without this, state would reset every time you called agent.invoke(...).






Walking through what happens when you run it






CODE
config = {"configurable": {"thread_id": "demo-1"}}

response = agent.invoke(
{"messages": [{"role": "user", "content": "I was charged twice for my subscription."}]},
config=config,
)






Here's the sequence:





  1. current_step starts at "triage" (the default).

  2. The middleware loads the triage prompt and gives the model only record_issue.

  3. The model reads "charged twice" and calls record_issue(issue="billing").

  4. That tool returns a Command setting issue_type = "billing" and current_step = "resolve".

  5. The agent loops back to call the model again — but now the middleware sees current_step = "resolve", so it loads a completely different prompt and gives the model resolve_billing, resolve_technical, resolve_other, and end_conversation.

  6. The model calls resolve_billing(), gets back the refund message, then calls end_conversation().

  7. That last call sets current_step = "done".

  8. If the model were called again, it would now be in the "done" stage with no tools at all, just told to say goodbye.



All of that happens within a single agent.invoke() call, and it's all one agent object the whole time.






Why bother with this instead of separate agents?



A few reasons this pattern is worth knowing:





  • One set of message history. Everything stays in the same conversation, so the model never loses context switching between "agents."


  • Cheap to reason about. All your stage logic lives in one dictionary (configs) instead of being scattered across multiple agent definitions.


  • Hard boundaries. Because tools are only exposed for the current step, you get built-in guardrails. The model literally can't jump ahead and issue a refund before it's identified the issue.


  • Easy to extend. Adding a new stage means adding one more entry to configs and maybe one more tool. You're not rewiring a multi-agent graph.






A mental model to keep



If multi-agent systems feel like handing a customer off between different departments, this handoffs pattern feels more like one employee with a binder of scripts, flipping to a new page depending on which stage the conversation is at. Same person, same memory of the conversation, different instructions and different tools available at each page.



That's really the whole idea behind handoffs in LangChain: state decides behavior, middleware reads the state, and tools are what move the state forward.






Try it yourself



If you want to experiment, a good next step is adding a fourth stage — maybe an "escalate" step for issues that don't fit billing, technical, or other. You'd just need:




  • One more entry in configs with its own prompt and tools

  • A tool that sets current_step = "escalate"



That's it. No new agent, no new graph. Just one more page in the binder.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Sam Altman calls GPT-6 Astra rollout ‘messy’ as enterprise users wait for access
1 Quelle
Swiss government explores replacing Microsoft 365 with open-source software
1 Quelle
What continuous operational resilience looks like under DORA
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Understanding "Handoffs" in LangChain(One Agent, Many Personalities)

Thematisch verwandte Begriffe: Understanding, Handoffs, LangChainOne, Agent · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...