State Management

Understanding how Runtime manages conversation state.

Overview

Runtime uses session-based state management to maintain conversation history across multiple turns.

Core Concept

Every conversation has:

User → [Session: abc123] → Pipeline → LLM
         ↓                    ↓
    [Redis/Memory Store]   Response

    Persisted History

Why State Management?

Problem: Stateless LLMs

LLMs don’t remember previous interactions:

// First call
response1 := llm.Complete("What's the capital of France?")
// Response: "Paris"

// Second call - LLM has no memory
response2 := llm.Complete("What about Germany?")
// Response: "Germany? What about it?" (No context!)

Solution: Conversation History

Pass history with each request:

messages := []Message{
    {Role: "user", Content: "What's the capital of France?"},
    {Role: "assistant", Content: "Paris"},
    {Role: "user", Content: "What about Germany?"},
}
response := llm.Complete(messages)
// Response: "The capital of Germany is Berlin"

Session-Based Architecture

Session ID

Each conversation has a unique ID:

sessionID := "user-123-conv-456"

result, err := pipeline.ExecuteWithSession(ctx, sessionID, "user", "Hello")

Sessions enable:

StateMiddleware

Manages state automatically:

stateMiddleware := middleware.StateMiddleware(store, &middleware.StateMiddlewareConfig{
    MaxMessages: 10,
    TTL:         24 * time.Hour,
})

pipeline := pipeline.NewPipeline(
    stateMiddleware,
    // ... other middleware
)

Before execution:

After execution:

State Store Interface

All stores implement:

type StateStore interface {
    Load(sessionID string) ([]Message, error)
    Save(sessionID string, messages []Message) error
    Delete(sessionID string) error
}

In-Memory Store

Fast, but not persistent:

store := statestore.NewInMemoryStateStore()

Characteristics:

Use cases:

Redis Store

Persistent and scalable:

redisClient := redis.NewClient(&redis.Options{
    Addr: "localhost:6379",
})
store := statestore.NewRedisStateStore(redisClient)

Characteristics:

Use cases:

Design Decisions

Why Session IDs?

Decision: Use session IDs to identify conversations

Rationale:

Alternative considered: Implicit session based on user ID. Rejected because:

Why Separate Store Interface?

Decision: StateStore is separate from middleware

Rationale:

Alternative considered: Embedding storage in StateMiddleware. Rejected as too coupled.

Why Message History?

Decision: Store full messages, not raw text

Rationale:

Alternative considered: Store only text. Rejected because:

Why TTL?

Decision: Messages expire after TTL

Rationale:

Trade-off: Active conversations may expire. Acceptable with reasonable TTL (e.g., 24 hours).

Storage Strategies

Message Limits

Limit history size:

StateMiddleware(store, &StateMiddlewareConfig{
    MaxMessages: 10,  // Keep last 10 messages only
})

Benefits:

Trade-off: Loses older context. Use higher limits for long conversations.

Sliding Window

Keep recent messages:

[Old messages...] [Recent 10 messages] ← Kept

    Discarded

Time-Based Expiration

Delete old sessions:

StateMiddleware(store, &StateMiddlewareConfig{
    TTL: 24 * time.Hour,  // Sessions expire after 24 hours
})

Manual Cleanup

Delete sessions explicitly:

// End conversation
store.Delete(sessionID)

Scaling Considerations

Single-Instance (In-Memory)

User → [Instance] → In-Memory Store

Limitations:

Multi-Instance (Redis)

User → [Instance 1] ↘
                      [Redis Store]
User → [Instance 2] ↗

Benefits:

Session Affinity

Route users to same instance:

User (session-123) → Instance 1
User (session-456) → Instance 2

Benefits:

Implementation: Load balancer with session affinity

State Loading Performance

Load Time

Typical performance:

Optimization Strategies

1. Message Limits

// Fast: Load last 10 messages
StateMiddleware(store, &StateMiddlewareConfig{
    MaxMessages: 10,
})

// Slow: Load all messages
StateMiddleware(store, &StateMiddlewareConfig{
    MaxMessages: -1,  // No limit
})

2. Lazy Loading

Load on demand:

type LazyStateStore struct {
    inner StateStore
    cache map[string][]Message
}

func (s *LazyStateStore) Load(sessionID string) ([]Message, error) {
    if cached, ok := s.cache[sessionID]; ok {
        return cached, nil  // Cache hit
    }
    
    messages, err := s.inner.Load(sessionID)
    s.cache[sessionID] = messages
    return messages, err
}

