Migration Guide: Jido 1.x to 2.0

View Source

After: You can upgrade from Jido 1.x with minimal surprises.

This guide helps you migrate existing Jido applications to version 2.0. The migration can be done incrementally—start with the minimum changes to get running, then adopt new patterns as needed.

Breaking Changes Summary

AreaV1V2Migration Effort
RuntimeGlobal singletonInstance-scoped supervisorSmall
Agent LifecycleAgentServer.start/1Jido.start_agent/3Small-Medium
Side EffectsMixed in callbacksDirective-basedMedium
MessagingJido.InstructionCloudEvents SignalsMedium-Large
OrchestrationRunners (Simple/Chain)Strategies + PlansMedium
ActionsJido.Actions.*Jido.Tools.*Small
ValidationNimbleOptionsZoi schemasSmall-Medium
ErrorsAd hoc tuplesSplode structured errorsSmall-Medium

Migration Path Overview

Choose your migration depth based on your timeline and needs:

  1. Minimal (1-2 hours): Add supervision tree, update agent starts
  2. Intermediate (1 day): Adopt Skills, use Directives for side effects
  3. Full (1-2 weeks): Pure cmd/2, Zoi schemas, Strategies, Plans

Step 1: Add Jido to Your Supervision Tree

V2 uses instance-scoped supervisors instead of a global singleton. Define an instance module and add it to your supervision tree.

# lib/my_app/jido.ex
defmodule MyApp.Jido do
  use Jido, otp_app: :my_app
end
# config/config.exs
config :my_app, MyApp.Jido,
  max_tasks: 1000,
  agent_pools: []
