GenAgent behaviour (GenAgent v0.8.0)

Copy Markdown View Source

A behaviour and supervision framework for long-running LLM agent processes, modeled as OTP state machines.

Each agent is a :gen_statem process wrapping a persistent LLM session. Every interaction is a prompt-response turn, and the implementation decides what happens between turns.

It is a GenServer but every call is a prompt.

GenAgent handles the mechanics of turns. Implementations handle the semantics of turns.

Public client functions address agents by their registered :name. The pid returned by start_agent/2 is for monitoring and supervision, not a client address. Synchronous calls return {:error, :not_found} when that name is absent at lookup. A concurrent stop or agent death after lookup can still exit the call, and a caller-supplied timeout exits on expiry. Casts are best effort and return :ok even when no agent is registered.

Installation

def deps do
  [
    {:gen_agent, "~> 0.8.0"} # x-release-please-version
  ]
end

Add at least one backend using its own installation guide: Claude, Codex, Anthropic, or OpenAI.

Quick start

defmodule MyApp.Coder do
  use GenAgent

  defmodule State do
    defstruct [:path, responses: []]
  end

  @impl true
  def init_agent(opts) do
    path = Keyword.fetch!(opts, :cwd)

    backend_opts = [
      cwd: path,
      system_prompt: "You are a coding assistant."
    ]

    {:ok, backend_opts, %State{path: path}}
  end

  @impl true
  def handle_response(_ref, response, state) do
    {:noreply, %{state | responses: state.responses ++ [response.text]}}
  end
end

# Start the agent under the GenAgent supervision tree.
{:ok, _pid} = GenAgent.start_agent(MyApp.Coder,
  name: "my-coder",
  backend: GenAgent.Backends.Claude,
  cwd: "/path/to/project"
)

# Synchronous prompt.
{:ok, response} = GenAgent.ask("my-coder", "What does lib/foo.ex do?")
IO.puts(response.text)

# Async prompt.
{:ok, ref} = GenAgent.tell("my-coder", "Add tests for lib/foo.ex")
GenAgent.poll("my-coder", ref)
#=> {:ok, :pending} while the turn runs, then {:ok, :completed, response}

# Or have the outcome sent to you as a message.
{:ok, ref} = GenAgent.tell_with_completion("my-coder", "Run the tests")

receive do
  {:gen_agent, :completion, "my-coder", ^ref, {:ok, response}} -> IO.puts(response.text)
  {:gen_agent, :completion, "my-coder", ^ref, {:error, reason}} -> IO.inspect(reason)
end

# External event.
GenAgent.notify("my-coder", {:ci_failed, "test_auth"})

GenAgent.stop("my-coder")

State model

An agent is a state machine with two states:

idle --- ask/tell/notify ---> processing
                                  |
                                  v
idle <--- handle_response --- processing (turn done)

Self-chaining: handle_response/3 can return {:prompt, text, state} to immediately dispatch another turn without a caller, useful for multi-step work that the agent drives itself.

Halting: handle_response/3, handle_error/3, handle_event/2, or pre_turn/2 can return {:halt, state} to go idle but freeze the mailbox. An ordinary ask/3, tell/3, or tell_with_completion/4 admitted while halted waits in the queue until resume/1 is called. Inspect status/2 or runtime_snapshot/2 to distinguish a halted queue from an active turn. The five-argument tell_with_completion/5 supports on_halt: :fail for callers that cannot resume the agent.

Errors

ask/3 returns {:error, reason} for a failed request, and poll/3 and tell_with_completion/4 expose the same request outcome. The error_reason/0 type permits backend-provided reasons; the framework_error_reason/0 type names reasons produced by GenAgent:

  • :pre_turn_skipped, :pre_turn_halted, :pre_turn_invalid -- pre_turn/2 skipped, halted, or returned an invalid value before an external request was dispatched. A crashing hook also reports :pre_turn_skipped to that caller.
  • {:pre_turn_crashed, exception_kind} -- a generated prompt's pre_turn/2 crashed; its handle_error/3 callback receives this reason. A generated prompt skipped or rejected for an invalid return reports the corresponding atom above to handle_error/3.
  • :task_supervisor_unavailable -- the prompt task could not start.
  • :no_terminal_event -- a backend stream ended without a terminal result or error event.
  • {:event_capture_overflow, diagnostics} -- lossless event capture exceeded its configured limit.
  • :timeout, :interrupted, {:task_crashed, reason} -- the watchdog, interruption, or prompt-task exit ended a turn.
  • {:overloaded, info} -- a pending input or generated prompt exceeded a configured limit.
  • :cancelled, :halted, :draining -- a queued request was cancelled, a request using on_halt: :fail was rejected by a halt, or the agent began draining before dispatch.
  • {:agent_terminated, reason} -- an active or queued ask was replied to during an orderly stop.

Lookup returns :not_found for an unregistered name; poll/3 uses it for an unknown or pruned ref. A backend may return any error term, including one that has the same value as a framework reason. See each API's documentation for when it accepts a request and when it returns an admission error.

Backends

Backends implement GenAgent.Backend and translate the LLM-specific wire protocol into the normalized GenAgent.Event stream the state machine consumes. Available backends (in sibling packages):

  • GenAgent.Backends.Claude (package: gen_agent_claude) -- wraps the Anthropic claude CLI via ClaudeWrapper.
  • GenAgent.Backends.Codex (package: gen_agent_codex) -- wraps the OpenAI codex CLI via CodexWrapper.
  • GenAgent.Backends.Anthropic (package: gen_agent_anthropic) -- calls the Anthropic HTTP API via Req.
  • GenAgent.Backends.OpenAI (package: gen_agent_openai) -- calls the OpenAI Responses API via Req.

A backend owns its session lifecycle, translates events, and carries any state it needs (session id, message history) in an opaque session term.

Callbacks

Lifecycle hooks (all optional):

  • pre_run/1 -- one-time setup after init_agent, before the first turn.
  • pre_turn/2 -- before each dispatch. Can rewrite the prompt, skip, or halt.
  • post_turn/3 -- after each turn, post-decision. For state-mutating side effects.
  • post_run/1 -- on clean {:halt, state} from a decision callback or pre_turn/2. For completion side effects.