3. Compression

Compress stored messages:

type CompressedStore struct {
    inner StateStore
}

func (s *CompressedStore) Save(sessionID string, messages []Message) error {
    compressed := compress(messages)
    return s.inner.Save(sessionID, compressed)
}

Trade-off: CPU for storage. Worth it for large histories.

Concurrency and Consistency

Race Conditions

Multiple requests per session:

Request 1: Load → Execute → Save
Request 2:     Load → Execute → Save

Problem: Request 2 may overwrite Request 1’s changes.

Solution: Optimistic Locking

type Message struct {
    // ... fields
    Version int
}

func (s *StateStore) Save(sessionID string, messages []Message, expectedVersion int) error {
    currentVersion := s.getVersion(sessionID)
    if currentVersion != expectedVersion {
        return ErrVersionMismatch
    }
    
    s.saveWithVersion(sessionID, messages, currentVersion+1)
    return nil
}

Retry on version mismatch.

Eventual Consistency

With Redis:

Impact: Rare edge cases where history may be stale. Acceptable for most applications.

Testing State Management

In-Memory for Tests

Use in-memory store for fast tests:

func TestStateManagement(t *testing.T) {
    store := statestore.NewInMemoryStateStore()
    
    // Test save
    messages := []Message
    err := store.Save("session-1", messages)
    assert.NoError(t, err)
    
    // Test load
    loaded, err := store.Load("session-1")
    assert.NoError(t, err)
    assert.Equal(t, messages, loaded)
}

Mock Store

For testing middleware:

type MockStateStore struct {
    messages map[string][]Message
}

func (m *MockStateStore) Load(sessionID string) ([]Message, error) {
    return m.messages[sessionID], nil
}

func (m *MockStateStore) Save(sessionID string, messages []Message) error {
    m.messages[sessionID] = messages
    return nil
}

Common Patterns

User Sessions

One session per user:

sessionID := fmt.Sprintf("user-%s", userID)
result, err := pipeline.ExecuteWithSession(ctx, sessionID, "user", "Hello")

Use case: Single ongoing conversation per user

Conversation Sessions

Multiple conversations per user:

sessionID := fmt.Sprintf("user-%s-conv-%s", userID, conversationID)
result, err := pipeline.ExecuteWithSession(ctx, sessionID, "user", "Hello")

Use case: User can start multiple conversations

Temporary Sessions

Anonymous sessions:

sessionID := uuid.New().String()
result, err := pipeline.ExecuteWithSession(ctx, sessionID, "user", "Hello")

Use case: Guest users, temporary chats

Cleanup on Logout

Delete user sessions:

func handleLogout(userID string) {
    sessionID := fmt.Sprintf("user-%s", userID)
    store.Delete(sessionID)
}

Production Considerations

Monitoring

Track state metrics:

Error Handling

Handle store failures gracefully:

messages, err := store.Load(sessionID)
if err != nil {
    log.Printf("Failed to load state: %v", err)
    // Continue with empty history
    messages = []Message{}
}

Backup and Recovery

Redis persistence:

Enable both for best durability.

Privacy Compliance

Example: Complete Setup

Development (In-Memory)

store := statestore.NewInMemoryStateStore()

stateMiddleware := middleware.StateMiddleware(store, &middleware.StateMiddlewareConfig{
    MaxMessages: 10,
    TTL:         1 * time.Hour,
})

pipeline := pipeline.NewPipeline(
    stateMiddleware,
    // ... other middleware
)

Production (Redis)

redisClient := redis.NewClient(&redis.Options{
    Addr:         "redis:6379",
    Password:     os.Getenv("REDIS_PASSWORD"),
    DB:           0,
    MaxRetries:   3,
    PoolSize:     10,
})

store := statestore.NewRedisStateStore(redisClient)

stateMiddleware := middleware.StateMiddleware(store, &middleware.StateMiddlewareConfig{
    MaxMessages: 20,
    TTL:         24 * time.Hour,
})

pipeline := pipeline.NewPipeline(
    stateMiddleware,
    // ... other middleware
)

Summary

State management provides:

Multi-turn conversations: Maintain context across requests
Multi-user support: Isolated sessions per user
Scalability: Redis for distributed deployments
Performance: Configurable history limits
Flexibility: Pluggable storage backends

Further Reading