State
What gets remembered in Liman - multi-turn conversations, persistence, and crash recovery
This page covers persistence. For how execution works, see Execution Model.
What state means in Liman
Every node owns its state independently. A NodeActor is created for each node and holds that node's state for the lifetime of the session:
- LLMNode state is the message history - every message sent and received.
- ToolNode state is the last tool call input and output.
State is saved to StateStorage after every node execution. Two things are
persisted: the actor state (what the node knows) and the executor state
(where in the graph execution is). Both allow serializing and deserializing the
full agent, enabling suspension, restoration, and async execution.
Context is not state
context is a third concept that looks similar but is not persisted. It is
call-time data passed by the caller at every agent.step():
await agent.step("Hello", context={"user_id": "u-123", "org_id": "o-456"})Context flows through the execution tree during that call - available to nodes
for CE DSL edge evaluation and credential injection via ServiceAccount - but
it is never written to StateStorage. When the call ends, context is gone.
If a node needs to remember something from context across turns (for example,
the current user ID), it must store it explicitly in its node_state. You can
also persist context yourself - for example, by saving it alongside the
execution ID in your own storage and re-passing it on the next agent.step()
call.
StateStorage does not enforce any access control by default. In a multi-user
system, a custom implementation must apply fine-grained permissions - for
example, Row Level Security (RLS) or ownership checks - to ensure that loading
state by execution_id never returns data belonging to another user.
Actor state
Owned by NodeActor, specific to the node type:
class NodeActorState(BaseModel):
actor_id: UUID
node_id: UUID
node_name: str
node_type: str # "LLMNode", "ToolNode", etc.
status: NodeActorStatus
has_error: bool
node_state: NS # LLMNodeState (messages), ToolNodeState (last call), …This state allow to restore any node from the serialized payload
Executor state
Owned by Executor, tracks the tree of running and suspended child executors:
class ExecutorState(BaseModel):
execution_id: UUID
node_actor_id: UUID
status: ExecutorStatus # idle | running | suspended | completed | failed
child_executor_ids: set[UUID] # every child ever spawned from this executorAt runtime, a tree of executors can look like this:
Root Executor (01a1dd9a-9374-44e7-b8a8-7fc891b29de0) - SUSPENDED
├── Child Executor 1 (child1) - SUSPENDED
├── Child Executor 2 (cda81fa7-75f1-4800-bc2b-3aae70aa0e60) - SUSPENDED
│ ├── Sub Executor 3 (f1e2d3c4-5678-90ab-cdef-1234567890ab) - COMPLETED
│ └── Sub Executor 4 (a1b2c3d4-5678-90ab-cdef-1234567890ab) - RUNNING
└── Child Executor 3 (b1c2d3e4-5678-90ab-cdef-1234567890ab) - RUNNINGThe executor derives what to run next by comparing child_executor_ids (what
has started) against the graph spec. It never stores a phase or step index -
the spec is the source of truth.
Multi-turn conversations
Within a session, the same NodeActor stays alive across user turns. This means an LLMNode automatically remembers the conversation:
agent = Agent(specs_dir="specs/", start_node="ChatNode", llm=llm)
await agent.step("My name is Alice.")
await agent.step("What is my name?") # LLMNode remembers AliceThe second call picks up the same NodeActor with the full message history in
node_state.messages. No session IDs or manual context passing needed.
State across restarts
Because both state types are written to StateStorage after every execution,
the agent can recover from a crash or a planned shutdown and continue from
exactly where it left off.
On restart:
- The Executor loads
ExecutorStatefromStateStorage. - For each active child, it loads the matching
NodeActorStateand callsNodeActor.create_or_restore(). - Execution continues - suspended nodes resume, completed nodes are skipped.
If we stop agent after the first call and then do
agent = Agent(specs_dir="specs/", start_node="ChatNode", llm=llm)
output = await agent.step("My name is Alice.")
save_to_db(output)
# -----------
# Stop
# -----------
agent = Agent(specs_dir="specs/", start_node="ChatNode", llm=llm)
input_ = ExecutorInput(
execution_id=get_from_db("execution_id"),
node_actor_id=get_from_db("node_actor_id"),
node_input="What is my name?",
)
await agent.step(input_) # LLMNode remembers AliceConfiguring state storage
InMemoryStateStorage is the default. It is useful for testing and
single-process use, but does not survive restarts.
from liman import Agent, InMemoryStateStorage
agent = Agent(
specs_dir="specs/",
start_node="ChatNode",
llm=llm,
state_storage=InMemoryStateStorage(), # default
)InMemoryStateStorage is lost when the process exits. Use a persistent
backend for production agents that need crash recovery or multi-turn sessions
across restarts.
To use a persistent backend, implement the StateStorage abstract class:
from liman import Agent, StateStorage
class RedisStateStorage(StateStorage):
async def save_actor_state(self, execution_id, actor_id, state): ...
async def load_actor_state(self, execution_id, actor_id): ...
async def save_executor_state(self, execution_id, state): ...
async def load_executor_state(self, execution_id): ...
agent = Agent(
specs_dir="specs/",
start_node="ChatNode",
llm=llm,
state_storage=RedisStateStorage(),
)For how the Executor uses its restored state to continue graph traversal, see Execution Model.
Last updated on