Skip to content

Document memory extension points and custom implementation patterns #41

@fogfish

Description

@fogfish

Problem: The Memory interface is designed to be extensible for custom memory strategies, but there's no documentation explaining:

  • How to implement custom memory
  • What the Observation.Relevance and Observation.Importance fields are for
  • Example patterns for different memory strategies
  • When to use Stream vs custom implementations

Current State:

  • Interface is clean and extensible ✅
  • Observation has fields for advanced strategies (Relevance, Importance) ✅
  • But no guidance on using them ❌

Required Changes:

  1. Add comprehensive godoc to Memory interface (memory.go):
// Memory is the core element of agent behavior. It maintains a comprehensive
// record of an agent's experience, recalls observations, and builds context
// windows for prompting.
//
// Built-in Implementations:
//  - Void: No memory retention (stateless)
//  - Stream: Sequential observations with configurable window size
//
// Custom Memory Strategies:
//
// The interface is designed for extension. Implement custom memory for:
//  - Semantic search (RAG): Use Observation.Relevance embeddings
//  - Importance weighting: Use Observation.Reply.Importance scores
//  - Hierarchical: Combine multiple memory layers
//  - Graph-based: Link related observations
//
// Example custom implementation:
//
//   type CustomMemory struct {
//       observations []*Observation
//   }
//
//   func (m *CustomMemory) Commit(obs *Observation) {
//       // Calculate importance score
//       obs.Reply.Importance = m.scoreImportance(obs)
//       m.observations = append(m.observations, obs)
//   }
//
//   func (m *CustomMemory) Context(prompt Message) []Message {
//       // Select most relevant observations
//       relevant := m.selectRelevant(prompt, m.observations)
//       return buildMessages(relevant)
//   }
//
// Thread Safety:
//
// Memory implementations should be safe for concurrent use when shared across
// multiple agent instances. Use sync.Mutex or other synchronization as needed.
// Built-in implementations (Stream, Void) are already thread-safe.
type Memory interface {
    // Purge clears all observations, resetting memory to initial state.
    // Called by agent.PromptOnce() to start fresh session.
    Purge()

    // Commit stores a new observation from agent execution.
    // Implementations can:
    //  - Calculate and store Relevance embeddings
    //  - Compute Importance scores
    //  - Apply eviction policies
    //  - Update indexes for search
    Commit(*Observation)

    // Context builds the message sequence for LLM prompting.
    // Given the current prompt, select and order relevant observations.
    // Implementations can:
    //  - Apply recency filters
    //  - Perform semantic search
    //  - Weight by importance
    //  - Enforce token budgets
    Context(chatter.Message) []chatter.Message
}
  1. Document Observation fields (memory.go):
// Observation represents a single interaction cycle in the agent's experience.
// Contains the query sent, the reply received, and metadata for memory management.
type Observation struct {
    // Created timestamp (GUID based on time)
    Created  guid.K
    
    // Accessed timestamp - updated when observation is retrieved.
    // Can be used for LRU eviction policies.
    Accessed guid.K
    
    // Query sent to LLM
    Query    Input
    
    // Reply received from LLM
    Reply    Reply
}

// Input represents the query and its semantic representation
type Input struct {
    // Content is the actual message sent to LLM
    Content   chatter.Message
    
    // Relevance is the embedding vector for semantic search.
    // Custom memory implementations can populate this for RAG/vector search.
    // Format: quantized float8 for efficient storage.
    // Example: embedder.Embed(Content) -> []float8.Float8
    Relevance []float8.Float8
}

// Reply represents the LLM response and its metadata
type Reply struct {
    // Content is the actual message received from LLM
    Content    chatter.Message
    
    // Relevance is the embedding vector for the reply.
    // Custom memory implementations can use this for similarity-based retrieval.
    Relevance  []float8.Float8
    
    // Importance is a score (0.0-1.0) indicating the significance of this observation.
    // Custom memory implementations can use this for:
    //  - Priority-based eviction (keep high importance)
    //  - Weighted recall (prefer important memories)
    //  - Reflection triggers (synthesize when importance crosses threshold)
    //
    // Scoring strategies:
    //  - Recency: Recent observations score higher
    //  - Novelty: Unique information scores higher
    //  - Explicit: Decoder assigns based on content
    //  - LLM-based: Ask LLM to score importance
    Importance float64
}
  1. Create memory implementation guide (doc/MEMORY.md):
