Files
hermes-agent 490bd32322 Add admin API to manage agents at runtime
- AgentRegistry is now file-backed (AGENTS_FILE, default
  /var/lib/n8n-openai/agents.json): agents persist across restarts and
  can be added/removed without a redeploy.
- New admin endpoints (separate ADMIN_API_KEY):
    GET    /admin/agents
    POST   /admin/agents   {model, webhook}
    DELETE /admin/agents/:model
- AGENTS env only seeds the store on first boot; the file is authoritative.
- NixOS module sets AGENTS_FILE under the writable StateDirectory.
2026-09-10 06:46:05 +00:00

125 lines
3.2 KiB
Elixir

defmodule N8nOpenaiAdapter.AgentRegistry do
@moduledoc """
GenServer holding the map of `model name -> n8n chat webhook URL`, persisted
to a JSON file so agents survive restarts and can be managed at runtime via
the admin API (no redeploy needed to add an agent).
Load order at startup:
1. If the agents file exists, load it (authoritative — admin edits persist).
2. Else seed from the `AGENTS` env var (JSON map) or the legacy
`N8N_WEBHOOK_URL`/`MODEL_NAME` pair, then write the file.
The file path comes from `AGENTS_FILE` (default `/var/lib/n8n-openai/agents.json`).
"""
use GenServer
@default_file "/var/lib/n8n-openai/agents.json"
# --- Client API ---
def start_link(initial_agents) do
GenServer.start_link(__MODULE__, initial_agents, name: __MODULE__)
end
@doc "Return the webhook URL for a model, or :error if unknown."
def webhook_for(model) do
GenServer.call(__MODULE__, {:webhook_for, model})
end
@doc "Return all known model names."
def model_names do
GenServer.call(__MODULE__, :model_names)
end
@doc "Return the full model -> webhook map."
def all do
GenServer.call(__MODULE__, :all)
end
@doc "Add or update an agent (model -> webhook). Persists to disk."
def put(model, webhook) do
GenServer.call(__MODULE__, {:put, model, webhook})
end
@doc "Remove an agent by model. Returns :ok or :not_found. Persists to disk."
def delete(model) do
GenServer.call(__MODULE__, {:delete, model})
end
# --- Server callbacks ---
@impl true
def init(seed) do
file = file_path()
agents = load_or_seed(file, seed)
{:ok, %{agents: agents, file: file}}
end
@impl true
def handle_call({:webhook_for, model}, _from, state) do
{:reply, Map.get(state.agents, model, :error), state}
end
@impl true
def handle_call(:model_names, _from, state) do
{:reply, Map.keys(state.agents), state}
end
@impl true
def handle_call(:all, _from, state) do
{:reply, state.agents, state}
end
@impl true
def handle_call({:put, model, webhook}, _from, state) do
agents = Map.put(state.agents, model, webhook)
persist(state.file, agents)
{:reply, :ok, %{state | agents: agents}}
end
@impl true
def handle_call({:delete, model}, _from, state) do
case Map.pop(state.agents, model) do
{nil, _} ->
{:reply, :not_found, state}
{_old, agents} ->
persist(state.file, agents)
{:reply, :ok, %{state | agents: agents}}
end
end
# --- Helpers ---
defp file_path, do: System.get_env("AGENTS_FILE", @default_file)
defp load_or_seed(file, seed) do
case File.read(file) do
{:ok, contents} ->
case Jason.decode(contents) do
{:ok, map} when is_map(map) -> map
_ -> seed_and_write(file, seed)
end
{:error, _} ->
seed_and_write(file, seed)
end
end
defp seed_and_write(file, seed) do
agents = normalize_seed(seed)
persist(file, agents)
agents
end
defp normalize_seed(seed) when is_map(seed), do: seed
defp normalize_seed(_), do: %{}
defp persist(file, agents) do
dir = Path.dirname(file)
File.mkdir_p!(dir)
File.write!(file, Jason.encode!(agents, pretty: true))
end
end