[PR #6886] fix: support Pydantic default values with reducer functions #5379

Open
opened 2026-02-20 17:51:37 -05:00 by yindo · 0 comments
Owner

Original Pull Request: https://github.com/langchain-ai/langgraph/pull/6886

State: open
Merged: No


Summary

Fixes #5225

When a Pydantic BaseModel state field has both a default value (Field(default=...) or Field(default_factory=...)) and a reducer function (Annotated[type, reducer]), the default value was being ignored. The channel was always initialized with the type's zero-argument constructor (e.g., [] for list, 0 for int).

Root Cause

BinaryOperatorAggregate.__init__ always initialized its value by calling typ() (the bare type constructor). It had no mechanism to receive or use schema-defined default values. While _get_channels and _get_channel had partial plumbing for passing the schema parameter, _is_field_binop didn't use it and BinaryOperatorAggregate didn't accept a default parameter.

Changes

  • _fields.py: Added Pydantic BaseModel support to get_field_default() to extract Field(default=...) and Field(default_factory=...) values via model_fields
  • binop.py: Added default parameter to BinaryOperatorAggregate.__init__(). When provided, uses it instead of typ(). Uses copy.deepcopy to prevent mutable default sharing. Preserves defaults through copy() and from_checkpoint()
  • state.py: Connected the schema context through _get_channels_get_channel_is_field_binop so field defaults are resolved and passed to BinaryOperatorAggregate

Example

from pydantic import BaseModel, Field
from typing import Annotated
import operator
from langgraph.graph import StateGraph, START, END

class State(BaseModel):
    messages: Annotated[list[str], operator.add] = Field(
        default_factory=lambda: ["welcome"]
    )
    count: Annotated[int, operator.add] = Field(default=100)

def node(state):
    return {"messages": ["hello"], "count": 5}

graph = StateGraph(State)
graph.add_node("process", node)
graph.add_edge(START, "process")
graph.add_edge("process", END)

result = graph.compile().invoke({})
# result["messages"] == ["welcome", "hello"]  ✅
# result["count"] == 105                       ✅

Test plan

  • Unit test for BinaryOperatorAggregate with default values (test_binop_with_default)
  • Integration test: default_factory with custom reducer (exact issue scenario)
  • Integration test: static Field(default=...) with operator.add
  • Integration test: default + user-provided initial input
  • Integration test: mutable default safety across multiple invocations
  • Integration test: backward compat - no default still works
  • Integration test: mixed fields with/without defaults
  • All existing tests pass (33/33 in test_channels, test_pydantic, test_state)
**Original Pull Request:** https://github.com/langchain-ai/langgraph/pull/6886 **State:** open **Merged:** No --- ## Summary Fixes #5225 When a Pydantic `BaseModel` state field has both a default value (`Field(default=...)` or `Field(default_factory=...)`) and a reducer function (`Annotated[type, reducer]`), the default value was being ignored. The channel was always initialized with the type's zero-argument constructor (e.g., `[]` for `list`, `0` for `int`). ### Root Cause `BinaryOperatorAggregate.__init__` always initialized its value by calling `typ()` (the bare type constructor). It had no mechanism to receive or use schema-defined default values. While `_get_channels` and `_get_channel` had partial plumbing for passing the `schema` parameter, `_is_field_binop` didn't use it and `BinaryOperatorAggregate` didn't accept a `default` parameter. ### Changes - **`_fields.py`**: Added Pydantic `BaseModel` support to `get_field_default()` to extract `Field(default=...)` and `Field(default_factory=...)` values via `model_fields` - **`binop.py`**: Added `default` parameter to `BinaryOperatorAggregate.__init__()`. When provided, uses it instead of `typ()`. Uses `copy.deepcopy` to prevent mutable default sharing. Preserves defaults through `copy()` and `from_checkpoint()` - **`state.py`**: Connected the schema context through `_get_channels` → `_get_channel` → `_is_field_binop` so field defaults are resolved and passed to `BinaryOperatorAggregate` ### Example ```python from pydantic import BaseModel, Field from typing import Annotated import operator from langgraph.graph import StateGraph, START, END class State(BaseModel): messages: Annotated[list[str], operator.add] = Field( default_factory=lambda: ["welcome"] ) count: Annotated[int, operator.add] = Field(default=100) def node(state): return {"messages": ["hello"], "count": 5} graph = StateGraph(State) graph.add_node("process", node) graph.add_edge(START, "process") graph.add_edge("process", END) result = graph.compile().invoke({}) # result["messages"] == ["welcome", "hello"] ✅ # result["count"] == 105 ✅ ``` ## Test plan - [x] Unit test for `BinaryOperatorAggregate` with default values (`test_binop_with_default`) - [x] Integration test: `default_factory` with custom reducer (exact issue scenario) - [x] Integration test: static `Field(default=...)` with `operator.add` - [x] Integration test: default + user-provided initial input - [x] Integration test: mutable default safety across multiple invocations - [x] Integration test: backward compat - no default still works - [x] Integration test: mixed fields with/without defaults - [x] All existing tests pass (33/33 in test_channels, test_pydantic, test_state)
yindo added the pull-request label 2026-02-20 17:51:37 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: langchain-ai/langgraph#5379