Built-in Middleware
Choose policies for the failure and resource limits your application actually needs. Add them through the
middleware DSL
, then check their ordering and total retry budget. All short module names below are under BeamWeaver.Agent.Middleware.
Run this page’s examples in order in one IEx session; later blocks reuse the modules and values defined earlier.
Start with a bounded agent
After Installation , this makes a real OpenAI call with bounded retries and a maximum of four model turns:
defmodule MyApp.BoundedAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
system_prompt "Answer briefly."
run_timeout 120_000
middleware do
use BeamWeaver.Agent.Middleware.ModelRetry, max_retries: 2, initial_delay: 500, max_delay: 5_000, jitter: true
use BeamWeaver.Agent.Middleware.ModelCallLimit, run_limit: 4
end
end
{:ok, result} = MyApp.BoundedAgent.invoke(%{messages: ["Explain parcel tracking in one sentence."]})
true = byte_size(result.messages |> List.last() |> BeamWeaver.Core.Message.text()) > 0
This successful request exercises the configured agent; it does not simulate an outage or exhaust the limits. Testing shows how to verify those branches deterministically without forcing provider failures.
Retries and fallbacks
ModelRetry
Retries transient provider failures. Defaults are two retries after the first attempt, zero initial delay, backoff 2.0, a 5,000 ms maximum delay and no jitter. Set a nonzero delay and jitter for a shared production service.
retry_on: accepts :transient, :error, error types or a predicate. :error still respects explicit retryable: false. Authentication, invalid requests and exhausted billing should be corrected rather than repeated.
on_failure: applies after covered retries are exhausted: :error returns the error, :continue ends with an assistant message, and a function builds that message. Errors outside the retry policy propagate unchanged. Failed streaming attempts remain observable as debug events; see
Streaming
.
ModelFallback
fallbacks: lists model identifiers or constructed model values in order. retry_on: controls which failures trigger fallback; the default covers all. A fallback uses its own credentials/connection settings and only compatible model options from the primary request.
Place fallback before retry to retry each candidate before moving on. Confirm that each fallback supports your tools, media and answer schema. Fallback changes the provider executing the task; it is not proof that the primary provider recovered.
Configure a real primary and fallback in order. This successful call checks the configuration; the failure branch is exercised with a scripted provider in the application testing guide:
defmodule MyApp.FallbackAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 128, timeout: 60_000
middleware [
{BeamWeaver.Agent.Middleware.ModelFallback, fallbacks: ["google:gemini-3.5-flash"]},
{BeamWeaver.Agent.Middleware.ModelRetry, max_retries: 1, initial_delay: 500}
]
end
{:ok, result} = MyApp.FallbackAgent.invoke(%{messages: ["Say hello briefly."]})
true = byte_size(BeamWeaver.Core.Message.text(List.last(result.messages))) > 0
ToolRetry
Takes the retry settings above plus tools: to restrict it to named actions. Its default retry_on: :error covers tool errors except those explicitly marked nonretryable. The final error normally returns to the model as a tool message. Retry a mutating action only if it is idempotent or has a stable application-level operation key.
This complete example makes the local lookup fail once, then succeed. A trusted per-run counter makes the transient failure reproducible while the model call is real:
defmodule MyApp.UnstableLookup do
use BeamWeaver.Tool
name "lookup_order"
description "Read the delivery day for order A-1001."
injected :context, :context
schema do
field :order_id, :string
end
@impl true
def invoke(_tool, %{"order_id" => "A-1001", context: %{counter: counter}}, _opts) do
attempt = Agent.get_and_update(counter, &{&1 + 1, &1 + 1})
if attempt == 1,
do: {:error, BeamWeaver.Core.Error.new(:timeout, "Temporary lookup failure")},
else: {:ok, "A-1001 arrives Thursday."}
end
end
defmodule MyApp.RetryingLookup do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
system_prompt "Use lookup_order once, then report its result."
tools [MyApp.UnstableLookup]
middleware [
{BeamWeaver.Agent.Middleware.ToolRetry, tools: ["lookup_order"], max_retries: 1, initial_delay: 10},
{BeamWeaver.Agent.Middleware.ToolChoice,
choice: fn request ->
if Enum.any?(request.messages, &(&1.role == :tool)), do: :none, else: :required
end}
]
end
{:ok, counter} = Agent.start_link(fn -> 0 end)
try do
{:ok, result} = MyApp.RetryingLookup.invoke(%{messages: ["Look up A-1001."]}, context: %{counter: counter})
2 = Agent.get(counter, & &1)
true = Enum.any?(result.messages, &(&1.role == :tool and BeamWeaver.Core.Message.text(&1) =~ "Thursday"))
after
Agent.stop(counter)
end
ToolError
on_error: receives the error and tool-call request. Return text for the model, or nil to fail the run. tools: scopes the rule. Invalid arguments and calls to unavailable tools retain their corrective errors. This middleware does not retry. With ToolError outside ToolRetry, it sees the last failure after retries.
Convert the same failed lookup into application-specific feedback. This agent makes only one tool attempt:
defmodule MyApp.ExplainedLookup do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
system_prompt "Call lookup_order, then explain its result without retrying."
tools [MyApp.UnstableLookup]
middleware [
{BeamWeaver.Agent.Middleware.ToolError,
on_error: fn _error, _request -> "The order service is temporarily unavailable." end},
{BeamWeaver.Agent.Middleware.ToolChoice,
choice: fn request ->
if Enum.any?(request.messages, &(&1.role == :tool)), do: :none, else: :required
end}
]
end
{:ok, counter} = Agent.start_link(fn -> 0 end)
try do
{:ok, result} = MyApp.ExplainedLookup.invoke(%{messages: ["Look up A-1001."]}, context: %{counter: counter})
1 = Agent.get(counter, & &1)
true =
Enum.any?(result.messages, &(&1.role == :tool and BeamWeaver.Core.Message.text(&1) =~ "temporarily unavailable"))
after
Agent.stop(counter)
end
Limits
ModelCallLimit
run_limit: bounds one invocation; thread_limit: spans saved calls on a thread. Neither is configured by default. Thread limits need a reused checkpointer. exit_behavior: :error returns :model_call_limit_exceeded; :end returns an explanatory message, customizable with message:.
A model-turn limit is distinct from token limits, provider attempts inside retry wrappers and graph supersteps. Use a whole-run timeout as well.
A thread budget survives separate invocations when the saver and thread are reused:
defmodule MyApp.TwoTurnAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 128, timeout: 60_000
middleware [{BeamWeaver.Agent.Middleware.ModelCallLimit, thread_limit: 2, exit_behavior: :end}]
end
opts = [checkpointer: BeamWeaver.Checkpoint.ETS.new(), config: %{"configurable" => %{"thread_id" => "two-turns"}}]
{:ok, _} = MyApp.TwoTurnAgent.invoke(%{messages: ["Say hello."]}, opts)
{:ok, _} = MyApp.TwoTurnAgent.invoke(%{messages: ["Say goodbye."]}, opts)
{:ok, limited} = MyApp.TwoTurnAgent.invoke(%{messages: ["One more turn."]}, opts)
true = BeamWeaver.Core.Message.text(List.last(limited.messages)) =~ "thread limit (2/2)"
ToolCallLimit
Use run_limit: and/or thread_limit:, optionally for one tool_name:. The run limit cannot exceed a supplied thread limit. The default exit_behavior: :continue rejects excess actions and lets the model explain; :end stops the loop and :error fails it. message: customizes feedback. Use separate instances when individual tools need separate limits.
A zero budget demonstrates the rejection boundary without running the tool handler:
{:ok, agent} =
BeamWeaver.Agent.build(
model: "openai:gpt-5.4-mini",
model_opts: [max_tokens: 256, timeout: 60_000],
tools: [MyApp.UnstableLookup],
middleware: [
{BeamWeaver.Agent.Middleware.ToolCallLimit, tool_name: "lookup_order", run_limit: 0, exit_behavior: :error},
{BeamWeaver.Agent.Middleware.ToolChoice, choice: :required}
]
)
{:error, error} = BeamWeaver.Agent.invoke(agent, %{messages: ["Look up A-1001."]})
:tool_call_limit_exceeded = error.type
Tools
ToolSelection
Selection changes both model-visible tools and the execution registry.
| Option | Purpose |
|---|---|
allow:, deny:
| Named inclusion/exclusion |
tags:, metadata:
| Match tool attributes |
predicate:
| Decide from the tool and request |
tools:
| Add a list or request-derived tools |
descriptions:
| Override model-visible descriptions |
model:, max_tools:, always_include:
| Model-assisted selection |
Model-assisted selection makes an additional call. max_retries: defaults to 1 for malformed selection output. on_parsing_failure: can fail (default), select none/all, or use a supplied list/function. Deliberately choose whether failure should broaden access.
A paused run retains selected tool names. Dynamically created tools from a function are rebuilt on a later model call, not automatically on tool resume; use stable tool entries for actions that must survive review. Agent exclude_tools and tool_descriptions declarations install a selection policy before your explicit one.
An allowlist can expose a read-only lookup while withholding unrelated actions. The selected tool must execute:
read =
BeamWeaver.Core.Tool.from_function!(
name: "read_policy",
description: "Read the refund policy.",
input_schema: %{"type" => "object", "properties" => %{}},
handler: fn _, _ -> {:ok, "Refunds take five business days."} end
)
{:ok, agent} =
BeamWeaver.Agent.build(
model: "openai:gpt-5.4-mini",
model_opts: [max_tokens: 256, timeout: 60_000],
tools: [read, MyApp.UnstableLookup],
system_prompt: "Read the refund policy before answering.",
middleware: [{BeamWeaver.Agent.Middleware.ToolSelection, allow: ["read_policy"]}]
)
{:ok, result} = BeamWeaver.Agent.invoke(agent, %{messages: ["How long do refunds take?"]})
["read_policy"] = result.messages |> Enum.filter(&(&1.role == :tool)) |> Enum.map(& &1.name) |> Enum.uniq()
ToolChoice
choice: sets :auto, :required, :none, or a provider-supported named choice. It can be a function of the request. when_tool_present: restricts application to matching offered tools. Requiring a tool forever prevents a normal prose answer; apply the requirement only to the turn that needs it.
Choose a tool on the first turn and permit a final answer afterward. This direct call makes the resolved policy visible:
alias BeamWeaver.Core.Message
alias BeamWeaver.Agent.Middleware.ToolChoice
alias BeamWeaver.Agent.ModelRequest
choice =
ToolChoice.new(
choice: fn request ->
if length(request.messages) == 1, do: :required, else: :auto
end
)
first_call = ModelRequest.new(messages: [Message.user("Where is my order A-1001?")])
ToolChoice.wrap_model_call(choice, first_call, & &1.tool_choice)
#=> :required
ToolCallNormalization
Agents with tools/middleware already normalize call IDs and JSON argument strings. An explicit instance customizes max_argument_chars: (default 20,000). Normalization repairs representation, not semantics or authorization.
Normalize a provider-shaped call before a tool is looked up. This local example shows both ID repair and argument truncation:
alias BeamWeaver.Core.Message
alias BeamWeaver.Agent.ModelRequest
alias BeamWeaver.Agent.Middleware.ToolCallNormalization
alias BeamWeaver.Agent.ModelResponse
call = %{id: "call 1", name: "add_note", args: ~s({"note": "#{String.duplicate("x", 30)}"})}
response = %ModelResponse{messages: [Message.assistant("", tool_calls: [call])]}
normalization = ToolCallNormalization.new(max_argument_chars: 10)
{:ok, %{messages: [message]}} =
ToolCallNormalization.wrap_model_call(normalization, ModelRequest.new(), fn _ -> {:ok, response} end)
[call] = message.tool_calls
{call.id, call.args}
#=> {"call_1", %{"note" => "xxxxxxxxxx\n\n... argument truncated at 10 bytes."}}
Prompts and output
DynamicPrompt
prompt: is a string/system message, a function of the request, a function of state and runtime, or an MFA tuple called with the request first. It replaces the base system prompt before other capability prompt sections are added. Validate required caller fields with context_schema.
Use validated context to construct the prompt on each call:
defmodule MyApp.NamedGreeter do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 128, timeout: 60_000
context_schema do
field :customer_name, :string
end
middleware [
{BeamWeaver.Agent.Middleware.DynamicPrompt,
prompt: fn _state, runtime ->
"Greet #{runtime.context.customer_name} by name, briefly."
end}
]
end
{:ok, result} = MyApp.NamedGreeter.invoke(%{messages: ["Hello"]}, context: %{customer_name: "Ada"})
true = BeamWeaver.Core.Message.text(List.last(result.messages)) =~ "Ada"
StructuredOutputRetry
Adds the invalid answer and feedback, then retries parsing/validation failures. max_retries: defaults to 1; feedback: is text with {error} or an error function. For the tool strategy, set handle_errors: false if this middleware should own correction rather than the strategy's own retry loop. See
Structured Output
.
Configure schema correction and execute a real extraction. This input is valid; use the malformed-answer example in Testing to force a retry:
defmodule MyApp.Schemas.Triage do
use BeamWeaver.Schema
field :category, :string, enum: ["billing", "shipping", "returns"]
field :urgent, :boolean
end
defmodule MyApp.SupportAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
response_schema MyApp.Schemas.Triage
middleware [
{BeamWeaver.Agent.Middleware.StructuredOutputRetry,
max_retries: 2, feedback: "Your answer didn't match the schema: {error}. Answer again."}
]
end
{:ok, result} =
MyApp.SupportAgent.invoke(%{
messages: ["An urgent shipping issue: my parcel never arrived. Classify as shipping and urgent."]
})
result.structured_response
#=> %{"category" => "shipping", "urgent" => true}
PII
Detects email, credit-card, IP, MAC-address and URL text. type: chooses a kind; omission checks all built-in kinds. A custom detector: is a regex string or match function. Strategies are :redact, :mask, :hash and :block (:pii_detected).
apply_to_input: defaults to true; apply_to_tool_results: and apply_to_output: default to false. Message-text edits do not scrub tool-call arguments, reasoning, artifacts, structured response maps or raw metadata. stream_transform/1 has a separate scope for emitted events.
Guardrails
explains those boundaries.
The saved input shows masking before the provider sees it. The number here is a synthetic example:
defmodule MyApp.SupportAgent do
use BeamWeaver.Agent
alias BeamWeaver.Agent.Middleware
model "openai:gpt-5.4-mini", max_tokens: 128, timeout: 60_000
middleware [
{Middleware.PII, type: :credit_card, strategy: :mask},
{Middleware.PII, type: :url, strategy: :block, apply_to_input: false, apply_to_output: true}
]
end
{:ok, result} = MyApp.SupportAgent.invoke(%{messages: ["My card is 4111 1111 1111 1111"]})
result.messages |> hd() |> BeamWeaver.Core.Message.text()
#=> "My card is **** **** **** 1111"
Context size
ContextEditing
After trigger: tokens, replace older tool results while keeping keep: recent results. Saved state is unchanged. Options include exclude_tools:, clear_tool_inputs:, placeholder:, token_count_method: and a custom token_counter:. clear_tool_uses/2 applies the transformation directly.
Transform a completed tool exchange locally to inspect exactly which result is hidden:
alias BeamWeaver.Core.Message
alias BeamWeaver.Agent.Middleware.ContextEditing
messages = [
Message.user("Where are A-1001 and A-1002?"),
Message.assistant("", tool_calls: [%{id: "call_1", name: "lookup_order", args: %{"order_id" => "A-1001"}}]),
Message.tool("Order A-1001 shipped on Monday.", tool_call_id: "call_1"),
Message.assistant("", tool_calls: [%{id: "call_2", name: "lookup_order", args: %{"order_id" => "A-1002"}}]),
Message.tool("Order A-1002 shipped on Tuesday.", tool_call_id: "call_2")
]
messages |> ContextEditing.clear_tool_uses(trigger: 0, keep: 1) |> Enum.map(&Message.text/1)
#=> ["Where are A-1001 and A-1002?", "", "[cleared]", "", "Order A-1002 shipped on Tuesday."]
Summarization
Replace old saved messages with a summary. model: or summarizer: creates it; trigger: and keep: define the window. Message, token and fractional limits are supported; fractional middleware limits require max_input_tokens:. A list of triggers means any; {:all, triggers} means all. on_overflow: false disables overflow-triggered recovery.
Context Management
provides an example that actually triggers and checks a summary.
This deliberately small threshold triggers a real summary and verifies that the order fact remains available:
defmodule MyApp.SummarizingAgent do
use BeamWeaver.Agent
model "openai:gpt-5.4-mini", max_tokens: 256, timeout: 60_000
system_prompt "Acknowledge supplied facts briefly. Retain order IDs and delivery dates."
middleware [
{BeamWeaver.Agent.Middleware.Summarization,
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini", max_tokens: 512, timeout: 60_000),
trigger: {:messages, 6},
keep: {:messages, 2}}
]
end
saver = BeamWeaver.Checkpoint.ETS.new()
opts = [checkpointer: saver, config: %{"configurable" => %{"thread_id" => "summary-demo"}}]
for fact <- ["Order A-1001 arrives Thursday.", "The delivery address is Lisbon.", "Contact me by email."] do
{:ok, _} = MyApp.SummarizingAgent.invoke(%{messages: [fact]}, opts)
end
{:ok, result} = MyApp.SummarizingAgent.invoke(%{messages: ["What is the order ID and delivery day?"]}, opts)
summary = result.messages |> hd() |> BeamWeaver.Core.Message.text()
true = String.starts_with?(summary, "Conversation summary:")
true = summary =~ "A-1001"
true = length(result.messages) < 8
answer = result.messages |> List.last() |> BeamWeaver.Core.Message.text()
true = answer =~ "A-1001"
true = String.downcase(answer) =~ "thursday"
summarize/2 applies the same trigger: and keep: to a message list outside a run. It returns {:ok, %{summary: message, kept: messages}}, :unchanged when the trigger isn't reached or nothing can be summarized, or {:error, error}:
alias BeamWeaver.Core.Message
alias BeamWeaver.Agent.Middleware.Summarization
summarization =
Summarization.new(
summarizer: fn old -> "The customer asked about #{length(old)} orders." end,
trigger: {:messages, 4},
keep: {:messages, 1}
)
history = for id <- ["A-1001", "A-1002", "A-1003", "A-1004"], do: Message.user("Where is order #{id}?")
{:ok, %{summary: summary, kept: kept}} = Summarization.summarize(summarization, history)
Message.text(summary)
#=> "Conversation summary:\nThe customer asked about 3 orders."
Enum.map(kept, &Message.text/1)
#=> ["Where is order A-1004?"]
Documented elsewhere
| Middleware | Application capability |
|---|---|
HumanInTheLoop
| Human review |
TodoList, Filesystem
| Planning and workspace tools |
Skills, Memory
| Skills and memory files |
Subagents, AsyncSubagents, SubagentOutputs
| Delegation |
ShellTool
| Command policies and sandboxes |
ToolEmulator
| Testing side-effecting tools |
UnsupportedMedia
| Model input adaptation |
PromptCaching
| Provider caching |
OverflowRecovery, CompactConversation
| Context management |
AutoMode, OpenAIModeration
| Guardrails |
TypeSafeModelRouter
| Model routing |