# State Persistence
This 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
```elixir
# 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
```elixir
# 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
```bash
mix sagents.gen.persistence MyApp.Conversations
```
This generates:
- `lib/my_app/conversations.ex` - Context module
- `lib/my_app/conversations/conversation.ex` - Conversation schema
- `lib/my_app/conversations/agent_state.ex` - State schema
- `lib/my_app/conversations/display_message.ex` - UI message schema
- `priv/repo/migrations/..._create_conversations.exs` - Migration
### With Options
```bash
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
```elixir
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
end
```
### AgentState
```elixir
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
end
```
### DisplayMessage
```elixir
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
end
```
## Context Module
The generated context provides CRUD operations:
```elixir
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
```
## Usage Patterns
### Creating a Conversation
```elixir
# 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 agent
```
### Starting an Agent with Persistence
```elixir
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
end
```
### Auto-Save Configuration
```elixir
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
```elixir
# Get current state
state = AgentServer.export_state(agent_id)
# Save to database
Conversations.save_agent_state(conversation_id, state)
```
### Loading Conversations
```elixir
# 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?
1. **Performance**: Don't deserialize full state just to show message list
2. **Flexibility**: Format content differently for UI vs LLM
3. **History**: Keep display messages even if the Agent's internal state is summarized
### Saving Display Messages
```elixir
# 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}
end
```
### Loading for UI
```elixir
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
)}
end
```
## Serialization
### State Serialization
```elixir
# 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
```elixir
# 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:
```elixir
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
end
```
## Migration 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:
```elixir
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: []}
end
```
This 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:
```elixir
# 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:
```elixir
# 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:
```elixir
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
end
```
Usage in context:
```elixir
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
```
## Best Practices
### 1. Always Use Scopes
```elixir
# Good
Conversations.get_conversation(scope, id)
# Bad - no authorization
Conversations.get_conversation(id)
```
### 2. Save on Completion
```elixir
# Configure auto-save with on_idle
auto_save: [
callback: &save/2,
on_idle: true # Save when agent becomes idle
]
```
### 3. Handle Missing State Gracefully
```elixir
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
```
### 4. Keep Display Messages in Sync
```elixir
# 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
```
### 5. Clean Up Old Conversations
```elixir
# 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
```