ALLM.Engine (allm v0.3.0)

Copy Markdown View Source

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_adaptermodule() | 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.
  • :modelString.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.

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.

t()

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

resolved_model()

@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()

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

t()

@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

merge_opts(engine, opts)

@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(opts \\ [])

@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(e, key, value)

@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(e, key, value)

@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(e, tool)

@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(e, more)

@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(engine, opts)

@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(engine, opts)

@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(engine, opts)

@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(e, model)

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