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'MaxExecutionTimecheck runs between steps and tool calls — graceful, produces a normal:max_execution_time_reachedfailure 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/2is the backstop: aftermax_execution_time_msplus a grace period it force-terminates the runner and returns a:execution_timeoutresult 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 viaBeamAgent.Verifier.Behaviourfor 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