The use GenAgent macro provides default implementations of most optional callbacks and lifecycle hooks, plus an overridable child_spec/1 that delegates to child_spec/2. It deliberately leaves handle_info/2 undefined so unexpected OTP messages can be logged.

Where callbacks run

Callback or stepRuns in
init_agent/1, pre_run/1, pre_turn/2agent process
handle_response/3, handle_error/3, handle_event/2, handle_info/2agent process
post_turn/3, post_run/1, terminate_agent/2agent process
handle_stream_event/2prompt task
Backend prompt/2,3, its event stream, update_session/2prompt task
Backend start_session/1, terminate_session/1, checkpoint_session/2agent process

Callback contracts

In this table, decision means callback_return/0 (a follow-up prompt must be a binary). Log levels describe GenAgent's callback failure logs. Raise/throw/exit refers to failures caught during callback execution; an untrappable process kill bypasses these handlers.

CallbackProcess / contextValid returnsInvalid returnRaise / throw / exit
init_agent/1agent, initialization{:ok, backend_opts, state}, {:error, reason}startup fails with {:init_agent_failed, value}error log; startup fails with {:init_failed, kind, failure_kind}
pre_run/1agent, after initialization{:ok, state}, {:error, reason}error log; stops with :pre_run_invaliderror log; stops with {:pre_run_crashed, failure_kind}
pre_turn/2agent, before dispatchpre_turn_return/0warning log; rejects with :pre_turn_invalidwarning log; external request gets :pre_turn_skipped; generated prompt calls handle_error/3 with {:pre_turn_crashed, failure_kind}
handle_response/3agent, successful turndecisionerror log; stops agenterror log; stops agent
handle_error/3agent, failed turn or generated-prompt rejectiondecisionerror log; keeps prior state and original errorerror log; keeps prior state and original error
handle_event/2, handle_info/2agent, immediate or buffered deliverydecisionerror log; keeps prior stateerror log; keeps prior state
handle_stream_event/2prompt task, each backend eventany term (next state)no return validationtask fails; agent calls handle_error/3 with a recorded checkpoint error, otherwise {:task_crashed, reason}
post_turn/3agent, after decision{:ok, state}warning log; keeps post-decision state and transitionwarning log; keeps post-decision state and transition
post_run/1agent, transition to halted:ok (return ignored)ignored without loggingwarning log; halt completes
terminate_agent/2agent, terminationany term (ignored)no return validationerror log; cleanup continues

current_name/0 returns the registered agent name in callbacks on either process, including init_agent/1 and handle_stream_event/2. It returns nil outside those processes. The name is kept separate from the options that an agent might forward to its backend.

Anything that runs in the agent process blocks the agent from handling other messages while it runs. Synchronous calls such as status/2, poll/3, tell/3, runtime_snapshot/2 and notify_ack/3 are handled by that process, so they wait until the callback returns. Their default timeout is :infinity, so a caller waits as long as the callback takes unless it passes a shorter timeout. Asynchronous casts such as notify/2 return immediately (the event is handled after the callback), and whereis/1 reads the registry without messaging the agent. Work in the prompt task does not block the agent process, with one exception: a backend's prompt/3 can request a session checkpoint through the :checkpoint function in its options. That call is synchronous, so the prompt task waits while the agent process runs checkpoint_session/2, and synchronous agent calls wait for it too. The agent also runs checkpoint_session/2 when restoring a stored checkpoint after a prompt task returns a success or error. If the task crashes, the agent keeps the earlier checkpoint without invoking the callback again.

Process lifecycle

Agents use restart: :temporary. A crashed or stopped agent must be started explicitly; GenAgent does not restore its previous state.

{:halt, state} marks work complete but keeps the agent registered so its final state can be read and it can be resumed. An external owner should read any final state it needs, then call stop/1 (or stop/2 for a caller-owned supervisor) to release the agent and backend session.

Each turn runs in a supervised prompt task. Task failures are delivered to handle_error/3 without taking down the agent. When the agent exits, its active prompt task is stopped, including when an abrupt exit bypasses termination callbacks.

Stopping the BEAM task does not establish that a provider's subprocess or remote request has stopped. The backend and its transport own external cancellation and resource cleanup.

Callback failure logs include the agent name and callback module. Logger metadata also carries :gen_agent and :gen_agent_module for filtering; applications can opt to include those keys in their Logger formatter. Stack frames include module, function, file, and line but omit argument values and exception messages, which may contain prompt or state data.

Public API

Data types

Telemetry

GenAgent emits telemetry events for observability:

  • [:gen_agent, :turn, :start | :stop | :error | :rejected]

  • [:gen_agent, :prompt, :start | :stop | :error]

  • [:gen_agent, :event, :received]
  • [:gen_agent, :state, :changed]
  • [:gen_agent, :mailbox, :queued]
  • [:gen_agent, :input, :rejected]
  • [:gen_agent, :halted]
  • [:gen_agent, :terminated]

Use the content-free turn events for metrics. The older prompt and event telemetry can include prompts, raw errors, and agent state. See GenAgent.Telemetry for measurements, units, correlation, and ordering.

What GenAgent does not do

  • It does not prescribe agent behavior (no retry logic, no summary format).
  • It does not prescribe inter-agent communication (agents can notify/2 each other but the message format is up to you).
  • It does not manage persistence across restarts.
  • It does not manage cost tracking or budgets.

See GenAgent.Backend for the backend behaviour, GenAgent.Event and GenAgent.Response for the data types delivered to callbacks.

Summary

Types

Opaque term owned by the implementation module, carried across callbacks.

Return value of callbacks that may request a follow-up action.

Error reason exposed by request APIs. This includes framework_error_reason/0 and arbitrary reasons from a backend.

Reasons generated by GenAgent for request failures and rejected work. Backend-specific reasons are not included here.

Name under which an agent is registered in GenAgent.Registry.

Return value of pre_turn/2. The hook can pass the prompt through (optionally rewritten), skip the turn, or halt the agent.

