# `ALLM.Engine`
[🔗](https://github.com/cykod/ALLM/blob/v0.5.0/lib/allm/engine.ex#L1)

Layer-B runtime engine — the composition point for an adapter, declared
tools, and default request params/context.

An engine is a plain struct carrying only modules, atoms, and serializable
plain data (no PIDs, refs, funs, or API keys). Call sites merge per-call
overrides via the `resolve_*` functions: **call opts win over engine
defaults**.

## Building an engine

The deterministic `ALLM.Providers.Fake` adapter requires no API key:

    iex> engine = ALLM.Engine.new(
    ...> adapter: ALLM.Providers.Fake,
    ...> adapter_opts: [script: [{:text, "Hi"}, {:finish, :stop}]]
    ...>)
    iex> engine.adapter
    ALLM.Providers.Fake

Swap `ALLM.Providers.Fake` for `ALLM.Providers.OpenAI`,
`ALLM.Providers.Anthropic`, or `ALLM.Providers.Gemini` and pass `:model`
to use a real provider. API keys resolve through `ALLM.Keys`.

## Serializability

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:

  * `:id` — `integer() | nil`. A plain positive integer
    (`System.unique_integer([:positive])`) auto-stamped by `new/1`, or
    `nil` for a hand-built `%Engine{}`. Round-trips natively through both
    ETF and JSON. `__from_tagged__/1` restores it verbatim (no
    re-stamping); a pre-fix serialized engine lacking `"id"` decodes to
    `nil`. Used as the per-engine cursor key for the deterministic
    `ALLM.Providers.Fake` / `ALLM.Providers.FakeImages` adapters (§31).
  * `:adapter`, `:tool_executor`, `:tool_result_encoder`, `:image_adapter`,
    `:embed_adapter` — `module | nil`. Modules are restored on JSON decode via
    `String.to_existing_atom/1`; an adapter module not loaded in the BEAM
    at decode time surfaces as `{:_unknown, :atom_decode_failed}` via the
    `ALLM.Serializer.from_json/1` error path (`[:adapter] :module_not_loaded`
    in 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.UndefinedError` from 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* via `String.to_existing_atom/1` but passes *values*
    through verbatim. This is the same caller-value asymmetry as
    `:params`/`:context`/`:metadata` below; 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 `:handler` is `nil` or
    `{Module, :function}`. A tool with an anonymous-function handler is not
    JSON-serializable (see `ALLM.Tool` moduledoc). Each tool's `:manual`
    flag (boolean, default `false`) controls per-tool opt-out of
    auto-execution: when `manual: true`, `ALLM.chat/3` under `mode:
    :auto` halts with `:manual_tool_calls` instead of running the
    handler.
  * `:params`, `:context`, `:metadata` — maps of serializable values whose
    keys are restored as atoms via `String.to_existing_atom/1` on 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` — `[]` today; reserved for a later version.

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.

# `resolved_model`

```elixir
@type resolved_model() :: String.t() | tuple() | struct() | nil
```

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.

# `retry`

```elixir
@type retry() :: :default | false | keyword()
```

# `t`

```elixir
@type t() :: %ALLM.Engine{
  adapter: module() | nil,
  adapter_opts: keyword(),
  context: map(),
  embed_adapter: module() | nil,
  id: integer() | nil,
  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()]
}
```

# `merge_opts`

```elixir
@spec merge_opts(
  t(),
  keyword()
) :: t()
```

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` — replaces `engine.model` via `with_model/2`.
  * `:tools` — dedup-by-name merge via `resolve_tools/2` (this does
    **not** call `put_tools/2`, which is naive append).
  * `:params` — shallow-merge into `engine.params`. The value **must be
    a map**; a non-map value (e.g., a keyword list) is silently dropped,
    because `engine.params` is itself a map and the merge target is not
    defined for other shapes.
  * `:context` — shallow-merge into `engine.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}

# `new`

```elixir
@spec new(keyword()) :: t()
```

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 (surfaces as
`%EngineError{reason: :missing_adapter}`), not here.

The module-typed fields `:adapter`, `:tool_executor`, `:tool_result_encoder`,
`:image_adapter`, and `:embed_adapter` must each be a module (a plain atom)
or `nil`. A
non-atom value — most commonly the unsupported
`tool_executor: {ALLM.ToolExecutor.Default, tools: %{...}}` form — raises
`ArgumentError` at construction rather than crashing later inside the tool
runner. Tool handlers live on the tool (`ALLM.tool(handler: fn args -> ... end)`),
not on the engine; `:tool_executor` is a bare module override for the
default executor. The guard is `is_atom/1`, so `nil` and any module (even one
not yet loaded) pass; a tuple, string, or map fails. See §6.4.

A stable, serializable `:id` is auto-stamped via
`System.unique_integer([:positive])` when none is supplied — this is the
per-engine identity used as the `Fake`/`FakeImages` multi-call cursor key
(see §31). Because every constructed engine gets a distinct id,
`Engine.new(opts) != Engine.new(opts)` by `:id` — each constructed engine
*is* a distinct instance. An explicitly-passed `id:` opt is preserved
(supports deterministic reconstruction). Derived engines built via
struct-update (`with_model/2`, `merge_opts/2`, `put_tool/2`, …) keep the
same `:id` — correct for "same engine, reconfigured"; use `new/1` when you
need two independent multi-call engines.

## 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
    []
    iex> is_integer(engine.id) and engine.id > 0
    true

# `put_context`

```elixir
@spec put_context(t(), atom() | String.t(), term()) :: t()
```

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}

# `put_param`

```elixir
@spec put_param(t(), atom() | String.t(), term()) :: t()
```

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}

# `put_tool`

```elixir
@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"]

# `put_tools`

```elixir
@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.

## 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"]

# `resolve_model`

```elixir
@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 the optional `LLMDB` model-catalog module 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), returns
the value verbatim. Core ALLM functions without the catalog.

## 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_params`

```elixir
@spec resolve_params(
  t(),
  keyword()
) :: map()
```

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.

Returned as a **map** (not a keyword list). Engine-field keys
(`:adapter`, `:adapter_opts`, `:model`, `:tools`, `:tool_executor`,
`:tool_result_encoder`, `:image_adapter`, `:embed_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 — unknown options in `opts`
are forwarded to the adapter unchanged.

## 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"}

# `resolve_tools`

```elixir
@spec resolve_tools(
  t(),
  keyword()
) :: [ALLM.Tool.t()]
```

Resolve the effective tool list for an adapter call using dedup-by-name
semantics.

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"]

# `with_model`

```elixir
@spec with_model(t(), String.t()) :: t()
```

Replace the engine's `:model` string.

## Examples

    iex> engine = ALLM.Engine.new(model: "old") |> ALLM.Engine.with_model("new")
    iex> engine.model
    "new"

---

*Consult [api-reference.md](api-reference.md) for complete listing*
