Packages

fnord

0.8.70

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_chunk_timeout_ms Application.compile_env(:fnord, :name_chunk_timeout_ms, 30_000)
@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)
def checkin_name(@default_name, _), do: :ok
def checkin_name(name, server) do
GenServer.cast(server, {:checkin_name, name})
end
@doc """
Restores the association between a `pid` and a `name`. This is useful to
re-associate a name after a process restart or similar event.
"""
def associate_name(name, server \\ @name)
def associate_name(nil, _), do: :ok
def associate_name(name, server) do
GenServer.call(server, {:associate_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 =
Services.Globals.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
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
@impl GenServer
def handle_call({:associate_name, name}, {caller_pid, _ref}, state) do
prev_pid = Map.get(state.name_to_pid, name)
state =
case prev_pid do
nil ->
state
^caller_pid ->
state
other ->
%{
state
| pid_to_name: Map.delete(state.pid_to_name, other),
name_to_pid: Map.delete(state.name_to_pid, name)
}
end
new_state = %{
state
| checked_out: MapSet.put(state.checked_out, name),
pid_to_name: Map.put(state.pid_to_name, caller_pid, name),
name_to_pid: Map.put(state.name_to_pid, name, caller_pid),
available: List.delete(state.available, name)
}
{:reply, :ok, new_state}
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
@doc false
# Ensures there are names available, allocating a new chunk if the pool is empty
defp ensure_names_available(%__MODULE__{available: []} = state) do
allocate_name_chunk(state)
end
defp ensure_names_available(state) do
{:ok, state}
end
# Allocates a chunk of names from the nomenclater
defp allocate_name_chunk(state) do
Services.Globals.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}")
new_state = %{
state
| available: names ++ state.available,
all_used: MapSet.union(state.all_used, MapSet.new(names))
}
{:ok, new_state}
:real ->
used_names = MapSet.to_list(state.all_used)
task =
Services.Globals.Spawn.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)
# Use Task.yield to allow configurable timeout
case Task.yield(task, @name_chunk_timeout_ms) do
{:ok, {: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}
{:ok, {:error, reason}} ->
UI.error("Failed to make up names for your agents", reason)
{:error, reason}
nil ->
Task.shutdown(task, :brutal_kill)
UI.error("Name allocation task timed out")
{:error, :timeout}
end
end
end
end