Reference returned for tell/2 requests.

Bounded, metadata-only observation of one agent's runtime state.

Callbacks

A prompt->response turn failed. Optional. Decide what to do next.

An external event arrived via notify/2. Optional.

Handle an ordinary OTP message sent to the agent process. Optional.

A prompt->response turn completed successfully. Decide what to do next.

A streaming event arrived mid-turn. Optional.

Initialize the agent. Return backend options and the initial agent state.

Clean-completion hook. Optional.

Per-turn post-dispatch hook. Optional.

One-time setup hook, fires after init_agent/1 and before the first turn. Optional.

Per-turn pre-dispatch hook. Optional.

The agent is shutting down. Optional. Clean up resources.

Functions

Send a synchronous prompt to an agent.

Build a temporary agent child spec for a caller-owned supervisor.

Return the registered name from an agent callback, or nil elsewhere.

Stop an agent after its active turn finishes.

Halt an agent from outside its callback module.

Interrupt an in-flight turn.

Interrupt the active turn only if its request reference matches ref.

Return an unordered list of registered agent names.

Push an external event into the agent.

Send an event and wait for the agent to acknowledge admission.

Check the status of a previously-issued tell/2 request.

Reset an agent's backend conversation context without restarting the agent.

Resume a halted agent.

Read a bounded, metadata-only runtime snapshot of an agent.

Start an agent under the GenAgent supervision tree.

Read an agent's current status.

Send an asynchronous prompt to an agent.

Submit an asynchronous prompt and opt into one completion message.

Look up the pid of a registered agent, or nil if not found. Pass the name, not this pid, to public client functions.

Types

agent_state()

@type agent_state() :: term()

Opaque term owned by the implementation module, carried across callbacks.

callback_return()

@type callback_return() ::
  {:noreply, agent_state()}
  | {:prompt, String.t(), agent_state()}
  | {:halt, agent_state()}

Return value of callbacks that may request a follow-up action.

error_reason()

@type error_reason() :: term()

Error reason exposed by request APIs. This includes framework_error_reason/0 and arbitrary reasons from a backend.

framework_error_reason()

@type framework_error_reason() ::
  :pre_turn_skipped
  | :pre_turn_halted
  | :pre_turn_invalid
  | {:pre_turn_crashed, atom()}
  | :task_supervisor_unavailable
  | :no_terminal_event
  | {:event_capture_overflow, map()}
  | :timeout
  | :interrupted
  | {:task_crashed, term()}
  | {:overloaded, map()}
  | :cancelled
  | :halted
  | :draining
  | :not_found
  | {:agent_terminated, term()}

Reasons generated by GenAgent for request failures and rejected work. Backend-specific reasons are not included here.

name()

@type name() :: term()

Name under which an agent is registered in GenAgent.Registry.

pre_turn_return()

@type pre_turn_return() ::
  {:ok, prompt :: String.t(), agent_state()}
  | {:skip, agent_state()}
  | {:halt, agent_state()}

Return value of pre_turn/2. The hook can pass the prompt through (optionally rewritten), skip the turn, or halt the agent.

request_ref()

@type request_ref() :: reference()

Reference returned for tell/2 requests.

runtime_snapshot()

@type runtime_snapshot() :: %{
  phase: :idle | :processing,
  halted: boolean(),
  halt_pending: boolean(),
  draining: boolean(),
  pending_prompts: non_neg_integer(),
  pending_notifications: non_neg_integer(),
  self_chain_pending: boolean(),
  current_request:
    nil
    | %{
        ref: request_ref(),
        origin: :ask | :tell | :event | :self_chain,
        attempt: pos_integer(),
        elapsed_ms: non_neg_integer(),
        watchdog_ms: non_neg_integer() | :infinity
      }
}

Bounded, metadata-only observation of one agent's runtime state.

Callbacks

handle_error(request_ref, reason, agent_state)

(optional)
@callback handle_error(
  request_ref :: reference(),
  reason :: error_reason(),
  agent_state()
) ::
  callback_return()

A prompt->response turn failed. Optional. Decide what to do next.

Called when the turn could not complete successfully. Covers:

  • The backend returned a synchronous {:error, reason} from GenAgent.Backend.prompt/2.
  • The event stream ended without a terminal :result or :error event.
  • The backend's event stream emitted a terminal :error event.
  • Lossless event capture exceeded a configured limit (delivered as {:event_capture_overflow, diagnostics}).
  • The prompt task crashed (delivered as {:task_crashed, reason}).
  • The configured task supervisor was unavailable before the turn started (:task_supervisor_unavailable). An immediate retry returned here is discarded to avoid a loop; a later request may retry after recovery.
  • The watchdog fired (:timeout).
  • The in-flight request was interrupted by interrupt/1 (:interrupted).
  • A generated prompt exceeded its queue limit (delivered as {:overloaded, info}), or its pre_turn/2 hook skipped, crashed, or returned an invalid value. A generated prompt halted by pre_turn/2 does not call this callback.

The backend may also supply its own reason. See error_reason/0 and the Errors section above for framework-generated reasons and their caller-visible results.

Returns the same value shape as handle_response/3, so the callback can go idle, retry with {:prompt, prompt, state}, or halt the agent. For ask and tell turns, a retry retains the original request reference and caller: ask waits, poll stays pending, and completion is sent exactly once, with the final outcome. Each failed attempt calls this callback and post_turn/3 again. There is no built-in retry cap; keep a budget in state. The watchdog applies separately to each attempt.

Interruption ends the caller's request with :interrupted; a prompt returned here becomes an independent follow-up. Errors on event and self-chain turns also produce independent follow-ups, as does handle_response/3. A retry rejected by the prompt byte cap delivers the original error. Pending retries follow queued-work halt and cancellation rules. The default implementation provided by use GenAgent is {:noreply, state}. Exceptions and malformed returns are logged and treated as {:noreply, previous_state} so the original turn error still reaches its caller.

handle_event(event, agent_state)

(optional)
@callback handle_event(event :: term(), agent_state()) :: callback_return()

An external event arrived via notify/2. Optional.

