ADR-0008: GenServer-based skill registry
View SourceStatus
Accepted
Context
Applications using Conjure need to:
- Load skills at startup
- Access skills throughout the application lifecycle
- Optionally reload skills at runtime (config changes, hot updates)
- Share skills across processes efficiently
Two patterns are possible:
Functional/Stateless: Load skills, pass them explicitly everywhere
{:ok, skills} = Conjure.load("/path")
Conjure.execute(tool_call, skills, opts)Stateful/GenServer: Register skills once, access by name
# At startup
{:ok, _} = Conjure.Registry.start_link(paths: ["/path"])
# Anywhere in application
skills = Conjure.Registry.list()
skill = Conjure.Registry.get("pdf")OTP applications commonly use supervision trees with named processes for shared state.
Decision
We will provide Conjure.Registry as an optional GenServer for stateful skill management.
The Registry:
- Loads skills from configured paths at startup
- Stores skills in process state (or ETS for concurrent access)
- Provides lookup by name
- Supports runtime reloading
- Integrates with supervision trees
defmodule Conjure.Registry do
use GenServer
# Client API
def start_link(opts \\ [])
def list(server \\ __MODULE__)
def get(server \\ __MODULE__, name)
def reload(server \\ __MODULE__)
def register(server \\ __MODULE__, skills)
# Also provide pure functional alternatives
def index(skills) # Create lookup map
def find(index, name) # Find in map
endUsage in supervision tree:
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{Conjure.Registry, name: MyApp.Skills, paths: ["/path/to/skills"]}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
endThe functional API remains available for users who prefer explicit state:
{:ok, skills} = Conjure.load("/path")
index = Conjure.Registry.index(skills)
skill = Conjure.Registry.find(index, "pdf")Consequences
Positive
- OTP-compliant design fits Elixir ecosystem conventions
- Supervision ensures skills survive process crashes
- Named process enables global access without passing state
- Runtime reloading for dynamic environments
- ETS-backed storage enables concurrent reads without contention
- Clear separation: Registry for state, other modules for logic
Negative
- Additional complexity for simple use cases
- Process naming can conflict in umbrella apps
- Global state makes testing slightly harder (must start/stop registry)
- Must handle registry not started errors
Neutral
- GenServer is optional; functional API always available
- Multiple registries can coexist with different names
- Registry doesn't own execution (just stores skills)
Implementation Details
State Structure
defmodule State do
defstruct [
:paths,
:skills,
:index,
:ets_table
]
endETS for Concurrent Access
For high-concurrency scenarios, skills are stored in ETS:
def init(opts) do
table = :ets.new(__MODULE__, [:set, :protected, read_concurrency: true])
paths = Keyword.get(opts, :paths, [])
{:ok, skills} = load_from_paths(paths)
Enum.each(skills, fn skill ->
:ets.insert(table, {skill.name, skill})
end)
{:ok, %State{paths: paths, skills: skills, ets_table: table}}
end
def handle_call({:get, name}, _from, state) do
result = case :ets.lookup(state.ets_table, name) do
[{^name, skill}] -> skill
[] -> nil
end
{:reply, result, state}
endReload Semantics
def handle_call(:reload, _from, state) do
case load_from_paths(state.paths) do
{:ok, skills} ->
:ets.delete_all_objects(state.ets_table)
Enum.each(skills, &:ets.insert(state.ets_table, {&1.name, &1}))
{:reply, :ok, %{state | skills: skills}}
{:error, reason} ->
# Keep old skills on reload failure
{:reply, {:error, reason}, state}
end
endAlternatives Considered
Application environment only
Store skills in application env. Rejected because:
- Not process-safe for updates
- No lifecycle management
- Awkward for multiple skill sets
Agent instead of GenServer
Simpler state wrapper. Rejected because:
- Less control over initialization
- No handle_info for future features (file watching)
- GenServer is standard for this pattern
Persistent term storage
Use :persistent_term for near-zero lookup cost. Rejected because:
- Global mutable state is dangerous
- Expensive to update (copies entire term)
- Overkill for typical skill counts
No registry (functional only)
Only provide functional loading. Rejected because:
- Forces users to solve state management
- Inconsistent with OTP conventions
- Makes runtime reload harder
Testing Considerations
# In tests, start registry in setup
setup do
start_supervised!({Conjure.Registry, paths: ["test/fixtures/skills"]})
:ok
end
# Or use functional API for isolation
test "loads skill" do
{:ok, skills} = Conjure.load("test/fixtures/skills")
assert length(skills) == 2
end