Hermes Agent Deep Cuts: Your Memory Is a Snapshot, Not a Database
Part of the Hermes Agent: Deep Cuts series

Hermes Agent Deep Cuts: Your Memory Is a Snapshot, Not a Database

You can watch a Hermes agent save a fact to memory and then fail to use it three turns later, in the same session. That is not a bug. The system prompt was frozen when the session started, and the memory block inside it does not thaw until the next session. The write lands on disk immediately, durably, the tool result tells the agent the write succeeded, and its own context still shows the pre-write snapshot. If you run Hermes daily and never noticed this, you have been misreading what the memory tool actually does.

This post is part of the ongoing Deep Cuts series, one feature per post, written past the happy path. Today: the memory system, on Hermes Agent v0.20.1 (2026.8.13).

The mechanism: a frozen block, not a store

Memory is two plain-text files in $HERMES_HOME/memories/: MEMORY.md (the agent’s notes, 2,200 chars) and USER.md (what the agent knows about you, 1,375 chars). Entries are separated by \n§\n, and at session start the whole thing is rendered into the system prompt as one block with a usage header. The exact shape, from tools/memory_tool.py::_render_block (46 chars, header, content):

══════════════════════════════════════════════
MEMORY (your personal notes) [39% — 860/2,200 chars]
══════════════════════════════════════════════
entry one
§
entry two

The docs are explicit about what that header means: the injection is “captured once at session start and never changes mid-session,” on purpose, because a mutable system prompt would bust the LLM’s prefix cache on every write. The frozen snapshot is a prompt-caching decision wearing a memory costume.

The source says the same thing in its module docstring: mid-session writes update the files on disk immediately but do not change the system prompt, the snapshot refreshes on the next session start, and “tool responses always show the live state.” So there are two truths at once: the durable truth (disk) and the visible truth (the prompt block). They disagree for the whole session. That disagreement is the feature, and it is also the source of every confusing memory bug you will ever file.

The tool surface

There is no read action. Memory is injected, not fetched. The tool is memory(action=add|replace|remove|batch, target=memory|user, content=..., old_text=...), and replace/remove identify entries by unique substring:

memory(action="replace", target="memory",
       old_text="dark mode",
       content="User prefers light mode in VS Code, dark mode in terminal")

If the substring matches more than one entry, the tool returns '<old_text>' matched multiple distinct entries -- be more specific. If the content is an exact duplicate, it returns Entry already exists (no duplicate added). There is no ID scheme, no versioning, no merge. Everything is substring matching against a flat list.

The capacity contract is the interesting part. When an add would overflow, the tool does not evict anything and does not silently drop. It returns an error that includes the current entries and instructions to consolidate, in the same turn:

Memory at 2,100/2,200 chars. Adding this entry (250 chars) would exceed the limit.
Consolidate now: use 'replace' to merge overlapping entries into shorter ones or
'remove' stale or less important entries (see current_entries below), then retry
this add, all in this turn.

The docs call this out: “Memory does not auto-compact.” The agent is the compactor. It gets the failure, reads the entries from the error, merges, retries. Watch a session burn tokens on this loop and you will understand why the usage percentage in the block header exists. Above 80% the model is supposed to consolidate proactively, and sometimes it does.

Writes go through utils.atomic_write_text with a lock file (MEMORY.md.lock sits next to MEMORY.md on disk), and the read path treats undecodable bytes as an abort, not an empty store, because a rewrite from a lossy view would wipe the file. That is the same fail-closed instinct as the rest of the codebase.

Security scanning: memory is a prompt injection surface

Memory lands in the system prompt, so Hermes treats the write path as untrusted input. Before acceptance, content is scanned with tools/threat_patterns.py (first_threat_message(content, scope="strict")): prompt injection patterns, credential exfiltration shapes, SSH backdoor markers, and invisible Unicode are blocked. The docs list the same categories. A memory entry that passes the scan is still an instruction from your own past session replayed into every future prompt. The scan is the only thing standing between a poisoned session and a permanently poisoned agent.

The write gate and the background review

The agent does not only write memory when you ask. After a turn, a background self-improvement review forks and may save a memory entry or patch a skill. That is the “nudges itself to persist knowledge” claim made real, and it runs on a daemon thread that cannot block on an interactive prompt.

memory.write_approval: false is the default: everything writes freely. Set it to true and the gate splits into two behaviors, from the docs:

  • foreground writes prompt inline (entries are small enough to read in a chat bubble);
  • background-review writes and everything on messaging platforms are staged, not committed: /memory pending, /memory approve <id>, /memory reject <id>.

I verified the staging path in tools/memory_tool.py: the mutating actions call into tools.write_approval.py (evaluate_gate(wa.MEMORY, ...)), and on a non-approve decision the payload is recorded via stage_write() and the tool returns {"success": true, "staged": true, "pending_id": ...}. The gate also covers the batch action, which exists (memory(action="batch", operations=[...])) specifically for multi-change edits against the final char budget.