Also used by notify_ack/3. Notifications admitted during a turn are buffered, then delivered in arrival order after the decision callback and post_turn/3, using their resulting state. When idle, including while halted, notifications are handled immediately. A prompt returned while halted is queued until resume/1; state updates still apply.

Raises, throws, exits, and malformed returns are logged at error level and treated as {:noreply, previous_state}, including when the notification was buffered during a turn.

handle_info(message, agent_state)

(optional)
@callback handle_info(message :: term(), agent_state()) :: callback_return()

Handle an ordinary OTP message sent to the agent process. Optional.

Timers, monitor :DOWN messages, and messages from other agents reach this callback after GenAgent has handled its own task and Registry messages. For example, Process.send_after(self(), :retry, delay_ms) can trigger a later {:prompt, text, state} without blocking the agent process. Messages received during a turn are buffered under the same count and byte limits as notify/2 events, then handled against the completed turn's state. Return {:noreply, state}, {:prompt, text, state}, or {:halt, state} as in handle_event/2. If absent, unexpected messages are logged and discarded. Exceptions and invalid returns are logged and leave the previous state intact.

handle_response(request_ref, response, agent_state)

@callback handle_response(
  request_ref :: reference(),
  response :: GenAgent.Response.t(),
  agent_state()
) ::
  callback_return()

A prompt->response turn completed successfully. Decide what to do next.

response.prompt contains the prompt actually dispatched to the backend, including any rewrite by pre_turn/2. The request ref and prompt can be recorded together for a transcript.

Returning {:prompt, next_prompt, state} completes the caller's request with this response, then starts an independent follow-up with a new request reference. Its outcome is visible to callbacks, not delivered to the original ask, poll or completion recipient. A completed request does not imply the agent is idle. Use explicit workflow stages when a caller needs the final result of several successful turns.

An exception or malformed return stops the agent. Use one of the three callback_return/0 shapes; in particular, a follow-up prompt must be a binary.

handle_stream_event(t, agent_state)

(optional)
@callback handle_stream_event(GenAgent.Event.t(), agent_state()) :: agent_state()

A streaming event arrived mid-turn. Optional.

Runs inside the task that is driving the prompt, not the agent process. Returns the updated agent state, which is threaded through subsequent stream events. On normal stream completion, including a terminal :error event or EOF without a terminal event, the final state is passed to handle_response/3 or handle_error/3 and then post_turn/3. A task crash, interruption, or watchdog kill cannot recover task-local callback state; those paths use the agent state from before stream consumption. Callback state is volatile and is not a durable record of provider activity.

init_agent(opts)

@callback init_agent(opts :: keyword()) ::
  {:ok, backend_opts :: keyword(), agent_state()} | {:error, reason :: term()}

Initialize the agent. Return backend options and the initial agent state.

opts is the keyword list passed to start_agent/2 minus the reserved keys consumed by GenAgent itself (:name, :backend, etc.). Use current_name/0 to read the registered name without adding it to options that might be forwarded to the backend.

post_run(agent_state)

(optional)
@callback post_run(agent_state()) :: :ok

Clean-completion hook. Optional.

Fires when handle_response/3, handle_error/3, handle_event/2, or pre_turn/2 returns {:halt, state}. Runs before the [:gen_agent, :halted] telemetry event is emitted.

On turn completion, post_turn/3 runs first, then notifications buffered during the turn are applied in order, then this hook and halted telemetry receive the resulting state. A buffered notification that halts does not skip later buffered notifications or finalize completion partway through the batch. No further prompt is dispatched during this drain.

Notifications arriving after completion may still update a halted agent's state. They do not retroactively change this completion snapshot or rerun the hook.

Runs once per transition to halted. Further halt decisions while the agent is already halted do not rerun the hook. After resume/1, a new halt transition runs it again.

Does NOT fire on crashes, stop/1, supervisor shutdown, or any abnormal exit -- terminate_agent/2 covers those paths.

Use cases: create a PR, post a completion summary, mark a task done in an external tracker. The semantic distinction from terminate_agent/2 is "completion" vs "termination."

Raises, throws, and exits are caught: a warning is logged and the halt transition still completes normally, leaving the agent alive and halted. The return value is ignored without validation or logging.

Default implementation: :ok.

post_turn(outcome, request_ref, agent_state)

(optional)
@callback post_turn(
  outcome :: {:ok, GenAgent.Response.t()} | {:error, reason :: term()},
  request_ref :: reference(),
  agent_state()
) :: {:ok, agent_state()}

Per-turn post-dispatch hook. Optional.

Fires after each turn, AFTER handle_response/3 or handle_error/3 has returned its decision. The hook sees the post-decision state. Runs for valid decisions and for failures normalized by handle_error/3 to {:noreply, previous_state}. A failure in handle_response/3 stops the agent before this hook runs.

The outcome is {:ok, response} for a successful turn or {:error, reason} for a failed one -- the same data delivered to the decision callbacks. The hook cannot override the decision callback's transition ({:noreply, ...}, {:prompt, ...}, {:halt, ...}); it only updates state.

Use cases: commit-per-turn (stateful side effect), persist a turn record, update a per-turn metric that needs to live on agent state. For pure observation, prefer telemetry handlers on [:gen_agent, :prompt, :stop].

Raises, throws, exits, or malformed returns are logged at warning level and the server continues with the transition the decision callback chose, retaining the post-decision state. The turn is not unwound.

Default implementation: {:ok, state}.

pre_run(agent_state)

(optional)
@callback pre_run(agent_state()) :: {:ok, agent_state()} | {:error, reason :: term()}

One-time setup hook, fires after init_agent/1 and before the first turn. Optional.

Runs in the agent process, so it blocks the first turn until it returns -- but does NOT block start_agent/2 from returning to the caller. Every synchronous call to the agent (status/2, poll/3, tell/3, runtime_snapshot/2, and so on) waits until the hook returns, and their default timeout is :infinity. notify/2 and whereis/1 do not wait. This is the right home for slow async setup that would otherwise freeze the starter: cloning a repo, creating a worktree, spinning up a sandbox, fetching secrets.

