mirror of
https://github.com/hwchase17/langchain.git
synced 2026-06-09 10:17:00 +00:00
feat: dynamic tool registration via middleware (#34842)
dependent upon https://github.com/langchain-ai/langgraph/pull/6711 1. relax constraint in `factory.py` to allow for tools not pre-registered in the `ModelRequest.tools` list 2. always add tool node if `wrap_tool_call` or `awrap_tool_call` is implemented 3. add tests confirming you can register new tools at runtime in `wrap_model_call` and execute them via `wrap_tool_call` allows for the following pattern ```py from langchain_core.messages import HumanMessage, ToolMessage from langchain_core.tools import tool from libs.langchain_v1.langchain.agents.factory import create_agent from libs.langchain_v1.langchain.agents.middleware.types import ( AgentMiddleware, ModelRequest, ToolCallRequest, ) @tool def get_weather(location: str) -> str: """Get the current weather for a location.""" return f"The weather in {location} is sunny and 72°F." @tool def calculate_tip(bill_amount: float, tip_percentage: float = 20.0) -> str: """Calculate the tip amount for a bill.""" tip = bill_amount * (tip_percentage / 100) return f"Tip: ${tip:.2f}, Total: ${bill_amount + tip:.2f}" class DynamicToolMiddleware(AgentMiddleware): """Middleware that adds and handles a dynamic tool.""" def wrap_model_call(self, request: ModelRequest, handler): updated = request.override(tools=[*request.tools, calculate_tip]) return handler(updated) def wrap_tool_call(self, request: ToolCallRequest, handler): if request.tool_call["name"] == "calculate_tip": return handler(request.override(tool=calculate_tip)) return handler(request) agent = create_agent(model="openai:gpt-4o-mini", tools=[get_weather], middleware=[DynamicToolMiddleware()]) result = agent.invoke({ "messages": [HumanMessage("What's the weather in NYC? Also calculate a 20% tip on a $85 bill")] }) for msg in result["messages"]: msg.pretty_print() ```
This commit is contained in:
@@ -63,6 +63,32 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
STRUCTURED_OUTPUT_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
STRUCTURED_OUTPUT_ERROR_TEMPLATE = "Error: {error}\n Please fix your mistakes."
|
||||||
|
|
||||||
|
DYNAMIC_TOOL_ERROR_TEMPLATE = """
|
||||||
|
Middleware added tools that the agent doesn't know how to execute.
|
||||||
|
|
||||||
|
Unknown tools: {unknown_tool_names}
|
||||||
|
Registered tools: {available_tool_names}
|
||||||
|
|
||||||
|
This happens when middleware modifies `request.tools` in `wrap_model_call` to include
|
||||||
|
tools that weren't passed to `create_agent()`.
|
||||||
|
|
||||||
|
How to fix this:
|
||||||
|
|
||||||
|
Option 1: Register tools at agent creation (recommended for most cases)
|
||||||
|
Pass the tools to `create_agent(tools=[...])` or set them on `middleware.tools`.
|
||||||
|
This makes tools available for every agent invocation.
|
||||||
|
|
||||||
|
Option 2: Handle dynamic tools in middleware (for tools created at runtime)
|
||||||
|
Implement `wrap_tool_call` to execute tools that are added dynamically:
|
||||||
|
|
||||||
|
class MyMiddleware(AgentMiddleware):
|
||||||
|
def wrap_tool_call(self, request, handler):
|
||||||
|
if request.tool_call["name"] == "dynamic_tool":
|
||||||
|
# Execute the dynamic tool yourself or override with tool instance
|
||||||
|
return handler(request.override(tool=my_dynamic_tool))
|
||||||
|
return handler(request)
|
||||||
|
""".strip()
|
||||||
|
|
||||||
FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT = [
|
FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT = [
|
||||||
# if model profile data are not available, these models are assumed to support
|
# if model profile data are not available, these models are assumed to support
|
||||||
# structured output
|
# structured output
|
||||||
@@ -775,14 +801,15 @@ def create_agent(
|
|||||||
# Tools that require client-side execution (must be in ToolNode)
|
# Tools that require client-side execution (must be in ToolNode)
|
||||||
available_tools = middleware_tools + regular_tools
|
available_tools = middleware_tools + regular_tools
|
||||||
|
|
||||||
# Only create ToolNode if we have client-side tools
|
# Create ToolNode if we have client-side tools OR if middleware defines wrap_tool_call
|
||||||
|
# (which may handle dynamically registered tools)
|
||||||
tool_node = (
|
tool_node = (
|
||||||
ToolNode(
|
ToolNode(
|
||||||
tools=available_tools,
|
tools=available_tools,
|
||||||
wrap_tool_call=wrap_tool_call_wrapper,
|
wrap_tool_call=wrap_tool_call_wrapper,
|
||||||
awrap_tool_call=awrap_tool_call_wrapper,
|
awrap_tool_call=awrap_tool_call_wrapper,
|
||||||
)
|
)
|
||||||
if available_tools
|
if available_tools or wrap_tool_call_wrapper or awrap_tool_call_wrapper
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -997,6 +1024,10 @@ def create_agent(
|
|||||||
ValueError: If `ToolStrategy` specifies tools not declared upfront.
|
ValueError: If `ToolStrategy` specifies tools not declared upfront.
|
||||||
"""
|
"""
|
||||||
# Validate ONLY client-side tools that need to exist in tool_node
|
# Validate ONLY client-side tools that need to exist in tool_node
|
||||||
|
# Skip validation when wrap_tool_call is defined, as middleware may handle
|
||||||
|
# dynamic tools that are added at runtime via wrap_model_call
|
||||||
|
has_wrap_tool_call = wrap_tool_call_wrapper or awrap_tool_call_wrapper
|
||||||
|
|
||||||
# Build map of available client-side tools from the ToolNode
|
# Build map of available client-side tools from the ToolNode
|
||||||
# (which has already converted callables)
|
# (which has already converted callables)
|
||||||
available_tools_by_name = {}
|
available_tools_by_name = {}
|
||||||
@@ -1004,29 +1035,23 @@ def create_agent(
|
|||||||
available_tools_by_name = tool_node.tools_by_name.copy()
|
available_tools_by_name = tool_node.tools_by_name.copy()
|
||||||
|
|
||||||
# Check if any requested tools are unknown CLIENT-SIDE tools
|
# Check if any requested tools are unknown CLIENT-SIDE tools
|
||||||
unknown_tool_names = []
|
# Only validate if wrap_tool_call is NOT defined (no dynamic tool handling)
|
||||||
for t in request.tools:
|
if not has_wrap_tool_call:
|
||||||
# Only validate BaseTool instances (skip built-in dict tools)
|
unknown_tool_names = []
|
||||||
if isinstance(t, dict):
|
for t in request.tools:
|
||||||
continue
|
# Only validate BaseTool instances (skip built-in dict tools)
|
||||||
if isinstance(t, BaseTool) and t.name not in available_tools_by_name:
|
if isinstance(t, dict):
|
||||||
unknown_tool_names.append(t.name)
|
continue
|
||||||
|
if isinstance(t, BaseTool) and t.name not in available_tools_by_name:
|
||||||
|
unknown_tool_names.append(t.name)
|
||||||
|
|
||||||
if unknown_tool_names:
|
if unknown_tool_names:
|
||||||
available_tool_names = sorted(available_tools_by_name.keys())
|
available_tool_names = sorted(available_tools_by_name.keys())
|
||||||
msg = (
|
msg = DYNAMIC_TOOL_ERROR_TEMPLATE.format(
|
||||||
f"Middleware returned unknown tool names: {unknown_tool_names}\n\n"
|
unknown_tool_names=unknown_tool_names,
|
||||||
f"Available client-side tools: {available_tool_names}\n\n"
|
available_tool_names=available_tool_names,
|
||||||
"To fix this issue:\n"
|
)
|
||||||
"1. Ensure the tools are passed to create_agent() via "
|
raise ValueError(msg)
|
||||||
"the 'tools' parameter\n"
|
|
||||||
"2. If using custom middleware with tools, ensure "
|
|
||||||
"they're registered via middleware.tools attribute\n"
|
|
||||||
"3. Verify that tool names in ModelRequest.tools match "
|
|
||||||
"the actual tool.name values\n"
|
|
||||||
"Note: Built-in provider tools (dict format) can be added dynamically."
|
|
||||||
)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
# Determine effective response format (auto-detect if needed)
|
# Determine effective response format (auto-detect if needed)
|
||||||
effective_response_format: ResponseFormat[Any] | None
|
effective_response_format: ResponseFormat[Any] | None
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ version = "1.2.6"
|
|||||||
requires-python = ">=3.10.0,<4.0.0"
|
requires-python = ">=3.10.0,<4.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"langchain-core>=1.2.7,<2.0.0",
|
"langchain-core>=1.2.7,<2.0.0",
|
||||||
"langgraph>=1.0.2,<1.1.0",
|
"langgraph>=1.0.7,<1.1.0",
|
||||||
"pydantic>=2.7.4,<3.0.0",
|
"pydantic>=2.7.4,<3.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"""Tests for dynamic tool registration via middleware.
|
||||||
|
|
||||||
|
These tests verify that middleware can dynamically register and handle tools
|
||||||
|
that are not declared upfront when creating the agent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain_core.messages import HumanMessage, ToolCall, ToolMessage
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.types import Command
|
||||||
|
|
||||||
|
from langchain.agents.factory import create_agent
|
||||||
|
from langchain.agents.middleware.types import (
|
||||||
|
AgentMiddleware,
|
||||||
|
ModelCallResult,
|
||||||
|
ModelRequest,
|
||||||
|
ModelResponse,
|
||||||
|
ToolCallRequest,
|
||||||
|
)
|
||||||
|
from tests.unit_tests.agents.model import FakeToolCallingModel
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def static_tool(value: str) -> str:
|
||||||
|
"""A static tool that is always available."""
|
||||||
|
return f"Static result: {value}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def dynamic_tool(value: str) -> str:
|
||||||
|
"""A dynamically registered tool."""
|
||||||
|
return f"Dynamic result: {value}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
def another_dynamic_tool(x: int, y: int) -> str:
|
||||||
|
"""Another dynamically registered tool for calculations."""
|
||||||
|
return f"Sum: {x + y}"
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Middleware classes
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicToolMiddleware(AgentMiddleware):
|
||||||
|
"""Middleware that dynamically adds and handles a tool (sync and async)."""
|
||||||
|
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool])
|
||||||
|
return handler(updated)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool])
|
||||||
|
return await handler(updated)
|
||||||
|
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
if request.tool_call["name"] == "dynamic_tool":
|
||||||
|
return handler(request.override(tool=dynamic_tool))
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
if request.tool_call["name"] == "dynamic_tool":
|
||||||
|
return await handler(request.override(tool=dynamic_tool))
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleDynamicToolsMiddleware(AgentMiddleware):
|
||||||
|
"""Middleware that dynamically adds multiple tools (sync and async)."""
|
||||||
|
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool, another_dynamic_tool])
|
||||||
|
return handler(updated)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool, another_dynamic_tool])
|
||||||
|
return await handler(updated)
|
||||||
|
|
||||||
|
def _handle_tool(self, request: ToolCallRequest) -> ToolCallRequest | None:
|
||||||
|
"""Return updated request if this is a dynamic tool, else None."""
|
||||||
|
tool_name = request.tool_call["name"]
|
||||||
|
if tool_name == "dynamic_tool":
|
||||||
|
return request.override(tool=dynamic_tool)
|
||||||
|
if tool_name == "another_dynamic_tool":
|
||||||
|
return request.override(tool=another_dynamic_tool)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
updated = self._handle_tool(request)
|
||||||
|
return handler(updated if updated else request)
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
updated = self._handle_tool(request)
|
||||||
|
return await handler(updated if updated else request)
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicToolMiddlewareWithoutHandler(AgentMiddleware):
|
||||||
|
"""Middleware that adds a dynamic tool but doesn't handle it."""
|
||||||
|
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool])
|
||||||
|
return handler(updated)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
updated = request.override(tools=[*request.tools, dynamic_tool])
|
||||||
|
return await handler(updated)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionalDynamicToolMiddleware(AgentMiddleware):
|
||||||
|
"""Middleware that conditionally adds a tool based on state (sync and async)."""
|
||||||
|
|
||||||
|
def _should_add_tool(self, request: ModelRequest) -> bool:
|
||||||
|
messages = request.state.get("messages", [])
|
||||||
|
return messages and "calculator" in str(messages[-1].content).lower()
|
||||||
|
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
if self._should_add_tool(request):
|
||||||
|
request = request.override(tools=[*request.tools, another_dynamic_tool])
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
if self._should_add_tool(request):
|
||||||
|
request = request.override(tools=[*request.tools, another_dynamic_tool])
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
if request.tool_call["name"] == "another_dynamic_tool":
|
||||||
|
return handler(request.override(tool=another_dynamic_tool))
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
if request.tool_call["name"] == "another_dynamic_tool":
|
||||||
|
return await handler(request.override(tool=another_dynamic_tool))
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Helper functions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_messages(result: dict[str, Any]) -> list[ToolMessage]:
|
||||||
|
"""Extract ToolMessage objects from agent result."""
|
||||||
|
return [m for m in result["messages"] if isinstance(m, ToolMessage)]
|
||||||
|
|
||||||
|
|
||||||
|
async def invoke_agent(agent: Any, message: str, *, use_async: bool) -> dict[str, Any]:
|
||||||
|
"""Invoke agent synchronously or asynchronously based on flag."""
|
||||||
|
input_data = {"messages": [HumanMessage(message)]}
|
||||||
|
config = {"configurable": {"thread_id": "test"}}
|
||||||
|
if use_async:
|
||||||
|
return await agent.ainvoke(input_data, config)
|
||||||
|
return agent.invoke(input_data, config)
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_async", [False, True])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tools",
|
||||||
|
[
|
||||||
|
pytest.param([static_tool], id="with_static_tools"),
|
||||||
|
pytest.param([], id="without_static_tools"),
|
||||||
|
pytest.param(None, id="with_none_tools"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_dynamic_tool_basic(*, use_async: bool, tools: list[Any] | None) -> None:
|
||||||
|
"""Test dynamic tool registration with various static tool configurations."""
|
||||||
|
model = FakeToolCallingModel(
|
||||||
|
tool_calls=[
|
||||||
|
[ToolCall(name="dynamic_tool", args={"value": "test"}, id="1")],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=tools, # type: ignore[arg-type]
|
||||||
|
middleware=[DynamicToolMiddleware()],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await invoke_agent(agent, "Use the dynamic tool", use_async=use_async)
|
||||||
|
|
||||||
|
tool_messages = get_tool_messages(result)
|
||||||
|
assert len(tool_messages) == 1
|
||||||
|
assert tool_messages[0].name == "dynamic_tool"
|
||||||
|
assert "Dynamic result: test" in tool_messages[0].content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_async", [False, True])
|
||||||
|
async def test_multiple_dynamic_tools_with_static(*, use_async: bool) -> None:
|
||||||
|
"""Test multiple dynamic tools and mixing with static tool calls."""
|
||||||
|
model = FakeToolCallingModel(
|
||||||
|
tool_calls=[
|
||||||
|
[
|
||||||
|
ToolCall(name="static_tool", args={"value": "static-call"}, id="1"),
|
||||||
|
ToolCall(name="dynamic_tool", args={"value": "first"}, id="2"),
|
||||||
|
ToolCall(name="another_dynamic_tool", args={"x": 5, "y": 3}, id="3"),
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=[static_tool],
|
||||||
|
middleware=[MultipleDynamicToolsMiddleware()],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await invoke_agent(agent, "Use all tools", use_async=use_async)
|
||||||
|
|
||||||
|
tool_messages = get_tool_messages(result)
|
||||||
|
assert len(tool_messages) == 3
|
||||||
|
|
||||||
|
tool_results = {m.name: m.content for m in tool_messages}
|
||||||
|
assert "Static result: static-call" in tool_results["static_tool"]
|
||||||
|
assert "Dynamic result: first" in tool_results["dynamic_tool"]
|
||||||
|
assert "Sum: 8" in tool_results["another_dynamic_tool"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_async", [False, True])
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"tools",
|
||||||
|
[
|
||||||
|
pytest.param([static_tool], id="with_static_tools"),
|
||||||
|
pytest.param([], id="without_static_tools"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_dynamic_tool_without_handler_raises_error(
|
||||||
|
*, use_async: bool, tools: list[Any]
|
||||||
|
) -> None:
|
||||||
|
"""Test that a helpful error is raised when dynamic tool is not handled."""
|
||||||
|
model = FakeToolCallingModel(
|
||||||
|
tool_calls=[
|
||||||
|
[ToolCall(name="dynamic_tool", args={"value": "test"}, id="1")],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=tools,
|
||||||
|
middleware=[DynamicToolMiddlewareWithoutHandler()],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match=r"(?s)Middleware added tools.*Unknown tools:.*dynamic_tool",
|
||||||
|
):
|
||||||
|
await invoke_agent(agent, "Use the dynamic tool", use_async=use_async)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_async", [False, True])
|
||||||
|
async def test_conditional_dynamic_tool(*, use_async: bool) -> None:
|
||||||
|
"""Test that dynamic tools can be conditionally added based on state."""
|
||||||
|
model = FakeToolCallingModel(
|
||||||
|
tool_calls=[
|
||||||
|
[ToolCall(name="another_dynamic_tool", args={"x": 10, "y": 20}, id="1")],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=[static_tool],
|
||||||
|
middleware=[ConditionalDynamicToolMiddleware()],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await invoke_agent(agent, "I need a calculator to add numbers", use_async=use_async)
|
||||||
|
|
||||||
|
tool_messages = get_tool_messages(result)
|
||||||
|
assert len(tool_messages) == 1
|
||||||
|
assert tool_messages[0].name == "another_dynamic_tool"
|
||||||
|
assert "Sum: 30" in tool_messages[0].content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_async", [False, True])
|
||||||
|
async def test_dynamic_tool_chained_middleware(*, use_async: bool) -> None:
|
||||||
|
"""Test dynamic tools work with multiple middleware in chain."""
|
||||||
|
call_log: list[str] = []
|
||||||
|
|
||||||
|
class LoggingMiddleware(AgentMiddleware):
|
||||||
|
def __init__(self, label: str) -> None:
|
||||||
|
self._label = label
|
||||||
|
|
||||||
|
def wrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], ModelResponse],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
call_log.append(f"{self._label}_model")
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_model_call(
|
||||||
|
self,
|
||||||
|
request: ModelRequest,
|
||||||
|
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
|
||||||
|
) -> ModelCallResult:
|
||||||
|
call_log.append(f"{self._label}_model")
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
def wrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
call_log.append(f"{self._label}_tool")
|
||||||
|
return handler(request)
|
||||||
|
|
||||||
|
async def awrap_tool_call(
|
||||||
|
self,
|
||||||
|
request: ToolCallRequest,
|
||||||
|
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
||||||
|
) -> ToolMessage | Command[Any]:
|
||||||
|
call_log.append(f"{self._label}_tool")
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
model = FakeToolCallingModel(
|
||||||
|
tool_calls=[
|
||||||
|
[ToolCall(name="dynamic_tool", args={"value": "chained"}, id="1")],
|
||||||
|
[],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
agent = create_agent(
|
||||||
|
model=model,
|
||||||
|
tools=[static_tool],
|
||||||
|
middleware=[LoggingMiddleware("first"), DynamicToolMiddleware()],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await invoke_agent(agent, "Use the dynamic tool", use_async=use_async)
|
||||||
|
|
||||||
|
tool_messages = get_tool_messages(result)
|
||||||
|
assert len(tool_messages) == 1
|
||||||
|
assert tool_messages[0].name == "dynamic_tool"
|
||||||
|
|
||||||
|
# Verify middleware chain was called
|
||||||
|
assert "first_model" in call_log
|
||||||
|
assert "first_tool" in call_log
|
||||||
@@ -152,7 +152,10 @@ def test_unknown_tool_raises_error() -> None:
|
|||||||
middleware=[BadMiddleware()],
|
middleware=[BadMiddleware()],
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Middleware returned unknown tool names"):
|
with pytest.raises(
|
||||||
|
ValueError,
|
||||||
|
match=r"(?s)Middleware added tools.*Unknown tools:.*unknown_tool",
|
||||||
|
):
|
||||||
agent.invoke({"messages": [HumanMessage("Hello")]})
|
agent.invoke({"messages": [HumanMessage("Hello")]})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
26
libs/langchain_v1/uv.lock
generated
26
libs/langchain_v1/uv.lock
generated
@@ -1970,7 +1970,7 @@ requires-dist = [
|
|||||||
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
|
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
|
||||||
{ name = "langchain-together", marker = "extra == 'together'" },
|
{ name = "langchain-together", marker = "extra == 'together'" },
|
||||||
{ name = "langchain-xai", marker = "extra == 'xai'" },
|
{ name = "langchain-xai", marker = "extra == 'xai'" },
|
||||||
{ name = "langgraph", specifier = ">=1.0.2,<1.1.0" },
|
{ name = "langgraph", specifier = ">=1.0.7,<1.1.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "deepseek", "xai", "perplexity"]
|
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "deepseek", "xai", "perplexity"]
|
||||||
@@ -2119,7 +2119,7 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "jsonpatch", specifier = ">=1.33.0,<2.0.0" },
|
{ name = "jsonpatch", specifier = ">=1.33.0,<2.0.0" },
|
||||||
{ name = "langsmith", specifier = ">=0.3.45,<1.0.0" },
|
{ name = "langsmith", specifier = ">=0.3.45,<1.0.0" },
|
||||||
{ name = "packaging", specifier = ">=23.2.0,<26.0.0" },
|
{ name = "packaging", specifier = ">=23.2.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
|
||||||
{ name = "pyyaml", specifier = ">=5.3.0,<7.0.0" },
|
{ name = "pyyaml", specifier = ">=5.3.0,<7.0.0" },
|
||||||
{ name = "tenacity", specifier = ">=8.1.0,!=8.4.0,<10.0.0" },
|
{ name = "tenacity", specifier = ">=8.1.0,!=8.4.0,<10.0.0" },
|
||||||
@@ -2131,7 +2131,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "grandalf", specifier = ">=0.8.0,<1.0.0" },
|
{ name = "grandalf", specifier = ">=0.8.0,<1.0.0" },
|
||||||
{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" },
|
{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" },
|
||||||
{ name = "setuptools", specifier = ">=67.6.1,<68.0.0" },
|
{ name = "setuptools", specifier = ">=67.6.1,<79.0.0" },
|
||||||
]
|
]
|
||||||
lint = [{ name = "ruff", specifier = ">=0.14.11,<0.15.0" }]
|
lint = [{ name = "ruff", specifier = ">=0.14.11,<0.15.0" }]
|
||||||
test = [
|
test = [
|
||||||
@@ -2303,7 +2303,7 @@ dev = [{ name = "langchain-core", editable = "../core" }]
|
|||||||
lint = [{ name = "ruff", specifier = ">=0.13.1,<0.14.0" }]
|
lint = [{ name = "ruff", specifier = ">=0.13.1,<0.14.0" }]
|
||||||
test = [
|
test = [
|
||||||
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
|
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
|
||||||
{ name = "langchain", editable = "../langchain_v1" },
|
{ name = "langchain", editable = "." },
|
||||||
{ name = "langchain-core", editable = "../core" },
|
{ name = "langchain-core", editable = "../core" },
|
||||||
{ name = "langchain-tests", editable = "../standard-tests" },
|
{ name = "langchain-tests", editable = "../standard-tests" },
|
||||||
{ name = "numpy", marker = "python_full_version < '3.13'", specifier = ">=1.26.4" },
|
{ name = "numpy", marker = "python_full_version < '3.13'", specifier = ">=1.26.4" },
|
||||||
@@ -2484,7 +2484,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.0.4"
|
version = "1.0.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2494,9 +2494,9 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "xxhash" },
|
{ name = "xxhash" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/3c/af87902d300c1f467165558c8966d8b1e1f896dace271d3f35a410a5c26a/langgraph-1.0.4.tar.gz", hash = "sha256:86d08e25d7244340f59c5200fa69fdd11066aa999b3164b531e2a20036fac156", size = 484397, upload-time = "2025-11-25T20:31:48.608Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/72/5b/f72655717c04e33d3b62f21b166dc063d192b53980e9e3be0e2a117f1c9f/langgraph-1.0.7.tar.gz", hash = "sha256:0cfdfee51e6e8cfe503ecc7367c73933437c505b03fa10a85c710975c8182d9a", size = 497098, upload-time = "2026-01-22T16:57:47.303Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/52/4eb25a3f60399da34ba34adff1b3e324cf0d87eb7a08cebf1882a9b5e0d5/langgraph-1.0.4-py3-none-any.whl", hash = "sha256:b1a835ceb0a8d69b9db48075e1939e28b1ad70ee23fa3fa8f90149904778bacf", size = 157271, upload-time = "2025-11-25T20:31:47.518Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/0e/fe80144e3e4048e5d19ccdb91ac547c1a7dc3da8dbd1443e210048194c14/langgraph-1.0.7-py3-none-any.whl", hash = "sha256:9d68e8f8dd8f3de2fec45f9a06de05766d9b075b78fb03171779893b7a52c4d2", size = 157353, upload-time = "2026-01-22T16:57:45.997Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2514,28 +2514,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-prebuilt"
|
name = "langgraph-prebuilt"
|
||||||
version = "1.0.5"
|
version = "1.0.7"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "langgraph-checkpoint" },
|
{ name = "langgraph-checkpoint" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/46/f9/54f8891b32159e4542236817aea2ee83de0de18bce28e9bdba08c7f93001/langgraph_prebuilt-1.0.5.tar.gz", hash = "sha256:85802675ad778cc7240fd02d47db1e0b59c0c86d8369447d77ce47623845db2d", size = 144453, upload-time = "2025-11-20T16:47:39.23Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/711aecd1a50999456850dc328f3cad72b4372d8218838d8d5326f80cb76f/langgraph_prebuilt-1.0.7.tar.gz", hash = "sha256:38e097e06de810de4d0e028ffc0e432bb56d1fb417620fb1dfdc76c5e03e4bf9", size = 163692, upload-time = "2026-01-22T16:45:22.801Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/5e/aeba4a5b39fe6e874e0dd003a82da71c7153e671312671a8dacc5cb7c1af/langgraph_prebuilt-1.0.5-py3-none-any.whl", hash = "sha256:22369563e1848862ace53fbc11b027c28dd04a9ac39314633bb95f2a7e258496", size = 35072, upload-time = "2025-11-20T16:47:38.187Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/49/5e37abb3f38a17a3487634abc2a5da87c208cc1d14577eb8d7184b25c886/langgraph_prebuilt-1.0.7-py3-none-any.whl", hash = "sha256:e14923516504405bb5edc3977085bc9622c35476b50c1808544490e13871fe7c", size = 35324, upload-time = "2026-01-22T16:45:21.784Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-sdk"
|
name = "langgraph-sdk"
|
||||||
version = "0.2.9"
|
version = "0.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/23/d8/40e01190a73c564a4744e29a6c902f78d34d43dad9b652a363a92a67059c/langgraph_sdk-0.2.9.tar.gz", hash = "sha256:b3bd04c6be4fa382996cd2be8fbc1e7cc94857d2bc6b6f4599a7f2a245975303", size = 99802, upload-time = "2025-09-20T18:49:14.734Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/0f/ed0634c222eed48a31ba48eab6881f94ad690d65e44fe7ca838240a260c1/langgraph_sdk-0.3.3.tar.gz", hash = "sha256:c34c3dce3b6848755eb61f0c94369d1ba04aceeb1b76015db1ea7362c544fb26", size = 130589, upload-time = "2026-01-13T00:30:43.894Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/05/b2d34e16638241e6f27a6946d28160d4b8b641383787646d41a3727e0896/langgraph_sdk-0.2.9-py3-none-any.whl", hash = "sha256:fbf302edadbf0fb343596f91c597794e936ef68eebc0d3e1d358b6f9f72a1429", size = 56752, upload-time = "2025-09-20T18:49:13.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/be/4ad511bacfdd854afb12974f407cb30010dceb982dc20c55491867b34526/langgraph_sdk-0.3.3-py3-none-any.whl", hash = "sha256:a52ebaf09d91143e55378bb2d0b033ed98f57f48c9ad35c8f81493b88705fc7b", size = 67021, upload-time = "2026-01-13T00:30:42.264Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user