Building Stateless Agents in Jido
View SourceOverview
Jido Agents may be used in either a stateful or stateless manner. Stateless agents provide an immutable, functional approach to workflow orchestration allowing the developer to overlay their own OTP lifecycle as needed. Stateless agents are also ideal for testing and development, as they can be manipulated and inspected as pure Elixir data structures.
Alternatively, you may use the Jido.Agent.Server
module to create a stateful agent that uses a GenServer for its lifecycle. Learn more about stateful agents in the next section.
Core Design Principles
- Immutability: All state transitions create new agent instances
- Pure Functions: Operations have no side effects
- Explicit State Flow: State changes are always returned, never mutated
- Functional Composition: Operations chain through return values
Implementation Guide
Basic Agent Structure
defmodule MyApp.StatelessAgent do
use Jido.Agent,
name: "stateless_agent",
description: "Pure functional workflow processor",
schema: [
input: [
type: :map,
required: true,
doc: "Input data to process"
],
format: [
type: {:in, ["json", "xml", "yaml"]},
required: true,
doc: "Data format"
],
metadata: [
type: :map,
default: %{},
doc: "Optional processing metadata"
],
status: [
type: {:in, [:pending, :processing, :complete]},
default: :pending,
doc: "Processing status"
]
],
actions: [
MyApp.Actions.ValidateInput,
MyApp.Actions.TransformData,
MyApp.Actions.EnrichMetadata
]
# No GenServer callbacks or state mutation
# All operations use core Agent module functions
end
Understanding the Agent Struct
At its core, a Jido agent is an Elixir struct that can be freely inspected and pattern matched against. This transparency is crucial for testing and debugging:
%MyApp.StatelessAgent{
id: "agent_123", # Unique identifier
state: %{}, # Current validated state
pending_instructions: queue, # Erlang queue of pending actions
actions: [], # List of allowed action modules
dirty_state?: false, # State modification tracking
result: nil # Last execution result
}
Inspecting Agent State
You can pattern match and inspect any aspect of the agent:
# Pattern match on specific states
def handle_agent(%{state: %{status: :complete}} = agent) do
# Handle completed agent
end
# Inspect pending instructions
instruction_count = :queue.len(agent.pending_instructions)
# Check allowed actions
allowed_actions = agent.actions
Core Operations
Action Registration
Before an agent can execute actions, they must be registered either at compile-time or runtime:
# Compile-time registration
use Jido.Agent,
actions: [
MyApp.Actions.ValidateInput,
MyApp.Actions.ProcessData
]
# Runtime registration
{:ok, agent} = Jido.Agent.register_action(agent, MyApp.Actions.NewAction)
{:ok, agent} = Jido.Agent.register_action(agent, [Action1, Action2])
# Remove action
{:ok, agent} = Jido.Agent.deregister_action(agent, MyApp.Actions.OldAction)
# Check registration
actions = Jido.Agent.registered_actions(agent)
Only registered actions can be used in planning - this provides a safety mechanism against executing unintended code.
Core Method Architecture
Jido agents provide four primary methods that work together for workflow orchestration:
New agent instances are created through the core Agent module:
# Basic creation
agent = MyApp.StatelessAgent.new()
# With initial state
agent = MyApp.StatelessProcessor.new("custom_id", %{
input: %{key: "value"},
format: "json"
})
1. State Management with set/3
The set/3
function manages agent state through immutable updates:
@spec set(agent :: t(), attrs :: map() | keyword(), opts :: keyword()) ::
{:ok, t()} | {:error, Error.t()}
Parameters:
agent
: The agent struct to updateattrs
: Map or keyword list of attributes to merge into stateopts
: Configuration options:strict_validation
- Enable/disable strict schema validation (default: false)
The set operation:
- Deep merges new attributes with existing state
- Validates against the agent's schema
- Sets the dirty_state? flag
- Returns a new agent instance
# Basic state update
{:ok, updated} = Jido.Agent.set(agent, %{
status: :processing,
metadata: %{started_at: DateTime.utc_now()}
})
# With strict validation
{:ok, validated} = Jido.Agent.set(agent, attrs, strict_validation: true)
{:ok, updated_agent} = Jido.Agent.set(agent, %{
input: new_input,
metadata: %{processed_at: DateTime.utc_now()}
})
# Validation happens automatically
{:error, reason} = Jido.Agent.set(agent, %{
format: "invalid" # Will fail format validation
})
2. Workflow Planning with plan/3
The plan/3
function builds an immutable queue of instructions:
@spec plan(agent :: t(), instructions :: instruction() | [instruction()], context :: map()) ::
{:ok, t()} | {:error, Error.t()}
Parameters:
agent
: The agent struct to plan actions forinstructions
: Single action module or list of instruction specifications- Single module:
MyAction
- With params:
{MyAction, %{param: value}}
- Multiple actions:
[Action1, {Action2, %{}}]
- Single module:
context
: Shared context map passed to all instructions
The planning process:
- Validates that all actions are registered
- Normalizes instructions into consistent format
- Adds instructions to pending queue
- Returns new agent with updated queue
# Single action
{:ok, agent} = Jido.Agent.plan(agent, ValidateAction)
# Multiple actions with context
{:ok, agent} = Jido.Agent.plan(
agent,
[
ValidateAction,
{ProcessAction, %{mode: :strict}},
FinalizeAction
],
%{request_id: "req_123"}
)
```elixir
{:ok, agent_with_plan} = Jido.Agent.plan(agent, [
ValidateInput,
{TransformData, %{target_format: "xml"}},
EnrichMetadata
])
# Planning preserves immutability
assert agent != agent_with_plan
3. Execution with run/2
The run/2
function executes pending instructions through a runner:
@spec run(agent :: t(), opts :: keyword()) ::
{:ok, t(), [Directive.t()]} | {:error, Error.t()}
Parameters:
agent
: The agent struct containing pending instructionsopts
: Execution options:runner
- Custom runner module (default: agent's configured runner):apply_state
- Merge results into agent state (default: true):timeout
- Execution timeout in milliseconds:retry
- Enable automatic retries (default: false):max_retries
- Maximum retry attempts:backoff
- Retry backoff strategy
The execution process:
- Validates runner configuration
- Executes pending instructions in order
- Handles any returned directives
- Returns new agent with results and directives
# Basic execution
{:ok, agent, directives} = Jido.Agent.run(agent)
# With custom options
{:ok, agent, directives} = Jido.Agent.run(agent,
runner: CustomRunner,
apply_state: false,
timeout: 5000,
retry: true,
max_retries: 3
)
```elixir
{:ok, final_agent, directives} = Jido.Agent.run(agent_with_plan)
# Results stored in agent
result = final_agent.result
# Original agent unchanged
assert agent_with_plan != final_agent
4. Composite Operations with cmd/4
The cmd/4
function composes set, plan, and run operations into a single call:
@spec cmd(agent :: t(), instructions :: instruction() | [instruction()],
attrs :: map(), opts :: keyword()) ::
{:ok, t(), [Directive.t()]} | {:error, Error.t()}
Parameters:
agent
: The agent struct to operate oninstructions
: Action specifications to planattrs
: State attributes to setopts
: Combined options for all operations- All options from
set/3
- All options from
plan/3
- All options from
run/2
- All options from
The command process:
- Sets new state with validation
- Plans specified instructions
- Executes instructions with runner
- Returns final agent state and directives
# Complete workflow in one call
{:ok, agent, directives} = Jido.Agent.cmd(
agent,
[ValidateAction, ProcessAction],
%{status: :processing, input: data},
strict_validation: true,
timeout: 10_000
)
```elixir
{:ok, final_agent, directives} = Jido.Agent.cmd(
agent,
[ValidateInput, TransformData],
%{input: new_data, format: "json"}
)
Purpose and Testing Philosophy
Stateless agents serve a crucial role in Jido's development workflow:
Test-First Development
- Write and test agent logic before adding server capabilities
- Focus on pure business logic without distributed system complexity
- Ensure core workflows work correctly in isolation
Predictable Testing
- Every operation produces new agent instances
- No hidden state or side effects
- Deterministic results for given inputs
Comprehensive Coverage
- Test state validation rules
- Verify action planning logic
- Ensure proper error handling
- Validate workflow composition
Example test suite:
defmodule MyApp.StatelessAgentTest do
use ExUnit.Case
setup do
agent = MyApp.StatelessAgent.new("test_id", %{
status: :pending
})
{:ok, agent: agent}
end
test "validates state updates", %{agent: agent} do
# Test state validation
{:error, error} = Jido.Agent.set(agent, %{
status: :invalid_status
})
assert error.type == :validation_error
# Test successful update
{:ok, updated} = Jido.Agent.set(agent, %{
status: :processing
})
assert updated.state.status == :processing
end
test "plans allowed actions only", %{agent: agent} do
# Test unregistered action
{:error, error} = Jido.Agent.plan(agent, UnregisteredAction)
# Register and plan action
{:ok, agent} = Jido.Agent.register_action(agent, AllowedAction)
{:ok, agent} = Jido.Agent.plan(agent, AllowedAction)
assert :queue.len(agent.pending_instructions) == 1
end
test "executes complete workflow", %{agent: agent} do
{:ok, final_agent, _directives} = Jido.Agent.cmd(
agent,
[ValidateAction, ProcessAction],
%{input: test_data()}
)
assert final_agent.state.status == :complete
assert final_agent.result != nil
end
end
Once core logic is verified, you can add server capabilities with confidence that the underlying agent behavior works correctly.
Advanced Patterns
Functional Composition
Chain operations using pattern matching:
def process_data(agent, input) do
with {:ok, agent1} <- Jido.Agent.set(agent, %{input: input}),
{:ok, agent2} <- Jido.Agent.plan(agent1, ValidateInput),
{:ok, agent3, _} <- Jido.Agent.run(agent2) do
{:ok, agent3}
end
end
State Flow Management
Track state transitions explicitly:
def safe_transform(agent, data) do
with {:ok, agent_with_data} <- set_with_validation(agent, data),
{:ok, agent_with_plan} <- plan_transformation(agent_with_data),
{:ok, final_agent, _} <- execute_transformation(agent_with_plan) do
{:ok, final_agent}
else
{:error, reason} -> handle_transform_error(reason)
end
end
defp set_with_validation(agent, data) do
Jido.Agent.set(agent, %{
input: data,
status: :processing
})
end
defp plan_transformation(agent) do
Jido.Agent.plan(agent, [
ValidateInput,
TransformData,
EnrichMetadata
])
end
defp execute_transformation(agent) do
Jido.Agent.run(agent)
end
Error Recovery
Implement explicit error handling:
def transform_with_retry(agent, data, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 3)
do_transform_with_retry(agent, data, 1, max_attempts)
end
defp do_transform_with_retry(agent, data, attempt, max_attempts) do
case safe_transform(agent, data) do
{:ok, transformed} ->
{:ok, transformed}
{:error, reason} when attempt < max_attempts ->
:timer.sleep(exponential_backoff(attempt))
do_transform_with_retry(agent, data, attempt + 1, max_attempts)
{:error, reason} ->
{:error, %{reason: reason, attempts: attempt}}
end
end
Best Practices
1. State Management
- Keep state minimal and focused
- Use computed properties when possible
- Validate state transitions early
- Return descriptive errors
2. Action Planning
- Build complete instruction sets
- Validate action sequences
- Consider compensation strategies
- Plan for retries
3. Execution
- Handle all error cases
- Use timeouts appropriately
- Implement backoff strategies
- Consider partial successes
4. Testing
Stateless agents enable simple testing:
test "processes data immutably" do
agent = StatelessAgent.new()
{:ok, updated} = Jido.Agent.set(agent, %{
input: test_data,
format: "json"
})
assert agent != updated
assert updated.state.input == test_data
end
Key Benefits
- Predictable Behavior: Pure functions with no side effects
- Thread Safety: Immutable state eliminates race conditions
- Easy Testing: Deterministic outputs for given inputs
- Simple Reasoning: Explicit state transitions
- Functional Composition: Natural operation chaining
Anti-Patterns to Avoid
- State Mutation: Never modify agent state directly
- Hidden Side Effects: Keep operations pure and explicit
- Implicit Dependencies: Make requirements clear in function signatures
- Complex State: Keep state minimal and focused
- Mixed Paradigms: Don't mix stateless and stateful approaches
See Also
Next Steps
- Review the Agent module documentation
- Explore the examples in the Getting Started Guide
- Join the GitHub Discussions
This pattern provides a solid foundation for building predictable, maintainable agent workflows while leveraging Jido's core orchestration capabilities.