gen_agent_ensemble
Multi-agent orchestration strategies for
GenAgent. Where GenAgent
gives you one process per LLM session, gen_agent_ensemble gives
you one process per logical session that owns N sub-agents under
a strategy. Single-agent is the degenerate Solo case.
This library is both:
- A library consumed by applications (MCP servers, LiveView apps, scripts) that need multi-agent orchestration.
- A platform for Elixir power users. Declare ensembles in
config/config.exs, runiex -S mix, and your ensembles are live as named processes you can drive directly.
Member names
Members register under {GenAgentEnsemble, session, bare_member}. Use
GenAgentEnsemble.member_name/2 or status.member_names when calling core APIs:
key = GenAgentEnsemble.member_name("research/2026", "reviewer/1")
%{name: ^key} = GenAgent.status(key)
This replaces handwritten "session/member" lookups. Bare strategy names stay
unchanged. See the member-name migration guide for
collision handling, telemetry and the remaining named-routing identity boundary.
Strategies (shipped)
| Strategy | Topology | Module |
|---|---|---|
| Solo | One agent, passthrough | GenAgentEnsemble.Strategies.Solo |
| Switchboard | Named fleet, caller-routed | GenAgentEnsemble.Strategies.Switchboard |
| Pool | N reusable workers, FIFO queue | GenAgentEnsemble.Strategies.Pool |
| Pipeline | Linear stage chain | GenAgentEnsemble.Strategies.Pipeline |
| Supervisor | Coordinator + dynamic worker fan-out | GenAgentEnsemble.Strategies.Supervisor |
| Debate | Two agents alternate until convergence | GenAgentEnsemble.Strategies.Debate |
| Consensus | N peer agents vote with structured verdict | GenAgentEnsemble.Strategies.Consensus |
See the strategy workflow guides for the shape of each strategy, canonical iex workflows, and per-strategy gotchas.
Install
Add a backend from its own installation guide when using Ensemble: Claude, Codex, Anthropic, or OpenAI.
The package resolves a compatible GenAgent core version through its dependency.
def deps do
[{:gen_agent_ensemble, "~> 0.7.0"}] # x-release-please-version
end
Quickstart (zero-setup demo)
The GenAgent checkout ships the "echo" ensemble pre-enabled in
extensions/ensemble/config/config.exs. It uses
GenAgentEnsemble.Backends.Echo -- no API keys, no external
services, every prompt echoed back with an "echo: " prefix.
Every other ensemble in that file is commented out as a template you
can enable after wiring up real credentials.
The echo config and the E alias come from extensions/ensemble
only; the checkout root has neither. Start iex from that directory.
Its .iex.exs aliases GenAgentEnsemble.IEx to E -- a module that
delegates the core API (list/0, ask/2, tell/2, ...) and adds
REPL-flavoured helpers on top:
cd extensions/ensemble
mix deps.get
iex -S mix
In your own application, add the dependency from Install,
declare ensembles under config :gen_agent_ensemble (see below), and
add alias GenAgentEnsemble.IEx, as: E to your own .iex.exs (or
call GenAgentEnsemble directly). The "echo" ensemble is not
defined for you.
iex> E.list()
["echo"]
iex> E.ask!("echo", "hello there")
"echo: hello there"
Once that feels right, uncomment one of the commented templates
in config/config.exs (Solo, Pool, Switchboard, Pipeline,
Supervisor, Debate, or Consensus), set the appropriate env var
(ANTHROPIC_API_KEY, OPENAI_API_KEY), and restart iex.
Real backend example
config :gen_agent_ensemble,
ensembles: [
[
name: "solo",
strategy: GenAgentEnsemble.Strategies.Solo,
opts: [
agent:
{"w", GenAgentEnsemble.Agents.Simple,
backend: GenAgent.Backends.Anthropic,
system: "You are a pragmatic Elixir reviewer.",
model: "claude-sonnet-4-6"}
]
]
]
Config-declared ensembles are started under GenAgentEnsemble.Supervisor
with a :transient restart policy. An explicit GenAgentEnsemble.stop/1
exits normally and the ensemble stays stopped until the application is
restarted; an abnormal crash of the session is restarted. A strategy halt does
not exit the process (see "Halting" below): the halted ensemble stays
registered until it is stopped explicitly.
Caller supervisors can use the same public child spec described below.
Recovery after a crash of the
GenAgentEnsemble.Supervisor itself is not provided.
Supervise ensembles in your application
Use {GenAgentEnsemble, opts} in your supervisor's children:
children =
for name <- ["research", "review"] do
{GenAgentEnsemble,
name: name,
strategy: GenAgentEnsemble.Strategies.Solo,
opts: [
agent: {"worker", GenAgentEnsemble.Agents.Simple,
backend: GenAgentEnsemble.Backends.Echo}
]}
end
Supervisor.start_link(children, strategy: :one_for_one)
GenAgentEnsemble.child_spec/1 requires :name, just like start_link/1.
Its ID is {GenAgentEnsemble, name}, so multiple distinct named ensembles
can share a supervisor without generating atoms. The :transient restart
policy keeps explicit stops stopped and restarts abnormal exits. A halted
ensemble is still a live child until it is stopped explicitly. shutdown: :infinity lets the owned agent tree finish using each
sub-agent's configured shutdown budget; blocking callbacks or infinite
budgets can prolong shutdown indefinitely. Override defaults with
Supervisor.child_spec/2 if needed. Direct start_link/1 calls retain their
caller-linked behavior.
Programmatic use:
iex> E.ask!("solo", "What's wrong with `Enum.map(list, &(&1 + 1))`?") |> IO.puts()
# Nothing is wrong with it -- valid idiomatic Elixir...
Async fan-out with a Pool (declare it in config too):
iex> {:ok, t1} = E.tell("qa-pool", "question one")
iex> {:ok, t2} = E.tell("qa-pool", "question two")
iex> E.status("qa-pool")
iex> E.await("qa-pool", t1, 60_000)
iex> E.await("qa-pool", t2, 60_000)
iex> E.drain("qa-pool") # [{token, text}, ...] of completed tokens
See the Pool workflow guide for the config shape and the fan-out-vs-serial gotcha.
See the strategy workflow guides for the canonical command sequences for every strategy (Solo, Switchboard, Pool, Pipeline, Supervisor, Debate, Consensus), including per-strategy gotchas and variations.
Public API
All functions are addressed by session name (the :name you put
in config). Shown here with the E alias (GenAgentEnsemble.IEx)
set up by the repo's .iex.exs.
Core
| Function | Purpose |
|---|---|
E.ask(name, prompt) |
Synchronous single-turn. Blocks until reply. |
E.tell(name, prompt) |
Async. Returns a token you poll or drain later. |
E.poll(name, token) |
Non-blocking check; consumes a completed token. |
E.inbox(name) |
Drain all retained completed tokens. |
E.notify(name, event) |
Send an event to the strategy (cast). |
E.status(name) |
Inspect strategy phase, queue depth, etc. |
E.stop(name) |
Stop an ensemble cleanly. |
E.list() |
Names of all running ensembles. |
E.start_link(opts) |
Start an ad-hoc ensemble imperatively (same shape |
| as a config entry). |
Completed tell results are retained per ensemble with a top-level
max_completed_results: 100 default, matching core's default tell result
limit. Set a non-negative integer (including 0 to disable retention), or
:infinity to explicitly allow unbounded retention. This option belongs
beside :name and :strategy, outside strategy :opts. Invalid values
return {:error, {:invalid_option, :max_completed_results, value}} before
strategy initialization or owned tree startup.
Successes, errors, and cancellations all count; the oldest by completion
order is evicted first. Asks are never cached. poll/2 consumes one retained
result and inbox/1 drains the cache; inbox entry order is unspecified.
await/3 does not consume results. Later poll/await calls for evicted or
consumed tokens return {:error, :not_found}. Already registered waiters
and tell_with_completion recipients still receive terminal results when
retention is zero or the cache is full. The limit bounds result count and
cache bookkeeping, not response bytes, strategy state, or pending work.
Outstanding requests are bounded separately. Top-level
max_pending_prompts: 1_000 and max_pending_prompt_bytes: 1_048_576
(each a non-negative integer or :infinity, validated like
max_completed_results) cap the public ask, tell and
tell_with_completion calls that are active or waiting in the session, for
every strategy. A call that does not fit returns
{:error, {:overloaded, info}} before any token, callback, or child work
exists; info has queue: :ensemble_prompts, limit: :count | :bytes, the
pending and incoming numbers, and both maximums. Zero rejects every new call.
Each accepted call is charged once with the :erlang.external_size/1 of its
original prompt and keeps that charge, through pipeline stages, rounds and
fanout, until it reaches a terminal result (success, error, cancellation,
rejection, or halt). A caller's ask timeout does not release it.
E.status(name) reports the counters under :admission, and each rejection
emits [:gen_agent_ensemble, :admission, :rejected]. This is a budget for the
original serialized prompts, not a memory limit or a limit on physical work:
the mailbox, strategy state and options, generated prompts, intermediate
outputs, backend histories, and completed results are bounded separately.
Halting
A strategy's first {:halt, reason} ends the session logically and
immediately: the rest of its operation batch is skipped, every pending token
finishes with {:error, {:halted, reason}} (asks, completion recipients and
registered await/3 callers included), and the strategy is never called
again. Results stored earlier are kept.
The ensemble process stays alive under the same PID and name so those results
can be collected. await/3, poll/2, inbox/1, cancel/2 and status/1
keep working (status/1 reports lifecycle: :halted); new ask, tell and
tell_with_completion calls return {:error, {:halted, reason}} without
starting work, and notify/2 is ignored. The owned agent tree is told to shut
down at the halt. That shutdown is asynchronous and each child's configured
budget applies, so there is no latency guarantee and no guarantee an external
provider has settled.
A halted ensemble cannot be resumed. It holds its name, and under a
supervisor it counts as an active child, until GenAgentEnsemble.stop/1, an
explicit supervisor termination, or the exit of its owner disposes of it.
Disposal discards the retained results. Until then they stay in memory with no
expiry, bounded by max_completed_results (a result count, not bytes; zero
keeps none). Halt errors count against that bound like any other result.
Helpers (iex-flavoured sugar)
| Function | Purpose |
|---|---|
E.ask!(name, prompt) |
Like ask/2 but returns the response text string. |
E.text(resp) |
Extract .text from %Response{} or {:ok, r}. |
E.puts(resp) |
Print response text (markdown-friendly). |
E.await(name, token) |
Block on a tell token, return the %Response{}. |
E.drain(name) |
inbox unwrapped to [{token, text}, ...]. |
For library code (not iex), call GenAgentEnsemble directly -- the
GenAgentEnsemble.IEx module is a humans-at-the-prompt convenience.
Ask timeouts. ask defaults to 30_000 ms. Override per call with
timeout: (non-negative integer or :infinity) or globally with
config :gen_agent_ensemble, ask_timeout: 120_000; the per-call option
wins. Expiry exits the calling process; the timeout itself does not
cancel the work, which keeps running only if the ensemble survives (the
caller catches the exit, or a separate supervised owner started the
ensemble). If the uncaught exit kills the process that called start_link,
the ensemble stops with it. For long or recoverable waits call
GenAgentEnsemble.tell/2 and GenAgentEnsemble.await/3, where a timeout
returns {:error, :timeout} and the result can be fetched later while the
ensemble is alive. (E.await is the iex helper and raises on timeout
instead.) Invalid values raise ArgumentError before any work is submitted.
GenAgentEnsemble.cancel(name, token) closes a pending token with
{:error, :cancelled} through the existing completion, await, ask, and
poll/inbox paths while preserving other tokens and the session. It returns
{:ok, :cancelled} for acknowledged child requests or
{:ok, :cancelled_unconfirmed} for uncertain child outcomes; neither proves
an external provider has settled. Finished/consumed/unknown tokens return
:already_finished or :not_found errors. Custom strategies must implement
handle_cancel/2, otherwise cancellation returns {:error, :unsupported}.
Ad-hoc ensembles from iex
You don't have to use config. Any ensemble can be started imperatively with the same opts shape:
iex> E.start_link(
...> name: "scratch",
...> strategy: GenAgentEnsemble.Strategies.Solo,
...> opts: [
...> agent: {"w", GenAgentEnsemble.Agents.Simple,
...> backend: GenAgentEnsemble.Backends.Echo,
...> transform: &String.upcase/1}
...> ]
...> )
iex> E.ask!("scratch", "hello")
"HELLO"
This is the natural way to prototype: try a config inline, iterate,
then promote to config/config.exs when you're happy with it.
Secrets and config/runtime.exs
API keys and other secrets don't belong in config/config.exs
(compile-time evaluated, checked into git). Use
config/runtime.exs or environment variables that the backend
reads directly.
For Anthropic:
export ANTHROPIC_API_KEY=...
iex -S mix
Built-in Simple agent
GenAgentEnsemble.Agents.Simple is a reusable one-turn callback
module. Accepts any backend options (:system, :system_prompt,
:model, :cwd, etc.) and forwards them to the backend. Use it
for iex experimentation and as the worker for simple ensembles.
For real projects you'll typically write your own callback module with richer state and prompt-engineered behaviour -- Simple is the shortest path to "working ensemble in 10 lines of config."
Telemetry
Ensemble emits observational session, token, and dispatch events. A
dispatch's agent name and ordinal identify Pipeline stages and Supervisor
branches; its turn reference links to core GenAgent telemetry. Events do
not include prompts or responses. See GenAgentEnsemble.Telemetry for the
event contract and cardinality guidance. Completion delivery does not rely
on telemetry handlers.
Development
From extensions/ensemble in the GenAgent checkout:
mix deps.get
mix test
Local development uses path dependencies on core and the four adapters.
Set GEN_AGENT_HEX=1 when building or publishing a standalone Hex archive;
the package then declares ordinary Hex requirements instead.
Full pre-commit checklist (matches CI):
mix format --check-formatted
mix compile --warnings-as-errors
mix credo --strict
mix dialyzer
mix test
Status
Pre-1.0. The strategy op vocabulary
(:start | :stop | :dispatch | :reply | :reply_error | :forward | :halt)
and public API are stable and unlikely to change further before 1.0.
Breaking changes bump the minor version; see CHANGELOG.md for
what's changed.