Table of Contents

Use Agent Memory for Long-Term Knowledge Across Sessions

By default, an agent's knowledge disappears when the conversation ends. AgentMemory gives agents persistent, searchable memory that survives across sessions. It uses an embedding model to store information as vectors, then automatically recalls relevant memories when the agent processes new messages. You store facts, experiences, and procedures into three memory types, serialize the memory to disk, and reload it in the next session.

For background on memory architectures, see the AI Agent Memory glossary entry.


Why This Matters

Two production problems that agent memory solves:

  1. Lost context between sessions. A support agent that forgets previous interactions forces users to repeat themselves. With memory, the agent recalls that "the customer upgraded to the Enterprise plan last week" and picks up where it left off.
  2. Growing expertise over time. A coding assistant that stores solutions to past debugging sessions builds a personal knowledge base. When a similar issue appears months later, it recalls the fix without searching documentation again.

Prerequisites

Requirement Minimum
.NET SDK 8.0+
VRAM 4+ GB (for embedding model) + 4+ GB (for chat model)

You need two models: one for embeddings (to index and search memories) and one for chat (the agent itself).


Step 1: Create the Project

dotnet new console -n AgentMemoryDemo
cd AgentMemoryDemo
dotnet add package LM-Kit.NET

Step 2: Understand the Three Memory Types

AgentMemory organizes information into three MemoryType categories, following cognitive science principles:

Type Purpose Examples
Semantic General knowledge and facts "C# supports pattern matching since version 7.0"
Episodic Specific events and experiences "On 2025-01-15, the user reported a timeout bug in the payment module"
Procedural Processes and how-to knowledge "To deploy to production: run tests, build Release, push to main, trigger CI"

All three types are stored in the same AgentMemory instance and searched together during recall. The memory type is attached as metadata, so you can filter by type if needed.


Step 3: Create and Populate Memory

using System.Text;
using LMKit.Model;
using LMKit.Agents;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// Load embedding model for memory indexing
Console.WriteLine("Loading embedding model...");
using LM embeddingModel = LM.LoadFromModelID("qwen3-embedding:0.6b",
    loadingProgress: p => { Console.Write($"\rLoading embeddings: {p * 100:F0}%   "); return true; });
Console.WriteLine();

// Create memory
var memory = new AgentMemory(embeddingModel);

// Store semantic knowledge
memory.SaveInformation(
    "dotnet_knowledge",
    "The .NET garbage collector uses three generations (0, 1, 2) to manage memory efficiently.",
    "gc_generations",
    MemoryType.Semantic);

memory.SaveInformation(
    "dotnet_knowledge",
    "ASP.NET Core middleware runs in the order it is registered in Program.cs.",
    "middleware_order",
    MemoryType.Semantic);

// Store episodic memory (past events)
memory.SaveInformation(
    "project_history",
    "The team migrated from .NET 6 to .NET 8 in March 2025. Key issue was breaking changes in the JSON serializer.",
    "migration_2025",
    MemoryType.Episodic);

// Store procedural memory (how-to knowledge)
memory.SaveInformation(
    "team_procedures",
    "To release a hotfix: create a branch from main, apply the fix, run all tests, get one approval, merge and tag.",
    "hotfix_procedure",
    MemoryType.Procedural);

Console.WriteLine($"Memory populated: {memory.DataSources.Count} data source(s)");

The SaveInformation parameters:

Parameter Purpose
dataSourceIdentifier Groups related memories (e.g., "dotnet_knowledge")
text The content to store
sectionIdentifier Unique ID within the data source
memoryType Semantic, Episodic, or Procedural

Tip: If you want facts to be extracted and stored automatically from conversations (instead of populating memory manually), see Remember Facts Across Chat Sessions with Automatic Memory Extraction.


Step 4: Attach Memory to an Agent

