mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-09 11:17:53 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d79f94742 |
@@ -187,7 +187,7 @@ def _should_bind_tools(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_model(model: LanguageModelLike) -> BaseChatModel:
|
def _get_underlying_model(model: LanguageModelLike) -> BaseChatModel:
|
||||||
"""Get the underlying model from a RunnableBinding or return the model itself."""
|
"""Get the underlying model from a RunnableBinding or return the model itself."""
|
||||||
if isinstance(model, RunnableSequence):
|
if isinstance(model, RunnableSequence):
|
||||||
model = next(
|
model = next(
|
||||||
@@ -241,8 +241,11 @@ def _validate_chat_history(
|
|||||||
raise ValueError(error_message)
|
raise ValueError(error_message)
|
||||||
|
|
||||||
|
|
||||||
|
DynamicModel = Callable[[StateSchemaType, RunnableConfig], BaseChatModel]
|
||||||
|
|
||||||
|
|
||||||
def create_react_agent(
|
def create_react_agent(
|
||||||
model: Union[str, LanguageModelLike],
|
model: Union[str, LanguageModelLike, DynamicModel],
|
||||||
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
|
tools: Union[Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode],
|
||||||
*,
|
*,
|
||||||
prompt: Optional[Prompt] = None,
|
prompt: Optional[Prompt] = None,
|
||||||
@@ -432,6 +435,8 @@ def create_react_agent(
|
|||||||
tool_node = ToolNode([t for t in tools if not isinstance(t, dict)])
|
tool_node = ToolNode([t for t in tools if not isinstance(t, dict)])
|
||||||
tool_classes = list(tool_node.tools_by_name.values())
|
tool_classes = list(tool_node.tools_by_name.values())
|
||||||
|
|
||||||
|
tool_calling_enabled = len(tool_classes) > 0
|
||||||
|
|
||||||
if isinstance(model, str):
|
if isinstance(model, str):
|
||||||
try:
|
try:
|
||||||
from langchain.chat_models import ( # type: ignore[import-not-found]
|
from langchain.chat_models import ( # type: ignore[import-not-found]
|
||||||
@@ -441,18 +446,52 @@ def create_react_agent(
|
|||||||
raise ImportError(
|
raise ImportError(
|
||||||
"Please install langchain (`pip install langchain`) to use '<provider>:<model>' string syntax for `model` parameter."
|
"Please install langchain (`pip install langchain`) to use '<provider>:<model>' string syntax for `model` parameter."
|
||||||
)
|
)
|
||||||
|
model_instance = cast(BaseChatModel, init_chat_model(model))
|
||||||
|
elif isinstance(model, Runnable):
|
||||||
|
model_instance = _get_underlying_model(model)
|
||||||
|
elif callable(model):
|
||||||
|
model_instance = None
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
"Expected `model` to be a string, LanguageModelLike, "
|
||||||
|
f"or callable, got {type(model)}"
|
||||||
|
)
|
||||||
|
|
||||||
model = cast(BaseChatModel, init_chat_model(model))
|
# If we have a static model, we'll attempt to bind tools to it.
|
||||||
|
if model_instance:
|
||||||
|
# Apply tool binding for static model
|
||||||
|
if (
|
||||||
|
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools))
|
||||||
|
and len(tool_classes + llm_builtin_tools) > 0
|
||||||
|
):
|
||||||
|
model_instance = cast(BaseChatModel, model_instance).bind_tools(
|
||||||
|
tool_classes + llm_builtin_tools
|
||||||
|
) # type: ignore[operator]
|
||||||
|
static_model: Optional[BaseChatModel] = (
|
||||||
|
_get_prompt_runnable(prompt) | model_instance
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
static_model = None
|
||||||
|
|
||||||
tool_calling_enabled = len(tool_classes) > 0
|
def _resolve_model(state: StateSchema, config: RunnableConfig) -> BaseChatModel:
|
||||||
|
"""Resolve the model, handling both static and dynamic models."""
|
||||||
|
if static_model:
|
||||||
|
return static_model
|
||||||
|
|
||||||
if (
|
# If we have a dynamic model, we need to resolve it at runtime
|
||||||
_should_bind_tools(model, tool_classes, num_builtin=len(llm_builtin_tools))
|
resolved_model = model(state, config) # type: ignore[call-arg]
|
||||||
and len(tool_classes + llm_builtin_tools) > 0
|
# Apply tools binding if needed
|
||||||
):
|
if (
|
||||||
model = cast(BaseChatModel, model).bind_tools(tool_classes + llm_builtin_tools) # type: ignore[operator]
|
_should_bind_tools(
|
||||||
|
resolved_model, tool_classes, num_builtin=len(llm_builtin_tools)
|
||||||
|
)
|
||||||
|
and len(tool_classes + llm_builtin_tools) > 0
|
||||||
|
):
|
||||||
|
resolved_model = cast(BaseChatModel, resolved_model).bind_tools(
|
||||||
|
tool_classes + llm_builtin_tools
|
||||||
|
)
|
||||||
|
|
||||||
model_runnable = _get_prompt_runnable(prompt) | model
|
return cast(BaseChatModel, _get_prompt_runnable(prompt) | resolved_model)
|
||||||
|
|
||||||
# If any of the tools are configured to return_directly after running,
|
# If any of the tools are configured to return_directly after running,
|
||||||
# our graph needs to check if these were called
|
# our graph needs to check if these were called
|
||||||
@@ -504,7 +543,8 @@ def create_react_agent(
|
|||||||
# Define the function that calls the model
|
# Define the function that calls the model
|
||||||
def call_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
def call_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
||||||
state = _get_model_input_state(state)
|
state = _get_model_input_state(state)
|
||||||
response = cast(AIMessage, model_runnable.invoke(state, config))
|
runnable = _resolve_model(state, config)
|
||||||
|
response = runnable.invoke(state, config)
|
||||||
# add agent name to the AIMessage
|
# add agent name to the AIMessage
|
||||||
response.name = name
|
response.name = name
|
||||||
|
|
||||||
@@ -522,7 +562,8 @@ def create_react_agent(
|
|||||||
|
|
||||||
async def acall_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
async def acall_model(state: StateSchema, config: RunnableConfig) -> StateSchema:
|
||||||
state = _get_model_input_state(state)
|
state = _get_model_input_state(state)
|
||||||
response = cast(AIMessage, await model_runnable.ainvoke(state, config))
|
runnable = _resolve_model(state, config)
|
||||||
|
response = await runnable.ainvoke(state, config)
|
||||||
# add agent name to the AIMessage
|
# add agent name to the AIMessage
|
||||||
response.name = name
|
response.name = name
|
||||||
if _are_more_steps_needed(state, response):
|
if _are_more_steps_needed(state, response):
|
||||||
@@ -561,13 +602,17 @@ def create_react_agent(
|
|||||||
def generate_structured_response(
|
def generate_structured_response(
|
||||||
state: StateSchema, config: RunnableConfig
|
state: StateSchema, config: RunnableConfig
|
||||||
) -> StateSchema:
|
) -> StateSchema:
|
||||||
|
"""Generate a structured response from the model."""
|
||||||
messages = _get_state_value(state, "messages")
|
messages = _get_state_value(state, "messages")
|
||||||
structured_response_schema = response_format
|
structured_response_schema = response_format
|
||||||
if isinstance(response_format, tuple):
|
if isinstance(response_format, tuple):
|
||||||
system_prompt, structured_response_schema = response_format
|
system_prompt, structured_response_schema = response_format
|
||||||
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
||||||
|
|
||||||
model_with_structured_output = _get_model(model).with_structured_output(
|
# We need to re-bind structured outputs to the model
|
||||||
|
model_instance_ = _resolve_model(state, config)
|
||||||
|
underlying_model = _get_underlying_model(model_instance_)
|
||||||
|
model_with_structured_output = underlying_model.with_structured_output(
|
||||||
cast(StructuredResponseSchema, structured_response_schema)
|
cast(StructuredResponseSchema, structured_response_schema)
|
||||||
)
|
)
|
||||||
response = model_with_structured_output.invoke(messages, config)
|
response = model_with_structured_output.invoke(messages, config)
|
||||||
@@ -576,13 +621,17 @@ def create_react_agent(
|
|||||||
async def agenerate_structured_response(
|
async def agenerate_structured_response(
|
||||||
state: StateSchema, config: RunnableConfig
|
state: StateSchema, config: RunnableConfig
|
||||||
) -> StateSchema:
|
) -> StateSchema:
|
||||||
|
"""Generate a structured response from the model."""
|
||||||
messages = _get_state_value(state, "messages")
|
messages = _get_state_value(state, "messages")
|
||||||
structured_response_schema = response_format
|
structured_response_schema = response_format
|
||||||
if isinstance(response_format, tuple):
|
if isinstance(response_format, tuple):
|
||||||
system_prompt, structured_response_schema = response_format
|
system_prompt, structured_response_schema = response_format
|
||||||
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
messages = [SystemMessage(content=system_prompt)] + list(messages)
|
||||||
|
|
||||||
model_with_structured_output = _get_model(model).with_structured_output(
|
# We need to re-bind structured outputs to the model
|
||||||
|
model_instance_ = _resolve_model(state, config)
|
||||||
|
underlying_model = _get_underlying_model(model_instance_)
|
||||||
|
model_with_structured_output = underlying_model.with_structured_output(
|
||||||
cast(StructuredResponseSchema, structured_response_schema)
|
cast(StructuredResponseSchema, structured_response_schema)
|
||||||
)
|
)
|
||||||
response = await model_with_structured_output.ainvoke(messages, config)
|
response = await model_with_structured_output.ainvoke(messages, config)
|
||||||
@@ -651,7 +700,7 @@ def create_react_agent(
|
|||||||
if post_model_hook is not None:
|
if post_model_hook is not None:
|
||||||
return "post_model_hook"
|
return "post_model_hook"
|
||||||
tool_calls = [
|
tool_calls = [
|
||||||
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
|
tool_node.inject_tool_args(call, state, store)
|
||||||
for call in last_message.tool_calls
|
for call in last_message.tool_calls
|
||||||
]
|
]
|
||||||
return [Send("tools", [tool_call]) for tool_call in tool_calls]
|
return [Send("tools", [tool_call]) for tool_call in tool_calls]
|
||||||
@@ -734,7 +783,7 @@ def create_react_agent(
|
|||||||
|
|
||||||
if pending_tool_calls:
|
if pending_tool_calls:
|
||||||
pending_tool_calls = [
|
pending_tool_calls = [
|
||||||
tool_node.inject_tool_args(call, state, store) # type: ignore[arg-type]
|
tool_node.inject_tool_args(call, state, store)
|
||||||
for call in pending_tool_calls
|
for call in pending_tool_calls
|
||||||
]
|
]
|
||||||
return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
|
return [Send("tools", [tool_call]) for tool_call in pending_tool_calls]
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import asyncio
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
import json
|
||||||
from copy import copy, deepcopy
|
from copy import copy, deepcopy
|
||||||
from dataclasses import replace
|
from dataclasses import dataclass, replace
|
||||||
|
from itertools import repeat
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
@@ -73,6 +74,7 @@ from typing_extensions import Annotated, get_args, get_origin
|
|||||||
from langgraph.errors import GraphBubbleUp
|
from langgraph.errors import GraphBubbleUp
|
||||||
from langgraph.store.base import BaseStore
|
from langgraph.store.base import BaseStore
|
||||||
from langgraph.types import Command, Send
|
from langgraph.types import Command, Send
|
||||||
|
from langgraph.typing import StateLike
|
||||||
from langgraph.utils.runnable import RunnableCallable
|
from langgraph.utils.runnable import RunnableCallable
|
||||||
|
|
||||||
INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
||||||
@@ -81,6 +83,43 @@ INVALID_TOOL_NAME_ERROR_TEMPLATE = (
|
|||||||
TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
TOOL_CALL_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolResolver:
|
||||||
|
"""Encapsulates tool metadata for thread-safe tool execution.
|
||||||
|
|
||||||
|
This class holds all the precomputed metadata needed for tool execution,
|
||||||
|
including tool instances, state injection mappings, and store injection settings.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tools_by_name: dict[str, BaseTool]
|
||||||
|
tool_to_state_args: dict[str, dict[str, Optional[str]]]
|
||||||
|
tool_to_store_arg: dict[str, Optional[str]]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_tools(tools: Sequence[Union[BaseTool, Callable]]) -> "ToolResolver":
|
||||||
|
"""Create a ToolResolver from a sequence of tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tools: Sequence of tools to process. Can be BaseTool instances or
|
||||||
|
callables that will be converted to tools.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ToolResolver containing all the metadata for the provided tools.
|
||||||
|
"""
|
||||||
|
tools_by_name = {}
|
||||||
|
tool_to_state_args = {}
|
||||||
|
tool_to_store_arg = {}
|
||||||
|
|
||||||
|
for tool in tools:
|
||||||
|
if not isinstance(tool, BaseTool):
|
||||||
|
tool = create_tool(tool)
|
||||||
|
tools_by_name[tool.name] = tool
|
||||||
|
tool_to_state_args[tool.name] = _get_state_args(tool)
|
||||||
|
tool_to_store_arg[tool.name] = _get_store_arg(tool)
|
||||||
|
|
||||||
|
return ToolResolver(tools_by_name, tool_to_state_args, tool_to_store_arg)
|
||||||
|
|
||||||
|
|
||||||
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
def msg_content_output(output: Any) -> Union[str, list[dict]]:
|
||||||
"""Convert tool output to valid message content format.
|
"""Convert tool output to valid message content format.
|
||||||
|
|
||||||
@@ -244,8 +283,11 @@ class ToolNode(RunnableCallable):
|
|||||||
Tool calls can also be passed directly as a list of `ToolCall` dicts.
|
Tool calls can also be passed directly as a list of `ToolCall` dicts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tools: A sequence of tools that can be invoked by this node. Tools can be
|
tools: Either a sequence of tools that can be invoked by this node, or a
|
||||||
BaseTool instances or plain functions that will be converted to tools.
|
callable that returns tools dynamically based on input, config, and store.
|
||||||
|
Static tools can be BaseTool instances or plain functions that will be
|
||||||
|
converted to tools. Dynamic tool providers receive (input, config, store)
|
||||||
|
and should return a sequence of tools for that specific execution.
|
||||||
name: The name identifier for this node in the graph. Used for debugging
|
name: The name identifier for this node in the graph. Used for debugging
|
||||||
and visualization. Defaults to "tools".
|
and visualization. Defaults to "tools".
|
||||||
tags: Optional metadata tags to associate with the node for filtering
|
tags: Optional metadata tags to associate with the node for filtering
|
||||||
@@ -316,7 +358,13 @@ class ToolNode(RunnableCallable):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
tools: Sequence[Union[BaseTool, Callable]],
|
tools: Union[
|
||||||
|
Sequence[Union[BaseTool, Callable]],
|
||||||
|
Callable[
|
||||||
|
[StateLike, RunnableConfig, Optional[BaseStore]],
|
||||||
|
Sequence[Union[BaseTool, Callable]],
|
||||||
|
],
|
||||||
|
],
|
||||||
*,
|
*,
|
||||||
name: str = "tools",
|
name: str = "tools",
|
||||||
tags: Optional[list[str]] = None,
|
tags: Optional[list[str]] = None,
|
||||||
@@ -328,24 +376,64 @@ class ToolNode(RunnableCallable):
|
|||||||
"""Initialize the ToolNode with the provided tools and configuration.
|
"""Initialize the ToolNode with the provided tools and configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
tools: Sequence of tools to make available for execution.
|
tools: Either a sequence of tools to make available for execution,
|
||||||
|
or a callable that returns tools dynamically based on input,
|
||||||
|
config, and store.
|
||||||
name: Node name for graph identification.
|
name: Node name for graph identification.
|
||||||
tags: Optional metadata tags.
|
tags: Optional metadata tags.
|
||||||
handle_tool_errors: Error handling configuration.
|
handle_tool_errors: Error handling configuration.
|
||||||
messages_key: State key containing messages.
|
messages_key: State key containing messages.
|
||||||
"""
|
"""
|
||||||
super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
|
super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
|
||||||
self.tools_by_name: dict[str, BaseTool] = {}
|
|
||||||
self.tool_to_state_args: dict[str, dict[str, Optional[str]]] = {}
|
|
||||||
self.tool_to_store_arg: dict[str, Optional[str]] = {}
|
|
||||||
self.handle_tool_errors = handle_tool_errors
|
self.handle_tool_errors = handle_tool_errors
|
||||||
self.messages_key = messages_key
|
self.messages_key = messages_key
|
||||||
for tool_ in tools:
|
|
||||||
if not isinstance(tool_, BaseTool):
|
if callable(tools):
|
||||||
tool_ = create_tool(tool_)
|
# Dynamic tool provider
|
||||||
self.tools_by_name[tool_.name] = tool_
|
self._tool_provider_fn = tools
|
||||||
self.tool_to_state_args[tool_.name] = _get_state_args(tool_)
|
self._static_resolver: Optional[ToolResolver] = None
|
||||||
self.tool_to_store_arg[tool_.name] = _get_store_arg(tool_)
|
# Likely migrate to property and raise a RunTimeError
|
||||||
|
self.tools_by_name: dict[str, BaseTool] = {}
|
||||||
|
self.tool_to_state_args: dict[str, dict[str, Optional[str]]] = {}
|
||||||
|
self.tool_to_store_arg: dict[str, Optional[str]] = {}
|
||||||
|
else:
|
||||||
|
# Static tools
|
||||||
|
self._tool_provider_fn = None
|
||||||
|
self._static_resolver = ToolResolver.from_tools(tools)
|
||||||
|
self.tools_by_name = self._static_resolver.tools_by_name
|
||||||
|
self.tool_to_state_args = self._static_resolver.tool_to_state_args
|
||||||
|
self.tool_to_store_arg = self._static_resolver.tool_to_store_arg
|
||||||
|
|
||||||
|
def _get_resolver(
|
||||||
|
self,
|
||||||
|
input: Union[list[AnyMessage], dict[str, Any], BaseModel],
|
||||||
|
config: Optional[RunnableConfig],
|
||||||
|
store: Optional[BaseStore],
|
||||||
|
) -> ToolResolver:
|
||||||
|
"""Get the appropriate ToolResolver for this execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input: The input to the tool node
|
||||||
|
config: The runnable configuration
|
||||||
|
store: The optional store instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A ToolResolver containing the tools and metadata for this execution.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If no tools are configured.
|
||||||
|
"""
|
||||||
|
if self._tool_provider_fn:
|
||||||
|
# Dynamic tools compute fresh for each run
|
||||||
|
# Note: config may be None during _parse_input,
|
||||||
|
# but tool provider should handle this
|
||||||
|
tools = self._tool_provider_fn(input, config, store)
|
||||||
|
return ToolResolver.from_tools(tools)
|
||||||
|
elif self._static_resolver:
|
||||||
|
# Static tools - use cached resolver
|
||||||
|
return self._static_resolver
|
||||||
|
else:
|
||||||
|
raise RuntimeError("ToolNode has no tools configured")
|
||||||
|
|
||||||
def _func(
|
def _func(
|
||||||
self,
|
self,
|
||||||
@@ -358,12 +446,16 @@ class ToolNode(RunnableCallable):
|
|||||||
*,
|
*,
|
||||||
store: Optional[BaseStore],
|
store: Optional[BaseStore],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
tool_calls, input_type = self._parse_input(input, store)
|
tool_calls, input_type = self._parse_input(input, store, config)
|
||||||
|
resolver = self._get_resolver(input, config, store)
|
||||||
config_list = get_config_list(config, len(tool_calls))
|
config_list = get_config_list(config, len(tool_calls))
|
||||||
input_types = [input_type] * len(tool_calls)
|
input_types = [input_type] * len(tool_calls)
|
||||||
with get_executor_for_config(config) as executor:
|
with get_executor_for_config(config) as executor:
|
||||||
outputs = [
|
outputs = [
|
||||||
*executor.map(self._run_one, tool_calls, input_types, config_list)
|
*executor.map(
|
||||||
|
lambda args: self._run_one(*args),
|
||||||
|
list(zip(tool_calls, input_types, config_list, repeat(resolver))),
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
return self._combine_tool_outputs(outputs, input_type)
|
return self._combine_tool_outputs(outputs, input_type)
|
||||||
@@ -379,9 +471,10 @@ class ToolNode(RunnableCallable):
|
|||||||
*,
|
*,
|
||||||
store: Optional[BaseStore],
|
store: Optional[BaseStore],
|
||||||
) -> Any:
|
) -> Any:
|
||||||
tool_calls, input_type = self._parse_input(input, store)
|
tool_calls, input_type = self._parse_input(input, store, config)
|
||||||
|
resolver = self._get_resolver(input, config, store)
|
||||||
outputs = await asyncio.gather(
|
outputs = await asyncio.gather(
|
||||||
*(self._arun_one(call, input_type, config) for call in tool_calls)
|
*(self._arun_one(call, input_type, config, resolver) for call in tool_calls)
|
||||||
)
|
)
|
||||||
|
|
||||||
return self._combine_tool_outputs(outputs, input_type)
|
return self._combine_tool_outputs(outputs, input_type)
|
||||||
@@ -435,20 +528,23 @@ class ToolNode(RunnableCallable):
|
|||||||
call: ToolCall,
|
call: ToolCall,
|
||||||
input_type: Literal["list", "dict", "tool_calls"],
|
input_type: Literal["list", "dict", "tool_calls"],
|
||||||
config: RunnableConfig,
|
config: RunnableConfig,
|
||||||
|
resolver: ToolResolver,
|
||||||
) -> ToolMessage:
|
) -> ToolMessage:
|
||||||
if invalid_tool_message := self._validate_tool_call(call):
|
if invalid_tool_message := self._validate_tool_call(call, resolver):
|
||||||
return invalid_tool_message
|
return invalid_tool_message
|
||||||
|
|
||||||
try:
|
try:
|
||||||
input = {**call, **{"type": "tool_call"}}
|
input = {**call, **{"type": "tool_call"}}
|
||||||
response = self.tools_by_name[call["name"]].invoke(input, config)
|
response = resolver.tools_by_name[call["name"]].invoke(input, config)
|
||||||
|
|
||||||
# GraphInterrupt is a special exception that will always be raised.
|
# GraphInterrupt is a special exception that will always be raised.
|
||||||
# It can be triggered in the following scenarios:
|
# It can be triggered in the following scenarios:
|
||||||
# (1) a NodeInterrupt is raised inside a tool
|
# (1) a NodeInterrupt is raised inside a tool
|
||||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
|
||||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
# called as a tool
|
||||||
|
# ---
|
||||||
|
# (2) and (3) can happen in a "supervisor w/ tools" multi-agent architecture
|
||||||
except GraphBubbleUp as e:
|
except GraphBubbleUp as e:
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -490,20 +586,23 @@ class ToolNode(RunnableCallable):
|
|||||||
call: ToolCall,
|
call: ToolCall,
|
||||||
input_type: Literal["list", "dict", "tool_calls"],
|
input_type: Literal["list", "dict", "tool_calls"],
|
||||||
config: RunnableConfig,
|
config: RunnableConfig,
|
||||||
|
resolver: ToolResolver,
|
||||||
) -> ToolMessage:
|
) -> ToolMessage:
|
||||||
if invalid_tool_message := self._validate_tool_call(call):
|
if invalid_tool_message := self._validate_tool_call(call, resolver):
|
||||||
return invalid_tool_message
|
return invalid_tool_message
|
||||||
|
|
||||||
try:
|
try:
|
||||||
input = {**call, **{"type": "tool_call"}}
|
input = {**call, **{"type": "tool_call"}}
|
||||||
response = await self.tools_by_name[call["name"]].ainvoke(input, config)
|
response = await resolver.tools_by_name[call["name"]].ainvoke(input, config)
|
||||||
|
|
||||||
# GraphInterrupt is a special exception that will always be raised.
|
# GraphInterrupt is a special exception that will always be raised.
|
||||||
# It can be triggered in the following scenarios:
|
# It can be triggered in the following scenarios:
|
||||||
# (1) a NodeInterrupt is raised inside a tool
|
# (1) a NodeInterrupt is raised inside a tool
|
||||||
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
# (2) a NodeInterrupt is raised inside a graph node for a graph called as a tool
|
||||||
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph called as a tool
|
# (3) a GraphInterrupt is raised when a subgraph is interrupted inside a graph
|
||||||
# (2 and 3 can happen in a "supervisor w/ tools" multi-agent architecture)
|
# called as a tool
|
||||||
|
# ---
|
||||||
|
# (2) and (3) can happen in a "supervisor w/ tools" multi-agent architecture
|
||||||
except GraphBubbleUp as e:
|
except GraphBubbleUp as e:
|
||||||
raise e
|
raise e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -549,7 +648,10 @@ class ToolNode(RunnableCallable):
|
|||||||
BaseModel,
|
BaseModel,
|
||||||
],
|
],
|
||||||
store: Optional[BaseStore],
|
store: Optional[BaseStore],
|
||||||
|
config: RunnableConfig,
|
||||||
) -> Tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
|
) -> Tuple[list[ToolCall], Literal["list", "dict", "tool_calls"]]:
|
||||||
|
"""Parse the input to extract tool calls and determine input type."""
|
||||||
|
input_type: Literal["list", "dict", "tool_calls"]
|
||||||
if isinstance(input, list):
|
if isinstance(input, list):
|
||||||
if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call":
|
if isinstance(input[-1], dict) and input[-1].get("type") == "tool_call":
|
||||||
input_type = "tool_calls"
|
input_type = "tool_calls"
|
||||||
@@ -573,17 +675,22 @@ class ToolNode(RunnableCallable):
|
|||||||
except StopIteration:
|
except StopIteration:
|
||||||
raise ValueError("No AIMessage found in input")
|
raise ValueError("No AIMessage found in input")
|
||||||
|
|
||||||
|
# For _parse_input, we need to compute the resolver to inject tool args
|
||||||
|
# We pass None for config since we don't have it yet at this stage
|
||||||
|
resolver = self._get_resolver(input, config=config, store=store)
|
||||||
tool_calls = [
|
tool_calls = [
|
||||||
self.inject_tool_args(call, input, store)
|
self.inject_tool_args(call, input, store, resolver)
|
||||||
for call in latest_ai_message.tool_calls
|
for call in latest_ai_message.tool_calls
|
||||||
]
|
]
|
||||||
return tool_calls, input_type
|
return tool_calls, input_type
|
||||||
|
|
||||||
def _validate_tool_call(self, call: ToolCall) -> Optional[ToolMessage]:
|
def _validate_tool_call(
|
||||||
if (requested_tool := call["name"]) not in self.tools_by_name:
|
self, call: ToolCall, resolver: ToolResolver
|
||||||
|
) -> Optional[ToolMessage]:
|
||||||
|
if (requested_tool := call["name"]) not in resolver.tools_by_name:
|
||||||
content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
|
content = INVALID_TOOL_NAME_ERROR_TEMPLATE.format(
|
||||||
requested_tool=requested_tool,
|
requested_tool=requested_tool,
|
||||||
available_tools=", ".join(self.tools_by_name.keys()),
|
available_tools=", ".join(resolver.tools_by_name.keys()),
|
||||||
)
|
)
|
||||||
return ToolMessage(
|
return ToolMessage(
|
||||||
content, name=requested_tool, tool_call_id=call["id"], status="error"
|
content, name=requested_tool, tool_call_id=call["id"], status="error"
|
||||||
@@ -599,8 +706,9 @@ class ToolNode(RunnableCallable):
|
|||||||
dict[str, Any],
|
dict[str, Any],
|
||||||
BaseModel,
|
BaseModel,
|
||||||
],
|
],
|
||||||
|
resolver: ToolResolver,
|
||||||
) -> ToolCall:
|
) -> ToolCall:
|
||||||
state_args = self.tool_to_state_args[tool_call["name"]]
|
state_args = resolver.tool_to_state_args[tool_call["name"]]
|
||||||
if state_args and isinstance(input, list):
|
if state_args and isinstance(input, list):
|
||||||
required_fields = list(state_args.values())
|
required_fields = list(state_args.values())
|
||||||
if (
|
if (
|
||||||
@@ -637,9 +745,9 @@ class ToolNode(RunnableCallable):
|
|||||||
return tool_call
|
return tool_call
|
||||||
|
|
||||||
def _inject_store(
|
def _inject_store(
|
||||||
self, tool_call: ToolCall, store: Optional[BaseStore]
|
self, tool_call: ToolCall, store: Optional[BaseStore], resolver: ToolResolver
|
||||||
) -> ToolCall:
|
) -> ToolCall:
|
||||||
store_arg = self.tool_to_store_arg[tool_call["name"]]
|
store_arg = resolver.tool_to_store_arg[tool_call["name"]]
|
||||||
if not store_arg:
|
if not store_arg:
|
||||||
return tool_call
|
return tool_call
|
||||||
|
|
||||||
@@ -664,6 +772,8 @@ class ToolNode(RunnableCallable):
|
|||||||
BaseModel,
|
BaseModel,
|
||||||
],
|
],
|
||||||
store: Optional[BaseStore],
|
store: Optional[BaseStore],
|
||||||
|
# TODO(EUGENE): Potentially breaking change?
|
||||||
|
resolver: Optional[ToolResolver] = None,
|
||||||
) -> ToolCall:
|
) -> ToolCall:
|
||||||
"""Inject graph state and store into tool call arguments.
|
"""Inject graph state and store into tool call arguments.
|
||||||
|
|
||||||
@@ -683,6 +793,8 @@ class ToolNode(RunnableCallable):
|
|||||||
Can be a message list, state dictionary, or BaseModel instance.
|
Can be a message list, state dictionary, or BaseModel instance.
|
||||||
store: The persistent store instance to inject into tools requiring storage.
|
store: The persistent store instance to inject into tools requiring storage.
|
||||||
Will be None if no store is configured for the graph.
|
Will be None if no store is configured for the graph.
|
||||||
|
resolver: The ToolResolver instance containing metadata about available
|
||||||
|
tools, including their state and store injection requirements.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A new ToolCall dictionary with the same structure as the input but with
|
A new ToolCall dictionary with the same structure as the input but with
|
||||||
@@ -698,12 +810,17 @@ class ToolNode(RunnableCallable):
|
|||||||
The injection is performed on a copy of the tool call to avoid mutating
|
The injection is performed on a copy of the tool call to avoid mutating
|
||||||
the original.
|
the original.
|
||||||
"""
|
"""
|
||||||
if tool_call["name"] not in self.tools_by_name:
|
|
||||||
|
resolver_ = resolver or self._static_resolver
|
||||||
|
|
||||||
|
if tool_call["name"] not in resolver_.tools_by_name:
|
||||||
return tool_call
|
return tool_call
|
||||||
|
|
||||||
tool_call_copy: ToolCall = copy(tool_call)
|
tool_call_copy: ToolCall = copy(tool_call)
|
||||||
tool_call_with_state = self._inject_state(tool_call_copy, input)
|
tool_call_with_state = self._inject_state(tool_call_copy, input, resolver_)
|
||||||
tool_call_with_store = self._inject_store(tool_call_with_state, store)
|
tool_call_with_store = self._inject_store(
|
||||||
|
tool_call_with_state, store, resolver_
|
||||||
|
)
|
||||||
return tool_call_with_store
|
return tool_call_with_store
|
||||||
|
|
||||||
def _validate_tool_command(
|
def _validate_tool_command(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from functools import partial
|
|||||||
from typing import (
|
from typing import (
|
||||||
Annotated,
|
Annotated,
|
||||||
List,
|
List,
|
||||||
|
Literal,
|
||||||
Optional,
|
Optional,
|
||||||
Type,
|
Type,
|
||||||
TypeVar,
|
TypeVar,
|
||||||
@@ -40,7 +41,7 @@ from langgraph.prebuilt.chat_agent_executor import (
|
|||||||
AgentState,
|
AgentState,
|
||||||
AgentStatePydantic,
|
AgentStatePydantic,
|
||||||
StateSchemaType,
|
StateSchemaType,
|
||||||
_get_model,
|
_get_underlying_model,
|
||||||
_should_bind_tools,
|
_should_bind_tools,
|
||||||
_validate_chat_history,
|
_validate_chat_history,
|
||||||
)
|
)
|
||||||
@@ -470,7 +471,7 @@ def test__infer_handled_types() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
|
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
|
||||||
def test_react_agent_with_structured_response(version: str) -> None:
|
def test_react_agent_with_structured_response(version: Literal["v1", "v2"]) -> None:
|
||||||
class WeatherResponse(BaseModel):
|
class WeatherResponse(BaseModel):
|
||||||
temperature: float = Field(description="The temperature in fahrenheit")
|
temperature: float = Field(description="The temperature in fahrenheit")
|
||||||
|
|
||||||
@@ -1346,7 +1347,7 @@ def test_should_bind_tools(tool_style: str) -> None:
|
|||||||
|
|
||||||
def test_get_model() -> None:
|
def test_get_model() -> None:
|
||||||
model = FakeToolCallingModel(tool_calls=[])
|
model = FakeToolCallingModel(tool_calls=[])
|
||||||
assert _get_model(model) == model
|
assert _get_underlying_model(model) == model
|
||||||
|
|
||||||
@dec_tool
|
@dec_tool
|
||||||
def some_tool(some_val: int) -> str:
|
def some_tool(some_val: int) -> str:
|
||||||
@@ -1354,18 +1355,18 @@ def test_get_model() -> None:
|
|||||||
return "meow"
|
return "meow"
|
||||||
|
|
||||||
model_with_tools = model.bind_tools([some_tool])
|
model_with_tools = model.bind_tools([some_tool])
|
||||||
assert _get_model(model_with_tools) == model
|
assert _get_underlying_model(model_with_tools) == model
|
||||||
|
|
||||||
seq = model | RunnableLambda(lambda message: message)
|
seq = model | RunnableLambda(lambda message: message)
|
||||||
assert _get_model(seq) == model
|
assert _get_underlying_model(seq) == model
|
||||||
|
|
||||||
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
|
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
|
||||||
lambda message: message
|
lambda message: message
|
||||||
)
|
)
|
||||||
assert _get_model(seq_with_tools) == model
|
assert _get_underlying_model(seq_with_tools) == model
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
_get_model(RunnableLambda(lambda message: message))
|
_get_underlying_model(RunnableLambda(lambda message: message))
|
||||||
|
|
||||||
|
|
||||||
def test_pre_model_hook() -> None:
|
def test_pre_model_hook() -> None:
|
||||||
|
|||||||
@@ -1129,3 +1129,49 @@ def test_tool_node_parent_command_with_send():
|
|||||||
graph=Command.PARENT,
|
graph=Command.PARENT,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tool_node_dynamic_tools_basic() -> None:
|
||||||
|
"""Test basic dynamic tool functionality."""
|
||||||
|
|
||||||
|
@dec_tool()
|
||||||
|
def answer_to_life() -> int:
|
||||||
|
"""A very nice dynamic tool."""
|
||||||
|
return 42
|
||||||
|
|
||||||
|
def tool_provider(state, config, store) -> list:
|
||||||
|
"""Provide dynamic tools based on state or config."""
|
||||||
|
return [answer_to_life]
|
||||||
|
|
||||||
|
tool_node = ToolNode(tool_provider)
|
||||||
|
result = await tool_node.ainvoke(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "answer_to_life",
|
||||||
|
"args": {},
|
||||||
|
"id": "1",
|
||||||
|
"type": "tool_call",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"messages": [
|
||||||
|
ToolMessage(
|
||||||
|
content="42",
|
||||||
|
tool_call_id="1",
|
||||||
|
name="answer_to_life",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test invoking a tool that's not defined
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await tool_node.ainvoke(
|
||||||
|
{
|
||||||
|
"name": "not_available",
|
||||||
|
"args": {},
|
||||||
|
"id": "1",
|
||||||
|
"type": "tool_call",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user