Powered by AppSignal & Oban Pro

Observe Agent Turns

observe-agent-turns.livemd

Observe Agent Turns

In this tutorial you attach a telemetry handler to a live Agent and follow one Turn from its start to its effects. You then compare a failed Turn and a failed effect, connect events to a caller's trace, and check the events against Jido.AgentServer.status/2.

Install Jido

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

Start an invoice Agent

The :pay route commits a new balance and returns one Jido.Agent.Directive.Emit. A payment of zero or less returns an error, so the tutorial can show both results.

defmodule ObserveTurns.Pay do
  use Jido.Action,
    name: "observe_turns_pay",
    schema: Jido.Joi.object(%{cents: Jido.Joi.integer()})

  @impl Jido.Action
  def run(%{cents: cents}, _context) when cents <= 0 do
    {:error, Jido.Error.validation_error("payment must be positive")}
  end

  def run(%{cents: cents}, %{agent_state: state}) do
    {:ok, %{state | paid_cents: state.paid_cents + cents},
     [Jido.Agent.Directive.emit("invoice.paid", %{cents: cents})]}
  end
end

defmodule ObserveTurns.Invoice do
  use Jido.Agent, name: "observe_turns_invoice"

  state do
    schema Jido.Joi.object(%{paid_cents: Jido.Joi.integer() |> Jido.Joi.default(0)})
  end

  routes do
    signal_source "/billing"
    route "invoice.pay", ObserveTurns.Pay, as: :pay
  end
end

{:ok, _instance} = Jido.start(name: ObserveTurns.Jido)

{:ok, invoice} =
  Jido.start_agent(ObserveTurns.Jido, ObserveTurns.Invoice,
    id: "invoice-1",
    default_dispatch: {:pid, target: self()}
  )

:ok = Jido.AgentServer.await_ready(invoice)
Jido.AgentServer.status(invoice).phase

The cell returns :idle. The Agent sends its invoice.paid Signal to this notebook through :default_dispatch.

Attach a handler

A telemetry handler runs in the process that emits the event. This one only forwards each event to the notebook. It uses a module function, because :telemetry logs a notice about anonymous handlers.

Jido.Telemetry.AgentServer.event_names/0 returns every AgentServer event. Jido.Telemetry.Exec.event_names/0 returns the Action and Flow events.

defmodule ObserveTurns.Forwarder do
  def handle(event, measurements, metadata, target) do
    send(target, {:jido_event, event, measurements, metadata})
  end
end

:ok =
  :telemetry.attach_many(
    "observe-turns",
    Jido.Telemetry.AgentServer.event_names() ++ Jido.Telemetry.Exec.event_names(),
    &ObserveTurns.Forwarder.handle/4,
    self()
  )

collect = fn collect, events ->
  receive do
    {:jido_event, event, measurements, metadata} ->
      collect.(collect, [{event, measurements, metadata} | events])
  after
    200 -> Enum.reverse(events)
  end
end

names = fn events -> Enum.map(events, fn {event, _, _} -> Enum.drop(event, 1) end) end

Follow a committed Turn

Send one payment and list the events in order.

{:ok, _agent, [_emit]} =
  Jido.AgentServer.call(invoice, ObserveTurns.Invoice.pay_signal!(%{cents: 500}))

paid_events = collect.(collect, [])
names.(paid_events)

The cell returns these events:

[:agent_server, :turn, :start]
[:action, :start]
[:action, :stop]
[:agent_server, :commit]
[:agent_server, :directives, :returned]
[:agent_server, :turn, :stop]
[:agent_server, :turn, :settled]
[:agent_server, :effects, :start]
[:agent_server, :effects, :stop]

The effect events come last. Jido.AgentServer.call/3 returns at commit, so a successful call says nothing about delivery.

Measurements hold numbers, and metadata holds bounded labels and IDs. Read both from the Turn stop event.

{_event, turn_measurements, turn_metadata} =
  Enum.find(paid_events, fn {event, _, _} -> event == [:jido, :agent_server, :turn, :stop] end)

%{status: :ok, stage: :commit, committed?: true, agent_id: "invoice-1", signal_type: "invoice.pay"} =
  turn_metadata

%{state_version_before: 0, state_version_after: 1, directive_count: 1, duration: duration} =
  turn_measurements

milliseconds = System.convert_time_unit(duration, :native, :millisecond)
is_integer(milliseconds)

The cell returns true. duration uses native time units, so convert it before you display it.

The Action events carry the same turn_id, which links work to its Turn.

paid_events
|> Enum.map(fn {_event, _measurements, metadata} -> metadata.turn_id end)
|> Enum.uniq()
|> length()

The cell returns 1.

The Signal itself arrived here too.

