State Persistence

The AI Agents library provides flexible mechanisms for persisting state between agent interactions and tool executions. This guide covers context serialization, cross-session persistence, and state management patterns.

Context Serialization

The library’s context system is designed to be fully serializable, enabling persistence across process boundaries.

Basic Serialization

Context objects can be converted to and from JSON:

# Run an agent
result = runner.run("Hello, my name is John")

# Serialize context to JSON
context_json = result.context.to_json

# Later, deserialize and continue conversation
restored_context = JSON.parse(context_json, symbolize_names: true)
next_result = runner.run("What's my name?", context: restored_context)
# => "Your name is John"

Database Storage

Store context in your database for long-term persistence:

# Store context in database
class ConversationState < ActiveRecord::Base
  def context=(hash)
    self.context_data = hash.to_json
  end
  
  def context
    JSON.parse(context_data, symbolize_names: true)
  end
end

# Usage
conversation = ConversationState.create(
  user_id: user.id,
  context: result.context.to_h
)

# Restore later
restored_context = conversation.context
continued_result = runner.run("Continue conversation", context: restored_context)

Cross-Session Persistence

Session-Based State

Maintain state across HTTP requests using sessions:

class ChatController < ApplicationController
  def send_message
    # Retrieve context from session
    context = session[:agent_context] || {}
    
    # Run agent
    result = agent_runner.run(params[:message], context: context)
    
    # Store updated context in session
    session[:agent_context] = result.context.to_h
    
    render json: { response: result.output }
  end
  
  def reset_conversation
    session[:agent_context] = nil
    render json: { message: "Conversation reset" }
  end
end

File-Based Persistence

For development or simple deployments:

class FileContextPersistence
  def initialize(storage_path = "./contexts")
    @storage_path = storage_path
    FileUtils.mkdir_p(@storage_path)
  end
  
  def save_context(user_id, context)
    file_path = context_file_path(user_id)
    File.write(file_path, context.to_json)
  end
  
  def load_context(user_id)
    file_path = context_file_path(user_id)
    return {} unless File.exist?(file_path)
    
    JSON.parse(File.read(file_path), symbolize_names: true)
  end
  
  def delete_context(user_id)
    file_path = context_file_path(user_id)
    File.delete(file_path) if File.exist?(file_path)
  end
  
  private
  
  def context_file_path(user_id)
    File.join(@storage_path, "#{user_id}.json")
  end
end

# Usage
persistence = FileContextPersistence.new
context = persistence.load_context(user.id)

result = runner.run(message, context: context)
persistence.save_context(user.id, result.context.to_h)

State Management Patterns

Context Layering

Organize context data into logical layers:

def build_layered_context(user, conversation_id)
  {
    # User layer - persistent across all conversations
    user: {
      id: user.id,
      name: user.name,
      preferences: user.preferences
    },
    
    # Conversation layer - specific to this conversation
    conversation: {
      id: conversation_id,
      started_at: Time.current,
      topic: nil
    },
    
    # Session layer - temporary data for current session
    session: {
      last_activity: Time.current,
      interaction_count: 0
    }
  }
end

Context Cleanup

Prevent context from growing indefinitely:

class ContextCleaner
  MAX_HISTORY_SIZE = 20
  MAX_CONTEXT_KEYS = 50
  
  def self.clean_context(context)
    cleaned = context.deep_dup
    
    # Limit conversation history
    if cleaned[:conversation_history]&.size > MAX_HISTORY_SIZE
      cleaned[:conversation_history] = cleaned[:conversation_history].last(MAX_HISTORY_SIZE)
    end
    
    # Remove old temporary data
    cleaned.delete(:temp_data) if cleaned[:temp_data]
    
    # Limit total context size
    if cleaned.keys.size > MAX_CONTEXT_KEYS
      # Keep essential keys, remove extras
      essential_keys = [:user_id, :current_agent, :conversation_history]
      extra_keys = cleaned.keys - essential_keys
      extra_keys.first(cleaned.keys.size - MAX_CONTEXT_KEYS).each do |key|
        cleaned.delete(key)
      end
    end
    
    cleaned
  end
end

# Use in your service
result = runner.run(message, context: context)
cleaned_context = ContextCleaner.clean_context(result.context.to_h)
save_context(user_id, cleaned_context)

Tool State Management

Stateless Tool Design

Tools should be stateless and rely on context for all data:

class DatabaseTool < Agents::Tool
  name "query_database"
  description "Query the application database"
  param :query, type: "string", desc: "SQL query to execute"
  
  def perform(tool_context, query:)
    # Get database connection from context, not instance variables
    db_config = tool_context.context[:database_config]
    connection = establish_connection(db_config)
    
    # Execute query and return results
    connection.execute(query)
  end
  
  private
  
  def establish_connection(config)
    # Create connection based on config
    ActiveRecord::Base.establish_connection(config)
  end