# lib/my_app/application.ex
defmodule MyApp.Application do
  use Application

  def start(_type, _args) do
    children = [
      # Add Jido as a supervised child
      MyApp.Jido,
      
      # Your other children...
      MyApp.Repo,
      MyAppWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: MyApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

The instance module provides functions for managing agents that you'll use throughout your application.

Step 2: Update Agent Starts

Replace direct start_link calls with your instance module's start_agent/2.

Before (V1)

# Starting an agent directly
{:ok, pid} = MyAgent.start_link(id: "agent-1")

# Or via AgentServer
{:ok, pid} = Jido.AgentServer.start_link(
  agent: MyAgent,
  agent_opts: [id: "agent-1"]
)

After (V2)

# Start via your instance module
{:ok, pid} = MyApp.Jido.start_agent(MyAgent, id: "agent-1")

# With additional options
{:ok, pid} = MyApp.Jido.start_agent(MyAgent,
  id: "agent-1",
  initial_state: %{counter: 0},
  strategy: Jido.Strategy.Direct
)

Why This Matters

  • Discovery: Agents are automatically registered and discoverable via MyApp.Jido.whereis/1
  • Lifecycle: The supervisor handles restarts and cleanup
  • Hierarchy: Enables parent-child agent relationships

Step 3: Update Lifecycle Calls

Replace direct process calls with your instance module's functions.

Before (V1)

# Stopping an agent
AgentServer.stop(pid)
GenServer.stop(pid)

# Finding an agent
pid = Process.whereis(:"agent_agent-1")

After (V2)

# Stop via instance module
MyApp.Jido.stop_agent("agent-1")

# Find via discovery
pid = MyApp.Jido.whereis("agent-1")

# List all agents
agents = MyApp.Jido.list_agents()

Step 4: Adopt Directives for Side Effects

V2 separates pure state transformations from side effects using Directives. This is the biggest conceptual change and can be adopted incrementally.

Before (V1): Ad Hoc Side Effects

defmodule MyAgent do
  use Jido.Agent

  def handle_result(agent, result) do
    # Side effect mixed with state logic
    Phoenix.PubSub.broadcast(MyApp.PubSub, "events", result)
    
    # External API call
    HTTPoison.post!("https://api.example.com/webhook", result)
    
    # Update state
    %{agent | state: Map.put(agent.state, :last_result, result)}
  end
end

After (V2): Declarative Directives

defmodule MyAgent do
  use Jido.Agent
  
  alias Jido.Agent.Directive
  alias Jido.Signal

  def cmd(agent, %Signal{type: "result.received"} = signal) do
    result = signal.data
    
    # Pure state update
    updated_agent = %{agent | 
      state: Map.put(agent.state, :last_result, result)
    }
    
    # Directives describe effects, don't execute them
    directives = [
      Directive.emit(
        Signal.new!("result.processed", result, source: "/agent"),
        {:pubsub, topic: "events"}
      ),
      Directive.emit(
        Signal.new!("webhook.send", result, source: "/agent"),
        {:http, url: "https://api.example.com/webhook"}
      )
    ]
    
    {updated_agent, directives}
  end
end

Core Directives

DirectivePurposeExample
EmitDispatch a signal via adaptersDirective.emit(signal, {:pubsub, topic: "events"})
SpawnSpawn a generic BEAM processDirective.spawn(Task, :async, [fn -> work() end])
SpawnAgentSpawn a child agent with hierarchyDirective.spawn_agent(ChildAgent, id: "child-1")
StopChildStop a tracked child agentDirective.stop_child("child-1")
ScheduleSchedule a delayed messageDirective.schedule(signal, delay: 5_000)
StopStop the agent processDirective.stop(:normal)
ErrorSignal an errorDirective.error(:validation_failed)

Step 5: Use CloudEvents Signals

V2 uses CloudEvents-compliant signals instead of ad hoc messages.

Before (V1): Ad Hoc Messages

# Sending messages
send(pid, {:task_complete, %{id: 123, result: "done"}})
GenServer.cast(pid, {:process, data})

# Handling in agent
def handle_info({:task_complete, payload}, state) do
  # process...
  {:noreply, state}
end

After (V2): Structured Signals

alias Jido.Signal

# Creating signals
signal = Signal.new!(
  "task.completed",
  %{id: 123, result: "done"},
  source: "/workers/processor-1"
)

# Dispatching to a specific agent (synchronous)
{:ok, agent} = Jido.AgentServer.call(pid, signal)

# Or asynchronously
:ok = Jido.AgentServer.cast(pid, signal)

# Handling in agent (via cmd/2)
def cmd(agent, %Signal{type: "task.completed"} = signal) do
  result = signal.data.result
  {update_state(agent, result), []}
end

Signal Anatomy

%Jido.Signal{
  type: "order.placed",           # Event type (required)
  source: "/checkout/web",        # Origin (required)
  id: "550e8400-...",             # Unique ID (auto-generated)
  data: %{order_id: 123},         # Payload
  subject: "user/456",            # Optional subject
  time: ~U[2024-01-15 10:30:00Z]  # Timestamp
}

Step 6: Migrate Actions to Tools

The Jido.Actions.* namespace has been renamed to Jido.Tools.*.

Before (V1)

defmodule MyApp.Actions.SendEmail do
  use Jido.Action,
    name: "send_email",
    description: "Sends an email",
    schema: [
      to: [type: :string, required: true],
      subject: [type: :string, required: true]
    ]

  @impl true
  def run(params, _context) do
    # send email...
    {:ok, %{sent: true}}
  end
end

After (V2)

defmodule MyApp.Tools.SendEmail do
  use Jido.Tool,
    name: "send_email",
    description: "Sends an email"

  @schema Zoi.struct(__MODULE__, %{
    to: Zoi.string(description: "Recipient email"),
    subject: Zoi.string(description: "Email subject")
  })

  @impl true
  def run(params, _context) do
    {:ok, %{sent: true}}
  end
end

Step 7: Adopt Zoi Schemas

V2 uses Zoi for schema definitions instead of NimbleOptions.

Before (V1): NimbleOptions

defmodule MyAgent do
  use Jido.Agent,
    name: "my_agent",
    schema: [
      name: [type: :string, required: true],
      count: [type: :integer, default: 0],
      tags: [type: {:list, :string}, default: []]
    ]
end

After (V2): Zoi Schemas

defmodule MyAgent do
  use Jido.Agent,
    name: "my_agent"

  @schema Zoi.struct(__MODULE__, %{
    name: Zoi.string(description: "Agent name"),
    count: Zoi.integer(default: 0),
    tags: Zoi.list(Zoi.string()) |> Zoi.default([])
  }, coerce: true)

  @type t :: unquote(Zoi.type_spec(@schema))
  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)
end

Zoi Benefits

  • Single source of truth for types, defaults, and validation
  • Automatic typespec generation
  • Coercion support
  • Better error messages