receive do
  {:signal, %Jido.Signal{type: type, data: data}} -> {type, data}
after
  1_000 -> :no_signal
end

The cell returns {"invoice.paid", %{cents: 500}}.

Follow a failed Turn

Now send a payment that the Action rejects.

{:error, %Jido.Error.ValidationError{}} =
  Jido.AgentServer.call(invoice, ObserveTurns.Invoice.pay_signal!(%{cents: 0}))

failed_events = collect.(collect, [])
names.(failed_events)

The cell returns five events. There is no commit and no effect:

[:agent_server, :turn, :start]
[:action, :start]
[:action, :stop]
[:agent_server, :turn, :stop]
[:agent_server, :turn, :settled]

The stop event reports the status and the stage where the Turn ended. The revision does not change. The Action stop event uses its own field, outcome.

{_event, failed_measurements, failed_metadata} =
  Enum.find(failed_events, fn {event, _, _} -> event == [:jido, :agent_server, :turn, :stop] end)

{_event, _measurements, action_metadata} =
  Enum.find(failed_events, fn {event, _, _} -> event == [:jido, :action, :stop] end)

{failed_metadata.status, failed_metadata.stage, failed_metadata.committed?,
 failed_measurements.state_version_after, action_metadata.outcome, action_metadata.error_type}

The cell returns {:error, :evaluate, false, 1, :error, :validation_error}. The event has the error type and no error text. To read the message, use the value that the caller received.

Separate a commit from its effects

A failed effect does not undo a commit, so watch both results. This second Agent dispatches to a process that has already exited.

gone = spawn(fn -> :ok end)
monitor = Process.monitor(gone)

receive do
  {:DOWN, ^monitor, :process, ^gone, _reason} -> :ok
end

{:ok, lost} =
  Jido.start_agent(ObserveTurns.Jido, ObserveTurns.Invoice,
    id: "invoice-2",
    default_dispatch: {:pid, target: gone}
  )

:ok = Jido.AgentServer.await_ready(lost)
_ = collect.(collect, [])

{:ok, %{state: %{paid_cents: 700}}, [_emit]} =
  Jido.AgentServer.call(lost, ObserveTurns.Invoice.pay_signal!(%{cents: 700}))

lost_events = collect.(collect, [])

[turn_stop_status] =
  for {[:jido, :agent_server, :turn, :stop], _, metadata} <- lost_events, do: metadata.status

[{effects_status, effects_stage, effects_measurements}] =
  for {[:jido, :agent_server, :effects, :stop], measurements, metadata} <- lost_events,
      do: {metadata.status, metadata.stage, Map.take(measurements, [:directive_completed, :directive_count])}

{turn_stop_status, effects_status, effects_stage, effects_measurements}

The cell returns {:ok, :error, :directive, %{directive_completed: 0, directive_count: 1}}. Alert on [:jido, :agent_server, :effects, :stop] as well as on the Turn stop event. A dashboard that reads only Turn results reports this payment as a full success.

Connect a caller's trace

A Signal can carry a W3C trace. Parse the traceparent from an inbound request and attach it to the Signal with Jido.Signal.Context.put_trace/2.

{:ok, inbound} =
  Jido.Trace.from_traceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01")

{:ok, traced} =
  Jido.Signal.Context.put_trace(ObserveTurns.Invoice.pay_signal!(%{cents: 100}), inbound)

{:ok, _agent, [_emit]} = Jido.AgentServer.call(invoice, traced)

[traced_metadata] =
  for {[:jido, :agent_server, :turn, :stop], _, metadata} <- collect.(collect, []), do: metadata

{traced_metadata.trace_id == inbound.trace_id, traced_metadata.span_id != inbound.span_id,
 traced_metadata.parent_span_id == inbound.span_id}

The cell returns {true, true, true}. The Turn joins the caller's trace with a new span of its own. A Signal with no trace starts a new root trace.

The Signal that the Turn emitted carries the same trace ID.

receive do
  {:signal, %Jido.Signal{} = emitted} ->
    Jido.Signal.Context.get_trace(emitted).trace_id == inbound.trace_id
after
  1_000 -> :no_signal
end

The cell returns true.

Compare with status

Telemetry reports each change as it happens, and Jido.AgentServer.status/2 reports where the Agent is now. last_outcome uses Server words such as :succeeded. Events use :ok.

%{status: last_status, state_version_after: revision} =
  Jido.AgentServer.status(invoice).last_outcome

{last_status, revision}

The cell returns {:succeeded, 2}.

Handlers stay attached until you remove them. Handlers are global to the node, so a shared system needs a filter on a field such as agent_id.

:telemetry.detach("observe-turns")

Next steps