Topology
One ensemble process owning N identical sub-agents. Each tell/ask lands on the next free worker; when all are busy, prompts queue FIFO and dispatch as workers free up.
+-------+ +-------+ +----------+
| E.* | -> | Pool | -> | worker 1 | -> backend session 1
+-------+ | | +----------+
| | +----------+
| | -> | worker 2 | -> backend session 2
| | +----------+
| | +----------+
| | -> | worker N | -> backend session N
+-------+ +----------+When to reach for it
- You have many independent prompts and want them answered in parallel.
- You want bounded concurrency: "up to N at once, overflow queues."
- The work is homogeneous -- same system prompt and backend for each worker.
For coordinated fan-out where the sub-prompts are derived from a parent prompt, use Supervisor. For a pipeline of transformations through different agents, use Pipeline.
Config
config :gen_agent_ensemble,
ensembles: [
[
name: "qa-pool",
strategy: GenAgentEnsemble.Strategies.Pool,
opts: [
worker_count: 3,
worker_template:
{"qa", GenAgentEnsemble.Agents.Simple,
backend: GenAgent.Backends.Anthropic,
system: "Answer tersely. One paragraph max.",
model: "claude-sonnet-4-6"}
]
]
]Workers are automatically named "qa-1", "qa-2", "qa-3" from
the template's prefix.
Canonical workflow
Fan-out batch
iex> questions = ["What is GenServer?", "What is a Supervisor?", "What is ETS?"]
iex> tokens = for q <- questions, do: elem(E.tell("qa-pool", q), 1)
["tok-3", "tok-4", "tok-5"]
iex> E.status("qa-pool")
{:ok, %{busy: 3, free: 0, queued: 0, workers: ["qa-1", "qa-2", "qa-3"], ...}}
iex> Process.sleep(5_000)
iex> E.drain("qa-pool")
[
{"tok-3", "A GenServer is..."},
{"tok-4", "A Supervisor is..."},
{"tok-5", "ETS is..."}
]Fire the tells in a single pipeline (for comprehension or
Enum.map) so they land at the Pool before any can finish.
Otherwise the first one completes and frees its worker, which then
picks up the second, and you lose parallelism -- see the gotcha
below.
Overflow queueing
iex> tokens = for q <- many_questions(), do: elem(E.tell("qa-pool", q), 1)
iex> E.status("qa-pool")
{:ok, %{busy: 3, free: 0, queued: 7, ...}}
# Drain as they finish:
iex> :timer.tc(fn -> Enum.each(tokens, &E.await("qa-pool", &1, 30_000)) end)One-off sync through the pool
iex> E.ask!("qa-pool", "single question")Uses whichever worker is free. If all are busy, queues behind them.
Variations
- Worker count. Tune
worker_count:for concurrency vs cost. Three is a reasonable default. - Heterogeneous workers? Not directly -- Pool clones one
template. If you need different system prompts per worker, use
Supervisor with a
:worker_templateand a decomposer that assigns roles, or run multiple Pool ensembles.
Gotchas
- Workers retain per-worker conversation history. The Pool
recycles workers across turns, and each worker's backend session
persists. If you fire three sequential tells slowly enough that
each finishes before the next arrives, all three land on the same
worker and accumulate as a multi-turn conversation -- you'll see
input-token counts grow and the worker's system prompt drift under
the accumulated context. Fire tells in one shot (
Enum.map) to spread across workers, or swap in a fresh-session callback module if you need isolated single turns. statusraces against fast turns. If you callE.statusin a separate iex line after firing tells, a sub-second turn may have already completed, showingbusy: 0. Capture status right after dispatch in the same expression if you want to see the in-flight state.- Worker death shrinks the pool. If all workers die, the session
halts (
{:halt, :pool_exhausted}). Restart the ensemble to recover.