Known Issues & Bugs

On this page

Every item below was found through static analysis of the actual memory source tree (not assumed from docstrings) and, where practical, reproduced with a short script. Severity is rated by how easily an ordinary user of the public API would trigger it.

🔴 High severity — crashes in ordinary use

ForgettingMechanism.apply() crashes run_maintenance() on an empty WorkingMemory

Python · 8 lines
def apply(self, entries):
    ...
    if len(entries) != 0:
        ...
        return alive, forgotten
    else:
        logger.debug("No entries to apply forgetting mechanism to.")
        return "entries is empty"   # <-- string, not a 2-tuple

AIMemoryManager.run_maintenance() does alive, forgotten = self._forgetter.apply(wm_entries) unconditionally. Any tenant whose WorkingMemory is currently empty (the default state until build_context() has been called at least once) causes:

ValueError: too many values to unpack (expected 2)

Reproduced:

Python · 4 lines
from zerda.memory.intelligent.forgetting_mechanism import ForgettingMechanism
fm = ForgettingMechanism()
alive, forgotten = fm.apply([])
# ValueError: too many values to unpack (expected 2)

Workaround: Check bundle.working.get_all() (or mgr.stats(tenant_id)["working"]["entries"]) is non-empty before calling run_maintenance(), or catch ValueError around the call, until this is patched upstream.

Affects: ForgettingMechanism, AIMemoryManager.run_maintenance.


from zerda.memory import * raises AttributeError

Both modules list 'memory_config' in __all__ but never bind a name called memory_config anywhere in the module (only the MemoryConfig class is imported). Python's import * machinery reads __all__ and looks up each name via getattr(module, name), which fails here.

Reproduced:

Python · 2 lines
exec("from zerda.memory import *")
# AttributeError: module 'memory' has no attribute 'memory_config'

Workaround: Import explicit names instead of using from zerda.memory import * (from zerda.memory import MemoryConfig, AIMemoryManager, ...), which sidesteps __all__ entirely.

Affects: the entire top-level package and memory.core.


get_messages(limit=N) crashes on ConversationBufferWindowMemory

Python · 4 lines
def get_messages(self, limit: int = None):
    if limit:
        return self.chat_memory[-limit:]   # deque doesn't support slicing
    return self.chat_memory.copy()

ConversationBufferWindowMemory.chat_memory is a collections.deque, which does not support slice indexing in Python. Calling get_messages(limit=3) (or any truthy limit) on a window-memory instance raises:

TypeError: sequence index must be integer, not 'slice'

get_messages() with no limit works (falls through to .copy(), which deque supports — though note deque.copy() returns another deque, so mem.get_messages()[-2:] also raises the same error).

Workaround: Convert to a list first: list(mem.get_messages())[-n:].

Affects: ConversationBufferWindowMemory.


AIMemoryManager.build_prompt() relies on a brittle string .replace()

Python · 6 lines
return self._context_builder.build_prompt(
    query, memories=None, system_instruction=system_instruction, user_profile_text=None,
).replace(
    "=== Current Query ===\n" + query,
    ctx.full_text + "\n\n=== Current Query ===\n" + query,
)

This calls ContextBuilder.build_prompt() a second, redundant time purely to get wrapper text, then splices in the real context via string replacement. If query happens to contain the literal substring "=== Current Query ===", or if ContextBuilder's template text ever changes independently, this breaks silently (no exception — the replacement simply won't happen or will happen at the wrong location).

Affects: AIMemoryManager.build_prompt. Prefer build_context() + your own prompt assembly.

🟠 Medium severity — silent incorrect behavior

Encrypted entries are not actually filtered out of build_context's history

build_context() filters STM entries with not e.metadata.get("encrypted") before including them in history, but save_interaction() never sets metadata["encrypted"] — it wraps the content as {"encrypted": ciphertext} instead. The filter checks the wrong location and never actually excludes anything; an encrypted entry's ciphertext dict is included in history as opaque text embedded directly in the assembled prompt.

Affects: AIMemoryManager.save_interaction / build_context.


Encryption defeats semantic search for that tenant

