Skip to content

Nodes and context

A node is a function, async def or plain def, decorated with @node: it receives the state and returns an update.

from nodestep import END, START, BaseState, Graph, NodeContext, node


class Note(BaseState):
    note: str = ""


@node
def stamp(state: Note, ctx: NodeContext) -> dict:
    return {"note": f"{ctx.node} ran in step {ctx.step}"}


graph = Graph(Note).flow(START >> stamp, stamp >> END)

print(graph.invoke({}).state.note)
stamp ran in step 0

stamp reads the run information from its second parameter and returns a dict with the field it changes.

What a node receives

The state comes first, as the declared type. A parameter annotated NodeContext, under any name, gets the run information:

Attribute Meaning
ctx.graph_name, ctx.node Names of the graph and of the node
ctx.run_id, ctx.root_run_id Id of this run, and of the outermost run when the graph runs inside another one
ctx.thread_id, ctx.branch_id Thread and history branch of the run
ctx.step, ctx.task_id Superstep, counted from 0, and the id of this task
ctx.context The context= object of the run, see Run context
ctx.emit(data) Sends a "custom" stream event, see Streaming
ctx.cache A dict that survives a pause of this task, see What happens on resume
ctx.spawn, ctx.gather, ctx.submit Start sub-agents and wait for them, see Sub-agents

What a node returns

A node returns an update or a routing decision:

Return value Meaning
a dict An update: the fields it names are merged into the state through their reducers
the state object it received The node changed the state in place; nodestep stores the difference
Command(update=..., goto=...) An update and the next targets
Send(node, payload) Run one node with its own input
END End the run after this node
None No update

Both styles end up as an update:

from nodestep import END, START, BaseState, Graph, node


class Cart(BaseState):
    items: list[str] = []
    count: int = 0


@node
def add_item(state: Cart) -> Cart:
    state.items.append("book")
    return state


@node
def count_items(state: Cart) -> dict:
    return {"count": len(state.items)}


graph = Graph(Cart).flow(
    START >> add_item,
    add_item >> count_items,
    count_items >> END,
)

print(graph.invoke({"items": ["pen"]}).state)
items=['pen', 'book'] count=2

add_item changes the state in place, and count_items returns a dict. Pick one style per node: changing the state and returning a dict raises an error.

Run context

The run context holds what nodes need but the state must not keep, such as settings, API clients and database handles. You pass it as context= on invoke, ainvoke, stream or astream. Nodes read it as ctx.context, and tools as ToolContext.context.

from dataclasses import dataclass

from nodestep import (
    END,
    START,
    BaseState,
    ContextNotProvidedError,
    Graph,
    NodeContext,
    node,
)


@dataclass
class Pricing:
    tax_rate: float


class Invoice(BaseState):
    net: float = 0.0
    gross: float = 0.0


@node
def add_tax(state: Invoice, ctx: NodeContext) -> dict:
    pricing: Pricing = ctx.context
    return {"gross": round(state.net * (1 + pricing.tax_rate), 2)}


graph = Graph(Invoice).flow(START >> add_tax, add_tax >> END)

print(graph.invoke({"net": 100}, context=Pricing(tax_rate=0.2)).state.gross)
try:
    graph.invoke({"net": 100})
except ContextNotProvidedError as error:
    print(type(error).__name__)
120.0
ContextNotProvidedError

The second run passes no context, so reading ctx.context raises ContextNotProvidedError. The context is not part of the thread: it is never stored, so pass it again on resume.

Command and Send

Command(update=..., goto=...) returns an update and the next targets. Send(node, payload) runs a node with the payload laid over the state, for that task only. Several Sends in one Command fan out: one task per payload, in the next superstep.

from typing import Annotated

from pydantic import Field

from nodestep import END, START, BaseState, Command, Graph, Send, add, node


class Research(BaseState):
    topics: list[str] = Field(default_factory=list)
    topic: str = ""
    notes: Annotated[list[str], add] = Field(default_factory=list)


@node
def research(state: Research) -> dict:
    return {"notes": [f"notes on {state.topic}"]}


@node(goto=[research])
def fan_out(state: Research) -> Command:
    return Command(goto=[Send(research, {"topic": topic}) for topic in state.topics])


graph = Graph(Research).flow(
    START >> fan_out,
    research >> END,
)

result = graph.invoke({"topics": ["solar", "wind"]})
print(result.state.notes)
print(repr(result.state.topic))
['notes on solar', 'notes on wind']
''

Each research task saw its own topic. The payload is not stored, so topic is empty after the run. Fan out with Send shows the pattern step by step.

Timeouts

@node(timeout=seconds) limits one run of the node. When the time is up, the node fails with NodeTimeoutError.

import asyncio

from nodestep import END, START, BaseState, Graph, NodeTimeoutError, node


class Lookup(BaseState):
    answer: str = ""


@node(timeout=0.1)
async def slow_lookup(state: Lookup) -> dict:
    await asyncio.sleep(5)
    return {"answer": "found"}


graph = Graph(Lookup).flow(START >> slow_lookup, slow_lookup >> END)

try:
    graph.invoke({})
except NodeTimeoutError as error:
    print(error)
Node 'slow_lookup' timed out after 0.1s

Rules and defaults

Rule or setting What happens
Plain def node Runs in a worker thread
Bound method Works as a node
Callable instances, classes and functools.partial objects TypeError
NodeContext parameter Found by its annotation, under any name
ctx.step Counted from 0
ctx.task_id The node name, then name[1], name[2] for further tasks of the node in the same superstep
Return value not in the table Any other value raises TypeError
Changing the input and returning a dict, Command, Send, END or None GraphExecutionError
In-place change of an add or add_messages field The update holds the appended items
In-place change of a merge_dict field The update holds the added or changed keys
Any other in-place change of those fields Replaces the whole field
In-place change of a field with a custom reducer GraphExecutionError
State copies Each task gets its own copy of the state
context= Passed on invoke, ainvoke, stream or astream; read as ctx.context in nodes and ToolContext.context in tools
ctx.context when no context was passed ContextNotProvidedError
Storage of the run context It is never stored: pass context= again on resume
Several Sends in one Command One task per payload in the next superstep
Send targets Every target is declared in @node(goto=[...])
Send payload Laid over the state for that task only; it skips the reducers and is not stored
Send payload keys Must be state fields with valid values
A pydantic model as Send payload Contributes only the fields set on it
@node(timeout=...) None: no limit. A run of the node that takes longer fails with NodeTimeoutError
Async node at the timeout Cancelled at the limit
Sync node at the timeout Its worker thread cannot be stopped, so its error is raised when the function returns
Resumed node Runs again from the top
ctx.cache Keeps what the node stored before the pause. It is saved with the paused task, normalized to JSON, and empty in every other task and in later runs of the node