Building Skills in JIDO
View SourceOverview
Skills are the fundamental building blocks of agent capabilities in JIDO. A Skill encapsulates:
- Signal routing and handling patterns
- State management and isolation
- Process supervision
- Configuration management
- Runtime adaptation
Think of Skills as composable "feature packs" that give agents new abilities. Just as a human might learn new skills like "cooking" or "programming", JIDO agents gain new capabilities by incorporating Skills.
Core Concepts
1. Skill Structure
A Skill is defined by several key components:
defmodule MyApp.WeatherMonitorSkill do
use Jido.Skill,
name: "weather_monitor",
description: "Monitors weather conditions and generates alerts",
category: "monitoring",
tags: ["weather", "alerts"],
vsn: "1.0.0",
schema_key: :weather,
signals: [
input: ["weather.data.received", "weather.alert.*"],
output: ["weather.alert.generated"]
],
config: [
weather_api: [
type: :map,
required: true,
doc: "Weather API configuration"
]
]
endLet's break down each component:
name: Unique identifier for the skill (required)description: Human-readable explanation of the skill's purposecategory: Broad classification for organizationtags: List of searchable tagsvsn: Version string for compatibility checkingschema_key: Atom key for state namespace isolationsignals: Input/output signal patterns the skill handlesconfig: Configuration schema for validation
2. State Management
Skills use schema_key for state namespace isolation. This prevents different skills from accidentally interfering with each other's state:
def initial_state do
%{
current_conditions: nil,
alert_history: [],
last_update: nil
}
endThis state will be stored under the skill's schema_key in the agent's state map:
%{
weather: %{ # Matches schema_key
current_conditions: nil,
alert_history: [],
last_update: nil
}
}3. Signal Routing
Skills define signal routing patterns using a combination of exact matches, wildcards, and pattern matching functions:
def router do
[
# High priority alerts
%{
path: "weather.alert.**",
instruction: %{
action: Actions.GenerateWeatherAlert
},
priority: 100
},
# Process incoming data
%{
path: "weather.data.received",
instruction: %{
action: Actions.ProcessWeatherData
}
},
# Match severe conditions
%{
path: "weather.condition.*",
match: fn signal ->
get_in(signal.data, [:severity]) >= 3
end,
instruction: %{
action: Actions.GenerateWeatherAlert
},
priority: 75
}
]
end4. Process Supervision
Skills can define child processes that need to run alongside the agent:
def child_spec(config) do
[
{WeatherAPI.Sensor,
[
name: "weather_api",
config: config.weather_api,
interval: :timer.minutes(15)
]},
{WeatherAlerts.Monitor,
[
name: "weather_alerts",
config: config.alerts
]}
]
endBuilding Skills
Step 1: Define the Skill Module
defmodule MyApp.DataProcessingSkill do
use Jido.Skill,
name: "data_processor",
description: "Processes and transforms data streams",
schema_key: :processor,
signals: [
input: ["data.received.*", "data.transform.*"],
output: ["data.processed.*"]
],
config: [
batch_size: [
type: :pos_integer,
default: 100,
doc: "Number of items to process in each batch"
]
]
endStep 2: Implement Required Callbacks
# Initial state for the skill's namespace
def initial_state do
%{
processed_count: 0,
last_batch: nil,
error_count: 0
}
end
# Child processes to supervise
def child_spec(config) do
[
{DataProcessor.BatchWorker,
[
name: "batch_worker",
batch_size: config.batch_size
]}
]
end
# Signal routing rules
def router do
[
%{
path: "data.received.*",
instruction: %{
action: Actions.ProcessData
}
}
]
endStep 3: Define Actions
defmodule MyApp.DataProcessingSkill.Actions do
defmodule ProcessData do
use Jido.Action,
name: "process_data",
description: "Processes incoming data batch",
schema: [
data: [type: {:list, :map}, required: true]
]
def run(%{data: data}, context) do
# Access skill config from context
batch_size = get_in(context, [:config, :batch_size])
# Process data...
{:ok, %{
processed: transformed_data,
count: length(transformed_data)
}}
end
end
endTesting Skills
1. Unit Testing Core Components
defmodule MyApp.DataProcessingSkillTest do
use ExUnit.Case
alias MyApp.DataProcessingSkill
describe "skill configuration" do
test "validates config schema" do
assert {:ok, config} =
Jido.Skill.validate_config(
DataProcessingSkill,
%{batch_size: 50}
)
assert config.batch_size == 50
end
test "rejects invalid config" do
assert {:error, error} =
Jido.Skill.validate_config(
DataProcessingSkill,
%{batch_size: -1}
)
assert error.type == :validation_error
end
end
describe "signal validation" do
test "accepts valid signal patterns" do
signal = %Jido.Signal{
type: "data.received.batch",
data: %{items: [1, 2, 3]}
}
assert :ok =
Jido.Skill.validate_signal(
signal,
DataProcessingSkill.signals()
)
end
end
end2. Integration Testing
defmodule MyApp.DataProcessingSkill.IntegrationTest do
use ExUnit.Case
setup do
# Start necessary processes
start_supervised!(DataProcessor.BatchWorker)
:ok
end
test "processes data through complete flow" do
# Create test signal
signal = %Jido.Signal{
type: "data.received.batch",
data: %{items: [1, 2, 3]}
}
# Find matching route
[route] = Enum.filter(
DataProcessingSkill.router(),
&(&1.path == "data.received.*")
)
# Execute action
{:ok, result} = route.instruction.action.run(
%{data: signal.data.items},
%{config: %{batch_size: 10}}
)
assert result.count == 3
assert length(result.processed) == 3
end
endBest Practices
State Isolation
- Use meaningful
schema_keynames - Keep state focused and minimal
- Document state structure
- Consider persistence needs
- Use meaningful
Signal Design
- Use consistent naming patterns
- Document signal formats
- Include necessary context
- Consider routing efficiency
Configuration
- Validate thoroughly
- Provide good defaults
- Document all options
- Consider runtime changes
Process Management
- Supervise child processes
- Handle crashes gracefully
- Monitor resource usage
- Consider distribution
Testing
- Test configuration validation
- Test signal routing
- Test state transitions
- Test process lifecycle
- Use property-based tests for complex logic
Common Patterns
1. Stateful Processing
defmodule StatefulSkill do
use Jido.Skill,
name: "stateful_processor",
schema_key: :processor
def router do
[
%{
path: "data.received",
instruction: %{
action: Actions.Process
}
}
]
end
# State updates are handled by the action itself
defmodule Actions.Process do
use Jido.Action,
name: "process_data"
def run(params, context) do
# Process data and return updates for state
{:ok, %{
last_result: processed_data,
timestamp: DateTime.utc_now()
}}
end
end
end2. Conditional Routing
def router do
[
%{
path: "event.*",
match: fn signal ->
signal.data.priority == :high
end,
instruction: %{
action: Actions.HandleHighPriority
},
priority: 100
}
]
end3. State Management
defmodule StateManagementSkill do
use Jido.Skill,
name: "state_manager",
schema_key: :manager
def router do
[
%{
path: "data.update",
instruction: %{
action: Actions.UpdateData
}
}
]
end
defmodule Actions.UpdateData do
use Jido.Action,
name: "update_data"
def run(_params, context) do
# Actions can read current state from context
current_count = get_in(context, [:state, :manager, :count]) || 0
# Return updates to be applied to state
{:ok, %{
count: current_count + 1,
last_update: DateTime.utc_now()
}}
end
end
endTroubleshooting
Common issues and solutions:
Signal Not Routing
- Check signal type matches patterns
- Verify skill is registered with agent
- Check priority conflicts
- Enable debug logging
State Not Updating
- Verify transform function
- Check schema_key path
- Validate state structure
- Check action results
Process Crashes
- Review supervision strategy
- Check resource limits
- Monitor error counts
- Add detailed logging
Advanced Topics
1. Dynamic Configuration
Skills can adapt their behavior based on configuration:
defmodule DynamicSkill do
use Jido.Skill,
name: "dynamic_processor",
schema_key: :processor,
config: [
mode: [
type: {:enum, [:fast, :thorough]},
default: :fast,
doc: "Processing mode to use"
]
]
def router do
[
%{
path: "data.*",
match: fn signal ->
# Can use config to determine routing
config = signal.metadata.config
should_process?(signal.data, config.mode)
end,
instruction: %{
action: Actions.ProcessData
}
}
]
end
defmodule Actions.ProcessData do
use Jido.Action,
name: "process_data"
def run(params, context) do
# Access config from context to determine behavior
mode = get_in(context, [:config, :mode])
result = case mode do
:fast -> quick_process(params)
:thorough -> detailed_process(params)
end
{:ok, result}
end
end
end2. Composable Skills
Skills can be composed to build more complex capabilities:
defmodule CompositeSkill do
use Jido.Skill,
name: "composite",
signals: merge_signals([
WeatherSkill.signals(),
AlertSkill.signals()
])
def child_spec(config) do
WeatherSkill.child_spec(config) ++
AlertSkill.child_spec(config)
end
end3. Distributed Skills
Skills can operate across nodes:
def child_spec(config) do
[
{DistributedWorker,
[
name: {:global, "worker_1"},
nodes: config.cluster_nodes
]}
]
endConclusion
Skills are a powerful abstraction for building modular, composable agent capabilities. By following the patterns and practices in this guide, you can create robust, maintainable skills that enhance your agents' abilities while maintaining clean separation of concerns.
Remember:
- Keep skills focused and single-purpose
- Design clear signal interfaces
- Manage state carefully
- Test thoroughly
- Document extensively
For more examples and advanced patterns, refer to the test suite and example implementations in the JIDO codebase.