diff --git a/libs/prebuilt/langgraph/prebuilt/_internal/__init__.py b/libs/prebuilt/langgraph/prebuilt/_internal/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/libs/prebuilt/langgraph/prebuilt/_internal/_typing.py b/libs/prebuilt/langgraph/prebuilt/_internal/_typing.py new file mode 100644 index 000000000..d50c48c09 --- /dev/null +++ b/libs/prebuilt/langgraph/prebuilt/_internal/_typing.py @@ -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) diff --git a/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py index 1f85e26ca..a8a7ffbfc 100644 --- a/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py +++ b/libs/prebuilt/langgraph/prebuilt/chat_agent_executor.py @@ -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], diff --git a/libs/prebuilt/tests/test_react_agent.py b/libs/prebuilt/tests/test_react_agent.py index cc2ca161b..b5c4e25fe 100644 --- a/libs/prebuilt/tests/test_react_agent.py +++ b/libs/prebuilt/tests/test_react_agent.py @@ -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