CouncilEx.AutoCouncil is an opt-in routing layer for callers that don't
know up-front which council fits a given prompt. It is purely additive:
existing static (use CouncilEx) and dynamic (%DynamicCouncil{})
councils are unaffected.
Mental model. From the runner's perspective, an
AutoCouncilis a council. Pass it toCouncilEx.run/3exactly like any other council. Internally, a strategy picks an underlying council from a catalog and the runner executes that. The picked council surfaces inresult.metadata.auto.
1. Quick start
auto =
CouncilEx.AutoCouncil.new(
name: "general-routing",
strategy: :rules,
catalog: [
%{id: "seo", council: MyApp.Councils.SEO, match: ~r/seo|sitemap/i, desc: "SEO audit"},
%{id: "code", council: MyApp.Councils.CodeReview, match: ~r/code|PR/i, desc: "Code review"}
],
on_no_match: {:fallback, MyApp.Councils.GeneralPurpose}
)
{:ok, result} = CouncilEx.run(auto, %{question: "audit my SEO"})
result.metadata.auto
# => %{strategy: :rules, kind: :static, catalog_id: "seo", reason: "matched ~r/seo|sitemap/i", latency_ms: 1, score: nil}2. Struct shape
%CouncilEx.AutoCouncil{
strategy: :rules | :cascade | :embedding | :llm_classify | :llm_build | {module(), keyword()},
catalog: [catalog_entry()] | {:registry, :council} | nil,
on_no_match: :error | {:fallback, council_or_id} | nil,
name: String.t() | nil,
options: keyword(), # strategy-specific
provider_check: boolean() # default false; see Provider awareness below
}Catalog entry:
%{
required(:council) => module() | %CouncilEx.DynamicCouncil{},
optional(:id) => String.t(),
optional(:desc) => String.t(), # used by LLM strategies
optional(:match) => Regex.t() | (binary() -> boolean()), # used by Rules
optional(:vector) => [float()], # used by Embedding
optional(:tags) => [atom()]
}Only :council is required. Strategies ignore fields they don't need, so
the same catalog can serve multiple strategies side-by-side.
3. Built-in strategies
3.1 :rules
First catalog entry whose :match field matches the prompt wins. :match
may be a Regex.t() or a 1-arity boolean function over the prompt
string. Entries without :match are never picked by this strategy.
Cost: zero. No LLM calls. Pure pattern matching.
AutoCouncil.new(
strategy: :rules,
catalog: [
%{id: "seo", council: MyApp.Councils.SEO, match: ~r/seo/i},
%{id: "support", council: MyApp.Councils.Support, match: fn t -> String.contains?(t, "ticket") end}
]
)Returns {:error, :no_match} if nothing fits. See :on_no_match for
fallback control.
3.2 :cascade
Try a chain of strategies in order; first success wins. Chain comes from
the parent's :options:
AutoCouncil.new(
strategy: :cascade,
catalog: catalog,
options: [chain: [:rules, :embedding, :llm_classify]]
)Per-step outcomes emit a [:council_ex, :auto_council, :cascade_step]
telemetry event so operators can see which steps earn their keep.
3.3 :embedding: stub
Planned: cosine similarity over precomputed catalog vectors. Today
returns {:error, :not_implemented}. Design notes:
- Catalog entries provide a
:vectorfield (precomputed at registration time). :optionscarries:embedder(1-arity fnbinary -> [float]) and:threshold.- Open: vector storage location, re-embed-on-edit signal, similarity function.
See module CouncilEx.AutoCouncil.Strategies.Embedding.
3.4 :llm_classify: stub
Planned: a cheap LLM picks one catalog id given the prompt and the
catalog descriptions. Today returns {:error, :not_implemented}. Design
notes:
- Catalog entries must carry
:idand:desc. :optionscarries:profile(registered profile name),:max_tokens, optional:system_promptoverride.- Open: structured output enforcement, retry-on-parse-failure policy, per-route budget cap.
See module CouncilEx.AutoCouncil.Strategies.LLMClassify.
3.5 :llm_build: stub
Planned: an LLM synthesizes a fresh %DynamicCouncil{} for the prompt
(members, system prompts, rounds, chair). The runner then executes
that synthesized council. Decision returns as {:built, dc} so callers
can see the plan was non-deterministic.
AutoCouncil.new(
strategy: :llm_build,
options: [
profile: "openai_balanced",
member_palette: [...],
max_members: 5,
allowed_rounds: [:independent_analysis, :peer_critique, :consensus_vote]
]
)Hard constraints to enforce inside the strategy: bounded member count,
restricted round set, mandatory DynamicCouncil.validate/1 pass, hard
planner timeout. See module CouncilEx.AutoCouncil.Strategies.LLMBuild.
3.6 Custom strategy
Any module implementing CouncilEx.AutoCouncil.Strategy:
defmodule MyApp.AutoCouncil.UserPreference do
@behaviour CouncilEx.AutoCouncil.Strategy
@impl true
def resolve(prompt, %CouncilEx.AutoCouncil{} = auto) do
# Pull catalog if you need it; the helper handles inline + registry forms.
entries = CouncilEx.AutoCouncil.Resolver.catalog_entries(auto)
user = Process.get(:current_user)
# ...your decision logic.
{:ok, {:static, MyApp.Councils.Default}, "user default"}
end
end
AutoCouncil.new(strategy: {MyApp.AutoCouncil.UserPreference, []})Return value:
{:ok, decision, reason :: String.t() | nil}
{:ok, decision, reason, score :: float() | nil, catalog_id :: String.t() | nil}
{:error, term()}Where decision is:
{:static, module()}: picked ause CouncilExmodule.{:dynamic, %DynamicCouncil{}}: picked a registered/inline data council.{:built, %DynamicCouncil{}}: synthesized fresh; non-deterministic.
The 3-tuple form is sugar; the resolver normalizes it to the 5-tuple internally.
4. Catalog sources
# Inline: explicit, easy to test, lives with the calling code.
AutoCouncil.new(strategy: :rules, catalog: [%{id: "seo", ...}])
# Registry-backed: shared, hot-reloadable, UI-editable.
AutoCouncil.new(strategy: :rules, catalog: {:registry, :council})Register entries in config:
config :council_ex, :registry,
councils: %{
"seo" => %{council: MyApp.Councils.SEO, match: ~r/seo/i, desc: "SEO audit"}
}Or at runtime:
:ok = CouncilEx.Registry.register_council("editorial", %{
council: editorial_dynamic_council,
match: ~r/editorial|copy/i,
desc: "Editorial review"
})CouncilEx.Registry.register_council/2 requires the :council key:
mistakes raise FunctionClauseError at registration time, not at route
time.
4b. Provider awareness
A catalog entry pointing at a council that uses an unconfigured provider
will fail at execution time. Set provider_check: true on the
AutoCouncil to drop those entries before strategies run.
AutoCouncil.new(
strategy: :rules,
catalog: [...],
provider_check: true
)How providers are discovered
| Council form | Source | Cost |
|---|---|---|
Static (use CouncilEx) | MyCouncil.__providers__/0 (hoisted at compile time by use CouncilEx) | constant function call |
Dynamic (%DynamicCouncil{}) | DynamicCouncil.to_spec/1 walk + member opts inspection | one walk per Providers.used/1 |
| Sub-council member | Recurses via __sub_council__/0 on the shim | proportional to nested depth |
The sentinel :__sub_council__ is filtered out and replaced with the
target's real providers: a parent's :providers set covers the whole
nested tree.
Caching
Provider sets are cached on the catalog entry under :providers:
CouncilEx.Registry.register_council/2computes and stashes:providersat registration time. Pass an explicit MapSet (ornil) to override the auto-computed value.AutoCouncil.new/1warms the cache for inline catalogs only whenprovider_check: trueis set, so the default path stays free.- A cache miss (registry-backed entry registered before
provider_checkexisted, or explicit:providers => nil) falls back to a liveProviders.eligible?/1call.
Invalidation
Re-register (or rebuild the inline catalog) when a council's provider
mix changes. There is no automatic detection; the cache is intentionally
simple. Static councils are effectively free; dynamic councils pay one
to_spec/1 walk per registration.
Telemetry
[:council_ex, :auto_council, :catalog_filtered]
measurements: %{filtered_out_count: int, kept_count: int}
metadata: %{auto_name, configured: [atom], dropped_ids: [String.t()]}Fires only when at least one entry is filtered out. Helpful for spotting load-order races (provider config arrives after catalog registration) and missing API keys in production.
Failure-mode philosophy
Providers.used/1 returns {:error, term()} when introspection fails
(e.g. profile module not loaded). The resolver treats {:error, _} as
"keep the entry": better to attempt the run and surface a clear
provider-config error than to silently drop a council due to a
load-order race. :filtered_out_count only counts entries that were
successfully introspected and found ineligible.
Public helpers
{:ok, %MapSet{}} = CouncilEx.AutoCouncil.Providers.used(MyCouncil)
{:ok, %MapSet{}} = CouncilEx.AutoCouncil.Providers.used(%DynamicCouncil{} = dc)
%MapSet{} = CouncilEx.AutoCouncil.Providers.configured()
true_or_false = CouncilEx.AutoCouncil.Providers.eligible?(MyCouncil)from_spec/1 is the underlying spec-walking function. Useful when
implementing custom strategies that need provider info.
5. on_no_match policy
Controls what happens when the strategy can't pick anything.
| Value | Behaviour |
|---|---|
:error (default) | Resolver returns {:error, reason}; CouncilEx.run/3 returns {:error, reason} without running anything. |
{:fallback, module} | Use the named module-form council. |
{:fallback, %DynamicCouncil{}} | Use the inline dynamic council. |
{:fallback, registered_id} | Look the id up in Registry :council; use that entry. |
The fallback decision is recorded in result.metadata.auto with
fallback?: true and reason: "on_no_match fallback".
6. Result metadata
result.metadata.auto
# => %{
# strategy: :rules | :cascade | :embedding | :llm_classify | :llm_build | atom_of_custom_module,
# kind: :static | :dynamic | :built,
# catalog_id: "seo" | nil,
# reason: "matched ~r/seo/i" | nil,
# score: 0.83 | nil,
# latency_ms: 2,
# fallback?: true # only present when on_no_match fired
# }kind semantics:
:static: ause CouncilExmodule ran.:dynamic: a pre-existing%DynamicCouncil{}(from catalog or fallback) ran. Reproducible.:built: an LLM synthesized the council fresh. Non-deterministic; running the same prompt twice will pick a different shape unless you opt into caching.
7. CouncilEx.auto/1,2 shortcut
For apps with a single default AutoCouncil, the call site can skip
constructing the struct:
config :council_ex, :auto,
strategy: :cascade,
catalog: {:registry, :council},
on_no_match: :error,
options: [chain: [:rules, :llm_classify]]CouncilEx.auto(%{question: "..."}) # uses configured default
CouncilEx.auto(%{question: "..."}, strategy: :rules) # per-call overridePer-call opts are merged on top of the configured default. Struct opts
(:strategy, :catalog, :on_no_match, :name, :options,
:provider_check) build the router; everything else is forwarded to
run/3, so you can pass :verbose, :verbose_io, or :await_timeout
in the same keyword list:
CouncilEx.auto(%{question: "audit my SEO"}, verbose: true)With no config and no overrides, CouncilEx.auto/1 returns
{:error, :no_auto_config}.
Returns the same {:ok, %Result{}} shape as CouncilEx.run/3; inspect
result.metadata.auto to see what was picked.
8. Telemetry
[:council_ex, :auto_council, :decision] # one per resolve
measurements: %{latency_ms: integer}
metadata: %{auto_name, strategy, kind, catalog_id, error}
# `error` (string) present only on the failure path; absent on success
[:council_ex, :auto_council, :cascade_step] # one per step inside :cascade
measurements: %{latency_ms: integer}
metadata: %{auto_name, strategy, status: :ok | :error, reason}
# `reason` is nil on :ok steps
[:council_ex, :auto_council, :catalog_filtered] # only when provider_check drops ≥1 entry
measurements: %{filtered_out_count: int, kept_count: int}
metadata: %{auto_name, configured: [atom], dropped_ids: [String.t()]}:decision fires once per resolve/2 call, including via the
CouncilEx.run/3 integration. :cascade_step fires once per attempted
step inside a :cascade. :catalog_filtered fires only when at least
one entry is dropped; see Provider awareness above for handler
guidance.
9. Composability
Because AutoCouncil is a council from the runner's perspective:
- An
AutoCouncilmay resolve to anotherAutoCouncil(cascading routing layers). - An
AutoCouncilcan be a sub-council member of a%DynamicCouncil{}. - A built
%DynamicCouncil{}can itself contain sub-councils.
No special-case wiring; the standard runner handles all of these.
10. Public API
| Function | Purpose |
|---|---|
CouncilEx.AutoCouncil.new/1 | Build the struct. |
CouncilEx.AutoCouncil.resolve/2 | Pick a council without running it. Handy for UI previews and tests. |
CouncilEx.run(%AutoCouncil{}, input, opts) | Resolve + run synchronously. Attaches :auto to result.metadata. |
CouncilEx.auto/1,2 | Shortcut backed by :council_ex, :auto app config. |
CouncilEx.Registry.register_council/2 | Register a routable catalog entry at runtime. Caches :providers. |
CouncilEx.AutoCouncil.Providers.used/1 | Inspect a council's provider set. |
CouncilEx.AutoCouncil.Providers.eligible?/2 | Check whether all providers a council needs are configured. |
CouncilEx.AutoCouncil.Providers.configured/0 | MapSet of currently configured provider keys. |
MyStaticCouncil.__providers__/0 | Compile-time provider set (hoisted by use CouncilEx). |
11. Deferred / open questions
These are intentionally out of scope for the initial slice. Notes live
inside the relevant moduledocs (CouncilEx.AutoCouncil.Strategies.{Embedding,LLMClassify,LLMBuild}).
- Cache: prompt → decision cache for
:llm_*strategies. Open: key shape (exact vs semantic vs embedding bucket), storage (ETS / Cachex / user-supplied), invalidation on catalog edits, opt-in semantics for:built(since caching turns a non-deterministic feature into a deterministic one). - Async API:
CouncilEx.start/3does not yet have an%AutoCouncil{}clause. The syncrun/3path is supported. Wiring async needs threading the routing meta through theRunServerso it lands on theResultat completion. - Vectors: where embedding vectors live (alongside the registry entry vs a parallel store), staleness signal on description edits.
:llm_buildschema enforcement: structured output vs prompt + post-validate. Telemetry on synthesized plans (member count, prompt lengths). Optional promotion of synthesized plans into the registry (would cross from:builtinto:dynamicon subsequent hits).
12. See also
README.md: Auto-routing sectionDYNAMIC_COUNCILS.md:%DynamicCouncil{}reference; AutoCouncil's:builtdecisions return data councils.COMPOSITION.md: sub-councils + adaptive routers; orthogonal to AutoCouncil but composes with it.COUNCILS.md: pre-built static topologies (Specialist,Consensus,Tournament,WeightedConsensus,JuryWithRetry, …), common registrants in an AutoCouncil catalog.RELATED_WORK.md: map of multi-agent LLM papers and projects (Council Mode, MAD, Adjudicator, karpathy/llm-council, etc.) onto CouncilEx capabilities.