tools_condition: routing to tools or END
Why this matters
In real agentic workflows, you need to decide at runtime whether the LLM should call tools or finish the conversation. tools_condition is the standard LangGraph pattern for this branching logic: mastering it is essential for building multi-turn agents that don't waste token calls or get stuck in loops.
Explanation
What it is: tools_condition is a built-in routing function in langgraph that examines an LLM message and returns either the name of a tool node to execute or END to terminate the graph. It checks if the message contains tool_calls: if yes, route to tools; if no, route to END. How it works mechanically: After your LLM node generates a message, you call add_conditional_edges with tools_condition as the routing function. The function inspects state['messages'][-1].tool_calls. If that list is non-empty, it returns the tool node name. If empty, it returns END. The graph then follows that edge. When to use it: This is the standard pattern for agentic loops where the LLM decides whether to call tools or finish. Use it whenever you have an LLM that may or may not need to invoke tools in a single turn.
Analogy
Think of tools_condition as a traffic light at an intersection. The LLM's output is a car arriving at the light. If the message has tool_calls (the light is red), route the car to the tool-handling road. If there are no tool_calls (the light is green), let the car go straight to the exit.
Code
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import tools_condition
from langchain_core.messages import BaseMessage, ToolMessage
from langchain_openai import ChatOpenAI
from typing import Annotated
from pydantic import BaseModel, Field
import anthropic
from typing_extensions import TypedDict
class State(TypedDict):
messages: Annotated[list[BaseMessage], lambda x, y: x + y]
def llm_node(state: State) -> State:
"""Simulate an LLM that may or may not call tools."""
# For demo, return a message with tool_calls
from langchain_core.messages import AIMessage
msg = AIMessage(
content="I'll help you with that.",
tool_calls=[
{
"id": "call_123",
"function": {"name": "weather", "arguments": '{"location": "NYC"}'},
"type": "function"
}
]
)
return {"messages": [msg]}
def tool_node(state: State) -> State:
"""Execute the tool and return the result."""
# Get the last AI message with tool_calls
last_message = state["messages"][-1]
tool_results = []
# For each tool_call, execute it (simulated)
for tool_call in last_message.tool_calls:
# In production, dispatch to actual tool handlers
result_msg = ToolMessage(
tool_call_id=tool_call["id"],
content=f"Weather in {tool_call['function']['name']}: 72F and sunny"
)
tool_results.append(result_msg)
return {"messages": tool_results}
def create_graph():
graph = StateGraph(State)
graph.add_node("llm", llm_node)
graph.add_node("tools", tool_node)
# Start with the LLM node
graph.add_edge(START, "llm")
# Use tools_condition to route: if tool_calls exist, go to "tools"; else go to END
graph.add_conditional_edges("llm", tools_condition, {"tools": "tools", END: END})
# After tools execute, loop back to LLM for another decision
graph.add_edge("tools", "llm")
return graph.compile()
if __name__ == "__main__":
compiled_graph = create_graph()
from langchain_core.messages import HumanMessage
initial_state = {"messages": [HumanMessage(content="What's the weather?")]}
result = compiled_graph.invoke(initial_state)
print("Final messages:")
for msg in result["messages"]:
print(f" {msg.__class__.__name__}: {msg.content[:60]}...") Final messages: HumanMessage: What's the weather? AIMessage: I'll help you with that. ToolMessage: Weather in weather: 72F and sunny AIMessage: I'll help you with that.
What just happened?
The graph started with a human message, sent it to the LLM node which returned an AIMessage with tool_calls. The tools_condition function detected the tool_calls list was non-empty and routed to the 'tools' node. The tool node extracted each tool_call, simulated execution, and returned ToolMessage results. Then the graph looped back to the LLM node, which received the tool results but had no more tool_calls to make, so tools_condition would route to END on the next iteration.
Common gotcha
The most common mistake: developers forget that tools_condition checks state['messages'][-1].tool_calls: specifically the last message in the messages list. If your LLM node appends a message that has an empty tool_calls list (or no tool_calls attribute at all), tools_condition will route to END, not to tools. Also, tools_condition returns a string (the node name), not a boolean: you must wire the conditional_edges mapping correctly, mapping the returned string to the actual node name.
Error recovery
KeyError: 'messages'ValueError: Conditional edge target 'X' not foundAttributeError: 'NoneType' object has no attribute 'tool_calls'Experienced dev note
In production, you'll often find that agents get stuck in loops or waste tokens calling the same tool repeatedly. The fix is usually not to change tools_condition itself, but to add a counter in your state or modify your LLM's system prompt to say 'if you've called this tool before, don't call it again.' tools_condition is just the router: it can't prevent bad LLM decisions, only observe them. Also, tools_condition only cares about the last message; if you're building multi-agent systems where multiple nodes may emit messages, you need to be explicit about which message the routing decision depends on.
Check your understanding
If your LLM node returns an AIMessage with an empty tool_calls list (like AIMessage(content='Done', tool_calls=[])), which node will execute next, and why?
Show answer hint
The answer requires understanding that tools_condition checks the truthiness of the tool_calls list itself, not just its existence. An empty list [] is falsy in Python, so it should route to END. The key insight is that the presence of the attribute is not what matters: it's whether the list has any items in it.