Jido Pure Functional Agent Design vs GenServer: A Complete Technical Guide
Jido separates decision logic from runtime side-effects by implementing agents as immutable data structures that return descriptive directives, while traditional GenServer-based agents combine state mutation and I/O within process callbacks.
Jido's architecture represents a fundamental shift in how Elixir developers build autonomous agents. Unlike conventional OTP designs where the GenServer process owns mutable state and executes side-effects directly, the agentjido/jido repository implements a pure functional pattern that makes business logic deterministic, composable, and trivial to test.
Core Architectural Differences
State Management: Mutation vs. Transformation
Traditional GenServer agents maintain mutable state within the process loop. When handle_call/3 or handle_cast/2 executes, it updates the state in-place using the %State{} struct returned from the callback.
In contrast, Jido agents in lib/jido/agent.ex are immutable structs (%Jido.Agent{}) that never mutate. Every operation returns a new agent value. The cmd/2 function implements this contract by returning {agent, directives} tuples, ensuring referential transparency—given the same inputs, you always receive the same outputs.
Command Execution: Impure vs. Pure Functions
GenServer-based designs execute commands and side-effects simultaneously. When you call GenServer.call/3, the process performs the computation and any I/O within the same synchronous step.
Jido's Jido.Agent.cmd/2 is strictly a pure function. It computes state transitions and returns directives—descriptions of external work like emitting signals or spawning children—but performs no actual side-effects. This separation allows you to test complex agent logic without mocking processes or network calls.
Side Effect Handling: Inline vs. Runtime Delegation
Traditional agents execute side-effects directly inside GenServer callbacks using Process.send/2 or Task.start/1. This tight coupling makes testing difficult and can lead to race conditions.
Jido delegates all side-effects to Jido.AgentServer in lib/jido/agent_server.ex—the sole mutable process in the architecture. The server receives directives from the pure agent, queues them, and drains them via the DirectiveExec protocol. This ensures that your business logic remains pure while the runtime handles the messy reality of I/O and process management.
Key Implementation Files
| File | Purpose |
|---|---|
lib/jido/agent.ex |
Core pure-functional API including new/1, set/2, validate/2, and cmd/2. Implements the __using__/1 macro for compile-time validation and plugin wiring. |
lib/jido/agent_server.ex |
GenServer runtime that owns mutable state, queues directives, performs signal routing, and executes side-effects via the DirectiveExec protocol. |
lib/jido/agent/strategy.ex |
Defines agent initialization strategies (default Direct) and custom execution semantics. |
lib/jido/agent/directive.ex |
Type definitions for effect descriptors including Emit, Spawn, Schedule, Error, and Stop. |
lib/jido/agent_server/state.ex |
Runtime state struct holding the directive queue, debug buffer, and child process information. |
lib/jido/plugin/*.ex |
Plugin modules that extend routing, scheduling, and monitoring without modifying core agent logic. |
Practical Code Examples
Defining a Pure Agent
defmodule CounterAgent do
use Jido.Agent,
name: "counter",
schema: [
count: [type: :integer, default: 0]
]
end
This module defines only data and schema. No GenServer callbacks are required, and no process starts when you call CounterAgent.new().
Writing Pure Actions
defmodule Increment do
@behaviour Jido.Action
@impl true
def execute(agent, %{by: by}) do
# Pure state transition – returns a new agent struct
{:ok, %{agent | state: Map.update!(agent.state, :count, &(&1 + by))}, []}
end
end
The execute/2 callback returns {:ok, agent, directives}. Here, the directive list is empty ([]), meaning no side-effects are requested.
Unit Testing Without Processes
agent = CounterAgent.new()
{agent, _dirs} = CounterAgent.cmd(agent, {Increment, %{by: 5}})
assert agent.state.count == 5
No GenServer starts, no mocking required, and tests execute instantly because the logic is pure.
Running Under the Runtime
{:ok, pid} = Jido.AgentServer.start_link(agent: CounterAgent)
# Send a signal that maps to the Increment action via a route
signal = Jido.Signal.new!("counter.increment", %{by: 3})
:ok = Jido.AgentServer.cast(pid, signal)
# Wait for completion (runtime will queue a directive, then drain it)
{:ok, state} = Jido.AgentServer.state(pid)
assert state.agent.state.count == 3
The AgentServer translates the signal to the Increment action, invokes cmd/2, receives the directive list, queues it, and drains the queue to execute any side-effects.
Handling Side Effects with Directives
defmodule LogIncrement do
@behaviour Jido.Action
@impl true
def execute(agent, %{by: by}) do
{:ok, agent, [%Jido.Agent.Directive.Emit{signal: %Jido.Signal{type: "log", data: %{msg: "inc #{by}"}}}]}
end
end
LogIncrement returns an Emit directive. The pure agent remains immutable, while the AgentServer runtime dispatches the "log" signal to registered handlers.
Summary
- Immutable State: Jido agents (
%Jido.Agent{}) return new structs on every operation rather than mutating in-place like GenServer state. - Pure Functions: The
cmd/2contract enforces referential transparency—business logic produces deterministic outputs without side-effects. - Directive Pattern: Side-effects are described declaratively via directives (
Emit,Spawn,Schedule) and executed only by theAgentServerruntime. - Testability: Pure agents enable unit testing without process overhead, while integration tests isolate runtime concerns from business logic.
- Extensibility: Compile-time plugin wiring and lifecycle hooks (
on_before_cmd/2,on_after_cmd/3) extend functionality without compromising purity.
Frequently Asked Questions
How does Jido handle state persistence if agents are immutable?
Jido agents achieve persistence through the AgentServer runtime rather than in-place mutation. When cmd/2 returns a new agent struct, the AgentServer in lib/jido/agent_server.ex stores this updated struct in its process state. For durable storage, you can configure plugins that emit directives to persist snapshots to databases or disk, keeping the core agent logic pure while the runtime handles I/O.
Can I use Jido agents without starting an AgentServer process?
Yes. Because Jido.Agent.cmd/2 is a pure function, you can instantiate agents with MyAgent.new() and execute commands directly in tests or embedded systems without any process overhead. This is ideal for deterministic simulations, property-based testing, or edge computing scenarios where you want the agent logic without the OTP runtime weight. You only need AgentServer when you require asynchronous signal routing, side-effect execution, or supervision trees.
What happens if a directive fails during execution?
Directive failures are handled by the DirectiveExec protocol implementation within AgentServer. When the runtime drains the directive queue, it executes each directive and captures results. If a directive fails, the AgentServer can emit error signals, retry according to strategy configurations, or escalate to supervisor processes depending on your plugin configuration. The pure agent itself remains unaffected because it already produced the complete directive list—failure handling is purely a runtime concern.
How do lifecycle hooks differ from GenServer callbacks?
Jido lifecycle hooks like on_before_cmd/2 and on_after_cmd/3 are pure functions that transform the agent struct or directive list, whereas GenServer callbacks like handle_call/3 perform side-effects. These hooks execute synchronously during the cmd/2 pipeline in lib/jido/agent.ex, allowing you to validate commands, enrich state, or inject additional directives without spawning processes or sending messages. Because they are pure, you can test hook logic with simple function calls rather than complex process interactions.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →