Powered by AppSignal & Oban Pro

Mutations and Diffs

agent-mutations-and-diffs.livemd

Mutations and Diffs

This tutorial edits an Agent's routes, members, and Plugins as data with Jido.Agent.mutate/2, reads the resulting Jido.Agent.Diff, and then returns the same edits from a Turn.

Install Jido

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

Set up an Agent

The Agent starts with one route. The other modules are the parts the mutations add.

defmodule MutationGuide.Refund do
  use Jido.Action,
    name: "mutation_guide_refund",
    schema: Jido.Joi.object(%{amount_cents: Jido.Joi.integer()})

  @impl Jido.Action
  def run(%{amount_cents: amount}, %{agent_state: state}) do
    {:ok, %{state | refunded_cents: state.refunded_cents + amount}}
  end
end

defmodule MutationGuide.FraudReviewer do
  use Jido.Agent, name: "fraud_reviewer"

  routes do
    route "refund.review" do
      action _input, schema: Jido.Joi.object(%{}), context: context do
        {:ok, context.agent_state}
      end
    end
  end
end

defmodule MutationGuide.RefundLog do
  use Jido.Plugin

  @impl true
  def state_spec(_opts), do: {:refund_log, Jido.Joi.integer() |> Jido.Joi.default(0)}
end

defmodule MutationGuide.RefundDesk do
  use Jido.Agent, name: "refund_desk"

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

  routes do
    signal_source "/billing/refunds"

    route "refund.issue", MutationGuide.Refund, as: :issue, defaults: %{amount_cents: 100}
  end
end

desk = MutationGuide.RefundDesk.instantiate!(id: "desk-1")
Enum.map(desk.routes, & &1.path)

The cell returns ["refund.issue"].

Apply a mutation

Jido.Agent.mutate/2 takes an Agent and an ordered list of mutation Directives. It returns a validated candidate and the Diff. The source does not change. No process is involved.

alias Jido.Agent.Directive

{:ok, candidate, diff} =
  Jido.Agent.mutate(desk, [
    Directive.route_add("refund.partial", MutationGuide.Refund, as: :partial),
    Directive.route_update("refund.issue", %{defaults: %{amount_cents: 500}})
  ])

{
  Enum.map(desk.routes, & &1.path),
  Enum.map(candidate.routes, & &1.path),
  Enum.map(diff.routes.added, & &1.path),
  Enum.map(diff.routes.updated, & &1.after.defaults)
}

The cell returns:

{["refund.issue"], ["refund.issue", "refund.partial"], ["refund.partial"], [%{amount_cents: 500}]}

Instances and definitions both mutate. A definition has no state, so Plugin state edits do not apply to it.

Read a Diff

A Jido.Agent.Diff describes the final difference between the two values.

Field Content
fields Changed top-level fields, as %{field => %{before: _, after: _}}
state nil, or %{before: _, after: _}
routes, agents, connections, plugins %{added: [], removed: [], updated: [], order: %{before: [], after: []}}

Jido matches routes by path, members by name, connections by {from, to, path}, and Plugins by provider. Each updated entry is %{before: _, after: _}.

Jido.Agent.Diff.changed?/2 checks one field, and Jido.Agent.Diff.empty?/1 checks them all. A reorder counts as a change. An edit that a later edit cancels does not.

alias Jido.Agent.Directive

{:ok, _same, cancelled} =
  Jido.Agent.mutate(desk, [
    Directive.route_add("refund.partial", MutationGuide.Refund),
    Directive.route_remove("refund.partial")
  ])

{Jido.Agent.Diff.changed?(diff, :routes), Jido.Agent.Diff.changed?(diff, :plugins),
 Jido.Agent.Diff.empty?(cancelled)}

The cell returns {true, false, true}.

Know what fails

A batch is atomic. One failure returns an error, with the index of the failing operation in path, and no candidate.

alias Jido.Agent.Directive

{:error, duplicate} =
  Jido.Agent.mutate(desk, [
    Directive.route_add("refund.partial", MutationGuide.Refund),
    Directive.route_add("refund.partial", MutationGuide.Refund)
  ])

{:error, missing} = Jido.Agent.mutate(desk, [Directive.route_remove("refund.void")])

{:error, order} = Jido.Agent.mutate(candidate, [Directive.routes_reorder(["refund.partial"])])

{:error, not_a_mutation} = Jido.Agent.mutate(desk, [Directive.server_stop()])

Enum.map([duplicate, missing, order, not_a_mutation], &{&1.code, &1.path})

