calling model_dump() on pydantic state variables drops tool_calls in AIMessage #1115

Closed
opened 2026-02-20 17:43:08 -05:00 by yindo · 7 comments
Owner

Originally created by @Thommy257 on GitHub (Jan 12, 2026).

Checked other resources

  • This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/).
  • I added a clear and detailed title that summarizes the issue.
  • I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example).
  • I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue.

Example Code

from langchain_core.messages import AIMessage, BaseMessage, ToolCall
from pydantic import BaseModel

class State(BaseModel):
    messages: list[BaseMessage]

msg = AIMessage(content='foo',
                tool_calls=[
                    ToolCall(
                        id='bar',
                        args={'baz': 'qux'},
                        name='quux'
                    ),
                ]
            )
state = State(messages=[msg])

print(msg.model_dump())
print(state.model_dump())

Error Message and Stack Trace (if applicable)

# output
{'content': 'foo', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': None, 'tool_calls': [{'name': 'quux', 'args': {'baz': 'qux'}, 'id': 'bar', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None}
{'messages': [{'content': 'foo', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': None}]}

Description

When using a Pydantic BaseModel as the state schema, tool calls are dropped from AIMessage objects when the state is serialized via model_dump().

Expected Behavior

Both msg.model_dump() and state.model_dump() should include the tool_calls in the serialized output.

Actual Behavior

  • msg.model_dump() correctly includes tool_calls.
  • state.model_dump() drops the tool_calls from the message.

Impact

This causes a KeyError when processing states that have been serialized using Pydantic, as the tool's call information is lost. See example below:

import operator
from typing import Annotated

from dotenv import load_dotenv
from langchain_core.messages import AIMessage, BaseMessage, ToolCall
from langchain_core.prompts.chat import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langgraph.graph import END, StateGraph
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool

from pydantic import BaseModel

load_dotenv()


class State(BaseModel):
    messages: Annotated[list[BaseMessage], operator.add]


@tool
def foo(bar: str) -> str:
    """A tool"""
    return f'The argument is {bar}'

prompt = ChatPromptTemplate.from_messages([
    ('system', 'Return "YES" if the tool output is in english, "NO" if not.'),
    ('placeholder', '{messages}')])
llm = ChatOpenAI(name='llm', model='gpt-5-mini', reasoning_effort='low')
agent = prompt | llm

def agent_node(state: State) -> State:
    msg = agent.invoke(state.model_dump())
    return State(messages=[msg])

graph = StateGraph(State)
graph.add_node('tools', ToolNode([foo]))
graph.add_node('agent', agent_node)
graph.set_entry_point('tools')
graph.add_edge('tools', 'agent')
graph.set_finish_point('agent')

compiled_agent = graph.compile()

state = State(messages=[AIMessage(
            content='The word to check is "cat"',
            tool_calls=[
                ToolCall(
                    id='baz',
                    args={'bar': 'cat'},
                    name='foo'
                ),
            ]
        )])

compiled_agent.invoke(state)

--> Triggers:

KeyError: 'tool_call_id'
During task with name 'agent' and id '29960e58-b80d-2b18-c5a2-855168b09a0a'

System Info

System Information

OS: Darwin
OS Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:07:05 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6020
Python Version: 3.12.11 (main, Jul 31 2025, 09:52:35) [Clang 17.0.0 (clang-1700.0.13.5)]

Package Information

langchain_core: 1.2.6
langchain: 1.2.3
langchain_community: 0.4.1
langsmith: 0.6.2
langchain_classic: 1.0.1
langchain_openai: 1.1.7
langchain_text_splitters: 1.1.0
langgraph_sdk: 0.3.1

Optional packages not installed

langserve

Other Dependencies

aiohttp: 3.13.3
dataclasses-json: 0.6.7
httpx: 0.28.1
httpx-sse: 0.4.3
jsonpatch: 1.33
langgraph: 1.0.5
numpy: 2.3.5
openai: 2.14.0
opentelemetry-api: 1.39.1
opentelemetry-exporter-otlp-proto-http: 1.39.1
opentelemetry-sdk: 1.39.1
orjson: 3.11.5
packaging: 25.0
pydantic: 2.12.5
pydantic-settings: 2.12.0
pytest: 9.0.2
PyYAML: 6.0.3
pyyaml: 6.0.3
requests: 2.32.5
requests-toolbelt: 1.0.0
sqlalchemy: 2.0.45
SQLAlchemy: 2.0.45
tenacity: 9.1.2
tiktoken: 0.12.0
typing-extensions: 4.15.0
uuid-utils: 0.13.0
zstandard: 0.25.0

Originally created by @Thommy257 on GitHub (Jan 12, 2026). ### Checked other resources - [x] This is a bug, not a usage question. For questions, please use the LangChain Forum (https://forum.langchain.com/). - [x] I added a clear and detailed title that summarizes the issue. - [x] I read what a minimal reproducible example is (https://stackoverflow.com/help/minimal-reproducible-example). - [x] I included a self-contained, minimal example that demonstrates the issue INCLUDING all the relevant imports. The code run AS IS to reproduce the issue. ### Example Code ```python from langchain_core.messages import AIMessage, BaseMessage, ToolCall from pydantic import BaseModel class State(BaseModel): messages: list[BaseMessage] msg = AIMessage(content='foo', tool_calls=[ ToolCall( id='bar', args={'baz': 'qux'}, name='quux' ), ] ) state = State(messages=[msg]) print(msg.model_dump()) print(state.model_dump()) ``` ### Error Message and Stack Trace (if applicable) ```shell # output {'content': 'foo', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': None, 'tool_calls': [{'name': 'quux', 'args': {'baz': 'qux'}, 'id': 'bar', 'type': 'tool_call'}], 'invalid_tool_calls': [], 'usage_metadata': None} {'messages': [{'content': 'foo', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': None}]} ``` ### Description When using a Pydantic `BaseModel` as the state schema, tool calls are dropped from `AIMessage` objects when the state is serialized via `model_dump()`. ### Expected Behavior Both `msg.model_dump()` and `state.model_dump()` should include the tool_calls in the serialized output. ### Actual Behavior - `msg.model_dump()` correctly includes tool_calls. - `state.model_dump()` drops the tool_calls from the message. ### Impact This causes a KeyError when processing states that have been serialized using Pydantic, as the tool's call information is lost. See example below: ```python import operator from typing import Annotated from dotenv import load_dotenv from langchain_core.messages import AIMessage, BaseMessage, ToolCall from langchain_core.prompts.chat import ChatPromptTemplate from langchain_openai import ChatOpenAI from langgraph.graph import END, StateGraph from langgraph.prebuilt import ToolNode from langchain_core.tools import tool from pydantic import BaseModel load_dotenv() class State(BaseModel): messages: Annotated[list[BaseMessage], operator.add] @tool def foo(bar: str) -> str: """A tool""" return f'The argument is {bar}' prompt = ChatPromptTemplate.from_messages([ ('system', 'Return "YES" if the tool output is in english, "NO" if not.'), ('placeholder', '{messages}')]) llm = ChatOpenAI(name='llm', model='gpt-5-mini', reasoning_effort='low') agent = prompt | llm def agent_node(state: State) -> State: msg = agent.invoke(state.model_dump()) return State(messages=[msg]) graph = StateGraph(State) graph.add_node('tools', ToolNode([foo])) graph.add_node('agent', agent_node) graph.set_entry_point('tools') graph.add_edge('tools', 'agent') graph.set_finish_point('agent') compiled_agent = graph.compile() state = State(messages=[AIMessage( content='The word to check is "cat"', tool_calls=[ ToolCall( id='baz', args={'bar': 'cat'}, name='foo' ), ] )]) compiled_agent.invoke(state) ``` --> Triggers: ``` KeyError: 'tool_call_id' During task with name 'agent' and id '29960e58-b80d-2b18-c5a2-855168b09a0a' ``` ### System Info System Information ------------------ > OS: Darwin > OS Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:07:05 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6020 > Python Version: 3.12.11 (main, Jul 31 2025, 09:52:35) [Clang 17.0.0 (clang-1700.0.13.5)] Package Information ------------------- > langchain_core: 1.2.6 > langchain: 1.2.3 > langchain_community: 0.4.1 > langsmith: 0.6.2 > langchain_classic: 1.0.1 > langchain_openai: 1.1.7 > langchain_text_splitters: 1.1.0 > langgraph_sdk: 0.3.1 Optional packages not installed ------------------------------- > langserve Other Dependencies ------------------ > aiohttp: 3.13.3 > dataclasses-json: 0.6.7 > httpx: 0.28.1 > httpx-sse: 0.4.3 > jsonpatch: 1.33 > langgraph: 1.0.5 > numpy: 2.3.5 > openai: 2.14.0 > opentelemetry-api: 1.39.1 > opentelemetry-exporter-otlp-proto-http: 1.39.1 > opentelemetry-sdk: 1.39.1 > orjson: 3.11.5 > packaging: 25.0 > pydantic: 2.12.5 > pydantic-settings: 2.12.0 > pytest: 9.0.2 > PyYAML: 6.0.3 > pyyaml: 6.0.3 > requests: 2.32.5 > requests-toolbelt: 1.0.0 > sqlalchemy: 2.0.45 > SQLAlchemy: 2.0.45 > tenacity: 9.1.2 > tiktoken: 0.12.0 > typing-extensions: 4.15.0 > uuid-utils: 0.13.0 > zstandard: 0.25.0
yindo added the bugpending labels 2026-02-20 17:43:08 -05:00
yindo closed this issue 2026-02-20 17:43:08 -05:00
Author
Owner

@ahmedibrahimX commented on GitHub (Jan 13, 2026):

Hi! I'd like to help with this issue. I've investigated the root cause and have a proposed solution I'd like to run by you before implementing.

Root Cause

The issue occurs during Pydantic serialization. When State.model_dump() is called on a model with list[BaseMessage] type annotations, Pydantic treats this as a union and serializes each message using only the BaseMessage schema, which drops subclass-specific fields like tool_calls.

Why the behavior differs:

  • AIMessage.model_dump() → uses concrete AIMessage schema → all fields preserved
  • State(messages=[msg]).model_dump() → uses base BaseMessage schema → subclass fields lost

Proposed Solution

Modify JsonPlusSerializer in langgraph/checkpoint/serde/jsonplus.py to handle BaseMessage objects specially:

  1. In _msgpack_default(): Check for BaseMessage instances before the general Pydantic model check and serialize using the concrete message class
  2. In _msgpack_ext_hook(): Use LC_REVIVER during deserialization to properly reconstruct message objects with all fields

Benefits:

  • Fixes the issue without requiring changes to user code
  • Preserves backward compatibility
  • Graceful fallback if langchain_core isn't available
  • Works for any State schema with message lists

Would this approach work for you? Happy to share a draft PR or adjust based on your feedback!

@ahmedibrahimX commented on GitHub (Jan 13, 2026): Hi! I'd like to help with this issue. I've investigated the root cause and have a proposed solution I'd like to run by you before implementing. ## Root Cause The issue occurs during Pydantic serialization. When `State.model_dump()` is called on a model with `list[BaseMessage]` type annotations, Pydantic treats this as a union and serializes each message using only the `BaseMessage` schema, which drops subclass-specific fields like `tool_calls`. **Why the behavior differs:** - `AIMessage.model_dump()` → uses concrete `AIMessage` schema → all fields preserved - `State(messages=[msg]).model_dump()` → uses base `BaseMessage` schema → subclass fields lost ## Proposed Solution Modify `JsonPlusSerializer` in `langgraph/checkpoint/serde/jsonplus.py` to handle `BaseMessage` objects specially: 1. **In `_msgpack_default()`**: Check for `BaseMessage` instances before the general Pydantic model check and serialize using the concrete message class 2. **In `_msgpack_ext_hook()`**: Use `LC_REVIVER` during deserialization to properly reconstruct message objects with all fields **Benefits:** - [x] Fixes the issue without requiring changes to user code - [x] Preserves backward compatibility - [x] Graceful fallback if `langchain_core` isn't available - [x] Works for any State schema with message lists Would this approach work for you? Happy to share a draft PR or adjust based on your feedback!
Author
Owner

@ahmedibrahimX commented on GitHub (Jan 13, 2026):

I've implemented this solution in PR #6685. The fix adds special handling for BaseMessage objects in both standalone and nested contexts (within Pydantic State objects). All tests passing and ready for review!

@ahmedibrahimX commented on GitHub (Jan 13, 2026): I've implemented this solution in PR #6685. The fix adds special handling for BaseMessage objects in both standalone and nested contexts (within Pydantic State objects). All tests passing and ready for review!
Author
Owner

@ahmedibrahimX commented on GitHub (Jan 14, 2026):

Hi @Thommy257,

I did a deep investigation into this issue and wanted to share what I found.

The Good News

LangGraph’s checkpoint system already handles this correctly, tool_calls are preserved when using MemorySaver and other checkpoint savers. I tested this with State defined as both a TypedDict and a Pydantic BaseModel, and both work as expected.

What’s Causing the Behavior

The issue you’re seeing happens when state.model_dump() is called directly inside the node:

def agent_node(state: State) -> State:
    msg = agent.invoke(state.model_dump())
    return State(messages=[msg])

When Pydantic serializes a model containing list[BaseMessage], it uses the base class schema, which doesn’t include subclass-specific fields like tool_calls. This is expected Pydantic behavior and occurs outside LangGraph’s serialization path.

Recommended Pattern

Instead of calling model_dump() on the full state, let LangGraph pass message objects through directly:

def agent_node(state: State) -> State:
    msg = agent.invoke({"messages": state.messages})
    return State(messages=[msg])

This preserves message types and tool_calls.

Why This Happens

LangGraph’s checkpoint persistence uses JsonPlusSerializer, which has special handling to preserve message subclasses. Calling model_dump() directly skips that mechanism and relies purely on Pydantic’s base-class serialization.

PR Status

I opened PR #6685 initially, but closed it after realizing it addressed a different edge case (nested Pydantic data in custom checkpoint payloads). This issue is best solved through usage rather than a code change.

Hope this helps — happy to clarify or test further.

@ahmedibrahimX commented on GitHub (Jan 14, 2026): Hi @Thommy257, I did a deep investigation into this issue and wanted to share what I found. ### The Good News LangGraph’s checkpoint system already handles this correctly, `tool_calls` are preserved when using MemorySaver and other checkpoint savers. I tested this with State defined as both a TypedDict and a Pydantic BaseModel, and both work as expected. ### What’s Causing the Behavior The issue you’re seeing happens when `state.model_dump()` is called directly inside the node: ```python def agent_node(state: State) -> State: msg = agent.invoke(state.model_dump()) return State(messages=[msg]) ``` When Pydantic serializes a model containing `list[BaseMessage]`, it uses the base class schema, which doesn’t include subclass-specific fields like tool_calls. This is expected Pydantic behavior and occurs outside LangGraph’s serialization path. ### Recommended Pattern Instead of calling `model_dump()` on the full state, let LangGraph pass message objects through directly: ```python def agent_node(state: State) -> State: msg = agent.invoke({"messages": state.messages}) return State(messages=[msg]) ``` This preserves message types and tool_calls. ### Why This Happens LangGraph’s checkpoint persistence uses JsonPlusSerializer, which has special handling to preserve message subclasses. Calling `model_dump()` directly skips that mechanism and relies purely on Pydantic’s base-class serialization. ### PR Status I opened PR #6685 initially, but closed it after realizing it addressed a different edge case (nested Pydantic data in custom checkpoint payloads). This issue is best solved through usage rather than a code change. Hope this helps — happy to clarify or test further.
Author
Owner

@Thommy257 commented on GitHub (Jan 14, 2026):

Hi @ahmedibrahimX,

Thanks for your response. In my specific use case, State has numerous variables that I would potentially like to pass to the agent. I have to admit that I'm a bit lazy here, but keeping track of what variables need to be passed to the ChatPromptTemplate and which not seems a bit tedious.

I solved the issue by adding a shallow_dump method to my State class:

def shallow_dump(self, /) -> dict[str, Any]:
    """Get a shallow dictionary dump of the model, depth 1 only."""
    keys = chain(self.__class__.model_fields.keys(),
                 self.__class__.model_computed_fields.keys())
    return {k: getattr(self, k) for k in keys}
@Thommy257 commented on GitHub (Jan 14, 2026): Hi @ahmedibrahimX, Thanks for your response. In my specific use case, `State` has numerous variables that I would potentially like to pass to the agent. I have to admit that I'm a bit lazy here, but keeping track of what variables need to be passed to the ChatPromptTemplate and which not seems a bit tedious. I solved the issue by adding a `shallow_dump` method to my `State` class: ```python def shallow_dump(self, /) -> dict[str, Any]: """Get a shallow dictionary dump of the model, depth 1 only.""" keys = chain(self.__class__.model_fields.keys(), self.__class__.model_computed_fields.keys()) return {k: getattr(self, k) for k in keys} ```
Author
Owner

@ahmedibrahimX commented on GitHub (Jan 14, 2026):

Hi @Thommy257 , that makes total sense, especially when the state grows and you want a low-friction way to pass context through.

I like this approach! Conceptually, shallow_dump is closer to state projection than serialization, which avoids the Pydantic base-class issue while keeping the ergonomics you want.

As long as the receiving code can handle the actual message objects (rather than needing pre-serialized dictionaries), this feels like a clean and explicit solution. Thanks for sharing, I think others may run into the same tradeoff.

@ahmedibrahimX commented on GitHub (Jan 14, 2026): Hi @Thommy257 , that makes total sense, especially when the state grows and you want a low-friction way to pass context through. I like this approach! Conceptually, `shallow_dump` is closer to state projection than serialization, which avoids the Pydantic base-class issue while keeping the ergonomics you want. As long as the receiving code can handle the actual message objects (rather than needing pre-serialized dictionaries), this feels like a clean and explicit solution. Thanks for sharing, I think others may run into the same tradeoff.
Author
Owner

@Momo-Not-Emo commented on GitHub (Jan 29, 2026):

This could be fix by using SerializeAsAny from pydantic. I have found a similar issue in https://github.com/langchain-ai/langchain/issues/34925

from pydantic import SerializeAsAny

class State(BaseModel):
    messages: list[SerializeAsAny[BaseMessage]]
@Momo-Not-Emo commented on GitHub (Jan 29, 2026): This could be fix by using `SerializeAsAny` from pydantic. I have found a similar issue in https://github.com/langchain-ai/langchain/issues/34925 ```py from pydantic import SerializeAsAny class State(BaseModel): messages: list[SerializeAsAny[BaseMessage]] ```
Author
Owner

@mdrxy commented on GitHub (Jan 30, 2026):

Closing as this is not related to LangGraph. See the issue mentioned by @Momo-Not-Emo

@mdrxy commented on GitHub (Jan 30, 2026): Closing as this is not related to LangGraph. See the issue mentioned by @Momo-Not-Emo
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#1115