// Load chat model
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:4b",
    loadingProgress: p => { Console.Write($"\rLoading chat: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// Build agent with memory
Agent agent = Agent.CreateBuilder(chatModel)
    .WithPersona("Senior .NET Developer")
    .WithMemory(memory)
    .Build();

var executor = new AgentExecutor();

// The agent automatically recalls relevant memories when answering
AgentExecutionResult result = executor.Execute(agent,
    "How does .NET manage memory with the garbage collector?");
Console.WriteLine(result.Content);

When the agent processes a message, it searches its memory for relevant content, injects the best matches into the context, and uses them to inform its response. This happens automatically with no extra code.


Step 5: Serialize and Restore Memory Across Sessions

string memoryPath = "agent_memory.bin";

// Save memory to disk
memory.Serialize(memoryPath);
Console.WriteLine($"Memory saved to {memoryPath}");

// --- Later, in a new session ---

// Restore memory from disk (requires the same embedding model)
AgentMemory restoredMemory = AgentMemory.Deserialize(memoryPath, embeddingModel);
Console.WriteLine($"Memory restored: {restoredMemory.DataSources.Count} data source(s)");

// Attach to a new agent
Agent newAgent = Agent.CreateBuilder(chatModel)
    .WithPersona("Senior .NET Developer")
    .WithMemory(restoredMemory)
    .Build();

Serialization supports three targets:

Method Target
Serialize(string path) File on disk
Serialize(Stream stream) Any writable stream
Serialize() Returns byte[]

And corresponding Deserialize overloads for each.


Step 6: Monitor Memory Recall with Events

Subscribe to MemoryRecall on MultiTurnConversation to see which memories are used and optionally filter them.

using LMKit.TextGeneration;

var conversation = new MultiTurnConversation(chatModel)
{
    Memory = memory
};

conversation.MemoryRecall += (sender, args) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"[Recall] Source: {args.MemoryCollection}, Type: {args.MemoryType}");
    Console.WriteLine($"  ID: {args.MemoryId}");
    Console.WriteLine($"  Content: {args.MemoryText[..Math.Min(args.MemoryText.Length, 80)]}...");
    Console.ResetColor();

    // Optionally skip this memory
    // args.Cancel = true;
};

var response = conversation.Submit("What was the key issue during the .NET migration?");
Console.WriteLine(response);

MemoryRecallEventArgs properties:

Property Type Purpose
MemoryCollection string Data source identifier
MemoryText string Recalled content
MemoryId string Section identifier
MemoryType MemoryType Semantic, Episodic, or Procedural
Metadata MetadataCollection Additional metadata
Prefix string Optional prefix for injected content (writable)
Cancel bool Set true to skip this memory (writable)

Step 7: Control the Recall Budget

MaximumRecallTokens limits how many tokens of memory are injected per turn. This prevents memory from consuming too much context.

var conversation = new MultiTurnConversation(chatModel)
{
    Memory = memory,
    MaximumRecallTokens = 512  // Default: ContextSize / 4, max: ContextSize / 2
};

Step 8: Manage Memory Data Sources

Add, remove, and inspect data sources programmatically.

// Check if memory is empty
Console.WriteLine($"Empty: {memory.IsEmpty()}");

// List data sources
foreach (var ds in memory.DataSources)
{
    MemoryType type = AgentMemory.GetMemoryType(ds);
    Console.WriteLine($"  [{type}] {ds.Identifier}");
}

// Remove a data source
memory.RemoveDataSource("project_history");

// Clear everything
memory.Clear();

Step 9: Configure Capacity Limits and Eviction

Over time, memory grows unbounded. Use MaxMemoryEntries to cap the total number of entries, and EvictionPolicy to control which entries are removed when the limit is reached.

using LMKit.Agents.Memory;

// Allow up to 200 entries; evict the least important first
memory.MaxMemoryEntries = 200;
memory.EvictionPolicy = MemoryEvictionPolicy.LowestImportanceFirst;

// Check current usage
Console.WriteLine($"Entries: {memory.EntryCount} / {memory.MaxMemoryEntries}");

Available eviction policies:

Policy Behavior
OldestFirst Removes the oldest entries first (default)
LowestImportanceFirst Removes Low importance entries before Medium and High; oldest first within the same importance
OldestLowestImportanceFirst Removes the oldest entries first; uses importance as a tiebreaker

