From 6017b137dc58515940d54676d0585833a38aacc6 Mon Sep 17 00:00:00 2001 From: ly-wang19 Date: Wed, 10 Jun 2026 16:53:13 +0800 Subject: [PATCH] fix(memory): don't crash search on FTS5 query syntax in user/LLM text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MemoryStore.search_facts() passed the raw search string straight to FTS5 `MATCH`, so any text containing FTS5 metasyntax — a stray double-quote, a `key:value` colon, a bare `AND`/`OR`/`NEAR`, or a parenthesis — raised an unhandled sqlite3.OperationalError and crashed the memory tool. The memory `search` action is exposed to the LLM, which routinely emits such queries (e.g. `memory:safe`, `say "hi`, `foo AND bar`). Retry on OperationalError with each whitespace token quoted as a literal phrase (_fts5_safe_query), so the search degrades to a sane literal match instead of crashing. Valid FTS5 queries (including prefix `term*`) are unaffected — the raw query is tried first. Adds tests for the crashing-input set, the colon-query fallback still finding a match, normal/prefix queries unchanged, and the sanitizer helper. --- plugins/memory/holographic/store.py | 26 ++++- .../test_holographic_store_search_fts5.py | 106 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/plugins/memory/test_holographic_store_search_fts5.py diff --git a/plugins/memory/holographic/store.py b/plugins/memory/holographic/store.py index bf58d7a22dbc..ad98c5edc466 100644 --- a/plugins/memory/holographic/store.py +++ b/plugins/memory/holographic/store.py @@ -95,6 +95,19 @@ def _clamp_trust(value: float) -> float: return max(_TRUST_MIN, min(_TRUST_MAX, value)) +def _fts5_safe_query(text: str) -> str: + """Turn free text into a crash-proof FTS5 MATCH query. + + User/LLM search strings routinely contain FTS5 query syntax — a stray + double-quote, a ``key:value`` colon, a bare ``AND``/``OR``/``NEAR``, or a + parenthesis — which otherwise raises ``sqlite3.OperationalError`` from + ``MATCH``. Wrap each whitespace token in double quotes (escaping embedded + quotes) so every term is matched literally and AND-combined. Returns ``""`` + when there are no usable tokens. + """ + return " ".join('"' + tok.replace('"', '""') + '"' for tok in text.split() if tok) + + class MemoryStore: """SQLite-backed fact store with entity resolution and trust scoring.""" @@ -274,7 +287,18 @@ def search_facts( LIMIT ? """ - rows = self._conn.execute(sql, params).fetchall() + try: + rows = self._conn.execute(sql, params).fetchall() + except sqlite3.OperationalError: + # The raw text wasn't a valid FTS5 query (stray quote/colon/ + # bare AND-OR-NEAR/paren). Retry with each token quoted as a + # literal phrase so the search degrades to a sane match instead + # of crashing the memory tool. + safe = _fts5_safe_query(query) + if not safe: + return [] + params[0] = safe + rows = self._conn.execute(sql, params).fetchall() results = [self._row_to_dict(r) for r in rows] if results: diff --git a/tests/plugins/memory/test_holographic_store_search_fts5.py b/tests/plugins/memory/test_holographic_store_search_fts5.py new file mode 100644 index 000000000000..4d782b5ea4fc --- /dev/null +++ b/tests/plugins/memory/test_holographic_store_search_fts5.py @@ -0,0 +1,106 @@ +"""MemoryStore.search_facts must not crash on FTS5 query syntax in user text. + +The memory ``search`` action is exposed to the LLM, which routinely emits +queries containing FTS5 metasyntax — a stray double-quote, a ``key:value`` +colon, a bare ``AND``/``OR``/``NEAR``, or a parenthesis. Passing those straight +to ``MATCH`` raised an unhandled ``sqlite3.OperationalError`` and crashed the +memory tool. search_facts now retries with each token quoted as a literal +phrase so the search degrades gracefully. +""" + +import sqlite3 + +import pytest + +from plugins.memory.holographic.store import MemoryStore, _fts5_safe_query + + +@pytest.fixture(autouse=True) +def _clean_shared_registry(): + """Each test starts and ends with an empty shared-connection registry.""" + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + yield + for entry in list(MemoryStore._shared.values()): + try: + entry["conn"].close() + except sqlite3.Error: + pass + MemoryStore._shared.clear() + + +@pytest.fixture +def store(tmp_path): + """A MemoryStore backed by a per-test database file. + + ``MemoryStore`` resolves ``db_path`` before ``sqlite3.connect`` and keys its + process-wide shared-connection registry on the resolved path, so passing + ``":memory:"`` yields a shared *on-disk* file rather than SQLite's in-memory + sentinel — leaking rows between tests. Use ``tmp_path`` and close the store, + matching ``test_holographic_store.py``. + """ + store = MemoryStore(tmp_path / "memory_store.db") + store.add_fact("Python is a programming language", category="tech") + store.add_fact("Rust is memory safe and fast", category="tech") + try: + yield store + finally: + store.close() + + +@pytest.mark.parametrize( + "query", + [ + '"unterminated', + "foo:bar", + "AND", + "a OR", + "(python", + "memory:safe", + "C++ AND rust", + 'say "hi', + "NEAR(", + ], +) +def test_fts5_metasyntax_does_not_crash(store, query): + # Must return a list, never raise sqlite3.OperationalError. + result = store.search_facts(query) + assert isinstance(result, list) + + +def test_bare_boolean_operators_no_longer_crash(store): + # `_sanitize_fts_query` neutralises most metasyntax before MATCH (colons, + # stray quotes, parens, NEAR), but a bare boolean operator survives it and + # still reaches FTS5 as a syntax error — on an unguarded store, + # `search_facts("AND")` raises `fts5: syntax error near "AND"` and + # `search_facts("a OR")` raises `fts5: syntax error near ""`. These are the + # cases the quoted-phrase retry still has to absorb. + for query in ("AND", "a OR"): + assert isinstance(store.search_facts(query), list), query + + +def test_normal_query_unaffected(store): + results = store.search_facts("python") + assert any("Python" in r["content"] for r in results) + + +def test_prefix_query_still_works(store): + # A valid FTS5 prefix query must keep working (try-raw before fallback). + results = store.search_facts("rust*") + assert any("Rust" in r["content"] for r in results) + + +def test_empty_after_sanitization_returns_empty(store): + # A query that's only quotes -> no usable tokens -> [] (no crash). + assert store.search_facts('"') == [] + + +def test_fts5_safe_query_helper(): + assert _fts5_safe_query("foo:bar baz") == '"foo:bar" "baz"' + # An embedded double-quote is doubled (FTS5 phrase escaping). + assert _fts5_safe_query('a"b') == '"a""b"' + assert _fts5_safe_query(" ") == ""