end

Tool State Persistence

Store tool-specific data in context:

class FileProcessorTool < Agents::Tool
  name "process_file"
  description "Process uploaded files"
  param :file_path, type: "string", desc: "Path to file"
  
  def perform(tool_context, file_path:)
    # Initialize tool state in context if needed
    tool_context.context[:file_processor] ||= {
      processed_files: [],
      processing_status: {}
    }
    
    # Process file
    result = process_file(file_path)
    
    # Update tool state in context
    tool_context.context[:file_processor][:processed_files] << file_path
    tool_context.context[:file_processor][:processing_status][file_path] = result[:status]
    
    result
  end
end

Advanced Persistence Patterns

Context Versioning

Track context changes over time:

class VersionedContext
  def initialize(initial_context = {})
    @versions = [initial_context.deep_dup]
    @current_version = 0
  end
  
  def update_context(new_context)
    @versions << new_context.deep_dup
    @current_version = @versions.size - 1
  end
  
  def current_context
    @versions[@current_version]
  end
  
  def rollback(versions = 1)
    target_version = [@current_version - versions, 0].max
    @current_version = target_version
    current_context
  end
  
  def context_history
    @versions.map.with_index do |context, index|
      {
        version: index,
        timestamp: context[:updated_at],
        agent: context[:current_agent]
      }
    end
  end
end

Context Encryption

Encrypt sensitive context data:

class EncryptedContextStorage
  def initialize(encryption_key)
    @cipher = OpenSSL::Cipher.new('AES-256-CBC')
    @key = encryption_key
  end
  
  def encrypt_context(context)
    @cipher.encrypt
    @cipher.key = @key
    
    encrypted_data = @cipher.update(context.to_json)
    encrypted_data << @cipher.final
    
    Base64.encode64(encrypted_data)
  end
  
  def decrypt_context(encrypted_data)
    @cipher.decrypt
    @cipher.key = @key
    
    decoded_data = Base64.decode64(encrypted_data)
    decrypted_data = @cipher.update(decoded_data)
    decrypted_data << @cipher.final
    
    JSON.parse(decrypted_data, symbolize_names: true)
  end
end

# Usage
storage = EncryptedContextStorage.new(Rails.application.secret_key_base)

# Encrypt before storing
encrypted_context = storage.encrypt_context(result.context.to_h)
database_record.update(encrypted_context: encrypted_context)

# Decrypt when loading
encrypted_data = database_record.encrypted_context
context = storage.decrypt_context(encrypted_data)

Distributed Context Storage

For multi-server deployments using Redis:

class RedisContextStorage
  def initialize(redis_client = Redis.new)
    @redis = redis_client
  end
  
  def save_context(user_id, context, ttl: 1.hour)
    key = context_key(user_id)
    @redis.setex(key, ttl, context.to_json)
  end
  
  def load_context(user_id)
    key = context_key(user_id)
    data = @redis.get(key)
    return {} unless data
    
    JSON.parse(data, symbolize_names: true)
  end
  
  def delete_context(user_id)
    key = context_key(user_id)
    @redis.del(key)
  end
  
  def extend_ttl(user_id, ttl: 1.hour)
    key = context_key(user_id)
    @redis.expire(key, ttl)
  end
  
  private
  
  def context_key(user_id)
    "agent_context:#{user_id}"
  end
end

Context Migration

Handle context format changes across application versions:

class ContextMigrator
  CURRENT_VERSION = 2
  
  def self.migrate_context(context)
    version = context[:_version] || 1
    
    case version
    when 1
      migrate_v1_to_v2(context)
    when 2
      context # Already current
    else
      raise "Unknown context version: #{version}"
    end
  end
  
  private
  
  def self.migrate_v1_to_v2(context)
    # V1 -> V2: Rename 'current_agent' to 'current_agent' (no change needed)
    migrated = context.deep_dup
    
    # No migration needed - current_agent is already the correct field name
    
    migrated[:_version] = 2
    migrated
  end
end

# Use in context loading
def load_context(user_id)
  raw_context = storage.load_context(user_id)
  ContextMigrator.migrate_context(raw_context)
end

Best Practices

Context Size Management

  • Regularly clean up old conversation history
  • Remove temporary data after use
  • Set reasonable size limits for context values

Security Considerations

  • Encrypt sensitive data in persistent storage
  • Validate context data when loading from external sources
  • Sanitize context data before serialization

Performance Optimization

  • Use lazy loading for large context objects
  • Cache frequently accessed context data
  • Consider context compression for large datasets

Error Handling

  • Always validate context structure after deserialization
  • Provide fallback default contexts for corrupted data
  • Log context-related errors for debugging

Copyright © 2025 Chatwoot Inc.