Persistent User Memory enables EDDI agents to remember facts, preferences, and context about individual users across conversations. Unlike conversation-scoped properties that are lost when a conversation ends, persistent memories survive indefinitely and are automatically loaded into every new conversation with the same user.
| Feature | Description |
|---|---|
| Scope | Per-user, per-agent (or globally shared) |
| Storage | MongoDB (usermemories collection) or PostgreSQL (usermemories table) |
| LLM Integration | 4 built-in tools for autonomous memory management |
| Visibility | self, group, global scoping |
| Guardrails | Configurable key/value limits, write-rate limits, capacity caps |
| GDPR | Full right-to-erasure support via REST API and MCP tools |
| Maintenance | Background "Dream" consolidation (stale pruning, contradiction detection, LLM summarization) |
┌─────────────────────────────────────────────────────┐
│ Conversation Pipeline │
│ │
│ LLM ──→ UserMemoryTool ──→ IUserMemoryStore │
│ ↑ ↑ │
│ │ │ │
│ AgentOrchestrator MongoUserMemoryStore │
│ (per-invocation) PostgresUserMemoryStore │
│ │
│ REST API ───────────────────→ IUserMemoryStore │
│ MCP Tools ──────────────────→ IUserMemoryStore │
│ DreamService (background) ─→ IUserMemoryStore │
└─────────────────────────────────────────────────────┘
Enable advanced memory features (LLM tools, Dream consolidation, guardrails, recall settings) in your agent's configuration:
{
"name": "My Agent",
"enableMemoryTools": true,
"userMemoryConfig": {
"maxEntriesPerUser": 500,
"maxRecallEntries": 50,
"recallOrder": "most_recent",
"onCapReached": "evict_oldest",
"guardrails": {
"maxKeyLength": 100,
"maxValueLength": 1000,
"maxWritesPerTurn": 10,
"allowedCategories": ["preference", "fact", "context"]
},
"dream": {
"enabled": true,
"pruneStaleAfterDays": 90,
"detectContradictions": true,
"summarizeInteractions": true,
"summarizeMinEntries": 5,
"summarizeTargetEntries": 2,
"summarizeGroupBy": "category",
"preserveAgentProvenance": false,
"maxCostPerRun": 0.50,
"parameters": { "apiKey": "${vault:anthropic-api-key}" }
}
}
}The LLM task (langchain.json) must separately enable built-in tools — builtInToolsWhitelist is a field of the LLM task, not of the agent:
{
"enableBuiltInTools": true,
"builtInToolsWhitelist": ["usermemory"]
}Attaching the memory tools is a three-way conjunction across the two configuration files: the agent's enableMemoryTools: true and its userMemoryConfig and the LLM task's enableBuiltInTools: true, with builtInToolsWhitelist either omitted (all built-ins) or containing "usermemory". Miss any part and the agent gets no memory tools, with only a WARN in the log.
Note:
enableMemoryToolsgates exactly one thing: attaching the LLMUserMemoryTool(and with it theguardrails, which only that tool enforces). Everything else inuserMemoryConfigapplies whenever the block is declared, with or without the flag —defaultVisibilityand the recall settings govern thelongTermproperty path that every agent uses, and Dream is switched on bydream.enabledplus a schedule. Earlier releases ignored the whole block unlessenableMemoryToolswas on, so a rule-based agent's"defaultVisibility": "self"silently persisted asglobal.
| Field | Type | Default | Description |
|---|---|---|---|
maxEntriesPerUser |
int |
500 |
Maximum memory entries per user |
maxRecallEntries |
int |
50 |
Maximum entries returned by recall |
recallOrder |
String |
"most_recent" |
"most_recent" (by updatedAt) or "most_accessed" (by accessCount) |
onCapReached |
String |
"evict_oldest" |
"reject" (refuse a new entry once maxEntriesPerUser is reached — updating an existing one is always allowed, it adds no row) or "evict_oldest" (permanently delete this agent's oldest entries to make room — entries owned by other agents are never evicted) |
defaultVisibility |
String |
"self" |
Visibility applied to a longTerm property that sets none (an unparseable value, or no userMemoryConfig at all, falls back to global). Applies without enableMemoryTools |
autoRecallCategories |
List<String> |
["preference","fact"] |
Reserved — not applied. Every visible category is recalled at conversation start. It cannot be switched on retroactively: the store writes this default into every saved agent, so a stored value is indistinguishable from an explicit one, and enforcing it would stop recalling context, legacy and property entries for all of them |
| Field | Type | Default | Description |
|---|---|---|---|
maxKeyLength |
int |
100 |
Maximum characters for memory keys |
maxValueLength |
int |
1000 |
Maximum characters for memory values |
maxWritesPerTurn |
int |
10 |
Write-rate limit per conversation turn. A re-save of a fact that is already stored unchanged is not a write and does not count |
allowedCategories |
List<String> |
["preference","fact","context"] |
Allowed memory categories |
allowedVisibilities |
List<String> |
["self"] |
Visibilities the rememberFact tool may write (self, group, global). By default the model can only store memories private to this agent, so a prompt-injected model cannot broadcast to every agent (global) or the group. The configured defaultVisibility is always added, so a default can never block every write |
allowGlobalKeyOverwrite |
boolean |
false |
Whether a global write may replace the value of an existing global key this agent does not provably own — one owned by another agent, or one whose entry records no owning agent (legacy/migrated data). Off by default: such a write is refused with a message suggesting a different key or self visibility |
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Enable background consolidation |
pruneStaleAfterDays |
int |
90 |
Remove entries whose updatedAt (last write) is older than N days. Recalling an entry does not refresh this — only a write does. Set to 0 to disable. |
detectContradictions |
boolean |
true |
Flag entries with same key but different values |
summarizeInteractions |
boolean |
false |
Enable LLM-driven memory consolidation |
summarizeMinEntries |
int |
5 |
Minimum entries in a group before summarization triggers |
summarizeTargetEntries |
int |
2 |
Target number of entries per group after consolidation. It is passed to the model; an answer above it (but below the original count) is kept whole — never truncated, because the originals are deleted |
summarizeGroupBy |
String |
"category" |
Grouping strategy: "category" or "all" |
preserveAgentProvenance |
boolean |
false |
Sub-group by sourceAgentId (preserves per-agent provenance) |
maxSummarizationCalls |
int |
10 |
Deprecated — prefer maxCostPerRun. Still honoured as a secondary backstop if you set it explicitly, because silently dropping a bound an operator wrote is worse than enforcing a redundant one. A call count is a poor budget: consolidations differ wildly in cost. |
summarizationPrompt |
String |
(built-in) | Custom LLM instructions for consolidation |
maxCostPerRun |
double |
0.50 |
Maximum dollar cost per dream cycle — the primary ceiling |
crossAgentMaintenance |
boolean |
false |
By default a dream cycle only touches memories the firing agent wrote (sourceAgentId). Set true to let it maintain the user's whole memory set across agents — otherwise agent A's retention setting would delete agent B's memories, and A's model endpoint would see B's private text. |
llmProvider |
String |
"anthropic" |
LLM provider for dream operations |
llmModel |
String |
"claude-sonnet-4-6" |
Model for dream operations. Passed under the key the provider reads (model for Ollama, modelId for Bedrock/HuggingFace/Vertex, deploymentName for Azure, modelName otherwise) |
parameters |
Map<String,String> |
{} |
Model parameters for the consolidation LLM — apiKey, baseUrl, temperature, … — passed to the model registry exactly like an LLM task's parameters block, so ${vault:…} and ${vars:…} resolve. Required when summarizeInteractions is true: a background dream cycle has no parent LLM task to inherit credentials from, so without it every summarization step fails with a provider 401 while stale pruning keeps working. |
schedule |
String |
"0 3 * * *" |
Cron expression the dream schedule should use |
contradictionResolution |
String |
"keep_newest" |
Reserved. The current detector counts and logs contradictions without resolving them. |
rememberFact resolves which stored entry a write lands on before writing: re-stating an unchanged fact answers ✅ Already remembered (unchanged) without writing, and an update of an existing entry never counts against maxEntriesPerUser.
When the LLM task has enableBuiltInTools: true and usermemory is in its builtInToolsWhitelist (or no whitelist is set), the LLM gets access to four tools:
Store a fact about the user.
Parameters:
key - Short key name (e.g. "favorite_color", "dietary_restriction")
value - The value to remember
category - One of: "preference", "fact", "context"
visibility - One of: "self", "group", "global" (default: "self")
Returns: "✅ Remembered: favorite_color = blue [preference, self]"
Retrieve all memories visible to this agent for the current user.
Parameters: none
Returns:
• name = Alice [fact, self]
• favorite_color = blue [preference, self]
• language = English [preference, global]
Search for memories by keyword across keys and values. The query is split into
terms on anything that is not a letter or digit, and an entry matches when
every term appears (case-insensitively) in its key or its value — so
"dog name", "dog_name" and "dog-name" all find the key dog_name.
Punctuation-only queries match nothing.
Parameters:
query - Search text (e.g. "color")
Returns: matching entries formatted as bullet list
Delete a specific memory by key.
Parameters:
key - The key name to forget (e.g. "favorite_color")
Returns: "✅ Forgotten: favorite_color"
| Scope | Description | Upsert Key |
|---|---|---|
self |
Only the agent that stored it can see it | (userId, key, sourceAgentId) among the agent's non-global entries |
group |
All agents in the same group conversation can see it | (userId, key, sourceAgentId) among the agent's non-global entries |
global |
All agents for this user can see it | (userId, key) |
A self or group write never touches the shared global entry with the same key — an agent can hold a private note and a shared fact under one key. (MongoDB used to key self/group writes without the visibility term, so saving a private note flipped the agent's own shared entry to self and every other agent lost it; PostgreSQL always had this identity.)
When agents participate in a Group Conversation, the groupId is automatically injected into the conversation context. Memories stored with group visibility are visible to all agents in that group — whether written by rememberFact or by a longTerm property with "visibility": "group", both of which stamp the conversation's group id on the entry. A group property in a conversation that belongs to no group is stored as self: without a group id no reader could ever match it, and self keeps it reachable without widening it.
The group is taken only from that injected context value. A client cannot supply
groupId in its own request context (see Reserved Context Keys),
and a conversation property named groupId does not select a group scope for the
usermemory tool.
A groupId found only on an earlier step of the conversation — a turn the owner sends
into a member conversation directly carries no group context — is used, by the
usermemory tool and when longTerm properties are persisted alike, only while the
discussion named on that step is running on this instance, this conversation is one of its
members, and it belongs to that group. Otherwise the turn is self-scoped, so a value a
client forged before reserved keys were filtered does not keep working.
Base path: /usermemorystore/memories
| Method | Path | Description |
|---|---|---|
GET |
/{userId} |
Get all memories for a user |
GET |
/{userId}/visible?agentId=&groupId=&order=&limit= |
Get memories visible to a specific agent |
GET |
/{userId}/search?q= |
Search memories by keyword |
GET |
/{userId}/category/{category} |
Get memories filtered by category |
GET |
/{userId}/key/{key} |
Get a specific memory by key |
PUT |
/ |
Upsert a memory entry (JSON body) — validated, see below |
DELETE |
/entry/{entryId} |
Delete a specific memory |
DELETE |
/{userId} |
Delete ALL memories for a user (GDPR) |
GET |
/{userId}/count |
Count memory entries |
REST PUT and MCP upsert_user_memory share one rule set (UserMemoryWriteRules) and answer 400 / an error instead of storing:
userId,keyandvisibilityare required; keys are at most 255 characters and values at most 64 KiB of JSON;selfandgroupentries need asourceAgentId(MCP:agentId), andgroupentries needgroupIds— an entry without them could never be read by anyone;categorymust be one ofpreference,fact,context,legacy,property; an absent category is stored asfact.
curl -X PUT http://localhost:7070/usermemorystore/memories \
-H "Content-Type: application/json" \
-d '{
"userId": "user-123",
"key": "preferred_language",
"value": "German",
"category": "preference",
"visibility": "global",
"sourceAgentId": "agent-456"
}'curl "http://localhost:7070/usermemorystore/memories/user-123/visible?agentId=agent-456&order=most_recent&limit=20"8 MCP tools are available for external integration and administration:
| Tool | Role | Description |
|---|---|---|
list_user_memories |
eddi-viewer |
List all entries for a user |
get_visible_memories |
eddi-viewer |
Get entries visible to a specific agent |
search_user_memories |
eddi-viewer |
Search by keyword |
get_memory_by_key |
eddi-viewer |
Look up by key name |
count_user_memories |
eddi-viewer |
Count entries |
upsert_user_memory |
eddi-admin |
Insert or update an entry (groupIds — comma-separated — for group visibility) |
delete_user_memory |
eddi-admin |
Delete a specific entry |
delete_all_user_memories |
eddi-admin |
Delete all memories except the _gdpr_ bookkeeping entries (requires CONFIRM) |
The delete_all_user_memories MCP tool and DELETE /{userId} REST endpoint permanently remove a user's memory entries and legacy properties, except the GDPR bookkeeping entries (keys starting with _gdpr_, such as an Art. 18 processing restriction), which only the GDPR admin endpoints set or lift. They are memory housekeeping, not an Art. 17 erasure — for that use DELETE /admin/gdpr/{userId} (MCP: delete_user_data), which removes everything, including those entries, across every store. The MCP tool requires an explicit confirmation="CONFIRM" parameter as a safety gate.
Keys starting with _gdpr_ are reserved: agents, property setters and the memory/property REST and MCP endpoints cannot write or delete them.
The Dream service performs background maintenance on user memories:
-
Stale Pruning — Removes entries whose
updatedAt(last write) is older thanpruneStaleAfterDaysdays. Recalling an entry does not refreshupdatedAt, so a fact read in every conversation is still pruned if nothing has rewritten it. This is a deterministic operation with zero LLM cost. -
Contradiction Detection — Identifies entries with the same key but different values (e.g.,
language=Englishfrom Agent A vslanguage=Germanfrom Agent B). Detection is read-only, so unlike pruning and summarization it looks past the firing agent's own entries: every entry is considered for a key the firing agent holds (a disagreement between two other agents is not its to report). The log names the key and agents at INFO; the conflicting values are logged at DEBUG only. V1 uses key-based matching; future versions will use LLM-driven semantic analysis. -
Interaction Summarization — When
summarizeInteractions=true, compresses multiple related facts into consolidated summaries using the configured LLM. Entries are grouped by thesummarizeGroupBystrategy (per-category or all together), and each group abovesummarizeMinEntriesis distilled towardsummarizeTargetEntriesentries. Safety guarantees: every write target is resolved before anything is written; new entries are inserted before originals are deleted; an original that receives a consolidated entry (the model reused its key) is updated in place and not deleted; a key that would overwrite a memory outside the group skips the group untouched; duplicate keys in the answer are merged; a failed insert restores any original it overwrote; an answer that only repeats originals verbatim may drop an original only when it duplicated a kept value (otherwise it merged nothing and the group is skipped); LLM failures or invalid responses preserve the original entries.
Setting dream.enabled: true does not start anything on its own — it is the per-agent veto that is checked when a dream schedule fires. Dream runs through the regular cluster-aware schedule machinery, so a cycle only happens if you also create a ScheduleConfiguration that targets it:
{
"name": "nightly-dream",
"triggerType": "CRON",
"cronExpression": "0 3 * * *",
"agentId": "<agentId>",
"agentVersion": 1,
"userId": "<userId>",
"metadata": { "dreamType": "dream_consolidation" }
}Create it with POST /schedulestore/schedules. A Dream schedule needs no message (it dispatches to DreamService, not to a conversation), but it does need a real userId — one without is rejected at creation, since the cycle consolidates exactly that user's memories.
ScheduleFireExecutor recognises the dreamType marker and dispatches to DreamService with the schedule's agentId, agentVersion and userId. The cron expression lives on the schedule — dream.schedule in the agent config is a documentation-only hint that the engine never reads.
The Dream service exposes Micrometer metrics (Prometheus names at /q/metrics replace the dots with underscores, e.g. eddi_dream_entries_pruned_total):
| Metric | Type | Description |
|---|---|---|
eddi.dream.users.processed |
Counter | Users processed across all dream cycles |
eddi.dream.entries.pruned |
Counter | Total entries pruned |
eddi.dream.contradictions.found |
Counter | Contradictions detected |
eddi.dream.entries.summarized |
Counter | Entries reduced by LLM consolidation |
eddi.dream.cycles.failed |
Counter | Cycles that were rejected or ended with an error |
eddi.dream.summarization.failed |
Counter | Consolidation LLM calls that failed |
eddi.dream.duration |
Timer | Duration of dream cycles |
In v6, the legacy IPropertiesStore interface and the properties collection have been removed. All user-scoped persistent data now lives in the unified usermemories collection.
| Aspect | Legacy Properties (v5) | User Memory (v6) |
|---|---|---|
| Storage | properties collection (flat map) |
usermemories collection (structured entries) |
| Interface | IPropertiesStore (deleted in v6) |
IUserMemoryStore |
| Scoping | Per-user only | Per-user, per-agent, per-group |
| LLM access | Via template variables only | Direct LLM tool access |
| Querying | Key lookup only | Key, category, search, visibility filtering |
| Administration | No REST API | Full CRUD REST API + MCP tools |
Legacy flat property operations (readProperties, mergeProperties, deleteProperties) continue to work through IUserMemoryStore — they operate on global visibility entries in the usermemories collection. The REST endpoint at /propertiesstore/properties/{userId} is preserved.
On first startup, PropertiesMigrationService automatically migrates existing properties documents into usermemories as global entries with category=legacy. A key the user already has in usermemories keeps its value — it is newer than the frozen v5 data (or the result of an earlier, partial run), so migration never overwrites it. A legacy document without a userId cannot be migrated and is skipped (not counted as a failure). Once every key made it across, the old collection is renamed to properties_migrated_v6 as a safety backup; after a partial failure it stays in place and the next startup retries. The migration is idempotent and skipped if no legacy collection exists.
Credentials are not migrated. Long-term memories are loaded into every conversation as {properties.*}, so anything copied here reaches template data, prompts and outbound API calls. Two kinds of key stay behind in properties_migrated_v6:
- the keys in
eddi.migration.properties.skip-keys(defaultuserInfo). In 5.x,userInfowas the caller's per-request identity: a platform bearer token next to names and ids, not a memory; - any key whose value holds a credential at any depth, judged by the same rules the agent export uses to redact secrets. The check is deliberately cautious: a long, random-looking identifier can be caught too. Its value stays in the backup collection, and an operator can restore it by hand.
Skipped keys are logged by name and count, never by value, and they don't count as failures. Before this rule, the migration copied userInfo across. A database that already ran it can be cleaned up with:
db.usermemories.deleteMany({ category: "legacy", key: "userInfo" })Once the migration is verified, consider dropping properties_migrated_v6, since it keeps the legacy values, credentials included.
Note: PostgreSQL deployments do not need migration — the
propertiestable only existed in MongoDB (v5).
Each memory entry contains:
{
"id": "ObjectId",
"userId": "user-123",
"key": "preferred_language",
"value": "German",
"category": "preference",
"visibility": "global",
"sourceAgentId": "agent-456",
"groupIds": ["group-1"],
"sourceConversationId": "conv-789",
"conflicted": false,
"accessCount": 12,
"createdAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-03-29T14:22:00Z"
}