When encryption_key is configured, entry.content is {"encrypted": ciphertext} by the time it reaches SemanticMemory.add(). _entry_to_text() has no special case for this shape, so it stringifies the ciphertext dict and embeds that — the resulting "semantic" vector carries no relationship to the original meaning. SemanticMemory.search() will still run and return results, but with no real semantic signal for encrypted tenants.

Affects: AIMemoryManager, SemanticMemory.


Access control fails open by default in AIMemoryManager

Python · 9 lines
def _check(self, user_id, tenant_id, perm):
    try:
        self._access_ctrl.require(user_id, tenant_id, perm)
    except PermissionError:
        perms = self._access_ctrl.list_permissions(user_id, tenant_id)
        if not perms:
            pass  # Open by default
        else:
            raise

A user with zero roles assigned for a tenant is granted access regardless of the permission requested. Only a user with some role that lacks the specific permission is actually denied. AccessController.require() itself is correctly deny-by-default in isolation — the fail-open behavior is introduced by this wrapper.

Affects: AIMemoryManager, AccessController. If you need real enforcement, call grant() explicitly for every user.


Compounding decay on repeated maintenance runs

Both methods compute a decay multiplier from elapsed time since last access, then multiply it onto the entry's current (already-decayed) importance rather than the original importance. Calling either method twice in quick succession — without last_access changing in between — decays the entry roughly twice as much as a single correctly-computed decay for that time window would.

Affects: LongTermMemory.apply_global_decay, ForgettingMechanism, and transitively AIMemoryManager.run_maintenance() if called frequently.


MemorySelector's token-budget loop skips rather than stops (docstring mismatch)

select()'s docstring says it returns "the top_k that fit the token budget." _apply_token_budget() actually uses continue (skip and keep checking smaller, lower-ranked candidates) rather than break (stop at the first oversized candidate) when an entry doesn't fit. WorkingMemory.load(), immediately downstream in the same pipeline, uses break instead — two different greedy strategies for conceptually the same operation, coexisting one call apart.

Affects: MemorySelector, WorkingMemory.


Hardcoded Arabic prefix in ConversationEntityMemory output

load_memory_variables() always prefixes retrieved entity context with the literal Arabic string "معلومات سابقة عن {entity}:", regardless of the lang constructor parameter (which only affects the stanza NER fallback path, not this output formatting).

Affects: ConversationEntityMemory.


default_capacity and other class-level config defaults ignore your actual MemoryConfig instance

Both modules do from ..core import MemoryConfig as memory_config (or from .config import MemoryConfig as memory_config) — importing the class, aliased with a lowercase name that reads like an instance — then read attributes like memory_config.max_short_term or memory_config.max_token_limit as class-level defaults. Because these are dataclass fields with plain (non-default_factory) defaults, this doesn't crash, but it means these particular defaults are always the hardcoded dataclass default (e.g. 1000, 2000), never whatever you've actually configured in a MemoryConfig instance elsewhere in your application.

Affects: MemoryType.default_capacity, ConversationSummaryMemory's max_token_limit default.

🟡 Low severity — edge cases and design inconsistencies

Zero as a limit/n argument returns everything, not nothing

get_recent(n)/get_recent_contexts(limit) both do items[-n:] guarded by n < len(items). For n=0 (or limit=0), the guard is True (assuming non-empty items), and Python's items[-0:] is equivalent to items[0:] — the entire list, not an empty one, since -0 == 0.

Affects: ShortTermMemory.get_recent, Entity.get_recent_contexts.


WorkingMemory does not validate capacity at construction

WorkingMemory(capacity=0) (or negative) is accepted silently at construction. The first call to add() then raises ValueError from an internal min() call over an empty dict — a confusing, indirect failure mode compared to ShortTermMemory's immediate, clear ValueError at construction time for the analogous window_size < 1 case.

Affects: WorkingMemory.


BaseRetriever references methods it never declares

