Powered by AppSignal & Oban Pro

FSM Strategy Deep Dive

guides/fsm-strategy.livemd

FSM Strategy Deep Dive

Mix.install([
  {:jido, path: "."}
])

Overview

The FSM (Finite State Machine) Strategy enables you to build agents with explicit state machines that control workflow progression. This is ideal for:

  • Order processing workflows
  • Document approval flows
  • Multi-step wizards
  • Any process with defined states and transitions

The FSM Strategy stores its state in agent.state.__strategy__ and automatically validates transitions.

Configuration

Configure the FSM Strategy in your agent definition:

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],
      cancelled_reason: [type: :string, default: nil],
      shipped_at: [type: :any, default: nil],
      delivered_at: [type: :any, default: nil]
    ],
    strategy: {Jido.Agent.Strategy.FSM,
      initial_state: "pending",
      transitions: %{
        "pending" => ["confirmed", "cancelled"],
        "confirmed" => ["shipped", "cancelled"],
        "shipped" => ["delivered"],
        "delivered" => [],
        "cancelled" => []
      },
      auto_transition: false
    }
end

FSM Strategy Options

Option Description Default
:initial_state The starting FSM state "idle"
:transitions Map of valid transitions %{from => [to_states]} Default workflow (see below)
:auto_transition Auto-return to 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"]
}

Inspecting FSM State

Use strategy_snapshot/1 to inspect the current FSM state without accessing internals:

snap = OrderAgent.strategy_snapshot(agent)

snap.status   # :idle, :running, :success, :failure
snap.done?    # boolean - whether in terminal state
snap.result   # last action result
snap.details  # %{fsm_state: "shipped", processed_count: 5, ...}

The details.fsm_state contains the current FSM state string.

Complete Example: Order Fulfillment

Let’s build a complete order fulfillment workflow with the following states:

pending → confirmed → shipped → delivered
    ↓         ↓
 cancelled  cancelled

Step 1: Define the Actions

defmodule ConfirmOrder do
  use Jido.Action,
    name: "confirm_order",
    description: "Confirm a pending order",
    schema: []

  @impl true
  def run(_params, context) do
    order_id = context.state[:order_id]
    IO.puts("✓ Order #{order_id} confirmed")
    {:ok, %{}}
  end
end

defmodule ShipOrder do
  use Jido.Action,
    name: "ship_order",
    description: "Ship a confirmed order",
    schema: [
      carrier: [type: :string, default: "Standard Shipping"]
    ]

  @impl true
  def run(params, context) do
    order_id = context.state[:order_id]
    IO.puts("📦 Order #{order_id} shipped via #{params.carrier}")
    {:ok, %{shipped_at: DateTime.utc_now()}}
  end
end

defmodule DeliverOrder do
  use Jido.Action,
    name: "deliver_order",
    description: "Mark an order as delivered",
    schema: []

  @impl true
  def run(_params, context) do
    order_id = context.state[:order_id]
    IO.puts("✅ Order #{order_id} delivered!")
    {:ok, %{delivered_at: DateTime.utc_now()}}
  end
end

defmodule CancelOrder do
  use Jido.Action,
    name: "cancel_order",
    description: "Cancel an order",
    schema: [
      reason: [type: :string, required: true]
    ]

  @impl true
  def run(params, context) do
    order_id = context.state[:order_id]
    IO.puts("❌ Order #{order_id} cancelled: #{params.reason}")
    {:ok, %{cancelled_reason: params.reason}}
  end
end

Step 2: Define the Order Agent

defmodule OrderAgent do
  use Jido.Agent,
    name: "order_agent",
    description: "Handles order lifecycle with FSM",
    schema: [
      order_id: [type: :string],
      customer: [type: :string],
      items: [type: {:list, :map}, default: []],
      total: [type: :float, default: 0.0],
      cancelled_reason: [type: :string, default: nil],
      shipped_at: [type: :any, default: nil],
      delivered_at: [type: :any, default: nil]
    ],
    strategy: {Jido.Agent.Strategy.FSM,
      initial_state: "pending",
      transitions: %{
        "pending" => ["confirmed", "cancelled"],
        "confirmed" => ["shipped", "cancelled"],
        "shipped" => ["delivered"],
        "delivered" => [],
        "cancelled" => []
      },
      auto_transition: false
    }
end

Step 3: Happy Path - Complete Order Flow

agent = OrderAgent.new(
  id: "order-001",
  state: %{
    order_id: "ORD-12345",
    customer: "Alice",
    items: [%{sku: "WIDGET-A", qty: 2}],
    total: 49.99
  }
)

IO.puts("Initial FSM state:")
snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(snap.details[:fsm_state], label: "FSM State")

Now let’s process the order through its lifecycle:

{agent, _directives} = OrderAgent.cmd(agent, ConfirmOrder)

snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(snap.details[:fsm_state], label: "After confirm")
IO.inspect(snap.details[:processed_count], label: "Actions processed")
{agent, _directives} = OrderAgent.cmd(agent, {ShipOrder, %{carrier: "FedEx Express"}})

snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(snap.details[:fsm_state], label: "After ship")
IO.inspect(agent.state[:shipped_at], label: "Shipped at")
{agent, _directives} = OrderAgent.cmd(agent, DeliverOrder)

snap = OrderAgent.strategy_snapshot(agent)
IO.inspect(snap.details[:fsm_state], label: "After deliver")
IO.inspect(agent.state[:delivered_at], label: "Delivered at")
IO.inspect(snap.status, label: "Status")

Step 4: Cancellation Flow

Let’s create a new order and cancel it:

cancelled_agent = OrderAgent.new(
  id: "order-002",
  state: %{
    order_id: "ORD-12346",
    customer: "Bob",
    items: [%{sku: "GADGET-B", qty: 1}],
    total: 99.99
  }
)

{cancelled_agent, _} = OrderAgent.cmd(cancelled_agent, ConfirmOrder)

snap = OrderAgent.strategy_snapshot(cancelled_agent)
IO.inspect(snap.details[:fsm_state], label: "Before cancel")
{cancelled_agent, _} = OrderAgent.cmd(
  cancelled_agent, 
  {CancelOrder, %{reason: "Customer requested cancellation"}}
)

snap = OrderAgent.strategy_snapshot(cancelled_agent)
IO.inspect(snap.details[:fsm_state], label: "After cancel")
IO.inspect(cancelled_agent.state[:cancelled_reason], label: "Reason")

Step 5: Invalid Transition Handling

The FSM Strategy prevents invalid transitions. Let’s try to ship a cancelled order:

{agent_after_invalid, directives} = OrderAgent.cmd(cancelled_agent, ShipOrder)

IO.inspect(directives, label: "Directives from invalid transition")

snap = OrderAgent.strategy_snapshot(agent_after_invalid)
IO.inspect(snap.details[:fsm_state], label: "FSM state unchanged")

The FSM Strategy returns an Error directive when an invalid transition is attempted, and the FSM state remains unchanged.

State-Dependent Action Routing

You can use handle_signal/2 to route signals based on FSM state:

defmodule SmartOrderAgent do
  use Jido.Agent,
    name: "smart_order_agent",
    schema: [
      order_id: [type: :string],
      customer: [type: :string]
    ],
    strategy: {Jido.Agent.Strategy.FSM,
      initial_state: "pending",
      transitions: %{
        "pending" => ["confirmed", "cancelled"],
        "confirmed" => ["shipped", "cancelled"],
        "shipped" => ["delivered"],
        "delivered" => [],
        "cancelled" => []
      },
      auto_transition: false
    }

  alias Jido.Signal

  @impl true
  def handle_signal(agent, %Signal{type: "order.process"} = signal) do
    snap = strategy_snapshot(agent)
    fsm_state = snap.details[:fsm_state]
    
    action = case fsm_state do
      "pending" -> ConfirmOrder
      "confirmed" -> {ShipOrder, %{carrier: signal.data[:carrier] || "Standard"}}
      "shipped" -> DeliverOrder
      _ -> nil
    end
    
    if action do
      cmd(agent, action)
    else
      {agent, []}
    end
  end

  def handle_signal(agent, %Signal{type: "order.cancel"} = signal) do
    snap = strategy_snapshot(agent)
    fsm_state = snap.details[:fsm_state]
    
    if fsm_state in ["pending", "confirmed"] do
      reason = signal.data[:reason] || "No reason provided"
      cmd(agent, {CancelOrder, %{reason: reason}})
    else
      {agent, []}
    end
  end

  def handle_signal(agent, _signal), do: {agent, []}
end
smart_agent = SmartOrderAgent.new(
  id: "smart-001",
  state: %{order_id: "ORD-99999", customer: "Charlie"}
)

alias Jido.Signal

process_signal = Signal.new!("order.process", %{}, source: "/api")

{smart_agent, _} = SmartOrderAgent.handle_signal(smart_agent, process_signal)
IO.inspect(SmartOrderAgent.strategy_snapshot(smart_agent).details[:fsm_state], label: "Step 1")

{smart_agent, _} = SmartOrderAgent.handle_signal(smart_agent, process_signal)
IO.inspect(SmartOrderAgent.strategy_snapshot(smart_agent).details[:fsm_state], label: "Step 2")

{smart_agent, _} = SmartOrderAgent.handle_signal(smart_agent, process_signal)
IO.inspect(SmartOrderAgent.strategy_snapshot(smart_agent).details[:fsm_state], label: "Step 3")

Summary

Concept Purpose
strategy: {FSM, opts} Configure FSM-based execution
:initial_state Starting state for the FSM
:transitions Map of valid state transitions
:auto_transition Auto-return to initial state (default: true)
strategy_snapshot/1 Inspect FSM state without internal access
snap.details[:fsm_state] Current FSM state string
snap.status Coarse status (:idle, :running, :success, :failure)

Next Steps