Packages

fnord

0.8.29

AI code archaeology

Current section

Files

Jump to
fnord lib services name_pool.ex
Raw

lib/services/name_pool.ex

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)
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
})
|> case 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}
end
end
end
end