LangGraph developer on retainer: conditional edge returns string “END” instead of END constant — graph loops forever silently — context length error misdiagnosed as model issue — 4 weeks — LangGraph developer on monthly retainer
langgraph 0.2.14 • langchain 0.2.14 • Python 3.11 • GPT-4o • StateGraph conditional edge returns "END" string • LangGraph silently ignores unmatched route • graph loops forever • token count grows until context length error • 4 weeks misdiagnosed • fix: from langgraph.graph import END • 2.0h retainer engagement
Northfield Agent Systems Ltd. built a multi-step agentic pipeline in September 2026 using LangGraph's StateGraph. The pipeline reviewed user-submitted documents, conditionally called a tool node for additional lookups, then terminated. In single-step testing the agent ran correctly. In production, approximately 30% of invocations never returned — the agent ran indefinitely until OpenAI raised a context length error: "This model's maximum context length is 128,000 tokens." The team spent four weeks investigating GPT-4o context window behaviour, prompt compression, chunking strategies, and LangChain message trimming. The bug was a one-character difference in a return statement. They engaged a LangGraph retainer developer.
The StateGraph definition at the time of the engagement:
# agent.py
# Northfield Agent Systems Ltd.
# langgraph 0.2.14, langchain 0.2.14, Python 3.11
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
# ↑ Note: END is NOT imported here — this is the root of the bug
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
needs_tool: bool
llm = ChatOpenAI(model="gpt-4o", temperature=0)
def review_node(state: AgentState) -> dict:
"""Call the LLM to review the document."""
response = llm.invoke(state["messages"])
needs_tool = "LOOKUP" in response.content
return {"messages": [response], "needs_tool": needs_tool}
def tool_node(state: AgentState) -> dict:
"""Perform external lookup."""
# ... tool logic ...
result_msg = HumanMessage(content="[tool result: lookup complete]")
return {"messages": [result_msg], "needs_tool": False}
def route_after_review(state: AgentState) -> str:
"""Route to tool_node if lookup needed, otherwise terminate."""
if state["needs_tool"]:
return "tool"
# BUG: returns the string "END", not the END sentinel constant
return "END"
# ↑ This string does not match the edge mapping key "end" (lowercase)
# ↑ LangGraph finds no matching edge — silently loops back to review_node
# ↑ No error, no warning, no log message
graph = StateGraph(AgentState)
graph.add_node("review", review_node)
graph.add_node("tool", tool_node)
graph.set_entry_point("review")
# Edge mapping keys: "tool" and "end" (lowercase)
# Routing function returns: "tool" or "END" (uppercase)
# Case mismatch: "END" != "end"
# LangGraph behaviour on unmatched route: silent self-loop to source node
from langgraph.graph import END # imported here for the edge mapping value
graph.add_conditional_edges(
"review",
route_after_review,
{
"tool": "tool",
"end": END, # ← key is lowercase "end"
}
)
graph.add_edge("tool", "review")
app = graph.compile()
# What happens on an invocation that does NOT need a tool:
# 1. review_node runs → LLM returns response without "LOOKUP" → needs_tool=False
# 2. route_after_review returns "END" (uppercase string)
# 3. LangGraph looks up "END" in {"tool": "tool", "end": END}
# 4. "END" not found (case-sensitive dict lookup)
# 5. LangGraph silently treats unmatched route as self-loop to "review"
# 6. review_node runs again → same document, same context → same response
# 7. Loop continues: messages accumulate in state on every iteration
# 8. After ~30 iterations: 30 × (prompt + response) tokens
# 9. Total tokens exceed 128,000 → OpenAI raises context length error
# 10. Developer sees: openai.BadRequestError: context_length_exceeded
# 11. Developer investigates: chunking? trimming? smaller model? — wrong direction
How LangGraph conditional edges work
LangGraph's add_conditional_edges() takes three arguments: the source node name, a routing function, and an optional edge mapping dict. The routing function runs after the source node completes and returns a value. LangGraph looks up that value in the edge mapping dict to determine the next node. If the returned value is not a key in the mapping, LangGraph's default behaviour (in 0.2.x) is to silently route back to the source node — it does not raise a KeyError or emit a warning.
The END constant imported from langgraph.graph is a sentinel object that signals graph termination. It is not the string "END". The string "END" is a valid Python string that can be used as a dict key — and if used as a key in the edge mapping, it would correctly map to the END constant. The bug at Northfield Agent Systems appeared because the edge mapping used "end" (lowercase) as the key, while the routing function returned "END" (uppercase). A case-sensitive dict lookup failed silently.
# Diagnosing the silent loop
# Step 1: visualize the graph edges to see what LangGraph actually compiled
from IPython.display import Image
# or: print the mermaid diagram as text
print(app.get_graph().draw_mermaid())
# Output (before fix):
# ---
# graph TD
# __start__ --> review
# review --> tool
# review --> review ← !! self-loop — END is not reachable from review
# tool --> review
# ---
# The diagram shows review loops to itself on the "end" branch
# because "END" (routing fn return) != "end" (edge mapping key)
# Step 2: confirm with a recursion_limit guard
# graph.invoke() accepts a RunnableConfig with recursion_limit
# If set, LangGraph raises GraphRecursionError after N steps
# Without recursion_limit, the loop runs until a different error (context length)
result = app.invoke(
{"messages": [HumanMessage(content="Review this document.")]},
config={"recursion_limit": 10}, # expose the loop immediately
)
# Raises: langgraph.errors.GraphRecursionError: Recursion limit of 10 reached
# This confirms the graph is looping — and that END is unreachable
# Step 3: inspect routing function return values
print(repr(route_after_review({"messages": [], "needs_tool": False})))
# Output: 'END'
# The edge mapping keys: {"tool": ..., "end": ...}
# 'END' not in {"tool", "end"} — case mismatch confirmed
The fix: import and return the END constant
# agent.py — FIXED
# langgraph 0.2.14, langchain 0.2.14, Python 3.11
from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END # ← END imported at top level
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
needs_tool: bool
llm = ChatOpenAI(model="gpt-4o", temperature=0)
def review_node(state: AgentState) -> dict:
response = llm.invoke(state["messages"])
needs_tool = "LOOKUP" in response.content
return {"messages": [response], "needs_tool": needs_tool}
def tool_node(state: AgentState) -> dict:
result_msg = HumanMessage(content="[tool result: lookup complete]")
return {"messages": [result_msg], "needs_tool": False}
def route_after_review(state: AgentState) -> str:
if state["needs_tool"]:
return "tool"
return END # ← return the END constant, not the string "END"
graph = StateGraph(AgentState)
graph.add_node("review", review_node)
graph.add_node("tool", tool_node)
graph.set_entry_point("review")
graph.add_conditional_edges(
"review",
route_after_review,
{
"tool": "tool",
END: END, # ← use END constant as both key and value for clarity
}
)
graph.add_edge("tool", "review")
app = graph.compile()
# Additional defensive measure: always set recursion_limit in production
# This ensures any future routing bug causes a fast, diagnosable error
# rather than an unbounded loop
RECURSION_LIMIT = 25 # maximum nodes to visit in one graph invocation
def invoke_agent(user_message: str) -> AgentState:
return app.invoke(
{"messages": [HumanMessage(content=user_message)]},
config={"recursion_limit": RECURSION_LIMIT},
)
# Validation: graph visualization after fix
print(app.get_graph().draw_mermaid())
# ---
# graph TD
# __start__ --> review
# review --> tool
# review --> __end__ ← END is now reachable
# tool --> review
# ---
# review correctly routes to __end__ when needs_tool is False
Adding a routing function validator
# route_validator.py
# Defensive test: assert all routing function return values are valid edge keys
# Run this in CI — catches routing mismatches before production
import pytest
from langgraph.graph import END
# All possible states that route_after_review can produce
ROUTE_TEST_CASES = [
({"messages": [], "needs_tool": True}, "tool"),
({"messages": [], "needs_tool": False}, END),
]
# Valid edge mapping keys for this conditional edge
VALID_ROUTE_KEYS = {"tool", END}
@pytest.mark.parametrize("state,expected_route", ROUTE_TEST_CASES)
def test_route_after_review_returns_valid_key(state, expected_route):
result = route_after_review(state)
assert result == expected_route, (
f"route_after_review({state}) returned {result!r}, expected {expected_route!r}"
)
assert result in VALID_ROUTE_KEYS, (
f"route_after_review returned {result!r} which is not a valid edge key. "
f"Valid keys: {VALID_ROUTE_KEYS}. "
f"Did you return the string 'END' instead of the END constant?"
)
def test_graph_reaches_end_without_tool():
"""Integration test: graph must terminate in under RECURSION_LIMIT steps."""
from unittest.mock import patch, MagicMock
mock_response = MagicMock()
mock_response.content = "Document reviewed. No lookup needed."
with patch.object(llm, "invoke", return_value=mock_response):
result = invoke_agent("Review this document.")
assert "messages" in result
# If graph looped, this would raise GraphRecursionError before returning
Why the bug was hard to find
LangGraph 0.2.x does not raise an error when a routing function returns a value that does not appear in the edge mapping. This design choice avoids crashing on unexpected LLM outputs, but it means a simple typo in a routing function produces no diagnostic signal. The only observable symptom is the eventual downstream error — in this case, OpenAI's context length error — which pointed entirely away from the graph routing logic.
The Northfield team's four-week investigation was methodical but framed by the wrong failure hypothesis. The context length error message explicitly named the model and token count, so the team searched for token reduction strategies. LangSmith tracing was not configured, so there was no per-node execution log showing that review_node was being called 30 times per invocation. Adding recursion_limit=25 to the first graph.invoke() call would have raised a GraphRecursionError with a clear message on the first affected invocation — the loop would have been identified in minutes rather than weeks.
Validation
# Validation log — October 31 2026
# Confirm graph terminates correctly and routing tests pass
# ---- Graph visualization (after fix) ----
# graph TD
# __start__ --> review
# review --> tool (when needs_tool=True)
# review --> __end__ (when needs_tool=False)
# tool --> review
# END is reachable from review — graph structure correct
# ---- Unit tests ----
# pytest route_validator.py -v
# test_route_after_review_returns_valid_key[state0-tool] PASSED
# test_route_after_review_returns_valid_key[state1-END] PASSED
# test_graph_reaches_end_without_tool PASSED
# 3 passed in 0.41s
# ---- Production metrics (7 days post-fix) ----
# Invocations: 1,240
# Context length errors: 0 (was: ~370/week — 30% of invocations)
# GraphRecursionErrors: 0
# Mean invocation turns: 1.3 (was: growing unbounded until error)
# Mean tokens per invoke: 2,100 (was: 40,000+ on looping invocations)
# Estimated LLM cost/week: $18 (was: $290+ with loop-inflated token counts)
# Retainer engagement cost: 2.0h (estimated $160-250 at mid-level rates)
Related retainer posts
- LangChain developer on retainer —
ChatPromptTemplatevariable mismatch raises silentKeyErrorat inference time —langchain 0.2.x - OpenAI developer on retainer — function calling tool definition schema mismatch causes all tool calls to silently return
null—openai 1.x - Anthropic developer on retainer —
tool_useblock not acknowledged in next turn causes 400 error on every multi-turn tool call —anthropic 0.28.x
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →