FSM Strategy Deep Dive
Mix.install([
{:jido, path: "."}
])
Overview
The FSM (Finite State Machine) Strategy gives an agent an explicit execution
state machine around cmd/2.
It keeps cmd/2 pure by emitting %Directive.RunInstruction{} directives for
runtime execution, then handling execution results when the runtime routes them
back through cmd/2.
This strategy is useful when you want to:
- track whether an agent is idle or actively processing work
- gate execution so commands only run from valid execution states
-
inspect strategy state via
strategy_snapshot/1
The FSM state lives in agent.state.__strategy__.
Key Contract
The built-in FSM strategy always enters "processing" while instructions are
running. That means custom transition maps must include "processing" as the
runtime execution state.
If :auto_transition is enabled, "processing" must also be able to return to
the configured initial state.
Use your regular agent state for domain workflow data such as
agent.state.order_status.
Configuration
defmodule OrderAgent do
use Jido.Agent,
name: "order_agent",
schema: [
order_id: [type: :string],
customer: [type: :string],
items: [type: {:list, :map}, default: []],
total: [type: :float, default: 0.0],
order_status: [type: :atom, default: :pending],
shipped_via: [type: :string, default: nil],
shipped_at: [type: :any, default: nil],
delivered_at: [type: :any, default: nil]
],
signal_routes: [
{"order.confirm", ConfirmOrder},
{"order.ship", ShipOrder},
{"order.deliver", DeliverOrder}
],
strategy: {Jido.Agent.Strategy.FSM,
initial_state: "ready",
transitions: %{
"ready" => ["processing"],
"processing" => ["ready"]
},
auto_transition: true
}
end
FSM Strategy Options
| Option | Description | Default |
|---|---|---|
:initial_state |
The starting execution state |
"idle" |
:transitions |
Map of valid execution transitions %{from => [to_states]} |
Default workflow (see below) |
:auto_transition |
Auto-return to the initial state after processing |
true |
Default Transitions
If no transitions are provided, the strategy uses:
%{
"idle" => ["processing"],
"processing" => ["idle", "completed", "failed"],
"completed" => ["idle"],
"failed" => ["idle"]
}
Runtime Execution
MyAgent.cmd/2 stays pure under this strategy. It prepares work and returns
directives; it does not execute instructions inline.
Use Jido.AgentServer.call/3 to run a signal through the runtime:
{:ok, pid} = Jido.AgentServer.start_link(agent: OrderAgent)
{:ok, agent} = Jido.AgentServer.call(pid, signal)
The runtime handles %Directive.RunInstruction{} for you and feeds the result
back into the strategy.
If you need the settled agent after the runtime finishes draining directives,
fetch it from Jido.AgentServer.state/1 after the strategy returns to :idle.
Inspecting Strategy State
Use strategy_snapshot/1 to inspect the FSM execution state:
snap = OrderAgent.strategy_snapshot(agent)
snap.status # :idle or :running
snap.done? # false for the default runtime flow
snap.result # last instruction result
snap.details[:fsm_state] # "ready" or "processing"
Keep domain state in the agent itself:
agent.state[:order_status] # :pending, :confirmed, :shipped, :delivered
Complete Example: Order Fulfillment at Runtime
This example uses the FSM strategy for execution-state tracking while the order
lifecycle itself lives in agent.state.order_status.
Step 1: Define the Actions
defmodule ConfirmOrder do
use Jido.Action,
name: "confirm_order",
schema: []
@impl true
def run(_params, context) do
case context.state[:order_status] do
:pending -> {:ok, %{order_status: :confirmed}}
other -> {:error, "cannot confirm order from #{inspect(other)}"}
end
end
end
defmodule ShipOrder do
use Jido.Action,
name: "ship_order",
schema: [
carrier: [type: :string, default: "Standard Shipping"]
]
@impl true
def run(%{carrier: carrier}, context) do
case context.state[:order_status] do
:confirmed ->
{:ok, %{order_status: :shipped, shipped_via: carrier, shipped_at: DateTime.utc_now()}}
other ->
{:error, "cannot ship order from #{inspect(other)}"}
end
end
end
defmodule DeliverOrder do
use Jido.Action,
name: "deliver_order",
schema: []
@impl true
def run(_params, context) do
case context.state[:order_status] do
:shipped -> {:ok, %{order_status: :delivered, delivered_at: DateTime.utc_now()}}
other -> {:error, "cannot deliver order from #{inspect(other)}"}
end
end
end
Step 2: Define the Agent
defmodule OrderAgent do
use Jido.Agent,
name: "order_agent",
description: "Handles order lifecycle while FSM tracks execution state",
schema: [
order_id: [type: :string],
customer: [type: :string],
items: [type: {:list, :map}, default: []],
total: [type: :float, default: 0.0],
order_status: [type: :atom, default: :pending],
shipped_via: [type: :string, default: nil],
shipped_at: [type: :any, default: nil],
delivered_at: [type: :any, default: nil]
],
signal_routes: [
{"order.confirm", ConfirmOrder},
{"order.ship", ShipOrder},
{"order.deliver", DeliverOrder}
],
strategy: {Jido.Agent.Strategy.FSM,
initial_state: "ready",
transitions: %{
"ready" => ["processing"],
"processing" => ["ready"]
},
auto_transition: true
}
end
Step 3: Start the Runtime
alias Jido.Signal
def wait_for_idle!(pid, timeout_ms \\ 1_000) do
deadline = System.monotonic_time(:millisecond) + timeout_ms
do_wait_for_idle(pid, deadline)
end
defp do_wait_for_idle(pid, deadline) do
{:ok, status} = Jido.AgentServer.status(pid)
cond do
status.snapshot.status == :idle ->
{:ok, state} = Jido.AgentServer.state(pid)
state.agent
System.monotonic_time(:millisecond) >= deadline ->
raise "timed out waiting for FSM runtime to become idle"
true ->
Process.sleep(10)
do_wait_for_idle(pid, deadline)
end
end
{:ok, pid} =
Jido.AgentServer.start_link(
agent: OrderAgent,
id: "order-001",
initial_state: %{
order_id: "ORD-12345",
customer: "Alice",
items: [%{sku: "WIDGET-A", qty: 2}],
total: 49.99,
order_status: :pending
}
)
Step 4: Process the Order
{:ok, _agent} =
Jido.AgentServer.call(pid, Signal.new!("order.confirm", %{}, source: "/guide"))
agent = wait_for_idle!(pid)
snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(agent.state[:order_status], label: "Order status")
IO.inspect(snap.details[:fsm_state], label: "FSM state")
IO.inspect(snap.details[:processed_count], label: "Actions processed")
{:ok, _agent} =
Jido.AgentServer.call(
pid,
Signal.new!("order.ship", %{carrier: "FedEx Express"}, source: "/guide")
)
agent = wait_for_idle!(pid)
snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(agent.state[:order_status], label: "Order status after ship")
IO.inspect(agent.state[:shipped_via], label: "Carrier")
IO.inspect(snap.details[:fsm_state], label: "FSM state")
{:ok, _agent} =
Jido.AgentServer.call(pid, Signal.new!("order.deliver", %{}, source: "/guide"))
agent = wait_for_idle!(pid)
snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(agent.state[:order_status], label: "Order status after deliver")
IO.inspect(agent.state[:delivered_at], label: "Delivered at")
IO.inspect(snap.details[:fsm_state], label: "FSM state")
After each successful runtime call:
-
agent.state.order_statusadvances through the business workflow -
snap.details[:fsm_state]returns to"ready" -
snap.details[:processed_count]increments
Common Pitfall: Missing "processing"
The following configuration looks like a business-state FSM, but it is invalid
for the built-in strategy because "processing" is missing:
defmodule BrokenOrderAgent do
use Jido.Agent,
name: "broken_order_agent",
strategy: {Jido.Agent.Strategy.FSM,
initial_state: "pending",
transitions: %{
"pending" => ["confirmed", "cancelled"],
"confirmed" => ["shipped", "cancelled"],
"shipped" => ["delivered"],
"delivered" => [],
"cancelled" => []
},
auto_transition: false
}
end
Calling cmd/2 on that agent returns an error directive immediately because the
strategy cannot transition from "pending" to "processing":
agent = BrokenOrderAgent.new()
{agent, directives} = BrokenOrderAgent.cmd(agent, ConfirmOrder)
IO.inspect(directives, label: "Directive(s)")
IO.inspect(BrokenOrderAgent.strategy_snapshot(agent).details[:fsm_state], label: "FSM state")
If you want business-state transitions like "pending" -> "confirmed", model
those in agent.state or build a custom strategy on top of this runtime
pattern.
Summary
| Concept | Purpose |
|---|---|
strategy: {FSM, opts} |
Configure execution-state tracking around cmd/2 |
"processing" |
Runtime state entered while instructions execute |
AgentServer.call/3 |
Executes %Directive.RunInstruction{} and returns the updated agent |
strategy_snapshot/1 |
Inspect execution state without reading internals |
agent.state |
Store domain workflow data such as order_status |
Next Steps
- Agents Guide - Core agent concepts
- Signals Guide - Route signals into actions
- Runtime Guide - Run agents in production