mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-13 13:17:52 +02:00
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:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user