BaseRetriever.asave_context calls self.save_context, and __aexit__ calls self.clear() — but BaseRetriever declares neither save_context nor clear (those belong to the separate BaseMemory interface). Any subclass implementing only the two documented abstract methods (add_documents, get_relevant_documents) will raise AttributeError if asave_context/aload_memory_variables/the async context-manager exit path is ever used. No class in this source tree subclasses BaseRetriever, so this hasn't manifested in shipped code, but it's a trap for anyone implementing this interface as documented.

Affects: BaseRetriever.


Async methods live on the wrong base class, and are duplicated per subclass

BaseMemory (the interface all four conversation-memory classes implement) declares no async methods. BaseRetriever (an unrelated interface with zero implementations in this source tree) declares asave_context/aload_memory_variables. Every concrete ConversationXMemory class independently re-implements an identical pair of these two methods at the bottom of its own file, rather than inheriting them from a shared location. A new custom BaseMemory subclass gets no async support unless you copy this boilerplate yourself.

Affects: BaseMemory, all Conversation Memories.


MemoryConfig.to_dict() only serializes a third of the fields

to_dict() returns 10 of the 32 declared fields. Use dataclasses.asdict(config) if you need a complete snapshot.

Affects: MemoryConfig.


Three different decay formulas coexist in the package

  • MemoryEntry.apply_decay() — linear decay based on days since last access.
  • LongTermMemory.apply_global_decay() — linear-per-call decay, applied multiplicatively onto current importance (see the compounding issue above).
  • ForgettingMechanism._decay_entry() — exponential decay with an access-frequency-based rate reduction ("spaced repetition"), also applied multiplicatively onto current importance.

None of these three are cross-consistent — an entry decayed via one formula and then via another will not behave as if a single coherent decay model were in effect across its lifetime.

Affects: MemoryEntry, LongTermMemory, ForgettingMechanism.


ChatMessage is exported but never used internally

Exported from both memory.core and the top-level memory package, but no built-in class in this source tree ever constructs or reads a ChatMessage — all four conversation memories use plain dicts internally. Available for your own external use, but disconnected from the rest of the package's data flow.

Affects: ChatMessage.


MemoryEntry.to_dict()/from_dict() drop embedding by default

to_dict() excludes embedding unless include_embedding=True is passed explicitly; from_dict()/from_json() never restore it regardless. Round-tripping an entry through JSON silently loses any computed embedding.

Affects: MemoryEntry.


MemoryConfig.from_env() only reads five of 32 fields, with one default mismatch

Only MEMORY_MAX_SHORT_TERM, MEMORY_MAX_LONG_TERM, MEMORY_EMBEDDING_MODEL, MEMORY_PERSISTENCE_PATH, MEMORY_ENABLE_PERSISTENCE are read; all other fields silently take their dataclass defaults. Additionally, MEMORY_MAX_SHORT_TERM's own fallback-if-unset is 100, not 1000 (the dataclass field's own default) — so from_env() with no environment variables set produces a different max_short_term than MemoryConfig() does.

Affects: MemoryConfig.

Not a bug, but worth knowing

  • MemoryConfig.max_procedral is a typo for "procedural," used consistently across config and memory_type — internally consistent, but a public-facing misspelling if you construct MemoryConfig(max_procedral=...) yourself.
  • SemanticMemory falls back to deterministic-random ("stub") embeddings if sentence-transformers isn't installed or the configured model isn't locally cached (local_files_only=True disables auto-download). Search still runs and returns scores in this mode, but they carry no real semantic signal. See SemanticMemory.
  • MemoryEncryptor silently uses base64 (not real encryption) if cryptography isn't installed, or if you never pass a secret_key at all (the latter case logs no warning). Check .is_real_encryption explicitly. See MemoryEncryptor.
  • SensitiveDataMasker's regex patterns (especially CREDIT_CARD, IBAN, PHONE_INTL) are broad heuristics with real false-positive potential on unrelated numeric/alphanumeric strings — evaluate against your own data before relying on them for compliance. See SensitiveDataMasker.

How this list was produced

Every item above was confirmed by direct inspection of the source in this archive (not inferred from naming or docstrings alone), and the high-severity items were additionally reproduced by executing the relevant code path in a sandboxed Python environment against this exact source tree. If you're reading this documentation against a newer version of the package, re-verify before assuming these still apply.

Related pages