defmodule AgentSea.Crew.Delegation.Auction do
@moduledoc """
Agents bid on the task; the best bid wins.
Bids are collected as a **parallel `GenServer.call` fan-out** — the bidding
window is literally the call timeout. Slow or dead bidders simply miss the
window (`on_timeout: :kill_task`); there is no `Promise.race` and no leaked
timers.
Context options:
* `:bidding_time_ms` — per-bid timeout (default 5000)
* `:minimum_bid` — drop bids below this confidence (default 0.0)
* `:selection_criteria` — `:confidence` (default) | `:fastest` | `:cheapest`
"""
@behaviour AgentSea.Crew.Delegation
alias AgentSea.Crew.Delegation.Result
@default_bidding_ms 5_000
@impl true
def delegate(_task, [], _ctx), do: {:error, :no_agents}
def delegate(task, agents, ctx) do
bidding_ms = Map.get(ctx, :bidding_time_ms, @default_bidding_ms)
minimum_bid = Map.get(ctx, :minimum_bid, 0.0)
criteria = Map.get(ctx, :selection_criteria, :confidence)
bids =
agents
|> Task.async_stream(
fn agent -> {agent, AgentSea.Agent.bid(agent.pid, task)} end,
timeout: bidding_ms,
on_timeout: :kill_task,
max_concurrency: max(length(agents), 1)
)
|> Enum.flat_map(fn
{:ok, {agent, {:ok, bid}}} -> [{agent, bid}]
# timed-out / crashed / failed bidders don't compete
_ -> []
end)
|> Enum.filter(fn {_agent, bid} -> bid.confidence >= minimum_bid end)
case pick(bids, criteria) do
nil ->
{:error, :no_bids}
{agent, bid} ->
{:ok,
%Result{
selected_agent: agent.name,
confidence: bid.confidence,
reason: "won auction (#{criteria}); confidence #{Float.round(bid.confidence, 2)}",
alternatives:
bids
|> Enum.reject(fn {a, _} -> a.name == agent.name end)
|> Enum.map(fn {a, _} -> a.name end)
}}
end
end
defp pick([], _criteria), do: nil
defp pick(bids, :confidence), do: Enum.max_by(bids, fn {_a, b} -> b.confidence end)
defp pick(bids, :fastest),
do: Enum.min_by(bids, fn {_a, b} -> b.estimated_time || :infinity end)
defp pick(bids, :cheapest),
do: Enum.min_by(bids, fn {_a, b} -> b.estimated_cost || :infinity end)
end