Return {:ok, state} to continue, or {:error, reason} to stop the agent before any turn runs. On error, terminate_agent/2 is called with {:pre_run_failed, reason}.

Raises, throws, and exits are logged at error level: the agent stops with {:pre_run_crashed, failure_kind} and terminate_agent/2 is called with that reason. A malformed return stops the agent with :pre_run_invalid and is logged at error level. failure_kind is the exception module for a raise, or :other for a caught throw or exit.

Default implementation: {:ok, state}.

pre_turn(prompt, agent_state)

(optional)
@callback pre_turn(prompt :: String.t(), agent_state()) :: pre_turn_return()

Per-turn pre-dispatch hook. Optional.

Fires before each prompt is dispatched to the backend, inside the agent process. Can observe, mutate state, rewrite the prompt (for augmentation or templating), skip the turn with :skip, or halt the agent entirely with :halt.

Use cases: prompt templating (inject context) and gating (halt if an external signal says stop). For rate limiting, use a timer and a handle_event/2 callback to start work later, as in the Retry guide.

The hook runs synchronously in the agent process before the prompt task is started. While it runs, synchronous calls to the agent (status/2, poll/3, tell/3, runtime_snapshot/2, and so on) wait, with a default timeout of :infinity. Sleeping here therefore delays those calls on every dispatch and may prevent orderly shutdown before the child spec's :shutdown timeout. notify/2 and whereis/1 do not wait.

When the prompt is rewritten, [:gen_agent, :prompt, :start] telemetry carries both the original and rewritten prompt plus a rewritten: true flag so the transformation is traceable.

For an external ask or tell, {:skip, state}, {:halt, state}, and an invalid return produce :pre_turn_skipped, :pre_turn_halted, and :pre_turn_invalid, respectively, in the caller's result or pollable outcome. A hook crash is caught and reported as :pre_turn_skipped to that caller. These rejected requests do not start a backend turn, run handle_error/3 or post_turn/3, or emit prompt-start/stop telemetry. A halt also runs post_run/1 and freezes queued work.

For prompts generated by handle_response/3 or handle_event/2, a skip, crash, or malformed return emits prompt-error telemetry and calls handle_error/3 so the agent can retry or halt. Crashes use {:pre_turn_crashed, exception_kind} on that generated-prompt path. A generated prompt halted by this hook does not call handle_error/3. Immediate generated-prompt retries are paced so agent calls remain responsive even if the hook keeps rejecting.

Default implementation: {:ok, prompt, state}.

terminate_agent(reason, agent_state)

(optional)
@callback terminate_agent(reason :: term(), agent_state()) :: term()

The agent is shutting down. Optional. Clean up resources.

Functions

ask(name, prompt, timeout \\ :infinity)

@spec ask(name(), String.t(), timeout()) ::
  {:ok, GenAgent.Response.t()} | {:error, error_reason()}

Send a synchronous prompt to an agent.

Blocks until the logical request completes and returns {:ok, response} or {:error, reason} (error_reason/0). A handle_error/3 retry keeps the caller waiting and returns only the final attempt's outcome. If the agent is processing another prompt, the caller is queued transparently and unblocks when its queued turn finishes.

If the agent is halted, the ask is queued until resume/1 is called; the watchdog does not bound this idle wait. Use status/2 or runtime_snapshot/2 to check whether the agent is halted. A caller timeout does not cancel a live caller's queued ask; it can still run after resume/1 without that caller receiving the result.

If admission to the pending prompt queue fails, returns {:error, {:overloaded, info}} immediately; no turn is accepted. info includes the queue, count or byte limit reached, current count and bytes, incoming bytes, and configured maxima.

An orderly agent stop replies to an active or queued ask with {:error, {:agent_terminated, reason}}. A call the agent has not yet handled, an abrupt agent death, or a forced kill can still exit the caller; catch :exit if the caller must survive those cases.

The default timeout is :infinity. The agent's own watchdog is the primary timeout mechanism -- callers generally should not need to set their own. Supplying a shorter timeout here will exit on expiry without affecting the agent. A queued ask is dropped if its calling process dies. If that ask is already active, it keeps running. A live caller's timeout leaves its ask queued or running. For cancellable queued work, use tell/3 or tell_with_completion/4 and cancel_request/3. Returns {:error, :not_found} if the agent name is not registered.

cancel_request(name, ref, timeout \\ :infinity)

@spec cancel_request(name(), request_ref(), timeout()) ::
  {:ok, :cancelled} | {:error, :current | :already_finished | :not_found}

Cancel a queued tell/3 or tell_with_completion/4 request by exact ref.

Returns {:ok, :cancelled} when the queued request is removed, or when its cancellation result is still cached. The cancellation releases its queue count and bytes immediately. poll/3 then returns {:error, :cancelled}. A completion recipient receives exactly one {:gen_agent, :completion, name, ref, {:error, :cancelled}} message.

Returns {:error, :current} for an active tell turn; use interrupt_request/3 to interrupt it. Returns {:error, :already_finished} for a cached terminal result other than cancellation. Returns {:error, :not_found} for an unknown ref, a pruned result, or an internal ask or event ref. Cancellation results count toward the bounded max_tell_results cache. Once pruned, a cancelled ref also returns {:error, :not_found}.

A queued turn has not started, so cancellation does not call handle_response/3, handle_error/3, or post_turn/3. The agent processes cancellation and dispatch in order: if dispatch happened first, this returns {:error, :current} and leaves the turn alone. The default call timeout is :infinity. Returns {:error, :not_found} if the agent name is not registered.

child_spec(module, opts)

@spec child_spec(module(), keyword()) :: Supervisor.child_spec()

Build a temporary agent child spec for a caller-owned supervisor.

Requires :name, :backend, and :task_supervisor. The selected Task.Supervisor must already be running; prompt tasks never fall back to GenAgent.TaskSupervisor. Other options have the same meaning as in start_agent/2.

