Skip to content

Commit 1ae7114

Browse files
joslatclaude
andcommitted
fix: idempotent cross-component message persistence + non-text-content policy (#89)
Closes the two remaining #89 acceptance criteria plus a related bug found while investigating them: - Neo4jChatHistoryProvider narrows MAF's default request-message filter to External-only, so it no longer re-persists another configured AIContextProvider's injected messages (e.g. Neo4jMemoryContextProvider's recalled-memory content) as new nodes every turn -- a real, previously-undiscovered duplication bug, not just a stated criterion. - Message persistence is now idempotent by id (Neo4jMessageRepository writes MERGE instead of CREATE), and Neo4jMemoryContextProvider/ Neo4jChatHistoryProvider/Neo4jChatMessageStore persist response messages under a deterministic id derived from the underlying ChatMessage.MessageId when the IChatClient populates one -- so independently-configured components observing the same response converge on one node instead of duplicating it. IMemoryIngestion.AddMessageWithIdAsync is added as a default interface method to stay backward-compatible; InstrumentedMemoryService gets a mandatory explicit override so the mechanism isn't silently defeated when observability is enabled. - Documents and tests the non-text-content policy: a message carrying only function/tool calls, results, or reasoning content is excluded from persistence and extraction in the two MAF providers (not the lower-level Neo4jChatMessageStore path, which persists everything unconditionally). Adversarially reviewed; full unit (2767) and live-Neo4j integration (285) suites green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016FCv8MKpe8tTULe8M6JJGi
1 parent 2d5997d commit 1ae7114

18 files changed

Lines changed: 793 additions & 101 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5050
user-stated preference was invisible unless the assistant repeated it. Extraction now considers the
5151
complete turn. `Neo4jChatHistoryProvider` (which already persisted both request and response messages)
5252
gained full-provenance extraction from both at no new risk. `Neo4jMemoryContextProvider` deliberately
53-
does not start persisting request messages as new `:Message` nodes (there is no idempotency mechanism to
54-
avoid duplicating what a chat-history provider/store may already persist) — captured facts/preferences
55-
are correct, but their provenance link to the literal source message is best-effort in that path, not
53+
does not start persisting request messages as new `:Message` nodes — request-message persistence
54+
ownership intentionally stays solely with `Neo4jChatHistoryProvider` — so captured facts/preferences are
55+
correct, but their provenance link to the literal source message is best-effort in that path, not
5656
guaranteed; documented, not silently accepted.
57+
- **Enabling more than one MAF message-persisting component could duplicate `:Message` nodes (#89).**
58+
`Neo4jChatHistoryProvider` now narrows MAF's default request-message filter to
59+
`AgentRequestMessageSourceType.External` only, so it no longer re-persists another configured
60+
`AIContextProvider`'s (e.g. `Neo4jMemoryContextProvider`'s) injected recalled-memory messages as new
61+
nodes every turn — a real, previously-undiscovered bug found while investigating this issue, not merely
62+
one of its stated acceptance criteria. On the response side, message persistence is now idempotent by
63+
id: `Neo4jMessageRepository`'s writes are `MERGE`-by-id instead of `CREATE` (a no-op, first-write-wins,
64+
for a repeated id — safe because `message_id` already has a uniqueness constraint, and no existing
65+
caller ever supplied a repeated id), and `Neo4jMemoryContextProvider`/`Neo4jChatHistoryProvider`/
66+
`Neo4jChatMessageStore` now persist a response message under a deterministic id derived from the
67+
underlying `ChatMessage.MessageId` when the `IChatClient` populates one — so two of these components
68+
observing the same response converge on one node instead of duplicating it. When the client doesn't
69+
populate `MessageId`, today's fresh-id behavior remains (no regression, but the gap remains for that
70+
client) — a disclosed limitation, not a silent one; see `docs/agent-framework.md`. Also explicitly
71+
documents the non-text-content policy for `Neo4jMemoryContextProvider`/`Neo4jChatHistoryProvider`: a
72+
message carrying only function/tool calls, function/tool results, or reasoning content is excluded from
73+
both persistence and extraction in those two providers (previously true as a side effect of the
74+
empty-text filter, now an explicit, tested contract for them specifically — the lower-level
75+
`Neo4jChatMessageStore`/`Neo4jMicrosoftMemoryFacade` path does not have this guard and persists every
76+
message it's given). #89 is now fully closed.
5777

5878
## [1.0.3] - 2026-07-14
5979

docs/agent-framework.md

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,36 @@ text isn't minted into spurious entities/facts/preferences every turn — so a p
6464
states is captured even if the assistant never repeats it back. `Neo4jChatHistoryProvider` persists both
6565
request and response messages as real `:Message` nodes (unchanged), so extraction there has full
6666
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.
67+
— only response messages are — because a caller-constructed request `ChatMessage` essentially never
68+
carries a stable identity that another persisting component would also see, so request-message persistence
69+
ownership intentionally stays solely with `Neo4jChatHistoryProvider`. The practical effect: a
70+
fact/preference extracted from the user's own request is still created and recallable, but both its
71+
`EXTRACTED_FROM` provenance edge and its own `source_message_ids` property will reference a message id that
72+
was never persisted, unless another component also persisted that exact message.
73+
74+
**Duplicate message persistence across components (#89)**: `Neo4jChatHistoryProvider` narrows MAF's
75+
default request-message filter to `AgentRequestMessageSourceType.External` only, so it never re-persists
76+
another configured `AIContextProvider`'s (e.g. `Neo4jMemoryContextProvider`'s) injected recalled-memory
77+
messages as new nodes every turn. On the **response** side, message persistence is idempotent by id:
78+
`Neo4jMemoryContextProvider`, `Neo4jChatHistoryProvider`, and `Neo4jChatMessageStore` all persist a response
79+
message under a deterministic id derived from the underlying `ChatMessage.MessageId` when the `IChatClient`
80+
populates one (true for many production clients, e.g. those backed by the OpenAI Responses API) — so if
81+
more than one of these components observes the same response message, they converge on the same
82+
`:Message` node instead of creating a duplicate. When the underlying client does **not** populate
83+
`MessageId`, each component still falls back to today's behavior (a fresh id per call), so combining more
84+
than one message-persisting component on the same agent can still duplicate that response message — this
85+
is a known, disclosed limitation of relying on provider-native identity rather than a cross-component
86+
idempotency protocol; there is no plan to add content-hash-based deduplication, since that would risk
87+
silently collapsing two genuinely distinct occurrences of identical text (e.g. the assistant saying
88+
"Understood." twice in different turns) into one node.
89+
90+
**Non-text content policy**: a `ChatMessage` whose content is exclusively non-`TextContent` (e.g. a
91+
function/tool call, a function/tool result, or a reasoning trace) has an empty `.Text`, and `Neo4jMemoryContextProvider`/`Neo4jChatHistoryProvider` exclude it from both persistence and automatic
92+
extraction — not just "tool messages," but any message carrying no literal text for a human or the
93+
extraction pipeline to act on. This guard is specific to those two providers; the lower-level
94+
`Neo4jChatMessageStore`/`Neo4jMicrosoftMemoryFacade` path persists every message it's given regardless of
95+
text content, so a host driving that path directly is responsible for filtering non-text messages itself
96+
if it wants the same behavior.
8197

8298
`Neo4jMicrosoftMemoryFacade` (the lower-level, manually-driven alternative to the context provider)
8399
does not yet wire configured `RecallOptions` into its own recall call — a known gap for a future pass, not

src/AgentMemory.Abstractions/Services/IMemoryIngestion.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,28 @@ Task<Message> AddMessageAsync(
1919
IReadOnlyDictionary<string, object>? metadata = null,
2020
CancellationToken cancellationToken = default);
2121

22+
/// <summary>
23+
/// Adds a message to short-term memory with a caller-supplied, deterministic id. Calling this twice
24+
/// with the same <paramref name="messageId"/> returns the pre-existing message unchanged (first-write-
25+
/// wins) instead of creating a duplicate node -- lets independently-configured persisting components
26+
/// (e.g. multiple MAF integration components observing the same underlying model response) converge on
27+
/// one message node when they share a stable identity for it, instead of each minting a fresh one.
28+
/// </summary>
29+
/// <remarks>
30+
/// Default implementation ignores <paramref name="messageId"/> and behaves exactly like
31+
/// <see cref="AddMessageAsync(string,string,string,string,IReadOnlyDictionary{string,object}?,CancellationToken)"/>
32+
/// (a fresh id every call) -- implementers of this interface are not required to override this member.
33+
/// </remarks>
34+
Task<Message> AddMessageWithIdAsync(
35+
string sessionId,
36+
string conversationId,
37+
string role,
38+
string content,
39+
string messageId,
40+
IReadOnlyDictionary<string, object>? metadata = null,
41+
CancellationToken cancellationToken = default) =>
42+
AddMessageAsync(sessionId, conversationId, role, content, metadata, cancellationToken);
43+
2244
/// <summary>
2345
/// Batch adds messages to short-term memory.
2446
/// </summary>

src/AgentMemory.AgentFramework/Mapping/MafTypeMapper.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ public static Message ToInternalMessage(
3838
public static ChatMessage ToChatMessage(Message message)
3939
=> new(ToMafRole(message.Role), message.Content);
4040

41+
/// <summary>
42+
/// Derives a deterministic persistence id from a response <see cref="ChatMessage"/>'s provider-native
43+
/// <see cref="ChatMessage.MessageId"/>, when the underlying <c>IChatClient</c> populates one (common
44+
/// for clients backed by e.g. the OpenAI Responses API). Returns null when absent -- caller-constructed
45+
/// messages (typically request/user messages) essentially never have one, so this only helps dedupe
46+
/// response messages that independently-configured persisting components (Neo4jMemoryContextProvider,
47+
/// Neo4jChatHistoryProvider, Neo4jChatMessageStore) might otherwise each persist as a separate node.
48+
/// </summary>
49+
public static string? TryGetProviderMessageId(ChatMessage message) =>
50+
string.IsNullOrWhiteSpace(message.MessageId) ? null : $"maf:{message.MessageId}";
51+
4152
/// <summary>
4253
/// Converts a <see cref="MemoryContext"/> to a list of context <see cref="ChatMessage"/> instances.
4354
/// </summary>

src/AgentMemory.AgentFramework/Neo4jChatHistoryProvider.cs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,17 @@ public Neo4jChatHistoryProvider(
3939
ILogger<Neo4jChatHistoryProvider> logger,
4040
IMemoryStoreContext? storeContext = null,
4141
IWritableMemoryOwnerContext? ownerContext = null)
42-
: base(null, null, null)
42+
// storeInputRequestMessageFilter narrows MAF's own default (which excludes only ChatHistory-sourced
43+
// messages) down to External-only. Without this, when a host also configures an AIContextProvider
44+
// (e.g. Neo4jMemoryContextProvider) on the same agent, that provider's injected messages (recalled
45+
// memory, wrapped as system-role content) show up in RequestMessages here too and get persisted as
46+
// brand-new :Message nodes every single turn (#89). Trade-off: a host using a DIFFERENT,
47+
// legitimate AIContextProvider whose injected content they actually want captured as persisted
48+
// history would now have it silently excluded too -- treating provider-injected context as
49+
// ephemeral (not history) is the correct default for this library, but it is a real behavior
50+
// change from MAF's own out-of-the-box default for that uncommon case.
51+
: base(null, static msgs => msgs.Where(
52+
m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.External), null)
4353
{
4454
_memoryService = memoryService ?? throw new ArgumentNullException(nameof(memoryService));
4555
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
@@ -140,18 +150,31 @@ internal async Task PerformStoreAsync(
140150
storedRequests.Add(stored);
141151
}
142152

143-
// Persist response messages
153+
// Persist response messages. When the underlying IChatClient stamps a provider-native
154+
// MessageId, persist under a deterministic id (#89) so another persisting component observing
155+
// the same response (e.g. Neo4jMemoryContextProvider, Neo4jChatMessageStore) converges on the
156+
// same :Message node instead of creating a duplicate. Falls back to today's fresh-id behavior
157+
// when absent.
144158
var storedResponses = new List<Abstractions.Domain.Message>();
145159
foreach (var msg in responseMessages)
146160
{
147161
if (string.IsNullOrWhiteSpace(msg.Text)) continue;
148-
var stored = await _memoryService
149-
.AddMessageAsync(
150-
sessionId, conversationId,
151-
MafTypeMapper.ToInternalRole(msg.Role),
152-
msg.Text,
153-
cancellationToken: cancellationToken)
154-
.ConfigureAwait(false);
162+
var providerId = MafTypeMapper.TryGetProviderMessageId(msg);
163+
var stored = providerId is not null
164+
? await _memoryService
165+
.AddMessageWithIdAsync(
166+
sessionId, conversationId,
167+
MafTypeMapper.ToInternalRole(msg.Role),
168+
msg.Text, providerId,
169+
cancellationToken: cancellationToken)
170+
.ConfigureAwait(false)
171+
: await _memoryService
172+
.AddMessageAsync(
173+
sessionId, conversationId,
174+
MafTypeMapper.ToInternalRole(msg.Role),
175+
msg.Text,
176+
cancellationToken: cancellationToken)
177+
.ConfigureAwait(false);
155178
storedResponses.Add(stored);
156179
}
157180

src/AgentMemory.AgentFramework/Neo4jChatMessageStore.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,18 @@ public async Task<Message> AddMessageAsync(
4040
try
4141
{
4242
var message = MafTypeMapper.ToInternalMessage(chatMessage, sessionId, conversationId, _clock, _idGenerator);
43-
return await _memoryService
44-
.AddMessageAsync(message.SessionId, message.ConversationId, message.Role, message.Content, message.Metadata, cancellationToken)
45-
.ConfigureAwait(false);
43+
// When the underlying IChatClient stamps a provider-native MessageId on this ChatMessage,
44+
// persist under a deterministic id (#89) so another persisting component observing the same
45+
// message (Neo4jMemoryContextProvider, Neo4jChatHistoryProvider) converges on the same
46+
// :Message node instead of creating a duplicate.
47+
var providerId = MafTypeMapper.TryGetProviderMessageId(chatMessage);
48+
return providerId is not null
49+
? await _memoryService
50+
.AddMessageWithIdAsync(message.SessionId, message.ConversationId, message.Role, message.Content, providerId, message.Metadata, cancellationToken)
51+
.ConfigureAwait(false)
52+
: await _memoryService
53+
.AddMessageAsync(message.SessionId, message.ConversationId, message.Role, message.Content, message.Metadata, cancellationToken)
54+
.ConfigureAwait(false);
4655
}
4756
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
4857
{

src/AgentMemory.AgentFramework/Neo4jMemoryContextProvider.cs

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,14 @@ internal async Task PerformStoreAsync(
197197
try
198198
{
199199
// 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.
200+
// deliberately NOT persisted here (#89): ChatMessage.MessageId (used below for response-side
201+
// dedup) is essentially never populated on caller-constructed request messages, so it can't
202+
// help there -- request-message persistence ownership intentionally stays solely with
203+
// Neo4jChatHistoryProvider. Building transient (never-persisted) Message objects for extraction
204+
// only captures what the user said without risking a duplicate node. Filtered to ChatRole.User
205+
// -- the same filter recall already applies (BuildContextAsync above) -- so a system prompt or
206+
// other non-user content accumulated in RequestMessages doesn't get minted into spurious
207+
// entities/facts/preferences every turn.
209208
var transientRequestMessages = requestMessages
210209
.Where(msg => msg.Role == ChatRole.User && !string.IsNullOrWhiteSpace(msg.Text))
211210
.Select(msg => MafTypeMapper.ToInternalMessage(msg, sessionId, conversationId, _clock, _idGenerator))
@@ -216,13 +215,27 @@ internal async Task PerformStoreAsync(
216215
{
217216
if (string.IsNullOrWhiteSpace(msg.Text)) continue;
218217

219-
var stored = await _memoryService
220-
.AddMessageAsync(
221-
sessionId, conversationId,
222-
MafTypeMapper.ToInternalRole(msg.Role),
223-
msg.Text,
224-
cancellationToken: cancellationToken)
225-
.ConfigureAwait(false);
218+
// When the underlying IChatClient stamps a provider-native MessageId on this response
219+
// message, persist it under a deterministic id (#89): if another persisting component
220+
// (e.g. Neo4jChatHistoryProvider, Neo4jChatMessageStore) configured on the same agent
221+
// observes the same response, it converges on this same :Message node instead of creating
222+
// a duplicate. Falls back to today's fresh-id behavior when absent (no regression).
223+
var providerId = MafTypeMapper.TryGetProviderMessageId(msg);
224+
var stored = providerId is not null
225+
? await _memoryService
226+
.AddMessageWithIdAsync(
227+
sessionId, conversationId,
228+
MafTypeMapper.ToInternalRole(msg.Role),
229+
msg.Text, providerId,
230+
cancellationToken: cancellationToken)
231+
.ConfigureAwait(false)
232+
: await _memoryService
233+
.AddMessageAsync(
234+
sessionId, conversationId,
235+
MafTypeMapper.ToInternalRole(msg.Role),
236+
msg.Text,
237+
cancellationToken: cancellationToken)
238+
.ConfigureAwait(false);
226239
storedMessages.Add(stored);
227240
}
228241

0 commit comments

Comments
 (0)