ExAgent (ExAgent v2.2.0)

View Source

ExAgent 2.2 is available on Hex. When upgrading from 1.x, read the migration guide for the runtime, model, event and snapshot changes. See supported features and limits for deployment requirements.

An agent framework for Elixir — structured output, tool-calling, streaming, stateful agents, multi-agent sessions and durable persistence, powered by the BEAM.

ExAgent is layered and opt-in: use just the one-shot core, or stack on the stateful runtime, persistence and coordination as you need them. It is built the Elixir way — recursion, behaviours, Ecto changesets, cheap concurrency for tools, supervision/durability, :telemetry, and events that plug straight into LiveView.

Layer 3  ExAgent.Session          coordinated multi-agent turns + shared state
Layer 2  ExAgent.Store            snapshots: resume after crash / restart
Layer 1  ExAgent.Server           a supervised, stateful, event-emitting agent
Layer 0  ExAgent.run/3            the one-shot model ⇄ tools loop
         ────────────────────────  events (ExAgent.Event) over ExAgent.PubSub

Features

  • One-shot agentic loop — a model ⇄ tools recursion built as idiomatic Elixir.
  • Type-derived tool schemas — define tools as plain functions; JSON Schema is generated from name :: Type annotations and @doc strings (no hand-written schemas).
  • Skills loaded on demand — the model sees a catalog of SKILL.md instructions (the Agent Skills format shared with other agent harnesses) and loads one when a task needs it; skills can unlock their own tools.
  • Structured output — any Ecto embedded_schema becomes the output spec; JSON Schema is derived from the schema and its changeset validations, validated with retry-on-failure.
  • Streaming — a lazy, demand-driven view of the same tool/output loop, with provisional deltas and one complete result or partial failure.
  • Supervised stateful agents — keep history, accumulate usage, thread stateful models across runs, and emit versioned events over PubSub (LiveView-ready).
  • Opt-in checkpoints — confirm complete state through a Store and restore a conversation on restart. ETS is in-process; Postgres uses your DB/repo. This does not replay or roll back arbitrary in-flight effects.
  • Multi-agent sessions — coordinated turns over shared state with pluggable turn policies (round_robin, initiative, or your own).
  • Orchestration — scoped delegation with ancestor budgets/permissions and hand-off between session participants, durable sequences, routing and bounded parallel flows.
  • Persisted human approval — pause at an admitted boundary and resume using versioned continuation records, explicit authority and recovery rules.
  • Robustness & safety — context compaction, usage/cost limits, and per-tool permissions (allow / ask / deny).
  • Model-agnostic — one stock ReqLLM backend with explicitly qualified profiles, custom specs/gateways and the public ExAgent.Model extension behaviour.
  • External tools (MCP) — consume stdio or opt-in Streamable HTTP tools with caller-owned timeouts, pending limits and transport cleanup.
  • Observable — :telemetry, app-level ExAgent.Event envelopes and opt-in native OpenTelemetry tracing and metrics. ExAgent owns its execution spans; the ReqLLM bridge supplies metadata without duplicate model spans. Content is disabled by default; the application owns its SDK and exporter.
  • Offline-first testing — a deterministic ExAgent.Models.Test model drives the full loop with no API key and no network.