Start the spec with DynamicSupervisor.start_child/2, or place {MyAgent, opts} in a static Supervisor child list when MyAgent uses GenAgent. The tuple calls the module's overridable child_spec/1, which delegates here. The agent is registered in GenAgent.Registry, so the regular name-based APIs work. Names must be unique across both caller-owned and global agents. Use stop/2 with the owning supervisor to stop an individual agent. Static children retain restart: :temporary: once stopped or crashed, they are removed from the supervisor and must be started explicitly again.

Put the task supervisor before the agent supervisor in the caller's supervision tree so agents shut down before their prompt-task supervisor. Use :rest_for_one if loss of the task supervisor should also stop the agents. See the README for an example.

current_name()

@spec current_name() :: name() | nil

Return the registered name from an agent callback, or nil elsewhere.

Available in init_agent/1, turn and lifecycle callbacks, and handle_stream_event/2, which runs in the prompt task. The name is scoped to those processes; tasks spawned by application callbacks do not inherit it. This avoids duplicating :name in backend options or agent state just to identify the current agent.

drain(name, timeout \\ :infinity)

@spec drain(name(), timeout()) ::
  :ok | {:error, :not_found | :timeout | {:agent_down, term()}}

Stop an agent after its active turn finishes.

Drain immediately refuses new asks, tells, and acknowledged notifications with {:error, :draining}. Already queued asks receive that error and queued tell_with_completion/4 recipients receive a completion with the same error. Queued notifications and callback-generated follow-up prompts are discarded. The active turn runs through its decision and post_turn/3 callbacks, then the agent terminates its backend session and exits. Plain notify/2 casts sent during drain remain best effort and are ignored.

Returns :ok only after the agent process has exited normally, or {:error, :not_found} if it is absent at lookup. If it exits abnormally, returns {:error, {:agent_down, reason}}. The timeout covers both the admission call and the wait for process exit; a caller-side timeout does not cancel the drain. As with other synchronous APIs, a timeout or a concurrent agent death during the admission call can exit the caller. The default timeout is :infinity.

halt(name)

@spec halt(name()) :: :ok

Halt an agent from outside its callback module.

When idle, the agent enters the halted state and runs post_run/1. An active turn is allowed to finish its callbacks first; the halt is pending until then. Queued prompts remain paused until resume/1. resume/1 during the active turn cancels a pending external halt. Calling halt/1 again is idempotent. Asynchronous, like resume/1; returns :ok immediately. status/2 and runtime_snapshot/2 expose :halt_pending while the active turn is finishing.

interrupt(name)

@spec interrupt(name()) :: :ok

Interrupt an in-flight turn.

Kills the prompt task and delivers {:error, :interrupted} to the waiting caller (if any). No-op if the agent is idle.

Asynchronous. Returns :ok immediately.

interrupt_request(name, ref, timeout \\ :infinity)

@spec interrupt_request(name(), request_ref(), timeout()) ::
  {:ok, :accepted} | {:error, :not_current | :idle | :not_found}

Interrupt the active turn only if its request reference matches ref.

Returns {:ok, :accepted} when the agent has cancelled that turn, {:error, :not_current} when another turn is active, or {:error, :idle} when no turn is active. A queued request is not interruptible through this API; use cancel_request/3 for a queued tell. Unlike interrupt/1, this operation is acknowledged by the agent and cannot cancel a successor turn after the observed request finishes. It is also safe against a replacement agent registered under the same name, because request references are unique.

The acknowledgement describes the agent's decision and BEAM task cancellation. It does not establish provider or OS process settlement. The default call timeout is :infinity. Returns {:error, :not_found} if the agent name is not registered.

list()

@spec list() :: [name()]

Return an unordered list of registered agent names.

This is a point-in-time view of GenAgent.Registry; agents running without registration are excluded. Returned agents are not guaranteed to remain alive, and registry cleanup may briefly lag agent termination.

notify(name, event)

@spec notify(name(), term()) :: :ok

Push an external event into the agent.

The event is delivered to handle_event/2. If the callback returns {:prompt, text, state} the prompt is dispatched (or queued, if the agent is busy).

Asynchronous. Returns :ok immediately, including when the agent later rejects the event because its pending queue is full. Rejections emit [:gen_agent, :input, :rejected] telemetry. Use notify_ack/3 when the sender needs an in-memory admission result.

notify_ack(name, event, timeout \\ :infinity)

@spec notify_ack(name(), term(), timeout()) :: :ok | {:error, term()}

Send an event and wait for the agent to acknowledge admission.

Returns :ok when the event was handled immediately or retained for delivery after the current turn. Returns {:error, {:overloaded, info}} when the pending notification queue cannot hold it, or an event handled while halted generates a prompt that cannot be queued. This acknowledges in-memory handling or retention, not durable delivery or a guarantee that a callback-generated prompt will run. A deferred callback-generated prompt can be rejected later if the prompt queue is full; the agent's handle_error/3 receives that overload. The legacy notify/2 remains a best-effort asynchronous cast and always returns :ok. Returns {:error, :not_found} if the agent name is not registered.

poll(name, ref, timeout \\ :infinity)

@spec poll(name(), request_ref(), timeout()) ::
  {:ok, :pending}
  | {:ok, :completed, GenAgent.Response.t()}
  | {:error, error_reason()}

Check the status of a previously-issued tell/2 request.

Returns:

  • {:ok, :pending} if the request is queued, in-flight, or between caller-owned retry attempts. This also includes work queued while halted until resume/1; use status/2 or runtime_snapshot/2 to distinguish a halted queue from an active turn.
  • {:ok, :completed, response} if the turn finished successfully.
  • {:error, reason} if the turn failed, including {:error, :cancelled} for a cancelled queued request.
  • {:error, :not_found} if the ref is unknown (never issued, or pruned from the bounded result cache).

Refs returned from tell/2 and tell_with_completion/4 are pollable until evicted by the count or byte limit. Refs from ask/2 are internal and reply directly to the caller. Returns {:error, :not_found} if the agent name is not registered.

reset_session(name, timeout \\ :infinity)

@spec reset_session(name(), timeout()) :: :ok | {:error, term()}

Reset an agent's backend conversation context without restarting the agent.

