State Persistence
Copy MarkdownThis document covers saving and restoring agent conversations.
Overview
Sagents separates configuration from state:
| Component | Stored In | Contains |
|---|---|---|
| Configuration | Code | Model, middleware, tools, prompts |
| State | Database | Messages, todos, metadata |
This separation means:
- You can update middleware without migrating data
- Agent capabilities are version-controlled with code
- Secrets (API keys) stay out of the database
Data Model
What Gets Persisted
# State structure
%State{
agent_id: "conversation-123", # Links to conversation
messages: [...], # Full conversation history
todos: [...], # Current TODO list
metadata: %{ # Middleware-specific data
"conversation_title" => "...",
"preferences" => %{...}
},
interrupt: nil # Pending HITL (if any)
}What Stays in Code
# Agent configuration
%Agent{
model: %ChatAnthropic{...}, # LLM configuration
middleware: [...], # Middleware stack
tools: [...], # Additional tools
base_system_prompt: "...", # System prompt
callbacks: %{...} # Event callbacks
}Code Generator
Basic Usage
mix sagents.gen.persistence MyApp.Conversations
This generates:
lib/my_app/conversations.ex- Context modulelib/my_app/conversations/conversation.ex- Conversation schemalib/my_app/conversations/agent_state.ex- State schemalib/my_app/conversations/display_message.ex- UI message schemapriv/repo/migrations/..._create_conversations.exs- Migration
With Options
mix sagents.gen.persistence MyApp.Conversations \
--scope MyApp.Accounts.Scope \
--owner-type user \
--owner-field user_id \
--owner-module MyApp.Accounts.User \
--table-prefix sagents_
Options:
--scope- Application scope module (required)--owner-type- Owner type (user, account, team, org, none)--owner-field- Foreign key field name--owner-module- Owner schema module--table-prefix- Database table prefix
Generated Schemas
Conversation
defmodule MyApp.Conversations.Conversation do
use Ecto.Schema
schema "sagents_conversations" do
field :title, :string
field :scope, :map # {:user, 123} stored as %{"type" => "user", "id" => 123}
belongs_to :user, MyApp.Accounts.User
has_one :agent_state, MyApp.Conversations.AgentState
has_many :display_messages, MyApp.Conversations.DisplayMessage
timestamps()
end
endAgentState
defmodule MyApp.Conversations.AgentState do
use Ecto.Schema
schema "sagents_agent_states" do
field :data, :map # Serialized State struct
belongs_to :conversation, MyApp.Conversations.Conversation
timestamps()
end
endDisplayMessage
defmodule MyApp.Conversations.DisplayMessage do
use Ecto.Schema
schema "sagents_display_messages" do
field :role, Ecto.Enum, values: [:user, :assistant, :tool, :system]
field :content, :string
field :metadata, :map # Tool calls, token usage, etc.
field :sequence, :integer # Ordering
belongs_to :conversation, MyApp.Conversations.Conversation
timestamps()
end
endContext Module
The generated context provides CRUD operations:
defmodule MyApp.Conversations do
# Conversation CRUD
def create_conversation(scope, attrs)
def get_conversation(scope, id)
def update_conversation(conversation, attrs)
def delete_conversation(scope, id)
def list_conversations(scope, opts \\ [])
# Agent state
def save_agent_state(conversation_id, state)
def load_agent_state(conversation_id)
# Display messages
def append_display_message(conversation_id, role, content, metadata \\ %{})
def load_display_messages(conversation_id)
def clear_display_messages(conversation_id)
endUsage Patterns
Creating a Conversation
# Get scope from current user
scope = {:user, current_user.id}
# Create conversation
{:ok, conversation} = MyApp.Conversations.create_conversation(scope, %{
title: "New Chat"
})
# Conversation is now ready for an agentStarting an Agent with Persistence
defmodule MyApp.Agents.Coordinator do
def start_conversation_session(conversation_id) do
agent_id = "conversation-#{conversation_id}"
case AgentServer.whereis(agent_id) do
nil -> start_new_agent(agent_id, conversation_id)
pid -> {:ok, %{agent_id: agent_id, pid: pid}}
end
end
defp start_new_agent(agent_id, conversation_id) do
# Load or create state
initial_state = case Conversations.load_agent_state(conversation_id) do
{:ok, state} -> state
{:error, :not_found} -> State.new!()
end
# Create agent from code
{:ok, agent} = AgentFactory.create_agent(agent_id: agent_id)
# Start with auto-save
{:ok, pid} = AgentServer.start_link(
agent: agent,
initial_state: initial_state,
pubsub: {Phoenix.PubSub, MyApp.PubSub},
auto_save: [
callback: fn _id, state ->
Conversations.save_agent_state(conversation_id, state)
end,
interval: 30_000,
on_idle: true
],
conversation_id: conversation_id,
save_new_message_fn: fn conv_id, message ->
Conversations.save_message(conv_id, message)
end
)
{:ok, %{agent_id: agent_id, pid: pid}}
end
endAuto-Save Configuration
AgentServer.start_link(
agent: agent,
auto_save: [
# Function called to save state
callback: fn agent_id, state ->
conversation_id = extract_conversation_id(agent_id)
Conversations.save_agent_state(conversation_id, state)
end,
# Save interval (only if changed)
interval: 30_000, # 30 seconds
# Save when execution completes
on_idle: true,
# Save before shutdown
on_shutdown: true # default: true
]
)Manual Save
# Get current state
state = AgentServer.export_state(agent_id)
# Save to database
Conversations.save_agent_state(conversation_id, state)Loading Conversations
# List user's conversations
conversations = Conversations.list_conversations(scope, [
order_by: [desc: :updated_at],
limit: 20
])
# Get specific conversation with messages
conversation = Conversations.get_conversation(scope, id)
display_messages = Conversations.load_display_messages(id)Display Messages
Display messages are a UI-friendly representation of the conversation:
Why Separate from State?
- Performance: Don't deserialize full state just to show message list
- Flexibility: Format content differently for UI vs LLM
- History: Keep display messages even if the Agent's internal state is summarized
Saving Display Messages
# Configure callback when starting agent
AgentServer.start_link(
agent: agent,
conversation_id: conversation_id,
save_new_message_fn: fn conv_id, message ->
# This is called for each message (user, assistant, tool)
# Returns {:ok, [saved_display_messages]} or {:error, reason}
Conversations.save_message(conv_id, message)
end
)
# Or save manually based on events
def handle_info({:agent, {:llm_message, message}}, socket) do
Conversations.append_display_message(
socket.assigns.conversation_id,
:assistant,
message.content,
%{token_usage: extract_usage(message)}
)
{:noreply, socket}
endLoading for UI
def mount(%{"id" => id}, _session, socket) do
conversation = Conversations.get_conversation(scope, id)
messages = Conversations.load_display_messages(id)
{:ok, assign(socket,
conversation: conversation,
messages: messages
)}
endSerialization
State Serialization
# State.to_serialized/1
%{
"messages" => [
%{
"role" => "user",
"content" => "Hello",
"metadata" => %{}
},
%{
"role" => "assistant",
"content" => "Hi there!",
"tool_calls" => [],
"metadata" => %{}
}
],
"todos" => [
%{
"id" => "todo-1",
"content" => "Task description",
"status" => "completed"
}
],
"metadata" => %{
"conversation_title" => "Greeting",
"custom_data" => %{...}
}
}State Deserialization
# State.from_serialized/2
{:ok, state} = State.from_serialized(agent_id, serialized_data)The agent_id is required because it's not stored in the serialized data (it's the conversation's identity).
Custom Serialization
Middleware can define custom serialization:
defmodule MyMiddleware do
@behaviour Sagents.Middleware
@impl true
def state_schema do
[
my_data: %{
serialize: fn data -> Base.encode64(:erlang.term_to_binary(data)) end,
deserialize: fn str -> :erlang.binary_to_term(Base.decode64!(str)) end
}
]
end
endMigration Patterns
Adding New Middleware
When adding middleware to existing agents, the middleware itself should handle missing state gracefully rather than migrating stored agent state. This keeps migration logic colocated with the middleware and avoids touching persisted data:
defmodule MyNewMiddleware do
@behaviour Sagents.Middleware
@impl true
def init(config), do: {:ok, config}
@impl true
def before_model(state, _config) do
# Initialize state if this middleware hasn't been used before
state = case State.get_metadata(state, :my_feature) do
nil -> State.put_metadata(state, :my_feature, default_value())
_ -> state
end
{:ok, state}
end
defp default_value, do: %{initialized: true, data: []}
endThis approach is preferred because:
- Migration logic lives with the middleware, not in persistence layer
- No database migrations needed when adding middleware
- Each middleware is responsible for its own defaults
- Existing conversations seamlessly gain new capabilities
Changing Middleware Configuration
Since middleware config is in code, just update the code:
# Before
{FileSystem, [enabled_tools: ["ls", "read_file"]]}
# After - existing states work fine
{FileSystem, [enabled_tools: ["ls", "read_file", "write_file"]]}Removing Middleware
Orphaned metadata is harmless and can be left in place:
# Before
middleware: [TodoList, OldMiddleware, FileSystem]
# After - OldMiddleware's metadata stays in state but is ignored
middleware: [TodoList, FileSystem]The unused metadata has no effect on agent behavior and will naturally disappear as conversations expire or are deleted.
Scope Pattern
Scopes isolate data between users/organizations:
defmodule MyApp.Accounts.Scope do
defstruct [:type, :id]
def for_user(user) do
%__MODULE__{type: :user, id: user.id}
end
def for_org(org) do
%__MODULE__{type: :organization, id: org.id}
end
endUsage in context:
def list_conversations(%Scope{type: :user, id: user_id}, opts) do
Conversation
|> where([c], c.user_id == ^user_id)
|> order_by([c], desc: c.updated_at)
|> Repo.all()
end
def list_conversations(%Scope{type: :organization, id: org_id}, opts) do
Conversation
|> join(:inner, [c], u in User, on: c.user_id == u.id)
|> where([c, u], u.organization_id == ^org_id)
|> Repo.all()
endBest Practices
1. Always Use Scopes
# Good
Conversations.get_conversation(scope, id)
# Bad - no authorization
Conversations.get_conversation(id)2. Save on Completion
# Configure auto-save with on_idle
auto_save: [
callback: &save/2,
on_idle: true # Save when agent becomes idle
]3. Handle Missing State Gracefully
case Conversations.load_agent_state(id) do
{:ok, state} ->
# Restore conversation
state
{:error, :not_found} ->
# Fresh state - this is normal for new conversations
State.new!()
{:error, :deserialization_failed} ->
# Data corruption - log and start fresh
Logger.error("Failed to deserialize state for #{id}")
State.new!()
end4. Keep Display Messages in Sync
# In LiveView
def handle_info({:agent, {:llm_message, message}}, socket) do
# Save to database
{:ok, display_msg} = Conversations.append_display_message(
socket.assigns.conversation_id,
:assistant,
message.content
)
# Update UI
{:noreply, update(socket, :messages, &(&1 ++ [display_msg]))}
end5. Clean Up Old Conversations
# Periodic cleanup job
def cleanup_old_conversations do
cutoff = DateTime.add(DateTime.utc_now(), -30, :day)
Conversation
|> where([c], c.updated_at < ^cutoff)
|> Repo.delete_all()
end