You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: docs/agent-framework.md
+30-14Lines changed: 30 additions & 14 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -64,20 +64,36 @@ text isn't minted into spurious entities/facts/preferences every turn — so a p
64
64
states is captured even if the assistant never repeats it back. `Neo4jChatHistoryProvider` persists both
65
65
request and response messages as real `:Message` nodes (unchanged), so extraction there has full
66
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.
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.
81
97
82
98
`Neo4jMicrosoftMemoryFacade` (the lower-level, manually-driven alternative to the context provider)
83
99
does not yet wire configured `RecallOptions` into its own recall call — a known gap for a future pass, not
0 commit comments