# Memory Implementation Guide

## Overview

The `Memory` interface enables custom memory strategies for agents. This guide explains how to implement and use custom memory.

## Built-in Implementations

### Void
- No memory retention
- Each prompt is independent
- Use for: Stateless agents, one-shot tasks

### Stream  
- Sequential storage with FIFO eviction
- Configurable capacity: `NewStream(cap int, stratum)`
- Use for: Conversations, context-aware tasks

## Implementing Custom Memory

### Basic Structure

```go
type CustomMemory struct {
    mu sync.Mutex  // Thread safety
    observations []*thinker.Observation
    // ... custom fields
}

func (m *CustomMemory) Purge() {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.observations = nil
}

func (m *CustomMemory) Commit(obs *thinker.Observation) {
    m.mu.Lock()
    defer m.mu.Unlock()
    // Custom logic here
}

func (m *CustomMemory) Context(prompt chatter.Message) []chatter.Message {
    m.mu.Lock()
    defer m.mu.Unlock()
    // Custom selection logic
}

Memory Strategy Patterns

Pattern 1: Importance-Weighted Memory

Retain observations based on importance scores:

type ImportanceMemory struct {
    mu sync.Mutex
    maxSize int
    heap []*thinker.Observation  // Priority queue
}

func (m *ImportanceMemory) Commit(obs *thinker.Observation) {
    // Score importance
    obs.Reply.Importance = m.scoreImportance(obs)
    
    // Add to priority queue
    m.heap = append(m.heap, obs)
    sort.Slice(m.heap, func(i, j int) bool {
        return m.heap[i].Reply.Importance > m.heap[j].Reply.Importance
    })
    
    // Evict lowest importance if full
    if len(m.heap) > m.maxSize {
        m.heap = m.heap[:m.maxSize]
    }
}

func (m *ImportanceMemory) scoreImportance(obs *thinker.Observation) float64 {
    // Example: recency + content length
    recency := time.Now().Sub(obs.Created.Time()).Hours()
    length := len(obs.Reply.Content.String())
    
    return 1.0 / (1.0 + recency) * math.Log(float64(length))
}

Pattern 2: Hybrid (Recent + Important)

Combine recent observations with historically important ones:

type HybridMemory struct {
    recent *memory.Stream
    important []*thinker.Observation
    importanceThreshold float64
}

func (m *HybridMemory) Commit(obs *thinker.Observation) {
    // Always add to recent
    m.recent.Commit(obs)
    
    // Add to important if significant
    if obs.Reply.Importance > m.importanceThreshold {
        m.important = append(m.important, obs)
    }
}

func (m *HybridMemory) Context(prompt chatter.Message) []chatter.Message {
    // Combine recent + important, deduplicate
    ctx := m.recent.Context(prompt)
    
    for _, obs := range m.important {
        // Add if not already in recent context
        ctx = append(ctx, obs.Query.Content, obs.Reply.Content)
    }
    
    return ctx
}

Pattern 3: Semantic Search (RAG)

Use embeddings for similarity-based retrieval:

type SemanticMemory struct {
    embedder Embedder // Your embedding service
    vectorDB VectorDB // Your vector storage
}

func (m *SemanticMemory) Commit(obs *thinker.Observation) {
    // Generate embeddings
    obs.Query.Relevance = m.embedder.Embed(obs.Query.Content)
    obs.Reply.Relevance = m.embedder.Embed(obs.Reply.Content)
    
    // Store in vector DB
    m.vectorDB.Insert(obs)
}

func (m *SemanticMemory) Context(prompt chatter.Message) []chatter.Message {
    // Embed query
    queryVector := m.embedder.Embed(prompt)
    
    // Find similar observations
    similar := m.vectorDB.Search(queryVector, topK=5)
    
    // Build context
    return buildContext(similar)
}

Using Custom Memory

// Create custom memory
customMem := NewCustomMemory()

// Use with agent
agent := agent.NewAutomata(
    llm,
    customMem,  // Your implementation
    encoder,
    decoder,
    reasoner,
)

Best Practices

  1. Thread Safety: Use mutex if memory is shared across agents
  2. Token Budgets: Consider LLM context limits when building context
  3. Eviction Policies: Define clear rules for what to forget
  4. Performance: Balance memory size vs retrieval speed
  5. Testing: Test with various context sizes and patterns

See Also

  • memory/stream.go - Reference implementation
  • examples/memory/ - Example custom implementations
  1. Add example custom memory (examples/11_custom_memory/memory.go):
package main

// Example: Priority-based memory that retains important observations
type PriorityMemory struct {
    mu          sync.Mutex
    maxSize     int
    stratum     chatter.Stratum
    observations []*thinker.Observation
}

func NewPriorityMemory(maxSize int, stratum chatter.Stratum) *PriorityMemory {
    return &PriorityMemory{
        maxSize: maxSize,
        stratum: stratum,
        observations: make([]*thinker.Observation, 0),
    }
}

func (m *PriorityMemory) Purge() {
    m.mu.Lock()
    defer m.mu.Unlock()
    m.observations = nil
}

func (m *PriorityMemory) Commit(obs *thinker.Observation) {
    m.mu.Lock()
    defer m.mu.Unlock()
    
    // Score importance (example: based on content length)
    obs.Reply.Importance = m.scoreImportance(obs)
    
    m.observations = append(m.observations, obs)
    
    // Sort by importance
    sort.Slice(m.observations, func(i, j int) bool {
        return m.observations[i].Reply.Importance > m.observations[j].Reply.Importance
    })
    
    // Keep top N
    if len(m.observations) > m.maxSize {
        m.observations = m.observations[:m.maxSize]
    }
}

func (m *PriorityMemory) Context(prompt chatter.Message) []chatter.Message {
    m.mu.Lock()
    defer m.mu.Unlock()
    
    ctx := make([]chatter.Message, 0)
    if len(m.stratum) > 0 {
        ctx = append(ctx, m.stratum)
    }
    
    // Return observations in chronological order (not priority order)
    sorted := make([]*thinker.Observation, len(m.observations))
    copy(sorted, m.observations)
    sort.Slice(sorted, func(i, j int) bool {
        return sorted[i].Created < sorted[j].Created
    })
    
    for _, obs := range sorted {
        ctx = append(ctx, obs.Query.Content, obs.Reply.Content)
    }
    
    ctx = append(ctx, prompt)
    return ctx
}

func (m *PriorityMemory) scoreImportance(obs *thinker.Observation) float64 {
    // Example scoring: longer responses are more important
    length := float64(len(obs.Reply.Content.String()))
    return math.Min(1.0, length/1000.0)
}

// Usage example in main()
func main() {
    llm, _ := autoconfig.FromNetRC("thinker")
    
    // Use custom memory
    customMem := NewPriorityMemory(20, "You are a helpful assistant")
    
    agent := agent.NewAutomata(
        llm,
        customMem,
        encoder,
        decoder,
        reasoner,
    )
    
    // Agent will use priority-based memory retention
    result, _ := agent.Prompt(context.Background(), input)
}
  1. Add to README.md:
### Custom Memory Implementations

The `Memory` interface is designed for extension. Implement custom strategies for:
- **Semantic search** (RAG): Use `Observation.Relevance` embeddings
- **Importance weighting**: Use `Observation.Reply.Importance` scores  
- **Hierarchical memory**: Combine multiple memory layers
- **Graph-based**: Link related observations

See `doc/MEMORY.md` for implementation guide and `examples/11_custom_memory` for example.

Estimated Effort: 3 hours
Skills Required: Technical writing, documentation, example creation

Benefits:

  • ✅ Users understand extensibility points
  • ✅ Clear guidance on using Relevance and Importance fields
  • ✅ Working examples to copy from
  • ✅ No code changes needed - pure documentation

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions