a7d6d88f6f
CI / changes (push) Has been cancelled
CI / cd libs/checkpoint (push) Has been cancelled
CI / cd libs/checkpoint-conformance (push) Has been cancelled
CI / cd libs/checkpoint-postgres (push) Has been cancelled
CI / cd libs/checkpoint-sqlite (push) Has been cancelled
CI / cd libs/cli (push) Has been cancelled
CI / cd libs/prebuilt (push) Has been cancelled
CI / cd libs/sdk-py (push) Has been cancelled
CI / cd libs/langgraph (push) Has been cancelled
CI / Check SDK methods matching (push) Has been cancelled
CI / Check CLI schema hasn't changed #3.13 (push) Has been cancelled
CI / CLI integration test (push) Has been cancelled
CI / sdk-py integration test (push) Has been cancelled
CI / CI Success (push) Has been cancelled
baseline / benchmark (push) Has been cancelled
Deploy Redirects to GitHub Pages / deploy (push) Has been cancelled
286 lines
9.0 KiB
Python
286 lines
9.0 KiB
Python
"""Test InjectedState with NotRequired state fields.
|
|
|
|
This tests the fix for https://github.com/langchain-ai/langchain/issues/35585
|
|
|
|
When using InjectedState(<field>) on a tool parameter, and the referenced field is
|
|
declared as NotRequired in the custom state schema, the ToolNode should gracefully
|
|
handle missing fields by injecting None instead of raising KeyError.
|
|
"""
|
|
|
|
import sys
|
|
from typing import Annotated
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage
|
|
from langchain_core.tools import tool
|
|
from langgraph.graph.message import add_messages
|
|
from pydantic import BaseModel, Field
|
|
from typing_extensions import NotRequired
|
|
|
|
from langgraph.prebuilt import InjectedState, ToolNode, create_react_agent
|
|
from langgraph.prebuilt.chat_agent_executor import AgentState
|
|
|
|
from .model import FakeToolCallingModel
|
|
|
|
|
|
class CustomAgentStateWithNotRequired(AgentState):
|
|
"""Custom state with a NotRequired field (TypedDict style)."""
|
|
|
|
city: NotRequired[str]
|
|
|
|
|
|
class CustomAgentStatePydanticWithDefault(BaseModel):
|
|
"""Custom state with Optional field and default (Pydantic style)."""
|
|
|
|
messages: Annotated[list[AnyMessage], add_messages]
|
|
remaining_steps: int = Field(default=10)
|
|
city: str | None = Field(default=None)
|
|
|
|
|
|
@tool
|
|
def get_weather(city: Annotated[str | None, InjectedState("city")] = None) -> str:
|
|
"""Get weather for a given city."""
|
|
if city is None:
|
|
return "No city provided"
|
|
return f"It's always sunny in {city}!"
|
|
|
|
|
|
def _create_mock_runtime(
|
|
state: dict | None = None,
|
|
store=None,
|
|
):
|
|
"""Create a mock Runtime for testing ToolNode directly."""
|
|
from unittest.mock import Mock
|
|
|
|
from langgraph.runtime import Runtime
|
|
|
|
mock_runtime = Mock(spec=Runtime)
|
|
mock_runtime.context = {}
|
|
return mock_runtime
|
|
|
|
|
|
def _create_config_with_runtime(store=None, state=None):
|
|
"""Create a RunnableConfig with mocked runtime for direct ToolNode testing."""
|
|
from langgraph.prebuilt.tool_node import ToolRuntime
|
|
|
|
tool_runtime = ToolRuntime(
|
|
state=state or {},
|
|
config={},
|
|
context={},
|
|
store=store,
|
|
stream_writer=None,
|
|
tools=[],
|
|
tool_call_id="test_id",
|
|
)
|
|
return {
|
|
"configurable": {
|
|
"__pregel_runtime": _create_mock_runtime(),
|
|
"__tool_runtime__": tool_runtime,
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 11),
|
|
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
|
|
)
|
|
def test_injected_state_not_required_field_missing_injects_none():
|
|
"""Test that InjectedState with NotRequired field injects None when field is missing.
|
|
|
|
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
|
|
"""
|
|
tool_node = ToolNode([get_weather])
|
|
|
|
tool_call = {
|
|
"name": "get_weather",
|
|
"args": {},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
}
|
|
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
|
|
|
|
# State WITHOUT the "city" field - should inject None instead of raising KeyError
|
|
state_without_city: CustomAgentStateWithNotRequired = {
|
|
"messages": [HumanMessage("What's the weather?"), ai_msg],
|
|
}
|
|
|
|
result = tool_node.invoke(
|
|
state_without_city,
|
|
config=_create_config_with_runtime(state=state_without_city),
|
|
)
|
|
|
|
assert len(result["messages"]) == 1
|
|
tool_msg = result["messages"][0]
|
|
assert isinstance(tool_msg, ToolMessage)
|
|
assert "No city provided" in tool_msg.content
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 11),
|
|
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
|
|
)
|
|
def test_injected_state_not_required_field_present_works():
|
|
"""Test that InjectedState with NotRequired field works when field IS present."""
|
|
tool_node = ToolNode([get_weather])
|
|
|
|
tool_call = {
|
|
"name": "get_weather",
|
|
"args": {},
|
|
"id": "call_1",
|
|
"type": "tool_call",
|
|
}
|
|
ai_msg = AIMessage("Let me check the weather", tool_calls=[tool_call])
|
|
|
|
# State WITH the "city" field - this should work
|
|
state_with_city: CustomAgentStateWithNotRequired = {
|
|
"messages": [HumanMessage("What's the weather?"), ai_msg],
|
|
"city": "San Francisco",
|
|
}
|
|
|
|
result = tool_node.invoke(
|
|
state_with_city,
|
|
config=_create_config_with_runtime(state=state_with_city),
|
|
)
|
|
|
|
assert len(result["messages"]) == 1
|
|
tool_msg = result["messages"][0]
|
|
assert isinstance(tool_msg, ToolMessage)
|
|
assert "San Francisco" in tool_msg.content
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 11),
|
|
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
|
|
)
|
|
def test_create_react_agent_injected_state_not_required_field_missing():
|
|
"""Test create_react_agent with InjectedState using NotRequired field that is missing.
|
|
|
|
This verifies the fix for https://github.com/langchain-ai/langchain/issues/35585
|
|
"""
|
|
model = FakeToolCallingModel(
|
|
tool_calls=[
|
|
[{"name": "get_weather", "args": {}, "id": "call_1"}],
|
|
[], # No more tool calls, agent should stop
|
|
]
|
|
)
|
|
|
|
agent = create_react_agent(
|
|
model,
|
|
tools=[get_weather],
|
|
state_schema=CustomAgentStateWithNotRequired,
|
|
)
|
|
|
|
# Invoke WITHOUT the city field - should work, injecting None
|
|
result = agent.invoke(
|
|
{"messages": [HumanMessage("What's the weather?")]},
|
|
)
|
|
|
|
# Check that the tool was called successfully with None injected
|
|
messages = result["messages"]
|
|
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
|
|
assert len(tool_messages) == 1
|
|
assert "No city provided" in tool_messages[0].content
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.version_info < (3, 11),
|
|
reason="InjectedState field extraction from Optional[Annotated[...]] not supported on Python <3.11",
|
|
)
|
|
def test_create_react_agent_injected_state_not_required_field_present():
|
|
"""Test create_react_agent with InjectedState using NotRequired field that IS present."""
|
|
model = FakeToolCallingModel(
|
|
tool_calls=[
|
|
[{"name": "get_weather", "args": {}, "id": "call_1"}],
|
|
[], # No more tool calls, agent should stop
|
|
]
|
|
)
|
|
|
|
agent = create_react_agent(
|
|
model,
|
|
tools=[get_weather],
|
|
state_schema=CustomAgentStateWithNotRequired,
|
|
)
|
|
|
|
# Invoke WITH the city field
|
|
result = agent.invoke(
|
|
{
|
|
"messages": [HumanMessage("What's the weather?")],
|
|
"city": "San Francisco",
|
|
},
|
|
)
|
|
|
|
# Check that the tool was called successfully
|
|
messages = result["messages"]
|
|
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
|
|
assert len(tool_messages) == 1
|
|
assert "San Francisco" in tool_messages[0].content
|
|
|
|
|
|
@tool
|
|
def get_weather_optional(city: Annotated[str | None, InjectedState("city")]) -> str:
|
|
"""Get weather for a given city (accepts None)."""
|
|
if city is None:
|
|
return "Please provide a city!"
|
|
return f"It's always sunny in {city}!"
|
|
|
|
|
|
def test_pydantic_state_with_default_field_missing_works():
|
|
"""Test that Pydantic state with Optional field and default=None works when field is missing.
|
|
|
|
This is the workaround suggested in the issue comments - using Pydantic BaseModel
|
|
with `city: Optional[str] = Field(default=None)` instead of TypedDict with NotRequired.
|
|
"""
|
|
model = FakeToolCallingModel(
|
|
tool_calls=[
|
|
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
|
|
[], # No more tool calls, agent should stop
|
|
]
|
|
)
|
|
|
|
agent = create_react_agent(
|
|
model,
|
|
tools=[get_weather_optional],
|
|
state_schema=CustomAgentStatePydanticWithDefault,
|
|
)
|
|
|
|
# Invoke WITHOUT the city field - should work because Pydantic provides default
|
|
result = agent.invoke(
|
|
{"messages": [HumanMessage("What's the weather?")]},
|
|
)
|
|
|
|
# Check that the tool was called successfully with None
|
|
messages = result["messages"]
|
|
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
|
|
assert len(tool_messages) == 1
|
|
assert "Please provide a city!" in tool_messages[0].content
|
|
|
|
|
|
def test_pydantic_state_with_default_field_present_works():
|
|
"""Test that Pydantic state with Optional field works when field IS present."""
|
|
model = FakeToolCallingModel(
|
|
tool_calls=[
|
|
[{"name": "get_weather_optional", "args": {}, "id": "call_1"}],
|
|
[], # No more tool calls, agent should stop
|
|
]
|
|
)
|
|
|
|
agent = create_react_agent(
|
|
model,
|
|
tools=[get_weather_optional],
|
|
state_schema=CustomAgentStatePydanticWithDefault,
|
|
)
|
|
|
|
# Invoke WITH the city field
|
|
result = agent.invoke(
|
|
{
|
|
"messages": [HumanMessage("What's the weather?")],
|
|
"city": "San Francisco",
|
|
},
|
|
)
|
|
|
|
# Check that the tool was called successfully
|
|
messages = result["messages"]
|
|
tool_messages = [m for m in messages if isinstance(m, ToolMessage)]
|
|
assert len(tool_messages) == 1
|
|
assert "San Francisco" in tool_messages[0].content
|