Chapter 7: State Machines Before Agent Frameworks
O-1001 has a drafted status email and sits in awaiting_approval until tomorrow. Maya will review in the morning. If the only record of that wait is chat history in one worker’s RAM, a restart forgets the phase — and a second worker may re-draft, send early, or park the case twice.
Operators need to read stored state, not ask a model to summarize a transcript.
draft ready → awaiting_approval → (overnight) → approve → executing → send → completed
This chapter names FSM phases — planning, awaiting_tool, awaiting_approval, retrying, terminal states — and makes legal exits explicit before any agent framework enters the picture.
First principles
A finite-state machine (FSM) makes case lifecycle inspectable and enforceable:
| Phase | Meaning |
|---|---|
idle | Case exists; not started |
planning | Choosing next action |
awaiting_tool | Tool call in flight |
awaiting_approval | Human gate |
executing | Performing permitted side effect |
retrying | Transient failure path |
completed / failed | Terminal |
For ShopOps, phase answers: Is O-1001 gathering facts? Waiting for a human? Retrying a gateway? Done? Operators read stored state—not a model summarizing a transcript.
Choose control structure to match lifecycle:
| Pattern | Use when |
|---|---|
| FSM | Small closed lifecycle; strong invariants |
| Graph / workflow | Branching pipelines with named nodes |
| Open loop | Exploratory tools with hard budgets + watchdogs |
Framework graphs implement transitions; they do not choose business states for you. If you cannot name what a case waits for, you cannot operate it.
Concrete example
AgentFSM drives ShopOps case lifecycle. Overnight, the case sits in awaiting_approval. Morning approval fires approve → executing.
Diagram
stateDiagram-v2
[*] --> idle
idle --> planning: start
planning --> awaiting_tool: tool_call
awaiting_tool --> planning: tool_ok
awaiting_tool --> retrying: tool_retry
retrying --> awaiting_tool: tool_call
planning --> awaiting_approval: propose_send
awaiting_approval --> executing: approve
awaiting_approval --> planning: reject
executing --> awaiting_tool: tool_call
planning --> completed: complete
executing --> completed: complete
awaiting_approval --> completed: complete
idle --> failed: fail
planning --> failed: fail
awaiting_tool --> failed: fail
awaiting_approval --> failed: fail
executing --> failed: fail
retrying --> failed: fail
Notice: approve from planning is illegal — tests should say so.
Implementation
PYTHONPATH=. python examples/ch07_fsm.py
shopops/fsm.py:
fsm = AgentFSM()
fsm.handle("start") # idle → planning
fsm.handle("tool_call") # → awaiting_tool
fsm.handle("tool_ok") # → planning
fsm.handle("propose_send") # → awaiting_approval
# ... overnight ...
fsm.handle("approve") # → executing
Illegal transitions raise IllegalTransition — do not coerce.
Wire FSM phase into State.phase (Ch 2) so checkpoints (Ch 8) persist the same noun ops dashboards show.
Overnight approval as a product feature
Order support is not a chat demo. Humans batch reviews. O-1001 may sit in awaiting_approval for hours. That is success:
- No worker thread held open
- No busy-loop polling the model
- No accidental send at 2am because a retry woke up
The FSM makes that park visible in SQL: WHERE phase = 'awaiting_approval'. Chat-history control flow cannot answer that query without brittle NLP.
Illegal transitions are tests you want
fsm = AgentFSM()
fsm.handle("start")
# cannot approve before proposing a send
with pytest.raises(IllegalTransition):
fsm.handle("approve")
Every illegal edge you don’t test becomes a silent coercion in some future refactor (“just set phase = executing”). Coercion is how duplicates and skipped policy checks appear.
Open loop still has a machine
Even if you keep a ReAct-style open loop for planning (Part IV), the case lifecycle can remain an FSM. Micro decisions may be open; macro status should not be. That hybrid is normal: open loop inside planning, FSM around the case.
Failure modes
Phantom states in prose. Transcript says “waiting on manager”; FSM still planning; a second worker sends.
Catch-all transitions. Everything can go to everywhere — FSM becomes décor.
Framework opacity. Generated graphs with unnamed nodes; on-call cannot answer “what is this case waiting on?”
Dual sources of truth. FSM says awaiting_approval, boolean awaiting_approval disagrees.
Retry as a vibe. No retrying phase; transport retries hide from operators.
Production considerations
- Emit phase as a first-class metric/label on every case.
- Persist phase in checkpoints; resume must reload FSM, not infer from last message.
- Property tests: random legal event sequences never enter undefined states; illegal events always throw.
- Map FSM names to any later framework node names in a glossary — one concept, two spellings.
- Durable execution systems (e.g. Temporal workflows) rhyme with this: explicit status, replay, signals for approval. [VERIFY SOURCE: Temporal docs conceptual]
Chapter summary
- Explicit FSM phases beat chat-inferred control flow.
- O-1001 parks in
awaiting_approvalovernight on purpose. - Illegal transitions must fail loudly.
- FSM vs graph vs open loop is a design choice with trade-offs.
- Frameworks map to states; they do not invent discipline.
- Align
State.phasewith the FSM. - Retrying is a named phase, not only a sleep().
- Code:
shopops/fsm.py,examples/ch07_fsm.py.
Exercises
- Write tests for every illegal transition out of
awaiting_approvalexceptapprove,reject,complete,fail. - Map each FSM phase to a hypothetical LangGraph node name (concept-only table).
- Add a
cancelevent fromawaiting_approval→failedwith reasoncancelled_by_ops. - Fuzz 1_000 random event sequences filtered to legal moves; assert no crash and phase always in the enum.
References
- Standard FSM / statechart teaching material (Hopcroft/Ullman or lighter tutorials). [VERIFY SOURCE for preferred citation]
- Temporal documentation — workflows, signals, determinism (conceptual overlap). [VERIFY SOURCE]
- Kleppmann, 2017 — application state vs message logs when you later add ledgers (Ch 16).