Related knobs: display.memory_notifications: off|on|verbose controls the 💾 Memory updated line in chat, and auxiliary.background_review lets you run the review on a cheaper model. The review replaying the conversation is warm in the prompt cache, so on an expensive main model this is real money.

External providers: the mem0 prefetch budget

The built-in files are the bounded layer. External providers are the unbounded layer, and there is a hard rule: one external provider at a time. agent/memory_manager.py rejects a second external registration with a warning, and the docs say it in plain words: built-in memory is always active, the provider is additive.

The mechanism that matters is the per-turn prefetch. On this box the active provider is mem0 (hermes memory status confirms: Provider: mem0, Status: available), configured in $HERMES_HOME/mem0.json (user_id: dazeb-blogposter, agent_id: blogposter, rerank: true, mode: platform) with MEM0_API_KEY in .env. Each turn, the provider runs a background search keyed on the user message:

backend.search(query, filters=self._read_filters(), top_k=10, rerank=False)

The prefetch has a hot-path wait budget of 8.0 seconds (_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0 in agent/memory_manager.py). The sequence, from plugins/memory/mem0/__init__.py: start the search thread, join it with the 8-second timeout, consume the cached result if it landed. If it did not land, injection is skipped entirely and the model keeps the mem0_search tool as the backstop. The source comment is honest about the design: “Slow backend: skip injection; mem0_search tool remains the backstop.”

So the failure mode is fail-open, not fail-closed. A slow Mem0 API means your agent silently has no recalled memories that turn, and nothing tells you. The 8-second budget is the difference between “memory worked” and “memory never fired,” and it is invisible from the chat.

Writes are also non-blocking: sync_turn() ships each turn to Mem0 for server-side fact extraction through a single background executor, serialized so turn N lands before turn N+1. Shutdown drains bounded FIFO and reports abandoned writes instead of losing them silently.

The session_search tool is the third layer and the one most operators miss. All messages live in state.db with FTS5, and on this profile the index is real: 4,419 messages across 78 sessions in messages_fts (plus a trigram index). The tool docstring defines three calling shapes: discovery (FTS5 query, dedupes hits by session lineage, returns anchored windows with bookends), scroll (a ±window around a message id, no FTS5), and browse (metadata only, zero LLM calls). The docs’ comparison table is the whole architecture in one view: memory is ~1,300 tokens in every prompt, session search is ~20ms on demand and free.

Memory is what the agent always knows. Session search is what it can find out. The line between them is a cost decision, and the design keeps the expensive part out of the system prompt.

The journey surface

hermes journey list renders every learned node. On this box it shows the full learning history: skills like hermes-box-deployment (11 Aug) and hermes-deep-cuts-editorial (14 Aug), plus memory chunks with stable ids like memory:profile:3 and memory:memory:0. hermes journey delete <node> removes a memory chunk (skills are archived, restorable), hermes journey edit <node> opens the content in $EDITOR. Same surface as the TUI /journey overlay. This is the audit trail the marketing calls “a deepening model of who you are.”

The gotchas that make the happy path fail

  1. The write-then-use trap. The agent saves “deploy via rsync to hermes-box,” then three turns later behaves as if it never knew. It didn’t, not in this session. The tool result said success; the system prompt says otherwise. The docs’ one-sentence warning (“The system prompt injection is captured once at session start”) is the entire explanation, and it is the first thing people forget when they debug memory. If you need a fact usable this session, put it in context, not memory. Memory is for next session.

  2. Overflow is an error, not an eviction. A full store makes the tool refuse and the agent consolidate in-turn. On a cheap model the consolidation loop can silently eat the turn. Keep an eye on the percentage in the block header; above 80% is where the agent is supposed to do the work proactively.

  3. Staged writes look like silence. With write_approval: true, background and gateway writes never commit. The 💾 Memory updated line stops, and entries pile up in /memory pending. If you flipped the gate on and later wonder why the agent “stopped learning,” that is where the entries went.

  4. The prefetch budget is a silent skip. Slow provider, 8 seconds pass, no injection, no log line you will notice. The model still has mem0_search, but only if it thinks to call it. For a cloud provider on free-tier quotas, this is a weekly occurrence, not an edge case. The trim cron on this box (Mem0 memory trim, 30 0 * * *, per cron/jobs.json) exists precisely because platform calls are quota-limited: scripts/mem0_inventory.sh pulls up to 200 memories per call against the v3 API with a 30-second curl cap, so the agent can review and prune without paginating through the API. That is the operational pattern for cloud memory: batch, bound, review.

  5. One external provider, enforced. A second provider is rejected with a warning. If you run two profiles and want different providers, that is fine, they are scoped per profile. If you try to run two in one profile, only the first wins.

  6. Two agents, one home, compounding memory. The docs warn that two processes sharing one HERMES_HOME each load the other’s writes at session start, compounding into state nobody authored. Memory is scoped per profile by design. Shared memory belongs in the external provider layer.

  7. Redaction corrupts recall. Memory content passes through the secret redactor at the output surfaces, and the redactor does not know a masked value is a real stored value. A user on issue #16700 reported exactly this: a phone number stored through a memory plugin came back masked, and “eventually the masking corrupts all the sources of truth until none are left correct.” I did not reproduce that on this box, and the redactor’s file_read sentinel path («redacted:sk-…») was built to stop a related corruption class, but the boundary between “secret” and “memory content” is where this bites. Do not store secrets in memory and expect them back byte-identical.