Step 8: Migrate to Splode Errors

V2 uses Splode for structured error handling.

Before (V1): Ad Hoc Tuples

def process(data) do
  case validate(data) do
    :ok -> {:ok, result}
    :error -> {:error, :validation_failed}
    {:error, reason} -> {:error, {:processing_error, reason}}
  end
end

After (V2): Splode Errors

defmodule MyApp.Errors do
  use Splode, error_classes: [
    validation: MyApp.Errors.Validation,
    processing: MyApp.Errors.Processing
  ]
end

defmodule MyApp.Errors.Validation.InvalidInput do
  use Splode.Error, fields: [:field, :reason], class: :validation
  
  def message(%{field: field, reason: reason}) do
    "Invalid #{field}: #{reason}"
  end
end

# Usage
def process(data) do
  case validate(data) do
    :ok -> 
      {:ok, result}
    {:error, field, reason} -> 
      {:error, MyApp.Errors.Validation.InvalidInput.exception(
        field: field, 
        reason: reason
      )}
  end
end

New Features in V2 (Optional)

These features are new in V2 and can be adopted as needed:

Parent-Child Agent Hierarchy

def cmd(agent, %Signal{type: "spawn.worker"} = signal) do
  {agent, [
    Directive.spawn_agent(WorkerAgent, 
      id: "worker-#{signal.data.id}",
      parent: agent
    )
  ]}
end

# Child can emit to parent
Directive.emit_to_parent(child_agent, signal)

Skills System

defmodule MyAgent do
  use Jido.Agent,
    skills: [
      MyApp.Skills.WebSearch,
      MyApp.Skills.DataAnalysis
    ]
end

Strategy Pattern

# Direct execution (default)
MyApp.Jido.start_agent(MyAgent, 
  strategy: Jido.Strategy.Direct
)

# FSM-based execution
MyApp.Jido.start_agent(MyAgent, 
  strategy: Jido.Strategy.FSM,
  strategy_opts: [initial_state: :idle]
)

Telemetry

V2 emits telemetry events for observability:

:telemetry.attach(
  "my-handler",
  [:jido, :agent, :cmd, :stop],
  fn _event, measurements, metadata, _config ->
    Logger.info("cmd took #{measurements.duration}ns")
  end,
  nil
)

Common Migration Patterns

Pattern 1: Gradual Directive Adoption

You don't need to convert all side effects at once. Start with the most critical paths:

def cmd(agent, signal) do
  # New code uses directives
  result = process(signal)
  
  # Legacy code still works (but should be migrated)
  LegacyNotifier.notify(result)
  
  {%{agent | state: result}, [
    Directive.emit(Signal.new!("processed", result, source: "/agent"), :default)
  ]}
end

Pattern 2: Wrapper for Legacy Agents

If you have many agents, your instance module already provides the wrapper:

# Define your instance module once
defmodule MyApp.Jido do
  use Jido, otp_app: :my_app
end

# Then use it throughout your application
MyApp.Jido.start_agent(MyAgent, id: "agent-1")
MyApp.Jido.stop_agent("agent-1")

Pattern 3: Signal Adapter for Legacy Messages

Bridge old message formats to signals:

def handle_info({:legacy_event, payload}, state) do
  signal = Signal.new!("legacy.event", payload, source: "/legacy")
  handle_info(signal, state)
end

Troubleshooting

"Agent not found" errors

Ensure you're using the correct Jido instance name:

# Wrong
Jido.start_agent(Jido, MyAgent, id: "test")

# Right
Jido.start_agent(MyApp.Jido, MyAgent, id: "test")

Directives not executing

Directives are only executed when returned from cmd/2. Ensure you're returning them:

# Wrong - directive is created but not returned
def cmd(agent, signal) do
  Directive.emit(signal, :default)
  {agent, []}
end

# Right
def cmd(agent, signal) do
  {agent, [Directive.emit(signal, :default)]}
end

Schema validation errors

If migrating from NimbleOptions, ensure required fields are marked:

# Zoi doesn't have `required: true`, fields are required by default
# Use Zoi.optional() for optional fields
@schema Zoi.struct(__MODULE__, %{
  name: Zoi.string(),                           # Required
  description: Zoi.string() |> Zoi.optional()   # Optional
})

Getting Help