Skip to content

Commit 176d54b

Browse files
joslatclaude
andcommitted
fix: persist and extract from the complete turn, not just the response (#89)
Neo4jMemoryContextProvider.StoreAIContextAsync/PerformStoreAsync and Neo4jChatHistoryProvider.StoreChatHistoryAsync only ran extraction over the assistant's response messages -- a turn like "User: I prefer window seats. / Assistant: Got it." only extracted from "Got it.", so a user-stated preference was invisible to extraction unless the assistant happened to repeat it. Per explicit scoping: extraction must see request + response, but message persistence must not blindly duplicate what another configured component (a chat-history provider/store) may already be persisting. Research confirmed there is no idempotency mechanism anywhere in the stack today (Neo4jMessageRepository.AddAsync always CREATEs a new node; MemoryService.AddMessageAsync always generates a fresh id; there is no caller-supplied-id or MERGE-by-id path) -- so naively concatenating and persisting everything would risk duplicate :Message nodes whenever a host also has a chat-history component configured. Two different fix shapes, split by what each provider already persists: - Neo4jChatHistoryProvider already persisted both request and response messages; the fix just also feeds the request messages to extraction (previously discarded after being persisted). Zero new duplication risk. Refactored StoreChatHistoryAsync's body into an internal PerformStoreAsync test seam, mirroring the sibling provider. - Neo4jMemoryContextProvider never persisted request messages; the fix deliberately keeps it that way (avoiding the duplication risk) and instead builds transient, never-persisted Message objects purely for extraction. The trade-off -- extracted facts/preferences from the user's request are captured correctly, but their EXTRACTED_FROM provenance edge and source_message_ids won't resolve to a real node unless another component also persisted that exact message -- is documented, not silently accepted. An adversarial review confirmed the no-duplication guarantee holds (no path from ExtractAndPersistAsync ever calls AddMessageAsync) and found one real gap worth fixing: request messages were being fed to extraction without the same ChatRole.User filter recall already applies, so a system prompt accumulated in RequestMessages could get minted into spurious entities/facts/preferences every turn. Fixed in both providers. It also noted (out of this issue's scope, pre-existing, not introduced here) that response-message persistence itself has no idempotency guarantee either -- documented as a known limitation when combining more than one message-persisting component on the same agent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d284fe2 commit 176d54b

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)