Short-Term Memory
Short-term memory is conversation state scoped to one thread. In BeamWeaver, that state lives in the graph state for an agent or graph and is persisted through an explicit checkpoint adapter.
The common short-term memory field is :messages, backed by BeamWeaver.Graph.Messages.channel/1. It stores system, user, assistant, and tool messages and knows how to append, replace, delete, and clear messages by ID.
Enable Memory
Agents already include message state. To persist it across turns, pass a checkpointer and reuse the same thread_id:
alias BeamWeaver.Checkpoint.ETS, as: CheckpointETS
alias BeamWeaver.Core.Message
checkpointer = CheckpointETS.new()
config = %{"configurable" => %{"thread_id" => "thread-1"}}
{:ok, _state} =
MyApp.Agent.invoke(
%{messages: [Message.user("Hi! My name is Bob.")]},
checkpointer: checkpointer,
config: config
)
{:ok, state} =
MyApp.Agent.invoke(
%{messages: [Message.user("What is my name?")]},
checkpointer: checkpointer,
config: config
)
state.messages |> List.last() |> Message.text()
Use BeamWeaver.Checkpoint.ETS for tests, examples, and local workflows.
Production Persistence
Use the Ecto checkpointer with PostgreSQL or SQLite for durable deployments:
checkpointer = BeamWeaver.Checkpoint.Ecto.new(repo: MyApp.Repo)
MyApp.Agent.invoke(
%{messages: [BeamWeaver.Core.Message.user("remember this")]},
checkpointer: checkpointer,
config: %{"configurable" => %{"thread_id" => "thread-123"}}
)
Create tables in your application migration with the versioned migration API:
defmodule MyApp.Repo.Migrations.CreateBeamWeaverCheckpoints do
use Ecto.Migration
def up do
BeamWeaver.Migrations.up(adapters: [:checkpoint])
end
def down do
BeamWeaver.Migrations.down(adapters: [:checkpoint], version: 1)
end
end
Custom Agent Memory
Default agent state contains :messages, :remaining_steps, private routing channels, and usage metadata. Add custom short-term state through middleware that owns the behavior:
defmodule MyApp.PreferenceMemory do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Graph
alias BeamWeaver.Graph.Channels.LastValue
def name(_middleware), do: :preference_memory
def state_schema(_middleware) do
%{preferences: Graph.channel(LastValue)}
end
end
defmodule MyApp.Agent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
middleware do
use MyApp.PreferenceMemory
end
end
MyApp.Agent.invoke(%{
messages: [BeamWeaver.Core.Message.user("Hello")],
preferences: %{theme: "dark"}
})
Elixir structs and typespecs are not Python TypedDict or Pydantic models. Use BeamWeaver.Agent.Schema.field/3, graph channels, and explicit JSON Schema maps where runtime validation or provider schemas are needed.
Trim Messages
Use BeamWeaver.Core.Messages.Utils.trim/2 when the model should see only the recent part of a conversation. In an agent, run trimming in before_model middleware and overwrite the message channel:
defmodule MyApp.TrimMessages do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Core.Messages.Utils
alias BeamWeaver.Graph.Overwrite
def name(_middleware), do: :trim_messages
def before_model(state, _runtime) do
messages = Map.get(state, :messages, Map.get(state, "messages", []))
if length(messages) <= 3 do
nil
else
with {:ok, trimmed} <-
Utils.trim(messages,
max_tokens: 2_000,
strategy: :last,
include_system: true
) do
%{messages: Overwrite.new(trimmed)}
end
end
end
end
Utils.trim/2 preserves tool-call adjacency: assistant messages keep only tool calls with matching retained tool results, and orphan tool messages are dropped.
Delete Messages
Use BeamWeaver.Graph.Messages.remove/1 for specific messages and remove_all/0 to clear the message history:
defmodule MyApp.DeleteOldMessages do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Graph.Messages
def name(_middleware), do: :delete_old_messages
def after_model(state, _runtime) do
messages = Map.get(state, :messages, Map.get(state, "messages", []))
if length(messages) > 6 do
old_messages =
messages
|> Enum.take(length(messages) - 6)
|> Enum.filter(&is_binary(&1.id))
|> Enum.map(&Messages.remove(&1.id))
%{messages: old_messages}
end
end
end
To clear everything:
%{messages: [BeamWeaver.Graph.Messages.remove_all()]}
Summarize Messages
Use BeamWeaver.Agent.Middleware.Summarization to summarize older turns and keep recent messages:
defmodule MyApp.SummarizingAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
middleware do
use BeamWeaver.Agent.Middleware.Summarization,
model: BeamWeaver.Models.init_chat_model!("openai:gpt-5.4-mini"),
trigger: {:tokens, 4_000},
keep: {:messages, 20}
end
end
The summarizer can trigger on message count, token count, or a fraction of the model profile context window. It rewrites the message channel with one system summary followed by retained recent turns.
Custom Strategies
Custom memory strategies are ordinary middleware. Use them when trimming, deleting, or summarization is not enough:
defmodule MyApp.FilterToolNoise do
@behaviour BeamWeaver.Agent.Middleware
alias BeamWeaver.Graph.Overwrite
def name(_middleware), do: :filter_tool_noise
def before_model(state, _runtime) do
messages = Map.get(state, :messages, Map.get(state, "messages", []))
filtered = Enum.reject(messages, &noisy_tool_result?/1)
if filtered == messages, do: nil, else: %{messages: Overwrite.new(filtered)}
end
defp noisy_tool_result?(%BeamWeaver.Core.Message{role: :tool, metadata: metadata}) do
metadata[:ephemeral?] == true or metadata["ephemeral?"] == true
end
defp noisy_tool_result?(_message), do: false
end
Keep custom memory policies close to the application behavior they protect: privacy filters, domain-specific retention, and tool-output pruning usually belong in separate middleware modules.
Read Memory In Tools
Tools read short-term memory by declaring injected state:
alias BeamWeaver.Core.Tool
get_last_user_message =
Tool.from_function!(
name: "get_last_user_message",
description: "Get the most recent user message.",
input_schema: %{
"type" => "object",
"properties" => %{"state" => %{"type" => "object"}},
"required" => ["state"]
},
injected: [state: :state],
handler: fn input, _opts ->
state = input[:state] || input["state"] || %{}
state
|> then(&Map.get(&1, :messages, Map.get(&1, "messages", [])))
|> Enum.reverse()
|> Enum.find(&match?(%BeamWeaver.Core.Message{role: :user}, &1))
|> case do
nil -> "No user messages found."
message -> BeamWeaver.Core.Message.text(message)
end
end
)
Injected fields are removed from the model-visible schema, so the model only sees arguments it is allowed to provide.
Write Memory From Tools
Return BeamWeaver.Graph.Command when a tool needs to update short-term state:
alias BeamWeaver.Core.{Message, Tool}
alias BeamWeaver.Graph.Command
set_user_name =
Tool.from_function!(
name: "set_user_name",
description: "Set the user's name in conversation state.",
input_schema: %{
"type" => "object",
"properties" => %{
"new_name" => %{"type" => "string"},
"tool_call_id" => %{"type" => "string"}
},
"required" => ["new_name", "tool_call_id"]
},
injected: [tool_call_id: :tool_call_id],
handler: fn input, _opts ->
name = input["new_name"] || input[:new_name]
call_id = input["tool_call_id"] || input[:tool_call_id]
%Command{
update: %{
user_name: name,
messages: [Message.tool("User name set to #{name}.", tool_call_id: call_id)]
}
}
end
)
Define reducers or channels for state fields that multiple tools may update in parallel. For example, BeamWeaver.Agent.Middleware.TodoList owns its TODO state and installs the tool that mutates it.
Dynamic Prompts From Memory
Use BeamWeaver.Agent.Middleware.DynamicPrompt when the system prompt depends on state or per-run context:
defmodule MyApp.ContextPromptAgent do
use BeamWeaver.Agent
model BeamWeaver.Models.init_chat_model!("openai:gpt-5.4")
context_schema do
field :user_name, :string, required: true
end
middleware do
use BeamWeaver.Agent.Middleware.DynamicPrompt,
prompt: fn request ->
user_name = request.runtime.context.user_name
"You are a helpful assistant. Address the user as #{user_name}."
end
end
end
This replaces Python callable prompt decorators with a middleware entry that is visible in the agent spec.
Inspect And Modify Checkpointed State
Compiled graphs and agents expose state inspection through the checkpointer:
config = %{"configurable" => %{"thread_id" => "thread-1"}}
{:ok, state} =
MyApp.Agent.get_state(
checkpointer: checkpointer,
config: config
)
{:ok, _config} =
BeamWeaver.Graph.Compiled.update_state(
compiled_graph,
config,
%{messages: [BeamWeaver.Graph.Messages.remove_all()]}
)
Use these APIs for administrative repair, tests, or explicit memory management. Normal conversation turns should update memory through agent invocation, middleware, tools, or graph nodes.