chore(prebuilt): breaking do not support prebound tools on model (#5912)

Do not support prebound tools on the model. There's reason users should be prebinding tools to the model!

This is a breaking change that might affect some users, but the work-around is simple -- provide tools into the create_react_agent api.
This commit is contained in:
Eugene Yurtsev
2025-08-14 14:46:11 -04:00
committed by GitHub
parent 69249e724d
commit 42f9683d73
4 changed files with 95 additions and 154 deletions
@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Awaitable, Callable, TypeVar, Union
from langgraph._internal._typing import StateLike
try:
from typing import ParamSpec # 3.10+
except ImportError:
from typing_extensions import ParamSpec # type: ignore
from langchain_core.language_models import LanguageModelInput
from langchain_core.messages import BaseMessage
from langchain_core.runnables import Runnable
P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T")
MaybeAwaitable = Union[T, Awaitable[T]]
SyncOrAsync = Callable[P, MaybeAwaitable[R]]
# PreConfiguredChatModel is used to support chat models that have beeen pre-configured
# using .bind().
# For example, chat_model.bind(api_key="...") will return a PreConfiguredChatModel
PreConfiguredChatModel = Runnable[LanguageModelInput, BaseMessage]
ContextT = TypeVar("ContextT", bound=StateLike)
@@ -1,3 +1,5 @@
from __future__ import annotations
import inspect
from typing import (
Any,
@@ -43,11 +45,15 @@ from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.graph.state import CompiledStateGraph
from langgraph.managed import RemainingSteps
from langgraph.prebuilt._internal._typing import (
ContextT,
PreConfiguredChatModel,
SyncOrAsync,
)
from langgraph.prebuilt.tool_node import ToolNode
from langgraph.runtime import Runtime
from langgraph.store.base import BaseStore
from langgraph.types import Checkpointer, Send
from langgraph.typing import ContextT
from langgraph.warnings import LangGraphDeprecatedSinceV10
StructuredResponse = Union[dict, BaseModel]
@@ -144,53 +150,6 @@ def _get_prompt_runnable(prompt: Optional[Prompt]) -> Runnable:
return prompt_runnable
def _should_bind_tools(
model: LanguageModelLike, tools: Sequence[BaseTool], num_builtin: int = 0
) -> bool:
if isinstance(model, RunnableSequence):
model = next(
(
step
for step in model.steps
if isinstance(step, (RunnableBinding, BaseChatModel))
),
model,
)
if not isinstance(model, RunnableBinding):
return True
if "tools" not in model.kwargs:
return True
bound_tools = model.kwargs["tools"]
if len(tools) != len(bound_tools) - num_builtin:
raise ValueError(
"Number of tools in the model.bind_tools() and tools passed to create_react_agent must match"
f" Got {len(tools)} tools, expected {len(bound_tools) - num_builtin}"
)
tool_names = set(tool.name for tool in tools)
bound_tool_names = set()
for bound_tool in bound_tools:
# OpenAI-style tool
if bound_tool.get("type") == "function":
bound_tool_name = bound_tool["function"]["name"]
# Anthropic-style tool
elif bound_tool.get("name"):
bound_tool_name = bound_tool["name"]
else:
# unknown tool type so we'll ignore it
continue
bound_tool_names.add(bound_tool_name)
if missing_tools := tool_names - bound_tool_names:
raise ValueError(f"Missing tools '{missing_tools}' in the model.bind_tools()")
return False
def _get_model(model: LanguageModelLike) -> BaseChatModel:
"""Get the underlying model from a RunnableBinding or return the model itself."""
if isinstance(model, RunnableSequence):
@@ -252,16 +211,12 @@ class _AgentBuilder:
self,
model: Union[
str,
LanguageModelLike,
Callable[[StateSchema, Runtime[ContextT]], BaseChatModel],
Callable[[StateSchema, Runtime[ContextT]], Awaitable[BaseChatModel]],
Callable[
BaseChatModel,
PreConfiguredChatModel,
SyncOrAsync[[StateSchema, Runtime[ContextT]], BaseModel],
SyncOrAsync[
[StateSchema, Runtime[ContextT]],
Runnable[LanguageModelInput, BaseMessage],
],
Callable[
[StateSchema, Runtime[ContextT]],
Awaitable[Runnable[LanguageModelInput, BaseMessage]],
Awaitable[PreConfiguredChatModel],
],
],
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
@@ -287,6 +242,28 @@ class _AgentBuilder:
"The 'use_individual_tool_nodes' option is only supported "
"in version 'v2' agents."
)
if isinstance(model, Runnable) and not isinstance(model, BaseChatModel):
# Then we allow for a preconfigured model at least for now.
if not hasattr(model, "bound") or not isinstance(
model.bound, BaseChatModel
):
raise TypeError(
"Expected `model` to be a BaseChatModel or a chat model that "
f"was pre-configured using `.bind()`. Instead got {type(model)}"
)
# Then it's a runnable binding. We don't want any pre-bound tools.
if (kwargs := getattr(model, "kwargs", {})) and "tools" in kwargs:
raise ValueError(
"The `model` parameter should not have pre-bound tools. "
"You are getting this error because the chat model you are using"
"was pre-bound with tools somewhere. The code that binds tools "
"looks like this: `model.bind_tools(...)`. "
"Remove the `bind_tools` call and pass the unbound model "
"and the `tools` parameter separately."
)
self.model = model
self.tools = tools
self.prompt = prompt
@@ -365,14 +342,7 @@ class _AgentBuilder:
)
model = init_chat_model(model)
if (
_should_bind_tools(
model, # type: ignore[arg-type]
self._tool_classes,
num_builtin=len(self._llm_builtin_tools),
)
and len(self._tool_classes + self._llm_builtin_tools) > 0
):
if len(self._tool_classes + self._llm_builtin_tools) > 0:
model = cast(BaseChatModel, model).bind_tools(
self._tool_classes + self._llm_builtin_tools # type: ignore[operator]
)
@@ -749,7 +719,9 @@ class _AgentBuilder:
paths.append(END)
return paths
def build(self) -> StateGraph:
def build(
self,
) -> StateGraph:
"""Build the agent workflow graph."""
workflow = StateGraph(
state_schema=self._final_state_schema,
@@ -846,15 +818,12 @@ class _AgentBuilder:
def create_react_agent(
model: Union[
str,
LanguageModelLike,
Callable[[StateSchema, Runtime[ContextT]], BaseChatModel],
Callable[[StateSchema, Runtime[ContextT]], Awaitable[BaseChatModel]],
Callable[
[StateSchema, Runtime[ContextT]], Runnable[LanguageModelInput, BaseMessage]
],
Callable[
BaseChatModel,
PreConfiguredChatModel,
SyncOrAsync[[StateSchema, Runtime[ContextT]], BaseModel],
SyncOrAsync[
[StateSchema, Runtime[ContextT]],
Awaitable[Runnable[LanguageModelInput, BaseMessage]],
Awaitable[PreConfiguredChatModel],
],
],
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
+25 -80
View File
@@ -19,7 +19,7 @@ from langchain_core.messages import (
ToolMessage,
)
from langchain_core.runnables import RunnableConfig, RunnableLambda
from langchain_core.tools import InjectedToolCallId, ToolException
from langchain_core.tools import BaseTool, InjectedToolCallId, ToolException
from langchain_core.tools import tool as dec_tool
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
@@ -36,7 +36,6 @@ from langgraph.prebuilt.chat_agent_executor import (
AgentStatePydantic,
StateSchemaType,
_get_model,
_should_bind_tools,
_validate_chat_history,
)
from langgraph.prebuilt.tool_node import (
@@ -266,7 +265,7 @@ async def test_prompt_with_store_async():
@pytest.mark.parametrize("tool_style", ["openai", "anthropic"])
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
@pytest.mark.parametrize("include_builtin", [True, False])
def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
def test_model_with_tools(tool_style: str, version: str, include_builtin: bool) -> None:
model = FakeToolCallingModel(tool_style=tool_style)
@dec_tool
@@ -279,7 +278,7 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
"""Tool 2 docstring."""
return f"Tool 2: {some_val}"
tools = [tool1, tool2]
tools: list[BaseTool | dict] = [tool1, tool2]
if include_builtin:
tools.append(
{
@@ -297,45 +296,30 @@ def test_model_with_tools(tool_style: str, version: str, include_builtin: bool):
}
)
# check valid agent constructor
agent = create_react_agent(
model.bind_tools(tools),
tools,
version=version,
)
result = agent.nodes["tools"].invoke(
{
"messages": [
AIMessage(
"hi?",
tool_calls=[
{
"name": "tool1",
"args": {"some_val": 2},
"id": "some 1",
},
{
"name": "tool2",
"args": {"some_val": 2},
"id": "some 2",
},
],
)
]
}
)
tool_messages: ToolMessage = result["messages"][-2:]
for tool_message in tool_messages:
assert tool_message.type == "tool"
assert tool_message.content in {"Tool 1: 2", "Tool 2: 2"}
assert tool_message.tool_call_id in {"some 1", "some 2"}
# test mismatching tool lengths
with pytest.raises(ValueError):
create_react_agent(model.bind_tools([tool1]), [tool1, tool2])
create_react_agent(
model.bind_tools(tools),
tools,
version=version,
)
# test missing bound tools
with pytest.raises(ValueError):
create_react_agent(model.bind_tools([tool1]), [tool2])
def test_support_preconfigured_models_but_not_for_tools() -> None:
"""Support (at least temporarily) some model pre-configuration.
This is a temporary workaround to support pre-configured models
for things like temperature or api keys (done via .bind).
We do not want users to pre-bind tools to the models.
"""
model = FakeToolCallingModel()
@dec_tool
def tool1(some_val: int) -> str:
"""Tool 1 docstring."""
return f"Tool 1: {some_val}"
create_react_agent(model.bind(temperature=3), [tool1])
def test__validate_messages():
@@ -1226,45 +1210,6 @@ def test_tool_node_node_interrupt(
)
@pytest.mark.parametrize("tool_style", ["openai", "anthropic"])
def test_should_bind_tools(tool_style: str) -> None:
@dec_tool
def some_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
@dec_tool
def some_other_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
model = FakeToolCallingModel(tool_style=tool_style)
# should bind when a regular model
assert _should_bind_tools(model, [])
assert _should_bind_tools(model, [some_tool])
# should bind when a seq
seq = model | RunnableLambda(lambda message: message)
assert _should_bind_tools(seq, [])
assert _should_bind_tools(seq, [some_tool])
# should not bind when a model with tools
assert not _should_bind_tools(model.bind_tools([some_tool]), [some_tool])
# should not bind when a seq with tools
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
lambda message: message
)
assert not _should_bind_tools(seq_with_tools, [some_tool])
# should raise on invalid inputs
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_other_tool])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_tool, some_other_tool])
def test_get_model() -> None:
model = FakeToolCallingModel(tool_calls=[])
assert _get_model(model) == model