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
- Admission and Deadlines explains the queue and every timeout.
- Cancel Turns stops work that a caller no longer wants.