hermes-memory-pgvector 0.5.0__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/PKG-INFO +23 -1
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/README.md +22 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/PKG-INFO +23 -1
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/SOURCES.txt +1 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/__init__.py +42 -5
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/__main__.py +9 -2
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/plugin.yaml +1 -1
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/store.py +74 -14
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/pyproject.toml +1 -1
- hermes_memory_pgvector-0.5.2/tests/test_empty_content.py +332 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/LICENSE +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/embed.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/identity.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/001_schema.sql +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/002_agent_attribution.sql +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/003_hybrid_search_fts.sql +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/004_runtime_grants.sql +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/writer.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/setup.cfg +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_async_writer.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_config_coercion.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_embed_timeouts.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_exclude_identities_live.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_hybrid_search.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_identity.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_install_shim.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_read_side_gate.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_save_config_merge.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_session_switch_contract.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_smoke.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_store_v04.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_system_prompt_block.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_tool_args_hardening.py +0 -0
- {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_turn_dedup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hermes-memory-pgvector
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Postgres + pgvector memory provider plugin for hermes-agent. Multi-agent storage layer with per-minion themes, identity governance, agent attribution + delegation provenance, async writer, no LLM in the memory hot path.
|
|
5
5
|
Author: Andrea Borghi
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -189,6 +189,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
|
|
|
189
189
|
- **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
|
|
190
190
|
- **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
|
|
191
191
|
|
|
192
|
+
## New in v0.5.1 — `memory remove` no longer wipes a whole theme
|
|
193
|
+
|
|
194
|
+
Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
|
|
195
|
+
|
|
196
|
+
- **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
|
|
197
|
+
|
|
198
|
+
Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
|
|
199
|
+
|
|
200
|
+
- **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
|
|
201
|
+
- **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
|
|
202
|
+
|
|
203
|
+
## New in v0.5.2 — documentation only
|
|
204
|
+
|
|
205
|
+
**No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
|
|
206
|
+
|
|
207
|
+
It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
|
|
208
|
+
|
|
209
|
+
- **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
|
|
210
|
+
- A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
|
|
211
|
+
|
|
212
|
+
If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
|
|
213
|
+
|
|
192
214
|
## Multi-agent / per-minion themes
|
|
193
215
|
|
|
194
216
|
Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
|
|
@@ -156,6 +156,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
|
|
|
156
156
|
- **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
|
|
157
157
|
- **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
|
|
158
158
|
|
|
159
|
+
## New in v0.5.1 — `memory remove` no longer wipes a whole theme
|
|
160
|
+
|
|
161
|
+
Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
|
|
162
|
+
|
|
163
|
+
- **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
|
|
164
|
+
|
|
165
|
+
Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
|
|
166
|
+
|
|
167
|
+
- **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
|
|
168
|
+
- **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
|
|
169
|
+
|
|
170
|
+
## New in v0.5.2 — documentation only
|
|
171
|
+
|
|
172
|
+
**No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
|
|
173
|
+
|
|
174
|
+
It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
|
|
175
|
+
|
|
176
|
+
- **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
|
|
177
|
+
- A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
|
|
178
|
+
|
|
179
|
+
If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
|
|
180
|
+
|
|
159
181
|
## Multi-agent / per-minion themes
|
|
160
182
|
|
|
161
183
|
Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hermes-memory-pgvector
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Postgres + pgvector memory provider plugin for hermes-agent. Multi-agent storage layer with per-minion themes, identity governance, agent attribution + delegation provenance, async writer, no LLM in the memory hot path.
|
|
5
5
|
Author: Andrea Borghi
|
|
6
6
|
License: BSD-3-Clause
|
|
@@ -189,6 +189,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
|
|
|
189
189
|
- **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
|
|
190
190
|
- **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
|
|
191
191
|
|
|
192
|
+
## New in v0.5.1 — `memory remove` no longer wipes a whole theme
|
|
193
|
+
|
|
194
|
+
Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
|
|
195
|
+
|
|
196
|
+
- **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
|
|
197
|
+
|
|
198
|
+
Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
|
|
199
|
+
|
|
200
|
+
- **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
|
|
201
|
+
- **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
|
|
202
|
+
|
|
203
|
+
## New in v0.5.2 — documentation only
|
|
204
|
+
|
|
205
|
+
**No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
|
|
206
|
+
|
|
207
|
+
It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
|
|
208
|
+
|
|
209
|
+
- **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
|
|
210
|
+
- A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
|
|
211
|
+
|
|
212
|
+
If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
|
|
213
|
+
|
|
192
214
|
## Multi-agent / per-minion themes
|
|
193
215
|
|
|
194
216
|
Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
|
|
@@ -21,6 +21,7 @@ hermes_pgvector/migrations/004_runtime_grants.sql
|
|
|
21
21
|
tests/test_async_writer.py
|
|
22
22
|
tests/test_config_coercion.py
|
|
23
23
|
tests/test_embed_timeouts.py
|
|
24
|
+
tests/test_empty_content.py
|
|
24
25
|
tests/test_exclude_identities_live.py
|
|
25
26
|
tests/test_hybrid_search.py
|
|
26
27
|
tests/test_identity.py
|
|
@@ -888,6 +888,27 @@ class PgvectorMemoryProvider(MemoryProvider):
|
|
|
888
888
|
if action not in ("add", "replace", "remove"):
|
|
889
889
|
logger.debug("pgvector ignoring unknown action: %r", action)
|
|
890
890
|
return
|
|
891
|
+
# An add/replace with no content produces a row that can NEVER be
|
|
892
|
+
# embedded: embed() raises EmbeddingError("empty input") on empty or
|
|
893
|
+
# whitespace text. Such a row is retried by every nightly backfill
|
|
894
|
+
# forever, always fails, and permanently prevents the NULL-embedding
|
|
895
|
+
# count from reaching zero -- destroying the one signal an operator
|
|
896
|
+
# watches. It also carries no information worth mirroring. `remove`
|
|
897
|
+
# is exempt: it legitimately arrives with empty content and targets
|
|
898
|
+
# the row via old_text.
|
|
899
|
+
if action in ("add", "replace") and not (content or "").strip():
|
|
900
|
+
# Not routine. on_memory_write only fires for writes the built-in
|
|
901
|
+
# tool COMMITTED, and it rejects empty add/replace content -- so
|
|
902
|
+
# arriving here means real content landed on disk and reached us
|
|
903
|
+
# as ''. Skipping keeps an un-embeddable row out of the table, but
|
|
904
|
+
# the mirror is now missing an entry the agent believes it saved,
|
|
905
|
+
# which is worth more than a debug line.
|
|
906
|
+
logger.warning(
|
|
907
|
+
"pgvector: %r for target=%r arrived with empty content; "
|
|
908
|
+
"skipping (the built-in store has it, the mirror will not)",
|
|
909
|
+
action, target,
|
|
910
|
+
)
|
|
911
|
+
return
|
|
891
912
|
|
|
892
913
|
meta = dict(metadata or {})
|
|
893
914
|
meta.setdefault("session_id", self._session_id)
|
|
@@ -957,11 +978,27 @@ class PgvectorMemoryProvider(MemoryProvider):
|
|
|
957
978
|
metadata=item.metadata,
|
|
958
979
|
)
|
|
959
980
|
elif item.action == "remove":
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
)
|
|
981
|
+
# The removal target lives in extra["old_text"], NOT in content.
|
|
982
|
+
# The built-in tool's remove op takes old_text and leaves content
|
|
983
|
+
# empty (tools/memory_tool.py: remove -> store.remove(target,
|
|
984
|
+
# old_text)), and the host forwards old_text via METADATA
|
|
985
|
+
# (memory_manager.notify_memory_tool_write). Reading item.content
|
|
986
|
+
# here meant remove() was called with "", which becomes
|
|
987
|
+
# `content LIKE '%%'` -- matching every row and deleting the
|
|
988
|
+
# entire mirror for that (agent_identity, target).
|
|
989
|
+
old_text = item.extra.get("old_text") or item.content
|
|
990
|
+
if not (old_text or "").strip():
|
|
991
|
+
logger.warning(
|
|
992
|
+
"pgvector refusing remove with no old_text for %s/%s "
|
|
993
|
+
"(would match every row)",
|
|
994
|
+
item.agent_identity, item.target,
|
|
995
|
+
)
|
|
996
|
+
else:
|
|
997
|
+
self._store.remove(
|
|
998
|
+
agent_identity=item.agent_identity,
|
|
999
|
+
target=item.target,
|
|
1000
|
+
old_text=old_text,
|
|
1001
|
+
)
|
|
965
1002
|
elif item.action == "turn":
|
|
966
1003
|
role = item.extra.get("role") or "user"
|
|
967
1004
|
sid = item.extra.get("session_id") or "default"
|
|
@@ -277,10 +277,17 @@ def cmd_stats(args) -> int:
|
|
|
277
277
|
conv = store.count_turns()
|
|
278
278
|
print(f"memory_entries: {mem} rows")
|
|
279
279
|
print(f"conversations: {conv} rows")
|
|
280
|
-
# null-embedding counts (dry-run backfill returns remaining-null per table)
|
|
280
|
+
# null-embedding counts (dry-run backfill returns remaining-null per table).
|
|
281
|
+
# `remaining` counts only rows that CAN be embedded; empty/whitespace rows
|
|
282
|
+
# are reported separately, because they never shrink and would otherwise
|
|
283
|
+
# make this number look permanently stuck for no actionable reason.
|
|
281
284
|
nulls = store.backfill_null_embeddings(embed_fn=lambda t: [0.0] * 768, dry_run=True)
|
|
282
285
|
for table, info in nulls.items():
|
|
283
|
-
|
|
286
|
+
line = f" {table}: {info['remaining']} null-embedding rows (backfillable)"
|
|
287
|
+
stuck = info.get("unembeddable")
|
|
288
|
+
if stuck:
|
|
289
|
+
line += f", {stuck} un-embeddable (empty content — will never backfill)"
|
|
290
|
+
print(line)
|
|
284
291
|
if store.ensure_migration_002_applied():
|
|
285
292
|
print("migration 002: applied — per-agent attribution:")
|
|
286
293
|
for row in store.agent_attribution():
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: pgvector
|
|
2
|
-
version: 0.5.
|
|
2
|
+
version: 0.5.2
|
|
3
3
|
description: "Postgres + pgvector storage layer for hermes-agent. Mirrors built-in MEMORY.md / USER.md + captures every substantive (user, assistant) chat turn into a conversations table. Per-agent themes via X-Hermes-Session-Key with v0.4 identity governance (PII/DM-key bucketing, bench isolation, allow-list). Agent attribution + parent->child delegation provenance (memory_agents / memory_agent_edges). 768-dim semantic recall with hybrid vector+full-text (RRF) ranking, embedding backfill, conversation TTL. Async writer + connection pool. No LLM mediation."
|
|
4
4
|
pip_dependencies:
|
|
5
5
|
- "psycopg[binary]>=3.3.5,<4"
|
|
@@ -296,18 +296,41 @@ class MemoryStore:
|
|
|
296
296
|
target: str,
|
|
297
297
|
old_text: str,
|
|
298
298
|
) -> int:
|
|
299
|
-
"""Delete
|
|
300
|
-
|
|
301
|
-
|
|
299
|
+
"""Delete THE entry in (agent_identity, target) matching old_text.
|
|
300
|
+
|
|
301
|
+
Deletes at most ONE row (lowest id), matching both the built-in tool
|
|
302
|
+
and this class's own replace(). The built-in requires a UNIQUE match
|
|
303
|
+
and errors on ambiguity (memory_tool_store._edit -> _find_unique_match),
|
|
304
|
+
so one built-in remove is one entry; a mirror that deleted every
|
|
305
|
+
substring match would remove strictly more -- and the mirror is the
|
|
306
|
+
side that accumulates stale rows MEMORY.md no longer has, so "every
|
|
307
|
+
match" is wider here than it would be there.
|
|
308
|
+
|
|
309
|
+
Returns the number of rows deleted (0 or 1).
|
|
310
|
+
|
|
311
|
+
REFUSES an empty or whitespace-only old_text. `%{""}%` is `LIKE '%%'`,
|
|
312
|
+
which matches every row -- so a caller that lost the removal target
|
|
313
|
+
would silently delete the entire mirror for that (agent_identity,
|
|
314
|
+
target) instead of one entry. A delete this destructive must never be
|
|
315
|
+
reachable by omission; the caller has to say what it means to remove.
|
|
302
316
|
"""
|
|
317
|
+
if not (old_text or "").strip():
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"remove() requires a non-empty old_text: an empty pattern is "
|
|
320
|
+
"LIKE '%%', which would delete every entry in this scope"
|
|
321
|
+
)
|
|
303
322
|
with self._get_pool().connection() as conn:
|
|
304
323
|
with conn.cursor() as cur:
|
|
305
324
|
cur.execute(
|
|
306
325
|
"""
|
|
307
326
|
DELETE FROM memory_entries
|
|
308
|
-
WHERE
|
|
309
|
-
|
|
310
|
-
|
|
327
|
+
WHERE id = (
|
|
328
|
+
SELECT id FROM memory_entries
|
|
329
|
+
WHERE agent_identity = %s
|
|
330
|
+
AND target = %s
|
|
331
|
+
AND content LIKE %s
|
|
332
|
+
ORDER BY id LIMIT 1
|
|
333
|
+
)
|
|
311
334
|
""",
|
|
312
335
|
(agent_identity, target, f"%{_escape_like(old_text)}%"),
|
|
313
336
|
)
|
|
@@ -922,7 +945,15 @@ class MemoryStore:
|
|
|
922
945
|
fail fast, never write a wrong-dim vector). If the embed endpoint is
|
|
923
946
|
unreachable, the run aborts cleanly (nothing to backfill right now).
|
|
924
947
|
|
|
925
|
-
|
|
948
|
+
Rows whose content is empty or whitespace are EXCLUDED, not failed.
|
|
949
|
+
embed() raises EmbeddingError("empty input") on such text, so selecting
|
|
950
|
+
them means retrying a guaranteed failure on every nightly run forever,
|
|
951
|
+
permanently pinning `failed` above zero and making "remaining == 0"
|
|
952
|
+
unreachable -- which destroys the one signal an operator watches. They
|
|
953
|
+
are reported separately as `unembeddable` so they are skipped, not
|
|
954
|
+
hidden.
|
|
955
|
+
|
|
956
|
+
Returns {table: {processed, succeeded, failed, remaining, unembeddable}}.
|
|
926
957
|
"""
|
|
927
958
|
tables = self._assert_whitelisted(tables)
|
|
928
959
|
result: Dict[str, Dict[str, Any]] = {}
|
|
@@ -933,7 +964,8 @@ class MemoryStore:
|
|
|
933
964
|
except Exception as exc: # noqa: BLE001
|
|
934
965
|
logger.warning("backfill aborted — embed endpoint unavailable: %s", str(exc)[:200])
|
|
935
966
|
return {t: {"processed": 0, "succeeded": 0, "failed": 0,
|
|
936
|
-
"remaining": None, "
|
|
967
|
+
"remaining": None, "unembeddable": None,
|
|
968
|
+
"note": "embed-unavailable"} for t in tables}
|
|
937
969
|
if not isinstance(probe, list) or len(probe) != 768:
|
|
938
970
|
got = len(probe) if isinstance(probe, list) else type(probe).__name__
|
|
939
971
|
raise ValueError(
|
|
@@ -944,10 +976,31 @@ class MemoryStore:
|
|
|
944
976
|
for t in tables:
|
|
945
977
|
with self._get_pool().connection() as conn:
|
|
946
978
|
with conn.cursor() as cur:
|
|
947
|
-
cur.execute(
|
|
948
|
-
|
|
979
|
+
cur.execute(
|
|
980
|
+
# content ~ '\S' means "has at least one non-whitespace
|
|
981
|
+
# character", which matches Python's str.strip()
|
|
982
|
+
# exactly. Postgres trim() defaults to SPACES ONLY, so a
|
|
983
|
+
# row holding just a newline or a tab would still be
|
|
984
|
+
# selected here, still raise EmbeddingError("empty
|
|
985
|
+
# input"), and still fail on every run -- the very bug
|
|
986
|
+
# this filter exists to stop.
|
|
987
|
+
rf"SELECT count(*) FILTER (WHERE content ~ '\S'), "
|
|
988
|
+
rf" count(*) FILTER (WHERE content !~ '\S' OR content IS NULL) "
|
|
989
|
+
f"FROM {t} WHERE embedding IS NULL"
|
|
990
|
+
)
|
|
991
|
+
row = cur.fetchone()
|
|
992
|
+
remaining, unembeddable = int(row[0]), int(row[1])
|
|
993
|
+
if unembeddable:
|
|
994
|
+
# Surfaced, not swallowed: these are permanently un-embeddable
|
|
995
|
+
# and would otherwise be invisible now that they are skipped.
|
|
996
|
+
logger.info(
|
|
997
|
+
"backfill %s: skipping %d row(s) with empty content "
|
|
998
|
+
"(cannot be embedded; delete them or leave them text-only)",
|
|
999
|
+
t, unembeddable,
|
|
1000
|
+
)
|
|
949
1001
|
if dry_run:
|
|
950
|
-
result[t] = {"processed": 0, "succeeded": 0, "failed": 0,
|
|
1002
|
+
result[t] = {"processed": 0, "succeeded": 0, "failed": 0,
|
|
1003
|
+
"remaining": remaining, "unembeddable": unembeddable}
|
|
951
1004
|
continue
|
|
952
1005
|
|
|
953
1006
|
processed = succeeded = failed = 0
|
|
@@ -955,7 +1008,10 @@ class MemoryStore:
|
|
|
955
1008
|
with self._get_pool().connection() as conn:
|
|
956
1009
|
with conn.cursor(row_factory=dict_row) as cur:
|
|
957
1010
|
cur.execute(
|
|
958
|
-
f"SELECT id, content FROM {t}
|
|
1011
|
+
f"SELECT id, content FROM {t} "
|
|
1012
|
+
f"WHERE embedding IS NULL "
|
|
1013
|
+
rf" AND content ~ '\S' "
|
|
1014
|
+
f"ORDER BY id LIMIT %s",
|
|
959
1015
|
(batch_size,),
|
|
960
1016
|
)
|
|
961
1017
|
rows = list(cur.fetchall())
|
|
@@ -991,10 +1047,14 @@ class MemoryStore:
|
|
|
991
1047
|
|
|
992
1048
|
with self._get_pool().connection() as conn:
|
|
993
1049
|
with conn.cursor() as cur:
|
|
994
|
-
cur.execute(
|
|
1050
|
+
cur.execute(
|
|
1051
|
+
f"SELECT count(*) FROM {t} "
|
|
1052
|
+
rf"WHERE embedding IS NULL AND content ~ '\S'"
|
|
1053
|
+
)
|
|
995
1054
|
remaining = int(cur.fetchone()[0])
|
|
996
1055
|
result[t] = {"processed": processed, "succeeded": succeeded,
|
|
997
|
-
"failed": failed, "remaining": remaining
|
|
1056
|
+
"failed": failed, "remaining": remaining,
|
|
1057
|
+
"unembeddable": unembeddable}
|
|
998
1058
|
return result
|
|
999
1059
|
|
|
1000
1060
|
def prune_conversations(self, *, older_than_days: int, dry_run: bool = False) -> int:
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hermes-memory-pgvector"
|
|
7
|
-
version = "0.5.
|
|
7
|
+
version = "0.5.2"
|
|
8
8
|
description = "Postgres + pgvector memory provider plugin for hermes-agent. Multi-agent storage layer with per-minion themes, identity governance, agent attribution + delegation provenance, async writer, no LLM in the memory hot path."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Empty-content rows must never be created, and never re-tried forever.
|
|
2
|
+
|
|
3
|
+
No DB required for the write-path half; the backfill half is live-mode.
|
|
4
|
+
|
|
5
|
+
IMPORTANT: point PG_TEST_DSN at a THROWAWAY database, never production. The
|
|
6
|
+
backfill cases call backfill_null_embeddings(), which sweeps the WHOLE table --
|
|
7
|
+
so on a shared database it would stamp every pre-existing NULL-embedding row
|
|
8
|
+
with this test's constant [0.1]*768 vector. Those rows are then no longer NULL
|
|
9
|
+
and a real backfill can never repair them. The `failed` assertion is table-wide
|
|
10
|
+
for the same reason.
|
|
11
|
+
|
|
12
|
+
Found in production. One row in `memory_entries` sat with `embedding IS NULL`
|
|
13
|
+
and `length(content) = 0` -- its metadata (`old_text`, `tool_name`) showing it
|
|
14
|
+
arrived through the built-in tool's `replace` path with empty new content.
|
|
15
|
+
|
|
16
|
+
Two separate defects met there:
|
|
17
|
+
|
|
18
|
+
* nothing on the write path rejected empty content, so the row was created;
|
|
19
|
+
* `backfill_null_embeddings` selected `WHERE embedding IS NULL` with no
|
|
20
|
+
content filter, so every nightly sweep re-fetched it, called embed(), got
|
|
21
|
+
EmbeddingError("empty input") -- which is unconditional for empty text --
|
|
22
|
+
marked it `failed`, and moved on. Forever.
|
|
23
|
+
|
|
24
|
+
The second is the damaging one. It pins `failed` above zero and makes
|
|
25
|
+
`remaining == 0` unreachable, so "are there un-embedded rows?" stops being
|
|
26
|
+
answerable: you cannot tell one permanently-stuck row from one new genuine
|
|
27
|
+
failure. Skipping them silently would be just as bad, so they are reported
|
|
28
|
+
separately as `unembeddable`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import pytest
|
|
38
|
+
|
|
39
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
40
|
+
|
|
41
|
+
from hermes_pgvector import PgvectorMemoryProvider # noqa: E402
|
|
42
|
+
from hermes_pgvector.store import MemoryStore # noqa: E402
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _RecordingWriter:
|
|
46
|
+
def __init__(self):
|
|
47
|
+
self.items = []
|
|
48
|
+
|
|
49
|
+
def enqueue(self, **kwargs) -> bool:
|
|
50
|
+
self.items.append(kwargs)
|
|
51
|
+
return True
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _provider():
|
|
55
|
+
p = PgvectorMemoryProvider()
|
|
56
|
+
p._healthy = True
|
|
57
|
+
p._writer = _RecordingWriter()
|
|
58
|
+
p._agent_identity = "marketing"
|
|
59
|
+
p._raw_identity = "marketing"
|
|
60
|
+
p._session_id = "sess-empty"
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- write path -------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("content", ["", " ", "\n", "\t \n "])
|
|
67
|
+
@pytest.mark.parametrize("action", ["add", "replace"])
|
|
68
|
+
def test_empty_add_or_replace_is_not_mirrored(action, content):
|
|
69
|
+
p = _provider()
|
|
70
|
+
p.on_memory_write(action=action, target="memory", content=content)
|
|
71
|
+
assert p._writer.items == [], (
|
|
72
|
+
f"{action!r} with {content!r} must not be mirrored -- it creates a row "
|
|
73
|
+
"that can never be embedded and that backfill retries forever"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_remove_with_empty_content_is_still_mirrored():
|
|
78
|
+
"""`remove` legitimately arrives with empty content -- the removal target
|
|
79
|
+
travels in metadata as old_text, not in content -- so the empty-content
|
|
80
|
+
guard must not swallow it.
|
|
81
|
+
|
|
82
|
+
NOTE: an earlier version of this docstring said the row is targeted "via
|
|
83
|
+
old_text" and left it there, which quietly asserted a path that was in fact
|
|
84
|
+
broken downstream: _worker read item.content, not extra["old_text"], so the
|
|
85
|
+
forwarded remove deleted EVERYTHING. See the _worker tests below -- this
|
|
86
|
+
test only pins that the enqueue happens, never that it is well-formed."""
|
|
87
|
+
p = _provider()
|
|
88
|
+
p.on_memory_write(
|
|
89
|
+
action="remove", target="memory", content="",
|
|
90
|
+
metadata={"old_text": "the entry being removed"},
|
|
91
|
+
)
|
|
92
|
+
assert len(p._writer.items) == 1
|
|
93
|
+
assert p._writer.items[0]["action"] == "remove"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_normal_add_is_unaffected():
|
|
97
|
+
p = _provider()
|
|
98
|
+
p.on_memory_write(action="add", target="memory", content="a real durable note")
|
|
99
|
+
assert len(p._writer.items) == 1
|
|
100
|
+
assert p._writer.items[0]["content"] == "a real durable note"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- backfill ---------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@pytest.fixture
|
|
106
|
+
def store():
|
|
107
|
+
dsn = os.environ.get("PG_TEST_DSN")
|
|
108
|
+
if not dsn:
|
|
109
|
+
pytest.skip("PG_TEST_DSN not set")
|
|
110
|
+
s = MemoryStore(dsn)
|
|
111
|
+
s.ensure_schema()
|
|
112
|
+
agent = "pytest-empty-" + os.urandom(4).hex()
|
|
113
|
+
yield s, agent
|
|
114
|
+
import psycopg
|
|
115
|
+
with psycopg.connect(dsn) as conn:
|
|
116
|
+
with conn.cursor() as cur:
|
|
117
|
+
cur.execute("DELETE FROM memory_entries WHERE agent_identity LIKE %s", (agent + "%",))
|
|
118
|
+
conn.commit()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _insert_raw(s, agent, content):
|
|
122
|
+
"""Bypass add() to plant the shape production actually had: NULL embedding
|
|
123
|
+
plus empty content."""
|
|
124
|
+
with s._get_pool().connection() as conn:
|
|
125
|
+
with conn.cursor() as cur:
|
|
126
|
+
cur.execute(
|
|
127
|
+
"INSERT INTO memory_entries (agent_identity, target, content, embedding, metadata)"
|
|
128
|
+
" VALUES (%s, 'memory', %s, NULL, '{}'::jsonb) RETURNING id",
|
|
129
|
+
(agent, content),
|
|
130
|
+
)
|
|
131
|
+
rid = cur.fetchone()[0]
|
|
132
|
+
conn.commit()
|
|
133
|
+
return rid
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_backfill_skips_empty_content_and_reports_it(store):
|
|
137
|
+
s, agent = store
|
|
138
|
+
empty_id = _insert_raw(s, agent, "")
|
|
139
|
+
real_id = _insert_raw(s, agent, "a genuinely embeddable durable note")
|
|
140
|
+
|
|
141
|
+
seen = []
|
|
142
|
+
|
|
143
|
+
def _embed_fn(text):
|
|
144
|
+
seen.append(text)
|
|
145
|
+
return [0.1] * 768
|
|
146
|
+
|
|
147
|
+
report = s.backfill_null_embeddings(embed_fn=_embed_fn, tables=["memory_entries"])
|
|
148
|
+
|
|
149
|
+
assert "" not in seen, "the empty row must never reach embed()"
|
|
150
|
+
assert any("genuinely embeddable" in x for x in seen), "the real row must be embedded"
|
|
151
|
+
assert report["memory_entries"]["unembeddable"] >= 1, "skipped rows must be reported, not hidden"
|
|
152
|
+
|
|
153
|
+
with s._get_pool().connection() as conn:
|
|
154
|
+
with conn.cursor() as cur:
|
|
155
|
+
cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (empty_id,))
|
|
156
|
+
assert cur.fetchone()[0] is True, "empty row is left alone, not failed"
|
|
157
|
+
cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (real_id,))
|
|
158
|
+
assert cur.fetchone()[0] is False, "real row got embedded"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scoped_backlog(s, agent):
|
|
162
|
+
"""`remaining`, computed for THIS test's rows only.
|
|
163
|
+
|
|
164
|
+
backfill_null_embeddings reports table-wide numbers, but the fixture only
|
|
165
|
+
cleans its own agent_identity prefix -- so asserting on the table-wide
|
|
166
|
+
figure makes the test depend on every other row in the database and fail
|
|
167
|
+
permanently after one interrupted run. Assert the same property, scoped."""
|
|
168
|
+
with s._get_pool().connection() as conn:
|
|
169
|
+
with conn.cursor() as cur:
|
|
170
|
+
cur.execute(
|
|
171
|
+
r"SELECT count(*) FROM memory_entries "
|
|
172
|
+
r"WHERE agent_identity = %s AND embedding IS NULL AND content ~ '\S'",
|
|
173
|
+
(agent,),
|
|
174
|
+
)
|
|
175
|
+
return int(cur.fetchone()[0])
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_backfill_remaining_can_reach_zero_despite_an_empty_row(store):
|
|
179
|
+
"""The point of the fix: an un-embeddable row must not pin the backlog
|
|
180
|
+
above zero forever, or 'is the backlog clear?' becomes unanswerable."""
|
|
181
|
+
s, agent = store
|
|
182
|
+
_insert_raw(s, agent, "")
|
|
183
|
+
_insert_raw(s, agent, "another embeddable note for the sweep")
|
|
184
|
+
assert _scoped_backlog(s, agent) == 1, "one embeddable row to start"
|
|
185
|
+
|
|
186
|
+
report = s.backfill_null_embeddings(
|
|
187
|
+
embed_fn=lambda t: [0.1] * 768, tables=["memory_entries"]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
assert _scoped_backlog(s, agent) == 0, (
|
|
191
|
+
"the backlog must exclude un-embeddable rows so it can actually reach 0"
|
|
192
|
+
)
|
|
193
|
+
# Asserted directly, not as a delta against a dry-run baseline: dry_run
|
|
194
|
+
# hardcodes "failed": 0 (store.py), so such a baseline is always 0 and the
|
|
195
|
+
# comparison would be theatre. `failed` is table-wide, which is sound here
|
|
196
|
+
# only because this fixture requires a THROWAWAY database (stated in the
|
|
197
|
+
# module docstring above) -- on a shared one, a pre-existing failing row
|
|
198
|
+
# would surface here, and that is worth knowing rather than hiding.
|
|
199
|
+
assert report["memory_entries"]["failed"] == 0, (
|
|
200
|
+
"an un-embeddable row must be skipped, not counted as a failure"
|
|
201
|
+
)
|
|
202
|
+
assert report["memory_entries"]["unembeddable"] >= 1
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_backfill_skips_whitespace_only_content(store):
|
|
206
|
+
"""Not just the empty string. Postgres trim() strips SPACES ONLY, so a row
|
|
207
|
+
holding a newline or tab used to pass the filter, reach embed(), and fail
|
|
208
|
+
forever -- the same bug for a different shape. The predicate now matches
|
|
209
|
+
Python's str.strip()."""
|
|
210
|
+
s, agent = store
|
|
211
|
+
for blank in ("", " ", chr(10), chr(9) + chr(10) + " "):
|
|
212
|
+
_insert_raw(s, agent, blank)
|
|
213
|
+
real_id = _insert_raw(s, agent, "a real note alongside the blank ones")
|
|
214
|
+
|
|
215
|
+
seen = []
|
|
216
|
+
|
|
217
|
+
def _embed_fn(text):
|
|
218
|
+
seen.append(text)
|
|
219
|
+
return [0.1] * 768
|
|
220
|
+
|
|
221
|
+
report = s.backfill_null_embeddings(embed_fn=_embed_fn, tables=["memory_entries"])
|
|
222
|
+
|
|
223
|
+
assert all(x.strip() for x in seen), f"a blank row reached embed(): {seen!r}"
|
|
224
|
+
assert report["memory_entries"]["unembeddable"] >= 4
|
|
225
|
+
assert _scoped_backlog(s, agent) == 0
|
|
226
|
+
|
|
227
|
+
with s._get_pool().connection() as conn:
|
|
228
|
+
with conn.cursor() as cur:
|
|
229
|
+
cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (real_id,))
|
|
230
|
+
assert cur.fetchone()[0] is False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# CRITICAL: the remove path must never be able to wipe a whole scope.
|
|
235
|
+
#
|
|
236
|
+
# store.remove() matches with `content LIKE %<old_text>%`. With an empty
|
|
237
|
+
# old_text that is LIKE '%%', which matches EVERY row -- so a remove that lost
|
|
238
|
+
# its target deletes the entire mirror for that (agent_identity, target)
|
|
239
|
+
# instead of one entry. Verified against Postgres: DELETE ... WHERE c LIKE '%%'
|
|
240
|
+
# removed all rows.
|
|
241
|
+
#
|
|
242
|
+
# _worker used to pass old_text=item.content. The built-in tool's remove op
|
|
243
|
+
# takes old_text and leaves content empty (tools/memory_tool.py:87), and the
|
|
244
|
+
# host forwards old_text through METADATA (memory_manager.notify_memory_tool_write),
|
|
245
|
+
# so item.content was ALWAYS '' for a remove -- making every mirrored
|
|
246
|
+
# `memory remove` a full wipe of that theme.
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
class _FakeStore:
|
|
250
|
+
def __init__(self):
|
|
251
|
+
self.removes = []
|
|
252
|
+
|
|
253
|
+
def remove(self, *, agent_identity, target, old_text):
|
|
254
|
+
self.removes.append(old_text)
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _worker_provider():
|
|
259
|
+
p = PgvectorMemoryProvider()
|
|
260
|
+
p._healthy = True
|
|
261
|
+
p._store = _FakeStore()
|
|
262
|
+
return p
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class _Item:
|
|
266
|
+
def __init__(self, content="", extra=None):
|
|
267
|
+
self.action = "remove"
|
|
268
|
+
self.agent_identity = "marketing"
|
|
269
|
+
self.target = "memory"
|
|
270
|
+
self.content = content
|
|
271
|
+
self.extra = extra or {}
|
|
272
|
+
self.metadata = {}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_worker_remove_uses_old_text_not_content():
|
|
276
|
+
"""The regression: content is empty for every real remove, so reading it
|
|
277
|
+
produced LIKE '%%' and deleted the whole scope."""
|
|
278
|
+
p = _worker_provider()
|
|
279
|
+
p._worker(_Item(content="", extra={"old_text": "the entry to delete"}))
|
|
280
|
+
assert p._store.removes == ["the entry to delete"]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def test_worker_remove_refuses_when_no_target_is_available():
|
|
284
|
+
"""Belt and braces: with neither content nor old_text there is nothing to
|
|
285
|
+
match, and the store must not be called at all."""
|
|
286
|
+
for item in (_Item(content="", extra={}), _Item(content=" ", extra={"old_text": " "})):
|
|
287
|
+
p = _worker_provider()
|
|
288
|
+
p._worker(item)
|
|
289
|
+
assert p._store.removes == [], "a target-less remove must never reach the store"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_worker_remove_falls_back_to_content_when_old_text_absent():
|
|
293
|
+
"""Older/other callers that put the target in content still work."""
|
|
294
|
+
p = _worker_provider()
|
|
295
|
+
p._worker(_Item(content="target in content", extra={}))
|
|
296
|
+
assert p._store.removes == ["target in content"]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_store_remove_rejects_empty_old_text():
|
|
300
|
+
"""Store-level guard, independent of any caller: an empty pattern is
|
|
301
|
+
LIKE '%%' and must be impossible to reach by omission."""
|
|
302
|
+
s = MemoryStore.__new__(MemoryStore) # no connection needed; guard is first
|
|
303
|
+
for bad in ("", " ", chr(10), chr(9) + " "):
|
|
304
|
+
with pytest.raises(ValueError, match="non-empty old_text"):
|
|
305
|
+
s.remove(agent_identity="a", target="memory", old_text=bad)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def test_store_remove_guard_runs_before_any_db_work(store):
|
|
309
|
+
"""Live: the guard must fire before touching the pool, and delete nothing."""
|
|
310
|
+
s, agent = store
|
|
311
|
+
_insert_raw(s, agent, "row one that must survive")
|
|
312
|
+
_insert_raw(s, agent, "row two that must survive")
|
|
313
|
+
|
|
314
|
+
with pytest.raises(ValueError):
|
|
315
|
+
s.remove(agent_identity=agent, target="memory", old_text="")
|
|
316
|
+
|
|
317
|
+
with s._get_pool().connection() as conn:
|
|
318
|
+
with conn.cursor() as cur:
|
|
319
|
+
cur.execute("SELECT count(*) FROM memory_entries WHERE agent_identity = %s", (agent,))
|
|
320
|
+
assert cur.fetchone()[0] == 2, "an empty remove must delete nothing"
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_store_remove_still_deletes_a_real_match(store):
|
|
324
|
+
s, agent = store
|
|
325
|
+
_insert_raw(s, agent, "delete me please")
|
|
326
|
+
_insert_raw(s, agent, "keep me around")
|
|
327
|
+
n = s.remove(agent_identity=agent, target="memory", old_text="delete me")
|
|
328
|
+
assert n == 1
|
|
329
|
+
with s._get_pool().connection() as conn:
|
|
330
|
+
with conn.cursor() as cur:
|
|
331
|
+
cur.execute("SELECT content FROM memory_entries WHERE agent_identity = %s", (agent,))
|
|
332
|
+
assert [r[0] for r in cur.fetchall()] == ["keep me around"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_exclude_identities_live.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_save_config_merge.py
RENAMED
|
File without changes
|
{hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_session_switch_contract.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_system_prompt_block.py
RENAMED
|
File without changes
|
{hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_tool_args_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|