State¶
The state is the typed data a graph works on: nodes read it and return updates, and reducers decide how each update merges in.
from typing import Annotated
from pydantic import Field
from nodestep import END, START, BaseState, Graph, add, node
class Notes(BaseState):
topic: str = ""
lines: Annotated[list[str], add] = Field(default_factory=list)
@node
def write(state: Notes) -> dict:
return {"topic": "release", "lines": ["tag v0.1.0"]}
graph = Graph(Notes).flow(START >> write, write >> END)
print(graph.invoke({"lines": ["update the changelog"]}).state)
topic='release' lines=['update the changelog', 'tag v0.1.0']
The node returns a new topic and one line. topic has no reducer, so the update replaces it. lines uses add, so the line is appended to the input.
State types¶
A state type describes the fields of the state. It is one of:
BaseState, a pydanticBaseModelwith the default configuration, or any otherBaseModel- a
TypedDict dict, for untyped state
Nodes receive the state as the declared type. result.state is the declared type, and result.data is the same state as a dict.
from typing import TypedDict
from nodestep import END, START, Graph, node
class Counter(TypedDict):
count: int
@node
def increment(state: Counter) -> dict:
return {"count": state["count"] + 1}
graph = Graph(Counter).flow(START >> increment, increment >> END)
print(graph.invoke({"count": 1}).state)
{'count': 2}
The state holds only data that survives a JSON round trip. Clients and handles go in the run context.
Reducers¶
A reducer merges an update to a field with the field's current value. You set it with Annotated, as lines does above. nodestep has four built-in reducers:
replace: the update replaces the value. Fields without a reducer use it.add: the items of a list update are appended.add_messages: chat messages are appended, and a message with a known id is edited.merge_dict: the top-level keys of a dict update are merged.
The exact rules are in Rules and defaults, and the signatures in the reducers reference.
Custom reducers¶
A custom reducer is a function that takes the current value and the update, and returns the new value.
from typing import Annotated, TypedDict
from nodestep import END, START, Graph, node
def join_lines(current: str | None, update: str) -> str:
return update if current is None else f"{current}\n{update}"
class Log(TypedDict):
text: Annotated[str, join_lines]
@node
def first(state: Log) -> dict:
return {"text": "started"}
@node
def second(state: Log) -> dict:
return {"text": "finished"}
graph = Graph(Log).flow(START >> first, first >> second, second >> END)
print(graph.invoke({}).state["text"])
started
finished
The first write of a field that has no value yet passes None as the current value. Your reducer must handle it; nodestep's own reducers do.
Bypass a reducer¶
Replace(value) sets a field without its reducer. RemoveMessage(id) in an add_messages update removes that message.
from typing import Annotated, TypedDict
from nodestep import END, START, Graph, RemoveMessage, Replace, add_messages, node
from nodestep.chat import AIMessage, HumanMessage, Message
class Conversation(TypedDict):
messages: Annotated[list[Message], add_messages]
topics: list[str]
@node
def tidy(state: Conversation) -> dict:
oldest = state["messages"][0]
return {
"messages": [RemoveMessage(oldest.id), AIMessage(content="Hello")],
"topics": Replace(["greeting"]),
}
graph = Graph(Conversation).flow(START >> tidy, tidy >> END)
result = graph.invoke(
{"messages": [HumanMessage(content="hi"), HumanMessage(content="there")]}
)
print([message.content for message in result.state["messages"]])
print(result.state["topics"])
['there', 'Hello']
['greeting']
Both work in node updates, Command(update=...), graph.update_state(...) and run input. They do not work in Send payloads.
Validation¶
nodestep validates input and updates before it stores them. An invalid value raises StateUpdateError, and a value that does not survive a JSON round trip raises StateStoreError.
from typing import Any
from nodestep import (
END,
START,
BaseState,
Graph,
StateStoreError,
StateUpdateError,
node,
)
class Order(BaseState):
quantity: int = 0
note: Any = None
@node
def noop(state: Order) -> dict:
return {}
graph = Graph(Order).flow(START >> noop, noop >> END)
print(graph.invoke({"quantity": "7"}).state.quantity)
for bad_input in [{"qty": 1}, {"quantity": "seven"}, {"note": object()}]:
try:
graph.invoke(bad_input)
except (StateUpdateError, StateStoreError) as error:
print(type(error).__name__)
7
StateUpdateError
StateUpdateError
StateStoreError
Validation uses pydantic's lax mode, so "7" becomes 7. qty is not a field, "seven" is not a number, and an object() has no JSON form. Strict mode and the rules for stored values are in Rules and defaults.
Input of a run¶
The input of a run is merged into the state like an update. On a new thread, it is merged into the schema defaults. On an existing thread, it is a patch on the stored state; Persistence and time travel explains threads.
A dict sets only the fields it names. A model instance is the full state, so it replaces every field.
Rules and defaults¶
| Rule or setting | What happens |
|---|---|
BaseState |
A pydantic BaseModel with pydantic's default configuration |
dict as the state type |
Accepts any key |
TypedDict field that was never set |
Has no value |
| What the state holds | Only data that survives a JSON round trip; clients and handles go in context= |
| Field without a reducer | replace, the default reducer: the update replaces the value |
add |
Appends the items of a list update; wrap a single item in a list |
add_messages, message without an id |
The message gets a uuid4 hex id, on a copy; your object is not changed |
add_messages, known id |
A message with the same id replaces the existing one in place, so returning a message again edits it |
merge_dict |
Shallow: merges the top-level keys of a dict update. It cannot remove a key; use Replace(value) |
| First write of a field with no value | The reducer receives None on the first write, for example on an unset TypedDict field; nodestep's own reducers handle it |
| Two reducers on one field | GraphConfigError when the graph is created; so does a reducer inside Optional[...] instead of around it, or a callable without two positional parameters |
Replace and RemoveMessage |
Work in node updates, Command(update=...), graph.update_state(...) and run input, not in Send payloads |
| Invalid value | StateUpdateError, naming the field and the writer |
| Value that does not survive a JSON round trip | StateStoreError |
| Validation mode | pydantic's lax mode: "7" becomes 7 for an int field |
| Strict mode | Set model_config = ConfigDict(strict=True) on the state model, or @with_config(ConfigDict(strict=True)) on a TypedDict |
| What a field is validated with | Each field on its own, with its type, its Annotated metadata and the schema's pydantic config |
| How values are stored | Every value is stored as JSON through its field's type, with or without a state store, and must read back equal, with the same types |
| Aliases and computed fields | Stored models keep their aliases and leave out computed fields |
Fields with exclude=True and private attributes |
Not stored, so a model is refused while they hold anything but their defaults |
SecretStr |
Refused anywhere in a value; pass secrets through context= |
| A storage refusal | Names the place in the value and the types, never the values |
| Input on a new thread | Merged into the schema defaults |
| Input on an existing thread | A patch, merged into the stored state through the reducers |
| Dict input | Sets the fields it names |
| Model instance as input | The full state: it replaces every field, so passing result.state back changes nothing |
| Input keys that are not state fields | StateUpdateError |