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

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 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` — see Phase 18 / spec §12.4) 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` — `[]` 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.

# `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(),
  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` (Non-obvious
    decision #6 — 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 (`: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
    []

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

# `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 `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_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
(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"}

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

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