Powered by AppSignal & Oban Pro

FSM Strategy Deep Dive

guides/fsm-strategy.livemd

FSM Strategy Deep Dive

Run in Livebook

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_status advances 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