feat(prebuilt): Add dynamic model to create_react_agent (#5651)

This PR allows a developer to change the model configuration at run time based on context. This includes that list of tools available to the model to call.

```python
def create_react_agent(
    model: Union[
        str, 
	LanguageModelLike,
        Callable[[SateLike, Runtime...], BaseChatModel], # <--- New
    ],
    tools: Union[
      Sequence[Union[BaseTool, Callable, dict[str, Any]]], ToolNode]
    ],
    *,
....


llm = init_chat_model(...)

def prepare_model(state, runtime):
   selected_tool_names = func(state, context)
   return llm.bind(tools=selected_tool_names)

create_react_agent(
  prepare_model,
  tools=all_known_tools
)
```

## Semantics

1. `tools` = are the known tools, used to configure ToolNode and will
configure:
    1. model provided as string
    2. model provided as BaseChatModel (if it has no tools bound to it)
2. If a user provides a dynamic model (callable), the user is
responsible for binding tools


Alternative considered:

1. Passing `Callable[[SateLike, Config...], list[BaseTool]]` to tools
2. Passing `Callable[[SateLike, Config...], list[str]]` to a tool
selector

Both have the issue that there's non obvious interplay between tool
selection and dynamic models. (i.e., if we want to introduce dynamic
models at in the future, the API will become tricky to explain)

---------

Co-authored-by: Sydney Runkle <54324534+sydney-runkle@users.noreply.github.com>
This commit is contained in:
Eugene Yurtsev
2025-07-25 14:48:15 -04:00
committed by GitHub
co-authored by Sydney Runkle
parent 8495f6f95d
commit f6aa19709e
2 changed files with 518 additions and 30 deletions
+374 -1
View File
@@ -13,10 +13,12 @@ from typing import (
)
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import (
AIMessage,
AnyMessage,
HumanMessage,
MessageLikeRepresentation,
RemoveMessage,
SystemMessage,
ToolCall,
@@ -52,6 +54,7 @@ from langgraph.prebuilt.tool_node import (
_get_state_args,
_infer_handled_types,
)
from langgraph.runtime import Runtime
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from langgraph.types import Command, Interrupt, interrupt
@@ -1092,7 +1095,7 @@ def test_inspect_react() -> None:
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_react_with_subgraph_tools(
sync_checkpointer: BaseCheckpointSaver, version: str
sync_checkpointer: BaseCheckpointSaver, version: Literal["v1", "v2"]
) -> None:
class State(TypedDict):
a: int
@@ -1367,6 +1370,376 @@ def test_get_model() -> None:
_get_model(RunnableLambda(lambda message: message))
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_basic(version: str) -> None:
"""Test basic dynamic model functionality."""
def dynamic_model(state, runtime: Runtime):
# Return different models based on state
if "urgent" in state["messages"][-1].content:
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], version=version)
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "hello"
result = agent.invoke({"messages": [HumanMessage("urgent help")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "urgent help"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_tools(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with tool calling."""
@dec_tool
def basic_tool(x: int) -> str:
"""Basic tool."""
return f"basic: {x}"
@dec_tool
def advanced_tool(x: int) -> str:
"""Advanced tool."""
return f"advanced: {x}"
def dynamic_model(state: dict, runtime: Runtime) -> BaseChatModel:
# Return model with different behaviors based on message content
if "advanced" in state["messages"][-1].content:
return FakeToolCallingModel(
tool_calls=[
[{"args": {"x": 1}, "id": "1", "name": "advanced_tool"}],
[],
]
)
else:
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "basic_tool"}], []]
)
agent = create_react_agent(
dynamic_model, [basic_tool, advanced_tool], version=version
)
# Test basic tool usage
result = agent.invoke({"messages": [HumanMessage("basic request")]})
assert len(result["messages"]) == 3
tool_message = result["messages"][-1]
assert tool_message.content == "basic: 1"
assert tool_message.name == "basic_tool"
# Test advanced tool usage
result = agent.invoke({"messages": [HumanMessage("advanced request")]})
assert len(result["messages"]) == 3
tool_message = result["messages"][-1]
assert tool_message.content == "advanced: 1"
assert tool_message.name == "advanced_tool"
@dataclasses.dataclass
class Context:
user_id: str
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_context(version: str) -> None:
"""Test dynamic model using config parameters."""
def dynamic_model(state, runtime: Runtime[Context]):
# Use context to determine model behavior
user_id = runtime.context.user_id
if user_id == "user_premium":
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(
dynamic_model, [], context_schema=Context, version=version
)
# Test with basic user
result = agent.invoke(
{"messages": [HumanMessage("hello")]},
context=Context(user_id="user_basic"),
)
assert len(result["messages"]) == 2
# Test with premium user
result = agent.invoke(
{"messages": [HumanMessage("hello")]},
context=Context(user_id="user_premium"),
)
assert len(result["messages"]) == 2
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_state_schema(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with custom state schema."""
class CustomDynamicState(AgentState):
model_preference: str = "default"
def dynamic_model(state: CustomDynamicState, runtime: Runtime) -> BaseChatModel:
# Use custom state field to determine model
if state.get("model_preference") == "advanced":
return FakeToolCallingModel(tool_calls=[])
else:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(
dynamic_model, [], state_schema=CustomDynamicState, version=version
)
result = agent.invoke(
{"messages": [HumanMessage("hello")], "model_preference": "advanced"}
)
assert len(result["messages"]) == 2
assert result["model_preference"] == "advanced"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_prompt(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model with different prompt types."""
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
return FakeToolCallingModel(tool_calls=[])
# Test with string prompt
agent = create_react_agent(dynamic_model, [], prompt="system_msg", version=version)
result = agent.invoke({"messages": [HumanMessage("human_msg")]})
assert result["messages"][-1].content == "system_msg-human_msg"
# Test with callable prompt
def dynamic_prompt(state: AgentState) -> list[MessageLikeRepresentation]:
"""Generate a dynamic system message based on state."""
return [{"role": "system", "content": "system_msg"}] + list(state["messages"])
agent = create_react_agent(
dynamic_model, [], prompt=dynamic_prompt, version=version
)
result = agent.invoke({"messages": [HumanMessage("human_msg")]})
assert result["messages"][-1].content == "system_msg-human_msg"
async def test_dynamic_model_async() -> None:
"""Test dynamic model with async operations."""
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [])
result = await agent.ainvoke({"messages": [HumanMessage("hello async")]})
assert len(result["messages"]) == 2
assert result["messages"][-1].content == "hello async"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_with_structured_response(version: str) -> None:
"""Test dynamic model with structured response format."""
class TestResponse(BaseModel):
message: str
confidence: float
def dynamic_model(state, runtime: Runtime):
expected_response = TestResponse(message="dynamic response", confidence=0.9)
return FakeToolCallingModel(
tool_calls=[], structured_response=expected_response
)
agent = create_react_agent(
dynamic_model, [], response_format=TestResponse, version=version
)
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert "structured_response" in result
assert result["structured_response"].message == "dynamic response"
assert result["structured_response"].confidence == 0.9
def test_dynamic_model_with_checkpointer(sync_checkpointer):
"""Test dynamic model with checkpointer."""
call_count = 0
def dynamic_model(state: AgentState, runtime: Runtime) -> BaseChatModel:
nonlocal call_count
call_count += 1
return FakeToolCallingModel(
tool_calls=[],
# Incrementing the call count as it is used to assign an id
# to the AIMessage.
# The default reducer semantics are to overwrite an existing message
# with the new one if the id matches.
index=call_count,
)
agent = create_react_agent(dynamic_model, [], checkpointer=sync_checkpointer)
config = {"configurable": {"thread_id": "test_dynamic"}}
# First call
result1 = agent.invoke({"messages": [HumanMessage("hello")]}, config)
assert len(result1["messages"]) == 2 # Human + AI message
# Second call - should load from checkpoint
result2 = agent.invoke({"messages": [HumanMessage("world")]}, config)
assert len(result2["messages"]) == 4
# Dynamic model should be called each time
assert call_count >= 2
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_state_dependent_tools(version: Literal["v1", "v2"]) -> None:
"""Test dynamic model that changes available tools based on state."""
@dec_tool
def tool_a(x: int) -> str:
"""Tool A."""
return f"A: {x}"
@dec_tool
def tool_b(x: int) -> str:
"""Tool B."""
return f"B: {x}"
def dynamic_model(state, runtime: Runtime):
# Switch tools based on message history
if any("use_b" in msg.content for msg in state["messages"]):
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 2}, "id": "1", "name": "tool_b"}], []]
)
else:
return FakeToolCallingModel(
tool_calls=[[{"args": {"x": 1}, "id": "1", "name": "tool_a"}], []]
)
agent = create_react_agent(dynamic_model, [tool_a, tool_b], version=version)
# Ask to use tool B
result = agent.invoke({"messages": [HumanMessage("use_b please")]})
last_message = result["messages"][-1]
assert isinstance(last_message, ToolMessage)
assert last_message.content == "B: 2"
# Ask to use tool A
result = agent.invoke({"messages": [HumanMessage("hello")]})
last_message = result["messages"][-1]
assert isinstance(last_message, ToolMessage)
assert last_message.content == "A: 1"
@pytest.mark.parametrize("version", REACT_TOOL_CALL_VERSIONS)
def test_dynamic_model_error_handling(version: Literal["v1", "v2"]) -> None:
"""Test error handling in dynamic model."""
def failing_dynamic_model(state, runtime: Runtime):
if "fail" in state["messages"][-1].content:
raise ValueError("Dynamic model failed")
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(failing_dynamic_model, [], version=version)
# Normal operation should work
result = agent.invoke({"messages": [HumanMessage("hello")]})
assert len(result["messages"]) == 2
# Should propagate the error
with pytest.raises(ValueError, match="Dynamic model failed"):
agent.invoke({"messages": [HumanMessage("fail now")]})
def test_dynamic_model_vs_static_model_behavior():
"""Test that dynamic and static models produce equivalent results when configured the same."""
# Static model
static_model = FakeToolCallingModel(tool_calls=[])
static_agent = create_react_agent(static_model, [])
# Dynamic model returning the same model
def dynamic_model(state, runtime: Runtime):
return FakeToolCallingModel(tool_calls=[])
dynamic_agent = create_react_agent(dynamic_model, [])
input_msg = {"messages": [HumanMessage("test message")]}
static_result = static_agent.invoke(input_msg)
dynamic_result = dynamic_agent.invoke(input_msg)
# Results should be equivalent (content-wise, IDs may differ)
assert len(static_result["messages"]) == len(dynamic_result["messages"])
assert static_result["messages"][0].content == dynamic_result["messages"][0].content
assert static_result["messages"][1].content == dynamic_result["messages"][1].content
def test_dynamic_model_receives_correct_state():
"""Test that the dynamic model function receives the correct state, not the model input."""
received_states = []
class CustomAgentState(AgentState):
custom_field: str
def dynamic_model(state, runtime: Runtime) -> BaseChatModel:
# Capture the state that's passed to the dynamic model function
received_states.append(state)
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentState)
# Test with initial state
input_state = {"messages": [HumanMessage("hello")], "custom_field": "test_value"}
agent.invoke(input_state)
# The dynamic model function should receive the original state, not the processed model input
assert len(received_states) == 1
received_state = received_states[0]
# Should have the custom field from original state
assert "custom_field" in received_state
assert received_state["custom_field"] == "test_value"
# Should have the original messages
assert len(received_state["messages"]) == 1
assert received_state["messages"][0].content == "hello"
async def test_dynamic_model_receives_correct_state_async():
"""Test that the async dynamic model function receives the correct state, not the model input."""
received_states = []
class CustomAgentStateAsync(AgentState):
custom_field: str
def dynamic_model(state, runtime: Runtime):
# Capture the state that's passed to the dynamic model function
received_states.append(state)
return FakeToolCallingModel(tool_calls=[])
agent = create_react_agent(dynamic_model, [], state_schema=CustomAgentStateAsync)
# Test with initial state
input_state = {
"messages": [HumanMessage("hello async")],
"custom_field": "test_value_async",
}
await agent.ainvoke(input_state)
# The dynamic model function should receive the original state, not the processed model input
assert len(received_states) == 1
received_state = received_states[0]
# Should have the custom field from original state
assert "custom_field" in received_state
assert received_state["custom_field"] == "test_value_async"
# Should have the original messages
assert len(received_state["messages"]) == 1
assert received_state["messages"][0].content == "hello async"
def test_pre_model_hook() -> None:
model = FakeToolCallingModel(tool_calls=[])