agentsope/agentsop-state-reducer
| Tool skill for declaring reducers on LangGraph state keys so parallel writes merge instead of crashing. Activates whenever a coder agent designs a StateGraph with parallel branches, fan-out via Send, multi-agent topologies, one value per step`. Encodes the rule "every state key is single-writer or has a reducer — nothing in between."
npx skills add https://github.com/agentsope/SkillAlchemy --skill agentsop-state-reducer
> Scope: a single decision — for each state key, declare a reducer or
> guarantee single-writer. Out of scope: checkpointers, HITL, supervisor vs.
> swarm — see langgraph-sop for those.
Activate when any trigger fires:
StateGraph, TypedDict/BaseModel schema,or Annotated[..., <reducer>] typing.
Send API,multiple agents writing shared state, or a supervisor pattern where workers
return concurrently.
langgraph.errors.InvalidUpdateError — message looks likeAt key 'messages': Can receive only one value per step. Use an Annotated key to handle multiple values.
or "do I need a reducer here?".
Do not activate if every key is written by exactly one node per superstep
(see §6: over-reducing single-writer keys is an anti-pattern).
**LangGraph state is either single-writer or has a reducer. Nothing in
between.**
When a node returns {"k": v}, LangGraph must decide how to merge v into
existing state["k"]. There are exactly two legal regimes:
Annotated). At most onenode writes the key per superstep. The new value *replaces* the old.
Two concurrent writers → InvalidUpdateError.
(Annotated[T, reducer_fn]). Any number of writers may write per
superstep; LangGraph folds them via reducer_fn(current, new).
The reducer is *commutative-enough* algebra that lets the engine schedule
parallel writes without you reasoning about interleavings. Three canonical
reducers cover ~90% of real graphs:
| Reducer | Type | Behaviour |
|---|---|---|
| add_messages (from langgraph.graph.message) | list[BaseMessage] | Append; dedupe-and-update by message id (in-place edit when IDs match) |
| operator.add | list, int, float, str | List concat / numeric sum |
| Custom (curr, new) -> merged | anything | Domain-specific merge (keep-latest, dedupe-by-id, LLM-summarise) |
> "Reducers are mandatory, not optional, for parallel execution."
> — [cheatsheet/gotchas]
The add_messages quirk that beginners miss: it is not plain append. If
a new message shares an id with one in state, it overwrites in place. This
is what makes HITL interrupt() + edit-state work — a human can edit the
last AI turn and resume.
The decision is per-key, not per-schema. A schema can mix freely: one key
single-writer, another with add_messages, another with operator.add.
Run top-down for every new or modified state schema.
For each key k in the state schema, list every node that returns k in its
update dict. Be paranoid: include nodes spawned via Send, subgraphs whose
output schema overlaps the parent, and any conditional branches.
k: T.swarm handoffs) → must declare a reducer. Plain k: T will crash.
*optional*: without one, each later write overwrites; with one, each write
folds in. Choose based on intent.
> Decision gate: if you cannot answer "which nodes write this key?" in one
> sentence per key, stop and redraw the graph before continuing.
Use the OP table in §4. Default ladder:
BaseMessage → add_messages.operator.add.operator.add.Before shipping, write a unit test that invokes the graph along the parallel
path with deterministic node outputs and asserts state matches expectation.
If the reducer is wrong (e.g., operator.add on dicts), this is where you
find out — not in production.
def test_parallel_merge():
out = graph.invoke({"items": []})
assert sorted(out["items"]) == ["a", "b"] # both workers' contributions present
In a docstring on the TypedDict / Pydantic model, note for each non-default
key *why* it has a reducer. Future maintainers will thank you when they
refactor.
Each op: Trigger → Action → Output.
add_messages for a chat history keymessages: list[BaseMessage] field; ≥1 of (HITLedit-state, multi-agent that all append turns, ReAct loop).
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
class S(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
in-place (enables HITL state edits).
operator.add for an accumulating listlist (e.g., one chunk per worker in a map step).
import operator
from typing import Annotated, TypedDict
class S(TypedDict):
chunks: Annotated[list[str], operator.add]
guaranteed** — if you need ordering, attach an index to each item and sort
downstream.
operator.add for a counterbranches.
retries: Annotated[int, operator.add]. Each node returns the*delta* ({"retries": 1}), not the new total.
{"retries": state["retries"]+1})with operator.add double-counts. Always return deltas under operator.add.
the key.
current_task: str.appears in the same superstep, the graph crashes loudly — which is the
desired safety signal.
summary field; you wantwhichever was generated more recently to win.
from datetime import datetime
Summary = dict # {"text": str, "ts": datetime}
def keep_latest(curr: Summary | None, new: Summary) -> Summary:
if curr is None or new["ts"] >= curr["ts"]:
return new
return curr
class S(TypedDict):
summary: Annotated[Summary, keep_latest]
first.
{"id": ..., ...}documents and the union should be de-duplicated.
def dedupe_by_id(curr: list[dict], new: list[dict]) -> list[dict]:
seen = {d["id"]: d for d in (curr or [])}
for d in new:
seen[d["id"]] = d # later wins on collision
return list(seen.values())
class S(TypedDict):
docs: Annotated[list[dict], dedupe_by_id]
testable.
InvalidUpdateError post-mortemInvalidUpdateError: At key '<k>': Can receive only one value per step. Use an Annotated key to handle multiple values.
<k> in the schema, identify the concurrentwriters, then pick OP-1/2/3/5/6 based on key semantics.
Step 4 probe test).
messages — what reducer?"agent emit {"messages": [AIMessage(...)]} in the same superstep when the
supervisor fans out. Without a reducer → InvalidUpdateError. Naive
operator.add works but loses HITL edit-in-place semantics.
own timestamp), but duplicates from retries must collapse.
operator.add — it concatenates blindly; retried messages withthe same id would appear twice and break the chat UI.
add_messages — explicitly designed for this. Append by default,overwrite in place when id matches [lc-docs/messages].
ids in each agent (e.g., uuid4()) so dedupe isdeterministic. If an agent retries, reuse the previous attempt's id.
resume" works, retried turns dedupe themselves.
add_messagesis almost always the right answer — operator.add is a code smell on
message lists.**
summary — append-merge-or-replace?"summarystring for the same document, in parallel. With no reducer → crash. With
operator.add on strings → concatenation glues them edge-to-edge ("A.B."
not "A. B."). Neither is semantically right.
"synthesise into one paragraph".
single-writer node calls the LLM once to merge:
def collect(curr: list[str] | None, new: str) -> list[str]:
return (curr or []) + [new]
class S(TypedDict):
summary_drafts: Annotated[list[str], collect]
summary: str # single-writer, set by merge_node
merge_node readsstate["summary_drafts"], runs one LLM call, writes
{"summary": "..."} (single-writer, no reducer needed).
extra LLM call.
mergeable values, don't force a reducer — collect into a list with
operator.add / a custom collector, then merge in a single-writer node
downstream.** Reducers are for trivial folds; LLM merges belong in nodes.
Send — do I still need a reducer?"[Send("worker", {"chunk": c}) for c in chunks].Each worker writes {"results": [...]}. Schema is
results: list[dict] — does Send change reducer requirements?
Send IS parallel writing. All workers complete in the samesuperstep and merge back into parent state.
Annotated[list[dict], operator.add] on results → crash onthe *first* run where >1 worker is spawned. (Schema looks fine in tests
with chunks=[c1] — fails in prod with chunks=[c1,c2].)
results: Annotated[list[dict], operator.add].Send ≡ parallel writers, always. Any state key aSend-target worker writes must have a reducer, even if the static graph
topology "looks" sequential.**
fine; tests pass with 1 worker; the first 2-chunk request in production
raises InvalidUpdateError. *Fix*: enumerate writers per key during
design (§3 Step 1), not after the page.
add_messages on non-message lists. It calls convert_to_messagesinternally and will either raise or silently corrupt your data when items
aren't BaseMessage subclasses. *Use operator.add or a custom reducer
for list[dict], list[str], list[Document].*
Annotated[str, lambda a,b: b] "just in case" on a key only the supervisor
writes is noise — and worse, it *hides* future bugs by silently accepting
a second writer that should have raised. *Leave single-writer keys
un-annotated; let the engine surface accidental concurrency.*
operator.add. Withretries: Annotated[int, operator.add], returning
{"retries": state["retries"] + 1} adds the *new total* to the old —
double-counting. *Return the delta ({"retries": 1}).*
Send is parallel. See Case 3. Any key writtenby a Send-target is a parallel-write key.
defined order. add_messages and operator.add are order-insensitive for
the *set* of items but the resulting list order is not guaranteed across
runs. *If you need a canonical order, sort downstream with an explicit key
(timestamp, branch index, etc.).*
of (current, new). Reading external state (DB, time.now() inside the
reducer) breaks checkpoint replay and time-travel debugging. *Push side
effects into nodes, never reducers.*
curr in place inside a custom reducer. Return a newvalue. Mutating the existing object can corrupt earlier checkpoints that
share the reference. *Always construct and return a fresh container.*
Hard boundary: reducers are a *write-merge* mechanism, not a
*read-consistency* mechanism. If you need transactional read-modify-write
across parallel nodes (e.g., "increment counter only if branch A succeeded"),
that's a routing problem — sequence the nodes, don't smuggle it into a
reducer.
The reducer pattern is not LangGraph's invention — it's a re-application of
two well-known patterns. Recognizing the lineage helps onboard fast.
| Framework | Analog | Same idea | Different |
|---|---|---|---|
| LangGraph | Annotated[T, reducer] | Pure (curr, new) -> merged fold over parallel writes per superstep | Per-key declaration; runs inside a checkpointable state machine |
| Redux / Elm | reducer(state, action) -> state | Pure fold over an action stream, single source of truth | Single root reducer over a stream; LangGraph has per-key reducers over a superstep set |
| CRDTs (Conflict-Free Replicated Data Types) | G-Counter, OR-Set, LWW-Register | Commutative+associative merge of concurrent writes from distributed replicas | CRDTs assume eventually-consistent replicas; LangGraph reducers run inside one process at superstep boundaries — no network partitions, but the algebra rhymes |
| Pregel / BSP | message combiners between supersteps | Merge multiple incoming messages per vertex per superstep | LangGraph's direct ancestor — add_messages is literally a combiner |
| CrewAI | none (no shared typed state) | — | Task outputs flow sequentially via context; no parallel write merge to declare. *That's why CrewAI is simpler — and why it can't express what LangGraph reducers express.* |
| AutoGen | conversation log | Append-only message history | Single ordered log, not a typed multi-key state — no need for per-key reducers, but no parallel-write-on-typed-fields either |
The Redux insight that transfers cleanly: **reducers must be pure, and that
purity is what enables replay/time-travel.** LangGraph's checkpoint replay
relies on exactly this. If you've internalised Redux discipline, the
LangGraph reducer rules are the same rules with a graph-shaped scope.
The CRDT insight that transfers cleanly: **commutative merge frees you from
reasoning about order.** operator.add on lists isn't strictly commutative
([a]+[b] != [b]+[a] as lists), but you should *treat it as if it were* and
sort downstream when order matters. Designing reducers to be
order-insensitive is the cheapest way to keep parallel graphs sane.
[cheatsheet/gotchas] = sumanmichael.github.io/langgraph-cheatsheet/cheatsheet/faqs-gotchas/— "Reducers are mandatory, not optional, for parallel execution."
[lc-docs/messages] = docs.langchain.com/oss/python/langgraph/ — add_messages "If a message with the same ID already exists, it updates that message in place rather than duplicating it."[aipractitioner/scaling] = aipractitioner.substack.com/p/scaling-langgraph-agents-parallelization— parallel branch failure semantics.
langgraph-sop for the full LangGraph SOP (state schemadesign, checkpointers, HITL, multi-agent topologies). This skill is the
zoom-in on reducers only.
Take agentsope/agentsop-state-reducer from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.