The backend must implement GenAgent.Backend.reset_session/1. This call is accepted only while the agent is idle; an active turn returns {:error, :busy} so its session cannot be replaced under it. A draining agent returns {:error, :draining}. Callback state and queued prompts are preserved. A backend without the callback returns {:error, :unsupported}. Returns {:error, :not_found} if the agent is not registered. The default call timeout is :infinity.

resume(name)

@spec resume(name()) :: :ok

Resume a halted agent.

Clears the halted flag and re-drains the mailbox, or cancels a pending external halt if an active turn is still running. No-op otherwise.

Asynchronous. Returns :ok immediately.

runtime_snapshot(name, timeout \\ :infinity)

@spec runtime_snapshot(name(), timeout()) :: runtime_snapshot() | {:error, :not_found}

Read a bounded, metadata-only runtime snapshot of an agent.

pending_prompts counts the prompt mailbox; pending_notifications counts notifications and ordinary OTP messages buffered during a turn; self_chain_pending reports a separately held callback-generated follow-up prompt. halt_pending means an external halt was requested while a turn was active and will take effect after that turn finishes. draining is true after drain/2 has stopped accepting work and before the agent exits. current_request is nil when idle and otherwise contains the volatile request ref, its origin (:event and :self_chain are callback-origin turns), 1-based attempt number, elapsed monotonic milliseconds since dispatch, and the configured watchdog duration. It excludes prompts, caller identities, callback state, backend sessions, events, and queued payloads. Elapsed time is an observation, not an exact countdown to the watchdog firing.

The snapshot is a point-in-time view of this BEAM coordinator, not durable application state, admission authority, or proof that external provider work has settled. The default call timeout is :infinity. Returns {:error, :not_found} if the agent name is not registered.

start_agent(module, opts)

@spec start_agent(module(), keyword()) :: DynamicSupervisor.on_start_child()

Start an agent under the GenAgent supervision tree.

module is the implementation module (the one that use GenAgent). opts must include:

  • :name -- the name the agent will register under in GenAgent.Registry.
  • :backend -- the backend module implementing GenAgent.Backend.

Event evidence is bounded by :max_events_per_turn (default 1_000) and :max_event_bytes_per_turn (default 1_048_576), measured as the sum of :erlang.external_size/1 for retained GenAgent.Event values. Both limits must be positive integers. With the default event_retention: :compact, an oversized turn succeeds with a bounded prefix in Response.events and Response.event_coverage reports omissions. All normalized events still reach handle_stream_event/2, and the terminal result, full response text, and usage remain available separately. Use event_retention: :lossless to require a complete event list; an event that exceeds either limit then fails the turn with {:event_capture_overflow, diagnostics} before entering the callback.

Pending prompt and deferred notification queues have independent count and payload-byte limits: :max_pending_prompts and :max_pending_notifications (both default 1_000), and :max_pending_prompt_bytes and :max_pending_notification_bytes (both default 1_048_576). Limits are non-negative integers; zero disables that pending queue. Bytes are the sum of :erlang.external_size/1 of each queued prompt or notification payload (including ordinary OTP messages deferred for handle_info/2), not the entire process memory. The active prompt and notifications handled immediately while idle are not pending. Self-chaining has one reserved slot outside the prompt count limit, but its payload must fit :max_pending_prompt_bytes.

:watchdog_ms is the per-turn deadline in milliseconds (default 600_000). It must be a positive integer or :infinity, which disables the watchdog. :max_tell_results is the number of completed tell/2 results kept for poll/2 (default 100). :max_tell_result_bytes bounds their combined serialized size (default 8_388_608, or 8 MiB), measured with :erlang.external_size/1 for each ref and result pair. Both limits must be non-negative integers; the oldest results are evicted first when either limit is exceeded. Zero retains none. A single result larger than the byte limit is immediately evicted. Results sent by tell_with_completion/4 remain pollable until evicted and count against both limits. This measures cached payloads, not total process memory.

:shutdown is the supervisor's graceful shutdown timeout in milliseconds (default 5_000), or :infinity. A callback still running when this timeout expires is killed and termination callbacks cannot run. Choose a value longer than any bounded callback or cleanup operation; :infinity can block the owning supervisor indefinitely. An explicit nil uses the default.

Invalid values for the watchdog and the capture and pending limits make start_agent/2 return {:error, {:init_failed, :error, ArgumentError}}. An invalid :shutdown raises ArgumentError while building the child spec. A missing :name or :backend raises KeyError in the caller. An explicit nil for a limit is treated as unset and uses the default.

The reserved keys :name, :backend, :watchdog_ms, :shutdown, :max_tell_results, :max_tell_result_bytes, :max_events_per_turn, :max_event_bytes_per_turn, :event_retention, :max_pending_prompts, :max_pending_prompt_bytes, :max_pending_notifications, and :max_pending_notification_bytes are consumed and not forwarded. Any other option, including :task_supervisor, is forwarded to init_agent/1. start_agent/2 always uses GenAgent.TaskSupervisor; use child_spec/2 to select a different task supervisor.

The child uses restart: :temporary. If it exits, call start_agent/2 explicitly to create another agent; its previous state is not restored. An agent also exits if its Registry partition is lost, so it cannot remain alive but unreachable by name after a Registry restart. The returned pid is for monitoring or supervision. Pass the registered :name to the public client functions, including stop/1.

status(name, timeout \\ :infinity)

@spec status(name(), timeout()) ::
  %{
    state: :idle | :processing,
    name: term(),
    queued: non_neg_integer(),
    current_request: request_ref() | nil,
    halted: boolean(),
    halt_pending: boolean(),
    draining: boolean(),
    agent_state: term()
  }
  | {:error, :not_found}

Read an agent's current status.

This compatibility API includes the full callback-maintained agent_state. While a turn is processing, that value is the server's latest retained state, not a live read of state inside the prompt task. Use runtime_snapshot/2 for a bounded metadata-only view. The legacy keys :state and :queued correspond to :phase and :pending_prompts in runtime_snapshot/2, respectively. Returns {:error, :not_found} if the agent name is not registered.

stop(name, supervisor \\ GenAgent.AgentSupervisor)