The cell returns:

[
  already_exists: [:directives, 1],
  not_found: [:directives, 0],
  invalid_route_order: [:directives, 0],
  invalid_mutation: [:directives, 0]
]

Jido checks references only in the final candidate. The order of a batch can therefore add a forward route before the member it targets. A batch that leaves a route pointing at nothing fails.

alias Jido.Agent.Directive

forward = %{kind: :forward, target: %{kind: :agent, name: :fraud}}

{:ok, with_member, member_diff} =
  Jido.Agent.mutate(desk, [
    Directive.route_add("refund.review", forward),
    Directive.agent_add(:fraud, MutationGuide.FraudReviewer)
  ])

{:error, dangling} = Jido.Agent.mutate(with_member, [Directive.agent_remove(:fraud)])

{Enum.map(member_diff.agents.added, & &1.name), dangling.code}

The cell returns {[:fraud], :unknown_forward_target}.

Add and remove a Plugin

A Plugin edit also manages the Plugin's state field on an instance. plugin_add/3 initializes it from the Plugin's schema, or from the :initial option.

alias Jido.Agent.Directive

{:ok, logged, plugin_diff} =
  Jido.Agent.mutate(desk, [Directive.plugin_add(MutationGuide.RefundLog, [], initial: 3)])

{logged.state, Enum.map(plugin_diff.plugins.added, & &1.provider), plugin_diff.state.before}

The cell returns {%{refunded_cents: 0, refund_log: 3}, [MutationGuide.RefundLog], %{refunded_cents: 0}}.

Removal discards data, so you must say so.

alias Jido.Agent.Directive

{:error, %{code: :plugin_state_discard_required}} =
  Jido.Agent.mutate(logged, [Directive.plugin_remove(MutationGuide.RefundLog)])

{:ok, removed, _diff} =
  Jido.Agent.mutate(logged, [Directive.plugin_remove(MutationGuide.RefundLog, state: :discard)])

removed.state

The cell returns %{refunded_cents: 0}.

Jido.Agent.Directive.plugin_configure/3 changes a Plugin's options. When the options change the state key, it needs a :migrate option of {module, function, args}. That function receives the old value first and returns {:ok, value} or {:error, reason}.

Mutate from a Turn

An Action can return mutation Directives. The Turn applies them to the same candidate as the new state, and the batch still contains them.

alias Jido.Agent.Directive

defmodule MutationGuide.OpenRefunds do
  use Jido.Action, name: "mutation_guide_open_refunds"

  @impl Jido.Action
  def run(_input, %{agent_state: state}) do
    {:ok, %{state | refunded_cents: 1},
     [Jido.Agent.Directive.route_add("refund.partial", MutationGuide.Refund)]}
  end
end

{:ok, opener, _diff} =
  Jido.Agent.mutate(desk, [Directive.route_add("refund.open", MutationGuide.OpenRefunds)])

open = Jido.Signal.new!("refund.open", %{}, source: "/billing/admin")
{:ok, opened, [%Directive.RouteAdd{path: "refund.partial"}]} = Jido.Agent.cmd(opener, open)

{opened.state.refunded_cents, Enum.map(opened.routes, & &1.path)}

The cell returns {1, ["refund.issue", "refund.open", "refund.partial"]}.

An AgentServer computes the Diff for a Turn like this and applies it to the running system. Members start or stop, and changed Plugins reconfigure. See Change Live Composition and Live Reconfiguration.

Mutation Directives

Every constructor is on Jido.Agent.Directive.

Target Constructors Update fields or options
Routes route_add/3, route_replace/3 :as, :defaults, :state, :reply
Routes route_update/2 :handler, :as, :defaults, :state, :reply
Routes route_remove/1, routes_reorder/1, routes_set/1 Reorder needs every current path
Members agent_add/3, agent_replace/3 :count, :initial_state
Members agent_update/2 :definition, :count, :initial_state
Members agent_remove/1 none
Connections connection_add/2, connection_replace/2 :from, :to, :delivery
Connections connection_update/2 :from, :to, :changes, where changes holds :delivery
Connections connection_remove/2 :from, :to
Topology topology_set/1 Replaces all members and connections
Plugins plugin_add/3 :initial
Plugins plugin_configure/3 :migrate
Plugins plugin_remove/2 :state, which accepts :discard

A route handler can be an Action or Flow module, or a handler Map such as %{kind: :forward, target: %{kind: :parent}}.

Next steps