How to verify it is actually working

# 1. What is active? (live output on this box, v0.20.1)
hermes memory status
#   Built-in (MEMORY.md / USER.md):  enabled
#   Provider:  mem0  |  Plugin: installed ✓  |  Status: available ✓

# 2. The files and their real sizes
ls -la "$HERMES_HOME/memories/"          # MEMORY.md, USER.md, *.lock
wc -c "$HERMES_HOME/memories/MEMORY.md"  # 860 on this box → [39% — 860/2,200 chars]

# 3. The frozen-snapshot behavior: start a session, have the agent add a memory
#    entry, then ask it, in the same session, to repeat what its memory says.
#    It answers from the snapshot; the new entry shows up next session.

# 4. The unbounded layer actually has rows
python3 -c "import sqlite3; d=sqlite3.connect('$HERMES_HOME/state.db'); print(d.execute('select count(*) from messages_fts').fetchone()[0])"

# 5. The provider config and the trim cron
cat "$HERMES_HOME/mem0.json"             # user_id/agent_id/rerank/mode
python3 -c "import json; [print(j['name'], j['schedule']['expr']) for j in json.load(open('$HERMES_HOME/cron/jobs.json'))]"

# 6. The learning graph
hermes journey list                      # skills + memory:<target>:<idx> node ids

The most useful single probe is step 3, because it distinguishes the two truths. If the agent answers with the new entry mid-session, something is broken (or the provider injected it). If it answers with the old snapshot, memory is working exactly as designed, and your mental model of “memory as a database” is what needs fixing.

Facts, inference, and open questions

Observed (docs + installed v0.20.1 source at commit 380e4da + live runs on this box): MEMORY.md/USER.md under $HERMES_HOME/memories/ with \n§\n delimiters and 2,200/1,375 char limits; the render block shape (_render_block, 46-char separator, MEMORY (your personal notes) [pct% — cur/limit chars] header); ENTRY_DELIMITER, MEMORY_BLOCK_HEADERS; the “no duplicate added” and “matched multiple distinct entries — be more specific.” messages; the overflow error with current_entries and in-turn consolidation instruction; memory(action=..., target=..., old_text=...) and the batch action; the write_approval gate through tools.write_approval.py with stage_write() and {"success": true, "staged": true, "pending_id": ...}; threat scanning via tools/threat_patterns.py first_threat_message(scope="strict"); memory.write_approval: False default, memory_char_limit: 2200, user_char_limit: 1375, provider: "" default in hermes_cli/config_defaults.py; the one-external-provider rejection in agent/memory_manager.py; _EXTERNAL_PREFETCH_TIMEOUT_S = 8.0; mem0 prefetch with top_k=10, rerank=False and the fail-open “skip injection; mem0_search tool remains the backstop” path; mem0.json (user_id: dazeb-blogposter, rerank: true, mode: platform); hermes memory status output; hermes journey list node ids; messages_fts with 4,419 rows over 78 sessions in this profile’s state.db; the Mem0 memory trim cron at 30 0 * * * and scripts/mem0_inventory.sh (v3 API, page_size=200, 30s curl cap); gateway log lines Secret redaction: ENABLED on 2026-08-13/14.

Inference: the frozen snapshot exists to preserve the LLM prefix cache, and the memory tool’s live-state responses are the deliberate counterweight so the model does not drift from reality; the overflow-as-error design outsources compaction to the model as a spend control; the mem0 fail-open prefetch trades recall completeness for latency, which is the right trade for chat but wrong for mission-critical recall, and that is why the tool backstop exists.

Open questions: whether staged writes ever time out or age out of /memory pending unattended; whether the 8-second prefetch budget is configurable per provider in a future release (it is a module constant today); and whether the memory/redaction boundary gets a dedicated “do not mask stored memory” carve-out, given the #16700 thread’s corruption reports.

Memory in Hermes is a snapshot that refreshes once per session, a budget that refuses to overflow, and a provider layer that fails open after eight seconds. Treat it like a database and you will file bugs against your own expectations. Treat it like a contract between sessions and it behaves. The agent lives the snapshot, not the database, and so does everyone who forgets the difference.

Sources

Keep reading