Skip to content

Latest commit

 

History

History
552 lines (428 loc) · 12.5 KB

File metadata and controls

552 lines (428 loc) · 12.5 KB

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

# 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 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

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
end

AgentState

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

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:

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

# 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

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

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?

  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

# 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

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

# 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
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:

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:

# 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
end

Usage 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

Best 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!()
end

4. 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]))}
end

5. 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