Skip to content

create_agent: the first before_model hook can return "model" but its path map omits it -> KeyError #40136

Description

@JessYanCoding

Submission checklist

  • This is a bug, not a usage question.
  • I added a clear and descriptive title that summarizes this issue.
  • I used the GitHub search to find a similar question and didn't find it.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
  • This is not related to the langchain-community package.
  • I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.

Package (Required)

  • langchain
  • langchain-openai
  • langchain-anthropic
  • langchain-classic
  • langchain-core
  • langchain-model-profiles
  • langchain-tests
  • langchain-text-splitters
  • langchain-chroma
  • langchain-deepseek
  • langchain-exa
  • langchain-fireworks
  • langchain-groq
  • langchain-huggingface
  • langchain-mistralai
  • langchain-nomic
  • langchain-ollama
  • langchain-openrouter
  • langchain-perplexity
  • langchain-qdrant
  • langchain-xai
  • Other / not sure / general

Related Issues / PRs

Reproduction Steps / Example Code (Python)

"""A `before_model` hook that returns `jump_to="model"` crashes `create_agent`.

Self-contained: stub chat model, no API key, no network. Run as-is with only
`langchain` installed.

Observed on langchain 1.3.18 / langchain-core 1.6.1 / langgraph 1.2.11:

    graph nodes: ['__end__', '__start__', 'RestartOnce.before_model', 'model', 'tools']
    ...
      File ".../langgraph/graph/_branch.py", line 203, in <listcomp>
        r if isinstance(r, Send) else self.ends[r] for r in result
                                      ~~~~~~~~~^^^
    KeyError: 'RestartOnce.before_model'
    During task with name 'RestartOnce.before_model'

The node exists in the graph; it is missing from the conditional edge's path map.
Putting another `before_model` middleware ahead of this one, or changing the hook
to `after_model` or `before_agent`, makes the same jump work.
"""

from typing import Any

from langchain.agents import create_agent
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.tools import tool


class StubModel(BaseChatModel):
    """Answers once, makes no tool calls, needs no credentials."""

    @property
    def _llm_type(self) -> str:
        return "stub"

    def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content="ok"))])

    def bind_tools(self, tools, **kwargs):
        return self


@tool
def my_tool(value: str) -> str:
    """A great tool."""
    return value.upper()


calls: list[str] = []


class RestartOnce(AgentMiddleware):
    @hook_config(can_jump_to=["model"])
    def before_model(self, state, runtime) -> dict[str, Any] | None:
        calls.append("before_model")
        if len(calls) == 1:
            return {"jump_to": "model"}  # re-enter the model loop once
        return None


agent = create_agent(model=StubModel(), tools=[my_tool], middleware=[RestartOnce()])
print("graph nodes:", sorted(agent.get_graph().nodes))
print(agent.invoke({"messages": [HumanMessage("hello")]}))

Error Message and Stack Trace (if applicable)

graph nodes: ['RestartOnce.before_model', '__end__', '__start__', 'model', 'tools']
Traceback (most recent call last):
  File "repro.py", line 65, in <module>
    print(agent.invoke({"messages": [HumanMessage("hello")]}))
  File ".../langgraph/pregel/main.py", line 3913, in invoke
  File ".../langgraph/pregel/main.py", line 2967, in stream
  File ".../langgraph/pregel/_runner.py", line 207, in tick
  File ".../langgraph/pregel/_retry.py", line 617, in run_with_retry
  File ".../langgraph/_internal/_runnable.py", line 709, in invoke
  File ".../langgraph/_internal/_runnable.py", line 447, in invoke
  File ".../langgraph/graph/_branch.py", line 167, in _route
    return self._finish(writer, input, result, config)
  File ".../langgraph/graph/_branch.py", line 203, in <listcomp>
    r if isinstance(r, Send) else self.ends[r] for r in result
                                  ~~~~~~~~~^^^
KeyError: 'RestartOnce.before_model'
During task with name 'RestartOnce.before_model'

Description

JumpTo is Literal["tools", "model", "end"], and hook_config(can_jump_to=...) accepts any of the three on any hook — there is no per-hook restriction in the types, in the decorator, or in the docs. Yet a before_model hook that actually returns {"jump_to": "model"} crashes the agent at runtime with a bare KeyError naming its own graph node, raised from inside LangGraph's branch resolution, with nothing pointing back at the middleware or at can_jump_to.

The cause is in _add_middleware_edge. The model loop is entered at the first before_model node, so model_destination (the loop entry node) is that node's own name. The guard name != model_destination then drops it from the conditional edge's path map, while jump_edge still resolves "model" to it — so LangGraph is asked for a destination that was never declared. This is the same shape as #38351, one function over: a router can return the loop entry node while the path map omits it.

Only one configuration breaks; every neighbouring one works:

configuration result
single before_model, jumps to "model" KeyError
first of two before_model, jumps KeyError
second of two before_model, jumps works
after_model jumps to "model" works
before_agent jumps to "model" works

A guardrail or validation middleware that rewrites state and wants the pre-model pipeline re-run is the natural use of this jump, and today it is simply unavailable — with an error that gives the user nothing to search for.

Expected behaviour: either the jump re-enters the loop at its entry node, exactly as the identical jump from after_model already does, or create_agent rejects the configuration while building the graph. Not an unexplained KeyError at run time.

Suggested fix: declare the self-destination, by replacing the name != model_destination guard with a model_destination not in destinations de-duplication. One line.

Verified locally against master: the four working shapes above still pass, the two failing ones now pass, and a combinatorial sweep over middleware configurations drops from 42 KeyErrors to zero. The langchain unit suite reports 1104 passed with the regression test and 1103 with the implementation change alone, so nothing else moves; ruff and mypy are clean.

A patch with that fix and a regression test is ready and linked to this issue. The assignment gate will auto-close that PR, and reopen_on_assignment will bring it back the moment this issue is assigned. Could a maintainer assign this to me?

System Info

Reproduced on a clean throwaway venv with only langchain installed from PyPI:

  • langchain 1.3.18
  • langchain-core 1.6.1
  • langgraph 1.2.11
  • Python 3.11.13
  • macOS 26.6.2 (arm64)

Also reproduced on master at 49da581.

Social handles (optional)

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugRelated to a bug, vulnerability, unexpected error with an existing featureexternallangchain`langchain` package issues & PRs

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions