JJoeven

Curriculum/Agent Architectures

State Machines for Agents

Named states, allowed tools per state, and guards on transitions. Graphs you can draw beat loops you cannot.

intermediate21 min13 / 24

A state machine is typed state plus explicit transitions:

  • From gather, get_ticket may move you to apply only if the ticket is open
  • From apply, refund may move you to done only if amount ≤ cap
  • Unknown transitions are errors, not improvisation

Typed state gave you a dict. The machine gives you edges. Allowed tools are not enough. Guards check the world before the phase changes. Refund in gather is illegal even if the model is sure. Open ticket with amount 80 fails the apply guard even if refund is a legal name in apply.

If you cannot draw the graph on a whiteboard, operators cannot debug 3 a.m. runs. Draw: gather → apply → done, plus handoff loops that stay put, plus search that stays in gather.

Allowed edges, not vibes
get_ticketrefundGatherApplyDone

Search stays in gather. Refund may move apply to done only if the guard passes.

Allowed edges, not vibes

LangGraph-style node graphs are this idea with nicer furniture. The furniture is not the architecture. If the library cannot express “refund illegal in gather,” you still write the allowlist.

Allowed vs guard vs next

Three tables:

  1. Allowed — which tool names may run in this state
  2. Guards — extra predicates on the observation (or on args) before a transition fires
  3. Next — where you go if allowed and guard pass

Search is allowed in gather and next is gather (self-loop). Get_ticket is allowed in gather; guard is status open; next is apply. Refund is not in gather’s allowed list — you never consult a guard. Amount 80 in apply: name allowed, guard fails, state does not change.

Failing a guard is not “try a nearby tool.” It is guard failed as an observation. The model may try a different path. Stop still caps. Do not auto-jump to done.

Unknown transitions are errors

If the model emits a tool that has no row, that is illegal tool. If you forgot to add a next mapping, fail closed — do not default to done. Missing next is a programmer bug. Defaulting to stay is safer than defaulting to success.

Handoff often stays in the same state and stops the loop. Done has an empty allowlist. Those are explicit.

Draw it, then test it

Tests can fire illegal edges with no tokens: transition("gather", "refund", {}) must not become apply. transition("gather", "get_ticket", {"status": "closed"}) stays gather. Open ticket moves. Amount 12 refunds to done. Amount 80 stays apply.

That is cheaper than hoping the model “knows.” Put the graph in config (dicts), not only in a prompt. The prompt may mention the phases. Code enforces them.

Guards read the world

Guards should read observations and typed fields, not thoughts. obs.get("status") == "open" is a guard. “The thought said it was open” is not. Caps on amount belong here and in the tool policy table (tools track). Duplicate the cap if you must. Do not leave it only in English.

Graphs you can operate

Operators should ask “which node?” not “what was the vibe?” The trace logs state before, tool, guard result, state after. When money moved, you should see apply → done on an allowed edge with a passing guard. If you see gather → done, you have a bug in next maps, not a clever model.

What this is not

This is not a multi-agent workflow of people. It is one agent’s phase graph. Multi-agent next track may use graphs between specialists. You still need this graph inside a specialist. Skip it and the swarm will refund in gather with extra hops.

This is not production orchestration (queues, k8s). Jobs later load this state. The machine is the logic. The worker is the runner.

Self-loops, illegal edges, and drawing the picture

Search that stays in gather is a self-loop: allowed, no phase change, still costs a step. You still append an observation. You still check thrash. A self-loop is not “free thinking.” Handoff that stays in apply is a self-loop plus a stop: phase remains so a human can resume the same node.

Illegal edges should be noisy. Return illegal tool refund as the message and as an observation. Silent ignore looks like the model “did nothing” and it will retry the same edge until budget. Pair with thrash. Tests should include at least one illegal edge per node, one failed guard per guarded edge, and one happy path across the whole graph.

Draw it: boxes for gather / apply / done, arrows labeled with tool names, notes on arrows for guards (“status open”, “amount <= 50”). If you cannot draw it in five minutes, the graph is too clever or it lives only in a prompt. Operators at 3 a.m. get the drawing, not your memory of the LangGraph blog.

Config shape is three dicts (allowed, guards, next) or one list of edges {from, tool, guard, to}. Either is fine. What is not fine is “the model will figure out the phase.” Next maps that default to done are how gather plus a typo pays money. Prefer default stay + error.

Guards can read typed state as well as obs: “refund only if ticket_id is not null.” That is still the world (you stored the id from a prior obs). They must not read the thought string.

Common mistakes

  • Allowed names without guards.
  • Guards that read thoughts.
  • Default next = done.
  • Graph only in the prompt.
  • Cannot draw it.
  • Empty error on illegal tool so the model retries the same edge forever (pair with thrash later).
Live PythonOpen full playgroundpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Refund in gather is illegal. Closed ticket fails the guard. Open ticket moves to apply. Amount 80 fails the apply guard; amount 12 moves to done. Read the tuples: state plus message. Unchanged state plus guard failed is a successful control outcome. The world did not lie; the machine refused.

The default amount in the refund guard is 999, so a missing amount fails the cap. Fail closed on missing money fields.

How agents use this

Put the graph in config, not only in a prompt. Tests can fire illegal edges with no tokens. That is cheaper than hoping the model “knows” not to refund in gather.

HITL is a special kind of stay: you leave phase as apply, set pending_approval, and stop the worker until a human resumes. The edge to done does not fire until approve. Next two lessons.

Check your understanding

What does a guard do in an agent state machine?