Skip to content

Commit 4c11fed

Browse files
authored
Merge pull request #110 from joslat/feature/persist-extract-full-turn-89
Persist and extract from the complete turn, not just the response (#89)
2 parents 240b82d + 176d54b commit 4c11fed

6 files changed

Lines changed: 556 additions & 23 deletions

File tree

docs/agent-framework.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ derives from `Microsoft.Agents.AI.AIContextProvider` and implements the framewor
4848
| MAF hook (C#) | When | What AgentMemory does | Python equivalent |
4949
|---|---|---|---|
5050
| `ProvideAIContextAsync(InvokingContext)``AIContext` | **Before** the model call | Embeds the user turn, recalls relevant memory, and returns it as `AIContext.Messages` to prepend to the prompt. | `before_run` |
51-
| `StoreAIContextAsync(InvokedContext)` | **After** the response | Persists the response messages and (optionally) runs extraction into the graph. Skipped if the run threw (`context.InvokeException`). | `after_run` |
51+
| `StoreAIContextAsync(InvokedContext)` | **After** the response | Persists the response messages and (optionally) runs extraction over the complete turn (request + response) into the graph. Skipped if the run threw (`context.InvokeException`). | `after_run` |
5252

5353
This is the same **bidirectional** behavior the official provider describes ("auto-retrieve before
5454
invocation, auto-save after responses") — recall is passive and automatic; you never call it by hand.
@@ -58,7 +58,28 @@ Native recall via `Neo4jMemoryContextProvider` respects your configured `MemoryO
5858
`IMemoryService.RecallAsync(...)` call (#87). The one exception is `RecallOptions.Scope`: native recall
5959
always derives scope from the invocation's authenticated owner (via #100's isolation policy), never from a
6060
statically configured `Scope`, so a global config value can't silently override the real, per-invocation
61-
owner. `Neo4jMicrosoftMemoryFacade` (the lower-level, manually-driven alternative to the context provider)
61+
owner. Automatic extraction (`AutoExtractOnPersist`) considers the **complete turn** — both what the user asked
62+
and what the assistant answered (#89), filtered to user-role content so a system prompt or other non-user
63+
text isn't minted into spurious entities/facts/preferences every turn — so a preference or fact the user
64+
states is captured even if the assistant never repeats it back. `Neo4jChatHistoryProvider` persists both
65+
request and response messages as real `:Message` nodes (unchanged), so extraction there has full
66+
provenance. `Neo4jMemoryContextProvider` deliberately does **not** persist request messages as new nodes
67+
— only response messages are — because a host may already have `Neo4jChatHistoryProvider`,
68+
`Neo4jChatMessageStore`, or their own component persisting the same request messages on the same agent,
69+
and there is no idempotency mechanism (no caller-supplied message id, no upsert-by-content) that would let
70+
this provider safely avoid creating a duplicate `:Message` node for the same logical message. The
71+
practical effect: a fact/preference extracted from the user's own request is still created and recallable,
72+
but both its `EXTRACTED_FROM` provenance edge and its own `source_message_ids` property will reference a
73+
message id that was never persisted, unless another component also persisted that exact message.
74+
75+
**This asymmetry is deliberate for requests, but note it does not (yet) extend to responses**: both
76+
`Neo4jMemoryContextProvider` and `Neo4jChatHistoryProvider` persist response messages unconditionally, with
77+
the same lack of an idempotency mechanism — so combining both providers (or either with
78+
`Neo4jChatMessageStore`/a custom component) on the same agent can still duplicate **response** `:Message`
79+
nodes today. Avoid wiring more than one message-persisting component onto the same agent until a real
80+
cross-component idempotency mechanism exists.
81+
82+
`Neo4jMicrosoftMemoryFacade` (the lower-level, manually-driven alternative to the context provider)
6283
does not yet wire configured `RecallOptions` into its own recall call — a known gap for a future pass, not
6384
covered by this fix.
6485

src/AgentMemory.AgentFramework/Neo4jChatHistoryProvider.cs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,26 +104,45 @@ protected override async ValueTask StoreChatHistoryAsync(
104104
{
105105
var (sessionId, conversationId, userId, applicationId) = ExtractIds(context.Session, context.Agent);
106106
using var storeScope = ApplyStoreContext(applicationId);
107+
await PerformStoreAsync(
108+
context.RequestMessages, context.ResponseMessages ?? [], sessionId, conversationId, cancellationToken, userId)
109+
.ConfigureAwait(false);
110+
}
111+
112+
/// <summary>Internal helper exposed for unit testing.</summary>
113+
internal async Task PerformStoreAsync(
114+
IEnumerable<ChatMessage> requestMessages,
115+
IEnumerable<ChatMessage> responseMessages,
116+
string sessionId,
117+
string conversationId,
118+
CancellationToken cancellationToken,
119+
string? userId = null)
120+
{
107121
using var ownerScope = ApplyOwnerContext(userId);
108122
try
109123
{
110-
// Persist request messages (user + system turns not already in memory)
111-
foreach (var msg in context.RequestMessages)
124+
// Persist request messages (user + system turns not already in memory). Both request and
125+
// response messages are genuinely persisted here, so -- unlike Neo4jMemoryContextProvider,
126+
// which deliberately does NOT persist request messages to avoid duplicating what this
127+
// provider already stores -- extraction can safely see both with full provenance (#89).
128+
var storedRequests = new List<Abstractions.Domain.Message>();
129+
foreach (var msg in requestMessages)
112130
{
113131
if (string.IsNullOrWhiteSpace(msg.Text)) continue;
114132
var message = MafTypeMapper.ToInternalMessage(
115133
msg, sessionId, conversationId, _clock, _idGenerator);
116-
await _memoryService
134+
var stored = await _memoryService
117135
.AddMessageAsync(
118136
message.SessionId, message.ConversationId,
119137
message.Role, message.Content, message.Metadata,
120138
cancellationToken)
121139
.ConfigureAwait(false);
140+
storedRequests.Add(stored);
122141
}
123142

124143
// Persist response messages
125144
var storedResponses = new List<Abstractions.Domain.Message>();
126-
foreach (var msg in context.ResponseMessages ?? [])
145+
foreach (var msg in responseMessages)
127146
{
128147
if (string.IsNullOrWhiteSpace(msg.Text)) continue;
129148
var stored = await _memoryService
@@ -136,15 +155,20 @@ await _memoryService
136155
storedResponses.Add(stored);
137156
}
138157

139-
// Optionally trigger knowledge extraction on the persisted responses
140-
if (_options.AutoExtractOnPersist && storedResponses.Count > 0)
158+
// Optionally trigger knowledge extraction on the complete persisted turn (#89): a fact or
159+
// preference the user states in their request is now captured even if the assistant never
160+
// repeats it back. Extraction input is filtered to user-role request messages -- persistence
161+
// above still stores every role (unchanged), but a system prompt or other non-user content
162+
// in RequestMessages shouldn't be minted into spurious entities/facts/preferences every turn.
163+
var turnMessages = storedRequests.Where(m => m.Role == "user").Concat(storedResponses).ToList();
164+
if (_options.AutoExtractOnPersist && turnMessages.Count > 0)
141165
{
142166
try
143167
{
144168
await _memoryService.ExtractAndPersistAsync(
145169
new Abstractions.Domain.ExtractionRequest
146170
{
147-
Messages = storedResponses,
171+
Messages = turnMessages,
148172
SessionId = sessionId,
149173
UserId = userId
150174
}, cancellationToken).ConfigureAwait(false);

src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public sealed class Neo4jMemoryContextProvider : AIContextProvider
1616
{
1717
private readonly IMemoryService _memoryService;
1818
private readonly IEmbeddingOrchestrator _embeddingOrchestrator;
19+
private readonly IClock _clock;
20+
private readonly IIdGenerator _idGenerator;
1921
private readonly RecallOptions _recallOptions;
2022
private readonly ContextFormatOptions _formatOptions;
2123
private readonly AgentFrameworkOptions _agentOptions;
@@ -26,6 +28,8 @@ public sealed class Neo4jMemoryContextProvider : AIContextProvider
2628
public Neo4jMemoryContextProvider(
2729
IMemoryService memoryService,
2830
IEmbeddingOrchestrator embeddingOrchestrator,
31+
IClock clock,
32+
IIdGenerator idGenerator,
2933
IOptions<MemoryOptions> memoryOptions,
3034
IOptions<ContextFormatOptions> formatOptions,
3135
IOptions<AgentFrameworkOptions> agentOptions,
@@ -39,6 +43,8 @@ public Neo4jMemoryContextProvider(
3943
{
4044
_memoryService = memoryService ?? throw new ArgumentNullException(nameof(memoryService));
4145
_embeddingOrchestrator = embeddingOrchestrator ?? throw new ArgumentNullException(nameof(embeddingOrchestrator));
46+
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
47+
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
4248
_recallOptions = memoryOptions?.Value.Recall ?? RecallOptions.Default;
4349
_formatOptions = formatOptions?.Value ?? new ContextFormatOptions();
4450
_agentOptions = agentOptions?.Value ?? new AgentFrameworkOptions();
@@ -169,16 +175,18 @@ protected override async ValueTask StoreAIContextAsync(
169175
return;
170176
}
171177

178+
var requestMessages = context.RequestMessages ?? Enumerable.Empty<ChatMessage>();
172179
var responseMessages = context.ResponseMessages ?? Enumerable.Empty<ChatMessage>();
173180
var ids = ExtractIds(context.Session, context.Agent);
174181
using var storeScope = ApplyStoreContext(ids.applicationId);
175182

176-
await PerformStoreAsync(responseMessages, ids.sessionId, ids.conversationId, cancellationToken, ids.userId)
183+
await PerformStoreAsync(requestMessages, responseMessages, ids.sessionId, ids.conversationId, cancellationToken, ids.userId)
177184
.ConfigureAwait(false);
178185
}
179186

180187
/// <summary>Internal helper exposed for unit testing.</summary>
181188
internal async Task PerformStoreAsync(
189+
IEnumerable<ChatMessage> requestMessages,
182190
IEnumerable<ChatMessage> responseMessages,
183191
string sessionId,
184192
string conversationId,
@@ -188,6 +196,21 @@ internal async Task PerformStoreAsync(
188196
using var ownerScope = _ownerContext?.BeginOwnerScope(userId);
189197
try
190198
{
199+
// Response messages are persisted as new :Message nodes, as before. Request messages are
200+
// deliberately NOT persisted here (#89): a host may already have a Neo4jChatHistoryProvider,
201+
// Neo4jChatMessageStore, or their own component persisting the same request messages on this
202+
// agent, and there is no idempotency mechanism (no MERGE-by-id, no caller-supplied id) that
203+
// would let this provider safely avoid creating a second, duplicate :Message node for the
204+
// same logical message. Building transient (never-persisted) Message objects for extraction
205+
// only avoids that risk entirely while still capturing what the user said. Filtered to
206+
// ChatRole.User -- the same filter recall already applies (BuildContextAsync above) -- so a
207+
// system prompt or other non-user content accumulated in RequestMessages doesn't get minted
208+
// into spurious entities/facts/preferences every turn.
209+
var transientRequestMessages = requestMessages
210+
.Where(msg => msg.Role == ChatRole.User && !string.IsNullOrWhiteSpace(msg.Text))
211+
.Select(msg => MafTypeMapper.ToInternalMessage(msg, sessionId, conversationId, _clock, _idGenerator))
212+
.ToList();
213+
191214
var storedMessages = new List<Message>();
192215
foreach (var msg in responseMessages)
193216
{
@@ -203,14 +226,25 @@ internal async Task PerformStoreAsync(
203226
storedMessages.Add(stored);
204227
}
205228

206-
if (_agentOptions.AutoExtractOnPersist && storedMessages.Count > 0)
229+
// Extraction sees the complete turn (#89): a fact or preference the user states in their
230+
// request is now captured even if the assistant never repeats it back. Because
231+
// transientRequestMessages were never persisted as :Message nodes above, provenance for
232+
// anything extracted from them is incomplete: the EXTRACTED_FROM edge silently fails to
233+
// attach (the MATCH in PersistenceStage's linking Cypher finds no such node), AND the
234+
// extracted node's own source_message_ids property will still list the transient (never
235+
// persisted) message id, i.e. a dangling reference, not just a missing edge. The extracted
236+
// fact/preference itself is still created and recallable correctly; only its link back to the
237+
// literal source message is best-effort, not guaranteed, unless another component also
238+
// persisted that exact message.
239+
var turnMessages = transientRequestMessages.Concat(storedMessages).ToList();
240+
if (_agentOptions.AutoExtractOnPersist && turnMessages.Count > 0)
207241
{
208242
try
209243
{
210244
await _memoryService.ExtractAndPersistAsync(
211245
new ExtractionRequest
212246
{
213-
Messages = storedMessages,
247+
Messages = turnMessages,
214248
SessionId = sessionId,
215249
UserId = userId
216250
}, cancellationToken).ConfigureAwait(false);
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using FluentAssertions;
5+
using AgentMemory;
6+
using AgentMemory.Abstractions.Domain;
7+
using AgentMemory.Abstractions.Services;
8+
using AgentMemory.AgentFramework;
9+
using AgentMemory.Core.Stubs;
10+
using AgentMemory.Tests.Integration.Fixtures;
11+
12+
namespace AgentMemory.Tests.Integration.AgentFramework;
13+
14+
/// <summary>
15+
/// Live-Neo4j proof of #89's core acceptance criterion: a preference stated ONLY in the user's request
16+
/// (never repeated by the assistant) is extracted and later recallable in a brand-new session, using a
17+
/// real, DI-resolved <see cref="Neo4jMemoryContextProvider"/> against real Neo4j.
18+
/// </summary>
19+
[Collection("Neo4j Integration")]
20+
[Trait("Category", "Integration")]
21+
public sealed class PersistExtractFullTurnIntegrationTests : IAsyncLifetime
22+
{
23+
private readonly Neo4jIntegrationFixture _fixture;
24+
private ServiceProvider _provider = null!;
25+
26+
public PersistExtractFullTurnIntegrationTests(Neo4jIntegrationFixture fixture) => _fixture = fixture;
27+
28+
public async Task InitializeAsync()
29+
{
30+
await _fixture.CleanDatabaseAsync();
31+
32+
var services = new ServiceCollection();
33+
services.AddLogging();
34+
35+
// Deterministic preference extractor registered BEFORE AddNeo4jAgentMemory so it wins the
36+
// TryAddScoped override -- same convention as ExtractionOwnerStampIntegrationTests.cs.
37+
services.AddSingleton<IPreferenceExtractor, DeterministicPreferenceExtractor>();
38+
39+
services.AddNeo4jAgentMemory(
40+
configureMemory: _ => { },
41+
configureNeo4j: o =>
42+
{
43+
o.Uri = _fixture.ConnectionString;
44+
o.Username = _fixture.User;
45+
o.Password = _fixture.Password;
46+
o.Database = "neo4j";
47+
o.EmbeddingDimensions = Neo4jIntegrationFixture.TestEmbeddingDimensions;
48+
});
49+
services.AddAgentMemoryFramework(o => o.AutoExtractOnPersist = true);
50+
services.AddSingleton<IEmbeddingGenerator<string, Embedding<float>>>(sp =>
51+
new StubEmbeddingGenerator(
52+
sp.GetRequiredService<ILogger<StubEmbeddingGenerator>>(),
53+
Neo4jIntegrationFixture.TestEmbeddingDimensions));
54+
55+
_provider = services.BuildServiceProvider(validateScopes: true);
56+
}
57+
58+
public async Task DisposeAsync()
59+
{
60+
if (_provider is not null) await _provider.DisposeAsync();
61+
}
62+
63+
[Fact]
64+
public async Task PreferenceStatedOnlyByUser_IsExtractedAndRecallableInANewSession()
65+
{
66+
// StubEmbeddingGenerator is deterministic (same text -> same vector); recall is a similarity
67+
// search (MemoryContextAssembler -> SearchPreferencesAsync), so the recall query below
68+
// deliberately reuses this exact marker text to guarantee a match, matching the
69+
// "identical embeddings" convention used elsewhere in this test suite
70+
// (e.g. McpResourceIsolationIntegrationTests' ContextResource tests).
71+
const string preferenceMarker = "I prefer dark mode everywhere.";
72+
73+
using var writeScope = _provider.CreateScope();
74+
var writeSp = writeScope.ServiceProvider;
75+
var memoryProvider = writeSp.GetRequiredService<Neo4jMemoryContextProvider>();
76+
77+
// The assistant's reply never repeats the preference -- extraction must still capture it because
78+
// it now sees the request message too (#89), not just the response.
79+
await memoryProvider.PerformStoreAsync(
80+
requestMessages: [new ChatMessage(ChatRole.User, preferenceMarker)],
81+
responseMessages: [new ChatMessage(ChatRole.Assistant, "Got it.")],
82+
sessionId: "session-full-turn",
83+
conversationId: "conv-full-turn",
84+
cancellationToken: CancellationToken.None,
85+
userId: "alice");
86+
87+
// Recall in a brand-new session for the same owner.
88+
using var recallScope = _provider.CreateScope();
89+
var recallSp = recallScope.ServiceProvider;
90+
var recallProvider = recallSp.GetRequiredService<Neo4jMemoryContextProvider>();
91+
92+
var context = await recallProvider.BuildContextAsync(
93+
[new ChatMessage(ChatRole.User, preferenceMarker)],
94+
sessionId: "session-new",
95+
conversationId: "conv-new",
96+
cancellationToken: CancellationToken.None,
97+
userId: "alice");
98+
99+
context.Messages.Should().NotBeNullOrEmpty();
100+
context.Messages!.Should().Contain(m => m.Text != null && m.Text.Contains("dark mode"),
101+
"a preference stated only in the user's request must be extracted and recallable in a later session");
102+
}
103+
104+
private sealed class DeterministicPreferenceExtractor : IPreferenceExtractor
105+
{
106+
public Task<IReadOnlyList<ExtractedPreference>> ExtractAsync(
107+
IReadOnlyList<Message> messages, CancellationToken cancellationToken = default)
108+
{
109+
var userMessage = messages.FirstOrDefault(m => m.Role == "user");
110+
if (userMessage is null || !userMessage.Content.Contains("prefer", StringComparison.OrdinalIgnoreCase))
111+
return Task.FromResult<IReadOnlyList<ExtractedPreference>>(Array.Empty<ExtractedPreference>());
112+
113+
return Task.FromResult<IReadOnlyList<ExtractedPreference>>(
114+
[
115+
new ExtractedPreference { Category = "style", PreferenceText = userMessage.Content, Confidence = 0.95 },
116+
]);
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)