Layer B runtime engine — see spec §6.
An engine is a plain struct carrying only modules, atoms, and serializable
plain data (no PIDs, refs, funs, or API keys). It is the composition point
for the adapter, declared tools, and default request params/context; call
sites merge per-call overrides via the resolve_* functions.
Serializability (closed contract — Non-obvious decision #7)
Engines are safe to round-trip through :erlang.term_to_binary/1 and JSON
iff every field carries only modules, atoms, or plain serializable data:
:adapter,:tool_executor,:tool_result_encoder,:image_adapter—module() | nil. Modules are restored on JSON decode viaString.to_existing_atom/1; an adapter module not loaded in the BEAM at decode time surfaces as{:_unknown, :atom_decode_failed}via theALLM.Serializer.from_json/1error path ([:adapter] :module_not_loadedin the field-error vocabulary).:adapter_opts— keyword list of serializable values only. A keyword list containing a fun (e.g. a Finch retry callback) is rejected by the JSON encoder (Protocol.UndefinedErrorfrom Jason). Atom values in the kwlist (e.g.adapter_opts: [mode: :strict]) survive ETF but lose type on JSON round-trip (become binaries) — the decoder restores kwlist keys viaString.to_existing_atom/1but passes values through verbatim. This is the same caller-value asymmetry as:params/:context/:metadatabelow; callers whose adapter opts rely on atom values for provider behaviour (e.g.verify: :peer) should convert at the adapter boundary rather than expect round-trip equality through JSON. The same rule applies to kwlist-shaped:retry.:model—String.t() | nil.:tools—[ALLM.Tool.t()]where each tool's:handlerisnilor{Module, :function}. A tool with an anonymous-function handler is not JSON-serializable (seeALLM.Toolmoduledoc). Each tool's:manualflag (boolean, defaultfalse— see Phase 18 / spec §12.4) controls per-tool opt-out of auto-execution: whenmanual: true,ALLM.chat/3undermode: :autohalts with:manual_tool_callsinstead of running the handler.:params,:context,:metadata— maps of serializable values whose keys are restored as atoms viaString.to_existing_atom/1on JSON decode. Values pass through verbatim — the library does not deep-type caller-supplied data. Non-stdlib struct values (e.g.DateTime,Decimal) survive ETF round-trip but lose type on JSON decode unless the caller supplies a custom decoder; this asymmetry is by design.:retry—:default | false | keyword().:middleware—[]in v0.2 per spec §29.
Resolution precedence for the resolve_* functions follows spec §10:
call opts win over engine defaults. Unknown opts keys that are not in
the engine-field deny-list are forwarded to the adapter unchanged via
resolve_params/2 — this is how provider-specific knobs like
:reasoning_effort reach the adapter without the resolver having to know
about them.
middleware must stay [] in v0.2 (§29) — reserved for a later version.
Summary
Types
Late-resolved model value returned by resolve_model/2. A bare string,
a provider-tagged tuple, a struct (typically %ALLM.ModelRef{} when
the optional LLMDB catalog is loaded — spec §6.3), or nil when the
engine has no model and no override was passed.
Functions
Compose per-call overrides into the engine struct, returning a new engine.
Build an %ALLM.Engine{} from keyword opts.
Set a single entry in the engine's :context map.
Set a single entry in the engine's :params map.
Append a single tool to the engine's :tools list.
Append multiple tools to the engine's :tools list.
Resolve the effective model for an adapter call.
Resolve the effective params map for an adapter call via a shallow merge
of engine.params with opts filtered by the engine-field deny-list
(Non-obvious decision #5).
Resolve the effective tool list for an adapter call using dedup-by-name semantics (Non-obvious decision #4, Invariant 4).
Replace the engine's :model string.
Types
Late-resolved model value returned by resolve_model/2. A bare string,
a provider-tagged tuple, a struct (typically %ALLM.ModelRef{} when
the optional LLMDB catalog is loaded — spec §6.3), or nil when the
engine has no model and no override was passed.
@type retry() :: :default | false | keyword()
@type t() :: %ALLM.Engine{ adapter: module() | nil, adapter_opts: keyword(), context: map(), image_adapter: module() | nil, metadata: map(), middleware: [module()], model: String.t() | nil, params: map(), retry: retry(), tool_executor: module() | nil, tool_result_encoder: module() | nil, tools: [ALLM.Tool.t()] }
Functions
Compose per-call overrides into the engine struct, returning a new engine.
Convenience helper — merge_opts/2 is not a primitive of the resolver
chain; execution functions typically use resolve_model/2,
resolve_tools/2, and resolve_params/2 directly rather than rebuilding
an engine. merge_opts/2 is useful when you want a single engine value
reflecting per-call overrides (e.g. for telemetry or for passing into a
pre-built helper).
Recognized opt keys:
:model— replacesengine.modelviawith_model/2.:tools— dedup-by-name merge viaresolve_tools/2(Non-obvious decision #6 — this does not callput_tools/2, which is naive append).:params— shallow-merge intoengine.params. The value must be a map; a non-map value (e.g., a keyword list) is silently dropped, becauseengine.paramsis itself a map and the merge target is not defined for other shapes.:context— shallow-merge intoengine.context. Same map-only rule as:params.
Any other opts key is silently dropped — unknown keys are for execution functions, not the engine itself.
Total on valid input: given any %ALLM.Engine{} and any keyword list,
merge_opts/2 returns an %ALLM.Engine{} and does not raise.
Examples
iex> a = ALLM.Tool.new(name: "a", description: "a", schema: %{})
iex> b = ALLM.Tool.new(name: "b", description: "b", schema: %{})
iex> engine = ALLM.Engine.new(model: "old") |> ALLM.Engine.put_tool(a)
iex> merged = ALLM.Engine.merge_opts(engine, model: "new", tools: [b], params: %{temperature: 0.9})
iex> merged.model
"new"
iex> Enum.map(merged.tools, & &1.name)
["a", "b"]
iex> merged.params
%{temperature: 0.9}
Build an %ALLM.Engine{} from keyword opts.
Accepts any subset of the documented struct fields; unknown keys raise
KeyError via struct!/2. :adapter may be nil at construction — the
missing-adapter check fires at adapter-call time (:missing_adapter per
spec §20), not here.
Examples
iex> engine = ALLM.Engine.new(adapter: ALLM.Providers.Fake, model: "fake:m")
iex> engine.adapter
ALLM.Providers.Fake
iex> engine.model
"fake:m"
iex> engine.tools
[]
Set a single entry in the engine's :context map.
:context is opaque to the library; tool handlers receive it as the second
argument when declared with arity 2.
Examples
iex> engine = ALLM.Engine.new() |> ALLM.Engine.put_context(:user_id, 42)
iex> engine.context
%{user_id: 42}
Set a single entry in the engine's :params map.
Keys may be atoms or strings — adapters decide which form they accept at wire time.
Examples
iex> engine = ALLM.Engine.new() |> ALLM.Engine.put_param(:temperature, 0.7)
iex> engine.params
%{temperature: 0.7}
@spec put_tool(t(), ALLM.Tool.t()) :: t()
Append a single tool to the engine's :tools list.
Naive append — does not dedup by :name. Use merge_opts/2 or
resolve_tools/2 when you need opts-win dedup semantics for per-call
overrides.
Examples
iex> tool = ALLM.Tool.new(name: "echo", description: "echo", schema: %{})
iex> engine = ALLM.Engine.new() |> ALLM.Engine.put_tool(tool)
iex> Enum.map(engine.tools, & &1.name)
["echo"]
@spec put_tools(t(), [ALLM.Tool.t()]) :: t()
Append multiple tools to the engine's :tools list.
Naive append (engine.tools ++ more) — does not dedup by :name. Use
merge_opts/2 for per-call override semantics that replace an engine tool
in place when names collide (Non-obvious decision #6).
Examples
iex> a = ALLM.Tool.new(name: "a", description: "a", schema: %{})
iex> b = ALLM.Tool.new(name: "b", description: "b", schema: %{})
iex> engine = ALLM.Engine.new() |> ALLM.Engine.put_tools([a, b])
iex> Enum.map(engine.tools, & &1.name)
["a", "b"]
@spec resolve_model( t(), keyword() ) :: resolved_model()
Resolve the effective model for an adapter call.
Reads opts[:model] if present, otherwise falls back to engine.model.
When llm_db is loaded in the BEAM, delegates to LLMDB.model/1 on the
chosen value so the caller can receive a catalog-backed model ref; when
absent (the default v0.2 configuration), returns the value verbatim
(Non-obvious decision #3, spec §6.3). Core must function without
llm_db.
Examples
iex> engine = ALLM.Engine.new(model: "fake:m")
iex> ALLM.Engine.resolve_model(engine, [])
"fake:m"
iex> ALLM.Engine.resolve_model(engine, model: "override")
"override"
iex> ALLM.Engine.resolve_model(ALLM.Engine.new(model: {:openai, "gpt-x"}), [])
{:openai, "gpt-x"}
Resolve the effective params map for an adapter call via a shallow merge
of engine.params with opts filtered by the engine-field deny-list
(Non-obvious decision #5).
Returned as a map (not a keyword list). Engine-field keys
(:adapter, :adapter_opts, :model, :tools, :tool_executor,
:tool_result_encoder, :image_adapter, :params, :context,
:retry, :middleware, :metadata, :api_key) are never forwarded —
they are consumed by the engine layer or by other resolvers. Every
other opts key flows through unchanged, so provider-specific knobs
(:reasoning_effort) and orchestration knobs (:max_turns,
:halt_when) naturally reach the adapter per spec §10's "unknown
options in opts are forwarded to the adapter unchanged" rule.
Examples
iex> engine = ALLM.Engine.new(params: %{temperature: 0.2, top_p: 1.0})
iex> ALLM.Engine.resolve_params(engine, temperature: 0.7)
%{temperature: 0.7, top_p: 1.0}
iex> ALLM.Engine.resolve_params(engine, model: "x", reasoning_effort: "high")
%{temperature: 0.2, top_p: 1.0, reasoning_effort: "high"}
@spec resolve_tools( t(), keyword() ) :: [ALLM.Tool.t()]
Resolve the effective tool list for an adapter call using dedup-by-name semantics (Non-obvious decision #4, Invariant 4).
The result preserves engine.tools order; each engine tool whose :name
matches a tool in opts[:tools] is replaced in place by the matching
opts tool. Opts tools whose :name doesn't match any engine tool are
appended in opts[:tools] order. Example: engine.tools = [a, b, c],
opts[:tools] = [b', d] → [a, b', c, d].
When opts has no :tools key, returns engine.tools unchanged.
Examples
iex> a = ALLM.Tool.new(name: "a", description: "a", schema: %{})
iex> b = ALLM.Tool.new(name: "b", description: "b", schema: %{})
iex> c = ALLM.Tool.new(name: "c", description: "c", schema: %{})
iex> b_prime = ALLM.Tool.new(name: "b", description: "override", schema: %{})
iex> d = ALLM.Tool.new(name: "d", description: "d", schema: %{})
iex> engine = ALLM.Engine.new() |> ALLM.Engine.put_tools([a, b, c])
iex> ALLM.Engine.resolve_tools(engine, tools: [b_prime, d]) |> Enum.map(& &1.name)
["a", "b", "c", "d"]
Replace the engine's :model string.
Examples
iex> engine = ALLM.Engine.new(model: "old") |> ALLM.Engine.with_model("new")
iex> engine.model
"new"