LangChain Documentation Appears to Contradict Itself Regarding Updating State in Nested Structures

The LangChain documentation refers to the ability to modify the state of an existing conversation using the Command class. One example in particular refers to a function get_user_preference which appears to retrieve a preference stored inside a dictionary called “user_preferences” due to the return preferences.get(pref_name, "Not set") line.

However, attempting to reconstruct this example using a custom state class which models the underlying structure conflicts with what the documentation says about retrieving an example user preference. (See code below).

from langchain.tools import tool, ToolRuntime
from langchain.messages import HumanMessage

`@tool
def get_last_user_message(runtime: ToolRuntime) -> str:
    """Get the most recent message from the user."""
    messages = runtime.state\["messages"\]

    # Find the last human message
    for message in reversed(messages):
        if isinstance(message, HumanMessage):
            return message.content

    return "No user messages found"`

# Access custom state fields
@tool
def get_user_preference(
    pref_name: str,
    runtime: ToolRuntime
) -> str:
    """Get a user preference value."""
    preferences = runtime.state.get("user_preferences", {})
    return preferences.get(pref_name, "Not set")

From the below example in the documentation, I can infer that “preferred_language” is part of the “user_preferences” dictionary, which I can model as in my code.

@tool
def set_language(language: str, runtime: ToolRuntime) -> Command:
    """Set the preferred response language."""
    return Command(
        update={
            "preferred_language": language,
            "messages": [
                ToolMessage(
                    content=f"Language set to {language}.",
                    tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )

Sample code example running an implementation with a dictionary of user preferences and using Command to update the state:

from langchain.agents import create_agent, AgentState
from langchain.tools import tool, ToolRuntime
from langchain.messages import HumanMessage, AIMessage, SystemMessage, ToolMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from typing_extensions import TypedDict

@tool 

def get_last_user_message(runtime: ToolRuntime) -> str: 
"""Get the most recent message from the user."""
    messages = runtime.state["messages"]
for message in reversed(messages):
if isinstance(message, HumanMessage):
return message.content
return "No user messages found"

@tool
def get_user_preference(
pref_name: str,
runtime: ToolRuntime
) -> str:
"""Get a user preference value."""
    preferences = runtime.state.get("user_preferences", {})
return preferences.get(pref_name, "Not set")

class CustomAgentState(AgentState):
    user_id: str
    user_preferences: dict

@tool 
def set_user_preferences_language(new_language: str, runtime: ToolRuntime[None, CustomAgentState]) -> Command: 
"""Set the user's preferred language in the conversation state."""
return Command(
update={
"user_preferences": new_language,
"messages": [
                ToolMessage(
content=f"Preferred language set to {new_language}.",
tool_call_id=runtime.tool_call_id,
                )
            ],
        }
    )

@tool
def inspect_state(runtime: ToolRuntime) -> str:
"""Inspect the current runtime state. This tool is called inspect_state."""
print("runtime state\n", runtime.state)
return runtime.state

agent = create_agent(
model="openai:gpt-5-nano",
tools=[get_last_user_message, get_user_preference, set_user_preferences_language],
state_schema=CustomAgentState,
checkpointer=InMemorySaver(),
)

messages = [
    {
"role": "user",
"content": "Update the user's preferred language to English (U.K.). Then output the user's preferred language. Finally, inspect the current runtime state by using the inspect_state tool."
    }
]

user_preferences = {
"preferred_language": "English",
"verbosity": "concise",
"tone": "friendly"
}

thread_config = {"configurable": {"thread_id": "1"}}
response = agent.invoke(
    {
"messages": messages,
"user_id": "user_123",
"user_preferences": user_preferences,
    },
    thread_config,
)

print(response)

I am getting the following error in my Terminal when running this:

    return preferences.get(pref_name, "Not set")

           ^^^^^^^^^^^^^^^

AttributeError: 'str' object has no attribute 'get'

During task with name 'tools' and id 'c8c09c7d-6b03-1384-9ff1-a5c1dbfbccfc'

My issue is not simply that I am seeing this error, but that the documentation demonstrates reading nested state (e.g. runtime.state["user_preferences"]) while there appears to be no documented way to update a nested field in an object like CustomAgentState where then fields are more complex than simple top-level fields (the example in the documentation is limited to showing how top-level state fields can be updated).

Hey @asaadjaber welcome to langchain community.

The docs aren’t actually contradicting themselves, those are two independent snippets that happen to live on the same page, and they don’t share a state schema.

  • In the Access state example, user_preferences is a dict field, so get_user_preference does preferences.get(pref_name).
  • In the Return a Command example, preferred_language is a separate top-level field. The docs never say it lives inside user_preferences, that inference is what’s tripping you up.

Why you get 'str' object has no attribute 'get'

Command(update=...) applies each top-level key through that key’s reducer. If a field has no reducer, the default is overwrite, straight from the docs: “If no reducer function is explicitly specified then it is assumed that all updates to that key should override it.” So this line:

update={"user_preferences": new_language}  # new_language is a str

replaces your entire dict with the string "English (U.K.)". Then get_user_preference calls .get() on a str → boom. There is no automatic deep-merge of nested keys.

How to update a nested field , pick one:

Option A, read/merge/write the whole dict inside the tool:

@tool
def set_user_preferences_language(
    new_language: str, runtime: ToolRuntime[None, CustomAgentState]
) -> Command:
    """Set the user's preferred language."""
    preferences = {**runtime.state.get("user_preferences", {}), "preferred_language": new_language}
    return Command(update={
        "user_preferences": preferences,
        "messages": [ToolMessage(content=f"Preferred language set to {new_language}.", tool_call_id=runtime.tool_call_id)],
    })

Option B, attach a merging reducer so partial updates merge instead of overwrite (also the recommended approach when tools run in parallel):

from typing import Annotated

def merge_prefs(left: dict, right: dict) -> dict:
    return {**(left or {}), **(right or {})}

class CustomAgentState(AgentState):
    user_id: str
    user_preferences: Annotated[dict, merge_prefs]

Then update={"user_preferences": {"preferred_language": new_language}} merges into the existing dict.

Either way, get_user_preference("preferred_language") returns the new value and inspect_state shows the full dict intact.

TL;DR: Command updates operate on top-level state keys, so to change one entry in a nested dict you must merge it yourself (or via a reducer) rather than assigning a scalar to the dict field.

Hi @keenborder786 , thank you for the welcome! Maybe the case isn’t that the documentation is contradicting itself, but rather that there are enough examples in the documentation to suggest that AgentState can be structured as nested state that there should be more explicit documentation about how to update this nested state using the Command class. Thanks for your solutions, I’ll try them out. There’s also an additional example from the documentation that points to a nested structure for AgentState:

class CustomAgentState(AgentState):   
 user_id: str    
preferences: dict

You’re absolutely right, and this is a great observation. The docs genuinely lead you toward nested state and then leave you hanging on how to update it, so this is a real gap worth calling out, not user error.

You’ve nailed it: Short-term memory explicitly models nested state with

class CustomAgentState(AgentState):
    user_id: str
    preferences: dict

and invokes it with "preferences": {"theme": "dark"}, and the tools page happily reads nested dicts, yet there’s no example anywhere showing how to update a single key inside one with Command. Your instinct that this deserves explicit documentation is spot on.

Here’s the key rule that makes it all click: Command(update=...) applies each top-level key through that key’s reducer, and the default reducer overwrites. So update={"preferences": <str>} swaps the whole dict for a scalar, no deep merge, which is exactly why the next read throws 'str' object has no attribute 'get'. Once you know that, the fix is clean and reliable.

Two solid patterns, both of which work great:

Merge it yourself in the tool:

return Command(update={
    "preferences": {**runtime.state.get("preferences", {}), "preferred_language": new_language},
    "messages": [ToolMessage(content=f"Set to {new_language}.", tool_call_id=runtime.tool_call_id)],
})

Or make merging automatic with a reducer (this is the better choice, especially for parallel tool calls):

from typing import Annotated

def merge(a: dict, b: dict) -> dict:
    return {**(a or {}), **(b or {})}

class CustomAgentState(AgentState):
    user_id: str
    preferences: Annotated[dict, merge]
# now update={"preferences": {"preferred_language": new_language}} merges in cleanly

Hello @asaadjaber,

If the solution resolved your issue, could you please close this thread by marking the answer as “Accepted Solution”? I’d really appreciate it. Thank you!