{"slug": "agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update", "title": "Agent memory: the FTS5 trigger bug that silently killed every update", "summary": "A developer building Soma, an agent-memory product exposed via an MCP endpoint, found that the canonical SQLite FTS5 update trigger silently broke every UPDATE on the facts table when the index used the trigram tokenizer, raising a bare 'SQL logic error' with no column or hint. The bug went undetected for two weeks because memory quality was measured with a single recall@3 metric over ten bilingual questions, so the advertised 'facts are updated, not duplicated' feature had never actually run. The fix replaces the delete-then-insert trigger with INSERT OR REPLACE and drops the old trigger on init, and the team now tests memory by ability — extraction, cross-session, temporal, supersede and abstention — modeled on LongMemEval-V2.", "body_md": "Your agent's memory is an archive until you can update it. Ours was an archive for two weeks, and nothing in the logs said so.\n\nWe store facts in SQLite with an FTS5 index. The tokenizer is `trigram`, because it handles Russian morphology without a stemmer: \"подпись\" and \"подписан\" share trigrams, and we get recall without shipping a dictionary.\n\nThe index is kept in sync by triggers:\n\n```\nCREATE TRIGGER facts_au AFTER UPDATE ON facts BEGIN\n  INSERT INTO facts_fts(facts_fts, rowid, text, tags) VALUES('delete', old.id, old.text, old.tags);\n  INSERT INTO facts_fts(rowid, text, tags) VALUES (new.id, new.text, new.tags);\nEND;\n```\n\nThat is the canonical FTS5 recipe from the docs, and it is wrong for `trigram`. Any `UPDATE` on `facts` failed with a bare:\n\n```\nsqlite3.OperationalError: SQL logic error\n```\n\nNo column, no hint. It took a deliberately written test to find it: we asked the memory to supersede an old price with a new one and the call raised. Which means the feature we advertised — \"facts are updated, not duplicated\" — had never once run.\n\n```\nDROP TRIGGER IF EXISTS facts_au;\nCREATE TRIGGER facts_au AFTER UPDATE ON facts BEGIN\n  INSERT OR REPLACE INTO facts_fts(rowid, text, tags) VALUES (new.id, new.text, new.tags);\nEND;\n```\n\n`INSERT OR REPLACE` works with trigram. The `DROP` matters as much as the new body: databases already on disk carry the old broken trigger, and `CREATE TRIGGER IF NOT EXISTS` would happily leave it in place. Init now heals existing files.\n\nThe lesson is not \"use REPLACE\". The lesson is that a single ranking metric hid a dead write path for two weeks.\n\nWe used to measure memory with one number: `recall@3` over ten bilingual questions. Ten green checks, and a feature that crashed on first use.\n\nLongMemEval-V2 (MIT) separates memory into abilities. We copied the shape, not the dataset, and wrote one test group per ability:\n\n| Ability | The question it answers | What breaks without it | \n|---|---|---|\n| Extraction | is the fact found at all? | memory is a write-only log | \n| Cross-session | does a fact from last week surface today? | each session starts blind | \n| Temporal | does a time window exclude the old value? | stale facts look current | \n| Supersede | does the new fact replace the old one? | contradictions pile up | \n| Abstention | does it return nothing when it knows nothing? | confident invention | \n\nThe last one is the one nobody tests. A memory that always returns its top-3 will always look useful — and will feed the model a plausible neighbour of the question instead of an honest \"I don't know\". Our test asserts that a question about an office in Tokyo returns no fact containing \"Tokyo\", not merely a low score.\n\n``` python\ndef test_superseded_fact_is_not_recalled(tmp_path):\n    m = Memory(path=str(tmp_path / \"m.db\"))\n    old = m.remember(\"Soma costs 19 USD per month.\", tags=\"pricing\")\n    m.supersede(old, \"Soma costs 49 USD per month.\", tags=\"pricing\")\n    hits = [r[\"text\"] for r in m.recall(\"how much does soma cost\", k=3)]\n    assert any(\"49\" in t for t in hits)\n    assert all(\"19\" not in t for t in hits)      # the dead path shows up here\n```\n\nRun against the broken trigger, this fails with `SQL logic error`. Run against the fix, it passes. That is the whole difference between a benchmark and a decoration.\n\nBecause our users write in Russian and English in the same memory. A word-based tokenizer splits \"подписан\" and \"подпись\" into different tokens and recall silently drops. Trigram costs index size and gives morphology for free. The trade-off is real: it also made an obscure FTS5 incompatibility reachable, which a word tokenizer would have hidden.\n\nIf you run FTS5 with `trigram`, check your update trigger today. The failure is silent until something tries to change a row.\n\nWe build Soma: envelopes with budget, mandate and signature, plus memory that keeps its word — facts, obligations and signed receipts behind one MCP endpoint. If your agent memory passed a single recall test and you have never updated a fact in it, you may have the same two weeks we had.", "url": "https://wpnews.pro/news/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update", "canonical_source": "https://dev.to/mrpkk/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update-5ah2", "published_at": "2026-10-23 22:18:18+00:00", "updated_at": "2026-10-23 22:39:59.700079+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "ai-infrastructure", "mlops", "developer-tools"], "entities": ["Soma", "SQLite", "FTS5", "LongMemEval-V2", "MCP"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update", "markdown": "https://wpnews.pro/news/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update.md", "text": "https://wpnews.pro/news/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update.txt", "jsonld": "https://wpnews.pro/news/agent-memory-the-fts5-trigger-bug-that-silently-killed-every-update.jsonld"}}