All memory entries (both manually stored and auto-extracted) receive a created_at timestamp automatically. This timestamp drives eviction ordering.

To monitor or prevent evictions:

memory.MemoryEvicted += (sender, e) =>
{
    Console.WriteLine($"Evicting: {e.Text} (from {e.DataSourceIdentifier})");

    // Cancel eviction for critical data sources
    if (e.DataSourceIdentifier == "critical_knowledge")
    {
        e.Cancel = true;
    }
};

Step 10: Apply Time-Decay Scoring

Over time, older memories may become less relevant. Time-decay scoring multiplies each memory's retrieval similarity by an exponential decay factor based on its age, making recent context surface more prominently.

// Memories older than 30 days score 50% of their original similarity.
// 60-day-old memories score 25%, 90-day-old score 12.5%, and so on.
memory.TimeDecayHalfLife = TimeSpan.FromDays(30);
Half-life Use case
TimeSpan.FromDays(30) Gentle decay for long-term knowledge bases
TimeSpan.FromDays(7) Moderate decay for active project contexts
TimeSpan.FromDays(1) Aggressive decay for ephemeral session data
TimeSpan.Zero No decay (default)

Entries without a created_at timestamp receive no decay penalty and are treated as brand new.


Step 11: Consolidate Similar Memories

As memory grows, related entries accumulate. ConsolidateAsync scans all entries, clusters semantically similar ones, and uses an LLM to merge each cluster into a single concise entry.

using LMKit.Agents.Memory;

// Use the chat model (or a lighter model) for summarization
var result = await memory.ConsolidateAsync(chatModel);

Console.WriteLine($"Merged {result.ClustersMerged} cluster(s).");
Console.WriteLine($"Entries: {result.EntryCountBefore} -> {result.EntryCountAfter}");

Control merge behavior:

Property Default Purpose
ConsolidationSimilarityThreshold 0.7 Minimum pairwise similarity to group entries into a cluster
MinClusterSize 2 Minimum entries in a cluster before it is merged

The consolidated entry inherits the earliest created_at timestamp and the highest importance from the cluster. A consolidated metadata flag marks merged entries.

To inspect or cancel merges:

memory.BeforeMemoryConsolidated += (sender, e) =>
{
    Console.WriteLine($"Merging {e.OriginalEntries.Count} entries in {e.DataSourceIdentifier}");
    Console.WriteLine($"  Into: {e.ConsolidatedText}");

    // Protect a data source from consolidation
    if (e.DataSourceIdentifier == "critical_knowledge")
    {
        e.Cancel = true;
    }
};

Step 12: Summarize Conversations into Episodic Memory

At the end of a session, call SummarizeConversationAsync to compress the full conversation into 1-3 episodic memory entries that capture the key topics and decisions.

// Before disposing the agent or starting a new session
var result = await memory.SummarizeConversationAsync(
    executor.ChatHistory, chatModel);

Console.WriteLine($"Stored {result.EntriesCreated} episodic entry(s):");
foreach (var summary in result.Summaries)
    Console.WriteLine($"  - {summary}");

Set MaxConversationSummaries to control the maximum number of entries per conversation (default 3). Conversations with fewer than 2 user messages are skipped automatically.


Step 13: Isolate Memory Per User

Use UserScopedMemory to namespace data sources by user ID when serving multiple users from a single shared memory store.

using LMKit.Agents.Memory;

var sharedMemory = new AgentMemory(embeddingModel);

// Each user gets an isolated view
var aliceMemory = new UserScopedMemory(sharedMemory, "alice");
var bobMemory = new UserScopedMemory(sharedMemory, "bob");

// Data is automatically namespaced: "alice::preferences", "bob::preferences"
await aliceMemory.SaveInformationAsync("preferences", "Prefers dark mode.", "theme");
await bobMemory.SaveInformationAsync("preferences", "Prefers light mode.", "theme");

// Scope retrieval to a single user before attaching to an agent
aliceMemory.ApplyFilter();
var agent = Agent.CreateBuilder(chatModel)
    .WithMemory(sharedMemory)
    .Build();

// Clear a specific user's data without affecting others
bobMemory.ClearUserData();