gen_agent_ensemble

CI Hex.pm Docs

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:

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.