defmodule Services.NamePool do @moduledoc """ A service that manages a pool of AI agent names, batch-allocating them from the nomenclater for efficiency. Names can be checked out and optionally checked back in for reuse within the same session. Each checked-out name is now associated with the caller's `pid`, and you can retrieve it via `get_name_by_pid/1`. The pool allocates names in chunks sized to the configured workers setting to maximize API efficiency without overwhelming the connection pool. """ use GenServer @name __MODULE__ @type t :: %__MODULE__{ available: [String.t()], checked_out: MapSet.t(String.t()), all_used: MapSet.t(String.t()), chunk_size: pos_integer(), pid_to_name: %{optional(pid()) => String.t()}, name_to_pid: %{optional(String.t()) => pid()} } defstruct available: [], checked_out: MapSet.new(), all_used: MapSet.new(), chunk_size: 12, pid_to_name: %{}, name_to_pid: %{} # ----------------------------------------------------------------------------- # Public API # ----------------------------------------------------------------------------- @default_name "Fnord Prefect" def default_name, do: @default_name @doc "Starts the name pool service" def start_link(opts \\ []) do name = Keyword.get(opts, :name, @name) init_opts = Keyword.drop(opts, [:name]) GenServer.start_link(__MODULE__, init_opts, name: name) end @doc """ Checks out a name from the pool. If the pool is empty or running low, automatically allocates a new chunk of names. Returns `{:ok, name}` or `{:error, reason}`. """ @spec checkout_name(atom() | pid()) :: {:ok, String.t()} | {:error, String.t()} def checkout_name(server \\ @name) do GenServer.call(server, :checkout_name, 30_000) end @doc """ Checks a name back into the pool for potential reuse. This is optional - names that are never checked back in will simply be lost when the session ends. """ @spec checkin_name(String.t(), atom() | pid()) :: :ok def checkin_name(name, server \\ @name) when is_binary(name) do GenServer.cast(server, {:checkin_name, name}) end @doc "Returns pool statistics for debugging/monitoring" def pool_stats(server \\ @name) do GenServer.call(server, :pool_stats) end @doc "Resets the pool state (mainly for testing)" def reset(server \\ @name) do GenServer.call(server, :reset) end @doc """ Returns `{:ok, name}` if the given `pid` has a checked‐out name, or `{:error, :not_found}` otherwise. """ @spec get_name_by_pid(pid(), atom() | pid()) :: {:ok, String.t()} | {:error, :not_found} def get_name_by_pid(pid, server \\ @name) when is_pid(pid) do GenServer.call(server, {:get_name_by_pid, pid}) end # ----------------------------------------------------------------------------- # GenServer Callbacks # ----------------------------------------------------------------------------- @impl GenServer def init(_opts) do chunk_size = Application.get_env(:fnord, :workers, 12) state = %__MODULE__{ available: [], checked_out: MapSet.new(), all_used: MapSet.new(), chunk_size: chunk_size, pid_to_name: %{}, name_to_pid: %{} } {:ok, state} end @impl GenServer def handle_call(:checkout_name, {caller_pid, _ref}, state) do if Map.has_key?(state.pid_to_name, caller_pid) do {:reply, {:ok, Map.get(state.pid_to_name, caller_pid)}, state} else case ensure_names_available(state) do {:ok, updated_state} -> case updated_state.available do [name | remaining] -> new_state = %{ updated_state | available: remaining, checked_out: MapSet.put(updated_state.checked_out, name), pid_to_name: Map.put(updated_state.pid_to_name, caller_pid, name), name_to_pid: Map.put(updated_state.name_to_pid, name, caller_pid) } {:reply, {:ok, name}, new_state} [] -> UI.error("Unable to allocate names from pool") {:reply, {:error, "No names available"}, updated_state} end {:error, reason} -> UI.error("Failed to ensure names available", reason) {:reply, {:error, reason}, state} end end end @impl GenServer def handle_call(:pool_stats, _from, state) do stats = %{ available_count: length(state.available), checked_out_count: MapSet.size(state.checked_out), all_used_count: MapSet.size(state.all_used), chunk_size: state.chunk_size } {:reply, stats, state} end @impl GenServer def handle_call(:reset, _from, state) do new_state = %{ state | available: [], checked_out: MapSet.new(), all_used: MapSet.new(), pid_to_name: %{}, name_to_pid: %{} } {:reply, :ok, new_state} end @impl GenServer def handle_call({:get_name_by_pid, pid}, _from, state) do case Map.fetch(state.pid_to_name, pid) do {:ok, name} -> {:reply, {:ok, name}, state} :error -> {:reply, {:error, :not_found}, state} end end @impl GenServer def handle_cast({:checkin_name, name}, state) do if MapSet.member?(state.checked_out, name) do pid = Map.get(state.name_to_pid, name) new_state = %{ state | available: [name | state.available], checked_out: MapSet.delete(state.checked_out, name), pid_to_name: Map.delete(state.pid_to_name, pid), name_to_pid: Map.delete(state.name_to_pid, name) } {:noreply, new_state} else UI.warn("Attempted to check in name that wasn't checked out", name) {:noreply, state} end end # ----------------------------------------------------------------------------- # Private Functions # ----------------------------------------------------------------------------- # Ensures we have at least one name available, allocating a new chunk if needed defp ensure_names_available(state) do if length(state.available) > 0 do {:ok, state} else allocate_name_chunk(state) end end # Allocates a chunk of names from the nomenclater defp allocate_name_chunk(state) do Application.get_env(:fnord, :nomenclater, :real) |> case do :fake -> start_num = MapSet.size(state.all_used) + 1 end_num = start_num + state.chunk_size - 1 names = start_num..end_num |> Enum.map(&"NPC ##{&1}") {:ok, %{ state | available: names ++ state.available, all_used: MapSet.union(state.all_used, MapSet.new(names)) }} :real -> used_names = MapSet.to_list(state.all_used) task = Task.async(fn -> AI.Agent.Nomenclater # `named?: false` prevents circular dependency with ourselves |> AI.Agent.new(named?: false) |> AI.Agent.get_response(%{ want: state.chunk_size, used: used_names }) end) case Task.await(task, 10_000) || Task.shutdown(task) do {:ok, names} when is_list(names) -> new_state = %{ state | available: names ++ state.available, all_used: MapSet.union(state.all_used, MapSet.new(names)) } {:ok, new_state} {:error, reason} -> UI.error("Failed to make up names for your agents", reason) {:error, reason} nil -> UI.error("Name allocation task timed out") {:error, :timeout} end end end end