A general-purpose Elixir agent harness — the LLM tool-use loop, guardrails, and verification other developers can build agents on top of (a shopping agent, a research agent, a coding agent, a workflow or internal-automation agent, etc.). Named BeamAgent rather than Agent to avoid colliding with Elixir's own stdlib Agent module.

Elixir was chosen for the BEAM properties:

  • Isolated processes — each agent run is its own GenServer, so one run crashing (a tool raising, for example) has no effect on any other.
  • Supervision — every run is started under a DynamicSupervisor, so cleanup on completion, failure, or crash is automatic and any number of runs can be active concurrently.
  • OTP behaviours — pluggable pieces (BeamAgent.LLM.Client, BeamAgent.Tools.Behaviour, BeamAgent.Verifier.Behaviour) are plain Elixir behaviours, not a bespoke plugin system.
  • Message passing keeps tool execution and the call/reply protocol between a run and its caller clean and easy to reason about.

Usage

BeamAgent.API.run(
  "Find the cheapest supermarket for a weekly shop",
  llm: {MyApp.LLMClient, model: "..."},
  tools: %{price_check: MyApp.Tools.PriceCheck},
  guardrails: [max_iterations: 8, max_execution_time_ms: 30_000],
  verification: [required_tools: [:price_check]]
)
#=> {:ok, %BeamAgent.Run{}} | {:error, %BeamAgent.Run{}}

{:ok, run} only when the run finished and verification passed; everything else — a guardrail tripped, an unknown tool, a hard timeout, a runner crash, or verification failing a plausible-looking answer — comes back as {:error, run} with the reason on run. Every run.trace entry is retained either way, so a failure is fully inspectable, not just a bare error atom.

To plug in a real model, implement BeamAgent.LLM.Client (chat/2 :: {:reply, text} | {:tool_call, atom, map}). To add a tool, implement BeamAgent.Tools.Behaviour and pass it in the :tools map — tools aren't hardcoded into the harness, each run supplies its own.

Lifecycle and supervision

BeamAgent.API.run/2 starts one BeamAgent.Runner under BeamAgent.RunSupervisor and blocks the caller until a result is ready. Runs are restart: :temporary — a finished, failed, or crashed run is a terminal result to hand back to the caller, not a transient failure to retry, so there is no automatic restart.

Two independent time limits can race, by design:

  • BeamAgent.Guardrails' MaxExecutionTime check runs between steps and tool calls — graceful, produces a normal :max_execution_time_reached failure once the runner notices.
  • A tool call itself can't be preempted mid-flight (e.g. a tool blocking on slow I/O). BeamAgent.API.run/2 is the backstop: after max_execution_time_ms plus a grace period it force-terminates the runner and returns a :execution_timeout result instead.

Notable features

  • Deterministic context compression (BeamAgent.Context) — no LLM call involved — that keeps the model-facing message count bounded as a run goes on, folding aged-out messages into a running summary rather than dropping them.
  • Four independent, individually configurable guardrails: max iterations, max context messages, max execution time, max tool calls.
  • Post-run verification (BeamAgent.Verifier) that catches a model claiming success without actually calling the tools it needed to — pluggable via BeamAgent.Verifier.Behaviour for domain-specific checks.
  • A full execution trace on every run, success or failure.
  • No dependencies — built entirely on stdlib/OTP.

Development

mix deps.get    # fetch deps (currently none declared)
mix compile      # compile
mix test          # run the full test suite
mix format          # format per .formatter.exs