Task: tasks/41-custom-state-field-reducers.md GitHub Issue: #41
StateFieldDefinsherma/langgraph/declarative/schema.pyhas three fields:name,type,default_build_state_class()insherma/langgraph/declarative/agent.pychecks if a field namedmessagesexists:- If yes: uses
MessagesStateas base class (providesadd_messagesappend reducer), skips themessagesfield annotation, adds all other fields as plain annotations (no reducer = replace) - If no: builds a plain
TypedDict(all fields use replace)
- If yes: uses
- There is no way to:
- Have a list field with append semantics other than
messages - Have the
messagesfield use replace semantics - Have multiple list fields with different reducer behaviors
- Have a list field with append semantics other than
LangGraph uses typing.Annotated to attach reducers to state fields:
from typing import Annotated
from operator import add
from langgraph.graph import add_messages
class MyState(TypedDict):
messages: Annotated[list, add_messages] # append reducer
items: Annotated[list, add] # simple list concat
summary: list # replace (no annotation)add_messages— LangGraph's smart message reducer (deduplicates by ID, handlesRemoveMessage)operator.add— simple list concatenation- No annotation — replace semantics (default)
For this feature, reducer: append on a list field should use add_messages (matching current messages behavior), and reducer: replace should use no annotation.
The issue specifies "LangGraph's add-message reducer" for append. This is the most useful behavior since list fields in agent state are predominantly message lists. Using operator.add would be a weaker default. We'll use add_messages for reducer: append.
File: sherma/langgraph/declarative/schema.py
- Add an optional
reducerfield:reducer: Literal["append", "replace"] | None = None Nonemeans "use default" —appendformessages,replacefor everything else- Add a validator: if
reduceris set on a non-list field, either ignore it silently or raise a validation error (prefer: ignore silently with a note in docs, matching AC: "Non-list fields ignore thereducersetting")
File: sherma/langgraph/declarative/agent.py
Current logic:
- If
messagesfield exists → useMessagesStatebase → all other fields get plain annotations - If no
messagesfield →TypedDictwith plain annotations
New logic:
- Determine effective reducer for each field:
field.reducerif explicitly set"append"iffield.name == "messages"andfield.type == "list"(backward compat)"replace"otherwise
- Build annotations dict:
- Fields with
reducer == "append"andtype == "list":Annotated[list, add_messages] - All other fields: plain type (replace semantics)
- Fields with
- No longer use
MessagesStateas base class. Instead, always build aTypedDictwith explicit annotations. This is cleaner and gives us full control.- The
messagesfield withreducer: appendgetsAnnotated[list, add_messages]— functionally equivalent toMessagesState - This removes the special-casing of
MessagesStateentirely
- The
Important: Verify that switching from MessagesState subclass to TypedDict with Annotated[list, add_messages] doesn't break any existing behavior. MessagesState is defined as:
class MessagesState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]So using Annotated[list, add_messages] on a TypedDict field is functionally identical.
File: tests/langgraph/declarative/test_schema.py
- Test
StateFieldDefwithreducerfield - Test default reducer resolution (messages → append, others → replace)
- Test that
reduceron non-list fields is ignored
File: tests/langgraph/declarative/test_agent.py
- Add a test with a list field using
reducer: replacealongsidemessagesusingreducer: append - Verify that the replace-list field overwrites (not appends) on state update
- Verify that the messages field still appends
File: docs/declarative-agents.md
- Update "State Schema" section to document the
reducerfield - Add example showing
reducer: appendandreducer: replaceon different list fields - Note that non-list fields always use replace regardless of
reducersetting - Note backward compatibility:
messagesdefaults toappend
File: skills/sherma/references/declarative-agents.md
- Mirror the same docs updates
File: skills/sherma/SKILL.md
- Update quick reference if state schema is mentioned there
class StateFieldDef(BaseModel):
"""A single field in the agent state schema."""
name: str
type: str = "str"
default: Any = None
reducer: Literal["append", "replace"] | None = Nonedef _build_state_class(agent_def, *, has_skills=False) -> type:
from typing import Annotated, TypedDict
from langgraph.graph import add_messages
inject_internal = _needs_internal_state(agent_def, has_skills=has_skills)
fields = agent_def.state.fields
td_fields: dict[str, Any] = {}
for field_def in fields:
py_type = _TYPE_MAP.get(field_def.type, str)
# Determine effective reducer
effective_reducer = field_def.reducer
if effective_reducer is None:
if field_def.name == "messages" and field_def.type == "list":
effective_reducer = "append"
else:
effective_reducer = "replace"
# Apply annotation for append reducer on list fields
if effective_reducer == "append" and field_def.type == "list":
td_fields[field_def.name] = Annotated[py_type, add_messages]
else:
td_fields[field_def.name] = py_type
if inject_internal:
td_fields[INTERNAL_STATE_KEY] = dict
return TypedDict("DynamicState", td_fields)- Existing YAML without
reducerfield:messageslist →append(same as before), all others →replace(same as before) - No breaking changes
| Risk | Mitigation |
|---|---|
Switching from MessagesState base to TypedDict + Annotated could subtly change behavior |
Both are TypedDict-based; MessagesState is just a TypedDict with Annotated[list, add_messages]. Run all existing tests to verify. |
add_messages on non-messages list fields may behave unexpectedly if items aren't LangChain messages |
Document that reducer: append uses LangGraph's message reducer and is designed for message lists. |
Users may expect append to do simple list concatenation |
Document clearly that append uses add_messages (smart dedup by ID). |
- Unit: Schema validation —
StateFieldDefacceptsreducer: "append","replace", orNone - Unit: Default resolution —
messageslist defaults to append, others default to replace - Integration: Replace list — Agent with
reducer: replacelist field; verify overwrite on update - Integration: Append messages — Existing
messagesbehavior unchanged - Integration: Explicit append on non-messages — A list field with
reducer: appendaccumulates - Regression — All existing tests pass unchanged