Giving an AI Agent Persistent Memory Without Making It Creepy
The landing page for Simmr says “A Chef That Learns You.” For a while, that was aspirational. The agent could look up your pantry, check your dietary restrictions, and generate recipes, but it forgot everything between conversations. If you told it you hated cilantro on Monday, it would suggest pico de gallo on Tuesday.
Conceptually, the fix was simple: give the agent tools to save and recall preferences. The implementation had more edges than I expected. This post covers what I built, what broke, and which design decisions held up.
The naive version
The first attempt was a single text blob. After each conversation, the agent would summarize what it learned about the user and append it to a soft_memory field. The prompt included this summary as context.
It worked for about a week. Then the problems started:
- Preferences contradicted each other. “Likes spicy food” sat next to “prefers mild flavors” because the user’s mood changed between sessions.
- Old information never got pruned. A passing comment about trying sushi became a permanent preference.
- The model could not distinguish between something the user stated explicitly and something it inferred from a single recipe request.
- Users had no visibility into what the agent “knew” about them, and no way to correct it.
The soft memory blob was a write-only log masquerading as a knowledge base.
What replaced it
I scrapped the blob and built a normalized memory system with three properties I wanted from the start: individual items with provenance, a trust hierarchy, and hard capacity limits.
Individual items
Each preference is a discrete record:
class MemoryItem:
category: str # "cuisine", "abstract_like", "abstract_dislike", etc.
value: str # "Thai food"
value_normalized: str # "thai food" (for deduplication)
asserted_by: str # "inferred" | "user_explicit" | "user_confirmed"
confidence: float # 0.0 - 1.0
evidence_snippet: str # "I love Thai food"
status: str # "active" | "deleted"
Five categories cover what matters for cooking: cuisines, cooking styles, dietary goals, likes, and dislikes. Each item stores the exact user quote that triggered it and the conversation it came from, even if that provenance is not fully exposed in the UI yet.
Trust hierarchy
Not all preferences are equal. The system tracks three levels:
- Inferred — the agent deduced something from context. Lowest trust, easiest to replace.
- User explicit — the user made a direct statement. “I hate cilantro” is explicit. “Maybe I should try more vegetables” is not.
- User confirmed — the user went to their settings page and confirmed a preference. Highest trust, protected from agent deletion.
Trust only moves upward. If the agent inferred you like Italian food and you later say “I love Italian food,” the item upgrades to explicit. If you confirm it in settings, it becomes confirmed. The agent cannot downgrade trust.
Trust controls eviction order and what the agent is allowed to delete. Inferred items go before explicit ones, and confirmed items are protected. A user who says “forget that I like Thai food” in chat can remove an explicit preference. But a confirmed preference requires the settings page.
Capacity limits
Unbounded memory is a problem. The model gets worse with more context, and users who chat frequently would accumulate noise. So the system has hard caps:
- 50 items per user
- 15 items per category
- 3 saves per agent turn
The per-turn limit prevents the model from dumping a batch of inferences after a long conversation. The per-category limit prevents any one dimension from drowning out the others — you might love 30 cuisines, but the agent only remembers your top 15.
When a cap is reached, the system replaces the oldest item with the lowest trust. If every item in the category has higher trust than the incoming one, the save fails gracefully. The agent gets a message explaining why and can tell the user.
The save tool
The agent saves preferences through a tool called save_user_memory. The model calls it when it detects an explicit preference statement:
class SaveUserMemoryArgs(BaseModel):
category: Literal["cuisine", "cooking_style", "dietary_goal",
"abstract_like", "abstract_dislike"]
value: str # 2-100 chars, max 8 words
evidence: str | None # The user's exact words
Before anything gets saved, the tool checks a few things:
- Is memory learning enabled? Users can flip a toggle in settings to disable learning entirely. If it is off, the tool returns early.
- Under rate limit? If 3 saves already happened this turn, reject.
- Suspicious or malformed value? Reject anything that looks like a password, email, API key, SSN, or URL, and enforce word and character limits.
If the value already exists (by normalized match), the tool upgrades its trust level and confidence instead of creating a duplicate. If it was previously soft-deleted, it gets reactivated. The normalization does a bit more than lowercase: it strips punctuation, collapses whitespace, converts hyphens like “one-pot” to spaces, and removes suffixes like “food” or “meals” so “Thai food” and “thai” collapse to the same value.
The delete tool
Deletion uses a separate tool called forget_user_memory. The user says “forget that I like Thai food” and the agent calls:
class ForgetUserMemoryArgs(BaseModel):
category: str | None # Optional narrowing
value: str | None # Fuzzy match on the value
item_id: int | None # Or exact ID
The tool searches by value (case-insensitive) and soft-deletes the match. It sets status = "deleted" rather than removing the row. This preserves the audit trail and makes reactivation possible if the user changes their mind.
The key guardrail: the tool refuses to delete user_confirmed items. If a user went to their settings and confirmed a preference, the agent cannot remove it through conversation. They have to go back to settings. This prevents a scenario where the agent misinterprets a statement and erases something the user explicitly validated.
How memory reaches the prompt
On each conversation turn, the system loads the user’s active memory items and builds a snapshot:
### What I've learned about you
(These are my observations - correct me if I'm wrong!)
Favorite cuisines: Thai, Italian
Likes: bold flavors, one-pot meals
Avoids: cilantro, complicated recipes
Goals: more protein, less carbs
The framing matters. “These are my observations” sets the expectation that the agent is fallible. “Correct me if I’m wrong” invites the user to fix mistakes.
The snapshot is rebuilt fresh per request from active items in the database. There is no cache to go stale. If the user deletes a preference in settings between two messages, it disappears from the next prompt immediately.
The settings page
Users need visibility into what the agent knows. The memory tab in settings shows every learned preference grouped by category, with visual indicators for trust level:
- A sparkle icon for explicit preferences (agent learned from conversation)
- A checkmark for confirmed preferences (user validated in settings)
- No icon for inferred preferences
Each item has two actions: confirm (upgrade to confirmed) and delete (soft-delete with a confirmation dialog). Confirming an item protects it from agent deletion and signals that the preference is stable. Deleting removes it from future prompts.
There is a learning toggle at the top. Turning it off stops the agent from saving new preferences. Existing preferences remain visible and active — the agent still uses them for context — but it will not add new ones. This is the escape hatch for users who want the personalization benefit from what the agent already knows without it continuing to learn.
What went wrong along the way
The agent saved too eagerly
Early on, the prompt instructions for when to save were too loose. The agent would infer preferences from recipe requests — “you asked for a Thai recipe, so you must like Thai food” — and save them. After three conversations, users had a dozen inferred preferences, most of which were noise from one-off requests.
The fix was strict prompt rules: only save from explicit first-person statements. “I love garlic” triggers a save. “Make me something with garlic” does not. “My friend is vegetarian” does not. “Maybe I should try more fish” does not.
The blob and the items coexisted awkwardly
During migration, both the old soft memory blob and the new normalized items existed in the system. The prompt included both, which meant the model sometimes saw contradictions between the two sources. An old blob might say “likes mild food” while a newer item said “likes spicy food.”
I kept both systems running during the transition but gave the normalized items priority in the prompt. The blob now serves as a fallback summary for users who have not had enough conversations to build up normalized preferences.
Category design was harder than expected
The original categories were too granular: “ingredient_like”, “ingredient_dislike”, “cuisine_like”, “cuisine_dislike”, “cooking_method”, “meal_time_preference”, and so on. The agent struggled to pick the right category, and users did not think in those terms. Nobody says “save a cuisine like” — they say “I love Thai food.”
I collapsed them into five categories that map to how people actually talk about food: cuisines, cooking styles, dietary goals, likes, and dislikes. The agent makes better decisions with fewer options, and the settings page is easier to understand.
The end-to-end flow
When a user says “I hate cilantro,” the agent recognizes an explicit dislike, calls save_user_memory, and stores “cilantro” as an abstract_dislike with the user’s quote as evidence. On the next turn, the prompt snapshot includes “Avoids: cilantro,” so the agent can avoid it in future recipes.
The user can then go to settings, see “cilantro” under “Things to Avoid” with the sparkle icon, and either confirm it (upgrade to protected) or delete it.
What I would do differently
Start with the settings page, not the save tool. I built the agent-side save mechanism first and the user-facing settings page second. That meant for a few weeks, the agent was saving preferences that users could not see or manage. Building the transparency layer first would have made the feature feel safer from day one.
Fewer categories sooner. The granular category system wasted a month of iteration. Five categories was always the right answer — I just did not trust the model to handle the ambiguity. It handled it fine.
Skip the blob entirely. The soft memory migration was unnecessary complexity. If I were starting over, I would go straight to normalized items with no legacy path.
The trust model is the feature
The most important design decision was not the save tool or the prompt engineering. It was the three-tier trust hierarchy and the rule that trust only moves upward. This creates a system where the agent can learn freely at low trust, the user’s explicit statements carry weight, and confirmed preferences are permanent until the user decides otherwise.
The difference is whether the user can see, correct, and control what the system remembers.