from dotenv import load_dotenv
load_dotenv()
%%capture --no-stderr
%pip install --quiet -U langchain_core langgraph
Default overwriting state¶
Let's use a TypedDict as our state schema.
from typing_extensions import TypedDict
from IPython.display import Image, display
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
foo: int
def node_1(state):
print("---Node 1---")
return {"foo": state['foo'] + 1}
# Build graph
builder = StateGraph(State)
builder.add_node("node_1", node_1)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
graph.invoke({"foo" : 1})
---Node 1---
{'foo': 2}
Let's look at the state update, return {"foo": state['foo'] + 1}.
As discussed before, by default LangGraph doesn't know the preferred way to update the state.
So, it will just overwrite the value of foo in node_1:
return {"foo": state['foo'] + 1}
If we pass {'foo': 1} as input, the state returned from the graph is {'foo': 2}.
Branching¶
Let's look at a case where our nodes branch.
class State(TypedDict):
foo: int
def node_1(state):
print("---Node 1---")
return {"foo": state['foo'] + 1}
def node_2(state):
print("---Node 2---")
return {"foo": state['foo'] + 1}
def node_3(state):
print("---Node 3---")
return {"foo": state['foo'] + 1}
# Build graph
builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_1", "node_3")
builder.add_edge("node_2", END)
builder.add_edge("node_3", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
from langgraph.errors import InvalidUpdateError
try:
graph.invoke({"foo" : 1})
except InvalidUpdateError as e:
print(f"InvalidUpdateError occurred: {e}")
---Node 1--- ---Node 2--- ---Node 3--- InvalidUpdateError occurred: At key 'foo': Can receive only one value per step. Use an Annotated key to handle multiple values.
We see a problem!
Node 1 branches to nodes 2 and 3.
Nodes 2 and 3 run in parallel, which means they run in the same step of the graph.
They both attempt to overwrite the state within the same step.
This is ambiguous for the graph! Which state should it keep?
Reducers¶
Reducers give us a general way to address this problem.
They specify how to perform updates.
We can use the Annotated type to specify a reducer function.
For example, in this case let's append the value returned from each node rather than overwriting them.
We just need a reducer that can perform this: operator.add is a function from Python's built-in operator module.
When operator.add is applied to lists, it performs list concatenation.
from operator import add
from typing import Annotated
class State(TypedDict):
foo: Annotated[list[int], add]
def node_1(state):
print("---Node 1---")
return {"foo": [state['foo'][0] + 1]}
# Build graph
builder = StateGraph(State)
builder.add_node("node_1", node_1)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
graph.invoke({"foo" : [1]})
---Node 1---
{'foo': [1, 2]}
Now, our state key foo is a list.
This operator.add reducer function will append updates from each node to this list.
def node_1(state):
print("---Node 1---")
return {"foo": [state['foo'][-1] + 1]}
def node_2(state):
print("---Node 2---")
return {"foo": [state['foo'][-1] + 1]}
def node_3(state):
print("---Node 3---")
return {"foo": [state['foo'][-1] + 1]}
# Build graph
builder = StateGraph(State)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_node("node_3", node_3)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_1", "node_3")
builder.add_edge("node_2", END)
builder.add_edge("node_3", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
We can see that updates in nodes 2 and 3 are performed concurrently because they are in the same step.
graph.invoke({"foo" : [1]})
---Node 1--- ---Node 2--- ---Node 3---
{'foo': [1, 2, 3, 3]}
Now, let's see what happens if we pass None to foo.
We see an error because our reducer, operator.add, attempts to concatenate NoneType pass as input to list in node_1.
try:
graph.invoke({"foo" : None})
except TypeError as e:
print(f"TypeError occurred: {e}")
TypeError occurred: can only concatenate list (not "NoneType") to list
Custom Reducers¶
To address cases like this,we can also define custom reducers.
For example, lets define custom reducer logic to combine lists and handle cases where either or both of the inputs might be None.
def reduce_list(left: list | None, right: list | None) -> list:
"""Safely combine two lists, handling cases where either or both inputs might be None.
Args:
left (list | None): The first list to combine, or None.
right (list | None): The second list to combine, or None.
Returns:
list: A new list containing all elements from both input lists.
If an input is None, it's treated as an empty list.
"""
if not left:
left = []
if not right:
right = []
return left + right
class DefaultState(TypedDict):
foo: Annotated[list[int], add]
class CustomReducerState(TypedDict):
foo: Annotated[list[int], reduce_list]
In node_1, we append the value 2.
def node_1(state):
print("---Node 1---")
return {"foo": [2]}
# Build graph
builder = StateGraph(DefaultState)
builder.add_node("node_1", node_1)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
try:
print(graph.invoke({"foo" : None}))
except TypeError as e:
print(f"TypeError occurred: {e}")
TypeError occurred: can only concatenate list (not "NoneType") to list
Now, try with our custom reducer. We can see that no error is thrown.
# Build graph
builder = StateGraph(CustomReducerState)
builder.add_node("node_1", node_1)
# Logic
builder.add_edge(START, "node_1")
builder.add_edge("node_1", END)
# Add
graph = builder.compile()
# View
display(Image(graph.get_graph().draw_mermaid_png()))
try:
print(graph.invoke({"foo" : None}))
except TypeError as e:
print(f"TypeError occurred: {e}")
---Node 1---
{'foo': [2]}
Messages¶
In module 1, we showed how to use a built-in reducer, add_messages, to handle messages in state.
We also showed that MessagesState is a useful shortcut if you want to work with messages.
MessagesStatehas a built-inmessageskey- It also has a built-in
add_messagesreducer for this key
These two are equivalent.
We'll use the MessagesState class via from langgraph.graph import MessagesState for brevity.
from typing import Annotated
from langgraph.graph import MessagesState
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
# Define a custom TypedDict that includes a list of messages with add_messages reducer
class CustomMessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
added_key_1: str
added_key_2: str
# etc
# Use MessagesState, which includes the messages key with add_messages reducer
class ExtendedMessagesState(MessagesState):
# Add any keys needed beyond messages, which is pre-built
added_key_1: str
added_key_2: str
# etc
Let's talk a bit more about usage of the add_messages reducer.
from langgraph.graph.message import add_messages
from langchain_core.messages import AIMessage, HumanMessage
# Initial state
initial_messages = [AIMessage(content="Hello! How can I assist you?", name="Model"),
HumanMessage(content="I'm looking for information on marine biology.", name="Lance")
]
# New message to add
new_message = AIMessage(content="Sure, I can help with that. What specifically are you interested in?", name="Model")
# Test
add_messages(initial_messages , new_message)
[AIMessage(content='Hello! How can I assist you?', name='Model', id='f470d868-cf1b-45b2-ae16-48154cd55c12'), HumanMessage(content="I'm looking for information on marine biology.", name='Lance', id='a07a88c5-cb2a-4cbd-9485-5edb9d658366'), AIMessage(content='Sure, I can help with that. What specifically are you interested in?', name='Model', id='7938e615-86c2-4cbb-944b-c9b2342dee68')]
So we can see that add_messages allows us to append messages to the messages key in our state.
Re-writing¶
Let's show some useful tricks when working with the add_messages reducer.
If we pass a message with the same ID as an existing one in our messages list, it will get overwritten!
# Initial state
initial_messages = [AIMessage(content="Hello! How can I assist you?", name="Model", id="1"),
HumanMessage(content="I'm looking for information on marine biology.", name="Lance", id="2")
]
# New message to add
new_message = HumanMessage(content="I'm looking for information on whales, specifically", name="Lance", id="2")
# Test
add_messages(initial_messages , new_message)
[AIMessage(content='Hello! How can I assist you?', name='Model', id='1'), HumanMessage(content="I'm looking for information on whales, specifically", name='Lance', id='2')]
Removal¶
We can remove messages by using RemoveMessage.
from langchain_core.messages import RemoveMessage
# Message list
messages = [AIMessage("Hi.", name="Bot", id="1")]
messages.append(HumanMessage("Hi.", name="Lance", id="2"))
messages.append(AIMessage("So you said you were researching ocean mammals?", name="Bot", id="3"))
messages.append(HumanMessage("Yes, I know about whales. But what others should I learn about?", name="Lance", id="4"))
# Isolate messages to delete
delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]]
print(delete_messages)
[RemoveMessage(content='', id='1'), RemoveMessage(content='', id='2')]
/var/folders/l9/bpjxdmfx7lvd1fbdjn38y5dh0000gn/T/ipykernel_17703/3097054180.py:10: LangChainBetaWarning: The class `RemoveMessage` is in beta. It is actively being worked on, so the API may change. delete_messages = [RemoveMessage(id=m.id) for m in messages[:-2]]
add_messages(messages , delete_messages)
[AIMessage(content='So you said you were researching ocean mammals?', name='Bot', id='3'), HumanMessage(content='Yes, I know about whales. But what others should I learn about?', name='Lance', id='4')]
We can see that mesage IDs 1 and 2, as noted in delete_messages are removed by the reducer.
We'll see this put into practice a bit later.