Requirements

  • Elixir 1.18+ (required by ReqLLM's mandatory llm_db dependency)
  • Runtime targets: Elixir 1.18 / OTP 28 and Elixir 1.20 / OTP 29

See supported features and limits for runtime and dependency requirements. These targets do not cover every patch release or dependency combination.

ReqLLM starts its own supervisor and Finch pool alongside ExAgent. Its default startup loads .env in the host's working directory. Applications that manage credentials themselves should set config :req_llm, load_dotenv: false before startup. ExAgent.Models.ReqLLM.new/1 is the general backend; custom Model/Test remain extension points. Tools, Ecto-tool output and streaming use an explicit Chat profile: :chat_tools_v1, or :openrouter_chat_tools_v1 for OpenRouter provider routing. Native JSON Schema uses a separate opt-in profile. Catalogue resolution alone does not enable those capabilities. See models and limits for credentials, profiles, routing, normalized usage and host retention boundaries.

Installation

Add the published package to your application:

def deps do
  [{:exagent, "~> 2.2"}]
end

To develop against a source checkout, replace that dependency with {:exagent, path: "../exAgent"} and adjust the path to your clone.

The library starts its own supervised ExAgent.Finch HTTP pool, a Registry (ExAgent.PubSub.Local), a Task.Supervisor, an ExAgent.Store.ETS table and an ExAgent.AgentSupervisor, so it works out of the box. Tune the Finch pool with:

config :exagent, :finch_pools, %{:default => [size: 32]}

ExAgent does not shadow OTP's Agent unless you alias it as Agent.

Quick start

The fastest way to try ExAgent is with Mix.install/2 (Livebook or a script) — using the built-in ExAgent.Models.Test model, no API key needed:

Mix.install([
  {:exagent, "~> 2.2"}
])

agent = ExAgent.new(model: "test", instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")

Resolve a stock catalogue string or tuple with explicit instance credentials:

{:ok, model} = ExAgent.Model.resolve({:openai, id: "gpt-4o"},
  api_key: System.fetch_env!("OPENAI_API_KEY"))
agent = ExAgent.new(model: model, instructions: "Be concise.")
{:ok, %{output: text}} = ExAgent.run(agent, "Hello!")

Summary

Functions

Build an agent from options. Use :output for the output spec.

Resume a persisted approval from an explicitly supplied trusted agent and configuration.

Open a new lazy stream for a persisted continuation; the earlier paused stream stays terminal.

Run the agent against prompt, returning {:ok, result} or {:error, _}.

Run synchronously and return the output value directly, raising on error.

Run an auxiliary/delegated agent under an existing run's execution scope.

Run the agent, returning a lazy stream of events as the model generates.

Types

output_type()

@type output_type() :: :text | module()

prompt()

result()

@type result() :: %{
  output: term(),
  messages: [ExAgent.Message.t()],
  new_messages: [ExAgent.Message.t()],
  usage: ExAgent.Message.Usage.t(),
  run_step: non_neg_integer(),
  model: ExAgent.Model.model(),
  run_id: String.t(),
  root_run_id: String.t(),
  parent_run_id: String.t() | nil,
  model_request_id: String.t() | nil,
  request_count: non_neg_integer(),
  tool_calls: non_neg_integer(),
  cost_cents: number() | nil,
  cost_status: :known | :unknown,
  status: :succeeded | :failed | :cancelled | :running,
  usage_status: :complete | :partial,
  pending_response: ExAgent.Message.Response.t() | nil
}

t()

@type t() :: %ExAgent{
  capabilities: [module() | struct()],
  instructions: [ExAgent.Message.Part.System.t()],
  max_history_bytes: pos_integer(),
  max_payload_bytes: pos_integer(),
  max_steps: pos_integer(),
  model: ExAgent.Model.model(),
  name: String.t() | nil,
  observability: ExAgent.Observability.OpenTelemetry.t() | nil,
  on_max_steps: :error | :final,
  output_mode: :tool | :native,
  output_retries: non_neg_integer(),
  output_type: output_type(),
  settings: ExAgent.ModelSettings.t(),
  tool_timeout: pos_integer(),
  tools: [ExAgent.Tool.t()],
  usage_limits: ExAgent.UsageLimits.t() | nil
}

Functions

new(opts)

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

Build an agent from options. Use :output for the output spec.

Ecto output defaults to output_mode: :tool. Explicit :native sends a separate JSON schema to a model declaring native support; the actual changeset still validates the final object. There is no automatic fallback between modes.

Valid tool schemas are prepared once on the reusable definition. Invalid schemas still fail as operational RunErrors when running the agent; each run also checks that a prepared validator matches the tool's current schema.

Retention defaults are max_payload_bytes: 1_048_576 and max_history_bytes: 8_388_608, configurable from1 through64MiB. They measure uncompressed Erlang external term size after decode, not live RAM. A tool may already have performed its effect when its returned payload exceeds a limit: its outcome/identity are preserved with an explicit payload_omitted marker and a terminal RunError. Such history cannot be executed again automatically.

:skills takes a list of ExAgent.Skills that the model loads on demand. It adds a load_skill tool (plus read_skill_file and the skills' gated tools), an ExAgent.Skills.Gate before :capabilities when a skill has tools, and ExAgent.Skills.Restore after them; see ExAgent.Skills. Invalid skills or clashing tool names raise ArgumentError.

:on_max_steps chooses what the last allowed step does. :error (default) keeps the previous behaviour. :final asks the model for its final answer in step max_steps: function tools stay declared but cannot be called, and a request-only instruction says the step limit was reached. Function calls in that step are not executed and the run fails with :max_steps_exceeded.

resume(agent, reference, opts \\ [])

Resume a persisted approval from an explicitly supplied trusted agent and configuration.

resume_stream(agent, reference, opts \\ [])

Open a new lazy stream for a persisted continuation; the earlier paused stream stays terminal.

run(agent, prompt, opts \\ [])

@spec run(t(), prompt(), keyword()) :: {:ok, result()} | {:error, term()}

Run the agent against prompt, returning {:ok, result} or {:error, _}.

prompt is a string or a non-empty list of content parts (text, images and documents; see ExAgent.Message.Part.User). An invalid part fails with {:invalid_prompt_part, index} and an empty list or other value with :invalid_prompt, before any model request.

Operational errors contain an ExAgent.RunError: its reason is the cause and partial preserves history, usage and the last confirmed model. Retrying the prompt is a new execution and can repeat effects.

Options:

  • :deps — dependency value threaded into RunContext.
  • :message_history — prior Message.t() list to continue from.
  • :max_payload_bytes, :max_history_bytes — finite overrides of the definition's postdecode retention bounds. Results expose :retention measurements of history and public data, excluding live model/configuration.
  • :model_settings — per-run settings overrides.
  • :stream_text — stream model text through the same agent loop.
  • :estimate_cost — (usage -> cents) for a homogeneous model tree, or (model, usage -> cents) for model-aware pricing; nil/:unknown remains unknown. Monetary limits require a valid estimator before model admission.
  • :deadline — optional absolute monotonic milliseconds; inherited descendants may only shorten it. Admission and native IO timeouts honor it, but arbitrary application callbacks are not preempted.
  • :max_concurrent_requests — optional positive cap, shared with descendants; saturated admission fails explicitly without adding a waiting queue.
  • :on_progress — optional runtime callback receiving full result-shaped snapshots; these contain a live model and must not be exported as JSON. While a batch is in flight, unresolved calls have status: :unknown in these snapshots; final history replaces them with the collected outcomes.
  • :observability — optional ExAgent.Observability.OpenTelemetry config; overrides the agent default. false disables instrumentation for this run.
  • :trace_context — ephemeral context captured with the OTel adapter; use for application-owned process boundaries, never persisted snapshots.

run!(agent, prompt, opts \\ [])

@spec run!(t(), prompt(), keyword()) :: term()

Run synchronously and return the output value directly, raising on error.

run_child(parent, agent, prompt, opts \\ [])

Run an auxiliary/delegated agent under an existing run's execution scope.

Accepts a RunContext or the state passed to a model capability. The helper inherits dependencies and fixes ancestry after caller options; a builder cannot replace the inherited scope. Child limits/policies add restrictions to all ancestors. Child progress is not forwarded as parent conversation history.

run_stream(agent, prompt, opts \\ [])

@spec run_stream(t(), prompt(), keyword()) :: Enumerable.t()

Run the agent, returning a lazy stream of events as the model generates.

This is the streaming variant. It yields:

  • {:delta, binary} — incremental output text (one per model text chunk),
  • {:result, map} — the final result once the stream completes.

On failure it yields one {:error, %ExAgent.RunError{}} terminal. Deltas are provisional text from all model requests; the final output is validated by the same tools/output/hooks/limits loop as run/3.

Each enumeration starts a new run. Halting enumeration or killing its owner cancels the owned worker; effects already performed are not rolled back. A suspended Enumerable continuation must be resumed or explicitly halted.