Skip to content

Commit 072ed9e

Browse files
franklinicclaude
andcommitted
feat(agentic): Phase 2 — long-term semantic memory + SQLite conversation store (#1544)
Long-term cross-thread memory (the durable counterpart to per-thread IConversationStore): - IAgentMemoryStore (AddAsync/SearchAsync/GetAllAsync/RemoveAsync) + AgentMemory/ScoredMemory. - InMemoryAgentMemoryStore: lexical-overlap ranking (zero-config, no model). - EmbeddingAgentMemoryStore<T>: TRUE semantic recall — embeds memories+query via IEmbeddingModel<T> and ranks by the RAG stack's ISimilarityMetric<T> (cosine by default), reusing existing RAG infrastructure. Drop-in over the lexical default (same interface). - MemoryAugmentedAgent<T> (IAgent<T>): retrieves memories relevant to the latest user message and injects them as system context before delegating to the inner agent (executor/supervisor/swarm). Read-only by design — what gets remembered stays under app control. Composes with ThreadedAgent (long-term recall + short-term thread memory). Durable conversation backend: - SqliteConversationStore (AiDotNet.Storage.Sqlite): DB-backed IConversationStore (auto-incrementing Seq preserves order, schema created on demand), keeping the native SQLite dep out of core. Mirrors the SQLite graph checkpointer. - 12 tests (9 memory green net10.0+net471; 3 SQLite conversation net10-gated for the e_sqlite3 native-lib reason — pre-existing repo-wide). No null-forgiving. Part of epic #1544 (Phase 2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6919788 commit 072ed9e

10 files changed

Lines changed: 1083 additions & 0 deletions

File tree

src/Agentic/Memory/AgentMemory.cs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
namespace AiDotNet.Agentic.Memory;
2+
3+
/// <summary>
4+
/// A single long-term memory: a piece of text the agent should be able to recall later, with a stable id
5+
/// and optional metadata. Memories live across conversation threads (unlike <see cref="IConversationStore"/>,
6+
/// which is per-thread short-term history).
7+
/// </summary>
8+
/// <remarks>
9+
/// <para><b>For Beginners:</b> Think of one sticky note the assistant keeps — e.g. "the user prefers metric
10+
/// units" or "the project deadline is in June". Each note has a unique id (so it can be updated or removed)
11+
/// and the note text itself. Later, when something relevant comes up, the assistant can find and re-read the
12+
/// note even if it's in a completely different conversation.
13+
/// </para>
14+
/// </remarks>
15+
public sealed class AgentMemory
16+
{
17+
/// <summary>
18+
/// Initializes a new memory.
19+
/// </summary>
20+
/// <param name="id">The stable, unique identifier for this memory.</param>
21+
/// <param name="content">The remembered text. Must be non-empty.</param>
22+
/// <param name="metadata">Optional key/value metadata (e.g., source, category). <c>null</c> means none.</param>
23+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="id"/> or <paramref name="content"/> is <c>null</c>.</exception>
24+
/// <exception cref="ArgumentException">Thrown when <paramref name="id"/> or <paramref name="content"/> is empty/whitespace.</exception>
25+
public AgentMemory(string id, string content, IReadOnlyDictionary<string, string>? metadata = null)
26+
{
27+
Guard.NotNullOrWhiteSpace(id);
28+
Guard.NotNullOrWhiteSpace(content);
29+
Id = id;
30+
Content = content;
31+
Metadata = metadata;
32+
}
33+
34+
/// <summary>Gets the stable, unique identifier for this memory.</summary>
35+
public string Id { get; }
36+
37+
/// <summary>Gets the remembered text.</summary>
38+
public string Content { get; }
39+
40+
/// <summary>Gets optional key/value metadata, or <c>null</c> when none was supplied.</summary>
41+
public IReadOnlyDictionary<string, string>? Metadata { get; }
42+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using AiDotNet.Interfaces;
2+
using AiDotNet.LinearAlgebra;
3+
using AiDotNet.RetrievalAugmentedGeneration.VectorSearch;
4+
using AiDotNet.RetrievalAugmentedGeneration.VectorSearch.Metrics;
5+
6+
namespace AiDotNet.Agentic.Memory;
7+
8+
/// <summary>
9+
/// A semantic <see cref="IAgentMemoryStore"/> that ranks memories by meaning, not words. It embeds each
10+
/// memory with an <see cref="IEmbeddingModel{T}"/> and scores a query by cosine similarity against those
11+
/// embeddings — reusing AiDotNet's RAG embedding and similarity-metric stack, so a memory about "due date"
12+
/// is recalled for a query about "deadline".
13+
/// </summary>
14+
/// <typeparam name="T">The numeric type of the embedding model and similarity metric.</typeparam>
15+
/// <remarks>
16+
/// <para>
17+
/// Memories and their embedding vectors are held in memory; the vector for each memory is computed once on
18+
/// <see cref="AddAsync"/>. This keeps the abstraction identical to the lexical
19+
/// <see cref="InMemoryAgentMemoryStore"/> while delivering true semantic recall — pick whichever fits the
20+
/// deployment without touching agent code.
21+
/// </para>
22+
/// <para><b>For Beginners:</b> Same notebook idea, but instead of matching words it matches <em>meaning</em>.
23+
/// It turns every note (and your question) into a list of numbers that capture meaning, then finds the notes
24+
/// whose numbers are closest to your question's — so synonyms and paraphrases still match.
25+
/// </para>
26+
/// </remarks>
27+
public sealed class EmbeddingAgentMemoryStore<T> : IAgentMemoryStore
28+
{
29+
private readonly object _gate = new();
30+
private readonly List<Entry> _entries = new();
31+
private readonly IEmbeddingModel<T> _embeddingModel;
32+
private readonly ISimilarityMetric<T> _similarity;
33+
34+
/// <summary>
35+
/// Initializes a new semantic memory store.
36+
/// </summary>
37+
/// <param name="embeddingModel">The model used to embed memories and queries.</param>
38+
/// <param name="similarity">The similarity metric. <c>null</c> uses cosine similarity.</param>
39+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="embeddingModel"/> is <c>null</c>.</exception>
40+
public EmbeddingAgentMemoryStore(IEmbeddingModel<T> embeddingModel, ISimilarityMetric<T>? similarity = null)
41+
{
42+
Guard.NotNull(embeddingModel);
43+
_embeddingModel = embeddingModel;
44+
_similarity = similarity ?? new CosineSimilarityMetric<T>();
45+
}
46+
47+
/// <inheritdoc/>
48+
public async Task<string> AddAsync(
49+
string content,
50+
IReadOnlyDictionary<string, string>? metadata = null,
51+
CancellationToken cancellationToken = default)
52+
{
53+
Guard.NotNullOrWhiteSpace(content);
54+
var id = Guid.NewGuid().ToString("N");
55+
var memory = new AgentMemory(id, content, metadata);
56+
var vector = await _embeddingModel.EmbedAsync(content).ConfigureAwait(false);
57+
58+
lock (_gate)
59+
{
60+
_entries.Add(new Entry(memory, vector));
61+
}
62+
63+
return id;
64+
}
65+
66+
/// <inheritdoc/>
67+
public async Task<IReadOnlyList<ScoredMemory>> SearchAsync(
68+
string query,
69+
int topK = 5,
70+
CancellationToken cancellationToken = default)
71+
{
72+
Guard.NotNullOrWhiteSpace(query);
73+
Guard.Positive(topK);
74+
75+
List<Entry> snapshot;
76+
lock (_gate)
77+
{
78+
snapshot = new List<Entry>(_entries);
79+
}
80+
81+
if (snapshot.Count == 0)
82+
{
83+
return new List<ScoredMemory>();
84+
}
85+
86+
var queryVector = await _embeddingModel.EmbedAsync(query).ConfigureAwait(false);
87+
88+
var scored = new List<ScoredMemory>(snapshot.Count);
89+
foreach (var entry in snapshot)
90+
{
91+
var score = Convert.ToDouble(_similarity.Calculate(queryVector, entry.Vector));
92+
scored.Add(new ScoredMemory(entry.Memory, score));
93+
}
94+
95+
var ordered = _similarity.HigherIsBetter
96+
? scored.OrderByDescending(s => s.Score)
97+
: scored.OrderBy(s => s.Score);
98+
99+
return ordered.Take(topK).ToList();
100+
}
101+
102+
/// <inheritdoc/>
103+
public Task<IReadOnlyList<AgentMemory>> GetAllAsync(CancellationToken cancellationToken = default)
104+
{
105+
lock (_gate)
106+
{
107+
IReadOnlyList<AgentMemory> all = _entries.Select(e => e.Memory).ToList();
108+
return Task.FromResult(all);
109+
}
110+
}
111+
112+
/// <inheritdoc/>
113+
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
114+
{
115+
Guard.NotNullOrWhiteSpace(id);
116+
117+
lock (_gate)
118+
{
119+
_entries.RemoveAll(e => string.Equals(e.Memory.Id, id, StringComparison.Ordinal));
120+
}
121+
122+
return Task.CompletedTask;
123+
}
124+
125+
private readonly struct Entry
126+
{
127+
public Entry(AgentMemory memory, Vector<T> vector)
128+
{
129+
Memory = memory;
130+
Vector = vector;
131+
}
132+
133+
public AgentMemory Memory { get; }
134+
135+
public Vector<T> Vector { get; }
136+
}
137+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace AiDotNet.Agentic.Memory;
2+
3+
/// <summary>
4+
/// A long-term, cross-thread memory store: it remembers facts and can retrieve the ones most relevant to a
5+
/// query. This is the durable counterpart to the per-thread <see cref="IConversationStore"/> and the
6+
/// retrieval source behind <see cref="MemoryAugmentedAgent{T}"/>.
7+
/// </summary>
8+
/// <remarks>
9+
/// <para>
10+
/// Implementations differ only in <em>how</em> they rank relevance: <see cref="InMemoryAgentMemoryStore"/>
11+
/// uses lexical overlap (zero-config, no model), while <c>EmbeddingAgentMemoryStore&lt;T&gt;</c> uses an
12+
/// <see cref="AiDotNet.Interfaces.IEmbeddingModel{T}"/> for true semantic similarity by reusing AiDotNet's
13+
/// RAG embedding + cosine-metric stack. Callers depend only on this interface, so semantic search is a
14+
/// drop-in upgrade over the lexical default.
15+
/// </para>
16+
/// <para><b>For Beginners:</b> This is the assistant's notebook of long-term facts. You can add notes,
17+
/// search for the notes most related to a question, list them, or remove one. Whether the search matches by
18+
/// shared words or by meaning depends on which implementation you plug in — the way you use it is the same.
19+
/// </para>
20+
/// </remarks>
21+
public interface IAgentMemoryStore
22+
{
23+
/// <summary>
24+
/// Stores a new memory and returns its generated id.
25+
/// </summary>
26+
/// <param name="content">The text to remember. Must be non-empty.</param>
27+
/// <param name="metadata">Optional key/value metadata. <c>null</c> means none.</param>
28+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
29+
/// <returns>The new memory's id.</returns>
30+
Task<string> AddAsync(
31+
string content,
32+
IReadOnlyDictionary<string, string>? metadata = null,
33+
CancellationToken cancellationToken = default);
34+
35+
/// <summary>
36+
/// Returns the memories most relevant to a query, highest score first.
37+
/// </summary>
38+
/// <param name="query">The query text. Must be non-empty.</param>
39+
/// <param name="topK">The maximum number of memories to return. Must be positive.</param>
40+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
41+
/// <returns>The top matches ordered by descending relevance (empty when nothing matches).</returns>
42+
Task<IReadOnlyList<ScoredMemory>> SearchAsync(
43+
string query,
44+
int topK = 5,
45+
CancellationToken cancellationToken = default);
46+
47+
/// <summary>
48+
/// Returns all stored memories (order unspecified).
49+
/// </summary>
50+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
51+
Task<IReadOnlyList<AgentMemory>> GetAllAsync(CancellationToken cancellationToken = default);
52+
53+
/// <summary>
54+
/// Removes a memory by id. A no-op when the id is unknown.
55+
/// </summary>
56+
/// <param name="id">The id of the memory to remove. Must be non-empty.</param>
57+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
58+
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
59+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System.Text;
2+
3+
namespace AiDotNet.Agentic.Memory;
4+
5+
/// <summary>
6+
/// A process-local <see cref="IAgentMemoryStore"/> that ranks memories by lexical overlap with the query —
7+
/// the fraction of the query's words that appear in the memory. Requires no embedding model, so it is the
8+
/// zero-config default; for meaning-based recall, use <c>EmbeddingAgentMemoryStore&lt;T&gt;</c>.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para><b>For Beginners:</b> This notebook finds notes by matching words. If you ask about "deadline" it
12+
/// finds notes containing "deadline", but it won't realize "due date" means the same thing — that needs the
13+
/// embedding-backed store. It's fast, needs no setup, and is great for tests and simple cases.
14+
/// </para>
15+
/// </remarks>
16+
public sealed class InMemoryAgentMemoryStore : IAgentMemoryStore
17+
{
18+
private readonly object _gate = new();
19+
private readonly List<AgentMemory> _memories = new();
20+
21+
/// <inheritdoc/>
22+
public Task<string> AddAsync(
23+
string content,
24+
IReadOnlyDictionary<string, string>? metadata = null,
25+
CancellationToken cancellationToken = default)
26+
{
27+
Guard.NotNullOrWhiteSpace(content);
28+
var id = Guid.NewGuid().ToString("N");
29+
var memory = new AgentMemory(id, content, metadata);
30+
31+
lock (_gate)
32+
{
33+
_memories.Add(memory);
34+
}
35+
36+
return Task.FromResult(id);
37+
}
38+
39+
/// <inheritdoc/>
40+
public Task<IReadOnlyList<ScoredMemory>> SearchAsync(
41+
string query,
42+
int topK = 5,
43+
CancellationToken cancellationToken = default)
44+
{
45+
Guard.NotNullOrWhiteSpace(query);
46+
Guard.Positive(topK);
47+
48+
var queryTerms = Tokenize(query);
49+
if (queryTerms.Count == 0)
50+
{
51+
return Task.FromResult<IReadOnlyList<ScoredMemory>>(new List<ScoredMemory>());
52+
}
53+
54+
List<AgentMemory> snapshot;
55+
lock (_gate)
56+
{
57+
snapshot = new List<AgentMemory>(_memories);
58+
}
59+
60+
var scored = new List<ScoredMemory>();
61+
foreach (var memory in snapshot)
62+
{
63+
var terms = Tokenize(memory.Content);
64+
if (terms.Count == 0)
65+
{
66+
continue;
67+
}
68+
69+
var matches = 0;
70+
foreach (var term in queryTerms)
71+
{
72+
if (terms.Contains(term))
73+
{
74+
matches++;
75+
}
76+
}
77+
78+
if (matches > 0)
79+
{
80+
scored.Add(new ScoredMemory(memory, (double)matches / queryTerms.Count));
81+
}
82+
}
83+
84+
IReadOnlyList<ScoredMemory> result = scored
85+
.OrderByDescending(s => s.Score)
86+
.Take(topK)
87+
.ToList();
88+
return Task.FromResult(result);
89+
}
90+
91+
/// <inheritdoc/>
92+
public Task<IReadOnlyList<AgentMemory>> GetAllAsync(CancellationToken cancellationToken = default)
93+
{
94+
lock (_gate)
95+
{
96+
IReadOnlyList<AgentMemory> all = new List<AgentMemory>(_memories);
97+
return Task.FromResult(all);
98+
}
99+
}
100+
101+
/// <inheritdoc/>
102+
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
103+
{
104+
Guard.NotNullOrWhiteSpace(id);
105+
106+
lock (_gate)
107+
{
108+
_memories.RemoveAll(m => string.Equals(m.Id, id, StringComparison.Ordinal));
109+
}
110+
111+
return Task.CompletedTask;
112+
}
113+
114+
private static HashSet<string> Tokenize(string text)
115+
{
116+
var terms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
117+
var current = new StringBuilder();
118+
foreach (var ch in text)
119+
{
120+
if (char.IsLetterOrDigit(ch))
121+
{
122+
current.Append(char.ToLowerInvariant(ch));
123+
}
124+
else if (current.Length > 0)
125+
{
126+
terms.Add(current.ToString());
127+
current.Clear();
128+
}
129+
}
130+
131+
if (current.Length > 0)
132+
{
133+
terms.Add(current.ToString());
134+
}
135+
136+
return terms;
137+
}
138+
}

0 commit comments

Comments
 (0)