@spec stop(name(), GenServer.server()) :: :ok | {:error, :not_found | :self_stop}

Stop an agent.

Terminates the agent process via its owning supervisor. Pass the supervisor as the second argument for an agent started with child_spec/2 or a {MyAgent, opts} tuple; the default is GenAgent.AgentSupervisor. For a static Supervisor, the child ID is the agent's registered name. Active and queued ask/3 callers receive an :agent_terminated error when the agent processes the orderly shutdown. Accepted tell_with_completion/4 requests do not receive a synthetic completion; recipients should monitor the agent to detect that uncertainty. Most callbacks run inside the agent process and can delay shutdown. If one exceeds the child spec's :shutdown timeout (default 5_000 ms), the supervisor kills the agent and terminate_agent/2 and backend terminate_session/1 cannot run. Configure :shutdown when starting an agent whose callbacks or cleanup can take longer. A blocked callback can also delay unrelated operations on the same supervisor during stop/2.

Do not call stop/1 or stop/2 for the same agent from one of its callbacks, including handle_stream_event/2 in the linked prompt task. The supervisor waits for the agent process to exit, so a synchronous self-stop would block until the child shutdown timeout and skip cleanup. Such a call returns {:error, :self_stop} immediately. To retain final state, return {:halt, state} from a decision callback, let an external owner inspect it, then have that owner call stop/1 or stop/2. If immediate teardown is intended and final state and cached results are not needed, post_run/1 can spawn a separate process to call stop/1 as the halt completes.

Returns :ok, {:error, :not_found}, or {:error, :self_stop}.

tell(name, prompt, timeout \\ :infinity)

@spec tell(name(), String.t(), timeout()) ::
  {:ok, request_ref()} | {:error, error_reason()}

Send an asynchronous prompt to an agent.

Returns {:ok, ref} when the agent accepts the prompt. A pre_turn/2 callback runs before acceptance for an idle agent, so this call waits for that callback even though it does not wait for the backend result. Use poll/2 to check on the result. The same queueing semantics as ask/2 apply: a tell accepted while halted remains pending until resume/1. poll/3 reports {:ok, :pending} during that wait; inspect status/2 or runtime_snapshot/2 to tell why it is pending. When a pending queue limit is reached, returns {:error, {:overloaded, info}} without an accepted ref. Returns {:error, :not_found} if the agent name is not registered.

tell_with_completion(name, prompt, recipient \\ self(), timeout \\ :infinity)

@spec tell_with_completion(name(), String.t(), pid(), timeout()) ::
  {:ok, request_ref()} | {:error, error_reason()}

Submit an asynchronous prompt and opt into one completion message.

recipient is a pid (defaults to the calling process). On an accepted request this returns {:ok, ref} and sends the recipient {:gen_agent, :completion, name, ref, {:ok, response}} or {:gen_agent, :completion, name, ref, {:error, reason}} when the logical request finishes. The recipient is registered within the same agent call that accepts the request, so even an immediate pre_turn/2 skip or fast backend response can be delivered before this function returns. Match on the returned ref to correlate the message.

Admission failure returns {:error, {:overloaded, info}} with no ref or completion message. Accepted queued requests deliver after their turn completes, unless cancelled with cancel_request/3. Requests accepted while halted remain queued until resume/1 unless the five-argument form opts into on_halt: :fail. The ref stays {:ok, :pending} from poll/3 during the halted wait. Inspect status/2 or runtime_snapshot/2 to distinguish a halted queue from an active turn. A cancelled queued request delivers {:gen_agent, :completion, name, ref, {:error, :cancelled}} without running turn lifecycle callbacks. pre_turn/2 skip, halt and invalid results deliver :pre_turn_skipped, :pre_turn_halted and :pre_turn_invalid without starting a turn. A hook crash also delivers :pre_turn_skipped. Successful and failed turns deliver after their decision and post_turn/3 callbacks. Interruptions deliver an error immediately. A watchdog timeout or backend failure may be retried by handle_error/3; the recipient receives only the final success or error under the original ref. A crashing handle_response/3 callback stops the agent before completion; monitor the agent when its death matters.

Delivery is a single BEAM message sent at most once per accepted request. It is independent of the bounded poll/3 result cache. A dead recipient does not receive the message, and any agent exit, including an orderly stop, can leave accepted requests without a completion message; a monitor reports that uncertainty. This does not prove that an external provider process has settled. The request ref remains suitable for interrupt_request/3 while active or cancel_request/3 while queued. A new agent under the same name never reuses it.

The five-argument form accepts on_halt: :fail. It rejects a new request with {:error, :halted} when the agent is already halted, and completes an opted-in queued request with {:error, :halted} if the agent halts before dispatch. The default preserves the ordinary queue-until-resume behavior. This is useful for callers, such as ensembles, that cannot resume a halted agent themselves.

The five-argument form also accepts stream_to: pid. The process receives {:gen_agent, :event, name, ref, %GenAgent.Event{}} for each event the backend streams during that request's turn, in stream order. Events are relayed through the agent after handle_stream_event/2 runs, so for one ref they arrive before the completion message when stream_to and recipient are the same process. Separate processes have no shared arrival order. Streaming is off by default and independent of event_retention: compact mode relays events that the retained history omits, and lossless mode does not relay the event it rejects for exceeding the capture limits. Caller-owned retries relay each attempt's events under the same request ref, so an intermediate :error event can precede later successful events and the single final completion message.

Delivery has no backpressure and is best effort. A dead stream_to process is ignored. An interrupt or watchdog timeout can drop events that were generated but not yet relayed; nothing is relayed for a ref after its request has finished. A request that never starts a turn (cancelled, rejected by pre_turn/2, halted or failed dispatch) produces no events. Returns {:error, :not_found} if the agent name is not registered.

tell_with_completion(name, prompt, recipient, timeout, opts)

@spec tell_with_completion(name(), String.t(), pid(), timeout(), keyword()) ::
  {:ok, request_ref()} | {:error, error_reason()}

whereis(name)

@spec whereis(name()) :: pid() | nil

Look up the pid of a registered agent, or nil if not found. Pass the name, not this pid, to public client functions.