This document covers saving and restoring agent conversations.
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
# 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)
}# Agent configuration
%Agent{
model: %ChatAnthropic{...}, # LLM configuration
middleware: [...], # Middleware stack
tools: [...], # Additional tools
base_system_prompt: "...", # System prompt
callbacks: %{...} # Event callbacks
}mix sagents.gen.persistence MyApp.ConversationsThis 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
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
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
enddefmodule 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
enddefmodule 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
endThe 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)
end# 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 agentdefmodule 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
endAgentServer.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
]
)# Get current state
state = AgentServer.export_state(agent_id)
# Save to database
Conversations.save_agent_state(conversation_id, state)# 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 are a UI-friendly representation of the conversation:
- 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
# 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}
enddef 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
)}
end# 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.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).
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
endWhen 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
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"]]}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.
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()
end# Good
Conversations.get_conversation(scope, id)
# Bad - no authorization
Conversations.get_conversation(id)# Configure auto-save with on_idle
auto_save: [
callback: &save/2,
on_idle: true # Save when agent becomes idle
]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!()
end# 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]))}
end# 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