State and reducers¶
State is the shared, typed data a graph operates on. It is a set of named channels, and each channel has a reducer — a pure function that folds a node's partial update into the current value. Reducers are the quiet centerpiece of AgentFlow: they are what make concurrent updates in one super-step merge deterministically instead of racing on last-writer-wins.
Declaring a schema¶
A state schema is a TypedDict subclass of State. Each field is a channel; its annotation carries the reducer.
from typing import Annotated
from agentflow import State, add, append, last
class ChatState(State):
messages: Annotated[list, append] # concatenate updates
tokens: Annotated[int, add] # sum updates
best: Annotated[float, last] # overwrite (the default)
title: str # bare field -> last-wins
A field annotated Annotated[type, reducer] uses that reducer. A bare field uses last (last-value-wins). Because State is total=False, every channel is optional at the type level — nodes emit partial updates, and a channel simply has no value until first written.
The built-in reducers¶
| Reducer | Folds (current, update) into |
None current is |
|---|---|---|
last |
the update | — (the default) |
append |
current + update (a list) |
empty list |
add |
current + update |
starts from the update |
merge |
{**current, **update} |
empty dict |
union |
current | update (a set) |
empty set |
append extends when the update is a list or tuple, and appends a single element otherwise. Any pure (current, update) -> new is a valid reducer — the built-ins are just the common ones. A reducer must be pure and must treat a None current as its empty value.
Structured channels with by_key¶
merge folds a whole dict with one policy (last-wins). When one dict channel holds fields that must accumulate differently, by_key gives each field its own reducer:
from agentflow import State, append, by_key, last
from typing import Annotated
search = by_key({
"intents": append, # grow
"candidates": append, # grow (concurrent workers each add one)
"events": last, # replace
"incumbent": last, # overwrite the current best, whole
})
class OptState(State):
search: Annotated[dict, search]
A node writes the fields it changes, and each is folded by that field's reducer. {"search": {"candidates": [c]}} appends one candidate; {"search": {"events": []}} clears events. Fields a node does not write are left untouched, so two nodes in one super-step writing different fields never clobber each other.
Three rules worth stating plainly:
- A field in the update that is not in the policy raises
KeyError. Writing an undeclared field is a bug, just like writing an undeclared top-level channel. - Each field's value is folded whole by its reducer. This is one level of structure, not a recursive deep-merge:
lastonincumbentreplaces the whole object, it does not patch one of its keys. - Use
by_keyfor distinct fields that accumulate differently, not for concurrent partial patches of the same nested object.
This is the channel behind a search or optimization loop, where a planner appends intents, concurrent workers append candidates, and a verifier replaces events and overwrites the incumbent whole. See the dual-state search recipe and examples/dual_state_search.py.
Nodes return deltas, not absolutes¶
A node returns a partial update — a dict of channel names to values — and the engine folds each value through that channel's reducer:
Under the reducers above, {"messages": [reply]} appends to the transcript and {"tokens": 42} adds 42 to the running count. The node never writes an absolute value or mutates state; it describes a change and lets the reducer apply it.
Why reducers, not assignment¶
When more than one node runs in the same super-step, several updates land on the state at once. If channels were plain variables, the result would depend on who wrote last. With reducers, the combination is a function you chose: two nodes each appending to messages produce both messages in a deterministic order; two nodes each adding to tokens produce the sum, regardless of order. Commutative reducers (add, union) don't depend on order at all; non-commutative ones (append, last) are applied in the runtime's deterministic node order.
Rules the engine enforces¶
- Nodes may only write declared channels. Writing an undeclared channel raises immediately rather than sitting silently in state — a typo fails fast.
- Channel names starting with
__, and the reserved names__stepand__next, belong to the engine. A schema or update that uses them is rejected. - Input validation happens up front:
invoke({...})rejects input keys that are not declared channels before the run starts.
State is pure data — no async, no I/O, no engine imports — so a schema can be understood and tested on its own.
Next: nodes and edges, which turn a state schema into a running graph.