Powered by AppSignal & Oban Pro

Call, Cast, and Await

send-signals-to-live-agents.livemd

Call, Cast, and Await

This tutorial sends Signals to a running order Agent in each request mode, then shows what each mode tells the caller and what it leaves running.

Install Jido

Mix.install([
  {:jido, github: "mikehostetler/new_jido", branch: "main"}
])

Start an order Agent

:pack updates state at once. :slow_pack holds its Turn until this notebook sends it a message, which lets the later cells observe a Turn in progress. :confirm also answers its caller with a reply Signal.

defmodule CallGuide.SlowPack do
  use Jido.Action,
    name: "call_guide_slow_pack",
    schema: Jido.Joi.object(%{order_id: Jido.Joi.string()})

  @impl Jido.Action
  def run(%{order_id: order_id}, %{agent_state: state, notify: notify}) do
    send(notify, {:packing, self()})

    receive do
      :finish -> {:ok, %{state | packed: state.packed ++ [order_id]}}
    end
  end
end

defmodule CallGuide.OrderDesk do
  use Jido.Agent, name: "order_desk"

  state do
    schema Jido.Joi.object(%{packed: Jido.Joi.list(Jido.Joi.string()) |> Jido.Joi.default([])})
  end

  routes do
    signal_source "/orders/desk"

    route "order.pack", as: :pack do
      action %{order_id: order_id},
        schema: Jido.Joi.object(%{order_id: Jido.Joi.string()}),
        context: context do
        {:ok, %{context.agent_state | packed: context.agent_state.packed ++ [order_id]}}
      end
    end

    route "order.slow_pack", CallGuide.SlowPack, as: :slow_pack

    route "order.confirm", as: :confirm do
      action %{order_id: order_id},
        schema: Jido.Joi.object(%{order_id: Jido.Joi.string()}),
        context: context do
        {:ok, reply} = Jido.Agent.build_signal(context, "order.confirmed", %{order_id: order_id})
        {:ok, context.agent_state, [Jido.Agent.Directive.reply(reply)]}
      end
    end
  end
end

{:ok, _instance} = Jido.start(name: CallGuide.Jido)
{:ok, desk} = Jido.start_agent(CallGuide.Jido, CallGuide.OrderDesk, id: "desk-1")
:ok = Jido.AgentServer.await_ready(desk)

The server runs one Turn at a time. Every request mode below enters the same queue and passes the same commit step.

Call and wait for the result

Jido.AgentServer.call/3 waits for the commit and returns the committed Agent with the Turn's Directives.

