Packages
AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.
Current section
Files
Jump to
Current section
Files
lib/raxol/agent/curator.ex
defmodule Raxol.Agent.Curator do
@moduledoc """
Keeps agent-authored skills healthy: ages unused ones and bounds their growth.
The Curator runs over the usage telemetry in `Raxol.Agent.Skills.Store`. Its
deterministic phase moves a skill `active -> stale -> archived` as it goes
unused, where archived skills move under `<root>/.archive/` and drop out of
`skills_list`. It only ever touches skills whose `created_by` is `:agent`;
user-authored, external, and pinned skills are left alone.
Passes are gated on time and idleness: a pass runs only when at least
`interval_hours` (default 168) have elapsed since the last one AND the agent
has been idle for `min_idle_hours` (default 2). The first pass after start is
deferred a full interval. The runtime reports activity with `note_activity/1`.
Before any non-dry-run pass, a `.tar.gz` of the skills root is written to a
backups directory (the newest `keep_backups` are retained); `rollback/1`
restores the latest. `run/1` forces a pass on demand, and `run(dry_run: true)`
reports the transitions it would make without touching anything.
The model-driven consolidation of near-duplicate skills is opt-in
(`consolidate: true`) and not yet implemented.
"""
use Raxol.Core.Behaviours.BaseManager
require Logger
alias Raxol.Agent.Skills.Store
@seconds_per_day 86_400
@seconds_per_hour 3_600
@defaults %{
stale_after_days: 30,
archive_after_days: 90,
interval_hours: 168,
min_idle_hours: 2,
keep_backups: 5,
consolidate: false,
tick_ms: @seconds_per_hour * 1_000
}
@type transition :: %{name: String.t(), from: atom(), to: atom()}
# -- Public API -------------------------------------------------------------
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "Report that the agent did work, resetting the idle clock."
@spec note_activity(GenServer.server()) :: :ok
def note_activity(server \\ __MODULE__), do: GenServer.cast(server, :note_activity)
@doc """
Run a pass now, bypassing the interval/idle gate. Pass `dry_run: true` to get
the transitions without applying them or writing a backup.
"""
@spec run(GenServer.server(), keyword()) :: %{
transitions: [transition()],
backup: String.t() | nil
}
def run(server \\ __MODULE__, opts \\ []) do
GenServer.call(server, {:run, Keyword.get(opts, :dry_run, false)})
end
@doc "The transitions a pass would make right now, without applying them."
@spec plan(GenServer.server()) :: [transition()]
def plan(server \\ __MODULE__), do: GenServer.call(server, :plan)
@doc "Restore the skills root from the most recent backup. Returns the path used."
@spec rollback(GenServer.server()) :: {:ok, String.t()} | {:error, term()}
def rollback(server \\ __MODULE__), do: GenServer.call(server, :rollback)
@doc "Current Curator state: thresholds, last pass time, next eligible time."
@spec status(GenServer.server()) :: map()
def status(server \\ __MODULE__), do: GenServer.call(server, :status)
# -- BaseManager callbacks --------------------------------------------------
@impl Raxol.Core.Behaviours.BaseManager
def init_manager(opts) do
skills = Keyword.fetch!(opts, :skills)
root = Store.root(store_opts(skills))
now = now()
config = Map.merge(@defaults, config_overrides(opts))
state =
Map.merge(config, %{
skills: skills,
root: root,
backups_dir: opts[:backups_dir] || default_backups_dir(root),
last_pass_at: nil,
last_activity_at: now,
next_allowed_at: now + config.interval_hours * @seconds_per_hour
})
schedule_tick(state.tick_ms)
{:ok, state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:run, dry_run}, _from, state) do
{:reply, do_pass(state, dry_run), state}
end
def handle_manager_call(:plan, _from, state) do
{:reply, compute_transitions(state), state}
end
def handle_manager_call(:rollback, _from, state) do
{:reply, do_rollback(state), state}
end
def handle_manager_call(:status, _from, state) do
{:reply, Map.drop(state, [:skills]), state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast(:note_activity, state) do
{:noreply, %{state | last_activity_at: now()}}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_info(:tick, state) do
state = if eligible?(state), do: run_gated_pass(state), else: state
schedule_tick(state.tick_ms)
{:noreply, state}
end
# -- pass orchestration -----------------------------------------------------
defp run_gated_pass(state) do
_ = do_pass(state, false)
now = now()
%{state | last_pass_at: now, next_allowed_at: now + state.interval_hours * @seconds_per_hour}
end
defp eligible?(state) do
now = now()
idle_hours = (now - state.last_activity_at) / @seconds_per_hour
now >= state.next_allowed_at and idle_hours >= state.min_idle_hours
end
defp do_pass(state, dry_run) do
transitions = compute_transitions(state)
backup = if dry_run, do: nil, else: write_backup(state)
unless dry_run, do: Enum.each(transitions, &apply_transition(state, &1))
%{transitions: transitions, backup: backup}
end
# -- lifecycle transitions --------------------------------------------------
defp compute_transitions(state) do
now = now()
state.skills
|> store_opts()
|> Store.telemetry()
|> Enum.filter(&curatable?/1)
|> Enum.flat_map(&transition_for(&1, state, now))
end
defp curatable?(row) do
row.created_by == :agent and row.source == :managed and not row.pinned
end
defp transition_for(row, state, now) do
idle_days = (now - reference_time(row, now)) / @seconds_per_day
cond do
idle_days >= state.archive_after_days and row.state != :archived ->
[%{name: row.name, from: row.state, to: :archived}]
idle_days >= state.stale_after_days and row.state == :active ->
[%{name: row.name, from: :active, to: :stale}]
true ->
[]
end
end
defp reference_time(row, now), do: row.last_used_at || row.created_at || now
defp apply_transition(state, %{name: name, to: :archived}) do
Store.archive(name, store_opts(state.skills))
end
defp apply_transition(state, %{name: name, to: to}) do
Store.set_state(name, to, store_opts(state.skills))
end
# -- backup / rollback ------------------------------------------------------
defp write_backup(state) do
File.mkdir_p!(state.backups_dir)
path = Path.join(state.backups_dir, "skills-#{now()}.tar.gz")
base = Path.basename(state.root)
case :erl_tar.create(charlist(path), [{charlist(base), charlist(state.root)}], [:compressed]) do
:ok ->
prune_backups(state)
path
{:error, reason} ->
Logger.warning("curator backup failed: #{inspect(reason)}")
nil
end
end
defp prune_backups(state) do
state.backups_dir
|> Path.join("skills-*.tar.gz")
|> Path.wildcard()
|> Enum.sort(:desc)
|> Enum.drop(state.keep_backups)
|> Enum.each(&File.rm/1)
end
defp do_rollback(state) do
case latest_backup(state) do
nil ->
{:error, :no_backup}
tarball ->
File.rm_rf!(state.root)
cwd = Path.dirname(state.root)
case :erl_tar.extract(charlist(tarball), [:compressed, {:cwd, charlist(cwd)}]) do
:ok ->
Store.reload(store_opts(state.skills))
{:ok, tarball}
{:error, reason} ->
{:error, reason}
end
end
end
defp latest_backup(state) do
state.backups_dir
|> Path.join("skills-*.tar.gz")
|> Path.wildcard()
|> Enum.sort(:desc)
|> List.first()
end
# -- helpers ----------------------------------------------------------------
defp config_overrides(opts) do
opts
|> Keyword.take(Map.keys(@defaults))
|> Map.new()
end
defp store_opts({_mod, opts}), do: opts
defp default_backups_dir(root), do: Path.join(Path.dirname(root), ".curator_backups")
defp schedule_tick(tick_ms), do: Process.send_after(self(), :tick, tick_ms)
defp charlist(path), do: String.to_charlist(path)
defp now, do: System.system_time(:second)
end