Skip to content

Latest commit

 

History

History
378 lines (289 loc) · 23.8 KB

File metadata and controls

378 lines (289 loc) · 23.8 KB

Persistent User Memory

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.

Overview

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)

Architecture

┌─────────────────────────────────────────────────────┐
│                   Conversation Pipeline              │
│                                                      │
│  LLM ──→ UserMemoryTool ──→ IUserMemoryStore        │
│            ↑                       ↑                 │
│            │                       │                 │
│      AgentOrchestrator     MongoUserMemoryStore      │
│      (per-invocation)      PostgresUserMemoryStore   │
│                                                      │
│  REST API ───────────────────→ IUserMemoryStore      │
│  MCP Tools ──────────────────→ IUserMemoryStore      │
│  DreamService (background) ─→ IUserMemoryStore      │
└─────────────────────────────────────────────────────┘

Agent Configuration

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: enableMemoryTools gates exactly one thing: attaching the LLM UserMemoryTool (and with it the guardrails, which only that tool enforces). Everything else in userMemoryConfig applies whenever the block is declared, with or without the flag — defaultVisibility and the recall settings govern the longTerm property path that every agent uses, and Dream is switched on by dream.enabled plus a schedule. Earlier releases ignored the whole block unless enableMemoryTools was on, so a rule-based agent's "defaultVisibility": "self" silently persisted as global.

Configuration Reference

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

Guardrails

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

Dream Configuration

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.

LLM Tools

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:

rememberFact

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]"

recallMemories

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]

searchMemory

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

forgetFact

Delete a specific memory by key.

Parameters:
  key - The key name to forget (e.g. "favorite_color")

Returns: "✅ Forgotten: favorite_color"

Visibility Scopes

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.)

Group Memory

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.

REST API

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

Write validation

REST PUT and MCP upsert_user_memory share one rule set (UserMemoryWriteRules) and answer 400 / an error instead of storing:

  • userId, key and visibility are required; keys are at most 255 characters and values at most 64 KiB of JSON;
  • self and group entries need a sourceAgentId (MCP: agentId), and group entries need groupIds — an entry without them could never be read by anyone;
  • category must be one of preference, fact, context, legacy, property; an absent category is stored as fact.

Example: Upsert a memory

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"
  }'

Example: Get visible memories

curl "http://localhost:7070/usermemorystore/memories/user-123/visible?agentId=agent-456&order=most_recent&limit=20"

MCP Tools

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)

GDPR Compliance

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.

Dream Consolidation

The Dream service performs background maintenance on user memories:

  1. Stale Pruning — Removes entries whose updatedAt (last write) is older than pruneStaleAfterDays days. Recalling an entry does not refresh updatedAt, so a fact read in every conversation is still pruned if nothing has rewritten it. This is a deterministic operation with zero LLM cost.

  2. Contradiction Detection — Identifies entries with the same key but different values (e.g., language=English from Agent A vs language=German from 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.

  3. Interaction Summarization — When summarizeInteractions=true, compresses multiple related facts into consolidated summaries using the configured LLM. Entries are grouped by the summarizeGroupBy strategy (per-category or all together), and each group above summarizeMinEntries is distilled toward summarizeTargetEntries entries. 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.

Scheduling a Dream Cycle

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.

Metrics

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

Migration from Legacy Properties

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

Backward Compatibility

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.

Startup Migration (MongoDB only)

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 (default userInfo). In 5.x, userInfo was 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 properties table only existed in MongoDB (v5).

Data Model

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"
}