{:ok, agent, []} =
  Jido.AgentServer.call(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-1"}))

agent.state
# The cell returns %{packed: ["o-1"]}

A failed Turn returns {:error, reason} and leaves the committed state as it was. A Signal with no matching route is one such failure.

unknown = Jido.Signal.new!("order.teleport", %{}, source: "/orders/api")
{:error, %Jido.Error.ValidationError{code: :unmatched_input}} = Jido.AgentServer.call(desk, unknown)

Jido.AgentServer.agent(desk).state
# The cell returns %{packed: ["o-1"]}

Pass caller context

The :context option adds values to the Action context for one Turn. Jido does not store them in Agent state or in the Signal. :turn_id, :trace_context, and the Agent command keys such as :agent_state are reserved. The next cells use :notify to tell the Action which process to message.

A caller timeout does not cancel the Turn

The third argument is a timeout in milliseconds or a keyword list. When the wait expires, the caller exits as with any OTP call. The Turn keeps running.

slow = CallGuide.OrderDesk.slow_pack_signal!(%{order_id: "o-2"})

outcome =
  try do
    Jido.AgentServer.call(desk, slow, timeout: 20, context: %{notify: self()})
  catch
    :exit, {:timeout, _details} -> :caller_stopped_waiting
  end

action =
  receive do
    {:packing, pid} -> pid
  end

active_stage = Jido.AgentServer.status(desk).active.stage
send(action, :finish)

{outcome, active_stage}
# The cell returns {:caller_stopped_waiting, :running}

The Action finished after the caller gave up, and its Turn committed. A later call runs after that Turn, so it sees o-2.

{:ok, agent, []} =
  Jido.AgentServer.call(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-3"}))

agent.state.packed
# The cell returns ["o-1", "o-2", "o-3"]

Use Jido.AgentServer.cancel/2 to stop a Turn. See Cancel Turns.

Cast without a reply

Jido.AgentServer.cast/3 returns :ok after it sends the Signal. That :ok says nothing about the Turn. A cast that fails, or one dropped because the queue is full, only produces a log entry.

:ok = Jido.AgentServer.cast(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-4"}))
:ok = Jido.AgentServer.cast(desk, unknown)

# A later call runs after both casts.
{:ok, agent, []} =
  Jido.AgentServer.call(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-5"}))

{agent.state.packed, Jido.AgentServer.status(desk).state_version}
# The cell returns {["o-1", "o-2", "o-3", "o-4", "o-5"], 5}

The version counts committed Turns only. The two Signals with no route did not advance it.

Submit now and await later

Jido.AgentServer.submit/3 returns a handle at once. Jido.AgentServer.await/2 collects the same result that call/3 would return. An await timeout returns {:error, :timeout} without exiting, and you can wait again.

pending =
  Jido.AgentServer.submit(desk, CallGuide.OrderDesk.slow_pack_signal!(%{order_id: "o-6"}),
    context: %{notify: self()}
  )

action =
  receive do
    {:packing, pid} -> pid
  end

first_wait = Jido.AgentServer.await(pending, 0)
send(action, :finish)
{:ok, agent, []} = Jido.AgentServer.await(pending)

{first_wait, List.last(agent.state.packed)}
# The cell returns {{:error, :timeout}, "o-6"}

Submit several requests to run them in order while your process does other work. Each result shows the state after its own Turn.

handles =
  for order_id <- ["o-7", "o-8"] do
    Jido.AgentServer.submit(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: order_id}))
  end

handles
|> Enum.map(&Jido.AgentServer.await/1)
|> Enum.map(fn {:ok, agent, []} -> List.last(agent.state.packed) end)
# The cell returns ["o-7", "o-8"]

A handle has these limits:

  • Only the submitting process can await or abandon it. Another process gets a Jido.Error.ValidationError.
  • You can collect a result once. Do not await a handle after you collect or abandon it.
  • A handle does not survive a restart of your process.

Call Jido.AgentServer.abandon/1 when you no longer want the reply. The Turn still runs.

abandoned = Jido.AgentServer.submit(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-9"}))
:ok = Jido.AgentServer.abandon(abandoned)

{:ok, agent, []} =
  Jido.AgentServer.call(desk, CallGuide.OrderDesk.pack_signal!(%{order_id: "o-10"}))

Enum.take(agent.state.packed, -2)
# The cell returns ["o-9", "o-10"]

If the server stops before it replies, await/2 returns {:error, %Jido.Error.ExecutionError{}}.

Receive a reply Signal

A reply is a separate Signal that answers the request. It reaches the calling process as a plain message after the Turn commits. The return value of call/3 still holds the Agent and the Directives.

{:ok, _agent, [%Jido.Agent.Directive.Reply{}]} =
  Jido.AgentServer.call(desk, CallGuide.OrderDesk.confirm_signal!(%{order_id: "o-10"}))

receive do
  %Jido.Signal{type: "order.confirmed", data: data} -> data
after
  1_000 -> :no_reply
end
# The cell returns %{order_id: "o-10"}

A cast has no caller to answer, so a cast that produces a reply fails before commit. See Agent Turns and Replies.

Read without a Turn

Reads answer while a Turn runs, and they do not join the Turn queue. They see committed data only.

Function Returns
Jido.AgentServer.agent/2 The committed Agent.
Jido.AgentServer.snapshot/2 The committed Agent and its state_version, read in the same server event.
Jido.AgentServer.plugin_state/3 One committed Plugin-owned field.
Jido.AgentServer.status/2 A runtime view. See Status and Turn History.
%{agent: %{id: "desk-1"}, state_version: version} = Jido.AgentServer.snapshot(desk)
is_integer(version)
# The cell returns true

Choose a request mode

You need Use Result
The result before you continue Jido.AgentServer.call/3 {:ok, agent, directives} or {:error, reason}. A timeout exits the caller.
The result later, from the same process Jido.AgentServer.submit/3, then Jido.AgentServer.await/2 The same result. A timeout returns {:error, :timeout}.
No result Jido.AgentServer.cast/3 :ok when the Signal is sent. Delivery is best effort.

Jido.call/4, Jido.submit/4, and Jido.cast/3 do the same through a Jido.Agent.Ref. Jido.cast/3 has no :context option.

Next steps